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.

159 lines · 8.6 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, workflowNumber } 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 wfs = await stub.workflows();
69 const wf = wfs.find((w) => w.path === id || w.path.endsWith(`/${id}`)) ?? (/^\d+$/.test(id) ? wfs.find((w) => workflowNumber(w.path) === Number(id)) : undefined);
70 if (!wf) throw new HttpError(404, "Not Found");
71 return { stub, wf };
72}
73
74misc.get(`${R}/actions/workflows/:workflow`, async (c) => {
75 const { wf } = await findWorkflow(c);
76 const b = baseOf(c);
77 const { owner, repo } = repoParams(c);
78 const now = isoSeconds(new Date().toISOString());
79 return c.json({
80 id: workflowNumber(wf.path),
81 node_id: nodeId("Workflow", `${owner}/${repo}/${wf.path}`),
82 name: wf.name,
83 path: wf.path,
84 state: wf.errors.length ? "invalid" : "active",
85 created_at: now,
86 updated_at: now,
87 url: `${repoApi(b, owner, repo)}/actions/workflows/${workflowNumber(wf.path)}`,
88 html_url: `${repoHtml(b, owner, repo)}/blob/HEAD/${wf.path}`,
89 badge_url: `${repoHtml(b, owner, repo)}/workflows/${encodeURIComponent(wf.name)}/badge.svg`,
90 errors: wf.errors,
91 triggers: wf.triggers,
92 });
93});
94
95misc.get(`${R}/actions/workflows/:workflow/runs`, async (c) => {
96 const { stub, wf } = await findWorkflow(c);
97 const perPage = Math.min(Math.max(Number(c.req.query("per_page")) || 30, 1), 100);
98 const page = Math.max(Number(c.req.query("page")) || 1, 1);
99 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 });
100 return c.json({ total_count: res.total, workflow_runs: res.runs.map((r) => ghRun(baseOf(c), repoParams(c).owner, repoParams(c).repo, r)) });
101});
102
103// ---------------------------------------------------------------- runs: attempts and logs
104
105misc.get(`${R}/actions/runs/:id/attempts/:attempt`, async (c) => {
106 const { stub } = await load(c);
107 const id = await stub.runAttemptId(c.req.param("id"), intParam(c, "attempt"));
108 return c.json(ghRun(baseOf(c), repoParams(c).owner, repoParams(c).repo, await stub.run(id)));
109});
110
111misc.get(`${R}/actions/runs/:id/logs`, async (c) => {
112 const { stub } = await load(c);
113 const zip = await stub.runLogsZip(c.req.param("id"));
114 return new Response(zip, { headers: { "content-type": "application/zip", "content-disposition": `attachment; filename="logs_${c.req.param("id").replace(/[^\w-]/g, "")}.zip"` } });
115});
116// GitHub's "delete workflow run logs" (repo/actions.ts deleteLogs): 204, and the deletion is audited.
117misc.delete(`${R}/actions/runs/:id/logs`, async (c) => {
118 const { stub, caller, repo } = await load(c, "write");
119 const jobs = await stub.deleteRunLogs(c.req.param("id"), caller);
120 auditRepo(c, caller, repo, "repo.actions.logs.delete", { run: c.req.param("id"), jobs: jobs.length });
121 return c.body(null, 204);
122});
123
124// ---------------------------------------------------------------- repository variables (GitHub's POST/GET/PATCH forms)
125// These see only the repository's own variables: organization variables are read and changed under
126// /orgs/:org/actions/variables, and a repository variable of the same name overrides one in workflows.
127
128misc.get(`${R}/actions/variables/:name`, async (c) => {
129 const { stub } = await load(c, "write");
130 const name = c.req.param("name").toUpperCase();
131 const vars = await stub.variables(false);
132 if (!(name in vars)) throw new HttpError(404, "Not Found");
133 return c.json({ name, value: vars[name], created_at: null, updated_at: null });
134});
135misc.post(`${R}/actions/variables`, async (c) => {
136 const { stub, caller, repo } = await load(c, "admin", WHILE_IMPORTING);
137 refuseAgent(caller, "variables");
138 const b = await jsonBody<{ name?: string; value?: string }>(c);
139 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" }] });
140 if (b.name.toUpperCase() in (await stub.variables(false))) throw new HttpError(409, "Already exists - Variable already exists");
141 await stub.setVariable(b.name, b.value);
142 auditRepo(c, caller, repo, "repo.variable.set", { name: b.name.toUpperCase() });
143 return c.json({}, 201);
144});
145misc.patch(`${R}/actions/variables/:name`, async (c) => {
146 const { stub, caller, repo } = await load(c, "admin", WHILE_IMPORTING);
147 refuseAgent(caller, "variables");
148 const name = c.req.param("name");
149 const vars = await stub.variables(false);
150 if (!(name.toUpperCase() in vars)) throw new HttpError(404, "Not Found");
151 const b = await jsonBody<{ name?: string; value?: string }>(c);
152 const next = typeof b.name === "string" && b.name ? b.name : name;
153 if (next.toUpperCase() !== name.toUpperCase() && next.toUpperCase() in vars) throw new HttpError(409, "Already exists - Variable already exists");
154 await stub.setVariable(next, typeof b.value === "string" ? b.value : vars[name.toUpperCase()]!);
155 if (next.toUpperCase() !== name.toUpperCase()) await stub.setVariable(name.toUpperCase(), null);
156 auditRepo(c, caller, repo, "repo.variable.set", { name: next.toUpperCase(), ...(next.toUpperCase() !== name.toUpperCase() ? { renamed_from: name.toUpperCase() } : {}) });
157 return c.body(null, 204);
158});
159