Commit

Issues and pull requests replace intents and attempts

An issue is what should change; a pull request is a proposed change in its own fork. One issue can have any number of pull requests, and it is always clear which one was taken: merging closes the issue with resolvedBy set to that pull request, and closes the others still in progress for it as superseded. Merging with keepIssueOpen leaves both alone, for a pull request that is only part of the work. - work: issues with labels, acceptance checks and comments; pull requests (draft, open, merged, closed); one sequence of numbers per repository shared by both; migrations squashed into one schema - labels need no setup: five defaults, and any other name exists once used - api and mcp: GitHub-shaped names and paths, addressed by repo and number - site: Issues and Pull requests tabs, label filters, issue page listing its pull requests with the merged one marked, conversation tab on pull requests - runner: g1t agents open a pull request per agent; commits no longer carry the harness's co-author trailer - docs, llms.txt, README and plan rewritten for the model

syntaqxcommitted Parent28dad98Browse files
60 files+3786−22180/60 viewed
+44−25
33 Git for AI scale: a forge for thousands of agents working on the same code at
44 once, running on Cloudflare Workers and Artifacts.
55
6−A pull request assumes one author and one change. g1t assumes many agents
7−working at once: you state a goal as an **intent**, any number of agents
8−**attempt** it in parallel, each in its own fork, and the one that works
9−ships.
6+g1t has the issues and pull requests you already know. What changes is how
7+many there are. An issue is opened by a person, an agent or your error
8+tracker; any number of agents each open a pull request for it, every one in
9+its own fork with a recording of how it was made; you merge one, and the
10+issue records which pull request resolved it while the others close as
11+superseded.
1012
1113 - Site: <https://g1t.sh>
12−- Docs: <https://g1t.sh/docs>
14+- Docs: <https://docs.g1t.sh>
1315 - API: <https://api.g1t.sh> · MCP: <https://mcp.g1t.sh>
1416 - Plan and design: [docs/PLAN.md](docs/PLAN.md)
1517
1719
1820 Working today:
1921
20−- Accounts, access tokens, public and private repositories.
21−- Git over HTTPS, including creating a repository by pushing to it.
22−- Intents, attempts (a copy-on-write fork each) and recorded agent sessions.
23−- Shipping: landing an attempt on `main`, refused when the attempt is behind
24− so that no commit is ever lost.
25−- Registration with email verification, and password reset.
26−- A REST API and an MCP server over the same operations.
22+- Accounts with email verification and password reset; sign-in from a tool
23+ by approving a code in the browser.
24+- Workspaces that own repositories, with members and roles.
25+- Public and private repositories, and git over HTTPS, including creating a
26+ repository by pushing to it.
27+- Issues with labels, acceptance checks and comments.
28+- Pull requests, each in a copy-on-write fork, with a diff and a recorded
29+ agent session. Several can be made for one issue.
30+- Merging: lands a pull request on `main`, closes its issue naming the pull
31+ request that resolved it, and closes the others for that issue as
32+ superseded. Refused when the pull request is behind, so no commit is lost.
33+- g1t agents: g1t's own agents working on an issue in sandboxes on
34+ Cloudflare Containers (preview, limited accounts).
35+- A REST API, an OpenAPI document and an MCP server over the same operations.
2736 - An event bus: every state change is published, logged and delivered to
2837 subscribers.
2938
30−Not built yet: diffs and review on the site, pull requests from branches,
31−server-side merging, running acceptance checks, hosted agents, git over SSH. See the build order in the
32−plan.
39+Not built yet: pull requests from branches, server-side merge commits,
40+review comments on lines, running acceptance checks, OAuth sign-in for MCP,
41+git over SSH. See the build order in the plan.
3342
3443 ## Try it
3544
3645 ```sh
37−# 1. Create an account at https://g1t.sh/register and an access token in Settings.
46+# 1. Create an account at https://g1t.sh/register, a workspace, and an
47+# access token in Settings.
3848 export G1T_TOKEN=g1t_…
3949
4050 # 2. Connect Claude Code.
4151 claude mcp add --transport http g1t https://mcp.g1t.sh \
4252 --header "Authorization: Bearer $G1T_TOKEN"
4353
44−# 3. Ask it to start an attempt on an open intent.
54+# 3. Ask it to open a pull request for an open issue.
4555 ```
4656
47−[Getting started](https://g1t.sh/docs) walks through this in full.
57+[Getting started](https://docs.g1t.sh/quickstart/) walks through this in
58+full. An assistant can do it for you from <https://g1t.sh/llms.txt>.
4859
4960 ## Layout
5061
5162 | Path | What it is |
5263 | --- | --- |
5364 | `apps/web` | The site: server-rendered React on a Worker. Holds no data. |
65+| `apps/docs` | The documentation site, with the API explorer. |
5466 | `apps/api` | REST API and MCP server. |
55−| `services/identity` | Accounts, sessions, keys and tokens. Rust. |
56−| `services/repos` | Repository registry, contents, forks, landing, git over HTTPS. Rust. |
57−| `services/work` | Intents, attempts and sessions. |
67+| `services/identity` | Accounts, workspaces, sessions, keys and tokens. Rust. |
68+| `services/repos` | Repository registry, contents, forks, diffs, landing, git over HTTPS. Rust. |
69+| `services/work` | Issues, pull requests, comments and sessions. Rust. |
5870 | `services/events` | The event bus and its log. |
71+| `services/runner` | Starts the sandboxes g1t agents work in. |
72+| `crates/runner` | The program inside a sandbox: runs the agent and reports back. Rust. |
5973 | `crates/contracts` | Types and service interfaces for the Rust services. |
6074 | `crates/kit` | Plumbing shared by Rust services on Workers. |
6175 | `crates/sshd` | Git over SSH, bridged to Artifacts. Not deployed yet. |
6276 | `packages/contracts` | The same interfaces for TypeScript callers. |
77+| `packages/theme` | Design tokens and the logo, shared by the site and the docs. |
6378
6479 Each service is its own Worker with its own database. They call each other
65−through service bindings and react to each other through events. Services
66−are being moved from TypeScript to Rust one at a time; identity and repos are
67−done.
80+through service bindings and react to each other through events. Anything
81+that is not a web UI is written in Rust or on its way there; the events
82+service and the API are next.
6883
6984 ## Run your own
7085
7186 You need a Cloudflare account on the Workers Paid plan (Artifacts requires
72−it), Node 22 or newer, and Rust with the `wasm32-unknown-unknown` target.
87+it), Node 22 or newer, Rust with the `wasm32-unknown-unknown` target, and
88+Docker to build the sandbox image.
7389
7490 ```sh
7591 npm install
91107 ```sh
92108 (cd services/identity && npx wrangler deploy)
93109 (cd services/repos && npx wrangler deploy)
110+(cd services/work && npx wrangler deploy)
94111 npm run deploy
112+(cd services/runner && npx wrangler deploy) # optional: g1t agents
113+(cd apps/docs && npm run deploy)
95114 ```
96115
97116 Create the first account by registering on your site, or with
+31−16
2828 * the operation's input from the path, query string and JSON body.
2929 */
3030 const ROUTES: {
31− method: "GET" | "POST";
31+ method: "GET" | "POST" | "PATCH";
3232 path: string;
3333 operation: string;
3434 input?: (params: Record<string, string>, query: Input, body: Input) => Input;
3838 { method: "GET", path: "/v1/repos", operation: "list_repos", input: (_p, q) => ({ query: q.q }) },
3939 { method: "POST", path: "/v1/repos", operation: "create_repo", input: (_p, _q, b) => b },
4040 { method: "GET", path: "/v1/repos/:owner/:name", operation: "get_repo", input: repo },
41− { method: "GET", path: "/v1/repos/:owner/:name/intents", operation: "list_intents", input: (p, q) => ({ ...repo(p), status: q.status }) },
42− { method: "POST", path: "/v1/repos/:owner/:name/intents", operation: "open_intent", input: (p, _q, b) => ({ ...b, ...repo(p) }) },
43− { method: "GET", path: "/v1/repos/:owner/:name/intents/:number", operation: "get_intent", input: (p) => ({ ...repo(p), number: Number(p.number) }) },
4441 { method: "GET", path: "/v1/repos/:owner/:name/events", operation: "list_events", input: (p, q) => ({ ...repo(p), before: q.before }) },
45− { method: "POST", path: "/v1/intents/:intent_id/attempts", operation: "start_attempt", input: (p, _q, b) => ({ ...b, intent_id: p.intent_id }) },
46− { method: "GET", path: "/v1/attempts/:attempt_id", operation: "get_attempt", input: (p) => p },
47− { method: "GET", path: "/v1/attempts/:attempt_id/session", operation: "read_session", input: (p, q) => ({ ...p, after: Number(q.after) || 0 }) },
48− { method: "POST", path: "/v1/attempts/:attempt_id/session", operation: "record_session", input: (p, _q, b) => ({ ...b, ...p }) },
49− { method: "POST", path: "/v1/attempts/:attempt_id/submit", operation: "submit_attempt", input: (p, _q, b) => ({ ...b, ...p }) },
50− { method: "POST", path: "/v1/attempts/:attempt_id/abandon", operation: "abandon_attempt", input: (p) => p },
51− { method: "POST", path: "/v1/attempts/:attempt_id/ship", operation: "ship_attempt", input: (p) => p },
52− { method: "GET", path: "/v1/attempts/:attempt_id/changes", operation: "get_attempt_changes", input: (p) => p },
42+ { method: "GET", path: "/v1/repos/:owner/:name/labels", operation: "list_labels", input: repo },
43+ { method: "GET", path: "/v1/repos/:owner/:name/issues", operation: "list_issues", input: (p, q) => ({ ...repo(p), state: q.state, label: q.label }) },
44+ { method: "POST", path: "/v1/repos/:owner/:name/issues", operation: "create_issue", input: (p, _q, b) => ({ ...b, ...repo(p) }) },
45+ { method: "GET", path: "/v1/repos/:owner/:name/issues/:number", operation: "get_issue", input: numbered },
46+ { method: "PATCH", path: "/v1/repos/:owner/:name/issues/:number", operation: "update_issue", input: numbered },
47+ { method: "POST", path: "/v1/repos/:owner/:name/issues/:number/close", operation: "close_issue", input: numbered },
48+ { method: "POST", path: "/v1/repos/:owner/:name/issues/:number/reopen", operation: "reopen_issue", input: numbered },
49+ { method: "POST", path: "/v1/repos/:owner/:name/issues/:number/comments", operation: "add_comment", input: numbered },
50+ { method: "GET", path: "/v1/repos/:owner/:name/pulls", operation: "list_pull_requests", input: (p, q) => ({ ...repo(p), state: q.state }) },
51+ { method: "POST", path: "/v1/repos/:owner/:name/pulls", operation: "create_pull_request", input: (p, _q, b) => ({ ...b, ...repo(p) }) },
52+ { method: "GET", path: "/v1/repos/:owner/:name/pulls/:number", operation: "get_pull_request", input: numbered },
53+ { method: "GET", path: "/v1/repos/:owner/:name/pulls/:number/changes", operation: "get_pull_request_changes", input: numbered },
54+ { method: "GET", path: "/v1/repos/:owner/:name/pulls/:number/session", operation: "read_session", input: (p, q) => ({ ...numbered(p), after: Number(q.after) || 0 }) },
55+ { method: "POST", path: "/v1/repos/:owner/:name/pulls/:number/session", operation: "record_session", input: numbered },
56+ { method: "POST", path: "/v1/repos/:owner/:name/pulls/:number/ready", operation: "mark_pull_request_ready", input: numbered },
57+ { method: "POST", path: "/v1/repos/:owner/:name/pulls/:number/close", operation: "close_pull_request", input: numbered },
58+ { method: "POST", path: "/v1/repos/:owner/:name/pulls/:number/merge", operation: "merge_pull_request", input: numbered },
5359 ];
5460
5561 function repo(params: Record<string, string>): Input {
5662 return { repo: `${params.owner}/${params.name}` };
5763 }
5864
65+/** An issue or pull request named in the path, with the body's fields. */
66+function numbered(params: Record<string, string>, _query: Input = {}, body: Input = {}): Input {
67+ return { ...body, ...repo(params), number: Number(params.number) };
68+}
69+
5970 /** The section of the API reference an operation is listed under. */
6071 function tagFor(operation: string): string {
6172 if (operation === "whoami" || operation.includes("workspace")) return "Accounts";
6273 if (operation.includes("session")) return "Sessions";
63− if (operation.includes("attempt")) return "Attempts";
64− if (operation.includes("intent")) return "Intents";
74+ if (operation.includes("pull_request")) return "Pull requests";
75+ if (/issue|label|comment/.test(operation)) return "Issues";
6576 return "Repositories";
6677 }
6778
6980
7081 // The API is called from browsers too: the reference's explorer, and apps
7182 // built on g1t. It carries no cookies, so any origin may call it.
72−app.use(cors({ origin: "*", allowHeaders: ["authorization", "content-type"] }));
83+app.use(cors({
84+ origin: "*",
85+ allowHeaders: ["authorization", "content-type"],
86+ allowMethods: ["GET", "POST", "PATCH", "OPTIONS"],
87+ }));
7388
7489 // `Authorization: Bearer g1t_…`. A missing token is an anonymous viewer; a
7590 // wrong one is rejected so a typo does not silently look signed out.
170185 const operation = operationsByName.get(route.operation)!;
171186 app.on(route.method, route.path, async (c) => {
172187 let body: Input = {};
173− if (route.method === "POST") {
188+ if (route.method !== "GET") {
174189 try {
175190 body = await c.req.json();
176191 } catch {
+13−5
22
33 /** What the OpenAPI document needs to know about a REST route. */
44 export type RouteDoc = {
5− method: "GET" | "POST";
5+ method: "GET" | "POST" | "PATCH";
66 path: string;
77 operation: string;
88 tag: string;
1717 };
1818 }
1919
20−/** A short title from an operation name: `open_intent` is "Open intent". */
20+/** A short title from an operation name: `create_issue` is "Create issue". */
2121 function title(operation: string): string {
2222 if (operation === "whoami") return "Get the current user";
2323 const words = operation.split("_").join(" ");
209209 tags: [
210210 { name: "Accounts", description: "Signing in from a tool, and the current user." },
211211 { name: "Repositories" },
212− { name: "Intents", description: "Goals stated against a repository." },
213− { name: "Attempts", description: "An agent's or person's run at an intent." },
214− { name: "Sessions", description: "The record of how an attempt was made." },
212+ {
213+ name: "Issues",
214+ description:
215+ "What should change in a repository, with labels and comments. Issues and pull requests share one sequence of numbers.",
216+ },
217+ {
218+ name: "Pull requests",
219+ description:
220+ "A proposed change in its own fork. Several can be made for one issue; the one merged resolves it.",
221+ },
222+ { name: "Sessions", description: "The record of how a pull request was made." },
215223 ],
216224 paths,
217225 components: {
+227−111
5757
5858 const BAD_REPO = fail("invalid", 'Give the repository as "owner/name".');
5959
60+const NUMBER = {
61+ type: "integer",
62+ description: "The number shown after the #. Issues and pull requests share one sequence.",
63+};
64+
65+const numbered = { repo: REPO, number: NUMBER };
66+
67+function strings(input: Input, key: string): string[] | undefined {
68+ const value = input[key];
69+ return Array.isArray(value) ? value.map(String) : undefined;
70+}
71+
72+function state(input: Input): "open" | "closed" | undefined {
73+ const value = text(input, "state");
74+ return value === "open" || value === "closed" ? value : undefined;
75+}
76+
77+/** Wraps an operation on a repository that anyone who can see it may call. */
78+function onRepo(
79+ run: (env: ApiEnv, path: RepoPath, viewer: Viewer, input: Input) => Promise<Result<unknown>>,
80+): Operation["run"] {
81+ return (env, viewer, input) => {
82+ const path = repoPath(input);
83+ return path ? run(env, path, viewer, input) : Promise.resolve(BAD_REPO);
84+ };
85+}
86+
87+/** Wraps an operation on a repository that needs a signed-in user. */
88+function onRepoAs(
89+ run: (env: ApiEnv, path: RepoPath, user: User, input: Input) => Promise<Result<unknown>>,
90+): Operation["run"] {
91+ return onRepo((env, path, viewer, input) =>
92+ viewer ? run(env, path, viewer, input) : Promise.resolve(SIGN_IN),
93+ );
94+}
95+
6096 /** Wraps an operation that needs a signed-in user. */
6197 function authed(
6298 run: (env: ApiEnv, user: User, input: Input) => Promise<Result<unknown>>,
139175 ),
140176 },
141177 {
142− name: "list_intents",
178+ name: "list_issues",
143179 description:
144− "Intents on a repository. An intent is a goal that agents attempt in parallel; it replaces issues and pull requests.",
180+ "Issues on a repository, newest first. An issue is something that should change: a bug, a feature, a question. Pull requests are made against it.",
145181 input: {
146182 type: "object",
147183 properties: {
148184 repo: REPO,
149− status: { type: "string", enum: ["open", "shipped", "withdrawn"] },
185+ state: { type: "string", enum: ["open", "closed"] },
186+ label: { type: "string", description: "Only issues carrying this label." },
150187 },
151188 required: ["repo"],
152189 },
153− run: async (env, viewer, input) => {
154− const path = repoPath(input);
155− const status = text(input, "status");
156− if (!path) return BAD_REPO;
157− return env.WORK.listIntents(
158− path,
159− viewer,
160− status === "open" || status === "shipped" || status === "withdrawn"
161− ? status
162− : undefined,
163− );
164− },
190+ run: onRepo((env, path, viewer, input) =>
191+ env.WORK.listIssues(path, viewer, {
192+ state: state(input),
193+ label: text(input, "label") || undefined,
194+ }),
195+ ),
165196 },
166197 {
167− name: "get_intent",
198+ name: "get_issue",
168199 description:
169− "An intent's brief, its acceptance checks, and every attempt made at it so far. Read this before starting an attempt.",
170− input: {
171− type: "object",
172− properties: { repo: REPO, number: { type: "integer" } },
173− required: ["repo", "number"],
174− },
175− run: async (env, viewer, input) => {
176− const path = repoPath(input);
177− return path
178− ? env.WORK.getIntent(path, Number(input.number), viewer)
179− : BAD_REPO;
180− },
200+ "An issue: its description, labels and acceptance checks, its comments, and every pull request made against it with its status. If the issue is closed, resolvedBy is the number of the pull request that was merged for it. Read this before opening a pull request, to see what others have already tried.",
201+ input: { type: "object", properties: numbered, required: ["repo", "number"] },
202+ run: onRepo((env, path, viewer, input) =>
203+ env.WORK.getIssue(path, Number(input.number), viewer),
204+ ),
181205 },
182206 {
183− name: "open_intent",
184− description: "State a new goal for a repository.",
207+ name: "create_issue",
208+ description: "Open an issue on a repository.",
185209 input: {
186210 type: "object",
187211 properties: {
188212 repo: REPO,
189− title: { type: "string", description: "The goal in one line." },
190− brief: {
213+ title: { type: "string", description: "The problem or goal in one line." },
214+ body: {
191215 type: "string",
192− description: "What an agent needs to do the work: outcome, constraints, context.",
216+ description:
217+ "Markdown. What an agent or a person needs to do the work: what is wrong or wanted, constraints, context.",
193218 },
219+ labels: {
220+ type: "array",
221+ items: { type: "string" },
222+ description:
223+ 'What kind of issue this is, e.g. "bug" or "feature". list_labels shows the labels in use; a new name creates a new label.',
224+ },
194225 checks: {
195226 type: "array",
196227 items: { type: "string" },
197− description: "Commands that must pass for an attempt to be accepted.",
228+ description: "Commands that must pass for a pull request to be accepted.",
198229 },
199230 },
200− required: ["repo", "title", "brief"],
231+ required: ["repo", "title"],
201232 },
202− run: authed(async (env, user, input) => {
203− const path = repoPath(input);
204− if (!path) return BAD_REPO;
205− return env.WORK.openIntent(user, path, {
233+ run: onRepoAs((env, path, user, input) =>
234+ env.WORK.openIssue(user, path, {
206235 title: text(input, "title"),
207− brief: text(input, "brief"),
208− checks: Array.isArray(input.checks) ? input.checks.map(String) : [],
209− });
210− }),
236+ body: text(input, "body"),
237+ labels: strings(input, "labels"),
238+ checks: strings(input, "checks"),
239+ }),
240+ ),
241+ },
242+ {
243+ name: "update_issue",
244+ description:
245+ "Change an issue's title, body or labels. Only the fields given are changed; labels replaces the whole set.",
246+ input: {
247+ type: "object",
248+ properties: {
249+ ...numbered,
250+ title: { type: "string" },
251+ body: { type: "string" },
252+ labels: { type: "array", items: { type: "string" } },
253+ },
254+ required: ["repo", "number"],
255+ },
256+ run: onRepoAs((env, path, user, input) =>
257+ env.WORK.updateIssue(user, path, Number(input.number), {
258+ title: typeof input.title === "string" ? input.title : undefined,
259+ body: typeof input.body === "string" ? input.body : undefined,
260+ labels: strings(input, "labels"),
261+ }),
262+ ),
263+ },
264+ {
265+ name: "close_issue",
266+ description:
267+ "Close an issue without a pull request. Merging a pull request made for an issue closes it for you.",
268+ input: {
269+ type: "object",
270+ properties: {
271+ ...numbered,
272+ reason: {
273+ type: "string",
274+ enum: ["completed", "not_planned"],
275+ description: "Defaults to completed.",
276+ },
277+ },
278+ required: ["repo", "number"],
279+ },
280+ run: onRepoAs((env, path, user, input) =>
281+ env.WORK.closeIssue(
282+ user,
283+ path,
284+ Number(input.number),
285+ text(input, "reason") === "not_planned" ? "not_planned" : "completed",
286+ ),
287+ ),
288+ },
289+ {
290+ name: "reopen_issue",
291+ description: "Reopen a closed issue.",
292+ input: { type: "object", properties: numbered, required: ["repo", "number"] },
293+ run: onRepoAs((env, path, user, input) =>
294+ env.WORK.reopenIssue(user, path, Number(input.number)),
295+ ),
296+ },
297+ {
298+ name: "list_labels",
299+ description: "The labels available on a repository's issues.",
300+ input: { type: "object", properties: { repo: REPO }, required: ["repo"] },
301+ run: onRepo((env, path, viewer) => env.WORK.listLabels(path, viewer)),
302+ },
303+ {
304+ name: "add_comment",
305+ description: "Comment on an issue or a pull request.",
306+ input: {
307+ type: "object",
308+ properties: { ...numbered, body: { type: "string", description: "Markdown." } },
309+ required: ["repo", "number", "body"],
310+ },
311+ run: onRepoAs((env, path, user, input) =>
312+ env.WORK.addComment(user, path, Number(input.number), text(input, "body")),
313+ ),
211314 },
212315 {
213− name: "start_attempt",
316+ name: "list_pull_requests",
214317 description:
215− "Begin working on an intent. Creates a private fork for this attempt and returns its git remote. Clone it, commit your work there, push, record your session as you go, then call submit_attempt.",
318+ "Pull requests on a repository, newest first. State open covers drafts and those ready for review; closed covers merged and closed.",
216319 input: {
217320 type: "object",
321+ properties: { repo: REPO, state: { type: "string", enum: ["open", "closed"] } },
322+ required: ["repo"],
323+ },
324+ run: onRepo((env, path, viewer, input) => env.WORK.listPulls(path, viewer, state(input))),
325+ },
326+ {
327+ name: "get_pull_request",
328+ description:
329+ "A pull request's status, head commit, comments and the issue it is for.",
330+ input: { type: "object", properties: numbered, required: ["repo", "number"] },
331+ run: onRepo((env, path, viewer, input) =>
332+ env.WORK.getPull(path, Number(input.number), viewer),
333+ ),
334+ },
335+ {
336+ name: "create_pull_request",
337+ 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+ input: {
340+ type: "object",
218341 properties: {
219− intent_id: { type: "string" },
342+ repo: REPO,
343+ issue: { type: "integer", description: "The number of the issue this is for." },
344+ title: {
345+ type: "string",
346+ description: "Defaults to the issue's title. Required when there is no issue.",
347+ },
220348 agent: {
221349 type: "string",
222350 description: 'A label for the agent doing the work, e.g. "claude-code".',
223351 },
224352 },
225− required: ["intent_id"],
353+ required: ["repo"],
226354 },
227− run: authed(async (env, user, input) => {
228− const started = await env.WORK.startAttempt(user, text(input, "intent_id"), {
355+ run: onRepoAs(async (env, path, user, input) => {
356+ const opened = await env.WORK.openPull(user, path, {
357+ issue: input.issue == null ? undefined : Number(input.issue),
358+ title: text(input, "title"),
229359 agent: text(input, "agent") || "agent",
230360 runtime: "external",
231361 });
232− if (!started.ok) return started;
233− const { fork } = started.value;
362+ if (!opened.ok) return opened;
363+ const { fork } = opened.value;
234364 return ok({
235− attempt: started.value,
365+ pull: opened.value,
236366 git: {
237367 remote: `https://g1t.sh/${fork.namespace}/${fork.name}.git`,
238368 username: user.username,
242372 }),
243373 },
244374 {
245− name: "get_attempt",
246− description: "An attempt's status, head commit and the intent it belongs to.",
247− input: {
248− type: "object",
249− properties: { attempt_id: { type: "string" } },
250− required: ["attempt_id"],
251− },
252− run: (env, viewer, input) =>
253− env.WORK.getAttempt(text(input, "attempt_id"), viewer),
254− },
255− {
256375 name: "record_session",
257376 description:
258− "Append entries to an attempt's session: the prompt you were given, your reasoning, the tools you ran. This is how people later see why a change was made, so record as you work, not only at the end.",
377+ "Append entries to a pull request's session: the prompt you were given, your reasoning, the tools you ran. This is how people later see why a change was made, so record as you work, not only at the end.",
259378 input: {
260379 type: "object",
261380 properties: {
262− attempt_id: { type: "string" },
381+ ...numbered,
263382 entries: {
264383 type: "array",
265384 items: {
276395 },
277396 },
278397 },
279− required: ["attempt_id", "entries"],
398+ required: ["repo", "number", "entries"],
280399 },
281− run: authed(async (env, user, input) => {
400+ run: onRepoAs(async (env, path, user, input) => {
282401 if (!Array.isArray(input.entries)) {
283402 return fail("invalid", "entries must be an array.");
284403 }
285404 return env.WORK.appendSession(
286405 user,
287− text(input, "attempt_id"),
406+ path,
407+ Number(input.number),
288408 input.entries as NewSessionEntry[],
289409 );
290410 }),
291411 },
292412 {
293413 name: "read_session",
294− description: "The recorded session of an attempt, oldest entry first.",
414+ description: "The recorded session of a pull request, oldest entry first.",
295415 input: {
296416 type: "object",
297417 properties: {
298− attempt_id: { type: "string" },
418+ ...numbered,
299419 after: { type: "integer", description: "Only entries after this sequence number." },
300420 },
301− required: ["attempt_id"],
421+ required: ["repo", "number"],
302422 },
303− run: (env, viewer, input) =>
304− env.WORK.readSession(
305− text(input, "attempt_id"),
306− viewer,
307− Number(input.after) || 0,
308− ),
423+ run: onRepo((env, path, viewer, input) =>
424+ env.WORK.readSession(path, Number(input.number), viewer, Number(input.after) || 0),
425+ ),
309426 },
310427 {
311− name: "submit_attempt",
428+ name: "mark_pull_request_ready",
312429 description:
313− "Mark an attempt ready to be compared and shipped. Push your commits first. The summary should say what changed and why.",
430+ "Mark a draft pull request ready for review. Push your commits first. The summary becomes its description and should say what changed and why.",
314431 input: {
315432 type: "object",
316− properties: { attempt_id: { type: "string" }, summary: { type: "string" } },
317− required: ["attempt_id", "summary"],
433+ properties: { ...numbered, summary: { type: "string", description: "Markdown." } },
434+ required: ["repo", "number", "summary"],
318435 },
319− run: authed((env, user, input) =>
320− env.WORK.submitAttempt(user, text(input, "attempt_id"), text(input, "summary")),
436+ run: onRepoAs((env, path, user, input) =>
437+ env.WORK.readyPull(user, path, Number(input.number), text(input, "summary")),
321438 ),
322439 },
323440 {
324− name: "abandon_attempt",
325− description: "Give up on an attempt.",
326− input: {
327− type: "object",
328− properties: { attempt_id: { type: "string" } },
329− required: ["attempt_id"],
330− },
331− run: authed((env, user, input) =>
332− env.WORK.abandonAttempt(user, text(input, "attempt_id")),
441+ name: "close_pull_request",
442+ description: "Close a pull request without merging it.",
443+ input: { type: "object", properties: numbered, required: ["repo", "number"] },
444+ run: onRepoAs((env, path, user, input) =>
445+ env.WORK.closePull(user, path, Number(input.number)),
333446 ),
334447 },
335448 {
336− name: "get_attempt_changes",
449+ name: "get_pull_request_changes",
337450 description:
338− "What an attempt changed: the files it touched and their line-by-line diff against the commit it started from. Use it to review an attempt or to compare several attempts at the same intent.",
339− input: {
340− type: "object",
341− properties: { attempt_id: { type: "string" } },
342− required: ["attempt_id"],
343− },
344− run: async (env, viewer, input) => {
345− const found = await env.WORK.getAttempt(text(input, "attempt_id"), viewer);
451+ "What a pull request changes: the files it touches and their line-by-line diff against the commit it started from. Use it to review a pull request or to compare several made for the same issue.",
452+ input: { type: "object", properties: numbered, required: ["repo", "number"] },
453+ run: onRepo(async (env, path, viewer, input) => {
454+ const found = await env.WORK.getPull(path, Number(input.number), viewer);
346455 if (!found.ok) return found;
347− const { forkRepoId, landedBase } = found.value.attempt;
348− return env.REPOS.compare(forkRepoId, viewer, landedBase);
349− },
456+ const { forkRepoId, mergeBase } = found.value.pull;
457+ return env.REPOS.compare(forkRepoId, viewer, mergeBase);
458+ }),
350459 },
351460 {
352− name: "ship_attempt",
461+ name: "merge_pull_request",
353462 description:
354− "Land an attempt on the repository's main branch and close its intent. Only the repository's owner can ship. Fails if main has moved since the attempt started; the attempt must then pull main into its fork and push before shipping again.",
463+ "Land a pull request on the repository's main branch. Only members of the repository's workspace can merge, and only once it is marked ready. Merging resolves the issue it was made for: the issue closes recording this pull request, and the other pull requests still in progress for that issue close as superseded. Fails if main has moved since the pull request was opened; pull main into its fork and push, then merge again.",
355464 input: {
356465 type: "object",
357− properties: { attempt_id: { type: "string" } },
358− required: ["attempt_id"],
466+ properties: {
467+ ...numbered,
468+ keep_issue_open: {
469+ type: "boolean",
470+ description:
471+ "Set when this pull request is only part of the work: the issue stays open and the other pull requests for it are left alone.",
472+ },
473+ },
474+ required: ["repo", "number"],
359475 },
360− run: authed((env, user, input) =>
361− env.WORK.shipAttempt(user, text(input, "attempt_id")),
476+ run: onRepoAs((env, path, user, input) =>
477+ env.WORK.mergePull(user, path, Number(input.number), input.keep_issue_open === true),
362478 ),
363479 },
364480 {
365481 name: "list_events",
366482 description:
367− "The timeline of a repository: pushes, intents, attempts and session activity, newest first.",
483+ "The timeline of a repository: pushes, issues, pull requests, comments and session activity, newest first.",
368484 input: {
369485 type: "object",
370486 properties: {
+33−35
11 ---
22 title: Forks and branches
3−description: Why an agent's attempt gets its own fork, when a branch is the better choice, and what each costs.
3+description: Why a pull request on g1t gets its own fork, when a branch is the better choice, and what each costs.
44 ---
55
6−On most forges, work happens on a branch of the repository. On g1t, an
7−agent's attempt happens in a **fork**: a separate repository that starts as
8−a copy of yours. People can still use branches. This page explains why the
9−two exist and what each is for.
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.
109
1110 ## The short version
1211
13−| | Fork per attempt | Branch in the repository |
12+| | Fork per pull request | Branch in the repository |
1413 | --- | --- | --- |
1514 | Built for | Agents, in any number | A person, or a few |
1615 | What the worker can write to | Its own fork, nothing else | The repository |
17−| Effect on the repository | None until something ships | A new ref for every piece of work |
16+| Effect on the repository | None until something merges | A new ref for every piece of work |
1817 | Sees `main` move | Only when it pulls | On the next fetch |
19−| Landing | Objects are copied in, then `main` moves | `main` moves |
18+| Merging | Objects are copied in, then `main` moves | `main` moves |
2019
2120 ## Why forks, for agents
2221
2827
2928 An agent working in a fork holds a credential for that fork. It can rewrite
3029 history, force-push, or delete everything it has, and `main` and every other
31−attempt are untouched. The limit is structural: it does not depend on a rule
32−being configured correctly.
30+pull request are untouched. The limit is structural: it does not depend on a
31+rule being configured correctly.
3332
34−### A thousand attempts leave the repository unchanged
33+### A thousand pull requests leave the repository unchanged
3534
3635 Every clone and fetch begins with the server listing the repository's refs.
37−A branch per attempt means a repository with thousands of refs, each listed
38−to every client on every fetch, and each needing cleanup once the work is
39−done or abandoned.
36+A branch per pull request means a repository with thousands of refs, each
37+listed to every client on every fetch, and each needing cleanup once the
38+work is merged or closed.
4039
41−Forks add nothing to the repository. An abandoned attempt is a fork that is
40+Forks add nothing to the repository. A closed pull request is a fork that is
4241 never looked at again. The repository's own refs stay the handful that
4342 describe the project.
4443
45−### Each attempt has its own capacity
44+### Each pull request has its own capacity
4645
4746 Storage and request limits apply per repository. A hundred agents pushing to
4847 one repository share one budget and slow each other down. A hundred forks
5655
5756 ### Anyone's agent can contribute
5857
59−Starting an attempt on a public repository does not need any access to it.
60−The attempt's owner pushes to their own fork, and the repository's owner
61−decides whether it ships. This is how open source has always taken
58+Opening a pull request on a public repository does not need any access to
59+it. The author pushes to their own fork, and the repository's workspace
60+decides whether it merges. This is how open source has always taken
6261 contributions from strangers, applied to agents.
6362
6463 ## What forks cost
6766
6867 - **Falling behind is silent.** A branch sees new commits on `main` at the
6968 next fetch. A fork has to pull from the original repository, which is a
70− second remote. g1t tells an attempt it is behind when it tries to ship,
71− and the fix is one pull, but the agent has to do it.
72−- **Landing does more work.** Shipping from a fork copies the new objects
69+ second remote. g1t tells a pull request it is behind when someone tries to
70+ merge it, and the fix is one pull, but its author has to do it.
71+- **Merging does more work.** Merging from a fork copies the new objects
7372 into the repository before moving `main`. From a branch they are already
7473 there.
75−- **It is one more concept.** Engineers who have only used branches need to
76− learn that an attempt's remote is a different URL.
74+- **The remote is a different URL.** Engineers used to pushing a branch to
75+ `origin` need to push to the pull request's fork instead.
7776
78−## When to use a branch
77+## When a branch is the better choice
7978
80−Use a branch when you are a person working on your own repository, or a
81−small team that already has write access. Nothing about isolation or scale
82−is at stake, and a branch is the tool you already know.
79+When you are a person working on your own repository, or a small team that
80+already has write access. Nothing about isolation or scale is at stake, and
81+a branch is the tool you already know.
8382
84−Opening pull requests from branches on g1t is being built. Today, attempts
85−are created from forks.
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.
8685
8786 ## How this adds up
8887
89−One person, one change: a branch and a pull request are right, and g1t keeps
90−them. Hundreds of agents, many of them wrong, some of them not yours: the
91−repository should not carry their weight or trust them with its history.
92−Forks give each of them room to work and give `main` one narrow, checked way
93−in.
88+One person, one change: a branch is right, and g1t keeps it. Hundreds of
89+agents, many of them wrong, some of them not yours: the repository should
90+not carry their weight or trust them with its history. Forks give each of
91+them room to work and give `main` one narrow, checked way in.
+96−59
11 ---
22 title: Concepts
3−description: Intents, attempts, shipping, sessions and events.
3+description: Issues, pull requests, merging, sessions and events.
44 ---
55
66 g1t is ordinary git: repositories, commits, branches, clone, push and pull all
7−work as they do anywhere. What it adds is a way to organise work when many
8−agents, and people, are changing the same code at once.
7+work as they do anywhere. On top of that it has the two things you already
8+know from other forges, **issues** and **pull requests**, built so that many
9+agents can work on the same issue at once.
910
10−If you know pull requests, the mapping is short: **an attempt is a pull
11−request**, and **an intent is the goal it serves**. The difference is that an
12−intent can have many attempts at once, and they are compared before one
13−lands.
11+| | What it is |
12+| --- | --- |
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. |
15+| **Session** | The record of how a pull request was made: prompts, reasoning, tool calls. |
1416
15−## Intent
17+The part that is different from other forges: one issue routinely has
18+several pull requests, each from a different agent, and g1t keeps track of
19+which one was merged.
1620
17−An intent is a goal stated against a repository. It plays the part of the
18−issue ("what should happen") and collects the changes proposed for it, so the
19−goal and the work stay in one place.
21+## Issues
22+
23+An issue says what should change in a repository. People open them, agents
24+open them, and so can anything with an access token, such as an error
25+tracker reporting a crash.
2026
21−An intent has:
27+An issue has:
2228
23−- a **title**, the goal in one line;
24−- a **brief**, the context an agent works from;
25−- **acceptance checks**, commands that must pass for an attempt to be
26− accepted;
27−- a **status**: `open`, `shipped` or `withdrawn`.
29+- a **title** and a **description** in Markdown. An agent given the issue
30+ works from this text;
31+- **labels**, which say what kind of issue it is;
32+- **acceptance checks**: commands a pull request should make pass;
33+- **comments**;
34+- a **state**: open or closed. A closed issue records why: `completed` or
35+ `not_planned`.
36+
37+Issues and pull requests share one sequence of numbers per repository, so
38+`#12` names exactly one of them.
2839
29−Intents are numbered per repository, like `#12`.
40+### Labels
3041
31−## Attempt
42+Every repository starts with `bug`, `feature`, `docs`, `chore` and
43+`question`. There is nothing to set up for others: putting a new name on an
44+issue creates the label. Labels are lowercase, and an issue can carry up to
45+ten.
3246
33−An attempt is one agent's run at an intent. Any number of attempts can run
34−against the same intent at the same time.
47+Filter a repository's issues by label on the site, or with `?label=` in the
48+API.
3549
36−Starting an attempt creates a **fork**: a copy-on-write copy of the
37−repository that belongs to that attempt alone. The agent clones the fork,
38−commits and pushes to it. Nothing it does can touch `main` or another
39−attempt.
50+## Pull requests
4051
41−An attempt's fork lives at `g1t.sh/attempts/<attempt id>.git`. It is exactly
42−as visible as the repository it came from.
52+A pull request is a proposed change. Opening one creates a **fork**: a
53+copy-on-write copy of the repository that belongs to that pull request
54+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.
4357
44−An attempt moves through these states:
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.
4560
4661 | Status | Meaning |
4762 | --- | --- |
48−| `working` | The agent is still making changes. |
49−| `submitted` | The agent has finished and written a summary. |
50−| `shipped` | The attempt was chosen and merged. |
51−| `abandoned` | The attempt was given up. |
63+| `draft` | Still being worked on. Every pull request starts here. |
64+| `open` | Ready for review, with a description of what changed and why. |
65+| `merged` | Landed on `main`. |
66+| `closed` | Closed without merging. |
5267
53−## Shipping
68+A pull request is normally opened **for an issue**. It can also stand alone,
69+with its own title, for a change nobody filed an issue about.
5470
55−A member of the repository's workspace ships an attempt to land it. Shipping moves `main`
56−to the attempt's head commit, marks the attempt `shipped` and closes the
57−intent.
71+## Several pull requests for one issue
5872
59−An attempt can only ship if it contains everything already on `main`. If
60−another attempt landed first, shipping is refused and the attempt is said to
61−be **behind**. Its agent pulls `main` into the fork, resolves any conflict,
62−pushes, and ships again. `main` never loses a commit this way, however many
63−attempts are racing.
73+Put five agents on an issue and you get five pull requests, each in its own
74+fork, each with its own session and its own diff. The issue's page lists
75+them all with their status.
6476
65−## Session
77+When you merge one:
6678
67−A session is the record of how an attempt was made: the prompt the agent was
68−given, its messages, the tools it called and what they returned.
79+- the pull request becomes `merged`, recording who merged it and when;
80+- the issue closes as `completed`, and records that pull request as the one
81+ that **resolved** it;
82+- every other pull request for that issue that was still a draft or open is
83+ closed, marked as **superseded** by the one that was merged.
6984
85+So the answer to "which one did we take?" is on the issue, on the merged
86+pull request, and on each one that was passed over.
87+
88+Sometimes several pull requests each do part of an issue. When merging, say
89+that the issue should stay open. The pull request merges, and the issue and
90+the other pull requests are left as they are.
91+
92+## Merging
93+
94+A member of the repository's workspace merges a pull request once it is
95+marked ready. Merging moves `main` to the pull request's head commit.
96+
97+A pull request can only merge if it contains everything already on `main`.
98+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
101+pull requests are in flight.
102+
103+## Sessions
104+
105+A session is the record of how a pull request was made: the prompt the agent
106+was given, its messages, the tools it called and what they returned.
107+
70108 Each session entry is stored with the fork's head commit at the time it was
71109 recorded. That link is what lets g1t show the reasoning behind a change
72110 rather than only the change.
76114
77115 ## Events
78116
79−Every state change in g1t is published as an event: a push, an intent being
80−opened, an attempt starting, a session growing. Events are delivered to the
81−services that react to them and are kept as a timeline per repository, which
82−you can read through the [API](/reference/api/).
117+Every state change in g1t is published as an event: a push, an issue being
118+opened, a pull request being merged, a session growing. Events are delivered
119+to the services that react to them and are kept as a timeline per
120+repository, which you can read through the [API](/reference/api/).
83121
84122 ## What is not built yet
85123
86−g1t is under active development. These parts of the model are designed but
87−not available yet:
124+g1t is under active development. These are designed but not available yet:
88125
89−- **Merging in g1t.** Shipping moves `main` forward to the attempt's head.
90− When `main` has moved, the attempt has to pull it in first; g1t does not
91− merge or rebase for you yet.
92−- **Diffs and review.** Seeing an attempt's changes and commenting on them
93− on the site.
94−- **Pull requests from branches.** Opening an attempt from a branch you
95− pushed, the way a pull request works elsewhere.
96−- **Checks.** Running an intent's acceptance checks automatically.
97−- **g1t agents for everyone.** g1t can run its own agents on an intent, each
126+- **Merging in g1t.** Merging moves `main` forward to the pull request's
127+ head. When `main` has moved, the pull request has to pull it in first; g1t
128+ 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+- **Review comments on lines.** Comments are on the pull request as a whole.
132+- **Checks.** Running an issue's acceptance checks automatically.
133+- **Assignees and milestones.**
134+- **g1t agents for everyone.** g1t can put its own agents on an issue, each
98135 in a sandbox. This is in preview and limited to selected accounts; anyone
99136 can bring their own agent today.
+2−2
1717 g1t sends a confirmation link from `noreply@g1t.sh`. It works for 24 hours.
1818
1919 Until you follow it you can sign in and look around, but you cannot create
20−repositories, push, or open intents. Those requests fail with `403` and a
20+repositories, push, or open issues and pull requests. Those requests fail with `403` and a
2121 message telling you to confirm your address. To get a new link, sign in and
2222 use the banner at the top of the site.
2323
3434
3535 | Role | Can |
3636 | --- | --- |
37−| Member | Create repositories, push, open intents, ship attempts. |
37+| Member | Create repositories, push, manage issues, merge pull requests. |
3838 | Owner | Everything a member can, and add or remove members. |
3939
4040 Manage members on the workspace's page, `g1t.sh/<workspace>`.
+46−22
1515 --header "Authorization: Bearer $G1T_TOKEN"
1616 ```
1717
18−Ask Claude Code to list the open intents on a repository, or to start an
19−attempt on one, and it will use the tools below.
18+Ask Claude Code to list the open issues on a repository, or to work on one,
19+and it will use the tools below.
2020
21−## How an agent works on an intent
21+## How an agent works on an issue
2222
23−1. `get_intent` to read the brief and acceptance checks, and to see what
24− other attempts exist.
25−2. `start_attempt` to get a fork. The response includes the git remote.
23+1. `get_issue` to read the description and acceptance checks, and to see
24+ which pull requests already exist for it.
25+2. `create_pull_request` with the issue's number. This opens a draft pull
26+ request and returns the git remote of its fork.
2627 3. Clone the fork, make changes, commit and push. Use the access token as the
2728 git password.
2829 4. `record_session` as it goes, so people can see its reasoning.
29−5. `submit_attempt` with a summary of what changed and why.
30+5. `mark_pull_request_ready` with a summary of what changed and why.
3031
31−If shipping reports that `main` has moved, pull `main` from the repository
32−into the fork, push, and the attempt can ship.
32+If merging reports that `main` has moved, pull `main` from the repository
33+into the fork and push. The pull request can then be merged.
3334
3435 ## Tools
3536
37+Repositories are always given as `owner/name`. Issues and pull requests are
38+given as the repository and a `number`; the two share one sequence, so a
39+number names exactly one of them.
40+
3641 | Tool | What it does |
3742 | --- | --- |
38−| `whoami` | The account the token belongs to. |
43+| `whoami` | The account the token belongs to, and its workspaces. |
44+| `create_workspace` | Create a workspace. |
3945 | `list_repos` | Repositories you can see, optionally filtered by a query. |
4046 | `get_repo` | One repository's details. |
41−| `create_repo` | Create a repository under your account. |
42−| `list_intents` | Intents on a repository, optionally by status. |
43−| `get_intent` | An intent's brief, checks and attempts. |
44−| `open_intent` | State a new goal for a repository. |
45−| `start_attempt` | Begin working on an intent; creates a fork. |
46−| `get_attempt` | An attempt's status and head commit. |
47+| `create_repo` | Create a repository in one of your workspaces. |
48+| `list_issues` | Issues on a repository, by state and label. |
49+| `get_issue` | An issue with its comments and every pull request made for it. |
50+| `create_issue` | Open an issue, with labels and acceptance checks. |
51+| `update_issue` | Change an issue's title, description or labels. |
52+| `close_issue` | Close an issue as completed or not planned. |
53+| `reopen_issue` | Reopen a closed issue. |
54+| `list_labels` | The labels in use on a repository. |
55+| `add_comment` | Comment on an issue or a pull request. |
56+| `list_pull_requests` | Pull requests on a repository, open or closed. |
57+| `get_pull_request` | A pull request's status, head commit, comments and issue. |
58+| `create_pull_request` | Open a draft pull request; creates a fork. |
4759 | `record_session` | Append prompts, messages and tool calls to the session. |
48−| `read_session` | Read an attempt's recorded session. |
49−| `submit_attempt` | Mark an attempt finished, with a summary. |
50−| `abandon_attempt` | Give up on an attempt. |
51−| `get_attempt_changes` | The files an attempt changed, with line-by-line diffs. |
52−| `ship_attempt` | Land an attempt on `main` and close its intent. Repository owner only. |
60+| `read_session` | Read a pull request's recorded session. |
61+| `mark_pull_request_ready` | Mark a draft ready for review, with a summary. |
62+| `close_pull_request` | Close a pull request without merging. |
63+| `get_pull_request_changes` | The files a pull request changes, with line-by-line diffs. |
64+| `merge_pull_request` | Land a pull request on `main` and resolve its issue. Workspace members only. |
5365 | `list_events` | A repository's timeline, newest first. |
5466
55−Repositories are always given as `owner/name`.
67+## Reviewing as an agent
68+
69+An agent can review as well as write. Given an issue with several pull
70+requests, it can call `get_pull_request_changes` and `read_session` on each,
71+compare them, leave its findings with `add_comment`, and, if its account is
72+a member of the workspace, `merge_pull_request` the best one.
73+
74+## Filing issues from another system
75+
76+Anything that holds an access token can open issues: an error tracker, a
77+monitor, a script. Call `create_issue`, or `POST
78+/v1/repos/{owner}/{name}/issues`, with a title, a description and labels
79+such as `bug`. The issue is attributed to the account the token belongs to.
5680
5781 ## Session entries
5882
+44−35
11 ---
22 title: g1t agents
3−description: Have g1t's own agents work on an intent.
3+description: Have g1t's own agents work on an issue.
44 ---
55
6−g1t can do the work itself. On an open intent, **Run g1t agents** starts up
7−to five agents at once. Each gets its own sandbox and its own fork, works
8−independently, and reports back as it goes.
6+g1t can do the work itself. On an open issue, **Assign g1t agents** starts
7+up to five agents at once. Each opens its own pull request for the issue,
8+works in its own sandbox and its own fork, and reports back as it goes.
99
1010 This is in preview and limited to selected accounts. Everyone can
1111 [bring their own agent](/guides/bring-your-own-agent/) today.
1212
13−## Starting a run
13+## Assigning agents
1414
15−1. Open an intent on a repository.
16−2. In **Run g1t agents**, choose how many agents to race and which model
17− they use.
18−3. Optionally add guidance for this run, on top of the intent's brief.
19−4. Choose **Run**.
15+1. Open an issue on a repository.
16+2. In **Assign g1t agents**, choose how many agents to put on it and which
17+ model they use.
18+3. Optionally add guidance for this run, on top of the issue's description.
19+4. Choose **Start**.
2020
21−Each agent appears as an attempt on the intent within a few seconds. The
22−page updates on its own while they work.
21+Each agent appears as a draft pull request on the issue within a few
22+seconds. The page updates on its own while they work.
2323
2424 ## What an agent does
2525
26−1. Clones its attempt's fork.
27−2. Reads the code and makes the change the intent asks for.
26+1. Clones its pull request's fork.
27+2. Reads the code and makes the change the issue asks for.
2828 3. Commits its work.
29−4. Pushes to the fork and submits the attempt with a summary.
29+4. Pushes to the fork and marks the pull request ready for review, with a
30+ summary as its description.
3031
31−Everything it reads, runs and decides is recorded in the attempt's
32+Everything it reads, runs and decides is recorded in the pull request's
3233 **Session** as it happens. The **Changes** tab shows the resulting diff.
3334
34−## Choosing between attempts
35+If an agent fails, or finishes without changing anything, its pull request
36+is closed and its session says why.
3537
36−Open each attempt, read its summary and its changes, and ship the one you
37−want. Shipping lands it on `main` and closes the intent. See
38−[shipping](/concepts/overview/#shipping) for what happens when `main` has moved.
38+## Choosing between pull requests
3939
40+Open each pull request, read its description and its changes, and merge the
41+one you want. Merging lands it on `main` and closes the issue, which records
42+that pull request as the one that resolved it. The other pull requests for
43+the issue close as superseded. See
44+[merging](/concepts/overview/#merging) for what happens when `main` has moved.
45+
4046 ## Choosing a model
4147
42−When you start a run you pick how much model to spend on it:
48+When you assign agents you pick how much model to spend:
4349
4450 | Choice | Model today | Use it for |
4551 | --- | --- | --- |
4753 | **Deep** | Claude Opus 5.5 | Hard problems that need the strongest reasoning. Slower and costlier. |
4854 | **Fast** | Claude Haiku 4.5 | Small, well-defined changes. |
4955
50−The menu always shows which model each choice runs on, and every attempt's
51−session opens with a note naming the model that produced it. You are paying
52−for model usage, so you can always see what you are getting.
56+The menu always shows which model each choice runs on, and every pull
57+request's session opens with a note naming the model that produced it. You
58+are paying for model usage, so you can always see what you are getting.
5359
5460 The choices keep their names when the model behind one is upgraded, so
55−intents and automations that say "Balanced" keep working. An agent's
56−attempt carries the label `g1t-agent`.
61+automations that say "Balanced" keep working. A pull request made by a g1t
62+agent carries the label `g1t-agent`, and its commits are authored by
63+`g1t agent`.
5764
5865 ## How model traffic is routed
5966
7077 | `AGENT_MODELS` | The choices offered, in order, each mapped to a provider's model. |
7178 | `AI_GATEWAY_ID` | The gateway to route through. Empty sends requests to the provider directly. |
7279
80+## What a sandbox has
81+
82+Git, common shell tools, and toolchains for Node.js, Python, Go and Rust, so
83+an agent can build and test most projects. If your project needs something
84+else, the agent will say in its summary what it could not run.
85+
7386 ## Limits in the preview
7487
75−- Sandboxes have git and common shell tools, but not every language's
76− toolchain. An agent may not be able to build or test your project, and
77− will say so in its summary.
78−- An agent is given one fork and the intent. Its credential, though, is
79− your account's for the length of the run; credentials limited to the
80− attempt are planned.
88+- An agent is given one fork and the issue. Its credential, though, is your
89+ account's for the length of the run; credentials limited to the pull
90+ request are planned.
8191 - A run has two hours. After that its credential expires and it can no
8292 longer push or report.
8393
8494 ## What a sandbox can reach
8595
8696 A sandbox holds one fork and a credential that expires two hours after the
87−run starts.
88−That credential, and the model key the agent runs on, are removed from
89−anything recorded in the session.
97+run starts. That credential, and the model key the agent runs on, are
98+removed from anything recorded in the session.
+5−5
4545 else it looks
4646 exactly like a repository that does not exist, both on the site and to git.
4747
48−## Attempt forks
48+## Pull request forks
4949
50−Each [attempt](/concepts/overview/) has its own remote:
50+Each [pull request](/concepts/overview/#pull-requests) has its own remote:
5151
5252 ```text
53−https://g1t.sh/attempts/<attempt id>.git
53+https://g1t.sh/pulls/<pull request id>.git
5454 ```
5555
56−Only the person who started the attempt can push to it. Pushes to a fork
57−update the attempt's head commit on its page.
56+Only whoever opened the pull request can push to it. Pushes to a fork
57+update the pull request's head commit on its page.
5858
5959 ## Limits
6060
+6−6
33 description: Guides and reference for g1t, the git forge built for AI scale.
44 template: splash
55 hero:
6− tagline: Git for AI scale. Host repositories, state goals as intents, and let any number of agents attempt them in parallel.
6+ tagline: Git for AI scale. Host repositories, track work as issues, and let any number of agents open pull requests for them in parallel.
77 actions:
88 - text: Quickstart
99 link: /quickstart/
1818
1919 <CardGrid>
2020 <Card title="Start here" icon="rocket">
21− Create an account, push a repository and put an agent on an intent in a
21+ Create an account, push a repository and put an agent on an issue in a
2222 few minutes.
2323
2424 [Quickstart](/quickstart/)
2525 </Card>
2626 <Card title="Understand the model" icon="puzzle">
27− Intents, attempts, sessions and shipping, and how they relate to the pull
28− requests you already know.
27+ Issues, pull requests and sessions, and how g1t keeps track of which pull
28+ request resolved an issue when many were made for it.
2929
3030 [Concepts](/concepts/overview/)
3131 </Card>
3232 <Card title="Let g1t do the work" icon="star">
33− Run g1t's own agents on an intent, each in a sandbox, and ship the best
34− result.
33+ Assign g1t's own agents to an issue, each in a sandbox, and merge the
34+ best pull request.
3535
3636 [g1t agents](/guides/g1t-agents/)
3737 </Card>
+25−13
44 ---
55
66 g1t is a git forge built for agents. You host repositories on it the way you
7−would anywhere else, and you describe work as **intents** that any number of
8−agents can attempt in parallel.
7+would anywhere else, track work as **issues**, and let any number of agents
8+open **pull requests** for the same issue in parallel.
99
1010 This page takes you from nothing to an agent working on your repository.
1111
4646 You can also create an empty repository from the **+** button in the header,
4747 and choose its workspace and whether it is public or private.
4848
49−## 4. Open an intent
49+## 4. Open an issue
5050
51−On the repository, open the **Intents** tab and choose **New intent**. Write:
51+On the repository, open the **Issues** tab and choose **New issue**. Write:
5252
53−- **Goal**: one line, such as "Make the parser streaming".
54−- **Brief**: what an agent needs to do the work.
55−- **Acceptance checks**: commands that must pass, one per line.
53+- **Title**: one line, such as "Parser drops the last line of a file".
54+- **Description**: what is wrong or wanted. An agent works from this.
55+- **Labels**: what kind of issue it is, such as `bug` or `feature`.
56+- **Acceptance checks**: commands that should pass, one per line.
5657
5758 ## 5. Put an agent on it
5859
6364 --header "Authorization: Bearer $G1T_TOKEN"
6465 ```
6566
66−Then ask it to work on the intent:
67+Then ask it to work on the issue:
68+
69+> Work on issue 1 of `<workspace>/my-project` on g1t. Open a pull request for
70+> it and record your session as you go.
71+
72+The agent opens a draft pull request, which comes with its own fork of the
73+repository. It pushes its commits there and marks the pull request ready
74+when it is done. You will find it on the issue's page, with its session and
75+its diff.
6776
68−> Look at the open intents on `<workspace>/my-project` on g1t, start an attempt
69−> on the first one, and record your session as you go.
77+## 6. Merge
7078
71−The agent gets its own fork of the repository, pushes its commits there, and
72−its attempt appears on the intent's page with its session.
79+Open the pull request, read **Changes** and **Session**, and choose
80+**Merge**. Its commits land on `main` and the issue closes, recording which
81+pull request resolved it.
7382
83+Ask more than one agent and you get more than one pull request for the same
84+issue. Merge the one you want; the others close as superseded.
85+
7486 ## Next
7587
76−- [Concepts](/concepts/overview/) explains intents, attempts and sessions.
88+- [Concepts](/concepts/overview/) explains issues, pull requests and sessions.
7789 - [Connect an agent](/guides/bring-your-own-agent/) lists every tool an agent can call.
7890 - [API](/reference/api/) documents the REST endpoints.
+63−25
5151 | 409 | `conflict` | The request conflicts with the current state. |
5252 | 422 | `invalid` | The input is not valid. |
5353
54−## Repositories
54+## Accounts and repositories
55+
56+In paths, `{owner}` is the workspace that owns the repository.
5557
5658 | Method | Path | |
5759 | --- | --- | --- |
60+| `GET` | `/v1/user` | The account the token belongs to, and its workspaces. |
61+| `POST` | `/v1/workspaces` | Create a workspace. Body: `slug`, `name`. |
5862 | `GET` | `/v1/repos?q=` | Repositories you can see. |
59−| `POST` | `/v1/repos` | Create one. Body: `name`, `description`, `private`. |
63+| `POST` | `/v1/repos` | Create one. Body: `workspace`, `name`, `description`, `private`. |
6064 | `GET` | `/v1/repos/{owner}/{name}` | One repository. |
6165 | `GET` | `/v1/repos/{owner}/{name}/events?before=` | Its timeline, newest first. |
6266
63−## Intents
67+## Issues
68+
69+Issues and pull requests share one sequence of numbers per repository.
6470
6571 | Method | Path | |
6672 | --- | --- | --- |
67−| `GET` | `/v1/repos/{owner}/{name}/intents?status=` | Intents on a repository. |
68−| `POST` | `/v1/repos/{owner}/{name}/intents` | Open one. Body: `title`, `brief`, `checks`. |
69−| `GET` | `/v1/repos/{owner}/{name}/intents/{number}` | An intent and its attempts. |
73+| `GET` | `/v1/repos/{owner}/{name}/issues?state=&label=` | Issues, newest first. `state` is `open` or `closed`. |
74+| `POST` | `/v1/repos/{owner}/{name}/issues` | Open one. Body: `title`, `body`, `labels`, `checks`. |
75+| `GET` | `/v1/repos/{owner}/{name}/issues/{number}` | An issue, its comments and its pull requests. |
76+| `PATCH` | `/v1/repos/{owner}/{name}/issues/{number}` | Change `title`, `body` or `labels`. |
77+| `POST` | `/v1/repos/{owner}/{name}/issues/{number}/close` | Close. Body: `reason`, `completed` or `not_planned`. |
78+| `POST` | `/v1/repos/{owner}/{name}/issues/{number}/reopen` | Reopen. |
79+| `POST` | `/v1/repos/{owner}/{name}/issues/{number}/comments` | Comment. Body: `body`. The number may be a pull request's. |
80+| `GET` | `/v1/repos/{owner}/{name}/labels` | The labels in use. |
7081
7182 ```sh
72−curl -X POST https://api.g1t.sh/v1/repos/syntaqx/hello/intents \
83+curl -X POST https://api.g1t.sh/v1/repos/syntaqx/hello/issues \
7384 -H "Authorization: Bearer $G1T_TOKEN" \
7485 -H "Content-Type: application/json" \
7586 -d '{
76− "title": "Greet the user by name",
77− "brief": "Take a name from the first argument; fall back to world.",
87+ "title": "Greeting should name the caller",
88+ "body": "Take a name from the first argument; fall back to world.",
89+ "labels": ["feature"],
7890 "checks": ["cargo build"]
7991 }'
8092 ```
8193
82−## Attempts
94+A closed issue says how it was closed. `resolvedBy` is the number of the
95+pull request whose merge closed it:
96+
97+```json
98+{
99+ "issue": { "number": 12, "state": "closed", "reason": "completed", "resolvedBy": 14 },
100+ "pulls": [
101+ { "number": 13, "status": "closed", "supersededBy": 14 },
102+ { "number": 14, "status": "merged", "mergedBy": "syntaqx" }
103+ ],
104+ "comments": []
105+}
106+```
107+
108+## Pull requests
83109
84110 | Method | Path | |
85111 | --- | --- | --- |
86−| `POST` | `/v1/intents/{intent_id}/attempts` | Start one. Body: `agent`. |
87−| `GET` | `/v1/attempts/{attempt_id}` | An attempt and its intent. |
88−| `POST` | `/v1/attempts/{attempt_id}/submit` | Finish. Body: `summary`. |
89−| `POST` | `/v1/attempts/{attempt_id}/abandon` | Give up. |
90−| `GET` | `/v1/attempts/{attempt_id}/changes` | The files it changed, with diffs. |
91−| `POST` | `/v1/attempts/{attempt_id}/ship` | Land it on `main`. Owner only; `409` if `main` has moved. |
112+| `GET` | `/v1/repos/{owner}/{name}/pulls?state=` | Pull requests, newest first. |
113+| `POST` | `/v1/repos/{owner}/{name}/pulls` | Open a draft. Body: `issue`, `title`, `agent`. |
114+| `GET` | `/v1/repos/{owner}/{name}/pulls/{number}` | A pull request, its comments and its issue. |
115+| `GET` | `/v1/repos/{owner}/{name}/pulls/{number}/changes` | The files it changes, with diffs. |
116+| `POST` | `/v1/repos/{owner}/{name}/pulls/{number}/ready` | Mark ready for review. Body: `summary`. |
117+| `POST` | `/v1/repos/{owner}/{name}/pulls/{number}/close` | Close without merging. |
118+| `POST` | `/v1/repos/{owner}/{name}/pulls/{number}/merge` | Land it on `main`. Body: `keep_issue_open`. Workspace members only; `409` if it is a draft or `main` has moved. |
92119
93−Starting an attempt returns the fork's git remote:
120+Opening a pull request returns the git remote of its fork:
94121
95122 ```json
96123 {
97− "attempt": { "id": "att_01…", "number": 1, "status": "working" },
98− "git": { "remote": "https://g1t.sh/attempts/att_01….git" }
124+ "pull": { "id": "pr_01…", "number": 14, "status": "draft", "issue": 12 },
125+ "git": { "remote": "https://g1t.sh/pulls/pr_01….git" }
99126 }
100127 ```
101128
129+`title` defaults to the issue's title, and is required when there is no
130+`issue`.
131+
132+Merging a pull request made for an issue closes the issue and records the
133+pull request in the issue's `resolvedBy`. Other pull requests for that issue
134+that are still a draft or open are closed with `supersededBy` set. Send
135+`"keep_issue_open": true` to merge without any of that.
136+
102137 ## Sessions
103138
104139 | Method | Path | |
105140 | --- | --- | --- |
106−| `GET` | `/v1/attempts/{attempt_id}/session?after=` | Entries after a sequence number. |
107−| `POST` | `/v1/attempts/{attempt_id}/session` | Append. Body: `entries`. |
141+| `GET` | `/v1/repos/{owner}/{name}/pulls/{number}/session?after=` | Entries after a sequence number. |
142+| `POST` | `/v1/repos/{owner}/{name}/pulls/{number}/session` | Append. Body: `entries`. |
108143
109144 ```sh
110−curl -X POST https://api.g1t.sh/v1/attempts/$ATTEMPT/session \
145+curl -X POST https://api.g1t.sh/v1/repos/syntaqx/hello/pulls/14/session \
111146 -H "Authorization: Bearer $G1T_TOKEN" \
112147 -H "Content-Type: application/json" \
113148 -d '{"entries": [{"kind": "message", "text": "Reading src/main.rs."}]}'
115150
116151 Up to 200 entries can be appended per request.
117152
118−## Identifiers
153+## Identifiers and times
119154
120−Ids are [TypeIDs](https://github.com/jetify-com/typeid): a prefix naming the
155+Issues and pull requests are addressed by repository and number. Other ids
156+are [TypeIDs](https://github.com/jetify-com/typeid): a prefix naming the
121157 kind of thing, then a UUIDv7 in base32, for example
122−`att_01jb2k7x9hfq0b3zj0f5s2m8ra`. They sort by creation time.
158+`pr_01jb2k7x9hfq0b3zj0f5s2m8ra`. They sort by creation time.
159+
160+Times are RFC 3339 in UTC, such as `2026-10-01T18:04:11.482Z`.
+1−1
2020 --color-accent-dim: var(--g1t-accent-dim);
2121
2222 --color-info: var(--g1t-info);
23− --color-shipped: var(--g1t-shipped);
23+ --color-merged: var(--g1t-merged);
2424 --color-warn: var(--g1t-warn);
2525 --color-danger: var(--g1t-danger);
2626
+1−1
3333 text='claude mcp add --transport http g1t https://mcp.g1t.sh --header "Authorization: Bearer $G1T_TOKEN"'
3434 />
3535 <p className="mt-2 text-xs text-muted">
36− Connects Claude Code to g1t. Then ask it to work on an intent in{" "}
36+ Connects Claude Code to g1t. Then ask it to work on an issue in{" "}
3737 <span className="font-mono text-fg">{path}</span>.{" "}
3838 <Link to="https://docs.g1t.sh/guides/bring-your-own-agent/" className="text-fg underline underline-offset-4">
3939 More
+1−1
137137 if (files.length === 0) {
138138 return (
139139 <EmptyState title="No changes yet">
140− Nothing has been pushed to this attempt's fork, or it matches the
140+ Nothing has been pushed to this pull request's fork, or it matches the
141141 repository it came from.
142142 </EmptyState>
143143 );
+20−20
4343 const LANES = [
4444 { y: 60, state: "passed" },
4545 { y: 135, state: "failed" },
46− { y: 210, state: "shipped" },
46+ { y: 210, state: "merged" },
4747 { y: 285, state: "passed" },
4848 { y: 360, state: "working" },
4949 ] as const;
5050
5151 /**
52− * The hero: one intent fans out to five attempts, each in its own lane;
53− * the one that ships lands on main.
52+ * The hero: one issue fans out to five pull requests, each in its own
53+ * lane; the one that is merged lands on main.
5454 */
55−export function ArenaIllustration() {
55+export function HeroIllustration() {
5656 return (
5757 <svg
5858 viewBox="0 0 1000 420"
5959 fill="none"
6060 className="w-full"
6161 role="img"
62− aria-label="An intent fans out to five attempts running in parallel; one ships to main."
62+ aria-label="An issue fans out to five pull requests being worked on in parallel; one is merged into main."
6363 >
6464 {/* Faint grid. */}
6565 <g stroke={LINE} strokeWidth="1" opacity="0.35">
7171 ))}
7272 </g>
7373
74− {/* The intent. */}
74+ {/* The issue. */}
7575 <g>
7676 <rect x="24" y="150" width="170" height="120" rx="12" fill={SURFACE} stroke={LINE} />
7777 <text x="42" y="178" fill={MUTED} fontSize="11" fontFamily="var(--font-mono)">
78− intent #12
78+ issue #12
7979 </text>
8080 <rect x="42" y="190" width="120" height="7" rx="3.5" fill={FAINT} />
8181 <rect x="42" y="206" width="134" height="5" rx="2.5" fill={LINE} />
9393 const out = `M194 210C250 210 250 ${y} 310 ${y}`;
9494 const lane = `M310 ${y}H690`;
9595 const back = `M690 ${y}C750 ${y} 750 210 806 210`;
96− const ships = state === "shipped";
96+ const merges = state === "merged";
9797 const failed = state === "failed";
9898 const working = state === "working";
99− const stroke = ships ? ACCENT : failed ? LINE : FAINT;
99+ const stroke = merges ? ACCENT : failed ? LINE : FAINT;
100100 const end = working ? 520 : failed ? 480 : 690;
101101 return (
102102 <g key={y}>
121121 d={back}
122122 stroke={stroke}
123123 strokeWidth="1.5"
124− strokeDasharray={ships ? undefined : "3 6"}
125− opacity={ships ? 1 : 0.5}
124+ strokeDasharray={merges ? undefined : "3 6"}
125+ opacity={merges ? 1 : 0.5}
126126 />
127127 )}
128128
129− <text x="310" y={y - 14} fill={ships ? ACCENT : MUTED} fontSize="11" fontFamily="var(--font-mono)">
130− attempt {i + 1}
129+ <text x="310" y={y - 14} fill={merges ? ACCENT : MUTED} fontSize="11" fontFamily="var(--font-mono)">
130+ #{13 + i}
131131 </text>
132132
133133 {/* Commits along the lane. */}
140140 cy={y}
141141 r="4"
142142 fill={SURFACE}
143− stroke={ships ? ACCENT : MUTED}
143+ stroke={merges ? ACCENT : MUTED}
144144 strokeWidth="1.5"
145145 />
146146 ))}
164164 path={`${out}H${end}`}
165165 dur={`${3.2 + i * 0.35}s`}
166166 begin={`${i * 0.5}s`}
167− color={ships ? ACCENT : MUTED}
167+ color={merges ? ACCENT : MUTED}
168168 />
169− {ships && <Pulse path={`${lane}${back.replace("M690 " + y, "")}`} dur="2.4s" begin="1.2s" r={3.5} />}
169+ {merges && <Pulse path={`${lane}${back.replace("M690 " + y, "")}`} dur="2.4s" begin="1.2s" r={3.5} />}
170170 </g>
171171 );
172172 })}
202202 return `M${cx} ${cy - h}L${cx + w} ${cy}L${cx} ${cy + h}L${cx - w} ${cy}Z`;
203203 }
204204
205−/** A brief with acceptance checks ticking off. */
206−export function IntentIllustration() {
205+/** An issue with acceptance checks ticking off. */
206+export function IssueIllustration() {
207207 return (
208208 <svg viewBox="0 0 320 180" fill="none" className="w-full" aria-hidden="true">
209209 <path d="M0 150H320" stroke={LINE} strokeDasharray="2 8" />
363363 );
364364 }
365365
366−/** Attempts queued in order, landing on main one at a time. */
367−export function ShipIllustration() {
366+/** Pull requests queued in order, landing on main one at a time. */
367+export function MergeIllustration() {
368368 return (
369369 <svg viewBox="0 0 320 180" fill="none" className="w-full" aria-hidden="true">
370370 <path d="M20 120H300" stroke={ACCENT} strokeWidth="1.5" />
+25−22
66
77 import {
88 AgentsIllustration,
9− ArenaIllustration,
9+ HeroIllustration,
1010 EventsIllustration,
1111 ForkIllustration,
12− IntentIllustration,
12+ IssueIllustration,
1313 SessionIllustration,
14− ShipIllustration,
14+ MergeIllustration,
1515 } from "./illustrations";
1616 import { RepoList } from "./repo-list";
1717 import { ButtonLink, CopyLine } from "./ui";
5656 const COMPARISON: [string, string, string][] = [
5757 [
5858 "Unit of work",
59− "A pull request: one author, one change",
60− "An intent: one goal, any number of attempts. One attempt is a pull request",
59+ "One issue, usually one pull request",
60+ "One issue, as many pull requests as you have agents, and a record of which one was merged",
6161 ],
6262 [
6363 "Where agents work",
6464 "Branches and local worktrees",
65− "A server-side fork per attempt",
65+ "A server-side fork per pull request",
6666 ],
6767 [
6868 "Why a change was made",
103103 </h1>
104104 <p className="mx-auto mt-6 max-w-xl animate-fade-up text-lg leading-7 text-muted text-balance">
105105 Git was built for people taking turns. g1t is a forge for thousands
106− of agents working on the same code at once: every attempt isolated,
106+ of agents working on the same code at once: every change isolated,
107107 every decision recorded, every change landed in order.
108108 </p>
109109 <div className="mt-8 flex animate-fade-up flex-wrap items-center justify-center gap-3">
116116 </ButtonLink>
117117 </div>
118118 <div className="mx-auto mt-14 max-w-5xl">
119− <ArenaIllustration />
119+ <HeroIllustration />
120120 </div>
121121 </div>
122122 </section>
130130 </h2>
131131 <div className="mt-12 grid gap-4 md:grid-cols-6">
132132 <FeatureCard
133− title="Start with an intent"
134− illustration={<IntentIllustration />}
133+ title="Start with an issue"
134+ illustration={<IssueIllustration />}
135135 >
136− Write the goal and the checks that prove it is done. Think of it as
137− an issue that can hold any number of competing pull requests.
136+ A bug, a feature, a report from your error tracker. Label it, add
137+ the checks that prove it is done, and put as many agents on it as
138+ you like.
138139 </FeatureCard>
139140 <FeatureCard
140− title="A fork for every attempt"
141+ title="A fork for every pull request"
141142 illustration={<ForkIllustration />}
142143 >
143144 Each agent gets its own copy of the repository the moment it starts.
147148 title="The session stays with the code"
148149 illustration={<SessionIllustration />}
149150 >
150− Prompts, reasoning and tool calls are recorded against the attempt
151− and the commit they produced, so you can see why, not only what.
151+ Prompts, reasoning and tool calls are recorded against the pull
152+ request and the commit they produced, so you can see why, not only
153+ what.
152154 </FeatureCard>
153155 <FeatureCard
154156 title="Bring any agent"
160162 </FeatureCard>
161163 <FeatureCard
162164 title="Converge on main"
163− illustration={<ShipIllustration />}
165+ illustration={<MergeIllustration />}
164166 wide
165167 >
166− However many attempts are in flight, changes reach main one at a
167− time and in order. An attempt that has fallen behind is told, and
168− catches up before it lands.
168+ However many pull requests are in flight, changes reach main one
169+ at a time and in order. Merge one and its issue closes, naming it;
170+ the others for that issue close as superseded.
169171 </FeatureCard>
170172 </div>
171173 </section>
179181 One command to put your agent to work
180182 </h2>
181183 <p className="mt-4 max-w-md leading-7 text-muted">
182− Add g1t to Claude Code and it can list intents, claim one, get a
183− fork to push to, and record its session as it goes. No plugin, no
184+ Add g1t to Claude Code and it can read the open issues, open a
185+ pull request with a fork to push to, and record its session as it
186+ goes. No plugin, no
184187 wrapper.
185188 </p>
186189 <div className="mt-6">
198201 <CopyLine prompt text="git clone https://g1t.sh/syntaqx/g1t.git" />
199202 <CopyLine
200203 prompt
201− text="curl https://api.g1t.sh/v1/repos/syntaqx/g1t/intents"
204+ text="curl https://api.g1t.sh/v1/repos/syntaqx/g1t/issues"
202205 />
203206 <div className="pt-2">
204207 <EventsIllustration />
+1−1
1−/** Three lanes converging on one node: many attempts, one ships. */
1+/** Three lanes converging on one node: many pull requests, one merged. */
22 export function Mark({ className }: { className?: string }) {
33 return (
44 <svg
+0−22
7474 return children ? <p className="text-sm text-danger">{children}</p> : null;
7575 }
7676
77−const STATUS_STYLES: Record<string, string> = {
78− open: "border-accent/30 bg-accent/10 text-accent",
79− working: "border-accent/30 bg-accent/10 text-accent",
80− submitted: "border-info/30 bg-info/10 text-info",
81− shipped: "border-shipped/30 bg-shipped/10 text-shipped",
82−};
83−
84−/** A status pill; unknown statuses render muted. */
85−export function Status({ value }: { value: string }) {
86− const style = STATUS_STYLES[value] ?? "border-line bg-surface text-muted";
87− return (
88− <span
89− className={`inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium ${style}`}
90− >
91− {value === "working" && (
92− <span className="size-1.5 animate-pulse rounded-full bg-accent" />
93− )}
94− {value}
95− </span>
96− );
97−}
98−
9977 /** A small outlined label. */
10078 export function Pill({ children }: { children: ReactNode }) {
10179 return (
+198−0
1+import {
2+ CircleCheck,
3+ CircleDot,
4+ CircleSlash,
5+ GitMerge,
6+ GitPullRequest,
7+ GitPullRequestClosed,
8+ GitPullRequestDraft,
9+} from "lucide-react";
10+import type { CSSProperties, ReactNode } from "react";
11+import { Form, Link } from "react-router";
12+
13+import type { Comment, Issue, Pull, State } from "@g1t/contracts";
14+
15+import { Markdown } from "./markdown";
16+import { Avatar, Button, Textarea, TimeAgo } from "./ui";
17+
18+/** Hues for the labels every repository starts with. */
19+const LABEL_HUES: Record<string, number> = {
20+ bug: 25,
21+ feature: 150,
22+ docs: 240,
23+ chore: 80,
24+ question: 310,
25+};
26+
27+/** A stable hue for any other label, from its name. */
28+function hueFor(name: string): number {
29+ let hash = 0;
30+ for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) % 360;
31+ return hash;
32+}
33+
34+export function Label({ name }: { name: string }) {
35+ const hue = LABEL_HUES[name] ?? hueFor(name);
36+ const style: CSSProperties = {
37+ color: `oklch(0.84 0.11 ${hue})`,
38+ borderColor: `oklch(0.84 0.11 ${hue} / 0.35)`,
39+ backgroundColor: `oklch(0.84 0.11 ${hue} / 0.1)`,
40+ };
41+ return (
42+ <span
43+ style={style}
44+ className="inline-flex shrink-0 items-center rounded-full border px-2 py-px text-xs font-medium"
45+ >
46+ {name}
47+ </span>
48+ );
49+}
50+
51+export function IssueIcon({
52+ issue,
53+ size = 16,
54+}: {
55+ issue: Pick<Issue, "state" | "reason">;
56+ size?: number;
57+}) {
58+ if (issue.state === "open") return <CircleDot size={size} className="shrink-0 text-accent" />;
59+ if (issue.reason === "not_planned") {
60+ return <CircleSlash size={size} className="shrink-0 text-faint" />;
61+ }
62+ return <CircleCheck size={size} className="shrink-0 text-merged" />;
63+}
64+
65+export function PullIcon({ status, size = 16 }: { status: Pull["status"]; size?: number }) {
66+ if (status === "merged") return <GitMerge size={size} className="shrink-0 text-merged" />;
67+ if (status === "closed") {
68+ return <GitPullRequestClosed size={size} className="shrink-0 text-danger" />;
69+ }
70+ if (status === "draft") {
71+ return <GitPullRequestDraft size={size} className="shrink-0 text-faint" />;
72+ }
73+ return <GitPullRequest size={size} className="shrink-0 text-accent" />;
74+}
75+
76+const PILL = "inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium";
77+
78+/** The large state badge at the top of an issue. */
79+export function IssueState({ issue }: { issue: Pick<Issue, "state" | "reason"> }) {
80+ const style =
81+ issue.state === "open"
82+ ? "border-accent/30 bg-accent/10 text-accent"
83+ : issue.reason === "not_planned"
84+ ? "border-line bg-surface text-muted"
85+ : "border-merged/30 bg-merged/10 text-merged";
86+ return (
87+ <span className={`${PILL} ${style}`}>
88+ <IssueIcon issue={issue} size={14} />
89+ {issue.state === "open"
90+ ? "Open"
91+ : issue.reason === "not_planned"
92+ ? "Closed as not planned"
93+ : "Closed"}
94+ </span>
95+ );
96+}
97+
98+const PULL_STYLES: Record<Pull["status"], string> = {
99+ draft: "border-line bg-surface text-muted",
100+ open: "border-accent/30 bg-accent/10 text-accent",
101+ merged: "border-merged/30 bg-merged/10 text-merged",
102+ closed: "border-danger/30 bg-danger/10 text-danger",
103+};
104+
105+const PULL_NAMES: Record<Pull["status"], string> = {
106+ draft: "Draft",
107+ open: "Open",
108+ merged: "Merged",
109+ closed: "Closed",
110+};
111+
112+export function PullState({ status }: { status: Pull["status"] }) {
113+ return (
114+ <span className={`${PILL} ${PULL_STYLES[status]}`}>
115+ <PullIcon status={status} size={14} />
116+ {PULL_NAMES[status]}
117+ </span>
118+ );
119+}
120+
121+/** The open and closed switch above a list. `to` is the list's own path. */
122+export function StateTabs({
123+ to,
124+ state,
125+ query = "",
126+ action,
127+}: {
128+ to: string;
129+ state: State;
130+ /** Other query parameters to keep, e.g. `label=bug`. */
131+ query?: string;
132+ action?: ReactNode;
133+}) {
134+ const link = (value: State) =>
135+ `${to}?${[value === "open" ? "" : "state=closed", query].filter(Boolean).join("&")}`;
136+ const tab = (value: State, label: string) => (
137+ <Link
138+ to={link(value)}
139+ className={
140+ "rounded-md px-3 py-1.5 text-sm transition-colors " +
141+ (state === value ? "bg-raised font-medium text-fg" : "text-muted hover:text-fg")
142+ }
143+ >
144+ {label}
145+ </Link>
146+ );
147+ return (
148+ <div className="flex flex-wrap items-center justify-between gap-3">
149+ <div className="flex gap-1 rounded-lg border border-line p-1">
150+ {tab("open", "Open")}
151+ {tab("closed", "Closed")}
152+ </div>
153+ {action}
154+ </div>
155+ );
156+}
157+
158+/** Comments in order, then the box to add one. */
159+export function Comments({
160+ comments,
161+ canComment,
162+}: {
163+ comments: Comment[];
164+ canComment: boolean;
165+}) {
166+ return (
167+ <div className="space-y-4">
168+ {comments.map((comment) => (
169+ <article key={comment.id} className="rounded-xl border border-line bg-surface">
170+ <header className="flex items-center gap-2 border-b border-line px-4 py-2 text-sm text-muted">
171+ <Avatar name={comment.author.username} size={18} />
172+ <span className="font-medium text-fg">{comment.author.username}</span>
173+ <span>
174+ commented <TimeAgo at={comment.createdAt} />
175+ </span>
176+ </header>
177+ <div className="px-4 py-3">
178+ <Markdown source={comment.body} />
179+ </div>
180+ </article>
181+ ))}
182+ {canComment ? (
183+ <Form method="post" className="space-y-2" key={comments.length}>
184+ <input type="hidden" name="action" value="comment" />
185+ <Textarea name="body" rows={3} required placeholder="Leave a comment. Markdown works." />
186+ <Button type="submit">Comment</Button>
187+ </Form>
188+ ) : (
189+ <p className="text-sm text-muted">
190+ <Link to="/login" className="text-fg underline underline-offset-4">
191+ Sign in
192+ </Link>{" "}
193+ to comment.
194+ </p>
195+ )}
196+ </div>
197+ );
198+}
+5−4
2020 route("tree/:ref/*", "routes/repo/tree.tsx"),
2121 route("blob/:ref/*", "routes/repo/blob.tsx"),
2222 route("commits", "routes/repo/commits.tsx"),
23− route("intents", "routes/repo/intents.tsx"),
24− route("intents/new", "routes/repo/intent-new.tsx"),
25− route("intents/:number", "routes/repo/intent.tsx"),
26− route("attempts/:id", "routes/repo/attempt.tsx"),
23+ route("issues", "routes/repo/issues.tsx"),
24+ route("issues/new", "routes/repo/issue-new.tsx"),
25+ route("issues/:number", "routes/repo/issue.tsx"),
26+ route("pulls", "routes/repo/pulls.tsx"),
27+ route("pull/:number", "routes/repo/pull.tsx"),
2728 ]),
2829 ] satisfies RouteConfig;
+29−28
99 ButtonLink,
1010 CopyLine,
1111 EmptyState,
12− Status,
1312 TimeAgo,
1413 } from "../components/ui";
14+import { PullIcon } from "../components/work";
1515 import { repos as reposApi, work } from "../lib/services.server";
1616 import { getViewer } from "../lib/session.server";
1717
2121 {
2222 name: "description",
2323 content:
24− "A git forge for thousands of agents working on the same code at once: every attempt isolated, every decision recorded, every change landed in order. Open source, built on Cloudflare.",
24+ "A git forge for thousands of agents working on the same code at once: every change isolated, every decision recorded, every change landed in order. Open source, built on Cloudflare.",
2525 },
2626 ];
2727 }
3232 if (viewer?.verified && (viewer.workspaces ?? []).length === 0) {
3333 throw redirect("/workspaces/new");
3434 }
35− const [repos, attempts] = await Promise.all([
35+ const [repos, pulls] = await Promise.all([
3636 reposApi.list(viewer, { memberOnly: Boolean(viewer) }),
37− work.listActiveAttempts(viewer),
37+ work.listActivePulls(viewer),
3838 ]);
39− // Mission control links to each attempt under its repo.
40− const attemptRepos = await Promise.all(
41− attempts.map(({ attempt }) => reposApi.getById(attempt.repoId, viewer)),
39+ // Mission control links to each pull request under its repo.
40+ const pullRepos = await Promise.all(
41+ pulls.map(({ pull }) => reposApi.getById(pull.repoId, viewer)),
4242 );
4343 return {
4444 viewer,
4545 repos,
46− active: attempts.flatMap((item, i) => {
47− const repo = attemptRepos[i];
46+ active: pulls.flatMap((item, i) => {
47+ const repo = pullRepos[i];
4848 return repo.ok ? [{ ...item, repo: repo.value }] : [];
4949 }),
5050 };
5454 const { viewer, repos, active } = loaderData;
5555 if (!viewer) return <Landing repos={repos} />;
5656
57− const working = active.filter(({ attempt }) => attempt.status === "working");
57+ const working = active.filter(({ pull }) => pull.status === "draft");
5858 return (
5959 <main className="mx-auto grid max-w-6xl gap-10 px-4 py-10 lg:grid-cols-[1fr_20rem]">
6060 <div className="min-w-0 space-y-10">
6767 </h1>
6868 <p className="text-sm text-muted">
6969 {working.length === 0
70− ? "Nothing is running right now."
71− : `${working.length} ${working.length === 1 ? "attempt is" : "attempts are"} running.`}
70+ ? "Nothing is being worked on right now."
71+ : `${working.length} ${working.length === 1 ? "pull request is" : "pull requests are"} being worked on.`}
7272 </p>
7373 </div>
7474 </div>
7878 <h2 className="text-sm font-medium text-muted">In progress</h2>
7979 <div className="mt-3">
8080 {active.length === 0 ? (
81− <EmptyState title="No attempts in progress">
82− Open an intent on a repository and start an attempt, or point
83− your agent at one.
81+ <EmptyState title="No pull requests in progress">
82+ Open an issue on a repository and put an agent on it, or point
83+ your own agent at one.
8484 </EmptyState>
8585 ) : (
8686 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
87− {active.map(({ attempt, intent, repo }) => (
88− <li key={attempt.id}>
87+ {active.map(({ pull, repo }) => (
88+ <li key={pull.id}>
8989 <Link
90− to={`/${repo.namespace}/${repo.name}/attempts/${attempt.id}`}
91− className="flex items-center gap-4 px-4 py-3 transition-colors hover:bg-raised"
90+ to={`/${repo.namespace}/${repo.name}/pull/${pull.number}`}
91+ className="flex items-center gap-3 px-4 py-3 transition-colors hover:bg-raised"
9292 >
93+ <PullIcon status={pull.status} />
9394 <span className="min-w-0 grow">
94− <span className="block truncate font-medium">
95− {intent.title}
96− </span>
95+ <span className="block truncate font-medium">{pull.title}</span>
9796 <span className="font-mono text-xs text-muted">
98− {repo.namespace}/{repo.name} #{intent.number} · attempt{" "}
99− {attempt.number} · {attempt.agent}
97+ {repo.namespace}/{repo.name}#{pull.number}
98+ {pull.issue != null && ` · for #${pull.issue}`} · {pull.agent}
10099 </span>
101100 </span>
102− <span className="shrink-0 text-xs text-faint">
103− <TimeAgo at={attempt.updatedAt} />
101+ <span className="shrink-0 text-xs text-muted">
102+ {pull.status === "draft" ? "In progress" : "Ready for review"}
103+ </span>
104+ <span className="w-14 shrink-0 text-right text-xs text-faint">
105+ <TimeAgo at={pull.updatedAt} />
104106 </span>
105− <Status value={attempt.status} />
106107 </Link>
107108 </li>
108109 ))}
145146 <div className="rounded-xl border border-line bg-surface p-5">
146147 <h2 className="font-medium">Explore</h2>
147148 <p className="mt-1.5 text-sm text-muted">
148− Browse public repositories and the intents open on them.
149+ Browse public repositories and the issues open on them.
149150 </p>
150151 <Link
151152 to="/explore"
+0−345
1−import {
2− Bot,
3− ChevronRight,
4− FileDiff,
5− GitCommitHorizontal,
6− MessagesSquare,
7− Rocket,
8− StickyNote,
9− User,
10− Wrench,
11−} from "lucide-react";
12−import { useEffect } from "react";
13−import { Form, Link, useRevalidator } from "react-router";
14−
15−import type { Comparison, SessionEntry } from "@g1t/contracts";
16−
17−import type { Route } from "./+types/attempt";
18−import { DiffView } from "../../components/diff-view";
19−import { Markdown } from "../../components/markdown";
20−import {
21− Button,
22− CopyLine,
23− EmptyState,
24− ErrorText,
25− Status,
26− Textarea,
27− TimeAgo,
28−} from "../../components/ui";
29−import { repos, work } from "../../lib/services.server";
30−import {
31− assertSameOrigin,
32− getViewer,
33− requireUser,
34− unwrap,
35−} from "../../lib/session.server";
36−
37−const REFRESH_MS = 4000;
38−const EMPTY_COMPARISON: Comparison = { base: null, head: "", files: [], truncated: false };
39−
40−export function meta({ loaderData }: Route.MetaArgs) {
41− return [
42− {
43− title: loaderData
44− ? `Attempt ${loaderData.attempt.number} · ${loaderData.intent.title} · g1t`
45− : "g1t",
46− },
47− ];
48−}
49−
50−export async function loader({ params, context, request }: Route.LoaderArgs) {
51− const viewer = getViewer(context);
52− const [found, session] = await Promise.all([
53− work.getAttempt(params.id, viewer),
54− work.readSession(params.id, viewer),
55− ]);
56− const detail = unwrap(found);
57− const repo = await repos.getById(detail.attempt.repoId, viewer);
58− const showChanges = new URL(request.url).searchParams.get("tab") === "changes";
59− const comparison = showChanges
60− ? await repos.compare(
61− detail.attempt.forkRepoId,
62− viewer,
63− detail.attempt.landedBase,
64− )
65− : null;
66− return {
67− ...detail,
68− // Null on the session tab; an empty comparison if it could not be made.
69− comparison: comparison && (comparison.ok ? comparison.value : EMPTY_COMPARISON),
70− session: unwrap(session),
71− viewer,
72− // Members of the repository's workspace can land an attempt.
73− canShip:
74− repo.ok &&
75− (viewer?.workspaces ?? []).some(
76− (membership) => membership.slug === repo.value.namespace,
77− ),
78− defaultBranch: repo.ok ? repo.value.defaultBranch : "main",
79− };
80−}
81−
82−export async function action({ request, params, context }: Route.ActionArgs) {
83− assertSameOrigin(request);
84− const user = requireUser(context, request);
85− const form = await request.formData();
86− const action = form.get("action");
87− const result =
88− action === "ship"
89− ? await work.shipAttempt(user, params.id)
90− : action === "abandon"
91− ? await work.abandonAttempt(user, params.id)
92− : await work.submitAttempt(
93− user,
94− params.id,
95− String(form.get("summary") ?? ""),
96− );
97− return result.ok ? null : { error: result.error.message };
98−}
99−
100−/** One step of the session, on the timeline's rail. */
101−function TabLink({
102− to,
103− active,
104− children,
105−}: {
106− to: string;
107− active: boolean;
108− children: React.ReactNode;
109−}) {
110− return (
111− <Link
112− to={to}
113− preventScrollReset
114− className={
115− "-mb-px flex items-center gap-2 border-b-2 px-1 pb-2.5 text-sm transition-colors " +
116− (active
117− ? "border-accent font-medium text-fg"
118− : "border-transparent text-muted hover:text-fg")
119− }
120− >
121− {children}
122− </Link>
123− );
124−}
125−
126−function Entry({ entry, agent }: { entry: SessionEntry; agent: string }) {
127− const isTool = entry.kind === "tool_call" || entry.kind === "tool_result";
128− const Icon =
129− entry.kind === "prompt"
130− ? User
131− : entry.kind === "note"
132− ? StickyNote
133− : isTool
134− ? Wrench
135− : Bot;
136− return (
137− <li className="relative pl-10">
138− <span
139− className={`absolute top-0.5 left-0 flex size-7 items-center justify-center rounded-full border bg-bg ${
140− entry.kind === "prompt"
141− ? "border-accent/50 text-accent"
142− : "border-line text-faint"
143− }`}
144− >
145− <Icon size={14} />
146− </span>
147− {isTool ? (
148− <details className="group rounded-lg border border-line bg-surface">
149− <summary className="flex cursor-pointer list-none items-center gap-2 px-3 py-1.5 text-sm">
150− <ChevronRight
151− size={14}
152− className="text-faint transition-transform group-open:rotate-90"
153− />
154− <span className="font-mono text-xs text-accent">
155− {entry.tool ?? "tool"}
156− </span>
157− <span className="truncate font-mono text-xs text-muted">
158− {entry.kind === "tool_result" ? "→ " : ""}
159− {entry.text.split("\n")[0]}
160− </span>
161− </summary>
162− <pre className="overflow-x-auto border-t border-line p-3 font-mono text-xs whitespace-pre-wrap text-muted">
163− {entry.text}
164− </pre>
165− </details>
166− ) : (
167− <div>
168− <p className="text-xs font-medium text-faint">
169− {entry.kind === "prompt"
170− ? "Prompt"
171− : entry.kind === "note"
172− ? "Note"
173− : agent}
174− </p>
175− {entry.kind === "message" ? (
176− <div className="mt-1">
177− <Markdown source={entry.text} />
178− </div>
179− ) : (
180− <p
181− className={`mt-1 text-[0.9375rem] leading-relaxed wrap-break-word whitespace-pre-wrap ${
182− entry.kind === "prompt" ? "font-medium" : ""
183− }`}
184− >
185− {entry.text}
186− </p>
187− )}
188− </div>
189− )}
190− {entry.commit && (
191− <p className="mt-1.5 flex items-center gap-1 font-mono text-xs text-faint">
192− <GitCommitHorizontal size={12} />
193− {entry.commit.slice(0, 7)}
194− </p>
195− )}
196− </li>
197− );
198−}
199−
200−export default function AttemptPage({
201− loaderData,
202− actionData,
203− params,
204−}: Route.ComponentProps) {
205− const { attempt, intent, session, viewer, canShip, defaultBranch, comparison } =
206− loaderData;
207− const base = `/${params.owner}/${params.repo}`;
208− const here = base + "/attempts/" + attempt.id;
209− const remote = `https://g1t.sh/${attempt.fork.namespace}/${attempt.fork.name}.git`;
210− const mine = viewer?.id === attempt.startedBy.id;
211− const active = attempt.status === "working" || attempt.status === "submitted";
212−
213− // Follow a running attempt without a manual reload.
214− const revalidator = useRevalidator();
215− const working = attempt.status === "working";
216− useEffect(() => {
217− if (!working) return;
218− const timer = setInterval(() => {
219− if (document.visibilityState === "visible") revalidator.revalidate();
220− }, REFRESH_MS);
221− return () => clearInterval(timer);
222− }, [working, revalidator]);
223−
224− return (
225− <div className="grid gap-8 lg:grid-cols-[1fr_19rem]">
226− <div className="min-w-0">
227− <p className="text-sm text-muted">
228− <Link
229− to={`${base}/intents/${intent.number}`}
230− className="hover:text-fg hover:underline"
231− >
232− {intent.title} <span className="text-faint">#{intent.number}</span>
233− </Link>
234− </p>
235− <div className="mt-1 flex flex-wrap items-center gap-3">
236− <h2 className="text-2xl font-semibold tracking-tight">
237− Attempt {attempt.number}
238− </h2>
239− <span className="flex items-center gap-1.5 font-mono text-sm text-muted">
240− <Bot size={15} />
241− {attempt.agent}
242− </span>
243− <Status value={attempt.status} />
244− </div>
245− <p className="mt-2 text-sm text-muted">
246− Started by{" "}
247− <span className="font-medium text-fg">{attempt.startedBy.username}</span>{" "}
248− <TimeAgo at={attempt.createdAt} />
249− </p>
250−
251− {attempt.summary && (
252− <section className="mt-6 rounded-xl border border-line bg-surface p-5">
253− <h3 className="text-xs font-medium tracking-wide text-faint uppercase">
254− Summary
255− </h3>
256− <div className="mt-2">
257− <Markdown source={attempt.summary} />
258− </div>
259− </section>
260− )}
261−
262− <nav className="mt-10 flex gap-6 border-b border-line">
263− <TabLink to={here} active={!comparison}>
264− <MessagesSquare size={15} />
265− Session
266− </TabLink>
267− <TabLink to={here + "?tab=changes"} active={Boolean(comparison)}>
268− <FileDiff size={15} />
269− Changes
270− </TabLink>
271− </nav>
272− <div className="mt-5">
273− {comparison ? (
274− <DiffView comparison={comparison} />
275− ) : session.length === 0 ? (
276− <EmptyState title="Nothing recorded yet">
277− The agent's prompts, reasoning and tool calls appear here as it
278− works.
279− </EmptyState>
280− ) : (
281− <ol className="relative space-y-5 before:absolute before:top-2 before:bottom-2 before:left-3.25 before:w-px before:bg-line">
282− {session.map((entry) => (
283− <Entry key={entry.seq} entry={entry} agent={attempt.agent} />
284− ))}
285− </ol>
286− )}
287− </div>
288− </div>
289−
290− <aside className="space-y-6">
291− {canShip && active && intent.status === "open" && (
292− <section className="rounded-xl border border-accent/30 bg-accent/5 p-4">
293− <h3 className="text-sm font-medium">Ship this attempt</h3>
294− <p className="mt-1 text-xs text-muted">
295− Lands its commits on {defaultBranch} and closes the intent.
296− </p>
297− <Form method="post" className="mt-3 *:w-full">
298− <Button variant="accent" type="submit" name="action" value="ship">
299− <Rocket size={15} />
300− Ship to {defaultBranch}
301− </Button>
302− </Form>
303− {!(mine && active) && <ErrorText>{actionData?.error}</ErrorText>}
304− </section>
305− )}
306− <section>
307− <h3 className="text-sm font-medium">Working copy</h3>
308− <p className="mt-1 text-xs text-muted">
309− This fork belongs to the attempt. Pushes to it show up here.
310− </p>
311− <div className="mt-2">
312− <CopyLine text={`git clone ${remote}`} />
313− </div>
314− <p className="mt-3 flex items-center gap-1.5 font-mono text-xs text-faint">
315− <GitCommitHorizontal size={13} />
316− {attempt.headCommit?.slice(0, 12) ?? "no commits pushed yet"}
317− </p>
318− </section>
319−
320− {mine && active && (
321− <section className="rounded-xl border border-line bg-surface p-4">
322− <h3 className="text-sm font-medium">
323− {attempt.status === "submitted" ? "Update summary" : "Submit"}
324− </h3>
325− <Form method="post" className="mt-3 space-y-2">
326− <Textarea
327− name="summary"
328− rows={4}
329− placeholder="What changed and why"
330− defaultValue={attempt.summary ?? ""}
331− />
332− <div className="flex gap-2">
333− <Button type="submit">Submit attempt</Button>
334− <Button variant="quiet" type="submit" name="action" value="abandon">
335− Abandon
336− </Button>
337− </div>
338− </Form>
339− <ErrorText>{actionData?.error}</ErrorText>
340− </section>
341− )}
342− </aside>
343− </div>
344− );
345−}
+0−60
1−import { Form, redirect } from "react-router";
2−
3−import type { Route } from "./+types/intent-new";
4−import { Button, ErrorText, Field, Input, Textarea } from "../../components/ui";
5−import { work } from "../../lib/services.server";
6−import { assertSameOrigin, requireUser } from "../../lib/session.server";
7−
8−export function loader({ request, context }: Route.LoaderArgs) {
9− requireUser(context, request);
10− return null;
11−}
12−
13−export async function action({ request, params, context }: Route.ActionArgs) {
14− assertSameOrigin(request);
15− const user = requireUser(context, request);
16− const form = await request.formData();
17− const result = await work.openIntent(
18− user,
19− { namespace: params.owner, name: params.repo },
20− {
21− title: String(form.get("title") ?? ""),
22− brief: String(form.get("brief") ?? ""),
23− checks: String(form.get("checks") ?? "").split("\n"),
24− },
25− );
26− if (!result.ok) return { error: result.error.message };
27− throw redirect(
28− `/${params.owner}/${params.repo}/intents/${result.value.number}`,
29− );
30−}
31−
32−export default function NewIntent({ actionData }: Route.ComponentProps) {
33− return (
34− <Form method="post" className="max-w-2xl space-y-4">
35− <Field label="Goal">
36− <Input
37− name="title"
38− required
39− autoFocus
40− maxLength={200}
41− placeholder="Make the parser streaming"
42− />
43− </Field>
44− <Field
45− label="Brief"
46− hint="What an agent is given to work from: the outcome you want, constraints, and anything it should know."
47− >
48− <Textarea name="brief" rows={8} />
49− </Field>
50− <Field
51− label="Acceptance checks (optional)"
52− hint="One command per line. An attempt is accepted when they all pass."
53− >
54− <Textarea name="checks" rows={3} placeholder="cargo test" />
55− </Field>
56− <ErrorText>{actionData?.error}</ErrorText>
57− <Button type="submit">Open intent</Button>
58− </Form>
59− );
60−}
+0−315
1−import { env } from "cloudflare:workers";
2−import { Bot, GitCommitHorizontal, Play, Sparkles, Terminal } from "lucide-react";
3−import { useEffect } from "react";
4−import {
5− Form,
6− Link,
7− redirect,
8− useNavigation,
9− useRevalidator,
10−} from "react-router";
11−
12−import type { Route } from "./+types/intent";
13−import { Markdown } from "../../components/markdown";
14−import {
15− Avatar,
16− Button,
17− CopyLine,
18− EmptyState,
19− ErrorText,
20− Input,
21− Status,
22− Textarea,
23− TimeAgo,
24−} from "../../components/ui";
25−import { work } from "../../lib/services.server";
26−import {
27− assertSameOrigin,
28− getViewer,
29− requireUser,
30− unwrap,
31−} from "../../lib/session.server";
32−
33−const REFRESH_MS = 4000;
34−
35−export function meta({ loaderData, params }: Route.MetaArgs) {
36− const title = loaderData ? `${loaderData.intent.title} · ` : "";
37− return [{ title: `${title}${params.owner}/${params.repo} · g1t` }];
38−}
39−
40−export async function loader({ params, context }: Route.LoaderArgs) {
41− const viewer = getViewer(context);
42− const detail = unwrap(
43− await work.getIntent(
44− { namespace: params.owner, name: params.repo },
45− Number(params.number),
46− viewer,
47− ),
48− );
49− return {
50− ...detail,
51− viewer,
52− agentModels: await env.RUNNER.models(viewer),
53− };
54−}
55−
56−export async function action({ request, params, context }: Route.ActionArgs) {
57− assertSameOrigin(request);
58− const user = requireUser(context, request);
59− const form = await request.formData();
60− const intentId = String(form.get("intentId") ?? "");
61−
62− if (form.get("action") === "run-hosted") {
63− const result = await env.RUNNER.run(user, intentId, {
64− count: Number(form.get("count")),
65− instructions: String(form.get("instructions") ?? ""),
66− model: String(form.get("model") ?? "") || undefined,
67− });
68− return result.ok ? null : { error: result.error.message };
69− }
70− if (form.get("action") === "withdraw") {
71− const result = await work.withdrawIntent(user, intentId);
72− return result.ok ? null : { error: result.error.message };
73− }
74− const result = await work.startAttempt(user, intentId, {
75− agent: String(form.get("agent") ?? ""),
76− runtime: "external",
77− });
78− if (!result.ok) return { error: result.error.message };
79− throw redirect(
80− `/${params.owner}/${params.repo}/attempts/${result.value.id}`,
81− );
82−}
83−
84−export default function IntentPage({
85− loaderData,
86− actionData,
87− params,
88−}: Route.ComponentProps) {
89− const { intent, attempts, viewer, agentModels } = loaderData;
90−
91− // Follow running attempts without a manual reload.
92− const revalidator = useRevalidator();
93− const navigation = useNavigation();
94− const running = attempts.some((attempt) => attempt.status === "working");
95− useEffect(() => {
96− if (!running) return;
97− const timer = setInterval(() => {
98− if (document.visibilityState === "visible") revalidator.revalidate();
99− }, REFRESH_MS);
100− return () => clearInterval(timer);
101− }, [running, revalidator]);
102− const starting = navigation.formData?.get("action") === "run-hosted";
103− const base = `/${params.owner}/${params.repo}`;
104− const open = intent.status === "open";
105− return (
106− <div className="grid gap-8 lg:grid-cols-[1fr_19rem]">
107− <div className="min-w-0">
108− <div className="flex items-start gap-3">
109− <h2 className="grow text-2xl font-semibold tracking-tight text-balance">
110− {intent.title}{" "}
111− <span className="font-normal text-faint">#{intent.number}</span>
112− </h2>
113− <Status value={intent.status} />
114− </div>
115− <p className="mt-2 flex items-center gap-2 text-sm text-muted">
116− <Avatar name={intent.author.username} size={18} />
117− <span>
118− <span className="font-medium text-fg">{intent.author.username}</span>{" "}
119− opened this <TimeAgo at={intent.createdAt} />
120− </span>
121− </p>
122−
123− {intent.brief && (
124− <div className="mt-6 rounded-xl border border-line bg-surface p-5">
125− <Markdown source={intent.brief} />
126− </div>
127− )}
128−
129− <div className="mt-10 flex items-baseline justify-between">
130− <h3 className="font-semibold tracking-tight">Attempts</h3>
131− <p className="text-sm text-muted">
132− {attempts.length === 0
133− ? "None yet"
134− : `${attempts.length} in the arena`}
135− </p>
136− </div>
137− <div className="mt-3">
138− {attempts.length === 0 ? (
139− <EmptyState title="No agent has attempted this yet">
140− Start an attempt here, or point an agent at intent{" "}
141− <code className="font-mono">{intent.id}</code>.
142− </EmptyState>
143− ) : (
144− // Each attempt is a lane hanging off a shared rail.
145− <ol className="relative space-y-3 pl-6 before:absolute before:top-2 before:bottom-2 before:left-1.75 before:w-px before:bg-line">
146− {attempts.map((attempt) => (
147− <li key={attempt.id} className="relative">
148− <span
149− className={`absolute top-5 -left-6 size-3.75 rounded-full border-2 border-bg ${
150− attempt.status === "working"
151− ? "bg-accent"
152− : attempt.status === "submitted"
153− ? "bg-info"
154− : attempt.status === "shipped"
155− ? "bg-shipped"
156− : "bg-line-strong"
157− }`}
158− />
159− <Link
160− to={`${base}/attempts/${attempt.id}`}
161− className="block rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong"
162− >
163− <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
164− <span className="flex items-center gap-1.5 font-mono text-sm font-medium">
165− <Bot size={15} className="text-faint" />
166− {attempt.agent}
167− </span>
168− {attempt.runtime === "hosted" && (
169− <span className="rounded-full border border-line px-1.5 py-px text-[0.6875rem] text-faint">
170− hosted
171− </span>
172− )}
173− <span className="text-sm text-faint">
174− attempt {attempt.number}
175− </span>
176− <Status value={attempt.status} />
177− <span className="ml-auto flex items-center gap-3 text-xs text-faint">
178− <span className="flex items-center gap-1 font-mono">
179− <GitCommitHorizontal size={13} />
180− {attempt.headCommit?.slice(0, 7) ?? "no commits"}
181− </span>
182− <TimeAgo at={attempt.updatedAt} />
183− </span>
184− </div>
185− {attempt.summary && (
186− <p className="mt-2.5 line-clamp-2 text-sm text-muted">
187− {attempt.summary}
188− </p>
189− )}
190− </Link>
191− </li>
192− ))}
193− </ol>
194− )}
195− </div>
196− </div>
197−
198− <aside className="space-y-6">
199− {intent.checks.length > 0 && (
200− <section>
201− <h3 className="text-sm font-medium">Acceptance checks</h3>
202− <ul className="mt-2 space-y-1.5">
203− {intent.checks.map((check) => (
204− <li
205− key={check}
206− className="flex items-center gap-2 rounded-md border border-line bg-surface px-2.5 py-1.5 font-mono text-xs"
207− >
208− <Terminal size={13} className="shrink-0 text-faint" />
209− <span className="truncate">{check}</span>
210− </li>
211− ))}
212− </ul>
213− </section>
214− )}
215−
216− {open && agentModels.length > 0 && (
217− <section className="rounded-xl border border-accent/30 bg-accent/5 p-4">
218− <h3 className="flex items-center gap-2 text-sm font-medium">
219− <Sparkles size={15} className="text-accent" />
220− Run g1t agents
221− </h3>
222− <p className="mt-1 text-xs text-muted">
223− Each agent works in its own sandbox and its own fork, in
224− parallel. Compare what they produce and ship the best.
225− </p>
226− <Form method="post" className="mt-3 space-y-2">
227− <input type="hidden" name="intentId" value={intent.id} />
228− <input type="hidden" name="action" value="run-hosted" />
229− <label className="flex items-center justify-between gap-3 text-sm">
230− <span className="text-muted">Agents</span>
231− <select
232− name="count"
233− defaultValue="1"
234− className="rounded-md border border-line bg-bg px-2 py-1 text-sm"
235− >
236− {[1, 2, 3, 4, 5].map((count) => (
237− <option key={count} value={count}>
238− {count}
239− </option>
240− ))}
241− </select>
242− </label>
243− <label className="flex items-center justify-between gap-3 text-sm">
244− <span className="text-muted">Model</span>
245− <select
246− name="model"
247− className="rounded-md border border-line bg-bg px-2 py-1 text-sm"
248− >
249− {agentModels.map((model) => (
250− <option key={model.id} value={model.id} title={model.description}>
251− {model.label} · {model.modelName}
252− </option>
253− ))}
254− </select>
255− </label>
256− <Textarea
257− name="instructions"
258− rows={2}
259− placeholder="Extra guidance (optional)"
260− />
261− <div className="*:w-full">
262− <Button variant="accent" type="submit" disabled={starting}>
263− <Play size={14} />
264− {starting ? "Starting sandboxes…" : "Run"}
265− </Button>
266− </div>
267− </Form>
268− </section>
269− )}
270−
271− {open && (
272− <section className="rounded-xl border border-line bg-surface p-4">
273− <h3 className="text-sm font-medium">Bring your own agent</h3>
274− <p className="mt-1 text-xs text-muted">
275− With g1t connected to your agent, give it this intent id.
276− </p>
277− <div className="mt-3">
278− <CopyLine text={intent.id} />
279− </div>
280− {viewer ? (
281− <Form method="post" className="mt-4 space-y-2 border-t border-line pt-4">
282− <input type="hidden" name="intentId" value={intent.id} />
283− <p className="text-xs text-muted">
284− Or start an attempt yourself and get a fork to push to.
285− </p>
286− <Input name="agent" placeholder="Agent name, e.g. claude-code" maxLength={60} />
287− <div className="*:w-full">
288− <Button type="submit">Start attempt</Button>
289− </div>
290− </Form>
291− ) : (
292− <p className="mt-4 border-t border-line pt-4 text-sm text-muted">
293− <Link to="/login" className="text-fg underline underline-offset-4">
294− Sign in
295− </Link>{" "}
296− to start an attempt.
297− </p>
298− )}
299− <ErrorText>{actionData?.error}</ErrorText>
300− </section>
301− )}
302−
303− {open && viewer?.id === intent.author.id && (
304− <Form method="post">
305− <input type="hidden" name="intentId" value={intent.id} />
306− <input type="hidden" name="action" value="withdraw" />
307− <Button variant="quiet" type="submit">
308− Withdraw intent
309− </Button>
310− </Form>
311− )}
312− </aside>
313− </div>
314− );
315−}
+0−87
1−import { CircleDot, GitMerge, Plus, XCircle } from "lucide-react";
2−import { Link } from "react-router";
3−
4−import type { Intent } from "@g1t/contracts";
5−
6−import type { Route } from "./+types/intents";
7−import { ButtonLink, EmptyState, TimeAgo } from "../../components/ui";
8−import { work } from "../../lib/services.server";
9−import { getViewer, unwrap } from "../../lib/session.server";
10−
11−export function meta({ params }: Route.MetaArgs) {
12− return [{ title: `Intents · ${params.owner}/${params.repo} · g1t` }];
13−}
14−
15−export async function loader({ params, context }: Route.LoaderArgs) {
16− const path = { namespace: params.owner, name: params.repo };
17− return {
18− intents: unwrap(await work.listIntents(path, getViewer(context))),
19− };
20−}
21−
22−function StatusIcon({ status }: { status: Intent["status"] }) {
23− if (status === "shipped") return <GitMerge size={16} className="text-shipped" />;
24− if (status === "withdrawn") return <XCircle size={16} className="text-faint" />;
25− return <CircleDot size={16} className="text-accent" />;
26−}
27−
28−export default function Intents({ loaderData, params }: Route.ComponentProps) {
29− const { intents } = loaderData;
30− const base = `/${params.owner}/${params.repo}/intents`;
31− const open = intents.filter((intent) => intent.status === "open").length;
32− return (
33− <div>
34− <div className="flex items-center justify-between gap-4">
35− <p className="text-sm text-muted">
36− <span className="font-medium text-fg">{open} open</span>
37− <span className="mx-2 text-faint">·</span>
38− {intents.length - open} closed
39− </p>
40− <ButtonLink to={`${base}/new`}>
41− <Plus size={15} />
42− New intent
43− </ButtonLink>
44− </div>
45− <div className="mt-4">
46− {intents.length === 0 ? (
47− <EmptyState title="No intents yet">
48− An intent is a goal for this repository. Agents attempt it in
49− parallel, each in its own fork.
50− </EmptyState>
51− ) : (
52− <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line">
53− {intents.map((intent) => (
54− <li key={intent.id}>
55− <Link
56− to={`${base}/${intent.number}`}
57− className="flex items-center gap-3 px-4 py-3 transition-colors hover:bg-surface"
58− >
59− <StatusIcon status={intent.status} />
60− <span className="min-w-0 grow">
61− <span className="block truncate font-medium">
62− {intent.title}
63− </span>
64− <span className="text-xs text-faint">
65− #{intent.number} opened <TimeAgo at={intent.createdAt} /> by{" "}
66− {intent.author.username}
67− </span>
68− </span>
69− {/* One dot per attempt: the lanes at a glance. */}
70− <span className="flex shrink-0 items-center gap-1" aria-hidden="true">
71− {Array.from({ length: Math.min(intent.attemptCount, 12) }, (_, i) => (
72− <span key={i} className="size-1.5 rounded-full bg-accent-dim" />
73− ))}
74− </span>
75− <span className="w-20 shrink-0 text-right text-xs text-muted">
76− {intent.attemptCount}{" "}
77− {intent.attemptCount === 1 ? "attempt" : "attempts"}
78− </span>
79− </Link>
80− </li>
81− ))}
82− </ul>
83− )}
84− </div>
85− </div>
86− );
87−}
+86−0
1+import { Form, redirect } from "react-router";
2+
3+import type { Route } from "./+types/issue-new";
4+import { Button, ErrorText, Field, Input, Textarea } from "../../components/ui";
5+import { Label } from "../../components/work";
6+import { work } from "../../lib/services.server";
7+import { assertSameOrigin, requireUser, unwrap } from "../../lib/session.server";
8+
9+export function meta({ params }: Route.MetaArgs) {
10+ return [{ title: `New issue · ${params.owner}/${params.repo} · g1t` }];
11+}
12+
13+export async function loader({ request, params, context }: Route.LoaderArgs) {
14+ const user = requireUser(context, request);
15+ const path = { namespace: params.owner, name: params.repo };
16+ return { labels: unwrap(await work.listLabels(path, user)) };
17+}
18+
19+export async function action({ request, params, context }: Route.ActionArgs) {
20+ assertSameOrigin(request);
21+ const user = requireUser(context, request);
22+ const form = await request.formData();
23+ const result = await work.openIssue(
24+ user,
25+ { namespace: params.owner, name: params.repo },
26+ {
27+ title: String(form.get("title") ?? ""),
28+ body: String(form.get("body") ?? ""),
29+ labels: [
30+ ...form.getAll("label").map(String),
31+ ...String(form.get("labels") ?? "").split(","),
32+ ],
33+ checks: String(form.get("checks") ?? "").split("\n"),
34+ },
35+ );
36+ if (!result.ok) return { error: result.error.message };
37+ throw redirect(`/${params.owner}/${params.repo}/issues/${result.value.number}`);
38+}
39+
40+export default function NewIssue({ loaderData, actionData }: Route.ComponentProps) {
41+ return (
42+ <Form method="post" className="max-w-2xl space-y-4">
43+ <Field label="Title">
44+ <Input
45+ name="title"
46+ required
47+ autoFocus
48+ maxLength={200}
49+ placeholder="Parser drops the last line of a file without a trailing newline"
50+ />
51+ </Field>
52+ <Field
53+ label="Description"
54+ hint="What is wrong or wanted, and anything needed to act on it. An agent given this issue works from this text."
55+ >
56+ <Textarea name="body" rows={8} />
57+ </Field>
58+ <fieldset>
59+ <legend className="mb-1.5 text-sm font-medium text-muted">Labels</legend>
60+ <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
61+ {loaderData.labels.map((name) => (
62+ <label key={name} className="flex cursor-pointer items-center gap-1.5">
63+ <input type="checkbox" name="label" value={name} className="accent-accent" />
64+ <Label name={name} />
65+ </label>
66+ ))}
67+ </div>
68+ <div className="mt-2">
69+ <Input
70+ name="labels"
71+ aria-label="Other labels"
72+ placeholder="Others, separated by commas: performance, good first issue"
73+ />
74+ </div>
75+ </fieldset>
76+ <Field
77+ label="Acceptance checks (optional)"
78+ hint="One command per line. A pull request for this issue should make them all pass."
79+ >
80+ <Textarea name="checks" rows={3} placeholder="cargo test" />
81+ </Field>
82+ <ErrorText>{actionData?.error}</ErrorText>
83+ <Button type="submit">Open issue</Button>
84+ </Form>
85+ );
86+}
+423−0
1+import { env } from "cloudflare:workers";
2+import { Bot, GitCommitHorizontal, GitMerge, Play, Sparkles, Terminal } from "lucide-react";
3+import { useEffect } from "react";
4+import { Form, Link, redirect, useNavigation, useRevalidator } from "react-router";
5+
6+import type { Pull } from "@g1t/contracts";
7+
8+import type { Route } from "./+types/issue";
9+import { Markdown } from "../../components/markdown";
10+import {
11+ Avatar,
12+ Button,
13+ CopyLine,
14+ EmptyState,
15+ ErrorText,
16+ Input,
17+ Textarea,
18+ TimeAgo,
19+} from "../../components/ui";
20+import { Comments, IssueState, Label, PullIcon } from "../../components/work";
21+import { work } from "../../lib/services.server";
22+import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server";
23+
24+const REFRESH_MS = 4000;
25+
26+export function meta({ loaderData, params }: Route.MetaArgs) {
27+ const title = loaderData ? `${loaderData.issue.title} · Issue #${loaderData.issue.number} · ` : "";
28+ return [{ title: `${title}${params.owner}/${params.repo} · g1t` }];
29+}
30+
31+export async function loader({ params, context }: Route.LoaderArgs) {
32+ const viewer = getViewer(context);
33+ const path = { namespace: params.owner, name: params.repo };
34+ const number = Number(params.number);
35+ const found = await work.getIssue(path, number, viewer);
36+ if (!found.ok) {
37+ // Issues and pull requests share numbers; this one may be a pull request.
38+ const pull = await work.getPull(path, number, viewer);
39+ if (pull.ok) throw redirect(`/${params.owner}/${params.repo}/pull/${number}`);
40+ throw new Response("Issue not found.", { status: 404 });
41+ }
42+ const [labels, agentModels] = await Promise.all([
43+ work.listLabels(path, viewer),
44+ env.RUNNER.models(viewer),
45+ ]);
46+ const { issue } = found.value;
47+ return {
48+ ...found.value,
49+ viewer,
50+ labels: labels.ok ? labels.value : [],
51+ agentModels,
52+ // The author and members of the workspace can change an issue.
53+ canManage:
54+ viewer != null &&
55+ (viewer.id === issue.author.id ||
56+ (viewer.workspaces ?? []).some((membership) => membership.slug === params.owner)),
57+ };
58+}
59+
60+export async function action({ request, params, context }: Route.ActionArgs) {
61+ assertSameOrigin(request);
62+ const user = requireUser(context, request);
63+ const form = await request.formData();
64+ const path = { namespace: params.owner, name: params.repo };
65+ const number = Number(params.number);
66+
67+ switch (form.get("action")) {
68+ case "run-hosted": {
69+ const result = await env.RUNNER.run(user, path, number, {
70+ count: Number(form.get("count")),
71+ instructions: String(form.get("instructions") ?? ""),
72+ model: String(form.get("model") ?? "") || undefined,
73+ });
74+ return result.ok ? null : { error: result.error.message };
75+ }
76+ case "open-pull": {
77+ const result = await work.openPull(user, path, {
78+ issue: number,
79+ agent: String(form.get("agent") ?? ""),
80+ runtime: "external",
81+ });
82+ if (!result.ok) return { error: result.error.message };
83+ throw redirect(`/${params.owner}/${params.repo}/pull/${result.value.number}`);
84+ }
85+ case "comment": {
86+ const result = await work.addComment(user, path, number, String(form.get("body") ?? ""));
87+ return result.ok ? null : { error: result.error.message };
88+ }
89+ case "labels": {
90+ const result = await work.updateIssue(user, path, number, {
91+ labels: [
92+ ...form.getAll("label").map(String),
93+ ...String(form.get("labels") ?? "").split(","),
94+ ],
95+ });
96+ return result.ok ? null : { error: result.error.message };
97+ }
98+ case "reopen": {
99+ const result = await work.reopenIssue(user, path, number);
100+ return result.ok ? null : { error: result.error.message };
101+ }
102+ default: {
103+ const result = await work.closeIssue(
104+ user,
105+ path,
106+ number,
107+ form.get("action") === "close-not-planned" ? "not_planned" : "completed",
108+ );
109+ return result.ok ? null : { error: result.error.message };
110+ }
111+ }
112+}
113+
114+/** What became of a pull request, in a few words. */
115+function outcome(pull: Pull): string {
116+ if (pull.status === "merged") return `Merged by ${pull.mergedBy ?? "someone"}`;
117+ if (pull.supersededBy != null) return `Closed · #${pull.supersededBy} was merged instead`;
118+ if (pull.status === "closed") return "Closed without merging";
119+ if (pull.status === "draft") return "Draft · in progress";
120+ return "Ready for review";
121+}
122+
123+function PullRow({ pull, base }: { pull: Pull; base: string }) {
124+ const merged = pull.status === "merged";
125+ return (
126+ <li>
127+ <Link
128+ to={`${base}/pull/${pull.number}`}
129+ className={`block rounded-xl border p-4 transition-colors ${
130+ merged
131+ ? "border-merged/40 bg-merged/5 hover:border-merged/70"
132+ : "border-line bg-surface hover:border-line-strong"
133+ } ${pull.status === "closed" ? "opacity-70" : ""}`}
134+ >
135+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
136+ <PullIcon status={pull.status} />
137+ <span className="font-medium">
138+ {pull.title} <span className="font-normal text-faint">#{pull.number}</span>
139+ </span>
140+ {pull.status === "draft" && (
141+ <span className="size-1.5 animate-pulse rounded-full bg-accent" />
142+ )}
143+ <span className="ml-auto flex items-center gap-3 text-xs text-faint">
144+ <span className="flex items-center gap-1 font-mono">
145+ <GitCommitHorizontal size={13} />
146+ {pull.headCommit?.slice(0, 7) ?? "no commits"}
147+ </span>
148+ <TimeAgo at={pull.updatedAt} />
149+ </span>
150+ </div>
151+ <p className="mt-1.5 flex flex-wrap items-center gap-x-2 text-xs text-muted">
152+ <span className={merged ? "font-medium text-merged" : ""}>{outcome(pull)}</span>
153+ <span className="text-faint">·</span>
154+ <span className="flex items-center gap-1 font-mono">
155+ <Bot size={12} />
156+ {pull.agent}
157+ </span>
158+ {pull.runtime === "hosted" && <span className="text-faint">on g1t</span>}
159+ <span className="text-faint">· opened by {pull.author.username}</span>
160+ </p>
161+ {pull.body && <p className="mt-2 line-clamp-2 text-sm text-muted">{pull.body}</p>}
162+ </Link>
163+ </li>
164+ );
165+}
166+
167+export default function IssuePage({ loaderData, actionData, params }: Route.ComponentProps) {
168+ const { issue, pulls, comments, viewer, labels, agentModels, canManage } = loaderData;
169+
170+ // Follow agents at work without a manual reload.
171+ const revalidator = useRevalidator();
172+ const navigation = useNavigation();
173+ const running = pulls.some((pull) => pull.status === "draft");
174+ useEffect(() => {
175+ if (!running) return;
176+ const timer = setInterval(() => {
177+ if (document.visibilityState === "visible") revalidator.revalidate();
178+ }, REFRESH_MS);
179+ return () => clearInterval(timer);
180+ }, [running, revalidator]);
181+
182+ const starting = navigation.formData?.get("action") === "run-hosted";
183+ const base = `/${params.owner}/${params.repo}`;
184+ const open = issue.state === "open";
185+ const reference = `${params.owner}/${params.repo}#${issue.number}`;
186+ const resolver = pulls.find((pull) => pull.number === issue.resolvedBy);
187+ // The merged pull request first, then the ones still in play, then the rest.
188+ const rank = { merged: 0, open: 1, draft: 2, closed: 3 } as const;
189+ const ordered = [...pulls].sort(
190+ (a, b) => rank[a.status] - rank[b.status] || a.number - b.number,
191+ );
192+
193+ return (
194+ <div className="grid gap-8 lg:grid-cols-[1fr_19rem]">
195+ <div className="min-w-0">
196+ <h2 className="text-2xl font-semibold tracking-tight text-balance">
197+ {issue.title} <span className="font-normal text-faint">#{issue.number}</span>
198+ </h2>
199+ <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2 text-sm text-muted">
200+ <IssueState issue={issue} />
201+ <span className="flex items-center gap-2">
202+ <Avatar name={issue.author.username} size={18} />
203+ <span>
204+ <span className="font-medium text-fg">{issue.author.username}</span> opened this{" "}
205+ <TimeAgo at={issue.createdAt} />
206+ </span>
207+ </span>
208+ {issue.labels.map((name) => (
209+ <Label key={name} name={name} />
210+ ))}
211+ </div>
212+
213+ {issue.resolvedBy != null && (
214+ <Link
215+ to={`${base}/pull/${issue.resolvedBy}`}
216+ className="mt-5 flex items-center gap-3 rounded-xl border border-merged/40 bg-merged/5 px-4 py-3 text-sm transition-colors hover:border-merged/70"
217+ >
218+ <GitMerge size={18} className="shrink-0 text-merged" />
219+ <span>
220+ Resolved by{" "}
221+ <span className="font-medium">
222+ {resolver?.title ?? "pull request"} #{issue.resolvedBy}
223+ </span>
224+ {resolver?.mergedBy && (
225+ <span className="text-muted">
226+ , merged by {resolver.mergedBy}{" "}
227+ {resolver.mergedAt && <TimeAgo at={resolver.mergedAt} />}
228+ </span>
229+ )}
230+ </span>
231+ </Link>
232+ )}
233+
234+ {issue.body && (
235+ <div className="mt-5 rounded-xl border border-line bg-surface p-5">
236+ <Markdown source={issue.body} />
237+ </div>
238+ )}
239+
240+ <div className="mt-10 flex items-baseline justify-between">
241+ <h3 className="font-semibold tracking-tight">Pull requests</h3>
242+ <p className="text-sm text-muted">
243+ {pulls.length === 0
244+ ? "None yet"
245+ : `${pulls.length} for this issue${
246+ pulls.some((pull) => pull.status === "merged") ? "" : ", none merged"
247+ }`}
248+ </p>
249+ </div>
250+ <div className="mt-3">
251+ {pulls.length === 0 ? (
252+ <EmptyState title="Nobody has worked on this yet">
253+ Put g1t agents on it, or point your own agent at{" "}
254+ <code className="font-mono">{reference}</code>.
255+ </EmptyState>
256+ ) : (
257+ <ol className="space-y-3">
258+ {ordered.map((pull) => (
259+ <PullRow key={pull.id} pull={pull} base={base} />
260+ ))}
261+ </ol>
262+ )}
263+ </div>
264+
265+ <h3 className="mt-10 font-semibold tracking-tight">Discussion</h3>
266+ <div className="mt-3">
267+ <Comments comments={comments} canComment={Boolean(viewer)} />
268+ </div>
269+ <div className="mt-2">
270+ <ErrorText>{actionData?.error}</ErrorText>
271+ </div>
272+ </div>
273+
274+ <aside className="space-y-6">
275+ {open && agentModels.length > 0 && (
276+ <section className="rounded-xl border border-accent/30 bg-accent/5 p-4">
277+ <h3 className="flex items-center gap-2 text-sm font-medium">
278+ <Sparkles size={15} className="text-accent" />
279+ Assign g1t agents
280+ </h3>
281+ <p className="mt-1 text-xs text-muted">
282+ Each agent opens its own pull request for this issue and works in
283+ its own sandbox, in parallel. Merge the one you want.
284+ </p>
285+ <Form method="post" className="mt-3 space-y-2">
286+ <input type="hidden" name="action" value="run-hosted" />
287+ <label className="flex items-center justify-between gap-3 text-sm">
288+ <span className="text-muted">Agents</span>
289+ <select
290+ name="count"
291+ defaultValue="1"
292+ className="rounded-md border border-line bg-bg px-2 py-1 text-sm"
293+ >
294+ {[1, 2, 3, 4, 5].map((count) => (
295+ <option key={count} value={count}>
296+ {count}
297+ </option>
298+ ))}
299+ </select>
300+ </label>
301+ <label className="flex items-center justify-between gap-3 text-sm">
302+ <span className="text-muted">Model</span>
303+ <select
304+ name="model"
305+ className="rounded-md border border-line bg-bg px-2 py-1 text-sm"
306+ >
307+ {agentModels.map((model) => (
308+ <option key={model.id} value={model.id} title={model.description}>
309+ {model.label} · {model.modelName}
310+ </option>
311+ ))}
312+ </select>
313+ </label>
314+ <Textarea name="instructions" rows={2} placeholder="Extra guidance (optional)" />
315+ <div className="*:w-full">
316+ <Button variant="accent" type="submit" disabled={starting}>
317+ <Play size={14} />
318+ {starting ? "Starting sandboxes…" : "Start"}
319+ </Button>
320+ </div>
321+ </Form>
322+ </section>
323+ )}
324+
325+ {open && (
326+ <section className="rounded-xl border border-line bg-surface p-4">
327+ <h3 className="text-sm font-medium">Bring your own agent</h3>
328+ <p className="mt-1 text-xs text-muted">
329+ With g1t connected to your agent, ask it to work on this issue.
330+ </p>
331+ <div className="mt-3">
332+ <CopyLine text={reference} />
333+ </div>
334+ {viewer ? (
335+ <Form method="post" className="mt-4 space-y-2 border-t border-line pt-4">
336+ <input type="hidden" name="action" value="open-pull" />
337+ <p className="text-xs text-muted">
338+ Or open a draft pull request yourself and get a fork to push to.
339+ </p>
340+ <Input name="agent" placeholder="Who is working, e.g. claude-code" maxLength={60} />
341+ <div className="*:w-full">
342+ <Button type="submit">Open pull request</Button>
343+ </div>
344+ </Form>
345+ ) : (
346+ <p className="mt-4 border-t border-line pt-4 text-sm text-muted">
347+ <Link to="/login" className="text-fg underline underline-offset-4">
348+ Sign in
349+ </Link>{" "}
350+ to open a pull request.
351+ </p>
352+ )}
353+ </section>
354+ )}
355+
356+ {issue.checks.length > 0 && (
357+ <section>
358+ <h3 className="text-sm font-medium">Acceptance checks</h3>
359+ <ul className="mt-2 space-y-1.5">
360+ {issue.checks.map((check) => (
361+ <li
362+ key={check}
363+ className="flex items-center gap-2 rounded-md border border-line bg-surface px-2.5 py-1.5 font-mono text-xs"
364+ >
365+ <Terminal size={13} className="shrink-0 text-faint" />
366+ <span className="truncate">{check}</span>
367+ </li>
368+ ))}
369+ </ul>
370+ </section>
371+ )}
372+
373+ {canManage && (
374+ <details className="group">
375+ <summary className="cursor-pointer list-none text-sm font-medium">
376+ Labels <span className="text-xs font-normal text-faint group-open:hidden">Edit</span>
377+ </summary>
378+ <Form method="post" className="mt-3 space-y-3" key={issue.labels.join()}>
379+ <input type="hidden" name="action" value="labels" />
380+ <div className="flex flex-wrap gap-x-3 gap-y-2">
381+ {labels.map((name) => (
382+ <label key={name} className="flex cursor-pointer items-center gap-1.5">
383+ <input
384+ type="checkbox"
385+ name="label"
386+ value={name}
387+ defaultChecked={issue.labels.includes(name)}
388+ className="accent-accent"
389+ />
390+ <Label name={name} />
391+ </label>
392+ ))}
393+ </div>
394+ <Input name="labels" placeholder="New labels, comma separated" />
395+ <Button variant="quiet" type="submit">
396+ Save labels
397+ </Button>
398+ </Form>
399+ </details>
400+ )}
401+
402+ {canManage && (
403+ <Form method="post" className="flex flex-wrap gap-2 border-t border-line pt-5">
404+ {open ? (
405+ <>
406+ <Button variant="quiet" type="submit" name="action" value="close-completed">
407+ Close issue
408+ </Button>
409+ <Button variant="quiet" type="submit" name="action" value="close-not-planned">
410+ Close as not planned
411+ </Button>
412+ </>
413+ ) : (
414+ <Button variant="quiet" type="submit" name="action" value="reopen">
415+ Reopen issue
416+ </Button>
417+ )}
418+ </Form>
419+ )}
420+ </aside>
421+ </div>
422+ );
423+}
+130−0
1+import { GitPullRequest, MessageSquare, Plus, X } from "lucide-react";
2+import { Link } from "react-router";
3+
4+import type { Route } from "./+types/issues";
5+import { ButtonLink, EmptyState, TimeAgo } from "../../components/ui";
6+import { IssueIcon, Label, StateTabs } from "../../components/work";
7+import { work } from "../../lib/services.server";
8+import { getViewer, unwrap } from "../../lib/session.server";
9+
10+export function meta({ params }: Route.MetaArgs) {
11+ return [{ title: `Issues · ${params.owner}/${params.repo} · g1t` }];
12+}
13+
14+export async function loader({ params, context, request }: Route.LoaderArgs) {
15+ const viewer = getViewer(context);
16+ const path = { namespace: params.owner, name: params.repo };
17+ const query = new URL(request.url).searchParams;
18+ const state = query.get("state") === "closed" ? "closed" : "open";
19+ const label = query.get("label") ?? "";
20+ const [issues, labels] = await Promise.all([
21+ work.listIssues(path, viewer, { state, label: label || undefined }),
22+ work.listLabels(path, viewer),
23+ ]);
24+ return { issues: unwrap(issues), labels: unwrap(labels), state, label } as const;
25+}
26+
27+export default function Issues({ loaderData, params }: Route.ComponentProps) {
28+ const { issues, labels, state, label } = loaderData;
29+ const repo = `/${params.owner}/${params.repo}`;
30+ const base = `${repo}/issues`;
31+ const stateQuery = state === "closed" ? "state=closed" : "";
32+ return (
33+ <div>
34+ <StateTabs
35+ to={base}
36+ state={state}
37+ query={label ? `label=${encodeURIComponent(label)}` : ""}
38+ action={
39+ <ButtonLink to={`${base}/new`}>
40+ <Plus size={15} />
41+ New issue
42+ </ButtonLink>
43+ }
44+ />
45+ <div className="mt-3 flex flex-wrap items-center gap-1.5">
46+ {labels.map((name) => {
47+ const active = name === label;
48+ const query = [stateQuery, active ? "" : `label=${encodeURIComponent(name)}`]
49+ .filter(Boolean)
50+ .join("&");
51+ return (
52+ <Link
53+ key={name}
54+ to={`${base}?${query}`}
55+ className={`flex items-center gap-1 rounded-full transition-opacity ${
56+ label && !active ? "opacity-45 hover:opacity-100" : ""
57+ }`}
58+ >
59+ <Label name={name} />
60+ {active && <X size={12} className="text-muted" />}
61+ </Link>
62+ );
63+ })}
64+ </div>
65+ <div className="mt-4">
66+ {issues.length === 0 ? (
67+ <EmptyState
68+ title={
69+ label
70+ ? `No ${state} issues labelled ${label}`
71+ : state === "open"
72+ ? "No open issues"
73+ : "No closed issues"
74+ }
75+ >
76+ An issue says what should change: a bug, a feature, a question.
77+ Agents and people open pull requests against it.
78+ </EmptyState>
79+ ) : (
80+ <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line">
81+ {issues.map((issue) => (
82+ <li key={issue.id}>
83+ <Link
84+ to={`${base}/${issue.number}`}
85+ className="flex items-start gap-3 px-4 py-3 transition-colors hover:bg-surface"
86+ >
87+ <span className="mt-0.5">
88+ <IssueIcon issue={issue} />
89+ </span>
90+ <span className="min-w-0 grow">
91+ <span className="flex flex-wrap items-center gap-x-2 gap-y-1">
92+ <span className="font-medium">{issue.title}</span>
93+ {issue.labels.map((name) => (
94+ <Label key={name} name={name} />
95+ ))}
96+ </span>
97+ <span className="mt-0.5 block text-xs text-faint">
98+ #{issue.number} opened <TimeAgo at={issue.createdAt} /> by{" "}
99+ {issue.author.username}
100+ {issue.resolvedBy != null && (
101+ <span className="text-merged"> · resolved by #{issue.resolvedBy}</span>
102+ )}
103+ </span>
104+ </span>
105+ <span className="mt-0.5 flex shrink-0 items-center gap-3 text-xs text-muted">
106+ {issue.pullCount > 0 && (
107+ <span
108+ className="flex items-center gap-1"
109+ title={`${issue.pullCount} pull ${issue.pullCount === 1 ? "request" : "requests"}`}
110+ >
111+ <GitPullRequest size={13} />
112+ {issue.pullCount}
113+ </span>
114+ )}
115+ {issue.commentCount > 0 && (
116+ <span className="flex items-center gap-1">
117+ <MessageSquare size={13} />
118+ {issue.commentCount}
119+ </span>
120+ )}
121+ </span>
122+ </Link>
123+ </li>
124+ ))}
125+ </ul>
126+ )}
127+ </div>
128+ </div>
129+ );
130+}
+21−9
1−import { BookMarked, Code2, History, Lock, Target } from "lucide-react";
1+import { BookMarked, CircleDot, Code2, GitPullRequest, History, Lock } from "lucide-react";
22 import type { ReactNode } from "react";
3−import { Link, NavLink, Outlet } from "react-router";
3+import { Link, NavLink, Outlet, useLocation } from "react-router";
44
55 import type { Route } from "./+types/layout";
66 import { Pill } from "../../components/ui";
1414 export async function loader({ params, context }: Route.LoaderArgs) {
1515 const viewer = getViewer(context);
1616 const path = { namespace: params.owner, name: params.repo };
17− const [repo, intents] = await Promise.all([
17+ const [repo, counts] = await Promise.all([
1818 repos.get(path, viewer),
19− work.listIntents(path, viewer, "open"),
19+ work.counts(path, viewer),
2020 ]);
2121 return {
2222 repo: unwrap(repo),
23− openIntents: intents.ok ? intents.value.length : 0,
23+ open: counts.ok ? counts.value : { issues: 0, pulls: 0 },
2424 };
2525 }
2626
2727 function Tab({
2828 to,
29+ also,
2930 end,
3031 icon,
3132 count,
3233 children,
3334 }: {
3435 to: string;
36+ /** Another path prefix under which this tab is the current one. */
37+ also?: string;
3538 end?: boolean;
3639 icon: ReactNode;
3740 count?: number;
3841 children: ReactNode;
3942 }) {
43+ const { pathname } = useLocation();
4044 return (
4145 <NavLink
4246 to={to}
4347 end={end}
4448 className={({ isActive }) =>
4549 `-mb-px flex items-center gap-2 border-b-2 px-1 pb-3 text-sm transition-colors ${
46− isActive
50+ isActive || (also && pathname.startsWith(also + "/"))
4751 ? "border-accent font-medium text-fg"
4852 : "border-transparent text-muted hover:text-fg"
4953 }`
6165 }
6266
6367 export default function RepoLayout({ loaderData }: Route.ComponentProps) {
64− const { repo, openIntents } = loaderData;
68+ const { repo, open } = loaderData;
6569 const base = `/${repo.namespace}/${repo.name}`;
6670 return (
6771 <>
9296 <Tab to={base} end icon={<Code2 size={15} />}>
9397 Code
9498 </Tab>
95− <Tab to={`${base}/intents`} icon={<Target size={15} />} count={openIntents}>
96− Intents
99+ <Tab to={`${base}/issues`} icon={<CircleDot size={15} />} count={open.issues}>
100+ Issues
101+ </Tab>
102+ <Tab
103+ to={`${base}/pulls`}
104+ also={`${base}/pull`}
105+ icon={<GitPullRequest size={15} />}
106+ count={open.pulls}
107+ >
108+ Pull requests
97109 </Tab>
98110 <Tab to={`${base}/commits`} icon={<History size={15} />}>
99111 Commits
+424−0
1+import {
2+ Bot,
3+ ChevronRight,
4+ FileDiff,
5+ GitCommitHorizontal,
6+ GitMerge,
7+ MessageSquare,
8+ MessagesSquare,
9+ StickyNote,
10+ User,
11+ Wrench,
12+} from "lucide-react";
13+import { useEffect } from "react";
14+import { Form, Link, redirect, useRevalidator } from "react-router";
15+
16+import type { Comparison, SessionEntry } from "@g1t/contracts";
17+
18+import type { Route } from "./+types/pull";
19+import { DiffView } from "../../components/diff-view";
20+import { Markdown } from "../../components/markdown";
21+import {
22+ Avatar,
23+ Button,
24+ CopyLine,
25+ EmptyState,
26+ ErrorText,
27+ Textarea,
28+ TimeAgo,
29+} from "../../components/ui";
30+import { Comments, IssueIcon, PullState } from "../../components/work";
31+import { repos, work } from "../../lib/services.server";
32+import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server";
33+
34+const REFRESH_MS = 4000;
35+const EMPTY_COMPARISON: Comparison = { base: null, head: "", files: [], truncated: false };
36+const TABS = ["conversation", "session", "changes"] as const;
37+type Tab = (typeof TABS)[number];
38+
39+export function meta({ loaderData, params }: Route.MetaArgs) {
40+ const title = loaderData
41+ ? `${loaderData.pull.title} · Pull request #${loaderData.pull.number} · `
42+ : "";
43+ return [{ title: `${title}${params.owner}/${params.repo} · g1t` }];
44+}
45+
46+export async function loader({ params, context, request }: Route.LoaderArgs) {
47+ const viewer = getViewer(context);
48+ const path = { namespace: params.owner, name: params.repo };
49+ const number = Number(params.number);
50+ const asked = new URL(request.url).searchParams.get("tab");
51+ const tab: Tab = TABS.find((name) => name === asked) ?? "conversation";
52+
53+ const [found, repo] = await Promise.all([
54+ work.getPull(path, number, viewer),
55+ repos.get(path, viewer),
56+ ]);
57+ if (!found.ok) {
58+ // Issues and pull requests share numbers; this one may be an issue.
59+ const issue = await work.getIssue(path, number, viewer);
60+ if (issue.ok) throw redirect(`/${params.owner}/${params.repo}/issues/${number}`);
61+ throw new Response("Pull request not found.", { status: 404 });
62+ }
63+ const { pull } = found.value;
64+ const [session, comparison] = await Promise.all([
65+ tab === "session" ? work.readSession(path, number, viewer) : null,
66+ tab === "changes" ? repos.compare(pull.forkRepoId, viewer, pull.mergeBase) : null,
67+ ]);
68+ const member = (viewer?.workspaces ?? []).some(
69+ (membership) => membership.slug === params.owner,
70+ );
71+ return {
72+ ...found.value,
73+ tab,
74+ session: session?.ok ? session.value : [],
75+ // An empty comparison if it could not be made.
76+ comparison: comparison && (comparison.ok ? comparison.value : EMPTY_COMPARISON),
77+ viewer,
78+ // Members of the repository's workspace can merge.
79+ canMerge: member,
80+ canManage: member || viewer?.id === pull.author.id,
81+ defaultBranch: repo.ok ? repo.value.defaultBranch : "main",
82+ };
83+}
84+
85+export async function action({ request, params, context }: Route.ActionArgs) {
86+ assertSameOrigin(request);
87+ const user = requireUser(context, request);
88+ const form = await request.formData();
89+ const path = { namespace: params.owner, name: params.repo };
90+ const number = Number(params.number);
91+ const action = form.get("action");
92+ const result =
93+ action === "merge"
94+ ? await work.mergePull(user, path, number, form.get("keepIssueOpen") === "on")
95+ : action === "close"
96+ ? await work.closePull(user, path, number)
97+ : action === "comment"
98+ ? await work.addComment(user, path, number, String(form.get("body") ?? ""))
99+ : await work.readyPull(user, path, number, String(form.get("summary") ?? ""));
100+ return result.ok ? null : { error: result.error.message, action };
101+}
102+
103+function TabLink({
104+ to,
105+ active,
106+ children,
107+}: {
108+ to: string;
109+ active: boolean;
110+ children: React.ReactNode;
111+}) {
112+ return (
113+ <Link
114+ to={to}
115+ preventScrollReset
116+ className={
117+ "-mb-px flex items-center gap-2 border-b-2 px-1 pb-2.5 text-sm transition-colors " +
118+ (active
119+ ? "border-accent font-medium text-fg"
120+ : "border-transparent text-muted hover:text-fg")
121+ }
122+ >
123+ {children}
124+ </Link>
125+ );
126+}
127+
128+/** One step of the session, on the timeline's rail. */
129+function Entry({ entry, agent }: { entry: SessionEntry; agent: string }) {
130+ const isTool = entry.kind === "tool_call" || entry.kind === "tool_result";
131+ const Icon =
132+ entry.kind === "prompt"
133+ ? User
134+ : entry.kind === "note"
135+ ? StickyNote
136+ : isTool
137+ ? Wrench
138+ : Bot;
139+ return (
140+ <li className="relative pl-10">
141+ <span
142+ className={`absolute top-0.5 left-0 flex size-7 items-center justify-center rounded-full border bg-bg ${
143+ entry.kind === "prompt"
144+ ? "border-accent/50 text-accent"
145+ : "border-line text-faint"
146+ }`}
147+ >
148+ <Icon size={14} />
149+ </span>
150+ {isTool ? (
151+ <details className="group rounded-lg border border-line bg-surface">
152+ <summary className="flex cursor-pointer list-none items-center gap-2 px-3 py-1.5 text-sm">
153+ <ChevronRight
154+ size={14}
155+ className="text-faint transition-transform group-open:rotate-90"
156+ />
157+ <span className="font-mono text-xs text-accent">
158+ {entry.tool ?? "tool"}
159+ </span>
160+ <span className="truncate font-mono text-xs text-muted">
161+ {entry.kind === "tool_result" ? "→ " : ""}
162+ {entry.text.split("\n")[0]}
163+ </span>
164+ </summary>
165+ <pre className="overflow-x-auto border-t border-line p-3 font-mono text-xs whitespace-pre-wrap text-muted">
166+ {entry.text}
167+ </pre>
168+ </details>
169+ ) : (
170+ <div>
171+ <p className="text-xs font-medium text-faint">
172+ {entry.kind === "prompt"
173+ ? "Prompt"
174+ : entry.kind === "note"
175+ ? "Note"
176+ : agent}
177+ </p>
178+ {entry.kind === "message" ? (
179+ <div className="mt-1">
180+ <Markdown source={entry.text} />
181+ </div>
182+ ) : (
183+ <p
184+ className={`mt-1 text-[0.9375rem] leading-relaxed wrap-break-word whitespace-pre-wrap ${
185+ entry.kind === "prompt" ? "font-medium" : ""
186+ }`}
187+ >
188+ {entry.text}
189+ </p>
190+ )}
191+ </div>
192+ )}
193+ {entry.commit && (
194+ <p className="mt-1.5 flex items-center gap-1 font-mono text-xs text-faint">
195+ <GitCommitHorizontal size={12} />
196+ {entry.commit.slice(0, 7)}
197+ </p>
198+ )}
199+ </li>
200+ );
201+}
202+
203+export default function PullPage({ loaderData, actionData, params }: Route.ComponentProps) {
204+ const {
205+ pull,
206+ issue,
207+ comments,
208+ tab,
209+ session,
210+ comparison,
211+ viewer,
212+ canMerge,
213+ canManage,
214+ defaultBranch,
215+ } = loaderData;
216+ const base = `/${params.owner}/${params.repo}`;
217+ const here = `${base}/pull/${pull.number}`;
218+ const remote = `https://g1t.sh/${pull.fork.namespace}/${pull.fork.name}.git`;
219+ const active = pull.status === "draft" || pull.status === "open";
220+
221+ // Follow an agent at work without a manual reload.
222+ const revalidator = useRevalidator();
223+ const working = pull.status === "draft";
224+ useEffect(() => {
225+ if (!working) return;
226+ const timer = setInterval(() => {
227+ if (document.visibilityState === "visible") revalidator.revalidate();
228+ }, REFRESH_MS);
229+ return () => clearInterval(timer);
230+ }, [working, revalidator]);
231+
232+ return (
233+ <div className="grid gap-8 lg:grid-cols-[1fr_19rem]">
234+ <div className="min-w-0">
235+ <h2 className="text-2xl font-semibold tracking-tight text-balance">
236+ {pull.title} <span className="font-normal text-faint">#{pull.number}</span>
237+ </h2>
238+ <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2 text-sm text-muted">
239+ <PullState status={pull.status} />
240+ <span className="flex items-center gap-2">
241+ <Avatar name={pull.author.username} size={18} />
242+ <span>
243+ <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>
246+ </span>
247+ </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>
253+ </div>
254+
255+ {issue && (
256+ <Link
257+ to={`${base}/issues/${issue.number}`}
258+ className="mt-4 flex items-center gap-2.5 rounded-xl border border-line bg-surface px-4 py-2.5 text-sm transition-colors hover:border-line-strong"
259+ >
260+ <IssueIcon issue={issue} />
261+ <span className="text-muted">
262+ {pull.status === "merged" && issue.resolvedBy === pull.number
263+ ? "Resolved"
264+ : "For issue"}
265+ </span>
266+ <span className="truncate font-medium">{issue.title}</span>
267+ <span className="text-faint">#{issue.number}</span>
268+ {issue.pullCount > 1 && (
269+ <span className="ml-auto shrink-0 text-xs text-faint">
270+ 1 of {issue.pullCount} pull requests
271+ </span>
272+ )}
273+ </Link>
274+ )}
275+
276+ {pull.status === "merged" && (
277+ <p className="mt-4 flex items-center gap-2.5 rounded-xl border border-merged/40 bg-merged/5 px-4 py-2.5 text-sm">
278+ <GitMerge size={16} className="shrink-0 text-merged" />
279+ <span>
280+ Merged into <span className="font-mono">{defaultBranch}</span> by{" "}
281+ <span className="font-medium">{pull.mergedBy}</span>{" "}
282+ {pull.mergedAt && <TimeAgo at={pull.mergedAt} />}
283+ {pull.headCommit && (
284+ <span className="font-mono text-muted"> · {pull.headCommit.slice(0, 7)}</span>
285+ )}
286+ </span>
287+ </p>
288+ )}
289+ {pull.supersededBy != null && (
290+ <p className="mt-4 rounded-xl border border-line bg-surface px-4 py-2.5 text-sm text-muted">
291+ Closed because{" "}
292+ <Link
293+ to={`${base}/pull/${pull.supersededBy}`}
294+ className="font-medium text-fg hover:underline"
295+ >
296+ #{pull.supersededBy}
297+ </Link>{" "}
298+ was merged for this issue instead.
299+ </p>
300+ )}
301+
302+ <nav className="mt-8 flex gap-6 border-b border-line">
303+ <TabLink to={here} active={tab === "conversation"}>
304+ <MessageSquare size={15} />
305+ Conversation
306+ </TabLink>
307+ <TabLink to={here + "?tab=session"} active={tab === "session"}>
308+ <MessagesSquare size={15} />
309+ Session
310+ </TabLink>
311+ <TabLink to={here + "?tab=changes"} active={tab === "changes"}>
312+ <FileDiff size={15} />
313+ Changes
314+ </TabLink>
315+ </nav>
316+ <div className="mt-5">
317+ {comparison ? (
318+ <DiffView comparison={comparison} />
319+ ) : tab === "session" ? (
320+ session.length === 0 ? (
321+ <EmptyState title="Nothing recorded yet">
322+ The agent's prompts, reasoning and tool calls appear here as it
323+ works.
324+ </EmptyState>
325+ ) : (
326+ <ol className="relative space-y-5 before:absolute before:top-2 before:bottom-2 before:left-3.25 before:w-px before:bg-line">
327+ {session.map((entry) => (
328+ <Entry key={entry.seq} entry={entry} agent={pull.agent} />
329+ ))}
330+ </ol>
331+ )
332+ ) : (
333+ <div className="space-y-4">
334+ {pull.body ? (
335+ <section className="rounded-xl border border-line bg-surface p-5">
336+ <Markdown source={pull.body} />
337+ </section>
338+ ) : (
339+ <p className="rounded-xl border border-dashed border-line px-5 py-4 text-sm text-muted">
340+ {working
341+ ? "No description yet. It is written when the pull request is marked ready for review."
342+ : "No description."}
343+ </p>
344+ )}
345+ <Comments comments={comments} canComment={Boolean(viewer)} />
346+ {actionData?.action === "comment" && <ErrorText>{actionData.error}</ErrorText>}
347+ </div>
348+ )}
349+ </div>
350+ </div>
351+
352+ <aside className="space-y-6">
353+ {canMerge && pull.status === "open" && (
354+ <section className="rounded-xl border border-accent/30 bg-accent/5 p-4">
355+ <h3 className="text-sm font-medium">Merge this pull request</h3>
356+ <p className="mt-1 text-xs text-muted">
357+ Lands its commits on {defaultBranch}.
358+ {issue?.state === "open" &&
359+ ` Closes issue #${issue.number}, and any other pull requests still open for it.`}
360+ </p>
361+ <Form method="post" className="mt-3 space-y-3">
362+ {issue?.state === "open" && (
363+ <label className="flex items-start gap-2 text-xs text-muted">
364+ <input type="checkbox" name="keepIssueOpen" className="mt-0.5 accent-accent" />
365+ <span>
366+ Keep #{issue.number} open. This is only part of the work.
367+ </span>
368+ </label>
369+ )}
370+ <div className="*:w-full">
371+ <Button variant="accent" type="submit" name="action" value="merge">
372+ <GitMerge size={15} />
373+ Merge into {defaultBranch}
374+ </Button>
375+ </div>
376+ </Form>
377+ {actionData?.action === "merge" && <ErrorText>{actionData.error}</ErrorText>}
378+ </section>
379+ )}
380+
381+ {canManage && active && (
382+ <section className="rounded-xl border border-line bg-surface p-4">
383+ <h3 className="text-sm font-medium">
384+ {pull.status === "draft" ? "Ready for review" : "Update description"}
385+ </h3>
386+ <Form method="post" className="mt-3 space-y-2">
387+ <Textarea
388+ name="summary"
389+ rows={4}
390+ placeholder="What changed and why"
391+ defaultValue={pull.body ?? ""}
392+ />
393+ <div className="flex flex-wrap gap-2">
394+ <Button type="submit">
395+ {pull.status === "draft" ? "Mark ready" : "Save"}
396+ </Button>
397+ <Button variant="quiet" type="submit" name="action" value="close">
398+ Close pull request
399+ </Button>
400+ </div>
401+ </Form>
402+ {actionData && actionData.action !== "merge" && actionData.action !== "comment" && (
403+ <ErrorText>{actionData.error}</ErrorText>
404+ )}
405+ </section>
406+ )}
407+
408+ <section>
409+ <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>
416+ <p className="mt-3 flex items-center gap-1.5 font-mono text-xs text-faint">
417+ <GitCommitHorizontal size={13} />
418+ {pull.headCommit?.slice(0, 12) ?? "no commits pushed yet"}
419+ </p>
420+ </section>
421+ </aside>
422+ </div>
423+ );
424+}
+77−0
1+import { Bot } from "lucide-react";
2+import { Link } from "react-router";
3+
4+import type { Route } from "./+types/pulls";
5+import { EmptyState, TimeAgo } from "../../components/ui";
6+import { PullIcon, StateTabs } from "../../components/work";
7+import { work } from "../../lib/services.server";
8+import { getViewer, unwrap } from "../../lib/session.server";
9+
10+export function meta({ params }: Route.MetaArgs) {
11+ return [{ title: `Pull requests · ${params.owner}/${params.repo} · g1t` }];
12+}
13+
14+export async function loader({ params, context, request }: Route.LoaderArgs) {
15+ const path = { namespace: params.owner, name: params.repo };
16+ const state =
17+ new URL(request.url).searchParams.get("state") === "closed" ? "closed" : "open";
18+ return {
19+ pulls: unwrap(await work.listPulls(path, getViewer(context), state)),
20+ state,
21+ } as const;
22+}
23+
24+export default function Pulls({ loaderData, params }: Route.ComponentProps) {
25+ const { pulls, state } = loaderData;
26+ const base = `/${params.owner}/${params.repo}`;
27+ return (
28+ <div>
29+ <StateTabs to={`${base}/pulls`} state={state} />
30+ <div className="mt-4">
31+ {pulls.length === 0 ? (
32+ <EmptyState
33+ title={state === "open" ? "No open pull requests" : "No closed pull requests"}
34+ >
35+ A pull request is a change proposed from its own fork. Open one
36+ from an issue, or have an agent do it.
37+ </EmptyState>
38+ ) : (
39+ <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line">
40+ {pulls.map((pull) => (
41+ <li key={pull.id}>
42+ <Link
43+ to={`${base}/pull/${pull.number}`}
44+ className="flex items-start gap-3 px-4 py-3 transition-colors hover:bg-surface"
45+ >
46+ <span className="mt-0.5">
47+ <PullIcon status={pull.status} />
48+ </span>
49+ <span className="min-w-0 grow">
50+ <span className="flex items-center gap-2">
51+ <span className="truncate font-medium">{pull.title}</span>
52+ {pull.status === "draft" && (
53+ <span className="shrink-0 rounded-full border border-line px-1.5 py-px text-[0.6875rem] text-faint">
54+ draft
55+ </span>
56+ )}
57+ </span>
58+ <span className="mt-0.5 block text-xs text-faint">
59+ #{pull.number} opened <TimeAgo at={pull.createdAt} /> by{" "}
60+ {pull.author.username}
61+ {pull.issue != null && <> · for #{pull.issue}</>}
62+ {pull.supersededBy != null && <> · superseded by #{pull.supersededBy}</>}
63+ </span>
64+ </span>
65+ <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}
68+ </span>
69+ </Link>
70+ </li>
71+ ))}
72+ </ul>
73+ )}
74+ </div>
75+ </div>
76+ );
77+}
+1−1
119119 </div>
120120 {!role && (
121121 <p className="mt-2 text-sm text-muted">
122− Members can create repositories here and ship changes to them.
122+ Members can create repositories here and merge pull requests into them.
123123 </p>
124124 )}
125125 </aside>
+46−26
11 # g1t
22
33 > g1t (https://g1t.sh) is a git forge built for AI agents. It is ordinary git
4−> over HTTPS, plus a way to organise work when many agents change the same
5−> code at once. A goal is an "intent"; each agent's try at it is an "attempt"
6−> in its own fork; the owner "ships" one attempt onto main. If you know pull
7−> requests: an attempt is a pull request, an intent is the goal it serves.
4+> over HTTPS, with issues and pull requests, built so that many agents can
5+> work on the same issue at once. Each pull request lives in its own fork
6+> and carries a recording of how it was made. Several can be opened for one
7+> issue; merging one closes the issue and records which one resolved it.
88
99 This file tells an assistant everything needed to get a person set up on g1t
1010 and working. You never ask for, see, or send the person's password. Accounts
3131
3232 A new account also gets a confirmation email from `noreply@g1t.sh`. Ask
3333 them to open it and follow the link. Until they do, the account cannot
34− create repositories, push, or open intents: those calls return `403`
34+ create repositories, push, or open issues: those calls return `403`
3535 with a message saying to confirm the address.
3636
3737 3. **Collect the token.** Poll every `interval` seconds, not faster:
8282
8383 ## Do work
8484
85−- **Open an intent:** `POST /v1/repos/{owner}/{name}/intents` with `title`,
86− `brief`, and optional `checks` (commands that must pass).
87−- **Start an attempt:** `POST /v1/intents/{intent_id}/attempts` with `agent`
88− (a label such as `claude-code`). The response has `git.remote`, the
89− attempt's own fork. Clone it, commit, and push to it with the token.
85+Issues and pull requests are addressed by repository and number, and share
86+one sequence of numbers: `#12` is one or the other. Below, `{repo}` stands
87+for `/v1/repos/{owner}/{name}`.
88+
89+- **Find work:** `GET {repo}/issues?state=open`, optionally `&label=bug`.
90+- **Open an issue:** `POST {repo}/issues` with `title`, `body`, and
91+ optional `labels` (such as `bug` or `feature`; a new name makes a new
92+ label) and `checks` (commands that should pass).
93+- **Read an issue:** `GET {repo}/issues/{number}`. It lists every pull
94+ request already made for it. A closed issue's `resolvedBy` is the number
95+ of the pull request that was merged.
96+- **Open a pull request:** `POST {repo}/pulls` with `issue` (its number) and
97+ `agent` (a label such as `claude-code`). Without an issue, send `title`.
98+ The response has `pull.number` and `git.remote`, the pull request's own
99+ fork. Clone it, commit, and push to it with the token. It starts as a
100+ draft.
90101 - **Record the session** as you work, so people can see why a change was
91− made: `POST /v1/attempts/{attempt_id}/session` with
102+ made: `POST {repo}/pulls/{number}/session` with
92103 `{"entries": [{"kind": "message", "text": "…"}]}`. Kinds are `prompt`,
93104 `message`, `tool_call`, `tool_result`, `note`. Never include secrets;
94105 sessions are as visible as the repository.
95−- **Submit:** `POST /v1/attempts/{attempt_id}/submit` with `summary`.
96−- **See what an attempt changed:** `GET /v1/attempts/{attempt_id}/changes`.
97−- **Ship** (members of the repository's workspace):
98− `POST /v1/attempts/{attempt_id}/ship`. A `409` saying main has moved means
99− the fork is behind: pull main from `https://g1t.sh/{owner}/{name}.git` into
100− the fork, push, and ship again.
106+- **Mark it ready:** `POST {repo}/pulls/{number}/ready` with `summary`, which
107+ becomes the pull request's description.
108+- **See what a pull request changes:** `GET {repo}/pulls/{number}/changes`.
109+- **Comment** on an issue or a pull request:
110+ `POST {repo}/issues/{number}/comments` with `body`.
111+- **Merge** (members of the repository's workspace):
112+ `POST {repo}/pulls/{number}/merge`. This closes the issue it was for and
113+ closes the other pull requests for that issue as superseded; send
114+ `{"keep_issue_open": true}` if this is only part of the work. A `409`
115+ saying main has moved means the fork is behind: pull main from
116+ `https://g1t.sh/{owner}/{name}.git` into the fork, push, and merge again.
101117
102−Every one of these is also an MCP tool with the same name in snake case:
103−`open_intent`, `start_attempt`, `record_session`, `submit_attempt`,
104−`get_attempt_changes`, `ship_attempt`, and `list_intents`, `get_intent`,
105−`get_attempt`, `read_session`, `list_repos`, `get_repo`, `create_repo`,
106−`list_events`, `create_workspace`, `whoami`.
118+Every one of these is also an MCP tool: `list_issues`, `get_issue`,
119+`create_issue`, `update_issue`, `close_issue`, `reopen_issue`,
120+`list_labels`, `add_comment`, `list_pull_requests`, `get_pull_request`,
121+`create_pull_request`, `record_session`, `read_session`,
122+`mark_pull_request_ready`, `close_pull_request`,
123+`get_pull_request_changes`, `merge_pull_request`, and `list_repos`,
124+`get_repo`, `create_repo`, `list_events`, `create_workspace`, `whoami`.
125+MCP tools take the repository as `repo`, written `owner/name`.
107126
108127 ## Facts
109128
113132 (401), `forbidden` (403), `not_found` (404), `conflict` (409), `invalid`
114133 (422). The full description is at https://api.g1t.sh/openapi.json.
115134 - Git remote: `https://g1t.sh/{workspace}/{repo}.git`. In API paths,
116− `{owner}` is the workspace. Attempt forks:
117− `https://g1t.sh/attempts/{attempt_id}.git`. SSH is not available.
135+ `{owner}` is the workspace. Pull request forks:
136+ `https://g1t.sh/pulls/{pull_request_id}.git`. SSH is not available.
118137 - Limits: 1 GB per repository, 32 MB per file, 100 MB per push.
119138 - Forgotten password: https://g1t.sh/forgot (the person does this, in a
120139 browser).
121−- Not available yet: merging on the server, running checks automatically,
122− OAuth sign-in for MCP.
140+- Times are RFC 3339 in UTC.
141+- Not available yet: pull requests from branches, merge commits made on
142+ the server, running checks automatically, OAuth sign-in for MCP.
123143
124144 ## More
125145
+38−23
3131 pub struct RepoForked {
3232 pub repo_id: String,
3333 pub source_repo_id: String,
34− pub attempt_id: String,
34+ pub pull_id: String,
3535 }
3636
3737 /// `after` is the commit the ref points to once the push has landed.
4444 pub after: String,
4545 }
4646
47−#[derive(Debug, Serialize)]
47+/// The payload of `issue.opened`, `issue.updated`, `issue.closed` and
48+/// `issue.reopened`; each uses the fields that apply to it.
49+#[derive(Debug, Default, Serialize)]
4850 #[serde(rename_all = "camelCase")]
49−pub struct IntentOpened {
50− pub intent_id: String,
51+pub struct IssueEvent {
52+ pub issue_id: String,
5153 pub repo_id: String,
5254 pub number: u32,
53− pub title: String,
54−}
55−
56−#[derive(Debug, Serialize)]
57−#[serde(rename_all = "camelCase")]
58−pub struct IntentClosed {
59− pub intent_id: String,
60− pub repo_id: String,
61− /// `shipped` or `withdrawn`.
62− pub reason: &'static str,
55+ #[serde(skip_serializing_if = "Option::is_none")]
56+ pub title: Option<String>,
57+ /// On close: `completed` or `not_planned`.
58+ #[serde(skip_serializing_if = "Option::is_none")]
59+ pub reason: Option<&'static str>,
60+ /// On close: the number of the pull request whose merge closed it.
61+ #[serde(skip_serializing_if = "Option::is_none")]
62+ pub resolved_by: Option<u32>,
6363 }
6464
65−/// The payload of `attempt.started`, `attempt.updated`, `attempt.submitted`
66−/// and `attempt.shipped`; each uses the fields that apply to it.
65+/// The payload of `pull.opened`, `pull.ready`, `pull.closed` and
66+/// `pull.merged`; each uses the fields that apply to it.
6767 #[derive(Debug, Default, Serialize)]
6868 #[serde(rename_all = "camelCase")]
69−pub struct AttemptEvent {
70− pub attempt_id: String,
71− pub intent_id: String,
69+pub struct PullEvent {
70+ pub pull_id: String,
7271 pub repo_id: String,
72+ pub number: u32,
73+ /// The number of the issue it is for.
74+ #[serde(skip_serializing_if = "Option::is_none")]
75+ pub issue: Option<u32>,
7376 #[serde(skip_serializing_if = "Option::is_none")]
7477 pub agent: Option<String>,
78+ /// On merge: the commit the branch now points to.
7579 #[serde(skip_serializing_if = "Option::is_none")]
76− pub status: Option<&'static str>,
80+ pub commit: Option<String>,
81+ /// On close: the pull request that was merged instead.
7782 #[serde(skip_serializing_if = "Option::is_none")]
78− pub commit: Option<String>,
83+ pub superseded_by: Option<u32>,
7984 }
8085
86+/// `comment.created`. `number` is the issue or pull request commented on.
8187 #[derive(Debug, Serialize)]
8288 #[serde(rename_all = "camelCase")]
89+pub struct CommentCreated {
90+ pub comment_id: String,
91+ pub repo_id: String,
92+ pub number: u32,
93+}
94+
95+#[derive(Debug, Serialize)]
96+#[serde(rename_all = "camelCase")]
8397 pub struct SessionAppended {
84− pub attempt_id: String,
85− pub session_id: String,
98+ pub pull_id: String,
99+ pub repo_id: String,
100+ pub number: u32,
86101 pub count: u32,
87102 }
88103
+1−1
2525 pub enum Role {
2626 /// Everything a member can, plus managing members.
2727 Owner,
28− /// Create repositories, push, open intents and ship.
28+ /// Create repositories, push, manage issues and merge pull requests.
2929 Member,
3030 }
3131
+2−1
1010 "search",
1111 "admin",
1212 "auth",
13− "attempts",
13+ "pulls",
14+ "issues",
1415 "oauth",
1516 "assets",
1617 "docs",
+7−7
1818 pub is_private: bool,
1919 pub owner_id: String,
2020 pub default_branch: String,
21− /// Set when this repo is an attempt's working copy of another repo.
21+ /// Set when this repo is a pull request's working copy of another repo.
2222 pub fork_of: Option<String>,
2323 /// RFC 3339.
2424 pub created_at: String,
110110 ReceivePack,
111111 }
112112
113−/// The result of landing an attempt.
113+/// The result of landing a pull request.
114114 #[derive(Clone, Debug, Serialize, Deserialize)]
115115 pub struct Landed {
116116 /// The commit the branch points to now.
117117 pub commit: String,
118118 /// The commit it pointed to before, if it had one. Comparing against
119− /// this shows what the attempt changed.
119+ /// this shows what the pull request changed.
120120 pub previous: Option<String>,
121121 }
122122
198198 pub limit: u32,
199199 }
200200
201−/// `fork_for_attempt`: a copy-on-write copy of the source repo, hidden from
202−/// listings, for one attempt to work in. Returns `Outcome<Repo>`.
201+/// `fork_for_pull`: a copy-on-write copy of the source repo, hidden from
202+/// listings, for one pull request to be made in. Returns `Outcome<Repo>`.
203203 #[derive(Debug, Serialize, Deserialize)]
204204 #[serde(rename_all = "camelCase")]
205205 pub struct ForkArgs {
206206 pub source_id: String,
207− pub attempt_id: String,
207+ pub pull_id: String,
208208 pub actor: User,
209209 }
210210
285285
286286 /// `compare`: what a repository's head changes.
287287 ///
288−/// With no `base`, an attempt's fork is compared against the point where it
288+/// With no `base`, a pull request's fork is compared against the point where it
289289 /// and the repository it came from last agreed, and any other repository
290290 /// against its head's parent. Returns `Outcome<Comparison>`.
291291 #[derive(Debug, Serialize, Deserialize)]
+255−80
1−//! The work service: intents, attempts and sessions.
1+//! The work service: issues, pull requests, comments and sessions.
22 //!
33 //! Each `*Args` struct is the argument of the method of the same name,
44 //! served at `POST /rpc/<method>`.
5+//!
6+//! Issues and pull requests share one sequence of numbers per repository,
7+//! so `#12` names exactly one of them.
58
69 use serde::{Deserialize, Serialize};
710
811 use crate::repos::RepoPath;
912 use crate::{User, Viewer};
1013
14+/// Labels every repository starts with. Any other label comes into being
15+/// the first time it is put on an issue.
16+pub const DEFAULT_LABELS: [&str; 5] = ["bug", "feature", "docs", "chore", "question"];
17+
18+/// `open` or `closed`: the filter on lists of issues and pull requests. An
19+/// open pull request is a draft or one ready for review; a closed one was
20+/// merged or closed without merging.
1121 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
1222 #[serde(rename_all = "lowercase")]
13−pub enum IntentStatus {
23+pub enum State {
1424 Open,
15− Shipped,
16− Withdrawn,
25+ Closed,
26+}
27+
28+/// Why an issue was closed.
29+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
30+#[serde(rename_all = "snake_case")]
31+pub enum IssueReason {
32+ /// The work was done. If a pull request did it, `resolved_by` names it.
33+ Completed,
34+ NotPlanned,
1735 }
1836
19−/// A goal stated against a repo: the issue, and the home of every attempt
20−/// made for it.
37+impl IssueReason {
38+ pub fn as_str(self) -> &'static str {
39+ match self {
40+ IssueReason::Completed => "completed",
41+ IssueReason::NotPlanned => "not_planned",
42+ }
43+ }
44+}
45+
46+/// Something that should change in a repository: a bug, a feature, a
47+/// question. Opened by a person, an agent or an integration. Pull requests
48+/// are made against it; the one that is merged resolves it.
2149 #[derive(Clone, Debug, Serialize, Deserialize)]
2250 #[serde(rename_all = "camelCase")]
23−pub struct Intent {
51+pub struct Issue {
2452 pub id: String,
2553 pub repo_id: String,
26− /// Sequential per repo, shown as `#12`.
54+ /// Shown as `#12`.
2755 pub number: u32,
2856 pub title: String,
29− /// The goal in prose: what an agent is given to work from.
30− pub brief: String,
31− /// Commands that must pass for an attempt to be accepted.
57+ /// Markdown. Also what an agent is given to work from.
58+ pub body: String,
59+ pub labels: Vec<String>,
60+ /// Commands that must pass for a pull request to be accepted.
3261 pub checks: Vec<String>,
33− pub status: IntentStatus,
62+ pub state: State,
63+ /// Set when closed.
64+ pub reason: Option<IssueReason>,
65+ /// The number of the pull request whose merge closed this issue.
66+ pub resolved_by: Option<u32>,
3467 pub author: User,
3568 /// RFC 3339.
3669 pub created_at: String,
37− pub attempt_count: u32,
70+ /// RFC 3339.
71+ pub updated_at: String,
72+ /// RFC 3339.
73+ pub closed_at: Option<String>,
74+ /// Pull requests made against this issue, in any state.
75+ pub pull_count: u32,
76+ pub comment_count: u32,
3877 }
3978
4079 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
4180 #[serde(rename_all = "lowercase")]
42−pub enum AttemptStatus {
43− Working,
44− Submitted,
45− Shipped,
46− Abandoned,
81+pub enum PullStatus {
82+ /// Still being worked on.
83+ Draft,
84+ /// Ready for review.
85+ Open,
86+ Merged,
87+ /// Closed without merging.
88+ Closed,
4789 }
4890
49−impl AttemptStatus {
91+impl PullStatus {
5092 pub fn as_str(self) -> &'static str {
5193 match self {
52− AttemptStatus::Working => "working",
53− AttemptStatus::Submitted => "submitted",
54− AttemptStatus::Shipped => "shipped",
55− AttemptStatus::Abandoned => "abandoned",
94+ PullStatus::Draft => "draft",
95+ PullStatus::Open => "open",
96+ PullStatus::Merged => "merged",
97+ PullStatus::Closed => "closed",
5698 }
5799 }
58100
59− /// Whether the attempt can still be changed or shipped.
101+ /// Whether the pull request can still be changed or merged.
60102 pub fn is_active(self) -> bool {
61− matches!(self, AttemptStatus::Working | AttemptStatus::Submitted)
103+ matches!(self, PullStatus::Draft | PullStatus::Open)
62104 }
63105 }
64106
65107 /// Where the agent runs: on g1t's sandboxes, or in someone's own session.
66108 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
67109 #[serde(rename_all = "lowercase")]
68−pub enum AttemptRuntime {
110+pub enum Runtime {
69111 Hosted,
70112 External,
71113 }
72114
73−/// One agent's or person's run at an intent, in its own fork.
115+/// A proposed change, made in its own fork by an agent or a person.
74116 #[derive(Clone, Debug, Serialize, Deserialize)]
75117 #[serde(rename_all = "camelCase")]
76−pub struct Attempt {
118+pub struct Pull {
77119 pub id: String,
78− pub intent_id: String,
79120 pub repo_id: String,
80− /// Sequential per intent.
121+ /// Shown as `#12`.
81122 pub number: u32,
123+ /// The number of the issue this is for, if any.
124+ pub issue: Option<u32>,
125+ pub title: String,
126+ /// Markdown: what changed and why. Set when marked ready.
127+ pub body: Option<String>,
82128 /// A label for the agent doing the work, e.g. `claude-code`.
83129 pub agent: String,
84− pub runtime: AttemptRuntime,
85− pub status: AttemptStatus,
86− /// The agent's own account of what it did, set on submit.
87− pub summary: Option<String>,
130+ pub runtime: Runtime,
131+ pub status: PullStatus,
88132 pub fork: RepoPath,
89133 /// The fork's repository id.
90134 pub fork_repo_id: String,
91135 pub head_commit: Option<String>,
92− /// For a shipped attempt, what the branch pointed to before it landed.
93− /// Comparing against it shows what the attempt changed.
94− pub landed_base: Option<String>,
95− pub started_by: User,
136+ /// For a merged pull request, what the branch pointed to before the
137+ /// merge. Comparing against it shows what the pull request changed.
138+ pub merge_base: Option<String>,
139+ /// Username of whoever merged it.
140+ pub merged_by: Option<String>,
141+ /// RFC 3339.
142+ pub merged_at: Option<String>,
143+ /// Set on a pull request closed because another one for the same issue
144+ /// was merged: that one's number.
145+ pub superseded_by: Option<u32>,
146+ pub author: User,
96147 /// RFC 3339.
97148 pub created_at: String,
98149 /// RFC 3339.
99150 pub updated_at: String,
100151 }
101152
153+#[derive(Clone, Debug, Serialize, Deserialize)]
154+#[serde(rename_all = "camelCase")]
155+pub struct Comment {
156+ pub id: String,
157+ pub author: User,
158+ /// Markdown.
159+ pub body: String,
160+ /// RFC 3339.
161+ pub created_at: String,
162+}
163+
102164 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
103165 #[serde(rename_all = "snake_case")]
104166 pub enum SessionEntryKind {
109171 Note,
110172 }
111173
112−/// One step of an agent's session: the "why" behind an attempt's commits.
174+/// One step of an agent's session: the "why" behind a pull request's commits.
113175 #[derive(Clone, Debug, Serialize, Deserialize)]
114176 pub struct SessionEntry {
115177 pub seq: u32,
134196 }
135197
136198 #[derive(Clone, Debug, Serialize, Deserialize)]
137−pub struct IntentDetail {
138− pub intent: Intent,
139− pub attempts: Vec<Attempt>,
199+pub struct IssueDetail {
200+ pub issue: Issue,
201+ /// Every pull request made against it, oldest first.
202+ pub pulls: Vec<Pull>,
203+ pub comments: Vec<Comment>,
140204 }
141205
142206 #[derive(Clone, Debug, Serialize, Deserialize)]
143−pub struct AttemptDetail {
144− pub attempt: Attempt,
145− pub intent: Intent,
207+pub struct PullDetail {
208+ pub pull: Pull,
209+ /// The issue it is for, if any.
210+ pub issue: Option<Issue>,
211+ pub comments: Vec<Comment>,
146212 }
147213
148−/// `open_intent`. Returns `Outcome<Intent>`.
214+/// `open_issue`. Returns `Outcome<Issue>`.
149215 #[derive(Debug, Serialize, Deserialize)]
150−pub struct OpenIntentArgs {
216+pub struct OpenIssueArgs {
151217 pub actor: User,
152218 pub repo: RepoPath,
153219 pub title: String,
154220 #[serde(default)]
155− pub brief: String,
221+ pub body: String,
222+ #[serde(default)]
223+ pub labels: Vec<String>,
156224 #[serde(default)]
157225 pub checks: Vec<String>,
158226 }
159227
160−/// `list_intents`. Returns `Outcome<Vec<Intent>>`.
228+/// `list_issues`, newest first. Returns `Outcome<Vec<Issue>>`.
161229 #[derive(Debug, Serialize, Deserialize)]
162−pub struct ListIntentsArgs {
230+pub struct ListIssuesArgs {
163231 pub repo: RepoPath,
164232 pub viewer: Viewer,
165233 #[serde(default)]
166− pub status: Option<IntentStatus>,
234+ pub state: Option<State>,
235+ /// Only issues carrying this label.
236+ #[serde(default)]
237+ pub label: Option<String>,
167238 }
168239
169−/// `get_intent`. Returns `Outcome<IntentDetail>`.
240+/// `list_pulls`, newest first. Returns `Outcome<Vec<Pull>>`.
170241 #[derive(Debug, Serialize, Deserialize)]
171−pub struct GetIntentArgs {
242+pub struct ListPullsArgs {
243+ pub repo: RepoPath,
244+ pub viewer: Viewer,
245+ #[serde(default)]
246+ pub state: Option<State>,
247+}
248+
249+/// `get_issue` (`Outcome<IssueDetail>`), `get_pull` (`Outcome<PullDetail>`),
250+/// `read_session` (`Outcome<Vec<SessionEntry>>`), `list_labels`
251+/// (`Outcome<Vec<String>>`) and `counts` (`Outcome<Counts>`). The last two
252+/// ignore `number`.
253+#[derive(Debug, Serialize, Deserialize)]
254+#[serde(rename_all = "camelCase")]
255+pub struct ViewArgs {
172256 pub repo: RepoPath,
257+ #[serde(default)]
173258 pub number: u32,
174259 pub viewer: Viewer,
260+ /// For `read_session`: only entries after this sequence number.
261+ #[serde(default)]
262+ pub after_seq: u32,
263+}
264+
265+/// How many issues and pull requests are open on a repository.
266+#[derive(Debug, Serialize, Deserialize)]
267+pub struct Counts {
268+ pub issues: u32,
269+ pub pulls: u32,
270+}
271+
272+/// `update_issue`: changes whichever fields are given. The author or a
273+/// member of the workspace may. Returns `Outcome<Issue>`.
274+#[derive(Debug, Serialize, Deserialize)]
275+pub struct UpdateIssueArgs {
276+ pub actor: User,
277+ pub repo: RepoPath,
278+ pub number: u32,
279+ #[serde(default)]
280+ pub title: Option<String>,
281+ #[serde(default)]
282+ pub body: Option<String>,
283+ #[serde(default)]
284+ pub labels: Option<Vec<String>>,
175285 }
176286
177−/// `withdraw_intent`. Returns `Outcome<Intent>`.
287+/// `close_issue` and `reopen_issue`. Each returns `Outcome<Issue>`.
178288 #[derive(Debug, Serialize, Deserialize)]
179−#[serde(rename_all = "camelCase")]
180−pub struct IntentActionArgs {
289+pub struct IssueActionArgs {
181290 pub actor: User,
182− pub intent_id: String,
291+ pub repo: RepoPath,
292+ pub number: u32,
293+ /// For `close_issue`; `completed` if left out.
294+ #[serde(default)]
295+ pub reason: Option<IssueReason>,
183296 }
184297
185−/// `start_attempt`: forks the repo for the agent and returns the attempt to
186−/// push to. Returns `Outcome<Attempt>`.
298+/// `add_comment`, on an issue or a pull request. Returns `Outcome<Comment>`.
187299 #[derive(Debug, Serialize, Deserialize)]
188−#[serde(rename_all = "camelCase")]
189−pub struct StartAttemptArgs {
300+pub struct AddCommentArgs {
190301 pub actor: User,
191− pub intent_id: String,
192− pub agent: String,
193− pub runtime: AttemptRuntime,
302+ pub repo: RepoPath,
303+ pub number: u32,
304+ pub body: String,
194305 }
195306
196−/// `get_attempt` and `read_session`.
307+/// `open_pull`: forks the repo and returns the draft pull request to push
308+/// to. Returns `Outcome<Pull>`.
197309 #[derive(Debug, Serialize, Deserialize)]
198−#[serde(rename_all = "camelCase")]
199−pub struct AttemptViewArgs {
200− pub attempt_id: String,
201− pub viewer: Viewer,
202− /// For `read_session`: only entries after this sequence number.
310+pub struct OpenPullArgs {
311+ pub actor: User,
312+ pub repo: RepoPath,
313+ /// The issue this is for.
203314 #[serde(default)]
204− pub after_seq: u32,
315+ pub issue: Option<u32>,
316+ /// Defaults to the issue's title; required without an issue.
317+ #[serde(default)]
318+ pub title: String,
319+ #[serde(default)]
320+ pub agent: String,
321+ pub runtime: Runtime,
205322 }
206323
207−/// `submit_attempt`, `abandon_attempt` and `ship_attempt`.
208−/// Each returns `Outcome<Attempt>`.
324+/// `ready_pull`, `close_pull` and `merge_pull`. Each returns `Outcome<Pull>`.
209325 #[derive(Debug, Serialize, Deserialize)]
210326 #[serde(rename_all = "camelCase")]
211−pub struct AttemptActionArgs {
327+pub struct PullActionArgs {
212328 pub actor: User,
213− pub attempt_id: String,
214− /// For `submit_attempt`: what changed and why.
329+ pub repo: RepoPath,
330+ pub number: u32,
331+ /// For `ready_pull`: what changed and why.
215332 #[serde(default)]
216333 pub summary: String,
334+ /// For `merge_pull`: leave the issue open and the other pull requests
335+ /// for it untouched, because this one is only part of the work.
336+ #[serde(default)]
337+ pub keep_issue_open: bool,
217338 }
218339
219−/// `list_active_attempts`: attempts in progress that the viewer started,
220−/// newest first. Returns `Vec<AttemptDetail>`.
340+/// A pull request in progress, with where it lives.
341+#[derive(Debug, Serialize, Deserialize)]
342+pub struct ActivePull {
343+ pub pull: Pull,
344+ pub issue: Option<Issue>,
345+}
346+
347+/// `list_active_pulls`: drafts and open pull requests the viewer started,
348+/// most recently active first. Returns `Vec<ActivePull>`.
221349 #[derive(Debug, Serialize, Deserialize)]
222350 pub struct ViewerArgs {
223351 pub viewer: Viewer,
225353
226354 /// `append_session`. Returns `Outcome<Appended>`.
227355 #[derive(Debug, Serialize, Deserialize)]
228−#[serde(rename_all = "camelCase")]
229356 pub struct AppendSessionArgs {
230357 pub actor: User,
231− pub attempt_id: String,
358+ pub repo: RepoPath,
359+ pub number: u32,
232360 pub entries: Vec<NewSessionEntry>,
233361 }
234362
236364 pub struct Appended {
237365 pub count: u32,
238366 }
367+
368+/// Lowercases, trims and de-duplicates labels, dropping empty ones.
369+/// Returns `None` if there are too many or one is too long.
370+pub fn normalize_labels(labels: &[String]) -> Option<Vec<String>> {
371+ const MAX_LABELS: usize = 10;
372+ const MAX_LABEL_CHARS: usize = 40;
373+ let mut normalized: Vec<String> = Vec::new();
374+ for label in labels {
375+ let label = label
376+ .split_whitespace()
377+ .collect::<Vec<_>>()
378+ .join(" ")
379+ .to_lowercase();
380+ if label.is_empty() || normalized.contains(&label) {
381+ continue;
382+ }
383+ if label.chars().count() > MAX_LABEL_CHARS {
384+ return None;
385+ }
386+ normalized.push(label);
387+ }
388+ (normalized.len() <= MAX_LABELS).then_some(normalized)
389+}
390+
391+#[cfg(test)]
392+mod tests {
393+ use super::normalize_labels;
394+
395+ fn labels(names: &[&str]) -> Vec<String> {
396+ names.iter().map(|name| (*name).to_owned()).collect()
397+ }
398+
399+ #[test]
400+ fn labels_are_lowercased_trimmed_and_unique() {
401+ assert_eq!(
402+ normalize_labels(&labels(&[" Bug ", "bug", "", "Good First Issue"])),
403+ Some(labels(&["bug", "good first issue"]))
404+ );
405+ }
406+
407+ #[test]
408+ fn too_long_or_too_many_labels_are_refused() {
409+ assert_eq!(normalize_labels(&["x".repeat(41)]), None);
410+ let many: Vec<String> = (0..11).map(|i| format!("label-{i}")).collect();
411+ assert_eq!(normalize_labels(&many), None);
412+ }
413+}
+12−15
1−//! Runs a coding agent on one attempt and reports back to g1t.
1+//! Runs a coding agent on one pull request and reports back to g1t.
22 //!
3−//! This is the program a hosted sandbox starts. It clones the attempt's
4−//! fork, runs the agent harness headless, streams what the agent does into
5−//! the attempt's session as it happens, pushes the result and submits the
6−//! attempt. It talks to g1t only through the public API and git, exactly
3+//! This is the program a hosted sandbox starts. It clones the pull
4+//! request's fork, runs the agent harness headless, streams what the agent
5+//! does into the pull request's session as it happens, pushes the result
6+//! and marks the pull request ready for review. It talks to g1t only through the public API and git, exactly
77 //! as an agent on someone's own machine would.
88 //!
99 //! Configuration comes from the environment:
1010 //!
1111 //! - `G1T_API`, `G1T_TOKEN`, `G1T_USER`: where and who to report as.
12−//! - `ATTEMPT_ID`, `GIT_REMOTE`: the attempt and its fork.
12+//! - `G1T_REPO`, `PULL_NUMBER`, `GIT_REMOTE`: the pull request and its fork.
1313 //! - `PROMPT`: what the agent is asked to do.
1414 //! - `COMMIT_MESSAGE`: used if the agent leaves changes uncommitted.
1515 //! - `ANTHROPIC_API_KEY`: read by the harness itself.
7474 Path::new("/work"),
7575 &["-c", &auth, "clone", "--quiet", &remote, WORKDIR],
7676 )
77− .context("could not clone the attempt's fork")?;
77+ .context("could not clone the pull request's fork")?;
7878 git(workdir, &["config", "user.name", "g1t agent"])?;
7979 git(workdir, &["config", "user.email", "agent@g1t.sh"])?;
8080 let branch = git(workdir, &["rev-parse", "--abbrev-ref", "HEAD"])?;
103103 &format!("HEAD:{branch}"),
104104 ],
105105 )
106− .context("could not push the attempt's commits")?;
106+ .context("could not push the pull request's commits")?;
107107 reporter.record(Entry::new(
108108 "note",
109− &format!(
110− "Pushed {} to the attempt's fork.",
111− &head[..head.len().min(12)]
112− ),
109+ &format!("Pushed {}.", &head[..head.len().min(12)]),
113110 ));
114111 Ok(summary)
115112 }
125122 match run(&mut reporter) {
126123 Ok(summary) => {
127124 reporter.flush();
128− if let Err(error) = reporter.submit(&summary) {
129− eprintln!("g1t-runner: could not submit: {error:#}");
125+ if let Err(error) = reporter.ready(&summary) {
126+ eprintln!("g1t-runner: could not mark the pull request ready: {error:#}");
130127 std::process::exit(1);
131128 }
132129 }
134131 eprintln!("g1t-runner: {error:#}");
135132 reporter.record(Entry::new("note", &format!("The run failed: {error:#}")));
136133 reporter.flush();
137− let _ = reporter.abandon();
134+ let _ = reporter.close();
138135 std::process::exit(1);
139136 }
140137 }
+15−14
1−//! Reports an attempt's progress to g1t through its public API.
1+//! Reports a pull request's progress to g1t through its public API.
22
33 use std::time::{Duration, Instant};
44
5353 pub struct Reporter {
5454 api: String,
5555 token: String,
56− attempt: String,
56+ /// The pull request's path in the API: `repos/<owner>/<name>/pulls/<number>`.
57+ pull: String,
5758 /// Values that must never reach a session, which is as public as the
5859 /// repository: the credentials this process was started with.
5960 secrets: Vec<String>,
7374 Ok(Reporter {
7475 api: var("G1T_API")?,
7576 token,
76− attempt: var("ATTEMPT_ID")?,
77+ pull: format!("repos/{}/pulls/{}", var("G1T_REPO")?, var("PULL_NUMBER")?),
7778 secrets,
7879 pending: Vec::new(),
7980 last_flush: Instant::now(),
8182 }
8283
8384 fn post(&self, action: &str, body: serde_json::Value) -> Result<()> {
84− ureq::post(&format!(
85− "{}/v1/attempts/{}/{action}",
86− self.api, self.attempt
87− ))
88− .set("authorization", &format!("Bearer {}", self.token))
89− .send_json(body)
90− .with_context(|| format!("{action} request failed"))?;
85+ ureq::post(&format!("{}/v1/{}/{action}", self.api, self.pull))
86+ .set("authorization", &format!("Bearer {}", self.token))
87+ .send_json(body)
88+ .with_context(|| format!("{action} request failed"))?;
9189 Ok(())
9290 }
9391
114112 }
115113 }
116114
117− pub fn submit(&self, summary: &str) -> Result<()> {
118− self.post("submit", serde_json::json!({ "summary": summary }))
115+ /// Marks the pull request ready for review, with `summary` as its
116+ /// description.
117+ pub fn ready(&self, summary: &str) -> Result<()> {
118+ self.post("ready", serde_json::json!({ "summary": summary }))
119119 }
120120
121− pub fn abandon(&self) -> Result<()> {
122− self.post("abandon", serde_json::json!({}))
121+ /// Closes the pull request without merging.
122+ pub fn close(&self) -> Result<()> {
123+ self.post("close", serde_json::json!({}))
123124 }
124125 }
125126
+171−147
1111
1212 ## Product model
1313
14−A pull request assumes one author and one change. g1t assumes many agents
15−working at once, in two shapes: several agents racing on the same goal, and
16−many different goals in flight that all have to land on `main`.
14+g1t keeps the two things every engineer already knows, issues and pull
15+requests, and changes the assumption underneath them. A forge built for
16+people expects one pull request per issue. g1t expects many agents working
17+at once, in two shapes: several agents on the same issue, and many different
18+issues in flight that all have to land on `main`.
1719
1820 | Concept | What it is |
1921 | --- | --- |
20−| **Intent** | A goal stated against a repo, with acceptance checks (commands that must pass). The issue, and the home of every pull request made for it. |
21−| **Attempt** | One agent's run at an intent, in its own Artifacts fork. Any number run in parallel. |
22−| **Session** | The agent's full context for an attempt: prompt, messages, tool calls, cost. Stored with the attempt and linked from every commit it produced. |
23−| **Arena** | The compare view for an intent: every attempt side by side with diff, check results, conflicts against main and against each other, and a reviewer agent's summary. |
24−| **Ship** | A person or a policy picks an attempt. A per-repo merge queue lands it; the other attempts are rebased by their agents or closed. |
22+| **Issue** | What should change in a repo: a bug, a feature, a question. Opened by a person, an agent or an integration such as an error tracker. Carries labels, acceptance checks (commands that must pass), comments, and every pull request made for it. |
23+| **Pull request** | A proposed change in its own Artifacts fork, made by an agent or a person, usually for an issue. Any number can be open for one issue. Starts as a draft; marked ready; merged or closed. |
24+| **Session** | The agent's full context for a pull request: prompt, messages, tool calls, cost. Stored with the pull request and linked from every commit it produced. |
25+| **Compare view** | Every pull request for an issue side by side with diff, check results, conflicts against main and against each other, and a reviewer agent's summary. |
26+| **Merge** | A person or a policy picks a pull request. A per-repo merge queue lands it. The issue closes, recording which pull request resolved it; the others for that issue close as superseded, or are rebased by their agents when the issue is kept open. |
27+
28+Issues and pull requests share one sequence of numbers per repository, so
29+`#12` names exactly one of them.
2530
2631 Features that fall out of the model:
2732
2833 - **Why-blame.** Click a line and see the prompt and reasoning that produced
2934 it, not only the commit.
30−- **Overlap radar.** Attempts that touch the same files are flagged while the
31− agents are still working, and the agents are told.
32−- **Live lanes.** Watch every attempt progress in real time.
35+- **Overlap radar.** Pull requests that touch the same files are flagged
36+ while the agents are still working, and the agents are told.
37+- **Live lanes.** Watch every pull request progress in real time.
38+
39+## Why issues and pull requests, not something new
40+
41+An earlier version of this plan merged the two into one new object, an
42+"issue" holding "pull requests". That was wrong, for three reasons.
3343
34−## Pull requests are not removed
44+- **Issues come from everywhere.** People file them, agents file them, and
45+ Sentry files them. Most are never worked on by whoever opened them. They
46+ need their own life: labels, triage, discussion, closing as not planned.
47+- **"Which change did we take?" needs two objects.** When five agents each
48+ propose a change, the answer has to be recorded somewhere other than the
49+ five proposals. On g1t it is on the issue: `resolved by #14`.
50+- **Nobody should have to learn a word to use the product.** An engineer who
51+ has used any forge can use g1t on the first day, and finds the agent
52+ features where they would look for them.
3553
36−g1t is ordinary git, and the pull request stays. The model extends it rather
37−than replacing it, so an engineer's habits keep working and the agent
38−features are there when wanted.
54+What g1t adds to the familiar pair:
3955
40−- **An attempt is a pull request.** It has a source (a fork, or a branch
41− pushed to the repo), a diff, review comments, checks and a merge button.
42− It is reachable as a pull request, with that name, in the UI and API.
43−- **An intent is the goal above it.** Opening a pull request the familiar way
44− creates its intent from the title and description, so nobody has to learn
45− the word to use the product.
46−- **The developer path is unchanged.** Push a branch, open a pull request,
47− get review, merge.
48−- **The agent path adds to it.** State the intent first, let several
49− attempts run, compare them, ship one.
56+- **Several pull requests per issue is the normal case**, not an accident.
57+ The issue's page lists them with their state, and merging one closes the
58+ issue with that pull request recorded and the others marked superseded.
59+- **A pull request can be part of the work.** Merging with "keep the issue
60+ open" leaves the issue and its other pull requests alone.
61+- **Every pull request has a fork and a session.** See
62+ [forks and branches](https://docs.g1t.sh/concepts/forks/).
63+- **Labels need no setup.** A repository starts with `bug`, `feature`,
64+ `docs`, `chore` and `question`; any other name becomes a label the first
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.
5069 - **Both paths meet at `main`.** The same landing rules apply to a person's
51− pull request and an agent's attempt.
70+ pull request and an agent's.
5271
5372 ## Converging on main
5473
55−Twelve intents started together will finish at different times and touch
74+Twelve issues started together will finish at different times and touch
5675 overlapping code. Getting them all into `main` without a person refereeing
5776 is the hard part, and it is handled in four places.
5877
5978 1. **Before work starts: plan the overlap away.** A project is a graph of
60− intents. A planner agent can split a large goal into intents, predict
79+ issues. A planner agent can split a large goal into issues, predict
6180 which files each will touch, and add a dependency where two would collide,
6281 so one starts from the other's result instead of from `main`.
63−2. **While agents work: overlap radar.** Each attempt's changed files and
64− symbols are tracked as it pushes. When two attempts from different intents
82+2. **While agents work: overlap radar.** Each pull request's changed files and
83+ symbols are tracked as it pushes. When two pull requests from different issues
6584 enter the same area, both agents are told what the other is doing there.
66−3. **When `main` moves: the author resolves.** Every open attempt is
67− trial-merged against the new `main`. A clean merge updates the attempt
68− silently. A conflict resumes that attempt's agent with its original
85+3. **When `main` moves: the author resolves.** Every open pull request is
86+ trial-merged against the new `main`. A clean merge updates the pull request
87+ silently. A conflict resumes that pull request's agent with its original
6988 session and the incoming change, so the conflict is resolved by the agent
7089 that wrote the code and still knows why.
71−4. **At landing: a speculative queue.** Shipped attempts enter the repo's
72− queue. g1t builds the combined states (`main`+A, `main`+A+B, …) and runs
73− their checks in parallel. Attempts land in order as their combined state
90+4. **At landing: a speculative queue.** Approved pull requests enter the
91+ repo's queue. g1t builds the combined states (`main`+A, `main`+A+B, …) and runs
92+ their checks in parallel. Pull requests land in order as their combined state
7493 passes; one that fails is ejected back to its agent and the states behind
7594 it are rebuilt. `main` only ever receives a state that passed.
7695
7796 Landing can be fully automatic: a repo policy such as "checks pass and the
78−reviewer agent approves" ships without a person.
97+reviewer agent approves" merges without a person.
7998
8099 ## Agents aware of each other
81100
82−Each repo keeps a live **work registry**: for every running attempt, its
83−intent, a running summary of what it has done, and the files and symbols it
101+Each repo keeps a live **work registry**: for every running pull request, its
102+issue, a running summary of what it has done, and the files and symbols it
84103 has touched or plans to touch. Agents use it through MCP tools; g1t also
85104 acts on it without being asked.
86105
87−- **Before starting.** When an intent is opened, or an agent is about to
88− begin a task, g1t searches open intents and running attempts for the same
106+- **Before starting.** When an issue is opened, or an agent is about to
107+ begin a task, g1t searches open issues and running pull requests for the same
89108 goal (by meaning, not wording) and for the same area of code. If a match
90109 exists the agent is told who is on it and how far along, and chooses: join
91110 as a deliberate racer, wait for the result, or drop the task. Duplicate
92− intents are offered for merging.
111+ issues are offered for merging.
93112 - **Finding out-of-scope work.** An agent that discovers something outside
94− its intent asks the registry who works there. If another attempt owns that
113+ its issue asks the registry who works there. If another pull request owns that
95114 area, it **hands off**: a note, the relevant excerpt of its session, and
96115 optionally commits the receiver can take. If nobody does, it opens a child
97− intent instead of widening its own change.
98−- **Asking.** An agent can put a question or a request to another attempt.
116+ issue instead of widening its own change.
117+- **Asking.** An agent can put a question or a request to another pull request.
99118 The receiver gets it at its next turn.
100−- **Waiting.** An agent that needs another attempt's result parks itself.
119+- **Waiting.** An agent that needs another pull request's result parks itself.
101120 Its sandbox sleeps, spend stops, and it resumes from the new state when
102− that attempt ships.
121+ that pull request merges.
103122 - **Agents that do not cooperate.** For pushes from tools that never call
104− these tools, g1t compares the pushed change against running attempts and
123+ these tools, g1t compares the pushed change against running pull requests and
105124 flags near-duplicates itself.
106125
107126 Every handoff, question and wait has a state (offered, accepted, declined,
115134 hundreds of agents, a person cannot read every diff, so review is by
116135 exception.
117136
118−- **Evidence, not diffs.** Every attempt carries a proof bundle: checks run
137+- **Evidence, not diffs.** Every pull request carries a proof bundle: checks run
119138 and their output, a preview URL, a plain-language summary, and the
120139 behaviour that changed.
121−- **Two agent reviewers.** One reviews the change against the intent. A
140+- **Two agent reviewers.** One reviews the change against the issue. A
122141 second is adversarial: it tries to break the change and reports what it
123142 found.
124143 - **Risk tiers.** Each change is scored from what it touches, how large it
125− is, and how the reviewers ruled. Low risk ships on policy; high risk goes
144+ is, and how the reviewers ruled. Low risk merges on policy; high risk goes
126145 to a person with the evidence already assembled.
127−- **Trust is earned.** An agent's record on a path (shipped, reverted, caught
146+- **Trust is earned.** An agent's record on a path (merged, reverted, caught
128147 by review) raises or lowers the tier its changes land in.
129−- **Sampling.** A share of auto-shipped changes is sent to a person anyway,
148+- **Sampling.** A share of auto-merged changes is sent to a person anyway,
130149 to keep the policy honest.
131150
132151 ## Rethinking the git primitives
133152
134−- **No branches for agents.** An attempt is a fork; `main` is the only
153+- **No branches for agents.** A pull request is a fork; `main` is the only
135154 long-lived line. There is nothing to name, clean up or go stale.
136−- **Projected main.** New attempts start from `main` plus everything already
155+- **Projected main.** New pull requests start from `main` plus everything already
137156 in the landing queue, so they are built on the state they will land on.
138157 - **Structural merge.** The merge engine merges by syntax tree, not by line,
139158 for supported languages. Two agents adding different functions to the same
142161 was at that moment plus the conversation up to it, continued with a
143162 different instruction. Branching applies to the reasoning as well as the
144163 code.
145−- **Provenance in history.** Every commit records its intent, session,
146− agent, model and cost, and is signed with a key issued to that attempt. The
164+- **Provenance in history.** Every commit records its issue, session,
165+ agent, model and cost, and is signed with a key issued to that pull request. The
147166 history can be audited by machine.
148167
149168 ## People in the loop
153172 People will keep pushing with plain git, their editor, or another tool. Every
154173 push goes through g1t's git front end, so none of it bypasses the model.
155174
156−- **A push to a branch becomes an attempt.** g1t adopts it with the pusher as
157− author. A reviewer agent writes the intent it appears to serve and offers
158− to attach it to an open intent it matches. From there it gets the same
159− checks, arena and queue as agent work.
175+- **A push to a branch becomes a pull request.** g1t adopts it with the pusher as
176+ author. A reviewer agent writes the issue it appears to serve and offers
177+ to attach it to an open issue it matches. From there it gets the same
178+ checks, compare view and queue as agent work.
160179 - **A push to `main` follows repo policy.** Protected: refused with a message
161180 saying which ref to push to instead, so it enters the queue. Open: accepted
162181 and treated as "`main` moved", which re-verifies the queue and triggers
163− resolve-on-move for every open attempt.
182+ resolve-on-move for every open pull request.
164183 - **Context is an open format.** A commit trailer names the session that
165184 produced it, so any tool can attach its transcript. Commits without one are
166185 shown in why-blame as "pushed by a person, no session".
167−- **Approval rules.** Per repo and per path: ship automatically, require a
186+- **Approval rules.** Per repo and per path: merge automatically, require a
168187 named person, or require a person when the change is large or the reviewer
169188 agent is unsure.
170189
177196 through the CLI hooks.
178197 - **Answer.** When an agent is blocked on a question, it appears in a "needs
179198 you" inbox and as a notification. The answer resumes the agent.
180−- **Take over and hand back.** Check out the attempt's fork, commit by hand,
199+- **Take over and hand back.** Check out the pull request's fork, commit by hand,
181200 push, and let the agent continue from there.
182201
183202 ### Planning by writing
184203
185204 - **Brief.** Write the outcome in prose on the site, or commit it as a
186− markdown file. A planner agent turns it into a project: intents, acceptance
205+ markdown file. A planner agent turns it into a project: issues, acceptance
187206 checks, dependencies. The person edits the graph before anything starts.
188207 - **Plan from their own agent.** The same operations are MCP tools, so a
189208 person can plan in their own Claude Code session and create the project
190209 from there.
191210 - **The brief stays the source of truth.** Editing it later re-plans: new
192− intents are added, obsolete ones are closed.
211+ issues are added, obsolete ones are closed.
193212
194213 ### Seeing what moved
195214
196−- **Project page.** The outcome, the intent graph coloured by state, and how
215+- **Project page.** The outcome, the issue graph coloured by state, and how
197216 many acceptance checks pass now compared with when the project started.
198217 - **Digest.** An agent-written summary per project and per person: what
199− shipped, what is blocked on whom, which conflicts were resolved, what it
218+ merged, what is blocked on whom, which conflicts were resolved, what it
200219 cost.
201−- **Timeline.** Every event (push, steer, check, conflict, ship) in order,
220+- **Timeline.** Every event (push, steer, check, conflict, merge) in order,
202221 each linked to the session and the person or agent behind it.
203222
204223 ## One session, any surface
208227
209228 - **Browser and phone.** The site is a responsive, installable web app with
210229 push notifications. Everything a person does (brief, steer, answer,
211− approve, ship) works there.
230+ approve, merge) works there.
212231 - **Claude Code.** Through `mcp.g1t.sh` and the CLI hooks, a local session is
213232 a g1t session: its transcript syncs as it runs and it appears in mission
214233 control like any other.
225244 - **Documents are first-class.** Specs, guides, policies and decisions live
226245 in repos as markdown, shown in a Docs view: rendered pages, edited in the
227246 browser like a document, with inline comments. "Suggest a change" is an
228− attempt and "publish" is ship, without git vocabulary.
229−- **Document intents.** "Write the onboarding guide for the billing API" is
230− an intent. Its acceptance checks are a checklist judged by a reviewer agent
247+ pull request and "publish" is merge, without git vocabulary.
248+- **Document issues.** "Write the onboarding guide for the billing API" is
249+ an issue. Its acceptance checks are a checklist judged by a reviewer agent
231250 instead of commands. Agents draft and revise; people comment and approve.
232251 - **Templates.** Product brief, RFC, decision record. A filled-in template is
233252 a brief the planner can turn into a project.
234253 - **Explain.** Ask about any repo, project or change in plain language and
235254 get an answer with links to the code and sessions behind it.
236255 - **Living documentation.** g1t generates "how this works" pages from the
237− code and keeps them current. When a shipped change contradicts a document,
238− an intent opens to update it.
239−- **See it, don't read it.** Every attempt on a deployable repo gets a
240− preview URL (Workers Builds from the attempt's fork), so an approver clicks
256+ code and keeps them current. When a merged change contradicts a document,
257+ an issue opens to update it.
258+- **See it, don't read it.** Every pull request on a deployable repo gets a
259+ preview URL (Workers Builds from the pull request's fork), so an approver clicks
241260 through the result instead of reading a diff. Changes are also summarised
242261 in plain language.
243262 - **Roles.** Viewer, commenter, planner, approver: a person can plan and
251270 | --- | --- |
252271 | **Workspace** | A company or team: its people, repos, agents, budget and policies. |
253272 | **Initiative** | A business outcome with an owner and measurable results, e.g. "move billing to usage-based pricing". Spans any number of repos. |
254−| **Project** | One deliverable inside an initiative: a brief and its graph of intents. |
255−| **Intent / Attempt** | As above. An intent may touch several repos; its attempt then holds one fork per repo and they land together. |
273+| **Project** | One deliverable inside an initiative: a brief and its graph of issues. |
274+| **Issue / Pull request** | As above. An issue may touch several repos; a pull request for it then holds one fork per repo and they land together. |
256275
257276 ### Portfolio
258277
259278 One page answers "where is the business" across every initiative:
260279
261280 - **Health** per initiative: on track, at risk, or blocked, derived from
262− facts (checks passing, intents stalled, questions waiting on a person),
281+ facts (checks passing, issues stalled, questions waiting on a person),
263282 not self-reported.
264−- **Progress** as measurable results: acceptance checks passing, intents
265− shipped out of planned, and the trend since the start.
283+- **Progress** as measurable results: acceptance checks passing, issues
284+ merged out of planned, and the trend since the start.
266285 - **Forecast** from actual throughput: at the current rate, when the
267− remaining intents land.
286+ remaining issues land.
268287 - **Spend** in tokens and dollars against a budget, per initiative.
269288 - **Waiting on people**: every decision or approval a person owes, by name.
270289 - **Roadmap**: initiatives laid out as now, next, later, with optional
273292 ### Status without asking
274293
275294 - **Standup.** An agent writes a daily report per initiative and one for the
276− whole workspace: what shipped, what changed direction, what is at risk and
295+ whole workspace: what merged, what changed direction, what is at risk and
277296 why, what needs a person. Delivered by email or webhook.
278297 - **Ask.** A question box over the full event log and all sessions: "what
279298 happened on the billing migration since Monday?" answers with links to the
284303 Work that runs for days needs supervision that does not depend on someone
285304 watching.
286305
287−- **Checkpoints.** A long attempt reports milestones against its intent, so
288− progress is visible before anything ships.
289−- **Stall and drift detection.** An attempt with no meaningful progress, or
290− whose changes have wandered away from its intent, is flagged and can be
306+- **Checkpoints.** A long pull request reports milestones against its issue, so
307+ progress is visible before anything merges.
308+- **Stall and drift detection.** A pull request with no meaningful progress, or
309+ whose changes have wandered away from its issue, is flagged and can be
291310 stopped or re-briefed automatically.
292−- **Budgets.** Hard limits on spend and time per attempt, project and
311+- **Budgets.** Hard limits on spend and time per pull request, project and
293312 initiative.
294313
295314 ### Context hub
311330 fresh it is.
312331 - **Connected sources stay where they are.** g1t indexes them for search and
313332 fetches the current version when an agent opens one. The external system
314− remains the source of truth, and a link placed on an intent ("see
333+ remains the source of truth, and a link placed on an issue ("see
315334 JIRA-482", a Notion URL) is pulled into the agent's starting context.
316335 - **Permissions carry over.** A connector only exposes what the connecting
317336 account can see, and a workspace admin chooses which spaces, projects or
335354 conflicts it cannot settle. People can pin an entry (agents may not change
336355 it), correct it, or retract it.
337356 - **Agents read it.** Every session starts with the context relevant to its
338− intent, found by search, and can query more through MCP.
357+ issue, found by search, and can query more through MCP.
339358 - **Updates are events.** A memory write or a change in a connected source
340359 is an event, so "when context changes, update the affected docs" is an
341360 automation, on by default.
349368 ## Working in g1t
350369
351370 - **Mission control.** The signed-in home page: every running session, every
352− intent waiting on a decision, and what shipped, across all repos.
353−- **Projects.** Group intents across repos toward one outcome and track how
354− many are open, racing, or shipped.
355−- **Steering.** Send a message to a running attempt, or to all attempts on an
356− intent at once, without stopping them.
371+ issue waiting on a decision, and what merged, across all repos.
372+- **Projects.** Group issues across repos toward one outcome and track how
373+ many are open, racing, or merged.
374+- **Steering.** Send a message to a running pull request, or to all pull requests on an
375+ issue at once, without stopping them.
357376 - **Automations.** Rules that start work without a person (next section).
358377
359378 ## Automations and integrations
366385
367386 | Source | Examples |
368387 | --- | --- |
369−| Git | push, ship, check failed, `main` moved |
370−| g1t | intent opened, attempt stalled, context updated, handoff declined, budget reached |
388+| Git | push, merge, check failed, `main` moved |
389+| g1t | issue opened, pull request stalled, context updated, handoff declined, budget reached |
371390 | Time | cron schedule |
372391 | Integrations | Sentry issue, PagerDuty incident, Linear or Jira ticket, Slack message or mention, GitHub issue, Stripe event |
373392 | Anything else | a signed generic webhook, or an email to a per-repo address |
374393
375−**Actions**: open an intent (optionally racing N attempts with a named
376−agent), message a running attempt, update documentation, notify, call a
394+**Actions**: open an issue (optionally assigning N agents to it), message
395+a running pull request, update documentation, notify, call a
377396 webhook, write back to the source system.
378397
379398 **Example: Sentry.** A new production error arrives. The automation opens an
380−intent with the stack trace, release and frequency as its brief. Why-blame
399+issue labelled `bug`, with the stack trace, release and frequency in its
400+description. Why-blame
381401 finds the session that wrote the failing line, so the fixing agent starts
382−with the original reasoning. When the fix ships, g1t comments on the Sentry
402+with the original reasoning. When the fix merges, g1t comments on the Sentry
383403 issue and resolves it.
384404
385405 ### Rules every automation obeys
386406
387407 - **Deduplication.** The same Sentry issue firing 500 times maps to one
388− intent.
408+ issue.
389409 - **Limits.** Concurrency and budget caps per automation.
390410 - **Loop protection.** Work started by an automation cannot retrigger the
391411 same automation without a person in between.
392412 - **External input is untrusted.** A webhook payload can contain text written
393413 by an attacker. Agents started by external events run with reduced
394− permissions and cannot ship without the repo's approval rule passing.
414+ permissions and cannot merge without the repo's approval rule passing.
395415
396416 **Checks** are the other half of what GitHub Actions does: build and test
397−commands declared in `.g1t/checks.yaml`, run in sandboxes on every attempt
417+commands declared in `.g1t/checks.yaml`, run in sandboxes on every pull request
398418 and on every combined state in the landing queue.
399419
400420 Agents can also reach integrations directly: an agent definition lists MCP
417437 | Hosted, g1t's model | g1t runs the sandbox and bills usage | Getting started; no keys to manage |
418438 | Hosted, your API key | Same sandbox, your Anthropic, OpenAI or Google key | Teams with existing contracts |
419439 | Hosted, your endpoint | Any OpenAI-compatible URL: Bedrock, Vertex, Azure, a self-hosted model | Private or fine-tuned models |
420−| Your runner | A g1t runner daemon on your own machines picks up attempts | Code or models that may not leave your network |
440+| Your runner | A g1t runner daemon on your own machines picks up pull requests | Code or models that may not leave your network |
421441 | Your own session | Local Claude Code, Cursor or any MCP client joins through `mcp.g1t.sh` | Individuals; subscription plans |
422442
423443 Decisions behind this:
433453 hosted sandbox; it needs an API key. People on subscriptions use their own
434454 Claude Code session, which is a full participant.
435455 - **Keys are secrets.** Stored in Cloudflare Secrets Store, injected into the
436− sandbox for one attempt, never shown again.
456+ sandbox for one pull request, never shown again.
437457
438458 ### Choosing the right agent automatically
439459
440−Because several agents can race on the same intent, every arena is an
441−evaluation on real work. g1t records, per repo and per kind of intent, each
460+Because several agents can work on the same issue, every issue with more
461+than one pull request is an evaluation on real work. g1t records, per repo and per kind of issue, each
442462 agent's win rate, cost and time. That produces a leaderboard, and a routing
443−policy: send each new intent to the agent that wins that kind most often,
463+policy: send each new issue to the agent that wins that kind most often,
444464 start with the cheapest that is good enough, and escalate to a stronger one
445465 when checks fail.
446466
455475 | --- | --- | --- |
456476 | Where a session works | A worktree on one developer's machine, or a cloud sandbox | A server-side fork that any agent on any machine can join and anyone can open |
457477 | Agent context | Lives in the app's session view | Stored with the repository and linked from each commit (why-blame) |
458−| Several agents on one task | Separate pull requests to compare by hand | One intent, one arena, ranked attempts |
478+| Several agents on one task | Separate pull requests to compare by hand | One issue holding every pull request made for it, compared side by side, with the merged one recorded on the issue |
459479 | Collisions between agents | Found as merge conflicts at the end | Flagged during the work (overlap radar) |
460−| Landing changes | One pull request at a time | A merge queue that ships the winner and rebases or closes the rest |
480+| Landing changes | One pull request at a time | A merge queue that lands the chosen one and closes the rest as superseded |
461481 | Which agents | Those offered through a Copilot subscription | Any MCP client, plus hosted agents |
462482
463483 ## How agents connect
464484
465485 1. **Bring your own agent.** A remote MCP server at `mcp.g1t.sh` lets Claude
466− Code (or any MCP client) list intents, claim one, get a clone URL and
486+ Code (or any MCP client) list issues, claim one, get a clone URL and
467487 token, report progress and submit. Adding it is one command; sign-in is a
468488 browser OAuth flow with no token to paste. The `g1t` CLI installs Claude
469489 Code hooks that upload the session transcript as the agent works.
470−2. **Hosted agents.** Press "Run 10 attempts" on an intent. g1t starts
471− sandboxes (Cloudflare Sandbox SDK), each running a coding agent headless
472− against its own fork.
490+2. **g1t agents.** Assign up to five of g1t's own agents to an issue. g1t
491+ starts a sandbox for each (Cloudflare Containers), running a coding agent
492+ headless against its own pull request and fork.
473493 3. **API and CLI.** Everything above is available at `api.g1t.sh` and
474494 through `g1t`.
475495
478498 | Host | What it serves |
479499 | --- | --- |
480500 | `g1t.sh` | The site, git over HTTPS, git over SSH |
481−| `api.g1t.sh` | Versioned REST API with a published OpenAPI document, cursor pagination, rate-limit headers, idempotency keys on writes, server-sent events for live attempt state, and signed webhooks |
501+| `api.g1t.sh` | Versioned REST API with a published OpenAPI document, cursor pagination, rate-limit headers, idempotency keys on writes, server-sent events for live pull request state, and signed webhooks |
482502 | `mcp.g1t.sh` | Remote MCP server over streamable HTTP |
483503
484504 g1t is its own OAuth 2.1 authorization server: authorization code with PKCE,
485505 dynamic client registration, discovery metadata, refresh tokens, and scopes
486−per resource (`repo:read`, `repo:write`, `intent:write`, `attempt:write`).
506+per resource (`repo:read`, `repo:write`, `issue:write`, `pull:write`).
487507 MCP clients, the CLI (device flow) and third-party apps all use it. Access
488508 tokens and SSH keys remain for git itself.
489509
491511
492512 | Component | Language | Runs on | Responsibility |
493513 | --- | --- | --- | --- |
494−| `packages/contracts` | TypeScript | — | The interface of every service, the event catalogue, shared types. Services and clients depend on this, never on each other's code. |
495−| `services/identity` | TypeScript | Worker + D1 | Accounts, sessions, SSH keys, access tokens; later the OAuth server |
496−| `services/repos` | TypeScript | Worker + D1 + Artifacts | Repository registry, contents, forks, git over HTTPS. Storage sits behind a `GitStore` port with an Artifacts adapter. |
497−| `services/events` | TypeScript | Worker + Queues + D1 | The event bus: durable log, and one queue per subscribing service |
498−| `services/work` | TypeScript | Worker + D1 | Intents, attempts, sessions; later a Durable Object per repo for the landing queue and live state |
514+| `crates/contracts`, `packages/contracts` | Rust, TypeScript | — | The interface of every service, the event catalogue, shared types. Services and clients depend on this, never on each other's code. |
515+| `services/identity` | Rust | Worker + D1 | Accounts, workspaces and memberships, sessions, SSH keys, access tokens, device sign-in; later the OAuth server |
516+| `services/repos` | Rust | Worker + D1 + Artifacts | Repository registry, contents, forks, diffs, landing, git over HTTPS. Storage sits behind a `GitStore` port with an Artifacts adapter. |
517+| `services/work` | Rust | Worker + D1 | Issues, pull requests, comments, sessions; later a Durable Object per repo for the landing queue and live state |
518+| `services/events` | TypeScript, moving to Rust | Worker + Queues + D1 | The event bus: durable log, and one queue per subscribing service |
519+| `services/runner`, `crates/runner` | TypeScript, Rust | Worker + Containers | Starts a sandbox per g1t agent; the program inside runs the agent harness and reports through the public API |
499520 | `apps/web` | TypeScript | Worker | Server-rendered site. Holds no data; calls services over RPC. |
500−| `apps/api` (next) | TypeScript | Worker | REST API (`api.g1t.sh`) and MCP server (`mcp.g1t.sh`) over the same services |
521+| `apps/docs` | TypeScript | Worker (static) | Documentation and the API explorer |
522+| `apps/api` | TypeScript, moving to Rust | Worker | REST API (`api.g1t.sh`) and MCP server (`mcp.g1t.sh`), both generated from one list of operations |
501523 | `crates/sshd` | Rust | Container | Git over SSH, bridged to Artifacts |
502524 | `crates/merged` | Rust | Container | Trial merges, conflict matrix, landing merges (needs real git; the Artifacts binding is read-only) |
503525 | `crates/core` | Rust | native and WASM | pkt-line, packfile and diff code shared by the above and by the Worker |
504−| `crates/g1t` | Rust | user's machine | CLI: auth, SSH proxy, Claude Code hooks, intents and attempts |
505−| runner image | — | Sandbox | Hosted agent environment |
526+| `crates/g1t` | Rust | user's machine | CLI: auth, SSH proxy, Claude Code hooks, issues and pull requests |
506527
507−Storage: Artifacts for repositories (one fork per attempt), D1 for accounts
528+Storage: Artifacts for repositories (one fork per pull request), D1 for accounts
508529 and metadata, R2 for session transcripts and logs, Durable Object SQLite for
509530 per-repo coordination state.
510531
516537 - **Expected failures are values.** Every call returns a `Result`, so "not
517538 found" or "forbidden" crosses a service boundary as data.
518539 - **Side effects travel as events.** A service publishes what happened
519− (`git.push`, `intent.opened`, `attempt.started`, …) to the bus and does not
540+ (`git.push`, `issue.opened`, `pull.merged`, …) to the bus and does not
520541 call other services to react. Each subscriber consumes from its own queue.
521542 Timelines, webhooks and automations read the same stream, which is what
522543 lets something like GitHub Actions be built on top.
531552
532553 The site is TypeScript. Everything behind it is Rust, compiled to
533554 WebAssembly for Workers and natively for containers and the CLI. Services
534−are being ported one at a time; identity is done. Rust services speak a
555+are being ported one at a time; identity, repos and work are done, events
556+and the API are next. Rust services speak a
535557 small JSON protocol over service bindings (`POST /rpc/<method>`), with the
536558 types in `crates/contracts`.
537559
539561
540562 Every id is a [TypeID](https://github.com/jetify-com/typeid): a prefix naming
541563 the kind of thing, then a UUIDv7 in lowercase base32, such as
542−`att_01jb2k7x9hfq0b3zj0f5s2m8ra`.
564+`pr_01jb2k7x9hfq0b3zj0f5s2m8ra`.
543565
544566 - The prefix makes an id self-describing and stops ids of different kinds
545567 being mixed up.
585607
586608 | Need | Product |
587609 | --- | --- |
588−| Repositories; a fork per attempt; data residency per workspace | Artifacts (forks, jurisdictions) |
610+| Repositories; a fork per pull request; data residency per workspace | Artifacts (forks, jurisdictions) |
589611 | Reacting to pushes | Artifacts event subscriptions on Queues |
590−| Preview URL per attempt; deploy on ship | Workers Builds and previews |
612+| Preview URL per pull request; deploy on merge | Workers Builds and previews |
591613 | Site, API, MCP, git front end | Workers |
592614 | Per-repo coordination, live updates | Durable Objects |
593−| Attempt lifecycles, automations | Workflows, Cron Triggers |
615+| Pull request lifecycles, automations | Workflows, Cron Triggers |
594616 | Agent sandboxes, SSH server, merge engine | Sandbox SDK and Containers |
595617 | Fast starts on large repos | ArtifactFS |
596618 | Model traffic, spend, budgets | AI Gateway |
603625 ## The submission
604626
605627 - **g1t is built on g1t.** This repository is hosted on g1t.sh, its features
606− are opened as intents and built by racing agents, and it deploys from
628+ are opened as issues and built by racing agents, and it deploys from
607629 Artifacts through Workers Builds. The history is the proof.
608−- **The demo follows one story.** A brief becomes a project; twelve intents
630+- **The demo follows one story.** A brief becomes a project; twelve issues
609631 fan out to dozens of agents; agents notice each other, hand off, and
610632 resolve a conflict; reviewers triage; the queue lands everything on
611633 `main`; why-blame explains a line; the portfolio shows where it all
618640
619641 ## Build order
620642
621−Done: site with marketing page and docs; git over HTTPS; accounts with
622−registration, email verification and password reset; intents, attempts and
623−sessions; REST API and MCP server; event bus; shipping an attempt to `main`
624−with a behind check. Identity and repos are in Rust.
643+Done: site with marketing page; separate docs site with API explorer; git
644+over HTTPS; accounts with registration, email verification, password reset
645+and device sign-in; workspaces with members; issues with labels, checks and
646+comments; pull requests in forks with diffs and sessions, several per
647+issue; merging with a behind check, which resolves the issue and supersedes
648+the rest; g1t agents in sandboxes with a choice of model; REST API, OpenAPI
649+and MCP server; event bus. Identity, repos and work are in Rust.
625650
626−1. Diffs and review on attempts; pull requests from pushed branches, under
627− that name.
651+1. Pull requests from branches pushed to the repository.
628652 2. OAuth server, so MCP clients sign in through the browser with no token
629653 to paste.
630−3. Port work, events and the API to Rust; event storage per the design
631− above.
654+3. Port events and the API to Rust; event storage per the design above.
632655 4. CLI with Claude Code hooks to record sessions automatically.
633−5. Hosted agents in sandboxes; acceptance checks.
656+5. Acceptance checks run in sandboxes; review comments on lines.
634657 6. Server-side merge and rebase; landing queue with speculative checks;
635658 resolve-on-move.
636−7. Arena, proof bundles, reviewers, risk tiers; work registry, handoff.
659+7. Compare view, proof bundles, reviewers, risk tiers; work registry,
660+ handoff.
637661 8. Projects, mission control, steering; why-blame, digest, timeline.
638−9. Workspaces, context hub, portfolio; automations and integrations.
662+9. Context hub, portfolio; automations and integrations (Sentry first).
639663 10. SSH; bot protection; own keys, endpoints and runners.
640−11. Large run (100+ agents across many intents), hardening, demo.
664+11. Large run (100+ agents across many issues), hardening, demo.
641665
642666 Later: code search, mirroring to GitHub, passkeys, SSH
643667 on port 22 without the CLI proxy (needs the Workers inbound TCP private
+27−18
8080 blob: (path, viewer, ref, filePath) =>
8181 call("blob", { path, viewer, ref, filePath }),
8282 log: (path, viewer, ref, limit) => call("log", { path, viewer, ref, limit }),
83− forkForAttempt: (sourceId, attemptId, actor) =>
84− call("fork_for_attempt", { sourceId, attemptId, actor }),
83+ forkForPull: (sourceId, pullId, actor) =>
84+ call("fork_for_pull", { sourceId, pullId, actor }),
8585 gitAccess: (path, viewer, service) =>
8686 call("git_access", { path, viewer, service }),
8787 land: (forkId, actor) => call("land", { forkId, actor }),
9292 export function workClient(service: ServiceBinding): WorkApi {
9393 const call = <T>(method: string, args: object) => rpc<T>(service, method, args);
9494 return {
95− openIntent: (actor, repo, input) => call("open_intent", { actor, repo, ...input }),
96− listIntents: (repo, viewer, status) => call("list_intents", { repo, viewer, status }),
97− getIntent: (repo, number, viewer) => call("get_intent", { repo, number, viewer }),
98− withdrawIntent: (actor, intentId) => call("withdraw_intent", { actor, intentId }),
99− startAttempt: (actor, intentId, input) =>
100− call("start_attempt", { actor, intentId, ...input }),
101− getAttempt: (attemptId, viewer) => call("get_attempt", { attemptId, viewer }),
102− submitAttempt: (actor, attemptId, summary) =>
103− call("submit_attempt", { actor, attemptId, summary }),
104− abandonAttempt: (actor, attemptId) => call("abandon_attempt", { actor, attemptId }),
105− shipAttempt: (actor, attemptId) => call("ship_attempt", { actor, attemptId }),
106− listActiveAttempts: (viewer) => call("list_active_attempts", { viewer }),
107− appendSession: (actor, attemptId, entries) =>
108− call("append_session", { actor, attemptId, entries }),
109− readSession: (attemptId, viewer, afterSeq = 0) =>
110− call("read_session", { attemptId, viewer, afterSeq }),
95+ openIssue: (actor, repo, input) => call("open_issue", { actor, repo, ...input }),
96+ listIssues: (repo, viewer, filter = {}) => call("list_issues", { repo, viewer, ...filter }),
97+ getIssue: (repo, number, viewer) => call("get_issue", { repo, number, viewer }),
98+ updateIssue: (actor, repo, number, input) =>
99+ call("update_issue", { actor, repo, number, ...input }),
100+ closeIssue: (actor, repo, number, reason) =>
101+ call("close_issue", { actor, repo, number, reason }),
102+ reopenIssue: (actor, repo, number) => call("reopen_issue", { actor, repo, number }),
103+ listLabels: (repo, viewer) => call("list_labels", { repo, viewer }),
104+ counts: (repo, viewer) => call("counts", { repo, viewer }),
105+ addComment: (actor, repo, number, body) =>
106+ call("add_comment", { actor, repo, number, body }),
107+ openPull: (actor, repo, input) => call("open_pull", { actor, repo, ...input }),
108+ listPulls: (repo, viewer, state) => call("list_pulls", { repo, viewer, state }),
109+ getPull: (repo, number, viewer) => call("get_pull", { repo, number, viewer }),
110+ readyPull: (actor, repo, number, summary) =>
111+ call("ready_pull", { actor, repo, number, summary }),
112+ closePull: (actor, repo, number) => call("close_pull", { actor, repo, number }),
113+ mergePull: (actor, repo, number, keepIssueOpen = false) =>
114+ call("merge_pull", { actor, repo, number, keepIssueOpen }),
115+ listActivePulls: (viewer) => call("list_active_pulls", { viewer }),
116+ appendSession: (actor, repo, number, entries) =>
117+ call("append_session", { actor, repo, number, entries }),
118+ readSession: (repo, number, viewer, afterSeq = 0) =>
119+ call("read_session", { repo, number, viewer, afterSeq }),
111120 };
112121 }
+20−8
88 */
99 export type EventPayloads = {
1010 "repo.created": { repoId: string; namespace: string; name: string; isPrivate: boolean };
11− "repo.forked": { repoId: string; sourceRepoId: string; attemptId: string };
11+ "repo.forked": { repoId: string; sourceRepoId: string; pullId: string };
1212 /** `after` is the commit the ref points to once the push has landed. */
1313 "git.push": { repoId: string; ref: string; after: string };
14− "intent.opened": { intentId: string; repoId: string; number: number; title: string };
15− "intent.closed": { intentId: string; repoId: string; reason: "shipped" | "withdrawn" };
16− "attempt.started": { attemptId: string; intentId: string; repoId: string; agent: string };
17− "attempt.updated": { attemptId: string; intentId: string; repoId: string; status: string };
18− "attempt.submitted": { attemptId: string; intentId: string; repoId: string };
19− "attempt.shipped": { attemptId: string; intentId: string; repoId: string; commit: string };
20− "session.appended": { attemptId: string; sessionId: string; count: number };
14+ "issue.opened": { issueId: string; repoId: string; number: number; title: string };
15+ "issue.updated": { issueId: string; repoId: string; number: number };
16+ /** `resolvedBy` is the number of the pull request whose merge closed it. */
17+ "issue.closed": {
18+ issueId: string;
19+ repoId: string;
20+ number: number;
21+ reason: "completed" | "not_planned";
22+ resolvedBy?: number;
23+ };
24+ "issue.reopened": { issueId: string; repoId: string; number: number };
25+ /** `issue` is the number of the issue the pull request is for. */
26+ "pull.opened": { pullId: string; repoId: string; number: number; issue?: number; agent: string };
27+ "pull.ready": { pullId: string; repoId: string; number: number; issue?: number };
28+ "pull.closed": { pullId: string; repoId: string; number: number; issue?: number };
29+ "pull.merged": { pullId: string; repoId: string; number: number; issue?: number; commit: string };
30+ /** `number` is the issue or pull request commented on. */
31+ "comment.created": { commentId: string; repoId: string; number: number };
32+ "session.appended": { pullId: string; repoId: string; number: number; count: number };
2133 };
2234
2335 export type EventType = keyof EventPayloads;
+1−1
66 /** Routes and reserved words that may not be registered as usernames. */
77 const RESERVED = new Set([
88 "api", "mcp", "login", "logout", "register", "new", "settings", "search",
9− "admin", "auth", "attempts", "verify", "forgot", "reset", "device", "workspaces", "u", "oauth", "assets", "docs", "explore", "g1t", "about",
9+ "admin", "auth", "pulls", "issues", "verify", "forgot", "reset", "device", "workspaces", "u", "oauth", "assets", "docs", "explore", "g1t", "about",
1010 ]);
1111
1212 export function isValidNamespace(value: string): boolean {
+4−4
1010 isPrivate: boolean;
1111 ownerId: string;
1212 defaultBranch: string;
13− /** Set when this repo is an attempt's working copy of another repo. */
13+ /** Set when this repo is a pull request's working copy of another repo. */
1414 forkOf: string | null;
1515 /** RFC 3339. */
1616 createdAt: string;
8888 log(path: RepoPath, viewer: Viewer, ref: string | null, limit: number): Promise<Result<Commit[]>>;
8989
9090 /**
91− * A copy-on-write copy of `source`, hidden from listings, for one attempt
91+ * A copy-on-write copy of `source`, hidden from listings, for one pull request
9292 * to work in.
9393 */
94− forkForAttempt(sourceId: string, attemptId: string, actor: User): Promise<Result<Repo>>;
94+ forkForPull(sourceId: string, pullId: string, actor: User): Promise<Result<Repo>>;
9595
9696 /**
9797 * Authorizes a git operation and returns where to send it. Pushing to a
107107 land(forkId: string, actor: User): Promise<Result<{ commit: string; previous: string | null }>>;
108108
109109 /**
110− * What a repository's head changes. An attempt's fork is compared with the
110+ * What a repository's head changes. A pull request's fork is compared with the
111111 * last commit it shares with the repository it came from, unless `base`
112112 * says otherwise.
113113 */
+9−8
11 import type { User, Viewer } from "./identity";
22 import type { Result } from "./result";
3−import type { Attempt } from "./work";
3+import type { RepoPath } from "./repos";
4+import type { Pull } from "./work";
45
56 /** A model a g1t agent can run on, as offered to the person starting it. */
67 export type AgentModel = {
1314 };
1415
1516 export type RunHostedInput = {
16− /** How many agents to race on the intent, each in its own sandbox. */
17+ /** How many agents to put on the issue, each in its own sandbox. */
1718 count: number;
18− /** Extra guidance appended to the intent's brief for these runs. */
19+ /** Extra guidance given to the agents along with the issue. */
1920 instructions?: string;
2021 /** One of the offered model ids; the first offered when absent. */
2122 model?: string;
2223 };
2324
24−/** Hosted agents: sandboxes on g1t that work on an intent. */
25+/** Hosted agents: sandboxes on g1t that work on an issue. */
2526 export interface RunnerApi {
2627 /** The models this viewer may run g1t agents on; empty if they may not. */
2728 models(viewer: Viewer): Promise<AgentModel[]>;
2829 /**
29− * Starts `count` attempts on the intent, each run by an agent in its own
30− * sandbox. Returns as soon as the sandboxes are starting; progress shows
31− * up in each attempt's session.
30+ * Opens `count` draft pull requests for the issue, each made by an agent
31+ * in its own sandbox. Returns as soon as the sandboxes are starting;
32+ * progress shows up in each pull request's session.
3233 */
33− run(actor: User, intentId: string, input: RunHostedInput): Promise<Result<Attempt[]>>;
34+ run(actor: User, repo: RepoPath, issue: number, input: RunHostedInput): Promise<Result<Pull[]>>;
3435 }
+136−44
22 import type { RepoPath } from "./repos";
33 import type { Result } from "./result";
44
5−export type IntentStatus = "open" | "shipped" | "withdrawn";
5+/**
6+ * The filter on lists of issues and pull requests. An open pull request is
7+ * a draft or one ready for review; a closed one was merged or closed
8+ * without merging.
9+ */
10+export type State = "open" | "closed";
11+
12+/** Why an issue was closed. */
13+export type IssueReason = "completed" | "not_planned";
614
7−/** A goal stated against a repo. Replaces the issue and the pull request. */
8−export type Intent = {
15+/**
16+ * Something that should change in a repository: a bug, a feature, a
17+ * question. Opened by a person, an agent or an integration. Pull requests
18+ * are made against it; the one that is merged resolves it.
19+ *
20+ * Issues and pull requests share one sequence of numbers per repository.
21+ */
22+export type Issue = {
923 id: string;
1024 repoId: string;
11− /** Sequential per repo, shown as `#12`. */
25+ /** Shown as `#12`. */
1226 number: number;
1327 title: string;
14− /** The goal in prose: what an agent is given to work from. */
15− brief: string;
16− /** Commands that must pass for an attempt to be accepted. */
28+ /** Markdown. Also what an agent is given to work from. */
29+ body: string;
30+ labels: string[];
31+ /** Commands that must pass for a pull request to be accepted. */
1732 checks: string[];
18− status: IntentStatus;
33+ state: State;
34+ /** Set when closed. */
35+ reason: IssueReason | null;
36+ /** The number of the pull request whose merge closed this issue. */
37+ resolvedBy: number | null;
1938 author: User;
2039 /** RFC 3339. */
2140 createdAt: string;
22− attemptCount: number;
41+ /** RFC 3339. */
42+ updatedAt: string;
43+ /** RFC 3339. */
44+ closedAt: string | null;
45+ /** Pull requests made against this issue, in any state. */
46+ pullCount: number;
47+ commentCount: number;
2348 };
2449
25−export type AttemptStatus = "working" | "submitted" | "shipped" | "abandoned";
50+/** `draft` is still being worked on; `open` is ready for review. */
51+export type PullStatus = "draft" | "open" | "merged" | "closed";
2652
2753 /** Where the agent runs: on g1t's sandboxes, or in someone's own session. */
28−export type AttemptRuntime = "hosted" | "external";
54+export type Runtime = "hosted" | "external";
2955
30−/** One agent's run at an intent, in its own fork. */
31−export type Attempt = {
56+/** A proposed change, made in its own fork by an agent or a person. */
57+export type Pull = {
3258 id: string;
33− intentId: string;
3459 repoId: string;
35− /** Sequential per intent. */
60+ /** Shown as `#12`. */
3661 number: number;
62+ /** The number of the issue this is for, if any. */
63+ issue: number | null;
64+ title: string;
65+ /** Markdown: what changed and why. Set when marked ready. */
66+ body: string | null;
3767 /** A label for the agent doing the work, e.g. `claude-code`. */
3868 agent: string;
39− runtime: AttemptRuntime;
40− status: AttemptStatus;
41− /** The agent's own account of what it did, set on submit. */
42− summary: string | null;
69+ runtime: Runtime;
70+ status: PullStatus;
4371 fork: RepoPath;
4472 /** The fork's repository id. */
4573 forkRepoId: string;
4674 headCommit: string | null;
4775 /**
48− * For a shipped attempt, what the branch pointed to before it landed.
49− * Comparing against it shows what the attempt changed.
76+ * For a merged pull request, what the branch pointed to before the merge.
77+ * Comparing against it shows what the pull request changed.
78+ */
79+ mergeBase: string | null;
80+ /** Username of whoever merged it. */
81+ mergedBy: string | null;
82+ /** RFC 3339. */
83+ mergedAt: string | null;
84+ /**
85+ * Set on a pull request closed because another one for the same issue was
86+ * merged: that one's number.
5087 */
51− landedBase: string | null;
52− startedBy: User;
88+ supersededBy: number | null;
89+ author: User;
5390 /** RFC 3339. */
5491 createdAt: string;
5592 /** RFC 3339. */
5693 updatedAt: string;
5794 };
5895
96+export type Comment = {
97+ id: string;
98+ author: User;
99+ /** Markdown. */
100+ body: string;
101+ /** RFC 3339. */
102+ createdAt: string;
103+};
104+
59105 export type SessionEntryKind = "prompt" | "message" | "tool_call" | "tool_result" | "note";
60106
61−/** One step of an agent's session: the "why" behind an attempt's commits. */
107+/** One step of an agent's session: the "why" behind a pull request's commits. */
62108 export type SessionEntry = {
63109 seq: number;
64110 kind: SessionEntryKind;
74120 export type NewSessionEntry = Pick<SessionEntry, "kind" | "text"> &
75121 Partial<Pick<SessionEntry, "tool" | "commit">>;
76122
77−export type IntentDetail = { intent: Intent; attempts: Attempt[] };
123+export type IssueDetail = {
124+ issue: Issue;
125+ /** Every pull request made against it, oldest first. */
126+ pulls: Pull[];
127+ comments: Comment[];
128+};
78129
79−export type OpenIntentInput = { title: string; brief: string; checks?: string[] };
130+export type PullDetail = {
131+ pull: Pull;
132+ /** The issue it is for, if any. */
133+ issue: Issue | null;
134+ comments: Comment[];
135+};
80136
81−export type StartAttemptInput = { agent: string; runtime: AttemptRuntime };
137+export type OpenIssueInput = {
138+ title: string;
139+ body: string;
140+ labels?: string[];
141+ checks?: string[];
142+};
143+
144+export type UpdateIssueInput = { title?: string; body?: string; labels?: string[] };
145+
146+export type OpenPullInput = {
147+ /** The number of the issue this is for. */
148+ issue?: number;
149+ /** Defaults to the issue's title; required without an issue. */
150+ title?: string;
151+ agent: string;
152+ runtime: Runtime;
153+};
82154
83−/** Intents, attempts and sessions. */
155+/** Issues, pull requests, comments and sessions. */
84156 export interface WorkApi {
85− openIntent(actor: User, repo: RepoPath, input: OpenIntentInput): Promise<Result<Intent>>;
86− listIntents(repo: RepoPath, viewer: Viewer, status?: IntentStatus): Promise<Result<Intent[]>>;
87− getIntent(repo: RepoPath, number: number, viewer: Viewer): Promise<Result<IntentDetail>>;
88− withdrawIntent(actor: User, intentId: string): Promise<Result<Intent>>;
157+ openIssue(actor: User, repo: RepoPath, input: OpenIssueInput): Promise<Result<Issue>>;
158+ /** Newest first. */
159+ listIssues(
160+ repo: RepoPath,
161+ viewer: Viewer,
162+ filter?: { state?: State; label?: string },
163+ ): Promise<Result<Issue[]>>;
164+ getIssue(repo: RepoPath, number: number, viewer: Viewer): Promise<Result<IssueDetail>>;
165+ /** The author or a member of the workspace may. */
166+ updateIssue(actor: User, repo: RepoPath, number: number, input: UpdateIssueInput): Promise<Result<Issue>>;
167+ closeIssue(actor: User, repo: RepoPath, number: number, reason?: IssueReason): Promise<Result<Issue>>;
168+ reopenIssue(actor: User, repo: RepoPath, number: number): Promise<Result<Issue>>;
169+ /** The default labels, then every other label in use on the repository. */
170+ listLabels(repo: RepoPath, viewer: Viewer): Promise<Result<string[]>>;
171+ /** How many issues and pull requests are open. */
172+ counts(repo: RepoPath, viewer: Viewer): Promise<Result<{ issues: number; pulls: number }>>;
89173
90− /** Forks the repo for the agent and returns the attempt to push to. */
91− startAttempt(actor: User, intentId: string, input: StartAttemptInput): Promise<Result<Attempt>>;
92− getAttempt(attemptId: string, viewer: Viewer): Promise<Result<{ attempt: Attempt; intent: Intent }>>;
93− submitAttempt(actor: User, attemptId: string, summary: string): Promise<Result<Attempt>>;
94− abandonAttempt(actor: User, attemptId: string): Promise<Result<Attempt>>;
174+ /** On an issue or a pull request. */
175+ addComment(actor: User, repo: RepoPath, number: number, body: string): Promise<Result<Comment>>;
176+
177+ /** Forks the repo and returns the draft pull request to push to. */
178+ openPull(actor: User, repo: RepoPath, input: OpenPullInput): Promise<Result<Pull>>;
179+ /** Newest first. */
180+ listPulls(repo: RepoPath, viewer: Viewer, state?: State): Promise<Result<Pull[]>>;
181+ getPull(repo: RepoPath, number: number, viewer: Viewer): Promise<Result<PullDetail>>;
182+ /** Marks a draft ready for review and sets its description. */
183+ readyPull(actor: User, repo: RepoPath, number: number, summary: string): Promise<Result<Pull>>;
184+ closePull(actor: User, repo: RepoPath, number: number): Promise<Result<Pull>>;
95185 /**
96− * Lands the attempt on the repository's default branch and closes its
97− * intent as shipped. Only the repository's owner may ship.
186+ * Lands the pull request on the repository's default branch. Unless
187+ * `keepIssueOpen`, that resolves the issue it was for: the issue closes
188+ * naming this pull request, and the others still in progress for it close
189+ * as superseded. Only members of the repository's workspace may merge.
98190 */
99− shipAttempt(actor: User, attemptId: string): Promise<Result<Attempt>>;
100− /** Attempts in progress that the viewer started, newest first. */
101− listActiveAttempts(viewer: Viewer): Promise<{ attempt: Attempt; intent: Intent }[]>;
191+ mergePull(actor: User, repo: RepoPath, number: number, keepIssueOpen?: boolean): Promise<Result<Pull>>;
192+ /** Drafts and open pull requests the viewer started, most recently active first. */
193+ listActivePulls(viewer: Viewer): Promise<{ pull: Pull; issue: Issue | null }[]>;
102194
103− appendSession(actor: User, attemptId: string, entries: NewSessionEntry[]): Promise<Result<{ count: number }>>;
104− readSession(attemptId: string, viewer: Viewer, afterSeq?: number): Promise<Result<SessionEntry[]>>;
195+ appendSession(actor: User, repo: RepoPath, number: number, entries: NewSessionEntry[]): Promise<Result<{ count: number }>>;
196+ readSession(repo: RepoPath, number: number, viewer: Viewer, afterSeq?: number): Promise<Result<SessionEntry[]>>;
105197 }
+1−1
2626
2727 /* Status colours, used sparingly. */
2828 --g1t-info: #7cc4ff;
29− --g1t-shipped: #b9a2ff;
29+ --g1t-merged: #b9a2ff;
3030 --g1t-warn: #f2c14a;
3131 --g1t-danger: #ff7a6b;
3232 }
+1−1
1−//! Landing an attempt: moving a repository's branch forward to a commit
1+//! Landing a pull request: moving a repository's branch forward to a commit
22 //! that so far exists only in a fork.
33 //!
44 //! The Artifacts binding cannot write, so this speaks git's smart HTTP
+13−13
2525 use registry::{Registry, can_read, can_write, store_key};
2626 use store::{ArtifactsStore, GitRepo, GitStore, Scope};
2727
28−/// Namespace that holds every attempt's fork: `attempts/<attempt id>`.
29−const ATTEMPTS_NAMESPACE: &str = "attempts";
28+/// Namespace that holds every pull request's fork: `pulls/<pull id>`.
29+const PULLS_NAMESPACE: &str = "pulls";
3030 const MAX_TEXT_BYTES: usize = 512 * 1024;
31−/// How far back an attempt may have forked and still be landed.
31+/// How far back a pull request may have forked and still be landed.
3232 const MAX_ANCESTRY: u32 = 1000;
3333 const SOURCE: &str = "repos";
3434 const UNVERIFIED: &str = "Confirm your email address first. Check your inbox, or resend the link from the banner on g1t.sh.";
325325 Ok(Outcome::Ok(git.log(&git_ref, a.limit).await?))
326326 }
327327
328− async fn fork_for_attempt(&self, a: ForkArgs) -> Result<Outcome<Repo>> {
328+ async fn fork_for_pull(&self, a: ForkArgs) -> Result<Outcome<Repo>> {
329329 let viewer = Some(a.actor.clone());
330330 let Some(source) = self
331331 .registry
338338 let now = now_ms();
339339 let fork = Repo {
340340 id: new_id("rep", now),
341− namespace: ATTEMPTS_NAMESPACE.to_owned(),
342− name: a.attempt_id.clone(),
341+ namespace: PULLS_NAMESPACE.to_owned(),
342+ name: a.pull_id.clone(),
343343 description: None,
344344 // A fork is exactly as visible as the repo it came from.
345345 is_private: source.is_private,
362362 data: RepoForked {
363363 repo_id: fork.id.clone(),
364364 source_repo_id: source.id,
365− attempt_id: a.attempt_id,
365+ pull_id: a.pull_id,
366366 },
367367 })
368368 .await?;
439439 if !can_write(&target, &actor) {
440440 return Ok(Outcome::fail(
441441 FailureCode::Forbidden,
442− "Only members of the repository's workspace can land an attempt.",
442+ "Only members of the repository's workspace can merge a pull request.",
443443 ));
444444 }
445445 if !a.actor.verified {
453453 let Some(new) = history.first().map(|commit| commit.hash.clone()) else {
454454 return Ok(Outcome::fail(
455455 FailureCode::Conflict,
456− "This attempt has no commits to land.",
456+ "This pull request has no commits to merge.",
457457 ));
458458 };
459459 let old = target_git
477477 return Ok(Outcome::fail(
478478 FailureCode::Conflict,
479479 format!(
480− "{branch} has moved since this attempt started. Pull {branch} into the attempt's fork, push, and land again."
480+ "{branch} has moved since this pull request was opened. Pull {branch} into the pull request's fork, push, and merge again."
481481 ),
482482 ));
483483 }
488488 land::fast_forward(&source_access, &target_access, branch, old.as_deref(), &new)
489489 .await?;
490490 if let Err(reason) = pushed {
491− // Most often another attempt landed between the check and the push.
491+ // Most often another pull request landed between the check and the push.
492492 return Ok(Outcome::fail(
493493 FailureCode::Conflict,
494494 format!("{branch} could not be updated: {reason}"),
592592 let response = git_http::forward(request, &git, &access).await?;
593593
594594 // Artifacts' own push notifications are per repository, which does
595− // not fit a repo per attempt, so the front end reports pushes itself.
595+ // not fit a repo per pull request, so the front end reports pushes itself.
596596 let pushed = git.endpoint == "git-receive-pack" && response.status_code() == 200;
597597 if pushed && let Some(repo) = self.registry.by_path(&git.path).await? {
598598 let head = self
643643 "tree" => reply(&repos.tree(args(body)?).await?),
644644 "blob" => reply(&repos.blob(args(body)?).await?),
645645 "log" => reply(&repos.log(args(body)?).await?),
646− "fork_for_attempt" => reply(&repos.fork_for_attempt(args(body)?).await?),
646+ "fork_for_pull" => reply(&repos.fork_for_pull(args(body)?).await?),
647647 "git_access" => reply(&repos.git_access(args(body)?).await?),
648648 "land" => reply(&repos.land(args(body)?).await?),
649649 "compare" => reply(&repos.compare(args(body)?).await?),
+2−2
4545 }
4646
4747 /// A repository belongs to its workspace, so any member may write to it. An
48−/// attempt's fork belongs to whoever started the attempt.
48+/// pull request's fork belongs to whoever opened the pull request.
4949 pub fn can_write(repo: &Repo, viewer: &Viewer) -> bool {
5050 viewer.as_ref().is_some_and(|user| {
5151 if repo.fork_of.is_some() {
8888 .map(Repo::from))
8989 }
9090
91− /// Repos the viewer may see, newest first. Excludes attempt forks.
91+ /// Repos the viewer may see, newest first. Excludes pull request forks.
9292 /// With `member_only`, only repos in the viewer's own workspaces.
9393 pub async fn list(
9494 &self,
+4−1
1−# The sandbox a hosted attempt runs in: git, the agent, the g1t runner, and
1+# The sandbox a g1t agent works in: git, the agent, the g1t runner, and
22 # the toolchains an agent needs to build and test what it changes.
33 # Build context: the repository root.
44
2626 RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
2727 | sh -s -- -y --profile minimal --default-toolchain stable
2828 ENV PATH=/home/node/.cargo/bin:$PATH
29+# Commits are the g1t agent's; the harness does not sign them as its own.
30+RUN mkdir -p /home/node/.claude \
31+ && echo '{"includeCoAuthoredBy": false}' > /home/node/.claude/settings.json
2932 ENTRYPOINT ["g1t-runner"]
+44−36
33
44 import {
55 type AgentModel,
6− type Attempt,
7− type Intent,
6+ type Issue,
7+ type Pull,
8+ type RepoPath,
89 type Result,
910 type RunHostedInput,
1011 type RunnerApi,
5253 /** How g1t's own agent is labelled. What runs behind it is g1t's choice. */
5354 const AGENT = "g1t-agent";
5455
55−type RunRequest = { actor: User; attemptId: string; envVars: Record<string, string> };
56+/** Which pull request a sandbox is working on, and as whom. */
57+type Run = { actor: User; repo: RepoPath; number: number };
58+type RunRequest = Run & { envVars: Record<string, string> };
5659
5760 /**
58− * One sandbox, for one attempt. The image's entrypoint is the g1t runner,
61+ * One sandbox, for one pull request. The image's entrypoint is the g1t runner,
5962 * which does the work and exits; this class only starts it and cleans up
6063 * if it dies without reporting.
6164 */
6366 sleepAfter = "45m";
6467
6568 async run(request: RunRequest): Promise<void> {
66− await this.ctx.storage.put("run", {
67− actor: request.actor,
68− attemptId: request.attemptId,
69− });
70− await this.start({ envVars: request.envVars, enableInternet: true });
69+ const { envVars, ...run } = request;
70+ await this.ctx.storage.put("run", run);
71+ await this.start({ envVars, enableInternet: true });
7172 }
7273
7374 override async onStop({ exitCode }: StopParams): Promise<void> {
7475 if (exitCode === 0) return;
75− // The runner abandons its own attempt when it fails. This covers a
76− // sandbox that was killed before it could; abandoning twice is refused
76+ // The runner closes its own pull request when it fails. This covers a
77+ // sandbox that was killed before it could; closing twice is refused
7778 // harmlessly.
78− const run = await this.ctx.storage.get<Pick<RunRequest, "actor" | "attemptId">>("run");
79− if (run) await workClient(this.env.WORK).abandonAttempt(run.actor, run.attemptId);
79+ const run = await this.ctx.storage.get<Run>("run");
80+ if (run) await workClient(this.env.WORK).closePull(run.actor, run.repo, run.number);
8081 }
8182 }
8283
100101 return vars;
101102 }
102103
103−function buildPrompt(intent: Intent, instructions: string): string {
104+function buildPrompt(issue: Issue, instructions: string): string {
104105 const parts = [
105106 "You are a coding agent working in the git repository checked out in the current directory.",
106− `Goal: ${intent.title}`,
107− intent.brief,
107+ `Issue #${issue.number}: ${issue.title}`,
108+ issue.body,
108109 ];
109− if (intent.checks.length > 0) {
110+ if (issue.checks.length > 0) {
110111 parts.push(
111− `These commands must pass when you are done. Run them if the tools are installed:\n${intent.checks.map((check) => `- ${check}`).join("\n")}`,
112+ `These commands must pass when you are done. Run them if the tools are installed:\n${issue.checks.map((check) => `- ${check}`).join("\n")}`,
112113 );
113114 }
114115 if (instructions) parts.push(instructions);
115116 parts.push(
116− "Make the change and keep it focused on the goal. Commit your work with a clear message. Do not push; that is done for you. Finish with a short summary of what you changed and why.",
117+ "Make the change and keep it focused on the issue. Commit your work with a clear message. Do not push; that is done for you. Finish with a short summary of what you changed and why.",
117118 );
118119 return parts.filter(Boolean).join("\n\n");
119120 }
150151
151152 async run(
152153 actor: User,
153− intentId: string,
154+ repo: RepoPath,
155+ issueNumber: number,
154156 input: RunHostedInput,
155− ): Promise<Result<Attempt[]>> {
157+ ): Promise<Result<Pull[]>> {
156158 if (!this.allowed(actor)) {
157159 return fail("forbidden", "g1t agents are not enabled for your account.");
158160 }
165167 const identity = identityClient(this.env.IDENTITY);
166168 const work = workClient(this.env.WORK);
167169
168− const attempts: Attempt[] = [];
170+ const found = await work.getIssue(repo, issueNumber, actor);
171+ if (!found.ok) return found;
172+ const { issue } = found.value;
173+ const prompt = buildPrompt(issue, input.instructions?.trim() ?? "");
174+
175+ const pulls: Pull[] = [];
169176 for (let i = 0; i < count; i++) {
170− const started = await work.startAttempt(actor, intentId, {
177+ const opened = await work.openPull(actor, repo, {
178+ issue: issue.number,
171179 agent: AGENT,
172180 runtime: "hosted",
173181 });
174182 // The first failure is the answer; later ones mean some already run.
175− if (!started.ok) return attempts.length ? ok(attempts) : started;
176− const attempt = started.value;
177− attempts.push(attempt);
183+ if (!opened.ok) return pulls.length ? ok(pulls) : opened;
184+ const pull = opened.value;
185+ pulls.push(pull);
178186
179− const found = await work.getAttempt(attempt.id, actor);
180− if (!found.ok) return found;
181187 // The sandbox acts as the person who started it, through a token
182188 // that only lives as long as a run can.
183189 const { token } = await identity.createAccessToken(
184190 actor,
185− `Hosted attempt ${attempt.id}`,
191+ `g1t agent on ${repo.namespace}/${repo.name}#${pull.number}`,
186192 TOKEN_TTL_SECONDS,
187193 );
188− const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(attempt.id));
194+ const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(pull.id));
189195 await sandbox.run({
190196 actor,
191− attemptId: attempt.id,
197+ repo,
198+ number: pull.number,
192199 envVars: {
193200 G1T_API: "https://api.g1t.sh",
194201 G1T_TOKEN: token,
195202 G1T_USER: actor.username,
196− ATTEMPT_ID: attempt.id,
197− GIT_REMOTE: `https://g1t.sh/${attempt.fork.namespace}/${attempt.fork.name}.git`,
198− COMMIT_MESSAGE: found.value.intent.title,
199− PROMPT: buildPrompt(found.value.intent, input.instructions?.trim() ?? ""),
203+ G1T_REPO: `${repo.namespace}/${repo.name}`,
204+ PULL_NUMBER: String(pull.number),
205+ GIT_REMOTE: `https://g1t.sh/${pull.fork.namespace}/${pull.fork.name}.git`,
206+ COMMIT_MESSAGE: issue.title,
207+ PROMPT: prompt,
200208 ...modelEnv(this.env, model),
201209 },
202210 });
203211 }
204− return ok(attempts);
212+ return ok(pulls);
205213 }
206214 }
+58−17
1−CREATE TABLE intents (
1+-- Issues, pull requests, comments and sessions.
2+-- Every timestamp is RFC 3339 UTC text.
3+
4+-- Issues and pull requests share one sequence of numbers per repository.
5+CREATE TABLE counters (
6+ repo_id TEXT PRIMARY KEY,
7+ last INTEGER NOT NULL
8+);
9+
10+CREATE TABLE issues (
211 id TEXT PRIMARY KEY,
312 repo_id TEXT NOT NULL,
413 number INTEGER NOT NULL,
514 title TEXT NOT NULL,
6− brief TEXT NOT NULL,
15+ body TEXT NOT NULL,
16+ -- JSON array of label names.
17+ labels TEXT NOT NULL DEFAULT '[]',
718 -- JSON array of commands.
819 checks TEXT NOT NULL DEFAULT '[]',
9− status TEXT NOT NULL DEFAULT 'open',
20+ state TEXT NOT NULL DEFAULT 'open',
21+ -- Why it was closed: completed or not_planned.
22+ reason TEXT,
23+ -- The number of the pull request whose merge closed it.
24+ resolved_by INTEGER,
1025 author_id TEXT NOT NULL,
1126 author_name TEXT NOT NULL,
12− created_at INTEGER NOT NULL,
27+ created_at TEXT NOT NULL,
28+ updated_at TEXT NOT NULL,
29+ closed_at TEXT,
1330 UNIQUE (repo_id, number)
1431 );
32+CREATE INDEX issues_by_state ON issues (repo_id, state, number);
1533
16−CREATE TABLE attempts (
34+CREATE TABLE pulls (
1735 id TEXT PRIMARY KEY,
18− intent_id TEXT NOT NULL REFERENCES intents (id),
1936 repo_id TEXT NOT NULL,
2037 number INTEGER NOT NULL,
38+ -- The issue it is for, if any, and that issue's number.
39+ issue_id TEXT REFERENCES issues (id),
40+ issue_number INTEGER,
41+ title TEXT NOT NULL,
42+ body TEXT,
2143 agent TEXT NOT NULL,
2244 runtime TEXT NOT NULL,
23− status TEXT NOT NULL DEFAULT 'working',
24− summary TEXT,
45+ status TEXT NOT NULL DEFAULT 'draft',
2546 fork_repo_id TEXT NOT NULL UNIQUE,
2647 fork_namespace TEXT NOT NULL,
2748 fork_name TEXT NOT NULL,
2849 head_commit TEXT,
29− started_by_id TEXT NOT NULL,
30− started_by_name TEXT NOT NULL,
31− created_at INTEGER NOT NULL,
32− updated_at INTEGER NOT NULL,
33− UNIQUE (intent_id, number)
50+ -- What the branch pointed to before a merged pull request landed.
51+ merge_base TEXT,
52+ merged_by TEXT,
53+ merged_at TEXT,
54+ -- The pull request merged instead of this one.
55+ superseded_by INTEGER,
56+ author_id TEXT NOT NULL,
57+ author_name TEXT NOT NULL,
58+ created_at TEXT NOT NULL,
59+ updated_at TEXT NOT NULL,
60+ UNIQUE (repo_id, number)
3461 );
35−CREATE INDEX attempts_starter ON attempts (started_by_id, status);
62+CREATE INDEX pulls_by_status ON pulls (repo_id, status, number);
63+CREATE INDEX pulls_by_issue ON pulls (issue_id);
64+CREATE INDEX pulls_by_author ON pulls (author_id, status);
3665
66+-- On an issue or a pull request: the two share numbers.
67+CREATE TABLE comments (
68+ id TEXT PRIMARY KEY,
69+ repo_id TEXT NOT NULL,
70+ number INTEGER NOT NULL,
71+ author_id TEXT NOT NULL,
72+ author_name TEXT NOT NULL,
73+ body TEXT NOT NULL,
74+ created_at TEXT NOT NULL
75+);
76+CREATE INDEX comments_by_subject ON comments (repo_id, number, id);
77+
3778 CREATE TABLE session_entries (
38− attempt_id TEXT NOT NULL REFERENCES attempts (id),
79+ pull_id TEXT NOT NULL REFERENCES pulls (id),
3980 seq INTEGER NOT NULL,
4081 kind TEXT NOT NULL,
4182 text TEXT NOT NULL,
4283 tool TEXT,
4384 -- The fork's head when the entry was recorded: links reasoning to code.
4485 "commit" TEXT,
45− at INTEGER NOT NULL,
46− PRIMARY KEY (attempt_id, seq)
86+ at TEXT NOT NULL,
87+ PRIMARY KEY (pull_id, seq)
4788 );
+0−2
1−-- What the branch pointed to before a shipped attempt landed.
2−ALTER TABLE attempts ADD COLUMN landed_base TEXT;
+0−80
1−-- Every timestamp becomes RFC 3339 UTC text instead of Unix milliseconds.
2−-- SQLite cannot change a column's type, so each table is rebuilt.
3−--
4−-- The new tables reference each other's new names; the renames at the end
5−-- carry those references along.
6−PRAGMA defer_foreign_keys = on;
7−
8−CREATE TABLE intents_new (
9− id TEXT PRIMARY KEY,
10− repo_id TEXT NOT NULL,
11− number INTEGER NOT NULL,
12− title TEXT NOT NULL,
13− brief TEXT NOT NULL,
14− -- JSON array of commands.
15− checks TEXT NOT NULL DEFAULT '[]',
16− status TEXT NOT NULL DEFAULT 'open',
17− author_id TEXT NOT NULL,
18− author_name TEXT NOT NULL,
19− created_at TEXT NOT NULL,
20− UNIQUE (repo_id, number)
21−);
22−INSERT INTO intents_new
23−SELECT id, repo_id, number, title, brief, checks, status, author_id, author_name,
24− strftime('%Y-%m-%dT%H:%M:%fZ', created_at / 1000.0, 'unixepoch')
25−FROM intents;
26−
27−CREATE TABLE attempts_new (
28− id TEXT PRIMARY KEY,
29− intent_id TEXT NOT NULL REFERENCES intents_new (id),
30− repo_id TEXT NOT NULL,
31− number INTEGER NOT NULL,
32− agent TEXT NOT NULL,
33− runtime TEXT NOT NULL,
34− status TEXT NOT NULL DEFAULT 'working',
35− summary TEXT,
36− fork_repo_id TEXT NOT NULL UNIQUE,
37− fork_namespace TEXT NOT NULL,
38− fork_name TEXT NOT NULL,
39− head_commit TEXT,
40− -- What the branch pointed to before a shipped attempt landed.
41− landed_base TEXT,
42− started_by_id TEXT NOT NULL,
43− started_by_name TEXT NOT NULL,
44− created_at TEXT NOT NULL,
45− updated_at TEXT NOT NULL,
46− UNIQUE (intent_id, number)
47−);
48−INSERT INTO attempts_new
49−SELECT id, intent_id, repo_id, number, agent, runtime, status, summary,
50− fork_repo_id, fork_namespace, fork_name, head_commit, landed_base,
51− started_by_id, started_by_name,
52− strftime('%Y-%m-%dT%H:%M:%fZ', created_at / 1000.0, 'unixepoch'),
53− strftime('%Y-%m-%dT%H:%M:%fZ', updated_at / 1000.0, 'unixepoch')
54−FROM attempts;
55−
56−CREATE TABLE session_entries_new (
57− attempt_id TEXT NOT NULL REFERENCES attempts_new (id),
58− seq INTEGER NOT NULL,
59− kind TEXT NOT NULL,
60− text TEXT NOT NULL,
61− tool TEXT,
62− -- The fork's head when the entry was recorded: links reasoning to code.
63− "commit" TEXT,
64− at TEXT NOT NULL,
65− PRIMARY KEY (attempt_id, seq)
66−);
67−INSERT INTO session_entries_new
68−SELECT attempt_id, seq, kind, text, tool, "commit",
69− strftime('%Y-%m-%dT%H:%M:%fZ', at / 1000.0, 'unixepoch')
70−FROM session_entries;
71−
72−DROP TABLE session_entries;
73−DROP TABLE attempts;
74−DROP TABLE intents;
75−
76−ALTER TABLE intents_new RENAME TO intents;
77−ALTER TABLE attempts_new RENAME TO attempts;
78−ALTER TABLE session_entries_new RENAME TO session_entries;
79−
80−CREATE INDEX attempts_starter ON attempts (started_by_id, status);
+753−357
1−//! The work service: intents, attempts and sessions.
1+//! The work service: issues, pull requests, comments and sessions.
22 //!
33 //! Other services reach it over `POST /rpc/<method>`; see
44 //! `g1t_contracts::work` for the methods and their arguments. It also
77 mod rows;
88
99 use g1t_contracts::events::{
10− AttemptEvent, Delivered, IntentClosed, IntentOpened, NewEvent, SessionAppended,
10+ CommentCreated, Delivered, IssueEvent, NewEvent, PullEvent, SessionAppended,
1111 };
12−use g1t_contracts::repos::{ForkArgs, GetArgs, GetByIdArgs, LandArgs, Landed, Repo};
12+use g1t_contracts::repos::{ForkArgs, GetArgs, LandArgs, Landed, Repo, RepoPath};
1313 use g1t_contracts::time::rfc3339;
1414 use g1t_contracts::work::*;
1515 use g1t_contracts::{FailureCode, Outcome, User, Viewer, new_id};
2020 Context, D1Database, Env, Fetcher, MessageBatch, MessageExt, Request, Response, Result, event,
2121 };
2222
23−use rows::{AttemptRow, IntentRow, SessionRow};
23+use rows::{CommentRow, IssueRow, NumberRow, PullRow, SessionRow, ValueRow};
2424
2525 const SOURCE: &str = "work";
2626 const MAX_ENTRY_BATCH: usize = 200;
2727 const MAX_ENTRY_CHARS: usize = 64_000;
28+const MAX_TITLE_CHARS: usize = 200;
2829 const SESSION_PAGE: u32 = 500;
30+const LIST_PAGE: u32 = 100;
2931 const UNVERIFIED: &str = "Confirm your email address first. Check your inbox, or resend the link from the banner on g1t.sh.";
3032
31−const INTENT_COLUMNS: &str = "intents.*,
32− (SELECT count(*) FROM attempts WHERE attempts.intent_id = intents.id) AS attempt_count";
33+const ISSUE_COLUMNS: &str = "issues.*,
34+ (SELECT count(*) FROM pulls WHERE pulls.issue_id = issues.id) AS pull_count,
35+ (SELECT count(*) FROM comments
36+ WHERE comments.repo_id = issues.repo_id AND comments.number = issues.number) AS comment_count";
3337
34−fn no_intent<T>() -> Outcome<T> {
35− Outcome::fail(FailureCode::NotFound, "Intent not found.")
38+fn no_issue<T>() -> Outcome<T> {
39+ Outcome::fail(FailureCode::NotFound, "Issue not found.")
3640 }
3741
38−fn no_attempt<T>() -> Outcome<T> {
39− Outcome::fail(FailureCode::NotFound, "Attempt not found.")
42+fn no_pull<T>() -> Outcome<T> {
43+ Outcome::fail(FailureCode::NotFound, "Pull request not found.")
4044 }
4145
4246 fn optional(value: &Option<String>) -> JsValue {
4347 value.as_deref().map_or(JsValue::NULL, JsValue::from)
4448 }
4549
50+fn optional_number(value: Option<u32>) -> JsValue {
51+ value.map_or(JsValue::NULL, JsValue::from)
52+}
53+
54+/// The lowercase name a `State` is stored and sent as.
55+fn state_name(state: Option<State>) -> Option<&'static str> {
56+ state.map(|state| match state {
57+ State::Open => "open",
58+ State::Closed => "closed",
59+ })
60+}
61+
62+/// A trimmed title, or why it cannot be used.
63+fn valid_title(title: &str) -> std::result::Result<&str, &'static str> {
64+ let title = title.trim();
65+ if title.is_empty() {
66+ Err("A title is required.")
67+ } else if title.chars().count() > MAX_TITLE_CHARS {
68+ Err("That title is too long.")
69+ } else {
70+ Ok(title)
71+ }
72+}
73+
74+/// Unwraps an `Outcome`, returning its failure from the enclosing method.
75+macro_rules! check {
76+ ($outcome:expr) => {
77+ match $outcome {
78+ Outcome::Ok(value) => value,
79+ Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
80+ }
81+ };
82+}
83+
4684 struct Work {
4785 db: D1Database,
4886 repos: Fetcher,
5189 }
5290
5391 impl Work {
54− async fn publish<T: Serialize>(&self, event: NewEvent<T>) -> Result<()> {
92+ async fn publish<T: Serialize>(
93+ &self,
94+ kind: &'static str,
95+ repo_id: &str,
96+ actor: &User,
97+ data: T,
98+ ) -> Result<()> {
99+ let event = NewEvent {
100+ kind,
101+ source: SOURCE,
102+ repo_id: Some(repo_id.to_owned()),
103+ actor: Some(actor.id.clone()),
104+ data,
105+ };
55106 js::call(&self.events, "publish", &[js::to_js(&[event])?]).await?;
56107 Ok(())
57108 }
58109
59− async fn repo_by_path(
60− &self,
61− path: &g1t_contracts::repos::RepoPath,
62− viewer: &Viewer,
63− ) -> Result<Outcome<Repo>> {
110+ /// The repository, if the viewer may see it. Whether they may is
111+ /// decided by the repos service.
112+ async fn repo(&self, path: &RepoPath, viewer: &Viewer) -> Result<Outcome<Repo>> {
64113 g1t_kit::call(
65114 &self.repos,
66115 "get",
72121 .await
73122 }
74123
75− async fn repo_by_id(&self, id: &str, viewer: &Viewer) -> Result<Outcome<Repo>> {
76− g1t_kit::call(
77− &self.repos,
78− "get_by_id",
79− &GetByIdArgs {
80− id: id.to_owned(),
81− viewer: viewer.clone(),
82− },
83− )
84− .await
124+ /// The next number in the repository's sequence. Taking it is one
125+ /// statement, so concurrent opens cannot be given the same number.
126+ async fn next_number(&self, repo_id: &str) -> Result<u32> {
127+ let row = self
128+ .db
129+ .prepare(
130+ "INSERT INTO counters (repo_id, last) VALUES (?, 1)
131+ ON CONFLICT (repo_id) DO UPDATE SET last = last + 1
132+ RETURNING last AS n",
133+ )
134+ .bind(&[repo_id.into()])?
135+ .first::<NumberRow>(None)
136+ .await?;
137+ row.map(|row| row.n)
138+ .ok_or_else(|| worker::Error::RustError("no number was assigned".into()))
85139 }
86140
87− async fn intent_by_id(&self, id: &str) -> Result<Option<Intent>> {
141+ async fn issue(&self, repo_id: &str, number: u32) -> Result<Option<Issue>> {
88142 Ok(self
89143 .db
90− .prepare(format!("SELECT {INTENT_COLUMNS} FROM intents WHERE id = ?"))
91− .bind(&[id.into()])?
92− .first::<IntentRow>(None)
144+ .prepare(format!(
145+ "SELECT {ISSUE_COLUMNS} FROM issues WHERE repo_id = ? AND number = ?"
146+ ))
147+ .bind(&[repo_id.into(), number.into()])?
148+ .first::<IssueRow>(None)
93149 .await?
94− .map(Intent::from))
150+ .map(Issue::from))
95151 }
96152
97− async fn attempt_by_id(&self, id: &str) -> Result<Option<Attempt>> {
153+ async fn pull(&self, repo_id: &str, number: u32) -> Result<Option<Pull>> {
98154 Ok(self
99155 .db
100− .prepare("SELECT * FROM attempts WHERE id = ?")
101− .bind(&[id.into()])?
102− .first::<AttemptRow>(None)
156+ .prepare("SELECT * FROM pulls WHERE repo_id = ? AND number = ?")
157+ .bind(&[repo_id.into(), number.into()])?
158+ .first::<PullRow>(None)
159+ .await?
160+ .map(Pull::from))
161+ }
162+
163+ async fn comments(&self, repo_id: &str, number: u32) -> Result<Vec<Comment>> {
164+ let rows = self
165+ .db
166+ .prepare(
167+ "SELECT * FROM comments WHERE repo_id = ? AND number = ? ORDER BY id LIMIT 500",
168+ )
169+ .bind(&[repo_id.into(), number.into()])?
170+ .all()
103171 .await?
104− .map(Attempt::from))
172+ .results::<CommentRow>()?;
173+ Ok(rows.into_iter().map(Comment::from).collect())
105174 }
106175
107− /// The attempt, if `actor` is the one running it.
108− async fn own_attempt(&self, actor: &User, id: &str) -> Result<Outcome<Attempt>> {
109− let Some(attempt) = self.attempt_by_id(id).await? else {
110− return Ok(no_attempt());
176+ /// The repository and one of its issues, as seen by `viewer`.
177+ async fn issue_at(
178+ &self,
179+ path: &RepoPath,
180+ number: u32,
181+ viewer: &Viewer,
182+ ) -> Result<Outcome<(Repo, Issue)>> {
183+ let Outcome::Ok(repo) = self.repo(path, viewer).await? else {
184+ return Ok(no_issue());
111185 };
112− // Reading it must be allowed before "forbidden" may reveal it exists.
113− let viewer = Some(actor.clone());
114− if let Outcome::Fail(_) = self.repo_by_id(&attempt.repo_id, &viewer).await? {
115− return Ok(no_attempt());
116− }
117− if attempt.started_by.id != actor.id {
118− return Ok(Outcome::fail(
119− FailureCode::Forbidden,
120− "Only the person who started an attempt can change it.",
121− ));
186+ Ok(match self.issue(&repo.id, number).await? {
187+ Some(issue) => Outcome::Ok((repo, issue)),
188+ None => no_issue(),
189+ })
190+ }
191+
192+ /// The repository and one of its pull requests, as seen by `viewer`.
193+ async fn pull_at(
194+ &self,
195+ path: &RepoPath,
196+ number: u32,
197+ viewer: &Viewer,
198+ ) -> Result<Outcome<(Repo, Pull)>> {
199+ let Outcome::Ok(repo) = self.repo(path, viewer).await? else {
200+ return Ok(no_pull());
201+ };
202+ Ok(match self.pull(&repo.id, number).await? {
203+ Some(pull) => Outcome::Ok((repo, pull)),
204+ None => no_pull(),
205+ })
206+ }
207+
208+ fn issue_event(issue: &Issue) -> IssueEvent {
209+ IssueEvent {
210+ issue_id: issue.id.clone(),
211+ repo_id: issue.repo_id.clone(),
212+ number: issue.number,
213+ ..IssueEvent::default()
122214 }
123− Ok(Outcome::Ok(attempt))
124215 }
125216
126− fn attempt_event(attempt: &Attempt) -> AttemptEvent {
127− AttemptEvent {
128− attempt_id: attempt.id.clone(),
129− intent_id: attempt.intent_id.clone(),
130− repo_id: attempt.repo_id.clone(),
131− ..AttemptEvent::default()
217+ fn pull_event(pull: &Pull) -> PullEvent {
218+ PullEvent {
219+ pull_id: pull.id.clone(),
220+ repo_id: pull.repo_id.clone(),
221+ number: pull.number,
222+ issue: pull.issue,
223+ ..PullEvent::default()
132224 }
133225 }
134226
135− async fn open_intent(&self, a: OpenIntentArgs) -> Result<Outcome<Intent>> {
227+ // --- Issues ------------------------------------------------------------
228+
229+ async fn open_issue(&self, a: OpenIssueArgs) -> Result<Outcome<Issue>> {
136230 if !a.actor.verified {
137231 return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED));
138232 }
139− let title = a.title.trim();
140− if title.is_empty() {
233+ let title = match valid_title(&a.title) {
234+ Ok(title) => title,
235+ Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
236+ };
237+ let Some(labels) = normalize_labels(&a.labels) else {
141238 return Ok(Outcome::fail(
142239 FailureCode::Invalid,
143− "An intent needs a title.",
240+ "An issue can have up to 10 labels of up to 40 characters each.",
144241 ));
145− }
146− let repo = match self.repo_by_path(&a.repo, &Some(a.actor.clone())).await? {
147− Outcome::Ok(repo) => repo,
148− Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
149242 };
243+ let repo = check!(self.repo(&a.repo, &Some(a.actor.clone())).await?);
150244 let checks: Vec<&str> = a
151245 .checks
152246 .iter()
155249 .collect();
156250
157251 let now = now_ms();
158− let id = new_id("int", now);
159− // Numbering and insert are one statement, so concurrent opens on the
160− // same repo cannot take the same number.
252+ let id = new_id("iss", now);
253+ let number = self.next_number(&repo.id).await?;
254+ let timestamp = rfc3339(now);
161255 self.db
162256 .prepare(
163− "INSERT INTO intents
164− (id, repo_id, number, title, brief, checks, author_id, author_name, created_at)
165− SELECT ?, ?, COALESCE(MAX(number), 0) + 1, ?, ?, ?, ?, ?, ?
166− FROM intents WHERE repo_id = ?",
257+ "INSERT INTO issues
258+ (id, repo_id, number, title, body, labels, checks, author_id, author_name,
259+ created_at, updated_at)
260+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
167261 )
168262 .bind(&[
169263 id.as_str().into(),
170264 repo.id.as_str().into(),
265+ number.into(),
171266 title.into(),
172− a.brief.trim().into(),
267+ a.body.trim().into(),
268+ serde_json::to_string(&labels)?.into(),
173269 serde_json::to_string(&checks)?.into(),
174270 a.actor.id.as_str().into(),
175271 a.actor.username.as_str().into(),
176− rfc3339(now).into(),
177− repo.id.as_str().into(),
272+ timestamp.as_str().into(),
273+ timestamp.as_str().into(),
178274 ])?
179275 .run()
180276 .await?;
181− let Some(intent) = self.intent_by_id(&id).await? else {
182− return Ok(no_intent());
277+ let Some(issue) = self.issue(&repo.id, number).await? else {
278+ return Ok(no_issue());
183279 };
184− self.publish(NewEvent {
185− kind: "intent.opened",
186− source: SOURCE,
187− repo_id: Some(intent.repo_id.clone()),
188− actor: Some(a.actor.id),
189− data: IntentOpened {
190− intent_id: intent.id.clone(),
191− repo_id: intent.repo_id.clone(),
192− number: intent.number,
193− title: intent.title.clone(),
280+ self.publish(
281+ "issue.opened",
282+ &repo.id,
283+ &a.actor,
284+ IssueEvent {
285+ title: Some(issue.title.clone()),
286+ ..Self::issue_event(&issue)
194287 },
195− })
288+ )
196289 .await?;
197− Ok(Outcome::Ok(intent))
290+ Ok(Outcome::Ok(issue))
198291 }
199292
200− async fn list_intents(&self, a: ListIntentsArgs) -> Result<Outcome<Vec<Intent>>> {
201− let repo = match self.repo_by_path(&a.repo, &a.viewer).await? {
202− Outcome::Ok(repo) => repo,
203− Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
204− };
205− let status = a
206− .status
207− .and_then(|status| serde_json::to_value(status).ok())
208− .and_then(|value| value.as_str().map(str::to_owned));
293+ async fn list_issues(&self, a: ListIssuesArgs) -> Result<Outcome<Vec<Issue>>> {
294+ let repo = check!(self.repo(&a.repo, &a.viewer).await?);
295+ let state = state_name(a.state).map_or(JsValue::NULL, JsValue::from);
296+ let label = a
297+ .label
298+ .map(|label| label.trim().to_lowercase())
299+ .filter(|label| !label.is_empty());
209300 let rows = self
210301 .db
211302 .prepare(format!(
212− "SELECT {INTENT_COLUMNS} FROM intents
213− WHERE repo_id = ? AND (? IS NULL OR status = ?)
214− ORDER BY number DESC LIMIT 100"
303+ "SELECT {ISSUE_COLUMNS} FROM issues
304+ WHERE repo_id = ? AND (? IS NULL OR state = ?)
305+ AND (? IS NULL OR EXISTS
306+ (SELECT 1 FROM json_each(issues.labels) WHERE json_each.value = ?))
307+ ORDER BY number DESC LIMIT ?"
215308 ))
216− .bind(&[repo.id.into(), optional(&status), optional(&status)])?
309+ .bind(&[
310+ repo.id.into(),
311+ state.clone(),
312+ state,
313+ optional(&label),
314+ optional(&label),
315+ LIST_PAGE.into(),
316+ ])?
217317 .all()
218318 .await?
219− .results::<IntentRow>()?;
220− Ok(Outcome::Ok(rows.into_iter().map(Intent::from).collect()))
319+ .results::<IssueRow>()?;
320+ Ok(Outcome::Ok(rows.into_iter().map(Issue::from).collect()))
221321 }
222322
223− async fn get_intent(&self, a: GetIntentArgs) -> Result<Outcome<IntentDetail>> {
224− let repo = match self.repo_by_path(&a.repo, &a.viewer).await? {
225− Outcome::Ok(repo) => repo,
226− Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
227− };
228− let row = self
323+ async fn get_issue(&self, a: ViewArgs) -> Result<Outcome<IssueDetail>> {
324+ let (repo, issue) = check!(self.issue_at(&a.repo, a.number, &a.viewer).await?);
325+ let pulls = self
229326 .db
230− .prepare(format!(
231− "SELECT {INTENT_COLUMNS} FROM intents WHERE repo_id = ? AND number = ?"
232− ))
233− .bind(&[repo.id.into(), a.number.into()])?
234− .first::<IntentRow>(None)
235− .await?;
236− let Some(intent) = row.map(Intent::from) else {
237− return Ok(no_intent());
238− };
239− let attempts = self
240− .db
241− .prepare("SELECT * FROM attempts WHERE intent_id = ? ORDER BY number")
242− .bind(&[intent.id.as_str().into()])?
327+ .prepare("SELECT * FROM pulls WHERE issue_id = ? ORDER BY number")
328+ .bind(&[issue.id.as_str().into()])?
243329 .all()
244330 .await?
245− .results::<AttemptRow>()?;
246− Ok(Outcome::Ok(IntentDetail {
247− intent,
248− attempts: attempts.into_iter().map(Attempt::from).collect(),
331+ .results::<PullRow>()?;
332+ Ok(Outcome::Ok(IssueDetail {
333+ comments: self.comments(&repo.id, issue.number).await?,
334+ pulls: pulls.into_iter().map(Pull::from).collect(),
335+ issue,
249336 }))
250337 }
251338
252− async fn withdraw_intent(&self, a: IntentActionArgs) -> Result<Outcome<Intent>> {
253− let Some(mut intent) = self.intent_by_id(&a.intent_id).await? else {
254− return Ok(no_intent());
255− };
256− let repo = match self
257− .repo_by_id(&intent.repo_id, &Some(a.actor.clone()))
258− .await?
259− {
260− Outcome::Ok(repo) => repo,
261− Outcome::Fail(_) => return Ok(no_intent()),
262− };
263− if intent.author.id != a.actor.id && !a.actor.is_member(&repo.namespace) {
339+ /// The issue, if `actor` wrote it or belongs to the repository's workspace.
340+ async fn manageable_issue(
341+ &self,
342+ actor: &User,
343+ path: &RepoPath,
344+ number: u32,
345+ ) -> Result<Outcome<Issue>> {
346+ let (repo, issue) = check!(self.issue_at(path, number, &Some(actor.clone())).await?);
347+ if issue.author.id != actor.id && !actor.is_member(&repo.namespace) {
264348 return Ok(Outcome::fail(
265349 FailureCode::Forbidden,
266− "Only the author or a member of the workspace can withdraw an intent.",
350+ "Only the author or a member of the workspace can change an issue.",
267351 ));
268352 }
269− if intent.status != IntentStatus::Open {
353+ Ok(Outcome::Ok(issue))
354+ }
355+
356+ async fn update_issue(&self, a: UpdateIssueArgs) -> Result<Outcome<Issue>> {
357+ let issue = check!(self.manageable_issue(&a.actor, &a.repo, a.number).await?);
358+ let title = match a.title.as_deref().map(valid_title) {
359+ Some(Err(message)) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
360+ Some(Ok(title)) => Some(title.to_owned()),
361+ None => None,
362+ };
363+ let labels = match a.labels.as_deref().map(normalize_labels) {
364+ Some(None) => {
365+ return Ok(Outcome::fail(
366+ FailureCode::Invalid,
367+ "An issue can have up to 10 labels of up to 40 characters each.",
368+ ));
369+ }
370+ Some(Some(labels)) => Some(serde_json::to_string(&labels)?),
371+ None => None,
372+ };
373+ let body = a.body.map(|body| body.trim().to_owned());
374+ self.db
375+ .prepare(
376+ "UPDATE issues
377+ SET title = COALESCE(?, title), body = COALESCE(?, body),
378+ labels = COALESCE(?, labels), updated_at = ?
379+ WHERE id = ?",
380+ )
381+ .bind(&[
382+ optional(&title),
383+ optional(&body),
384+ optional(&labels),
385+ rfc3339(now_ms()).into(),
386+ issue.id.as_str().into(),
387+ ])?
388+ .run()
389+ .await?;
390+ let Some(issue) = self.issue(&issue.repo_id, issue.number).await? else {
391+ return Ok(no_issue());
392+ };
393+ self.publish(
394+ "issue.updated",
395+ &issue.repo_id,
396+ &a.actor,
397+ Self::issue_event(&issue),
398+ )
399+ .await?;
400+ Ok(Outcome::Ok(issue))
401+ }
402+
403+ async fn close_issue(&self, a: IssueActionArgs) -> Result<Outcome<Issue>> {
404+ let mut issue = check!(self.manageable_issue(&a.actor, &a.repo, a.number).await?);
405+ if issue.state == State::Closed {
270406 return Ok(Outcome::fail(
271407 FailureCode::Conflict,
272− "This intent is already closed.",
408+ "This issue is already closed.",
273409 ));
274410 }
411+ let reason = a.reason.unwrap_or(IssueReason::Completed);
412+ let now = rfc3339(now_ms());
275413 self.db
276− .prepare("UPDATE intents SET status = 'withdrawn' WHERE id = ?")
277− .bind(&[intent.id.as_str().into()])?
414+ .prepare(
415+ "UPDATE issues SET state = 'closed', reason = ?, closed_at = ?, updated_at = ?
416+ WHERE id = ?",
417+ )
418+ .bind(&[
419+ reason.as_str().into(),
420+ now.as_str().into(),
421+ now.as_str().into(),
422+ issue.id.as_str().into(),
423+ ])?
278424 .run()
279425 .await?;
280− self.publish(NewEvent {
281− kind: "intent.closed",
282− source: SOURCE,
283− repo_id: Some(intent.repo_id.clone()),
284− actor: Some(a.actor.id),
285− data: IntentClosed {
286− intent_id: intent.id.clone(),
287− repo_id: intent.repo_id.clone(),
288− reason: "withdrawn",
426+ self.publish(
427+ "issue.closed",
428+ &issue.repo_id,
429+ &a.actor,
430+ IssueEvent {
431+ reason: Some(reason.as_str()),
432+ ..Self::issue_event(&issue)
289433 },
290− })
434+ )
291435 .await?;
292− intent.status = IntentStatus::Withdrawn;
293− Ok(Outcome::Ok(intent))
436+ issue.state = State::Closed;
437+ issue.reason = Some(reason);
438+ issue.closed_at = Some(now.clone());
439+ issue.updated_at = now;
440+ Ok(Outcome::Ok(issue))
441+ }
442+
443+ async fn reopen_issue(&self, a: IssueActionArgs) -> Result<Outcome<Issue>> {
444+ let mut issue = check!(self.manageable_issue(&a.actor, &a.repo, a.number).await?);
445+ if issue.state == State::Open {
446+ return Ok(Outcome::fail(
447+ FailureCode::Conflict,
448+ "This issue is already open.",
449+ ));
450+ }
451+ let now = rfc3339(now_ms());
452+ self.db
453+ .prepare(
454+ "UPDATE issues
455+ SET state = 'open', reason = NULL, resolved_by = NULL, closed_at = NULL,
456+ updated_at = ?
457+ WHERE id = ?",
458+ )
459+ .bind(&[now.as_str().into(), issue.id.as_str().into()])?
460+ .run()
461+ .await?;
462+ self.publish(
463+ "issue.reopened",
464+ &issue.repo_id,
465+ &a.actor,
466+ Self::issue_event(&issue),
467+ )
468+ .await?;
469+ issue.state = State::Open;
470+ issue.reason = None;
471+ issue.resolved_by = None;
472+ issue.closed_at = None;
473+ issue.updated_at = now;
474+ Ok(Outcome::Ok(issue))
294475 }
295476
296− async fn start_attempt(&self, a: StartAttemptArgs) -> Result<Outcome<Attempt>> {
477+ /// The default labels, then every other label in use on the repository.
478+ async fn list_labels(&self, a: ViewArgs) -> Result<Outcome<Vec<String>>> {
479+ let repo = check!(self.repo(&a.repo, &a.viewer).await?);
480+ let used = self
481+ .db
482+ .prepare(
483+ "SELECT DISTINCT json_each.value AS value
484+ FROM issues, json_each(issues.labels)
485+ WHERE issues.repo_id = ? ORDER BY 1 LIMIT 200",
486+ )
487+ .bind(&[repo.id.into()])?
488+ .all()
489+ .await?
490+ .results::<ValueRow>()?;
491+ let mut labels: Vec<String> = DEFAULT_LABELS.iter().map(|label| (*label).into()).collect();
492+ for row in used {
493+ if !labels.contains(&row.value) {
494+ labels.push(row.value);
495+ }
496+ }
497+ Ok(Outcome::Ok(labels))
498+ }
499+
500+ async fn counts(&self, a: ViewArgs) -> Result<Outcome<Counts>> {
501+ let repo = check!(self.repo(&a.repo, &a.viewer).await?);
502+ let counts = self
503+ .db
504+ .prepare(
505+ "SELECT
506+ (SELECT count(*) FROM issues WHERE repo_id = ? AND state = 'open') AS issues,
507+ (SELECT count(*) FROM pulls
508+ WHERE repo_id = ? AND status IN ('draft', 'open')) AS pulls",
509+ )
510+ .bind(&[repo.id.as_str().into(), repo.id.as_str().into()])?
511+ .first::<Counts>(None)
512+ .await?;
513+ Ok(Outcome::Ok(counts.unwrap_or(Counts {
514+ issues: 0,
515+ pulls: 0,
516+ })))
517+ }
518+
519+ // --- Comments ----------------------------------------------------------
520+
521+ async fn add_comment(&self, a: AddCommentArgs) -> Result<Outcome<Comment>> {
297522 if !a.actor.verified {
298523 return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED));
299524 }
300− let Some(intent) = self.intent_by_id(&a.intent_id).await? else {
301− return Ok(no_intent());
302− };
303− if intent.status != IntentStatus::Open {
525+ let body = a.body.trim();
526+ if body.is_empty() {
527+ return Ok(Outcome::fail(
528+ FailureCode::Invalid,
529+ "A comment cannot be empty.",
530+ ));
531+ }
532+ if body.chars().count() > MAX_ENTRY_CHARS {
533+ return Ok(Outcome::fail(
534+ FailureCode::Invalid,
535+ "That comment is too long.",
536+ ));
537+ }
538+ let repo = check!(self.repo(&a.repo, &Some(a.actor.clone())).await?);
539+ // The number names an issue or a pull request, never both.
540+ let table = if self.issue(&repo.id, a.number).await?.is_some() {
541+ "issues"
542+ } else if self.pull(&repo.id, a.number).await?.is_some() {
543+ "pulls"
544+ } else {
304545 return Ok(Outcome::fail(
305− FailureCode::Conflict,
306− "This intent is already closed.",
546+ FailureCode::NotFound,
547+ "No issue or pull request has that number.",
307548 ));
549+ };
550+
551+ let now = now_ms();
552+ let comment = Comment {
553+ id: new_id("cmt", now),
554+ author: a.actor.clone(),
555+ body: body.to_owned(),
556+ created_at: rfc3339(now),
557+ };
558+ self.db
559+ .batch(vec![
560+ self.db
561+ .prepare(
562+ "INSERT INTO comments
563+ (id, repo_id, number, author_id, author_name, body, created_at)
564+ VALUES (?, ?, ?, ?, ?, ?, ?)",
565+ )
566+ .bind(&[
567+ comment.id.as_str().into(),
568+ repo.id.as_str().into(),
569+ a.number.into(),
570+ a.actor.id.as_str().into(),
571+ a.actor.username.as_str().into(),
572+ body.into(),
573+ comment.created_at.as_str().into(),
574+ ])?,
575+ self.db
576+ .prepare(format!(
577+ "UPDATE {table} SET updated_at = ? WHERE repo_id = ? AND number = ?"
578+ ))
579+ .bind(&[
580+ comment.created_at.as_str().into(),
581+ repo.id.as_str().into(),
582+ a.number.into(),
583+ ])?,
584+ ])
585+ .await?;
586+ self.publish(
587+ "comment.created",
588+ &repo.id,
589+ &a.actor,
590+ CommentCreated {
591+ comment_id: comment.id.clone(),
592+ repo_id: repo.id.clone(),
593+ number: a.number,
594+ },
595+ )
596+ .await?;
597+ Ok(Outcome::Ok(comment))
598+ }
599+
600+ // --- Pull requests -----------------------------------------------------
601+
602+ async fn open_pull(&self, a: OpenPullArgs) -> Result<Outcome<Pull>> {
603+ if !a.actor.verified {
604+ return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED));
308605 }
606+ let repo = check!(self.repo(&a.repo, &Some(a.actor.clone())).await?);
607+ let issue = match a.issue {
608+ Some(number) => match self.issue(&repo.id, number).await? {
609+ Some(issue) if issue.state == State::Open => Some(issue),
610+ Some(_) => {
611+ return Ok(Outcome::fail(
612+ FailureCode::Conflict,
613+ "This issue is closed.",
614+ ));
615+ }
616+ None => return Ok(no_issue()),
617+ },
618+ None => None,
619+ };
620+ // A pull request for an issue takes the issue's title unless given one.
621+ let title = match (a.title.trim(), &issue) {
622+ ("", Some(issue)) => issue.title.clone(),
623+ (title, _) => match valid_title(title) {
624+ Ok(title) => title.to_owned(),
625+ Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
626+ },
627+ };
309628 let agent = match a.agent.trim() {
310629 "" => "agent",
311630 agent => agent,
312631 };
313632 let runtime = match a.runtime {
314− AttemptRuntime::Hosted => "hosted",
315− AttemptRuntime::External => "external",
633+ Runtime::Hosted => "hosted",
634+ Runtime::External => "external",
316635 };
317636
318637 let now = now_ms();
319− let id = new_id("att", now);
638+ let id = new_id("pr", now);
320639 let fork: Outcome<Repo> = g1t_kit::call(
321640 &self.repos,
322− "fork_for_attempt",
641+ "fork_for_pull",
323642 &ForkArgs {
324− source_id: intent.repo_id.clone(),
325− attempt_id: id.clone(),
643+ source_id: repo.id.clone(),
644+ pull_id: id.clone(),
326645 actor: a.actor.clone(),
327646 },
328647 )
329648 .await?;
330− let fork = match fork {
331− Outcome::Ok(fork) => fork,
332− // The repo is not visible to this actor, so neither is the intent.
333− Outcome::Fail(failure) if failure.code == FailureCode::NotFound => {
334− return Ok(no_intent());
335− }
336− Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
337− };
649+ let fork = check!(fork);
338650
651+ let number = self.next_number(&repo.id).await?;
339652 let timestamp = rfc3339(now);
340653 self.db
341654 .prepare(
342− "INSERT INTO attempts
343− (id, intent_id, repo_id, number, agent, runtime, fork_repo_id,
344− fork_namespace, fork_name, started_by_id, started_by_name,
655+ "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,
345658 created_at, updated_at)
346− SELECT ?, ?, ?, COALESCE(MAX(number), 0) + 1, ?, ?, ?, ?, ?, ?, ?, ?, ?
347− FROM attempts WHERE intent_id = ?",
659+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
348660 )
349661 .bind(&[
350662 id.as_str().into(),
351− intent.id.as_str().into(),
352− intent.repo_id.as_str().into(),
663+ repo.id.as_str().into(),
664+ number.into(),
665+ optional(&issue.as_ref().map(|issue| issue.id.clone())),
666+ optional_number(issue.as_ref().map(|issue| issue.number)),
667+ title.into(),
353668 agent.into(),
354669 runtime.into(),
355670 fork.id.into(),
359674 a.actor.username.as_str().into(),
360675 timestamp.as_str().into(),
361676 timestamp.as_str().into(),
362− intent.id.as_str().into(),
363677 ])?
364678 .run()
365679 .await?;
366− let Some(attempt) = self.attempt_by_id(&id).await? else {
367− return Ok(no_attempt());
680+ let Some(pull) = self.pull(&repo.id, number).await? else {
681+ return Ok(no_pull());
368682 };
369− self.publish(NewEvent {
370− kind: "attempt.started",
371− source: SOURCE,
372− repo_id: Some(attempt.repo_id.clone()),
373− actor: Some(a.actor.id),
374− data: AttemptEvent {
375− agent: Some(attempt.agent.clone()),
376− ..Self::attempt_event(&attempt)
683+ self.publish(
684+ "pull.opened",
685+ &repo.id,
686+ &a.actor,
687+ PullEvent {
688+ agent: Some(pull.agent.clone()),
689+ ..Self::pull_event(&pull)
377690 },
378− })
691+ )
379692 .await?;
380− Ok(Outcome::Ok(attempt))
693+ Ok(Outcome::Ok(pull))
381694 }
382695
383− async fn get_attempt(&self, a: AttemptViewArgs) -> Result<Outcome<AttemptDetail>> {
384− let Some(attempt) = self.attempt_by_id(&a.attempt_id).await? else {
385− return Ok(no_attempt());
696+ async fn list_pulls(&self, a: ListPullsArgs) -> Result<Outcome<Vec<Pull>>> {
697+ let repo = check!(self.repo(&a.repo, &a.viewer).await?);
698+ let filter = match a.state {
699+ Some(State::Open) => "AND status IN ('draft', 'open')",
700+ Some(State::Closed) => "AND status IN ('merged', 'closed')",
701+ None => "",
386702 };
387− if let Outcome::Fail(_) = self.repo_by_id(&attempt.repo_id, &a.viewer).await? {
388− return Ok(no_attempt());
389− }
390− let Some(intent) = self.intent_by_id(&attempt.intent_id).await? else {
391− return Ok(no_attempt());
703+ let rows = self
704+ .db
705+ .prepare(format!(
706+ "SELECT * FROM pulls WHERE repo_id = ? {filter} ORDER BY number DESC LIMIT ?"
707+ ))
708+ .bind(&[repo.id.into(), LIST_PAGE.into()])?
709+ .all()
710+ .await?
711+ .results::<PullRow>()?;
712+ Ok(Outcome::Ok(rows.into_iter().map(Pull::from).collect()))
713+ }
714+
715+ async fn get_pull(&self, a: ViewArgs) -> Result<Outcome<PullDetail>> {
716+ let (repo, pull) = check!(self.pull_at(&a.repo, a.number, &a.viewer).await?);
717+ let issue = match pull.issue {
718+ Some(number) => self.issue(&repo.id, number).await?,
719+ None => None,
392720 };
393− Ok(Outcome::Ok(AttemptDetail { attempt, intent }))
721+ Ok(Outcome::Ok(PullDetail {
722+ comments: self.comments(&repo.id, pull.number).await?,
723+ issue,
724+ pull,
725+ }))
394726 }
395727
396− /// Submits or abandons an attempt on behalf of the one running it.
397− async fn close_attempt(
728+ /// The pull request, if it is still active and `actor` opened it or
729+ /// belongs to the repository's workspace.
730+ async fn manageable_pull(
398731 &self,
399− a: AttemptActionArgs,
400− status: AttemptStatus,
401− ) -> Result<Outcome<Attempt>> {
402− let mut attempt = match self.own_attempt(&a.actor, &a.attempt_id).await? {
403− Outcome::Ok(attempt) => attempt,
404− failed => return Ok(failed),
405− };
406− if !attempt.status.is_active() {
732+ actor: &User,
733+ path: &RepoPath,
734+ number: u32,
735+ ) -> Result<Outcome<Pull>> {
736+ let (repo, pull) = check!(self.pull_at(path, number, &Some(actor.clone())).await?);
737+ if pull.author.id != actor.id && !actor.is_member(&repo.namespace) {
407738 return Ok(Outcome::fail(
739+ FailureCode::Forbidden,
740+ "Only whoever opened a pull request, or a member of the workspace, can change it.",
741+ ));
742+ }
743+ if !pull.status.is_active() {
744+ return Ok(Outcome::fail(
408745 FailureCode::Conflict,
409− format!("This attempt is already {}.", attempt.status.as_str()),
746+ format!("This pull request is already {}.", pull.status.as_str()),
410747 ));
411748 }
749+ Ok(Outcome::Ok(pull))
750+ }
751+
752+ /// Marks a draft ready for review, or updates the description of one
753+ /// that already is.
754+ async fn ready_pull(&self, a: PullActionArgs) -> Result<Outcome<Pull>> {
755+ let mut pull = check!(self.manageable_pull(&a.actor, &a.repo, a.number).await?);
412756 let summary = Some(a.summary.trim().to_owned()).filter(|summary| !summary.is_empty());
413757 let now = rfc3339(now_ms());
414758 self.db
415759 .prepare(
416− "UPDATE attempts SET status = ?, summary = COALESCE(?, summary), updated_at = ?
760+ "UPDATE pulls SET status = 'open', body = COALESCE(?, body), updated_at = ?
417761 WHERE id = ?",
418762 )
419763 .bind(&[
420− status.as_str().into(),
421764 optional(&summary),
422765 now.as_str().into(),
423− attempt.id.as_str().into(),
766+ pull.id.as_str().into(),
424767 ])?
425768 .run()
426769 .await?;
427− let submitted = status == AttemptStatus::Submitted;
428− self.publish(NewEvent {
429− kind: if submitted {
430− "attempt.submitted"
431− } else {
432− "attempt.updated"
433− },
434− source: SOURCE,
435− repo_id: Some(attempt.repo_id.clone()),
436− actor: Some(a.actor.id),
437− data: AttemptEvent {
438− status: (!submitted).then_some(status.as_str()),
439− ..Self::attempt_event(&attempt)
440− },
441− })
770+ if pull.status == PullStatus::Draft {
771+ self.publish(
772+ "pull.ready",
773+ &pull.repo_id,
774+ &a.actor,
775+ Self::pull_event(&pull),
776+ )
777+ .await?;
778+ }
779+ pull.status = PullStatus::Open;
780+ pull.body = summary.or(pull.body);
781+ pull.updated_at = now;
782+ Ok(Outcome::Ok(pull))
783+ }
784+
785+ async fn close_pull(&self, a: PullActionArgs) -> Result<Outcome<Pull>> {
786+ let mut pull = check!(self.manageable_pull(&a.actor, &a.repo, a.number).await?);
787+ let now = rfc3339(now_ms());
788+ self.db
789+ .prepare("UPDATE pulls SET status = 'closed', updated_at = ? WHERE id = ?")
790+ .bind(&[now.as_str().into(), pull.id.as_str().into()])?
791+ .run()
792+ .await?;
793+ self.publish(
794+ "pull.closed",
795+ &pull.repo_id,
796+ &a.actor,
797+ Self::pull_event(&pull),
798+ )
442799 .await?;
443− attempt.status = status;
444− attempt.summary = summary.or(attempt.summary);
445− attempt.updated_at = now;
446− Ok(Outcome::Ok(attempt))
800+ pull.status = PullStatus::Closed;
801+ pull.updated_at = now;
802+ Ok(Outcome::Ok(pull))
447803 }
448804
449− async fn ship_attempt(&self, a: AttemptActionArgs) -> Result<Outcome<Attempt>> {
450− let Some(mut attempt) = self.attempt_by_id(&a.attempt_id).await? else {
451− return Ok(no_attempt());
452− };
453− // Whether the actor may see and write the repo is decided by repos.
805+ /// Lands the pull request on the repository's default branch. Unless
806+ /// told to keep it open, that resolves the issue it was for: the issue
807+ /// closes naming this pull request, and the others still in progress
808+ /// for it close as superseded.
809+ async fn merge_pull(&self, a: PullActionArgs) -> Result<Outcome<Pull>> {
454810 let viewer = Some(a.actor.clone());
455− if let Outcome::Fail(_) = self.repo_by_id(&attempt.repo_id, &viewer).await? {
456− return Ok(no_attempt());
811+ let (repo, mut pull) = check!(self.pull_at(&a.repo, a.number, &viewer).await?);
812+ match pull.status {
813+ PullStatus::Open => {}
814+ PullStatus::Draft => {
815+ return Ok(Outcome::fail(
816+ FailureCode::Conflict,
817+ "This pull request is still a draft. Mark it ready for review first.",
818+ ));
819+ }
820+ status => {
821+ return Ok(Outcome::fail(
822+ FailureCode::Conflict,
823+ format!("This pull request is already {}.", status.as_str()),
824+ ));
825+ }
457826 }
458− if !attempt.status.is_active() {
459− return Ok(Outcome::fail(
460− FailureCode::Conflict,
461− format!("This attempt is already {}.", attempt.status.as_str()),
462− ));
463− }
464− let Some(intent) = self.intent_by_id(&attempt.intent_id).await? else {
465− return Ok(no_intent());
827+ let issue = match pull.issue {
828+ Some(number) if !a.keep_issue_open => self
829+ .issue(&repo.id, number)
830+ .await?
831+ .filter(|issue| issue.state == State::Open),
832+ _ => None,
466833 };
467− if intent.status != IntentStatus::Open {
468− return Ok(Outcome::fail(
469− FailureCode::Conflict,
470− "This intent is already closed.",
471− ));
472− }
473834
835+ // Whether the actor may write to the repository is decided by repos.
474836 let landed: Outcome<Landed> = g1t_kit::call(
475837 &self.repos,
476838 "land",
477839 &LandArgs {
478− fork_id: attempt.fork_repo_id.clone(),
840+ fork_id: pull.fork_repo_id.clone(),
479841 actor: a.actor.clone(),
480842 },
481843 )
482844 .await?;
483− let landed = match landed {
484− Outcome::Ok(landed) => landed,
485− Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
486− };
845+ let landed = check!(landed);
487846
488847 let now = rfc3339(now_ms());
489− self.db
490− .batch(vec![
848+ let mut statements = vec![
849+ self.db
850+ .prepare(
851+ "UPDATE pulls
852+ SET status = 'merged', head_commit = ?, merge_base = ?, merged_by = ?,
853+ merged_at = ?, updated_at = ?
854+ WHERE id = ?",
855+ )
856+ .bind(&[
857+ landed.commit.as_str().into(),
858+ optional(&landed.previous),
859+ a.actor.username.as_str().into(),
860+ now.as_str().into(),
861+ now.as_str().into(),
862+ pull.id.as_str().into(),
863+ ])?,
864+ ];
865+ if let Some(issue) = &issue {
866+ statements.push(
491867 self.db
492868 .prepare(
493− "UPDATE attempts
494− SET status = 'shipped', head_commit = ?, landed_base = ?, updated_at = ?
869+ "UPDATE issues
870+ SET state = 'closed', reason = 'completed', resolved_by = ?,
871+ closed_at = ?, updated_at = ?
495872 WHERE id = ?",
496873 )
497874 .bind(&[
498− landed.commit.as_str().into(),
499− optional(&landed.previous),
875+ pull.number.into(),
876+ now.as_str().into(),
500877 now.as_str().into(),
501− attempt.id.as_str().into(),
878+ issue.id.as_str().into(),
502879 ])?,
880+ );
881+ statements.push(
503882 self.db
504− .prepare("UPDATE intents SET status = 'shipped' WHERE id = ?")
505− .bind(&[intent.id.as_str().into()])?,
506− ])
507− .await?;
508− self.publish(NewEvent {
509− kind: "attempt.shipped",
510− source: SOURCE,
511− repo_id: Some(attempt.repo_id.clone()),
512− actor: Some(a.actor.id.clone()),
513− data: AttemptEvent {
883+ .prepare(
884+ "UPDATE pulls SET status = 'closed', superseded_by = ?, updated_at = ?
885+ WHERE issue_id = ? AND id != ? AND status IN ('draft', 'open')",
886+ )
887+ .bind(&[
888+ pull.number.into(),
889+ now.as_str().into(),
890+ issue.id.as_str().into(),
891+ pull.id.as_str().into(),
892+ ])?,
893+ );
894+ }
895+ self.db.batch(statements).await?;
896+
897+ self.publish(
898+ "pull.merged",
899+ &repo.id,
900+ &a.actor,
901+ PullEvent {
514902 commit: Some(landed.commit.clone()),
515− ..Self::attempt_event(&attempt)
903+ ..Self::pull_event(&pull)
516904 },
517− })
905+ )
518906 .await?;
519− self.publish(NewEvent {
520− kind: "intent.closed",
521− source: SOURCE,
522− repo_id: Some(attempt.repo_id.clone()),
523− actor: Some(a.actor.id),
524− data: IntentClosed {
525− intent_id: intent.id,
526− repo_id: attempt.repo_id.clone(),
527− reason: "shipped",
528− },
529− })
530− .await?;
907+ if let Some(issue) = &issue {
908+ self.publish(
909+ "issue.closed",
910+ &repo.id,
911+ &a.actor,
912+ IssueEvent {
913+ reason: Some(IssueReason::Completed.as_str()),
914+ resolved_by: Some(pull.number),
915+ ..Self::issue_event(issue)
916+ },
917+ )
918+ .await?;
919+ }
531920
532− attempt.status = AttemptStatus::Shipped;
533− attempt.head_commit = Some(landed.commit);
534− attempt.landed_base = landed.previous;
535− attempt.updated_at = now;
536− Ok(Outcome::Ok(attempt))
921+ pull.status = PullStatus::Merged;
922+ pull.head_commit = Some(landed.commit);
923+ pull.merge_base = landed.previous;
924+ pull.merged_by = Some(a.actor.username);
925+ pull.merged_at = Some(now.clone());
926+ pull.updated_at = now;
927+ Ok(Outcome::Ok(pull))
537928 }
538929
539− async fn list_active_attempts(&self, a: ViewerArgs) -> Result<Vec<AttemptDetail>> {
930+ async fn list_active_pulls(&self, a: ViewerArgs) -> Result<Vec<ActivePull>> {
540931 let Some(viewer) = a.viewer else {
541932 return Ok(Vec::new());
542933 };
543934 let rows = self
544935 .db
545936 .prepare(
546− "SELECT * FROM attempts
547− WHERE started_by_id = ? AND status IN ('working', 'submitted')
937+ "SELECT * FROM pulls
938+ WHERE author_id = ? AND status IN ('draft', 'open')
548939 ORDER BY updated_at DESC LIMIT 50",
549940 )
550941 .bind(&[viewer.id.into()])?
551942 .all()
552943 .await?
553− .results::<AttemptRow>()?;
944+ .results::<PullRow>()?;
554945 let mut active = Vec::with_capacity(rows.len());
555− for attempt in rows.into_iter().map(Attempt::from) {
556− if let Some(intent) = self.intent_by_id(&attempt.intent_id).await? {
557− active.push(AttemptDetail { attempt, intent });
558− }
946+ for pull in rows.into_iter().map(Pull::from) {
947+ let issue = match pull.issue {
948+ Some(number) => self.issue(&pull.repo_id, number).await?,
949+ None => None,
950+ };
951+ active.push(ActivePull { pull, issue });
559952 }
560953 Ok(active)
561954 }
562955
956+ // --- Sessions ----------------------------------------------------------
957+
563958 async fn append_session(&self, a: AppendSessionArgs) -> Result<Outcome<Appended>> {
564959 if a.entries.is_empty() {
565960 return Ok(Outcome::Ok(Appended { count: 0 }));
570965 format!("Send at most {MAX_ENTRY_BATCH} entries at a time."),
571966 ));
572967 }
573− let attempt = match self.own_attempt(&a.actor, &a.attempt_id).await? {
574− Outcome::Ok(attempt) => attempt,
575− Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
576− };
968+ let viewer = Some(a.actor.clone());
969+ let (_, pull) = check!(self.pull_at(&a.repo, a.number, &viewer).await?);
970+ if pull.author.id != a.actor.id {
971+ return Ok(Outcome::fail(
972+ FailureCode::Forbidden,
973+ "Only whoever opened a pull request can record its session.",
974+ ));
975+ }
577976
578977 let now = rfc3339(now_ms());
579978 let count = a.entries.len() as u32;
586985 statements.push(
587986 self.db
588987 .prepare(
589− "INSERT INTO session_entries (attempt_id, seq, kind, text, tool, \"commit\", at)
988+ "INSERT INTO session_entries (pull_id, seq, kind, text, tool, \"commit\", at)
590989 SELECT ?, COALESCE(MAX(seq), 0) + 1, ?, ?, ?, ?, ?
591− FROM session_entries WHERE attempt_id = ?",
990+ FROM session_entries WHERE pull_id = ?",
592991 )
593992 .bind(&[
594− attempt.id.as_str().into(),
993+ pull.id.as_str().into(),
595994 kind.as_str().unwrap_or("note").into(),
596995 text.into(),
597996 optional(&entry.tool),
598− optional(&entry.commit.or_else(|| attempt.head_commit.clone())),
997+ optional(&entry.commit.or_else(|| pull.head_commit.clone())),
599998 now.as_str().into(),
600− attempt.id.as_str().into(),
999+ pull.id.as_str().into(),
6011000 ])?,
6021001 );
6031002 }
6041003 statements.push(
6051004 self.db
606− .prepare("UPDATE attempts SET updated_at = ? WHERE id = ?")
607− .bind(&[now.as_str().into(), attempt.id.as_str().into()])?,
1005+ .prepare("UPDATE pulls SET updated_at = ? WHERE id = ?")
1006+ .bind(&[now.as_str().into(), pull.id.as_str().into()])?,
6081007 );
6091008 self.db.batch(statements).await?;
610− self.publish(NewEvent {
611− kind: "session.appended",
612− source: SOURCE,
613− repo_id: Some(attempt.repo_id.clone()),
614− actor: Some(a.actor.id),
615− data: SessionAppended {
616− attempt_id: attempt.id.clone(),
617− session_id: attempt.id,
1009+ self.publish(
1010+ "session.appended",
1011+ &pull.repo_id,
1012+ &a.actor,
1013+ SessionAppended {
1014+ pull_id: pull.id.clone(),
1015+ repo_id: pull.repo_id.clone(),
1016+ number: pull.number,
6181017 count,
6191018 },
620− })
1019+ )
6211020 .await?;
6221021 Ok(Outcome::Ok(Appended { count }))
6231022 }
6241023
625− async fn read_session(&self, a: AttemptViewArgs) -> Result<Outcome<Vec<SessionEntry>>> {
626− let after_seq = a.after_seq;
627− let attempt_id = a.attempt_id.clone();
628− if let Outcome::Fail(failure) = self.get_attempt(a).await? {
629− return Ok(Outcome::Fail(failure));
630− }
1024+ async fn read_session(&self, a: ViewArgs) -> Result<Outcome<Vec<SessionEntry>>> {
1025+ let (_, pull) = check!(self.pull_at(&a.repo, a.number, &a.viewer).await?);
6311026 let rows = self
6321027 .db
6331028 .prepare(
6341029 "SELECT seq, kind, text, tool, \"commit\", at FROM session_entries
635− WHERE attempt_id = ? AND seq > ? ORDER BY seq LIMIT ?",
1030+ WHERE pull_id = ? AND seq > ? ORDER BY seq LIMIT ?",
6361031 )
637− .bind(&[attempt_id.into(), after_seq.into(), SESSION_PAGE.into()])?
1032+ .bind(&[pull.id.into(), a.after_seq.into(), SESSION_PAGE.into()])?
6381033 .all()
6391034 .await?
6401035 .results::<SessionRow>()?;
6431038 ))
6441039 }
6451040
646− /// A push to an attempt's fork moves that attempt's head.
1041+ /// A push to a pull request's fork moves that pull request's head.
6471042 async fn on_event(&self, event: &Delivered) -> Result<()> {
6481043 if event.kind != "git.push" {
6491044 return Ok(());
6531048 return Ok(());
6541049 };
6551050 self.db
656− .prepare("UPDATE attempts SET head_commit = ?, updated_at = ? WHERE fork_repo_id = ?")
1051+ .prepare(
1052+ "UPDATE pulls SET head_commit = ?, updated_at = ?
1053+ WHERE fork_repo_id = ? AND status IN ('draft', 'open')",
1054+ )
6571055 .bind(&[after.into(), rfc3339(now_ms()).into(), repo_id.into()])?
6581056 .run()
6591057 .await?;
6781076 let work = service(&env)?;
6791077
6801078 match method.as_str() {
681− "open_intent" => reply(&work.open_intent(args(body)?).await?),
682− "list_intents" => reply(&work.list_intents(args(body)?).await?),
683− "get_intent" => reply(&work.get_intent(args(body)?).await?),
684− "withdraw_intent" => reply(&work.withdraw_intent(args(body)?).await?),
685− "start_attempt" => reply(&work.start_attempt(args(body)?).await?),
686− "get_attempt" => reply(&work.get_attempt(args(body)?).await?),
687− "submit_attempt" => reply(
688− &work
689− .close_attempt(args(body)?, AttemptStatus::Submitted)
690− .await?,
691− ),
692− "abandon_attempt" => reply(
693− &work
694− .close_attempt(args(body)?, AttemptStatus::Abandoned)
695− .await?,
696− ),
697− "ship_attempt" => reply(&work.ship_attempt(args(body)?).await?),
698− "list_active_attempts" => reply(&work.list_active_attempts(args(body)?).await?),
1079+ "open_issue" => reply(&work.open_issue(args(body)?).await?),
1080+ "list_issues" => reply(&work.list_issues(args(body)?).await?),
1081+ "get_issue" => reply(&work.get_issue(args(body)?).await?),
1082+ "update_issue" => reply(&work.update_issue(args(body)?).await?),
1083+ "close_issue" => reply(&work.close_issue(args(body)?).await?),
1084+ "reopen_issue" => reply(&work.reopen_issue(args(body)?).await?),
1085+ "list_labels" => reply(&work.list_labels(args(body)?).await?),
1086+ "counts" => reply(&work.counts(args(body)?).await?),
1087+ "add_comment" => reply(&work.add_comment(args(body)?).await?),
1088+ "open_pull" => reply(&work.open_pull(args(body)?).await?),
1089+ "list_pulls" => reply(&work.list_pulls(args(body)?).await?),
1090+ "get_pull" => reply(&work.get_pull(args(body)?).await?),
1091+ "ready_pull" => reply(&work.ready_pull(args(body)?).await?),
1092+ "close_pull" => reply(&work.close_pull(args(body)?).await?),
1093+ "merge_pull" => reply(&work.merge_pull(args(body)?).await?),
1094+ "list_active_pulls" => reply(&work.list_active_pulls(args(body)?).await?),
6991095 "append_session" => reply(&work.append_session(args(body)?).await?),
7001096 "read_session" => reply(&work.read_session(args(body)?).await?),
7011097 _ => Response::error("Unknown method", 404),
+88−35
33 use g1t_contracts::User;
44 use g1t_contracts::repos::RepoPath;
55 use g1t_contracts::work::{
6− Attempt, AttemptRuntime, AttemptStatus, Intent, IntentStatus, SessionEntry, SessionEntryKind,
6+ Comment, Issue, IssueReason, Pull, PullStatus, Runtime, SessionEntry, SessionEntryKind, State,
77 };
88 use serde::Deserialize;
99
10+fn user(id: String, username: String) -> User {
11+ User {
12+ id,
13+ username,
14+ ..User::default()
15+ }
16+}
17+
1018 #[derive(Deserialize)]
11−pub struct IntentRow {
19+pub struct IssueRow {
1220 pub id: String,
1321 pub repo_id: String,
1422 pub number: u32,
1523 pub title: String,
16− pub brief: String,
24+ pub body: String,
25+ /// JSON array of label names.
26+ pub labels: String,
1727 /// JSON array of commands.
1828 pub checks: String,
19− pub status: IntentStatus,
29+ pub state: State,
30+ pub reason: Option<IssueReason>,
31+ pub resolved_by: Option<u32>,
2032 pub author_id: String,
2133 pub author_name: String,
2234 pub created_at: String,
23− pub attempt_count: u32,
35+ pub updated_at: String,
36+ pub closed_at: Option<String>,
37+ pub pull_count: u32,
38+ pub comment_count: u32,
2439 }
2540
26−impl From<IntentRow> for Intent {
27− fn from(row: IntentRow) -> Self {
28− Intent {
41+impl From<IssueRow> for Issue {
42+ fn from(row: IssueRow) -> Self {
43+ Issue {
2944 id: row.id,
3045 repo_id: row.repo_id,
3146 number: row.number,
3247 title: row.title,
33− brief: row.brief,
48+ body: row.body,
49+ labels: serde_json::from_str(&row.labels).unwrap_or_default(),
3450 checks: serde_json::from_str(&row.checks).unwrap_or_default(),
35− status: row.status,
36− author: User {
37− id: row.author_id,
38− username: row.author_name,
39− ..User::default()
40− },
51+ state: row.state,
52+ reason: row.reason,
53+ resolved_by: row.resolved_by,
54+ author: user(row.author_id, row.author_name),
4155 created_at: row.created_at,
42− attempt_count: row.attempt_count,
56+ updated_at: row.updated_at,
57+ closed_at: row.closed_at,
58+ pull_count: row.pull_count,
59+ comment_count: row.comment_count,
4360 }
4461 }
4562 }
4663
4764 #[derive(Deserialize)]
48−pub struct AttemptRow {
65+pub struct PullRow {
4966 pub id: String,
50− pub intent_id: String,
5167 pub repo_id: String,
5268 pub number: u32,
69+ pub issue_number: Option<u32>,
70+ pub title: String,
71+ pub body: Option<String>,
5372 pub agent: String,
54− pub runtime: AttemptRuntime,
55− pub status: AttemptStatus,
56− pub summary: Option<String>,
73+ pub runtime: Runtime,
74+ pub status: PullStatus,
5775 pub fork_repo_id: String,
5876 pub fork_namespace: String,
5977 pub fork_name: String,
6078 pub head_commit: Option<String>,
61− pub landed_base: Option<String>,
62− pub started_by_id: String,
63− pub started_by_name: String,
79+ pub merge_base: Option<String>,
80+ pub merged_by: Option<String>,
81+ pub merged_at: Option<String>,
82+ pub superseded_by: Option<u32>,
83+ pub author_id: String,
84+ pub author_name: String,
6485 pub created_at: String,
6586 pub updated_at: String,
6687 }
6788
68−impl From<AttemptRow> for Attempt {
69− fn from(row: AttemptRow) -> Self {
70− Attempt {
89+impl From<PullRow> for Pull {
90+ fn from(row: PullRow) -> Self {
91+ Pull {
7192 id: row.id,
72− intent_id: row.intent_id,
7393 repo_id: row.repo_id,
7494 number: row.number,
95+ issue: row.issue_number,
96+ title: row.title,
97+ body: row.body,
7598 agent: row.agent,
7699 runtime: row.runtime,
77100 status: row.status,
78− summary: row.summary,
79101 fork: RepoPath {
80102 namespace: row.fork_namespace,
81103 name: row.fork_name,
82104 },
83105 fork_repo_id: row.fork_repo_id,
84106 head_commit: row.head_commit,
85− landed_base: row.landed_base,
86− started_by: User {
87− id: row.started_by_id,
88− username: row.started_by_name,
89− ..User::default()
90− },
107+ merge_base: row.merge_base,
108+ merged_by: row.merged_by,
109+ merged_at: row.merged_at,
110+ superseded_by: row.superseded_by,
111+ author: user(row.author_id, row.author_name),
91112 created_at: row.created_at,
92113 updated_at: row.updated_at,
93114 }
95116 }
96117
97118 #[derive(Deserialize)]
119+pub struct CommentRow {
120+ pub id: String,
121+ pub author_id: String,
122+ pub author_name: String,
123+ pub body: String,
124+ pub created_at: String,
125+}
126+
127+impl From<CommentRow> for Comment {
128+ fn from(row: CommentRow) -> Self {
129+ Comment {
130+ id: row.id,
131+ author: user(row.author_id, row.author_name),
132+ body: row.body,
133+ created_at: row.created_at,
134+ }
135+ }
136+}
137+
138+#[derive(Deserialize)]
98139 pub struct SessionRow {
99140 pub seq: u32,
100141 pub kind: SessionEntryKind,
116157 }
117158 }
118159 }
160+
161+/// A single number selected as `n`.
162+#[derive(Deserialize)]
163+pub struct NumberRow {
164+ pub n: u32,
165+}
166+
167+/// A single string selected as `value`.
168+#[derive(Deserialize)]
169+pub struct ValueRow {
170+ pub value: String,
171+}