Merge dev/giteactl: giteactl v1
This commit is contained in:
70
giteactl/README.md
Normal file
70
giteactl/README.md
Normal file
@@ -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 <repo> [--limit 10] runs: status, branch, duration, title
|
||||
giteactl log <repo> <run> [--job N] print a job log
|
||||
giteactl wait <repo> [--timeout 30m] block until newest run finishes
|
||||
giteactl wait-quiet [--max-active 1] block until the runner is quiet
|
||||
giteactl release <repo> [<tag>] 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/<owner>/<repo>/<hex(id%256)>/<id>.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 .
|
||||
```
|
||||
280
giteactl/gitea/gitea.go
Normal file
280
giteactl/gitea/gitea.go
Normal file
@@ -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: <dir>/<owner>/<repo>/<hex(taskID%256)>/<taskID>.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)
|
||||
}
|
||||
}
|
||||
149
giteactl/gitea/gitea_test.go
Normal file
149
giteactl/gitea/gitea_test.go
Normal file
@@ -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, "<!DOCTYPE html><html>login page</html>")
|
||||
}))
|
||||
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")
|
||||
}
|
||||
}
|
||||
3
giteactl/go.mod
Normal file
3
giteactl/go.mod
Normal file
@@ -0,0 +1,3 @@
|
||||
module gitea.brasse-pc.eu/brasse/agent-tools/giteactl
|
||||
|
||||
go 1.24
|
||||
330
giteactl/main.go
Normal file
330
giteactl/main.go
Normal file
@@ -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 <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)
|
||||
}
|
||||
Reference in New Issue
Block a user