9d1519222c
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>
122 lines
3.3 KiB
Go
122 lines
3.3 KiB
Go
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
|
|
}
|
|
}
|