// giteactl gives agents first-class access to Gitea Actions runs, job // logs and releases — including the hard-learned homelab rule // "serialize heavy builds" (wait-quiet). See doc/tool-parity.md §4.1. package main import ( "encoding/json" "flag" "fmt" "os" "os/exec" "path/filepath" "strconv" "strings" "time" "gitea.brasse-pc.eu/brasse/agent-tools/giteactl/gitea" ) var version = "dev" const usage = `giteactl - Gitea Actions runs, CI logs and releases for agents Usage: giteactl runs [--limit 10] list runs: status, branch, duration giteactl log [--job N] print a run's job log giteactl wait [--timeout 30m] [--interval 15s] block until the newest run finishes exit 0 = green, 2 = red, 3 = timeout giteactl wait-quiet [--max-active 1] [--timeout 30m] block until <=N builds are active across the heavy_repos in the config giteactl release [] release assets + download URLs giteactl version Config: ~/.config/giteactl/config.json (created on first run; GITEACTL_CONFIG overrides). token is needed for private repos; public repos work without. Log fetching tries the API (token), then the public web route, then ssh+zstd against the server's log storage. The Pi5 rule: more than 2 heavy builds take every service down. Always 'giteactl wait-quiet && git push' when pushing build-triggering repos. ` type config struct { URL string `json:"url"` Owner string `json:"owner"` Token string `json:"token"` LogSSHHost string `json:"log_ssh_host"` LogDir string `json:"log_dir"` HeavyRepos []string `json:"heavy_repos"` } func defaultConfig() *config { return &config{ URL: "https://gitea.brasse-pc.eu", Owner: "brasse", Token: "", LogSSHHost: "pi5-claude", LogDir: "/srv/storage1/gitea/actions_log", HeavyRepos: []string{"agent-helm", "agent-tools", "FitnessDroid", "brasse-pc.eu-v2", "Archivum", "lyssnarr", "Npm-cli"}, } } func configPath() string { if p := os.Getenv("GITEACTL_CONFIG"); p != "" { return p } dir, err := os.UserConfigDir() if err != nil { dir = "." } return filepath.Join(dir, "giteactl", "config.json") } func loadConfig() *config { cfg := defaultConfig() path := configPath() data, err := os.ReadFile(path) if os.IsNotExist(err) { if err := os.MkdirAll(filepath.Dir(path), 0o755); err == nil { out, _ := json.MarshalIndent(cfg, "", " ") os.WriteFile(path, append(out, '\n'), 0o600) fmt.Fprintf(os.Stderr, "giteactl: created %s\n", path) } return cfg } if err == nil { json.Unmarshal(data, cfg) } return cfg } func main() { if len(os.Args) < 2 { fmt.Print(usage) os.Exit(2) } cfg := loadConfig() client := gitea.New(cfg.URL, cfg.Owner, cfg.Token) switch os.Args[1] { case "runs": cmdRuns(client, os.Args[2:]) case "log": cmdLog(client, cfg, os.Args[2:]) case "wait": cmdWait(client, os.Args[2:]) case "wait-quiet": cmdWaitQuiet(client, cfg, os.Args[2:]) case "release": cmdRelease(client, os.Args[2:]) case "version", "--version", "-v": fmt.Println("giteactl", version) case "help", "--help", "-h": fmt.Print(usage) default: die("unknown command %q — run 'giteactl help'", os.Args[1]) } } func cmdRuns(client *gitea.Client, args []string) { fs := flag.NewFlagSet("runs", flag.ExitOnError) limit := fs.Int("limit", 10, "max jobs to list") pos := parseInterspersed(fs, args) if len(pos) != 1 { die("runs needs exactly one repo name") } tasks, err := client.Tasks(pos[0], *limit) if err != nil { die("%v", err) } if len(tasks) == 0 { fmt.Println("no Actions runs") return } fmt.Printf("%-6s %-18s %-9s %-14s %-9s %s\n", "RUN", "JOB", "STATUS", "BRANCH", "TOOK", "TITLE") for _, t := range tasks { took := "" if d := t.Duration(); d > 0 { took = d.Round(time.Second).String() } fmt.Printf("%-6d %-18s %-9s %-14s %-9s %s\n", t.RunNumber, trunc(t.Name, 18), t.Status, trunc(t.HeadBranch, 14), took, trunc(t.DisplayTitle, 46)) } } func cmdLog(client *gitea.Client, cfg *config, args []string) { fs := flag.NewFlagSet("log", flag.ExitOnError) job := fs.Int("job", 0, "job index within the run (when a run has several)") pos := parseInterspersed(fs, args) if len(pos) != 2 { die("log needs: giteactl log ") } repo := pos[0] runNo, err := strconv.ParseInt(pos[1], 10, 64) if err != nil { die("%q is not a run number", pos[1]) } tasks, err := client.Tasks(repo, 100) if err != nil { die("%v", err) } var runTasks []gitea.Task for _, t := range tasks { if t.RunNumber == runNo { runTasks = append(runTasks, t) } } if len(runTasks) == 0 { die("run %d not found among the latest 100 jobs", runNo) } if *job >= len(runTasks) { die("run %d has %d jobs (0..%d)", runNo, len(runTasks), len(runTasks)-1) } // tasks are newest-first; job index counts from the run's start task := runTasks[len(runTasks)-1-*job] // 1) API (needs token), 2) public web route, 3) ssh + zstd if cfg.Token != "" { if log, err := client.LogAPI(repo, task.ID); err == nil { fmt.Print(log) return } } if log, err := client.LogWeb(repo, runNo, *job); err == nil { fmt.Print(log) return } path := gitea.ZstPath(cfg.LogDir, cfg.Owner, repo, task.ID) fmt.Fprintf(os.Stderr, "giteactl: HTTP routes failed, trying ssh %s cat %s\n", cfg.LogSSHHost, path) ssh := exec.Command("sh", "-c", fmt.Sprintf("ssh %s cat %q | zstd -dc", cfg.LogSSHHost, path)) ssh.Stdout, ssh.Stderr = os.Stdout, os.Stderr if err := ssh.Run(); err != nil { die("all log sources failed (api/web/ssh): %v", err) } } func cmdWait(client *gitea.Client, args []string) { fs := flag.NewFlagSet("wait", flag.ExitOnError) timeout := fs.Duration("timeout", 30*time.Minute, "total time budget") interval := fs.Duration("interval", 15*time.Second, "poll interval") pos := parseInterspersed(fs, args) if len(pos) != 1 { die("wait needs exactly one repo name") } res, err := gitea.WaitRun(pos[0], func(r string) ([]gitea.Task, error) { return client.Tasks(r, 30) }, *interval, *timeout, func(s string) { fmt.Fprintln(os.Stderr, "giteactl: "+s) }) if err != nil { die("%v", err) } for _, t := range res.Tasks { fmt.Printf("run %d %-18s %s\n", t.RunNumber, t.Name, t.Status) } switch { case !res.Done: fmt.Fprintln(os.Stderr, "giteactl: timeout — run still active") os.Exit(3) case !res.AllOK: fmt.Fprintln(os.Stderr, "giteactl: run finished RED") os.Exit(2) } fmt.Fprintln(os.Stderr, "giteactl: run finished green") } func cmdWaitQuiet(client *gitea.Client, cfg *config, args []string) { fs := flag.NewFlagSet("wait-quiet", flag.ExitOnError) maxActive := fs.Int("max-active", 1, "max simultaneously active builds") timeout := fs.Duration("timeout", 30*time.Minute, "total time budget") interval := fs.Duration("interval", 20*time.Second, "poll interval") parseInterspersed(fs, args) // private repos without a token become blind spots, not failures — // warn once per repo and count the ones we can see warned := map[string]bool{} fetch := func(r string) ([]gitea.Task, error) { tasks, err := client.Tasks(r, 10) if err != nil { if !warned[r] { warned[r] = true fmt.Fprintf(os.Stderr, "giteactl: warning: cannot check %s (%v)\n", r, err) } return nil, nil } return tasks, nil } ok, err := gitea.WaitQuiet(cfg.HeavyRepos, *maxActive, fetch, *interval, *timeout, func(s string) { fmt.Fprintln(os.Stderr, "giteactl: "+s) }) if err != nil { die("%v", err) } if !ok { fmt.Fprintln(os.Stderr, "giteactl: timeout — runner still busy") os.Exit(3) } fmt.Println("runner quiet — safe to push") } func cmdRelease(client *gitea.Client, args []string) { if len(args) < 1 { die("release needs: giteactl release []") } repo := args[0] releases, err := client.Releases(repo, 30) if err != nil { die("%v", err) } if len(releases) == 0 { fmt.Println("no releases") return } rel := releases[0] if len(args) > 1 { found := false for _, r := range releases { if r.TagName == args[1] { rel, found = r, true break } } if !found { die("no release with tag %q", args[1]) } } fmt.Printf("%s (%s, published %s)\n", rel.TagName, rel.Name, rel.PublishedAt.Local().Format("2006-01-02 15:04")) for _, a := range rel.Assets { fmt.Printf(" %-28s %8.1f KiB %s\n", a.Name, float64(a.Size)/1024, a.DownloadURL) } } func trunc(s string, n int) string { if len(s) <= n { return s } return s[:n-1] + "…" } // parseInterspersed lets flags appear before or after positional args. func parseInterspersed(fs *flag.FlagSet, args []string) []string { var flags, pos []string for i := 0; i < len(args); i++ { a := args[i] if len(a) > 1 && a[0] == '-' { flags = append(flags, a) name := strings.TrimLeft(a, "-") if eq := strings.Index(name, "="); eq >= 0 { continue } if f := fs.Lookup(name); f != nil { if bf, ok := f.Value.(interface{ IsBoolFlag() bool }); ok && bf.IsBoolFlag() { continue } } if i+1 < len(args) { i++ flags = append(flags, args[i]) } } else { pos = append(pos, a) } } fs.Parse(flags) return pos } func die(format string, args ...interface{}) { fmt.Fprintf(os.Stderr, "giteactl: "+format+"\n", args...) os.Exit(1) }