diff --git a/AGENT.md b/AGENT.md index 102f6db..9972d02 100644 --- a/AGENT.md +++ b/AGENT.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 207e373..f3d35a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. `` 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). diff --git a/cmd/server/main.go b/cmd/server/main.go index afdb362..ff3eb9d 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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"` diff --git a/components/pr-list/pr-list.js b/components/pr-list/pr-list.js index 649e3e7..f89471a 100644 --- a/components/pr-list/pr-list.js +++ b/components/pr-list/pr-list.js @@ -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); }
-

Pull requests

+
+

Pull requests

+ +

Select a repository.

`; + this.shadowRoot.getElementById('new').addEventListener('click', () => this.#create()); } #esc(s) { diff --git a/components/pr-list/pr-list.md b/components/pr-list/pr-list.md index bfee90e..660c301 100644 --- a/components/pr-list/pr-list.md +++ b/components/pr-list/pr-list.md @@ -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 diff --git a/internal/forge/forge.go b/internal/forge/forge.go index 18d126b..ac33d4f 100644 --- a/internal/forge/forge.go +++ b/internal/forge/forge.go @@ -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) } diff --git a/internal/forge/gitea.go b/internal/forge/gitea.go index bf65cce..7f1193d 100644 --- a/internal/forge/gitea.go +++ b/internal/forge/gitea.go @@ -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). diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index 828e547..8bfb713 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -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", diff --git a/internal/service/service.go b/internal/service/service.go index 8310e1a..e1dc426 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -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). diff --git a/web/templates/help.html b/web/templates/help.html index 1220e14..3f60225 100644 --- a/web/templates/help.html +++ b/web/templates/help.html @@ -88,7 +88,9 @@

Pull requests: Merge & clean up

When a repository is hosted on your Gitea server, its open pull requests - appear under the details panel. Each has a Merge & clean up + appear under the details panel. Use New pull request… to + open one from the selected repo's current branch (push the branch first). + Each open PR has a Merge & clean up button: it squash-merges the pull request and deletes its branch 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