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:
@@ -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", ""),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user