Jaypore CI

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

commit 41e49247af2ee8f30b45cad1d31454498cdda584
parent 3f8b5cea3a9d67942dbfadaed000430b0e4ffcf2
Author: Arjoonn@exe.dev <arjoonn+exe.dev@midpathsoftware.com>
Date:   Wed, 25 Feb 2026 07:14:45 +0000

Add cron facility and multi-run support

- Add .jci/crontab file format for scheduling CI runs
- Add 'git jci cron ls' to list configured and installed cron jobs
- Add 'git jci cron sync' to sync crontab with system cron
- Add --multi flag to 'git jci run' for multiple runs per commit
- Multi-run results stored in refs/jci-runs/<commit>/<timestamp>-<random>
- Update push/pull/prune to handle both refs/jci/ and refs/jci-runs/
- Update web UI to show multi-run results

Crontab format:
  # comment
  SCHEDULE [branch:BRANCH] [name:NAME]
  0 * * * * branch:main name:hourly
  */15 * * * * name:quick-check

Co-authored-by: Shelley <shelley@exe.dev>

Diffstat:
Mcmd/git-jci/main.go | 18++++++++++++------
Ainternal/jci/cron.go | 298+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/jci/git.go | 39++++++++++++++++++++++++++++++++++++++-
Minternal/jci/prune.go | 32+++++++++++++++++++++++++-------
Minternal/jci/pull.go | 8+++++++-
Minternal/jci/push.go | 39++++++++++++++++++++++-----------------
Minternal/jci/run.go | 46++++++++++++++++++++++++++++++++++++++++------
Minternal/jci/web.go | 138++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
8 files changed, 562 insertions(+), 56 deletions(-)

