| 1 | import { Hono } from "hono"; |
| 2 | import type { Env } from "../env.js"; |
| 3 | import { requireAccount, type AppContext, type AppVars } from "../auth/principal.js"; |
| 4 | import { loadRepo } from "../http/common.js"; |
| 5 | import { HttpError } from "../lib/util.js"; |
| 6 | import { isProvider, ModelKeys, type ModelKeyInfo } from "./keys.js"; |
| 7 | import { getTask, lineJson, messageTask, myTasks, projectStub, requirePersonSession, requireTaskReader, retryTask, startTask, stopTask, taskJson, taskNumber, type StartInput } from "./tasks.js"; |
| 8 | import { askForReview, findingJson, reviewSettings, reviewSettingsJson, saveReviewSettings, type SettingsInput } from "./reviews.js"; |
| 9 | import { gatherToday, todayJson } from "./today.js"; |
| 10 | |
| 11 | async function jsonBody<T>(c: AppContext): Promise<T> { |
| 12 | try { |
| 13 | return (await c.req.json()) as T; |
| 14 | } catch { |
| 15 | throw new HttpError(400, "request body must be JSON"); |
| 16 | } |
| 17 | } |
| 18 | |
| 19 | /** Diffinery-only REST endpoints for agentic work, mounted under /api/v3 (see http/api.ts). */ |
| 20 | export const agenticApi = new Hono<{ Bindings: Env; Variables: AppVars }>(); |
| 21 | |
| 22 | /** The caller's token restriction to repositories, if any. */ |
| 23 | export const tokenRepoIds = (c: AppContext): string[] | null => { |
| 24 | const p = c.get("principal"); |
| 25 | return p.type === "account" ? p.repoIds : null; |
| 26 | }; |
| 27 | |
| 28 | agenticApi.get("/diffinery/today", async (c) => { |
| 29 | const me = requireAccount(c); |
| 30 | const today = await gatherToday(c.env, c.get("dir"), me, { repoIds: tokenRepoIds(c) }); |
| 31 | return c.json(todayJson(c.env.PUBLIC_URL.replace(/\/$/, ""), today)); |
| 32 | }); |
| 33 | |
| 34 | // ---------------------------------------------------------------- model access (agentic/keys.ts) |
| 35 | |
| 36 | const keyJson = (k: ModelKeyInfo) => ({ provider: k.provider, hint: k.hint, opted_in_at: k.optedInAt, created_at: k.createdAt, updated_at: k.updatedAt }); |
| 37 | |
| 38 | agenticApi.get("/diffinery/model-keys", async (c) => c.json((await new ModelKeys(c.env).list(requireAccount(c).id)).map(keyJson))); |
| 39 | |
| 40 | agenticApi.put("/diffinery/model-keys/:provider", async (c) => { |
| 41 | // A key is spent by tasks this person starts; only they, in a browser, can set it. |
| 42 | const me = requirePersonSession(c); |
| 43 | const provider = c.req.param("provider"); |
| 44 | if (!isProvider(provider)) throw new HttpError(404, "unknown provider: use anthropic or openai"); |
| 45 | const b = await jsonBody<{ key?: unknown; accept_terms?: unknown }>(c); |
| 46 | if (typeof b.key !== "string") throw new HttpError(422, "`key` is required"); |
| 47 | const k = await new ModelKeys(c.env).set(me, provider, b.key, b.accept_terms === true); |
| 48 | c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "model_key.set", target: provider, detail: { hint: k.hint, opted_in_at: k.optedInAt } })); |
| 49 | return c.json(keyJson(k)); |
| 50 | }); |
| 51 | |
| 52 | agenticApi.delete("/diffinery/model-keys/:provider", async (c) => { |
| 53 | const me = requirePersonSession(c); |
| 54 | const provider = c.req.param("provider"); |
| 55 | if (!isProvider(provider)) throw new HttpError(404, "unknown provider: use anthropic or openai"); |
| 56 | if (!(await new ModelKeys(c.env).delete(me.id, provider))) throw new HttpError(404, "no key for that provider"); |
| 57 | c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "model_key.delete", target: provider })); |
| 58 | return c.body(null, 204); |
| 59 | }); |
| 60 | |
| 61 | // ---------------------------------------------------------------- agent tasks (agentic/tasks.ts) |
| 62 | |
| 63 | const base = (c: AppContext) => c.env.PUBLIC_URL.replace(/\/$/, ""); |
| 64 | const full = (l: { repo: { owner_login: string; name: string } }) => `${l.repo.owner_login}/${l.repo.name}`; |
| 65 | const repoOf = (c: AppContext, min: "read" | "write" | "admin" = "read") => loadRepo(c, c.req.param("owner")!, c.req.param("repo")!, min); |
| 66 | |
| 67 | agenticApi.get("/repos/:owner/:repo/diffinery/tasks", async (c) => { |
| 68 | const l = await repoOf(c); |
| 69 | requireTaskReader(l); |
| 70 | const state = c.req.query("state"); |
| 71 | const tasks = await projectStub(c.env, l.repo.id).list({ limit: Number(c.req.query("per_page")) || 50, state: state === "running" || state === "ready" || state === "failed" || state === "stopped" || state === "active" ? state : undefined }); |
| 72 | return c.json(tasks.map((t) => taskJson(base(c), full(l), t))); |
| 73 | }); |
| 74 | |
| 75 | agenticApi.post("/repos/:owner/:repo/diffinery/tasks", async (c) => { |
| 76 | requirePersonSession(c); |
| 77 | const l = await repoOf(c, "write"); |
| 78 | const t = await startTask(c, l, await jsonBody<StartInput>(c)); |
| 79 | return c.json(taskJson(base(c), full(l), t), 201); |
| 80 | }); |
| 81 | |
| 82 | agenticApi.get("/repos/:owner/:repo/diffinery/tasks/:number", async (c) => { |
| 83 | const l = await repoOf(c); |
| 84 | return c.json(taskJson(base(c), full(l), await getTask(c, l, taskNumber(c.req.param("number"))))); |
| 85 | }); |
| 86 | |
| 87 | agenticApi.get("/repos/:owner/:repo/diffinery/tasks/:number/transcript", async (c) => { |
| 88 | const l = await repoOf(c); |
| 89 | const n = taskNumber(c.req.param("number")); |
| 90 | await getTask(c, l, n); |
| 91 | const lines = await projectStub(c.env, l.repo.id).lines(n, Number(c.req.query("after")) || 0, Number(c.req.query("per_page")) || 2000); |
| 92 | return c.json(lines.map(lineJson)); |
| 93 | }); |
| 94 | |
| 95 | agenticApi.post("/repos/:owner/:repo/diffinery/tasks/:number/messages", async (c) => { |
| 96 | requirePersonSession(c); |
| 97 | const l = await repoOf(c, "write"); |
| 98 | const b = await jsonBody<{ body?: unknown }>(c); |
| 99 | return c.json(taskJson(base(c), full(l), await messageTask(c, l, taskNumber(c.req.param("number")), b.body)), 201); |
| 100 | }); |
| 101 | |
| 102 | agenticApi.post("/repos/:owner/:repo/diffinery/tasks/:number/retry", async (c) => { |
| 103 | requirePersonSession(c); |
| 104 | const l = await repoOf(c, "write"); |
| 105 | return c.json(taskJson(base(c), full(l), await retryTask(c, l, taskNumber(c.req.param("number")))), 201); |
| 106 | }); |
| 107 | |
| 108 | agenticApi.post("/repos/:owner/:repo/diffinery/tasks/:number/stop", async (c) => { |
| 109 | const l = await repoOf(c, "write"); |
| 110 | return c.json(taskJson(base(c), full(l), await stopTask(c, l, taskNumber(c.req.param("number"))))); |
| 111 | }); |
| 112 | |
| 113 | agenticApi.get("/diffinery/tasks", async (c) => { |
| 114 | const me = requireAccount(c); |
| 115 | return c.json(await myTasks(c, me, tokenRepoIds(c))); |
| 116 | }); |
| 117 | |
| 118 | // ---------------------------------------------------------------- agent review (agentic/reviews.ts) |
| 119 | |
| 120 | agenticApi.get("/repos/:owner/:repo/diffinery/agent-review", async (c) => { |
| 121 | const l = await repoOf(c); |
| 122 | requireTaskReader(l); |
| 123 | return c.json(reviewSettingsJson(await reviewSettings(c.env, l.repo.id))); |
| 124 | }); |
| 125 | |
| 126 | agenticApi.put("/repos/:owner/:repo/diffinery/agent-review", async (c) => { |
| 127 | // Every review spends the key of the admin who turns reviews on: only they, in a browser, can. |
| 128 | requirePersonSession(c); |
| 129 | const l = await repoOf(c, "admin"); |
| 130 | const b = await jsonBody<SettingsInput & { accept_terms?: unknown }>(c); |
| 131 | const s = await saveReviewSettings(c, l, { ...b, accept: b.accept_terms }); |
| 132 | const me = requireAccount(c); |
| 133 | c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "review.settings", target: full(l), detail: { enabled: s?.enabled ?? false, agent: s?.agent.login, runtime: s?.runtime, trigger: s?.trigger, autofix: s?.autofix } })); |
| 134 | return c.json(reviewSettingsJson(s)); |
| 135 | }); |
| 136 | |
| 137 | agenticApi.get("/repos/:owner/:repo/pulls/:number/diffinery/agent-review", async (c) => { |
| 138 | const l = await repoOf(c); |
| 139 | requireTaskReader(l); |
| 140 | const n = Number(c.req.param("number")); |
| 141 | if (!Number.isInteger(n) || n <= 0) throw new HttpError(404, "not found"); |
| 142 | const stub = projectStub(c.env, l.repo.id); |
| 143 | const [t, findings] = await Promise.all([stub.reviewTask(n), stub.findings(n)]); |
| 144 | return c.json({ task: t ? taskJson(base(c), full(l), t) : null, reviewed_sha: t?.reviewed_sha ?? null, findings: findings.map((f) => findingJson(base(c), full(l), f)) }); |
| 145 | }); |
| 146 | |
| 147 | agenticApi.post("/repos/:owner/:repo/pulls/:number/diffinery/agent-review", async (c) => { |
| 148 | const l = await repoOf(c, "write"); |
| 149 | const n = Number(c.req.param("number")); |
| 150 | if (!Number.isInteger(n) || n <= 0) throw new HttpError(404, "not found"); |
| 151 | const out = await askForReview(c, l, n); |
| 152 | return c.json({ status: out.status, task: out.task ? `${base(c)}/${full(l)}/tasks/${out.task}` : null }, 202); |
| 153 | }); |