fda3761ead
FetchState fetched the PR (merged=true) but then failed the whole state fetch when CommitStatus 404'd for a head commit that no longer existed (the branch was deleted after a squash/rebase merge). The merge signal was discarded, so the watch loop treated every post-merge poll as a transient error and never exited -- the 37-minute hang seen in production. - Add a typed APIError carrying the HTTP status so callers can detect a 404 without parsing error strings. - FetchState now tolerates a 404 from CommitStatus (commit gone => no CI status) and returns the authoritative merged/closed PR state. - Regression tests: FetchState survives a 404 status; the full watch loop, driven through a real client, detects a merge whose head commit is gone (both fail/hang before the fix).
170 lines
4.9 KiB
Go
170 lines
4.9 KiB
Go
package agent
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
// APIError is a non-2xx response from the Gitea API. It carries the status code
|
|
// so callers can react to specific failures (e.g. tolerate a 404 for a commit
|
|
// whose branch was deleted after a merge) instead of parsing error strings.
|
|
type APIError struct {
|
|
Method string
|
|
Path string
|
|
StatusCode int
|
|
Body string
|
|
}
|
|
|
|
func (e *APIError) Error() string {
|
|
return fmt.Sprintf("gitea %s %s: HTTP %d: %s", e.Method, e.Path, e.StatusCode, e.Body)
|
|
}
|
|
|
|
// isNotFound reports whether err is a Gitea 404.
|
|
func isNotFound(err error) bool {
|
|
var apiErr *APIError
|
|
return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound
|
|
}
|
|
|
|
// GiteaClient talks to the Gitea REST API as the agent user.
|
|
type GiteaClient struct {
|
|
BaseURL string
|
|
Token string
|
|
HTTP *http.Client
|
|
}
|
|
|
|
// NewGiteaClient builds a client from the configured base URL and a Vault-minted
|
|
// token.
|
|
func NewGiteaClient(token string) *GiteaClient {
|
|
return &GiteaClient{BaseURL: GiteaURL(), Token: token, HTTP: httpClient}
|
|
}
|
|
|
|
func (c *GiteaClient) do(method, path string, body any, out any) error {
|
|
var reader io.Reader
|
|
if body != nil {
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
reader = bytes.NewReader(b)
|
|
}
|
|
url := strings.TrimRight(c.BaseURL, "/") + path
|
|
req, err := http.NewRequest(method, url, reader)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Authorization", "token "+c.Token)
|
|
req.Header.Set("Accept", "application/json")
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
|
|
resp, err := c.HTTP.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
data, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return &APIError{Method: method, Path: path, StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(data))}
|
|
}
|
|
if out != nil && len(data) > 0 {
|
|
if err := json.Unmarshal(data, out); err != nil {
|
|
return fmt.Errorf("gitea %s %s: decoding response: %w", method, path, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// User is the subset of the Gitea user object we care about.
|
|
type User struct {
|
|
Login string `json:"login"`
|
|
ID int64 `json:"id"`
|
|
}
|
|
|
|
// Whoami returns the authenticated user (GET /api/v1/user).
|
|
func (c *GiteaClient) Whoami() (User, error) {
|
|
var u User
|
|
err := c.do(http.MethodGet, "/api/v1/user", nil, &u)
|
|
return u, err
|
|
}
|
|
|
|
// PullRequest is the subset of Gitea's PR object we track.
|
|
type PullRequest struct {
|
|
Number int `json:"number"`
|
|
State string `json:"state"`
|
|
Title string `json:"title"`
|
|
Merged bool `json:"merged"`
|
|
Mergeable bool `json:"mergeable"`
|
|
HTMLURL string `json:"html_url"`
|
|
Head struct {
|
|
Sha string `json:"sha"`
|
|
} `json:"head"`
|
|
}
|
|
|
|
// CreatePROptions are the fields for opening a PR.
|
|
type CreatePROptions struct {
|
|
Base string `json:"base"`
|
|
Head string `json:"head"`
|
|
Title string `json:"title"`
|
|
Body string `json:"body"`
|
|
}
|
|
|
|
// CreatePR opens a pull request (POST /api/v1/repos/{owner}/{repo}/pulls).
|
|
func (c *GiteaClient) CreatePR(repoPath string, opts CreatePROptions) (PullRequest, error) {
|
|
var pr PullRequest
|
|
err := c.do(http.MethodPost, "/api/v1/repos/"+repoPath+"/pulls", opts, &pr)
|
|
return pr, err
|
|
}
|
|
|
|
// GetPR fetches a single pull request.
|
|
func (c *GiteaClient) GetPR(repoPath string, number int) (PullRequest, error) {
|
|
var pr PullRequest
|
|
err := c.do(http.MethodGet, fmt.Sprintf("/api/v1/repos/%s/pulls/%d", repoPath, number), nil, &pr)
|
|
return pr, err
|
|
}
|
|
|
|
// Comment is the subset of an issue comment we track.
|
|
type Comment struct {
|
|
ID int64 `json:"id"`
|
|
User User `json:"user"`
|
|
Body string `json:"body"`
|
|
}
|
|
|
|
// CreateComment posts a comment on the PR's issue thread
|
|
// (POST /api/v1/repos/{owner}/{repo}/issues/{n}/comments).
|
|
func (c *GiteaClient) CreateComment(repoPath string, number int, body string) (Comment, error) {
|
|
var cm Comment
|
|
payload := map[string]string{"body": body}
|
|
err := c.do(http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/issues/%d/comments", repoPath, number), payload, &cm)
|
|
return cm, err
|
|
}
|
|
|
|
// ListComments lists the PR's issue comments.
|
|
func (c *GiteaClient) ListComments(repoPath string, number int) ([]Comment, error) {
|
|
var out []Comment
|
|
err := c.do(http.MethodGet, fmt.Sprintf("/api/v1/repos/%s/issues/%d/comments", repoPath, number), nil, &out)
|
|
return out, err
|
|
}
|
|
|
|
// CombinedStatus is the combined commit status for a ref.
|
|
type CombinedStatus struct {
|
|
State string `json:"state"`
|
|
}
|
|
|
|
// CommitStatus returns the combined CI status for a commit SHA
|
|
// (GET /api/v1/repos/{owner}/{repo}/commits/{sha}/status). An empty ref yields
|
|
// an empty state without an API call.
|
|
func (c *GiteaClient) CommitStatus(repoPath, sha string) (string, error) {
|
|
if sha == "" {
|
|
return "", nil
|
|
}
|
|
var cs CombinedStatus
|
|
err := c.do(http.MethodGet, fmt.Sprintf("/api/v1/repos/%s/commits/%s/status", repoPath, sha), nil, &cs)
|
|
return cs.State, err
|
|
}
|