diff --git a/cmd/git-jci/main.go b/cmd/git-jci/main.go @@ -28,6 +28,8 @@ func main() { err = jci.Pull(args) case "prune": err = jci.Prune(args) + case "cron": + err = jci.Cron(args) case "help", "-h", "--help": printUsage() return @@ -49,11 +51,15 @@ func printUsage() { 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 + run [--multi] Run CI for the current commit and store results + --multi: Allow multiple runs per commit (uses timestamp ID) + 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 -CI results are stored in refs/jci/<commit> namespace.`) +CI results are stored in refs/jci/<commit> namespace. +With --multi, results are stored in refs/jci/<commit>/<runid>.`) } diff --git a/internal/jci/cron.go b/internal/jci/cron.go @@ -0,0 +1,298 @@ +package jci + +import ( + "bufio" + "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 fmt.Errorf("usage: git jci cron <ls|sync>") + } + + switch args[0] { + case "ls": + return cronList() + case "sync": + return cronSync() + default: + return fmt.Errorf("unknown cron command: %s (use ls or sync)", args[0]) + } +} + +// cronList shows current cron jobs from .jci/crontab and system cron +func cronList() error { + repoRoot, err := GetRepoRoot() + if err != nil { + return fmt.Errorf("not in a git repository: %w", err) + } + + 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("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 +} + +// cronSync synchronizes .jci/crontab with system cron +func cronSync() error { + repoRoot, err := GetRepoRoot() + if err != nil { + return fmt.Errorf("not in a git repository: %w", err) + } + + repoID := getRepoID(repoRoot) + marker := cronMarkerPrefix + repoID + + // 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) + } + } + + // Get current system crontab + currentCron, err := getCurrentCrontab() + if err != nil { + return fmt.Errorf("failed to read current crontab: %w", 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) + } + } + + // Find git-jci binary path + jciBinary, err := findJCIBinary() + if err != nil { + return fmt.Errorf("could not find git-jci binary: %w", err) + } + + // 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 --multi", jciBinary) + + comment := e.Name + if comment == "" { + comment = "jci" + } + + line := fmt.Sprintf("%s %s %s [%s]", e.Schedule, cmd, marker, comment) + newLines = append(newLines, line) + } + + // 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" + + if err := installCrontab(newCron); err != nil { + return fmt.Errorf("failed to install crontab: %w", err) + } + + 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) + } + + return nil +} + +// 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 { + return nil, err + } + defer f.Close() + + 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*(.*)`) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + // Skip empty lines and comments + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + matches := scheduleRe.FindStringSubmatch(line) + if matches == nil { + continue // Invalid line, skip + } + + entry := CronEntry{ + Schedule: matches[1], + } + + // 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) + } + + 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 + } + return string(out), nil +} + +// installCrontab installs a new crontab +func installCrontab(content string) error { + cmd := exec.Command("crontab", "-") + cmd.Stdin = strings.NewReader(content) + 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 @@ -38,7 +38,8 @@ func RefExists(ref string) bool { } // 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 { +// If runID is non-empty, stores under refs/jci/<commit>/<runID> instead +func StoreTree(dir string, commit string, message string, runID string) error { repoRoot, err := GetRepoRoot() if err != nil { return err @@ -64,7 +65,12 @@ func StoreTree(dir string, commit string, message string) error { commitID := strings.TrimSpace(string(commitOut)) // Update ref + // Single runs: refs/jci/<commit> + // Multi runs: refs/jci-runs/<commit>/<runid> ref := "refs/jci/" + commit + if 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 +148,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 @@ -12,8 +12,8 @@ func Push(args []string) error { remote = args[0] } - // Get local refs - localRefs, err := ListJCIRefs() + // Get all local JCI refs + localRefs, err := ListAllJCIRefs() if err != nil { return err } @@ -29,9 +29,8 @@ func Push(args []string) error { // Find refs that need to be pushed var toPush []string for _, ref := range localRefs { - commit := strings.TrimPrefix(ref, "jci/") - if !remoteRefs[commit] { - toPush = append(toPush, "refs/jci/"+commit) + if !remoteRefs[ref] { + toPush = append(toPush, ref) } } @@ -55,25 +54,31 @@ func Push(args []string) error { return nil } -// getRemoteJCIRefs returns a set of commits that have CI refs on the remote +// 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 { - return remoteCI - } - if out == "" { - return remoteCI + if err == nil && out != "" { + for _, line := range strings.Split(out, "\n") { + parts := strings.Fields(line) + if len(parts) >= 2 { + remoteCI[parts[1]] = true + } + } } - for _, line := range strings.Split(out, "\n") { - parts := strings.Fields(line) - if len(parts) >= 2 { - ref := parts[1] - commit := strings.TrimPrefix(ref, "refs/jci/") - remoteCI[commit] = 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,7 +11,16 @@ import ( ) // Run executes CI for the current commit +// With --multi flag, allows multiple runs per commit (uses timestamp+random suffix) func Run(args []string) error { + // Parse flags + multiRun := false + for _, arg := range args { + if arg == "--multi" { + multiRun = true + } + } + // Get current commit commit, err := GetCurrentCommit() if err != nil { @@ -18,11 +29,18 @@ 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 run ID for multi-run mode + var runID string + if multiRun { + runID = generateRunID() + fmt.Printf("Run ID: %s\n", runID) + } else { + // Check if CI already ran for this commit (single-run mode) + ref := "refs/jci/" + commit + if RefExists(ref) { + fmt.Printf("CI results already exist for %s\n", commit[:12]) + return nil + } } repoRoot, err := GetRepoRoot() @@ -64,13 +82,20 @@ func Run(args []string) error { // Store results in git msg := fmt.Sprintf("CI results for %s", commit[:12]) - if storeErr := StoreTree(outputDir, commit, msg); storeErr != nil { + if runID != "" { + 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/" + commit + if runID != "" { + 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) @@ -197,3 +222,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 @@ -110,7 +118,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 { @@ -118,6 +126,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") @@ -130,18 +151,38 @@ 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) } @@ -150,8 +191,11 @@ func getBranchCommits(branch string, limit int) ([]CommitInfo, error) { // getCIStatus returns "success", "failed", or "running" based on status.txt func getCIStatus(commit string) string { - ref := "refs/jci/" + commit - + 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() @@ -190,20 +234,45 @@ type CommitDetail struct { Date string `json:"date"` Status string `json:"status"` Files []string `json:"files"` + Ref string `json:"ref"` // The actual ref used } // 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 + + // Check if this is a run-specific request: <commit>/<runid> + if strings.Contains(commit, "/") { + parts := strings.SplitN(commit, "/", 2) + actualCommit = parts[0] + runID := parts[1] + ref = "refs/jci-runs/" + actualCommit + "/" + runID + } 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 + runRefs, _ := ListJCIRunRefs() + for _, r := range runRefs { + if strings.HasPrefix(r, "refs/jci-runs/"+commit+"/") { + ref = r // Use the last one (they're sorted) + } + } + } + } + if !RefExists(ref) { http.Error(w, "not found", 404) return } // 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) @@ -217,11 +286,12 @@ func serveCommitAPI(w http.ResponseWriter, commit string) { } detail := CommitDetail{ - Hash: commit, + Hash: actualCommit, Author: author, Date: date, Status: status, Files: files, + Ref: ref, } w.Header().Set("Content-Type", "application/json") @@ -674,7 +744,39 @@ func showMainPage(w http.ResponseWriter, r *http.Request) { } func serveFromRef(w http.ResponseWriter, commit string, filePath string) { - ref := "refs/jci/" + commit + var ref string + + // Check if this is a run-specific request: <commit>/<runid>/<file> + // The commit param might be "abc123/1234567890-abcd" + if strings.Count(commit, "/") >= 1 { + // Could be <commit>/<runid> or just <commit> + parts := strings.SplitN(commit, "/", 2) + if len(parts) == 2 && len(parts[1]) > 0 { + // Check if parts[1] looks like a runID (timestamp-random) + if strings.Contains(parts[1], "-") && len(parts[1]) >= 10 { + ref = "refs/jci-runs/" + parts[0] + "/" + parts[1] + } else { + // It's probably <commit>/<file>, treat commit as just the hash + ref = "refs/jci/" + parts[0] + filePath = parts[1] + "/" + filePath + } + } + } + + if ref == "" { + ref = "refs/jci/" + commit + } + + if !RefExists(ref) { + // Try to find a run ref + runRefs, _ := ListJCIRunRefs() + for _, r := range runRefs { + if strings.HasPrefix(r, "refs/jci-runs/"+commit+"/") { + ref = r + } + } + } + if !RefExists(ref) { http.Error(w, "CI results not found for commit: "+commit, 404) return