9d1519222c
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>
100 lines
3.3 KiB
Go
100 lines
3.3 KiB
Go
// 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
|
|
}
|