This commit is contained in:
2026-06-22 08:55:57 +08:00
parent dbb87823e8
commit 6ade6e8fa9
325 changed files with 41131 additions and 855 deletions
+259
View File
@@ -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) }
+152
View File
@@ -0,0 +1,152 @@
package vcs
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
)
func newTestProvider(t *testing.T, h http.Handler) *GiteaProvider {
t.Helper()
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
return NewGiteaProvider(srv.URL, "test-token", 5*time.Second)
}
func TestGitea_CreatePR(t *testing.T) {
repo := RepoRef{Owner: "owner", Name: "repo"}
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/owner/repo/pulls", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "token test-token", r.Header.Get("Authorization"))
var body map[string]any
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
assert.Equal(t, "feature", body["head"])
assert.Equal(t, "main", body["base"])
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(giteaPR{
Number: 42, State: "open", Mergeable: true,
HTMLURL: "https://git.jerryyan.net/owner/repo/pulls/42",
Head: giteaBranch{Sha: "abc123", Ref: "feature"},
})
})
mux.HandleFunc("/api/v1/repos/owner/repo/commits/abc123/status", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(giteaCombinedStatus{State: "success"})
})
p := newTestProvider(t, mux)
pr, err := p.CreatePR(context.Background(), CreatePRInput{
Repo: repo, Head: "feature", Base: "main", Title: "T", Body: "B",
})
require.NoError(t, err)
assert.Equal(t, int64(42), pr.Number)
assert.Equal(t, "open", pr.State)
assert.Equal(t, "https://git.jerryyan.net/owner/repo/pulls/42", pr.HTMLURL)
assert.Equal(t, "success", pr.CIState)
}
func TestGitea_GetPR_MergedAndCI(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/owner/repo/pulls/7", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(giteaPR{
Number: 7, State: "closed", Merged: true, MergedCommitID: "deadbeef",
HTMLURL: "https://git.jerryyan.net/owner/repo/pulls/7",
Head: giteaBranch{Sha: "sha7"},
})
})
mux.HandleFunc("/api/v1/repos/owner/repo/commits/sha7/status", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(giteaCombinedStatus{State: "failure"})
})
p := newTestProvider(t, mux)
pr, err := p.GetPR(context.Background(), RepoRef{Owner: "owner", Name: "repo"}, 7)
require.NoError(t, err)
assert.True(t, pr.Merged)
assert.Equal(t, "merged", pr.State)
assert.Equal(t, "deadbeef", pr.MergeCommitSHA)
assert.Equal(t, "failure", pr.CIState)
}
func TestGitea_Merge(t *testing.T) {
var mergeCalled bool
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/owner/repo/pulls/3/merge", func(w http.ResponseWriter, r *http.Request) {
mergeCalled = true
assert.Equal(t, http.MethodPost, r.Method)
var body map[string]any
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
assert.Equal(t, "squash", body["Do"])
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("/api/v1/repos/owner/repo/pulls/3", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(giteaPR{Number: 3, State: "closed", Merged: true, MergedCommitID: "mergesha", Head: giteaBranch{Sha: "h3"}})
})
mux.HandleFunc("/api/v1/repos/owner/repo/commits/h3/status", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(giteaCombinedStatus{State: "success"})
})
p := newTestProvider(t, mux)
sha, err := p.Merge(context.Background(), RepoRef{Owner: "owner", Name: "repo"}, 3, MergeInput{Method: "squash"})
require.NoError(t, err)
assert.True(t, mergeCalled)
assert.Equal(t, "mergesha", sha)
}
func TestGitea_Comment(t *testing.T) {
var gotBody string
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/owner/repo/issues/5/comments", func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
gotBody, _ = body["body"].(string)
w.WriteHeader(http.StatusCreated)
})
p := newTestProvider(t, mux)
require.NoError(t, p.Comment(context.Background(), RepoRef{Owner: "owner", Name: "repo"}, 5, "hello"))
assert.Equal(t, "hello", gotBody)
}
func TestGitea_ListIssues(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/owner/repo/issues", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "issues", r.URL.Query().Get("type"))
_ = json.NewEncoder(w).Encode([]giteaIssue{
{Number: 1, Title: "bug", State: "open", HTMLURL: "u1"},
})
})
p := newTestProvider(t, mux)
issues, err := p.ListIssues(context.Background(), RepoRef{Owner: "owner", Name: "repo"}, IssueListOptions{State: "open"})
require.NoError(t, err)
require.Len(t, issues, 1)
assert.Equal(t, "bug", issues[0].Title)
}
func TestGitea_Non2xxError(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/owner/repo/pulls/9", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"not found"}`))
})
p := newTestProvider(t, mux)
_, err := p.GetPR(context.Background(), RepoRef{Owner: "owner", Name: "repo"}, 9)
require.Error(t, err)
ae, ok := errs.As(err)
require.True(t, ok)
assert.Equal(t, errs.CodeNotFound, ae.Code)
}
func TestGitea_Host(t *testing.T) {
p := NewGiteaProvider("https://git.jerryyan.net:3000", "", 0)
assert.Equal(t, "git.jerryyan.net", p.Host())
}
+89
View File
@@ -0,0 +1,89 @@
package vcs
import (
"net/url"
"strings"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
)
// ErrUnsupportedHost is returned by ParseRepoRef when the remote points at a
// host other than the one the v1 Gitea provider serves.
var ErrUnsupportedHost = errs.New(errs.CodeInvalidInput, "vcs: unsupported git host for PR operations")
// ParseRepoRef derives owner/name from a workspace GitRemoteURL. It accepts:
//
// https://host/owner/name(.git)
// http://host/owner/name(.git)
// ssh://git@host/owner/name(.git)
// git@host:owner/name(.git)
//
// wantHost is the bare host the provider serves (e.g. "git.jerryyan.net"); when
// non-empty the remote host must match it (port stripped, case-insensitive),
// otherwise ErrUnsupportedHost is returned. wantHost empty skips the host
// check (host-agnostic parse, used in tests).
func ParseRepoRef(remoteURL, wantHost string) (RepoRef, error) {
remoteURL = strings.TrimSpace(remoteURL)
if remoteURL == "" {
return RepoRef{}, errs.New(errs.CodeInvalidInput, "vcs: empty remote url")
}
host, path, err := splitHostPath(remoteURL)
if err != nil {
return RepoRef{}, err
}
if wantHost != "" && !sameHost(host, wantHost) {
return RepoRef{}, ErrUnsupportedHost
}
owner, name, err := splitOwnerName(path)
if err != nil {
return RepoRef{}, err
}
return RepoRef{Owner: owner, Name: name}, nil
}
// splitHostPath extracts the bare host and the repo path from a remote URL,
// handling both scheme URLs and the scp-like git@host:owner/name form.
func splitHostPath(remoteURL string) (host, path string, err error) {
// scp-like: git@host:owner/name.git (no scheme, single colon before path)
if !strings.Contains(remoteURL, "://") && strings.Contains(remoteURL, "@") && strings.Contains(remoteURL, ":") {
at := strings.Index(remoteURL, "@")
rest := remoteURL[at+1:]
colon := strings.Index(rest, ":")
if colon < 0 {
return "", "", errs.New(errs.CodeInvalidInput, "vcs: malformed scp remote url")
}
return rest[:colon], rest[colon+1:], nil
}
u, perr := url.Parse(remoteURL)
if perr != nil || u.Host == "" {
return "", "", errs.Wrap(perr, errs.CodeInvalidInput, "vcs: cannot parse remote url")
}
return u.Hostname(), u.Path, nil
}
// splitOwnerName turns "/owner/name.git" or "owner/name.git" into (owner, name).
func splitOwnerName(path string) (owner, name string, err error) {
path = strings.Trim(path, "/")
path = strings.TrimSuffix(path, ".git")
parts := strings.Split(path, "/")
if len(parts) < 2 || parts[0] == "" || parts[len(parts)-1] == "" {
return "", "", errs.New(errs.CodeInvalidInput, "vcs: remote url must contain owner/name")
}
// Gitea repos are owner/name; deeper paths are not valid repo refs.
return parts[0], parts[len(parts)-1], nil
}
// sameHost compares hosts case-insensitively, ignoring any :port suffix.
func sameHost(a, b string) bool {
return strings.EqualFold(stripPort(a), stripPort(b))
}
func stripPort(h string) string {
if i := strings.LastIndex(h, ":"); i >= 0 {
return h[:i]
}
return h
}
+60
View File
@@ -0,0 +1,60 @@
package vcs
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseRepoRef_HTTPS(t *testing.T) {
cases := []struct{ url, owner, name string }{
{"https://git.jerryyan.net/owner/repo.git", "owner", "repo"},
{"https://git.jerryyan.net/owner/repo", "owner", "repo"},
{"http://git.jerryyan.net/acme/widgets.git", "acme", "widgets"},
{"https://git.jerryyan.net:3000/owner/repo.git", "owner", "repo"},
}
for _, c := range cases {
ref, err := ParseRepoRef(c.url, "git.jerryyan.net")
require.NoError(t, err, c.url)
assert.Equal(t, c.owner, ref.Owner, c.url)
assert.Equal(t, c.name, ref.Name, c.url)
}
}
func TestParseRepoRef_SSHAndScp(t *testing.T) {
cases := []struct{ url, owner, name string }{
{"git@git.jerryyan.net:owner/repo.git", "owner", "repo"},
{"git@git.jerryyan.net:owner/repo", "owner", "repo"},
{"ssh://git@git.jerryyan.net/owner/repo.git", "owner", "repo"},
}
for _, c := range cases {
ref, err := ParseRepoRef(c.url, "git.jerryyan.net")
require.NoError(t, err, c.url)
assert.Equal(t, c.owner, ref.Owner, c.url)
assert.Equal(t, c.name, ref.Name, c.url)
}
}
func TestParseRepoRef_UnsupportedHost(t *testing.T) {
_, err := ParseRepoRef("https://github.com/owner/repo.git", "git.jerryyan.net")
require.ErrorIs(t, err, ErrUnsupportedHost)
_, err = ParseRepoRef("git@github.com:owner/repo.git", "git.jerryyan.net")
require.ErrorIs(t, err, ErrUnsupportedHost)
}
func TestParseRepoRef_Invalid(t *testing.T) {
for _, bad := range []string{"", "not-a-url", "https://git.jerryyan.net/only-owner"} {
_, err := ParseRepoRef(bad, "git.jerryyan.net")
require.Error(t, err, bad)
}
}
func TestParseRepoRef_HostAgnostic(t *testing.T) {
// wantHost empty => skip host check
ref, err := ParseRepoRef("https://github.com/owner/repo.git", "")
require.NoError(t, err)
assert.Equal(t, "owner", ref.Owner)
assert.Equal(t, "repo", ref.Name)
}
+75
View File
@@ -0,0 +1,75 @@
// Package vcs abstracts the git-host (VCS) REST API the platform uses to open
// and merge pull requests. v1 ships a Gitea-first implementation (gitea.go)
// targeting the self-hosted host git.jerryyan.net; other hosts return an
// Unimplemented error from the RepoRef parser / provider.
//
// The provider is intentionally narrow: only the verbs the change-request
// merge gate needs (CreatePR / GetPR / Comment / Merge / ListIssues). Secrets
// (the platform Gitea token) are held by the concrete provider, never passed
// through the interface.
package vcs
import "context"
// Provider is the git-host API surface consumed by the changerequest service.
type Provider interface {
// CreatePR opens a pull request from in.Head into in.Base.
CreatePR(ctx context.Context, in CreatePRInput) (*PullRequest, error)
// GetPR fetches a single PR by its host-side index/number, including a
// best-effort CI state derived from the head commit's combined status.
GetPR(ctx context.Context, repo RepoRef, number int64) (*PullRequest, error)
// Comment posts a comment on the PR (Gitea treats PRs as issues for comments).
Comment(ctx context.Context, repo RepoRef, number int64, body string) error
// Merge merges the PR via in.Method, returning the resulting merge commit SHA.
Merge(ctx context.Context, repo RepoRef, number int64, in MergeInput) (mergeSHA string, err error)
// ListIssues lists repository issues (not PRs) for orientation / linking.
ListIssues(ctx context.Context, repo RepoRef, opts IssueListOptions) ([]Issue, error)
}
// RepoRef identifies a repository on the host as owner/name. Derived from a
// workspace's GitRemoteURL by ParseRepoRef.
type RepoRef struct {
Owner string
Name string
}
// CreatePRInput is the input to Provider.CreatePR.
type CreatePRInput struct {
Repo RepoRef
Head string // source branch
Base string // target branch
Title string
Body string
}
// MergeInput controls how Provider.Merge merges the PR.
type MergeInput struct {
Method string // "merge" | "squash" | "rebase"
Title string // merge commit title (optional)
Message string // merge commit message body (optional)
}
// PullRequest is the host's view of a PR mapped to platform fields.
type PullRequest struct {
Number int64
State string // "open" | "closed" (Gitea: PR closed covers merged too)
Mergeable bool
Merged bool
MergeCommitSHA string
HTMLURL string
CIState string // "unknown" | "pending" | "success" | "failure"
}
// Issue is a minimal host issue projection.
type Issue struct {
Number int64
Title string
State string
HTMLURL string
}
// IssueListOptions filters ListIssues. State empty => host default ("open").
type IssueListOptions struct {
State string // "open" | "closed" | "all"
Limit int
}