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.

239 lines · 13.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 { parsePayload, type Deployment, type DeploymentStatus, type Environment } from "../repo/deployments.js";
6import type { Caller } from "../repo/types.js";
7import { baseOf, ghUser, isoSeconds, nodeId, pageOf, paginate, repoApi, repoHtml, setLinks } from "./gh.js";
8import { intParam, jsonBody, loadTarget, repoParams } from "./ghctx.js";
9
10/** Deployments, deployment statuses and environments, with environment secrets and variables (GitHub REST). */
11export const deployments = new Hono<{ Bindings: Env; Variables: AppVars }>();
12
13const R = "/repos/:owner/:repo";
14const load = (c: AppContext, min: "read" | "write" | "admin" = "read") => loadTarget(c, min);
15const api = (c: AppContext) => repoApi(baseOf(c), repoParams(c).owner, repoParams(c).repo);
16const envName = (c: AppContext) => (c.req.param("env") ?? "");
17
18function deploymentJson(c: AppContext, d: Deployment): Record<string, unknown> {
19 const url = `${api(c)}/deployments/${d.id}`;
20 return {
21 url,
22 id: d.id,
23 node_id: nodeId("Deployment", `${repoParams(c).owner}/${repoParams(c).repo}/${d.id}`),
24 sha: d.sha,
25 ref: d.ref,
26 task: d.task,
27 payload: parsePayload(d.payload),
28 original_environment: d.originalEnvironment,
29 environment: d.environment,
30 description: d.description,
31 creator: ghUser(baseOf(c), d.creator),
32 created_at: isoSeconds(d.createdAt),
33 updated_at: isoSeconds(d.updatedAt),
34 statuses_url: `${url}/statuses`,
35 repository_url: api(c),
36 transient_environment: d.transient,
37 production_environment: d.production,
38 performed_via_github_app: null,
39 };
40}
41
42function statusJson(c: AppContext, s: DeploymentStatus) {
43 const dep = `${api(c)}/deployments/${s.deploymentId}`;
44 return {
45 url: `${dep}/statuses/${s.id}`,
46 id: s.id,
47 node_id: nodeId("DeploymentStatus", `${repoParams(c).owner}/${repoParams(c).repo}/${s.id}`),
48 state: s.state,
49 creator: ghUser(baseOf(c), s.creator),
50 description: s.description,
51 environment: s.environment,
52 target_url: s.logUrl,
53 log_url: s.logUrl,
54 environment_url: s.environmentUrl,
55 created_at: isoSeconds(s.createdAt),
56 updated_at: isoSeconds(s.createdAt),
57 deployment_url: dep,
58 repository_url: api(c),
59 performed_via_github_app: null,
60 };
61}
62
63function environmentJson(c: AppContext, e: Environment) {
64 const name = encodeURIComponent(e.name);
65 return {
66 id: e.id,
67 node_id: nodeId("Environment", `${repoParams(c).owner}/${repoParams(c).repo}/${e.id}`),
68 name: e.name,
69 url: `${api(c)}/environments/${name}`,
70 html_url: `${repoHtml(baseOf(c), repoParams(c).owner, repoParams(c).repo)}/deployments/activity_log?environments_filter=${name}`,
71 created_at: isoSeconds(e.createdAt),
72 updated_at: isoSeconds(e.updatedAt),
73 can_admins_bypass: true,
74 protection_rules: e.policy ? [{ id: e.id, node_id: nodeId("BranchPolicy", e.id), type: "branch_policy" }] : [],
75 deployment_branch_policy: e.policy ? { protected_branches: e.policy.protected_branches, custom_branch_policies: e.policy.custom_branch_policies } : null,
76 };
77}
78
79/** Environment secrets and settings are sensitive: changes go to the audit log. */
80function audit(c: AppContext, caller: Caller, action: string, detail: Record<string, unknown>) {
81 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 }));
82}
83
84// ---------------------------------------------------------------- deployments
85
86deployments.get(`${R}/deployments`, async (c) => {
87 const { stub } = await load(c);
88 const p = pageOf(c);
89 const q = (k: string) => c.req.query(k) || undefined;
90 const res = await stub.deployments({ sha: q("sha"), ref: q("ref"), task: q("task"), environment: q("environment"), limit: p.perPage, offset: p.offset });
91 setLinks(c, p, res.total);
92 return c.json(res.items.map((d) => deploymentJson(c, d)));
93});
94deployments.post(`${R}/deployments`, async (c) => {
95 const { stub, caller } = await load(c, "write");
96 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);
97 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"))) {
98 throw new HttpError(422, "required_contexts must be an array of strings", { errors: [{ resource: "Deployment", code: "invalid", field: "required_contexts" }] });
99 }
100 const required = Array.isArray(b.required_contexts) ? (b.required_contexts as string[]) : undefined;
101 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);
102 return c.json(deploymentJson(c, d), 201);
103});
104deployments.get(`${R}/deployments/:id`, async (c) => {
105 const { stub } = await load(c);
106 return c.json(deploymentJson(c, await stub.deployment(intParam(c, "id"))));
107});
108deployments.delete(`${R}/deployments/:id`, async (c) => {
109 const { stub, caller } = await load(c, "write");
110 await stub.deleteDeployment(intParam(c, "id"), caller);
111 return c.body(null, 204);
112});
113deployments.get(`${R}/deployments/:id/statuses`, async (c) => {
114 const { stub } = await load(c);
115 return c.json(paginate(c, (await stub.deploymentStatuses(intParam(c, "id"))).map((s) => statusJson(c, s))));
116});
117deployments.post(`${R}/deployments/:id/statuses`, async (c) => {
118 const { stub, caller } = await load(c, "write");
119 const b = await jsonBody<{ state?: string; target_url?: string; log_url?: string; description?: string; environment?: string; environment_url?: string; auto_inactive?: boolean }>(c);
120 return c.json(statusJson(c, await stub.createDeploymentStatus(intParam(c, "id"), b, caller)), 201);
121});
122deployments.get(`${R}/deployments/:id/statuses/:status`, async (c) => {
123 const { stub } = await load(c);
124 return c.json(statusJson(c, await stub.deploymentStatus(intParam(c, "id"), intParam(c, "status"))));
125});
126
127// ---------------------------------------------------------------- environments
128
129deployments.get(`${R}/environments`, async (c) => {
130 const { stub } = await load(c);
131 const all = await stub.environments();
132 return c.json({ total_count: all.length, environments: paginate(c, all).map((e) => environmentJson(c, e)) });
133});
134deployments.get(`${R}/environments/:env`, async (c) => {
135 const { stub } = await load(c);
136 return c.json(environmentJson(c, await stub.environment(envName(c))));
137});
138deployments.put(`${R}/environments/:env`, async (c) => {
139 const { stub, caller } = await load(c, "admin");
140 const b = await jsonBody<{ wait_timer?: number; reviewers?: unknown[] | null; deployment_branch_policy?: { protected_branches?: boolean; custom_branch_policies?: boolean } | null; prevent_self_review?: boolean }>(c, true);
141 const e = await stub.upsertEnvironment(envName(c), b, caller);
142 audit(c, caller, "environment.update", { environment: e.name, deployment_branch_policy: b.deployment_branch_policy ?? null });
143 return c.json(environmentJson(c, e));
144});
145deployments.delete(`${R}/environments/:env`, async (c) => {
146 const { stub, caller } = await load(c, "admin");
147 await stub.deleteEnvironment(envName(c), caller);
148 audit(c, caller, "environment.delete", { environment: envName(c) });
149 return c.body(null, 204);
150});
151deployments.get(`${R}/environments/:env/deployment-branch-policies`, async (c) => {
152 const { stub } = await load(c);
153 const e = await stub.environment(envName(c));
154 const list = e.policy?.patterns ?? [];
155 return c.json({ total_count: list.length, branch_policies: paginate(c, list).map((p) => ({ ...p, node_id: nodeId("BranchPolicy", `${e.id}/${p.id}`) })) });
156});
157deployments.post(`${R}/environments/:env/deployment-branch-policies`, async (c) => {
158 const { stub, caller } = await load(c, "admin");
159 const b = await jsonBody<{ name?: string; type?: string }>(c);
160 const p = await stub.addEnvironmentBranchPattern(envName(c), b, caller);
161 audit(c, caller, "environment.branch_policy.create", { environment: envName(c), pattern: p.name, type: p.type });
162 return c.json({ ...p, node_id: nodeId("BranchPolicy", p.id) });
163});
164deployments.delete(`${R}/environments/:env/deployment-branch-policies/:id`, async (c) => {
165 const { stub, caller } = await load(c, "admin");
166 await stub.removeEnvironmentBranchPattern(envName(c), intParam(c, "id"), caller);
167 audit(c, caller, "environment.branch_policy.delete", { environment: envName(c), id: intParam(c, "id") });
168 return c.body(null, 204);
169});
170
171// ---------------------------------------------------------------- environment secrets (names only, never values) and variables
172
173deployments.get(`${R}/environments/:env/secrets`, async (c) => {
174 const { stub } = await load(c, "admin");
175 const list = await stub.environmentSecretNames(envName(c));
176 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) })) });
177});
178deployments.get(`${R}/environments/:env/secrets/:name`, async (c) => {
179 const { stub } = await load(c, "admin");
180 const s = (await stub.environmentSecretNames(envName(c))).find((x) => x.name === c.req.param("name").toUpperCase());
181 if (!s) throw new HttpError(404, "Not Found");
182 return c.json({ name: s.name, created_at: isoSeconds(s.updatedAt), updated_at: isoSeconds(s.updatedAt) });
183});
184deployments.put(`${R}/environments/:env/secrets/:name`, async (c) => {
185 const { stub, caller } = await load(c, "admin");
186 if (caller.kind === "agent") throw new HttpError(403, "agents cannot manage secrets");
187 const b = await jsonBody<{ value?: string; encrypted_value?: string }>(c);
188 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");
189 if (typeof b.value !== "string") throw new HttpError(422, "value is required (send it over HTTPS; it is encrypted at rest)");
190 const existed = (await stub.environmentSecretNames(envName(c))).some((x) => x.name === c.req.param("name").toUpperCase());
191 await stub.setEnvironmentSecret(envName(c), c.req.param("name"), b.value, caller);
192 audit(c, caller, "environment.secret.set", { environment: envName(c), name: c.req.param("name").toUpperCase() });
193 return c.body(null, existed ? 204 : 201);
194});
195deployments.delete(`${R}/environments/:env/secrets/:name`, async (c) => {
196 const { stub, caller } = await load(c, "admin");
197 if (caller.kind === "agent") throw new HttpError(403, "agents cannot manage secrets");
198 await stub.deleteEnvironmentSecret(envName(c), c.req.param("name"), caller);
199 audit(c, caller, "environment.secret.delete", { environment: envName(c), name: c.req.param("name").toUpperCase() });
200 return c.body(null, 204);
201});
202
203const 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) });
204
205deployments.get(`${R}/environments/:env/variables`, async (c) => {
206 const { stub } = await load(c, "write");
207 const list = await stub.environmentVariables(envName(c));
208 return c.json({ total_count: list.length, variables: paginate(c, list, 10).map(variableJson) });
209});
210deployments.get(`${R}/environments/:env/variables/:name`, async (c) => {
211 const { stub } = await load(c, "write");
212 const v = (await stub.environmentVariables(envName(c))).find((x) => x.name === c.req.param("name").toUpperCase());
213 if (!v) throw new HttpError(404, "Not Found");
214 return c.json(variableJson(v));
215});
216deployments.post(`${R}/environments/:env/variables`, async (c) => {
217 const { stub, caller } = await load(c, "admin");
218 const b = await jsonBody<{ name?: string; value?: string }>(c);
219 if (typeof b.name !== "string" || typeof b.value !== "string") throw new HttpError(422, "name and value are required");
220 if ((await stub.environmentVariables(envName(c))).some((x) => x.name === b.name!.toUpperCase())) throw new HttpError(409, "Already exists - Variable already exists");
221 await stub.setEnvironmentVariable(envName(c), b.name, b.value, caller);
222 return c.json({}, 201);
223});
224deployments.patch(`${R}/environments/:env/variables/:name`, async (c) => {
225 const { stub, caller } = await load(c, "admin");
226 const b = await jsonBody<{ name?: string; value?: string }>(c);
227 const name = c.req.param("name");
228 const current = (await stub.environmentVariables(envName(c))).find((x) => x.name === name.toUpperCase());
229 if (!current) throw new HttpError(404, "Not Found");
230 const next = typeof b.name === "string" && b.name ? b.name : name;
231 await stub.setEnvironmentVariable(envName(c), next, typeof b.value === "string" ? b.value : current.value, caller);
232 if (next.toUpperCase() !== name.toUpperCase()) await stub.setEnvironmentVariable(envName(c), name, null, caller);
233 return c.body(null, 204);
234});
235deployments.delete(`${R}/environments/:env/variables/:name`, async (c) => {
236 const { stub, caller } = await load(c, "admin");
237 await stub.setEnvironmentVariable(envName(c), c.req.param("name"), null, caller);
238 return c.body(null, 204);
239});