Jaypore CI

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

commit 076d3d7bc877aaa49f4542f7feea68518133d85b
parent 7a2522aa7f886a01a0c2863097d8fb5f5ba85a68
Author: Arjoonn Sharma <arjoonn@midpathsoftware.com>
Date:   Sat, 28 Feb 2026 08:48:55 +0000

TTY fix in pipe (!10)

Reviewed-on: https://gitea.midpathsoftware.com/midpath/jayporeci/pulls/10
Co-authored-by: Arjoonn Sharma <arjoonn@midpathsoftware.com>
Co-committed-by: Arjoonn Sharma <arjoonn@midpathsoftware.com>

Diffstat:
M.jci/run.sh | 128++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
APLAN.md | 4++++
MREADME.md | 46++++++++++++++++++++++++++++++++++++++++++++++
MVERSION | 2+-
Mcmd/git-jci/main.go | 22+++++++++++++---------
Mgo.mod | 4++--
Minternal/jci/cron.go | 339+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Minternal/jci/cron_parser.go | 9---------
Minternal/jci/cron_types.go | 7-------
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 | 901+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Dscripts/build_website.sh | 50--------------------------------------------------
Ascripts/check_go.sh | 20++++++++++++++++++++
Ascripts/generate_readme_index.sh | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mwww_jci/public/index.html | 6++++--
19 files changed, 1334 insertions(+), 465 deletions(-)

