Slice 5: Gitea forge - PRs and Merge & clean up
New internal/forge (provider-abstracted, Gitea impl via code.gitea.io/sdk/gitea) with tested remote-URL parsing and read+write ops: list open PRs, and merge-and-cleanup (squash-merge + delete head branch when head/base share a repo). Service resolves repo->owner/repo from remotes (prefers origin) and records a pr-merged event; config gains GITEA_URL/GITEA_TOKEN (forge disabled without both). MCP tools list_prs and merge_and_cleanup_pr (merge tool tells Claude to confirm first, 1.4). HTTP GET /api/repo/prs, POST /api/repo/pr/merge. New <pr-list> component with a confirming Merge & clean up button, hidden when no forge. Verified graceful-disabled path; real merge pending token + a designated PR. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
package forge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
)
|
||||
|
||||
// Gitea is a Provider backed by a Gitea/Forgejo instance. It also decides which
|
||||
// repos it serves: only those whose remote host matches its base URL.
|
||||
type Gitea struct {
|
||||
host string
|
||||
client *gitea.Client
|
||||
}
|
||||
|
||||
// NewGitea builds a Gitea provider from a base URL and token. It returns
|
||||
// (nil, nil) when not configured (either value empty) so forge features simply
|
||||
// stay absent (§8.4 graceful degradation).
|
||||
func NewGitea(baseURL, token string) (*Gitea, error) {
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
token = strings.TrimSpace(token)
|
||||
if baseURL == "" || token == "" {
|
||||
return nil, nil
|
||||
}
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil || u.Hostname() == "" {
|
||||
return nil, fmt.Errorf("invalid GITEA_URL %q", baseURL)
|
||||
}
|
||||
c, err := gitea.NewClient(baseURL, gitea.SetToken(token))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Gitea{host: u.Hostname(), client: c}, nil
|
||||
}
|
||||
|
||||
// Handles reports whether a remote host is this Gitea instance.
|
||||
func (g *Gitea) Handles(host string) bool {
|
||||
return strings.EqualFold(host, g.host)
|
||||
}
|
||||
|
||||
// ListPullRequests lists the open PRs of owner/repo.
|
||||
func (g *Gitea) ListPullRequests(_ context.Context, owner, repo string) ([]PullRequest, error) {
|
||||
prs, _, err := g.client.ListRepoPullRequests(owner, repo, gitea.ListPullRequestsOptions{State: gitea.StateOpen})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]PullRequest, 0, len(prs))
|
||||
for _, p := range prs {
|
||||
out = append(out, toPR(p))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MergeAndCleanup merges the PR and deletes its head branch (when the head is in
|
||||
// the same repo — never a fork's branch). Callers MUST have confirmed with the
|
||||
// user first (§1.4).
|
||||
func (g *Gitea) MergeAndCleanup(_ context.Context, owner, repo string, number int64, method MergeMethod) (MergeResult, error) {
|
||||
pr, _, err := g.client.GetPullRequest(owner, repo, number)
|
||||
if err != nil {
|
||||
return MergeResult{}, err
|
||||
}
|
||||
branch := ""
|
||||
if pr.Head != nil {
|
||||
branch = pr.Head.Ref
|
||||
}
|
||||
sameRepo := pr.Head != nil && pr.Base != nil && pr.Head.RepoID == pr.Base.RepoID
|
||||
del := sameRepo && branch != ""
|
||||
|
||||
merged, _, err := g.client.MergePullRequest(owner, repo, number, gitea.MergePullRequestOption{
|
||||
Style: toStyle(method),
|
||||
DeleteBranchAfterMerge: &del,
|
||||
})
|
||||
if err != nil {
|
||||
return MergeResult{}, err
|
||||
}
|
||||
|
||||
res := MergeResult{Number: number, Merged: merged, Branch: branch, BranchDeleted: del && merged}
|
||||
// Best-effort fallback in case the merge option didn't delete the branch
|
||||
// (older Gitea). Ignore the error — the branch may already be gone.
|
||||
if merged && del {
|
||||
_, _, _ = g.client.DeleteRepoBranch(owner, repo, branch)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func toPR(p *gitea.PullRequest) PullRequest {
|
||||
pr := PullRequest{
|
||||
Number: p.Index,
|
||||
Title: p.Title,
|
||||
Draft: p.Draft,
|
||||
Mergeable: p.Mergeable,
|
||||
URL: p.HTMLURL,
|
||||
}
|
||||
if p.Poster != nil {
|
||||
pr.Author = p.Poster.UserName
|
||||
}
|
||||
if p.Head != nil {
|
||||
pr.Head = p.Head.Ref
|
||||
}
|
||||
if p.Base != nil {
|
||||
pr.Base = p.Base.Ref
|
||||
}
|
||||
if p.Head != nil && p.Base != nil {
|
||||
pr.SameRepo = p.Head.RepoID == p.Base.RepoID
|
||||
}
|
||||
return pr
|
||||
}
|
||||
|
||||
func toStyle(m MergeMethod) gitea.MergeStyle {
|
||||
switch m {
|
||||
case MergeMerge:
|
||||
return gitea.MergeStyleMerge
|
||||
case MergeRebase:
|
||||
return gitea.MergeStyleRebase
|
||||
default:
|
||||
return gitea.MergeStyleSquash
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user