run.go (6155B)
1 package jci 2 3 import ( 4 "crypto/rand" 5 "encoding/hex" 6 "fmt" 7 "os" 8 "os/exec" 9 "path/filepath" 10 "time" 11 ) 12 13 // Run executes CI for the current commit 14 // Each run gets a unique ID (timestamp+random suffix) stored in refs/jci-runs/<commit>/<runid> 15 func Run(args []string) error { 16 // Get current commit 17 commit, err := GetCurrentCommit() 18 if err != nil { 19 return fmt.Errorf("failed to get current commit: %w", err) 20 } 21 22 fmt.Printf("Running CI for commit %s\n", commit[:12]) 23 24 // Generate unique run ID 25 runID := generateRunID() 26 fmt.Printf("Run ID: %s\n", runID) 27 28 repoRoot, err := GetRepoRoot() 29 if err != nil { 30 return fmt.Errorf("failed to get repo root: %w", err) 31 } 32 33 // Check if .jci/run.sh exists 34 runScript := filepath.Join(repoRoot, ".jci", "run.sh") 35 if _, err := os.Stat(runScript); os.IsNotExist(err) { 36 return fmt.Errorf(".jci/run.sh not found - create it to define your CI pipeline") 37 } 38 39 // Create output directory .jci/<commit> 40 outputDir := filepath.Join(repoRoot, ".jci", commit) 41 if err := os.MkdirAll(outputDir, 0755); err != nil { 42 return fmt.Errorf("failed to create output dir: %w", err) 43 } 44 45 // Set initial status to "running" 46 statusFile := filepath.Join(outputDir, "status.txt") 47 os.WriteFile(statusFile, []byte("running"), 0644) 48 49 // Run CI 50 err = runCI(repoRoot, outputDir, commit) 51 // Continue even if CI fails - we still want to store the results 52 53 // Update status based on result 54 if err != nil { 55 os.WriteFile(statusFile, []byte("err"), 0644) 56 } else { 57 os.WriteFile(statusFile, []byte("ok"), 0644) 58 } 59 60 // Generate index.html with results 61 if err := generateIndexHTML(outputDir, commit, err); err != nil { 62 fmt.Printf("Warning: failed to generate index.html: %v\n", err) 63 } 64 65 // Store results in git 66 msg := fmt.Sprintf("CI results for %s (run %s)", commit[:12], runID) 67 if storeErr := StoreTree(outputDir, commit, msg, runID); storeErr != nil { 68 return fmt.Errorf("failed to store CI results: %w", storeErr) 69 } 70 71 // Clean up the output directory after storing in git 72 os.RemoveAll(outputDir) 73 74 ref := "refs/jci-runs/" + commit + "/" + runID 75 fmt.Printf("CI results stored at %s\n", ref) 76 if err != nil { 77 return fmt.Errorf("CI failed (results stored): %w", err) 78 } 79 return nil 80 } 81 82 // runCI executes .jci/run.sh and captures output 83 func runCI(repoRoot string, outputDir string, commit string) error { 84 runScript := filepath.Join(repoRoot, ".jci", "run.sh") 85 outputFile := filepath.Join(outputDir, "run.output.txt") 86 87 // Create output file 88 f, err := os.Create(outputFile) 89 if err != nil { 90 return fmt.Errorf("failed to create output file: %w", err) 91 } 92 defer f.Close() 93 94 // Write header 95 fmt.Fprintf(f, "=== JCI Run Output ===\n") 96 fmt.Fprintf(f, "Commit: %s\n", commit) 97 fmt.Fprintf(f, "Started: %s\n", time.Now().Format(time.RFC3339)) 98 fmt.Fprintf(f, "======================\n\n") 99 100 // Run the script 101 cmd := exec.Command("bash", runScript) 102 cmd.Dir = outputDir 103 cmd.Env = append(os.Environ(), 104 "JCI_COMMIT="+commit, 105 "JCI_REPO_ROOT="+repoRoot, 106 "JCI_OUTPUT_DIR="+outputDir, 107 ) 108 109 // Capture both stdout and stderr to the same file 110 cmd.Stdout = f 111 cmd.Stderr = f 112 113 fmt.Printf("Executing .jci/run.sh...\n") 114 startTime := time.Now() 115 runErr := cmd.Run() 116 duration := time.Since(startTime) 117 118 // Write footer 119 fmt.Fprintf(f, "\n======================\n") 120 fmt.Fprintf(f, "Finished: %s\n", time.Now().Format(time.RFC3339)) 121 fmt.Fprintf(f, "Duration: %s\n", duration.Round(time.Millisecond)) 122 if runErr != nil { 123 fmt.Fprintf(f, "Exit: FAILED - %v\n", runErr) 124 } else { 125 fmt.Fprintf(f, "Exit: SUCCESS\n") 126 } 127 128 return runErr 129 } 130 131 // generateIndexHTML creates a minimal index.html for standalone viewing 132 // The main UI is served by the web server; this is for direct file access 133 func generateIndexHTML(outputDir string, commit string, ciErr error) error { 134 commitMsg, _ := git("log", "-1", "--format=%s", commit) 135 136 statusIcon := "✓ PASSED" 137 statusColor := "#1a7f37" 138 statusBg := "#dafbe1" 139 if ciErr != nil { 140 statusIcon = "✗ FAILED" 141 statusColor = "#cf222e" 142 statusBg = "#ffebe9" 143 } 144 145 // Read output for standalone view 146 outputContent := "" 147 outputFile := filepath.Join(outputDir, "run.output.txt") 148 if data, err := os.ReadFile(outputFile); err == nil { 149 outputContent = string(data) 150 } 151 152 html := fmt.Sprintf(`<!DOCTYPE html> 153 <html> 154 <head> 155 <meta charset="utf-8"> 156 <title>%s %s</title> 157 <style> 158 body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace; font-size: 13px; background: #f5f5f5; color: #24292f; padding: 16px; } 159 .header { margin-bottom: 12px; padding: 12px; background: #fff; border-radius: 8px; border: 1px solid #d0d7de; } 160 .status { display: inline-block; padding: 4px 10px; border-radius: 16px; font-weight: 600; font-size: 12px; background: %s; color: %s; } 161 .commit-info { margin-top: 8px; color: #57606a; font-size: 12px; } 162 .commit-hash { color: #0969da; font-family: monospace; } 163 pre { white-space: pre-wrap; background: #fff; padding: 16px; border-radius: 8px; border: 1px solid #d0d7de; font-family: "Monaco", "Menlo", monospace; font-size: 12px; line-height: 1.5; } 164 </style> 165 </head> 166 <body> 167 <div class="header"> 168 <span class="status">%s</span> 169 <div class="commit-info"><span class="commit-hash">%s</span> %s</div> 170 </div> 171 <pre>%s</pre> 172 </body> 173 </html> 174 `, commit[:7], escapeHTML(commitMsg), 175 statusBg, statusColor, 176 statusIcon, commit[:7], escapeHTML(commitMsg), 177 escapeHTML(outputContent)) 178 179 indexPath := filepath.Join(outputDir, "index.html") 180 return os.WriteFile(indexPath, []byte(html), 0644) 181 } 182 183 func escapeHTML(s string) string { 184 replacer := map[rune]string{ 185 '<': "<", 186 '>': ">", 187 '&': "&", 188 '"': """, 189 '\'': "'", 190 } 191 result := "" 192 for _, r := range s { 193 if rep, ok := replacer[r]; ok { 194 result += rep 195 } else { 196 result += string(r) 197 } 198 } 199 return result 200 } 201 202 // generateRunID creates a unique run identifier: <unix_timestamp>-<4_random_chars> 203 func generateRunID() string { 204 timestamp := time.Now().Unix() 205 b := make([]byte, 2) 206 rand.Read(b) 207 randomSuffix := hex.EncodeToString(b) 208 return fmt.Sprintf("%d-%s", timestamp, randomSuffix) 209 }