Jaypore CI

> Jaypore CI: Minimal, Offline, Local CI system.
Log | Files | Refs | README | LICENSE

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 		'<':  "&lt;",
    189 		'>':  "&gt;",
    190 		'&':  "&amp;",
    191 		'"':  "&quot;",
    192 		'\'': "&#39;",
    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 }