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:
@@ -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