Files
GitManager/internal/forge/gitea.go
T
TBNilles ad00654487 Slice 10: create_pr (open a pull request)
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>
2026-09-20 17:34:13 -04:00

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
}
}