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
| 3 | 3 | Git for AI scale: a forge for thousands of agents working on the same code at | |
| 4 | 4 | once, running on Cloudflare Workers and Artifacts. | |
| 5 | 5 | ||
| 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. | |
| 10 | 12 | ||
| 11 | 13 | - Site: <https://g1t.sh> | |
| 12 | − | - Docs: <https://g1t.sh/docs> | |
| 14 | + | - Docs: <https://docs.g1t.sh> | |
| 13 | 15 | - API: <https://api.g1t.sh> · MCP: <https://mcp.g1t.sh> | |
| 14 | 16 | - Plan and design: [docs/PLAN.md](docs/PLAN.md) | |
| 15 | 17 | ||
| 17 | 19 | ||
| 18 | 20 | Working today: | |
| 19 | 21 | ||
| 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. | |
| 27 | 36 | - An event bus: every state change is published, logged and delivered to | |
| 28 | 37 | subscribers. | |
| 29 | 38 | ||
| 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. | |
| 33 | 42 | ||
| 34 | 43 | ## Try it | |
| 35 | 44 | ||
| 36 | 45 | ```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. | |
| 38 | 48 | export G1T_TOKEN=g1t_… | |
| 39 | 49 | ||
| 40 | 50 | # 2. Connect Claude Code. | |
| 41 | 51 | claude mcp add --transport http g1t https://mcp.g1t.sh \ | |
| 42 | 52 | --header "Authorization: Bearer $G1T_TOKEN" | |
| 43 | 53 | ||
| 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. | |
| 45 | 55 | ``` | |
| 46 | 56 | ||
| 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>. | |
| 48 | 59 | ||
| 49 | 60 | ## Layout | |
| 50 | 61 | ||
| 51 | 62 | | Path | What it is | | |
| 52 | 63 | | --- | --- | | |
| 53 | 64 | | `apps/web` | The site: server-rendered React on a Worker. Holds no data. | | |
| 65 | + | | `apps/docs` | The documentation site, with the API explorer. | | |
| 54 | 66 | | `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. | | |
| 58 | 70 | | `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. | | |
| 59 | 73 | | `crates/contracts` | Types and service interfaces for the Rust services. | | |
| 60 | 74 | | `crates/kit` | Plumbing shared by Rust services on Workers. | | |
| 61 | 75 | | `crates/sshd` | Git over SSH, bridged to Artifacts. Not deployed yet. | | |
| 62 | 76 | | `packages/contracts` | The same interfaces for TypeScript callers. | | |
| 77 | + | | `packages/theme` | Design tokens and the logo, shared by the site and the docs. | | |
| 63 | 78 | ||
| 64 | 79 | 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. | |
| 68 | 83 | ||
| 69 | 84 | ## Run your own | |
| 70 | 85 | ||
| 71 | 86 | 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. | |
| 73 | 89 | ||
| 74 | 90 | ```sh | |
| 75 | 91 | npm install | |
| 91 | 107 | ```sh | |
| 92 | 108 | (cd services/identity && npx wrangler deploy) | |
| 93 | 109 | (cd services/repos && npx wrangler deploy) | |
| 110 | + | (cd services/work && npx wrangler deploy) | |
| 94 | 111 | npm run deploy | |
| 112 | + | (cd services/runner && npx wrangler deploy) # optional: g1t agents | |
| 113 | + | (cd apps/docs && npm run deploy) | |
| 95 | 114 | ``` | |
| 96 | 115 | ||
| 97 | 116 | Create the first account by registering on your site, or with |
| 28 | 28 | * the operation's input from the path, query string and JSON body. | |
| 29 | 29 | */ | |
| 30 | 30 | const ROUTES: { | |
| 31 | − | method: "GET" | "POST"; | |
| 31 | + | method: "GET" | "POST" | "PATCH"; | |
| 32 | 32 | path: string; | |
| 33 | 33 | operation: string; | |
| 34 | 34 | input?: (params: Record<string, string>, query: Input, body: Input) => Input; | |
| 38 | 38 | { method: "GET", path: "/v1/repos", operation: "list_repos", input: (_p, q) => ({ query: q.q }) }, | |
| 39 | 39 | { method: "POST", path: "/v1/repos", operation: "create_repo", input: (_p, _q, b) => b }, | |
| 40 | 40 | { 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) }) }, | |
| 44 | 41 | { 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 }, | |
| 53 | 59 | ]; | |
| 54 | 60 | ||
| 55 | 61 | function repo(params: Record<string, string>): Input { | |
| 56 | 62 | return { repo: `${params.owner}/${params.name}` }; | |
| 57 | 63 | } | |
| 58 | 64 | ||
| 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 | + | ||
| 59 | 70 | /** The section of the API reference an operation is listed under. */ | |
| 60 | 71 | function tagFor(operation: string): string { | |
| 61 | 72 | if (operation === "whoami" || operation.includes("workspace")) return "Accounts"; | |
| 62 | 73 | 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"; | |
| 65 | 76 | return "Repositories"; | |
| 66 | 77 | } | |
| 67 | 78 | ||
| 69 | 80 | ||
| 70 | 81 | // The API is called from browsers too: the reference's explorer, and apps | |
| 71 | 82 | // 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 | + | })); | |
| 73 | 88 | ||
| 74 | 89 | // `Authorization: Bearer g1t_…`. A missing token is an anonymous viewer; a | |
| 75 | 90 | // wrong one is rejected so a typo does not silently look signed out. | |
| 170 | 185 | const operation = operationsByName.get(route.operation)!; | |
| 171 | 186 | app.on(route.method, route.path, async (c) => { | |
| 172 | 187 | let body: Input = {}; | |
| 173 | − | if (route.method === "POST") { | |
| 188 | + | if (route.method !== "GET") { | |
| 174 | 189 | try { | |
| 175 | 190 | body = await c.req.json(); | |
| 176 | 191 | } catch { |
| 2 | 2 | ||
| 3 | 3 | /** What the OpenAPI document needs to know about a REST route. */ | |
| 4 | 4 | export type RouteDoc = { | |
| 5 | − | method: "GET" | "POST"; | |
| 5 | + | method: "GET" | "POST" | "PATCH"; | |
| 6 | 6 | path: string; | |
| 7 | 7 | operation: string; | |
| 8 | 8 | tag: string; | |
| 17 | 17 | }; | |
| 18 | 18 | } | |
| 19 | 19 | ||
| 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". */ | |
| 21 | 21 | function title(operation: string): string { | |
| 22 | 22 | if (operation === "whoami") return "Get the current user"; | |
| 23 | 23 | const words = operation.split("_").join(" "); | |
| 209 | 209 | tags: [ | |
| 210 | 210 | { name: "Accounts", description: "Signing in from a tool, and the current user." }, | |
| 211 | 211 | { 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." }, | |
| 215 | 223 | ], | |
| 216 | 224 | paths, | |
| 217 | 225 | components: { |
| 57 | 57 | ||
| 58 | 58 | const BAD_REPO = fail("invalid", 'Give the repository as "owner/name".'); | |
| 59 | 59 | ||
| 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 | + | ||
| 60 | 96 | /** Wraps an operation that needs a signed-in user. */ | |
| 61 | 97 | function authed( | |
| 62 | 98 | run: (env: ApiEnv, user: User, input: Input) => Promise<Result<unknown>>, | |
| 139 | 175 | ), | |
| 140 | 176 | }, | |
| 141 | 177 | { | |
| 142 | − | name: "list_intents", | |
| 178 | + | name: "list_issues", | |
| 143 | 179 | 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.", | |
| 145 | 181 | input: { | |
| 146 | 182 | type: "object", | |
| 147 | 183 | properties: { | |
| 148 | 184 | 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." }, | |
| 150 | 187 | }, | |
| 151 | 188 | required: ["repo"], | |
| 152 | 189 | }, | |
| 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 | + | ), | |
| 165 | 196 | }, | |
| 166 | 197 | { | |
| 167 | − | name: "get_intent", | |
| 198 | + | name: "get_issue", | |
| 168 | 199 | 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 | + | ), | |
| 181 | 205 | }, | |
| 182 | 206 | { | |
| 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.", | |
| 185 | 209 | input: { | |
| 186 | 210 | type: "object", | |
| 187 | 211 | properties: { | |
| 188 | 212 | 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: { | |
| 191 | 215 | 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.", | |
| 193 | 218 | }, | |
| 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 | + | }, | |
| 194 | 225 | checks: { | |
| 195 | 226 | type: "array", | |
| 196 | 227 | 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.", | |
| 198 | 229 | }, | |
| 199 | 230 | }, | |
| 200 | − | required: ["repo", "title", "brief"], | |
| 231 | + | required: ["repo", "title"], | |
| 201 | 232 | }, | |
| 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, { | |
| 206 | 235 | 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 | + | ), | |
| 211 | 314 | }, | |
| 212 | 315 | { | |
| 213 | − | name: "start_attempt", | |
| 316 | + | name: "list_pull_requests", | |
| 214 | 317 | 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.", | |
| 216 | 319 | input: { | |
| 217 | 320 | 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", | |
| 218 | 341 | 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 | + | }, | |
| 220 | 348 | agent: { | |
| 221 | 349 | type: "string", | |
| 222 | 350 | description: 'A label for the agent doing the work, e.g. "claude-code".', | |
| 223 | 351 | }, | |
| 224 | 352 | }, | |
| 225 | − | required: ["intent_id"], | |
| 353 | + | required: ["repo"], | |
| 226 | 354 | }, | |
| 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"), | |
| 229 | 359 | agent: text(input, "agent") || "agent", | |
| 230 | 360 | runtime: "external", | |
| 231 | 361 | }); | |
| 232 | − | if (!started.ok) return started; | |
| 233 | − | const { fork } = started.value; | |
| 362 | + | if (!opened.ok) return opened; | |
| 363 | + | const { fork } = opened.value; | |
| 234 | 364 | return ok({ | |
| 235 | − | attempt: started.value, | |
| 365 | + | pull: opened.value, | |
| 236 | 366 | git: { | |
| 237 | 367 | remote: `https://g1t.sh/${fork.namespace}/${fork.name}.git`, | |
| 238 | 368 | username: user.username, | |
| 242 | 372 | }), | |
| 243 | 373 | }, | |
| 244 | 374 | { | |
| 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 | − | { | |
| 256 | 375 | name: "record_session", | |
| 257 | 376 | 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.", | |
| 259 | 378 | input: { | |
| 260 | 379 | type: "object", | |
| 261 | 380 | properties: { | |
| 262 | − | attempt_id: { type: "string" }, | |
| 381 | + | ...numbered, | |
| 263 | 382 | entries: { | |
| 264 | 383 | type: "array", | |
| 265 | 384 | items: { | |
| 276 | 395 | }, | |
| 277 | 396 | }, | |
| 278 | 397 | }, | |
| 279 | − | required: ["attempt_id", "entries"], | |
| 398 | + | required: ["repo", "number", "entries"], | |
| 280 | 399 | }, | |
| 281 | − | run: authed(async (env, user, input) => { | |
| 400 | + | run: onRepoAs(async (env, path, user, input) => { | |
| 282 | 401 | if (!Array.isArray(input.entries)) { | |
| 283 | 402 | return fail("invalid", "entries must be an array."); | |
| 284 | 403 | } | |
| 285 | 404 | return env.WORK.appendSession( | |
| 286 | 405 | user, | |
| 287 | − | text(input, "attempt_id"), | |
| 406 | + | path, | |
| 407 | + | Number(input.number), | |
| 288 | 408 | input.entries as NewSessionEntry[], | |
| 289 | 409 | ); | |
| 290 | 410 | }), | |
| 291 | 411 | }, | |
| 292 | 412 | { | |
| 293 | 413 | 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.", | |
| 295 | 415 | input: { | |
| 296 | 416 | type: "object", | |
| 297 | 417 | properties: { | |
| 298 | − | attempt_id: { type: "string" }, | |
| 418 | + | ...numbered, | |
| 299 | 419 | after: { type: "integer", description: "Only entries after this sequence number." }, | |
| 300 | 420 | }, | |
| 301 | − | required: ["attempt_id"], | |
| 421 | + | required: ["repo", "number"], | |
| 302 | 422 | }, | |
| 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 | + | ), | |
| 309 | 426 | }, | |
| 310 | 427 | { | |
| 311 | − | name: "submit_attempt", | |
| 428 | + | name: "mark_pull_request_ready", | |
| 312 | 429 | 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.", | |
| 314 | 431 | input: { | |
| 315 | 432 | 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"], | |
| 318 | 435 | }, | |
| 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")), | |
| 321 | 438 | ), | |
| 322 | 439 | }, | |
| 323 | 440 | { | |
| 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)), | |
| 333 | 446 | ), | |
| 334 | 447 | }, | |
| 335 | 448 | { | |
| 336 | − | name: "get_attempt_changes", | |
| 449 | + | name: "get_pull_request_changes", | |
| 337 | 450 | 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); | |
| 346 | 455 | 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 | + | }), | |
| 350 | 459 | }, | |
| 351 | 460 | { | |
| 352 | − | name: "ship_attempt", | |
| 461 | + | name: "merge_pull_request", | |
| 353 | 462 | 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.", | |
| 355 | 464 | input: { | |
| 356 | 465 | 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"], | |
| 359 | 475 | }, | |
| 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), | |
| 362 | 478 | ), | |
| 363 | 479 | }, | |
| 364 | 480 | { | |
| 365 | 481 | name: "list_events", | |
| 366 | 482 | 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.", | |
| 368 | 484 | input: { | |
| 369 | 485 | type: "object", | |
| 370 | 486 | properties: { |
| 1 | 1 | --- | |
| 2 | 2 | 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. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 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. | |
| 10 | 9 | ||
| 11 | 10 | ## The short version | |
| 12 | 11 | ||
| 13 | − | | | Fork per attempt | Branch in the repository | | |
| 12 | + | | | Fork per pull request | Branch in the repository | | |
| 14 | 13 | | --- | --- | --- | | |
| 15 | 14 | | Built for | Agents, in any number | A person, or a few | | |
| 16 | 15 | | 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 | | |
| 18 | 17 | | 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 | | |
| 20 | 19 | ||
| 21 | 20 | ## Why forks, for agents | |
| 22 | 21 | ||
| 28 | 27 | ||
| 29 | 28 | An agent working in a fork holds a credential for that fork. It can rewrite | |
| 30 | 29 | 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. | |
| 33 | 32 | ||
| 34 | − | ### A thousand attempts leave the repository unchanged | |
| 33 | + | ### A thousand pull requests leave the repository unchanged | |
| 35 | 34 | ||
| 36 | 35 | 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. | |
| 40 | 39 | ||
| 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 | |
| 42 | 41 | never looked at again. The repository's own refs stay the handful that | |
| 43 | 42 | describe the project. | |
| 44 | 43 | ||
| 45 | − | ### Each attempt has its own capacity | |
| 44 | + | ### Each pull request has its own capacity | |
| 46 | 45 | ||
| 47 | 46 | Storage and request limits apply per repository. A hundred agents pushing to | |
| 48 | 47 | one repository share one budget and slow each other down. A hundred forks | |
| 56 | 55 | ||
| 57 | 56 | ### Anyone's agent can contribute | |
| 58 | 57 | ||
| 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 | |
| 62 | 61 | contributions from strangers, applied to agents. | |
| 63 | 62 | ||
| 64 | 63 | ## What forks cost | |
| 67 | 66 | ||
| 68 | 67 | - **Falling behind is silent.** A branch sees new commits on `main` at the | |
| 69 | 68 | 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 | |
| 73 | 72 | into the repository before moving `main`. From a branch they are already | |
| 74 | 73 | 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. | |
| 77 | 76 | ||
| 78 | − | ## When to use a branch | |
| 77 | + | ## When a branch is the better choice | |
| 79 | 78 | ||
| 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. | |
| 83 | 82 | ||
| 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. | |
| 86 | 85 | ||
| 87 | 86 | ## How this adds up | |
| 88 | 87 | ||
| 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. |
| 1 | 1 | --- | |
| 2 | 2 | title: Concepts | |
| 3 | − | description: Intents, attempts, shipping, sessions and events. | |
| 3 | + | description: Issues, pull requests, merging, sessions and events. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | 6 | 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. | |
| 9 | 10 | ||
| 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. | | |
| 14 | 16 | ||
| 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. | |
| 16 | 20 | ||
| 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. | |
| 20 | 26 | ||
| 21 | − | An intent has: | |
| 27 | + | An issue has: | |
| 22 | 28 | ||
| 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. | |
| 28 | 39 | ||
| 29 | − | Intents are numbered per repository, like `#12`. | |
| 40 | + | ### Labels | |
| 30 | 41 | ||
| 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. | |
| 32 | 46 | ||
| 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. | |
| 35 | 49 | ||
| 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 | |
| 40 | 51 | ||
| 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. | |
| 43 | 57 | ||
| 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. | |
| 45 | 60 | ||
| 46 | 61 | | Status | Meaning | | |
| 47 | 62 | | --- | --- | | |
| 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. | | |
| 52 | 67 | ||
| 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. | |
| 54 | 70 | ||
| 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 | |
| 58 | 72 | ||
| 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. | |
| 64 | 76 | ||
| 65 | − | ## Session | |
| 77 | + | When you merge one: | |
| 66 | 78 | ||
| 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. | |
| 69 | 84 | ||
| 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 | + | ||
| 70 | 108 | Each session entry is stored with the fork's head commit at the time it was | |
| 71 | 109 | recorded. That link is what lets g1t show the reasoning behind a change | |
| 72 | 110 | rather than only the change. | |
| 76 | 114 | ||
| 77 | 115 | ## Events | |
| 78 | 116 | ||
| 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/). | |
| 83 | 121 | ||
| 84 | 122 | ## What is not built yet | |
| 85 | 123 | ||
| 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: | |
| 88 | 125 | ||
| 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 | |
| 98 | 135 | in a sandbox. This is in preview and limited to selected accounts; anyone | |
| 99 | 136 | can bring their own agent today. |
| 17 | 17 | g1t sends a confirmation link from `noreply@g1t.sh`. It works for 24 hours. | |
| 18 | 18 | ||
| 19 | 19 | 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 | |
| 21 | 21 | message telling you to confirm your address. To get a new link, sign in and | |
| 22 | 22 | use the banner at the top of the site. | |
| 23 | 23 | ||
| 34 | 34 | ||
| 35 | 35 | | Role | Can | | |
| 36 | 36 | | --- | --- | | |
| 37 | − | | Member | Create repositories, push, open intents, ship attempts. | | |
| 37 | + | | Member | Create repositories, push, manage issues, merge pull requests. | | |
| 38 | 38 | | Owner | Everything a member can, and add or remove members. | | |
| 39 | 39 | ||
| 40 | 40 | Manage members on the workspace's page, `g1t.sh/<workspace>`. |
| 15 | 15 | --header "Authorization: Bearer $G1T_TOKEN" | |
| 16 | 16 | ``` | |
| 17 | 17 | ||
| 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. | |
| 20 | 20 | ||
| 21 | − | ## How an agent works on an intent | |
| 21 | + | ## How an agent works on an issue | |
| 22 | 22 | ||
| 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. | |
| 26 | 27 | 3. Clone the fork, make changes, commit and push. Use the access token as the | |
| 27 | 28 | git password. | |
| 28 | 29 | 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. | |
| 30 | 31 | ||
| 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. | |
| 33 | 34 | ||
| 34 | 35 | ## Tools | |
| 35 | 36 | ||
| 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 | + | ||
| 36 | 41 | | Tool | What it does | | |
| 37 | 42 | | --- | --- | | |
| 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. | | |
| 39 | 45 | | `list_repos` | Repositories you can see, optionally filtered by a query. | | |
| 40 | 46 | | `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. | | |
| 47 | 59 | | `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. | | |
| 53 | 65 | | `list_events` | A repository's timeline, newest first. | | |
| 54 | 66 | ||
| 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. | |
| 56 | 80 | ||
| 57 | 81 | ## Session entries | |
| 58 | 82 |
| 1 | 1 | --- | |
| 2 | 2 | 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. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 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. | |
| 9 | 9 | ||
| 10 | 10 | This is in preview and limited to selected accounts. Everyone can | |
| 11 | 11 | [bring their own agent](/guides/bring-your-own-agent/) today. | |
| 12 | 12 | ||
| 13 | − | ## Starting a run | |
| 13 | + | ## Assigning agents | |
| 14 | 14 | ||
| 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**. | |
| 20 | 20 | ||
| 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. | |
| 23 | 23 | ||
| 24 | 24 | ## What an agent does | |
| 25 | 25 | ||
| 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. | |
| 28 | 28 | 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. | |
| 30 | 31 | ||
| 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 | |
| 32 | 33 | **Session** as it happens. The **Changes** tab shows the resulting diff. | |
| 33 | 34 | ||
| 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. | |
| 35 | 37 | ||
| 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 | |
| 39 | 39 | ||
| 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 | + | ||
| 40 | 46 | ## Choosing a model | |
| 41 | 47 | ||
| 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: | |
| 43 | 49 | ||
| 44 | 50 | | Choice | Model today | Use it for | | |
| 45 | 51 | | --- | --- | --- | | |
| 47 | 53 | | **Deep** | Claude Opus 5.5 | Hard problems that need the strongest reasoning. Slower and costlier. | | |
| 48 | 54 | | **Fast** | Claude Haiku 4.5 | Small, well-defined changes. | | |
| 49 | 55 | ||
| 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. | |
| 53 | 59 | ||
| 54 | 60 | 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`. | |
| 57 | 64 | ||
| 58 | 65 | ## How model traffic is routed | |
| 59 | 66 | ||
| 70 | 77 | | `AGENT_MODELS` | The choices offered, in order, each mapped to a provider's model. | | |
| 71 | 78 | | `AI_GATEWAY_ID` | The gateway to route through. Empty sends requests to the provider directly. | | |
| 72 | 79 | ||
| 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 | + | ||
| 73 | 86 | ## Limits in the preview | |
| 74 | 87 | ||
| 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. | |
| 81 | 91 | - A run has two hours. After that its credential expires and it can no | |
| 82 | 92 | longer push or report. | |
| 83 | 93 | ||
| 84 | 94 | ## What a sandbox can reach | |
| 85 | 95 | ||
| 86 | 96 | 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. |
| 45 | 45 | else it looks | |
| 46 | 46 | exactly like a repository that does not exist, both on the site and to git. | |
| 47 | 47 | ||
| 48 | − | ## Attempt forks | |
| 48 | + | ## Pull request forks | |
| 49 | 49 | ||
| 50 | − | Each [attempt](/concepts/overview/) has its own remote: | |
| 50 | + | Each [pull request](/concepts/overview/#pull-requests) has its own remote: | |
| 51 | 51 | ||
| 52 | 52 | ```text | |
| 53 | − | https://g1t.sh/attempts/<attempt id>.git | |
| 53 | + | https://g1t.sh/pulls/<pull request id>.git | |
| 54 | 54 | ``` | |
| 55 | 55 | ||
| 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. | |
| 58 | 58 | ||
| 59 | 59 | ## Limits | |
| 60 | 60 |
| 3 | 3 | description: Guides and reference for g1t, the git forge built for AI scale. | |
| 4 | 4 | template: splash | |
| 5 | 5 | 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. | |
| 7 | 7 | actions: | |
| 8 | 8 | - text: Quickstart | |
| 9 | 9 | link: /quickstart/ | |
| 18 | 18 | ||
| 19 | 19 | <CardGrid> | |
| 20 | 20 | <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 | |
| 22 | 22 | few minutes. | |
| 23 | 23 | ||
| 24 | 24 | [Quickstart](/quickstart/) | |
| 25 | 25 | </Card> | |
| 26 | 26 | <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. | |
| 29 | 29 | ||
| 30 | 30 | [Concepts](/concepts/overview/) | |
| 31 | 31 | </Card> | |
| 32 | 32 | <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. | |
| 35 | 35 | ||
| 36 | 36 | [g1t agents](/guides/g1t-agents/) | |
| 37 | 37 | </Card> |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | 6 | 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. | |
| 9 | 9 | ||
| 10 | 10 | This page takes you from nothing to an agent working on your repository. | |
| 11 | 11 | ||
| 46 | 46 | You can also create an empty repository from the **+** button in the header, | |
| 47 | 47 | and choose its workspace and whether it is public or private. | |
| 48 | 48 | ||
| 49 | − | ## 4. Open an intent | |
| 49 | + | ## 4. Open an issue | |
| 50 | 50 | ||
| 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: | |
| 52 | 52 | ||
| 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. | |
| 56 | 57 | ||
| 57 | 58 | ## 5. Put an agent on it | |
| 58 | 59 | ||
| 63 | 64 | --header "Authorization: Bearer $G1T_TOKEN" | |
| 64 | 65 | ``` | |
| 65 | 66 | ||
| 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. | |
| 67 | 76 | ||
| 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 | |
| 70 | 78 | ||
| 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. | |
| 73 | 82 | ||
| 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 | + | ||
| 74 | 86 | ## Next | |
| 75 | 87 | ||
| 76 | − | - [Concepts](/concepts/overview/) explains intents, attempts and sessions. | |
| 88 | + | - [Concepts](/concepts/overview/) explains issues, pull requests and sessions. | |
| 77 | 89 | - [Connect an agent](/guides/bring-your-own-agent/) lists every tool an agent can call. | |
| 78 | 90 | - [API](/reference/api/) documents the REST endpoints. |
| 51 | 51 | | 409 | `conflict` | The request conflicts with the current state. | | |
| 52 | 52 | | 422 | `invalid` | The input is not valid. | | |
| 53 | 53 | ||
| 54 | − | ## Repositories | |
| 54 | + | ## Accounts and repositories | |
| 55 | + | ||
| 56 | + | In paths, `{owner}` is the workspace that owns the repository. | |
| 55 | 57 | ||
| 56 | 58 | | Method | Path | | | |
| 57 | 59 | | --- | --- | --- | | |
| 60 | + | | `GET` | `/v1/user` | The account the token belongs to, and its workspaces. | | |
| 61 | + | | `POST` | `/v1/workspaces` | Create a workspace. Body: `slug`, `name`. | | |
| 58 | 62 | | `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`. | | |
| 60 | 64 | | `GET` | `/v1/repos/{owner}/{name}` | One repository. | | |
| 61 | 65 | | `GET` | `/v1/repos/{owner}/{name}/events?before=` | Its timeline, newest first. | | |
| 62 | 66 | ||
| 63 | − | ## Intents | |
| 67 | + | ## Issues | |
| 68 | + | ||
| 69 | + | Issues and pull requests share one sequence of numbers per repository. | |
| 64 | 70 | ||
| 65 | 71 | | Method | Path | | | |
| 66 | 72 | | --- | --- | --- | | |
| 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. | | |
| 70 | 81 | ||
| 71 | 82 | ```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 \ | |
| 73 | 84 | -H "Authorization: Bearer $G1T_TOKEN" \ | |
| 74 | 85 | -H "Content-Type: application/json" \ | |
| 75 | 86 | -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"], | |
| 78 | 90 | "checks": ["cargo build"] | |
| 79 | 91 | }' | |
| 80 | 92 | ``` | |
| 81 | 93 | ||
| 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 | |
| 83 | 109 | ||
| 84 | 110 | | Method | Path | | | |
| 85 | 111 | | --- | --- | --- | | |
| 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. | | |
| 92 | 119 | ||
| 93 | − | Starting an attempt returns the fork's git remote: | |
| 120 | + | Opening a pull request returns the git remote of its fork: | |
| 94 | 121 | ||
| 95 | 122 | ```json | |
| 96 | 123 | { | |
| 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" } | |
| 99 | 126 | } | |
| 100 | 127 | ``` | |
| 101 | 128 | ||
| 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 | + | ||
| 102 | 137 | ## Sessions | |
| 103 | 138 | ||
| 104 | 139 | | Method | Path | | | |
| 105 | 140 | | --- | --- | --- | | |
| 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`. | | |
| 108 | 143 | ||
| 109 | 144 | ```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 \ | |
| 111 | 146 | -H "Authorization: Bearer $G1T_TOKEN" \ | |
| 112 | 147 | -H "Content-Type: application/json" \ | |
| 113 | 148 | -d '{"entries": [{"kind": "message", "text": "Reading src/main.rs."}]}' | |
| 115 | 150 | ||
| 116 | 151 | Up to 200 entries can be appended per request. | |
| 117 | 152 | ||
| 118 | − | ## Identifiers | |
| 153 | + | ## Identifiers and times | |
| 119 | 154 | ||
| 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 | |
| 121 | 157 | 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`. |
| 20 | 20 | --color-accent-dim: var(--g1t-accent-dim); | |
| 21 | 21 | ||
| 22 | 22 | --color-info: var(--g1t-info); | |
| 23 | − | --color-shipped: var(--g1t-shipped); | |
| 23 | + | --color-merged: var(--g1t-merged); | |
| 24 | 24 | --color-warn: var(--g1t-warn); | |
| 25 | 25 | --color-danger: var(--g1t-danger); | |
| 26 | 26 |
| 33 | 33 | text='claude mcp add --transport http g1t https://mcp.g1t.sh --header "Authorization: Bearer $G1T_TOKEN"' | |
| 34 | 34 | /> | |
| 35 | 35 | <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{" "} | |
| 37 | 37 | <span className="font-mono text-fg">{path}</span>.{" "} | |
| 38 | 38 | <Link to="https://docs.g1t.sh/guides/bring-your-own-agent/" className="text-fg underline underline-offset-4"> | |
| 39 | 39 | More |
| 137 | 137 | if (files.length === 0) { | |
| 138 | 138 | return ( | |
| 139 | 139 | <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 | |
| 141 | 141 | repository it came from. | |
| 142 | 142 | </EmptyState> | |
| 143 | 143 | ); |
| 43 | 43 | const LANES = [ | |
| 44 | 44 | { y: 60, state: "passed" }, | |
| 45 | 45 | { y: 135, state: "failed" }, | |
| 46 | − | { y: 210, state: "shipped" }, | |
| 46 | + | { y: 210, state: "merged" }, | |
| 47 | 47 | { y: 285, state: "passed" }, | |
| 48 | 48 | { y: 360, state: "working" }, | |
| 49 | 49 | ] as const; | |
| 50 | 50 | ||
| 51 | 51 | /** | |
| 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. | |
| 54 | 54 | */ | |
| 55 | − | export function ArenaIllustration() { | |
| 55 | + | export function HeroIllustration() { | |
| 56 | 56 | return ( | |
| 57 | 57 | <svg | |
| 58 | 58 | viewBox="0 0 1000 420" | |
| 59 | 59 | fill="none" | |
| 60 | 60 | className="w-full" | |
| 61 | 61 | 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." | |
| 63 | 63 | > | |
| 64 | 64 | {/* Faint grid. */} | |
| 65 | 65 | <g stroke={LINE} strokeWidth="1" opacity="0.35"> | |
| 71 | 71 | ))} | |
| 72 | 72 | </g> | |
| 73 | 73 | ||
| 74 | − | {/* The intent. */} | |
| 74 | + | {/* The issue. */} | |
| 75 | 75 | <g> | |
| 76 | 76 | <rect x="24" y="150" width="170" height="120" rx="12" fill={SURFACE} stroke={LINE} /> | |
| 77 | 77 | <text x="42" y="178" fill={MUTED} fontSize="11" fontFamily="var(--font-mono)"> | |
| 78 | − | intent #12 | |
| 78 | + | issue #12 | |
| 79 | 79 | </text> | |
| 80 | 80 | <rect x="42" y="190" width="120" height="7" rx="3.5" fill={FAINT} /> | |
| 81 | 81 | <rect x="42" y="206" width="134" height="5" rx="2.5" fill={LINE} /> | |
| 93 | 93 | const out = `M194 210C250 210 250 ${y} 310 ${y}`; | |
| 94 | 94 | const lane = `M310 ${y}H690`; | |
| 95 | 95 | const back = `M690 ${y}C750 ${y} 750 210 806 210`; | |
| 96 | − | const ships = state === "shipped"; | |
| 96 | + | const merges = state === "merged"; | |
| 97 | 97 | const failed = state === "failed"; | |
| 98 | 98 | const working = state === "working"; | |
| 99 | − | const stroke = ships ? ACCENT : failed ? LINE : FAINT; | |
| 99 | + | const stroke = merges ? ACCENT : failed ? LINE : FAINT; | |
| 100 | 100 | const end = working ? 520 : failed ? 480 : 690; | |
| 101 | 101 | return ( | |
| 102 | 102 | <g key={y}> | |
| 121 | 121 | d={back} | |
| 122 | 122 | stroke={stroke} | |
| 123 | 123 | 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} | |
| 126 | 126 | /> | |
| 127 | 127 | )} | |
| 128 | 128 | ||
| 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} | |
| 131 | 131 | </text> | |
| 132 | 132 | ||
| 133 | 133 | {/* Commits along the lane. */} | |
| 140 | 140 | cy={y} | |
| 141 | 141 | r="4" | |
| 142 | 142 | fill={SURFACE} | |
| 143 | − | stroke={ships ? ACCENT : MUTED} | |
| 143 | + | stroke={merges ? ACCENT : MUTED} | |
| 144 | 144 | strokeWidth="1.5" | |
| 145 | 145 | /> | |
| 146 | 146 | ))} | |
| 164 | 164 | path={`${out}H${end}`} | |
| 165 | 165 | dur={`${3.2 + i * 0.35}s`} | |
| 166 | 166 | begin={`${i * 0.5}s`} | |
| 167 | − | color={ships ? ACCENT : MUTED} | |
| 167 | + | color={merges ? ACCENT : MUTED} | |
| 168 | 168 | /> | |
| 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} />} | |
| 170 | 170 | </g> | |
| 171 | 171 | ); | |
| 172 | 172 | })} | |
| 202 | 202 | return `M${cx} ${cy - h}L${cx + w} ${cy}L${cx} ${cy + h}L${cx - w} ${cy}Z`; | |
| 203 | 203 | } | |
| 204 | 204 | ||
| 205 | − | /** A brief with acceptance checks ticking off. */ | |
| 206 | − | export function IntentIllustration() { | |
| 205 | + | /** An issue with acceptance checks ticking off. */ | |
| 206 | + | export function IssueIllustration() { | |
| 207 | 207 | return ( | |
| 208 | 208 | <svg viewBox="0 0 320 180" fill="none" className="w-full" aria-hidden="true"> | |
| 209 | 209 | <path d="M0 150H320" stroke={LINE} strokeDasharray="2 8" /> | |
| 363 | 363 | ); | |
| 364 | 364 | } | |
| 365 | 365 | ||
| 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() { | |
| 368 | 368 | return ( | |
| 369 | 369 | <svg viewBox="0 0 320 180" fill="none" className="w-full" aria-hidden="true"> | |
| 370 | 370 | <path d="M20 120H300" stroke={ACCENT} strokeWidth="1.5" /> |
| 6 | 6 | ||
| 7 | 7 | import { | |
| 8 | 8 | AgentsIllustration, | |
| 9 | − | ArenaIllustration, | |
| 9 | + | HeroIllustration, | |
| 10 | 10 | EventsIllustration, | |
| 11 | 11 | ForkIllustration, | |
| 12 | − | IntentIllustration, | |
| 12 | + | IssueIllustration, | |
| 13 | 13 | SessionIllustration, | |
| 14 | − | ShipIllustration, | |
| 14 | + | MergeIllustration, | |
| 15 | 15 | } from "./illustrations"; | |
| 16 | 16 | import { RepoList } from "./repo-list"; | |
| 17 | 17 | import { ButtonLink, CopyLine } from "./ui"; | |
| 56 | 56 | const COMPARISON: [string, string, string][] = [ | |
| 57 | 57 | [ | |
| 58 | 58 | "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", | |
| 61 | 61 | ], | |
| 62 | 62 | [ | |
| 63 | 63 | "Where agents work", | |
| 64 | 64 | "Branches and local worktrees", | |
| 65 | − | "A server-side fork per attempt", | |
| 65 | + | "A server-side fork per pull request", | |
| 66 | 66 | ], | |
| 67 | 67 | [ | |
| 68 | 68 | "Why a change was made", | |
| 103 | 103 | </h1> | |
| 104 | 104 | <p className="mx-auto mt-6 max-w-xl animate-fade-up text-lg leading-7 text-muted text-balance"> | |
| 105 | 105 | 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, | |
| 107 | 107 | every decision recorded, every change landed in order. | |
| 108 | 108 | </p> | |
| 109 | 109 | <div className="mt-8 flex animate-fade-up flex-wrap items-center justify-center gap-3"> | |
| 116 | 116 | </ButtonLink> | |
| 117 | 117 | </div> | |
| 118 | 118 | <div className="mx-auto mt-14 max-w-5xl"> | |
| 119 | − | <ArenaIllustration /> | |
| 119 | + | <HeroIllustration /> | |
| 120 | 120 | </div> | |
| 121 | 121 | </div> | |
| 122 | 122 | </section> | |
| 130 | 130 | </h2> | |
| 131 | 131 | <div className="mt-12 grid gap-4 md:grid-cols-6"> | |
| 132 | 132 | <FeatureCard | |
| 133 | − | title="Start with an intent" | |
| 134 | − | illustration={<IntentIllustration />} | |
| 133 | + | title="Start with an issue" | |
| 134 | + | illustration={<IssueIllustration />} | |
| 135 | 135 | > | |
| 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. | |
| 138 | 139 | </FeatureCard> | |
| 139 | 140 | <FeatureCard | |
| 140 | − | title="A fork for every attempt" | |
| 141 | + | title="A fork for every pull request" | |
| 141 | 142 | illustration={<ForkIllustration />} | |
| 142 | 143 | > | |
| 143 | 144 | Each agent gets its own copy of the repository the moment it starts. | |
| 147 | 148 | title="The session stays with the code" | |
| 148 | 149 | illustration={<SessionIllustration />} | |
| 149 | 150 | > | |
| 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. | |
| 152 | 154 | </FeatureCard> | |
| 153 | 155 | <FeatureCard | |
| 154 | 156 | title="Bring any agent" | |
| 160 | 162 | </FeatureCard> | |
| 161 | 163 | <FeatureCard | |
| 162 | 164 | title="Converge on main" | |
| 163 | − | illustration={<ShipIllustration />} | |
| 165 | + | illustration={<MergeIllustration />} | |
| 164 | 166 | wide | |
| 165 | 167 | > | |
| 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. | |
| 169 | 171 | </FeatureCard> | |
| 170 | 172 | </div> | |
| 171 | 173 | </section> | |
| 179 | 181 | One command to put your agent to work | |
| 180 | 182 | </h2> | |
| 181 | 183 | <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 | |
| 184 | 187 | wrapper. | |
| 185 | 188 | </p> | |
| 186 | 189 | <div className="mt-6"> | |
| 198 | 201 | <CopyLine prompt text="git clone https://g1t.sh/syntaqx/g1t.git" /> | |
| 199 | 202 | <CopyLine | |
| 200 | 203 | 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" | |
| 202 | 205 | /> | |
| 203 | 206 | <div className="pt-2"> | |
| 204 | 207 | <EventsIllustration /> |
| 1 | − | /** Three lanes converging on one node: many attempts, one ships. */ | |
| 1 | + | /** Three lanes converging on one node: many pull requests, one merged. */ | |
| 2 | 2 | export function Mark({ className }: { className?: string }) { | |
| 3 | 3 | return ( | |
| 4 | 4 | <svg |
| 74 | 74 | return children ? <p className="text-sm text-danger">{children}</p> : null; | |
| 75 | 75 | } | |
| 76 | 76 | ||
| 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 | − | ||
| 99 | 77 | /** A small outlined label. */ | |
| 100 | 78 | export function Pill({ children }: { children: ReactNode }) { | |
| 101 | 79 | return ( |
| 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 | + | } |
| 20 | 20 | route("tree/:ref/*", "routes/repo/tree.tsx"), | |
| 21 | 21 | route("blob/:ref/*", "routes/repo/blob.tsx"), | |
| 22 | 22 | 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"), | |
| 27 | 28 | ]), | |
| 28 | 29 | ] satisfies RouteConfig; |
| 9 | 9 | ButtonLink, | |
| 10 | 10 | CopyLine, | |
| 11 | 11 | EmptyState, | |
| 12 | − | Status, | |
| 13 | 12 | TimeAgo, | |
| 14 | 13 | } from "../components/ui"; | |
| 14 | + | import { PullIcon } from "../components/work"; | |
| 15 | 15 | import { repos as reposApi, work } from "../lib/services.server"; | |
| 16 | 16 | import { getViewer } from "../lib/session.server"; | |
| 17 | 17 | ||
| 21 | 21 | { | |
| 22 | 22 | name: "description", | |
| 23 | 23 | 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.", | |
| 25 | 25 | }, | |
| 26 | 26 | ]; | |
| 27 | 27 | } | |
| 32 | 32 | if (viewer?.verified && (viewer.workspaces ?? []).length === 0) { | |
| 33 | 33 | throw redirect("/workspaces/new"); | |
| 34 | 34 | } | |
| 35 | − | const [repos, attempts] = await Promise.all([ | |
| 35 | + | const [repos, pulls] = await Promise.all([ | |
| 36 | 36 | reposApi.list(viewer, { memberOnly: Boolean(viewer) }), | |
| 37 | − | work.listActiveAttempts(viewer), | |
| 37 | + | work.listActivePulls(viewer), | |
| 38 | 38 | ]); | |
| 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)), | |
| 42 | 42 | ); | |
| 43 | 43 | return { | |
| 44 | 44 | viewer, | |
| 45 | 45 | repos, | |
| 46 | − | active: attempts.flatMap((item, i) => { | |
| 47 | − | const repo = attemptRepos[i]; | |
| 46 | + | active: pulls.flatMap((item, i) => { | |
| 47 | + | const repo = pullRepos[i]; | |
| 48 | 48 | return repo.ok ? [{ ...item, repo: repo.value }] : []; | |
| 49 | 49 | }), | |
| 50 | 50 | }; | |
| 54 | 54 | const { viewer, repos, active } = loaderData; | |
| 55 | 55 | if (!viewer) return <Landing repos={repos} />; | |
| 56 | 56 | ||
| 57 | − | const working = active.filter(({ attempt }) => attempt.status === "working"); | |
| 57 | + | const working = active.filter(({ pull }) => pull.status === "draft"); | |
| 58 | 58 | return ( | |
| 59 | 59 | <main className="mx-auto grid max-w-6xl gap-10 px-4 py-10 lg:grid-cols-[1fr_20rem]"> | |
| 60 | 60 | <div className="min-w-0 space-y-10"> | |
| 67 | 67 | </h1> | |
| 68 | 68 | <p className="text-sm text-muted"> | |
| 69 | 69 | {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.`} | |
| 72 | 72 | </p> | |
| 73 | 73 | </div> | |
| 74 | 74 | </div> | |
| 78 | 78 | <h2 className="text-sm font-medium text-muted">In progress</h2> | |
| 79 | 79 | <div className="mt-3"> | |
| 80 | 80 | {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. | |
| 84 | 84 | </EmptyState> | |
| 85 | 85 | ) : ( | |
| 86 | 86 | <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}> | |
| 89 | 89 | <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" | |
| 92 | 92 | > | |
| 93 | + | <PullIcon status={pull.status} /> | |
| 93 | 94 | <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> | |
| 97 | 96 | <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} | |
| 100 | 99 | </span> | |
| 101 | 100 | </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} /> | |
| 104 | 106 | </span> | |
| 105 | − | <Status value={attempt.status} /> | |
| 106 | 107 | </Link> | |
| 107 | 108 | </li> | |
| 108 | 109 | ))} | |
| 145 | 146 | <div className="rounded-xl border border-line bg-surface p-5"> | |
| 146 | 147 | <h2 className="font-medium">Explore</h2> | |
| 147 | 148 | <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. | |
| 149 | 150 | </p> | |
| 150 | 151 | <Link | |
| 151 | 152 | to="/explore" |
| 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 | − | } |
| 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 | − | } |
| 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 | − | } |
| 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 | − | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 1 | − | import { BookMarked, Code2, History, Lock, Target } from "lucide-react"; | |
| 1 | + | import { BookMarked, CircleDot, Code2, GitPullRequest, History, Lock } from "lucide-react"; | |
| 2 | 2 | import type { ReactNode } from "react"; | |
| 3 | − | import { Link, NavLink, Outlet } from "react-router"; | |
| 3 | + | import { Link, NavLink, Outlet, useLocation } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import type { Route } from "./+types/layout"; | |
| 6 | 6 | import { Pill } from "../../components/ui"; | |
| 14 | 14 | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 15 | 15 | const viewer = getViewer(context); | |
| 16 | 16 | const path = { namespace: params.owner, name: params.repo }; | |
| 17 | − | const [repo, intents] = await Promise.all([ | |
| 17 | + | const [repo, counts] = await Promise.all([ | |
| 18 | 18 | repos.get(path, viewer), | |
| 19 | − | work.listIntents(path, viewer, "open"), | |
| 19 | + | work.counts(path, viewer), | |
| 20 | 20 | ]); | |
| 21 | 21 | return { | |
| 22 | 22 | repo: unwrap(repo), | |
| 23 | − | openIntents: intents.ok ? intents.value.length : 0, | |
| 23 | + | open: counts.ok ? counts.value : { issues: 0, pulls: 0 }, | |
| 24 | 24 | }; | |
| 25 | 25 | } | |
| 26 | 26 | ||
| 27 | 27 | function Tab({ | |
| 28 | 28 | to, | |
| 29 | + | also, | |
| 29 | 30 | end, | |
| 30 | 31 | icon, | |
| 31 | 32 | count, | |
| 32 | 33 | children, | |
| 33 | 34 | }: { | |
| 34 | 35 | to: string; | |
| 36 | + | /** Another path prefix under which this tab is the current one. */ | |
| 37 | + | also?: string; | |
| 35 | 38 | end?: boolean; | |
| 36 | 39 | icon: ReactNode; | |
| 37 | 40 | count?: number; | |
| 38 | 41 | children: ReactNode; | |
| 39 | 42 | }) { | |
| 43 | + | const { pathname } = useLocation(); | |
| 40 | 44 | return ( | |
| 41 | 45 | <NavLink | |
| 42 | 46 | to={to} | |
| 43 | 47 | end={end} | |
| 44 | 48 | className={({ isActive }) => | |
| 45 | 49 | `-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 + "/")) | |
| 47 | 51 | ? "border-accent font-medium text-fg" | |
| 48 | 52 | : "border-transparent text-muted hover:text-fg" | |
| 49 | 53 | }` | |
| 61 | 65 | } | |
| 62 | 66 | ||
| 63 | 67 | export default function RepoLayout({ loaderData }: Route.ComponentProps) { | |
| 64 | − | const { repo, openIntents } = loaderData; | |
| 68 | + | const { repo, open } = loaderData; | |
| 65 | 69 | const base = `/${repo.namespace}/${repo.name}`; | |
| 66 | 70 | return ( | |
| 67 | 71 | <> | |
| 92 | 96 | <Tab to={base} end icon={<Code2 size={15} />}> | |
| 93 | 97 | Code | |
| 94 | 98 | </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 | |
| 97 | 109 | </Tab> | |
| 98 | 110 | <Tab to={`${base}/commits`} icon={<History size={15} />}> | |
| 99 | 111 | Commits |
| 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 | + | } |
| 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 | + | } |
| 119 | 119 | </div> | |
| 120 | 120 | {!role && ( | |
| 121 | 121 | <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. | |
| 123 | 123 | </p> | |
| 124 | 124 | )} | |
| 125 | 125 | </aside> |
| 1 | 1 | # g1t | |
| 2 | 2 | ||
| 3 | 3 | > 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. | |
| 8 | 8 | ||
| 9 | 9 | This file tells an assistant everything needed to get a person set up on g1t | |
| 10 | 10 | and working. You never ask for, see, or send the person's password. Accounts | |
| 31 | 31 | ||
| 32 | 32 | A new account also gets a confirmation email from `noreply@g1t.sh`. Ask | |
| 33 | 33 | 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` | |
| 35 | 35 | with a message saying to confirm the address. | |
| 36 | 36 | ||
| 37 | 37 | 3. **Collect the token.** Poll every `interval` seconds, not faster: | |
| 82 | 82 | ||
| 83 | 83 | ## Do work | |
| 84 | 84 | ||
| 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. | |
| 90 | 101 | - **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 | |
| 92 | 103 | `{"entries": [{"kind": "message", "text": "…"}]}`. Kinds are `prompt`, | |
| 93 | 104 | `message`, `tool_call`, `tool_result`, `note`. Never include secrets; | |
| 94 | 105 | 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. | |
| 101 | 117 | ||
| 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`. | |
| 107 | 126 | ||
| 108 | 127 | ## Facts | |
| 109 | 128 | ||
| 113 | 132 | (401), `forbidden` (403), `not_found` (404), `conflict` (409), `invalid` | |
| 114 | 133 | (422). The full description is at https://api.g1t.sh/openapi.json. | |
| 115 | 134 | - 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. | |
| 118 | 137 | - Limits: 1 GB per repository, 32 MB per file, 100 MB per push. | |
| 119 | 138 | - Forgotten password: https://g1t.sh/forgot (the person does this, in a | |
| 120 | 139 | 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. | |
| 123 | 143 | ||
| 124 | 144 | ## More | |
| 125 | 145 |
| 31 | 31 | pub struct RepoForked { | |
| 32 | 32 | pub repo_id: String, | |
| 33 | 33 | pub source_repo_id: String, | |
| 34 | − | pub attempt_id: String, | |
| 34 | + | pub pull_id: String, | |
| 35 | 35 | } | |
| 36 | 36 | ||
| 37 | 37 | /// `after` is the commit the ref points to once the push has landed. | |
| 44 | 44 | pub after: String, | |
| 45 | 45 | } | |
| 46 | 46 | ||
| 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)] | |
| 48 | 50 | #[serde(rename_all = "camelCase")] | |
| 49 | − | pub struct IntentOpened { | |
| 50 | − | pub intent_id: String, | |
| 51 | + | pub struct IssueEvent { | |
| 52 | + | pub issue_id: String, | |
| 51 | 53 | pub repo_id: String, | |
| 52 | 54 | 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>, | |
| 63 | 63 | } | |
| 64 | 64 | ||
| 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. | |
| 67 | 67 | #[derive(Debug, Default, Serialize)] | |
| 68 | 68 | #[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, | |
| 72 | 71 | 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>, | |
| 73 | 76 | #[serde(skip_serializing_if = "Option::is_none")] | |
| 74 | 77 | pub agent: Option<String>, | |
| 78 | + | /// On merge: the commit the branch now points to. | |
| 75 | 79 | #[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. | |
| 77 | 82 | #[serde(skip_serializing_if = "Option::is_none")] | |
| 78 | − | pub commit: Option<String>, | |
| 83 | + | pub superseded_by: Option<u32>, | |
| 79 | 84 | } | |
| 80 | 85 | ||
| 86 | + | /// `comment.created`. `number` is the issue or pull request commented on. | |
| 81 | 87 | #[derive(Debug, Serialize)] | |
| 82 | 88 | #[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")] | |
| 83 | 97 | 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, | |
| 86 | 101 | pub count: u32, | |
| 87 | 102 | } | |
| 88 | 103 |
| 25 | 25 | pub enum Role { | |
| 26 | 26 | /// Everything a member can, plus managing members. | |
| 27 | 27 | Owner, | |
| 28 | − | /// Create repositories, push, open intents and ship. | |
| 28 | + | /// Create repositories, push, manage issues and merge pull requests. | |
| 29 | 29 | Member, | |
| 30 | 30 | } | |
| 31 | 31 |
| 10 | 10 | "search", | |
| 11 | 11 | "admin", | |
| 12 | 12 | "auth", | |
| 13 | − | "attempts", | |
| 13 | + | "pulls", | |
| 14 | + | "issues", | |
| 14 | 15 | "oauth", | |
| 15 | 16 | "assets", | |
| 16 | 17 | "docs", |
| 18 | 18 | pub is_private: bool, | |
| 19 | 19 | pub owner_id: String, | |
| 20 | 20 | 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. | |
| 22 | 22 | pub fork_of: Option<String>, | |
| 23 | 23 | /// RFC 3339. | |
| 24 | 24 | pub created_at: String, | |
| 110 | 110 | ReceivePack, | |
| 111 | 111 | } | |
| 112 | 112 | ||
| 113 | − | /// The result of landing an attempt. | |
| 113 | + | /// The result of landing a pull request. | |
| 114 | 114 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 115 | 115 | pub struct Landed { | |
| 116 | 116 | /// The commit the branch points to now. | |
| 117 | 117 | pub commit: String, | |
| 118 | 118 | /// 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. | |
| 120 | 120 | pub previous: Option<String>, | |
| 121 | 121 | } | |
| 122 | 122 | ||
| 198 | 198 | pub limit: u32, | |
| 199 | 199 | } | |
| 200 | 200 | ||
| 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>`. | |
| 203 | 203 | #[derive(Debug, Serialize, Deserialize)] | |
| 204 | 204 | #[serde(rename_all = "camelCase")] | |
| 205 | 205 | pub struct ForkArgs { | |
| 206 | 206 | pub source_id: String, | |
| 207 | − | pub attempt_id: String, | |
| 207 | + | pub pull_id: String, | |
| 208 | 208 | pub actor: User, | |
| 209 | 209 | } | |
| 210 | 210 | ||
| 285 | 285 | ||
| 286 | 286 | /// `compare`: what a repository's head changes. | |
| 287 | 287 | /// | |
| 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 | |
| 289 | 289 | /// and the repository it came from last agreed, and any other repository | |
| 290 | 290 | /// against its head's parent. Returns `Outcome<Comparison>`. | |
| 291 | 291 | #[derive(Debug, Serialize, Deserialize)] |
| 1 | − | //! The work service: intents, attempts and sessions. | |
| 1 | + | //! The work service: issues, pull requests, comments and sessions. | |
| 2 | 2 | //! | |
| 3 | 3 | //! Each `*Args` struct is the argument of the method of the same name, | |
| 4 | 4 | //! 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. | |
| 5 | 8 | ||
| 6 | 9 | use serde::{Deserialize, Serialize}; | |
| 7 | 10 | ||
| 8 | 11 | use crate::repos::RepoPath; | |
| 9 | 12 | use crate::{User, Viewer}; | |
| 10 | 13 | ||
| 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. | |
| 11 | 21 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 12 | 22 | #[serde(rename_all = "lowercase")] | |
| 13 | − | pub enum IntentStatus { | |
| 23 | + | pub enum State { | |
| 14 | 24 | 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, | |
| 17 | 35 | } | |
| 18 | 36 | ||
| 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. | |
| 21 | 49 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 22 | 50 | #[serde(rename_all = "camelCase")] | |
| 23 | − | pub struct Intent { | |
| 51 | + | pub struct Issue { | |
| 24 | 52 | pub id: String, | |
| 25 | 53 | pub repo_id: String, | |
| 26 | − | /// Sequential per repo, shown as `#12`. | |
| 54 | + | /// Shown as `#12`. | |
| 27 | 55 | pub number: u32, | |
| 28 | 56 | 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. | |
| 32 | 61 | 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>, | |
| 34 | 67 | pub author: User, | |
| 35 | 68 | /// RFC 3339. | |
| 36 | 69 | 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, | |
| 38 | 77 | } | |
| 39 | 78 | ||
| 40 | 79 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 41 | 80 | #[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, | |
| 47 | 89 | } | |
| 48 | 90 | ||
| 49 | − | impl AttemptStatus { | |
| 91 | + | impl PullStatus { | |
| 50 | 92 | pub fn as_str(self) -> &'static str { | |
| 51 | 93 | 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", | |
| 56 | 98 | } | |
| 57 | 99 | } | |
| 58 | 100 | ||
| 59 | − | /// Whether the attempt can still be changed or shipped. | |
| 101 | + | /// Whether the pull request can still be changed or merged. | |
| 60 | 102 | pub fn is_active(self) -> bool { | |
| 61 | − | matches!(self, AttemptStatus::Working | AttemptStatus::Submitted) | |
| 103 | + | matches!(self, PullStatus::Draft | PullStatus::Open) | |
| 62 | 104 | } | |
| 63 | 105 | } | |
| 64 | 106 | ||
| 65 | 107 | /// Where the agent runs: on g1t's sandboxes, or in someone's own session. | |
| 66 | 108 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 67 | 109 | #[serde(rename_all = "lowercase")] | |
| 68 | − | pub enum AttemptRuntime { | |
| 110 | + | pub enum Runtime { | |
| 69 | 111 | Hosted, | |
| 70 | 112 | External, | |
| 71 | 113 | } | |
| 72 | 114 | ||
| 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. | |
| 74 | 116 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 75 | 117 | #[serde(rename_all = "camelCase")] | |
| 76 | − | pub struct Attempt { | |
| 118 | + | pub struct Pull { | |
| 77 | 119 | pub id: String, | |
| 78 | − | pub intent_id: String, | |
| 79 | 120 | pub repo_id: String, | |
| 80 | − | /// Sequential per intent. | |
| 121 | + | /// Shown as `#12`. | |
| 81 | 122 | 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>, | |
| 82 | 128 | /// A label for the agent doing the work, e.g. `claude-code`. | |
| 83 | 129 | 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, | |
| 88 | 132 | pub fork: RepoPath, | |
| 89 | 133 | /// The fork's repository id. | |
| 90 | 134 | pub fork_repo_id: String, | |
| 91 | 135 | 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, | |
| 96 | 147 | /// RFC 3339. | |
| 97 | 148 | pub created_at: String, | |
| 98 | 149 | /// RFC 3339. | |
| 99 | 150 | pub updated_at: String, | |
| 100 | 151 | } | |
| 101 | 152 | ||
| 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 | + | ||
| 102 | 164 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 103 | 165 | #[serde(rename_all = "snake_case")] | |
| 104 | 166 | pub enum SessionEntryKind { | |
| 109 | 171 | Note, | |
| 110 | 172 | } | |
| 111 | 173 | ||
| 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. | |
| 113 | 175 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 114 | 176 | pub struct SessionEntry { | |
| 115 | 177 | pub seq: u32, | |
| 134 | 196 | } | |
| 135 | 197 | ||
| 136 | 198 | #[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>, | |
| 140 | 204 | } | |
| 141 | 205 | ||
| 142 | 206 | #[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>, | |
| 146 | 212 | } | |
| 147 | 213 | ||
| 148 | − | /// `open_intent`. Returns `Outcome<Intent>`. | |
| 214 | + | /// `open_issue`. Returns `Outcome<Issue>`. | |
| 149 | 215 | #[derive(Debug, Serialize, Deserialize)] | |
| 150 | − | pub struct OpenIntentArgs { | |
| 216 | + | pub struct OpenIssueArgs { | |
| 151 | 217 | pub actor: User, | |
| 152 | 218 | pub repo: RepoPath, | |
| 153 | 219 | pub title: String, | |
| 154 | 220 | #[serde(default)] | |
| 155 | − | pub brief: String, | |
| 221 | + | pub body: String, | |
| 222 | + | #[serde(default)] | |
| 223 | + | pub labels: Vec<String>, | |
| 156 | 224 | #[serde(default)] | |
| 157 | 225 | pub checks: Vec<String>, | |
| 158 | 226 | } | |
| 159 | 227 | ||
| 160 | − | /// `list_intents`. Returns `Outcome<Vec<Intent>>`. | |
| 228 | + | /// `list_issues`, newest first. Returns `Outcome<Vec<Issue>>`. | |
| 161 | 229 | #[derive(Debug, Serialize, Deserialize)] | |
| 162 | − | pub struct ListIntentsArgs { | |
| 230 | + | pub struct ListIssuesArgs { | |
| 163 | 231 | pub repo: RepoPath, | |
| 164 | 232 | pub viewer: Viewer, | |
| 165 | 233 | #[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>, | |
| 167 | 238 | } | |
| 168 | 239 | ||
| 169 | − | /// `get_intent`. Returns `Outcome<IntentDetail>`. | |
| 240 | + | /// `list_pulls`, newest first. Returns `Outcome<Vec<Pull>>`. | |
| 170 | 241 | #[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 { | |
| 172 | 256 | pub repo: RepoPath, | |
| 257 | + | #[serde(default)] | |
| 173 | 258 | pub number: u32, | |
| 174 | 259 | 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>>, | |
| 175 | 285 | } | |
| 176 | 286 | ||
| 177 | − | /// `withdraw_intent`. Returns `Outcome<Intent>`. | |
| 287 | + | /// `close_issue` and `reopen_issue`. Each returns `Outcome<Issue>`. | |
| 178 | 288 | #[derive(Debug, Serialize, Deserialize)] | |
| 179 | − | #[serde(rename_all = "camelCase")] | |
| 180 | − | pub struct IntentActionArgs { | |
| 289 | + | pub struct IssueActionArgs { | |
| 181 | 290 | 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>, | |
| 183 | 296 | } | |
| 184 | 297 | ||
| 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>`. | |
| 187 | 299 | #[derive(Debug, Serialize, Deserialize)] | |
| 188 | − | #[serde(rename_all = "camelCase")] | |
| 189 | − | pub struct StartAttemptArgs { | |
| 300 | + | pub struct AddCommentArgs { | |
| 190 | 301 | 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, | |
| 194 | 305 | } | |
| 195 | 306 | ||
| 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>`. | |
| 197 | 309 | #[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. | |
| 203 | 314 | #[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, | |
| 205 | 322 | } | |
| 206 | 323 | ||
| 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>`. | |
| 209 | 325 | #[derive(Debug, Serialize, Deserialize)] | |
| 210 | 326 | #[serde(rename_all = "camelCase")] | |
| 211 | − | pub struct AttemptActionArgs { | |
| 327 | + | pub struct PullActionArgs { | |
| 212 | 328 | 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. | |
| 215 | 332 | #[serde(default)] | |
| 216 | 333 | 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, | |
| 217 | 338 | } | |
| 218 | 339 | ||
| 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>`. | |
| 221 | 349 | #[derive(Debug, Serialize, Deserialize)] | |
| 222 | 350 | pub struct ViewerArgs { | |
| 223 | 351 | pub viewer: Viewer, | |
| 225 | 353 | ||
| 226 | 354 | /// `append_session`. Returns `Outcome<Appended>`. | |
| 227 | 355 | #[derive(Debug, Serialize, Deserialize)] | |
| 228 | − | #[serde(rename_all = "camelCase")] | |
| 229 | 356 | pub struct AppendSessionArgs { | |
| 230 | 357 | pub actor: User, | |
| 231 | − | pub attempt_id: String, | |
| 358 | + | pub repo: RepoPath, | |
| 359 | + | pub number: u32, | |
| 232 | 360 | pub entries: Vec<NewSessionEntry>, | |
| 233 | 361 | } | |
| 234 | 362 | ||
| 236 | 364 | pub struct Appended { | |
| 237 | 365 | pub count: u32, | |
| 238 | 366 | } | |
| 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 | + | } |
| 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. | |
| 2 | 2 | //! | |
| 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 | |
| 7 | 7 | //! as an agent on someone's own machine would. | |
| 8 | 8 | //! | |
| 9 | 9 | //! Configuration comes from the environment: | |
| 10 | 10 | //! | |
| 11 | 11 | //! - `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. | |
| 13 | 13 | //! - `PROMPT`: what the agent is asked to do. | |
| 14 | 14 | //! - `COMMIT_MESSAGE`: used if the agent leaves changes uncommitted. | |
| 15 | 15 | //! - `ANTHROPIC_API_KEY`: read by the harness itself. | |
| 74 | 74 | Path::new("/work"), | |
| 75 | 75 | &["-c", &auth, "clone", "--quiet", &remote, WORKDIR], | |
| 76 | 76 | ) | |
| 77 | − | .context("could not clone the attempt's fork")?; | |
| 77 | + | .context("could not clone the pull request's fork")?; | |
| 78 | 78 | git(workdir, &["config", "user.name", "g1t agent"])?; | |
| 79 | 79 | git(workdir, &["config", "user.email", "agent@g1t.sh"])?; | |
| 80 | 80 | let branch = git(workdir, &["rev-parse", "--abbrev-ref", "HEAD"])?; | |
| 103 | 103 | &format!("HEAD:{branch}"), | |
| 104 | 104 | ], | |
| 105 | 105 | ) | |
| 106 | − | .context("could not push the attempt's commits")?; | |
| 106 | + | .context("could not push the pull request's commits")?; | |
| 107 | 107 | reporter.record(Entry::new( | |
| 108 | 108 | "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)]), | |
| 113 | 110 | )); | |
| 114 | 111 | Ok(summary) | |
| 115 | 112 | } | |
| 125 | 122 | match run(&mut reporter) { | |
| 126 | 123 | Ok(summary) => { | |
| 127 | 124 | 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:#}"); | |
| 130 | 127 | std::process::exit(1); | |
| 131 | 128 | } | |
| 132 | 129 | } | |
| 134 | 131 | eprintln!("g1t-runner: {error:#}"); | |
| 135 | 132 | reporter.record(Entry::new("note", &format!("The run failed: {error:#}"))); | |
| 136 | 133 | reporter.flush(); | |
| 137 | − | let _ = reporter.abandon(); | |
| 134 | + | let _ = reporter.close(); | |
| 138 | 135 | std::process::exit(1); | |
| 139 | 136 | } | |
| 140 | 137 | } |
| 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. | |
| 2 | 2 | ||
| 3 | 3 | use std::time::{Duration, Instant}; | |
| 4 | 4 | ||
| 53 | 53 | pub struct Reporter { | |
| 54 | 54 | api: String, | |
| 55 | 55 | token: String, | |
| 56 | − | attempt: String, | |
| 56 | + | /// The pull request's path in the API: `repos/<owner>/<name>/pulls/<number>`. | |
| 57 | + | pull: String, | |
| 57 | 58 | /// Values that must never reach a session, which is as public as the | |
| 58 | 59 | /// repository: the credentials this process was started with. | |
| 59 | 60 | secrets: Vec<String>, | |
| 73 | 74 | Ok(Reporter { | |
| 74 | 75 | api: var("G1T_API")?, | |
| 75 | 76 | token, | |
| 76 | − | attempt: var("ATTEMPT_ID")?, | |
| 77 | + | pull: format!("repos/{}/pulls/{}", var("G1T_REPO")?, var("PULL_NUMBER")?), | |
| 77 | 78 | secrets, | |
| 78 | 79 | pending: Vec::new(), | |
| 79 | 80 | last_flush: Instant::now(), | |
| 81 | 82 | } | |
| 82 | 83 | ||
| 83 | 84 | 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"))?; | |
| 91 | 89 | Ok(()) | |
| 92 | 90 | } | |
| 93 | 91 | ||
| 114 | 112 | } | |
| 115 | 113 | } | |
| 116 | 114 | ||
| 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 })) | |
| 119 | 119 | } | |
| 120 | 120 | ||
| 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!({})) | |
| 123 | 124 | } | |
| 124 | 125 | } | |
| 125 | 126 |
| 11 | 11 | ||
| 12 | 12 | ## Product model | |
| 13 | 13 | ||
| 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`. | |
| 17 | 19 | ||
| 18 | 20 | | Concept | What it is | | |
| 19 | 21 | | --- | --- | | |
| 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. | |
| 25 | 30 | ||
| 26 | 31 | Features that fall out of the model: | |
| 27 | 32 | ||
| 28 | 33 | - **Why-blame.** Click a line and see the prompt and reasoning that produced | |
| 29 | 34 | 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. | |
| 33 | 43 | ||
| 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. | |
| 35 | 53 | ||
| 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: | |
| 39 | 55 | ||
| 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. | |
| 50 | 69 | - **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. | |
| 52 | 71 | ||
| 53 | 72 | ## Converging on main | |
| 54 | 73 | ||
| 55 | − | Twelve intents started together will finish at different times and touch | |
| 74 | + | Twelve issues started together will finish at different times and touch | |
| 56 | 75 | overlapping code. Getting them all into `main` without a person refereeing | |
| 57 | 76 | is the hard part, and it is handled in four places. | |
| 58 | 77 | ||
| 59 | 78 | 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 | |
| 61 | 80 | which files each will touch, and add a dependency where two would collide, | |
| 62 | 81 | 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 | |
| 65 | 84 | 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 | |
| 69 | 88 | session and the incoming change, so the conflict is resolved by the agent | |
| 70 | 89 | 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 | |
| 74 | 93 | passes; one that fails is ejected back to its agent and the states behind | |
| 75 | 94 | it are rebuilt. `main` only ever receives a state that passed. | |
| 76 | 95 | ||
| 77 | 96 | 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. | |
| 79 | 98 | ||
| 80 | 99 | ## Agents aware of each other | |
| 81 | 100 | ||
| 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 | |
| 84 | 103 | has touched or plans to touch. Agents use it through MCP tools; g1t also | |
| 85 | 104 | acts on it without being asked. | |
| 86 | 105 | ||
| 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 | |
| 89 | 108 | goal (by meaning, not wording) and for the same area of code. If a match | |
| 90 | 109 | exists the agent is told who is on it and how far along, and chooses: join | |
| 91 | 110 | 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. | |
| 93 | 112 | - **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 | |
| 95 | 114 | area, it **hands off**: a note, the relevant excerpt of its session, and | |
| 96 | 115 | 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. | |
| 99 | 118 | 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. | |
| 101 | 120 | Its sandbox sleeps, spend stops, and it resumes from the new state when | |
| 102 | − | that attempt ships. | |
| 121 | + | that pull request merges. | |
| 103 | 122 | - **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 | |
| 105 | 124 | flags near-duplicates itself. | |
| 106 | 125 | ||
| 107 | 126 | Every handoff, question and wait has a state (offered, accepted, declined, | |
| 115 | 134 | hundreds of agents, a person cannot read every diff, so review is by | |
| 116 | 135 | exception. | |
| 117 | 136 | ||
| 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 | |
| 119 | 138 | and their output, a preview URL, a plain-language summary, and the | |
| 120 | 139 | 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 | |
| 122 | 141 | second is adversarial: it tries to break the change and reports what it | |
| 123 | 142 | found. | |
| 124 | 143 | - **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 | |
| 126 | 145 | 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 | |
| 128 | 147 | 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, | |
| 130 | 149 | to keep the policy honest. | |
| 131 | 150 | ||
| 132 | 151 | ## Rethinking the git primitives | |
| 133 | 152 | ||
| 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 | |
| 135 | 154 | 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 | |
| 137 | 156 | in the landing queue, so they are built on the state they will land on. | |
| 138 | 157 | - **Structural merge.** The merge engine merges by syntax tree, not by line, | |
| 139 | 158 | for supported languages. Two agents adding different functions to the same | |
| 142 | 161 | was at that moment plus the conversation up to it, continued with a | |
| 143 | 162 | different instruction. Branching applies to the reasoning as well as the | |
| 144 | 163 | 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 | |
| 147 | 166 | history can be audited by machine. | |
| 148 | 167 | ||
| 149 | 168 | ## People in the loop | |
| 153 | 172 | People will keep pushing with plain git, their editor, or another tool. Every | |
| 154 | 173 | push goes through g1t's git front end, so none of it bypasses the model. | |
| 155 | 174 | ||
| 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. | |
| 160 | 179 | - **A push to `main` follows repo policy.** Protected: refused with a message | |
| 161 | 180 | saying which ref to push to instead, so it enters the queue. Open: accepted | |
| 162 | 181 | 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. | |
| 164 | 183 | - **Context is an open format.** A commit trailer names the session that | |
| 165 | 184 | produced it, so any tool can attach its transcript. Commits without one are | |
| 166 | 185 | 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 | |
| 168 | 187 | named person, or require a person when the change is large or the reviewer | |
| 169 | 188 | agent is unsure. | |
| 170 | 189 | ||
| 177 | 196 | through the CLI hooks. | |
| 178 | 197 | - **Answer.** When an agent is blocked on a question, it appears in a "needs | |
| 179 | 198 | 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, | |
| 181 | 200 | push, and let the agent continue from there. | |
| 182 | 201 | ||
| 183 | 202 | ### Planning by writing | |
| 184 | 203 | ||
| 185 | 204 | - **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 | |
| 187 | 206 | checks, dependencies. The person edits the graph before anything starts. | |
| 188 | 207 | - **Plan from their own agent.** The same operations are MCP tools, so a | |
| 189 | 208 | person can plan in their own Claude Code session and create the project | |
| 190 | 209 | from there. | |
| 191 | 210 | - **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. | |
| 193 | 212 | ||
| 194 | 213 | ### Seeing what moved | |
| 195 | 214 | ||
| 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 | |
| 197 | 216 | many acceptance checks pass now compared with when the project started. | |
| 198 | 217 | - **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 | |
| 200 | 219 | cost. | |
| 201 | − | - **Timeline.** Every event (push, steer, check, conflict, ship) in order, | |
| 220 | + | - **Timeline.** Every event (push, steer, check, conflict, merge) in order, | |
| 202 | 221 | each linked to the session and the person or agent behind it. | |
| 203 | 222 | ||
| 204 | 223 | ## One session, any surface | |
| 208 | 227 | ||
| 209 | 228 | - **Browser and phone.** The site is a responsive, installable web app with | |
| 210 | 229 | push notifications. Everything a person does (brief, steer, answer, | |
| 211 | − | approve, ship) works there. | |
| 230 | + | approve, merge) works there. | |
| 212 | 231 | - **Claude Code.** Through `mcp.g1t.sh` and the CLI hooks, a local session is | |
| 213 | 232 | a g1t session: its transcript syncs as it runs and it appears in mission | |
| 214 | 233 | control like any other. | |
| 225 | 244 | - **Documents are first-class.** Specs, guides, policies and decisions live | |
| 226 | 245 | in repos as markdown, shown in a Docs view: rendered pages, edited in the | |
| 227 | 246 | 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 | |
| 231 | 250 | instead of commands. Agents draft and revise; people comment and approve. | |
| 232 | 251 | - **Templates.** Product brief, RFC, decision record. A filled-in template is | |
| 233 | 252 | a brief the planner can turn into a project. | |
| 234 | 253 | - **Explain.** Ask about any repo, project or change in plain language and | |
| 235 | 254 | get an answer with links to the code and sessions behind it. | |
| 236 | 255 | - **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 | |
| 241 | 260 | through the result instead of reading a diff. Changes are also summarised | |
| 242 | 261 | in plain language. | |
| 243 | 262 | - **Roles.** Viewer, commenter, planner, approver: a person can plan and | |
| 251 | 270 | | --- | --- | | |
| 252 | 271 | | **Workspace** | A company or team: its people, repos, agents, budget and policies. | | |
| 253 | 272 | | **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. | | |
| 256 | 275 | ||
| 257 | 276 | ### Portfolio | |
| 258 | 277 | ||
| 259 | 278 | One page answers "where is the business" across every initiative: | |
| 260 | 279 | ||
| 261 | 280 | - **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), | |
| 263 | 282 | 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. | |
| 266 | 285 | - **Forecast** from actual throughput: at the current rate, when the | |
| 267 | − | remaining intents land. | |
| 286 | + | remaining issues land. | |
| 268 | 287 | - **Spend** in tokens and dollars against a budget, per initiative. | |
| 269 | 288 | - **Waiting on people**: every decision or approval a person owes, by name. | |
| 270 | 289 | - **Roadmap**: initiatives laid out as now, next, later, with optional | |
| 273 | 292 | ### Status without asking | |
| 274 | 293 | ||
| 275 | 294 | - **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 | |
| 277 | 296 | why, what needs a person. Delivered by email or webhook. | |
| 278 | 297 | - **Ask.** A question box over the full event log and all sessions: "what | |
| 279 | 298 | happened on the billing migration since Monday?" answers with links to the | |
| 284 | 303 | Work that runs for days needs supervision that does not depend on someone | |
| 285 | 304 | watching. | |
| 286 | 305 | ||
| 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 | |
| 291 | 310 | 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 | |
| 293 | 312 | initiative. | |
| 294 | 313 | ||
| 295 | 314 | ### Context hub | |
| 311 | 330 | fresh it is. | |
| 312 | 331 | - **Connected sources stay where they are.** g1t indexes them for search and | |
| 313 | 332 | 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 | |
| 315 | 334 | JIRA-482", a Notion URL) is pulled into the agent's starting context. | |
| 316 | 335 | - **Permissions carry over.** A connector only exposes what the connecting | |
| 317 | 336 | account can see, and a workspace admin chooses which spaces, projects or | |
| 335 | 354 | conflicts it cannot settle. People can pin an entry (agents may not change | |
| 336 | 355 | it), correct it, or retract it. | |
| 337 | 356 | - **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. | |
| 339 | 358 | - **Updates are events.** A memory write or a change in a connected source | |
| 340 | 359 | is an event, so "when context changes, update the affected docs" is an | |
| 341 | 360 | automation, on by default. | |
| 349 | 368 | ## Working in g1t | |
| 350 | 369 | ||
| 351 | 370 | - **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. | |
| 357 | 376 | - **Automations.** Rules that start work without a person (next section). | |
| 358 | 377 | ||
| 359 | 378 | ## Automations and integrations | |
| 366 | 385 | ||
| 367 | 386 | | Source | Examples | | |
| 368 | 387 | | --- | --- | | |
| 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 | | |
| 371 | 390 | | Time | cron schedule | | |
| 372 | 391 | | Integrations | Sentry issue, PagerDuty incident, Linear or Jira ticket, Slack message or mention, GitHub issue, Stripe event | | |
| 373 | 392 | | Anything else | a signed generic webhook, or an email to a per-repo address | | |
| 374 | 393 | ||
| 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 | |
| 377 | 396 | webhook, write back to the source system. | |
| 378 | 397 | ||
| 379 | 398 | **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 | |
| 381 | 401 | 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 | |
| 383 | 403 | issue and resolves it. | |
| 384 | 404 | ||
| 385 | 405 | ### Rules every automation obeys | |
| 386 | 406 | ||
| 387 | 407 | - **Deduplication.** The same Sentry issue firing 500 times maps to one | |
| 388 | − | intent. | |
| 408 | + | issue. | |
| 389 | 409 | - **Limits.** Concurrency and budget caps per automation. | |
| 390 | 410 | - **Loop protection.** Work started by an automation cannot retrigger the | |
| 391 | 411 | same automation without a person in between. | |
| 392 | 412 | - **External input is untrusted.** A webhook payload can contain text written | |
| 393 | 413 | 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. | |
| 395 | 415 | ||
| 396 | 416 | **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 | |
| 398 | 418 | and on every combined state in the landing queue. | |
| 399 | 419 | ||
| 400 | 420 | Agents can also reach integrations directly: an agent definition lists MCP | |
| 417 | 437 | | Hosted, g1t's model | g1t runs the sandbox and bills usage | Getting started; no keys to manage | | |
| 418 | 438 | | Hosted, your API key | Same sandbox, your Anthropic, OpenAI or Google key | Teams with existing contracts | | |
| 419 | 439 | | 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 | | |
| 421 | 441 | | Your own session | Local Claude Code, Cursor or any MCP client joins through `mcp.g1t.sh` | Individuals; subscription plans | | |
| 422 | 442 | ||
| 423 | 443 | Decisions behind this: | |
| 433 | 453 | hosted sandbox; it needs an API key. People on subscriptions use their own | |
| 434 | 454 | Claude Code session, which is a full participant. | |
| 435 | 455 | - **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. | |
| 437 | 457 | ||
| 438 | 458 | ### Choosing the right agent automatically | |
| 439 | 459 | ||
| 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 | |
| 442 | 462 | 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, | |
| 444 | 464 | start with the cheapest that is good enough, and escalate to a stronger one | |
| 445 | 465 | when checks fail. | |
| 446 | 466 | ||
| 455 | 475 | | --- | --- | --- | | |
| 456 | 476 | | 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 | | |
| 457 | 477 | | 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 | | |
| 459 | 479 | | 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 | | |
| 461 | 481 | | Which agents | Those offered through a Copilot subscription | Any MCP client, plus hosted agents | | |
| 462 | 482 | ||
| 463 | 483 | ## How agents connect | |
| 464 | 484 | ||
| 465 | 485 | 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 | |
| 467 | 487 | token, report progress and submit. Adding it is one command; sign-in is a | |
| 468 | 488 | browser OAuth flow with no token to paste. The `g1t` CLI installs Claude | |
| 469 | 489 | 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. | |
| 473 | 493 | 3. **API and CLI.** Everything above is available at `api.g1t.sh` and | |
| 474 | 494 | through `g1t`. | |
| 475 | 495 | ||
| 478 | 498 | | Host | What it serves | | |
| 479 | 499 | | --- | --- | | |
| 480 | 500 | | `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 | | |
| 482 | 502 | | `mcp.g1t.sh` | Remote MCP server over streamable HTTP | | |
| 483 | 503 | ||
| 484 | 504 | g1t is its own OAuth 2.1 authorization server: authorization code with PKCE, | |
| 485 | 505 | 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`). | |
| 487 | 507 | MCP clients, the CLI (device flow) and third-party apps all use it. Access | |
| 488 | 508 | tokens and SSH keys remain for git itself. | |
| 489 | 509 | ||
| 491 | 511 | ||
| 492 | 512 | | Component | Language | Runs on | Responsibility | | |
| 493 | 513 | | --- | --- | --- | --- | | |
| 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 | | |
| 499 | 520 | | `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 | | |
| 501 | 523 | | `crates/sshd` | Rust | Container | Git over SSH, bridged to Artifacts | | |
| 502 | 524 | | `crates/merged` | Rust | Container | Trial merges, conflict matrix, landing merges (needs real git; the Artifacts binding is read-only) | | |
| 503 | 525 | | `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 | | |
| 506 | 527 | ||
| 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 | |
| 508 | 529 | and metadata, R2 for session transcripts and logs, Durable Object SQLite for | |
| 509 | 530 | per-repo coordination state. | |
| 510 | 531 | ||
| 516 | 537 | - **Expected failures are values.** Every call returns a `Result`, so "not | |
| 517 | 538 | found" or "forbidden" crosses a service boundary as data. | |
| 518 | 539 | - **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 | |
| 520 | 541 | call other services to react. Each subscriber consumes from its own queue. | |
| 521 | 542 | Timelines, webhooks and automations read the same stream, which is what | |
| 522 | 543 | lets something like GitHub Actions be built on top. | |
| 531 | 552 | ||
| 532 | 553 | The site is TypeScript. Everything behind it is Rust, compiled to | |
| 533 | 554 | 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 | |
| 535 | 557 | small JSON protocol over service bindings (`POST /rpc/<method>`), with the | |
| 536 | 558 | types in `crates/contracts`. | |
| 537 | 559 | ||
| 539 | 561 | ||
| 540 | 562 | Every id is a [TypeID](https://github.com/jetify-com/typeid): a prefix naming | |
| 541 | 563 | the kind of thing, then a UUIDv7 in lowercase base32, such as | |
| 542 | − | `att_01jb2k7x9hfq0b3zj0f5s2m8ra`. | |
| 564 | + | `pr_01jb2k7x9hfq0b3zj0f5s2m8ra`. | |
| 543 | 565 | ||
| 544 | 566 | - The prefix makes an id self-describing and stops ids of different kinds | |
| 545 | 567 | being mixed up. | |
| 585 | 607 | ||
| 586 | 608 | | Need | Product | | |
| 587 | 609 | | --- | --- | | |
| 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) | | |
| 589 | 611 | | 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 | | |
| 591 | 613 | | Site, API, MCP, git front end | Workers | | |
| 592 | 614 | | Per-repo coordination, live updates | Durable Objects | | |
| 593 | − | | Attempt lifecycles, automations | Workflows, Cron Triggers | | |
| 615 | + | | Pull request lifecycles, automations | Workflows, Cron Triggers | | |
| 594 | 616 | | Agent sandboxes, SSH server, merge engine | Sandbox SDK and Containers | | |
| 595 | 617 | | Fast starts on large repos | ArtifactFS | | |
| 596 | 618 | | Model traffic, spend, budgets | AI Gateway | | |
| 603 | 625 | ## The submission | |
| 604 | 626 | ||
| 605 | 627 | - **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 | |
| 607 | 629 | 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 | |
| 609 | 631 | fan out to dozens of agents; agents notice each other, hand off, and | |
| 610 | 632 | resolve a conflict; reviewers triage; the queue lands everything on | |
| 611 | 633 | `main`; why-blame explains a line; the portfolio shows where it all | |
| 618 | 640 | ||
| 619 | 641 | ## Build order | |
| 620 | 642 | ||
| 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. | |
| 625 | 650 | ||
| 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. | |
| 628 | 652 | 2. OAuth server, so MCP clients sign in through the browser with no token | |
| 629 | 653 | 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. | |
| 632 | 655 | 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. | |
| 634 | 657 | 6. Server-side merge and rebase; landing queue with speculative checks; | |
| 635 | 658 | 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. | |
| 637 | 661 | 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). | |
| 639 | 663 | 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. | |
| 641 | 665 | ||
| 642 | 666 | Later: code search, mirroring to GitHub, passkeys, SSH | |
| 643 | 667 | on port 22 without the CLI proxy (needs the Workers inbound TCP private |
| 80 | 80 | blob: (path, viewer, ref, filePath) => | |
| 81 | 81 | call("blob", { path, viewer, ref, filePath }), | |
| 82 | 82 | 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 }), | |
| 85 | 85 | gitAccess: (path, viewer, service) => | |
| 86 | 86 | call("git_access", { path, viewer, service }), | |
| 87 | 87 | land: (forkId, actor) => call("land", { forkId, actor }), | |
| 92 | 92 | export function workClient(service: ServiceBinding): WorkApi { | |
| 93 | 93 | const call = <T>(method: string, args: object) => rpc<T>(service, method, args); | |
| 94 | 94 | 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 }), | |
| 111 | 120 | }; | |
| 112 | 121 | } |
| 8 | 8 | */ | |
| 9 | 9 | export type EventPayloads = { | |
| 10 | 10 | "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 }; | |
| 12 | 12 | /** `after` is the commit the ref points to once the push has landed. */ | |
| 13 | 13 | "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 }; | |
| 21 | 33 | }; | |
| 22 | 34 | ||
| 23 | 35 | export type EventType = keyof EventPayloads; |
| 6 | 6 | /** Routes and reserved words that may not be registered as usernames. */ | |
| 7 | 7 | const RESERVED = new Set([ | |
| 8 | 8 | "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", | |
| 10 | 10 | ]); | |
| 11 | 11 | ||
| 12 | 12 | export function isValidNamespace(value: string): boolean { |
| 10 | 10 | isPrivate: boolean; | |
| 11 | 11 | ownerId: string; | |
| 12 | 12 | 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. */ | |
| 14 | 14 | forkOf: string | null; | |
| 15 | 15 | /** RFC 3339. */ | |
| 16 | 16 | createdAt: string; | |
| 88 | 88 | log(path: RepoPath, viewer: Viewer, ref: string | null, limit: number): Promise<Result<Commit[]>>; | |
| 89 | 89 | ||
| 90 | 90 | /** | |
| 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 | |
| 92 | 92 | * to work in. | |
| 93 | 93 | */ | |
| 94 | − | forkForAttempt(sourceId: string, attemptId: string, actor: User): Promise<Result<Repo>>; | |
| 94 | + | forkForPull(sourceId: string, pullId: string, actor: User): Promise<Result<Repo>>; | |
| 95 | 95 | ||
| 96 | 96 | /** | |
| 97 | 97 | * Authorizes a git operation and returns where to send it. Pushing to a | |
| 107 | 107 | land(forkId: string, actor: User): Promise<Result<{ commit: string; previous: string | null }>>; | |
| 108 | 108 | ||
| 109 | 109 | /** | |
| 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 | |
| 111 | 111 | * last commit it shares with the repository it came from, unless `base` | |
| 112 | 112 | * says otherwise. | |
| 113 | 113 | */ |
| 1 | 1 | import type { User, Viewer } from "./identity"; | |
| 2 | 2 | import type { Result } from "./result"; | |
| 3 | − | import type { Attempt } from "./work"; | |
| 3 | + | import type { RepoPath } from "./repos"; | |
| 4 | + | import type { Pull } from "./work"; | |
| 4 | 5 | ||
| 5 | 6 | /** A model a g1t agent can run on, as offered to the person starting it. */ | |
| 6 | 7 | export type AgentModel = { | |
| 13 | 14 | }; | |
| 14 | 15 | ||
| 15 | 16 | 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. */ | |
| 17 | 18 | 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. */ | |
| 19 | 20 | instructions?: string; | |
| 20 | 21 | /** One of the offered model ids; the first offered when absent. */ | |
| 21 | 22 | model?: string; | |
| 22 | 23 | }; | |
| 23 | 24 | ||
| 24 | − | /** Hosted agents: sandboxes on g1t that work on an intent. */ | |
| 25 | + | /** Hosted agents: sandboxes on g1t that work on an issue. */ | |
| 25 | 26 | export interface RunnerApi { | |
| 26 | 27 | /** The models this viewer may run g1t agents on; empty if they may not. */ | |
| 27 | 28 | models(viewer: Viewer): Promise<AgentModel[]>; | |
| 28 | 29 | /** | |
| 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. | |
| 32 | 33 | */ | |
| 33 | − | run(actor: User, intentId: string, input: RunHostedInput): Promise<Result<Attempt[]>>; | |
| 34 | + | run(actor: User, repo: RepoPath, issue: number, input: RunHostedInput): Promise<Result<Pull[]>>; | |
| 34 | 35 | } |
| 2 | 2 | import type { RepoPath } from "./repos"; | |
| 3 | 3 | import type { Result } from "./result"; | |
| 4 | 4 | ||
| 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"; | |
| 6 | 14 | ||
| 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 = { | |
| 9 | 23 | id: string; | |
| 10 | 24 | repoId: string; | |
| 11 | − | /** Sequential per repo, shown as `#12`. */ | |
| 25 | + | /** Shown as `#12`. */ | |
| 12 | 26 | number: number; | |
| 13 | 27 | 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. */ | |
| 17 | 32 | 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; | |
| 19 | 38 | author: User; | |
| 20 | 39 | /** RFC 3339. */ | |
| 21 | 40 | 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; | |
| 23 | 48 | }; | |
| 24 | 49 | ||
| 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"; | |
| 26 | 52 | ||
| 27 | 53 | /** 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"; | |
| 29 | 55 | ||
| 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 = { | |
| 32 | 58 | id: string; | |
| 33 | − | intentId: string; | |
| 34 | 59 | repoId: string; | |
| 35 | − | /** Sequential per intent. */ | |
| 60 | + | /** Shown as `#12`. */ | |
| 36 | 61 | 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; | |
| 37 | 67 | /** A label for the agent doing the work, e.g. `claude-code`. */ | |
| 38 | 68 | 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; | |
| 43 | 71 | fork: RepoPath; | |
| 44 | 72 | /** The fork's repository id. */ | |
| 45 | 73 | forkRepoId: string; | |
| 46 | 74 | headCommit: string | null; | |
| 47 | 75 | /** | |
| 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. | |
| 50 | 87 | */ | |
| 51 | − | landedBase: string | null; | |
| 52 | − | startedBy: User; | |
| 88 | + | supersededBy: number | null; | |
| 89 | + | author: User; | |
| 53 | 90 | /** RFC 3339. */ | |
| 54 | 91 | createdAt: string; | |
| 55 | 92 | /** RFC 3339. */ | |
| 56 | 93 | updatedAt: string; | |
| 57 | 94 | }; | |
| 58 | 95 | ||
| 96 | + | export type Comment = { | |
| 97 | + | id: string; | |
| 98 | + | author: User; | |
| 99 | + | /** Markdown. */ | |
| 100 | + | body: string; | |
| 101 | + | /** RFC 3339. */ | |
| 102 | + | createdAt: string; | |
| 103 | + | }; | |
| 104 | + | ||
| 59 | 105 | export type SessionEntryKind = "prompt" | "message" | "tool_call" | "tool_result" | "note"; | |
| 60 | 106 | ||
| 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. */ | |
| 62 | 108 | export type SessionEntry = { | |
| 63 | 109 | seq: number; | |
| 64 | 110 | kind: SessionEntryKind; | |
| 74 | 120 | export type NewSessionEntry = Pick<SessionEntry, "kind" | "text"> & | |
| 75 | 121 | Partial<Pick<SessionEntry, "tool" | "commit">>; | |
| 76 | 122 | ||
| 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 | + | }; | |
| 78 | 129 | ||
| 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 | + | }; | |
| 80 | 136 | ||
| 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 | + | }; | |
| 82 | 154 | ||
| 83 | − | /** Intents, attempts and sessions. */ | |
| 155 | + | /** Issues, pull requests, comments and sessions. */ | |
| 84 | 156 | 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 }>>; | |
| 89 | 173 | ||
| 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>>; | |
| 95 | 185 | /** | |
| 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. | |
| 98 | 190 | */ | |
| 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 }[]>; | |
| 102 | 194 | ||
| 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[]>>; | |
| 105 | 197 | } |
| 26 | 26 | ||
| 27 | 27 | /* Status colours, used sparingly. */ | |
| 28 | 28 | --g1t-info: #7cc4ff; | |
| 29 | − | --g1t-shipped: #b9a2ff; | |
| 29 | + | --g1t-merged: #b9a2ff; | |
| 30 | 30 | --g1t-warn: #f2c14a; | |
| 31 | 31 | --g1t-danger: #ff7a6b; | |
| 32 | 32 | } |
| 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 | |
| 2 | 2 | //! that so far exists only in a fork. | |
| 3 | 3 | //! | |
| 4 | 4 | //! The Artifacts binding cannot write, so this speaks git's smart HTTP |
| 25 | 25 | use registry::{Registry, can_read, can_write, store_key}; | |
| 26 | 26 | use store::{ArtifactsStore, GitRepo, GitStore, Scope}; | |
| 27 | 27 | ||
| 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"; | |
| 30 | 30 | 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. | |
| 32 | 32 | const MAX_ANCESTRY: u32 = 1000; | |
| 33 | 33 | const SOURCE: &str = "repos"; | |
| 34 | 34 | const UNVERIFIED: &str = "Confirm your email address first. Check your inbox, or resend the link from the banner on g1t.sh."; | |
| 325 | 325 | Ok(Outcome::Ok(git.log(&git_ref, a.limit).await?)) | |
| 326 | 326 | } | |
| 327 | 327 | ||
| 328 | − | async fn fork_for_attempt(&self, a: ForkArgs) -> Result<Outcome<Repo>> { | |
| 328 | + | async fn fork_for_pull(&self, a: ForkArgs) -> Result<Outcome<Repo>> { | |
| 329 | 329 | let viewer = Some(a.actor.clone()); | |
| 330 | 330 | let Some(source) = self | |
| 331 | 331 | .registry | |
| 338 | 338 | let now = now_ms(); | |
| 339 | 339 | let fork = Repo { | |
| 340 | 340 | 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(), | |
| 343 | 343 | description: None, | |
| 344 | 344 | // A fork is exactly as visible as the repo it came from. | |
| 345 | 345 | is_private: source.is_private, | |
| 362 | 362 | data: RepoForked { | |
| 363 | 363 | repo_id: fork.id.clone(), | |
| 364 | 364 | source_repo_id: source.id, | |
| 365 | − | attempt_id: a.attempt_id, | |
| 365 | + | pull_id: a.pull_id, | |
| 366 | 366 | }, | |
| 367 | 367 | }) | |
| 368 | 368 | .await?; | |
| 439 | 439 | if !can_write(&target, &actor) { | |
| 440 | 440 | return Ok(Outcome::fail( | |
| 441 | 441 | 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.", | |
| 443 | 443 | )); | |
| 444 | 444 | } | |
| 445 | 445 | if !a.actor.verified { | |
| 453 | 453 | let Some(new) = history.first().map(|commit| commit.hash.clone()) else { | |
| 454 | 454 | return Ok(Outcome::fail( | |
| 455 | 455 | FailureCode::Conflict, | |
| 456 | − | "This attempt has no commits to land.", | |
| 456 | + | "This pull request has no commits to merge.", | |
| 457 | 457 | )); | |
| 458 | 458 | }; | |
| 459 | 459 | let old = target_git | |
| 477 | 477 | return Ok(Outcome::fail( | |
| 478 | 478 | FailureCode::Conflict, | |
| 479 | 479 | 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." | |
| 481 | 481 | ), | |
| 482 | 482 | )); | |
| 483 | 483 | } | |
| 488 | 488 | land::fast_forward(&source_access, &target_access, branch, old.as_deref(), &new) | |
| 489 | 489 | .await?; | |
| 490 | 490 | 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. | |
| 492 | 492 | return Ok(Outcome::fail( | |
| 493 | 493 | FailureCode::Conflict, | |
| 494 | 494 | format!("{branch} could not be updated: {reason}"), | |
| 592 | 592 | let response = git_http::forward(request, &git, &access).await?; | |
| 593 | 593 | ||
| 594 | 594 | // 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. | |
| 596 | 596 | let pushed = git.endpoint == "git-receive-pack" && response.status_code() == 200; | |
| 597 | 597 | if pushed && let Some(repo) = self.registry.by_path(&git.path).await? { | |
| 598 | 598 | let head = self | |
| 643 | 643 | "tree" => reply(&repos.tree(args(body)?).await?), | |
| 644 | 644 | "blob" => reply(&repos.blob(args(body)?).await?), | |
| 645 | 645 | "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?), | |
| 647 | 647 | "git_access" => reply(&repos.git_access(args(body)?).await?), | |
| 648 | 648 | "land" => reply(&repos.land(args(body)?).await?), | |
| 649 | 649 | "compare" => reply(&repos.compare(args(body)?).await?), |
| 45 | 45 | } | |
| 46 | 46 | ||
| 47 | 47 | /// 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. | |
| 49 | 49 | pub fn can_write(repo: &Repo, viewer: &Viewer) -> bool { | |
| 50 | 50 | viewer.as_ref().is_some_and(|user| { | |
| 51 | 51 | if repo.fork_of.is_some() { | |
| 88 | 88 | .map(Repo::from)) | |
| 89 | 89 | } | |
| 90 | 90 | ||
| 91 | − | /// Repos the viewer may see, newest first. Excludes attempt forks. | |
| 91 | + | /// Repos the viewer may see, newest first. Excludes pull request forks. | |
| 92 | 92 | /// With `member_only`, only repos in the viewer's own workspaces. | |
| 93 | 93 | pub async fn list( | |
| 94 | 94 | &self, |
| 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 | |
| 2 | 2 | # the toolchains an agent needs to build and test what it changes. | |
| 3 | 3 | # Build context: the repository root. | |
| 4 | 4 | ||
| 26 | 26 | RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ | |
| 27 | 27 | | sh -s -- -y --profile minimal --default-toolchain stable | |
| 28 | 28 | 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 | |
| 29 | 32 | ENTRYPOINT ["g1t-runner"] |
| 3 | 3 | ||
| 4 | 4 | import { | |
| 5 | 5 | type AgentModel, | |
| 6 | − | type Attempt, | |
| 7 | − | type Intent, | |
| 6 | + | type Issue, | |
| 7 | + | type Pull, | |
| 8 | + | type RepoPath, | |
| 8 | 9 | type Result, | |
| 9 | 10 | type RunHostedInput, | |
| 10 | 11 | type RunnerApi, | |
| 52 | 53 | /** How g1t's own agent is labelled. What runs behind it is g1t's choice. */ | |
| 53 | 54 | const AGENT = "g1t-agent"; | |
| 54 | 55 | ||
| 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> }; | |
| 56 | 59 | ||
| 57 | 60 | /** | |
| 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, | |
| 59 | 62 | * which does the work and exits; this class only starts it and cleans up | |
| 60 | 63 | * if it dies without reporting. | |
| 61 | 64 | */ | |
| 63 | 66 | sleepAfter = "45m"; | |
| 64 | 67 | ||
| 65 | 68 | 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 }); | |
| 71 | 72 | } | |
| 72 | 73 | ||
| 73 | 74 | override async onStop({ exitCode }: StopParams): Promise<void> { | |
| 74 | 75 | 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 | |
| 77 | 78 | // 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); | |
| 80 | 81 | } | |
| 81 | 82 | } | |
| 82 | 83 | ||
| 100 | 101 | return vars; | |
| 101 | 102 | } | |
| 102 | 103 | ||
| 103 | − | function buildPrompt(intent: Intent, instructions: string): string { | |
| 104 | + | function buildPrompt(issue: Issue, instructions: string): string { | |
| 104 | 105 | const parts = [ | |
| 105 | 106 | "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, | |
| 108 | 109 | ]; | |
| 109 | − | if (intent.checks.length > 0) { | |
| 110 | + | if (issue.checks.length > 0) { | |
| 110 | 111 | 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")}`, | |
| 112 | 113 | ); | |
| 113 | 114 | } | |
| 114 | 115 | if (instructions) parts.push(instructions); | |
| 115 | 116 | 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.", | |
| 117 | 118 | ); | |
| 118 | 119 | return parts.filter(Boolean).join("\n\n"); | |
| 119 | 120 | } | |
| 150 | 151 | ||
| 151 | 152 | async run( | |
| 152 | 153 | actor: User, | |
| 153 | − | intentId: string, | |
| 154 | + | repo: RepoPath, | |
| 155 | + | issueNumber: number, | |
| 154 | 156 | input: RunHostedInput, | |
| 155 | − | ): Promise<Result<Attempt[]>> { | |
| 157 | + | ): Promise<Result<Pull[]>> { | |
| 156 | 158 | if (!this.allowed(actor)) { | |
| 157 | 159 | return fail("forbidden", "g1t agents are not enabled for your account."); | |
| 158 | 160 | } | |
| 165 | 167 | const identity = identityClient(this.env.IDENTITY); | |
| 166 | 168 | const work = workClient(this.env.WORK); | |
| 167 | 169 | ||
| 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[] = []; | |
| 169 | 176 | 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, | |
| 171 | 179 | agent: AGENT, | |
| 172 | 180 | runtime: "hosted", | |
| 173 | 181 | }); | |
| 174 | 182 | // 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); | |
| 178 | 186 | ||
| 179 | − | const found = await work.getAttempt(attempt.id, actor); | |
| 180 | − | if (!found.ok) return found; | |
| 181 | 187 | // The sandbox acts as the person who started it, through a token | |
| 182 | 188 | // that only lives as long as a run can. | |
| 183 | 189 | const { token } = await identity.createAccessToken( | |
| 184 | 190 | actor, | |
| 185 | − | `Hosted attempt ${attempt.id}`, | |
| 191 | + | `g1t agent on ${repo.namespace}/${repo.name}#${pull.number}`, | |
| 186 | 192 | TOKEN_TTL_SECONDS, | |
| 187 | 193 | ); | |
| 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)); | |
| 189 | 195 | await sandbox.run({ | |
| 190 | 196 | actor, | |
| 191 | − | attemptId: attempt.id, | |
| 197 | + | repo, | |
| 198 | + | number: pull.number, | |
| 192 | 199 | envVars: { | |
| 193 | 200 | G1T_API: "https://api.g1t.sh", | |
| 194 | 201 | G1T_TOKEN: token, | |
| 195 | 202 | 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, | |
| 200 | 208 | ...modelEnv(this.env, model), | |
| 201 | 209 | }, | |
| 202 | 210 | }); | |
| 203 | 211 | } | |
| 204 | − | return ok(attempts); | |
| 212 | + | return ok(pulls); | |
| 205 | 213 | } | |
| 206 | 214 | } |
| 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 ( | |
| 2 | 11 | id TEXT PRIMARY KEY, | |
| 3 | 12 | repo_id TEXT NOT NULL, | |
| 4 | 13 | number INTEGER NOT NULL, | |
| 5 | 14 | 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 '[]', | |
| 7 | 18 | -- JSON array of commands. | |
| 8 | 19 | 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, | |
| 10 | 25 | author_id TEXT NOT NULL, | |
| 11 | 26 | 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, | |
| 13 | 30 | UNIQUE (repo_id, number) | |
| 14 | 31 | ); | |
| 32 | + | CREATE INDEX issues_by_state ON issues (repo_id, state, number); | |
| 15 | 33 | ||
| 16 | − | CREATE TABLE attempts ( | |
| 34 | + | CREATE TABLE pulls ( | |
| 17 | 35 | id TEXT PRIMARY KEY, | |
| 18 | − | intent_id TEXT NOT NULL REFERENCES intents (id), | |
| 19 | 36 | repo_id TEXT NOT NULL, | |
| 20 | 37 | 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, | |
| 21 | 43 | agent TEXT NOT NULL, | |
| 22 | 44 | runtime TEXT NOT NULL, | |
| 23 | − | status TEXT NOT NULL DEFAULT 'working', | |
| 24 | − | summary TEXT, | |
| 45 | + | status TEXT NOT NULL DEFAULT 'draft', | |
| 25 | 46 | fork_repo_id TEXT NOT NULL UNIQUE, | |
| 26 | 47 | fork_namespace TEXT NOT NULL, | |
| 27 | 48 | fork_name TEXT NOT NULL, | |
| 28 | 49 | 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) | |
| 34 | 61 | ); | |
| 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); | |
| 36 | 65 | ||
| 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 | + | ||
| 37 | 78 | CREATE TABLE session_entries ( | |
| 38 | − | attempt_id TEXT NOT NULL REFERENCES attempts (id), | |
| 79 | + | pull_id TEXT NOT NULL REFERENCES pulls (id), | |
| 39 | 80 | seq INTEGER NOT NULL, | |
| 40 | 81 | kind TEXT NOT NULL, | |
| 41 | 82 | text TEXT NOT NULL, | |
| 42 | 83 | tool TEXT, | |
| 43 | 84 | -- The fork's head when the entry was recorded: links reasoning to code. | |
| 44 | 85 | "commit" TEXT, | |
| 45 | − | at INTEGER NOT NULL, | |
| 46 | − | PRIMARY KEY (attempt_id, seq) | |
| 86 | + | at TEXT NOT NULL, | |
| 87 | + | PRIMARY KEY (pull_id, seq) | |
| 47 | 88 | ); |
| 1 | − | -- What the branch pointed to before a shipped attempt landed. | |
| 2 | − | ALTER TABLE attempts ADD COLUMN landed_base TEXT; |
| 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); |
| 1 | − | //! The work service: intents, attempts and sessions. | |
| 1 | + | //! The work service: issues, pull requests, comments and sessions. | |
| 2 | 2 | //! | |
| 3 | 3 | //! Other services reach it over `POST /rpc/<method>`; see | |
| 4 | 4 | //! `g1t_contracts::work` for the methods and their arguments. It also | |
| 7 | 7 | mod rows; | |
| 8 | 8 | ||
| 9 | 9 | use g1t_contracts::events::{ | |
| 10 | − | AttemptEvent, Delivered, IntentClosed, IntentOpened, NewEvent, SessionAppended, | |
| 10 | + | CommentCreated, Delivered, IssueEvent, NewEvent, PullEvent, SessionAppended, | |
| 11 | 11 | }; | |
| 12 | − | use g1t_contracts::repos::{ForkArgs, GetArgs, GetByIdArgs, LandArgs, Landed, Repo}; | |
| 12 | + | use g1t_contracts::repos::{ForkArgs, GetArgs, LandArgs, Landed, Repo, RepoPath}; | |
| 13 | 13 | use g1t_contracts::time::rfc3339; | |
| 14 | 14 | use g1t_contracts::work::*; | |
| 15 | 15 | use g1t_contracts::{FailureCode, Outcome, User, Viewer, new_id}; | |
| 20 | 20 | Context, D1Database, Env, Fetcher, MessageBatch, MessageExt, Request, Response, Result, event, | |
| 21 | 21 | }; | |
| 22 | 22 | ||
| 23 | − | use rows::{AttemptRow, IntentRow, SessionRow}; | |
| 23 | + | use rows::{CommentRow, IssueRow, NumberRow, PullRow, SessionRow, ValueRow}; | |
| 24 | 24 | ||
| 25 | 25 | const SOURCE: &str = "work"; | |
| 26 | 26 | const MAX_ENTRY_BATCH: usize = 200; | |
| 27 | 27 | const MAX_ENTRY_CHARS: usize = 64_000; | |
| 28 | + | const MAX_TITLE_CHARS: usize = 200; | |
| 28 | 29 | const SESSION_PAGE: u32 = 500; | |
| 30 | + | const LIST_PAGE: u32 = 100; | |
| 29 | 31 | const UNVERIFIED: &str = "Confirm your email address first. Check your inbox, or resend the link from the banner on g1t.sh."; | |
| 30 | 32 | ||
| 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"; | |
| 33 | 37 | ||
| 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.") | |
| 36 | 40 | } | |
| 37 | 41 | ||
| 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.") | |
| 40 | 44 | } | |
| 41 | 45 | ||
| 42 | 46 | fn optional(value: &Option<String>) -> JsValue { | |
| 43 | 47 | value.as_deref().map_or(JsValue::NULL, JsValue::from) | |
| 44 | 48 | } | |
| 45 | 49 | ||
| 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 | + | ||
| 46 | 84 | struct Work { | |
| 47 | 85 | db: D1Database, | |
| 48 | 86 | repos: Fetcher, | |
| 51 | 89 | } | |
| 52 | 90 | ||
| 53 | 91 | 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 | + | }; | |
| 55 | 106 | js::call(&self.events, "publish", &[js::to_js(&[event])?]).await?; | |
| 56 | 107 | Ok(()) | |
| 57 | 108 | } | |
| 58 | 109 | ||
| 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>> { | |
| 64 | 113 | g1t_kit::call( | |
| 65 | 114 | &self.repos, | |
| 66 | 115 | "get", | |
| 72 | 121 | .await | |
| 73 | 122 | } | |
| 74 | 123 | ||
| 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())) | |
| 85 | 139 | } | |
| 86 | 140 | ||
| 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>> { | |
| 88 | 142 | Ok(self | |
| 89 | 143 | .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) | |
| 93 | 149 | .await? | |
| 94 | − | .map(Intent::from)) | |
| 150 | + | .map(Issue::from)) | |
| 95 | 151 | } | |
| 96 | 152 | ||
| 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>> { | |
| 98 | 154 | Ok(self | |
| 99 | 155 | .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() | |
| 103 | 171 | .await? | |
| 104 | − | .map(Attempt::from)) | |
| 172 | + | .results::<CommentRow>()?; | |
| 173 | + | Ok(rows.into_iter().map(Comment::from).collect()) | |
| 105 | 174 | } | |
| 106 | 175 | ||
| 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()); | |
| 111 | 185 | }; | |
| 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() | |
| 122 | 214 | } | |
| 123 | − | Ok(Outcome::Ok(attempt)) | |
| 124 | 215 | } | |
| 125 | 216 | ||
| 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() | |
| 132 | 224 | } | |
| 133 | 225 | } | |
| 134 | 226 | ||
| 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>> { | |
| 136 | 230 | if !a.actor.verified { | |
| 137 | 231 | return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED)); | |
| 138 | 232 | } | |
| 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 { | |
| 141 | 238 | return Ok(Outcome::fail( | |
| 142 | 239 | FailureCode::Invalid, | |
| 143 | − | "An intent needs a title.", | |
| 240 | + | "An issue can have up to 10 labels of up to 40 characters each.", | |
| 144 | 241 | )); | |
| 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)), | |
| 149 | 242 | }; | |
| 243 | + | let repo = check!(self.repo(&a.repo, &Some(a.actor.clone())).await?); | |
| 150 | 244 | let checks: Vec<&str> = a | |
| 151 | 245 | .checks | |
| 152 | 246 | .iter() | |
| 155 | 249 | .collect(); | |
| 156 | 250 | ||
| 157 | 251 | 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); | |
| 161 | 255 | self.db | |
| 162 | 256 | .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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 167 | 261 | ) | |
| 168 | 262 | .bind(&[ | |
| 169 | 263 | id.as_str().into(), | |
| 170 | 264 | repo.id.as_str().into(), | |
| 265 | + | number.into(), | |
| 171 | 266 | title.into(), | |
| 172 | − | a.brief.trim().into(), | |
| 267 | + | a.body.trim().into(), | |
| 268 | + | serde_json::to_string(&labels)?.into(), | |
| 173 | 269 | serde_json::to_string(&checks)?.into(), | |
| 174 | 270 | a.actor.id.as_str().into(), | |
| 175 | 271 | 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(), | |
| 178 | 274 | ])? | |
| 179 | 275 | .run() | |
| 180 | 276 | .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()); | |
| 183 | 279 | }; | |
| 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) | |
| 194 | 287 | }, | |
| 195 | − | }) | |
| 288 | + | ) | |
| 196 | 289 | .await?; | |
| 197 | − | Ok(Outcome::Ok(intent)) | |
| 290 | + | Ok(Outcome::Ok(issue)) | |
| 198 | 291 | } | |
| 199 | 292 | ||
| 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()); | |
| 209 | 300 | let rows = self | |
| 210 | 301 | .db | |
| 211 | 302 | .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 ?" | |
| 215 | 308 | )) | |
| 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 | + | ])? | |
| 217 | 317 | .all() | |
| 218 | 318 | .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())) | |
| 221 | 321 | } | |
| 222 | 322 | ||
| 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 | |
| 229 | 326 | .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()])? | |
| 243 | 329 | .all() | |
| 244 | 330 | .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, | |
| 249 | 336 | })) | |
| 250 | 337 | } | |
| 251 | 338 | ||
| 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) { | |
| 264 | 348 | return Ok(Outcome::fail( | |
| 265 | 349 | 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.", | |
| 267 | 351 | )); | |
| 268 | 352 | } | |
| 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 { | |
| 270 | 406 | return Ok(Outcome::fail( | |
| 271 | 407 | FailureCode::Conflict, | |
| 272 | − | "This intent is already closed.", | |
| 408 | + | "This issue is already closed.", | |
| 273 | 409 | )); | |
| 274 | 410 | } | |
| 411 | + | let reason = a.reason.unwrap_or(IssueReason::Completed); | |
| 412 | + | let now = rfc3339(now_ms()); | |
| 275 | 413 | 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 | + | ])? | |
| 278 | 424 | .run() | |
| 279 | 425 | .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) | |
| 289 | 433 | }, | |
| 290 | − | }) | |
| 434 | + | ) | |
| 291 | 435 | .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)) | |
| 294 | 475 | } | |
| 295 | 476 | ||
| 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>> { | |
| 297 | 522 | if !a.actor.verified { | |
| 298 | 523 | return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED)); | |
| 299 | 524 | } | |
| 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 { | |
| 304 | 545 | 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.", | |
| 307 | 548 | )); | |
| 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)); | |
| 308 | 605 | } | |
| 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 | + | }; | |
| 309 | 628 | let agent = match a.agent.trim() { | |
| 310 | 629 | "" => "agent", | |
| 311 | 630 | agent => agent, | |
| 312 | 631 | }; | |
| 313 | 632 | let runtime = match a.runtime { | |
| 314 | − | AttemptRuntime::Hosted => "hosted", | |
| 315 | − | AttemptRuntime::External => "external", | |
| 633 | + | Runtime::Hosted => "hosted", | |
| 634 | + | Runtime::External => "external", | |
| 316 | 635 | }; | |
| 317 | 636 | ||
| 318 | 637 | let now = now_ms(); | |
| 319 | − | let id = new_id("att", now); | |
| 638 | + | let id = new_id("pr", now); | |
| 320 | 639 | let fork: Outcome<Repo> = g1t_kit::call( | |
| 321 | 640 | &self.repos, | |
| 322 | − | "fork_for_attempt", | |
| 641 | + | "fork_for_pull", | |
| 323 | 642 | &ForkArgs { | |
| 324 | − | source_id: intent.repo_id.clone(), | |
| 325 | − | attempt_id: id.clone(), | |
| 643 | + | source_id: repo.id.clone(), | |
| 644 | + | pull_id: id.clone(), | |
| 326 | 645 | actor: a.actor.clone(), | |
| 327 | 646 | }, | |
| 328 | 647 | ) | |
| 329 | 648 | .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); | |
| 338 | 650 | ||
| 651 | + | let number = self.next_number(&repo.id).await?; | |
| 339 | 652 | let timestamp = rfc3339(now); | |
| 340 | 653 | self.db | |
| 341 | 654 | .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, | |
| 345 | 658 | created_at, updated_at) | |
| 346 | − | SELECT ?, ?, ?, COALESCE(MAX(number), 0) + 1, ?, ?, ?, ?, ?, ?, ?, ?, ? | |
| 347 | − | FROM attempts WHERE intent_id = ?", | |
| 659 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 348 | 660 | ) | |
| 349 | 661 | .bind(&[ | |
| 350 | 662 | 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(), | |
| 353 | 668 | agent.into(), | |
| 354 | 669 | runtime.into(), | |
| 355 | 670 | fork.id.into(), | |
| 359 | 674 | a.actor.username.as_str().into(), | |
| 360 | 675 | timestamp.as_str().into(), | |
| 361 | 676 | timestamp.as_str().into(), | |
| 362 | − | intent.id.as_str().into(), | |
| 363 | 677 | ])? | |
| 364 | 678 | .run() | |
| 365 | 679 | .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()); | |
| 368 | 682 | }; | |
| 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) | |
| 377 | 690 | }, | |
| 378 | − | }) | |
| 691 | + | ) | |
| 379 | 692 | .await?; | |
| 380 | − | Ok(Outcome::Ok(attempt)) | |
| 693 | + | Ok(Outcome::Ok(pull)) | |
| 381 | 694 | } | |
| 382 | 695 | ||
| 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 => "", | |
| 386 | 702 | }; | |
| 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, | |
| 392 | 720 | }; | |
| 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 | + | })) | |
| 394 | 726 | } | |
| 395 | 727 | ||
| 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( | |
| 398 | 731 | &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) { | |
| 407 | 738 | 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( | |
| 408 | 745 | FailureCode::Conflict, | |
| 409 | − | format!("This attempt is already {}.", attempt.status.as_str()), | |
| 746 | + | format!("This pull request is already {}.", pull.status.as_str()), | |
| 410 | 747 | )); | |
| 411 | 748 | } | |
| 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?); | |
| 412 | 756 | let summary = Some(a.summary.trim().to_owned()).filter(|summary| !summary.is_empty()); | |
| 413 | 757 | let now = rfc3339(now_ms()); | |
| 414 | 758 | self.db | |
| 415 | 759 | .prepare( | |
| 416 | − | "UPDATE attempts SET status = ?, summary = COALESCE(?, summary), updated_at = ? | |
| 760 | + | "UPDATE pulls SET status = 'open', body = COALESCE(?, body), updated_at = ? | |
| 417 | 761 | WHERE id = ?", | |
| 418 | 762 | ) | |
| 419 | 763 | .bind(&[ | |
| 420 | − | status.as_str().into(), | |
| 421 | 764 | optional(&summary), | |
| 422 | 765 | now.as_str().into(), | |
| 423 | − | attempt.id.as_str().into(), | |
| 766 | + | pull.id.as_str().into(), | |
| 424 | 767 | ])? | |
| 425 | 768 | .run() | |
| 426 | 769 | .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 | + | ) | |
| 442 | 799 | .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)) | |
| 447 | 803 | } | |
| 448 | 804 | ||
| 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>> { | |
| 454 | 810 | 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 | + | } | |
| 457 | 826 | } | |
| 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, | |
| 466 | 833 | }; | |
| 467 | − | if intent.status != IntentStatus::Open { | |
| 468 | − | return Ok(Outcome::fail( | |
| 469 | − | FailureCode::Conflict, | |
| 470 | − | "This intent is already closed.", | |
| 471 | − | )); | |
| 472 | − | } | |
| 473 | 834 | ||
| 835 | + | // Whether the actor may write to the repository is decided by repos. | |
| 474 | 836 | let landed: Outcome<Landed> = g1t_kit::call( | |
| 475 | 837 | &self.repos, | |
| 476 | 838 | "land", | |
| 477 | 839 | &LandArgs { | |
| 478 | − | fork_id: attempt.fork_repo_id.clone(), | |
| 840 | + | fork_id: pull.fork_repo_id.clone(), | |
| 479 | 841 | actor: a.actor.clone(), | |
| 480 | 842 | }, | |
| 481 | 843 | ) | |
| 482 | 844 | .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); | |
| 487 | 846 | ||
| 488 | 847 | 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( | |
| 491 | 867 | self.db | |
| 492 | 868 | .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 = ? | |
| 495 | 872 | WHERE id = ?", | |
| 496 | 873 | ) | |
| 497 | 874 | .bind(&[ | |
| 498 | − | landed.commit.as_str().into(), | |
| 499 | − | optional(&landed.previous), | |
| 875 | + | pull.number.into(), | |
| 876 | + | now.as_str().into(), | |
| 500 | 877 | now.as_str().into(), | |
| 501 | − | attempt.id.as_str().into(), | |
| 878 | + | issue.id.as_str().into(), | |
| 502 | 879 | ])?, | |
| 880 | + | ); | |
| 881 | + | statements.push( | |
| 503 | 882 | 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 { | |
| 514 | 902 | commit: Some(landed.commit.clone()), | |
| 515 | − | ..Self::attempt_event(&attempt) | |
| 903 | + | ..Self::pull_event(&pull) | |
| 516 | 904 | }, | |
| 517 | − | }) | |
| 905 | + | ) | |
| 518 | 906 | .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 | + | } | |
| 531 | 920 | ||
| 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)) | |
| 537 | 928 | } | |
| 538 | 929 | ||
| 539 | − | async fn list_active_attempts(&self, a: ViewerArgs) -> Result<Vec<AttemptDetail>> { | |
| 930 | + | async fn list_active_pulls(&self, a: ViewerArgs) -> Result<Vec<ActivePull>> { | |
| 540 | 931 | let Some(viewer) = a.viewer else { | |
| 541 | 932 | return Ok(Vec::new()); | |
| 542 | 933 | }; | |
| 543 | 934 | let rows = self | |
| 544 | 935 | .db | |
| 545 | 936 | .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') | |
| 548 | 939 | ORDER BY updated_at DESC LIMIT 50", | |
| 549 | 940 | ) | |
| 550 | 941 | .bind(&[viewer.id.into()])? | |
| 551 | 942 | .all() | |
| 552 | 943 | .await? | |
| 553 | − | .results::<AttemptRow>()?; | |
| 944 | + | .results::<PullRow>()?; | |
| 554 | 945 | 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 }); | |
| 559 | 952 | } | |
| 560 | 953 | Ok(active) | |
| 561 | 954 | } | |
| 562 | 955 | ||
| 956 | + | // --- Sessions ---------------------------------------------------------- | |
| 957 | + | ||
| 563 | 958 | async fn append_session(&self, a: AppendSessionArgs) -> Result<Outcome<Appended>> { | |
| 564 | 959 | if a.entries.is_empty() { | |
| 565 | 960 | return Ok(Outcome::Ok(Appended { count: 0 })); | |
| 570 | 965 | format!("Send at most {MAX_ENTRY_BATCH} entries at a time."), | |
| 571 | 966 | )); | |
| 572 | 967 | } | |
| 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 | + | } | |
| 577 | 976 | ||
| 578 | 977 | let now = rfc3339(now_ms()); | |
| 579 | 978 | let count = a.entries.len() as u32; | |
| 586 | 985 | statements.push( | |
| 587 | 986 | self.db | |
| 588 | 987 | .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) | |
| 590 | 989 | SELECT ?, COALESCE(MAX(seq), 0) + 1, ?, ?, ?, ?, ? | |
| 591 | − | FROM session_entries WHERE attempt_id = ?", | |
| 990 | + | FROM session_entries WHERE pull_id = ?", | |
| 592 | 991 | ) | |
| 593 | 992 | .bind(&[ | |
| 594 | − | attempt.id.as_str().into(), | |
| 993 | + | pull.id.as_str().into(), | |
| 595 | 994 | kind.as_str().unwrap_or("note").into(), | |
| 596 | 995 | text.into(), | |
| 597 | 996 | optional(&entry.tool), | |
| 598 | − | optional(&entry.commit.or_else(|| attempt.head_commit.clone())), | |
| 997 | + | optional(&entry.commit.or_else(|| pull.head_commit.clone())), | |
| 599 | 998 | now.as_str().into(), | |
| 600 | − | attempt.id.as_str().into(), | |
| 999 | + | pull.id.as_str().into(), | |
| 601 | 1000 | ])?, | |
| 602 | 1001 | ); | |
| 603 | 1002 | } | |
| 604 | 1003 | statements.push( | |
| 605 | 1004 | 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()])?, | |
| 608 | 1007 | ); | |
| 609 | 1008 | 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, | |
| 618 | 1017 | count, | |
| 619 | 1018 | }, | |
| 620 | − | }) | |
| 1019 | + | ) | |
| 621 | 1020 | .await?; | |
| 622 | 1021 | Ok(Outcome::Ok(Appended { count })) | |
| 623 | 1022 | } | |
| 624 | 1023 | ||
| 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?); | |
| 631 | 1026 | let rows = self | |
| 632 | 1027 | .db | |
| 633 | 1028 | .prepare( | |
| 634 | 1029 | "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 ?", | |
| 636 | 1031 | ) | |
| 637 | − | .bind(&[attempt_id.into(), after_seq.into(), SESSION_PAGE.into()])? | |
| 1032 | + | .bind(&[pull.id.into(), a.after_seq.into(), SESSION_PAGE.into()])? | |
| 638 | 1033 | .all() | |
| 639 | 1034 | .await? | |
| 640 | 1035 | .results::<SessionRow>()?; | |
| 643 | 1038 | )) | |
| 644 | 1039 | } | |
| 645 | 1040 | ||
| 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. | |
| 647 | 1042 | async fn on_event(&self, event: &Delivered) -> Result<()> { | |
| 648 | 1043 | if event.kind != "git.push" { | |
| 649 | 1044 | return Ok(()); | |
| 653 | 1048 | return Ok(()); | |
| 654 | 1049 | }; | |
| 655 | 1050 | 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 | + | ) | |
| 657 | 1055 | .bind(&[after.into(), rfc3339(now_ms()).into(), repo_id.into()])? | |
| 658 | 1056 | .run() | |
| 659 | 1057 | .await?; | |
| 678 | 1076 | let work = service(&env)?; | |
| 679 | 1077 | ||
| 680 | 1078 | 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?), | |
| 699 | 1095 | "append_session" => reply(&work.append_session(args(body)?).await?), | |
| 700 | 1096 | "read_session" => reply(&work.read_session(args(body)?).await?), | |
| 701 | 1097 | _ => Response::error("Unknown method", 404), |
| 3 | 3 | use g1t_contracts::User; | |
| 4 | 4 | use g1t_contracts::repos::RepoPath; | |
| 5 | 5 | use g1t_contracts::work::{ | |
| 6 | − | Attempt, AttemptRuntime, AttemptStatus, Intent, IntentStatus, SessionEntry, SessionEntryKind, | |
| 6 | + | Comment, Issue, IssueReason, Pull, PullStatus, Runtime, SessionEntry, SessionEntryKind, State, | |
| 7 | 7 | }; | |
| 8 | 8 | use serde::Deserialize; | |
| 9 | 9 | ||
| 10 | + | fn user(id: String, username: String) -> User { | |
| 11 | + | User { | |
| 12 | + | id, | |
| 13 | + | username, | |
| 14 | + | ..User::default() | |
| 15 | + | } | |
| 16 | + | } | |
| 17 | + | ||
| 10 | 18 | #[derive(Deserialize)] | |
| 11 | − | pub struct IntentRow { | |
| 19 | + | pub struct IssueRow { | |
| 12 | 20 | pub id: String, | |
| 13 | 21 | pub repo_id: String, | |
| 14 | 22 | pub number: u32, | |
| 15 | 23 | pub title: String, | |
| 16 | − | pub brief: String, | |
| 24 | + | pub body: String, | |
| 25 | + | /// JSON array of label names. | |
| 26 | + | pub labels: String, | |
| 17 | 27 | /// JSON array of commands. | |
| 18 | 28 | pub checks: String, | |
| 19 | − | pub status: IntentStatus, | |
| 29 | + | pub state: State, | |
| 30 | + | pub reason: Option<IssueReason>, | |
| 31 | + | pub resolved_by: Option<u32>, | |
| 20 | 32 | pub author_id: String, | |
| 21 | 33 | pub author_name: String, | |
| 22 | 34 | 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, | |
| 24 | 39 | } | |
| 25 | 40 | ||
| 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 { | |
| 29 | 44 | id: row.id, | |
| 30 | 45 | repo_id: row.repo_id, | |
| 31 | 46 | number: row.number, | |
| 32 | 47 | title: row.title, | |
| 33 | − | brief: row.brief, | |
| 48 | + | body: row.body, | |
| 49 | + | labels: serde_json::from_str(&row.labels).unwrap_or_default(), | |
| 34 | 50 | 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), | |
| 41 | 55 | 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, | |
| 43 | 60 | } | |
| 44 | 61 | } | |
| 45 | 62 | } | |
| 46 | 63 | ||
| 47 | 64 | #[derive(Deserialize)] | |
| 48 | − | pub struct AttemptRow { | |
| 65 | + | pub struct PullRow { | |
| 49 | 66 | pub id: String, | |
| 50 | − | pub intent_id: String, | |
| 51 | 67 | pub repo_id: String, | |
| 52 | 68 | pub number: u32, | |
| 69 | + | pub issue_number: Option<u32>, | |
| 70 | + | pub title: String, | |
| 71 | + | pub body: Option<String>, | |
| 53 | 72 | pub agent: String, | |
| 54 | − | pub runtime: AttemptRuntime, | |
| 55 | − | pub status: AttemptStatus, | |
| 56 | − | pub summary: Option<String>, | |
| 73 | + | pub runtime: Runtime, | |
| 74 | + | pub status: PullStatus, | |
| 57 | 75 | pub fork_repo_id: String, | |
| 58 | 76 | pub fork_namespace: String, | |
| 59 | 77 | pub fork_name: String, | |
| 60 | 78 | 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, | |
| 64 | 85 | pub created_at: String, | |
| 65 | 86 | pub updated_at: String, | |
| 66 | 87 | } | |
| 67 | 88 | ||
| 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 { | |
| 71 | 92 | id: row.id, | |
| 72 | − | intent_id: row.intent_id, | |
| 73 | 93 | repo_id: row.repo_id, | |
| 74 | 94 | number: row.number, | |
| 95 | + | issue: row.issue_number, | |
| 96 | + | title: row.title, | |
| 97 | + | body: row.body, | |
| 75 | 98 | agent: row.agent, | |
| 76 | 99 | runtime: row.runtime, | |
| 77 | 100 | status: row.status, | |
| 78 | − | summary: row.summary, | |
| 79 | 101 | fork: RepoPath { | |
| 80 | 102 | namespace: row.fork_namespace, | |
| 81 | 103 | name: row.fork_name, | |
| 82 | 104 | }, | |
| 83 | 105 | fork_repo_id: row.fork_repo_id, | |
| 84 | 106 | 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), | |
| 91 | 112 | created_at: row.created_at, | |
| 92 | 113 | updated_at: row.updated_at, | |
| 93 | 114 | } | |
| 95 | 116 | } | |
| 96 | 117 | ||
| 97 | 118 | #[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)] | |
| 98 | 139 | pub struct SessionRow { | |
| 99 | 140 | pub seq: u32, | |
| 100 | 141 | pub kind: SessionEntryKind, | |
| 116 | 157 | } | |
| 117 | 158 | } | |
| 118 | 159 | } | |
| 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 | + | } |