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.

158 lines · 8.5 KBRaw History
1import { Hono } from "hono";
2import type { Env } from "../env.js";
3import type { AppContext, AppVars } from "../auth/principal.js";
4import { HttpError } from "../lib/util.js";
5import { baseOf, ghRun, isoSeconds, nodeId, normalizeTopics, repoApi, repoHtml } from "./gh.js";
6import { intParam, jsonBody, loadTarget, repoParams } from "./ghctx.js";
7import { auditRepo, refuseAgent, WHILE_IMPORTING } from "./common.js";
8
9/**
10 * Smaller GitHub endpoints scripts and actions call: rate limits, topics,
11 * repository_dispatch, workflow details, run attempts and log archives, and
12 * the POST/PATCH/GET forms of repository variables.
13 */
14export const misc = new Hono<{ Bindings: Env; Variables: AppVars }>();
15
16const R = "/repos/:owner/:repo";
17const load = (c: AppContext, min: "read" | "write" | "admin" = "read", opts: { whileImporting?: boolean } = {}) => loadTarget(c, min, opts);
18
19// Diffinery doesn't meter API calls; report a generous, constant budget so clients that check it carry on.
20const LIMIT = 15_000;
21misc.get("/rate_limit", (c) => {
22 const reset = Math.floor(Date.now() / 1000) + 3600;
23 const r = (limit: number) => ({ limit, used: 0, remaining: limit, reset });
24 return c.json({ resources: { core: r(LIMIT), search: r(1000), graphql: r(LIMIT), integration_manifest: r(LIMIT), code_search: r(1000), actions_runner_registration: r(LIMIT) }, rate: r(LIMIT) });
25});
26
27// ---------------------------------------------------------------- topics
28
29misc.get(`${R}/topics`, async (c) => {
30 const { repo } = await load(c);
31 let names: string[] = [];
32 try {
33 names = JSON.parse(repo.topics ?? "[]");
34 } catch {
35 names = [];
36 }
37 return c.json({ names });
38});
39misc.put(`${R}/topics`, async (c) => {
40 const { repo, caller } = await load(c, "admin");
41 const b = await jsonBody<{ names?: unknown }>(c);
42 if (!Array.isArray(b.names)) throw new HttpError(422, "names must be an array", { errors: [{ resource: "Repository", code: "invalid", field: "names" }] });
43 const names = normalizeTopics(b.names);
44 await c.get("dir").setTopics(repo.id, names);
45 c.executionCtx.waitUntil(c.get("dir").audit({ actorId: caller.id, actorLogin: caller.login, onBehalfOf: caller.onBehalfOf, action: "repo.topics", target: `${repo.owner_login}/${repo.name}`, detail: { names } }));
46 return c.json({ names });
47});
48
49// ---------------------------------------------------------------- repository_dispatch
50
51misc.post(`${R}/dispatches`, async (c) => {
52 const { stub, caller } = await load(c, "write");
53 const b = await jsonBody<{ event_type?: string; client_payload?: unknown }>(c);
54 if (typeof b.event_type !== "string" || !b.event_type || b.event_type.length > 100) throw new HttpError(422, "event_type is required (at most 100 characters)", { errors: [{ resource: "Repository", code: "invalid", field: "event_type" }] });
55 const payload = b.client_payload ?? {};
56 if (typeof payload !== "object" || Array.isArray(payload)) throw new HttpError(422, "client_payload must be an object");
57 if (Object.keys(payload).length > 10) throw new HttpError(422, "client_payload can have at most 10 top-level properties");
58 if (JSON.stringify(payload).length > 64 * 1024) throw new HttpError(422, "client_payload is too large");
59 await stub.repositoryDispatch(b.event_type, payload as Record<string, unknown>, caller);
60 return c.body(null, 204);
61});
62
63// ---------------------------------------------------------------- workflows
64
65async function findWorkflow(c: AppContext) {
66 const { stub } = await load(c);
67 const id = (c.req.param("workflow") ?? "");
68 const wf = (await stub.workflows()).find((w) => w.path === id || w.path.endsWith(`/${id}`));
69 if (!wf) throw new HttpError(404, "Not Found");
70 return { stub, wf };
71}
72
73misc.get(`${R}/actions/workflows/:workflow`, async (c) => {
74 const { wf } = await findWorkflow(c);
75 const b = baseOf(c);
76 const { owner, repo } = repoParams(c);
77 const now = isoSeconds(new Date().toISOString());
78 return c.json({
79 id: wf.path,
80 node_id: nodeId("Workflow", `${owner}/${repo}/${wf.path}`),
81 name: wf.name,
82 path: wf.path,
83 state: wf.errors.length ? "invalid" : "active",
84 created_at: now,
85 updated_at: now,
86 url: `${repoApi(b, owner, repo)}/actions/workflows/${encodeURIComponent(wf.path)}`,
87 html_url: `${repoHtml(b, owner, repo)}/blob/HEAD/${wf.path}`,
88 badge_url: `${repoHtml(b, owner, repo)}/workflows/${encodeURIComponent(wf.name)}/badge.svg`,
89 errors: wf.errors,
90 triggers: wf.triggers,
91 });
92});
93
94misc.get(`${R}/actions/workflows/:workflow/runs`, async (c) => {
95 const { stub, wf } = await findWorkflow(c);
96 const perPage = Math.min(Math.max(Number(c.req.query("per_page")) || 30, 1), 100);
97 const page = Math.max(Number(c.req.query("page")) || 1, 1);
98 const res = await stub.runs({ workflow: wf.path, branch: c.req.query("branch"), status: c.req.query("status"), event: c.req.query("event"), sha: c.req.query("head_sha"), limit: perPage, offset: (page - 1) * perPage });
99 return c.json({ total_count: res.total, workflow_runs: res.runs.map((r) => ghRun(baseOf(c), repoParams(c).owner, repoParams(c).repo, r)) });
100});
101
102// ---------------------------------------------------------------- runs: attempts and logs
103
104misc.get(`${R}/actions/runs/:id/attempts/:attempt`, async (c) => {
105 const { stub } = await load(c);
106 const id = await stub.runAttemptId(c.req.param("id"), intParam(c, "attempt"));
107 return c.json(ghRun(baseOf(c), repoParams(c).owner, repoParams(c).repo, await stub.run(id)));
108});
109
110misc.get(`${R}/actions/runs/:id/logs`, async (c) => {
111 const { stub } = await load(c);
112 const zip = await stub.runLogsZip(c.req.param("id"));
113 return new Response(zip, { headers: { "content-type": "application/zip", "content-disposition": `attachment; filename="logs_${c.req.param("id").replace(/[^\w-]/g, "")}.zip"` } });
114});
115// GitHub's "delete workflow run logs" (repo/actions.ts deleteLogs): 204, and the deletion is audited.
116misc.delete(`${R}/actions/runs/:id/logs`, async (c) => {
117 const { stub, caller, repo } = await load(c, "write");
118 const jobs = await stub.deleteRunLogs(c.req.param("id"), caller);
119 auditRepo(c, caller, repo, "repo.actions.logs.delete", { run: c.req.param("id"), jobs: jobs.length });
120 return c.body(null, 204);
121});
122
123// ---------------------------------------------------------------- repository variables (GitHub's POST/GET/PATCH forms)
124// These see only the repository's own variables: organization variables are read and changed under
125// /orgs/:org/actions/variables, and a repository variable of the same name overrides one in workflows.
126
127misc.get(`${R}/actions/variables/:name`, async (c) => {
128 const { stub } = await load(c, "write");
129 const name = c.req.param("name").toUpperCase();
130 const vars = await stub.variables(false);
131 if (!(name in vars)) throw new HttpError(404, "Not Found");
132 return c.json({ name, value: vars[name], created_at: null, updated_at: null });
133});
134misc.post(`${R}/actions/variables`, async (c) => {
135 const { stub, caller, repo } = await load(c, "admin", WHILE_IMPORTING);
136 refuseAgent(caller, "variables");
137 const b = await jsonBody<{ name?: string; value?: string }>(c);
138 if (typeof b.name !== "string" || typeof b.value !== "string") throw new HttpError(422, "name and value are required", { errors: [{ resource: "ActionsVariable", code: "missing_field", field: typeof b.name !== "string" ? "name" : "value" }] });
139 if (b.name.toUpperCase() in (await stub.variables(false))) throw new HttpError(409, "Already exists - Variable already exists");
140 await stub.setVariable(b.name, b.value);
141 auditRepo(c, caller, repo, "repo.variable.set", { name: b.name.toUpperCase() });
142 return c.json({}, 201);
143});
144misc.patch(`${R}/actions/variables/:name`, async (c) => {
145 const { stub, caller, repo } = await load(c, "admin", WHILE_IMPORTING);
146 refuseAgent(caller, "variables");
147 const name = c.req.param("name");
148 const vars = await stub.variables(false);
149 if (!(name.toUpperCase() in vars)) throw new HttpError(404, "Not Found");
150 const b = await jsonBody<{ name?: string; value?: string }>(c);
151 const next = typeof b.name === "string" && b.name ? b.name : name;
152 if (next.toUpperCase() !== name.toUpperCase() && next.toUpperCase() in vars) throw new HttpError(409, "Already exists - Variable already exists");
153 await stub.setVariable(next, typeof b.value === "string" ? b.value : vars[name.toUpperCase()]!);
154 if (next.toUpperCase() !== name.toUpperCase()) await stub.setVariable(name.toUpperCase(), null);
155 auditRepo(c, caller, repo, "repo.variable.set", { name: next.toUpperCase(), ...(next.toUpperCase() !== name.toUpperCase() ? { renamed_from: name.toUpperCase() } : {}) });
156 return c.body(null, 204);
157});
158