diff --git a/.jci/run.sh b/.jci/run.sh @@ -7,13 +7,76 @@ # JCI_REPO_ROOT - Repository root path # JCI_OUTPUT_DIR - Output directory (where artifacts should go) -set -e +set -eo pipefail -echo "=== JCI CI Pipeline ===" -echo "Commit: ${JCI_COMMIT:0:12}" -echo "" +PIPELINE_NAME="JayporeCI" -cd "$JCI_REPO_ROOT" +# Summary bookkeeping -------------------------------------------------------- +declare -a SUMMARY_PIPELINE=() +declare -a SUMMARY_BUILD_MATRIX=() + +add_pipeline_summary() { + local icon="$1" + local label="$2" + local ref="$3" + SUMMARY_PIPELINE+=("${icon}|${label}|${ref}") +} + +add_build_matrix_entry() { + local icon="$1" + local label="$2" + local artifact="$3" + SUMMARY_BUILD_MATRIX+=("${icon}|${label}|${artifact}") +} + +print_summary() { + local exit_code="$1" + local commit="${JCI_COMMIT:-unknown}" + local short_sha="${commit:0:12}" + local overall_icon="🟒" + [[ "${exit_code}" -ne 0 ]] && overall_icon="πŸ”΄" + + printf "β•” %s : %s [sha %s]\n" "${overall_icon}" "${PIPELINE_NAME}" "${short_sha}" + echo "┏━ Pipeline" + echo "┃" + + if [[ ${#SUMMARY_PIPELINE[@]} -eq 0 ]]; then + echo "┃ βšͺ : Pipeline exited before recording any steps" + else + for entry in "${SUMMARY_PIPELINE[@]}"; do + IFS='|' read -r icon label ref <<< "${entry}" + printf "┃ %s : %-20s [%s]\n" "${icon}" "${label}" "${ref}" + done + fi + + if [[ ${#SUMMARY_BUILD_MATRIX[@]} -gt 0 ]]; then + echo "┃" + printf "┃ 🧱 Build Matrix (%d targets)\n" "${#SUMMARY_BUILD_MATRIX[@]}" + for entry in "${SUMMARY_BUILD_MATRIX[@]}"; do + IFS='|' read -r icon label artifact <<< "${entry}" + printf "┃ %s : %-18s [%s]\n" "${icon}" "${label}" "${artifact}" + done + fi + + echo "┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛" +} + +run_step() { + local label="$1" + local ref="$2" + shift 2 + + set +e + "$@" + local exit_code=$? + set -e + + local icon="🟒" + [[ ${exit_code} -ne 0 ]] && icon="πŸ”΄" + add_pipeline_summary "${icon}" "${label}" "${ref}" + + return ${exit_code} +} # --------------------------------------------------------------------------- # Helper: build one binary inside its own Docker container. @@ -23,7 +86,8 @@ cd "$JCI_REPO_ROOT" # Each invocation is fire-and-forget (&); collect PIDs for later wait. # --------------------------------------------------------------------------- PIDS=() -TARGETS=() # human-readable label per job, index-matched to PIDS +TARGETS=() # human-readable label per job, index-matched to PIDS +TARGET_ARTIFACTS=() build_target() { local goos="$1" @@ -47,35 +111,56 @@ build_target() { & PIDS+=($!) - TARGETS+=("${label} β†’ bin/${output_name}") + TARGETS+=("${label}") + TARGET_ARTIFACTS+=("${output_name}") } +trap 'print_summary $?' EXIT + +echo "=== JCI CI Pipeline ===" +echo "Commit: ${JCI_COMMIT:0:12}" +echo "" + +cd "$JCI_REPO_ROOT" + # --------------------------------------------------------------------------- echo "--- Step 1: Updating code with latest VERSION ---" -scripts/sync_version.sh +run_step "Sync Version" "scripts/sync_version.sh" scripts/sync_version.sh +echo "" -# Step 1: Build Docker image (sequential β€” other steps depend on it) +# Step 2: Build Docker image (sequential β€” other steps depend on it) # --------------------------------------------------------------------------- echo "--- Step 2: Building Docker image via scripts/build_image.sh ---" -scripts/build_image.sh +run_step "Build Image" "scripts/build_image.sh" scripts/build_image.sh echo "" # --------------------------------------------------------------------------- -# Step 2: Cross-compile binaries in parallel +# Step 3: Static analysis / sanity checks +# --------------------------------------------------------------------------- +echo "--- Step 3: Go formatting, vet, and build checks ---" +run_step "Go Lint" "scripts/check_go.sh" \ + docker run --rm -v "$PWD:/tmp/repo" -w /tmp/repo golang:1.25 \ + bash -c "/tmp/repo/scripts/check_go.sh" +echo "" + +# --------------------------------------------------------------------------- +# Step 4: Cross-compile binaries in parallel # # Targets: # Linux amd64 git-jci-linux-amd64 # Linux arm64 git-jci-linux-arm64 # Linux arm (v7) git-jci-linux-armv7 +# Linux 386 git-jci-linux-386 # Windows amd64 git-jci-windows-amd64.exe # Windows arm64 git-jci-windows-arm64.exe # --------------------------------------------------------------------------- -echo "--- Step 3: Building binaries in parallel ---" +echo "--- Step 4: Building binaries in parallel ---" mkdir -p bin build_target linux amd64 git-jci-linux-amd64 build_target linux arm64 git-jci-linux-arm64 build_target linux arm git-jci-linux-armv7 7 +build_target linux 386 git-jci-linux-386 build_target windows amd64 git-jci-windows-amd64.exe build_target windows arm64 git-jci-windows-arm64.exe @@ -90,29 +175,38 @@ FAILED=0 for i in "${!PIDS[@]}"; do pid="${PIDS[$i]}" label="${TARGETS[$i]}" + artifact="${TARGET_ARTIFACTS[$i]}" if wait "${pid}"; then - echo "[OK] ${label}" + echo "[OK] ${label} β†’ bin/${artifact}" + add_build_matrix_entry "🟒" "${label}" "${artifact}" else - echo "[FAIL] ${label}" + echo "[FAIL] ${label} β†’ bin/${artifact}" + add_build_matrix_entry "πŸ”΄" "${label}" "${artifact}" FAILED=$((FAILED + 1)) fi done echo "" + +total_targets=${#TARGETS[@]} if [[ "${FAILED}" -gt 0 ]]; then echo "${FAILED} build(s) failed. See output above for details." + add_pipeline_summary "πŸ”΄" "Build Matrix" "${FAILED}/${total_targets} failed" exit 1 fi +add_pipeline_summary "🟒" "Build Matrix" "${total_targets} targets" + echo "All binaries built successfully:" ls -lh bin/git-jci-* echo "" # --------------------------------------------------------------------------- -# Step 3: Build site (sequential β€” needs the binaries to be present) +# Step 5: Build site (sequential β€” needs the binaries to be present) # --------------------------------------------------------------------------- -echo "--- Step 4: Building site inside jci container ---" -docker run --rm -it -v "$PWD:/tmp/Jaypore CI" jci "/tmp/Jaypore CI/scripts/build_site.sh" +echo "--- Step 5: Building site inside jci container ---" +run_step "Docs & Site" "scripts/build_site.sh" \ + docker run --rm -v "$PWD:/tmp/Jaypore CI" jci "/tmp/Jaypore CI/scripts/build_site.sh" echo "" echo "All steps completed successfully!" diff --git a/PLAN.md b/PLAN.md @@ -0,0 +1,4 @@ +- I want to refactor the APIs in the `jci web` command. +- The UI should ask for a list of branches, and that should NOT return a complete list of commits. +- A separate API should be used for a list of commits, which should be paginated by 100 commits per page. +- The overall goal of this refactor is to retain the same web functionality but use multiple API calls on-demand so that we have a FAST user experience while browsing. diff --git a/README.md b/README.md @@ -2,6 +2,33 @@ > Jaypore CI: Minimal, Offline, Local CI system. +--- + +- [Install](#install) +- [Config](#config) +- [Environment Vars](#environment-vars) +- [Example workflow](#example-workflow) +- [How it works](#how-it-works) +- [FAQ / Needs / Wants / Todos](#faq-needs-wants-todos) +- [Examples](#examples) + - [Lint, Build, Test, Publish a golang project](#lint-build-test-publish-a-golang-project) + - [Pylint, Pytest, Coverage report](#pylint-pytest-coverage-report) + - [Build Jekyll and publish to netlify](#build-jekyll-and-publish-to-netlify) + - [Build Docusaurus and publish to S3 bucket](#build-docusaurus-and-publish-to-s3-bucket) + - [Run a docker compose of redis, postgres, django, and run API tests against it.](#run-a-docker-compose-of-redis-postgres-django-and-run-api-tests-against-it) + - [Schedule a midnight build and push status to telegram](#schedule-a-midnight-build-and-push-status-to-telegram) + - [Run trufflehog scan on repo every hour](#run-trufflehog-scan-on-repo-every-hour) + - [Run lint --fix on pre-commit for python, go, JS in the same repo](#run-lint-fix-on-pre-commit-for-python-go-js-in-the-same-repo) + - [Create sub-pipelines for python / js / go and run when changes are there in any folder](#create-sub-pipelines-for-python-js-go-and-run-when-changes-are-there-in-any-folder) + - [Set and use Secrets to publish messages to telegram](#set-and-use-secrets-to-publish-messages-to-telegram) + - [Send mail on scheduled pipe failures](#send-mail-on-scheduled-pipe-failures) + - [Midnight auto-update dependencies and ensure tests are passing after update](#midnight-auto-update-dependencies-and-ensure-tests-are-passing-after-update) + - [Build and publish docker images](#build-and-publish-docker-images) + - [Run pipelines on this repo, when changes happen in upstream projects](#run-pipelines-on-this-repo-when-changes-happen-in-upstream-projects) + - [Run pipelines on another repo, when changes affect downstream projects](#run-pipelines-on-another-repo-when-changes-affect-downstream-projects) + +--- + ## Install ```bash @@ -113,3 +140,22 @@ part of the git repository. - [ ] Ecosystem of reusable actions/tasks with versioned catalogs and templates - This is already there? Not sure if this is something we even need to solve? - [ ] Validate infrastructure-as-code changes and deployment pipelines via dry runs + + +## Examples + +### Lint, Build, Test, Publish a golang project +### Pylint, Pytest, Coverage report +### Build Jekyll and publish to netlify +### Build Docusaurus and publish to S3 bucket +### Run a docker compose of redis, postgres, django, and run API tests against it. +### Schedule a midnight build and push status to telegram +### Run trufflehog scan on repo every hour +### Run lint --fix on pre-commit for python, go, JS in the same repo +### Create sub-pipelines for python / js / go and run when changes are there in any folder +### Set and use Secrets to publish messages to telegram +### Send mail on scheduled pipe failures +### Midnight auto-update dependencies and ensure tests are passing after update +### Build and publish docker images +### Run pipelines on this repo, when changes happen in upstream projects +### Run pipelines on another repo, when changes affect downstream projects diff --git a/VERSION b/VERSION @@ -1 +1 @@ -1.0.1 +1.0.2 diff --git a/cmd/git-jci/main.go b/cmd/git-jci/main.go @@ -4,7 +4,7 @@ import ( "fmt" "os" - "github.com/exedev/git-jci/internal/jci" + "github.com/theSage21/jaypore_ci/internal/jci" ) // version is set at build time via -ldflags "-X main.version=<version>". @@ -32,6 +32,8 @@ func main() { 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 @@ -56,13 +58,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 - 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/go.mod b/go.mod @@ -1,3 +1,3 @@ -module github.com/exedev/git-jci +module github.com/theSage21/jaypore_ci -go 1.22.2 +go 1.22 diff --git a/internal/jci/cron.go b/internal/jci/cron.go @@ -2,184 +2,303 @@ 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 +// Additional metadata (line number, command, raw text) is populated when +// entries are loaded via the cron parser utilities. +type CronEntry struct { + Schedule string // e.g., "0 * * * *" + Name string // optional name/comment + Branch string // branch to run on (default: current) + Line int // source line number (optional) + Command string // parsed command (optional) + Raw string // raw line text (optional) +} + +// 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/cron_parser.go b/internal/jci/cron_parser.go @@ -10,15 +10,6 @@ import ( "strings" ) -// CronEntry represents a single line from .jci/crontab -// It contains the parsed schedule as well as the command portion. -type CronEntry struct { - Line int - Schedule string - Command string - Raw string -} - // LoadCronEntries opens .jci/crontab (if it exists) and parses all entries. func LoadCronEntries(repoRoot string) ([]CronEntry, error) { cronPath := filepath.Join(repoRoot, ".jci", "crontab") diff --git a/internal/jci/cron_types.go b/internal/jci/cron_types.go @@ -76,13 +76,6 @@ func (job CronJob) shellCommand(repoRoot string) string { return full } -func shellEscape(value string) string { - if value == "" { - return "''" - } - return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" -} - func splitCommandHeadTail(cmd string) (string, string) { parts := strings.Fields(cmd) if len(parts) == 0 { 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 @@ -12,19 +12,26 @@ import ( // BranchInfo holds branch data for the UI type BranchInfo struct { - Name string `json:"name"` - IsRemote bool `json:"isRemote"` - Commits []CommitInfo `json:"commits"` + Name string `json:"name"` + IsRemote bool `json:"isRemote"` } // 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,15 +59,15 @@ 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) + // API endpoint for branch data (names only, no commits) + if path == "/api/branches" { + serveBranchesAPI(w) return } - // API endpoint for branch data - if path == "/api/branches" { - serveBranchesAPI(w) + // API endpoint for paginated commits for a branch + if path == "/api/commits" { + serveCommitsAPI(w, r) return } @@ -71,19 +78,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,94 +118,42 @@ 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) { - // Get commit hash and message - out, err := git("log", branch, fmt.Sprintf("--max-count=%d", limit), "--format=%H|%s") - if err != nil { - return nil, err - } - if out == "" { - return nil, nil - } - // Get local JCI refs - jciRefs, _ := ListJCIRefs() - jciSet := make(map[string]bool) - for _, ref := range jciRefs { - commit := strings.TrimPrefix(ref, "jci/") - jciSet[commit] = true - } - - // Get remote JCI refs for CI push status - remoteCI := getRemoteJCIRefs("origin") - - var commits []CommitInfo - for _, line := range strings.Split(out, "\n") { - parts := strings.SplitN(line, "|", 2) - if len(parts) != 2 { - continue - } - hash := parts[0] - msg := parts[1] - - commit := CommitInfo{ - Hash: hash, - ShortHash: hash[:7], - Message: msg, - HasCI: jciSet[hash], - CIPushed: remoteCI[hash], - } +// getCIStatus returns "success", "failed", or "running" based on status.txt +func getCIStatus(commit string) string { + return getCIStatusFromRef("refs/jci/" + commit) +} - if commit.HasCI { - commit.CIStatus = getCIStatus(hash) +// 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" } - - commits = append(commits, commit) } - return commits, nil -} - -// getCIStatus returns "success" or "failed" based on CI results -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") - out, err := cmd.Output() + // 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 +161,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,18 +243,21 @@ 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") json.NewEncoder(w).Encode(detail) } -// serveBranchesAPI returns branch/commit data as JSON +// serveBranchesAPI returns branch names as JSON (no commits for fast load) func serveBranchesAPI(w http.ResponseWriter) { branches, err := getLocalBranches() if err != nil { @@ -251,14 +267,9 @@ func serveBranchesAPI(w http.ResponseWriter) { var branchInfos []BranchInfo for _, branch := range branches { - commits, err := getBranchCommits(branch, 20) - if err != nil { - continue - } branchInfos = append(branchInfos, BranchInfo{ Name: branch, IsRemote: false, - Commits: commits, }) } @@ -266,6 +277,141 @@ func serveBranchesAPI(w http.ResponseWriter) { json.NewEncoder(w).Encode(branchInfos) } +// CommitsPage holds a page of commits for the paginated API +type CommitsPage struct { + Branch string `json:"branch"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + HasMore bool `json:"hasMore"` + Commits []CommitInfo `json:"commits"` +} + +const commitsPageSize = 100 + +// serveCommitsAPI returns a paginated list of commits for a branch. +// Query params: branch (required), page (optional, 0-indexed, default 0) +func serveCommitsAPI(w http.ResponseWriter, r *http.Request) { + branch := r.URL.Query().Get("branch") + if branch == "" { + http.Error(w, "branch query parameter is required", 400) + return + } + + page := 0 + if p := r.URL.Query().Get("page"); p != "" { + fmt.Sscanf(p, "%d", &page) + if page < 0 { + page = 0 + } + } + + // Fetch one extra commit beyond the page size to detect whether more pages exist + offset := page * commitsPageSize + limit := commitsPageSize + 1 + + commits, err := getBranchCommitsPaginated(branch, offset, limit) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + + hasMore := len(commits) > commitsPageSize + if hasMore { + commits = commits[:commitsPageSize] + } + + result := CommitsPage{ + Branch: branch, + Page: page, + PageSize: commitsPageSize, + HasMore: hasMore, + Commits: commits, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(result) +} + +// getBranchCommitsPaginated returns commits for a branch starting at the given offset. +func getBranchCommitsPaginated(branch string, offset, limit int) ([]CommitInfo, error) { + out, err := git("log", branch, + fmt.Sprintf("--skip=%d", offset), + fmt.Sprintf("--max-count=%d", limit), + "--format=%H|%s") + if err != nil { + return nil, err + } + if out == "" { + return nil, nil + } + + // Get local JCI refs (single-run) + jciRefs, _ := ListJCIRefs() + jciSet := make(map[string]bool) + for _, ref := range jciRefs { + commit := strings.TrimPrefix(ref, "jci/") + jciSet[commit] = true + } + + // Get local JCI run refs (multi-run): commit -> list of run refs + jciRuns := make(map[string][]string) + runRefs, _ := ListJCIRunRefs() + for _, ref := range runRefs { + 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") + + var commits []CommitInfo + for _, line := range strings.Split(out, "\n") { + parts := strings.SplitN(line, "|", 2) + if len(parts) != 2 { + continue + } + hash := parts[0] + msg := parts[1] + + hasCI := jciSet[hash] || len(jciRuns[hash]) > 0 + commit := CommitInfo{ + Hash: hash, + ShortHash: hash[:7], + Message: msg, + HasCI: hasCI, + CIPushed: remoteCI["refs/jci/"+hash], + } + + if jciSet[hash] { + commit.CIStatus = getCIStatus(hash) + } + + for _, runRef := range jciRuns[hash] { + rparts := strings.Split(strings.TrimPrefix(runRef, "refs/jci-runs/"), "/") + if len(rparts) >= 2 { + runID := rparts[1] + status := getCIStatusFromRef(runRef) + commit.Runs = append(commit.Runs, RunInfo{ + RunID: runID, + Status: status, + Ref: runRef, + }) + } + } + + if commit.CIStatus == "" && len(commit.Runs) > 0 { + commit.CIStatus = commit.Runs[len(commit.Runs)-1].Status + } + + commits = append(commits, commit) + } + + return commits, nil +} + func showMainPage(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") fmt.Fprint(w, `<!DOCTYPE html> @@ -279,41 +425,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 +468,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 +564,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 +614,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 +644,8 @@ 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; + let currentBranch = null, currentPage = 0, loadedCommits = [], hasMoreCommits = false; async function loadBranches() { const res = await fetch('/api/branches'); @@ -453,43 +653,111 @@ func showMainPage(w http.ResponseWriter, r *http.Request) { const select = document.getElementById('branchSelect'); select.innerHTML = branches.map(b => '<option value="' + b.name + '">' + b.name + '</option>').join(''); const def = branches.find(b => b.name === 'main') || branches[0]; - if (def) { select.value = def.name; showBranch(def.name); } - - // Check URL for initial commit + if (def) { select.value = def.name; await showBranch(def.name); } + + // Check URL for initial commit and file const m = location.pathname.match(/^\/jci\/([a-f0-9]+)/); if (m) selectCommitByHash(m[1]); } - 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 noCiClass = c.hasCI ? '' : 'no-ci'; - return '<li class="commit-item ' + noCiClass + '" data-hash="' + c.hash + '" data-hasci="' + c.hasCI + '">' + - '<span class="ci-dot ' + 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>'; - }).join(''); - list.querySelectorAll('.commit-item').forEach(el => { + function getStatusLabel(status) { + switch(status) { + case 'success': return 'βœ“ ok'; + case 'failed': return 'βœ— err'; + case 'running': return 'β‹― run'; + default: return 'β€”'; + } + } + + function renderCommitItem(c) { + const status = c.hasCI ? (c.ciStatus || 'none') : 'none'; + const noCiClass = c.hasCI ? '' : 'no-ci'; + let pushBadge = ''; + if (c.hasCI) { + pushBadge = c.ciPushed + ? '<span class="push-badge pushed">pushed</span>' + : '<span class="push-badge local">local</span>'; + } + return '<li class="commit-item ' + noCiClass + '" data-hash="' + c.hash + '" data-hasci="' + c.hasCI + '">' + + '<span class="status-badge ' + status + '">' + getStatusLabel(status) + '</span>' + + '<span class="commit-hash">' + c.shortHash + '</span>' + + '<span class="commit-msg">' + escapeHtml(c.message) + '</span>' + + pushBadge + '</li>'; + } + + function attachCommitClickHandlers() { + document.querySelectorAll('.commit-item').forEach(el => { el.onclick = () => selectCommit(el.dataset.hash, el.dataset.hasci === 'true'); }); } - function selectCommitByHash(hash) { - // Find full hash from branches - for (const b of branches) { - const c = (b.commits || []).find(c => c.hash.startsWith(hash)); - if (c) { selectCommit(c.hash, c.hasCI); return; } + async function showBranch(name) { + currentBranch = name; + currentPage = 0; + loadedCommits = []; + hasMoreCommits = false; + const list = document.getElementById('commitList'); + list.innerHTML = '<li style="padding:8px 10px;color:#656d76;">Loading…</li>'; + await loadMoreCommits(); + } + + async function loadMoreCommits() { + const res = await fetch('/api/commits?branch=' + encodeURIComponent(currentBranch) + '&page=' + currentPage); + const data = await res.json(); + loadedCommits = loadedCommits.concat(data.commits || []); + hasMoreCommits = data.hasMore || false; + + const list = document.getElementById('commitList'); + // Remove existing load-more button if present + const oldBtn = document.getElementById('loadMoreBtn'); + if (oldBtn) oldBtn.remove(); + + if (currentPage === 0) { + list.innerHTML = loadedCommits.map(renderCommitItem).join(''); + } else { + // Append newly loaded commits (remove loading placeholder first) + const placeholder = document.getElementById('loadMorePlaceholder'); + if (placeholder) placeholder.remove(); + const frag = document.createDocumentFragment(); + const tmp = document.createElement('ul'); + tmp.innerHTML = (data.commits || []).map(renderCommitItem).join(''); + while (tmp.firstChild) frag.appendChild(tmp.firstChild); + list.appendChild(frag); } + + attachCommitClickHandlers(); + + if (hasMoreCommits) { + const btn = document.createElement('li'); + btn.id = 'loadMoreBtn'; + btn.style.cssText = 'padding:8px 10px;text-align:center;cursor:pointer;color:#0969da;border-top:1px solid #eaeef2;'; + btn.textContent = 'Load more commits…'; + btn.onclick = async () => { + btn.textContent = 'Loading…'; + btn.onclick = null; + currentPage++; + await loadMoreCommits(); + }; + list.appendChild(btn); + } + + // Re-highlight selected commit if any + if (currentCommit) { + document.querySelectorAll('.commit-item').forEach(el => + el.classList.toggle('selected', el.dataset.hash === currentCommit) + ); + } + } + + function selectCommitByHash(hash) { + // Search already-loaded commits + const c = loadedCommits.find(c => c.hash.startsWith(hash)); + if (c) { selectCommit(c.hash, c.hasCI); return; } } 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 +768,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 +855,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,25 +876,115 @@ 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'; - history.pushState(null, '', '/jci/' + currentCommit + '/' + name); + contentHeader.innerHTML = '<span class="filename">' + escapeHtml(name) + '</span>' + + '<a class="download-btn" href="' + rawUrl + '" target="_blank">↓ Download</a>'; + + 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) { if (!t) return ''; return t.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); @@ -571,21 +993,56 @@ 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>'; } }; document.getElementById('branchSelect').onchange = e => showBranch(e.target.value); loadBranches(); + </script> </body> </html> `) } -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 diff --git a/scripts/build_website.sh b/scripts/build_website.sh @@ -1,50 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -: "${DOCKER_BUILDKIT:=1}" - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" -PUBLIC_DIR="${REPO_ROOT}/www_jci/public" -RELEASES_DIR="${PUBLIC_DIR}/releases" - -IMAGE_NAME="jci-stagit" -CONTAINER_NAME="jci-stagit-builder" - -# Ensure releases directory exists -mkdir -p "${RELEASES_DIR}" - -# Build docker image with stagit (using inline Dockerfile via here-doc) -if ! docker image inspect "${IMAGE_NAME}">/dev/null 2>&1; then - tmp_dir="$(mktemp -d)" - mkdir -p "${tmp_dir}/repo" "${tmp_dir}/public_assets" - cp -R "${REPO_ROOT}"/.[!.]* "${tmp_dir}/repo" 2>/dev/null || true - cp -R "${REPO_ROOT}"/* "${tmp_dir}/repo" - cp "${PUBLIC_DIR}/assets/logo.png" "${tmp_dir}/public_assets/logo.png" - cp "${REPO_ROOT}/www_jci/Dockerfile" "${tmp_dir}/Dockerfile" - docker build -t "${IMAGE_NAME}" "${tmp_dir}" - rm -rf "${tmp_dir}" -fi - -# Prepare context for stagit run -BUILD_CONTEXT="$(mktemp -d)" -mkdir -p "${BUILD_CONTEXT}/repo" "${BUILD_CONTEXT}/public_assets" -cp -a "${REPO_ROOT}/." "${BUILD_CONTEXT}/repo" -cp "${PUBLIC_DIR}/assets/logo.png" "${BUILD_CONTEXT}/public_assets/logo.png" -if [ -f "${REPO_ROOT}/ui/src/libs/git.style.css" ]; then - cp "${REPO_ROOT}/ui/src/libs/git.style.css" "${BUILD_CONTEXT}/stagit_style.css" -else - cat <<'EOF' > "${BUILD_CONTEXT}/stagit_style.css" -:root {font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;} -EOF -fi - -docker run --rm \ - -v "${BUILD_CONTEXT}/repo:/tmp/repo" \ - -v "${PUBLIC_DIR}/assets/logo.png:/data/www_jci/public/assets/logo.png" \ - -v "${RELEASES_DIR}:/gitrepo" \ - "${IMAGE_NAME}" - -rm -rf "${BUILD_CONTEXT}" - -echo "Releases site generated at ${RELEASES_DIR}" diff --git a/scripts/check_go.sh b/scripts/check_go.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +cd "${REPO_ROOT}" + +if ! command -v go >/dev/null 2>&1; then + echo "Go toolchain not found in PATH." + exit 1 +fi + +echo "Running gofmt checks..." +gofmt -e cmd > /dev/null +gofmt -e internal > /dev/null + +echo "Running go vet..." +go vet ./... + +echo "Go formatting, and vet checks passed." diff --git a/scripts/generate_readme_index.sh b/scripts/generate_readme_index.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +README_FILE="${1:-README.md}" + +if [[ ! -f "${README_FILE}" ]]; then + echo "README file not found: ${README_FILE}" >&2 + exit 1 +fi + +python3 - "$README_FILE" <<'PY' +import sys +import re +from pathlib import Path + +readme_path = Path(sys.argv[1]) +text = readme_path.read_text(encoding="utf-8") + +lines = text.splitlines() +dash_lines = [idx for idx, line in enumerate(lines) if line.strip() == '---'] +if len(dash_lines) < 2: + raise SystemExit("Expected at least two lines containing only '---' in README") + +heading_pattern = re.compile(r'^(#{2,3})\s+(.*)$', re.MULTILINE) +headings = [] +for match in heading_pattern.finditer(text): + level = len(match.group(1)) + title = match.group(2).strip() + if not title: + continue + slug = title.lower() + slug = re.sub(r'[^a-z0-9\s-]', '', slug) + slug = re.sub(r'\s+', '-', slug).strip('-') + slug = re.sub(r'-+', '-', slug) + if not slug: + continue + headings.append((level, title, f"#{slug}")) + +index_lines = [] +for level, title, anchor in headings: + indent = '' if level == 2 else ' ' + index_lines.append(f"{indent}- [{title}]({anchor})") + +first, second = dash_lines[:2] +result_lines = [] +result_lines.extend(lines[:first + 1]) +result_lines.append('') +if index_lines: + result_lines.extend(index_lines) +result_lines.append('') +result_lines.extend(lines[second:]) + +new_text = "\n".join(result_lines) +if not new_text.endswith("\n"): + new_text += "\n" + +readme_path.write_text(new_text, encoding="utf-8") +PY diff --git a/www_jci/public/index.html b/www_jci/public/index.html @@ -15,7 +15,7 @@ <div> <a href="/binaries.html">Binaries</a> <a href="/releases/file/README.md.html">Docs</a> - <span id="jci-version">v1.0.1</span> + <span id="jci-version">v1.0.2</span> </div> </nav> <h1><img src="assets/logo.png" alt="JCI logo" style="height:1em;width:auto;vertical-align:middle;margin-right:0.4em;">Minimal. Offline. Local.</h1> @@ -50,7 +50,9 @@ purchasing a Commercial License. <p> Your support allows for continued maintenance and new features. </p> - <form id="rzp-form" class="rzp-skeleton" style="display:inline-block;min-width:172px;min-height:48px;"><script src="https://checkout.razorpay.com/v1/payment-button.js" data-payment_button_id="pl_SKcbmqJhCoHh1L" async> </script> </form> + + + <form id="rzp-form" class="rzp-skeleton" style="display:inline-block;min-width:172px;min-height:48px;"><script src="https://checkout.razorpay.com/v1/payment-button.js" data-payment_button_id="pl_SLSx4a5vakmaec" async> </script> </form> <script> (function () { var form = document.getElementById('rzp-form');