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.

154 lines · 8.4 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, repoApi, repoHtml } from "./gh.js";
6import { intParam, jsonBody, loadTarget, repoParams } from "./ghctx.js";
7import { auditRepo, refuseAgent } 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") => loadTarget(c, min);
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 = [...new Set(b.names.map((n) => String(n).trim().toLowerCase()).filter(Boolean))];
44 const bad = names.find((n) => !/^[a-z0-9][a-z0-9-]{0,49}$/.test(n));
45 if (bad) throw new HttpError(422, `${bad} is not a valid topic. Topics must start with a lowercase letter or number, consist of 50 characters or less, and can include hyphens.`, { errors: [{ resource: "Repository", code: "invalid", field: "names" }] });
46 if (names.length > 20) throw new HttpError(422, "a repository can have at most 20 topics");
47 await c.get("dir").setTopics(repo.id, names);
48 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 } }));
49 return c.json({ names });
50});
51
52// ---------------------------------------------------------------- repository_dispatch
53
54misc.post(`${R}/dispatches`, async (c) => {
55 const { stub, caller } = await load(c, "write");
56 const b = await jsonBody<{ event_type?: string; client_payload?: unknown }>(c);
57 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" }] });
58 const payload = b.client_payload ?? {};
59 if (typeof payload !== "object" || Array.isArray(payload)) throw new HttpError(422, "client_payload must be an object");
60 if (Object.keys(payload).length > 10) throw new HttpError(422, "client_payload can have at most 10 top-level properties");
61 if (JSON.stringify(payload).length > 64 * 1024) throw new HttpError(422, "client_payload is too large");
62 await stub.repositoryDispatch(b.event_type, payload as Record<string, unknown>, caller);
63 return c.body(null, 204);
64});
65
66// ---------------------------------------------------------------- workflows
67
68async function findWorkflow(c: AppContext) {
69 const { stub } = await load(c);
70 const id = (c.req.param("workflow") ?? "");
71 const wf = (await stub.workflows()).find((w) => w.path === id || w.path.endsWith(`/${id}`));
72 if (!wf) throw new HttpError(404, "Not Found");
73 return { stub, wf };
74}
75
76misc.get(`${R}/actions/workflows/:workflow`, async (c) => {
77 const { wf } = await findWorkflow(c);
78 const b = baseOf(c);
79 const { owner, repo } = repoParams(c);
80 const now = isoSeconds(new Date().toISOString());
81 return c.json({
82 id: wf.path,
83 node_id: nodeId("Workflow", `${owner}/${repo}/${wf.path}`),
84 name: wf.name,
85 path: wf.path,
86 state: wf.errors.length ? "invalid" : "active",
87 created_at: now,
88 updated_at: now,
89 url: `${repoApi(b, owner, repo)}/actions/workflows/${encodeURIComponent(wf.path)}`,
90 html_url: `${repoHtml(b, owner, repo)}/blob/HEAD/${wf.path}`,
91 badge_url: `${repoHtml(b, owner, repo)}/workflows/${encodeURIComponent(wf.name)}/badge.svg`,
92 errors: wf.errors,
93 triggers: wf.triggers,
94 });
95});
96
97misc.get(`${R}/actions/workflows/:workflow/runs`, async (c) => {
98 const { stub, wf } = await findWorkflow(c);
99 const perPage = Math.min(Math.max(Number(c.req.query("per_page")) || 30, 1), 100);
100 const page = Math.max(Number(c.req.query("page")) || 1, 1);
101 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 });
102 return c.json({ total_count: res.total, workflow_runs: res.runs.map((r) => ghRun(baseOf(c), repoParams(c).owner, repoParams(c).repo, r)) });
103});
104
105// ---------------------------------------------------------------- runs: attempts and logs
106
107misc.get(`${R}/actions/runs/:id/attempts/:attempt`, async (c) => {
108 const { stub } = await load(c);
109 const id = await stub.runAttemptId(c.req.param("id"), intParam(c, "attempt"));
110 return c.json(ghRun(baseOf(c), repoParams(c).owner, repoParams(c).repo, await stub.run(id)));
111});
112
113misc.get(`${R}/actions/runs/:id/logs`, async (c) => {
114 const { stub } = await load(c);
115 const zip = await stub.runLogsZip(c.req.param("id"));
116 return new Response(zip, { headers: { "content-type": "application/zip", "content-disposition": `attachment; filename="logs_${c.req.param("id").replace(/[^\w-]/g, "")}.zip"` } });
117});
118
119// ---------------------------------------------------------------- repository variables (GitHub's POST/GET/PATCH forms)
120// These see only the repository's own variables: organization variables are read and changed under
121// /orgs/:org/actions/variables, and a repository variable of the same name overrides one in workflows.
122
123misc.get(`${R}/actions/variables/:name`, async (c) => {
124 const { stub } = await load(c, "write");
125 const name = c.req.param("name").toUpperCase();
126 const vars = await stub.variables(false);
127 if (!(name in vars)) throw new HttpError(404, "Not Found");
128 return c.json({ name, value: vars[name], created_at: null, updated_at: null });
129});
130misc.post(`${R}/actions/variables`, async (c) => {
131 const { stub, caller, repo } = await load(c, "admin");
132 refuseAgent(caller, "variables");
133 const b = await jsonBody<{ name?: string; value?: string }>(c);
134 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" }] });
135 if (b.name.toUpperCase() in (await stub.variables(false))) throw new HttpError(409, "Already exists - Variable already exists");
136 await stub.setVariable(b.name, b.value);
137 auditRepo(c, caller, repo, "repo.variable.set", { name: b.name.toUpperCase() });
138 return c.json({}, 201);
139});
140misc.patch(`${R}/actions/variables/:name`, async (c) => {
141 const { stub, caller, repo } = await load(c, "admin");
142 refuseAgent(caller, "variables");
143 const name = c.req.param("name");
144 const vars = await stub.variables(false);
145 if (!(name.toUpperCase() in vars)) throw new HttpError(404, "Not Found");
146 const b = await jsonBody<{ name?: string; value?: string }>(c);
147 const next = typeof b.name === "string" && b.name ? b.name : name;
148 if (next.toUpperCase() !== name.toUpperCase() && next.toUpperCase() in vars) throw new HttpError(409, "Already exists - Variable already exists");
149 await stub.setVariable(next, typeof b.value === "string" ? b.value : vars[name.toUpperCase()]!);
150 if (next.toUpperCase() !== name.toUpperCase()) await stub.setVariable(name.toUpperCase(), null);
151 auditRepo(c, caller, repo, "repo.variable.set", { name: next.toUpperCase(), ...(next.toUpperCase() !== name.toUpperCase() ? { renamed_from: name.toUpperCase() } : {}) });
152 return c.body(null, 204);
153});
154