run.go (6433B)
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 only if the job didn't already produce one 61 indexPath := filepath.Join(outputDir, "index.html") 62 if _, statErr := os.Stat(indexPath); os.IsNotExist(statErr) { 63 if htmlErr := generateIndexHTML(outputDir, commit, err); htmlErr != nil { 64 fmt.Printf("Warning: failed to generate index.html: %v\n", htmlErr) 65 } 66 } 67 68 // Store results in git 69 msg := fmt.Sprintf("CI results for %s (run %s)", commit[:12], runID) 70 if storeErr := StoreTree(outputDir, commit, msg, runID); storeErr != nil { 71 return fmt.Errorf("failed to store CI results: %w", storeErr) 72 } 73 74 // Clean up the output directory after storing in git 75 os.RemoveAll(outputDir) 76 77 ref := "refs/jci-runs/" + commit + "/" + runID 78 fmt.Printf("CI results stored at %s\n", ref) 79 if err != nil { 80 return fmt.Errorf("CI failed (results stored): %w", err) 81 } 82 return nil 83 } 84 85 // runCI executes .jci/run.sh and captures output 86 func runCI(repoRoot string, outputDir string, commit string) error { 87 runScript := filepath.Join(repoRoot, ".jci", "run.sh") 88 outputFile := filepath.Join(outputDir, "run.output.txt") 89 90 // Create output file 91 f, err := os.Create(outputFile) 92 if err != nil { 93 return fmt.Errorf("failed to create output file: %w", err) 94 } 95 defer f.Close() 96 97 // Write header 98 fmt.Fprintf(f, "=== JCI Run Output ===\n") 99 fmt.Fprintf(f, "Commit: %s\n", commit) 100 fmt.Fprintf(f, "Started: %s\n", time.Now().Format(time.RFC3339)) 101 fmt.Fprintf(f, "======================\n\n") 102 103 // Run the script 104 cmd := exec.Command("bash", runScript) 105 cmd.Dir = outputDir 106 cmd.Env = append(os.Environ(), 107 "JCI_COMMIT="+commit, 108 "JCI_REPO_ROOT="+repoRoot, 109 "JCI_OUTPUT_DIR="+outputDir, 110 ) 111 112 // Capture both stdout and stderr to the same file 113 cmd.Stdout = f 114 cmd.Stderr = f 115 116 fmt.Printf("Executing .jci/run.sh...\n") 117 startTime := time.Now() 118 runErr := cmd.Run() 119 duration := time.Since(startTime) 120 121 // Write footer 122 fmt.Fprintf(f, "\n======================\n") 123 fmt.Fprintf(f, "Finished: %s\n", time.Now().Format(time.RFC3339)) 124 fmt.Fprintf(f, "Duration: %s\n", duration.Round(time.Millisecond)) 125 if runErr != nil { 126 fmt.Fprintf(f, "Exit: FAILED - %v\n", runErr) 127 } else { 128 fmt.Fprintf(f, "Exit: SUCCESS\n") 129 } 130 131 return runErr 132 } 133 134 // generateIndexHTML creates a minimal index.html for standalone viewing 135 // The main UI is served by the web server; this is for direct file access 136 func generateIndexHTML(outputDir string, commit string, ciErr error) error { 137 commitMsg, _ := git("log", "-1", "--format=%s", commit) 138 139 statusIcon := "✓ PASSED" 140 statusColor := "#1a7f37" 141 statusBg := "#dafbe1" 142 if ciErr != nil { 143 statusIcon = "✗ FAILED" 144 statusColor = "#cf222e" 145 statusBg = "#ffebe9" 146 } 147 148 // Read output for standalone view 149 outputContent := "" 150 outputFile := filepath.Join(outputDir, "run.output.txt") 151 if data, err := os.ReadFile(outputFile); err == nil { 152 outputContent = string(data) 153 } 154 155 html := fmt.Sprintf(`<!DOCTYPE html> 156 <html> 157 <head> 158 <meta charset="utf-8"> 159 <title>%s %s</title> 160 <style> 161 body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace; font-size: 13px; background: #f5f5f5; color: #24292f; padding: 16px; } 162 .header { margin-bottom: 12px; padding: 12px; background: #fff; border-radius: 8px; border: 1px solid #d0d7de; } 163 .status { display: inline-block; padding: 4px 10px; border-radius: 16px; font-weight: 600; font-size: 12px; background: %s; color: %s; } 164 .commit-info { margin-top: 8px; color: #57606a; font-size: 12px; } 165 .commit-hash { color: #0969da; font-family: monospace; } 166 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; } 167 </style> 168 </head> 169 <body> 170 <div class="header"> 171 <span class="status">%s</span> 172 <div class="commit-info"><span class="commit-hash">%s</span> %s</div> 173 </div> 174 <pre>%s</pre> 175 </body> 176 </html> 177 `, commit[:7], escapeHTML(commitMsg), 178 statusBg, statusColor, 179 statusIcon, commit[:7], escapeHTML(commitMsg), 180 escapeHTML(outputContent)) 181 182 indexPath := filepath.Join(outputDir, "index.html") 183 return os.WriteFile(indexPath, []byte(html), 0644) 184 } 185 186 func escapeHTML(s string) string { 187 replacer := map[rune]string{ 188 '<': "<", 189 '>': ">", 190 '&': "&", 191 '"': """, 192 '\'': "'", 193 } 194 result := "" 195 for _, r := range s { 196 if rep, ok := replacer[r]; ok { 197 result += rep 198 } else { 199 result += string(r) 200 } 201 } 202 return result 203 } 204 205 // generateRunID creates a unique run identifier: <unix_timestamp>-<4_random_chars> 206 func generateRunID() string { 207 timestamp := time.Now().Unix() 208 b := make([]byte, 2) 209 if _, err := rand.Read(b); err != nil { 210 // Extremely unlikely; fall back to timestamp-only to avoid blocking 211 return fmt.Sprintf("%d-0000", timestamp) 212 } 213 return fmt.Sprintf("%d-%s", timestamp, hex.EncodeToString(b)) 214 }