runs/log/wait/wait-quiet/release against the Gitea API. Log fetch tries API (token) -> public web route -> ssh+zstd file storage (hex-bucket path verified against the real Pi5 layout). wait-quiet encodes the "serialize heavy builds" rule with private repos as warnings, not failures. Injectable fetchers make the wait logic testable. Spec: doc/tool-parity.md 4.1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KikHkfCiC3yELbsMN8fT9
331 lines
9.3 KiB
Go
331 lines
9.3 KiB
Go
// 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 <repo> [--limit 10] list runs: status, branch, duration
|
|
giteactl log <repo> <run> [--job N] print a run's job log
|
|
giteactl wait <repo> [--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 <repo> [<tag>] 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> <run-number>")
|
|
}
|
|
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> [<tag>]")
|
|
}
|
|
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)
|
|
}
|