Jaypore CI

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

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 		'<':  "&lt;",
    186 		'>':  "&gt;",
    187 		'&':  "&amp;",
    188 		'"':  "&quot;",
    189 		'\'': "&#39;",
    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 }