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:
@@ -366,8 +366,8 @@ obeys the safety rules (§1.4).
|
||||
- Read: `list_repos`, `get_repo`, `get_active_project`, `get_activity`,
|
||||
`get_pending_switch`, `list_prs`.
|
||||
- Act: `git_fetch`, `git_pull`, `git_push`, `git_commit`, `git_checkout`,
|
||||
`create_branch`, `git_discard_changes`, `merge_and_cleanup_pr`,
|
||||
`set_active_project`, `ack_switch`. (More — `create_pr` — as they land.)
|
||||
`create_branch`, `git_discard_changes`, `create_pr`, `merge_and_cleanup_pr`,
|
||||
`set_active_project`, `ack_switch`.
|
||||
- **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
|
||||
structured output must be a JSON **object** (`type: "object"`). A handler that
|
||||
|
||||
@@ -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`,
|
||||
`AGENT.md` (§8.1). Checkout isn't §1.4-destructive — git refuses if it would
|
||||
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).
|
||||
|
||||
@@ -240,6 +240,23 @@ func main() {
|
||||
}
|
||||
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 {
|
||||
var body struct {
|
||||
Path string `json:"path"`
|
||||
|
||||
@@ -10,6 +10,7 @@ class PRList extends HTMLElement {
|
||||
#controller = null;
|
||||
#onSelect = null;
|
||||
#path = '';
|
||||
#repo = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -18,7 +19,7 @@ class PRList extends HTMLElement {
|
||||
|
||||
connectedCallback() {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
const body = this.shadowRoot.getElementById('body');
|
||||
if (prs.length === 0) {
|
||||
@@ -102,8 +122,13 @@ class PRList extends HTMLElement {
|
||||
:host { display: block; }
|
||||
.box { background: var(--surface-1); border: 1px solid var(--border);
|
||||
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;
|
||||
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; }
|
||||
li { border-top: 1px solid var(--border); padding-top: 8px; }
|
||||
li:first-child { border-top: none; padding-top: 0; }
|
||||
@@ -124,9 +149,13 @@ class PRList extends HTMLElement {
|
||||
.error { color: var(--color-danger); }
|
||||
</style>
|
||||
<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>`;
|
||||
this.shadowRoot.getElementById('new').addEventListener('click', () => this.#create());
|
||||
}
|
||||
|
||||
#esc(s) {
|
||||
|
||||
@@ -13,10 +13,14 @@ naming the PR, base, and branch to be deleted.
|
||||
configured or the repo isn't on the forge host.
|
||||
- **Listens:** `repo:select` on `document` — loads PRs for `event.detail.path`.
|
||||
- **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
|
||||
- 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
|
||||
- Requires `GITEA_URL` + `GITEA_TOKEN` on the server; otherwise the component
|
||||
|
||||
@@ -48,11 +48,21 @@ type MergeResult struct {
|
||||
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.
|
||||
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)
|
||||
CreatePullRequest(ctx context.Context, owner, repo string, pr NewPR) (PullRequest, error)
|
||||
MergeAndCleanup(ctx context.Context, owner, repo string, number int64, method MergeMethod) (MergeResult, error)
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,36 @@ func (g *Gitea) ListPullRequests(_ context.Context, owner, repo string) ([]PullR
|
||||
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).
|
||||
|
||||
@@ -81,6 +81,15 @@ type mergePRInput struct {
|
||||
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.
|
||||
type gitCommitInput struct {
|
||||
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
|
||||
})
|
||||
|
||||
// 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.
|
||||
mcpsdk.AddTool(s, &mcpsdk.Tool{
|
||||
Name: "merge_and_cleanup_pr",
|
||||
|
||||
@@ -137,6 +137,22 @@ func (s *Service) ForgePRs(ctx context.Context, repoPath string) ([]forge.PullRe
|
||||
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.
|
||||
// The caller is responsible for confirming with the user first (§1.4); actor
|
||||
// distinguishes a UI action (user) from an MCP one (claude).
|
||||
|
||||
@@ -88,7 +88,9 @@
|
||||
<h2>Pull requests: Merge & clean up</h2>
|
||||
<p>
|
||||
When a repository is hosted on your Gitea server, its open pull requests
|
||||
appear under the details panel. Each has a <strong>Merge & 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 & clean up</strong>
|
||||
button: it squash-merges the pull request and <strong>deletes its
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user