You've already forked agentic-coding-workflow
260 lines
7.5 KiB
Go
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) }
|