Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

153 lines · 7.8 KBRaw History
1import { Hono } from "hono";
2import type { Env } from "../env.js";
3import { requireAccount, type AppContext, type AppVars } from "../auth/principal.js";
4import { loadRepo } from "../http/common.js";
5import { HttpError } from "../lib/util.js";
6import { isProvider, ModelKeys, type ModelKeyInfo } from "./keys.js";
7import { getTask, lineJson, messageTask, myTasks, projectStub, requirePersonSession, requireTaskReader, retryTask, startTask, stopTask, taskJson, taskNumber, type StartInput } from "./tasks.js";
8import { askForReview, findingJson, reviewSettings, reviewSettingsJson, saveReviewSettings, type SettingsInput } from "./reviews.js";
9import { gatherToday, todayJson } from "./today.js";
10
11async 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). */
20export const agenticApi = new Hono<{ Bindings: Env; Variables: AppVars }>();
21
22/** The caller's token restriction to repositories, if any. */
23export const tokenRepoIds = (c: AppContext): string[] | null => {
24 const p = c.get("principal");
25 return p.type === "account" ? p.repoIds : null;
26};
27
28agenticApi.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
36const keyJson = (k: ModelKeyInfo) => ({ provider: k.provider, hint: k.hint, opted_in_at: k.optedInAt, created_at: k.createdAt, updated_at: k.updatedAt });
37
38agenticApi.get("/diffinery/model-keys", async (c) => c.json((await new ModelKeys(c.env).list(requireAccount(c).id)).map(keyJson)));
39
40agenticApi.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
52agenticApi.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
63const base = (c: AppContext) => c.env.PUBLIC_URL.replace(/\/$/, "");
64const full = (l: { repo: { owner_login: string; name: string } }) => `${l.repo.owner_login}/${l.repo.name}`;
65const repoOf = (c: AppContext, min: "read" | "write" | "admin" = "read") => loadRepo(c, c.req.param("owner")!, c.req.param("repo")!, min);
66
67agenticApi.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
75agenticApi.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
82agenticApi.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
87agenticApi.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
95agenticApi.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
102agenticApi.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
108agenticApi.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
113agenticApi.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
120agenticApi.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
126agenticApi.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
137agenticApi.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
147agenticApi.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});