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>
This commit is contained in:
2026-09-20 17:34:13 -04:00
parent e1999bcf21
commit ad00654487
10 changed files with 149 additions and 7 deletions
+2 -2
View File
@@ -366,8 +366,8 @@ obeys the safety rules (§1.4).
- Read: `list_repos`, `get_repo`, `get_active_project`, `get_activity`, - Read: `list_repos`, `get_repo`, `get_active_project`, `get_activity`,
`get_pending_switch`, `list_prs`. `get_pending_switch`, `list_prs`.
- Act: `git_fetch`, `git_pull`, `git_push`, `git_commit`, `git_checkout`, - Act: `git_fetch`, `git_pull`, `git_push`, `git_commit`, `git_checkout`,
`create_branch`, `git_discard_changes`, `merge_and_cleanup_pr`, `create_branch`, `git_discard_changes`, `create_pr`, `merge_and_cleanup_pr`,
`set_active_project`, `ack_switch`. (More — `create_pr` — as they land.) `set_active_project`, `ack_switch`.
- **A tool's result type must be a struct, never a bare slice/map/scalar.** The - **A tool's result type must be a struct, never a bare slice/map/scalar.** The
go-sdk infers each tool's `outputSchema` from its handler's result type, and MCP go-sdk infers each tool's `outputSchema` from its handler's result type, and MCP
structured output must be a JSON **object** (`type: "object"`). A handler that structured output must be a JSON **object** (`type: "object"`). A handler that
+13
View File
@@ -230,3 +230,16 @@ Append-only running history of all changes (AGENT.md §9.1). Newest last.
`cmd/server/main.go`, `components/repo-menu`, `web/templates/help.html`, `cmd/server/main.go`, `components/repo-menu`, `web/templates/help.html`,
`AGENT.md` (§8.1). Checkout isn't §1.4-destructive — git refuses if it would `AGENT.md` (§8.1). Checkout isn't §1.4-destructive — git refuses if it would
overwrite uncommitted changes. overwrite uncommitted changes.
## 2026-09-20 — Slice 10: create_pr
- **What:** forge `CreatePullRequest` (Gitea; empty base → repo default branch,
via GetRepo). Service `CreatePR` (records `pr-created`). HTTP
`POST /api/repo/pr/create`. MCP tool `create_pr`. `<pr-list>` gained a
"New pull request…" button (head = selected repo's current branch, base =
default). AGENT.md §8.1 lists `create_pr` in Act.
- **Why:** Open PRs from the app or Claude — the front half of the PR workflow
whose back half is "Merge & clean up".
- **Affects:** `internal/forge`, `internal/service`, `internal/mcp`,
`cmd/server/main.go`, `components/pr-list`, `web/templates/help.html`,
`AGENT.md`.
- **Note:** the head branch must already exist on the remote (push first).
+17
View File
@@ -240,6 +240,23 @@ func main() {
} }
return c.JSON(http.StatusOK, map[string]any{"supported": true, "prs": prs}) return c.JSON(http.StatusOK, map[string]any{"supported": true, "prs": prs})
}) })
e.POST("/api/repo/pr/create", func(c echo.Context) error {
var body struct {
Path string `json:"path"`
Head string `json:"head"`
Base string `json:"base"`
Title string `json:"title"`
Body string `json:"body"`
}
if err := c.Bind(&body); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"})
}
pr, err := svc.CreatePR(c.Request().Context(), activity.ActorUser, body.Path, body.Head, body.Base, body.Title, body.Body)
if err != nil {
return c.JSON(http.StatusBadGateway, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, pr)
})
e.POST("/api/repo/pr/merge", func(c echo.Context) error { e.POST("/api/repo/pr/merge", func(c echo.Context) error {
var body struct { var body struct {
Path string `json:"path"` Path string `json:"path"`
+32 -3
View File
@@ -10,6 +10,7 @@ class PRList extends HTMLElement {
#controller = null; #controller = null;
#onSelect = null; #onSelect = null;
#path = ''; #path = '';
#repo = null;
constructor() { constructor() {
super(); super();
@@ -18,7 +19,7 @@ class PRList extends HTMLElement {
connectedCallback() { connectedCallback() {
this.#renderShell(); this.#renderShell();
this.#onSelect = (e) => this.#load(e.detail?.path); this.#onSelect = (e) => { this.#repo = e.detail; this.#load(e.detail?.path); };
document.addEventListener('repo:select', this.#onSelect); document.addEventListener('repo:select', this.#onSelect);
} }
@@ -64,6 +65,25 @@ class PRList extends HTMLElement {
} }
} }
async #create() {
const branch = this.#repo?.branch;
if (!branch) return;
const title = window.prompt(`Open a pull request from "${branch}" (into the default branch).\nTitle:`, branch);
if (title === null) return;
try {
const res = await fetch('/api/repo/pr/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: this.#path, head: branch, base: '', title: title.trim() || branch }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
this.#load(this.#path); // refresh so the new PR appears
} catch (err) {
window.alert(`Create PR failed: ${err.message}`);
}
}
#render(prs) { #render(prs) {
const body = this.shadowRoot.getElementById('body'); const body = this.shadowRoot.getElementById('body');
if (prs.length === 0) { if (prs.length === 0) {
@@ -102,8 +122,13 @@ class PRList extends HTMLElement {
:host { display: block; } :host { display: block; }
.box { background: var(--surface-1); border: 1px solid var(--border); .box { background: var(--surface-1); border: 1px solid var(--border);
border-radius: var(--radius); padding: 14px 16px; } border-radius: var(--radius); padding: 14px 16px; }
.hd { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em;
color: var(--color-fg-muted); margin: 0 0 8px; } color: var(--color-fg-muted); margin: 0; }
#new { margin-left: auto; font: inherit; cursor: pointer; padding: 3px 10px;
border-radius: var(--radius-sm); border: 1px solid var(--fill-accent);
color: var(--fill-accent); background: transparent; }
#new:hover { background: var(--surface-2); }
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; } ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
li { border-top: 1px solid var(--border); padding-top: 8px; } li { border-top: 1px solid var(--border); padding-top: 8px; }
li:first-child { border-top: none; padding-top: 0; } li:first-child { border-top: none; padding-top: 0; }
@@ -124,9 +149,13 @@ class PRList extends HTMLElement {
.error { color: var(--color-danger); } .error { color: var(--color-danger); }
</style> </style>
<div class="box"> <div class="box">
<h3>Pull requests</h3> <div class="hd">
<h3>Pull requests</h3>
<button id="new" title="Open a PR from the current branch">New pull request…</button>
</div>
<div id="body"><p class="muted">Select a repository.</p></div> <div id="body"><p class="muted">Select a repository.</p></div>
</div>`; </div>`;
this.shadowRoot.getElementById('new').addEventListener('click', () => this.#create());
} }
#esc(s) { #esc(s) {
+5 -1
View File
@@ -13,10 +13,14 @@ naming the PR, base, and branch to be deleted.
configured or the repo isn't on the forge host. configured or the repo isn't on the forge host.
- **Listens:** `repo:select` on `document` — loads PRs for `event.detail.path`. - **Listens:** `repo:select` on `document` — loads PRs for `event.detail.path`.
- **Fetches:** `GET /api/repo/prs?path=…` (`{supported:false}` → hidden). - **Fetches:** `GET /api/repo/prs?path=…` (`{supported:false}` → hidden).
- **Writes:** `POST /api/repo/pr/merge {path, number}` after a `confirm()`. - **Writes:** `POST /api/repo/pr/merge {path, number}` after a `confirm()`;
`POST /api/repo/pr/create {path, head, base, title}` via the "New pull request…"
button (head = the selected repo's current branch, base = the repo default).
## History ## History
- 2026-09-20: created — slice 5 (forge); list open PRs + "Merge & clean up". - 2026-09-20: created — slice 5 (forge); list open PRs + "Merge & clean up".
- 2026-09-20: added "New pull request…" (create_pr) — opens a PR from the
selected repo's current branch into the default branch (slice 10).
## Notes / gotchas ## Notes / gotchas
- Requires `GITEA_URL` + `GITEA_TOKEN` on the server; otherwise the component - Requires `GITEA_URL` + `GITEA_TOKEN` on the server; otherwise the component
+10
View File
@@ -48,11 +48,21 @@ type MergeResult struct {
BranchDeleted bool `json:"branchDeleted"` BranchDeleted bool `json:"branchDeleted"`
} }
// NewPR describes a pull request to open. An empty Base means "the repo's
// default branch".
type NewPR struct {
Head string
Base string
Title string
Body string
}
// Provider talks to one hosting provider. // Provider talks to one hosting provider.
type Provider interface { type Provider interface {
// Handles reports whether this provider serves the given remote host. // Handles reports whether this provider serves the given remote host.
Handles(host string) bool Handles(host string) bool
ListPullRequests(ctx context.Context, owner, repo string) ([]PullRequest, error) ListPullRequests(ctx context.Context, owner, repo string) ([]PullRequest, error)
CreatePullRequest(ctx context.Context, owner, repo string, pr NewPR) (PullRequest, error)
MergeAndCleanup(ctx context.Context, owner, repo string, number int64, method MergeMethod) (MergeResult, error) MergeAndCleanup(ctx context.Context, owner, repo string, number int64, method MergeMethod) (MergeResult, error)
} }
+30
View File
@@ -54,6 +54,36 @@ func (g *Gitea) ListPullRequests(_ context.Context, owner, repo string) ([]PullR
return out, nil 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 // 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 // the same repo — never a fork's branch). Callers MUST have confirmed with the
// user first (§1.4). // user first (§1.4).
+21
View File
@@ -81,6 +81,15 @@ type mergePRInput struct {
Number int64 `json:"number" jsonschema:"the pull request number to merge and clean up"` Number int64 `json:"number" jsonschema:"the pull request number to merge and clean up"`
} }
// createPRInput describes a pull request to open.
type createPRInput struct {
Path string `json:"path" jsonschema:"absolute path of the repository, from list_repos"`
Head string `json:"head" jsonschema:"the branch to merge from (must already exist on the remote)"`
Base string `json:"base" jsonschema:"the branch to merge into; leave empty for the repo's default branch"`
Title string `json:"title" jsonschema:"the pull request title"`
Body string `json:"body" jsonschema:"the pull request description (optional)"`
}
// gitCommitInput is the argument schema for git_commit. // gitCommitInput is the argument schema for git_commit.
type gitCommitInput struct { type gitCommitInput struct {
Path string `json:"path" jsonschema:"absolute path of the repository, from list_repos"` Path string `json:"path" jsonschema:"absolute path of the repository, from list_repos"`
@@ -196,6 +205,18 @@ func NewServer(svc *service.Service, version string) *mcpsdk.Server {
return nil, prListOutput{PRs: prs}, nil return nil, prListOutput{PRs: prs}, nil
}) })
// create_pr — open a pull request from a branch.
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "create_pr",
Description: "Open a pull request from head into base (leave base empty for the repo's default branch). The head branch must already exist on the remote — push it first. Path is from list_repos.",
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in createPRInput) (*mcpsdk.CallToolResult, forge.PullRequest, error) {
pr, err := svc.CreatePR(ctx, activity.ActorClaude, in.Path, in.Head, in.Base, in.Title, in.Body)
if err != nil {
return nil, forge.PullRequest{}, err
}
return nil, pr, nil
})
// merge_and_cleanup_pr — DESTRUCTIVE: merges a PR and deletes its branch. // merge_and_cleanup_pr — DESTRUCTIVE: merges a PR and deletes its branch.
mcpsdk.AddTool(s, &mcpsdk.Tool{ mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "merge_and_cleanup_pr", Name: "merge_and_cleanup_pr",
+16
View File
@@ -137,6 +137,22 @@ func (s *Service) ForgePRs(ctx context.Context, repoPath string) ([]forge.PullRe
return s.forge.ListPullRequests(ctx, owner, repo) return s.forge.ListPullRequests(ctx, owner, repo)
} }
// CreatePR opens a pull request from head into base (empty base = the repo's
// default branch) and records the action. The head branch must already exist on
// the remote (push it first).
func (s *Service) CreatePR(ctx context.Context, actor activity.Actor, repoPath, head, base, title, body string) (forge.PullRequest, error) {
owner, repo, err := s.resolveForge(ctx, repoPath)
if err != nil {
return forge.PullRequest{}, err
}
pr, err := s.forge.CreatePullRequest(ctx, owner, repo, forge.NewPR{Head: head, Base: base, Title: title, Body: body})
if err != nil {
return forge.PullRequest{}, err
}
s.feed.Record(actor, "pr-created", filepath.Clean(repoPath), fmt.Sprintf("PR #%d %s → %s", pr.Number, pr.Head, pr.Base))
return pr, nil
}
// MergeAndCleanup merges a PR and deletes its branch, then records the action. // 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 // The caller is responsible for confirming with the user first (§1.4); actor
// distinguishes a UI action (user) from an MCP one (claude). // distinguishes a UI action (user) from an MCP one (claude).
+3 -1
View File
@@ -88,7 +88,9 @@
<h2>Pull requests: Merge &amp; clean up</h2> <h2>Pull requests: Merge &amp; clean up</h2>
<p> <p>
When a repository is hosted on your Gitea server, its open pull requests When a repository is hosted on your Gitea server, its open pull requests
appear under the details panel. Each has a <strong>Merge &amp; clean up</strong> appear under the details panel. Use <strong>New pull request…</strong> to
open one from the selected repo's current branch (push the branch first).
Each open PR has a <strong>Merge &amp; clean up</strong>
button: it squash-merges the pull request and <strong>deletes its button: it squash-merges the pull request and <strong>deletes its
branch</strong> in one step, so finished work doesn't leave branches lying branch</strong> in one step, so finished work doesn't leave branches lying
around. You'll be asked to confirm — it names the pull request and the branch around. You'll be asked to confirm — it names the pull request and the branch