Files
2026-06-22 08:55:57 +08:00

260 lines
7.5 KiB
Go

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 <PAT>" 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) }