From 4b54458511ed19762bc62408c4990839fa19f7ca Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 7 Aug 2026 00:48:46 +0200 Subject: [PATCH] giteactl: Actions runs, CI logs, wait/wait-quiet, releases 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 Claude-Session: https://claude.ai/code/session_011KikHkfCiC3yELbsMN8fT9 --- giteactl/README.md | 70 ++++++++ giteactl/gitea/gitea.go | 280 +++++++++++++++++++++++++++++ giteactl/gitea/gitea_test.go | 149 ++++++++++++++++ giteactl/go.mod | 3 + giteactl/main.go | 330 +++++++++++++++++++++++++++++++++++ 5 files changed, 832 insertions(+) create mode 100644 giteactl/README.md create mode 100644 giteactl/gitea/gitea.go create mode 100644 giteactl/gitea/gitea_test.go create mode 100644 giteactl/go.mod create mode 100644 giteactl/main.go diff --git a/giteactl/README.md b/giteactl/README.md new file mode 100644 index 0000000..8691029 --- /dev/null +++ b/giteactl/README.md @@ -0,0 +1,70 @@ +# giteactl — Gitea Actions, CI logs and releases for agents + +Removes the biggest daily friction from +[`doc/tool-parity.md`](../doc/tool-parity.md) §4.1: CI status was +polled ad hoc, logs lived as zst files on the Pi5, and the +hard-learned rule *"more than 2 heavy builds take the Pi5 down"* had +no tooling. One stable command prefix for all of it. + +## Usage + +``` +giteactl runs [--limit 10] runs: status, branch, duration, title +giteactl log [--job N] print a job log +giteactl wait [--timeout 30m] block until newest run finishes +giteactl wait-quiet [--max-active 1] block until the runner is quiet +giteactl release [] release assets + download URLs +``` + +| Exit | wait | wait-quiet | +|------|------|------------| +| 0 | run finished **green** | runner quiet — safe to push | +| 2 | run finished red | — | +| 3 | timeout, still running | timeout, still busy | + +The serialization rule as one line: + +```bash +giteactl wait-quiet && git push +``` + +## Log fetching + +`giteactl log` tries three sources in order: + +1. **API** `…/actions/jobs/{id}/logs` — needs `token` in the config +2. **Public web route** — works anonymously for public repos +3. **ssh + zstd** — reads Gitea's file storage + (`actions_log////.log.zst`) through + the read-only `pi5-claude` ssh account and decompresses locally + +So public-repo logs work with zero setup; private repos need a token +(or fall back to ssh). + +## Config + +`~/.config/giteactl/config.json`, created on first run +(`GITEACTL_CONFIG` overrides): + +```json +{ + "url": "https://gitea.brasse-pc.eu", + "owner": "brasse", + "token": "", + "log_ssh_host": "pi5-claude", + "log_dir": "/srv/storage1/gitea/actions_log", + "heavy_repos": ["agent-helm", "agent-tools", "FitnessDroid", "brasse-pc.eu-v2", "Archivum", "lyssnarr", "Npm-cli"] +} +``` + +`heavy_repos` is what `wait-quiet` watches. Private repos it cannot +read become **warnings, not failures** — add a token to remove the +blind spots. Create one in Gitea: Settings → Applications → +Generate token (read-only scopes suffice). + +## Build & test + +```bash +go test ./... +go build -o build/giteactl . +``` diff --git a/giteactl/gitea/gitea.go b/giteactl/gitea/gitea.go new file mode 100644 index 0000000..38b19b8 --- /dev/null +++ b/giteactl/gitea/gitea.go @@ -0,0 +1,280 @@ +// Package gitea wraps the slice of Gitea's REST API that agents need +// daily: Actions runs, job logs and releases — plus the wait logic +// that encodes the homelab's "serialize heavy builds" rule. +package gitea + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strings" + "time" +) + +// Task is one Actions job as returned by /actions/tasks. Gitea calls +// these tasks; each run can hold several. +type Task struct { + ID int64 `json:"id"` + Name string `json:"name"` + HeadBranch string `json:"head_branch"` + RunNumber int64 `json:"run_number"` + Status string `json:"status"` + DisplayTitle string `json:"display_title"` + WorkflowID string `json:"workflow_id"` + URL string `json:"url"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + RunStartedAt time.Time `json:"run_started_at"` +} + +// Active reports whether the task still occupies (or will occupy) the +// runner. +func (t Task) Active() bool { + switch t.Status { + case "running", "waiting", "blocked": + return true + } + return false +} + +// OK reports whether the task ended well (skipped counts as ok). +func (t Task) OK() bool { + return t.Status == "success" || t.Status == "skipped" +} + +func (t Task) Duration() time.Duration { + if t.RunStartedAt.IsZero() || t.UpdatedAt.IsZero() || t.Active() { + return 0 + } + d := t.UpdatedAt.Sub(t.RunStartedAt) + if d < 0 { + return 0 + } + return d +} + +// Release is a Gitea release with its downloadable assets. +type Release struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + PublishedAt time.Time `json:"published_at"` + Assets []struct { + Name string `json:"name"` + Size int64 `json:"size"` + DownloadURL string `json:"browser_download_url"` + } `json:"assets"` +} + +type Client struct { + BaseURL string // e.g. https://gitea.brasse-pc.eu + Owner string + Token string // optional; required for private repos + HTTP *http.Client +} + +func New(baseURL, owner, token string) *Client { + return &Client{ + BaseURL: strings.TrimRight(baseURL, "/"), + Owner: owner, + Token: token, + HTTP: &http.Client{Timeout: 60 * time.Second}, + } +} + +func (c *Client) get(path string) (*http.Response, error) { + req, err := http.NewRequest("GET", c.BaseURL+path, nil) + if err != nil { + return nil, err + } + if c.Token != "" { + req.Header.Set("Authorization", "token "+c.Token) + } + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode == 404 { + resp.Body.Close() + return nil, fmt.Errorf("not found (private repo without token in the config?)") + } + if resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + resp.Body.Close() + return nil, fmt.Errorf("gitea answered %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + return resp, nil +} + +// Tasks lists a repo's Actions jobs, newest first. +func (c *Client) Tasks(repo string, limit int) ([]Task, error) { + resp, err := c.get(fmt.Sprintf("/api/v1/repos/%s/%s/actions/tasks?limit=%d", + url.PathEscape(c.Owner), url.PathEscape(repo), limit)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + var out struct { + WorkflowRuns []Task `json:"workflow_runs"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + sort.Slice(out.WorkflowRuns, func(i, j int) bool { + return out.WorkflowRuns[i].ID > out.WorkflowRuns[j].ID + }) + if limit > 0 && len(out.WorkflowRuns) > limit { + out.WorkflowRuns = out.WorkflowRuns[:limit] + } + return out.WorkflowRuns, nil +} + +// LogAPI fetches a job log through the token-authenticated API. +func (c *Client) LogAPI(repo string, taskID int64) (string, error) { + resp, err := c.get(fmt.Sprintf("/api/v1/repos/%s/%s/actions/jobs/%d/logs", + url.PathEscape(c.Owner), url.PathEscape(repo), taskID)) + if err != nil { + return "", err + } + defer resp.Body.Close() + data, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20)) + return string(data), err +} + +// LogWeb fetches a job log through the web route, which works +// anonymously for public repos (job = index within the run, not id). +func (c *Client) LogWeb(repo string, runNumber int64, jobIndex int) (string, error) { + resp, err := c.get(fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/logs", + url.PathEscape(c.Owner), url.PathEscape(repo), runNumber, jobIndex)) + if err != nil { + return "", err + } + defer resp.Body.Close() + data, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20)) + if err != nil { + return "", err + } + if strings.HasPrefix(strings.TrimSpace(string(data)), "<") { + return "", fmt.Errorf("got HTML instead of a log (login page? private repo needs a token)") + } + return string(data), nil +} + +// ZstPath is where Gitea's file storage keeps a task's compressed log +// on the server: ////.log.zst. +// Used by the ssh fallback for logs the HTTP routes no longer serve. +func ZstPath(dir, owner, repo string, taskID int64) string { + return fmt.Sprintf("%s/%s/%s/%02x/%d.log.zst", dir, owner, repo, taskID%256, taskID) +} + +// Releases lists a repo's releases, newest first. +func (c *Client) Releases(repo string, limit int) ([]Release, error) { + resp, err := c.get(fmt.Sprintf("/api/v1/repos/%s/%s/releases?limit=%d", + url.PathEscape(c.Owner), url.PathEscape(repo), limit)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + var out []Release + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + return out, nil +} + +// --- wait logic (injectable fetch so it is testable) --- + +type TaskFetcher func(repo string) ([]Task, error) + +type WaitResult struct { + Done bool // latest run finished within the timeout + AllOK bool + Tasks []Task // the latest run's tasks (or last seen) + Attempts int +} + +// WaitRun polls until every task of repo's newest run has finished. +func WaitRun(repo string, fetch TaskFetcher, interval, timeout time.Duration, log func(string)) (WaitResult, error) { + start := time.Now() + res := WaitResult{} + for { + res.Attempts++ + tasks, err := fetch(repo) + if err != nil { + return res, err + } + if len(tasks) == 0 { + return res, fmt.Errorf("repo has no Actions runs") + } + latest := tasks[0].RunNumber + var runTasks []Task + active := false + allOK := true + for _, t := range tasks { + if t.RunNumber != latest { + continue + } + runTasks = append(runTasks, t) + if t.Active() { + active = true + } else if !t.OK() { + allOK = false + } + } + res.Tasks = runTasks + if !active { + res.Done, res.AllOK = true, allOK + return res, nil + } + if log != nil { + log(fmt.Sprintf("run #%d still active (%d jobs), waiting...", latest, len(runTasks))) + } + if time.Since(start)+interval > timeout { + return res, nil + } + time.Sleep(interval) + } +} + +// CountActive counts active jobs across the given repos. +func CountActive(repos []string, fetch TaskFetcher) (int, []string, error) { + count := 0 + var busy []string + for _, r := range repos { + tasks, err := fetch(r) + if err != nil { + return 0, nil, fmt.Errorf("%s: %w", r, err) + } + for _, t := range tasks { + if t.Active() { + count++ + busy = append(busy, fmt.Sprintf("%s#%d(%s)", r, t.RunNumber, t.Status)) + } + } + } + return count, busy, nil +} + +// WaitQuiet blocks until at most maxActive jobs run across repos — +// the "serialize pushes, >2 heavy builds take the Pi5 down" rule. +func WaitQuiet(repos []string, maxActive int, fetch TaskFetcher, interval, timeout time.Duration, log func(string)) (bool, error) { + start := time.Now() + for { + n, busy, err := CountActive(repos, fetch) + if err != nil { + return false, err + } + if n <= maxActive { + return true, nil + } + if log != nil { + log(fmt.Sprintf("%d active builds (max %d): %s", n, maxActive, strings.Join(busy, " "))) + } + if time.Since(start)+interval > timeout { + return false, nil + } + time.Sleep(interval) + } +} diff --git a/giteactl/gitea/gitea_test.go b/giteactl/gitea/gitea_test.go new file mode 100644 index 0000000..7424d3a --- /dev/null +++ b/giteactl/gitea/gitea_test.go @@ -0,0 +1,149 @@ +package gitea + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestTasksParsesAndSorts(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "/repos/brasse/agent-tools/actions/tasks") { + t.Errorf("unexpected path %s", r.URL.Path) + } + if r.Header.Get("Authorization") != "token tok" { + t.Errorf("token header missing") + } + fmt.Fprint(w, `{"workflow_runs":[ + {"id":110,"name":"build","run_number":5,"status":"success"}, + {"id":111,"name":"build-release","run_number":6,"status":"running"}]}`) + })) + defer srv.Close() + tasks, err := New(srv.URL, "brasse", "tok").Tasks("agent-tools", 10) + if err != nil { + t.Fatal(err) + } + if len(tasks) != 2 || tasks[0].ID != 111 { + t.Errorf("tasks not sorted newest first: %+v", tasks) + } + if !tasks[0].Active() || tasks[1].Active() { + t.Error("Active() wrong") + } +} + +func TestPrivateRepoHint(t *testing.T) { + srv := httptest.NewServer(http.NotFoundHandler()) + defer srv.Close() + _, err := New(srv.URL, "brasse", "").Tasks("infra-Doc", 5) + if err == nil || !strings.Contains(err.Error(), "token") { + t.Errorf("404 should hint about tokens, got %v", err) + } +} + +func TestLogWebRejectsHTML(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "login page") + })) + defer srv.Close() + _, err := New(srv.URL, "brasse", "").LogWeb("x", 1, 0) + if err == nil || !strings.Contains(err.Error(), "HTML") { + t.Errorf("HTML response should error, got %v", err) + } +} + +func TestZstPathHexBucket(t *testing.T) { + // verified against the real Pi5 layout: task 53 -> 35/53.log.zst + cases := map[int64]string{ + 53: "/logs/brasse/FitnessDroid/35/53.log.zst", + 52: "/logs/brasse/FitnessDroid/34/52.log.zst", + 86: "/logs/brasse/FitnessDroid/56/86.log.zst", + 2: "/logs/brasse/FitnessDroid/02/2.log.zst", + 258: "/logs/brasse/FitnessDroid/02/258.log.zst", + } + for id, want := range cases { + if got := ZstPath("/logs", "brasse", "FitnessDroid", id); got != want { + t.Errorf("ZstPath(%d) = %s, want %s", id, got, want) + } + } +} + +func TestWaitRunFinishes(t *testing.T) { + calls := 0 + fetch := func(repo string) ([]Task, error) { + calls++ + status := "running" + if calls >= 3 { + status = "success" + } + return []Task{ + {ID: 2, RunNumber: 7, Status: status}, + {ID: 1, RunNumber: 6, Status: "failure"}, // older run must not matter + }, nil + } + res, err := WaitRun("x", fetch, time.Millisecond, time.Second, nil) + if err != nil { + t.Fatal(err) + } + if !res.Done || !res.AllOK || res.Attempts != 3 { + t.Errorf("unexpected: %+v", res) + } +} + +func TestWaitRunRedFailure(t *testing.T) { + fetch := func(repo string) ([]Task, error) { + return []Task{{ID: 2, RunNumber: 7, Status: "failure"}}, nil + } + res, err := WaitRun("x", fetch, time.Millisecond, time.Second, nil) + if err != nil { + t.Fatal(err) + } + if !res.Done || res.AllOK { + t.Errorf("failure must give Done && !AllOK: %+v", res) + } +} + +func TestWaitRunTimeout(t *testing.T) { + fetch := func(repo string) ([]Task, error) { + return []Task{{ID: 2, RunNumber: 7, Status: "running"}}, nil + } + res, err := WaitRun("x", fetch, 5*time.Millisecond, 15*time.Millisecond, nil) + if err != nil { + t.Fatal(err) + } + if res.Done { + t.Errorf("should have timed out: %+v", res) + } +} + +func TestWaitQuietCountsAcrossRepos(t *testing.T) { + step := 0 + fetch := func(repo string) ([]Task, error) { + // step 0: both repos busy; step >= 1: only one + if repo == "a" && step > 0 { + return []Task{{ID: 1, Status: "success"}}, nil + } + return []Task{{ID: 2, Status: "running"}}, nil + } + log := func(string) { step++ } + ok, err := WaitQuiet([]string{"a", "b"}, 1, fetch, time.Millisecond, time.Second, log) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Error("should reach quiet state") + } +} + +func TestTaskDuration(t *testing.T) { + start := time.Now().Add(-90 * time.Second) + tk := Task{Status: "success", RunStartedAt: start, UpdatedAt: start.Add(75 * time.Second)} + if d := tk.Duration(); d != 75*time.Second { + t.Errorf("duration = %s", d) + } + if (Task{Status: "running", RunStartedAt: start}).Duration() != 0 { + t.Error("active task should have zero duration") + } +} diff --git a/giteactl/go.mod b/giteactl/go.mod new file mode 100644 index 0000000..d1f1aab --- /dev/null +++ b/giteactl/go.mod @@ -0,0 +1,3 @@ +module gitea.brasse-pc.eu/brasse/agent-tools/giteactl + +go 1.24 diff --git a/giteactl/main.go b/giteactl/main.go new file mode 100644 index 0000000..7249925 --- /dev/null +++ b/giteactl/main.go @@ -0,0 +1,330 @@ +// 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) +}