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:
2026-09-20 08:37:51 -04:00
parent 2b77e15b36
commit 9d1519222c
16 changed files with 657 additions and 13 deletions
+99
View File
@@ -0,0 +1,99 @@
// Package forge is the provider-abstracted boundary to a git hosting service
// (AGENT.md §8.4). Gitea/Forgejo is the first provider; GitHub/GitLab can drop in
// behind the same interface. It is read + write: the write path powers
// "Merge & clean up" (merge a PR + delete its branch), which callers must confirm
// per §1.4. With no provider configured the features degrade gracefully.
package forge
import (
"context"
"errors"
"net/url"
"strings"
)
// Sentinel errors so callers (and the UI) can degrade gracefully.
var (
ErrNotConfigured = errors.New("forge integration not configured")
ErrNotSupported = errors.New("repository is not on a supported forge host")
)
// PullRequest is the provider-neutral view of an open PR/MR.
type PullRequest struct {
Number int64 `json:"number"`
Title string `json:"title"`
Author string `json:"author"`
Head string `json:"head"` // head branch
Base string `json:"base"` // base branch
Draft bool `json:"draft"`
Mergeable bool `json:"mergeable"`
URL string `json:"url"`
SameRepo bool `json:"sameRepo"` // head & base in the same repo (branch is deletable)
}
// MergeMethod selects how a PR is merged.
type MergeMethod string
const (
MergeSquash MergeMethod = "squash"
MergeMerge MergeMethod = "merge"
MergeRebase MergeMethod = "rebase"
)
// MergeResult reports the outcome of a merge-and-cleanup.
type MergeResult struct {
Number int64 `json:"number"`
Merged bool `json:"merged"`
Branch string `json:"branch"`
BranchDeleted bool `json:"branchDeleted"`
}
// Provider talks to one hosting provider.
type Provider interface {
// Handles reports whether this provider serves the given remote host.
Handles(host string) bool
ListPullRequests(ctx context.Context, owner, repo string) ([]PullRequest, error)
MergeAndCleanup(ctx context.Context, owner, repo string, number int64, method MergeMethod) (MergeResult, error)
}
// ParseRemote extracts (host, owner, repo) from a git remote URL, handling both
// https ("https://host/owner/repo.git") and scp-like ssh ("git@host:owner/repo.git").
func ParseRemote(remote string) (host, owner, repo string, ok bool) {
remote = strings.TrimSpace(remote)
if remote == "" {
return "", "", "", false
}
// scp-like ssh form has no scheme: user@host:path
if !strings.Contains(remote, "://") && strings.Contains(remote, "@") && strings.Contains(remote, ":") {
rest := remote[strings.Index(remote, "@")+1:]
colon := strings.Index(rest, ":")
host = rest[:colon]
owner, repo, ok = splitOwnerRepo(rest[colon+1:])
return host, owner, repo, ok
}
u, err := url.Parse(remote)
if err != nil || u.Hostname() == "" {
return "", "", "", false
}
owner, repo, ok = splitOwnerRepo(u.Path)
return u.Hostname(), owner, repo, ok
}
// splitOwnerRepo turns "/owner/repo.git" (or subgroups) into (owner, repo). It
// takes the last two path segments, which covers the common single-owner case.
func splitOwnerRepo(path string) (owner, repo string, ok bool) {
path = strings.Trim(path, "/")
path = strings.TrimSuffix(path, ".git")
parts := strings.Split(path, "/")
if len(parts) < 2 {
return "", "", false
}
owner = parts[len(parts)-2]
repo = parts[len(parts)-1]
if owner == "" || repo == "" {
return "", "", false
}
return owner, repo, true
}
+31
View File
@@ -0,0 +1,31 @@
package forge
import "testing"
func TestParseRemote(t *testing.T) {
cases := []struct {
in string
host, owner, repo string
ok bool
}{
{"https://git.nilles.net/TBNilles/GitManager.git", "git.nilles.net", "TBNilles", "GitManager", true},
{"https://git.nilles.net/TBNilles/GitManager", "git.nilles.net", "TBNilles", "GitManager", true},
{"git@git.nilles.net:TBNilles/GitManager.git", "git.nilles.net", "TBNilles", "GitManager", true},
{"https://git.nilles.net:3000/org/sub/Repo.git", "git.nilles.net", "sub", "Repo", true},
{"ssh://git@git.nilles.net:2222/TBNilles/GitManager.git", "git.nilles.net", "TBNilles", "GitManager", true},
{"not a url", "", "", "", false},
{"https://git.nilles.net/", "", "", "", false},
}
for _, c := range cases {
host, owner, repo, ok := ParseRemote(c.in)
if ok != c.ok {
t.Errorf("ParseRemote(%q) ok = %v, want %v", c.in, ok, c.ok)
continue
}
// Field values only matter on success.
if ok && (host != c.host || owner != c.owner || repo != c.repo) {
t.Errorf("ParseRemote(%q) = (%q,%q,%q), want (%q,%q,%q)",
c.in, host, owner, repo, c.host, c.owner, c.repo)
}
}
}
+121
View File
@@ -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
}
}