Pull requests from branches
A pull request can now come from a branch pushed to the repository, the way engineers already work, as well as from a fork made for it, the way agents work. - repos: list branches by reading git's ref advertisement, since the storage binding does not list refs; compare a branch against the point where it left the default branch; land a branch with the same fast-forward and behind check as a fork - work: a pull request has a fork or a branch; one opened from a branch is ready for review at once, a branch has one open pull request at a time, and pushes to the branch move its head - api and mcp: create_pull_request takes branch and body - site: New pull request page with a branch picker; pull request pages show the branch - the work schema is still one migration; production was reset - docs, llms.txt, README and plan updated
| 26 | 26 | - Public and private repositories, and git over HTTPS, including creating a | |
| 27 | 27 | repository by pushing to it. | |
| 28 | 28 | - Issues with labels, acceptance checks and comments. | |
| 29 | − | - Pull requests, each in a copy-on-write fork, with a diff and a recorded | |
| 30 | − | agent session. Several can be made for one issue. | |
| 29 | + | - Pull requests with a diff and a recorded agent session: in a | |
| 30 | + | copy-on-write fork, which is how agents work, or from a branch pushed to | |
| 31 | + | the repository. Several can be made for one issue. | |
| 31 | 32 | - Merging: lands a pull request on `main`, closes its issue naming the pull | |
| 32 | 33 | request that resolved it, and closes the others for that issue as | |
| 33 | 34 | superseded. Refused when the pull request is behind, so no commit is lost. | |
| 37 | 38 | - An event bus: every state change is published, logged and delivered to | |
| 38 | 39 | subscribers. | |
| 39 | 40 | ||
| 40 | − | Not built yet: pull requests from branches, server-side merge commits, | |
| 41 | − | review comments on lines, running acceptance checks, git over SSH. See the build order in the plan. | |
| 41 | + | Not built yet: server-side merge commits, review comments on lines, running acceptance checks, git over SSH. See the build order in the plan. | |
| 42 | 42 | ||
| 43 | 43 | ## Try it | |
| 44 | 44 |
| 10 | 10 | type WorkApi, | |
| 11 | 11 | fail, | |
| 12 | 12 | ok, | |
| 13 | + | pullComparison, | |
| 13 | 14 | } from "@g1t/contracts"; | |
| 14 | 15 | ||
| 15 | 16 | export interface ApiEnv { | |
| 335 | 336 | { | |
| 336 | 337 | name: "create_pull_request", | |
| 337 | 338 | description: | |
| 338 | − | "Start a change. Opens a draft pull request with its own fork of the repository and returns the fork's git remote. Clone it, commit your work there, push, record your session as you go, then call mark_pull_request_ready. Give the issue it is for whenever there is one.", | |
| 339 | + | "Start a change. Opens a draft pull request with its own fork of the repository and returns the fork's git remote. Clone it, commit your work there, push, record your session as you go, then call mark_pull_request_ready. Give the issue it is for whenever there is one. If the change is already on a branch pushed to the repository, give that branch instead: no fork is made and the pull request is ready for review at once.", | |
| 339 | 340 | input: { | |
| 340 | 341 | type: "object", | |
| 341 | 342 | properties: { | |
| 345 | 346 | type: "string", | |
| 346 | 347 | description: "Defaults to the issue's title. Required when there is no issue.", | |
| 347 | 348 | }, | |
| 349 | + | branch: { | |
| 350 | + | type: "string", | |
| 351 | + | description: | |
| 352 | + | "A branch already pushed to the repository that holds the change. Leave out to get a fork.", | |
| 353 | + | }, | |
| 354 | + | body: { | |
| 355 | + | type: "string", | |
| 356 | + | description: "Markdown: what changed and why. Mainly for pull requests from a branch.", | |
| 357 | + | }, | |
| 348 | 358 | agent: { | |
| 349 | 359 | type: "string", | |
| 350 | 360 | description: 'A label for the agent doing the work, e.g. "claude-code".', | |
| 356 | 366 | const opened = await env.WORK.openPull(user, path, { | |
| 357 | 367 | issue: input.issue == null ? undefined : Number(input.issue), | |
| 358 | 368 | title: text(input, "title"), | |
| 369 | + | body: text(input, "body"), | |
| 370 | + | branch: text(input, "branch") || undefined, | |
| 359 | 371 | agent: text(input, "agent") || "agent", | |
| 360 | 372 | runtime: "external", | |
| 361 | 373 | }); | |
| 363 | 375 | const { fork } = opened.value; | |
| 364 | 376 | return ok({ | |
| 365 | 377 | pull: opened.value, | |
| 378 | + | // Where to push. A pull request from a branch has no fork: push to | |
| 379 | + | // that branch of the repository. | |
| 366 | 380 | git: { | |
| 367 | − | remote: `https://g1t.sh/${fork.namespace}/${fork.name}.git`, | |
| 381 | + | remote: fork | |
| 382 | + | ? `https://g1t.sh/${fork.namespace}/${fork.name}.git` | |
| 383 | + | : `https://g1t.sh/${path.namespace}/${path.name}.git`, | |
| 368 | 384 | username: user.username, | |
| 369 | 385 | password: "your g1t access token", | |
| 370 | 386 | }, | |
| 453 | 469 | run: onRepo(async (env, path, viewer, input) => { | |
| 454 | 470 | const found = await env.WORK.getPull(path, Number(input.number), viewer); | |
| 455 | 471 | if (!found.ok) return found; | |
| 456 | − | const { forkRepoId, mergeBase } = found.value.pull; | |
| 457 | − | return env.REPOS.compare(forkRepoId, viewer, mergeBase); | |
| 472 | + | const { repoId, base, head } = pullComparison(found.value.pull); | |
| 473 | + | return env.REPOS.compare(repoId, viewer, base, head); | |
| 458 | 474 | }), | |
| 459 | 475 | }, | |
| 460 | 476 | { |
| 3 | 3 | description: Why a pull request on g1t gets its own fork, when a branch is the better choice, and what each costs. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | − | On most forges, a pull request comes from a branch of the repository. On | |
| 7 | − | g1t, a pull request comes from a **fork**: a separate repository that starts | |
| 8 | − | as a copy of yours. This page explains why, and what it costs. | |
| 6 | + | On most forges, a pull request comes from a branch of the repository. g1t | |
| 7 | + | has those too. But an agent's pull request comes from a **fork**: a separate | |
| 8 | + | repository that starts as a copy of yours. This page explains why, what it | |
| 9 | + | costs, and when to use which. | |
| 9 | 10 | ||
| 10 | 11 | ## The short version | |
| 11 | 12 | ||
| 80 | 81 | already has write access. Nothing about isolation or scale is at stake, and | |
| 81 | 82 | a branch is the tool you already know. | |
| 82 | 83 | ||
| 83 | − | You can push branches to a g1t repository today. Opening a pull request | |
| 84 | − | *from* one is being built; until then, every pull request has a fork. | |
| 84 | + | Push the branch, open **Pull requests**, choose **New pull request** and | |
| 85 | + | pick it. Or from the API, send `branch` when creating the pull request: | |
| 86 | + | ||
| 87 | + | ```sh | |
| 88 | + | git push origin my-change | |
| 89 | + | curl -X POST https://api.g1t.sh/v1/repos/<workspace>/<repo>/pulls \ | |
| 90 | + | -H "Authorization: Bearer $G1T_TOKEN" -H "Content-Type: application/json" \ | |
| 91 | + | -d '{"branch": "my-change", "title": "My change", "issue": 12}' | |
| 92 | + | ``` | |
| 93 | + | ||
| 94 | + | A branch can have one open pull request at a time. Pushing to the branch | |
| 95 | + | updates it. | |
| 85 | 96 | ||
| 86 | 97 | ## How this adds up | |
| 87 | 98 |
| 11 | 11 | | | What it is | | |
| 12 | 12 | | --- | --- | | |
| 13 | 13 | | **Issue** | What should change: a bug, a feature, a question. | | |
| 14 | − | | **Pull request** | A proposed change, in its own fork. Usually made for an issue. | | |
| 14 | + | | **Pull request** | A proposed change, in its own fork or on a branch. Usually made for an issue. | | |
| 15 | 15 | | **Session** | The record of how a pull request was made: prompts, reasoning, tool calls. | | |
| 16 | 16 | ||
| 17 | 17 | The part that is different from other forges: one issue routinely has | |
| 49 | 49 | ||
| 50 | 50 | ## Pull requests | |
| 51 | 51 | ||
| 52 | − | A pull request is a proposed change. Opening one creates a **fork**: a | |
| 52 | + | A pull request is a proposed change. There are two ways to make one. | |
| 53 | + | ||
| 54 | + | **In a fork.** This is how agents work. Opening the pull request creates a | |
| 53 | 55 | copy-on-write copy of the repository that belongs to that pull request | |
| 54 | 56 | alone. Its author clones the fork, commits and pushes to it. Nothing they do | |
| 55 | − | can touch `main` or another pull request. [Forks and branches](/concepts/forks/) | |
| 56 | − | explains why. | |
| 57 | + | can touch `main` or another pull request. The fork lives at | |
| 58 | + | `g1t.sh/pulls/<pull request id>.git` and is exactly as visible as the | |
| 59 | + | repository it came from. The pull request starts as a draft. | |
| 60 | + | ||
| 61 | + | **From a branch.** This is the way you already know. Push a branch to the | |
| 62 | + | repository, then open a pull request from it on the **Pull requests** tab. | |
| 63 | + | It needs write access to the repository, and it is ready for review as soon | |
| 64 | + | as it is opened. | |
| 57 | 65 | ||
| 58 | − | A pull request's fork lives at `g1t.sh/pulls/<pull request id>.git`. It is | |
| 59 | − | exactly as visible as the repository it came from. | |
| 66 | + | [Forks and branches](/concepts/forks/) explains when each is the better | |
| 67 | + | choice. | |
| 60 | 68 | ||
| 61 | 69 | | Status | Meaning | | |
| 62 | 70 | | --- | --- | | |
| 63 | − | | `draft` | Still being worked on. Every pull request starts here. | | |
| 71 | + | | `draft` | Still being worked on. A pull request with a fork starts here. | | |
| 64 | 72 | | `open` | Ready for review, with a description of what changed and why. | | |
| 65 | 73 | | `merged` | Landed on `main`. | | |
| 66 | 74 | | `closed` | Closed without merging. | | |
| 96 | 104 | ||
| 97 | 105 | A pull request can only merge if it contains everything already on `main`. | |
| 98 | 106 | If something else landed first, merging is refused and the pull request is | |
| 99 | − | **behind**. Its author pulls `main` into the fork, resolves any conflict, | |
| 100 | − | pushes, and merges again. `main` never loses a commit this way, however many | |
| 107 | + | **behind**. Its author pulls `main` into the fork or the branch, resolves any | |
| 108 | + | conflict, pushes, and merges again. `main` never loses a commit this way, however many | |
| 101 | 109 | pull requests are in flight. | |
| 102 | 110 | ||
| 103 | 111 | ## Sessions | |
| 126 | 134 | - **Merging in g1t.** Merging moves `main` forward to the pull request's | |
| 127 | 135 | head. When `main` has moved, the pull request has to pull it in first; g1t | |
| 128 | 136 | does not create merge commits or rebase for you yet. | |
| 129 | − | - **Pull requests from branches.** Opening a pull request from a branch you | |
| 130 | − | pushed to the repository itself. Today every pull request has a fork. | |
| 131 | 137 | - **Review comments on lines.** Comments are on the pull request as a whole. | |
| 132 | 138 | - **Checks.** Running an issue's acceptance checks automatically. | |
| 133 | 139 | - **Assignees and milestones.** |
| 69 | 69 | | `add_comment` | Comment on an issue or a pull request. | | |
| 70 | 70 | | `list_pull_requests` | Pull requests on a repository, open or closed. | | |
| 71 | 71 | | `get_pull_request` | A pull request's status, head commit, comments and issue. | | |
| 72 | − | | `create_pull_request` | Open a draft pull request; creates a fork. | | |
| 72 | + | | `create_pull_request` | Open a draft pull request with a fork, or one from a branch already pushed. | | |
| 73 | 73 | | `record_session` | Append prompts, messages and tool calls to the session. | | |
| 74 | 74 | | `read_session` | Read a pull request's recorded session. | | |
| 75 | 75 | | `mark_pull_request_ready` | Mark a draft ready for review, with a summary. | |
| 45 | 45 | else it looks | |
| 46 | 46 | exactly like a repository that does not exist, both on the site and to git. | |
| 47 | 47 | ||
| 48 | + | ## Branches | |
| 49 | + | ||
| 50 | + | Push any branch to a repository you can write to, and open a | |
| 51 | + | [pull request](/concepts/overview/#pull-requests) from it on the | |
| 52 | + | repository's **Pull requests** tab. | |
| 53 | + | ||
| 54 | + | ```sh | |
| 55 | + | git switch -c my-change | |
| 56 | + | git push origin my-change | |
| 57 | + | ``` | |
| 58 | + | ||
| 48 | 59 | ## Pull request forks | |
| 49 | 60 | ||
| 50 | − | Each [pull request](/concepts/overview/#pull-requests) has its own remote: | |
| 61 | + | A pull request that was not opened from a branch has its own remote: | |
| 51 | 62 | ||
| 52 | 63 | ```text | |
| 53 | 64 | https://g1t.sh/pulls/<pull request id>.git |
| 120 | 120 | | Method | Path | | | |
| 121 | 121 | | --- | --- | --- | | |
| 122 | 122 | | `GET` | `/v1/repos/{owner}/{name}/pulls?state=` | Pull requests, newest first. | | |
| 123 | − | | `POST` | `/v1/repos/{owner}/{name}/pulls` | Open a draft. Body: `issue`, `title`, `agent`. | | |
| 123 | + | | `POST` | `/v1/repos/{owner}/{name}/pulls` | Open one. Body: `issue`, `title`, `agent`, and for a branch `branch`, `body`. | | |
| 124 | 124 | | `GET` | `/v1/repos/{owner}/{name}/pulls/{number}` | A pull request, its comments and its issue. | | |
| 125 | 125 | | `GET` | `/v1/repos/{owner}/{name}/pulls/{number}/changes` | The files it changes, with diffs. | | |
| 126 | 126 | | `POST` | `/v1/repos/{owner}/{name}/pulls/{number}/ready` | Mark ready for review. Body: `summary`. | | |
| 139 | 139 | `title` defaults to the issue's title, and is required when there is no | |
| 140 | 140 | `issue`. | |
| 141 | 141 | ||
| 142 | + | Send `branch` to open the pull request from a branch already pushed to the | |
| 143 | + | repository. No fork is made, `git.remote` is the repository itself, and the | |
| 144 | + | pull request is `open` at once: | |
| 145 | + | ||
| 146 | + | ```json | |
| 147 | + | { | |
| 148 | + | "pull": { "number": 15, "status": "open", "branch": "my-change", "fork": null }, | |
| 149 | + | "git": { "remote": "https://g1t.sh/syntaqx/hello.git" } | |
| 150 | + | } | |
| 151 | + | ``` | |
| 152 | + | ||
| 142 | 153 | Merging a pull request made for an issue closes the issue and records the | |
| 143 | 154 | pull request in the issue's `resolvedBy`. Other pull requests for that issue | |
| 144 | 155 | that are still a draft or open are closed with `supersededBy` set. Send |
| 25 | 25 | route("issues/new", "routes/repo/issue-new.tsx"), | |
| 26 | 26 | route("issues/:number", "routes/repo/issue.tsx"), | |
| 27 | 27 | route("pulls", "routes/repo/pulls.tsx"), | |
| 28 | + | route("pulls/new", "routes/repo/pull-new.tsx"), | |
| 28 | 29 | route("pull/:number", "routes/repo/pull.tsx"), | |
| 29 | 30 | ]), | |
| 30 | 31 | ] satisfies RouteConfig; |
| 341 | 341 | <div className="*:w-full"> | |
| 342 | 342 | <Button type="submit">Open pull request</Button> | |
| 343 | 343 | </div> | |
| 344 | + | <p className="text-xs text-muted"> | |
| 345 | + | Already pushed a branch?{" "} | |
| 346 | + | <Link | |
| 347 | + | to={`${base}/pulls/new?issue=${issue.number}`} | |
| 348 | + | className="text-fg underline underline-offset-4" | |
| 349 | + | > | |
| 350 | + | Open a pull request from it | |
| 351 | + | </Link> | |
| 352 | + | . | |
| 353 | + | </p> | |
| 344 | 354 | </Form> | |
| 345 | 355 | ) : ( | |
| 346 | 356 | <p className="mt-4 border-t border-line pt-4 text-sm text-muted"> |
| 1 | + | import { GitBranch } from "lucide-react"; | |
| 2 | + | import { Form, redirect } from "react-router"; | |
| 3 | + | ||
| 4 | + | import type { Route } from "./+types/pull-new"; | |
| 5 | + | import { | |
| 6 | + | Button, | |
| 7 | + | CopyLine, | |
| 8 | + | EmptyState, | |
| 9 | + | ErrorText, | |
| 10 | + | Field, | |
| 11 | + | Input, | |
| 12 | + | Textarea, | |
| 13 | + | } from "../../components/ui"; | |
| 14 | + | import { repos, work } from "../../lib/services.server"; | |
| 15 | + | import { assertSameOrigin, requireUser, unwrap } from "../../lib/session.server"; | |
| 16 | + | ||
| 17 | + | export function meta({ params }: Route.MetaArgs) { | |
| 18 | + | return [{ title: `New pull request · ${params.owner}/${params.repo} · g1t` }]; | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | export async function loader({ request, params, context }: Route.LoaderArgs) { | |
| 22 | + | const user = requireUser(context, request); | |
| 23 | + | const path = { namespace: params.owner, name: params.repo }; | |
| 24 | + | const [repo, branches, open] = await Promise.all([ | |
| 25 | + | repos.get(path, user), | |
| 26 | + | repos.branches(path, user), | |
| 27 | + | work.listPulls(path, user, "open"), | |
| 28 | + | ]); | |
| 29 | + | const { defaultBranch } = unwrap(repo); | |
| 30 | + | const all = unwrap(branches); | |
| 31 | + | const mainHead = all.find((branch) => branch.name === defaultBranch)?.hash; | |
| 32 | + | // A branch that already has an open pull request cannot have another. | |
| 33 | + | const taken = new Set(open.ok ? open.value.map((pull) => pull.branch) : []); | |
| 34 | + | const query = new URL(request.url).searchParams; | |
| 35 | + | return { | |
| 36 | + | defaultBranch, | |
| 37 | + | branches: all | |
| 38 | + | // A branch at the same commit as the default branch has nothing to merge. | |
| 39 | + | .filter((branch) => branch.name !== defaultBranch && branch.hash !== mainHead) | |
| 40 | + | .map((branch) => branch.name) | |
| 41 | + | .filter((name) => !taken.has(name)), | |
| 42 | + | selected: query.get("branch") ?? "", | |
| 43 | + | issue: query.get("issue") ?? "", | |
| 44 | + | }; | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 48 | + | assertSameOrigin(request); | |
| 49 | + | const user = requireUser(context, request); | |
| 50 | + | const form = await request.formData(); | |
| 51 | + | const issue = Number(form.get("issue")); | |
| 52 | + | const result = await work.openPull( | |
| 53 | + | user, | |
| 54 | + | { namespace: params.owner, name: params.repo }, | |
| 55 | + | { | |
| 56 | + | branch: String(form.get("branch") ?? ""), | |
| 57 | + | title: String(form.get("title") ?? ""), | |
| 58 | + | body: String(form.get("body") ?? ""), | |
| 59 | + | issue: issue > 0 ? issue : undefined, | |
| 60 | + | agent: user.username, | |
| 61 | + | runtime: "external", | |
| 62 | + | }, | |
| 63 | + | ); | |
| 64 | + | if (!result.ok) return { error: result.error.message }; | |
| 65 | + | throw redirect(`/${params.owner}/${params.repo}/pull/${result.value.number}`); | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | export default function NewPull({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 69 | + | const { defaultBranch, branches, selected, issue } = loaderData; | |
| 70 | + | const remote = `https://g1t.sh/${params.owner}/${params.repo}.git`; | |
| 71 | + | ||
| 72 | + | if (branches.length === 0) { | |
| 73 | + | return ( | |
| 74 | + | <div className="max-w-2xl"> | |
| 75 | + | <EmptyState title="No branch to open a pull request from"> | |
| 76 | + | Push a branch to this repository, then come back. | |
| 77 | + | </EmptyState> | |
| 78 | + | <div className="mt-4 space-y-2"> | |
| 79 | + | <CopyLine prompt text="git switch -c my-change" /> | |
| 80 | + | <CopyLine prompt text={`git push ${remote} my-change`} /> | |
| 81 | + | </div> | |
| 82 | + | <p className="mt-4 text-sm text-muted"> | |
| 83 | + | Agents do not need a branch. Open an issue and assign agents to it, | |
| 84 | + | and each gets a pull request with its own fork. | |
| 85 | + | </p> | |
| 86 | + | </div> | |
| 87 | + | ); | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | return ( | |
| 91 | + | <Form method="post" className="max-w-2xl space-y-4"> | |
| 92 | + | <div className="flex flex-wrap items-center gap-2 rounded-xl border border-line bg-surface px-4 py-3 text-sm"> | |
| 93 | + | <GitBranch size={15} className="text-faint" /> | |
| 94 | + | <span className="text-muted">Merge</span> | |
| 95 | + | <select | |
| 96 | + | name="branch" | |
| 97 | + | defaultValue={branches.includes(selected) ? selected : branches[0]} | |
| 98 | + | aria-label="Branch to merge" | |
| 99 | + | className="rounded-md border border-line bg-bg px-2 py-1 font-mono text-sm" | |
| 100 | + | > | |
| 101 | + | {branches.map((name) => ( | |
| 102 | + | <option key={name} value={name}> | |
| 103 | + | {name} | |
| 104 | + | </option> | |
| 105 | + | ))} | |
| 106 | + | </select> | |
| 107 | + | <span className="text-muted">into</span> | |
| 108 | + | <span className="font-mono">{defaultBranch}</span> | |
| 109 | + | </div> | |
| 110 | + | <Field label="Title"> | |
| 111 | + | <Input name="title" required autoFocus maxLength={200} /> | |
| 112 | + | </Field> | |
| 113 | + | <Field label="Description" hint="What changed and why. Markdown works."> | |
| 114 | + | <Textarea name="body" rows={8} /> | |
| 115 | + | </Field> | |
| 116 | + | <Field | |
| 117 | + | label="Issue (optional)" | |
| 118 | + | hint="The number of the issue this resolves. Merging the pull request closes it." | |
| 119 | + | > | |
| 120 | + | <Input name="issue" type="number" min={1} defaultValue={issue} placeholder="12" /> | |
| 121 | + | </Field> | |
| 122 | + | <ErrorText>{actionData?.error}</ErrorText> | |
| 123 | + | <Button type="submit">Open pull request</Button> | |
| 124 | + | </Form> | |
| 125 | + | ); | |
| 126 | + | } |
| 2 | 2 | Bot, | |
| 3 | 3 | ChevronRight, | |
| 4 | 4 | FileDiff, | |
| 5 | + | GitBranch, | |
| 5 | 6 | GitCommitHorizontal, | |
| 6 | 7 | GitMerge, | |
| 7 | 8 | MessageSquare, | |
| 13 | 14 | import { useEffect } from "react"; | |
| 14 | 15 | import { Form, Link, redirect, useRevalidator } from "react-router"; | |
| 15 | 16 | ||
| 16 | − | import type { Comparison, SessionEntry } from "@g1t/contracts"; | |
| 17 | + | import { type Comparison, type SessionEntry, pullComparison } from "@g1t/contracts"; | |
| 17 | 18 | ||
| 18 | 19 | import type { Route } from "./+types/pull"; | |
| 19 | 20 | import { DiffView } from "../../components/diff-view"; | |
| 61 | 62 | throw new Response("Pull request not found.", { status: 404 }); | |
| 62 | 63 | } | |
| 63 | 64 | const { pull } = found.value; | |
| 65 | + | const range = pullComparison(pull); | |
| 64 | 66 | const [session, comparison] = await Promise.all([ | |
| 65 | 67 | tab === "session" ? work.readSession(path, number, viewer) : null, | |
| 66 | − | tab === "changes" ? repos.compare(pull.forkRepoId, viewer, pull.mergeBase) : null, | |
| 68 | + | tab === "changes" | |
| 69 | + | ? repos.compare(range.repoId, viewer, range.base, range.head) | |
| 70 | + | : null, | |
| 67 | 71 | ]); | |
| 68 | 72 | const member = (viewer?.workspaces ?? []).some( | |
| 69 | 73 | (membership) => membership.slug === params.owner, | |
| 215 | 219 | } = loaderData; | |
| 216 | 220 | const base = `/${params.owner}/${params.repo}`; | |
| 217 | 221 | const here = `${base}/pull/${pull.number}`; | |
| 218 | − | const remote = `https://g1t.sh/${pull.fork.namespace}/${pull.fork.name}.git`; | |
| 222 | + | const remote = pull.fork | |
| 223 | + | ? `https://g1t.sh/${pull.fork.namespace}/${pull.fork.name}.git` | |
| 224 | + | : `https://g1t.sh/${params.owner}/${params.repo}.git`; | |
| 219 | 225 | const active = pull.status === "draft" || pull.status === "open"; | |
| 220 | 226 | ||
| 221 | 227 | // Follow an agent at work without a manual reload. | |
| 241 | 247 | <Avatar name={pull.author.username} size={18} /> | |
| 242 | 248 | <span> | |
| 243 | 249 | <span className="font-medium text-fg">{pull.author.username}</span>{" "} | |
| 244 | − | {pull.status === "merged" ? "merged" : "wants to merge"} into{" "} | |
| 245 | − | <span className="font-mono text-fg">{defaultBranch}</span> | |
| 250 | + | {pull.status === "merged" ? "merged" : "wants to merge"} | |
| 251 | + | {pull.branch && ( | |
| 252 | + | <> | |
| 253 | + | {" "} | |
| 254 | + | <span className="font-mono text-fg">{pull.branch}</span> | |
| 255 | + | </> | |
| 256 | + | )}{" "} | |
| 257 | + | into <span className="font-mono text-fg">{defaultBranch}</span> | |
| 246 | 258 | </span> | |
| 247 | 259 | </span> | |
| 248 | − | <span className="flex items-center gap-1.5 font-mono text-xs"> | |
| 249 | − | <Bot size={14} /> | |
| 250 | − | {pull.agent} | |
| 251 | − | {pull.runtime === "hosted" && <span className="text-faint">on g1t</span>} | |
| 252 | − | </span> | |
| 260 | + | {/* A pull request from a branch was made by its author, not an agent. */} | |
| 261 | + | {!pull.branch && ( | |
| 262 | + | <span className="flex items-center gap-1.5 font-mono text-xs"> | |
| 263 | + | <Bot size={14} /> | |
| 264 | + | {pull.agent} | |
| 265 | + | {pull.runtime === "hosted" && <span className="text-faint">on g1t</span>} | |
| 266 | + | </span> | |
| 267 | + | )} | |
| 253 | 268 | </div> | |
| 254 | 269 | ||
| 255 | 270 | {issue && ( | |
| 407 | 422 | ||
| 408 | 423 | <section> | |
| 409 | 424 | <h3 className="text-sm font-medium">Working copy</h3> | |
| 410 | − | <p className="mt-1 text-xs text-muted"> | |
| 411 | − | This fork belongs to the pull request. Pushes to it show up here. | |
| 412 | − | </p> | |
| 413 | − | <div className="mt-2"> | |
| 414 | − | <CopyLine text={`git clone ${remote}`} /> | |
| 415 | − | </div> | |
| 425 | + | {pull.branch ? ( | |
| 426 | + | <> | |
| 427 | + | <p className="mt-1 flex items-center gap-1.5 text-xs text-muted"> | |
| 428 | + | <GitBranch size={13} /> | |
| 429 | + | Branch <span className="font-mono text-fg">{pull.branch}</span> of this | |
| 430 | + | repository. Pushes to it show up here. | |
| 431 | + | </p> | |
| 432 | + | <div className="mt-2"> | |
| 433 | + | <CopyLine text={`git clone -b ${pull.branch} ${remote}`} /> | |
| 434 | + | </div> | |
| 435 | + | </> | |
| 436 | + | ) : ( | |
| 437 | + | <> | |
| 438 | + | <p className="mt-1 text-xs text-muted"> | |
| 439 | + | This fork belongs to the pull request. Pushes to it show up here. | |
| 440 | + | </p> | |
| 441 | + | <div className="mt-2"> | |
| 442 | + | <CopyLine text={`git clone ${remote}`} /> | |
| 443 | + | </div> | |
| 444 | + | </> | |
| 445 | + | )} | |
| 416 | 446 | <p className="mt-3 flex items-center gap-1.5 font-mono text-xs text-faint"> | |
| 417 | 447 | <GitCommitHorizontal size={13} /> | |
| 418 | 448 | {pull.headCommit?.slice(0, 12) ?? "no commits pushed yet"} |
| 1 | − | import { Bot } from "lucide-react"; | |
| 1 | + | import { Bot, GitBranch, Plus } from "lucide-react"; | |
| 2 | 2 | import { Link } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/pulls"; | |
| 5 | − | import { EmptyState, TimeAgo } from "../../components/ui"; | |
| 5 | + | import { ButtonLink, EmptyState, TimeAgo } from "../../components/ui"; | |
| 6 | 6 | import { PullIcon, StateTabs } from "../../components/work"; | |
| 7 | 7 | import { work } from "../../lib/services.server"; | |
| 8 | 8 | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 26 | 26 | const base = `/${params.owner}/${params.repo}`; | |
| 27 | 27 | return ( | |
| 28 | 28 | <div> | |
| 29 | − | <StateTabs to={`${base}/pulls`} state={state} /> | |
| 29 | + | <StateTabs | |
| 30 | + | to={`${base}/pulls`} | |
| 31 | + | state={state} | |
| 32 | + | action={ | |
| 33 | + | <ButtonLink to={`${base}/pulls/new`}> | |
| 34 | + | <Plus size={15} /> | |
| 35 | + | New pull request | |
| 36 | + | </ButtonLink> | |
| 37 | + | } | |
| 38 | + | /> | |
| 30 | 39 | <div className="mt-4"> | |
| 31 | 40 | {pulls.length === 0 ? ( | |
| 32 | 41 | <EmptyState | |
| 33 | 42 | title={state === "open" ? "No open pull requests" : "No closed pull requests"} | |
| 34 | 43 | > | |
| 35 | − | A pull request is a change proposed from its own fork. Open one | |
| 36 | − | from an issue, or have an agent do it. | |
| 44 | + | A pull request proposes a change. Assign agents to an issue and | |
| 45 | + | each opens one in its own fork, or push a branch and open one | |
| 46 | + | yourself. | |
| 37 | 47 | </EmptyState> | |
| 38 | 48 | ) : ( | |
| 39 | 49 | <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line"> | |
| 63 | 73 | </span> | |
| 64 | 74 | </span> | |
| 65 | 75 | <span className="mt-0.5 flex shrink-0 items-center gap-1 font-mono text-xs text-muted"> | |
| 66 | − | <Bot size={13} /> | |
| 67 | − | {pull.agent} | |
| 76 | + | {pull.branch ? <GitBranch size={13} /> : <Bot size={13} />} | |
| 77 | + | {pull.branch ?? pull.agent} | |
| 68 | 78 | </span> | |
| 69 | 79 | </Link> | |
| 70 | 80 | </li> |
| 101 | 101 | `agent` (a label such as `claude-code`). Without an issue, send `title`. | |
| 102 | 102 | The response has `pull.number` and `git.remote`, the pull request's own | |
| 103 | 103 | fork. Clone it, commit, and push to it with the token. It starts as a | |
| 104 | − | draft. | |
| 104 | + | draft. If the change is already on a branch pushed to the repository, | |
| 105 | + | send `branch` (and `title`, `body`) instead: no fork is made and the pull | |
| 106 | + | request is ready at once. | |
| 105 | 107 | - **Record the session** as you work, so people can see why a change was | |
| 106 | 108 | made: `POST {repo}/pulls/{number}/session` with | |
| 107 | 109 | `{"entries": [{"kind": "message", "text": "…"}]}`. Kinds are `prompt`, | |
| 145 | 147 | - OAuth 2.1 for applications: metadata at | |
| 146 | 148 | `https://api.g1t.sh/.well-known/oauth-authorization-server`; authorization | |
| 147 | 149 | code with PKCE (S256), public clients, dynamic registration. | |
| 148 | − | - Not available yet: pull requests from branches, merge commits made on | |
| 149 | − | the server, running checks automatically. | |
| 150 | + | - Not available yet: merge commits made on the server, running checks | |
| 151 | + | automatically. | |
| 150 | 152 | ||
| 151 | 153 | ## More | |
| 152 | 154 |
| 218 | 218 | pub service: GitService, | |
| 219 | 219 | } | |
| 220 | 220 | ||
| 221 | − | /// `land`: moves the default branch of the repo a fork came from to the | |
| 222 | − | /// fork's head. Refused with `conflict` when the fork is behind, since | |
| 223 | − | /// that would discard commits. Returns `Outcome<Landed>`. | |
| 221 | + | /// `land`: moves a repository's default branch to the head of a pull | |
| 222 | + | /// request's source. Refused with `conflict` when the source is behind, | |
| 223 | + | /// since that would discard commits. Returns `Outcome<Landed>`. | |
| 224 | 224 | #[derive(Debug, Serialize, Deserialize)] | |
| 225 | 225 | #[serde(rename_all = "camelCase")] | |
| 226 | 226 | pub struct LandArgs { | |
| 227 | − | pub fork_id: String, | |
| 227 | + | /// The repository holding the commits: a pull request's fork, or the | |
| 228 | + | /// target itself when landing one of its own branches. | |
| 229 | + | pub source_id: String, | |
| 230 | + | /// The branch of the source to land. Required when the source is the | |
| 231 | + | /// target; a fork lands its default branch. | |
| 232 | + | #[serde(default)] | |
| 233 | + | pub branch: Option<String>, | |
| 228 | 234 | pub actor: User, | |
| 229 | 235 | } | |
| 230 | 236 | ||
| 283 | 289 | pub truncated: bool, | |
| 284 | 290 | } | |
| 285 | 291 | ||
| 286 | − | /// `compare`: what a repository's head changes. | |
| 292 | + | /// `compare`: what `head` changes relative to `base`. | |
| 287 | 293 | /// | |
| 288 | − | /// With no `base`, a pull request's fork is compared against the point where it | |
| 289 | − | /// and the repository it came from last agreed, and any other repository | |
| 290 | − | /// against its head's parent. Returns `Outcome<Comparison>`. | |
| 294 | + | /// `head` is a branch or a commit, and defaults to the default branch. | |
| 295 | + | /// With no `base`, a fork is compared against the point where it and the | |
| 296 | + | /// repository it came from last agreed; a branch against the point where it | |
| 297 | + | /// left the default branch; and the default branch against its head's | |
| 298 | + | /// parent. Returns `Outcome<Comparison>`. | |
| 291 | 299 | #[derive(Debug, Serialize, Deserialize)] | |
| 292 | 300 | #[serde(rename_all = "camelCase")] | |
| 293 | 301 | pub struct CompareArgs { | |
| 295 | 303 | pub viewer: Viewer, | |
| 296 | 304 | #[serde(default)] | |
| 297 | 305 | pub base: Option<String>, | |
| 306 | + | #[serde(default)] | |
| 307 | + | pub head: Option<String>, | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | /// A branch and the commit it points to. | |
| 311 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 312 | + | pub struct Branch { | |
| 313 | + | pub name: String, | |
| 314 | + | pub hash: String, | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | /// `branches`: the repository's branches, default branch first. | |
| 318 | + | /// Returns `Outcome<Vec<Branch>>`. | |
| 319 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 320 | + | pub struct BranchesArgs { | |
| 321 | + | pub path: RepoPath, | |
| 322 | + | pub viewer: Viewer, | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | /// `head`: the commit a branch points to, or null. For services reacting | |
| 326 | + | /// to a push, which have no viewer; it reveals nothing but a commit hash. | |
| 327 | + | /// Returns `Option<String>`. | |
| 328 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 329 | + | #[serde(rename_all = "camelCase")] | |
| 330 | + | pub struct HeadArgs { | |
| 331 | + | pub repo_id: String, | |
| 332 | + | pub branch: String, | |
| 298 | 333 | } |
| 112 | 112 | External, | |
| 113 | 113 | } | |
| 114 | 114 | ||
| 115 | − | /// A proposed change, made in its own fork by an agent or a person. | |
| 115 | + | /// A proposed change. It is made either in a fork created for it, which is | |
| 116 | + | /// how agents work, or on a branch pushed to the repository itself. | |
| 116 | 117 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 117 | 118 | #[serde(rename_all = "camelCase")] | |
| 118 | 119 | pub struct Pull { | |
| 129 | 130 | pub agent: String, | |
| 130 | 131 | pub runtime: Runtime, | |
| 131 | 132 | pub status: PullStatus, | |
| 132 | − | pub fork: RepoPath, | |
| 133 | + | /// The fork holding the change, unless it is on a branch. | |
| 134 | + | pub fork: Option<RepoPath>, | |
| 133 | 135 | /// The fork's repository id. | |
| 134 | − | pub fork_repo_id: String, | |
| 136 | + | pub fork_repo_id: Option<String>, | |
| 137 | + | /// The branch of the repository holding the change, unless it is in a | |
| 138 | + | /// fork. | |
| 139 | + | pub branch: Option<String>, | |
| 135 | 140 | pub head_commit: Option<String>, | |
| 136 | 141 | /// For a merged pull request, what the branch pointed to before the | |
| 137 | 142 | /// merge. Comparing against it shows what the pull request changed. | |
| 304 | 309 | pub body: String, | |
| 305 | 310 | } | |
| 306 | 311 | ||
| 307 | − | /// `open_pull`: forks the repo and returns the draft pull request to push | |
| 308 | − | /// to. Returns `Outcome<Pull>`. | |
| 312 | + | /// `open_pull`. Without `branch`, forks the repo and returns a draft pull | |
| 313 | + | /// request to push to. With it, opens a pull request, ready for review, | |
| 314 | + | /// for a branch already pushed to the repo. Returns `Outcome<Pull>`. | |
| 309 | 315 | #[derive(Debug, Serialize, Deserialize)] | |
| 310 | 316 | pub struct OpenPullArgs { | |
| 311 | 317 | pub actor: User, | |
| 316 | 322 | /// Defaults to the issue's title; required without an issue. | |
| 317 | 323 | #[serde(default)] | |
| 318 | 324 | pub title: String, | |
| 325 | + | /// What changed and why. Usually set later, when a draft is marked ready. | |
| 326 | + | #[serde(default)] | |
| 327 | + | pub body: String, | |
| 328 | + | /// A branch of the repository that already holds the change. | |
| 329 | + | #[serde(default)] | |
| 330 | + | pub branch: Option<String>, | |
| 319 | 331 | #[serde(default)] | |
| 320 | 332 | pub agent: String, | |
| 321 | 333 | pub runtime: Runtime, |
| 63 | 63 | - **Labels need no setup.** A repository starts with `bug`, `feature`, | |
| 64 | 64 | `docs`, `chore` and `question`; any other name becomes a label the first | |
| 65 | 65 | time it is used, so an integration can tag what it files. | |
| 66 | − | - **The developer path is unchanged.** Push, open a pull request, get review, | |
| 67 | − | merge. Pull requests from a branch pushed to the repo itself are next in | |
| 68 | − | the build order; today each one has a fork. | |
| 66 | + | - **The developer path is unchanged.** Push a branch, open a pull request | |
| 67 | + | from it, get review, merge. Agents get a fork per pull request instead. | |
| 69 | 68 | - **Both paths meet at `main`.** The same landing rules apply to a person's | |
| 70 | 69 | pull request and an agent's. | |
| 71 | 70 | ||
| 646 | 645 | over HTTPS; accounts with registration, email verification, password reset | |
| 647 | 646 | and device sign-in; an OAuth 2.1 server, so MCP clients sign in through the | |
| 648 | 647 | browser with no token to paste; workspaces with members; issues with labels, checks and | |
| 649 | − | comments; pull requests in forks with diffs and sessions, several per | |
| 650 | − | issue; merging with a behind check, which resolves the issue and supersedes | |
| 648 | + | comments; pull requests in forks or from branches, with diffs and sessions, | |
| 649 | + | several per issue; merging with a behind check, which resolves the issue and supersedes | |
| 651 | 650 | the rest; g1t agents in sandboxes with a choice of model; REST API, OpenAPI | |
| 652 | 651 | and MCP server; event bus. Identity, repos and work are in Rust. | |
| 653 | 652 | ||
| 654 | − | 1. Pull requests from branches pushed to the repository. | |
| 653 | + | 1. Branch protection, and deleting a branch once its pull request merges. | |
| 655 | 654 | 2. Scopes on OAuth grants and access tokens. | |
| 656 | 655 | 3. Port events and the API to Rust; event storage per the design above. | |
| 657 | 656 | 4. CLI with Claude Code hooks to record sessions automatically. |
| 91 | 91 | call("fork_for_pull", { sourceId, pullId, actor }), | |
| 92 | 92 | gitAccess: (path, viewer, service) => | |
| 93 | 93 | call("git_access", { path, viewer, service }), | |
| 94 | − | land: (forkId, actor) => call("land", { forkId, actor }), | |
| 95 | − | compare: (repoId, viewer, base) => call("compare", { repoId, viewer, base }), | |
| 94 | + | branches: (path, viewer) => call("branches", { path, viewer }), | |
| 95 | + | land: (sourceId, actor, branch) => call("land", { sourceId, actor, branch }), | |
| 96 | + | compare: (repoId, viewer, base, head) => call("compare", { repoId, viewer, base, head }), | |
| 96 | 97 | }; | |
| 97 | 98 | } | |
| 98 | 99 |
| 99 | 99 | */ | |
| 100 | 100 | gitAccess(path: RepoPath, viewer: Viewer, service: GitService): Promise<Result<GitAccess>>; | |
| 101 | 101 | ||
| 102 | + | /** The repository's branches, default branch first. */ | |
| 103 | + | branches(path: RepoPath, viewer: Viewer): Promise<Result<Branch[]>>; | |
| 104 | + | ||
| 102 | 105 | /** | |
| 103 | − | * Moves the default branch of the repo a fork came from to the fork's | |
| 104 | − | * head. Refused with "conflict" when the fork is behind, since that would | |
| 106 | + | * Moves a repository's default branch to the head of a pull request's | |
| 107 | + | * source: a fork (`sourceId` is the fork) or one of the repository's own | |
| 108 | + | * branches (`sourceId` is the repository, and `branch` is required). | |
| 109 | + | * Refused with "conflict" when the source is behind, since that would | |
| 105 | 110 | * discard commits. | |
| 106 | 111 | */ | |
| 107 | − | land(forkId: string, actor: User): Promise<Result<{ commit: string; previous: string | null }>>; | |
| 112 | + | land(sourceId: string, actor: User, branch?: string | null): Promise<Result<{ commit: string; previous: string | null }>>; | |
| 108 | 113 | ||
| 109 | 114 | /** | |
| 110 | − | * What a repository's head changes. A pull request's fork is compared with the | |
| 111 | − | * last commit it shares with the repository it came from, unless `base` | |
| 112 | − | * says otherwise. | |
| 115 | + | * What `head` changes relative to `base`. `head` is a branch or a commit | |
| 116 | + | * and defaults to the default branch. With no `base`, a fork is compared | |
| 117 | + | * with the last commit it shares with the repository it came from, and a | |
| 118 | + | * branch with the point where it left the default branch. | |
| 113 | 119 | */ | |
| 114 | − | compare(repoId: string, viewer: Viewer, base?: string | null): Promise<Result<Comparison>>; | |
| 120 | + | compare(repoId: string, viewer: Viewer, base?: string | null, head?: string | null): Promise<Result<Comparison>>; | |
| 115 | 121 | } | |
| 116 | 122 | ||
| 123 | + | /** A branch and the commit it points to. */ | |
| 124 | + | export type Branch = { name: string; hash: string }; | |
| 125 | + | ||
| 117 | 126 | export type DiffLine = { | |
| 118 | 127 | kind: "context" | "add" | "delete"; | |
| 119 | 128 | /** Line number in the old file; null for added lines. */ |
| 53 | 53 | /** Where the agent runs: on g1t's sandboxes, or in someone's own session. */ | |
| 54 | 54 | export type Runtime = "hosted" | "external"; | |
| 55 | 55 | ||
| 56 | − | /** A proposed change, made in its own fork by an agent or a person. */ | |
| 56 | + | /** | |
| 57 | + | * A proposed change. It is made either in a fork created for it, which is | |
| 58 | + | * how agents work, or on a branch pushed to the repository itself. | |
| 59 | + | */ | |
| 57 | 60 | export type Pull = { | |
| 58 | 61 | id: string; | |
| 59 | 62 | repoId: string; | |
| 68 | 71 | agent: string; | |
| 69 | 72 | runtime: Runtime; | |
| 70 | 73 | status: PullStatus; | |
| 71 | − | fork: RepoPath; | |
| 74 | + | /** The fork holding the change, unless it is on a branch. */ | |
| 75 | + | fork: RepoPath | null; | |
| 72 | 76 | /** The fork's repository id. */ | |
| 73 | − | forkRepoId: string; | |
| 77 | + | forkRepoId: string | null; | |
| 78 | + | /** The branch of the repository holding the change, unless it is in a fork. */ | |
| 79 | + | branch: string | null; | |
| 74 | 80 | headCommit: string | null; | |
| 75 | 81 | /** | |
| 76 | 82 | * For a merged pull request, what the branch pointed to before the merge. | |
| 148 | 154 | issue?: number; | |
| 149 | 155 | /** Defaults to the issue's title; required without an issue. */ | |
| 150 | 156 | title?: string; | |
| 157 | + | /** What changed and why. Usually set later, when a draft is marked ready. */ | |
| 158 | + | body?: string; | |
| 159 | + | /** | |
| 160 | + | * A branch of the repository that already holds the change. The pull | |
| 161 | + | * request is then ready for review at once and has no fork. | |
| 162 | + | */ | |
| 163 | + | branch?: string; | |
| 151 | 164 | agent: string; | |
| 152 | 165 | runtime: Runtime; | |
| 153 | 166 | }; | |
| 174 | 187 | /** On an issue or a pull request. */ | |
| 175 | 188 | addComment(actor: User, repo: RepoPath, number: number, body: string): Promise<Result<Comment>>; | |
| 176 | 189 | ||
| 177 | − | /** Forks the repo and returns the draft pull request to push to. */ | |
| 190 | + | /** | |
| 191 | + | * Opens a pull request: a draft with a fork to push to, or, given a | |
| 192 | + | * branch, one ready for review. | |
| 193 | + | */ | |
| 178 | 194 | openPull(actor: User, repo: RepoPath, input: OpenPullInput): Promise<Result<Pull>>; | |
| 179 | 195 | /** Newest first. */ | |
| 180 | 196 | listPulls(repo: RepoPath, viewer: Viewer, state?: State): Promise<Result<Pull[]>>; | |
| 195 | 211 | appendSession(actor: User, repo: RepoPath, number: number, entries: NewSessionEntry[]): Promise<Result<{ count: number }>>; | |
| 196 | 212 | readSession(repo: RepoPath, number: number, viewer: Viewer, afterSeq?: number): Promise<Result<SessionEntry[]>>; | |
| 197 | 213 | } | |
| 214 | + | ||
| 215 | + | /** | |
| 216 | + | * What to pass `ReposApi.compare` to see what a pull request changes. | |
| 217 | + | * | |
| 218 | + | * A fork is compared as a whole. A branch is compared by name while the | |
| 219 | + | * pull request is open, and by the commit it was merged or closed at | |
| 220 | + | * afterwards, so later pushes to the branch do not change the record. | |
| 221 | + | */ | |
| 222 | + | export function pullComparison(pull: Pull): { | |
| 223 | + | repoId: string; | |
| 224 | + | base: string | null; | |
| 225 | + | head: string | null; | |
| 226 | + | } { | |
| 227 | + | if (pull.forkRepoId) return { repoId: pull.forkRepoId, base: pull.mergeBase, head: null }; | |
| 228 | + | const settled = pull.status === "merged" || pull.status === "closed"; | |
| 229 | + | return { | |
| 230 | + | repoId: pull.repoId, | |
| 231 | + | base: pull.mergeBase, | |
| 232 | + | head: (settled && pull.headCommit) || pull.branch, | |
| 233 | + | }; | |
| 234 | + | } |
| 1 | 1 | //! Landing a pull request: moving a repository's branch forward to a commit | |
| 2 | − | //! that so far exists only in a fork. | |
| 2 | + | //! from a fork, or from another of its own branches. | |
| 3 | 3 | //! | |
| 4 | 4 | //! The Artifacts binding cannot write, so this speaks git's smart HTTP | |
| 5 | 5 | //! protocol directly. It asks the fork for a pack holding exactly the | |
| 43 | 43 | } | |
| 44 | 44 | ||
| 45 | 45 | /// The payloads of the pkt-lines in `bytes`, and the offset where they stop. | |
| 46 | − | fn read_pkt_lines(bytes: &[u8]) -> (Vec<&[u8]>, usize) { | |
| 46 | + | pub(crate) fn read_pkt_lines(bytes: &[u8]) -> (Vec<&[u8]>, usize) { | |
| 47 | 47 | let mut lines = Vec::new(); | |
| 48 | 48 | let mut position = 0; | |
| 49 | 49 | while position + 4 <= bytes.len() { | |
| 86 | 86 | Some(&PACK_BAND) => pack.extend_from_slice(&line[1..]), | |
| 87 | 87 | Some(&ERROR_BAND) => { | |
| 88 | 88 | return Err(Error::RustError(format!( | |
| 89 | − | "the fork refused the fetch: {}", | |
| 89 | + | "the source refused the fetch: {}", | |
| 90 | 90 | String::from_utf8_lossy(&line[1..]) | |
| 91 | 91 | ))); | |
| 92 | 92 | } | |
| 96 | 96 | } | |
| 97 | 97 | if !pack.starts_with(b"PACK") { | |
| 98 | 98 | return Err(Error::RustError(format!( | |
| 99 | − | "the fork did not send a pack: {}", | |
| 99 | + | "the source did not send a pack: {}", | |
| 100 | 100 | String::from_utf8_lossy(&response) | |
| 101 | 101 | ))); | |
| 102 | 102 | } |
| 8 | 8 | mod diff; | |
| 9 | 9 | mod git_http; | |
| 10 | 10 | mod land; | |
| 11 | + | mod refs; | |
| 11 | 12 | mod registry; | |
| 12 | 13 | mod store; | |
| 13 | 14 | ||
| 325 | 326 | Ok(Outcome::Ok(git.log(&git_ref, a.limit).await?)) | |
| 326 | 327 | } | |
| 327 | 328 | ||
| 329 | + | /// The repository's branches, default branch first. | |
| 330 | + | async fn branches(&self, a: BranchesArgs) -> Result<Outcome<Vec<Branch>>> { | |
| 331 | + | let Some(repo) = self.readable(&a.path, &a.viewer).await? else { | |
| 332 | + | return Ok(not_found()); | |
| 333 | + | }; | |
| 334 | + | let mut branches = self.store.open(&store_key(&repo)).await?.branches().await?; | |
| 335 | + | branches.sort_by_key(|branch| branch.name != repo.default_branch); | |
| 336 | + | Ok(Outcome::Ok(branches)) | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | async fn head(&self, a: HeadArgs) -> Result<Option<String>> { | |
| 340 | + | let Some(repo) = self.registry.by_id(&a.repo_id).await? else { | |
| 341 | + | return Ok(None); | |
| 342 | + | }; | |
| 343 | + | let git = self.store.open(&store_key(&repo)).await?; | |
| 344 | + | Ok(git | |
| 345 | + | .log(&a.branch, 1) | |
| 346 | + | .await? | |
| 347 | + | .into_iter() | |
| 348 | + | .next() | |
| 349 | + | .map(|commit| commit.hash)) | |
| 350 | + | } | |
| 351 | + | ||
| 328 | 352 | async fn fork_for_pull(&self, a: ForkArgs) -> Result<Outcome<Repo>> { | |
| 329 | 353 | let viewer = Some(a.actor.clone()); | |
| 330 | 354 | let Some(source) = self | |
| 426 | 450 | ||
| 427 | 451 | async fn land(&self, a: LandArgs) -> Result<Outcome<Landed>> { | |
| 428 | 452 | let actor: Viewer = Some(a.actor.clone()); | |
| 429 | − | let Some(fork) = self.registry.by_id(&a.fork_id).await? else { | |
| 453 | + | let Some(source) = self.registry.by_id(&a.source_id).await? else { | |
| 430 | 454 | return Ok(not_found()); | |
| 431 | 455 | }; | |
| 432 | − | let target = match &fork.fork_of { | |
| 456 | + | // A fork lands on the repository it came from; a branch on its own. | |
| 457 | + | let target = match &source.fork_of { | |
| 433 | 458 | Some(id) => self.registry.by_id(id).await?, | |
| 434 | − | None => None, | |
| 459 | + | None => Some(source.clone()), | |
| 435 | 460 | }; | |
| 436 | 461 | let Some(target) = target.filter(|repo| can_read(repo, &actor)) else { | |
| 437 | 462 | return Ok(not_found()); | |
| 447 | 472 | } | |
| 448 | 473 | ||
| 449 | 474 | let branch = &target.default_branch; | |
| 450 | − | let fork_git = self.store.open(&store_key(&fork)).await?; | |
| 475 | + | let from_fork = source.id != target.id; | |
| 476 | + | let source_branch = match a.branch { | |
| 477 | + | Some(name) if !from_fork && name == *branch => { | |
| 478 | + | return Ok(Outcome::fail( | |
| 479 | + | FailureCode::Invalid, | |
| 480 | + | format!("{branch} cannot be merged into itself."), | |
| 481 | + | )); | |
| 482 | + | } | |
| 483 | + | Some(name) => name, | |
| 484 | + | None if from_fork => branch.clone(), | |
| 485 | + | None => { | |
| 486 | + | return Ok(Outcome::fail( | |
| 487 | + | FailureCode::Invalid, | |
| 488 | + | "Say which branch to merge.", | |
| 489 | + | )); | |
| 490 | + | } | |
| 491 | + | }; | |
| 492 | + | ||
| 493 | + | let source_git = self.store.open(&store_key(&source)).await?; | |
| 451 | 494 | let target_git = self.store.open(&store_key(&target)).await?; | |
| 452 | − | let history = fork_git.log(branch, MAX_ANCESTRY).await?; | |
| 495 | + | let history = source_git.log(&source_branch, MAX_ANCESTRY).await?; | |
| 453 | 496 | let Some(new) = history.first().map(|commit| commit.hash.clone()) else { | |
| 454 | 497 | return Ok(Outcome::fail( | |
| 455 | 498 | FailureCode::Conflict, | |
| 472 | 515 | // Moving the branch to a commit that does not descend from its | |
| 473 | 516 | // current head would discard whatever landed in between. | |
| 474 | 517 | if let Some(old) = &old | |
| 475 | − | && !descends_from(&fork_git, &history, old).await? | |
| 518 | + | && !descends_from(&source_git, &history, old).await? | |
| 476 | 519 | { | |
| 520 | + | let remedy = if from_fork { | |
| 521 | + | format!("Pull {branch} into the pull request's fork, push, and merge again.") | |
| 522 | + | } else { | |
| 523 | + | format!("Merge {branch} into {source_branch}, push, and merge again.") | |
| 524 | + | }; | |
| 477 | 525 | return Ok(Outcome::fail( | |
| 478 | 526 | FailureCode::Conflict, | |
| 479 | − | format!( | |
| 480 | − | "{branch} has moved since this pull request was opened. Pull {branch} into the pull request's fork, push, and merge again." | |
| 481 | − | ), | |
| 527 | + | format!("{branch} has moved since this pull request was opened. {remedy}"), | |
| 482 | 528 | )); | |
| 483 | 529 | } | |
| 484 | 530 | ||
| 485 | − | let source_access = fork_git.access(Scope::Read).await?; | |
| 531 | + | // For a branch the objects are already in the target; sending them | |
| 532 | + | // again is harmless and keeps one way of moving a ref. | |
| 533 | + | let source_access = source_git.access(Scope::Read).await?; | |
| 486 | 534 | let target_access = target_git.access(Scope::Write).await?; | |
| 487 | 535 | let pushed = | |
| 488 | 536 | land::fast_forward(&source_access, &target_access, branch, old.as_deref(), &new) | |
| 511 | 559 | return Ok(not_found()); | |
| 512 | 560 | }; | |
| 513 | 561 | let git = self.store.open(&store_key(&repo)).await?; | |
| 514 | − | let history = git.log(&repo.default_branch, MAX_ANCESTRY).await?; | |
| 562 | + | let head_ref = a.head.as_deref().unwrap_or(&repo.default_branch); | |
| 563 | + | let history = git.log(head_ref, MAX_ANCESTRY).await?; | |
| 515 | 564 | let Some(head) = history.first() else { | |
| 516 | 565 | return Ok(Outcome::fail( | |
| 517 | 566 | FailureCode::Conflict, | |
| 518 | − | "This repository has no commits yet.", | |
| 567 | + | "There are no commits to compare.", | |
| 519 | 568 | )); | |
| 520 | 569 | }; | |
| 521 | 570 | ||
| 571 | + | // Where the head's history meets the default branch of `against`. | |
| 572 | + | let shared_with = async |against: &Repo| -> Result<Option<String>> { | |
| 573 | + | let against_git = self.store.open(&store_key(against)).await?; | |
| 574 | + | let shared: HashSet<String> = against_git | |
| 575 | + | .log(&against.default_branch, MAX_ANCESTRY) | |
| 576 | + | .await? | |
| 577 | + | .into_iter() | |
| 578 | + | .map(|commit| commit.hash) | |
| 579 | + | .collect(); | |
| 580 | + | nearest_ancestor_in(&git, &history, &shared).await | |
| 581 | + | }; | |
| 522 | 582 | let base = match (a.base, &repo.fork_of) { | |
| 523 | 583 | (Some(base), _) => Some(base), | |
| 524 | 584 | // A fork is compared with the last commit it shares with the | |
| 525 | 585 | // repository it came from. | |
| 526 | 586 | (None, Some(target_id)) => match self.registry.by_id(target_id).await? { | |
| 527 | − | Some(target) => { | |
| 528 | − | let target_git = self.store.open(&store_key(&target)).await?; | |
| 529 | − | let shared: HashSet<String> = target_git | |
| 530 | − | .log(&target.default_branch, MAX_ANCESTRY) | |
| 531 | − | .await? | |
| 532 | − | .into_iter() | |
| 533 | − | .map(|commit| commit.hash) | |
| 534 | − | .collect(); | |
| 535 | − | nearest_ancestor_in(&git, &history, &shared).await? | |
| 536 | − | } | |
| 587 | + | Some(target) => shared_with(&target).await?, | |
| 537 | 588 | None => None, | |
| 538 | 589 | }, | |
| 590 | + | // A branch, with the point where it left the default branch. | |
| 591 | + | (None, None) if head_ref != repo.default_branch => shared_with(&repo).await?, | |
| 539 | 592 | (None, None) => head.parents.first().cloned(), | |
| 540 | 593 | }; | |
| 541 | 594 | let base_tree = match &base { | |
| 645 | 698 | "log" => reply(&repos.log(args(body)?).await?), | |
| 646 | 699 | "fork_for_pull" => reply(&repos.fork_for_pull(args(body)?).await?), | |
| 647 | 700 | "git_access" => reply(&repos.git_access(args(body)?).await?), | |
| 701 | + | "branches" => reply(&repos.branches(args(body)?).await?), | |
| 702 | + | "head" => reply(&repos.head(args(body)?).await?), | |
| 648 | 703 | "land" => reply(&repos.land(args(body)?).await?), | |
| 649 | 704 | "compare" => reply(&repos.compare(args(body)?).await?), | |
| 650 | 705 | _ => Response::error("Unknown method", 404), |
| 1 | + | //! Listing a repository's branches. | |
| 2 | + | //! | |
| 3 | + | //! The Artifacts binding reads commits, trees and blobs but does not list | |
| 4 | + | //! refs, so this asks the way a git client does: the ref advertisement of | |
| 5 | + | //! the smart HTTP protocol. | |
| 6 | + | ||
| 7 | + | use g1t_contracts::repos::{Branch, GitAccess}; | |
| 8 | + | use worker::{Error, Fetch, Headers, Method, Request, RequestInit, Result}; | |
| 9 | + | ||
| 10 | + | use crate::land::read_pkt_lines; | |
| 11 | + | ||
| 12 | + | const HEADS: &str = "refs/heads/"; | |
| 13 | + | ||
| 14 | + | /// The branches in a ref advertisement, in the order advertised. | |
| 15 | + | fn parse_advertisement(bytes: &[u8]) -> Vec<Branch> { | |
| 16 | + | let (lines, _) = read_pkt_lines(bytes); | |
| 17 | + | lines | |
| 18 | + | .into_iter() | |
| 19 | + | .filter_map(|line| { | |
| 20 | + | // `<hash> <ref>`, and on the first ref a NUL then capabilities. | |
| 21 | + | let line = line.split(|byte| *byte == 0).next()?; | |
| 22 | + | let line = std::str::from_utf8(line).ok()?.trim_end(); | |
| 23 | + | let (hash, name) = line.split_once(' ')?; | |
| 24 | + | Some(Branch { | |
| 25 | + | name: name.strip_prefix(HEADS)?.to_owned(), | |
| 26 | + | hash: hash.to_owned(), | |
| 27 | + | }) | |
| 28 | + | }) | |
| 29 | + | .collect() | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | pub async fn branches(access: &GitAccess) -> Result<Vec<Branch>> { | |
| 33 | + | let headers = Headers::new(); | |
| 34 | + | headers.set("authorization", &format!("Bearer {}", access.token))?; | |
| 35 | + | let mut init = RequestInit::new(); | |
| 36 | + | init.with_method(Method::Get).with_headers(headers); | |
| 37 | + | let request = Request::new_with_init( | |
| 38 | + | &format!("{}/info/refs?service=git-upload-pack", access.remote), | |
| 39 | + | &init, | |
| 40 | + | )?; | |
| 41 | + | let mut response = Fetch::Request(request).send().await?; | |
| 42 | + | let bytes = response.bytes().await?; | |
| 43 | + | if response.status_code() != 200 { | |
| 44 | + | return Err(Error::RustError(format!( | |
| 45 | + | "listing refs returned {}", | |
| 46 | + | response.status_code() | |
| 47 | + | ))); | |
| 48 | + | } | |
| 49 | + | Ok(parse_advertisement(&bytes)) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | #[cfg(test)] | |
| 53 | + | mod tests { | |
| 54 | + | use super::parse_advertisement; | |
| 55 | + | ||
| 56 | + | fn pkt(payload: &str) -> Vec<u8> { | |
| 57 | + | format!("{:04x}{payload}", payload.len() + 4).into_bytes() | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | #[test] | |
| 61 | + | fn branches_are_read_from_an_advertisement() { | |
| 62 | + | let main = "c71546fcd893ef8b0f57388b65e620d759705dda"; | |
| 63 | + | let shout = "4807077b296e6edbf410d55e72749d3e1170c291"; | |
| 64 | + | let advertisement = [ | |
| 65 | + | pkt("# service=git-upload-pack\n"), | |
| 66 | + | b"0000".to_vec(), | |
| 67 | + | pkt(&format!( | |
| 68 | + | "{main} HEAD\0side-band-64k symref=HEAD:refs/heads/main\n" | |
| 69 | + | )), | |
| 70 | + | pkt(&format!("{main} refs/heads/main\n")), | |
| 71 | + | pkt(&format!("{shout} refs/heads/shout\n")), | |
| 72 | + | pkt(&format!("{shout} refs/tags/v1.0.0\n")), | |
| 73 | + | b"0000".to_vec(), | |
| 74 | + | ] | |
| 75 | + | .concat(); | |
| 76 | + | let branches = parse_advertisement(&advertisement); | |
| 77 | + | let names: Vec<&str> = branches.iter().map(|branch| branch.name.as_str()).collect(); | |
| 78 | + | assert_eq!(names, ["main", "shout"]); | |
| 79 | + | assert_eq!(branches[1].hash, shout); | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | #[test] | |
| 83 | + | fn an_empty_repository_has_no_branches() { | |
| 84 | + | let advertisement = [pkt("# service=git-upload-pack\n"), b"00000000".to_vec()].concat(); | |
| 85 | + | assert!(parse_advertisement(&advertisement).is_empty()); | |
| 86 | + | } | |
| 87 | + | } |
| 3 | 3 | //! The service depends on the [`GitStore`] and [`GitRepo`] ports; | |
| 4 | 4 | //! [`ArtifactsStore`] is the adapter for Cloudflare Artifacts. | |
| 5 | 5 | ||
| 6 | − | use g1t_contracts::repos::{Commit, EntryKind, GitAccess, Signature, TreeEntry}; | |
| 6 | + | use g1t_contracts::repos::{Branch, Commit, EntryKind, GitAccess, Signature, TreeEntry}; | |
| 7 | 7 | use g1t_contracts::time::rfc3339; | |
| 8 | 8 | use g1t_kit::js; | |
| 9 | 9 | use serde::Deserialize; | |
| 40 | 40 | pub trait GitRepo { | |
| 41 | 41 | /// A remote URL and short-lived credential for git itself. | |
| 42 | 42 | async fn access(&self, scope: Scope) -> Result<GitAccess>; | |
| 43 | + | /// Every branch and the commit it points to. | |
| 44 | + | async fn branches(&self) -> Result<Vec<Branch>>; | |
| 43 | 45 | /// Newest first along the first-parent chain; empty for an unknown ref. | |
| 44 | 46 | async fn log(&self, git_ref: &str, limit: u32) -> Result<Vec<Commit>>; | |
| 45 | 47 | /// The parents of a commit, or `None` if the commit does not exist. | |
| 169 | 171 | }) | |
| 170 | 172 | } | |
| 171 | 173 | ||
| 174 | + | async fn branches(&self) -> Result<Vec<Branch>> { | |
| 175 | + | crate::refs::branches(&self.access(Scope::Read).await?).await | |
| 176 | + | } | |
| 177 | + | ||
| 172 | 178 | async fn log(&self, git_ref: &str, limit: u32) -> Result<Vec<Commit>> { | |
| 173 | 179 | let options = js::to_js(&serde_json::json!({ "ref": git_ref, "limit": limit }))?; | |
| 174 | 180 | let commits: Vec<RawCommit> = |
| 182 | 182 | // The first failure is the answer; later ones mean some already run. | |
| 183 | 183 | if (!opened.ok) return pulls.length ? ok(pulls) : opened; | |
| 184 | 184 | const pull = opened.value; | |
| 185 | + | // Opened without a branch, so it has a fork. | |
| 186 | + | const fork = pull.fork!; | |
| 185 | 187 | pulls.push(pull); | |
| 186 | 188 | ||
| 187 | 189 | // The sandbox acts as the person who started it, through a token | |
| 202 | 204 | G1T_USER: actor.username, | |
| 203 | 205 | G1T_REPO: `${repo.namespace}/${repo.name}`, | |
| 204 | 206 | PULL_NUMBER: String(pull.number), | |
| 205 | − | GIT_REMOTE: `https://g1t.sh/${pull.fork.namespace}/${pull.fork.name}.git`, | |
| 207 | + | GIT_REMOTE: `https://g1t.sh/${fork.namespace}/${fork.name}.git`, | |
| 206 | 208 | COMMIT_MESSAGE: issue.title, | |
| 207 | 209 | PROMPT: prompt, | |
| 208 | 210 | ...modelEnv(this.env, model), |
| 43 | 43 | agent TEXT NOT NULL, | |
| 44 | 44 | runtime TEXT NOT NULL, | |
| 45 | 45 | status TEXT NOT NULL DEFAULT 'draft', | |
| 46 | − | fork_repo_id TEXT NOT NULL UNIQUE, | |
| 47 | − | fork_namespace TEXT NOT NULL, | |
| 48 | − | fork_name TEXT NOT NULL, | |
| 46 | + | -- Where the change is: a fork made for the pull request, or a branch of | |
| 47 | + | -- the repository itself. | |
| 48 | + | fork_repo_id TEXT UNIQUE, | |
| 49 | + | fork_namespace TEXT, | |
| 50 | + | fork_name TEXT, | |
| 51 | + | source_branch TEXT, | |
| 49 | 52 | head_commit TEXT, | |
| 50 | 53 | -- What the branch pointed to before a merged pull request landed. | |
| 51 | 54 | merge_base TEXT, |
| 9 | 9 | use g1t_contracts::events::{ | |
| 10 | 10 | CommentCreated, Delivered, IssueEvent, NewEvent, PullEvent, SessionAppended, | |
| 11 | 11 | }; | |
| 12 | − | use g1t_contracts::repos::{ForkArgs, GetArgs, LandArgs, Landed, Repo, RepoPath}; | |
| 12 | + | use g1t_contracts::repos::{ForkArgs, GetArgs, HeadArgs, LandArgs, Landed, Repo, RepoPath}; | |
| 13 | 13 | use g1t_contracts::time::rfc3339; | |
| 14 | 14 | use g1t_contracts::work::*; | |
| 15 | 15 | use g1t_contracts::{FailureCode, Outcome, User, Viewer, new_id}; | |
| 20 | 20 | Context, D1Database, Env, Fetcher, MessageBatch, MessageExt, Request, Response, Result, event, | |
| 21 | 21 | }; | |
| 22 | 22 | ||
| 23 | − | use rows::{CommentRow, IssueRow, NumberRow, PullRow, SessionRow, ValueRow}; | |
| 23 | + | use rows::{BranchRow, CommentRow, IssueRow, NumberRow, PullRow, SessionRow, ValueRow}; | |
| 24 | 24 | ||
| 25 | 25 | const SOURCE: &str = "work"; | |
| 26 | 26 | const MAX_ENTRY_BATCH: usize = 200; | |
| 636 | 636 | ||
| 637 | 637 | let now = now_ms(); | |
| 638 | 638 | let id = new_id("pr", now); | |
| 639 | − | let fork: Outcome<Repo> = g1t_kit::call( | |
| 640 | − | &self.repos, | |
| 641 | − | "fork_for_pull", | |
| 642 | − | &ForkArgs { | |
| 643 | − | source_id: repo.id.clone(), | |
| 644 | − | pull_id: id.clone(), | |
| 645 | − | actor: a.actor.clone(), | |
| 646 | − | }, | |
| 647 | − | ) | |
| 648 | − | .await?; | |
| 649 | − | let fork = check!(fork); | |
| 639 | + | let branch = a | |
| 640 | + | .branch | |
| 641 | + | .as_deref() | |
| 642 | + | .map(str::trim) | |
| 643 | + | .filter(|branch| !branch.is_empty()); | |
| 644 | + | // The change is on a branch already pushed to the repository, or | |
| 645 | + | // will be made in a fork created for this pull request. | |
| 646 | + | let (fork, head) = match branch { | |
| 647 | + | Some(branch) => { | |
| 648 | + | if branch == repo.default_branch { | |
| 649 | + | return Ok(Outcome::fail( | |
| 650 | + | FailureCode::Invalid, | |
| 651 | + | format!("Choose a branch other than {branch}."), | |
| 652 | + | )); | |
| 653 | + | } | |
| 654 | + | let head: Option<String> = g1t_kit::call( | |
| 655 | + | &self.repos, | |
| 656 | + | "head", | |
| 657 | + | &HeadArgs { | |
| 658 | + | repo_id: repo.id.clone(), | |
| 659 | + | branch: branch.to_owned(), | |
| 660 | + | }, | |
| 661 | + | ) | |
| 662 | + | .await?; | |
| 663 | + | let Some(head) = head else { | |
| 664 | + | return Ok(Outcome::fail( | |
| 665 | + | FailureCode::NotFound, | |
| 666 | + | format!("There is no branch named {branch}. Push it first."), | |
| 667 | + | )); | |
| 668 | + | }; | |
| 669 | + | let existing = self | |
| 670 | + | .db | |
| 671 | + | .prepare( | |
| 672 | + | "SELECT number AS n FROM pulls | |
| 673 | + | WHERE repo_id = ? AND source_branch = ? AND status IN ('draft', 'open')", | |
| 674 | + | ) | |
| 675 | + | .bind(&[repo.id.as_str().into(), branch.into()])? | |
| 676 | + | .first::<NumberRow>(None) | |
| 677 | + | .await?; | |
| 678 | + | if let Some(existing) = existing { | |
| 679 | + | return Ok(Outcome::fail( | |
| 680 | + | FailureCode::Conflict, | |
| 681 | + | format!("Pull request #{} is already open for {branch}.", existing.n), | |
| 682 | + | )); | |
| 683 | + | } | |
| 684 | + | (None, Some(head)) | |
| 685 | + | } | |
| 686 | + | None => { | |
| 687 | + | let fork: Outcome<Repo> = g1t_kit::call( | |
| 688 | + | &self.repos, | |
| 689 | + | "fork_for_pull", | |
| 690 | + | &ForkArgs { | |
| 691 | + | source_id: repo.id.clone(), | |
| 692 | + | pull_id: id.clone(), | |
| 693 | + | actor: a.actor.clone(), | |
| 694 | + | }, | |
| 695 | + | ) | |
| 696 | + | .await?; | |
| 697 | + | (Some(check!(fork)), None) | |
| 698 | + | } | |
| 699 | + | }; | |
| 700 | + | // A branch already holds the work, so its pull request is ready for | |
| 701 | + | // review from the start; one with a fork starts as a draft. | |
| 702 | + | let status = if branch.is_some() { "open" } else { "draft" }; | |
| 703 | + | let body = Some(a.body.trim().to_owned()).filter(|body| !body.is_empty()); | |
| 650 | 704 | ||
| 651 | 705 | let number = self.next_number(&repo.id).await?; | |
| 652 | 706 | let timestamp = rfc3339(now); | |
| 653 | 707 | self.db | |
| 654 | 708 | .prepare( | |
| 655 | 709 | "INSERT INTO pulls | |
| 656 | − | (id, repo_id, number, issue_id, issue_number, title, agent, runtime, | |
| 657 | − | fork_repo_id, fork_namespace, fork_name, author_id, author_name, | |
| 658 | − | created_at, updated_at) | |
| 659 | − | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 710 | + | (id, repo_id, number, issue_id, issue_number, title, body, agent, runtime, | |
| 711 | + | status, fork_repo_id, fork_namespace, fork_name, source_branch, head_commit, | |
| 712 | + | author_id, author_name, created_at, updated_at) | |
| 713 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 660 | 714 | ) | |
| 661 | 715 | .bind(&[ | |
| 662 | 716 | id.as_str().into(), | |
| 665 | 719 | optional(&issue.as_ref().map(|issue| issue.id.clone())), | |
| 666 | 720 | optional_number(issue.as_ref().map(|issue| issue.number)), | |
| 667 | 721 | title.into(), | |
| 722 | + | optional(&body), | |
| 668 | 723 | agent.into(), | |
| 669 | 724 | runtime.into(), | |
| 670 | − | fork.id.into(), | |
| 671 | − | fork.namespace.into(), | |
| 672 | − | fork.name.into(), | |
| 725 | + | status.into(), | |
| 726 | + | optional(&fork.as_ref().map(|fork| fork.id.clone())), | |
| 727 | + | optional(&fork.as_ref().map(|fork| fork.namespace.clone())), | |
| 728 | + | optional(&fork.as_ref().map(|fork| fork.name.clone())), | |
| 729 | + | optional(&branch.map(str::to_owned)), | |
| 730 | + | optional(&head), | |
| 673 | 731 | a.actor.id.as_str().into(), | |
| 674 | 732 | a.actor.username.as_str().into(), | |
| 675 | 733 | timestamp.as_str().into(), | |
| 837 | 895 | &self.repos, | |
| 838 | 896 | "land", | |
| 839 | 897 | &LandArgs { | |
| 840 | − | fork_id: pull.fork_repo_id.clone(), | |
| 898 | + | // A pull request from a branch lands from the repository itself. | |
| 899 | + | source_id: pull.fork_repo_id.clone().unwrap_or_else(|| repo.id.clone()), | |
| 900 | + | branch: pull.branch.clone(), | |
| 841 | 901 | actor: a.actor.clone(), | |
| 842 | 902 | }, | |
| 843 | 903 | ) | |
| 1038 | 1098 | )) | |
| 1039 | 1099 | } | |
| 1040 | 1100 | ||
| 1041 | − | /// A push to a pull request's fork moves that pull request's head. | |
| 1101 | + | /// A push moves the head of the pull requests it concerns: the one | |
| 1102 | + | /// whose fork was pushed to, or those from branches of the repository | |
| 1103 | + | /// that was. | |
| 1042 | 1104 | async fn on_event(&self, event: &Delivered) -> Result<()> { | |
| 1043 | 1105 | if event.kind != "git.push" { | |
| 1044 | 1106 | return Ok(()); | |
| 1047 | 1109 | else { | |
| 1048 | 1110 | return Ok(()); | |
| 1049 | 1111 | }; | |
| 1112 | + | let now = rfc3339(now_ms()); | |
| 1050 | 1113 | self.db | |
| 1051 | 1114 | .prepare( | |
| 1052 | 1115 | "UPDATE pulls SET head_commit = ?, updated_at = ? | |
| 1053 | 1116 | WHERE fork_repo_id = ? AND status IN ('draft', 'open')", | |
| 1054 | 1117 | ) | |
| 1055 | − | .bind(&[after.into(), rfc3339(now_ms()).into(), repo_id.into()])? | |
| 1118 | + | .bind(&[after.into(), now.as_str().into(), repo_id.into()])? | |
| 1056 | 1119 | .run() | |
| 1057 | 1120 | .await?; | |
| 1121 | + | ||
| 1122 | + | // The event does not say which branch moved, so each open pull | |
| 1123 | + | // request from a branch of this repository is checked. | |
| 1124 | + | let from_branches = self | |
| 1125 | + | .db | |
| 1126 | + | .prepare( | |
| 1127 | + | "SELECT id, source_branch, head_commit FROM pulls | |
| 1128 | + | WHERE repo_id = ? AND source_branch IS NOT NULL AND status IN ('draft', 'open')", | |
| 1129 | + | ) | |
| 1130 | + | .bind(&[repo_id.into()])? | |
| 1131 | + | .all() | |
| 1132 | + | .await? | |
| 1133 | + | .results::<BranchRow>()?; | |
| 1134 | + | for row in from_branches { | |
| 1135 | + | let head: Option<String> = g1t_kit::call( | |
| 1136 | + | &self.repos, | |
| 1137 | + | "head", | |
| 1138 | + | &HeadArgs { | |
| 1139 | + | repo_id: repo_id.to_owned(), | |
| 1140 | + | branch: row.source_branch, | |
| 1141 | + | }, | |
| 1142 | + | ) | |
| 1143 | + | .await?; | |
| 1144 | + | if let Some(head) = head.filter(|head| Some(head) != row.head_commit.as_ref()) { | |
| 1145 | + | self.db | |
| 1146 | + | .prepare("UPDATE pulls SET head_commit = ?, updated_at = ? WHERE id = ?") | |
| 1147 | + | .bind(&[head.into(), now.as_str().into(), row.id.into()])? | |
| 1148 | + | .run() | |
| 1149 | + | .await?; | |
| 1150 | + | } | |
| 1151 | + | } | |
| 1058 | 1152 | Ok(()) | |
| 1059 | 1153 | } | |
| 1060 | 1154 | } |
| 72 | 72 | pub agent: String, | |
| 73 | 73 | pub runtime: Runtime, | |
| 74 | 74 | pub status: PullStatus, | |
| 75 | − | pub fork_repo_id: String, | |
| 76 | − | pub fork_namespace: String, | |
| 77 | − | pub fork_name: String, | |
| 75 | + | pub fork_repo_id: Option<String>, | |
| 76 | + | pub fork_namespace: Option<String>, | |
| 77 | + | pub fork_name: Option<String>, | |
| 78 | + | pub source_branch: Option<String>, | |
| 78 | 79 | pub head_commit: Option<String>, | |
| 79 | 80 | pub merge_base: Option<String>, | |
| 80 | 81 | pub merged_by: Option<String>, | |
| 98 | 99 | agent: row.agent, | |
| 99 | 100 | runtime: row.runtime, | |
| 100 | 101 | status: row.status, | |
| 101 | − | fork: RepoPath { | |
| 102 | − | namespace: row.fork_namespace, | |
| 103 | − | name: row.fork_name, | |
| 104 | − | }, | |
| 102 | + | fork: row | |
| 103 | + | .fork_namespace | |
| 104 | + | .zip(row.fork_name) | |
| 105 | + | .map(|(namespace, name)| RepoPath { namespace, name }), | |
| 105 | 106 | fork_repo_id: row.fork_repo_id, | |
| 107 | + | branch: row.source_branch, | |
| 106 | 108 | head_commit: row.head_commit, | |
| 107 | 109 | merge_base: row.merge_base, | |
| 108 | 110 | merged_by: row.merged_by, | |
| 158 | 160 | } | |
| 159 | 161 | } | |
| 160 | 162 | ||
| 163 | + | /// An open pull request made from a branch of its repository. | |
| 164 | + | #[derive(Deserialize)] | |
| 165 | + | pub struct BranchRow { | |
| 166 | + | pub id: String, | |
| 167 | + | pub source_branch: String, | |
| 168 | + | pub head_commit: Option<String>, | |
| 169 | + | } | |
| 170 | + | ||
| 161 | 171 | /// A single number selected as `n`. | |
| 162 | 172 | #[derive(Deserialize)] | |
| 163 | 173 | pub struct NumberRow { |