| 1 | import { Hono } from "hono"; |
| 2 | import type { Env } from "../env.js"; |
| 3 | import type { AppContext, AppVars } from "../auth/principal.js"; |
| 4 | import { HttpError } from "../lib/util.js"; |
| 5 | import { baseOf, ghRun, isoSeconds, nodeId, normalizeTopics, repoApi, repoHtml, workflowNumber } from "./gh.js"; |
| 6 | import { intParam, jsonBody, loadTarget, repoParams } from "./ghctx.js"; |
| 7 | import { 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 | */ |
| 14 | export const misc = new Hono<{ Bindings: Env; Variables: AppVars }>(); |
| 15 | |
| 16 | const R = "/repos/:owner/:repo"; |
| 17 | const 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. |
| 20 | const LIMIT = 15_000; |
| 21 | misc.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 | |
| 29 | misc.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 | }); |
| 39 | misc.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 | |
| 51 | misc.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 | |
| 65 | async 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 | |
| 74 | misc.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 | |
| 95 | misc.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 | |
| 105 | misc.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 | |
| 111 | misc.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. |
| 117 | misc.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 | |
| 128 | misc.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 | }); |
| 135 | misc.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 | }); |
| 145 | misc.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 | |