API and MCP server in Rust; a public index at the API root
- apps/api is a Rust Worker. One enum of operations drives the REST routes, the MCP tools and the OpenAPI document, so adding an operation without describing or running it does not compile - GET api.g1t.sh/ returns where everything is, as URL templates, the way api.github.com does - mcp.g1t.sh opened in a browser describes the server, how to connect and its tools. Unauthenticated calls are refused in JSON-RPC shape with the pointer that makes a client sign in - the MCP server's instructions describe issues and pull requests; the old ones still spoke of intents and attempts - OAuth client ids are encoded in Rust and decoded by the site; a test pins the two to the same bytes
24 files+2215−13170/24 viewed
| 816 | 816 | version = "0.1.0" | |
| 817 | 817 | ||
| 818 | 818 | [[package]] | |
| 819 | + | name = "g1t-api" | |
| 820 | + | version = "0.1.0" | |
| 821 | + | dependencies = [ | |
| 822 | + | "base64 0.22.1", | |
| 823 | + | "form_urlencoded", | |
| 824 | + | "g1t-contracts", | |
| 825 | + | "g1t-kit", | |
| 826 | + | "serde", | |
| 827 | + | "serde_json", | |
| 828 | + | "worker", | |
| 829 | + | ] | |
| 830 | + | ||
| 831 | + | [[package]] | |
| 819 | 832 | name = "g1t-contracts" | |
| 820 | 833 | version = "0.1.0" | |
| 821 | 834 | dependencies = [ | |
| 2380 | 2393 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2381 | 2394 | checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" | |
| 2382 | 2395 | dependencies = [ | |
| 2396 | + | "indexmap", | |
| 2383 | 2397 | "itoa", | |
| 2384 | 2398 | "memchr", | |
| 2385 | 2399 | "serde", |
| 1 | 1 | [workspace] | |
| 2 | 2 | resolver = "3" | |
| 3 | − | members = ["crates/*", "services/events", "services/identity", "services/repos", "services/work"] | |
| 3 | + | members = ["apps/api", "crates/*", "services/events", "services/identity", "services/repos", "services/work"] | |
| 4 | 4 | ||
| 5 | 5 | [workspace.package] | |
| 6 | 6 | edition = "2024" |
| 60 | 60 | | --- | --- | | |
| 61 | 61 | | `apps/web` | The site: server-rendered React on a Worker. Holds no data. | | |
| 62 | 62 | | `apps/docs` | The documentation site, with the API explorer. | | |
| 63 | − | | `apps/api` | REST API and MCP server. | | |
| 63 | + | | `apps/api` | REST API and MCP server. Rust. | | |
| 64 | 64 | | `services/identity` | Accounts, workspaces, sessions, keys and tokens. Rust. | | |
| 65 | 65 | | `services/repos` | Repository registry, contents, forks, diffs, landing, git over HTTPS. Rust. | | |
| 66 | 66 | | `services/work` | Issues, pull requests, comments and sessions. Rust. | | |
| 74 | 74 | | `packages/theme` | Design tokens and the logo, shared by the site and the docs. | | |
| 75 | 75 | ||
| 76 | 76 | Each service is its own Worker with its own database. They call each other | |
| 77 | − | through service bindings and react to each other through events. Anything | |
| 78 | − | that is not a web UI is written in Rust or on its way there; the API is | |
| 79 | − | next. | |
| 77 | + | through service bindings and react to each other through events. Everything | |
| 78 | + | that is not a web UI is written in Rust, except the small Worker that | |
| 79 | + | starts sandboxes, which uses a TypeScript-only Cloudflare library. | |
| 80 | 80 | ||
| 81 | 81 | ## Run your own | |
| 82 | 82 | ||
| 106 | 106 | (cd services/identity && npx wrangler deploy) | |
| 107 | 107 | (cd services/repos && npx wrangler deploy) | |
| 108 | 108 | (cd services/work && npx wrangler deploy) | |
| 109 | + | (cd apps/api && npx wrangler deploy) | |
| 109 | 110 | npm run deploy | |
| 110 | 111 | (cd services/runner && npx wrangler deploy) # optional: g1t agents | |
| 111 | 112 | (cd apps/docs && npm run deploy) |
| 1 | + | [package] | |
| 2 | + | name = "g1t-api" | |
| 3 | + | version = "0.1.0" | |
| 4 | + | edition.workspace = true | |
| 5 | + | license.workspace = true | |
| 6 | + | description = "The REST API and MCP server." | |
| 7 | + | ||
| 8 | + | [lib] | |
| 9 | + | crate-type = ["cdylib"] | |
| 10 | + | ||
| 11 | + | [dependencies] | |
| 12 | + | g1t-contracts.workspace = true | |
| 13 | + | g1t-kit.workspace = true | |
| 14 | + | serde.workspace = true | |
| 15 | + | serde_json = { workspace = true, features = ["preserve_order"] } | |
| 16 | + | worker.workspace = true | |
| 17 | + | base64 = "0.22" | |
| 18 | + | form_urlencoded = "1" |
| 1 | − | { | |
| 2 | − | "name": "@g1t/api", | |
| 3 | − | "version": "0.1.0", | |
| 4 | − | "private": true, | |
| 5 | − | "type": "module", | |
| 6 | − | "license": "MIT", | |
| 7 | − | "scripts": { | |
| 8 | − | "typecheck": "wrangler types --include-env=false && tsc -p tsconfig.json", | |
| 9 | − | "deploy": "wrangler deploy" | |
| 10 | − | }, | |
| 11 | − | "dependencies": { | |
| 12 | − | "@g1t/contracts": "*", | |
| 13 | − | "hono": "^4" | |
| 14 | − | } | |
| 15 | − | } |
| 1 | − | import { Hono } from "hono"; | |
| 2 | − | import { cors } from "hono/cors"; | |
| 3 | − | ||
| 4 | − | import { | |
| 5 | − | type ServiceBinding, | |
| 6 | − | type Viewer, | |
| 7 | − | eventsClient, | |
| 8 | − | httpStatus, | |
| 9 | − | identityClient, | |
| 10 | − | reposClient, | |
| 11 | − | workClient, | |
| 12 | − | } from "@g1t/contracts"; | |
| 13 | − | ||
| 14 | − | import { handleMcp } from "./mcp"; | |
| 15 | − | import { MCP_CHALLENGE, oauth } from "./oauth"; | |
| 16 | − | import { openApiDocument } from "./openapi"; | |
| 17 | − | import { type ApiEnv, operations, operationsByName } from "./operations"; | |
| 18 | − | ||
| 19 | − | type Input = Record<string, unknown>; | |
| 20 | − | /** The Worker's raw bindings; Rust services are reached through clients. */ | |
| 21 | − | type Bindings = { | |
| 22 | − | EVENTS: ServiceBinding; | |
| 23 | − | IDENTITY: ServiceBinding; | |
| 24 | − | REPOS: ServiceBinding; | |
| 25 | − | WORK: ServiceBinding; | |
| 26 | − | }; | |
| 27 | − | type App = { Bindings: Bindings; Variables: { viewer: Viewer; services: ApiEnv } }; | |
| 28 | − | ||
| 29 | − | /** | |
| 30 | − | * REST routes. Each maps an HTTP request onto one operation; `input` builds | |
| 31 | − | * the operation's input from the path, query string and JSON body. | |
| 32 | − | */ | |
| 33 | − | const ROUTES: { | |
| 34 | − | method: "GET" | "POST" | "PATCH"; | |
| 35 | − | path: string; | |
| 36 | − | operation: string; | |
| 37 | − | input?: (params: Record<string, string>, query: Input, body: Input) => Input; | |
| 38 | − | }[] = [ | |
| 39 | − | { method: "GET", path: "/v1/user", operation: "whoami" }, | |
| 40 | − | { method: "POST", path: "/v1/workspaces", operation: "create_workspace", input: (_p, _q, b) => b }, | |
| 41 | − | { method: "GET", path: "/v1/repos", operation: "list_repos", input: (_p, q) => ({ query: q.q }) }, | |
| 42 | − | { method: "POST", path: "/v1/repos", operation: "create_repo", input: (_p, _q, b) => b }, | |
| 43 | − | { method: "GET", path: "/v1/repos/:owner/:name", operation: "get_repo", input: repo }, | |
| 44 | − | { method: "GET", path: "/v1/repos/:owner/:name/events", operation: "list_events", input: (p, q) => ({ ...repo(p), before: q.before }) }, | |
| 45 | − | { method: "GET", path: "/v1/repos/:owner/:name/labels", operation: "list_labels", input: repo }, | |
| 46 | − | { method: "GET", path: "/v1/repos/:owner/:name/issues", operation: "list_issues", input: (p, q) => ({ ...repo(p), state: q.state, label: q.label }) }, | |
| 47 | − | { method: "POST", path: "/v1/repos/:owner/:name/issues", operation: "create_issue", input: (p, _q, b) => ({ ...b, ...repo(p) }) }, | |
| 48 | − | { method: "GET", path: "/v1/repos/:owner/:name/issues/:number", operation: "get_issue", input: numbered }, | |
| 49 | − | { method: "PATCH", path: "/v1/repos/:owner/:name/issues/:number", operation: "update_issue", input: numbered }, | |
| 50 | − | { method: "POST", path: "/v1/repos/:owner/:name/issues/:number/close", operation: "close_issue", input: numbered }, | |
| 51 | − | { method: "POST", path: "/v1/repos/:owner/:name/issues/:number/reopen", operation: "reopen_issue", input: numbered }, | |
| 52 | − | { method: "POST", path: "/v1/repos/:owner/:name/issues/:number/comments", operation: "add_comment", input: numbered }, | |
| 53 | − | { method: "GET", path: "/v1/repos/:owner/:name/pulls", operation: "list_pull_requests", input: (p, q) => ({ ...repo(p), state: q.state }) }, | |
| 54 | − | { method: "POST", path: "/v1/repos/:owner/:name/pulls", operation: "create_pull_request", input: (p, _q, b) => ({ ...b, ...repo(p) }) }, | |
| 55 | − | { method: "GET", path: "/v1/repos/:owner/:name/pulls/:number", operation: "get_pull_request", input: numbered }, | |
| 56 | − | { method: "GET", path: "/v1/repos/:owner/:name/pulls/:number/changes", operation: "get_pull_request_changes", input: numbered }, | |
| 57 | − | { method: "GET", path: "/v1/repos/:owner/:name/pulls/:number/session", operation: "read_session", input: (p, q) => ({ ...numbered(p), after: Number(q.after) || 0 }) }, | |
| 58 | − | { method: "POST", path: "/v1/repos/:owner/:name/pulls/:number/session", operation: "record_session", input: numbered }, | |
| 59 | − | { method: "POST", path: "/v1/repos/:owner/:name/pulls/:number/ready", operation: "mark_pull_request_ready", input: numbered }, | |
| 60 | − | { method: "POST", path: "/v1/repos/:owner/:name/pulls/:number/close", operation: "close_pull_request", input: numbered }, | |
| 61 | − | { method: "POST", path: "/v1/repos/:owner/:name/pulls/:number/merge", operation: "merge_pull_request", input: numbered }, | |
| 62 | − | ]; | |
| 63 | − | ||
| 64 | − | function repo(params: Record<string, string>): Input { | |
| 65 | − | return { repo: `${params.owner}/${params.name}` }; | |
| 66 | − | } | |
| 67 | − | ||
| 68 | − | /** An issue or pull request named in the path, with the body's fields. */ | |
| 69 | − | function numbered(params: Record<string, string>, _query: Input = {}, body: Input = {}): Input { | |
| 70 | − | return { ...body, ...repo(params), number: Number(params.number) }; | |
| 71 | − | } | |
| 72 | − | ||
| 73 | − | /** The section of the API reference an operation is listed under. */ | |
| 74 | − | function tagFor(operation: string): string { | |
| 75 | − | if (operation === "whoami" || operation.includes("workspace")) return "Accounts"; | |
| 76 | − | if (operation.includes("session")) return "Sessions"; | |
| 77 | − | if (operation.includes("pull_request")) return "Pull requests"; | |
| 78 | − | if (/issue|label|comment/.test(operation)) return "Issues"; | |
| 79 | − | return "Repositories"; | |
| 80 | − | } | |
| 81 | − | ||
| 82 | − | const app = new Hono<App>(); | |
| 83 | − | ||
| 84 | − | // The API is called from browsers too: the reference's explorer, and apps | |
| 85 | − | // built on g1t. It carries no cookies, so any origin may call it. | |
| 86 | − | app.use(cors({ | |
| 87 | − | origin: "*", | |
| 88 | − | allowHeaders: ["authorization", "content-type"], | |
| 89 | − | allowMethods: ["GET", "POST", "PATCH", "OPTIONS"], | |
| 90 | − | })); | |
| 91 | − | ||
| 92 | − | // `Authorization: Bearer g1t_…`. A missing token is an anonymous viewer; a | |
| 93 | − | // wrong one is rejected so a typo does not silently look signed out. | |
| 94 | − | app.use(async (c, next) => { | |
| 95 | − | const [scheme, token] = (c.req.header("authorization") ?? "").split(" "); | |
| 96 | − | const services: ApiEnv = { | |
| 97 | − | EVENTS: eventsClient(c.env.EVENTS), | |
| 98 | − | IDENTITY: identityClient(c.env.IDENTITY), | |
| 99 | − | REPOS: reposClient(c.env.REPOS), | |
| 100 | − | WORK: workClient(c.env.WORK), | |
| 101 | − | }; | |
| 102 | − | c.set("services", services); | |
| 103 | − | let viewer: Viewer = null; | |
| 104 | − | if (scheme?.toLowerCase() === "bearer" && token) { | |
| 105 | − | viewer = await services.IDENTITY.userForAccessToken(token); | |
| 106 | − | if (!viewer) { | |
| 107 | − | return c.json( | |
| 108 | − | { error: { code: "unauthenticated", message: "Invalid access token." } }, | |
| 109 | − | 401, | |
| 110 | − | // Tells an MCP client where to sign in again. | |
| 111 | − | { "www-authenticate": `${MCP_CHALLENGE}, error="invalid_token"` }, | |
| 112 | − | ); | |
| 113 | − | } | |
| 114 | − | } | |
| 115 | − | c.set("viewer", viewer); | |
| 116 | − | await next(); | |
| 117 | − | }); | |
| 118 | − | ||
| 119 | − | // Signing in with OAuth. Served on both hosts: an MCP client looks for the | |
| 120 | − | // metadata next to the MCP server. | |
| 121 | − | app.route("/", oauth); | |
| 122 | − | ||
| 123 | − | app.all("*", async (c, next) => { | |
| 124 | − | if (!new URL(c.req.url).hostname.startsWith("mcp.")) return next(); | |
| 125 | − | const viewer = c.get("viewer"); | |
| 126 | − | // The MCP server needs a signed-in user. Saying so this way is what | |
| 127 | − | // makes a client open the browser to sign in. | |
| 128 | − | if (!viewer) { | |
| 129 | − | return c.json( | |
| 130 | − | { | |
| 131 | − | error: { | |
| 132 | − | code: "unauthenticated", | |
| 133 | − | message: "Sign in to use the g1t MCP server.", | |
| 134 | − | }, | |
| 135 | − | }, | |
| 136 | − | 401, | |
| 137 | − | { "www-authenticate": MCP_CHALLENGE }, | |
| 138 | − | ); | |
| 139 | − | } | |
| 140 | − | return handleMcp(c.req.raw, c.get("services"), viewer); | |
| 141 | − | }); | |
| 142 | − | ||
| 143 | − | // Signing in from a tool. Accounts are created, and passwords typed, only | |
| 144 | − | // in a browser; a tool gets its token by having a person approve a code. | |
| 145 | − | ||
| 146 | − | async function jsonBody(request: Request): Promise<Record<string, unknown>> { | |
| 147 | − | try { | |
| 148 | − | return await request.json(); | |
| 149 | − | } catch { | |
| 150 | − | return {}; | |
| 151 | − | } | |
| 152 | − | } | |
| 153 | − | ||
| 154 | − | app.post("/v1/device/code", async (c) => { | |
| 155 | − | const body = await jsonBody(c.req.raw); | |
| 156 | − | const started = await c | |
| 157 | − | .get("services") | |
| 158 | − | .IDENTITY.deviceStart(String(body.client_name ?? "")); | |
| 159 | − | return c.json({ | |
| 160 | − | device_code: started.deviceCode, | |
| 161 | − | user_code: started.userCode, | |
| 162 | − | verification_uri: "https://g1t.sh/device", | |
| 163 | − | verification_uri_complete: `https://g1t.sh/device?code=${started.userCode}`, | |
| 164 | − | expires_in: started.expiresIn, | |
| 165 | − | interval: started.interval, | |
| 166 | − | }); | |
| 167 | − | }); | |
| 168 | − | ||
| 169 | − | app.post("/v1/device/token", async (c) => { | |
| 170 | − | const body = await jsonBody(c.req.raw); | |
| 171 | − | const claim = await c | |
| 172 | − | .get("services") | |
| 173 | − | .IDENTITY.deviceClaim(String(body.device_code ?? "")); | |
| 174 | − | if (claim.status !== "approved") return c.json({ status: claim.status }); | |
| 175 | − | return c.json({ | |
| 176 | − | status: "approved", | |
| 177 | − | token: claim.token, | |
| 178 | − | username: claim.user.username, | |
| 179 | − | verified: claim.user.verified === true, | |
| 180 | − | }); | |
| 181 | − | }); | |
| 182 | − | ||
| 183 | − | app.get("/openapi.json", (c) => | |
| 184 | − | c.json( | |
| 185 | − | openApiDocument( | |
| 186 | − | ROUTES.map(({ method, path, operation }) => ({ | |
| 187 | − | method, | |
| 188 | − | path, | |
| 189 | − | operation, | |
| 190 | − | tag: tagFor(operation), | |
| 191 | − | })), | |
| 192 | − | ), | |
| 193 | − | ), | |
| 194 | − | ); | |
| 195 | − | ||
| 196 | − | app.get("/", (c) => | |
| 197 | − | c.json({ | |
| 198 | − | name: "g1t API", | |
| 199 | − | version: "v1", | |
| 200 | − | documentation: "https://docs.g1t.sh/api", | |
| 201 | − | openapi: "https://api.g1t.sh/openapi.json", | |
| 202 | − | operations: operations.map(({ name, description }) => ({ name, description })), | |
| 203 | − | }), | |
| 204 | − | ); | |
| 205 | − | ||
| 206 | − | for (const route of ROUTES) { | |
| 207 | − | const operation = operationsByName.get(route.operation)!; | |
| 208 | − | app.on(route.method, route.path, async (c) => { | |
| 209 | − | let body: Input = {}; | |
| 210 | − | if (route.method !== "GET") { | |
| 211 | − | try { | |
| 212 | − | body = await c.req.json(); | |
| 213 | − | } catch { | |
| 214 | − | // An empty or non-JSON body is treated as no input. | |
| 215 | − | } | |
| 216 | − | } | |
| 217 | − | const input = route.input?.(c.req.param(), c.req.query(), body) ?? {}; | |
| 218 | − | const outcome = await operation.run(c.get("services"), c.get("viewer"), input); | |
| 219 | − | if (outcome.ok) return c.json(outcome.value); | |
| 220 | − | return c.json( | |
| 221 | − | { error: outcome.error }, | |
| 222 | − | httpStatus(outcome.error) as 401 | 403 | 404 | 409 | 422, | |
| 223 | − | ); | |
| 224 | − | }); | |
| 225 | − | } | |
| 226 | − | ||
| 227 | − | app.notFound((c) => | |
| 228 | − | c.json({ error: { code: "not_found", message: "No such endpoint." } }, 404), | |
| 229 | − | ); | |
| 230 | − | ||
| 231 | − | export default app; |
| 1 | + | //! The public API: REST at api.g1t.sh and the MCP server at mcp.g1t.sh. | |
| 2 | + | //! | |
| 3 | + | //! One Worker, two hostnames. Both are thin adapters over the same | |
| 4 | + | //! operations (see [`operations::Op`]), which call the services that own | |
| 5 | + | //! the data. This Worker holds none. | |
| 6 | + | ||
| 7 | + | mod mcp; | |
| 8 | + | mod oauth; | |
| 9 | + | mod openapi; | |
| 10 | + | mod operations; | |
| 11 | + | mod rest; | |
| 12 | + | ||
| 13 | + | use g1t_contracts::identity::{ | |
| 14 | + | DeviceClaim, DeviceClaimArgs, DeviceStart, DeviceStartArgs, TokenArgs, | |
| 15 | + | }; | |
| 16 | + | use g1t_contracts::{Failure, FailureCode, Outcome, Viewer}; | |
| 17 | + | use serde_json::{Value, json}; | |
| 18 | + | use worker::{Context, Env, Method, Request, Response, Result, event}; | |
| 19 | + | ||
| 20 | + | use operations::Services; | |
| 21 | + | ||
| 22 | + | const API: &str = "https://api.g1t.sh"; | |
| 23 | + | ||
| 24 | + | fn method_name(method: Method) -> &'static str { | |
| 25 | + | match method { | |
| 26 | + | Method::Get => "GET", | |
| 27 | + | Method::Post => "POST", | |
| 28 | + | Method::Patch => "PATCH", | |
| 29 | + | Method::Put => "PUT", | |
| 30 | + | Method::Delete => "DELETE", | |
| 31 | + | Method::Options => "OPTIONS", | |
| 32 | + | Method::Head => "HEAD", | |
| 33 | + | _ => "OTHER", | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | /// An error in the shape every endpoint uses. | |
| 38 | + | fn failure(failure: &Failure) -> Result<Response> { | |
| 39 | + | Ok(Response::from_json(&json!({ "error": failure }))?.with_status(failure.code.http_status())) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | fn fail(code: FailureCode, message: &str) -> Result<Response> { | |
| 43 | + | failure(&Failure { | |
| 44 | + | code, | |
| 45 | + | message: message.to_owned(), | |
| 46 | + | }) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | /// A request body as JSON. An empty or malformed body is no input. | |
| 50 | + | async fn json_body(request: &mut Request) -> Value { | |
| 51 | + | request.json().await.unwrap_or(Value::Null) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /// Who a request's `Authorization: Bearer g1t_…` names. A missing token is | |
| 55 | + | /// an anonymous viewer; a wrong one is refused, so that a typo does not | |
| 56 | + | /// silently look signed out. | |
| 57 | + | async fn authenticate( | |
| 58 | + | request: &Request, | |
| 59 | + | services: &Services, | |
| 60 | + | ) -> Result<std::result::Result<Viewer, Response>> { | |
| 61 | + | let header = request.headers().get("authorization")?.unwrap_or_default(); | |
| 62 | + | let token = match header.split_once(' ') { | |
| 63 | + | Some((scheme, token)) if scheme.eq_ignore_ascii_case("bearer") && !token.is_empty() => { | |
| 64 | + | token.trim() | |
| 65 | + | } | |
| 66 | + | _ => return Ok(Ok(None)), | |
| 67 | + | }; | |
| 68 | + | let viewer: Viewer = g1t_kit::call( | |
| 69 | + | &services.identity, | |
| 70 | + | "user_for_access_token", | |
| 71 | + | &TokenArgs { | |
| 72 | + | token: token.to_owned(), | |
| 73 | + | }, | |
| 74 | + | ) | |
| 75 | + | .await?; | |
| 76 | + | if viewer.is_some() { | |
| 77 | + | return Ok(Ok(viewer)); | |
| 78 | + | } | |
| 79 | + | let mut response = fail(FailureCode::Unauthenticated, "Invalid access token.")?; | |
| 80 | + | // Tells an MCP client where to sign in again. | |
| 81 | + | response.headers_mut().set( | |
| 82 | + | "www-authenticate", | |
| 83 | + | &format!("{}, error=\"invalid_token\"", oauth::MCP_CHALLENGE), | |
| 84 | + | )?; | |
| 85 | + | Ok(Err(response)) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | /// Where everything is, for someone or something exploring the API. | |
| 89 | + | fn index() -> Value { | |
| 90 | + | let repo = format!("{API}/v1/repos/{{owner}}/{{name}}"); | |
| 91 | + | json!({ | |
| 92 | + | "documentation_url": "https://docs.g1t.sh/api/reference/", | |
| 93 | + | "openapi_url": format!("{API}/openapi.json"), | |
| 94 | + | "mcp_url": "https://mcp.g1t.sh", | |
| 95 | + | "current_user_url": format!("{API}/v1/user"), | |
| 96 | + | "workspaces_url": format!("{API}/v1/workspaces"), | |
| 97 | + | "repositories_url": format!("{API}/v1/repos{{?q}}"), | |
| 98 | + | "repository_url": repo, | |
| 99 | + | "repository_events_url": format!("{repo}/events{{?before}}"), | |
| 100 | + | "labels_url": format!("{repo}/labels"), | |
| 101 | + | "issues_url": format!("{repo}/issues{{?state,label}}"), | |
| 102 | + | "issue_url": format!("{repo}/issues/{{number}}"), | |
| 103 | + | "issue_comments_url": format!("{repo}/issues/{{number}}/comments"), | |
| 104 | + | "pulls_url": format!("{repo}/pulls{{?state}}"), | |
| 105 | + | "pull_url": format!("{repo}/pulls/{{number}}"), | |
| 106 | + | "pull_changes_url": format!("{repo}/pulls/{{number}}/changes"), | |
| 107 | + | "pull_session_url": format!("{repo}/pulls/{{number}}/session{{?after}}"), | |
| 108 | + | "device_code_url": format!("{API}/v1/device/code"), | |
| 109 | + | "device_token_url": format!("{API}/v1/device/token"), | |
| 110 | + | "oauth_metadata_url": format!("{API}/.well-known/oauth-authorization-server"), | |
| 111 | + | "git_url": "https://g1t.sh/{owner}/{name}.git", | |
| 112 | + | }) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // Signing in from a tool. Accounts are created, and passwords typed, only | |
| 116 | + | // in a browser; a tool gets its token by having a person approve a code. | |
| 117 | + | ||
| 118 | + | async fn device_code(request: &mut Request, services: &Services) -> Result<Response> { | |
| 119 | + | let body = json_body(request).await; | |
| 120 | + | let started: DeviceStart = g1t_kit::call( | |
| 121 | + | &services.identity, | |
| 122 | + | "device_start", | |
| 123 | + | &DeviceStartArgs { | |
| 124 | + | client_name: body["client_name"].as_str().unwrap_or_default().to_owned(), | |
| 125 | + | }, | |
| 126 | + | ) | |
| 127 | + | .await?; | |
| 128 | + | Response::from_json(&json!({ | |
| 129 | + | "device_code": started.device_code, | |
| 130 | + | "user_code": started.user_code, | |
| 131 | + | "verification_uri": "https://g1t.sh/device", | |
| 132 | + | "verification_uri_complete": format!("https://g1t.sh/device?code={}", started.user_code), | |
| 133 | + | "expires_in": started.expires_in, | |
| 134 | + | "interval": started.interval, | |
| 135 | + | })) | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | async fn device_token(request: &mut Request, services: &Services) -> Result<Response> { | |
| 139 | + | let body = json_body(request).await; | |
| 140 | + | let claim: DeviceClaim = g1t_kit::call( | |
| 141 | + | &services.identity, | |
| 142 | + | "device_claim", | |
| 143 | + | &DeviceClaimArgs { | |
| 144 | + | device_code: body["device_code"].as_str().unwrap_or_default().to_owned(), | |
| 145 | + | }, | |
| 146 | + | ) | |
| 147 | + | .await?; | |
| 148 | + | Response::from_json(&match claim { | |
| 149 | + | DeviceClaim::Approved { token, user } => json!({ | |
| 150 | + | "status": "approved", | |
| 151 | + | "token": token, | |
| 152 | + | "username": user.username, | |
| 153 | + | "verified": user.verified, | |
| 154 | + | }), | |
| 155 | + | DeviceClaim::Pending => json!({ "status": "pending" }), | |
| 156 | + | DeviceClaim::Denied => json!({ "status": "denied" }), | |
| 157 | + | DeviceClaim::Expired => json!({ "status": "expired" }), | |
| 158 | + | }) | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | async fn respond(mut request: Request, env: &Env) -> Result<Response> { | |
| 162 | + | let method = method_name(request.method()); | |
| 163 | + | if method == "OPTIONS" { | |
| 164 | + | return Ok(Response::empty()?.with_status(204)); | |
| 165 | + | } | |
| 166 | + | let url = request.url()?; | |
| 167 | + | let path = url.path().to_owned(); | |
| 168 | + | let on_mcp = url.host_str().is_some_and(|host| host.starts_with("mcp.")); | |
| 169 | + | let services = Services::new(env)?; | |
| 170 | + | ||
| 171 | + | let viewer = match authenticate(&request, &services).await? { | |
| 172 | + | Ok(viewer) => viewer, | |
| 173 | + | Err(refused) => return Ok(refused), | |
| 174 | + | }; | |
| 175 | + | if let Some(response) = oauth::handle(&mut request, &services, method, &path).await? { | |
| 176 | + | return Ok(response); | |
| 177 | + | } | |
| 178 | + | if on_mcp { | |
| 179 | + | return mcp::handle(request, &services, &viewer).await; | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | match (method, path.trim_end_matches('/')) { | |
| 183 | + | ("GET", "" | "/v1") => return Response::from_json(&index()), | |
| 184 | + | ("GET", "/openapi.json") => return Response::from_json(&openapi::document()), | |
| 185 | + | ("POST", "/v1/device/code") => return device_code(&mut request, &services).await, | |
| 186 | + | ("POST", "/v1/device/token") => return device_token(&mut request, &services).await, | |
| 187 | + | _ => {} | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | let query: Vec<(String, String)> = url | |
| 191 | + | .query_pairs() | |
| 192 | + | .map(|(name, value)| (name.into_owned(), value.into_owned())) | |
| 193 | + | .collect(); | |
| 194 | + | let body = if method == "GET" { | |
| 195 | + | Value::Null | |
| 196 | + | } else { | |
| 197 | + | json_body(&mut request).await | |
| 198 | + | }; | |
| 199 | + | let Some((route, input)) = rest::resolve(method, &path, &query, body) else { | |
| 200 | + | return fail(FailureCode::NotFound, "No such endpoint."); | |
| 201 | + | }; | |
| 202 | + | match route.op.run(&services, &viewer, &input).await? { | |
| 203 | + | Outcome::Ok(value) => Response::from_json(&value), | |
| 204 | + | Outcome::Fail(refused) => failure(&refused), | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | // The API is called from browsers too: the reference's explorer, and apps | |
| 209 | + | // built on g1t. It carries no cookies, so any origin may call it. | |
| 210 | + | #[event(fetch)] | |
| 211 | + | async fn fetch(request: Request, env: Env, _ctx: Context) -> Result<Response> { | |
| 212 | + | let mut response = respond(request, &env).await?; | |
| 213 | + | let headers = response.headers_mut(); | |
| 214 | + | headers.set("access-control-allow-origin", "*")?; | |
| 215 | + | headers.set( | |
| 216 | + | "access-control-allow-headers", | |
| 217 | + | "authorization, content-type", | |
| 218 | + | )?; | |
| 219 | + | headers.set("access-control-allow-methods", "GET, POST, PATCH, OPTIONS")?; | |
| 220 | + | headers.set("access-control-expose-headers", "www-authenticate")?; | |
| 221 | + | Ok(response) | |
| 222 | + | } |
| 1 | + | //! MCP over streamable HTTP. The server keeps no session state, so every | |
| 2 | + | //! POST is answered directly with JSON. | |
| 3 | + | ||
| 4 | + | use g1t_contracts::{Outcome, Viewer}; | |
| 5 | + | use serde_json::{Value, json}; | |
| 6 | + | use worker::{Method, Request, Response, Result}; | |
| 7 | + | ||
| 8 | + | use crate::oauth::MCP_CHALLENGE; | |
| 9 | + | use crate::operations::{Op, Services}; | |
| 10 | + | ||
| 11 | + | const SUPPORTED_VERSIONS: [&str; 3] = ["2025-06-18", "2025-03-26", "2024-11-05"]; | |
| 12 | + | ||
| 13 | + | const INSTRUCTIONS: &str = "g1t is a git forge with issues and pull requests, built so that many agents can work on the same issue at once. | |
| 14 | + | To work on an issue: get_issue to read it and see the pull requests already made for it, then create_pull_request with the issue's number. You get a draft pull request with its own fork to clone and push to. Call record_session as you work so people can see your reasoning, push your commits, and call mark_pull_request_ready with a summary. | |
| 15 | + | Issues and pull requests are named by repository (\"owner/name\") and number, and share one sequence of numbers."; | |
| 16 | + | ||
| 17 | + | fn result(id: &Value, value: Value) -> Value { | |
| 18 | + | json!({ "jsonrpc": "2.0", "id": id, "result": value }) | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | fn error(id: &Value, code: i32, message: &str) -> Value { | |
| 22 | + | json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }) | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | /// Answers one JSON-RPC request, or `None` for a notification. | |
| 26 | + | async fn answer(services: &Services, viewer: &Viewer, request: &Value) -> Result<Option<Value>> { | |
| 27 | + | // Notifications carry no id and get no response. | |
| 28 | + | let Some(id) = request.get("id") else { | |
| 29 | + | return Ok(None); | |
| 30 | + | }; | |
| 31 | + | let params = &request["params"]; | |
| 32 | + | let answer = match request["method"].as_str().unwrap_or_default() { | |
| 33 | + | "initialize" => { | |
| 34 | + | let requested = params["protocolVersion"].as_str().unwrap_or_default(); | |
| 35 | + | let version = SUPPORTED_VERSIONS | |
| 36 | + | .into_iter() | |
| 37 | + | .find(|version| *version == requested) | |
| 38 | + | .unwrap_or(SUPPORTED_VERSIONS[0]); | |
| 39 | + | result( | |
| 40 | + | id, | |
| 41 | + | json!({ | |
| 42 | + | "protocolVersion": version, | |
| 43 | + | "capabilities": { "tools": {} }, | |
| 44 | + | "serverInfo": { "name": "g1t", "version": "0.1.0" }, | |
| 45 | + | "instructions": INSTRUCTIONS, | |
| 46 | + | }), | |
| 47 | + | ) | |
| 48 | + | } | |
| 49 | + | "ping" => result(id, json!({})), | |
| 50 | + | "tools/list" => { | |
| 51 | + | let tools: Vec<Value> = Op::ALL | |
| 52 | + | .into_iter() | |
| 53 | + | .map(|op| { | |
| 54 | + | json!({ | |
| 55 | + | "name": op.name(), | |
| 56 | + | "description": op.description(), | |
| 57 | + | "inputSchema": op.input(), | |
| 58 | + | }) | |
| 59 | + | }) | |
| 60 | + | .collect(); | |
| 61 | + | result(id, json!({ "tools": tools })) | |
| 62 | + | } | |
| 63 | + | "tools/call" => { | |
| 64 | + | let Some(op) = Op::by_name(params["name"].as_str().unwrap_or_default()) else { | |
| 65 | + | return Ok(Some(error(id, -32602, "Unknown tool."))); | |
| 66 | + | }; | |
| 67 | + | let outcome = op.run(services, viewer, ¶ms["arguments"]).await?; | |
| 68 | + | // A failed operation is a tool result the model can read and | |
| 69 | + | // act on, not a protocol error. | |
| 70 | + | let (text, failed) = match outcome { | |
| 71 | + | Outcome::Ok(value) => (serde_json::to_string_pretty(&value)?, false), | |
| 72 | + | Outcome::Fail(failure) => (failure.message, true), | |
| 73 | + | }; | |
| 74 | + | result( | |
| 75 | + | id, | |
| 76 | + | json!({ "content": [{ "type": "text", "text": text }], "isError": failed }), | |
| 77 | + | ) | |
| 78 | + | } | |
| 79 | + | method => error(id, -32601, &format!("Method not found: {method}")), | |
| 80 | + | }; | |
| 81 | + | Ok(Some(answer)) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | /// What someone sees when they open the server's address in a browser: | |
| 85 | + | /// what this is, how to connect, and what it offers. | |
| 86 | + | fn card() -> Value { | |
| 87 | + | let tools: Vec<Value> = Op::ALL | |
| 88 | + | .into_iter() | |
| 89 | + | .map(|op| json!({ "name": op.name(), "description": op.description() })) | |
| 90 | + | .collect(); | |
| 91 | + | json!({ | |
| 92 | + | "name": "g1t", | |
| 93 | + | "description": "The g1t MCP server: issues, pull requests and sessions for agents.", | |
| 94 | + | "endpoint": "https://mcp.g1t.sh", | |
| 95 | + | "transport": "streamable-http", | |
| 96 | + | "protocol_versions": SUPPORTED_VERSIONS, | |
| 97 | + | "connect": "claude mcp add --transport http g1t https://mcp.g1t.sh", | |
| 98 | + | "authorization": { | |
| 99 | + | "required": true, | |
| 100 | + | "oauth_protected_resource": "https://mcp.g1t.sh/.well-known/oauth-protected-resource", | |
| 101 | + | "alternative": "Authorization: Bearer <g1t access token>", | |
| 102 | + | }, | |
| 103 | + | "documentation_url": "https://docs.g1t.sh/guides/bring-your-own-agent/", | |
| 104 | + | "instructions": INSTRUCTIONS, | |
| 105 | + | "tools": tools, | |
| 106 | + | }) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | pub async fn handle( | |
| 110 | + | mut request: Request, | |
| 111 | + | services: &Services, | |
| 112 | + | viewer: &Viewer, | |
| 113 | + | ) -> Result<Response> { | |
| 114 | + | if request.method() != Method::Post { | |
| 115 | + | // A client asking for a stream of server messages is told there is | |
| 116 | + | // none. Anyone else, a person with a browser, gets a description. | |
| 117 | + | let wants_stream = request | |
| 118 | + | .headers() | |
| 119 | + | .get("accept")? | |
| 120 | + | .is_some_and(|accept| accept.contains("text/event-stream")); | |
| 121 | + | if request.method() == Method::Get && !wants_stream { | |
| 122 | + | return Response::from_json(&card()); | |
| 123 | + | } | |
| 124 | + | let mut response = Response::empty()?.with_status(405); | |
| 125 | + | response.headers_mut().set("allow", "GET, POST")?; | |
| 126 | + | return Ok(response); | |
| 127 | + | } | |
| 128 | + | // Calls need a signed-in user. Answering 401 with this header is what | |
| 129 | + | // makes a client open the browser to sign in. | |
| 130 | + | if viewer.is_none() { | |
| 131 | + | let mut response = Response::from_json(&error( | |
| 132 | + | &Value::Null, | |
| 133 | + | -32001, | |
| 134 | + | "Sign in to use the g1t MCP server.", | |
| 135 | + | ))? | |
| 136 | + | .with_status(401); | |
| 137 | + | response | |
| 138 | + | .headers_mut() | |
| 139 | + | .set("www-authenticate", MCP_CHALLENGE)?; | |
| 140 | + | return Ok(response); | |
| 141 | + | } | |
| 142 | + | let Ok(body) = request.json::<Value>().await else { | |
| 143 | + | return Ok( | |
| 144 | + | Response::from_json(&error(&Value::Null, -32700, "Parse error"))?.with_status(400), | |
| 145 | + | ); | |
| 146 | + | }; | |
| 147 | + | let accepted = || Ok(Response::empty()?.with_status(202)); | |
| 148 | + | match body { | |
| 149 | + | Value::Array(batch) => { | |
| 150 | + | let mut answers = Vec::new(); | |
| 151 | + | for request in &batch { | |
| 152 | + | answers.extend(answer(services, viewer, request).await?); | |
| 153 | + | } | |
| 154 | + | if answers.is_empty() { | |
| 155 | + | accepted() | |
| 156 | + | } else { | |
| 157 | + | Response::from_json(&answers) | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | single => match answer(services, viewer, &single).await? { | |
| 161 | + | Some(answer) => Response::from_json(&answer), | |
| 162 | + | None => accepted(), | |
| 163 | + | }, | |
| 164 | + | } | |
| 165 | + | } |
| 1 | − | import type { Viewer } from "@g1t/contracts"; | |
| 2 | − | ||
| 3 | − | import { type ApiEnv, operations, operationsByName } from "./operations"; | |
| 4 | − | ||
| 5 | − | const SUPPORTED_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"]; | |
| 6 | − | ||
| 7 | − | const INSTRUCTIONS = `g1t is a git forge where agents work on intents. | |
| 8 | − | An intent is a goal on a repository. To work on one: get_intent, then | |
| 9 | − | start_attempt (you get your own fork to clone and push to), record_session | |
| 10 | − | as you work so people can see your reasoning, push your commits, and | |
| 11 | − | submit_attempt with a summary.`; | |
| 12 | − | ||
| 13 | − | type JsonRpcRequest = { | |
| 14 | − | jsonrpc: "2.0"; | |
| 15 | − | id?: string | number | null; | |
| 16 | − | method: string; | |
| 17 | − | params?: Record<string, unknown>; | |
| 18 | − | }; | |
| 19 | − | ||
| 20 | − | function result(id: JsonRpcRequest["id"], value: unknown): object { | |
| 21 | − | return { jsonrpc: "2.0", id, result: value }; | |
| 22 | − | } | |
| 23 | − | ||
| 24 | − | function error(id: JsonRpcRequest["id"], code: number, message: string): object { | |
| 25 | − | return { jsonrpc: "2.0", id: id ?? null, error: { code, message } }; | |
| 26 | − | } | |
| 27 | − | ||
| 28 | − | async function handle( | |
| 29 | − | env: ApiEnv, | |
| 30 | − | viewer: Viewer, | |
| 31 | − | request: JsonRpcRequest, | |
| 32 | − | ): Promise<object | null> { | |
| 33 | − | // Notifications carry no id and get no response. | |
| 34 | − | if (request.id === undefined) return null; | |
| 35 | − | ||
| 36 | − | switch (request.method) { | |
| 37 | − | case "initialize": { | |
| 38 | − | const requested = String(request.params?.protocolVersion ?? ""); | |
| 39 | − | return result(request.id, { | |
| 40 | − | protocolVersion: SUPPORTED_VERSIONS.includes(requested) | |
| 41 | − | ? requested | |
| 42 | − | : SUPPORTED_VERSIONS[0], | |
| 43 | − | capabilities: { tools: {} }, | |
| 44 | − | serverInfo: { name: "g1t", version: "0.1.0" }, | |
| 45 | − | instructions: INSTRUCTIONS, | |
| 46 | − | }); | |
| 47 | − | } | |
| 48 | − | case "ping": | |
| 49 | − | return result(request.id, {}); | |
| 50 | − | case "tools/list": | |
| 51 | − | return result(request.id, { | |
| 52 | − | tools: operations.map(({ name, description, input }) => ({ | |
| 53 | − | name, | |
| 54 | − | description, | |
| 55 | − | inputSchema: input, | |
| 56 | − | })), | |
| 57 | − | }); | |
| 58 | − | case "tools/call": { | |
| 59 | − | const operation = operationsByName.get(String(request.params?.name)); | |
| 60 | − | if (!operation) return error(request.id, -32602, "Unknown tool."); | |
| 61 | − | const input = (request.params?.arguments ?? {}) as Record<string, unknown>; | |
| 62 | − | const outcome = await operation.run(env, viewer, input); | |
| 63 | − | // A failed operation is a tool result the model can read and act on, | |
| 64 | − | // not a protocol error. | |
| 65 | − | return result(request.id, { | |
| 66 | − | content: [ | |
| 67 | − | { | |
| 68 | − | type: "text", | |
| 69 | − | text: outcome.ok | |
| 70 | − | ? JSON.stringify(outcome.value, null, 2) | |
| 71 | − | : outcome.error.message, | |
| 72 | − | }, | |
| 73 | − | ], | |
| 74 | − | isError: !outcome.ok, | |
| 75 | − | }); | |
| 76 | − | } | |
| 77 | − | default: | |
| 78 | − | return error(request.id, -32601, `Method not found: ${request.method}`); | |
| 79 | − | } | |
| 80 | − | } | |
| 81 | − | ||
| 82 | − | /** | |
| 83 | − | * MCP over streamable HTTP. The server keeps no session state, so every | |
| 84 | − | * POST is answered directly with JSON. | |
| 85 | − | */ | |
| 86 | − | export async function handleMcp( | |
| 87 | − | request: Request, | |
| 88 | − | env: ApiEnv, | |
| 89 | − | viewer: Viewer, | |
| 90 | − | ): Promise<Response> { | |
| 91 | − | if (request.method !== "POST") { | |
| 92 | − | return new Response(null, { status: 405, headers: { allow: "POST" } }); | |
| 93 | − | } | |
| 94 | − | let body: JsonRpcRequest | JsonRpcRequest[]; | |
| 95 | − | try { | |
| 96 | − | body = await request.json(); | |
| 97 | − | } catch { | |
| 98 | − | return Response.json(error(null, -32700, "Parse error"), { status: 400 }); | |
| 99 | − | } | |
| 100 | − | if (Array.isArray(body)) { | |
| 101 | − | const responses = ( | |
| 102 | − | await Promise.all(body.map((item) => handle(env, viewer, item))) | |
| 103 | − | ).filter((response) => response !== null); | |
| 104 | − | return responses.length | |
| 105 | − | ? Response.json(responses) | |
| 106 | − | : new Response(null, { status: 202 }); | |
| 107 | − | } | |
| 108 | − | const response = await handle(env, viewer, body); | |
| 109 | − | return response ? Response.json(response) : new Response(null, { status: 202 }); | |
| 110 | − | } |
| 1 | + | //! The OAuth 2.1 endpoints an application calls directly. The page where a | |
| 2 | + | //! person approves is on the site, at g1t.sh/oauth/authorize. | |
| 3 | + | //! | |
| 4 | + | //! Applications sign people in with the authorization code flow and PKCE. | |
| 5 | + | //! They are public clients: none holds a secret. | |
| 6 | + | ||
| 7 | + | use base64::Engine; | |
| 8 | + | use base64::engine::general_purpose::URL_SAFE_NO_PAD; | |
| 9 | + | use g1t_contracts::Outcome; | |
| 10 | + | use g1t_contracts::identity::{OAuthExchangeArgs, OAuthRefreshArgs, OAuthTokens}; | |
| 11 | + | use serde::Serialize; | |
| 12 | + | use serde_json::{Map, Value, json}; | |
| 13 | + | use worker::{Request, Response, Result, Url}; | |
| 14 | + | ||
| 15 | + | use crate::operations::Services; | |
| 16 | + | ||
| 17 | + | const ISSUER: &str = "https://api.g1t.sh"; | |
| 18 | + | const MCP_RESOURCE: &str = "https://mcp.g1t.sh"; | |
| 19 | + | /// What an MCP client is told when it must sign in first (RFC 9728). | |
| 20 | + | pub const MCP_CHALLENGE: &str = | |
| 21 | + | "Bearer resource_metadata=\"https://mcp.g1t.sh/.well-known/oauth-protected-resource\""; | |
| 22 | + | ||
| 23 | + | const CLIENT_PREFIX: &str = "g1c_"; | |
| 24 | + | const MAX_NAME_CHARS: usize = 80; | |
| 25 | + | const MAX_REDIRECTS: usize = 5; | |
| 26 | + | const MAX_URI_CHARS: usize = 500; | |
| 27 | + | /// Schemes that run or expose content instead of opening an application. | |
| 28 | + | const FORBIDDEN_SCHEMES: [&str; 6] = ["javascript", "data", "file", "blob", "vbscript", "about"]; | |
| 29 | + | const LOOPBACK_HOSTS: [&str; 3] = ["localhost", "127.0.0.1", "[::1]"]; | |
| 30 | + | ||
| 31 | + | /// Whether an application may ask to be redirected here: an https address, | |
| 32 | + | /// http on this machine only, or an application's own scheme. | |
| 33 | + | fn is_valid_redirect_uri(uri: &str) -> bool { | |
| 34 | + | let Ok(url) = Url::parse(uri) else { | |
| 35 | + | return false; | |
| 36 | + | }; | |
| 37 | + | if uri.len() > MAX_URI_CHARS || url.fragment().is_some() { | |
| 38 | + | return false; | |
| 39 | + | } | |
| 40 | + | match url.scheme() { | |
| 41 | + | "https" => true, | |
| 42 | + | "http" => url | |
| 43 | + | .host_str() | |
| 44 | + | .is_some_and(|host| LOOPBACK_HOSTS.contains(&host)), | |
| 45 | + | scheme => !FORBIDDEN_SCHEMES.contains(&scheme), | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | /// A client as its id carries it. The site decodes the same shape; see | |
| 50 | + | /// `packages/contracts/src/oauth.ts`. | |
| 51 | + | #[derive(Serialize)] | |
| 52 | + | struct Client<'a> { | |
| 53 | + | n: &'a str, | |
| 54 | + | r: &'a [String], | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | /// The client id for a client, or `None` if what it asks for is not | |
| 58 | + | /// allowed. Nothing is stored: the id is the registration itself, encoded, | |
| 59 | + | /// so this open endpoint cannot be used to fill a database. | |
| 60 | + | fn encode_client(name: &str, redirect_uris: &[String]) -> Option<(String, String)> { | |
| 61 | + | let name: String = name.trim().chars().take(MAX_NAME_CHARS).collect(); | |
| 62 | + | let name = if name.is_empty() { | |
| 63 | + | "An application".to_owned() | |
| 64 | + | } else { | |
| 65 | + | name | |
| 66 | + | }; | |
| 67 | + | let allowed = !redirect_uris.is_empty() | |
| 68 | + | && redirect_uris.len() <= MAX_REDIRECTS | |
| 69 | + | && redirect_uris.iter().all(|uri| is_valid_redirect_uri(uri)); | |
| 70 | + | if !allowed { | |
| 71 | + | return None; | |
| 72 | + | } | |
| 73 | + | let encoded = serde_json::to_string(&Client { | |
| 74 | + | n: &name, | |
| 75 | + | r: redirect_uris, | |
| 76 | + | }) | |
| 77 | + | .ok()?; | |
| 78 | + | Some(( | |
| 79 | + | format!("{CLIENT_PREFIX}{}", URL_SAFE_NO_PAD.encode(encoded)), | |
| 80 | + | name, | |
| 81 | + | )) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | fn oauth_error(error: &str, description: &str) -> Result<Response> { | |
| 85 | + | let mut response = | |
| 86 | + | Response::from_json(&json!({ "error": error, "error_description": description }))? | |
| 87 | + | .with_status(400); | |
| 88 | + | response.headers_mut().set("cache-control", "no-store")?; | |
| 89 | + | Ok(response) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | /// The request body as fields, whether sent as a form or as JSON. | |
| 93 | + | async fn fields(request: &mut Request) -> Map<String, Value> { | |
| 94 | + | let json = request | |
| 95 | + | .headers() | |
| 96 | + | .get("content-type") | |
| 97 | + | .ok() | |
| 98 | + | .flatten() | |
| 99 | + | .is_some_and(|kind| kind.contains("json")); | |
| 100 | + | let body = request.text().await.unwrap_or_default(); | |
| 101 | + | if json { | |
| 102 | + | return match serde_json::from_str(&body) { | |
| 103 | + | Ok(Value::Object(fields)) => fields, | |
| 104 | + | _ => Map::new(), | |
| 105 | + | }; | |
| 106 | + | } | |
| 107 | + | form_urlencoded::parse(body.as_bytes()) | |
| 108 | + | .map(|(name, value)| (name.into_owned(), Value::String(value.into_owned()))) | |
| 109 | + | .collect() | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | fn server_metadata() -> Value { | |
| 113 | + | json!({ | |
| 114 | + | "issuer": ISSUER, | |
| 115 | + | "authorization_endpoint": "https://g1t.sh/oauth/authorize", | |
| 116 | + | "token_endpoint": format!("{ISSUER}/oauth/token"), | |
| 117 | + | "registration_endpoint": format!("{ISSUER}/oauth/register"), | |
| 118 | + | "response_types_supported": ["code"], | |
| 119 | + | "grant_types_supported": ["authorization_code", "refresh_token"], | |
| 120 | + | "code_challenge_methods_supported": ["S256"], | |
| 121 | + | "token_endpoint_auth_methods_supported": ["none"], | |
| 122 | + | "service_documentation": "https://docs.g1t.sh/guides/authentication/", | |
| 123 | + | }) | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | async fn register(request: &mut Request) -> Result<Response> { | |
| 127 | + | let body = fields(request).await; | |
| 128 | + | let redirect_uris: Vec<String> = body | |
| 129 | + | .get("redirect_uris") | |
| 130 | + | .and_then(Value::as_array) | |
| 131 | + | .map(|uris| { | |
| 132 | + | uris.iter() | |
| 133 | + | .filter_map(|uri| uri.as_str().map(str::to_owned)) | |
| 134 | + | .collect() | |
| 135 | + | }) | |
| 136 | + | .unwrap_or_default(); | |
| 137 | + | let name = body | |
| 138 | + | .get("client_name") | |
| 139 | + | .and_then(Value::as_str) | |
| 140 | + | .unwrap_or_default(); | |
| 141 | + | let Some((client_id, client_name)) = encode_client(name, &redirect_uris) else { | |
| 142 | + | return oauth_error( | |
| 143 | + | "invalid_redirect_uri", | |
| 144 | + | "Give one to five redirect_uris: https addresses, http on localhost, or the application's own scheme.", | |
| 145 | + | ); | |
| 146 | + | }; | |
| 147 | + | Ok(Response::from_json(&json!({ | |
| 148 | + | "client_id": client_id, | |
| 149 | + | "client_name": client_name, | |
| 150 | + | "redirect_uris": redirect_uris, | |
| 151 | + | "grant_types": ["authorization_code", "refresh_token"], | |
| 152 | + | "response_types": ["code"], | |
| 153 | + | "token_endpoint_auth_method": "none", | |
| 154 | + | }))? | |
| 155 | + | .with_status(201)) | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | async fn token(request: &mut Request, services: &Services) -> Result<Response> { | |
| 159 | + | let body = fields(request).await; | |
| 160 | + | let text = |key: &str| { | |
| 161 | + | body.get(key) | |
| 162 | + | .and_then(Value::as_str) | |
| 163 | + | .unwrap_or_default() | |
| 164 | + | .to_owned() | |
| 165 | + | }; | |
| 166 | + | let issued: Outcome<OAuthTokens> = match text("grant_type").as_str() { | |
| 167 | + | "authorization_code" => { | |
| 168 | + | if text("code").is_empty() | |
| 169 | + | || text("code_verifier").is_empty() | |
| 170 | + | || text("client_id").is_empty() | |
| 171 | + | { | |
| 172 | + | return oauth_error( | |
| 173 | + | "invalid_request", | |
| 174 | + | "code, code_verifier and client_id are required.", | |
| 175 | + | ); | |
| 176 | + | } | |
| 177 | + | g1t_kit::call( | |
| 178 | + | &services.identity, | |
| 179 | + | "oauth_exchange", | |
| 180 | + | &OAuthExchangeArgs { | |
| 181 | + | code: text("code"), | |
| 182 | + | code_verifier: text("code_verifier"), | |
| 183 | + | client_id: text("client_id"), | |
| 184 | + | redirect_uri: text("redirect_uri"), | |
| 185 | + | }, | |
| 186 | + | ) | |
| 187 | + | .await? | |
| 188 | + | } | |
| 189 | + | "refresh_token" => { | |
| 190 | + | if text("refresh_token").is_empty() || text("client_id").is_empty() { | |
| 191 | + | return oauth_error( | |
| 192 | + | "invalid_request", | |
| 193 | + | "refresh_token and client_id are required.", | |
| 194 | + | ); | |
| 195 | + | } | |
| 196 | + | g1t_kit::call( | |
| 197 | + | &services.identity, | |
| 198 | + | "oauth_refresh", | |
| 199 | + | &OAuthRefreshArgs { | |
| 200 | + | refresh_token: text("refresh_token"), | |
| 201 | + | client_id: text("client_id"), | |
| 202 | + | }, | |
| 203 | + | ) | |
| 204 | + | .await? | |
| 205 | + | } | |
| 206 | + | _ => { | |
| 207 | + | return oauth_error( | |
| 208 | + | "unsupported_grant_type", | |
| 209 | + | "grant_type must be authorization_code or refresh_token.", | |
| 210 | + | ); | |
| 211 | + | } | |
| 212 | + | }; | |
| 213 | + | let tokens = match issued { | |
| 214 | + | Outcome::Ok(tokens) => tokens, | |
| 215 | + | Outcome::Fail(failure) => return oauth_error("invalid_grant", &failure.message), | |
| 216 | + | }; | |
| 217 | + | let mut response = Response::from_json(&json!({ | |
| 218 | + | "access_token": tokens.access_token, | |
| 219 | + | "token_type": "Bearer", | |
| 220 | + | "expires_in": tokens.expires_in, | |
| 221 | + | "refresh_token": tokens.refresh_token, | |
| 222 | + | }))?; | |
| 223 | + | response.headers_mut().set("cache-control", "no-store")?; | |
| 224 | + | Ok(response) | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | /// Answers the request if it is for an OAuth endpoint. These are served on | |
| 228 | + | /// both hosts: an MCP client looks for the metadata next to the MCP server. | |
| 229 | + | pub async fn handle( | |
| 230 | + | request: &mut Request, | |
| 231 | + | services: &Services, | |
| 232 | + | method: &str, | |
| 233 | + | path: &str, | |
| 234 | + | ) -> Result<Option<Response>> { | |
| 235 | + | let response = match (method, path) { | |
| 236 | + | ("GET", "/.well-known/oauth-authorization-server") => { | |
| 237 | + | Response::from_json(&server_metadata())? | |
| 238 | + | } | |
| 239 | + | // Asked for with or without the MCP server's path appended. | |
| 240 | + | ("GET", path) if path.starts_with("/.well-known/oauth-protected-resource") => { | |
| 241 | + | Response::from_json(&json!({ | |
| 242 | + | "resource": MCP_RESOURCE, | |
| 243 | + | "authorization_servers": [ISSUER], | |
| 244 | + | "bearer_methods_supported": ["header"], | |
| 245 | + | "resource_documentation": "https://docs.g1t.sh/guides/bring-your-own-agent/", | |
| 246 | + | }))? | |
| 247 | + | } | |
| 248 | + | ("POST", "/oauth/register") => register(request).await?, | |
| 249 | + | ("POST", "/oauth/token") => token(request, services).await?, | |
| 250 | + | _ => return Ok(None), | |
| 251 | + | }; | |
| 252 | + | Ok(Some(response)) | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | #[cfg(test)] | |
| 256 | + | mod tests { | |
| 257 | + | use super::*; | |
| 258 | + | ||
| 259 | + | fn uris(list: &[&str]) -> Vec<String> { | |
| 260 | + | list.iter().map(|uri| (*uri).to_owned()).collect() | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | #[test] | |
| 264 | + | fn a_client_id_matches_the_one_the_site_decodes() { | |
| 265 | + | // Produced by `encodeOAuthClient` in packages/contracts/src/oauth.ts. | |
| 266 | + | let (id, name) = | |
| 267 | + | encode_client(" e2e MCP client ", &uris(&["http://localhost:1/callback"])).unwrap(); | |
| 268 | + | assert_eq!( | |
| 269 | + | id, | |
| 270 | + | "g1c_eyJuIjoiZTJlIE1DUCBjbGllbnQiLCJyIjpbImh0dHA6Ly9sb2NhbGhvc3Q6MS9jYWxsYmFjayJdfQ" | |
| 271 | + | ); | |
| 272 | + | assert_eq!(name, "e2e MCP client"); | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | #[test] | |
| 276 | + | fn redirects_are_https_loopback_or_an_application_scheme() { | |
| 277 | + | for good in [ | |
| 278 | + | "https://example.com/cb", | |
| 279 | + | "http://localhost:8123/cb", | |
| 280 | + | "http://127.0.0.1/cb", | |
| 281 | + | "cursor://anysphere.cursor-mcp/oauth/callback", | |
| 282 | + | ] { | |
| 283 | + | assert!(is_valid_redirect_uri(good), "{good}"); | |
| 284 | + | } | |
| 285 | + | for bad in [ | |
| 286 | + | "http://evil.example/cb", | |
| 287 | + | "javascript:alert(1)", | |
| 288 | + | "data:text/html,x", | |
| 289 | + | "https://example.com/cb#fragment", | |
| 290 | + | "not a url", | |
| 291 | + | ] { | |
| 292 | + | assert!(!is_valid_redirect_uri(bad), "{bad}"); | |
| 293 | + | } | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | #[test] | |
| 297 | + | fn a_client_needs_one_to_five_redirects() { | |
| 298 | + | assert!(encode_client("x", &[]).is_none()); | |
| 299 | + | assert!(encode_client("x", &uris(&["https://a.example/cb"; 6])).is_none()); | |
| 300 | + | let (_, name) = encode_client("", &uris(&["https://a.example/cb"])).unwrap(); | |
| 301 | + | assert_eq!(name, "An application"); | |
| 302 | + | } | |
| 303 | + | } |
| 1 | − | import { Hono } from "hono"; | |
| 2 | − | ||
| 3 | − | import { | |
| 4 | − | type IdentityApi, | |
| 5 | − | decodeOAuthClient, | |
| 6 | − | encodeOAuthClient, | |
| 7 | − | } from "@g1t/contracts"; | |
| 8 | − | ||
| 9 | − | /** | |
| 10 | − | * The OAuth 2.1 endpoints an application calls directly. The page where a | |
| 11 | − | * person approves is on the site, at g1t.sh/oauth/authorize. | |
| 12 | − | * | |
| 13 | − | * Applications sign people in with the authorization code flow and PKCE. | |
| 14 | − | * They are public clients: none holds a secret. | |
| 15 | − | */ | |
| 16 | − | const ISSUER = "https://api.g1t.sh"; | |
| 17 | − | const MCP_RESOURCE = "https://mcp.g1t.sh"; | |
| 18 | − | ||
| 19 | − | /** Authorization server metadata (RFC 8414). */ | |
| 20 | − | const SERVER_METADATA = { | |
| 21 | − | issuer: ISSUER, | |
| 22 | − | authorization_endpoint: "https://g1t.sh/oauth/authorize", | |
| 23 | − | token_endpoint: `${ISSUER}/oauth/token`, | |
| 24 | − | registration_endpoint: `${ISSUER}/oauth/register`, | |
| 25 | − | response_types_supported: ["code"], | |
| 26 | − | grant_types_supported: ["authorization_code", "refresh_token"], | |
| 27 | − | code_challenge_methods_supported: ["S256"], | |
| 28 | − | token_endpoint_auth_methods_supported: ["none"], | |
| 29 | − | service_documentation: "https://docs.g1t.sh/guides/authentication/", | |
| 30 | − | }; | |
| 31 | − | ||
| 32 | − | /** What an MCP client is told when it must sign in first (RFC 9728). */ | |
| 33 | − | export const MCP_CHALLENGE = `Bearer resource_metadata="${MCP_RESOURCE}/.well-known/oauth-protected-resource"`; | |
| 34 | − | ||
| 35 | − | type Fields = Record<string, unknown>; | |
| 36 | − | ||
| 37 | − | function oauthError(error: string, description: string, status: 400 | 401 = 400) { | |
| 38 | − | return Response.json( | |
| 39 | − | { error, error_description: description }, | |
| 40 | − | { status, headers: { "cache-control": "no-store" } }, | |
| 41 | − | ); | |
| 42 | − | } | |
| 43 | − | ||
| 44 | − | /** The request body as fields, whether sent as a form or as JSON. */ | |
| 45 | − | async function fields(request: Request): Promise<Fields> { | |
| 46 | − | try { | |
| 47 | − | if (request.headers.get("content-type")?.includes("json")) return await request.json(); | |
| 48 | − | return Object.fromEntries(await request.formData()); | |
| 49 | − | } catch { | |
| 50 | − | return {}; | |
| 51 | − | } | |
| 52 | − | } | |
| 53 | − | ||
| 54 | − | export const oauth = new Hono<{ Variables: { services: { IDENTITY: IdentityApi } } }>(); | |
| 55 | − | ||
| 56 | − | oauth.get("/.well-known/oauth-authorization-server", (c) => c.json(SERVER_METADATA)); | |
| 57 | − | ||
| 58 | − | // Served for the MCP server, with or without its path appended. | |
| 59 | − | oauth.get("/.well-known/oauth-protected-resource/*", protectedResource); | |
| 60 | − | oauth.get("/.well-known/oauth-protected-resource", protectedResource); | |
| 61 | − | ||
| 62 | − | function protectedResource() { | |
| 63 | − | return Response.json({ | |
| 64 | − | resource: MCP_RESOURCE, | |
| 65 | − | authorization_servers: [ISSUER], | |
| 66 | − | bearer_methods_supported: ["header"], | |
| 67 | − | resource_documentation: "https://docs.g1t.sh/guides/bring-your-own-agent/", | |
| 68 | − | }); | |
| 69 | − | } | |
| 70 | − | ||
| 71 | − | // Dynamic client registration (RFC 7591). Nothing is stored: the client id | |
| 72 | − | // returned is the registration itself, encoded. | |
| 73 | − | oauth.post("/oauth/register", async (c) => { | |
| 74 | − | const body = await fields(c.req.raw); | |
| 75 | − | const redirectUris = Array.isArray(body.redirect_uris) ? body.redirect_uris.map(String) : []; | |
| 76 | − | const name = typeof body.client_name === "string" ? body.client_name : ""; | |
| 77 | − | const clientId = encodeOAuthClient({ name, redirectUris }); | |
| 78 | − | if (!clientId) { | |
| 79 | − | return oauthError( | |
| 80 | − | "invalid_redirect_uri", | |
| 81 | − | "Give one to five redirect_uris: https addresses, http on localhost, or the application's own scheme.", | |
| 82 | − | ); | |
| 83 | − | } | |
| 84 | − | const client = decodeOAuthClient(clientId)!; | |
| 85 | − | return c.json( | |
| 86 | − | { | |
| 87 | − | client_id: clientId, | |
| 88 | − | client_name: client.name, | |
| 89 | − | redirect_uris: client.redirectUris, | |
| 90 | − | grant_types: ["authorization_code", "refresh_token"], | |
| 91 | − | response_types: ["code"], | |
| 92 | − | token_endpoint_auth_method: "none", | |
| 93 | − | }, | |
| 94 | − | 201, | |
| 95 | − | ); | |
| 96 | − | }); | |
| 97 | − | ||
| 98 | − | oauth.post("/oauth/token", async (c) => { | |
| 99 | − | const body = await fields(c.req.raw); | |
| 100 | − | const text = (key: string) => (typeof body[key] === "string" ? (body[key] as string) : ""); | |
| 101 | − | const identity = c.get("services").IDENTITY; | |
| 102 | − | ||
| 103 | − | let result; | |
| 104 | − | switch (text("grant_type")) { | |
| 105 | − | case "authorization_code": | |
| 106 | − | if (!text("code") || !text("code_verifier") || !text("client_id")) { | |
| 107 | − | return oauthError("invalid_request", "code, code_verifier and client_id are required."); | |
| 108 | − | } | |
| 109 | − | result = await identity.oauthExchange( | |
| 110 | − | text("code"), | |
| 111 | − | text("code_verifier"), | |
| 112 | − | text("client_id"), | |
| 113 | − | text("redirect_uri"), | |
| 114 | − | ); | |
| 115 | − | break; | |
| 116 | − | case "refresh_token": | |
| 117 | − | if (!text("refresh_token") || !text("client_id")) { | |
| 118 | − | return oauthError("invalid_request", "refresh_token and client_id are required."); | |
| 119 | − | } | |
| 120 | − | result = await identity.oauthRefresh(text("refresh_token"), text("client_id")); | |
| 121 | − | break; | |
| 122 | − | default: | |
| 123 | − | return oauthError( | |
| 124 | − | "unsupported_grant_type", | |
| 125 | − | "grant_type must be authorization_code or refresh_token.", | |
| 126 | − | ); | |
| 127 | − | } | |
| 128 | − | if (!result.ok) return oauthError("invalid_grant", result.error.message); | |
| 129 | − | return c.json( | |
| 130 | − | { | |
| 131 | − | access_token: result.value.accessToken, | |
| 132 | − | token_type: "Bearer", | |
| 133 | − | expires_in: result.value.expiresIn, | |
| 134 | − | refresh_token: result.value.refreshToken, | |
| 135 | − | }, | |
| 136 | − | 200, | |
| 137 | − | { "cache-control": "no-store" }, | |
| 138 | − | ); | |
| 139 | − | }); |
| 1 | + | //! The OpenAPI document, generated from the same list the routes are. | |
| 2 | + | ||
| 3 | + | use serde_json::{Map, Value, json}; | |
| 4 | + | ||
| 5 | + | use crate::operations::Op; | |
| 6 | + | use crate::rest::{ROUTES, Route}; | |
| 7 | + | ||
| 8 | + | /// The section of the API reference an operation is listed under. | |
| 9 | + | fn tag(op: Op) -> &'static str { | |
| 10 | + | let name = op.name(); | |
| 11 | + | if op == Op::Whoami || name.contains("workspace") { | |
| 12 | + | "Accounts" | |
| 13 | + | } else if name.contains("session") { | |
| 14 | + | "Sessions" | |
| 15 | + | } else if name.contains("pull_request") { | |
| 16 | + | "Pull requests" | |
| 17 | + | } else if ["issue", "label", "comment"] | |
| 18 | + | .iter() | |
| 19 | + | .any(|word| name.contains(word)) | |
| 20 | + | { | |
| 21 | + | "Issues" | |
| 22 | + | } else { | |
| 23 | + | "Repositories" | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | /// A short title from an operation name: `create_issue` is "Create issue". | |
| 28 | + | fn title(op: Op) -> String { | |
| 29 | + | if op == Op::Whoami { | |
| 30 | + | return "Get the current user".to_owned(); | |
| 31 | + | } | |
| 32 | + | let words = op.name().replace('_', " "); | |
| 33 | + | let mut letters = words.chars(); | |
| 34 | + | match letters.next() { | |
| 35 | + | Some(first) => first.to_uppercase().chain(letters).collect(), | |
| 36 | + | None => words, | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | /// `/v1/repos/:owner/:name` as OpenAPI writes it: `/v1/repos/{owner}/{name}`. | |
| 41 | + | fn openapi_path(route: &Route) -> String { | |
| 42 | + | route | |
| 43 | + | .path | |
| 44 | + | .split('/') | |
| 45 | + | .map(|segment| match segment.strip_prefix(':') { | |
| 46 | + | Some(name) => format!("{{{name}}}"), | |
| 47 | + | None => segment.to_owned(), | |
| 48 | + | }) | |
| 49 | + | .collect::<Vec<_>>() | |
| 50 | + | .join("/") | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | fn error_response(description: &str) -> Value { | |
| 54 | + | json!({ | |
| 55 | + | "description": description, | |
| 56 | + | "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }, | |
| 57 | + | }) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | fn operation(route: &Route) -> Value { | |
| 61 | + | let op = route.op; | |
| 62 | + | let path_params: Vec<&str> = route.params().collect(); | |
| 63 | + | // `owner` and `name` in the path stand for the operation's `repo` input. | |
| 64 | + | let covered = |name: &str| name == "repo" || path_params.contains(&name); | |
| 65 | + | let mut properties = op.properties(); | |
| 66 | + | properties.retain(|name, _| !covered(name)); | |
| 67 | + | let required: Vec<String> = op | |
| 68 | + | .required() | |
| 69 | + | .into_iter() | |
| 70 | + | .filter(|name| !covered(name)) | |
| 71 | + | .collect(); | |
| 72 | + | ||
| 73 | + | let mut parameters: Vec<Value> = path_params | |
| 74 | + | .iter() | |
| 75 | + | .map(|name| { | |
| 76 | + | json!({ | |
| 77 | + | "name": name, | |
| 78 | + | "in": "path", | |
| 79 | + | "required": true, | |
| 80 | + | "schema": { "type": if *name == "number" { "integer" } else { "string" } }, | |
| 81 | + | }) | |
| 82 | + | }) | |
| 83 | + | .collect(); | |
| 84 | + | let mut body = Value::Null; | |
| 85 | + | if route.method == "GET" { | |
| 86 | + | for (name, key) in route.query { | |
| 87 | + | parameters.push(json!({ | |
| 88 | + | "name": name, | |
| 89 | + | "in": "query", | |
| 90 | + | "required": false, | |
| 91 | + | "schema": properties.get(*key).cloned().unwrap_or_else(|| json!({})), | |
| 92 | + | })); | |
| 93 | + | } | |
| 94 | + | } else if !properties.is_empty() { | |
| 95 | + | let mut schema = json!({ "type": "object", "properties": properties }); | |
| 96 | + | if !required.is_empty() { | |
| 97 | + | schema["required"] = json!(required); | |
| 98 | + | } | |
| 99 | + | body = json!({ | |
| 100 | + | "required": !required.is_empty(), | |
| 101 | + | "content": { "application/json": { "schema": schema } }, | |
| 102 | + | }); | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | let mut described = json!({ | |
| 106 | + | "operationId": op.name(), | |
| 107 | + | "tags": [tag(op)], | |
| 108 | + | "summary": title(op), | |
| 109 | + | "description": op.description(), | |
| 110 | + | "parameters": parameters, | |
| 111 | + | "responses": { | |
| 112 | + | "200": { | |
| 113 | + | "description": "Success.", | |
| 114 | + | "content": { "application/json": { "schema": {} } }, | |
| 115 | + | }, | |
| 116 | + | "401": error_response("A token is required, or the one sent is not valid."), | |
| 117 | + | "403": error_response("Signed in, but not allowed to do this."), | |
| 118 | + | "404": error_response("It does not exist, or you cannot see it."), | |
| 119 | + | "409": error_response("The request conflicts with the current state."), | |
| 120 | + | "422": error_response("The input is not valid."), | |
| 121 | + | }, | |
| 122 | + | }); | |
| 123 | + | if !body.is_null() { | |
| 124 | + | described["requestBody"] = body; | |
| 125 | + | } | |
| 126 | + | described | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | /// Entries for device sign-in, which is not an operation. | |
| 130 | + | fn onboarding() -> Map<String, Value> { | |
| 131 | + | let paths = json!({ | |
| 132 | + | "/v1/device/code": { | |
| 133 | + | "post": { | |
| 134 | + | "operationId": "device_code", | |
| 135 | + | "tags": ["Accounts"], | |
| 136 | + | "summary": "Start signing in", | |
| 137 | + | "description": "Begins a device sign-in. Show the person `verification_uri_complete` and have them open it in a browser, where they sign in or register and approve the code. Then poll `/v1/device/token`.", | |
| 138 | + | "security": [], | |
| 139 | + | "requestBody": { | |
| 140 | + | "content": { "application/json": { "schema": { | |
| 141 | + | "type": "object", | |
| 142 | + | "properties": { | |
| 143 | + | "client_name": { | |
| 144 | + | "type": "string", | |
| 145 | + | "description": "What is asking, shown to the person approving. For example, Claude Code.", | |
| 146 | + | }, | |
| 147 | + | }, | |
| 148 | + | } } }, | |
| 149 | + | }, | |
| 150 | + | "responses": { "200": { | |
| 151 | + | "description": "The codes for this sign-in.", | |
| 152 | + | "content": { "application/json": { "schema": { | |
| 153 | + | "type": "object", | |
| 154 | + | "properties": { | |
| 155 | + | "device_code": { "type": "string", "description": "Secret. Send it to /v1/device/token." }, | |
| 156 | + | "user_code": { "type": "string", "description": "Shown to the person, like WDJB-MJHT." }, | |
| 157 | + | "verification_uri": { "type": "string" }, | |
| 158 | + | "verification_uri_complete": { | |
| 159 | + | "type": "string", | |
| 160 | + | "description": "The link to give the person; it carries the code.", | |
| 161 | + | }, | |
| 162 | + | "expires_in": { "type": "integer", "description": "Seconds until the codes expire." }, | |
| 163 | + | "interval": { "type": "integer", "description": "Seconds to wait between polls." }, | |
| 164 | + | }, | |
| 165 | + | } } }, | |
| 166 | + | } }, | |
| 167 | + | }, | |
| 168 | + | }, | |
| 169 | + | "/v1/device/token": { | |
| 170 | + | "post": { | |
| 171 | + | "operationId": "device_token", | |
| 172 | + | "tags": ["Accounts"], | |
| 173 | + | "summary": "Finish signing in", | |
| 174 | + | "description": "Asks whether the person has approved. Poll no faster than the interval. The token is returned once.", | |
| 175 | + | "security": [], | |
| 176 | + | "requestBody": { | |
| 177 | + | "required": true, | |
| 178 | + | "content": { "application/json": { "schema": { | |
| 179 | + | "type": "object", | |
| 180 | + | "required": ["device_code"], | |
| 181 | + | "properties": { "device_code": { "type": "string" } }, | |
| 182 | + | } } }, | |
| 183 | + | }, | |
| 184 | + | "responses": { "200": { | |
| 185 | + | "description": "The state of the sign-in.", | |
| 186 | + | "content": { "application/json": { "schema": { | |
| 187 | + | "type": "object", | |
| 188 | + | "required": ["status"], | |
| 189 | + | "properties": { | |
| 190 | + | "status": { "type": "string", "enum": ["pending", "approved", "denied", "expired"] }, | |
| 191 | + | "token": { "type": "string", "description": "Present when approved." }, | |
| 192 | + | "username": { "type": "string" }, | |
| 193 | + | "verified": { | |
| 194 | + | "type": "boolean", | |
| 195 | + | "description": "Whether the account's email is confirmed.", | |
| 196 | + | }, | |
| 197 | + | }, | |
| 198 | + | } } }, | |
| 199 | + | } }, | |
| 200 | + | }, | |
| 201 | + | }, | |
| 202 | + | }); | |
| 203 | + | match paths { | |
| 204 | + | Value::Object(paths) => paths, | |
| 205 | + | _ => Map::new(), | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | pub fn document() -> Value { | |
| 210 | + | let mut paths = onboarding(); | |
| 211 | + | for route in ROUTES { | |
| 212 | + | let entry = paths | |
| 213 | + | .entry(openapi_path(route)) | |
| 214 | + | .or_insert_with(|| json!({})); | |
| 215 | + | entry[route.method.to_lowercase()] = operation(route); | |
| 216 | + | } | |
| 217 | + | json!({ | |
| 218 | + | "openapi": "3.1.0", | |
| 219 | + | "info": { | |
| 220 | + | "title": "g1t API", | |
| 221 | + | "version": "1", | |
| 222 | + | "description": "The REST API for g1t, a git forge built for agents. The same operations are available to agents as MCP tools at https://mcp.g1t.sh.", | |
| 223 | + | "license": { "name": "MIT", "identifier": "MIT" }, | |
| 224 | + | }, | |
| 225 | + | "servers": [{ "url": "https://api.g1t.sh" }], | |
| 226 | + | "security": [{ "token": [] }, {}], | |
| 227 | + | "tags": [ | |
| 228 | + | { "name": "Accounts", "description": "Signing in from a tool, and the current user." }, | |
| 229 | + | { "name": "Repositories" }, | |
| 230 | + | { | |
| 231 | + | "name": "Issues", | |
| 232 | + | "description": "What should change in a repository, with labels and comments. Issues and pull requests share one sequence of numbers.", | |
| 233 | + | }, | |
| 234 | + | { | |
| 235 | + | "name": "Pull requests", | |
| 236 | + | "description": "A proposed change in its own fork or on a branch. Several can be made for one issue; the one merged resolves it.", | |
| 237 | + | }, | |
| 238 | + | { "name": "Sessions", "description": "The record of how a pull request was made." }, | |
| 239 | + | ], | |
| 240 | + | "paths": paths, | |
| 241 | + | "components": { | |
| 242 | + | "securitySchemes": { | |
| 243 | + | "token": { | |
| 244 | + | "type": "http", | |
| 245 | + | "scheme": "bearer", | |
| 246 | + | "description": "An access token, `g1t_…`. Public data needs none.", | |
| 247 | + | }, | |
| 248 | + | }, | |
| 249 | + | "schemas": { | |
| 250 | + | "Error": { | |
| 251 | + | "type": "object", | |
| 252 | + | "required": ["error"], | |
| 253 | + | "properties": { | |
| 254 | + | "error": { | |
| 255 | + | "type": "object", | |
| 256 | + | "required": ["code", "message"], | |
| 257 | + | "properties": { | |
| 258 | + | "code": { | |
| 259 | + | "type": "string", | |
| 260 | + | "enum": ["unauthenticated", "forbidden", "not_found", "conflict", "invalid"], | |
| 261 | + | }, | |
| 262 | + | "message": { "type": "string" }, | |
| 263 | + | }, | |
| 264 | + | }, | |
| 265 | + | }, | |
| 266 | + | }, | |
| 267 | + | }, | |
| 268 | + | }, | |
| 269 | + | }) | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | #[cfg(test)] | |
| 273 | + | mod tests { | |
| 274 | + | use super::*; | |
| 275 | + | ||
| 276 | + | #[test] | |
| 277 | + | fn every_route_is_documented_once() { | |
| 278 | + | let document = document(); | |
| 279 | + | let mut ids = Vec::new(); | |
| 280 | + | for (_, methods) in document["paths"].as_object().unwrap() { | |
| 281 | + | for (_, operation) in methods.as_object().unwrap() { | |
| 282 | + | ids.push(operation["operationId"].as_str().unwrap().to_owned()); | |
| 283 | + | } | |
| 284 | + | } | |
| 285 | + | for op in Op::ALL { | |
| 286 | + | assert_eq!( | |
| 287 | + | ids.iter().filter(|id| *id == op.name()).count(), | |
| 288 | + | 1, | |
| 289 | + | "{}", | |
| 290 | + | op.name() | |
| 291 | + | ); | |
| 292 | + | } | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | #[test] | |
| 296 | + | fn path_and_query_inputs_are_not_repeated_in_the_body() { | |
| 297 | + | let document = document(); | |
| 298 | + | let merge = &document["paths"]["/v1/repos/{owner}/{name}/pulls/{number}/merge"]["post"]; | |
| 299 | + | let body = &merge["requestBody"]["content"]["application/json"]["schema"]["properties"]; | |
| 300 | + | assert!(body.get("keep_issue_open").is_some()); | |
| 301 | + | assert!(body.get("repo").is_none() && body.get("number").is_none()); | |
| 302 | + | let list = &document["paths"]["/v1/repos"]["get"]; | |
| 303 | + | assert_eq!(list["parameters"][0]["name"], "q"); | |
| 304 | + | assert!(list.get("requestBody").is_none()); | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | #[test] | |
| 308 | + | fn titles_read_as_sentences() { | |
| 309 | + | assert_eq!(title(Op::CreateIssue), "Create issue"); | |
| 310 | + | assert_eq!(title(Op::Whoami), "Get the current user"); | |
| 311 | + | } | |
| 312 | + | } |
| 1 | − | import { operationsByName } from "./operations"; | |
| 2 | − | ||
| 3 | − | /** What the OpenAPI document needs to know about a REST route. */ | |
| 4 | − | export type RouteDoc = { | |
| 5 | − | method: "GET" | "POST" | "PATCH"; | |
| 6 | − | path: string; | |
| 7 | − | operation: string; | |
| 8 | − | tag: string; | |
| 9 | − | }; | |
| 10 | − | ||
| 11 | − | const ERROR = { $ref: "#/components/schemas/Error" }; | |
| 12 | − | ||
| 13 | − | function errorResponse(description: string) { | |
| 14 | − | return { | |
| 15 | − | description, | |
| 16 | − | content: { "application/json": { schema: ERROR } }, | |
| 17 | − | }; | |
| 18 | − | } | |
| 19 | − | ||
| 20 | − | /** A short title from an operation name: `create_issue` is "Create issue". */ | |
| 21 | − | function title(operation: string): string { | |
| 22 | − | if (operation === "whoami") return "Get the current user"; | |
| 23 | − | const words = operation.split("_").join(" "); | |
| 24 | − | return words[0].toUpperCase() + words.slice(1); | |
| 25 | − | } | |
| 26 | − | ||
| 27 | − | /** Hono's `:name` path parameters as OpenAPI's `{name}`. */ | |
| 28 | − | function openApiPath(path: string): string { | |
| 29 | − | return path.replace(/:([a-z_]+)/g, "{$1}"); | |
| 30 | − | } | |
| 31 | − | ||
| 32 | − | /** Hand-written entries for device sign-in, which is not an operation. */ | |
| 33 | − | const ONBOARDING = { | |
| 34 | − | "/v1/device/code": { | |
| 35 | − | post: { | |
| 36 | − | operationId: "device_code", | |
| 37 | − | tags: ["Accounts"], | |
| 38 | − | summary: "Start signing in", | |
| 39 | − | description: | |
| 40 | − | "Begins a device sign-in. Show the person `verification_uri_complete` and have them open it in a browser, where they sign in or register and approve the code. Then poll `/v1/device/token`.", | |
| 41 | − | security: [], | |
| 42 | − | requestBody: { | |
| 43 | − | content: { | |
| 44 | − | "application/json": { | |
| 45 | − | schema: { | |
| 46 | − | type: "object", | |
| 47 | − | properties: { | |
| 48 | − | client_name: { | |
| 49 | − | type: "string", | |
| 50 | − | description: "What is asking, shown to the person approving. For example, Claude Code.", | |
| 51 | − | }, | |
| 52 | − | }, | |
| 53 | − | }, | |
| 54 | − | }, | |
| 55 | − | }, | |
| 56 | − | }, | |
| 57 | − | responses: { | |
| 58 | − | "200": { | |
| 59 | − | description: "The codes for this sign-in.", | |
| 60 | − | content: { | |
| 61 | − | "application/json": { | |
| 62 | − | schema: { | |
| 63 | − | type: "object", | |
| 64 | − | properties: { | |
| 65 | − | device_code: { type: "string", description: "Secret. Send it to /v1/device/token." }, | |
| 66 | − | user_code: { type: "string", description: "Shown to the person, like WDJB-MJHT." }, | |
| 67 | − | verification_uri: { type: "string" }, | |
| 68 | − | verification_uri_complete: { | |
| 69 | − | type: "string", | |
| 70 | − | description: "The link to give the person; it carries the code.", | |
| 71 | − | }, | |
| 72 | − | expires_in: { type: "integer", description: "Seconds until the codes expire." }, | |
| 73 | − | interval: { type: "integer", description: "Seconds to wait between polls." }, | |
| 74 | − | }, | |
| 75 | − | }, | |
| 76 | − | }, | |
| 77 | − | }, | |
| 78 | − | }, | |
| 79 | − | }, | |
| 80 | − | }, | |
| 81 | − | }, | |
| 82 | − | "/v1/device/token": { | |
| 83 | − | post: { | |
| 84 | − | operationId: "device_token", | |
| 85 | − | tags: ["Accounts"], | |
| 86 | − | summary: "Finish signing in", | |
| 87 | − | description: | |
| 88 | − | "Asks whether the person has approved. Poll no faster than the interval. The token is returned once.", | |
| 89 | − | security: [], | |
| 90 | − | requestBody: { | |
| 91 | − | required: true, | |
| 92 | − | content: { | |
| 93 | − | "application/json": { | |
| 94 | − | schema: { | |
| 95 | − | type: "object", | |
| 96 | − | required: ["device_code"], | |
| 97 | − | properties: { device_code: { type: "string" } }, | |
| 98 | − | }, | |
| 99 | − | }, | |
| 100 | − | }, | |
| 101 | − | }, | |
| 102 | − | responses: { | |
| 103 | − | "200": { | |
| 104 | − | description: "The state of the sign-in.", | |
| 105 | − | content: { | |
| 106 | − | "application/json": { | |
| 107 | − | schema: { | |
| 108 | − | type: "object", | |
| 109 | − | required: ["status"], | |
| 110 | − | properties: { | |
| 111 | − | status: { type: "string", enum: ["pending", "approved", "denied", "expired"] }, | |
| 112 | − | token: { type: "string", description: "Present when approved." }, | |
| 113 | − | username: { type: "string" }, | |
| 114 | − | verified: { | |
| 115 | − | type: "boolean", | |
| 116 | − | description: "Whether the account's email is confirmed.", | |
| 117 | − | }, | |
| 118 | − | }, | |
| 119 | − | }, | |
| 120 | − | }, | |
| 121 | − | }, | |
| 122 | − | }, | |
| 123 | − | }, | |
| 124 | − | }, | |
| 125 | − | }, | |
| 126 | − | }; | |
| 127 | − | ||
| 128 | − | /** The OpenAPI document, generated from the same list the routes are. */ | |
| 129 | − | export function openApiDocument(routes: RouteDoc[]) { | |
| 130 | − | const paths: Record<string, Record<string, unknown>> = { ...ONBOARDING }; | |
| 131 | − | for (const route of routes) { | |
| 132 | − | const operation = operationsByName.get(route.operation)!; | |
| 133 | − | const pathParams = [...route.path.matchAll(/:([a-z_]+)/g)].map((match) => match[1]); | |
| 134 | − | // `owner` and `name` in the path stand for the operation's `repo` input. | |
| 135 | − | const covered = new Set([...pathParams, "repo"]); | |
| 136 | − | const inputs = Object.entries(operation.input.properties).filter( | |
| 137 | − | ([name]) => !covered.has(name), | |
| 138 | − | ); | |
| 139 | − | const required = (operation.input.required ?? []).filter( | |
| 140 | − | (name) => !covered.has(name), | |
| 141 | − | ); | |
| 142 | − | ||
| 143 | − | const parameters: unknown[] = pathParams.map((name) => ({ | |
| 144 | − | name, | |
| 145 | − | in: "path", | |
| 146 | − | required: true, | |
| 147 | − | schema: { type: name === "number" ? "integer" : "string" }, | |
| 148 | − | })); | |
| 149 | − | let requestBody: unknown; | |
| 150 | − | if (route.method === "GET") { | |
| 151 | − | for (const [name, schema] of inputs) { | |
| 152 | − | parameters.push({ | |
| 153 | − | // The repository search parameter is `q` on the wire. | |
| 154 | − | name: route.operation === "list_repos" && name === "query" ? "q" : name, | |
| 155 | − | in: "query", | |
| 156 | − | required: false, | |
| 157 | − | schema, | |
| 158 | − | }); | |
| 159 | − | } | |
| 160 | − | } else if (inputs.length > 0) { | |
| 161 | − | requestBody = { | |
| 162 | − | required: required.length > 0, | |
| 163 | − | content: { | |
| 164 | − | "application/json": { | |
| 165 | − | schema: { | |
| 166 | − | type: "object", | |
| 167 | − | properties: Object.fromEntries(inputs), | |
| 168 | − | ...(required.length > 0 ? { required } : {}), | |
| 169 | − | }, | |
| 170 | − | }, | |
| 171 | − | }, | |
| 172 | − | }; | |
| 173 | − | } | |
| 174 | − | ||
| 175 | − | const path = openApiPath(route.path); | |
| 176 | − | paths[path] ??= {}; | |
| 177 | − | paths[path][route.method.toLowerCase()] = { | |
| 178 | − | operationId: route.operation, | |
| 179 | − | tags: [route.tag], | |
| 180 | − | summary: title(route.operation), | |
| 181 | − | description: operation.description, | |
| 182 | − | parameters, | |
| 183 | − | ...(requestBody ? { requestBody } : {}), | |
| 184 | − | responses: { | |
| 185 | − | "200": { | |
| 186 | − | description: "Success.", | |
| 187 | − | content: { "application/json": { schema: {} } }, | |
| 188 | − | }, | |
| 189 | − | "401": errorResponse("A token is required, or the one sent is not valid."), | |
| 190 | − | "403": errorResponse("Signed in, but not allowed to do this."), | |
| 191 | − | "404": errorResponse("It does not exist, or you cannot see it."), | |
| 192 | − | "409": errorResponse("The request conflicts with the current state."), | |
| 193 | − | "422": errorResponse("The input is not valid."), | |
| 194 | − | }, | |
| 195 | − | }; | |
| 196 | − | } | |
| 197 | − | ||
| 198 | − | return { | |
| 199 | − | openapi: "3.1.0", | |
| 200 | − | info: { | |
| 201 | − | title: "g1t API", | |
| 202 | − | version: "1", | |
| 203 | − | description: | |
| 204 | − | "The REST API for g1t, a git forge built for agents. The same operations are available to agents as MCP tools at https://mcp.g1t.sh.", | |
| 205 | − | license: { name: "MIT", identifier: "MIT" }, | |
| 206 | − | }, | |
| 207 | − | servers: [{ url: "https://api.g1t.sh" }], | |
| 208 | − | security: [{ token: [] }, {}], | |
| 209 | − | tags: [ | |
| 210 | − | { name: "Accounts", description: "Signing in from a tool, and the current user." }, | |
| 211 | − | { name: "Repositories" }, | |
| 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." }, | |
| 223 | − | ], | |
| 224 | − | paths, | |
| 225 | − | components: { | |
| 226 | − | securitySchemes: { | |
| 227 | − | token: { | |
| 228 | − | type: "http", | |
| 229 | − | scheme: "bearer", | |
| 230 | − | description: "An access token, `g1t_…`. Public data needs none.", | |
| 231 | − | }, | |
| 232 | − | }, | |
| 233 | − | schemas: { | |
| 234 | − | Error: { | |
| 235 | − | type: "object", | |
| 236 | − | required: ["error"], | |
| 237 | − | properties: { | |
| 238 | − | error: { | |
| 239 | − | type: "object", | |
| 240 | − | required: ["code", "message"], | |
| 241 | − | properties: { | |
| 242 | − | code: { | |
| 243 | − | type: "string", | |
| 244 | − | enum: ["unauthenticated", "forbidden", "not_found", "conflict", "invalid"], | |
| 245 | − | }, | |
| 246 | − | message: { type: "string" }, | |
| 247 | − | }, | |
| 248 | − | }, | |
| 249 | − | }, | |
| 250 | − | }, | |
| 251 | − | }, | |
| 252 | − | }, | |
| 253 | − | }; | |
| 254 | − | } |
| 1 | + | //! Everything a client can do through the API. | |
| 2 | + | //! | |
| 3 | + | //! REST routes, MCP tools and the OpenAPI document are all generated from | |
| 4 | + | //! [`Op`], so the surfaces cannot drift apart: adding a variant without | |
| 5 | + | //! describing it or running it does not compile. | |
| 6 | + | ||
| 7 | + | use g1t_contracts::events::{Event, ListArgs as ListEventsArgs}; | |
| 8 | + | use g1t_contracts::identity::CreateWorkspaceArgs; | |
| 9 | + | use g1t_contracts::repos::{ | |
| 10 | + | CompareArgs, CreateArgs, GetArgs, ListArgs as ListReposArgs, Repo, RepoPath, | |
| 11 | + | }; | |
| 12 | + | use g1t_contracts::work::*; | |
| 13 | + | use g1t_contracts::{FailureCode, Outcome, Viewer}; | |
| 14 | + | use serde::Serialize; | |
| 15 | + | use serde::de::DeserializeOwned; | |
| 16 | + | use serde_json::{Map, Value, json}; | |
| 17 | + | use worker::{Env, Fetcher, Result}; | |
| 18 | + | ||
| 19 | + | /// The services the API is a front for. | |
| 20 | + | pub struct Services { | |
| 21 | + | pub identity: Fetcher, | |
| 22 | + | pub repos: Fetcher, | |
| 23 | + | pub work: Fetcher, | |
| 24 | + | pub events: Fetcher, | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | impl Services { | |
| 28 | + | pub fn new(env: &Env) -> Result<Self> { | |
| 29 | + | Ok(Services { | |
| 30 | + | identity: env.service("IDENTITY")?, | |
| 31 | + | repos: env.service("REPOS")?, | |
| 32 | + | work: env.service("WORK")?, | |
| 33 | + | events: env.service("EVENTS")?, | |
| 34 | + | }) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 39 | + | pub enum Op { | |
| 40 | + | Whoami, | |
| 41 | + | CreateWorkspace, | |
| 42 | + | ListRepos, | |
| 43 | + | GetRepo, | |
| 44 | + | CreateRepo, | |
| 45 | + | ListIssues, | |
| 46 | + | GetIssue, | |
| 47 | + | CreateIssue, | |
| 48 | + | UpdateIssue, | |
| 49 | + | CloseIssue, | |
| 50 | + | ReopenIssue, | |
| 51 | + | ListLabels, | |
| 52 | + | AddComment, | |
| 53 | + | ListPullRequests, | |
| 54 | + | GetPullRequest, | |
| 55 | + | CreatePullRequest, | |
| 56 | + | RecordSession, | |
| 57 | + | ReadSession, | |
| 58 | + | MarkPullRequestReady, | |
| 59 | + | ClosePullRequest, | |
| 60 | + | GetPullRequestChanges, | |
| 61 | + | MergePullRequest, | |
| 62 | + | ListEvents, | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> { | |
| 66 | + | Ok(Outcome::fail(code, message)) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | fn ok<T: Serialize>(value: &T) -> Result<Outcome<Value>> { | |
| 70 | + | Ok(Outcome::Ok(serde_json::to_value(value)?)) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | /// Calls a method that returns an `Outcome`, decoding its value as `T`. | |
| 74 | + | async fn call<A: Serialize, T: DeserializeOwned>( | |
| 75 | + | service: &Fetcher, | |
| 76 | + | method: &str, | |
| 77 | + | args: &A, | |
| 78 | + | ) -> Result<Outcome<T>> { | |
| 79 | + | g1t_kit::call(service, method, args).await | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | /// Calls a method that returns an `Outcome`, passing its value through. | |
| 83 | + | async fn pass<A: Serialize>(service: &Fetcher, method: &str, args: &A) -> Result<Outcome<Value>> { | |
| 84 | + | call(service, method, args).await | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | fn text(input: &Value, key: &str) -> String { | |
| 88 | + | input[key].as_str().unwrap_or_default().to_owned() | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | fn optional_text(input: &Value, key: &str) -> Option<String> { | |
| 92 | + | input[key] | |
| 93 | + | .as_str() | |
| 94 | + | .filter(|value| !value.is_empty()) | |
| 95 | + | .map(str::to_owned) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | /// A whole number given as a number or as digits. | |
| 99 | + | fn integer(input: &Value, key: &str) -> Option<u32> { | |
| 100 | + | match &input[key] { | |
| 101 | + | Value::Number(number) => number.as_u64().and_then(|n| u32::try_from(n).ok()), | |
| 102 | + | Value::String(digits) => digits.parse().ok(), | |
| 103 | + | _ => None, | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | fn strings(input: &Value, key: &str) -> Option<Vec<String>> { | |
| 108 | + | input[key].as_array().map(|items| { | |
| 109 | + | items | |
| 110 | + | .iter() | |
| 111 | + | .map(|item| match item { | |
| 112 | + | Value::String(text) => text.clone(), | |
| 113 | + | other => other.to_string(), | |
| 114 | + | }) | |
| 115 | + | .collect() | |
| 116 | + | }) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | fn state(input: &Value) -> Option<State> { | |
| 120 | + | match input["state"].as_str() { | |
| 121 | + | Some("open") => Some(State::Open), | |
| 122 | + | Some("closed") => Some(State::Closed), | |
| 123 | + | _ => None, | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | /// The repository named by `repo`, written `owner/name`. | |
| 128 | + | fn repo_path(input: &Value) -> Option<RepoPath> { | |
| 129 | + | let mut parts = input["repo"].as_str()?.split('/'); | |
| 130 | + | match (parts.next(), parts.next(), parts.next()) { | |
| 131 | + | (Some(namespace), Some(name), None) if !namespace.is_empty() && !name.is_empty() => { | |
| 132 | + | Some(RepoPath { | |
| 133 | + | namespace: namespace.to_owned(), | |
| 134 | + | name: name.to_owned(), | |
| 135 | + | }) | |
| 136 | + | } | |
| 137 | + | _ => None, | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | /// An object schema. `required` names the properties that must be given. | |
| 142 | + | fn object(properties: Value, required: &[&str]) -> Value { | |
| 143 | + | let mut schema = json!({ "type": "object", "properties": properties }); | |
| 144 | + | if !required.is_empty() { | |
| 145 | + | schema["required"] = json!(required); | |
| 146 | + | } | |
| 147 | + | schema | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | /// The properties naming an issue or pull request, with `more` added. | |
| 151 | + | fn numbered(more: Value) -> Value { | |
| 152 | + | let mut properties = json!({ | |
| 153 | + | "repo": repo_schema(), | |
| 154 | + | "number": { | |
| 155 | + | "type": "integer", | |
| 156 | + | "description": "The number shown after the #. Issues and pull requests share one sequence.", | |
| 157 | + | }, | |
| 158 | + | }); | |
| 159 | + | if let (Some(all), Value::Object(more)) = (properties.as_object_mut(), more) { | |
| 160 | + | all.extend(more); | |
| 161 | + | } | |
| 162 | + | properties | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | fn repo_schema() -> Value { | |
| 166 | + | json!({ | |
| 167 | + | "type": "string", | |
| 168 | + | "description": "Repository as \"owner/name\", e.g. \"syntaqx/hello\".", | |
| 169 | + | }) | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | /// What `ReposApi.compare` needs to show what a pull request changes. | |
| 173 | + | /// | |
| 174 | + | /// A fork is compared as a whole. A branch is compared by name while the | |
| 175 | + | /// pull request is open, and by the commit it was merged or closed at | |
| 176 | + | /// afterwards, so later pushes to the branch do not change the record. | |
| 177 | + | fn comparison(pull: Pull, viewer: &Viewer) -> CompareArgs { | |
| 178 | + | let settled = matches!(pull.status, PullStatus::Merged | PullStatus::Closed); | |
| 179 | + | let (repo_id, head) = match pull.fork_repo_id { | |
| 180 | + | Some(fork) => (fork, None), | |
| 181 | + | None => ( | |
| 182 | + | pull.repo_id, | |
| 183 | + | pull.head_commit.filter(|_| settled).or(pull.branch), | |
| 184 | + | ), | |
| 185 | + | }; | |
| 186 | + | CompareArgs { | |
| 187 | + | repo_id, | |
| 188 | + | viewer: viewer.clone(), | |
| 189 | + | base: pull.merge_base, | |
| 190 | + | head, | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | impl Op { | |
| 195 | + | pub const ALL: [Op; 23] = [ | |
| 196 | + | Op::Whoami, | |
| 197 | + | Op::CreateWorkspace, | |
| 198 | + | Op::ListRepos, | |
| 199 | + | Op::GetRepo, | |
| 200 | + | Op::CreateRepo, | |
| 201 | + | Op::ListIssues, | |
| 202 | + | Op::GetIssue, | |
| 203 | + | Op::CreateIssue, | |
| 204 | + | Op::UpdateIssue, | |
| 205 | + | Op::CloseIssue, | |
| 206 | + | Op::ReopenIssue, | |
| 207 | + | Op::ListLabels, | |
| 208 | + | Op::AddComment, | |
| 209 | + | Op::ListPullRequests, | |
| 210 | + | Op::GetPullRequest, | |
| 211 | + | Op::CreatePullRequest, | |
| 212 | + | Op::RecordSession, | |
| 213 | + | Op::ReadSession, | |
| 214 | + | Op::MarkPullRequestReady, | |
| 215 | + | Op::ClosePullRequest, | |
| 216 | + | Op::GetPullRequestChanges, | |
| 217 | + | Op::MergePullRequest, | |
| 218 | + | Op::ListEvents, | |
| 219 | + | ]; | |
| 220 | + | ||
| 221 | + | pub fn by_name(name: &str) -> Option<Op> { | |
| 222 | + | Op::ALL.into_iter().find(|op| op.name() == name) | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | /// The operation's name: its MCP tool name and OpenAPI operation id. | |
| 226 | + | pub fn name(self) -> &'static str { | |
| 227 | + | match self { | |
| 228 | + | Op::Whoami => "whoami", | |
| 229 | + | Op::CreateWorkspace => "create_workspace", | |
| 230 | + | Op::ListRepos => "list_repos", | |
| 231 | + | Op::GetRepo => "get_repo", | |
| 232 | + | Op::CreateRepo => "create_repo", | |
| 233 | + | Op::ListIssues => "list_issues", | |
| 234 | + | Op::GetIssue => "get_issue", | |
| 235 | + | Op::CreateIssue => "create_issue", | |
| 236 | + | Op::UpdateIssue => "update_issue", | |
| 237 | + | Op::CloseIssue => "close_issue", | |
| 238 | + | Op::ReopenIssue => "reopen_issue", | |
| 239 | + | Op::ListLabels => "list_labels", | |
| 240 | + | Op::AddComment => "add_comment", | |
| 241 | + | Op::ListPullRequests => "list_pull_requests", | |
| 242 | + | Op::GetPullRequest => "get_pull_request", | |
| 243 | + | Op::CreatePullRequest => "create_pull_request", | |
| 244 | + | Op::RecordSession => "record_session", | |
| 245 | + | Op::ReadSession => "read_session", | |
| 246 | + | Op::MarkPullRequestReady => "mark_pull_request_ready", | |
| 247 | + | Op::ClosePullRequest => "close_pull_request", | |
| 248 | + | Op::GetPullRequestChanges => "get_pull_request_changes", | |
| 249 | + | Op::MergePullRequest => "merge_pull_request", | |
| 250 | + | Op::ListEvents => "list_events", | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | pub fn description(self) -> &'static str { | |
| 255 | + | match self { | |
| 256 | + | Op::Whoami => "The account the access token belongs to, and its workspaces.", | |
| 257 | + | Op::CreateWorkspace => { | |
| 258 | + | "Create a workspace. A workspace owns repositories and is the first part of their address, g1t.sh/<workspace>/<repo>. The whoami tool lists the ones you already belong to." | |
| 259 | + | } | |
| 260 | + | Op::ListRepos => "Repositories you can see, optionally filtered by a search query.", | |
| 261 | + | Op::GetRepo => "One repository's details.", | |
| 262 | + | Op::CreateRepo => "Create a repository in one of your workspaces.", | |
| 263 | + | Op::ListIssues => { | |
| 264 | + | "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." | |
| 265 | + | } | |
| 266 | + | Op::GetIssue => { | |
| 267 | + | "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." | |
| 268 | + | } | |
| 269 | + | Op::CreateIssue => "Open an issue on a repository.", | |
| 270 | + | Op::UpdateIssue => { | |
| 271 | + | "Change an issue's title, body or labels. Only the fields given are changed; labels replaces the whole set." | |
| 272 | + | } | |
| 273 | + | Op::CloseIssue => { | |
| 274 | + | "Close an issue without a pull request. Merging a pull request made for an issue closes it for you." | |
| 275 | + | } | |
| 276 | + | Op::ReopenIssue => "Reopen a closed issue.", | |
| 277 | + | Op::ListLabels => "The labels available on a repository's issues.", | |
| 278 | + | Op::AddComment => "Comment on an issue or a pull request.", | |
| 279 | + | Op::ListPullRequests => { | |
| 280 | + | "Pull requests on a repository, newest first. State open covers drafts and those ready for review; closed covers merged and closed." | |
| 281 | + | } | |
| 282 | + | Op::GetPullRequest => { | |
| 283 | + | "A pull request's status, head commit, comments and the issue it is for." | |
| 284 | + | } | |
| 285 | + | Op::CreatePullRequest => { | |
| 286 | + | "Start a change. Opens a draft pull request with its own fork of the repository and returns the fork's git remote. Clone it, commit your work there, push, record your session as you go, then call mark_pull_request_ready. Give the issue it is for whenever there is one. If the change is already on a branch pushed to the repository, give that branch instead: no fork is made and the pull request is ready for review at once." | |
| 287 | + | } | |
| 288 | + | Op::RecordSession => { | |
| 289 | + | "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." | |
| 290 | + | } | |
| 291 | + | Op::ReadSession => "The recorded session of a pull request, oldest entry first.", | |
| 292 | + | Op::MarkPullRequestReady => { | |
| 293 | + | "Mark a draft pull request ready for review. Push your commits first. The summary becomes its description and should say what changed and why." | |
| 294 | + | } | |
| 295 | + | Op::ClosePullRequest => "Close a pull request without merging it.", | |
| 296 | + | Op::GetPullRequestChanges => { | |
| 297 | + | "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." | |
| 298 | + | } | |
| 299 | + | Op::MergePullRequest => { | |
| 300 | + | "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 or branch and push, then merge again." | |
| 301 | + | } | |
| 302 | + | Op::ListEvents => { | |
| 303 | + | "The timeline of a repository: pushes, issues, pull requests, comments and session activity, newest first." | |
| 304 | + | } | |
| 305 | + | } | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | /// The JSON Schema of the operation's input. | |
| 309 | + | pub fn input(self) -> Value { | |
| 310 | + | let repo_only = || object(json!({ "repo": repo_schema() }), &["repo"]); | |
| 311 | + | let just_numbered = || object(numbered(json!({})), &["repo", "number"]); | |
| 312 | + | let states = json!({ "type": "string", "enum": ["open", "closed"] }); | |
| 313 | + | match self { | |
| 314 | + | Op::Whoami => object(json!({}), &[]), | |
| 315 | + | Op::CreateWorkspace => object( | |
| 316 | + | json!({ | |
| 317 | + | "slug": { | |
| 318 | + | "type": "string", | |
| 319 | + | "description": "Its name in URLs: lowercase letters, digits and single hyphens.", | |
| 320 | + | }, | |
| 321 | + | "name": { "type": "string", "description": "A display name." }, | |
| 322 | + | }), | |
| 323 | + | &["slug"], | |
| 324 | + | ), | |
| 325 | + | Op::ListRepos => object( | |
| 326 | + | json!({ | |
| 327 | + | "query": { "type": "string", "description": "Matches name or description." }, | |
| 328 | + | }), | |
| 329 | + | &[], | |
| 330 | + | ), | |
| 331 | + | Op::GetRepo | Op::ListLabels => repo_only(), | |
| 332 | + | Op::CreateRepo => object( | |
| 333 | + | json!({ | |
| 334 | + | "workspace": { | |
| 335 | + | "type": "string", | |
| 336 | + | "description": "The workspace to create it in. May be left out if you belong to exactly one.", | |
| 337 | + | }, | |
| 338 | + | "name": { "type": "string" }, | |
| 339 | + | "description": { "type": "string" }, | |
| 340 | + | "private": { "type": "boolean" }, | |
| 341 | + | }), | |
| 342 | + | &["name"], | |
| 343 | + | ), | |
| 344 | + | Op::ListIssues => object( | |
| 345 | + | json!({ | |
| 346 | + | "repo": repo_schema(), | |
| 347 | + | "state": states, | |
| 348 | + | "label": { "type": "string", "description": "Only issues carrying this label." }, | |
| 349 | + | }), | |
| 350 | + | &["repo"], | |
| 351 | + | ), | |
| 352 | + | Op::GetIssue | |
| 353 | + | | Op::ReopenIssue | |
| 354 | + | | Op::GetPullRequest | |
| 355 | + | | Op::ClosePullRequest | |
| 356 | + | | Op::GetPullRequestChanges => just_numbered(), | |
| 357 | + | Op::CreateIssue => object( | |
| 358 | + | json!({ | |
| 359 | + | "repo": repo_schema(), | |
| 360 | + | "title": { "type": "string", "description": "The problem or goal in one line." }, | |
| 361 | + | "body": { | |
| 362 | + | "type": "string", | |
| 363 | + | "description": "Markdown. What an agent or a person needs to do the work: what is wrong or wanted, constraints, context.", | |
| 364 | + | }, | |
| 365 | + | "labels": { | |
| 366 | + | "type": "array", | |
| 367 | + | "items": { "type": "string" }, | |
| 368 | + | "description": "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.", | |
| 369 | + | }, | |
| 370 | + | "checks": { | |
| 371 | + | "type": "array", | |
| 372 | + | "items": { "type": "string" }, | |
| 373 | + | "description": "Commands that must pass for a pull request to be accepted.", | |
| 374 | + | }, | |
| 375 | + | }), | |
| 376 | + | &["repo", "title"], | |
| 377 | + | ), | |
| 378 | + | Op::UpdateIssue => object( | |
| 379 | + | numbered(json!({ | |
| 380 | + | "title": { "type": "string" }, | |
| 381 | + | "body": { "type": "string" }, | |
| 382 | + | "labels": { "type": "array", "items": { "type": "string" } }, | |
| 383 | + | })), | |
| 384 | + | &["repo", "number"], | |
| 385 | + | ), | |
| 386 | + | Op::CloseIssue => object( | |
| 387 | + | numbered(json!({ | |
| 388 | + | "reason": { | |
| 389 | + | "type": "string", | |
| 390 | + | "enum": ["completed", "not_planned"], | |
| 391 | + | "description": "Defaults to completed.", | |
| 392 | + | }, | |
| 393 | + | })), | |
| 394 | + | &["repo", "number"], | |
| 395 | + | ), | |
| 396 | + | Op::AddComment => object( | |
| 397 | + | numbered(json!({ "body": { "type": "string", "description": "Markdown." } })), | |
| 398 | + | &["repo", "number", "body"], | |
| 399 | + | ), | |
| 400 | + | Op::ListPullRequests => { | |
| 401 | + | object(json!({ "repo": repo_schema(), "state": states }), &["repo"]) | |
| 402 | + | } | |
| 403 | + | Op::CreatePullRequest => object( | |
| 404 | + | json!({ | |
| 405 | + | "repo": repo_schema(), | |
| 406 | + | "issue": { "type": "integer", "description": "The number of the issue this is for." }, | |
| 407 | + | "title": { | |
| 408 | + | "type": "string", | |
| 409 | + | "description": "Defaults to the issue's title. Required when there is no issue.", | |
| 410 | + | }, | |
| 411 | + | "branch": { | |
| 412 | + | "type": "string", | |
| 413 | + | "description": "A branch already pushed to the repository that holds the change. Leave out to get a fork.", | |
| 414 | + | }, | |
| 415 | + | "body": { | |
| 416 | + | "type": "string", | |
| 417 | + | "description": "Markdown: what changed and why. Mainly for pull requests from a branch.", | |
| 418 | + | }, | |
| 419 | + | "agent": { | |
| 420 | + | "type": "string", | |
| 421 | + | "description": "A label for the agent doing the work, e.g. \"claude-code\".", | |
| 422 | + | }, | |
| 423 | + | }), | |
| 424 | + | &["repo"], | |
| 425 | + | ), | |
| 426 | + | Op::RecordSession => object( | |
| 427 | + | numbered(json!({ | |
| 428 | + | "entries": { | |
| 429 | + | "type": "array", | |
| 430 | + | "items": { | |
| 431 | + | "type": "object", | |
| 432 | + | "properties": { | |
| 433 | + | "kind": { | |
| 434 | + | "type": "string", | |
| 435 | + | "enum": ["prompt", "message", "tool_call", "tool_result", "note"], | |
| 436 | + | }, | |
| 437 | + | "text": { "type": "string" }, | |
| 438 | + | "tool": { "type": "string", "description": "Tool name, for tool entries." }, | |
| 439 | + | }, | |
| 440 | + | "required": ["kind", "text"], | |
| 441 | + | }, | |
| 442 | + | }, | |
| 443 | + | })), | |
| 444 | + | &["repo", "number", "entries"], | |
| 445 | + | ), | |
| 446 | + | Op::ReadSession => object( | |
| 447 | + | numbered(json!({ | |
| 448 | + | "after": { "type": "integer", "description": "Only entries after this sequence number." }, | |
| 449 | + | })), | |
| 450 | + | &["repo", "number"], | |
| 451 | + | ), | |
| 452 | + | Op::MarkPullRequestReady => object( | |
| 453 | + | numbered(json!({ "summary": { "type": "string", "description": "Markdown." } })), | |
| 454 | + | &["repo", "number", "summary"], | |
| 455 | + | ), | |
| 456 | + | Op::MergePullRequest => object( | |
| 457 | + | numbered(json!({ | |
| 458 | + | "keep_issue_open": { | |
| 459 | + | "type": "boolean", | |
| 460 | + | "description": "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.", | |
| 461 | + | }, | |
| 462 | + | })), | |
| 463 | + | &["repo", "number"], | |
| 464 | + | ), | |
| 465 | + | Op::ListEvents => object( | |
| 466 | + | json!({ | |
| 467 | + | "repo": repo_schema(), | |
| 468 | + | "before": { "type": "string", "description": "Event id to page back from." }, | |
| 469 | + | }), | |
| 470 | + | &["repo"], | |
| 471 | + | ), | |
| 472 | + | } | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | /// Whether the operation refuses an anonymous caller outright. | |
| 476 | + | fn needs_user(self) -> bool { | |
| 477 | + | !matches!( | |
| 478 | + | self, | |
| 479 | + | Op::ListRepos | |
| 480 | + | | Op::GetRepo | |
| 481 | + | | Op::ListIssues | |
| 482 | + | | Op::GetIssue | |
| 483 | + | | Op::ListLabels | |
| 484 | + | | Op::ListPullRequests | |
| 485 | + | | Op::GetPullRequest | |
| 486 | + | | Op::ReadSession | |
| 487 | + | | Op::GetPullRequestChanges | |
| 488 | + | | Op::ListEvents | |
| 489 | + | ) | |
| 490 | + | } | |
| 491 | + | ||
| 492 | + | /// Whether the operation is about one repository, named by `repo`. | |
| 493 | + | fn needs_repo(self) -> bool { | |
| 494 | + | !matches!( | |
| 495 | + | self, | |
| 496 | + | Op::Whoami | Op::CreateWorkspace | Op::ListRepos | Op::CreateRepo | |
| 497 | + | ) | |
| 498 | + | } | |
| 499 | + | ||
| 500 | + | pub async fn run( | |
| 501 | + | self, | |
| 502 | + | services: &Services, | |
| 503 | + | viewer: &Viewer, | |
| 504 | + | input: &Value, | |
| 505 | + | ) -> Result<Outcome<Value>> { | |
| 506 | + | if self.needs_user() && viewer.is_none() { | |
| 507 | + | return failed( | |
| 508 | + | FailureCode::Unauthenticated, | |
| 509 | + | "This needs a g1t access token.", | |
| 510 | + | ); | |
| 511 | + | } | |
| 512 | + | // Checked above for every operation that uses it. | |
| 513 | + | let actor = || viewer.clone().unwrap_or_default(); | |
| 514 | + | let repo = match repo_path(input) { | |
| 515 | + | Some(repo) => repo, | |
| 516 | + | None if self.needs_repo() => { | |
| 517 | + | return failed( | |
| 518 | + | FailureCode::Invalid, | |
| 519 | + | "Give the repository as \"owner/name\".", | |
| 520 | + | ); | |
| 521 | + | } | |
| 522 | + | None => RepoPath { | |
| 523 | + | namespace: String::new(), | |
| 524 | + | name: String::new(), | |
| 525 | + | }, | |
| 526 | + | }; | |
| 527 | + | let number = integer(input, "number").unwrap_or_default(); | |
| 528 | + | let view = || ViewArgs { | |
| 529 | + | repo: repo.clone(), | |
| 530 | + | number, | |
| 531 | + | viewer: viewer.clone(), | |
| 532 | + | after_seq: integer(input, "after").unwrap_or_default(), | |
| 533 | + | }; | |
| 534 | + | let pull_action = || PullActionArgs { | |
| 535 | + | actor: actor(), | |
| 536 | + | repo: repo.clone(), | |
| 537 | + | number, | |
| 538 | + | summary: text(input, "summary"), | |
| 539 | + | keep_issue_open: input["keep_issue_open"].as_bool() == Some(true), | |
| 540 | + | }; | |
| 541 | + | let Services { | |
| 542 | + | identity, | |
| 543 | + | repos, | |
| 544 | + | work, | |
| 545 | + | events, | |
| 546 | + | } = services; | |
| 547 | + | ||
| 548 | + | match self { | |
| 549 | + | Op::Whoami => ok(&actor()), | |
| 550 | + | Op::CreateWorkspace => { | |
| 551 | + | pass( | |
| 552 | + | identity, | |
| 553 | + | "create_workspace", | |
| 554 | + | &CreateWorkspaceArgs { | |
| 555 | + | user: actor(), | |
| 556 | + | slug: text(input, "slug"), | |
| 557 | + | name: text(input, "name"), | |
| 558 | + | }, | |
| 559 | + | ) | |
| 560 | + | .await | |
| 561 | + | } | |
| 562 | + | Op::ListRepos => { | |
| 563 | + | let found: Vec<Repo> = g1t_kit::call( | |
| 564 | + | repos, | |
| 565 | + | "list", | |
| 566 | + | &ListReposArgs { | |
| 567 | + | viewer: viewer.clone(), | |
| 568 | + | query: optional_text(input, "query"), | |
| 569 | + | namespace: None, | |
| 570 | + | member_only: false, | |
| 571 | + | }, | |
| 572 | + | ) | |
| 573 | + | .await?; | |
| 574 | + | ok(&found) | |
| 575 | + | } | |
| 576 | + | Op::GetRepo => { | |
| 577 | + | pass( | |
| 578 | + | repos, | |
| 579 | + | "get", | |
| 580 | + | &GetArgs { | |
| 581 | + | path: repo, | |
| 582 | + | viewer: viewer.clone(), | |
| 583 | + | }, | |
| 584 | + | ) | |
| 585 | + | .await | |
| 586 | + | } | |
| 587 | + | Op::CreateRepo => { | |
| 588 | + | let owner = actor(); | |
| 589 | + | // Someone in exactly one workspace need not name it. | |
| 590 | + | let namespace = optional_text(input, "workspace").unwrap_or_else(|| { | |
| 591 | + | match owner.workspaces.as_slice() { | |
| 592 | + | [only] => only.slug.clone(), | |
| 593 | + | _ => String::new(), | |
| 594 | + | } | |
| 595 | + | }); | |
| 596 | + | pass( | |
| 597 | + | repos, | |
| 598 | + | "create", | |
| 599 | + | &CreateArgs { | |
| 600 | + | owner, | |
| 601 | + | namespace, | |
| 602 | + | name: text(input, "name"), | |
| 603 | + | description: optional_text(input, "description"), | |
| 604 | + | is_private: input["private"].as_bool() == Some(true), | |
| 605 | + | }, | |
| 606 | + | ) | |
| 607 | + | .await | |
| 608 | + | } | |
| 609 | + | Op::ListIssues => { | |
| 610 | + | pass( | |
| 611 | + | work, | |
| 612 | + | "list_issues", | |
| 613 | + | &ListIssuesArgs { | |
| 614 | + | repo, | |
| 615 | + | viewer: viewer.clone(), | |
| 616 | + | state: state(input), | |
| 617 | + | label: optional_text(input, "label"), | |
| 618 | + | }, | |
| 619 | + | ) | |
| 620 | + | .await | |
| 621 | + | } | |
| 622 | + | Op::GetIssue => pass(work, "get_issue", &view()).await, | |
| 623 | + | Op::CreateIssue => { | |
| 624 | + | pass( | |
| 625 | + | work, | |
| 626 | + | "open_issue", | |
| 627 | + | &OpenIssueArgs { | |
| 628 | + | actor: actor(), | |
| 629 | + | repo, | |
| 630 | + | title: text(input, "title"), | |
| 631 | + | body: text(input, "body"), | |
| 632 | + | labels: strings(input, "labels").unwrap_or_default(), | |
| 633 | + | checks: strings(input, "checks").unwrap_or_default(), | |
| 634 | + | }, | |
| 635 | + | ) | |
| 636 | + | .await | |
| 637 | + | } | |
| 638 | + | Op::UpdateIssue => { | |
| 639 | + | pass( | |
| 640 | + | work, | |
| 641 | + | "update_issue", | |
| 642 | + | &UpdateIssueArgs { | |
| 643 | + | actor: actor(), | |
| 644 | + | repo, | |
| 645 | + | number, | |
| 646 | + | title: input["title"].as_str().map(str::to_owned), | |
| 647 | + | body: input["body"].as_str().map(str::to_owned), | |
| 648 | + | labels: strings(input, "labels"), | |
| 649 | + | }, | |
| 650 | + | ) | |
| 651 | + | .await | |
| 652 | + | } | |
| 653 | + | Op::CloseIssue | Op::ReopenIssue => { | |
| 654 | + | let reason = match input["reason"].as_str() { | |
| 655 | + | Some("not_planned") => IssueReason::NotPlanned, | |
| 656 | + | _ => IssueReason::Completed, | |
| 657 | + | }; | |
| 658 | + | let method = if self == Op::CloseIssue { | |
| 659 | + | "close_issue" | |
| 660 | + | } else { | |
| 661 | + | "reopen_issue" | |
| 662 | + | }; | |
| 663 | + | pass( | |
| 664 | + | work, | |
| 665 | + | method, | |
| 666 | + | &IssueActionArgs { | |
| 667 | + | actor: actor(), | |
| 668 | + | repo, | |
| 669 | + | number, | |
| 670 | + | reason: Some(reason), | |
| 671 | + | }, | |
| 672 | + | ) | |
| 673 | + | .await | |
| 674 | + | } | |
| 675 | + | Op::ListLabels => pass(work, "list_labels", &view()).await, | |
| 676 | + | Op::AddComment => { | |
| 677 | + | pass( | |
| 678 | + | work, | |
| 679 | + | "add_comment", | |
| 680 | + | &AddCommentArgs { | |
| 681 | + | actor: actor(), | |
| 682 | + | repo, | |
| 683 | + | number, | |
| 684 | + | body: text(input, "body"), | |
| 685 | + | }, | |
| 686 | + | ) | |
| 687 | + | .await | |
| 688 | + | } | |
| 689 | + | Op::ListPullRequests => { | |
| 690 | + | pass( | |
| 691 | + | work, | |
| 692 | + | "list_pulls", | |
| 693 | + | &ListPullsArgs { | |
| 694 | + | repo, | |
| 695 | + | viewer: viewer.clone(), | |
| 696 | + | state: state(input), | |
| 697 | + | }, | |
| 698 | + | ) | |
| 699 | + | .await | |
| 700 | + | } | |
| 701 | + | Op::GetPullRequest => pass(work, "get_pull", &view()).await, | |
| 702 | + | Op::CreatePullRequest => { | |
| 703 | + | let user = actor(); | |
| 704 | + | let opened: Outcome<Pull> = call( | |
| 705 | + | work, | |
| 706 | + | "open_pull", | |
| 707 | + | &OpenPullArgs { | |
| 708 | + | actor: user.clone(), | |
| 709 | + | repo: repo.clone(), | |
| 710 | + | issue: integer(input, "issue"), | |
| 711 | + | title: text(input, "title"), | |
| 712 | + | body: text(input, "body"), | |
| 713 | + | branch: optional_text(input, "branch"), | |
| 714 | + | agent: optional_text(input, "agent").unwrap_or_else(|| "agent".into()), | |
| 715 | + | runtime: Runtime::External, | |
| 716 | + | }, | |
| 717 | + | ) | |
| 718 | + | .await?; | |
| 719 | + | let pull = match opened { | |
| 720 | + | Outcome::Ok(pull) => pull, | |
| 721 | + | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 722 | + | }; | |
| 723 | + | // Where to push. A pull request from a branch has no fork: | |
| 724 | + | // push to that branch of the repository. | |
| 725 | + | let source = pull.fork.as_ref().unwrap_or(&repo); | |
| 726 | + | let remote = format!("https://g1t.sh/{}/{}.git", source.namespace, source.name); | |
| 727 | + | ok(&json!({ | |
| 728 | + | "pull": pull, | |
| 729 | + | "git": { | |
| 730 | + | "remote": remote, | |
| 731 | + | "username": user.username, | |
| 732 | + | "password": "your g1t access token", | |
| 733 | + | }, | |
| 734 | + | })) | |
| 735 | + | } | |
| 736 | + | Op::RecordSession => { | |
| 737 | + | let Ok(entries) = serde_json::from_value(input["entries"].clone()) else { | |
| 738 | + | return failed( | |
| 739 | + | FailureCode::Invalid, | |
| 740 | + | "entries must be a list of objects with a kind and a text.", | |
| 741 | + | ); | |
| 742 | + | }; | |
| 743 | + | pass( | |
| 744 | + | work, | |
| 745 | + | "append_session", | |
| 746 | + | &AppendSessionArgs { | |
| 747 | + | actor: actor(), | |
| 748 | + | repo, | |
| 749 | + | number, | |
| 750 | + | entries, | |
| 751 | + | }, | |
| 752 | + | ) | |
| 753 | + | .await | |
| 754 | + | } | |
| 755 | + | Op::ReadSession => pass(work, "read_session", &view()).await, | |
| 756 | + | Op::MarkPullRequestReady => pass(work, "ready_pull", &pull_action()).await, | |
| 757 | + | Op::ClosePullRequest => pass(work, "close_pull", &pull_action()).await, | |
| 758 | + | Op::MergePullRequest => pass(work, "merge_pull", &pull_action()).await, | |
| 759 | + | Op::GetPullRequestChanges => { | |
| 760 | + | let found: Outcome<PullDetail> = call(work, "get_pull", &view()).await?; | |
| 761 | + | match found { | |
| 762 | + | Outcome::Ok(detail) => { | |
| 763 | + | pass(repos, "compare", &comparison(detail.pull, viewer)).await | |
| 764 | + | } | |
| 765 | + | Outcome::Fail(failure) => Ok(Outcome::Fail(failure)), | |
| 766 | + | } | |
| 767 | + | } | |
| 768 | + | Op::ListEvents => { | |
| 769 | + | let found: Outcome<Repo> = call( | |
| 770 | + | repos, | |
| 771 | + | "get", | |
| 772 | + | &GetArgs { | |
| 773 | + | path: repo, | |
| 774 | + | viewer: viewer.clone(), | |
| 775 | + | }, | |
| 776 | + | ) | |
| 777 | + | .await?; | |
| 778 | + | let repo = match found { | |
| 779 | + | Outcome::Ok(repo) => repo, | |
| 780 | + | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 781 | + | }; | |
| 782 | + | let timeline: Vec<Event> = g1t_kit::call( | |
| 783 | + | events, | |
| 784 | + | "list", | |
| 785 | + | &ListEventsArgs { | |
| 786 | + | repo_id: Some(repo.id), | |
| 787 | + | before: optional_text(input, "before"), | |
| 788 | + | ..ListEventsArgs::default() | |
| 789 | + | }, | |
| 790 | + | ) | |
| 791 | + | .await?; | |
| 792 | + | ok(&timeline) | |
| 793 | + | } | |
| 794 | + | } | |
| 795 | + | } | |
| 796 | + | } | |
| 797 | + | ||
| 798 | + | impl Op { | |
| 799 | + | /// The properties of the operation's input schema. | |
| 800 | + | pub fn properties(self) -> Map<String, Value> { | |
| 801 | + | match self.input() { | |
| 802 | + | Value::Object(mut schema) => match schema.remove("properties") { | |
| 803 | + | Some(Value::Object(properties)) => properties, | |
| 804 | + | _ => Map::new(), | |
| 805 | + | }, | |
| 806 | + | _ => Map::new(), | |
| 807 | + | } | |
| 808 | + | } | |
| 809 | + | ||
| 810 | + | /// The names of the properties that must be given. | |
| 811 | + | pub fn required(self) -> Vec<String> { | |
| 812 | + | self.input()["required"] | |
| 813 | + | .as_array() | |
| 814 | + | .map(|names| { | |
| 815 | + | names | |
| 816 | + | .iter() | |
| 817 | + | .filter_map(|name| name.as_str().map(str::to_owned)) | |
| 818 | + | .collect() | |
| 819 | + | }) | |
| 820 | + | .unwrap_or_default() | |
| 821 | + | } | |
| 822 | + | } | |
| 823 | + | ||
| 824 | + | #[cfg(test)] | |
| 825 | + | mod tests { | |
| 826 | + | use super::*; | |
| 827 | + | ||
| 828 | + | #[test] | |
| 829 | + | fn names_are_unique_and_found_again() { | |
| 830 | + | for op in Op::ALL { | |
| 831 | + | assert_eq!(Op::by_name(op.name()), Some(op)); | |
| 832 | + | } | |
| 833 | + | assert_eq!(Op::by_name("start_attempt"), None); | |
| 834 | + | } | |
| 835 | + | ||
| 836 | + | #[test] | |
| 837 | + | fn required_properties_exist() { | |
| 838 | + | for op in Op::ALL { | |
| 839 | + | let properties = op.properties(); | |
| 840 | + | for name in op.required() { | |
| 841 | + | assert!(properties.contains_key(&name), "{}: {name}", op.name()); | |
| 842 | + | } | |
| 843 | + | } | |
| 844 | + | } | |
| 845 | + | ||
| 846 | + | #[test] | |
| 847 | + | fn a_repository_is_owner_slash_name() { | |
| 848 | + | let path = repo_path(&json!({ "repo": "syntaqx/hello" })).unwrap(); | |
| 849 | + | assert_eq!( | |
| 850 | + | (path.namespace.as_str(), path.name.as_str()), | |
| 851 | + | ("syntaqx", "hello") | |
| 852 | + | ); | |
| 853 | + | for bad in ["syntaqx", "a/b/c", "/hello", "syntaqx/", ""] { | |
| 854 | + | assert!(repo_path(&json!({ "repo": bad })).is_none(), "{bad}"); | |
| 855 | + | } | |
| 856 | + | } | |
| 857 | + | ||
| 858 | + | #[test] | |
| 859 | + | fn numbers_are_read_from_numbers_and_digits() { | |
| 860 | + | assert_eq!(integer(&json!({ "number": 12 }), "number"), Some(12)); | |
| 861 | + | assert_eq!(integer(&json!({ "number": "12" }), "number"), Some(12)); | |
| 862 | + | assert_eq!(integer(&json!({ "number": "x" }), "number"), None); | |
| 863 | + | assert_eq!(integer(&json!({}), "number"), None); | |
| 864 | + | } | |
| 865 | + | } |
| 1 | − | import { | |
| 2 | − | type EventsApi, | |
| 3 | − | type IdentityApi, | |
| 4 | − | type NewSessionEntry, | |
| 5 | − | type RepoPath, | |
| 6 | − | type ReposApi, | |
| 7 | − | type Result, | |
| 8 | − | type User, | |
| 9 | − | type Viewer, | |
| 10 | − | type WorkApi, | |
| 11 | − | fail, | |
| 12 | − | ok, | |
| 13 | − | pullComparison, | |
| 14 | − | } from "@g1t/contracts"; | |
| 15 | − | ||
| 16 | − | export interface ApiEnv { | |
| 17 | − | IDENTITY: IdentityApi; | |
| 18 | − | REPOS: ReposApi; | |
| 19 | − | WORK: WorkApi; | |
| 20 | − | EVENTS: EventsApi; | |
| 21 | − | } | |
| 22 | − | ||
| 23 | − | type Input = Record<string, unknown>; | |
| 24 | − | ||
| 25 | − | type JsonSchema = { | |
| 26 | − | type: "object"; | |
| 27 | − | properties: Record<string, object>; | |
| 28 | − | required?: string[]; | |
| 29 | − | }; | |
| 30 | − | ||
| 31 | − | /** | |
| 32 | − | * One thing a client can do. REST routes and MCP tools are both generated | |
| 33 | − | * from this list, so the two surfaces cannot drift apart. | |
| 34 | − | */ | |
| 35 | − | export type Operation = { | |
| 36 | − | name: string; | |
| 37 | − | description: string; | |
| 38 | − | input: JsonSchema; | |
| 39 | − | run(env: ApiEnv, viewer: Viewer, input: Input): Promise<Result<unknown>>; | |
| 40 | − | }; | |
| 41 | − | ||
| 42 | − | const SIGN_IN = fail("unauthenticated", "This needs a g1t access token."); | |
| 43 | − | ||
| 44 | − | const REPO = { | |
| 45 | − | type: "string", | |
| 46 | − | description: 'Repository as "owner/name", e.g. "syntaqx/hello".', | |
| 47 | − | }; | |
| 48 | − | ||
| 49 | − | function text(input: Input, key: string): string { | |
| 50 | − | const value = input[key]; | |
| 51 | − | return typeof value === "string" ? value : ""; | |
| 52 | − | } | |
| 53 | − | ||
| 54 | − | function repoPath(input: Input): RepoPath | null { | |
| 55 | − | const [namespace, name, ...rest] = text(input, "repo").split("/"); | |
| 56 | − | return namespace && name && rest.length === 0 ? { namespace, name } : null; | |
| 57 | − | } | |
| 58 | − | ||
| 59 | − | const BAD_REPO = fail("invalid", 'Give the repository as "owner/name".'); | |
| 60 | − | ||
| 61 | − | const NUMBER = { | |
| 62 | − | type: "integer", | |
| 63 | − | description: "The number shown after the #. Issues and pull requests share one sequence.", | |
| 64 | − | }; | |
| 65 | − | ||
| 66 | − | const numbered = { repo: REPO, number: NUMBER }; | |
| 67 | − | ||
| 68 | − | function strings(input: Input, key: string): string[] | undefined { | |
| 69 | − | const value = input[key]; | |
| 70 | − | return Array.isArray(value) ? value.map(String) : undefined; | |
| 71 | − | } | |
| 72 | − | ||
| 73 | − | function state(input: Input): "open" | "closed" | undefined { | |
| 74 | − | const value = text(input, "state"); | |
| 75 | − | return value === "open" || value === "closed" ? value : undefined; | |
| 76 | − | } | |
| 77 | − | ||
| 78 | − | /** Wraps an operation on a repository that anyone who can see it may call. */ | |
| 79 | − | function onRepo( | |
| 80 | − | run: (env: ApiEnv, path: RepoPath, viewer: Viewer, input: Input) => Promise<Result<unknown>>, | |
| 81 | − | ): Operation["run"] { | |
| 82 | − | return (env, viewer, input) => { | |
| 83 | − | const path = repoPath(input); | |
| 84 | − | return path ? run(env, path, viewer, input) : Promise.resolve(BAD_REPO); | |
| 85 | − | }; | |
| 86 | − | } | |
| 87 | − | ||
| 88 | − | /** Wraps an operation on a repository that needs a signed-in user. */ | |
| 89 | − | function onRepoAs( | |
| 90 | − | run: (env: ApiEnv, path: RepoPath, user: User, input: Input) => Promise<Result<unknown>>, | |
| 91 | − | ): Operation["run"] { | |
| 92 | − | return onRepo((env, path, viewer, input) => | |
| 93 | − | viewer ? run(env, path, viewer, input) : Promise.resolve(SIGN_IN), | |
| 94 | − | ); | |
| 95 | − | } | |
| 96 | − | ||
| 97 | − | /** Wraps an operation that needs a signed-in user. */ | |
| 98 | − | function authed( | |
| 99 | − | run: (env: ApiEnv, user: User, input: Input) => Promise<Result<unknown>>, | |
| 100 | − | ): Operation["run"] { | |
| 101 | − | return (env, viewer, input) => | |
| 102 | − | viewer ? run(env, viewer, input) : Promise.resolve(SIGN_IN); | |
| 103 | − | } | |
| 104 | − | ||
| 105 | − | export const operations: Operation[] = [ | |
| 106 | − | { | |
| 107 | − | name: "whoami", | |
| 108 | − | description: "The account the access token belongs to, and its workspaces.", | |
| 109 | − | input: { type: "object", properties: {} }, | |
| 110 | − | run: authed(async (_env, user) => ok(user)), | |
| 111 | − | }, | |
| 112 | − | { | |
| 113 | − | name: "create_workspace", | |
| 114 | − | description: | |
| 115 | − | "Create a workspace. A workspace owns repositories and is the first part of their address, g1t.sh/<workspace>/<repo>. The whoami tool lists the ones you already belong to.", | |
| 116 | − | input: { | |
| 117 | − | type: "object", | |
| 118 | − | properties: { | |
| 119 | − | slug: { | |
| 120 | − | type: "string", | |
| 121 | − | description: "Its name in URLs: lowercase letters, digits and single hyphens.", | |
| 122 | − | }, | |
| 123 | − | name: { type: "string", description: "A display name." }, | |
| 124 | − | }, | |
| 125 | − | required: ["slug"], | |
| 126 | − | }, | |
| 127 | − | run: authed((env, user, input) => | |
| 128 | − | env.IDENTITY.createWorkspace(user, text(input, "slug"), text(input, "name")), | |
| 129 | − | ), | |
| 130 | − | }, | |
| 131 | − | { | |
| 132 | − | name: "list_repos", | |
| 133 | − | description: "Repositories you can see, optionally filtered by a search query.", | |
| 134 | − | input: { | |
| 135 | − | type: "object", | |
| 136 | − | properties: { query: { type: "string", description: "Matches name or description." } }, | |
| 137 | − | }, | |
| 138 | − | run: async (env, viewer, input) => | |
| 139 | − | ok(await env.REPOS.list(viewer, { query: text(input, "query") })), | |
| 140 | − | }, | |
| 141 | − | { | |
| 142 | − | name: "get_repo", | |
| 143 | − | description: "One repository's details.", | |
| 144 | − | input: { type: "object", properties: { repo: REPO }, required: ["repo"] }, | |
| 145 | − | run: async (env, viewer, input) => { | |
| 146 | − | const path = repoPath(input); | |
| 147 | − | return path ? env.REPOS.get(path, viewer) : BAD_REPO; | |
| 148 | − | }, | |
| 149 | − | }, | |
| 150 | − | { | |
| 151 | − | name: "create_repo", | |
| 152 | − | description: "Create a repository in one of your workspaces.", | |
| 153 | − | input: { | |
| 154 | − | type: "object", | |
| 155 | − | properties: { | |
| 156 | − | workspace: { | |
| 157 | − | type: "string", | |
| 158 | − | description: | |
| 159 | − | "The workspace to create it in. May be left out if you belong to exactly one.", | |
| 160 | − | }, | |
| 161 | − | name: { type: "string" }, | |
| 162 | − | description: { type: "string" }, | |
| 163 | − | private: { type: "boolean" }, | |
| 164 | − | }, | |
| 165 | − | required: ["name"], | |
| 166 | − | }, | |
| 167 | − | run: authed((env, user, input) => | |
| 168 | − | env.REPOS.create(user, { | |
| 169 | − | namespace: | |
| 170 | − | text(input, "workspace") || | |
| 171 | − | (user.workspaces?.length === 1 ? user.workspaces[0].slug : ""), | |
| 172 | − | name: text(input, "name"), | |
| 173 | − | description: text(input, "description"), | |
| 174 | − | isPrivate: input.private === true, | |
| 175 | − | }), | |
| 176 | − | ), | |
| 177 | − | }, | |
| 178 | − | { | |
| 179 | − | name: "list_issues", | |
| 180 | − | description: | |
| 181 | − | "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.", | |
| 182 | − | input: { | |
| 183 | − | type: "object", | |
| 184 | − | properties: { | |
| 185 | − | repo: REPO, | |
| 186 | − | state: { type: "string", enum: ["open", "closed"] }, | |
| 187 | − | label: { type: "string", description: "Only issues carrying this label." }, | |
| 188 | − | }, | |
| 189 | − | required: ["repo"], | |
| 190 | − | }, | |
| 191 | − | run: onRepo((env, path, viewer, input) => | |
| 192 | − | env.WORK.listIssues(path, viewer, { | |
| 193 | − | state: state(input), | |
| 194 | − | label: text(input, "label") || undefined, | |
| 195 | − | }), | |
| 196 | − | ), | |
| 197 | − | }, | |
| 198 | − | { | |
| 199 | − | name: "get_issue", | |
| 200 | − | description: | |
| 201 | − | "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.", | |
| 202 | − | input: { type: "object", properties: numbered, required: ["repo", "number"] }, | |
| 203 | − | run: onRepo((env, path, viewer, input) => | |
| 204 | − | env.WORK.getIssue(path, Number(input.number), viewer), | |
| 205 | − | ), | |
| 206 | − | }, | |
| 207 | − | { | |
| 208 | − | name: "create_issue", | |
| 209 | − | description: "Open an issue on a repository.", | |
| 210 | − | input: { | |
| 211 | − | type: "object", | |
| 212 | − | properties: { | |
| 213 | − | repo: REPO, | |
| 214 | − | title: { type: "string", description: "The problem or goal in one line." }, | |
| 215 | − | body: { | |
| 216 | − | type: "string", | |
| 217 | − | description: | |
| 218 | − | "Markdown. What an agent or a person needs to do the work: what is wrong or wanted, constraints, context.", | |
| 219 | − | }, | |
| 220 | − | labels: { | |
| 221 | − | type: "array", | |
| 222 | − | items: { type: "string" }, | |
| 223 | − | description: | |
| 224 | − | '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.', | |
| 225 | − | }, | |
| 226 | − | checks: { | |
| 227 | − | type: "array", | |
| 228 | − | items: { type: "string" }, | |
| 229 | − | description: "Commands that must pass for a pull request to be accepted.", | |
| 230 | − | }, | |
| 231 | − | }, | |
| 232 | − | required: ["repo", "title"], | |
| 233 | − | }, | |
| 234 | − | run: onRepoAs((env, path, user, input) => | |
| 235 | − | env.WORK.openIssue(user, path, { | |
| 236 | − | title: text(input, "title"), | |
| 237 | − | body: text(input, "body"), | |
| 238 | − | labels: strings(input, "labels"), | |
| 239 | − | checks: strings(input, "checks"), | |
| 240 | − | }), | |
| 241 | − | ), | |
| 242 | − | }, | |
| 243 | − | { | |
| 244 | − | name: "update_issue", | |
| 245 | − | description: | |
| 246 | − | "Change an issue's title, body or labels. Only the fields given are changed; labels replaces the whole set.", | |
| 247 | − | input: { | |
| 248 | − | type: "object", | |
| 249 | − | properties: { | |
| 250 | − | ...numbered, | |
| 251 | − | title: { type: "string" }, | |
| 252 | − | body: { type: "string" }, | |
| 253 | − | labels: { type: "array", items: { type: "string" } }, | |
| 254 | − | }, | |
| 255 | − | required: ["repo", "number"], | |
| 256 | − | }, | |
| 257 | − | run: onRepoAs((env, path, user, input) => | |
| 258 | − | env.WORK.updateIssue(user, path, Number(input.number), { | |
| 259 | − | title: typeof input.title === "string" ? input.title : undefined, | |
| 260 | − | body: typeof input.body === "string" ? input.body : undefined, | |
| 261 | − | labels: strings(input, "labels"), | |
| 262 | − | }), | |
| 263 | − | ), | |
| 264 | − | }, | |
| 265 | − | { | |
| 266 | − | name: "close_issue", | |
| 267 | − | description: | |
| 268 | − | "Close an issue without a pull request. Merging a pull request made for an issue closes it for you.", | |
| 269 | − | input: { | |
| 270 | − | type: "object", | |
| 271 | − | properties: { | |
| 272 | − | ...numbered, | |
| 273 | − | reason: { | |
| 274 | − | type: "string", | |
| 275 | − | enum: ["completed", "not_planned"], | |
| 276 | − | description: "Defaults to completed.", | |
| 277 | − | }, | |
| 278 | − | }, | |
| 279 | − | required: ["repo", "number"], | |
| 280 | − | }, | |
| 281 | − | run: onRepoAs((env, path, user, input) => | |
| 282 | − | env.WORK.closeIssue( | |
| 283 | − | user, | |
| 284 | − | path, | |
| 285 | − | Number(input.number), | |
| 286 | − | text(input, "reason") === "not_planned" ? "not_planned" : "completed", | |
| 287 | − | ), | |
| 288 | − | ), | |
| 289 | − | }, | |
| 290 | − | { | |
| 291 | − | name: "reopen_issue", | |
| 292 | − | description: "Reopen a closed issue.", | |
| 293 | − | input: { type: "object", properties: numbered, required: ["repo", "number"] }, | |
| 294 | − | run: onRepoAs((env, path, user, input) => | |
| 295 | − | env.WORK.reopenIssue(user, path, Number(input.number)), | |
| 296 | − | ), | |
| 297 | − | }, | |
| 298 | − | { | |
| 299 | − | name: "list_labels", | |
| 300 | − | description: "The labels available on a repository's issues.", | |
| 301 | − | input: { type: "object", properties: { repo: REPO }, required: ["repo"] }, | |
| 302 | − | run: onRepo((env, path, viewer) => env.WORK.listLabels(path, viewer)), | |
| 303 | − | }, | |
| 304 | − | { | |
| 305 | − | name: "add_comment", | |
| 306 | − | description: "Comment on an issue or a pull request.", | |
| 307 | − | input: { | |
| 308 | − | type: "object", | |
| 309 | − | properties: { ...numbered, body: { type: "string", description: "Markdown." } }, | |
| 310 | − | required: ["repo", "number", "body"], | |
| 311 | − | }, | |
| 312 | − | run: onRepoAs((env, path, user, input) => | |
| 313 | − | env.WORK.addComment(user, path, Number(input.number), text(input, "body")), | |
| 314 | − | ), | |
| 315 | − | }, | |
| 316 | − | { | |
| 317 | − | name: "list_pull_requests", | |
| 318 | − | description: | |
| 319 | − | "Pull requests on a repository, newest first. State open covers drafts and those ready for review; closed covers merged and closed.", | |
| 320 | − | input: { | |
| 321 | − | type: "object", | |
| 322 | − | properties: { repo: REPO, state: { type: "string", enum: ["open", "closed"] } }, | |
| 323 | − | required: ["repo"], | |
| 324 | − | }, | |
| 325 | − | run: onRepo((env, path, viewer, input) => env.WORK.listPulls(path, viewer, state(input))), | |
| 326 | − | }, | |
| 327 | − | { | |
| 328 | − | name: "get_pull_request", | |
| 329 | − | description: | |
| 330 | − | "A pull request's status, head commit, comments and the issue it is for.", | |
| 331 | − | input: { type: "object", properties: numbered, required: ["repo", "number"] }, | |
| 332 | − | run: onRepo((env, path, viewer, input) => | |
| 333 | − | env.WORK.getPull(path, Number(input.number), viewer), | |
| 334 | − | ), | |
| 335 | − | }, | |
| 336 | − | { | |
| 337 | − | name: "create_pull_request", | |
| 338 | − | description: | |
| 339 | − | "Start a change. Opens a draft pull request with its own fork of the repository and returns the fork's git remote. Clone it, commit your work there, push, record your session as you go, then call mark_pull_request_ready. Give the issue it is for whenever there is one. If the change is already on a branch pushed to the repository, give that branch instead: no fork is made and the pull request is ready for review at once.", | |
| 340 | − | input: { | |
| 341 | − | type: "object", | |
| 342 | − | properties: { | |
| 343 | − | repo: REPO, | |
| 344 | − | issue: { type: "integer", description: "The number of the issue this is for." }, | |
| 345 | − | title: { | |
| 346 | − | type: "string", | |
| 347 | − | description: "Defaults to the issue's title. Required when there is no issue.", | |
| 348 | − | }, | |
| 349 | − | branch: { | |
| 350 | − | type: "string", | |
| 351 | − | description: | |
| 352 | − | "A branch already pushed to the repository that holds the change. Leave out to get a fork.", | |
| 353 | − | }, | |
| 354 | − | body: { | |
| 355 | − | type: "string", | |
| 356 | − | description: "Markdown: what changed and why. Mainly for pull requests from a branch.", | |
| 357 | − | }, | |
| 358 | − | agent: { | |
| 359 | − | type: "string", | |
| 360 | − | description: 'A label for the agent doing the work, e.g. "claude-code".', | |
| 361 | − | }, | |
| 362 | − | }, | |
| 363 | − | required: ["repo"], | |
| 364 | − | }, | |
| 365 | − | run: onRepoAs(async (env, path, user, input) => { | |
| 366 | − | const opened = await env.WORK.openPull(user, path, { | |
| 367 | − | issue: input.issue == null ? undefined : Number(input.issue), | |
| 368 | − | title: text(input, "title"), | |
| 369 | − | body: text(input, "body"), | |
| 370 | − | branch: text(input, "branch") || undefined, | |
| 371 | − | agent: text(input, "agent") || "agent", | |
| 372 | − | runtime: "external", | |
| 373 | − | }); | |
| 374 | − | if (!opened.ok) return opened; | |
| 375 | − | const { fork } = opened.value; | |
| 376 | − | return ok({ | |
| 377 | − | pull: opened.value, | |
| 378 | − | // Where to push. A pull request from a branch has no fork: push to | |
| 379 | − | // that branch of the repository. | |
| 380 | − | git: { | |
| 381 | − | remote: fork | |
| 382 | − | ? `https://g1t.sh/${fork.namespace}/${fork.name}.git` | |
| 383 | − | : `https://g1t.sh/${path.namespace}/${path.name}.git`, | |
| 384 | − | username: user.username, | |
| 385 | − | password: "your g1t access token", | |
| 386 | − | }, | |
| 387 | − | }); | |
| 388 | − | }), | |
| 389 | − | }, | |
| 390 | − | { | |
| 391 | − | name: "record_session", | |
| 392 | − | description: | |
| 393 | − | "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.", | |
| 394 | − | input: { | |
| 395 | − | type: "object", | |
| 396 | − | properties: { | |
| 397 | − | ...numbered, | |
| 398 | − | entries: { | |
| 399 | − | type: "array", | |
| 400 | − | items: { | |
| 401 | − | type: "object", | |
| 402 | − | properties: { | |
| 403 | − | kind: { | |
| 404 | − | type: "string", | |
| 405 | − | enum: ["prompt", "message", "tool_call", "tool_result", "note"], | |
| 406 | − | }, | |
| 407 | − | text: { type: "string" }, | |
| 408 | − | tool: { type: "string", description: "Tool name, for tool entries." }, | |
| 409 | − | }, | |
| 410 | − | required: ["kind", "text"], | |
| 411 | − | }, | |
| 412 | − | }, | |
| 413 | − | }, | |
| 414 | − | required: ["repo", "number", "entries"], | |
| 415 | − | }, | |
| 416 | − | run: onRepoAs(async (env, path, user, input) => { | |
| 417 | − | if (!Array.isArray(input.entries)) { | |
| 418 | − | return fail("invalid", "entries must be an array."); | |
| 419 | − | } | |
| 420 | − | return env.WORK.appendSession( | |
| 421 | − | user, | |
| 422 | − | path, | |
| 423 | − | Number(input.number), | |
| 424 | − | input.entries as NewSessionEntry[], | |
| 425 | − | ); | |
| 426 | − | }), | |
| 427 | − | }, | |
| 428 | − | { | |
| 429 | − | name: "read_session", | |
| 430 | − | description: "The recorded session of a pull request, oldest entry first.", | |
| 431 | − | input: { | |
| 432 | − | type: "object", | |
| 433 | − | properties: { | |
| 434 | − | ...numbered, | |
| 435 | − | after: { type: "integer", description: "Only entries after this sequence number." }, | |
| 436 | − | }, | |
| 437 | − | required: ["repo", "number"], | |
| 438 | − | }, | |
| 439 | − | run: onRepo((env, path, viewer, input) => | |
| 440 | − | env.WORK.readSession(path, Number(input.number), viewer, Number(input.after) || 0), | |
| 441 | − | ), | |
| 442 | − | }, | |
| 443 | − | { | |
| 444 | − | name: "mark_pull_request_ready", | |
| 445 | − | description: | |
| 446 | − | "Mark a draft pull request ready for review. Push your commits first. The summary becomes its description and should say what changed and why.", | |
| 447 | − | input: { | |
| 448 | − | type: "object", | |
| 449 | − | properties: { ...numbered, summary: { type: "string", description: "Markdown." } }, | |
| 450 | − | required: ["repo", "number", "summary"], | |
| 451 | − | }, | |
| 452 | − | run: onRepoAs((env, path, user, input) => | |
| 453 | − | env.WORK.readyPull(user, path, Number(input.number), text(input, "summary")), | |
| 454 | − | ), | |
| 455 | − | }, | |
| 456 | − | { | |
| 457 | − | name: "close_pull_request", | |
| 458 | − | description: "Close a pull request without merging it.", | |
| 459 | − | input: { type: "object", properties: numbered, required: ["repo", "number"] }, | |
| 460 | − | run: onRepoAs((env, path, user, input) => | |
| 461 | − | env.WORK.closePull(user, path, Number(input.number)), | |
| 462 | − | ), | |
| 463 | − | }, | |
| 464 | − | { | |
| 465 | − | name: "get_pull_request_changes", | |
| 466 | − | description: | |
| 467 | − | "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.", | |
| 468 | − | input: { type: "object", properties: numbered, required: ["repo", "number"] }, | |
| 469 | − | run: onRepo(async (env, path, viewer, input) => { | |
| 470 | − | const found = await env.WORK.getPull(path, Number(input.number), viewer); | |
| 471 | − | if (!found.ok) return found; | |
| 472 | − | const { repoId, base, head } = pullComparison(found.value.pull); | |
| 473 | − | return env.REPOS.compare(repoId, viewer, base, head); | |
| 474 | − | }), | |
| 475 | − | }, | |
| 476 | − | { | |
| 477 | − | name: "merge_pull_request", | |
| 478 | − | description: | |
| 479 | − | "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.", | |
| 480 | − | input: { | |
| 481 | − | type: "object", | |
| 482 | − | properties: { | |
| 483 | − | ...numbered, | |
| 484 | − | keep_issue_open: { | |
| 485 | − | type: "boolean", | |
| 486 | − | description: | |
| 487 | − | "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.", | |
| 488 | − | }, | |
| 489 | − | }, | |
| 490 | − | required: ["repo", "number"], | |
| 491 | − | }, | |
| 492 | − | run: onRepoAs((env, path, user, input) => | |
| 493 | − | env.WORK.mergePull(user, path, Number(input.number), input.keep_issue_open === true), | |
| 494 | − | ), | |
| 495 | − | }, | |
| 496 | − | { | |
| 497 | − | name: "list_events", | |
| 498 | − | description: | |
| 499 | − | "The timeline of a repository: pushes, issues, pull requests, comments and session activity, newest first.", | |
| 500 | − | input: { | |
| 501 | − | type: "object", | |
| 502 | − | properties: { | |
| 503 | − | repo: REPO, | |
| 504 | − | before: { type: "string", description: "Event id to page back from." }, | |
| 505 | − | }, | |
| 506 | − | required: ["repo"], | |
| 507 | − | }, | |
| 508 | − | run: async (env, viewer, input) => { | |
| 509 | − | const path = repoPath(input); | |
| 510 | − | if (!path) return BAD_REPO; | |
| 511 | − | const repo = await env.REPOS.get(path, viewer); | |
| 512 | − | if (!repo.ok) return repo; | |
| 513 | − | return ok( | |
| 514 | − | await env.EVENTS.list({ | |
| 515 | − | repoId: repo.value.id, | |
| 516 | − | before: text(input, "before") || undefined, | |
| 517 | − | }), | |
| 518 | − | ); | |
| 519 | − | }, | |
| 520 | − | }, | |
| 521 | − | ]; | |
| 522 | − | ||
| 523 | − | export const operationsByName = new Map( | |
| 524 | − | operations.map((operation) => [operation.name, operation]), | |
| 525 | − | ); |
| 1 | + | //! REST: each route maps an HTTP request onto one operation. | |
| 2 | + | ||
| 3 | + | use serde_json::{Map, Value}; | |
| 4 | + | ||
| 5 | + | use crate::operations::Op; | |
| 6 | + | ||
| 7 | + | pub struct Route { | |
| 8 | + | pub method: &'static str, | |
| 9 | + | /// Segments starting with `:` are parameters. | |
| 10 | + | pub path: &'static str, | |
| 11 | + | pub op: Op, | |
| 12 | + | /// Query parameters the route reads, as `(name in the URL, input name)`. | |
| 13 | + | pub query: &'static [(&'static str, &'static str)], | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | const fn route( | |
| 17 | + | method: &'static str, | |
| 18 | + | path: &'static str, | |
| 19 | + | op: Op, | |
| 20 | + | query: &'static [(&'static str, &'static str)], | |
| 21 | + | ) -> Route { | |
| 22 | + | Route { | |
| 23 | + | method, | |
| 24 | + | path, | |
| 25 | + | op, | |
| 26 | + | query, | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | pub const ROUTES: &[Route] = &[ | |
| 31 | + | route("GET", "/v1/user", Op::Whoami, &[]), | |
| 32 | + | route("POST", "/v1/workspaces", Op::CreateWorkspace, &[]), | |
| 33 | + | route("GET", "/v1/repos", Op::ListRepos, &[("q", "query")]), | |
| 34 | + | route("POST", "/v1/repos", Op::CreateRepo, &[]), | |
| 35 | + | route("GET", "/v1/repos/:owner/:name", Op::GetRepo, &[]), | |
| 36 | + | route( | |
| 37 | + | "GET", | |
| 38 | + | "/v1/repos/:owner/:name/events", | |
| 39 | + | Op::ListEvents, | |
| 40 | + | &[("before", "before")], | |
| 41 | + | ), | |
| 42 | + | route("GET", "/v1/repos/:owner/:name/labels", Op::ListLabels, &[]), | |
| 43 | + | route( | |
| 44 | + | "GET", | |
| 45 | + | "/v1/repos/:owner/:name/issues", | |
| 46 | + | Op::ListIssues, | |
| 47 | + | &[("state", "state"), ("label", "label")], | |
| 48 | + | ), | |
| 49 | + | route( | |
| 50 | + | "POST", | |
| 51 | + | "/v1/repos/:owner/:name/issues", | |
| 52 | + | Op::CreateIssue, | |
| 53 | + | &[], | |
| 54 | + | ), | |
| 55 | + | route( | |
| 56 | + | "GET", | |
| 57 | + | "/v1/repos/:owner/:name/issues/:number", | |
| 58 | + | Op::GetIssue, | |
| 59 | + | &[], | |
| 60 | + | ), | |
| 61 | + | route( | |
| 62 | + | "PATCH", | |
| 63 | + | "/v1/repos/:owner/:name/issues/:number", | |
| 64 | + | Op::UpdateIssue, | |
| 65 | + | &[], | |
| 66 | + | ), | |
| 67 | + | route( | |
| 68 | + | "POST", | |
| 69 | + | "/v1/repos/:owner/:name/issues/:number/close", | |
| 70 | + | Op::CloseIssue, | |
| 71 | + | &[], | |
| 72 | + | ), | |
| 73 | + | route( | |
| 74 | + | "POST", | |
| 75 | + | "/v1/repos/:owner/:name/issues/:number/reopen", | |
| 76 | + | Op::ReopenIssue, | |
| 77 | + | &[], | |
| 78 | + | ), | |
| 79 | + | route( | |
| 80 | + | "POST", | |
| 81 | + | "/v1/repos/:owner/:name/issues/:number/comments", | |
| 82 | + | Op::AddComment, | |
| 83 | + | &[], | |
| 84 | + | ), | |
| 85 | + | route( | |
| 86 | + | "GET", | |
| 87 | + | "/v1/repos/:owner/:name/pulls", | |
| 88 | + | Op::ListPullRequests, | |
| 89 | + | &[("state", "state")], | |
| 90 | + | ), | |
| 91 | + | route( | |
| 92 | + | "POST", | |
| 93 | + | "/v1/repos/:owner/:name/pulls", | |
| 94 | + | Op::CreatePullRequest, | |
| 95 | + | &[], | |
| 96 | + | ), | |
| 97 | + | route( | |
| 98 | + | "GET", | |
| 99 | + | "/v1/repos/:owner/:name/pulls/:number", | |
| 100 | + | Op::GetPullRequest, | |
| 101 | + | &[], | |
| 102 | + | ), | |
| 103 | + | route( | |
| 104 | + | "GET", | |
| 105 | + | "/v1/repos/:owner/:name/pulls/:number/changes", | |
| 106 | + | Op::GetPullRequestChanges, | |
| 107 | + | &[], | |
| 108 | + | ), | |
| 109 | + | route( | |
| 110 | + | "GET", | |
| 111 | + | "/v1/repos/:owner/:name/pulls/:number/session", | |
| 112 | + | Op::ReadSession, | |
| 113 | + | &[("after", "after")], | |
| 114 | + | ), | |
| 115 | + | route( | |
| 116 | + | "POST", | |
| 117 | + | "/v1/repos/:owner/:name/pulls/:number/session", | |
| 118 | + | Op::RecordSession, | |
| 119 | + | &[], | |
| 120 | + | ), | |
| 121 | + | route( | |
| 122 | + | "POST", | |
| 123 | + | "/v1/repos/:owner/:name/pulls/:number/ready", | |
| 124 | + | Op::MarkPullRequestReady, | |
| 125 | + | &[], | |
| 126 | + | ), | |
| 127 | + | route( | |
| 128 | + | "POST", | |
| 129 | + | "/v1/repos/:owner/:name/pulls/:number/close", | |
| 130 | + | Op::ClosePullRequest, | |
| 131 | + | &[], | |
| 132 | + | ), | |
| 133 | + | route( | |
| 134 | + | "POST", | |
| 135 | + | "/v1/repos/:owner/:name/pulls/:number/merge", | |
| 136 | + | Op::MergePullRequest, | |
| 137 | + | &[], | |
| 138 | + | ), | |
| 139 | + | ]; | |
| 140 | + | ||
| 141 | + | impl Route { | |
| 142 | + | /// The names of the route's path parameters, in order. | |
| 143 | + | pub fn params(&self) -> impl Iterator<Item = &'static str> { | |
| 144 | + | self.path | |
| 145 | + | .split('/') | |
| 146 | + | .filter_map(|segment| segment.strip_prefix(':')) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | /// The values of the path parameters, if `path` is this route's. | |
| 150 | + | fn matches<'a>(&self, path: &'a str) -> Option<Vec<(&'static str, &'a str)>> { | |
| 151 | + | let mut values = Vec::new(); | |
| 152 | + | let mut actual = path.trim_end_matches('/').split('/'); | |
| 153 | + | for expected in self.path.split('/') { | |
| 154 | + | let segment = actual.next()?; | |
| 155 | + | match expected.strip_prefix(':') { | |
| 156 | + | Some(name) if !segment.is_empty() => values.push((name, segment)), | |
| 157 | + | Some(_) => return None, | |
| 158 | + | None if expected == segment => {} | |
| 159 | + | None => return None, | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | actual.next().is_none().then_some(values) | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | /// The route for a request, and the operation input it describes. | |
| 167 | + | /// | |
| 168 | + | /// The input is the JSON body, overlaid with the query parameters the route | |
| 169 | + | /// reads and then with what the path names: `owner` and `name` become | |
| 170 | + | /// `repo`, and `number` becomes an integer. | |
| 171 | + | pub fn resolve( | |
| 172 | + | method: &str, | |
| 173 | + | path: &str, | |
| 174 | + | query: &[(String, String)], | |
| 175 | + | body: Value, | |
| 176 | + | ) -> Option<(&'static Route, Value)> { | |
| 177 | + | let (route, params) = ROUTES | |
| 178 | + | .iter() | |
| 179 | + | .filter(|route| route.method == method) | |
| 180 | + | .find_map(|route| Some((route, route.matches(path)?)))?; | |
| 181 | + | ||
| 182 | + | let mut input = match body { | |
| 183 | + | Value::Object(fields) => fields, | |
| 184 | + | _ => Map::new(), | |
| 185 | + | }; | |
| 186 | + | for (name, key) in route.query { | |
| 187 | + | if let Some((_, value)) = query.iter().find(|(query_name, _)| query_name == name) { | |
| 188 | + | input.insert((*key).to_owned(), Value::String(value.clone())); | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | let param = |wanted: &str| { | |
| 192 | + | params | |
| 193 | + | .iter() | |
| 194 | + | .find(|(name, _)| *name == wanted) | |
| 195 | + | .map(|(_, value)| *value) | |
| 196 | + | }; | |
| 197 | + | if let (Some(owner), Some(name)) = (param("owner"), param("name")) { | |
| 198 | + | input.insert("repo".to_owned(), Value::String(format!("{owner}/{name}"))); | |
| 199 | + | } | |
| 200 | + | if let Some(number) = param("number") { | |
| 201 | + | // Not a number: zero, which no issue or pull request has. | |
| 202 | + | input.insert( | |
| 203 | + | "number".to_owned(), | |
| 204 | + | number.parse::<u32>().unwrap_or(0).into(), | |
| 205 | + | ); | |
| 206 | + | } | |
| 207 | + | Some((route, Value::Object(input))) | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | #[cfg(test)] | |
| 211 | + | mod tests { | |
| 212 | + | use serde_json::json; | |
| 213 | + | ||
| 214 | + | use super::*; | |
| 215 | + | ||
| 216 | + | #[test] | |
| 217 | + | fn a_path_resolves_to_its_operation_and_input() { | |
| 218 | + | let (route, input) = resolve( | |
| 219 | + | "POST", | |
| 220 | + | "/v1/repos/syntaqx/hello/pulls/14/merge", | |
| 221 | + | &[], | |
| 222 | + | json!({ "keep_issue_open": true, "number": 99, "repo": "someone/else" }), | |
| 223 | + | ) | |
| 224 | + | .unwrap(); | |
| 225 | + | assert_eq!(route.op, Op::MergePullRequest); | |
| 226 | + | // What the path names wins over the body. | |
| 227 | + | assert_eq!( | |
| 228 | + | input, | |
| 229 | + | json!({ "keep_issue_open": true, "number": 14, "repo": "syntaqx/hello" }) | |
| 230 | + | ); | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | #[test] | |
| 234 | + | fn query_parameters_are_renamed() { | |
| 235 | + | let query = [ | |
| 236 | + | ("q".to_owned(), "parser".to_owned()), | |
| 237 | + | ("x".to_owned(), "y".to_owned()), | |
| 238 | + | ]; | |
| 239 | + | let (route, input) = resolve("GET", "/v1/repos", &query, Value::Null).unwrap(); | |
| 240 | + | assert_eq!(route.op, Op::ListRepos); | |
| 241 | + | assert_eq!(input, json!({ "query": "parser" })); | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | #[test] | |
| 245 | + | fn method_and_shape_must_match() { | |
| 246 | + | assert!(resolve("GET", "/v1/repos/a/b/issues/1/close", &[], Value::Null).is_none()); | |
| 247 | + | assert!(resolve("GET", "/v1/repos/a", &[], Value::Null).is_none()); | |
| 248 | + | assert!(resolve("GET", "/v1/repos/a/b/issues/1/extra", &[], Value::Null).is_none()); | |
| 249 | + | assert!(resolve("GET", "/v1/repos/a/b/", &[], Value::Null).is_some()); | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | #[test] | |
| 253 | + | fn every_parameter_and_query_name_is_an_input() { | |
| 254 | + | for route in ROUTES { | |
| 255 | + | let properties = route.op.properties(); | |
| 256 | + | for (_, key) in route.query { | |
| 257 | + | assert!(properties.contains_key(*key), "{}: {key}", route.path); | |
| 258 | + | } | |
| 259 | + | for name in route.params() { | |
| 260 | + | let covered = matches!(name, "owner" | "name") && properties.contains_key("repo") | |
| 261 | + | || properties.contains_key(name); | |
| 262 | + | assert!(covered, "{}: {name}", route.path); | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | #[test] | |
| 268 | + | fn every_operation_has_a_route() { | |
| 269 | + | for op in Op::ALL { | |
| 270 | + | assert!(ROUTES.iter().any(|route| route.op == op), "{}", op.name()); | |
| 271 | + | } | |
| 272 | + | } | |
| 273 | + | } |
| 1 | − | { | |
| 2 | − | "extends": "../../tsconfig.base.json", | |
| 3 | − | "include": ["src/**/*", "worker-configuration.d.ts"] | |
| 4 | − | } |
| 3 | 3 | "name": "g1t-api", | |
| 4 | 4 | "account_id": "1e6f2cffa3f445920836e8ebe446bb58", | |
| 5 | 5 | "compatibility_date": "2026-09-26", | |
| 6 | − | "main": "./src/index.ts", | |
| 6 | + | "main": "build/index.js", | |
| 7 | + | "build": { "command": "cargo install -q worker-build@0.8.7 && worker-build --release" }, | |
| 7 | 8 | "workers_dev": false, | |
| 8 | 9 | // One Worker, two hostnames: REST on api, MCP on mcp. Both are thin | |
| 9 | 10 | // adapters over the same operations. |
| 110 | 110 | ## Other clients | |
| 111 | 111 | ||
| 112 | 112 | The server speaks MCP over streamable HTTP and answers each request with | |
| 113 | − | JSON. Every request needs to be signed in. | |
| 113 | + | JSON. Every call needs to be signed in. Opening | |
| 114 | + | [mcp.g1t.sh](https://mcp.g1t.sh) in a browser shows what the server is, how | |
| 115 | + | to connect, and the tools it offers. | |
| 114 | 116 | ||
| 115 | 117 | A client that supports MCP authorization needs only the URL. An | |
| 116 | 118 | unauthenticated request is answered with `401` and a pointer to |
| 11 | 11 | machine-readable description is at | |
| 12 | 12 | [api.g1t.sh/openapi.json](https://api.g1t.sh/openapi.json). | |
| 13 | 13 | ||
| 14 | + | ## Start at the root | |
| 15 | + | ||
| 16 | + | The API is public. Anything you could see on the site without signing in, | |
| 17 | + | you can read without a token. `GET https://api.g1t.sh/` returns where | |
| 18 | + | everything is, as URL templates: | |
| 19 | + | ||
| 20 | + | ```sh | |
| 21 | + | curl https://api.g1t.sh/ | |
| 22 | + | ``` | |
| 23 | + | ||
| 24 | + | ```json | |
| 25 | + | { | |
| 26 | + | "documentation_url": "https://docs.g1t.sh/api/reference/", | |
| 27 | + | "current_user_url": "https://api.g1t.sh/v1/user", | |
| 28 | + | "repository_url": "https://api.g1t.sh/v1/repos/{owner}/{name}", | |
| 29 | + | "issues_url": "https://api.g1t.sh/v1/repos/{owner}/{name}/issues{?state,label}", | |
| 30 | + | "pulls_url": "https://api.g1t.sh/v1/repos/{owner}/{name}/pulls{?state}" | |
| 31 | + | } | |
| 32 | + | ``` | |
| 33 | + | ||
| 14 | 34 | ## Authentication | |
| 15 | 35 | ||
| 16 | − | Send an [access token](https://g1t.sh/settings) as a bearer token: | |
| 36 | + | A token is needed to change anything, and to see what is private. Send an | |
| 37 | + | [access token](https://g1t.sh/settings) as a bearer token: | |
| 17 | 38 | ||
| 18 | 39 | ```sh | |
| 19 | 40 | curl https://api.g1t.sh/v1/user \ |
| 132 | 132 | ||
| 133 | 133 | ## Facts | |
| 134 | 134 | ||
| 135 | − | - API base: `https://api.g1t.sh`. Auth: `Authorization: Bearer g1t_…`. | |
| 136 | − | Public data needs no token. Errors are | |
| 135 | + | - API base: `https://api.g1t.sh`. `GET /` lists every URL as a template. | |
| 136 | + | Auth: `Authorization: Bearer g1t_…`. Public data needs no token. Errors are | |
| 137 | 137 | `{"error": {"code": "…", "message": "…"}}` with codes `unauthenticated` | |
| 138 | 138 | (401), `forbidden` (403), `not_found` (404), `conflict` (409), `invalid` | |
| 139 | 139 | (422). The full description is at https://api.g1t.sh/openapi.json. |
| 520 | 520 | | `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 | | |
| 521 | 521 | | `apps/web` | TypeScript | Worker | Server-rendered site. Holds no data; calls services over RPC. | | |
| 522 | 522 | | `apps/docs` | TypeScript | Worker (static) | Documentation and the API explorer | | |
| 523 | − | | `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 | | |
| 523 | + | | `apps/api` | Rust | Worker | REST API (`api.g1t.sh`), MCP server (`mcp.g1t.sh`) and OpenAPI document, all generated from one list of operations; the OAuth endpoints | | |
| 524 | 524 | | `crates/sshd` | Rust | Container | Git over SSH, bridged to Artifacts | | |
| 525 | 525 | | `crates/merged` | Rust | Container | Trial merges, conflict matrix, landing merges (needs real git; the Artifacts binding is read-only) | | |
| 526 | 526 | | `crates/core` | Rust | native and WASM | pkt-line, packfile and diff code shared by the above and by the Worker | | |
| 552 | 552 | ## Languages | |
| 553 | 553 | ||
| 554 | 554 | The site is TypeScript. Everything behind it is Rust, compiled to | |
| 555 | − | WebAssembly for Workers and natively for containers and the CLI. Services | |
| 556 | − | are being ported one at a time; identity, repos, work and events are done, | |
| 557 | − | and the API is next. Rust services speak a | |
| 555 | + | WebAssembly for Workers and natively for containers and the CLI. Identity, repos, work, events and the API are all Rust. The one exception | |
| 556 | + | is the Worker that starts sandboxes, because Cloudflare's Containers | |
| 557 | + | library is TypeScript. Rust services speak a | |
| 558 | 558 | small JSON protocol over service bindings (`POST /rpc/<method>`), with the | |
| 559 | 559 | types in `crates/contracts`. | |
| 560 | 560 | ||
| 648 | 648 | comments; pull requests in forks or from branches, with diffs and sessions, | |
| 649 | 649 | several per issue; merging with a behind check, which resolves the issue and supersedes | |
| 650 | 650 | the rest; g1t agents in sandboxes with a choice of model; REST API, OpenAPI | |
| 651 | − | and MCP server; event bus. Identity, repos, work and events are in Rust. | |
| 651 | + | and MCP server; event bus. Every service and the API are in Rust. | |
| 652 | 652 | ||
| 653 | 653 | 1. Branch protection, and deleting a branch once its pull request merges. | |
| 654 | 654 | 2. Scopes on OAuth grants and access tokens. | |
| 655 | − | 3. Port the API to Rust; event storage per the design above. | |
| 655 | + | 3. Event storage per the design above: per-repo hot log, Iceberg on R2, | |
| 656 | + | hash-chained audit. | |
| 656 | 657 | 4. CLI with Claude Code hooks to record sessions automatically. | |
| 657 | 658 | 5. Acceptance checks run in sandboxes; review comments on lines. | |
| 658 | 659 | 6. Server-side merge and rebase; landing queue with speculative checks; |
| 15 | 15 | "wrangler": "^4.146.0" | |
| 16 | 16 | } | |
| 17 | 17 | }, | |
| 18 | − | "apps/api": { | |
| 19 | − | "name": "@g1t/api", | |
| 20 | − | "version": "0.1.0", | |
| 21 | − | "license": "MIT", | |
| 22 | − | "dependencies": { | |
| 23 | − | "@g1t/contracts": "*", | |
| 24 | − | "hono": "^4" | |
| 25 | − | } | |
| 26 | − | }, | |
| 27 | 18 | "apps/docs": { | |
| 28 | 19 | "name": "@g1t/docs", | |
| 29 | 20 | "version": "0.1.0", | |
| 1580 | 1571 | "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", | |
| 1581 | 1572 | "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", | |
| 1582 | 1573 | "license": "MIT" | |
| 1583 | − | }, | |
| 1584 | − | "node_modules/@g1t/api": { | |
| 1585 | − | "resolved": "apps/api", | |
| 1586 | − | "link": true | |
| 1587 | 1574 | }, | |
| 1588 | 1575 | "node_modules/@g1t/contracts": { | |
| 1589 | 1576 | "resolved": "packages/contracts", | |
| 6147 | 6134 | "funding": { | |
| 6148 | 6135 | "type": "opencollective", | |
| 6149 | 6136 | "url": "https://opencollective.com/unified" | |
| 6150 | − | } | |
| 6151 | − | }, | |
| 6152 | − | "node_modules/hono": { | |
| 6153 | − | "version": "4.13.12", | |
| 6154 | − | "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.12.tgz", | |
| 6155 | − | "integrity": "sha512-6E2QDAc9Ick9Sq77ZrGS/dk2WUYni91aufTw6LJKpV7w8kW5/GxVUc650FOADOmlwg3K+f7Pun6XlV+pYmW6gw==", | |
| 6156 | − | "license": "MIT", | |
| 6157 | − | "engines": { | |
| 6158 | − | "node": ">=16.9.0" | |
| 6159 | 6137 | } | |
| 6160 | 6138 | }, | |
| 6161 | 6139 | "node_modules/html-escaper": { |
| 5 | 5 | "workspaces": ["apps/*", "services/*", "packages/*"], | |
| 6 | 6 | "scripts": { | |
| 7 | 7 | "typecheck": "npm run typecheck --workspaces --if-present", | |
| 8 | − | "deploy": "npm run deploy -w @g1t/api -w @g1t/web" | |
| 8 | + | "deploy": "npm run deploy -w @g1t/web" | |
| 9 | 9 | }, | |
| 10 | 10 | "devDependencies": { | |
| 11 | 11 | "typescript": "^5.9.3", |