package vcs import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" ) // DefaultGiteaBaseURL is the platform's self-hosted Gitea instance. const DefaultGiteaBaseURL = "https://git.jerryyan.net" // giteaErrBodyLimit caps how much of a non-2xx response body is surfaced in the // wrapped error message. const giteaErrBodyLimit = 512 // GiteaProvider implements Provider against the Gitea REST v1 API. Auth uses // the "Authorization: token " header with a single platform-level token. type GiteaProvider struct { baseURL string token string client *http.Client } // NewGiteaProvider constructs a GiteaProvider. baseURL empty defaults to // DefaultGiteaBaseURL; timeout <= 0 defaults to 15s. A fresh http.Client is // used (low call frequency), mirroring the webhook handler pattern. func NewGiteaProvider(baseURL, token string, timeout time.Duration) *GiteaProvider { if baseURL == "" { baseURL = DefaultGiteaBaseURL } if timeout <= 0 { timeout = 15 * time.Second } return &GiteaProvider{ baseURL: strings.TrimRight(baseURL, "/"), token: token, client: &http.Client{Timeout: timeout}, } } // Host returns the bare host (no scheme/port) this provider serves, for // ParseRepoRef host matching. func (g *GiteaProvider) Host() string { u, err := url.Parse(g.baseURL) if err != nil || u.Host == "" { return "" } return u.Hostname() } // ===== Gitea wire DTOs ===== type giteaPR struct { Number int64 `json:"number"` State string `json:"state"` Mergeable bool `json:"mergeable"` Merged bool `json:"merged"` MergedCommitID string `json:"merge_commit_sha"` HTMLURL string `json:"html_url"` Head giteaBranch `json:"head"` } type giteaBranch struct { Sha string `json:"sha"` Ref string `json:"ref"` } type giteaCombinedStatus struct { State string `json:"state"` // pending|success|error|failure } type giteaIssue struct { Number int64 `json:"number"` Title string `json:"title"` State string `json:"state"` HTMLURL string `json:"html_url"` } // ===== Provider impl ===== func (g *GiteaProvider) CreatePR(ctx context.Context, in CreatePRInput) (*PullRequest, error) { body := map[string]any{ "head": in.Head, "base": in.Base, "title": in.Title, "body": in.Body, } path := fmt.Sprintf("/api/v1/repos/%s/%s/pulls", esc(in.Repo.Owner), esc(in.Repo.Name)) var pr giteaPR if err := g.do(ctx, http.MethodPost, path, body, &pr); err != nil { return nil, err } out := mapPR(pr) // best-effort CI state from the head commit's combined status out.CIState = g.ciState(ctx, in.Repo, pr.Head.Sha) return &out, nil } func (g *GiteaProvider) GetPR(ctx context.Context, repo RepoRef, number int64) (*PullRequest, error) { path := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", esc(repo.Owner), esc(repo.Name), number) var pr giteaPR if err := g.do(ctx, http.MethodGet, path, nil, &pr); err != nil { return nil, err } out := mapPR(pr) out.CIState = g.ciState(ctx, repo, pr.Head.Sha) return &out, nil } func (g *GiteaProvider) Comment(ctx context.Context, repo RepoRef, number int64, body string) error { path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", esc(repo.Owner), esc(repo.Name), number) return g.do(ctx, http.MethodPost, path, map[string]any{"body": body}, nil) } func (g *GiteaProvider) Merge(ctx context.Context, repo RepoRef, number int64, in MergeInput) (string, error) { method := in.Method if method == "" { method = "merge" } body := map[string]any{"Do": method} if in.Title != "" { body["MergeTitleField"] = in.Title } if in.Message != "" { body["MergeMessageField"] = in.Message } path := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", esc(repo.Owner), esc(repo.Name), number) if err := g.do(ctx, http.MethodPost, path, body, nil); err != nil { return "", err } // Gitea's merge endpoint returns 200 with no SHA; re-fetch to read the merge commit. pr, err := g.GetPR(ctx, repo, number) if err != nil { return "", nil // merge succeeded; sha lookup best-effort } return pr.MergeCommitSHA, nil } func (g *GiteaProvider) ListIssues(ctx context.Context, repo RepoRef, opts IssueListOptions) ([]Issue, error) { q := url.Values{} q.Set("type", "issues") if opts.State != "" { q.Set("state", opts.State) } if opts.Limit > 0 { q.Set("limit", strconv.Itoa(opts.Limit)) } path := fmt.Sprintf("/api/v1/repos/%s/%s/issues?%s", esc(repo.Owner), esc(repo.Name), q.Encode()) var rows []giteaIssue if err := g.do(ctx, http.MethodGet, path, nil, &rows); err != nil { return nil, err } out := make([]Issue, 0, len(rows)) for _, r := range rows { out = append(out, Issue{Number: r.Number, Title: r.Title, State: r.State, HTMLURL: r.HTMLURL}) } return out, nil } // ciState fetches the combined commit status for sha and maps it to the // platform's ci_state vocabulary. Missing status / errors degrade to "unknown". func (g *GiteaProvider) ciState(ctx context.Context, repo RepoRef, sha string) string { if sha == "" { return "unknown" } path := fmt.Sprintf("/api/v1/repos/%s/%s/commits/%s/status", esc(repo.Owner), esc(repo.Name), esc(sha)) var cs giteaCombinedStatus if err := g.do(ctx, http.MethodGet, path, nil, &cs); err != nil { return "unknown" } switch cs.State { case "success": return "success" case "pending": return "pending" case "error", "failure": return "failure" default: return "unknown" } } // ===== HTTP plumbing ===== // do performs a request, sets the token auth header, and decodes a 2xx JSON // body into out (out nil => body discarded). Non-2xx becomes a typed upstream // error carrying a truncated body. func (g *GiteaProvider) do(ctx context.Context, method, path string, body, out any) error { var rdr io.Reader if body != nil { b, err := json.Marshal(body) if err != nil { return errs.Wrap(err, errs.CodeInternal, "vcs: marshal request body") } rdr = bytes.NewReader(b) } req, err := http.NewRequestWithContext(ctx, method, g.baseURL+path, rdr) if err != nil { return errs.Wrap(err, errs.CodeInternal, "vcs: build request") } if g.token != "" { req.Header.Set("Authorization", "token "+g.token) } req.Header.Set("Accept", "application/json") if body != nil { req.Header.Set("Content-Type", "application/json") } resp, err := g.client.Do(req) if err != nil { return errs.Wrap(err, errs.CodeUpstream, "vcs: gitea request failed") } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { snippet, _ := io.ReadAll(io.LimitReader(resp.Body, giteaErrBodyLimit)) code := errs.CodeUpstream if resp.StatusCode == http.StatusNotFound { code = errs.CodeNotFound } return errs.New(code, fmt.Sprintf("vcs: gitea %s %s -> %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(snippet)))) } if out == nil { _, _ = io.Copy(io.Discard, resp.Body) return nil } if err := json.NewDecoder(resp.Body).Decode(out); err != nil { return errs.Wrap(err, errs.CodeUpstream, "vcs: decode gitea response") } return nil } func mapPR(pr giteaPR) PullRequest { state := pr.State if pr.Merged { state = "merged" } return PullRequest{ Number: pr.Number, State: state, Mergeable: pr.Mergeable, Merged: pr.Merged, MergeCommitSHA: pr.MergedCommitID, HTMLURL: pr.HTMLURL, } } // esc path-escapes a single URL segment (owner/name/sha may contain odd chars). func esc(s string) string { return url.PathEscape(s) }