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) }
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user