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.

312 lines · 17.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 { parsePayload, type Deployment, type DeploymentReview, type DeploymentStatus, type Environment, type Reviewer } from "../repo/deployments.js";
6import type { PendingDeployment } from "../repo/actions.js";
7import type { Caller } from "../repo/types.js";
8import { baseOf, ghUser, isoSeconds, nodeId, pageOf, paginate, repoApi, repoHtml, setLinks } from "./gh.js";
9import { intParam, jsonBody, loadTarget, repoParams } from "./ghctx.js";
10import { refuseAgent, WHILE_IMPORTING } from "./common.js";
11import { resolveReviewers, reviewDeployments, viewerFor } from "./environment-reviews.js";
12
13/** Deployments, deployment statuses and environments, with environment secrets and variables (GitHub REST). */
14export const deployments = 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);
18const api = (c: AppContext) => repoApi(baseOf(c), repoParams(c).owner, repoParams(c).repo);
19const envName = (c: AppContext) => (c.req.param("env") ?? "");
20
21function deploymentJson(c: AppContext, d: Deployment): Record<string, unknown> {
22 const url = `${api(c)}/deployments/${d.id}`;
23 return {
24 url,
25 id: d.id,
26 node_id: nodeId("Deployment", `${repoParams(c).owner}/${repoParams(c).repo}/${d.id}`),
27 sha: d.sha,
28 ref: d.ref,
29 task: d.task,
30 payload: parsePayload(d.payload),
31 original_environment: d.originalEnvironment,
32 environment: d.environment,
33 description: d.description,
34 creator: ghUser(baseOf(c), d.creator),
35 created_at: isoSeconds(d.createdAt),
36 updated_at: isoSeconds(d.updatedAt),
37 statuses_url: `${url}/statuses`,
38 repository_url: api(c),
39 transient_environment: d.transient,
40 production_environment: d.production,
41 performed_via_github_app: null,
42 };
43}
44
45function statusJson(c: AppContext, s: DeploymentStatus) {
46 const dep = `${api(c)}/deployments/${s.deploymentId}`;
47 return {
48 url: `${dep}/statuses/${s.id}`,
49 id: s.id,
50 node_id: nodeId("DeploymentStatus", `${repoParams(c).owner}/${repoParams(c).repo}/${s.id}`),
51 state: s.state,
52 creator: ghUser(baseOf(c), s.creator),
53 description: s.description,
54 environment: s.environment,
55 target_url: s.logUrl,
56 log_url: s.logUrl,
57 environment_url: s.environmentUrl,
58 created_at: isoSeconds(s.createdAt),
59 updated_at: isoSeconds(s.createdAt),
60 deployment_url: dep,
61 repository_url: api(c),
62 performed_via_github_app: null,
63 };
64}
65
66const reviewerJson = (c: AppContext, r: Reviewer) => ({ type: "User", reviewer: ghUser(baseOf(c), { id: r.id, login: r.login, kind: "user" }) });
67
68/** An environment as the REST API shows it inside other objects (pending deployments, approvals). */
69function environmentRef(c: AppContext, e: { id: number; name: string }) {
70 return {
71 id: e.id,
72 node_id: nodeId("Environment", `${repoParams(c).owner}/${repoParams(c).repo}/${e.id}`),
73 name: e.name,
74 url: `${api(c)}/environments/${encodeURIComponent(e.name)}`,
75 html_url: `${repoHtml(baseOf(c), repoParams(c).owner, repoParams(c).repo)}/settings/environments/${encodeURIComponent(e.name)}`,
76 };
77}
78
79function environmentJson(c: AppContext, e: Environment) {
80 const p = e.protection;
81 const rules: Array<Record<string, unknown>> = [];
82 if (p.waitTimer > 0) rules.push({ id: e.id * 10 + 1, node_id: nodeId("WaitTimer", e.id), type: "wait_timer", wait_timer: p.waitTimer });
83 if (p.reviewers.length) rules.push({ id: e.id * 10 + 2, node_id: nodeId("RequiredReviewers", e.id), type: "required_reviewers", prevent_self_review: p.preventSelfReview, reviewers: p.reviewers.map((r) => reviewerJson(c, r)) });
84 if (e.policy) rules.push({ id: e.id * 10 + 3, node_id: nodeId("BranchPolicy", e.id), type: "branch_policy" });
85 return {
86 ...environmentRef(c, e),
87 created_at: isoSeconds(e.createdAt),
88 updated_at: isoSeconds(e.updatedAt),
89 // Nobody skips an environment's rules, admins included.
90 can_admins_bypass: false,
91 protection_rules: rules,
92 deployment_branch_policy: e.policy ? { protected_branches: e.policy.protected_branches, custom_branch_policies: e.policy.custom_branch_policies } : null,
93 };
94}
95
96function pendingJson(c: AppContext, p: PendingDeployment) {
97 return {
98 environment: environmentRef(c, p.environment),
99 wait_timer: p.waitTimer,
100 wait_timer_started_at: isoSeconds(p.waitTimerStartedAt),
101 current_user_can_approve: p.currentUserCanApprove,
102 reviewers: p.reviewers.map((r) => reviewerJson(c, r)),
103 };
104}
105
106function approvalJson(c: AppContext, r: DeploymentReview, envs: Environment[]) {
107 const e = envs.find((x) => x.name.toLowerCase() === r.environment.toLowerCase());
108 return {
109 url: `${api(c)}/actions/runs/${r.runId}/approvals`,
110 environments: [environmentRef(c, e ?? { id: 0, name: r.environment })],
111 state: r.state,
112 user: ghUser(baseOf(c), { id: r.user.id, login: r.user.login, kind: "user" }),
113 comment: r.comment,
114 };
115}
116
117/** Environment secrets and settings are sensitive: changes go to the audit log. */
118function audit(c: AppContext, caller: Caller, action: string, detail: Record<string, unknown>) {
119 c.executionCtx.waitUntil(c.get("dir").audit({ actorId: caller.id, actorLogin: caller.login, onBehalfOf: caller.onBehalfOf, action, target: `${repoParams(c).owner}/${repoParams(c).repo}`, detail }));
120}
121
122// ---------------------------------------------------------------- deployments
123
124deployments.get(`${R}/deployments`, async (c) => {
125 const { stub } = await load(c);
126 const p = pageOf(c);
127 const q = (k: string) => c.req.query(k) || undefined;
128 const res = await stub.deployments({ sha: q("sha"), ref: q("ref"), task: q("task"), environment: q("environment"), limit: p.perPage, offset: p.offset });
129 setLinks(c, p, res.total);
130 return c.json(res.items.map((d) => deploymentJson(c, d)));
131});
132deployments.post(`${R}/deployments`, async (c) => {
133 const { stub, caller } = await load(c, "write");
134 const b = await jsonBody<{ ref?: string; task?: string; auto_merge?: boolean; required_contexts?: unknown; payload?: unknown; environment?: string; description?: string | null; transient_environment?: boolean; production_environment?: boolean }>(c);
135 if (b.required_contexts !== undefined && b.required_contexts !== null && (!Array.isArray(b.required_contexts) || b.required_contexts.length > 100 || b.required_contexts.some((x) => typeof x !== "string"))) {
136 throw new HttpError(422, "required_contexts must be an array of strings", { errors: [{ resource: "Deployment", code: "invalid", field: "required_contexts" }] });
137 }
138 const required = Array.isArray(b.required_contexts) ? (b.required_contexts as string[]) : undefined;
139 const d = await stub.createDeployment({ ref: b.ref, task: b.task, payload: b.payload, environment: b.environment, description: b.description, transient_environment: b.transient_environment, production_environment: b.production_environment, required_contexts: required }, caller);
140 return c.json(deploymentJson(c, d), 201);
141});
142deployments.get(`${R}/deployments/:id`, async (c) => {
143 const { stub } = await load(c);
144 return c.json(deploymentJson(c, await stub.deployment(intParam(c, "id"))));
145});
146deployments.delete(`${R}/deployments/:id`, async (c) => {
147 const { stub, caller } = await load(c, "write");
148 await stub.deleteDeployment(intParam(c, "id"), caller);
149 return c.body(null, 204);
150});
151deployments.get(`${R}/deployments/:id/statuses`, async (c) => {
152 const { stub } = await load(c);
153 return c.json(paginate(c, (await stub.deploymentStatuses(intParam(c, "id"))).map((s) => statusJson(c, s))));
154});
155deployments.post(`${R}/deployments/:id/statuses`, async (c) => {
156 const { stub, caller } = await load(c, "write");
157 const b = await jsonBody<{ state?: string; target_url?: string; log_url?: string; description?: string; environment?: string; environment_url?: string; auto_inactive?: boolean }>(c);
158 return c.json(statusJson(c, await stub.createDeploymentStatus(intParam(c, "id"), b, caller)), 201);
159});
160deployments.get(`${R}/deployments/:id/statuses/:status`, async (c) => {
161 const { stub } = await load(c);
162 return c.json(statusJson(c, await stub.deploymentStatus(intParam(c, "id"), intParam(c, "status"))));
163});
164
165// ---------------------------------------------------------------- environments
166
167deployments.get(`${R}/environments`, async (c) => {
168 const { stub } = await load(c);
169 const all = await stub.environments();
170 return c.json({ total_count: all.length, environments: paginate(c, all).map((e) => environmentJson(c, e)) });
171});
172deployments.get(`${R}/environments/:env`, async (c) => {
173 const { stub } = await load(c);
174 return c.json(environmentJson(c, await stub.environment(envName(c))));
175});
176deployments.put(`${R}/environments/:env`, async (c) => {
177 const { stub, caller, repo } = await load(c, "admin", WHILE_IMPORTING);
178 const b = await jsonBody<{ wait_timer?: unknown; reviewers?: unknown; deployment_branch_policy?: { protected_branches?: boolean; custom_branch_policies?: boolean } | null; prevent_self_review?: unknown }>(c, true);
179 if (caller.kind === "agent") throw new HttpError(403, "only repository admins (people, not agents) can manage environments");
180 const reviewers = await resolveReviewers(c, repo, b.reviewers);
181 const e = await stub.upsertEnvironment(envName(c), { ...b, reviewers }, caller);
182 audit(c, caller, "environment.update", {
183 environment: e.name,
184 deployment_branch_policy: b.deployment_branch_policy ?? null,
185 ...(reviewers !== undefined ? { reviewers: (reviewers ?? []).map((r) => r.login) } : {}),
186 ...(b.wait_timer !== undefined ? { wait_timer: b.wait_timer } : {}),
187 ...(b.prevent_self_review !== undefined ? { prevent_self_review: b.prevent_self_review } : {}),
188 });
189 return c.json(environmentJson(c, e));
190});
191deployments.delete(`${R}/environments/:env`, async (c) => {
192 const { stub, caller } = await load(c, "admin", WHILE_IMPORTING);
193 await stub.deleteEnvironment(envName(c), caller);
194 audit(c, caller, "environment.delete", { environment: envName(c) });
195 return c.body(null, 204);
196});
197deployments.get(`${R}/environments/:env/deployment-branch-policies`, async (c) => {
198 const { stub } = await load(c);
199 const e = await stub.environment(envName(c));
200 const list = e.policy?.patterns ?? [];
201 return c.json({ total_count: list.length, branch_policies: paginate(c, list).map((p) => ({ ...p, node_id: nodeId("BranchPolicy", `${e.id}/${p.id}`) })) });
202});
203deployments.post(`${R}/environments/:env/deployment-branch-policies`, async (c) => {
204 const { stub, caller } = await load(c, "admin", WHILE_IMPORTING);
205 const b = await jsonBody<{ name?: string; type?: string }>(c);
206 const p = await stub.addEnvironmentBranchPattern(envName(c), b, caller);
207 audit(c, caller, "environment.branch_policy.create", { environment: envName(c), pattern: p.name, type: p.type });
208 return c.json({ ...p, node_id: nodeId("BranchPolicy", p.id) });
209});
210deployments.delete(`${R}/environments/:env/deployment-branch-policies/:id`, async (c) => {
211 const { stub, caller } = await load(c, "admin", WHILE_IMPORTING);
212 await stub.removeEnvironmentBranchPattern(envName(c), intParam(c, "id"), caller);
213 audit(c, caller, "environment.branch_policy.delete", { environment: envName(c), id: intParam(c, "id") });
214 return c.body(null, 204);
215});
216
217// ---------------------------------------------------------------- environment secrets (names only, never values) and variables
218
219deployments.get(`${R}/environments/:env/secrets`, async (c) => {
220 const { stub } = await load(c, "admin");
221 const list = await stub.environmentSecretNames(envName(c));
222 return c.json({ total_count: list.length, secrets: paginate(c, list).map((s) => ({ name: s.name, created_at: isoSeconds(s.updatedAt), updated_at: isoSeconds(s.updatedAt) })) });
223});
224deployments.get(`${R}/environments/:env/secrets/:name`, async (c) => {
225 const { stub } = await load(c, "admin");
226 const s = (await stub.environmentSecretNames(envName(c))).find((x) => x.name === c.req.param("name").toUpperCase());
227 if (!s) throw new HttpError(404, "Not Found");
228 return c.json({ name: s.name, created_at: isoSeconds(s.updatedAt), updated_at: isoSeconds(s.updatedAt) });
229});
230deployments.put(`${R}/environments/:env/secrets/:name`, async (c) => {
231 const { stub, caller } = await load(c, "admin", WHILE_IMPORTING);
232 if (caller.kind === "agent") throw new HttpError(403, "agents cannot manage secrets");
233 const b = await jsonBody<{ value?: string; encrypted_value?: string }>(c);
234 if (typeof b.value !== "string" && typeof b.encrypted_value === "string") throw new HttpError(422, "encrypted_value (sealed box) is not supported yet: send the secret as `value` over HTTPS; it is encrypted at rest");
235 if (typeof b.value !== "string") throw new HttpError(422, "value is required (send it over HTTPS; it is encrypted at rest)");
236 const existed = (await stub.environmentSecretNames(envName(c))).some((x) => x.name === c.req.param("name").toUpperCase());
237 await stub.setEnvironmentSecret(envName(c), c.req.param("name"), b.value, caller);
238 audit(c, caller, "environment.secret.set", { environment: envName(c), name: c.req.param("name").toUpperCase() });
239 return c.body(null, existed ? 204 : 201);
240});
241deployments.delete(`${R}/environments/:env/secrets/:name`, async (c) => {
242 const { stub, caller } = await load(c, "admin", WHILE_IMPORTING);
243 if (caller.kind === "agent") throw new HttpError(403, "agents cannot manage secrets");
244 await stub.deleteEnvironmentSecret(envName(c), c.req.param("name"), caller);
245 audit(c, caller, "environment.secret.delete", { environment: envName(c), name: c.req.param("name").toUpperCase() });
246 return c.body(null, 204);
247});
248
249const variableJson = (v: { name: string; value: string; createdAt: string; updatedAt: string }) => ({ name: v.name, value: v.value, created_at: isoSeconds(v.createdAt), updated_at: isoSeconds(v.updatedAt) });
250
251deployments.get(`${R}/environments/:env/variables`, async (c) => {
252 const { stub } = await load(c, "write");
253 const list = await stub.environmentVariables(envName(c));
254 return c.json({ total_count: list.length, variables: paginate(c, list, 10).map(variableJson) });
255});
256deployments.get(`${R}/environments/:env/variables/:name`, async (c) => {
257 const { stub } = await load(c, "write");
258 const v = (await stub.environmentVariables(envName(c))).find((x) => x.name === c.req.param("name").toUpperCase());
259 if (!v) throw new HttpError(404, "Not Found");
260 return c.json(variableJson(v));
261});
262deployments.post(`${R}/environments/:env/variables`, async (c) => {
263 const { stub, caller } = await load(c, "admin", WHILE_IMPORTING);
264 refuseAgent(caller, "variables");
265 const b = await jsonBody<{ name?: string; value?: string }>(c);
266 if (typeof b.name !== "string" || typeof b.value !== "string") throw new HttpError(422, "name and value are required");
267 if ((await stub.environmentVariables(envName(c))).some((x) => x.name === b.name!.toUpperCase())) throw new HttpError(409, "Already exists - Variable already exists");
268 await stub.setEnvironmentVariable(envName(c), b.name, b.value, caller);
269 audit(c, caller, "environment.variable.set", { environment: envName(c), name: b.name.toUpperCase() });
270 return c.json({}, 201);
271});
272deployments.patch(`${R}/environments/:env/variables/:name`, async (c) => {
273 const { stub, caller } = await load(c, "admin", WHILE_IMPORTING);
274 refuseAgent(caller, "variables");
275 const b = await jsonBody<{ name?: string; value?: string }>(c);
276 const name = c.req.param("name");
277 const all = await stub.environmentVariables(envName(c));
278 const current = all.find((x) => x.name === name.toUpperCase());
279 if (!current) throw new HttpError(404, "Not Found");
280 const next = typeof b.name === "string" && b.name ? b.name : name;
281 if (next.toUpperCase() !== name.toUpperCase() && all.some((x) => x.name === next.toUpperCase())) throw new HttpError(409, "Already exists - Variable already exists");
282 await stub.setEnvironmentVariable(envName(c), next, typeof b.value === "string" ? b.value : current.value, caller);
283 if (next.toUpperCase() !== name.toUpperCase()) await stub.setEnvironmentVariable(envName(c), name, null, caller);
284 audit(c, caller, "environment.variable.set", { environment: envName(c), name: next.toUpperCase(), ...(next.toUpperCase() !== name.toUpperCase() ? { renamed_from: name.toUpperCase() } : {}) });
285 return c.body(null, 204);
286});
287deployments.delete(`${R}/environments/:env/variables/:name`, async (c) => {
288 const { stub, caller } = await load(c, "admin", WHILE_IMPORTING);
289 refuseAgent(caller, "variables");
290 await stub.setEnvironmentVariable(envName(c), c.req.param("name"), null, caller);
291 audit(c, caller, "environment.variable.delete", { environment: envName(c), name: c.req.param("name").toUpperCase() });
292 return c.body(null, 204);
293});
294
295// ---------------------------------------------------------------- deployments waiting on reviewers (pending deployments)
296
297deployments.get(`${R}/actions/runs/:id/pending_deployments`, async (c) => {
298 const { stub, repo } = await load(c);
299 const viewer = await viewerFor(c, repo);
300 return c.json((await stub.pendingDeployments(c.req.param("id"), viewer)).map((x) => pendingJson(c, x)));
301});
302deployments.post(`${R}/actions/runs/:id/pending_deployments`, async (c) => {
303 const l = await load(c);
304 const b = await jsonBody<{ environment_ids?: unknown; state?: unknown; comment?: unknown }>(c);
305 const out = await reviewDeployments(c, l, c.req.param("id"), b);
306 return c.json(out.deployments.map((d) => deploymentJson(c, d)));
307});
308deployments.get(`${R}/actions/runs/:id/approvals`, async (c) => {
309 const { stub } = await load(c);
310 const [reviews, envs] = await Promise.all([stub.deploymentReviews(c.req.param("id")), stub.environments()]);
311 return c.json(reviews.map((r) => approvalJson(c, r, envs)));
312});