Jaypore CI

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

commit fcb752edf737777c6e5f681f2fbf1bfc9cd9504a
parent bcacec63cfe080c4dab80ef59b77da3dc87b32c5
Author: Your Name <you@example.com>
Date:   Sat, 28 Feb 2026 11:49:54 +0530

Merge branch 'cli_clean' into ttyfix

Diffstat:
Mcmd/git-jci/main.go | 94+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Minternal/jci/cron.go | 333+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Minternal/jci/git.go | 39+++++++++++++++++++++++++++++++++++----
Minternal/jci/prune.go | 32+++++++++++++++++++++++++-------
Minternal/jci/pull.go | 8+++++++-
Minternal/jci/push.go | 64+++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Minternal/jci/run.go | 60+++++++++++++++++++++++++++++++++++++++++++-----------------
Minternal/jci/web.go | 656++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
8 files changed, 941 insertions(+), 345 deletions(-)

diff --git a/cmd/git-jci/main.go b/cmd/git-jci/main.go @@ -1,10 +1,10 @@ package main import ( - "fmt" - "os" + "fmt" + "os" - "github.com/exedev/git-jci/internal/jci" + "github.com/exedev/git-jci/internal/jci" ) // version is set at build time via -ldflags "-X main.version=<version>". @@ -12,57 +12,61 @@ import ( var version = "dev" func main() { - if len(os.Args) < 2 { - printUsage() - os.Exit(1) - } + if len(os.Args) < 2 { + printUsage() + os.Exit(1) + } - cmd := os.Args[1] - args := os.Args[2:] + cmd := os.Args[1] + args := os.Args[2:] - var err error - switch cmd { - case "run": - err = jci.Run(args) - case "web": - err = jci.Web(args) - case "push": - err = jci.Push(args) - case "pull": - err = jci.Pull(args) - case "prune": - err = jci.Prune(args) - case "version", "--version", "-v": - fmt.Println("git-jci version " + version) - return - case "help", "-h", "--help": - printUsage() - return - default: - fmt.Fprintf(os.Stderr, "unknown command: %s\n", cmd) - printUsage() - os.Exit(1) - } + var err error + switch cmd { + case "run": + err = jci.Run(args) + case "web": + err = jci.Web(args) + case "push": + err = jci.Push(args) + case "pull": + err = jci.Pull(args) + case "prune": + err = jci.Prune(args) + case "cron": + err = jci.Cron(args) + case "version", "--version", "-v": + fmt.Println("git-jci version " + version) + return + case "help", "-h", "--help": + printUsage() + return + default: + fmt.Fprintf(os.Stderr, "unknown command: %s\n", cmd) + printUsage() + os.Exit(1) + } - if err != nil { - fmt.Fprintf(os.Stderr, "error: %v\n", err) - os.Exit(1) - } + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } } func printUsage() { - fmt.Printf(`git-jci %s - Local-first CI system stored in git + fmt.Printf(`git-jci %s - Local-first CI system stored in git Usage: git jci <command> [options] Commands: - run Run CI for the current commit and store results - web Start a web server to view CI results - push Push CI results to remote - pull Pull CI results from remote - prune Remove old CI results - version Print the version and exit + run [--multi] Run CI for the current commit and store results + web Start a web server to view CI results + push Push CI results to remote + pull Pull CI results from remote + prune Remove old CI results + cron ls List cron jobs for this repository + cron sync Sync .jci/crontab with system cron + version Print the version and exit -CI results are stored in refs/jci/<commit> namespace. -`, version) +CI results are stored in refs/jci/<commit>. +With --multi, results are stored in refs/jci-runs/<commit>/<runid>.`, version) } diff --git a/internal/jci/cron.go b/internal/jci/cron.go @@ -2,184 +2,297 @@ package jci import ( "bufio" - "crypto/sha1" - "encoding/hex" - "errors" + "bytes" + "crypto/sha256" "fmt" "os" "os/exec" "path/filepath" + "regexp" "strings" ) +const cronMarkerPrefix = "# JCI:" + +// CronEntry represents a parsed crontab entry +type CronEntry struct { + Schedule string // e.g., "0 * * * *" + Name string // optional name/comment + Branch string // branch to run on (default: current) +} + +// Cron handles cron subcommands func Cron(args []string) error { if len(args) == 0 { - return errors.New("usage: git jci cron <ls|sync>") - } - - repoRoot, err := GetRepoRoot() - if err != nil { - return err + return fmt.Errorf("usage: git jci cron <ls|sync>") } switch args[0] { - case "ls", "list": - return cronList(repoRoot) + case "ls": + return cronList() case "sync": - return cronSync(repoRoot) + return cronSync() default: - return fmt.Errorf("unknown cron subcommand: %s", args[0]) + return fmt.Errorf("unknown cron command: %s (use ls or sync)", args[0]) } } -func cronList(repoRoot string) error { - entries, err := LoadCronEntries(repoRoot) +// cronList shows current cron jobs from .jci/crontab and system cron +func cronList() error { + repoRoot, err := GetRepoRoot() if err != nil { - return err + return fmt.Errorf("not in a git repository: %w", err) } - if len(entries) == 0 { - fmt.Println("No cron jobs defined. Create .jci/crontab to add jobs.") - return nil + repoID := getRepoID(repoRoot) + + // Show .jci/crontab entries + crontabFile := filepath.Join(repoRoot, ".jci", "crontab") + entries, err := parseCrontab(crontabFile) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to parse .jci/crontab: %w", err) } - fmt.Printf("%-10s %-17s %-10s %s\n", "ID", "SCHEDULE", "TYPE", "COMMAND") - for _, entry := range entries { - job := newCronJob(entry, repoRoot) - fmt.Printf("%-10s %-17s %-10s %s\n", job.ID[:8], job.Schedule, job.Type, job.Command) - if job.Type == CronJobBinary { - if _, err := os.Stat(job.BinaryPath); os.IsNotExist(err) { - fmt.Printf(" warning: binary %s does not exist\n", job.BinaryPath) + fmt.Printf("Repository: %s\n", repoRoot) + fmt.Printf("Repo ID: %s\n\n", repoID[:12]) + + if len(entries) == 0 { + fmt.Println("No entries in .jci/crontab") + } else { + fmt.Println("Configured in .jci/crontab:") + for _, e := range entries { + branch := e.Branch + if branch == "" { + branch = "(current)" + } + name := e.Name + if name == "" { + name = "(unnamed)" } + fmt.Printf(" %-20s %-15s %s\n", e.Schedule, branch, name) + } + } + + // Show what's in system crontab + fmt.Println("\nInstalled in system cron:") + systemEntries, err := getSystemCronEntries(repoID) + if err != nil { + fmt.Printf(" (could not read system cron: %v)\n", err) + } else if len(systemEntries) == 0 { + fmt.Println(" (none)") + } else { + for _, line := range systemEntries { + fmt.Printf(" %s\n", line) } } + return nil } -func cronSync(repoRoot string) error { - entries, err := LoadCronEntries(repoRoot) +// cronSync synchronizes .jci/crontab with system cron +func cronSync() error { + repoRoot, err := GetRepoRoot() if err != nil { - return err + return fmt.Errorf("not in a git repository: %w", err) } - if len(entries) == 0 { - return errors.New("no cron jobs defined in .jci/crontab") - } + repoID := getRepoID(repoRoot) + marker := cronMarkerPrefix + repoID - var jobs []CronJob - for _, entry := range entries { - jobs = append(jobs, newCronJob(entry, repoRoot)) + // Parse .jci/crontab + crontabFile := filepath.Join(repoRoot, ".jci", "crontab") + entries, err := parseCrontab(crontabFile) + if err != nil { + if os.IsNotExist(err) { + entries = nil // No crontab file = remove all entries + } else { + return fmt.Errorf("failed to parse .jci/crontab: %w", err) + } } - block, err := buildCronBlock(repoRoot, jobs) + // Get current system crontab + currentCron, err := getCurrentCrontab() if err != nil { - return err + return fmt.Errorf("failed to read current crontab: %w", err) } - existing, err := readCrontab() - if err != nil { - return err + // Remove old JCI entries for this repo + var newLines []string + for _, line := range strings.Split(currentCron, "\n") { + if !strings.Contains(line, marker) { + newLines = append(newLines, line) + } } - updated := applyCronBlock(existing, block, repoRoot) - if err := installCrontab(updated); err != nil { - return err + // Find git-jci binary path + jciBinary, err := findJCIBinary() + if err != nil { + return fmt.Errorf("could not find git-jci binary: %w", err) } - fmt.Printf("Synced %d cron job(s).\n", len(jobs)) - return nil -} + // Add new entries + for _, e := range entries { + cmd := fmt.Sprintf("cd %s && git fetch --quiet 2>/dev/null; ", shellEscape(repoRoot)) + if e.Branch != "" { + cmd += fmt.Sprintf("git checkout --quiet %s 2>/dev/null && git pull --quiet 2>/dev/null; ", shellEscape(e.Branch)) + } + cmd += fmt.Sprintf("%s run", jciBinary) -func newCronJob(entry CronEntry, repoRoot string) CronJob { - jobType, binPath, binArgs := classifyCronCommand(entry.Command, repoRoot) - id := cronJobID(entry.Schedule, entry.Command) - logPath := filepath.Join(repoRoot, ".jci", fmt.Sprintf("cron-%s.log", id[:8])) - return CronJob{ - ID: id, - Schedule: entry.Schedule, - Command: entry.Command, - Type: jobType, - BinaryPath: binPath, - BinaryArgs: binArgs, - Line: entry.Line, - CronLog: logPath, - } -} + comment := e.Name + if comment == "" { + comment = "jci" + } -func buildCronBlock(repoRoot string, jobs []CronJob) (string, error) { - if err := os.MkdirAll(filepath.Join(repoRoot, ".jci"), 0755); err != nil { - return "", fmt.Errorf("failed to create .jci directory: %w", err) + line := fmt.Sprintf("%s %s %s [%s]", e.Schedule, cmd, marker, comment) + newLines = append(newLines, line) } - blockID := cronBlockMarker(repoRoot) - var lines []string - lines = append(lines, fmt.Sprintf("# BEGIN %s", blockID)) + // Write new crontab + newCron := strings.Join(newLines, "\n") + // Clean up multiple empty lines + for strings.Contains(newCron, "\n\n\n") { + newCron = strings.ReplaceAll(newCron, "\n\n\n", "\n\n") + } + newCron = strings.TrimSpace(newCron) + "\n" - for _, job := range jobs { - line := fmt.Sprintf("%s %s", job.Schedule, job.shellCommand(repoRoot)) - lines = append(lines, line) + if err := installCrontab(newCron); err != nil { + return fmt.Errorf("failed to install crontab: %w", err) } - lines = append(lines, fmt.Sprintf("# END %s", blockID)) - return strings.Join(lines, "\n"), nil -} + if len(entries) == 0 { + fmt.Printf("Removed all JCI cron entries for %s\n", repoRoot) + } else { + fmt.Printf("Synced %d cron entries for %s\n", len(entries), repoRoot) + } -func cronBlockMarker(repoRoot string) string { - hash := sha1.Sum([]byte(repoRoot)) - return fmt.Sprintf("git-jci %s %s", repoRoot, hex.EncodeToString(hash[:8])) + return nil } -func readCrontab() (string, error) { - if _, err := exec.LookPath("crontab"); err != nil { - return "", fmt.Errorf("crontab command not found: %w", err) - } - - cmd := exec.Command("crontab", "-l") - out, err := cmd.CombinedOutput() +// parseCrontab parses a .jci/crontab file +// Format: +// # comment +// SCHEDULE [branch:BRANCH] [name:NAME] +// 0 * * * * # every hour, current branch +// 0 0 * * * branch:main # daily at midnight, main branch +// */15 * * * * name:quick-test # every 15 min +func parseCrontab(path string) ([]CronEntry, error) { + f, err := os.Open(path) if err != nil { - if strings.Contains(string(out), "no crontab for") { - return "", nil - } - return "", fmt.Errorf("crontab -l: %v", err) + return nil, err } - return string(out), nil -} + defer f.Close() -func applyCronBlock(existing, block, repoRoot string) string { - begin := fmt.Sprintf("# BEGIN %s", cronBlockMarker(repoRoot)) - end := fmt.Sprintf("# END %s", cronBlockMarker(repoRoot)) + var entries []CronEntry + scanner := bufio.NewScanner(f) + scheduleRe := regexp.MustCompile(`^([*0-9,/-]+\s+[*0-9,/-]+\s+[*0-9,/-]+\s+[*0-9,/-]+\s+[*0-9,/-]+)\s*(.*)`) - var result []string - scanner := bufio.NewScanner(strings.NewReader(existing)) - skip := false for scanner.Scan() { - line := scanner.Text() - trimmed := strings.TrimSpace(line) - if trimmed == begin { - skip = true + line := strings.TrimSpace(scanner.Text()) + + // Skip empty lines and comments + if line == "" || strings.HasPrefix(line, "#") { continue } - if trimmed == end { - skip = false - continue + + matches := scheduleRe.FindStringSubmatch(line) + if matches == nil { + continue // Invalid line, skip + } + + entry := CronEntry{ + Schedule: matches[1], } - if !skip { - result = append(result, line) + + // Parse options + opts := matches[2] + for _, part := range strings.Fields(opts) { + if strings.HasPrefix(part, "branch:") { + entry.Branch = strings.TrimPrefix(part, "branch:") + } else if strings.HasPrefix(part, "name:") { + entry.Name = strings.TrimPrefix(part, "name:") + } } + + entries = append(entries, entry) } - if len(result) != 0 && strings.TrimSpace(result[len(result)-1]) != "" { - result = append(result, "") + return entries, scanner.Err() +} + +// getRepoID generates a unique ID for a repository based on its path +func getRepoID(repoRoot string) string { + h := sha256.Sum256([]byte(repoRoot)) + return fmt.Sprintf("%x", h) +} + +// getCurrentCrontab returns the current user's crontab +func getCurrentCrontab() (string, error) { + cmd := exec.Command("crontab", "-l") + out, err := cmd.Output() + if err != nil { + // No crontab for user is not an error + if strings.Contains(err.Error(), "no crontab") { + return "", nil + } + // Check stderr for "no crontab" message + if exitErr, ok := err.(*exec.ExitError); ok { + if strings.Contains(string(exitErr.Stderr), "no crontab") { + return "", nil + } + } + return "", err } - result = append(result, block) - return strings.Join(result, "\n") + "\n" + return string(out), nil } +// installCrontab installs a new crontab func installCrontab(content string) error { cmd := exec.Command("crontab", "-") cmd.Stdin = strings.NewReader(content) - if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("failed to install crontab: %v (%s)", err, string(out)) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("%v: %s", err, stderr.String()) } return nil } + +// getSystemCronEntries returns JCI entries for this repo from system cron +func getSystemCronEntries(repoID string) ([]string, error) { + current, err := getCurrentCrontab() + if err != nil { + return nil, err + } + + marker := cronMarkerPrefix + repoID + var entries []string + for _, line := range strings.Split(current, "\n") { + if strings.Contains(line, marker) { + entries = append(entries, line) + } + } + return entries, nil +} + +// findJCIBinary finds the path to git-jci binary +func findJCIBinary() (string, error) { + // First try to find ourselves + exe, err := os.Executable() + if err == nil { + return exe, nil + } + + // Try PATH + path, err := exec.LookPath("git-jci") + if err == nil { + return path, nil + } + + return "", fmt.Errorf("git-jci not found in PATH") +} + +// shellEscape escapes a string for safe use in shell +func shellEscape(s string) string { + // Simple escaping - wrap in single quotes and escape existing single quotes + return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" +} diff --git a/internal/jci/git.go b/internal/jci/git.go @@ -37,8 +37,8 @@ func RefExists(ref string) bool { return err == nil } -// StoreTree stores a directory as a tree object and creates a commit under refs/jci/<commit> -func StoreTree(dir string, commit string, message string) error { +// StoreTree stores a directory as a tree object and creates a commit under refs/jci-runs/<commit>/<runID> +func StoreTree(dir string, commit string, message string, runID string) error { repoRoot, err := GetRepoRoot() if err != nil { return err @@ -63,8 +63,8 @@ func StoreTree(dir string, commit string, message string) error { } commitID := strings.TrimSpace(string(commitOut)) - // Update ref - ref := "refs/jci/" + commit + // Update ref: refs/jci-runs/<commit>/<runid> + ref := "refs/jci-runs/" + commit + "/" + runID if _, err := git("update-ref", ref, commitID); err != nil { return fmt.Errorf("git update-ref: %v", err) } @@ -142,3 +142,34 @@ func ListJCIRefs() ([]string, error) { } return strings.Split(out, "\n"), nil } + +// ListJCIRunRefs returns all refs under refs/jci-runs/ +func ListJCIRunRefs() ([]string, error) { + out, err := git("for-each-ref", "--format=%(refname)", "refs/jci-runs/") + if err != nil { + return nil, err + } + if out == "" { + return nil, nil + } + return strings.Split(out, "\n"), nil +} + +// ListAllJCIRefs returns all JCI refs (both single and multi-run) +func ListAllJCIRefs() ([]string, error) { + var allRefs []string + + // Get refs/jci/* + out, err := git("for-each-ref", "--format=%(refname)", "refs/jci/") + if err == nil && out != "" { + allRefs = append(allRefs, strings.Split(out, "\n")...) + } + + // Get refs/jci-runs/* + out, err = git("for-each-ref", "--format=%(refname)", "refs/jci-runs/") + if err == nil && out != "" { + allRefs = append(allRefs, strings.Split(out, "\n")...) + } + + return allRefs, nil +} diff --git a/internal/jci/prune.go b/internal/jci/prune.go @@ -212,11 +212,10 @@ func pruneRemote(opts *PruneOptions) error { fmt.Printf("Fetching CI refs from %s...\n", remote) - // Get remote refs - out, err := git("ls-remote", remote, "refs/jci/*") - if err != nil { - return fmt.Errorf("failed to list remote refs: %v", err) - } + // Get remote refs (both jci and jci-runs) + out1, _ := git("ls-remote", remote, "refs/jci/*") + out2, _ := git("ls-remote", remote, "refs/jci-runs/*") + out := strings.TrimSpace(out1 + "\n" + out2) if out == "" { fmt.Println("No CI results on remote") @@ -239,7 +238,7 @@ func pruneRemote(opts *PruneOptions) error { } refName := parts[1] - commit := strings.TrimPrefix(refName, "refs/jci/") + commit := extractCommitFromRef(refName) info := RefInfo{ Ref: refName, @@ -313,7 +312,8 @@ func pruneRemote(opts *PruneOptions) error { for i, info := range toPrune { printProgress(i+1, len(toPrune), "Deleting") // Push empty ref to delete - _, err := git("push", remote, ":refs/jci/"+info.Commit) + // Push empty ref to delete + _, err := git("push", remote, ":"+info.Ref) if err != nil { fmt.Printf("\n Warning: failed to delete %s: %v\n", info.Commit[:12], err) continue @@ -401,3 +401,21 @@ func formatAge(d time.Duration) string { } return "<1h" } + +// extractCommitFromRef extracts the commit hash from a JCI ref +// refs/jci/<commit> -> <commit> +// refs/jci-runs/<commit>/<runid> -> <commit> +func extractCommitFromRef(ref string) string { + if strings.HasPrefix(ref, "refs/jci-runs/") { + // refs/jci-runs/<commit>/<runid> + parts := strings.Split(strings.TrimPrefix(ref, "refs/jci-runs/"), "/") + if len(parts) >= 1 { + return parts[0] + } + } else if strings.HasPrefix(ref, "refs/jci/") { + return strings.TrimPrefix(ref, "refs/jci/") + } else if strings.HasPrefix(ref, "jci/") { + return strings.TrimPrefix(ref, "jci/") + } + return ref +} diff --git a/internal/jci/pull.go b/internal/jci/pull.go @@ -16,7 +16,13 @@ func Pull(args []string) error { // Fetch all refs/jci/* from remote _, err := git("fetch", remote, "refs/jci/*:refs/jci/*") if err != nil { - return err + fmt.Printf("Warning: %v\n", err) + } + + // Fetch all refs/jci-runs/* from remote + _, err = git("fetch", remote, "refs/jci-runs/*:refs/jci-runs/*") + if err != nil { + fmt.Printf("Warning: %v\n", err) } fmt.Println("Done") diff --git a/internal/jci/push.go b/internal/jci/push.go @@ -2,6 +2,7 @@ package jci import ( "fmt" + "strings" ) // Push pushes CI results to remote @@ -11,24 +12,73 @@ func Push(args []string) error { remote = args[0] } - refs, err := ListJCIRefs() + // Get all local JCI refs + localRefs, err := ListAllJCIRefs() if err != nil { return err } - if len(refs) == 0 { + if len(localRefs) == 0 { fmt.Println("No CI results to push") return nil } - fmt.Printf("Pushing %d CI result(s) to %s...\n", len(refs), remote) + // Get remote refs to find what's already pushed + remoteRefs := getRemoteJCIRefs(remote) - // Push all refs/jci/* to remote - _, err = git("push", remote, "refs/jci/*:refs/jci/*") - if err != nil { - return err + // Find refs that need to be pushed + var toPush []string + for _, ref := range localRefs { + if !remoteRefs[ref] { + toPush = append(toPush, ref) + } + } + + if len(toPush) == 0 { + fmt.Println("All CI results already pushed") + return nil + } + + fmt.Printf("Pushing %d new CI result(s) to %s...\n", len(toPush), remote) + + // Push each ref individually + for _, ref := range toPush { + _, err = git("push", remote, ref+":"+ref) + if err != nil { + return fmt.Errorf("failed to push %s: %w", ref, err) + } + fmt.Printf(" %s\n", ref) } fmt.Println("Done") return nil } + +// getRemoteJCIRefs returns a set of refs that exist on the remote +func getRemoteJCIRefs(remote string) map[string]bool { + remoteCI := make(map[string]bool) + + // Get refs/jci/* + out, err := git("ls-remote", "--refs", remote, "refs/jci/*") + if err == nil && out != "" { + for _, line := range strings.Split(out, "\n") { + parts := strings.Fields(line) + if len(parts) >= 2 { + remoteCI[parts[1]] = true + } + } + } + + // Get refs/jci-runs/* + out, err = git("ls-remote", "--refs", remote, "refs/jci-runs/*") + if err == nil && out != "" { + for _, line := range strings.Split(out, "\n") { + parts := strings.Fields(line) + if len(parts) >= 2 { + remoteCI[parts[1]] = true + } + } + } + + return remoteCI +} diff --git a/internal/jci/run.go b/internal/jci/run.go @@ -1,6 +1,8 @@ package jci import ( + "crypto/rand" + "encoding/hex" "fmt" "os" "os/exec" @@ -9,6 +11,7 @@ import ( ) // Run executes CI for the current commit +// Each run gets a unique ID (timestamp+random suffix) stored in refs/jci-runs/<commit>/<runid> func Run(args []string) error { // Get current commit commit, err := GetCurrentCommit() @@ -18,12 +21,9 @@ func Run(args []string) error { fmt.Printf("Running CI for commit %s\n", commit[:12]) - // Check if CI already ran for this commit - ref := "refs/jci/" + commit - if RefExists(ref) { - fmt.Printf("CI results already exist for %s\n", commit[:12]) - return nil - } + // Generate unique run ID + runID := generateRunID() + fmt.Printf("Run ID: %s\n", runID) repoRoot, err := GetRepoRoot() if err != nil { @@ -42,24 +42,36 @@ func Run(args []string) error { return fmt.Errorf("failed to create output dir: %w", err) } + // Set initial status to "running" + statusFile := filepath.Join(outputDir, "status.txt") + os.WriteFile(statusFile, []byte("running"), 0644) + // Run CI err = runCI(repoRoot, outputDir, commit) // Continue even if CI fails - we still want to store the results + // Update status based on result + if err != nil { + os.WriteFile(statusFile, []byte("err"), 0644) + } else { + os.WriteFile(statusFile, []byte("ok"), 0644) + } + // Generate index.html with results if err := generateIndexHTML(outputDir, commit, err); err != nil { fmt.Printf("Warning: failed to generate index.html: %v\n", err) } // Store results in git - msg := fmt.Sprintf("CI results for %s", commit[:12]) - if storeErr := StoreTree(outputDir, commit, msg); storeErr != nil { + msg := fmt.Sprintf("CI results for %s (run %s)", commit[:12], runID) + if storeErr := StoreTree(outputDir, commit, msg, runID); storeErr != nil { return fmt.Errorf("failed to store CI results: %w", storeErr) } // Clean up the output directory after storing in git os.RemoveAll(outputDir) + ref := "refs/jci-runs/" + commit + "/" + runID fmt.Printf("CI results stored at %s\n", ref) if err != nil { return fmt.Errorf("CI failed (results stored): %w", err) @@ -121,11 +133,13 @@ func runCI(repoRoot string, outputDir string, commit string) error { func generateIndexHTML(outputDir string, commit string, ciErr error) error { commitMsg, _ := git("log", "-1", "--format=%s", commit) - status := "success" statusIcon := "✓ PASSED" + statusColor := "#1a7f37" + statusBg := "#dafbe1" if ciErr != nil { - status = "failed" statusIcon = "✗ FAILED" + statusColor = "#cf222e" + statusBg = "#ffebe9" } // Read output for standalone view @@ -141,22 +155,25 @@ func generateIndexHTML(outputDir string, commit string, ciErr error) error { <meta charset="utf-8"> <title>%s %s</title> <style> - body { font-family: monospace; font-size: 12px; background: #1a1a1a; color: #e0e0e0; padding: 8px; } - .header { margin-bottom: 8px; } - .%s { color: %s; font-weight: bold; } - pre { white-space: pre-wrap; } + body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace; font-size: 13px; background: #f5f5f5; color: #24292f; padding: 16px; } + .header { margin-bottom: 12px; padding: 12px; background: #fff; border-radius: 8px; border: 1px solid #d0d7de; } + .status { display: inline-block; padding: 4px 10px; border-radius: 16px; font-weight: 600; font-size: 12px; background: %s; color: %s; } + .commit-info { margin-top: 8px; color: #57606a; font-size: 12px; } + .commit-hash { color: #0969da; font-family: monospace; } + 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; } </style> </head> <body> <div class="header"> - <span class="%s">%s</span> %s %s + <span class="status">%s</span> + <div class="commit-info"><span class="commit-hash">%s</span> %s</div> </div> <pre>%s</pre> </body> </html> `, commit[:7], escapeHTML(commitMsg), - status, map[string]string{"success": "#3fb950", "failed": "#f85149"}[status], - status, statusIcon, commit[:7], escapeHTML(commitMsg), + statusBg, statusColor, + statusIcon, commit[:7], escapeHTML(commitMsg), escapeHTML(outputContent)) indexPath := filepath.Join(outputDir, "index.html") @@ -181,3 +198,12 @@ func escapeHTML(s string) string { } return result } + +// generateRunID creates a unique run identifier: <unix_timestamp>-<4_random_chars> +func generateRunID() string { + timestamp := time.Now().Unix() + b := make([]byte, 2) + rand.Read(b) + randomSuffix := hex.EncodeToString(b) + return fmt.Sprintf("%d-%s", timestamp, randomSuffix) +} diff --git a/internal/jci/web.go b/internal/jci/web.go @@ -19,12 +19,20 @@ type BranchInfo struct { // CommitInfo holds commit data for the UI type CommitInfo struct { - Hash string `json:"hash"` - ShortHash string `json:"shortHash"` - Message string `json:"message"` - HasCI bool `json:"hasCI"` - CIStatus string `json:"ciStatus"` // "success", "failed", or "" - CIPushed bool `json:"ciPushed"` // whether CI ref is pushed to remote + Hash string `json:"hash"` + ShortHash string `json:"shortHash"` + Message string `json:"message"` + HasCI bool `json:"hasCI"` + CIStatus string `json:"ciStatus"` // "success", "failed", or "" + CIPushed bool `json:"ciPushed"` // whether CI ref is pushed to remote + Runs []RunInfo `json:"runs"` // multiple runs (for cron) +} + +// RunInfo holds info about a single CI run +type RunInfo struct { + RunID string `json:"runId"` + Status string `json:"status"` + Ref string `json:"ref"` } // Web starts a web server to view CI results @@ -52,12 +60,6 @@ func Web(args []string) error { func handleRequest(w http.ResponseWriter, r *http.Request, repoRoot string) { path := r.URL.Path - // Root or /jci/... without file: show main SPA - if path == "/" || (strings.HasPrefix(path, "/jci/") && !strings.Contains(strings.TrimPrefix(path, "/jci/"), ".")) { - showMainPage(w, r) - return - } - // API endpoint for branch data if path == "/api/branches" { serveBranchesAPI(w) @@ -71,19 +73,27 @@ func handleRequest(w http.ResponseWriter, r *http.Request, repoRoot string) { return } - // /jci/<commit>/<file> - serve files from that commit's CI results - if strings.HasPrefix(path, "/jci/") { - parts := strings.SplitN(strings.TrimPrefix(path, "/jci/"), "/", 2) - commit := parts[0] - filePath := "" - if len(parts) > 1 { - filePath = parts[1] - } - if filePath == "" { - showMainPage(w, r) + // /jci/<commit>/<file>/raw - serve raw file + // Also handles /jci/<commit>/<runid>/<file>/raw + if strings.HasPrefix(path, "/jci/") && strings.HasSuffix(path, "/raw") { + trimmed := strings.TrimPrefix(path, "/jci/") + trimmed = strings.TrimSuffix(trimmed, "/raw") + // trimmed is now: <commit>/<file> or <commit>/<runid>/<file> + parts := strings.SplitN(trimmed, "/", 3) + if len(parts) == 2 && parts[1] != "" { + // <commit>/<file> + serveFromRef(w, parts[0], "", parts[1]) + return + } else if len(parts) == 3 && parts[2] != "" { + // <commit>/<runid>/<file> + serveFromRef(w, parts[0], parts[1], parts[2]) return } - serveFromRef(w, commit, filePath) + } + + // Root or /jci/... - show main SPA (UI handles routing) + if path == "/" || strings.HasPrefix(path, "/jci/") { + showMainPage(w, r) return } @@ -103,29 +113,6 @@ func getLocalBranches() ([]string, error) { } // getRemoteJCIRefs returns a set of commits that have CI refs pushed to remote -func getRemoteJCIRefs(remote string) map[string]bool { - remoteCI := make(map[string]bool) - - // Get remote JCI refs - out, err := git("ls-remote", "--refs", remote, "refs/jci/*") - if err != nil { - return remoteCI - } - if out == "" { - return remoteCI - } - - for _, line := range strings.Split(out, "\n") { - parts := strings.Fields(line) - if len(parts) >= 2 { - // refs/jci/<commit> -> <commit> - ref := parts[1] - commit := strings.TrimPrefix(ref, "refs/jci/") - remoteCI[commit] = true - } - } - return remoteCI -} // getBranchCommits returns recent commits for a branch func getBranchCommits(branch string, limit int) ([]CommitInfo, error) { @@ -138,7 +125,7 @@ func getBranchCommits(branch string, limit int) ([]CommitInfo, error) { return nil, nil } - // Get local JCI refs + // Get local JCI refs (single-run) jciRefs, _ := ListJCIRefs() jciSet := make(map[string]bool) for _, ref := range jciRefs { @@ -146,6 +133,19 @@ func getBranchCommits(branch string, limit int) ([]CommitInfo, error) { jciSet[commit] = true } + // Get local JCI run refs (multi-run) + // Map: commit -> list of run refs + jciRuns := make(map[string][]string) + runRefs, _ := ListJCIRunRefs() + for _, ref := range runRefs { + // ref is refs/jci-runs/<commit>/<runid> + parts := strings.Split(strings.TrimPrefix(ref, "refs/jci-runs/"), "/") + if len(parts) >= 2 { + commit := parts[0] + jciRuns[commit] = append(jciRuns[commit], ref) + } + } + // Get remote JCI refs for CI push status remoteCI := getRemoteJCIRefs("origin") @@ -158,39 +158,77 @@ func getBranchCommits(branch string, limit int) ([]CommitInfo, error) { hash := parts[0] msg := parts[1] + hasCI := jciSet[hash] || len(jciRuns[hash]) > 0 commit := CommitInfo{ Hash: hash, ShortHash: hash[:7], Message: msg, - HasCI: jciSet[hash], - CIPushed: remoteCI[hash], + HasCI: hasCI, + CIPushed: remoteCI["refs/jci/"+hash], } - if commit.HasCI { + if jciSet[hash] { commit.CIStatus = getCIStatus(hash) } + // Add multi-run info + for _, runRef := range jciRuns[hash] { + parts := strings.Split(strings.TrimPrefix(runRef, "refs/jci-runs/"), "/") + if len(parts) >= 2 { + runID := parts[1] + status := getCIStatusFromRef(runRef) + commit.Runs = append(commit.Runs, RunInfo{ + RunID: runID, + Status: status, + Ref: runRef, + }) + } + } + + // If no single-run status but has runs, use latest run status + if commit.CIStatus == "" && len(commit.Runs) > 0 { + commit.CIStatus = commit.Runs[len(commit.Runs)-1].Status + } + commits = append(commits, commit) } return commits, nil } -// getCIStatus returns "success" or "failed" based on CI results +// getCIStatus returns "success", "failed", or "running" based on status.txt func getCIStatus(commit string) string { - // Try to read the index.html and look for status - ref := "refs/jci/" + commit - cmd := exec.Command("git", "show", ref+":index.html") + return getCIStatusFromRef("refs/jci/" + commit) +} + +// getCIStatusFromRef returns status from any ref +func getCIStatusFromRef(ref string) string { + // Try to read status.txt (new format) + cmd := exec.Command("git", "show", ref+":status.txt") out, err := cmd.Output() + if err == nil { + status := strings.TrimSpace(string(out)) + switch status { + case "ok": + return "success" + case "err": + return "failed" + case "running": + return "running" + } + } + + // Fallback: parse index.html for old results + cmd = exec.Command("git", "show", ref+":index.html") + out, err = cmd.Output() if err != nil { return "" } - content := string(out) - if strings.Contains(content, "class=\"status success\"") { + if strings.Contains(content, "PASSED") || strings.Contains(content, "SUCCESS") { return "success" } - if strings.Contains(content, "class=\"status failed\"") { + if strings.Contains(content, "FAILED") { return "failed" } return "" @@ -198,25 +236,75 @@ func getCIStatus(commit string) string { // CommitDetail holds detailed commit info for the API type CommitDetail struct { - Hash string `json:"hash"` - Author string `json:"author"` - Date string `json:"date"` - Status string `json:"status"` - Files []string `json:"files"` + Hash string `json:"hash"` + Author string `json:"author"` + Date string `json:"date"` + Status string `json:"status"` + Files []string `json:"files"` + Ref string `json:"ref"` // The actual ref used + RunID string `json:"runId"` // Current run ID + Runs []RunInfo `json:"runs"` // All runs for this commit } // serveCommitAPI returns commit details and file list +// commit can be just <hash> or <hash>/<runid> func serveCommitAPI(w http.ResponseWriter, commit string) { - ref := "refs/jci/" + commit + var ref string + var actualCommit string + var currentRunID string + + // Check if this is a run-specific request: <commit>/<runid> + if strings.Contains(commit, "/") { + parts := strings.SplitN(commit, "/", 2) + actualCommit = parts[0] + currentRunID = parts[1] + ref = "refs/jci-runs/" + actualCommit + "/" + currentRunID + } else { + actualCommit = commit + ref = "refs/jci/" + commit + // Check if single-run ref exists, otherwise try to find latest run + if !RefExists(ref) { + // Look for runs - get the latest one + runRefs, _ := ListJCIRunRefs() + for _, r := range runRefs { + if strings.HasPrefix(r, "refs/jci-runs/"+commit+"/") { + ref = r // Use the last one (they're sorted) + parts := strings.Split(r, "/") + if len(parts) >= 4 { + currentRunID = parts[3] + } + } + } + } + } + if !RefExists(ref) { http.Error(w, "not found", 404) return } + // Get all runs for this commit + var runs []RunInfo + runRefs, _ := ListJCIRunRefs() + for _, r := range runRefs { + if strings.HasPrefix(r, "refs/jci-runs/"+actualCommit+"/") { + parts := strings.Split(r, "/") + if len(parts) >= 4 { + runID := parts[3] + status := getCIStatusFromRef(r) + runs = append(runs, RunInfo{ + RunID: runID, + Status: status, + Ref: r, + }) + } + } + } + // Get commit info - author, _ := git("log", "-1", "--format=%an", commit) - date, _ := git("log", "-1", "--format=%cr", commit) - status := getCIStatus(commit) + author, _ := git("log", "-1", "--format=%an", actualCommit) + date, _ := git("log", "-1", "--format=%cr", actualCommit) + status := getCIStatusFromRef(ref) // List files in the CI ref filesOut, err := git("ls-tree", "--name-only", ref) @@ -230,11 +318,14 @@ func serveCommitAPI(w http.ResponseWriter, commit string) { } detail := CommitDetail{ - Hash: commit, + Hash: actualCommit, Author: author, Date: date, Status: status, Files: files, + Ref: ref, + RunID: currentRunID, + Runs: runs, } w.Header().Set("Content-Type", "application/json") @@ -279,41 +370,42 @@ func showMainPage(w http.ResponseWriter, r *http.Request) { body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace; font-size: 12px; - background: #1a1a1a; - color: #e0e0e0; + background: #f5f5f5; + color: #333; display: flex; height: 100vh; overflow: hidden; } - a { color: #58a6ff; text-decoration: none; } + a { color: #0969da; text-decoration: none; } a:hover { text-decoration: underline; } /* Left panel - commits */ .commits-panel { - width: 240px; - background: #1e1e1e; - border-right: 1px solid #333; + width: 280px; + background: #fff; + border-right: 1px solid #d0d7de; display: flex; flex-direction: column; flex-shrink: 0; } .panel-header { - padding: 6px 8px; - background: #252525; - border-bottom: 1px solid #333; + padding: 8px 10px; + background: #f6f8fa; + border-bottom: 1px solid #d0d7de; display: flex; align-items: center; - gap: 6px; + gap: 8px; } - .panel-header h1 { font-size: 12px; font-weight: 600; color: #888; } + .panel-header h1 { font-size: 13px; font-weight: 600; color: #24292f; } + .logo { height: 24px; width: auto; } .branch-selector { flex: 1; - padding: 2px 4px; - font-size: 11px; - border: 1px solid #444; - border-radius: 3px; - background: #2a2a2a; - color: #fff; + padding: 4px 8px; + font-size: 12px; + border: 1px solid #d0d7de; + border-radius: 6px; + background: #fff; + color: #24292f; } .commit-list { list-style: none; @@ -321,62 +413,95 @@ func showMainPage(w http.ResponseWriter, r *http.Request) { flex: 1; } .commit-item { - padding: 3px 6px; + padding: 6px 10px; cursor: pointer; display: flex; align-items: center; - gap: 4px; - border-bottom: 1px solid #252525; + gap: 8px; + border-bottom: 1px solid #eaeef2; } - .commit-item:hover { background: #2a2a2a; } - .commit-item.selected { background: #2d4a3e; } + .commit-item:hover { background: #f6f8fa; } + .commit-item.selected { background: #ddf4ff; } .commit-item.no-ci { opacity: 0.5; } - .ci-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; } - .ci-dot.success { background: #3fb950; } - .ci-dot.failed { background: #f85149; } - .ci-dot.none { background: #484f58; } - .commit-hash { font-size: 10px; color: #58a6ff; flex-shrink: 0; } - .commit-msg { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #888; font-size: 11px; } - .ci-push-badge { font-size: 8px; color: #666; } - .ci-push-badge.pushed { color: #3fb950; } + + /* Status indicator */ + .status-badge { + font-size: 10px; + font-weight: 600; + padding: 2px 6px; + border-radius: 12px; + flex-shrink: 0; + } + .status-badge.success { background: #dafbe1; color: #1a7f37; } + .status-badge.failed { background: #ffebe9; color: #cf222e; } + .status-badge.running { background: #fff8c5; color: #9a6700; } + .status-badge.none { background: #eaeef2; color: #656d76; } + + .commit-hash { font-size: 11px; color: #0969da; flex-shrink: 0; font-family: monospace; } + .commit-msg { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #57606a; font-size: 12px; } + + /* Push status badge */ + .push-badge { + font-size: 9px; + font-weight: 600; + padding: 1px 5px; + border-radius: 10px; + flex-shrink: 0; + } + .push-badge.pushed { background: #ddf4ff; color: #0969da; } + .push-badge.local { background: #fff8c5; color: #9a6700; } /* Middle panel - files */ .files-panel { - width: 180px; - background: #1e1e1e; - border-right: 1px solid #333; + width: 200px; + background: #fff; + border-right: 1px solid #d0d7de; display: flex; flex-direction: column; flex-shrink: 0; } .files-panel.hidden { display: none; } .commit-info { - padding: 6px 8px; - background: #252525; - border-bottom: 1px solid #333; - font-size: 11px; + padding: 10px 12px; + background: #f6f8fa; + border-bottom: 1px solid #d0d7de; + font-size: 12px; + } + .commit-info .status-line { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; } - .commit-info .status { font-weight: 600; } - .commit-info .status.success { color: #3fb950; } - .commit-info .status.failed { color: #f85149; } - .commit-info .hash { color: #58a6ff; } - .commit-info .meta { color: #666; margin-top: 2px; } + .commit-info .status-icon { font-size: 14px; font-weight: bold; } + .commit-info .status-icon.success { color: #1a7f37; } + .commit-info .status-icon.failed { color: #cf222e; } + .commit-info .status-icon.running { color: #9a6700; } + .commit-info .hash { color: #0969da; font-family: monospace; } + .commit-info .meta { color: #656d76; margin-top: 4px; font-size: 11px; } + .run-selector { margin-top: 8px; } + .run-selector select { width: 100%; padding: 4px 6px; font-size: 11px; border: 1px solid #d0d7de; border-radius: 4px; background: #fff; } + .run-nav { display: flex; gap: 4px; margin-top: 6px; } + .run-nav button { flex: 1; padding: 4px 8px; font-size: 10px; border: 1px solid #d0d7de; border-radius: 4px; background: #f6f8fa; cursor: pointer; } + .run-nav button:hover { background: #eaeef2; } + .run-nav button:disabled { opacity: 0.5; cursor: not-allowed; } .file-list { list-style: none; overflow-y: auto; flex: 1; } .file-item { - padding: 3px 8px; + padding: 6px 12px; cursor: pointer; - border-bottom: 1px solid #252525; + border-bottom: 1px solid #eaeef2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - font-size: 11px; + font-size: 12px; + color: #24292f; } - .file-item:hover { background: #2a2a2a; } - .file-item.selected { background: #2d4a3e; } + .file-item:hover { background: #f6f8fa; } + .file-item.selected { background: #ddf4ff; } /* Right panel - content */ .content-panel { @@ -384,26 +509,44 @@ func showMainPage(w http.ResponseWriter, r *http.Request) { display: flex; flex-direction: column; min-width: 0; - background: #1a1a1a; + background: #fff; } .content-header { - padding: 4px 8px; - background: #252525; - border-bottom: 1px solid #333; + padding: 6px 12px; + background: #f6f8fa; + border-bottom: 1px solid #d0d7de; + font-size: 12px; + color: #57606a; + font-family: monospace; + display: flex; + align-items: center; + justify-content: space-between; + } + .content-header .filename { flex: 1; } + .download-btn { + padding: 3px 8px; font-size: 11px; - color: #888; + background: #fff; + border: 1px solid #d0d7de; + border-radius: 4px; + color: #24292f; + cursor: pointer; + text-decoration: none; } + .download-btn:hover { background: #f6f8fa; text-decoration: none; } .content-body { flex: 1; overflow: auto; + background: #fff; } .content-body pre { - padding: 8px; - font-family: "Monaco", "Menlo", monospace; - font-size: 11px; - line-height: 1.4; + padding: 12px; + font-family: "Monaco", "Menlo", "Consolas", monospace; + font-size: 12px; + line-height: 1.5; white-space: pre-wrap; word-wrap: break-word; + color: #24292f; } .content-body iframe { width: 100%; @@ -416,19 +559,20 @@ func showMainPage(w http.ResponseWriter, r *http.Request) { align-items: center; justify-content: center; height: 100%; - color: #666; + color: #656d76; + font-size: 13px; } @media (max-width: 700px) { - .commits-panel { width: 180px; } - .files-panel { width: 140px; } + .commits-panel { width: 200px; } + .files-panel { width: 150px; } } </style> </head> <body> <div class="commits-panel"> <div class="panel-header"> - <h1>JCI</h1> + <img class="logo" src="data:image/png;base64,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" alt="JCI"> <select class="branch-selector" id="branchSelect"></select> </div> <ul class="commit-list" id="commitList"></ul> @@ -445,7 +589,7 @@ func showMainPage(w http.ResponseWriter, r *http.Request) { </div> <script> - let branches = [], currentCommit = null, currentFiles = [], currentFile = null; + let branches = [], currentCommit = null, currentFiles = [], currentFile = null, currentRuns = [], currentRunId = null; async function loadBranches() { const res = await fetch('/api/branches'); @@ -455,25 +599,40 @@ func showMainPage(w http.ResponseWriter, r *http.Request) { const def = branches.find(b => b.name === 'main') || branches[0]; if (def) { select.value = def.name; showBranch(def.name); } - // Check URL for initial commit + // Check URL for initial commit and file const m = location.pathname.match(/^\/jci\/([a-f0-9]+)/); if (m) selectCommitByHash(m[1]); } + function getStatusLabel(status) { + switch(status) { + case 'success': return '✓ ok'; + case 'failed': return '✗ err'; + case 'running': return '⋯ run'; + default: return '—'; + } + } + function showBranch(name) { const branch = branches.find(b => b.name === name); if (!branch) return; const list = document.getElementById('commitList'); list.innerHTML = (branch.commits || []).map(c => { - const status = c.hasCI ? c.ciStatus : 'none'; - const pushIcon = c.hasCI ? (c.ciPushed ? '↑' : '○') : ''; - const pushClass = c.ciPushed ? 'pushed' : ''; + const status = c.hasCI ? (c.ciStatus || 'none') : 'none'; const noCiClass = c.hasCI ? '' : 'no-ci'; + let pushBadge = ''; + if (c.hasCI) { + if (c.ciPushed) { + pushBadge = '<span class="push-badge pushed">pushed</span>'; + } else { + pushBadge = '<span class="push-badge local">local</span>'; + } + } return '<li class="commit-item ' + noCiClass + '" data-hash="' + c.hash + '" data-hasci="' + c.hasCI + '">' + - '<span class="ci-dot ' + status + '"></span>' + + '<span class="status-badge ' + status + '">' + getStatusLabel(status) + '</span>' + '<span class="commit-hash">' + c.shortHash + '</span>' + '<span class="commit-msg">' + escapeHtml(c.message) + '</span>' + - '<span class="ci-push-badge ' + pushClass + '">' + pushIcon + '</span></li>'; + pushBadge + '</li>'; }).join(''); list.querySelectorAll('.commit-item').forEach(el => { el.onclick = () => selectCommit(el.dataset.hash, el.dataset.hasci === 'true'); @@ -490,6 +649,7 @@ func showMainPage(w http.ResponseWriter, r *http.Request) { async function selectCommit(hash, hasCI) { currentCommit = hash; + currentFile = null; document.querySelectorAll('.commit-item').forEach(el => el.classList.toggle('selected', el.dataset.hash === hash) ); @@ -500,24 +660,83 @@ func showMainPage(w http.ResponseWriter, r *http.Request) { if (!hasCI) { filesPanel.classList.add('hidden'); - contentHeader.textContent = ''; + contentHeader.innerHTML = ''; contentBody.innerHTML = '<div class="empty-state">No CI results. Run: git jci run</div>'; history.pushState(null, '', '/'); return; } filesPanel.classList.remove('hidden'); - history.pushState(null, '', '/jci/' + hash + '/'); + // Only update URL to commit if not already on a file URL for this commit + if (!location.pathname.startsWith('/jci/' + hash)) { + history.pushState(null, '', '/jci/' + hash); + } // Load commit info and files try { const infoRes = await fetch('/api/commit/' + hash); const info = await infoRes.json(); + let statusIcon = '?'; + let statusClass = ''; + if (info.status === 'success') { statusIcon = '✓'; statusClass = 'success'; } + else if (info.status === 'failed') { statusIcon = '✗'; statusClass = 'failed'; } + else if (info.status === 'running') { statusIcon = '⋯'; statusClass = 'running'; } + + // Store runs info + currentRuns = info.runs || []; + currentRunId = info.runId || null; + + // Build run selector if multiple runs + let runSelectorHtml = ''; + if (currentRuns.length > 1) { + const runIdx = currentRuns.findIndex(r => r.runId === currentRunId); + runSelectorHtml = '<div class="run-selector">' + + '<select id="runSelect">' + + currentRuns.map((r, i) => { + const ts = parseInt(r.runId.split('-')[0]) * 1000; + const date = new Date(ts).toLocaleString(); + const statusEmoji = r.status === 'success' ? '✓' : r.status === 'failed' ? '✗' : '?'; + const selected = r.runId === currentRunId ? ' selected' : ''; + return '<option value="' + r.runId + '"' + selected + '>' + statusEmoji + ' Run ' + (i+1) + ' - ' + date + '</option>'; + }).join('') + + '</select></div>' + + '<div class="run-nav">' + + '<button id="prevRun"' + (runIdx <= 0 ? ' disabled' : '') + '>← Prev</button>' + + '<button id="nextRun"' + (runIdx >= currentRuns.length - 1 ? ' disabled' : '') + '>Next →</button>' + + '</div>'; + } else if (currentRuns.length === 1) { + const ts = parseInt(currentRuns[0].runId.split('-')[0]) * 1000; + const date = new Date(ts).toLocaleString(); + runSelectorHtml = '<div class="meta">Run: ' + date + '</div>'; + } + document.getElementById('commitInfo').innerHTML = - '<div><span class="status ' + info.status + '">' + (info.status === 'success' ? '✓' : '✗') + '</span> ' + + '<div class="status-line">' + + '<span class="status-icon ' + statusClass + '">' + statusIcon + '</span>' + '<span class="hash">' + hash.slice(0,7) + '</span></div>' + - '<div class="meta">' + escapeHtml(info.author) + ' · ' + escapeHtml(info.date) + '</div>'; + '<div class="meta">' + escapeHtml(info.author) + ' · ' + escapeHtml(info.date) + '</div>' + + runSelectorHtml; + + // Set up run selector events + const runSelect = document.getElementById('runSelect'); + if (runSelect) { + runSelect.onchange = (e) => selectRun(hash, e.target.value); + } + const prevBtn = document.getElementById('prevRun'); + const nextBtn = document.getElementById('nextRun'); + if (prevBtn) { + prevBtn.onclick = () => { + const idx = currentRuns.findIndex(r => r.runId === currentRunId); + if (idx > 0) selectRun(hash, currentRuns[idx-1].runId); + }; + } + if (nextBtn) { + nextBtn.onclick = () => { + const idx = currentRuns.findIndex(r => r.runId === currentRunId); + if (idx < currentRuns.length - 1) selectRun(hash, currentRuns[idx+1].runId); + }; + } currentFiles = info.files || []; const fileList = document.getElementById('fileList'); @@ -528,15 +747,20 @@ func showMainPage(w http.ResponseWriter, r *http.Request) { el.onclick = () => loadFile(el.dataset.file); }); - // Load default file - const defaultFile = currentFiles.find(f => f === 'run.output.txt') || currentFiles[0]; - if (defaultFile) loadFile(defaultFile); + // Check URL for initial file, otherwise load default + const urlMatch = location.pathname.match(/^\/jci\/[a-f0-9]+\/(.+)$/); + if (urlMatch && urlMatch[1]) { + loadFile(urlMatch[1], true); + } else { + const defaultFile = currentFiles.find(f => f === 'run.output.txt') || currentFiles[0]; + if (defaultFile) loadFile(defaultFile, true); + } } catch (e) { contentBody.innerHTML = '<div class="empty-state">Failed to load</div>'; } } - function loadFile(name) { + function loadFile(name, skipHistory) { currentFile = name; document.querySelectorAll('.file-item').forEach(el => el.classList.toggle('selected', el.dataset.file === name) @@ -544,23 +768,113 @@ func showMainPage(w http.ResponseWriter, r *http.Request) { const contentHeader = document.getElementById('contentHeader'); const contentBody = document.getElementById('contentBody'); - contentHeader.textContent = name; + // Include runId in URL if available + const commitPath = currentRunId ? currentCommit + '/' + currentRunId : currentCommit; + const rawUrl = '/jci/' + commitPath + '/' + name + '/raw'; + + contentHeader.innerHTML = '<span class="filename">' + escapeHtml(name) + '</span>' + + '<a class="download-btn" href="' + rawUrl + '" target="_blank">↓ Download</a>'; - history.pushState(null, '', '/jci/' + currentCommit + '/' + name); + if (!skipHistory) { + history.pushState(null, '', '/jci/' + commitPath + '/' + name); + } - const ext = name.split('.').pop().toLowerCase(); + const ext = name.includes('.') ? name.split('.').pop().toLowerCase() : ''; const textExts = ['txt', 'log', 'sh', 'go', 'py', 'js', 'json', 'yaml', 'yml', 'md', 'css', 'xml', 'toml', 'ini', 'conf']; - const url = '/jci/' + currentCommit + '/' + name; + const imageExts = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico']; if (ext === 'html' || ext === 'htm') { - contentBody.innerHTML = '<iframe src="' + url + '"></iframe>'; - } else if (textExts.includes(ext) || !name.includes('.')) { - fetch(url).then(r => r.text()).then(text => { + contentBody.innerHTML = '<iframe src="' + rawUrl + '"></iframe>'; + } else if (imageExts.includes(ext)) { + contentBody.innerHTML = '<div style="padding: 12px; text-align: center;"><img src="' + rawUrl + '" style="max-width: 100%; max-height: 100%;"></div>'; + } else if (textExts.includes(ext)) { + fetch(rawUrl).then(r => r.text()).then(text => { contentBody.innerHTML = '<pre>' + escapeHtml(text) + '</pre>'; }); } else { - contentBody.innerHTML = '<div class="empty-state"><a href="' + url + '" download>Download ' + name + '</a></div>'; + contentBody.innerHTML = '<div class="empty-state">Binary file. <a href="' + rawUrl + '" target="_blank">Download ' + escapeHtml(name) + '</a></div>'; + } + } + + async function selectRun(hash, runId) { + currentRunId = runId; + currentFile = null; + // Fetch the specific run + const infoRes = await fetch('/api/commit/' + hash + '/' + runId); + const info = await infoRes.json(); + + let statusIcon = '?'; + let statusClass = ''; + if (info.status === 'success') { statusIcon = '✓'; statusClass = 'success'; } + else if (info.status === 'failed') { statusIcon = '✗'; statusClass = 'failed'; } + else if (info.status === 'running') { statusIcon = '⋯'; statusClass = 'running'; } + + // Update runs from response + currentRuns = info.runs || []; + + // Build run selector + let runSelectorHtml = ''; + if (currentRuns.length > 1) { + const runIdx = currentRuns.findIndex(r => r.runId === currentRunId); + runSelectorHtml = '<div class="run-selector">' + + '<select id="runSelect">' + + currentRuns.map((r, i) => { + const ts = parseInt(r.runId.split('-')[0]) * 1000; + const date = new Date(ts).toLocaleString(); + const statusEmoji = r.status === 'success' ? '✓' : r.status === 'failed' ? '✗' : '?'; + const selected = r.runId === currentRunId ? ' selected' : ''; + return '<option value="' + r.runId + '"' + selected + '>' + statusEmoji + ' Run ' + (i+1) + ' - ' + date + '</option>'; + }).join('') + + '</select></div>' + + '<div class="run-nav">' + + '<button id="prevRun"' + (runIdx <= 0 ? ' disabled' : '') + '>← Prev</button>' + + '<button id="nextRun"' + (runIdx >= currentRuns.length - 1 ? ' disabled' : '') + '>Next →</button>' + + '</div>'; } + + document.getElementById('commitInfo').innerHTML = + '<div class="status-line">' + + '<span class="status-icon ' + statusClass + '">' + statusIcon + '</span>' + + '<span class="hash">' + hash.slice(0,7) + '</span></div>' + + '<div class="meta">' + escapeHtml(info.author) + ' · ' + escapeHtml(info.date) + '</div>' + + runSelectorHtml; + + // Re-attach event listeners + const runSelect = document.getElementById('runSelect'); + if (runSelect) { + runSelect.onchange = (e) => selectRun(hash, e.target.value); + } + const prevBtn = document.getElementById('prevRun'); + const nextBtn = document.getElementById('nextRun'); + if (prevBtn) { + prevBtn.onclick = () => { + const idx = currentRuns.findIndex(r => r.runId === currentRunId); + if (idx > 0) selectRun(hash, currentRuns[idx-1].runId); + }; + } + if (nextBtn) { + nextBtn.onclick = () => { + const idx = currentRuns.findIndex(r => r.runId === currentRunId); + if (idx < currentRuns.length - 1) selectRun(hash, currentRuns[idx+1].runId); + }; + } + + // Update files + currentFiles = info.files || []; + const fileList = document.getElementById('fileList'); + fileList.innerHTML = currentFiles.map(f => + '<li class="file-item" data-file="' + f + '">' + f + '</li>' + ).join(''); + fileList.querySelectorAll('.file-item').forEach(el => { + el.onclick = () => loadFile(el.dataset.file); + }); + + // Update URL + history.pushState(null, '', '/jci/' + hash + '/' + runId); + + // Load default file + const defaultFile = currentFiles.find(f => f === 'run.output.txt') || currentFiles[0]; + if (defaultFile) loadFile(defaultFile, true); } function escapeHtml(t) { @@ -571,8 +885,25 @@ func showMainPage(w http.ResponseWriter, r *http.Request) { window.onpopstate = () => { const m = location.pathname.match(/^\/jci\/([a-f0-9]+)(?:\/(.+))?/); if (m) { - if (m[1] !== currentCommit) selectCommitByHash(m[1]); - else if (m[2] && m[2] !== currentFile) loadFile(m[2]); + const commit = m[1]; + const file = m[2] || null; + if (commit !== currentCommit) { + selectCommitByHash(commit); + } else if (file && file !== currentFile) { + loadFile(file, true); + } else if (!file && currentFile) { + // Went back to commit-only URL + currentFile = null; + const defaultFile = currentFiles.find(f => f === 'run.output.txt') || currentFiles[0]; + if (defaultFile) loadFile(defaultFile, true); + } + } else if (location.pathname === '/') { + currentCommit = null; + currentFile = null; + document.querySelectorAll('.commit-item').forEach(el => el.classList.remove('selected')); + document.getElementById('filesPanel').classList.add('hidden'); + document.getElementById('contentHeader').innerHTML = ''; + document.getElementById('contentBody').innerHTML = '<div class="empty-state">Select a commit</div>'; } }; @@ -584,8 +915,25 @@ func showMainPage(w http.ResponseWriter, r *http.Request) { `) } -func serveFromRef(w http.ResponseWriter, commit string, filePath string) { - ref := "refs/jci/" + commit +func serveFromRef(w http.ResponseWriter, commit string, runID string, filePath string) { + var ref string + + // Build ref based on whether we have a runID + if runID != "" { + ref = "refs/jci-runs/" + commit + "/" + runID + } else { + ref = "refs/jci/" + commit + // If single-run ref doesn't exist, try to find latest run + if !RefExists(ref) { + runRefs, _ := ListJCIRunRefs() + for _, r := range runRefs { + if strings.HasPrefix(r, "refs/jci-runs/"+commit+"/") { + ref = r // Use last matching (sorted by timestamp) + } + } + } + } + if !RefExists(ref) { http.Error(w, "CI results not found for commit: "+commit, 404) return