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,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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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