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
+6 -2
View File
@@ -34,8 +34,10 @@ type Config struct {
Dev bool // readable console logging vs structured JSON
LogFile string // optional file to also append logs to
GitHubToken string // optional forge token (AGENT.md §8)
GitLabToken string // optional forge token (AGENT.md §8)
GiteaURL string // Gitea/Forgejo base URL (e.g. https://git.nilles.net)
GiteaToken string // Gitea token (read + PR write + branch delete) — §8.4
GitHubToken string // optional forge token (later provider)
GitLabToken string // optional forge token (later provider)
}
// Load reads .env (if present) then the environment, applying defaults.
@@ -55,6 +57,8 @@ func Load() (Config, error) {
ScanFetchEnabled: envBool("SCAN_FETCH_ENABLED", false),
Dev: strings.EqualFold(env("APP_ENV", "dev"), "dev"),
LogFile: env("LOG_FILE", ""),
GiteaURL: env("GITEA_URL", ""),
GiteaToken: env("GITEA_TOKEN", ""),
GitHubToken: env("GITHUB_TOKEN", ""),
GitLabToken: env("GITLAB_TOKEN", ""),
}
+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
}
}
+41
View File
@@ -14,6 +14,7 @@ import (
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
"gitmanager/internal/activity"
"gitmanager/internal/forge"
"gitmanager/internal/repos"
"gitmanager/internal/service"
)
@@ -64,6 +65,22 @@ type ackSwitchOutput struct {
Target string `json:"target,omitempty" jsonschema:"the repository now active"`
}
// repoPathInput selects a repository by path (from list_repos).
type repoPathInput struct {
Path string `json:"path" jsonschema:"absolute path of the repository, exactly as returned by list_repos"`
}
// prListOutput wraps the pull requests (object, per the schema rule).
type prListOutput struct {
PRs []forge.PullRequest `json:"prs" jsonschema:"open pull requests for the repository"`
}
// mergePRInput selects the PR to merge and clean up.
type mergePRInput struct {
Path string `json:"path" jsonschema:"absolute path of the repository, from list_repos"`
Number int64 `json:"number" jsonschema:"the pull request number to merge and clean up"`
}
// NewServer builds the MCP server and registers the (currently read-only) tools.
func NewServer(svc *service.Service, version string) *mcpsdk.Server {
s := mcpsdk.NewServer(&mcpsdk.Implementation{
@@ -144,6 +161,30 @@ func NewServer(svc *service.Service, version string) *mcpsdk.Server {
return nil, ackSwitchOutput{Switched: true, Target: p.Target}, nil
})
// list_prs — open pull requests for a repo (§8.4).
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "list_prs",
Description: "List the open pull requests for a repository (needs a configured forge such as Gitea). The path must be one returned by list_repos.",
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in repoPathInput) (*mcpsdk.CallToolResult, prListOutput, error) {
prs, err := svc.ForgePRs(ctx, in.Path)
if err != nil {
return nil, prListOutput{}, err
}
return nil, prListOutput{PRs: prs}, nil
})
// merge_and_cleanup_pr — DESTRUCTIVE: merges a PR and deletes its branch.
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "merge_and_cleanup_pr",
Description: "Merge a pull request (squash) AND delete its source branch — the \"Merge & clean up\" action. This is irreversible: confirm the exact PR number and repository with the user BEFORE calling. Merged history remains on the host; only the branch is removed.",
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in mergePRInput) (*mcpsdk.CallToolResult, forge.MergeResult, error) {
res, err := svc.MergeAndCleanup(ctx, activity.ActorClaude, in.Path, in.Number)
if err != nil {
return nil, forge.MergeResult{}, err
}
return nil, res, nil
})
return s
}
+1 -1
View File
@@ -42,7 +42,7 @@ func TestMCPRoundTrip(t *testing.T) {
scanner := repos.NewScanner(g, log, []string{root}, 3, nil, time.Minute, false)
scanner.Refresh(context.Background())
svc := service.New(g, scanner.Index, activity.New(log, 200))
svc := service.New(g, scanner.Index, activity.New(log, 200), nil)
srv := NewServer(svc, "test")
// Wire an in-memory client<->server session.
+78 -4
View File
@@ -11,6 +11,7 @@ import (
"path/filepath"
"gitmanager/internal/activity"
"gitmanager/internal/forge"
"gitmanager/internal/git"
"gitmanager/internal/repos"
)
@@ -20,12 +21,13 @@ type Service struct {
git *git.CLI
index *repos.Index
feed *activity.Feed
forge *forge.Gitea // nil when no forge is configured
}
// New builds a Service over the git boundary, the scanner's repo index, and the
// activity feed.
func New(g *git.CLI, index *repos.Index, feed *activity.Feed) *Service {
return &Service{git: g, index: index, feed: feed}
// New builds a Service over the git boundary, the scanner's repo index, the
// activity feed, and (optionally) a forge provider.
func New(g *git.CLI, index *repos.Index, feed *activity.Feed, fg *forge.Gitea) *Service {
return &Service{git: g, index: index, feed: feed, forge: fg}
}
// ListRepos returns a snapshot of every discovered repository.
@@ -115,3 +117,75 @@ func (s *Service) AckSwitch(summary string) (activity.PendingSwitch, bool) {
func (s *Service) CancelSwitch(actor activity.Actor) (activity.PendingSwitch, bool) {
return s.feed.CancelSwitch(actor)
}
// --- Forge (Gitea) — PRs and "Merge & clean up" (§8.4) ---------------------
// ForgeConfigured reports whether any forge provider is set up.
func (s *Service) ForgeConfigured() bool { return s.forge != nil }
// ForgePRs lists open pull requests for a repo. Returns forge.ErrNotConfigured
// when no provider is set, or forge.ErrNotSupported when the repo's remote is not
// on the configured host.
func (s *Service) ForgePRs(ctx context.Context, repoPath string) ([]forge.PullRequest, error) {
owner, repo, err := s.resolveForge(ctx, repoPath)
if err != nil {
return nil, err
}
return s.forge.ListPullRequests(ctx, owner, repo)
}
// MergeAndCleanup merges a PR and deletes its branch, then records the action.
// The caller is responsible for confirming with the user first (§1.4); actor
// distinguishes a UI action (user) from an MCP one (claude).
func (s *Service) MergeAndCleanup(ctx context.Context, actor activity.Actor, repoPath string, number int64) (forge.MergeResult, error) {
owner, repo, err := s.resolveForge(ctx, repoPath)
if err != nil {
return forge.MergeResult{}, err
}
res, err := s.forge.MergeAndCleanup(ctx, owner, repo, number, forge.MergeSquash)
if err != nil {
return forge.MergeResult{}, err
}
detail := fmt.Sprintf("merged PR #%d", res.Number)
if res.BranchDeleted && res.Branch != "" {
detail += fmt.Sprintf(", deleted branch %s", res.Branch)
}
s.feed.Record(actor, "pr-merged", filepath.Clean(repoPath), detail)
return res, nil
}
// resolveForge maps a repo path to (owner, repo) on the configured forge host via
// its git remotes, preferring "origin".
func (s *Service) resolveForge(ctx context.Context, repoPath string) (owner, repo string, err error) {
if s.forge == nil {
return "", "", forge.ErrNotConfigured
}
base, ok := s.index.Get(filepath.Clean(repoPath))
if !ok {
return "", "", fmt.Errorf("unknown repository %q", repoPath)
}
remotes, err := s.git.RemoteDetails(ctx, base.Path)
if err != nil {
return "", "", err
}
// Prefer origin, then any matching remote.
var fallback [2]string
haveFallback := false
for _, rm := range remotes {
host, o, r, ok := forge.ParseRemote(rm.URL)
if !ok || !s.forge.Handles(host) {
continue
}
if rm.Name == "origin" {
return o, r, nil
}
if !haveFallback {
fallback = [2]string{o, r}
haveFallback = true
}
}
if haveFallback {
return fallback[0], fallback[1], nil
}
return "", "", forge.ErrNotSupported
}