ad00654487
forge CreatePullRequest (Gitea; empty base resolves to the repo default branch). Service CreatePR records a pr-created event. HTTP POST /api/repo/pr/create; MCP tool create_pr; <pr-list> New pull request button (head = selected repo current branch, base = default). Live-tested end-to-end: app create_pr opened a real PR (base auto-resolved), listed, then cleaned up; main untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
152 lines
4.1 KiB
Go
152 lines
4.1 KiB
Go
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
|
|
}
|
|
|
|
// CreatePullRequest opens a PR. An empty Base resolves to the repo's default
|
|
// branch. Requires the head branch to already exist on the remote.
|
|
func (g *Gitea) CreatePullRequest(_ context.Context, owner, repo string, pr NewPR) (PullRequest, error) {
|
|
if strings.TrimSpace(pr.Head) == "" {
|
|
return PullRequest{}, fmt.Errorf("a head branch is required")
|
|
}
|
|
base := strings.TrimSpace(pr.Base)
|
|
if base == "" {
|
|
r, _, err := g.client.GetRepo(owner, repo)
|
|
if err != nil {
|
|
return PullRequest{}, err
|
|
}
|
|
base = r.DefaultBranch
|
|
}
|
|
title := pr.Title
|
|
if strings.TrimSpace(title) == "" {
|
|
title = pr.Head
|
|
}
|
|
created, _, err := g.client.CreatePullRequest(owner, repo, gitea.CreatePullRequestOption{
|
|
Head: pr.Head,
|
|
Base: base,
|
|
Title: title,
|
|
Body: pr.Body,
|
|
})
|
|
if err != nil {
|
|
return PullRequest{}, err
|
|
}
|
|
return toPR(created), 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
|
|
}
|
|
}
|