You've already forked agentic-coding-workflow
bugfix
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
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) }
|
||||
Reference in New Issue
Block a user