// 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: