Wire the main Worker: REST API, MCP server, webhooks, cron, self-hosted runners
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL
26 files changed+5201−90
Files (26)
- apps/diffinery/src/git/protocol.ts+2−2
- apps/diffinery/src/http/api.ts+680−0
- apps/diffinery/src/http/git.ts+92−0
- apps/diffinery/src/http/runner.ts+134−0
- apps/diffinery/src/index.ts+209−0
- apps/diffinery/src/lib/directory.ts+4−4
- apps/diffinery/src/mcp/server.ts+457−0
- apps/diffinery/src/repo/RepoDO.ts+14−5
- apps/diffinery/src/repo/actions.ts+62−17
- apps/diffinery/src/repo/collab.ts+21−21
- apps/diffinery/src/repo/schema.ts+1−1
- apps/diffinery/src/runner/containers.ts+4−11
- apps/diffinery/src/runner/workflow.ts+2−2
- apps/diffinery/src/web/account.tsx+1620−0
- apps/diffinery/src/web/assets.ts+356−0
- apps/diffinery/src/web/csrf.ts+74−0
- apps/diffinery/src/web/markdown.ts+543−0
- apps/diffinery/src/web/ui.tsx+732−0
- apps/diffinery/tsconfig.json+11−0
- containers/runner/.dockerignore+2−0
- containers/runner/Dockerfile+50−0
- containers/runner/src/mask.ts+8−2
- containers/runner/test/job.test.ts+98−10
- containers/runner/test/unit.test.ts+2−0
- packages/shared/src/events.ts+3−2
- packages/shared/src/runner.ts+20−13
modifiedapps/diffinery/src/git/protocol.ts+2−2View file
| @@ -3,10 +3,10 @@ | ||
| 3 | 3 | |
| 4 | 4 | export const AGENT = "diffinery/0.1"; |
| 5 | 5 | |
| 6 | -export interface RefEntry { | |
| 6 | +export type RefEntry = { | |
| 7 | 7 | name: string; |
| 8 | 8 | oid: string; |
| 9 | -} | |
| 9 | +}; | |
| 10 | 10 | |
| 11 | 11 | export interface UploadContext { |
| 12 | 12 | store: ObjectReader & { readRaw(oid: string): Promise<RawObject | null>; has(oid: string): boolean }; |
addedapps/diffinery/src/http/api.ts+680−0View file
| @@ -1,0 +1,680 @@ | ||
| 1 | +import { Hono } from "hono"; | |
| 2 | +import { fromUtf8 } from "@diffinery/git-core"; | |
| 3 | +import type { Env } from "../env.js"; | |
| 4 | +import { requireAccount, type AppContext, type AppVars } from "../auth/principal.js"; | |
| 5 | +import type { Account, Repo, TokenScope } from "../lib/directory.js"; | |
| 6 | +import { encryptSecret } from "../lib/crypto.js"; | |
| 7 | +import { publicJwk } from "../lib/jwt.js"; | |
| 8 | +import { HttpError, b64, fromB64 } from "../lib/util.js"; | |
| 9 | +import type { ProtectionRules, Role } from "../repo/types.js"; | |
| 10 | +import type { RunInfo } from "../repo/actions.js"; | |
| 11 | +import { loadRepo, repoStub } from "./common.js"; | |
| 12 | + | |
| 13 | +/** | |
| 14 | + * REST API. Paths and payloads follow GitHub's REST API v3 where practical so | |
| 15 | + * existing scripts and tools work; Diffinery-only endpoints live under /api/v3/diffinery. | |
| 16 | + */ | |
| 17 | +export const api = new Hono<{ Bindings: Env; Variables: AppVars }>().basePath("/api/v3"); | |
| 18 | + | |
| 19 | +const userJson = (c: AppContext, a: Pick<Account, "id" | "login" | "kind" | "name">) => ({ | |
| 20 | + id: a.id, | |
| 21 | + login: a.login, | |
| 22 | + name: a.name, | |
| 23 | + type: a.kind === "org" ? "Organization" : a.kind === "agent" ? "Bot" : "User", | |
| 24 | + html_url: `${c.env.PUBLIC_URL}/${a.login}`, | |
| 25 | +}); | |
| 26 | + | |
| 27 | +const repoJson = (c: AppContext, r: Repo) => ({ | |
| 28 | + id: r.id, | |
| 29 | + name: r.name, | |
| 30 | + full_name: `${r.owner_login}/${r.name}`, | |
| 31 | + owner: { login: r.owner_login, type: r.owner_kind === "org" ? "Organization" : "User" }, | |
| 32 | + private: r.visibility !== "public", | |
| 33 | + visibility: r.visibility, | |
| 34 | + description: r.description, | |
| 35 | + default_branch: r.default_branch, | |
| 36 | + archived: !!r.archived, | |
| 37 | + html_url: `${c.env.PUBLIC_URL}/${r.owner_login}/${r.name}`, | |
| 38 | + clone_url: `${c.env.PUBLIC_URL}/${r.owner_login}/${r.name}.git`, | |
| 39 | + created_at: r.created_at, | |
| 40 | + updated_at: r.updated_at, | |
| 41 | + pushed_at: r.pushed_at, | |
| 42 | +}); | |
| 43 | + | |
| 44 | +async function body<T>(c: AppContext): Promise<T> { | |
| 45 | + try { | |
| 46 | + return (await c.req.json()) as T; | |
| 47 | + } catch { | |
| 48 | + throw new HttpError(400, "request body must be JSON"); | |
| 49 | + } | |
| 50 | +} | |
| 51 | + | |
| 52 | +const P = (c: AppContext) => ({ owner: c.req.param("owner")!, repo: c.req.param("repo")! }); | |
| 53 | +const num = (c: AppContext, name = "number") => { | |
| 54 | + const n = Number(c.req.param(name)); | |
| 55 | + if (!Number.isInteger(n) || n < 1) throw new HttpError(404, "not found"); | |
| 56 | + return n; | |
| 57 | +}; | |
| 58 | + | |
| 59 | +// ---------------------------------------------------------------- meta & users | |
| 60 | + | |
| 61 | +api.get("/", (c) => c.json({ name: "Diffinery", api: "v3", docs: `${c.env.PUBLIC_URL}/docs/api`, mcp: `${c.env.PUBLIC_URL}/mcp` })); | |
| 62 | +api.get("/meta", (c) => c.json({ name: "Diffinery", oidc: !!c.env.OIDC_PRIVATE_KEY })); | |
| 63 | +api.get("/user", (c) => { | |
| 64 | + const a = requireAccount(c); | |
| 65 | + return c.json({ ...userJson(c, a), email: a.email, site_admin: !!a.site_admin }); | |
| 66 | +}); | |
| 67 | +api.get("/users/:login", async (c) => { | |
| 68 | + requireAccount(c); | |
| 69 | + const a = await c.get("dir").accountByLogin(c.req.param("login")); | |
| 70 | + if (!a) throw new HttpError(404, "not found"); | |
| 71 | + return c.json(userJson(c, a)); | |
| 72 | +}); | |
| 73 | +api.get("/user/orgs", async (c) => c.json((await c.get("dir").orgsFor(requireAccount(c).id)).map((o) => ({ ...userJson(c, o), role: o.role })))); | |
| 74 | +api.post("/user/orgs", async (c) => { | |
| 75 | + const a = requireAccount(c); | |
| 76 | + const b = await body<{ login: string; name?: string }>(c); | |
| 77 | + return c.json(userJson(c, await c.get("dir").createOrg(b.login, b.name ?? b.login, a)), 201); | |
| 78 | +}); | |
| 79 | +api.get("/orgs/:org/members", async (c) => { | |
| 80 | + const a = requireAccount(c); | |
| 81 | + const dir = c.get("dir"); | |
| 82 | + const org = await dir.accountByLogin(c.req.param("org")); | |
| 83 | + if (!org || org.kind !== "org") throw new HttpError(404, "not found"); | |
| 84 | + if (!(await dir.orgRole(org.id, a.id)) && !a.site_admin) throw new HttpError(404, "not found"); | |
| 85 | + return c.json((await dir.orgMembers(org.id)).map((m) => ({ ...userJson(c, m), role: m.role }))); | |
| 86 | +}); | |
| 87 | +api.post("/orgs/:org/invitations", async (c) => { | |
| 88 | + const a = requireAccount(c); | |
| 89 | + const dir = c.get("dir"); | |
| 90 | + const org = await dir.accountByLogin(c.req.param("org")); | |
| 91 | + if (!org || org.kind !== "org") throw new HttpError(404, "not found"); | |
| 92 | + const role = await dir.orgRole(org.id, a.id); | |
| 93 | + if (role !== "owner" && role !== "admin" && !a.site_admin) throw new HttpError(403, "only organization owners and admins can invite"); | |
| 94 | + const b = await body<{ email: string; role?: "member" | "admin" | "owner" }>(c); | |
| 95 | + if (!/^[^@\s]+@[^@\s]+$/.test(b.email ?? "")) throw new HttpError(422, "a valid email is required"); | |
| 96 | + const inv = await dir.createInvite(a, { email: b.email, orgId: org.id, role: b.role ?? "member" }); | |
| 97 | + c.executionCtx.waitUntil(dir.audit({ actorId: a.id, actorLogin: a.login, action: "org.invite", target: org.login, detail: { email: b.email, role: b.role ?? "member" } })); | |
| 98 | + return c.json({ id: inv.id, url: inv.url }, 201); | |
| 99 | +}); | |
| 100 | + | |
| 101 | +// ---------------------------------------------------------------- repositories | |
| 102 | + | |
| 103 | +api.get("/user/repos", async (c) => c.json((await c.get("dir").reposVisibleTo(requireAccount(c))).map((r) => repoJson(c, r)))); | |
| 104 | +api.post("/user/repos", async (c) => { | |
| 105 | + const a = requireAccount(c); | |
| 106 | + const b = await body<{ name: string; description?: string; private?: boolean; visibility?: Repo["visibility"]; default_branch?: string }>(c); | |
| 107 | + const owner = a.kind === "agent" ? (await c.get("dir").account(a.owner_id!))! : a; | |
| 108 | + const r = await c.get("dir").createRepo(owner, a, { name: b.name, description: b.description, visibility: b.visibility ?? (b.private === false ? "internal" : "private"), defaultBranch: b.default_branch }); | |
| 109 | + return c.json(repoJson(c, r), 201); | |
| 110 | +}); | |
| 111 | +api.get("/orgs/:org/repos", async (c) => { | |
| 112 | + const a = requireAccount(c); | |
| 113 | + const org = await c.get("dir").accountByLogin(c.req.param("org")); | |
| 114 | + if (!org) throw new HttpError(404, "not found"); | |
| 115 | + return c.json((await c.get("dir").reposVisibleTo(a, org.id)).map((r) => repoJson(c, r))); | |
| 116 | +}); | |
| 117 | +api.post("/orgs/:org/repos", async (c) => { | |
| 118 | + const a = requireAccount(c); | |
| 119 | + const org = await c.get("dir").accountByLogin(c.req.param("org")); | |
| 120 | + if (!org || org.kind !== "org") throw new HttpError(404, "organization not found"); | |
| 121 | + const b = await body<{ name: string; description?: string; private?: boolean; visibility?: Repo["visibility"]; default_branch?: string }>(c); | |
| 122 | + const r = await c.get("dir").createRepo(org, a, { name: b.name, description: b.description, visibility: b.visibility ?? (b.private === false ? "internal" : "private"), defaultBranch: b.default_branch }); | |
| 123 | + return c.json(repoJson(c, r), 201); | |
| 124 | +}); | |
| 125 | +api.get("/repos/:owner/:repo", async (c) => { | |
| 126 | + const { repo, caller, stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 127 | + return c.json({ ...repoJson(c, repo), permissions: { role: caller.role }, stats: await stub.stats() }); | |
| 128 | +}); | |
| 129 | +api.patch("/repos/:owner/:repo", async (c) => { | |
| 130 | + const { repo } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); | |
| 131 | + const b = await body<{ name?: string; description?: string; visibility?: Repo["visibility"]; default_branch?: string; archived?: boolean }>(c); | |
| 132 | + return c.json(repoJson(c, await c.get("dir").updateRepo(repo, { name: b.name, description: b.description, visibility: b.visibility, defaultBranch: b.default_branch, archived: b.archived }))); | |
| 133 | +}); | |
| 134 | +api.delete("/repos/:owner/:repo", async (c) => { | |
| 135 | + const { repo, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); | |
| 136 | + if (caller.kind === "agent") throw new HttpError(403, "agents cannot delete repositories"); | |
| 137 | + await c.get("dir").deleteRepo(repo); | |
| 138 | + return c.body(null, 204); | |
| 139 | +}); | |
| 140 | +api.get("/repos/:owner/:repo/collaborators", async (c) => { | |
| 141 | + const { repo } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 142 | + return c.json((await c.get("dir").collaborators(repo.id)).map((x) => ({ ...userJson(c, x.account), role_name: x.role }))); | |
| 143 | +}); | |
| 144 | +api.put("/repos/:owner/:repo/collaborators/:login", async (c) => { | |
| 145 | + const { repo } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); | |
| 146 | + const who = await c.get("dir").accountByLogin(c.req.param("login")); | |
| 147 | + if (!who) throw new HttpError(404, "user not found"); | |
| 148 | + const b = await body<{ permission?: Role | "pull" | "push" }>(c).catch(() => ({ permission: "write" as Role })); | |
| 149 | + const map: Record<string, Role> = { pull: "read", push: "write" }; | |
| 150 | + await c.get("dir").setCollaborator(repo.id, who.id, map[b.permission ?? ""] ?? (b.permission as Role) ?? "write"); | |
| 151 | + return c.body(null, 204); | |
| 152 | +}); | |
| 153 | +api.delete("/repos/:owner/:repo/collaborators/:login", async (c) => { | |
| 154 | + const { repo } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); | |
| 155 | + const who = await c.get("dir").accountByLogin(c.req.param("login")); | |
| 156 | + if (who) await c.get("dir").setCollaborator(repo.id, who.id, null); | |
| 157 | + return c.body(null, 204); | |
| 158 | +}); | |
| 159 | + | |
| 160 | +// ---------------------------------------------------------------- git data | |
| 161 | + | |
| 162 | +api.get("/repos/:owner/:repo/branches", async (c) => { | |
| 163 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 164 | + return c.json((await stub.branches()).map((b) => ({ name: b.name, commit: { sha: b.sha }, protected: b.protected, default: b.isDefault }))); | |
| 165 | +}); | |
| 166 | +api.get("/repos/:owner/:repo/branches/:branch", async (c) => { | |
| 167 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 168 | + const sha = await stub.resolve(c.req.param("branch")); | |
| 169 | + if (!sha) throw new HttpError(404, "branch not found"); | |
| 170 | + return c.json({ name: c.req.param("branch"), commit: await stub.commitInfo(sha) }); | |
| 171 | +}); | |
| 172 | +api.get("/repos/:owner/:repo/tags", async (c) => { | |
| 173 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 174 | + return c.json((await stub.tags()).map((t) => ({ name: t.name, commit: { sha: t.target } }))); | |
| 175 | +}); | |
| 176 | +api.get("/repos/:owner/:repo/git/refs/*", async (c) => { | |
| 177 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 178 | + const prefix = `refs/${c.req.path.split("/git/refs/")[1] ?? ""}`; | |
| 179 | + return c.json(stub.refs ? (await stub.refs(prefix)).map((r) => ({ ref: r.name, object: { sha: r.oid } })) : []); | |
| 180 | +}); | |
| 181 | +api.post("/repos/:owner/:repo/git/refs", async (c) => { | |
| 182 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write"); | |
| 183 | + const b = await body<{ ref: string; sha: string }>(c); | |
| 184 | + await stub.createRef(b.ref, b.sha, caller); | |
| 185 | + return c.json({ ref: b.ref, object: { sha: b.sha } }, 201); | |
| 186 | +}); | |
| 187 | +api.patch("/repos/:owner/:repo/git/refs/*", async (c) => { | |
| 188 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write"); | |
| 189 | + const ref = `refs/${c.req.path.split("/git/refs/")[1]}`; | |
| 190 | + const b = await body<{ sha: string; force?: boolean }>(c); | |
| 191 | + await stub.updateRef(ref, b.sha, !!b.force, caller); | |
| 192 | + return c.json({ ref, object: { sha: b.sha } }); | |
| 193 | +}); | |
| 194 | +api.delete("/repos/:owner/:repo/git/refs/*", async (c) => { | |
| 195 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write"); | |
| 196 | + await stub.deleteRef(`refs/${c.req.path.split("/git/refs/")[1]}`, caller); | |
| 197 | + return c.body(null, 204); | |
| 198 | +}); | |
| 199 | +api.get("/repos/:owner/:repo/commits", async (c) => { | |
| 200 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 201 | + const perPage = Number(c.req.query("per_page") ?? 30); | |
| 202 | + const page = Number(c.req.query("page") ?? 1); | |
| 203 | + return c.json(await stub.log(c.req.query("sha") ?? "HEAD", { path: c.req.query("path"), limit: perPage, skip: (page - 1) * perPage })); | |
| 204 | +}); | |
| 205 | +api.get("/repos/:owner/:repo/commits/:ref", async (c) => { | |
| 206 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 207 | + return c.json(await stub.commitWithDiff(c.req.param("ref"))); | |
| 208 | +}); | |
| 209 | +api.get("/repos/:owner/:repo/compare/:spec", async (c) => { | |
| 210 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 211 | + const [base, head] = c.req.param("spec").split("..."); | |
| 212 | + if (!base || !head) throw new HttpError(422, "use base...head"); | |
| 213 | + return c.json(await stub.compare(base, head)); | |
| 214 | +}); | |
| 215 | +api.get("/repos/:owner/:repo/contents/*", async (c) => { | |
| 216 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 217 | + const path = decodeURIComponent(c.req.path.split("/contents/")[1] ?? ""); | |
| 218 | + const ref = c.req.query("ref") ?? "HEAD"; | |
| 219 | + const tree = await stub.tree(ref, path); | |
| 220 | + if (tree) return c.json(tree.items.map((i) => ({ name: i.name, path: i.path, sha: i.oid, size: i.size ?? 0, type: i.type === "tree" ? "dir" : i.type === "commit" ? "submodule" : "file" }))); | |
| 221 | + const blob = await stub.blob(ref, path); | |
| 222 | + if (!blob) throw new HttpError(404, "not found"); | |
| 223 | + return c.json({ type: "file", name: path.split("/").pop(), path, sha: blob.oid, size: blob.size, encoding: "base64", content: b64(blob.content), commit: blob.sha }); | |
| 224 | +}); | |
| 225 | +api.get("/repos/:owner/:repo/contents", async (c) => { | |
| 226 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 227 | + const tree = await stub.tree(c.req.query("ref") ?? "HEAD", ""); | |
| 228 | + if (!tree) return c.json([]); | |
| 229 | + return c.json(tree.items.map((i) => ({ name: i.name, path: i.path, sha: i.oid, size: i.size ?? 0, type: i.type === "tree" ? "dir" : "file" }))); | |
| 230 | +}); | |
| 231 | +api.put("/repos/:owner/:repo/contents/*", async (c) => { | |
| 232 | + const { stub, caller, repo } = await loadRepo(c, P(c).owner, P(c).repo, "write"); | |
| 233 | + const path = decodeURIComponent(c.req.path.split("/contents/")[1] ?? ""); | |
| 234 | + const b = await body<{ message: string; content: string; branch?: string; sha?: string }>(c); | |
| 235 | + const res = await stub.commitFiles({ branch: b.branch ?? repo.default_branch, message: b.message, changes: [{ path, content: fromB64(b.content) }] }, caller); | |
| 236 | + return c.json({ commit: { sha: res.sha }, content: { path } }, 201); | |
| 237 | +}); | |
| 238 | +api.delete("/repos/:owner/:repo/contents/*", async (c) => { | |
| 239 | + const { stub, caller, repo } = await loadRepo(c, P(c).owner, P(c).repo, "write"); | |
| 240 | + const path = decodeURIComponent(c.req.path.split("/contents/")[1] ?? ""); | |
| 241 | + const b = await body<{ message: string; branch?: string }>(c); | |
| 242 | + const res = await stub.commitFiles({ branch: b.branch ?? repo.default_branch, message: b.message, changes: [{ path, delete: true }] }, caller); | |
| 243 | + return c.json({ commit: { sha: res.sha } }); | |
| 244 | +}); | |
| 245 | + | |
| 246 | +/** Agent-friendly multi-file commit without a Git client. */ | |
| 247 | +api.post("/repos/:owner/:repo/diffinery/commits", async (c) => { | |
| 248 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write"); | |
| 249 | + const b = await body<{ branch: string; message: string; base?: string; expected_head_sha?: string; files: Array<{ path: string; content?: string; encoding?: "utf-8" | "base64"; delete?: boolean; mode?: "100644" | "100755" }> }>(c); | |
| 250 | + if (!Array.isArray(b.files) || b.files.length === 0) throw new HttpError(422, "files is required"); | |
| 251 | + const changes = b.files.map((f) => (f.delete ? { path: f.path, delete: true } : { path: f.path, mode: f.mode, content: f.encoding === "base64" ? fromB64(f.content ?? "") : new TextEncoder().encode(f.content ?? "") })); | |
| 252 | + return c.json(await stub.commitFiles({ branch: b.branch, message: b.message, changes, createBranchFrom: b.base, expectedHeadSha: b.expected_head_sha }, caller), 201); | |
| 253 | +}); | |
| 254 | + | |
| 255 | +api.get("/repos/:owner/:repo/raw/:ref/*", async (c) => { | |
| 256 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 257 | + const path = decodeURIComponent(c.req.path.split(`/raw/${c.req.param("ref")}/`)[1] ?? ""); | |
| 258 | + const blob = await stub.blob(c.req.param("ref"), path); | |
| 259 | + if (!blob) throw new HttpError(404, "not found"); | |
| 260 | + return new Response(blob.content, { headers: { "content-type": blob.binary ? "application/octet-stream" : "text/plain; charset=utf-8", "x-content-type-options": "nosniff", "content-security-policy": "sandbox" } }); | |
| 261 | +}); | |
| 262 | + | |
| 263 | +// ---------------------------------------------------------------- branch protection | |
| 264 | + | |
| 265 | +api.get("/repos/:owner/:repo/branches/:branch/protection", async (c) => { | |
| 266 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); | |
| 267 | + const rule = (await stub.listProtections()).find((p) => p.pattern === c.req.param("branch")); | |
| 268 | + if (!rule) throw new HttpError(404, "branch not protected"); | |
| 269 | + return c.json(rule); | |
| 270 | +}); | |
| 271 | +api.put("/repos/:owner/:repo/branches/:branch/protection", async (c) => { | |
| 272 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); | |
| 273 | + if (caller.kind === "agent") throw new HttpError(403, "agents cannot change branch protection"); | |
| 274 | + const rules = await body<ProtectionRules>(c); | |
| 275 | + await stub.setProtection(c.req.param("branch"), rules); | |
| 276 | + return c.json({ pattern: c.req.param("branch"), rules }); | |
| 277 | +}); | |
| 278 | +api.delete("/repos/:owner/:repo/branches/:branch/protection", async (c) => { | |
| 279 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); | |
| 280 | + if (caller.kind === "agent") throw new HttpError(403, "agents cannot change branch protection"); | |
| 281 | + await stub.setProtection(c.req.param("branch"), null); | |
| 282 | + return c.body(null, 204); | |
| 283 | +}); | |
| 284 | + | |
| 285 | +// ---------------------------------------------------------------- issues & pulls | |
| 286 | + | |
| 287 | +const issueJson = (c: AppContext, owner: string, repo: string, i: Awaited<ReturnType<ReturnType<typeof repoStub>["issue"]>>["issue"]) => ({ | |
| 288 | + number: i.number, | |
| 289 | + title: i.title, | |
| 290 | + body: i.body, | |
| 291 | + state: i.pull?.merged ? "closed" : i.state, | |
| 292 | + state_reason: i.stateReason, | |
| 293 | + user: { login: i.author.login, type: i.author.kind === "agent" ? "Bot" : "User" }, | |
| 294 | + labels: i.labels.map((name) => ({ name })), | |
| 295 | + assignees: i.assignees.map((login) => ({ login })), | |
| 296 | + comments: i.comments, | |
| 297 | + created_at: i.createdAt, | |
| 298 | + updated_at: i.updatedAt, | |
| 299 | + closed_at: i.closedAt, | |
| 300 | + html_url: `${c.env.PUBLIC_URL}/${owner}/${repo}/${i.kind === "pull" ? "pull" : "issues"}/${i.number}`, | |
| 301 | + ...(i.pull | |
| 302 | + ? { | |
| 303 | + pull_request: { url: `${c.env.PUBLIC_URL}/api/v3/repos/${owner}/${repo}/pulls/${i.number}` }, | |
| 304 | + head: { ref: i.pull.head, sha: i.pull.headSha }, | |
| 305 | + base: { ref: i.pull.base }, | |
| 306 | + draft: i.pull.draft, | |
| 307 | + merged: i.pull.merged, | |
| 308 | + merged_at: i.pull.mergedAt, | |
| 309 | + merged_by: i.pull.mergedBy ? { login: i.pull.mergedBy } : null, | |
| 310 | + merge_commit_sha: i.pull.mergeCommit, | |
| 311 | + requested_reviewers: i.pull.requestedReviewers.map((login) => ({ login })), | |
| 312 | + intent: i.pull.intent, | |
| 313 | + } | |
| 314 | + : {}), | |
| 315 | +}); | |
| 316 | + | |
| 317 | +for (const kind of ["issues", "pulls"] as const) { | |
| 318 | + api.get(`/repos/:owner/:repo/${kind}`, async (c) => { | |
| 319 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 320 | + const perPage = Math.min(Number(c.req.query("per_page") ?? 30), 100); | |
| 321 | + const page = Number(c.req.query("page") ?? 1); | |
| 322 | + const res = await stub.issues({ kind: kind === "pulls" ? "pull" : undefined, state: (c.req.query("state") as "open" | "closed" | "all") ?? "open", label: c.req.query("labels"), author: c.req.query("creator"), q: c.req.query("q"), limit: perPage, offset: (page - 1) * perPage }); | |
| 323 | + c.header("x-total-count", String(res.total)); | |
| 324 | + return c.json(res.items.map((i) => issueJson(c, P(c).owner, P(c).repo, i))); | |
| 325 | + }); | |
| 326 | +} | |
| 327 | +api.post("/repos/:owner/:repo/issues", async (c) => { | |
| 328 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 329 | + const b = await body<{ title: string; body?: string; labels?: string[]; assignees?: string[] }>(c); | |
| 330 | + return c.json(issueJson(c, P(c).owner, P(c).repo, await stub.createIssue(b, caller)), 201); | |
| 331 | +}); | |
| 332 | +api.get("/repos/:owner/:repo/issues/:number", async (c) => { | |
| 333 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 334 | + return c.json(issueJson(c, P(c).owner, P(c).repo, (await stub.issue(num(c))).issue)); | |
| 335 | +}); | |
| 336 | +api.patch("/repos/:owner/:repo/issues/:number", async (c) => { | |
| 337 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 338 | + const b = await body<{ title?: string; body?: string; state?: "open" | "closed"; state_reason?: string; labels?: string[]; assignees?: string[] }>(c); | |
| 339 | + return c.json(issueJson(c, P(c).owner, P(c).repo, await stub.updateIssue(num(c), { ...b, stateReason: b.state_reason }, caller))); | |
| 340 | +}); | |
| 341 | +api.get("/repos/:owner/:repo/issues/:number/comments", async (c) => { | |
| 342 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 343 | + return c.json((await stub.issue(num(c))).comments.filter((x) => !x.reviewId).map((x) => ({ id: x.id, body: x.body, user: { login: x.author.login, type: x.author.kind === "agent" ? "Bot" : "User" }, created_at: x.createdAt, updated_at: x.updatedAt }))); | |
| 344 | +}); | |
| 345 | +api.post("/repos/:owner/:repo/issues/:number/comments", async (c) => { | |
| 346 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 347 | + const b = await body<{ body: string }>(c); | |
| 348 | + const x = await stub.addComment(num(c), b.body, caller); | |
| 349 | + return c.json({ id: x.id, body: x.body, user: { login: x.author.login }, created_at: x.createdAt }, 201); | |
| 350 | +}); | |
| 351 | +api.patch("/repos/:owner/:repo/issues/comments/:id", async (c) => { | |
| 352 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 353 | + await stub.editComment(c.req.param("id"), (await body<{ body: string }>(c)).body, caller); | |
| 354 | + return c.json({ id: c.req.param("id") }); | |
| 355 | +}); | |
| 356 | +api.get("/repos/:owner/:repo/labels", async (c) => { | |
| 357 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 358 | + return c.json(await stub.labels()); | |
| 359 | +}); | |
| 360 | +api.post("/repos/:owner/:repo/labels", async (c) => { | |
| 361 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "triage"); | |
| 362 | + const b = await body<{ name: string; color?: string; description?: string }>(c); | |
| 363 | + await stub.setLabel(b.name, b.color ?? "888888", b.description ?? ""); | |
| 364 | + return c.json(b, 201); | |
| 365 | +}); | |
| 366 | + | |
| 367 | +api.post("/repos/:owner/:repo/pulls", async (c) => { | |
| 368 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write"); | |
| 369 | + const b = await body<{ title: string; body?: string; head: string; base?: string; draft?: boolean; intent?: string; reviewers?: string[] }>(c); | |
| 370 | + return c.json(issueJson(c, P(c).owner, P(c).repo, await stub.createPull({ ...b, head: b.head.includes(":") ? b.head.split(":")[1]! : b.head }, caller)), 201); | |
| 371 | +}); | |
| 372 | +api.get("/repos/:owner/:repo/pulls/:number", async (c) => { | |
| 373 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 374 | + const n = num(c); | |
| 375 | + const { issue } = await stub.issue(n); | |
| 376 | + if (issue.kind !== "pull") throw new HttpError(404, "not a pull request"); | |
| 377 | + const m = await stub.mergeability(n, caller); | |
| 378 | + return c.json({ ...issueJson(c, P(c).owner, P(c).repo, issue), mergeable: m.mergeable, mergeable_state: m.state, merge_blockers: m.blockers, approvals: m.approvals, required_approvals: m.requiredApprovals, checks: m.checks }); | |
| 379 | +}); | |
| 380 | +api.patch("/repos/:owner/:repo/pulls/:number", async (c) => { | |
| 381 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 382 | + const b = await body<{ title?: string; body?: string; state?: "open" | "closed" }>(c); | |
| 383 | + return c.json(issueJson(c, P(c).owner, P(c).repo, await stub.updateIssue(num(c), b, caller))); | |
| 384 | +}); | |
| 385 | +api.get("/repos/:owner/:repo/pulls/:number/files", async (c) => { | |
| 386 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 387 | + const res = await stub.pullFiles(num(c)); | |
| 388 | + return c.json(res.files.map((f) => ({ filename: f.path, status: f.status === "deleted" ? "removed" : f.status, additions: f.additions, deletions: f.deletions, changes: f.additions + f.deletions, patch: f.patch, binary: f.binary }))); | |
| 389 | +}); | |
| 390 | +api.get("/repos/:owner/:repo/pulls/:number/commits", async (c) => { | |
| 391 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 392 | + return c.json(await stub.pullCommits(num(c))); | |
| 393 | +}); | |
| 394 | +api.get("/repos/:owner/:repo/pulls/:number/merge", async (c) => { | |
| 395 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 396 | + return c.json(await stub.mergeability(num(c), caller)); | |
| 397 | +}); | |
| 398 | +api.put("/repos/:owner/:repo/pulls/:number/merge", async (c) => { | |
| 399 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write"); | |
| 400 | + const b = await body<{ merge_method?: "merge" | "squash" | "rebase"; commit_title?: string; commit_message?: string; sha?: string; delete_branch?: boolean }>(c).catch(() => ({}) as Record<string, undefined>); | |
| 401 | + return c.json(await stub.mergePull(num(c), { method: b.merge_method, title: b.commit_title, message: b.commit_message, expectedHeadSha: b.sha, deleteBranch: b.delete_branch }, caller)); | |
| 402 | +}); | |
| 403 | +api.get("/repos/:owner/:repo/pulls/:number/reviews", async (c) => { | |
| 404 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 405 | + return c.json((await stub.issue(num(c))).reviews.map((r) => ({ id: r.id, state: r.state, body: r.body, commit_id: r.commitId, submitted_at: r.submittedAt, user: { login: r.author.login, type: r.author.kind === "agent" ? "Bot" : "User" } }))); | |
| 406 | +}); | |
| 407 | +api.post("/repos/:owner/:repo/pulls/:number/reviews", async (c) => { | |
| 408 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 409 | + const b = await body<{ event?: "APPROVE" | "REQUEST_CHANGES" | "COMMENT"; body?: string; comments?: Array<{ path: string; line: number; side?: "LEFT" | "RIGHT"; body: string }> }>(c); | |
| 410 | + const r = await stub.submitReview(num(c), { event: b.event ?? "COMMENT", body: b.body, comments: b.comments }, caller); | |
| 411 | + return c.json({ id: r.id, state: r.state, body: r.body, commit_id: r.commitId, submitted_at: r.submittedAt }, 201); | |
| 412 | +}); | |
| 413 | +api.get("/repos/:owner/:repo/pulls/:number/comments", async (c) => { | |
| 414 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 415 | + return c.json((await stub.issue(num(c))).comments.filter((x) => x.reviewId).map((x) => ({ id: x.id, body: x.body, path: x.path, line: x.line, side: x.side, commit_id: x.commitId, in_reply_to_id: x.inReplyTo, user: { login: x.author.login }, created_at: x.createdAt, resolved: x.resolved }))); | |
| 416 | +}); | |
| 417 | +api.post("/repos/:owner/:repo/pulls/:number/requested_reviewers", async (c) => { | |
| 418 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 419 | + const b = await body<{ reviewers: string[] }>(c); | |
| 420 | + await stub.requestReviewers(num(c), b.reviewers ?? [], caller); | |
| 421 | + return c.json({ requested_reviewers: b.reviewers }, 201); | |
| 422 | +}); | |
| 423 | + | |
| 424 | +// ---------------------------------------------------------------- checks (for plugins and external CI) | |
| 425 | + | |
| 426 | +api.get("/repos/:owner/:repo/commits/:ref/check-runs", async (c) => { | |
| 427 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 428 | + const sha = await stub.resolve(c.req.param("ref")); | |
| 429 | + if (!sha) throw new HttpError(404, "ref not found"); | |
| 430 | + const runs = await stub.checks(sha); | |
| 431 | + return c.json({ total_count: runs.length, check_runs: runs.map((r) => ({ id: r.id, name: r.name, head_sha: r.sha, status: r.status, conclusion: r.conclusion, details_url: r.detailsUrl, output: r.output, app: { slug: r.app }, started_at: r.startedAt, completed_at: r.completedAt })) }); | |
| 432 | +}); | |
| 433 | +api.post("/repos/:owner/:repo/check-runs", async (c) => { | |
| 434 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write"); | |
| 435 | + const b = await body<{ name: string; head_sha: string; status?: "queued" | "in_progress" | "completed"; conclusion?: string; details_url?: string; output?: { title?: string; summary?: string } }>(c); | |
| 436 | + const check = await stub.upsertCheck({ sha: b.head_sha, name: b.name, status: b.status ?? "queued", conclusion: b.conclusion, detailsUrl: b.details_url, output: b.output, app: caller.login }); | |
| 437 | + return c.json(check, 201); | |
| 438 | +}); | |
| 439 | + | |
| 440 | +// ---------------------------------------------------------------- actions | |
| 441 | + | |
| 442 | +const runJson = (c: AppContext, owner: string, repo: string, r: RunInfo) => ({ | |
| 443 | + id: r.id, | |
| 444 | + run_number: r.number, | |
| 445 | + run_attempt: r.attempt, | |
| 446 | + name: r.workflowName, | |
| 447 | + display_title: r.title, | |
| 448 | + path: r.workflowPath, | |
| 449 | + event: r.event, | |
| 450 | + head_branch: r.ref.replace(/^refs\/heads\//, ""), | |
| 451 | + head_sha: r.sha, | |
| 452 | + status: r.status, | |
| 453 | + conclusion: r.conclusion, | |
| 454 | + actor: { login: r.actor.login, type: r.actor.kind === "agent" ? "Bot" : "User" }, | |
| 455 | + created_at: r.createdAt, | |
| 456 | + updated_at: r.updatedAt, | |
| 457 | + html_url: `${c.env.PUBLIC_URL}/${owner}/${repo}/actions/runs/${r.id}`, | |
| 458 | + pull_request_number: r.prNumber, | |
| 459 | +}); | |
| 460 | + | |
| 461 | +api.get("/repos/:owner/:repo/actions/workflows", async (c) => { | |
| 462 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 463 | + const wfs = await stub.workflows(); | |
| 464 | + return c.json({ total_count: wfs.length, workflows: wfs.map((w) => ({ id: w.path, name: w.name, path: w.path, state: w.errors.length ? "invalid" : "active", errors: w.errors, triggers: w.triggers })) }); | |
| 465 | +}); | |
| 466 | +api.post("/repos/:owner/:repo/actions/workflows/:workflow/dispatches", async (c) => { | |
| 467 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write"); | |
| 468 | + const b = await body<{ ref: string; inputs?: Record<string, unknown> }>(c); | |
| 469 | + const run = await stub.dispatchWorkflow(decodeURIComponent(c.req.param("workflow")), b.ref, b.inputs ?? {}, caller); | |
| 470 | + return c.json({ run_id: run.id, html_url: `${c.env.PUBLIC_URL}/${P(c).owner}/${P(c).repo}/actions/runs/${run.id}` }, 201); | |
| 471 | +}); | |
| 472 | +// Self-hosted runners poll this to pick up `runs-on: [self-hosted, ...]` jobs (see `diffinery runner`). | |
| 473 | +api.post("/repos/:owner/:repo/actions/runners/claim", async (c) => { | |
| 474 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); | |
| 475 | + const b = await body<{ name?: string; labels?: string[] }>(c); | |
| 476 | + const job = await stub.claimJob(b.name ?? "runner", Array.isArray(b.labels) ? b.labels.map(String) : [], caller); | |
| 477 | + if (!job) return c.body(null, 204); | |
| 478 | + return c.json({ job_id: job.jobId, run_id: job.runId, name: job.name, token: job.token, api_url: new URL(c.req.url).origin }); | |
| 479 | +}); | |
| 480 | +api.get("/repos/:owner/:repo/actions/runs", async (c) => { | |
| 481 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 482 | + const perPage = Math.min(Number(c.req.query("per_page") ?? 25), 100); | |
| 483 | + const page = Number(c.req.query("page") ?? 1); | |
| 484 | + const res = await stub.runs({ workflow: c.req.query("workflow"), 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 }); | |
| 485 | + return c.json({ total_count: res.total, workflow_runs: res.runs.map((r) => runJson(c, P(c).owner, P(c).repo, r)) }); | |
| 486 | +}); | |
| 487 | +api.get("/repos/:owner/:repo/actions/runs/:id", async (c) => { | |
| 488 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 489 | + const r = await stub.run(c.req.param("id")); | |
| 490 | + return c.json({ ...runJson(c, P(c).owner, P(c).repo, r), jobs: r.jobs }); | |
| 491 | +}); | |
| 492 | +api.get("/repos/:owner/:repo/actions/runs/:id/jobs", async (c) => { | |
| 493 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 494 | + const jobs = await stub.runJobs(c.req.param("id")); | |
| 495 | + return c.json({ total_count: jobs.length, jobs }); | |
| 496 | +}); | |
| 497 | +api.post("/repos/:owner/:repo/actions/runs/:id/cancel", async (c) => { | |
| 498 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write"); | |
| 499 | + await stub.cancelRun(c.req.param("id"), caller); | |
| 500 | + return c.json({}, 202); | |
| 501 | +}); | |
| 502 | +for (const path of ["rerun", "rerun-failed-jobs"]) { | |
| 503 | + api.post(`/repos/:owner/:repo/actions/runs/:id/${path}`, async (c) => { | |
| 504 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write"); | |
| 505 | + const r = await stub.rerun(c.req.param("id"), caller); | |
| 506 | + return c.json({ run_id: r.id }, 201); | |
| 507 | + }); | |
| 508 | +} | |
| 509 | +api.get("/repos/:owner/:repo/actions/jobs/:id", async (c) => { | |
| 510 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 511 | + return c.json(await stub.job(c.req.param("id"))); | |
| 512 | +}); | |
| 513 | +api.get("/repos/:owner/:repo/actions/jobs/:id/logs", async (c) => { | |
| 514 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 515 | + const { lines } = await stub.jobLogs(c.req.param("id"), Number(c.req.query("after") ?? -1)); | |
| 516 | + if (c.req.query("format") === "json") return c.json(lines); | |
| 517 | + return c.text(lines.map((l) => `${l.t} ${l.text}`).join("\n")); | |
| 518 | +}); | |
| 519 | +api.get("/repos/:owner/:repo/actions/runs/:id/artifacts", async (c) => { | |
| 520 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo); | |
| 521 | + const list = await stub.artifacts(c.req.param("id")); | |
| 522 | + return c.json({ total_count: list.length, artifacts: list.map((a) => ({ name: a.name, size_in_bytes: a.size, created_at: a.createdAt, archive_download_url: `${c.env.PUBLIC_URL}/${P(c).owner}/${P(c).repo}/actions/runs/${c.req.param("id")}/artifacts/${encodeURIComponent(a.name)}` })) }); | |
| 523 | +}); | |
| 524 | +api.get("/repos/:owner/:repo/actions/secrets", async (c) => { | |
| 525 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); | |
| 526 | + const s = await stub.secretNames(); | |
| 527 | + return c.json({ total_count: s.length, secrets: s.map((x) => ({ name: x.name, updated_at: x.updatedAt })) }); | |
| 528 | +}); | |
| 529 | +api.put("/repos/:owner/:repo/actions/secrets/:name", async (c) => { | |
| 530 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); | |
| 531 | + if (caller.kind === "agent") throw new HttpError(403, "agents cannot manage secrets"); | |
| 532 | + const b = await body<{ value: string }>(c); | |
| 533 | + if (typeof b.value !== "string") throw new HttpError(422, "value is required (send it over HTTPS; it is encrypted at rest)"); | |
| 534 | + await stub.setSecret(c.req.param("name"), b.value); | |
| 535 | + return c.body(null, 204); | |
| 536 | +}); | |
| 537 | +api.delete("/repos/:owner/:repo/actions/secrets/:name", async (c) => { | |
| 538 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); | |
| 539 | + await stub.deleteSecret(c.req.param("name")); | |
| 540 | + return c.body(null, 204); | |
| 541 | +}); | |
| 542 | +api.get("/repos/:owner/:repo/actions/variables", async (c) => { | |
| 543 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "write"); | |
| 544 | + return c.json({ variables: Object.entries(await stub.variables()).map(([name, value]) => ({ name, value })) }); | |
| 545 | +}); | |
| 546 | +api.put("/repos/:owner/:repo/actions/variables/:name", async (c) => { | |
| 547 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); | |
| 548 | + await stub.setVariable(c.req.param("name"), (await body<{ value: string }>(c)).value); | |
| 549 | + return c.body(null, 204); | |
| 550 | +}); | |
| 551 | +api.put("/orgs/:org/actions/secrets/:name", async (c) => { | |
| 552 | + const a = requireAccount(c); | |
| 553 | + if (a.kind === "agent") throw new HttpError(403, "agents cannot manage secrets"); | |
| 554 | + const dir = c.get("dir"); | |
| 555 | + const org = await dir.accountByLogin(c.req.param("org")); | |
| 556 | + if (!org || org.kind !== "org") throw new HttpError(404, "not found"); | |
| 557 | + const role = await dir.orgRole(org.id, a.id); | |
| 558 | + if (role !== "owner" && role !== "admin" && !a.site_admin) throw new HttpError(403, "organization admins only"); | |
| 559 | + const name = c.req.param("name").toUpperCase(); | |
| 560 | + if (!/^[A-Z_][A-Z0-9_]*$/.test(name)) throw new HttpError(422, "invalid secret name"); | |
| 561 | + const { value } = await body<{ value: string }>(c); | |
| 562 | + await c.env.DB.prepare("INSERT OR REPLACE INTO org_secrets (org_id, name, ciphertext, updated_at) VALUES (?, ?, ?, ?)").bind(org.id, name, await encryptSecret(c.env.MASTER_KEY, `org:${org.id}`, value), new Date().toISOString()).run(); | |
| 563 | + return c.body(null, 204); | |
| 564 | +}); | |
| 565 | + | |
| 566 | +// ---------------------------------------------------------------- webhooks | |
| 567 | + | |
| 568 | +api.get("/repos/:owner/:repo/hooks", async (c) => { | |
| 569 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); | |
| 570 | + return c.json((await stub.webhooks()).map(({ secret: _s, ...h }) => h)); | |
| 571 | +}); | |
| 572 | +api.post("/repos/:owner/:repo/hooks", async (c) => { | |
| 573 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); | |
| 574 | + const b = await body<{ config: { url: string; secret?: string }; events?: string[] }>(c); | |
| 575 | + return c.json(await stub.createWebhook(b.config?.url, b.events ?? ["*"], b.config?.secret), 201); | |
| 576 | +}); | |
| 577 | +api.delete("/repos/:owner/:repo/hooks/:id", async (c) => { | |
| 578 | + const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); | |
| 579 | + await stub.deleteWebhook(c.req.param("id")); | |
| 580 | + return c.body(null, 204); | |
| 581 | +}); | |
| 582 | + | |
| 583 | +// ---------------------------------------------------------------- Diffinery: tokens, agents, sandboxes, notifications | |
| 584 | + | |
| 585 | +api.get("/diffinery/tokens", async (c) => c.json(await c.get("dir").tokens(requireAccount(c).id))); | |
| 586 | +api.post("/diffinery/tokens", async (c) => { | |
| 587 | + const a = requireAccount(c); | |
| 588 | + const p = c.get("principal"); | |
| 589 | + if (p.type === "account" && p.via === "token" && !p.scopes.includes("admin")) throw new HttpError(403, "creating tokens requires an admin-scoped token or a browser session"); | |
| 590 | + const b = await body<{ name: string; scopes?: TokenScope[]; repositories?: string[]; expires_in_days?: number }>(c); | |
| 591 | + let repoIds: string[] | null = null; | |
| 592 | + if (b.repositories?.length) { | |
| 593 | + repoIds = []; | |
| 594 | + for (const full of b.repositories) { | |
| 595 | + const [o, n] = full.split("/"); | |
| 596 | + const r = o && n ? await c.get("dir").repo(o, n) : null; | |
| 597 | + if (!r) throw new HttpError(422, `unknown repository ${full}`); | |
| 598 | + repoIds.push(r.id); | |
| 599 | + } | |
| 600 | + } | |
| 601 | + const t = await c.get("dir").createToken(a, { name: b.name, scopes: b.scopes, repoIds, expiresInDays: b.expires_in_days }); | |
| 602 | + c.executionCtx.waitUntil(c.get("dir").audit({ actorId: a.id, actorLogin: a.login, action: "token.create", detail: { name: b.name, scopes: b.scopes } })); | |
| 603 | + return c.json(t, 201); | |
| 604 | +}); | |
| 605 | +api.delete("/diffinery/tokens/:id", async (c) => { | |
| 606 | + await c.get("dir").revokeToken(requireAccount(c).id, c.req.param("id")); | |
| 607 | + return c.body(null, 204); | |
| 608 | +}); | |
| 609 | +api.get("/diffinery/agents", async (c) => { | |
| 610 | + const a = requireAccount(c); | |
| 611 | + const orgs = (await c.get("dir").orgsFor(a.id)).filter((o) => o.role !== "member").map((o) => o.id); | |
| 612 | + return c.json((await c.get("dir").agentsOwnedBy([a.id, ...orgs])).map((x) => ({ ...userJson(c, x), owner_id: x.owner_id, policy: x.agent_policy ? JSON.parse(x.agent_policy) : {} }))); | |
| 613 | +}); | |
| 614 | +api.post("/diffinery/agents", async (c) => { | |
| 615 | + const a = requireAccount(c); | |
| 616 | + if (a.kind !== "user") throw new HttpError(403, "only people can create agents"); | |
| 617 | + const b = await body<{ login: string; name?: string; org?: string; policy?: Record<string, unknown>; token?: { scopes?: TokenScope[]; expires_in_days?: number } }>(c); | |
| 618 | + const dir = c.get("dir"); | |
| 619 | + const org = b.org ? await dir.accountByLogin(b.org) : null; | |
| 620 | + const agent = await dir.createAgent(a, { login: b.login, name: b.name, orgId: org?.id, policy: b.policy }); | |
| 621 | + const token = await dir.createToken(agent, { name: "default", scopes: b.token?.scopes ?? ["write"], expiresInDays: b.token?.expires_in_days ?? 90 }); | |
| 622 | + c.executionCtx.waitUntil(dir.audit({ actorId: a.id, actorLogin: a.login, action: "agent.create", target: agent.login })); | |
| 623 | + return c.json({ agent: userJson(c, agent), token }, 201); | |
| 624 | +}); | |
| 625 | +api.post("/diffinery/agents/:login/tokens", async (c) => { | |
| 626 | + const a = requireAccount(c); | |
| 627 | + const dir = c.get("dir"); | |
| 628 | + const agent = await dir.accountByLogin(c.req.param("login")); | |
| 629 | + if (!agent || agent.kind !== "agent") throw new HttpError(404, "agent not found"); | |
| 630 | + const orgRole = agent.owner_id && agent.owner_id !== a.id ? await dir.orgRole(agent.owner_id, a.id) : null; | |
| 631 | + if (agent.owner_id !== a.id && orgRole !== "owner" && orgRole !== "admin") throw new HttpError(403, "not your agent"); | |
| 632 | + const b = await body<{ name?: string; scopes?: TokenScope[]; expires_in_days?: number }>(c).catch(() => ({}) as Record<string, undefined>); | |
| 633 | + return c.json(await dir.createToken(agent, { name: b.name ?? "token", scopes: b.scopes, expiresInDays: b.expires_in_days }), 201); | |
| 634 | +}); | |
| 635 | +api.delete("/diffinery/agents/:login", async (c) => { | |
| 636 | + const a = requireAccount(c); | |
| 637 | + const dir = c.get("dir"); | |
| 638 | + const agent = await dir.accountByLogin(c.req.param("login")); | |
| 639 | + if (!agent || agent.kind !== "agent") throw new HttpError(404, "agent not found"); | |
| 640 | + const orgRole = agent.owner_id && agent.owner_id !== a.id ? await dir.orgRole(agent.owner_id, a.id) : null; | |
| 641 | + if (agent.owner_id !== a.id && orgRole !== "owner" && orgRole !== "admin" && !a.site_admin) throw new HttpError(403, "not your agent"); | |
| 642 | + await dir.setStatus(agent.id, "disabled"); | |
| 643 | + c.executionCtx.waitUntil(dir.audit({ actorId: a.id, actorLogin: a.login, action: "agent.disable", target: agent.login })); | |
| 644 | + return c.body(null, 204); | |
| 645 | +}); | |
| 646 | + | |
| 647 | +api.post("/repos/:owner/:repo/diffinery/sandboxes", async (c) => { | |
| 648 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write"); | |
| 649 | + const b = await body<{ ref?: string }>(c).catch(() => ({}) as { ref?: string }); | |
| 650 | + return c.json(await stub.createSandbox(caller, b.ref), 201); | |
| 651 | +}); | |
| 652 | +api.post("/repos/:owner/:repo/diffinery/sandboxes/:id/:action", async (c) => { | |
| 653 | + const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write"); | |
| 654 | + const id = c.req.param("id"); | |
| 655 | + const action = c.req.param("action"); | |
| 656 | + await stub.sandbox(id, caller); | |
| 657 | + if (action === "close") { | |
| 658 | + await stub.closeSandbox(id, caller); | |
| 659 | + return c.body(null, 204); | |
| 660 | + } | |
| 661 | + const map: Record<string, string> = { exec: "/exec", read: "/files/read", write: "/files/write" }; | |
| 662 | + if (!map[action]) throw new HttpError(404, "unknown sandbox action"); | |
| 663 | + const res = await c.env.SANDBOX.get(c.env.SANDBOX.idFromName(id)).call(map[action]!, JSON.stringify(await c.req.json().catch(() => ({})))); | |
| 664 | + return new Response(res.body, { status: res.status, headers: { "content-type": res.json ? "application/json" : "text/plain; charset=utf-8" } }); | |
| 665 | +}); | |
| 666 | + | |
| 667 | +api.get("/notifications", async (c) => c.json(await c.get("dir").notifications(requireAccount(c).id, c.req.query("all") !== "true"))); | |
| 668 | +api.put("/notifications", async (c) => { | |
| 669 | + await c.get("dir").markRead(requireAccount(c).id); | |
| 670 | + return c.body(null, 205); | |
| 671 | +}); | |
| 672 | + | |
| 673 | +// OIDC discovery for workflow id tokens. | |
| 674 | +export const wellKnown = new Hono<{ Bindings: Env; Variables: AppVars }>(); | |
| 675 | +wellKnown.get("/.well-known/openid-configuration", (c) => | |
| 676 | + c.json({ issuer: c.env.PUBLIC_URL, jwks_uri: `${c.env.PUBLIC_URL}/.well-known/jwks`, response_types_supported: ["id_token"], subject_types_supported: ["public"], id_token_signing_alg_values_supported: ["ES256"], claims_supported: ["sub", "aud", "iss", "repository", "ref", "sha", "workflow", "actor", "run_id"] }), | |
| 677 | +); | |
| 678 | +wellKnown.get("/.well-known/jwks", (c) => c.json({ keys: c.env.OIDC_PRIVATE_KEY ? [publicJwk(JSON.parse(c.env.OIDC_PRIVATE_KEY))] : [] })); | |
| 679 | + | |
| 680 | +export { fromUtf8 }; |
addedapps/diffinery/src/http/git.ts+92−0View file
| @@ -1,0 +1,92 @@ | ||
| 1 | +import { Hono } from "hono"; | |
| 2 | +import type { Env } from "../env.js"; | |
| 3 | +import type { AppVars } from "../auth/principal.js"; | |
| 4 | +import { HttpError } from "../lib/util.js"; | |
| 5 | +import { loadRepo } from "./common.js"; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * Git smart HTTP (protocol v2 for fetch/clone, v0 for push) and the Git LFS | |
| 9 | + * batch API, at https://diffinery.com/<owner>/<repo>.git | |
| 10 | + */ | |
| 11 | +export const git = new Hono<{ Bindings: Env; Variables: AppVars }>(); | |
| 12 | + | |
| 13 | +const noCache = { "cache-control": "no-cache, max-age=0, must-revalidate", pragma: "no-cache" }; | |
| 14 | + | |
| 15 | +git.get("/:owner/:repo/info/refs", async (c) => { | |
| 16 | + const service = c.req.query("service"); | |
| 17 | + if (service !== "git-upload-pack" && service !== "git-receive-pack") throw new HttpError(403, "dumb HTTP protocol is not supported; update git"); | |
| 18 | + const { stub } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), service === "git-receive-pack" ? "write" : "read"); | |
| 19 | + if (service === "git-upload-pack" && !/version=2/.test(c.req.header("git-protocol") ?? "")) { | |
| 20 | + throw new HttpError(400, "Diffinery requires Git protocol v2 (git 2.26 or newer, or run: git config --global protocol.version 2)"); | |
| 21 | + } | |
| 22 | + const body = await stub.gitInfoRefs(service); | |
| 23 | + return new Response(body, { headers: { "content-type": `application/x-${service}-advertisement`, ...noCache } }); | |
| 24 | +}); | |
| 25 | + | |
| 26 | +git.post("/:owner/:repo/git-upload-pack", async (c) => { | |
| 27 | + const { stub } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), "read"); | |
| 28 | + const body = await readBody(c.req.raw); | |
| 29 | + const out = await stub.gitUploadPack(body); | |
| 30 | + return new Response(out, { headers: { "content-type": "application/x-git-upload-pack-result", ...noCache } }); | |
| 31 | +}); | |
| 32 | + | |
| 33 | +git.post("/:owner/:repo/git-receive-pack", async (c) => { | |
| 34 | + const { stub, caller, repo } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), "write"); | |
| 35 | + const body = await readBody(c.req.raw); | |
| 36 | + const out = await stub.gitReceivePack(body, caller); | |
| 37 | + c.executionCtx.waitUntil(c.get("dir").touchPushed(repo.id)); | |
| 38 | + return new Response(out, { headers: { "content-type": "application/x-git-receive-pack-result", ...noCache } }); | |
| 39 | +}); | |
| 40 | + | |
| 41 | +async function readBody(req: Request): Promise<Uint8Array> { | |
| 42 | + let stream = req.body; | |
| 43 | + if (!stream) return new Uint8Array(0); | |
| 44 | + if (req.headers.get("content-encoding") === "gzip") stream = stream.pipeThrough(new DecompressionStream("gzip")); | |
| 45 | + return new Uint8Array(await new Response(stream).arrayBuffer()); | |
| 46 | +} | |
| 47 | + | |
| 48 | +// ---------------------------------------------------------------- Git LFS | |
| 49 | + | |
| 50 | +const lfsKey = (repoId: string, oid: string) => `repos/${repoId}/lfs/${oid.slice(0, 2)}/${oid.slice(2, 4)}/${oid}`; | |
| 51 | +const LFS_TYPE = "application/vnd.git-lfs+json"; | |
| 52 | + | |
| 53 | +git.post("/:owner/:repo/info/lfs/objects/batch", async (c) => { | |
| 54 | + const req = (await c.req.json()) as { operation: "upload" | "download"; objects: Array<{ oid: string; size: number }> }; | |
| 55 | + const { repo } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), req.operation === "upload" ? "write" : "read"); | |
| 56 | + const base = `${c.env.PUBLIC_URL}/${c.req.param("owner")}/${c.req.param("repo")}/info/lfs/objects`; | |
| 57 | + const auth = c.req.header("authorization"); | |
| 58 | + const objects = []; | |
| 59 | + for (const o of req.objects.slice(0, 1000)) { | |
| 60 | + if (!/^[0-9a-f]{64}$/.test(o.oid)) { | |
| 61 | + objects.push({ oid: o.oid, size: o.size, error: { code: 422, message: "invalid oid" } }); | |
| 62 | + continue; | |
| 63 | + } | |
| 64 | + const head = await c.env.BUCKET.head(lfsKey(repo.id, o.oid)); | |
| 65 | + if (req.operation === "download") { | |
| 66 | + if (!head) objects.push({ oid: o.oid, size: o.size, error: { code: 404, message: "object not found" } }); | |
| 67 | + else objects.push({ oid: o.oid, size: head.size, authenticated: true, actions: { download: { href: `${base}/${o.oid}`, header: auth ? { Authorization: auth } : {}, expires_in: 3600 } } }); | |
| 68 | + } else { | |
| 69 | + objects.push(head ? { oid: o.oid, size: o.size, authenticated: true } : { oid: o.oid, size: o.size, authenticated: true, actions: { upload: { href: `${base}/${o.oid}`, header: auth ? { Authorization: auth } : {}, expires_in: 3600 } } }); | |
| 70 | + } | |
| 71 | + } | |
| 72 | + return c.body(JSON.stringify({ transfer: "basic", objects, hash_algo: "sha256" }), 200, { "content-type": LFS_TYPE }); | |
| 73 | +}); | |
| 74 | + | |
| 75 | +git.get("/:owner/:repo/info/lfs/objects/:oid", async (c) => { | |
| 76 | + const { repo } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), "read"); | |
| 77 | + const obj = await c.env.BUCKET.get(lfsKey(repo.id, c.req.param("oid"))); | |
| 78 | + if (!obj) throw new HttpError(404, "object not found"); | |
| 79 | + return new Response(obj.body, { headers: { "content-type": "application/octet-stream", "content-length": String(obj.size) } }); | |
| 80 | +}); | |
| 81 | + | |
| 82 | +git.put("/:owner/:repo/info/lfs/objects/:oid", async (c) => { | |
| 83 | + const { repo } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), "write"); | |
| 84 | + const oid = c.req.param("oid"); | |
| 85 | + if (!/^[0-9a-f]{64}$/.test(oid)) throw new HttpError(422, "invalid oid"); | |
| 86 | + // Verify the content hash while storing: R2 checks the SHA-256 we pass. | |
| 87 | + const body = await c.req.arrayBuffer(); | |
| 88 | + const digest = [...new Uint8Array(await crypto.subtle.digest("SHA-256", body))].map((b) => b.toString(16).padStart(2, "0")).join(""); | |
| 89 | + if (digest !== oid) throw new HttpError(422, "content does not match oid"); | |
| 90 | + await c.env.BUCKET.put(lfsKey(repo.id, oid), body); | |
| 91 | + return c.body(null, 200); | |
| 92 | +}); |
addedapps/diffinery/src/http/runner.ts+134−0View file
| @@ -1,0 +1,134 @@ | ||
| 1 | +import { Hono } from "hono"; | |
| 2 | +import type { JobCompletion, LogLine, StepReport } from "@diffinery/shared"; | |
| 3 | +import type { Env } from "../env.js"; | |
| 4 | +import type { AppContext, AppVars } from "../auth/principal.js"; | |
| 5 | +import { credentialFrom } from "../auth/principal.js"; | |
| 6 | +import { signEs256 } from "../lib/jwt.js"; | |
| 7 | +import { HttpError } from "../lib/util.js"; | |
| 8 | +import { repoStub } from "./common.js"; | |
| 9 | + | |
| 10 | +/** Endpoints the runner agent inside a job container calls. Auth: the job token. */ | |
| 11 | +export const runner = new Hono<{ Bindings: Env; Variables: AppVars }>(); | |
| 12 | + | |
| 13 | +function jobAuth(req: Request, jobId: string): { token: string; repoId: string } { | |
| 14 | + const token = credentialFrom(req); | |
| 15 | + if (!token?.startsWith("djt_")) throw new HttpError(401, "job token required"); | |
| 16 | + const [, repoId, tokJob] = token.split("_"); | |
| 17 | + if (!repoId || tokJob !== jobId) throw new HttpError(401, "token does not match job"); | |
| 18 | + return { token, repoId }; | |
| 19 | +} | |
| 20 | + | |
| 21 | +runner.get("/runner/jobs/:jobId", async (c) => { | |
| 22 | + const jobId = c.req.param("jobId"); | |
| 23 | + const { token, repoId } = jobAuth(c.req.raw, jobId); | |
| 24 | + return c.json(await repoStub(c.env, repoId).runnerSpec(jobId, token)); | |
| 25 | +}); | |
| 26 | + | |
| 27 | +runner.post("/runner/jobs/:jobId/logs", async (c) => { | |
| 28 | + const jobId = c.req.param("jobId"); | |
| 29 | + const { token, repoId } = jobAuth(c.req.raw, jobId); | |
| 30 | + const { lines } = (await c.req.json()) as { lines: LogLine[] }; | |
| 31 | + await repoStub(c.env, repoId).runnerLogs(jobId, token, lines ?? []); | |
| 32 | + return c.json({ ok: true }); | |
| 33 | +}); | |
| 34 | + | |
| 35 | +runner.post("/runner/jobs/:jobId/steps/:index", async (c) => { | |
| 36 | + const jobId = c.req.param("jobId"); | |
| 37 | + const { token, repoId } = jobAuth(c.req.raw, jobId); | |
| 38 | + await repoStub(c.env, repoId).runnerStep(jobId, token, Number(c.req.param("index")), (await c.req.json()) as StepReport); | |
| 39 | + return c.json({ ok: true }); | |
| 40 | +}); | |
| 41 | + | |
| 42 | +runner.post("/runner/jobs/:jobId/complete", async (c) => { | |
| 43 | + const jobId = c.req.param("jobId"); | |
| 44 | + const { token, repoId } = jobAuth(c.req.raw, jobId); | |
| 45 | + await repoStub(c.env, repoId).runnerComplete(jobId, token, (await c.req.json()) as JobCompletion); | |
| 46 | + return c.json({ ok: true }); | |
| 47 | +}); | |
| 48 | + | |
| 49 | +// ---- cache (actions/cache compatible semantics, backed by R2) | |
| 50 | + | |
| 51 | +runner.get("/runner/jobs/:jobId/cache", async (c) => { | |
| 52 | + const jobId = c.req.param("jobId"); | |
| 53 | + const { token, repoId } = jobAuth(c.req.raw, jobId); | |
| 54 | + const key = c.req.query("key") ?? ""; | |
| 55 | + const restore = (c.req.query("restore") ?? "").split(",").map((s) => s.trim()).filter(Boolean); | |
| 56 | + const hit = await repoStub(c.env, repoId).cacheLookup(jobId, token, key, restore); | |
| 57 | + if (!hit) return c.json({ message: "cache miss" }, 404); | |
| 58 | + return c.json({ key: hit.key, url: `${new URL(c.req.url).origin}/runner/jobs/${jobId}/cache/blob/${encodeURIComponent(hit.key)}` }); | |
| 59 | +}); | |
| 60 | + | |
| 61 | +runner.get("/runner/jobs/:jobId/cache/blob/:key", async (c) => { | |
| 62 | + const jobId = c.req.param("jobId"); | |
| 63 | + const { repoId } = jobAuth(c.req.raw, jobId); | |
| 64 | + const r2Key = await repoStub(c.env, repoId).cacheKeyFor(c.req.param("key")); | |
| 65 | + const obj = await c.env.BUCKET.get(r2Key); | |
| 66 | + if (!obj) throw new HttpError(404, "cache entry missing"); | |
| 67 | + return new Response(obj.body, { headers: { "content-type": "application/gzip", "content-length": String(obj.size) } }); | |
| 68 | +}); | |
| 69 | + | |
| 70 | +runner.put("/runner/jobs/:jobId/cache/:key", async (c) => { | |
| 71 | + const jobId = c.req.param("jobId"); | |
| 72 | + const { token, repoId } = jobAuth(c.req.raw, jobId); | |
| 73 | + const key = c.req.param("key"); | |
| 74 | + if (key.length > 512) throw new HttpError(422, "cache key too long"); | |
| 75 | + const stub = repoStub(c.env, repoId); | |
| 76 | + const r2Key = await stub.cacheKeyFor(key); | |
| 77 | + if (await c.env.BUCKET.head(r2Key)) return c.json({ message: "cache entry already exists" }, 409); | |
| 78 | + const put = await c.env.BUCKET.put(r2Key, c.req.raw.body!, { httpMetadata: { contentType: "application/gzip" } }); | |
| 79 | + const evicted = await stub.cacheSaved(jobId, token, key, put?.size ?? 0); | |
| 80 | + if (evicted.length) await c.env.BUCKET.delete(evicted); | |
| 81 | + return c.json({ ok: true }); | |
| 82 | +}); | |
| 83 | + | |
| 84 | +// ---- artifacts | |
| 85 | + | |
| 86 | +runner.put("/runner/jobs/:jobId/artifacts/:name", async (c) => { | |
| 87 | + const jobId = c.req.param("jobId"); | |
| 88 | + const { token, repoId } = jobAuth(c.req.raw, jobId); | |
| 89 | + const stub = repoStub(c.env, repoId); | |
| 90 | + const r2Key = await stub.artifactKeyFor(jobId, c.req.param("name")); | |
| 91 | + const put = await c.env.BUCKET.put(r2Key, c.req.raw.body!, { httpMetadata: { contentType: "application/gzip" } }); | |
| 92 | + await stub.artifactSaved(jobId, token, c.req.param("name"), put?.size ?? 0); | |
| 93 | + return c.json({ ok: true }); | |
| 94 | +}); | |
| 95 | + | |
| 96 | +runner.get("/runner/jobs/:jobId/artifacts/:name", async (c) => { | |
| 97 | + const jobId = c.req.param("jobId"); | |
| 98 | + const { repoId } = jobAuth(c.req.raw, jobId); | |
| 99 | + const obj = await c.env.BUCKET.get(await repoStub(c.env, repoId).artifactKeyFor(jobId, c.req.param("name"))); | |
| 100 | + if (!obj) throw new HttpError(404, "artifact not found"); | |
| 101 | + return new Response(obj.body, { headers: { "content-type": "application/gzip" } }); | |
| 102 | +}); | |
| 103 | + | |
| 104 | +// ---- OIDC id tokens (actions/core getIDToken compatible: GET ?audience= → { value }) | |
| 105 | + | |
| 106 | +const oidc = async (c: AppContext) => { | |
| 107 | + const jobId = c.req.param("jobId")!; | |
| 108 | + const { token, repoId } = jobAuth(c.req.raw, jobId); | |
| 109 | + if (!c.env.OIDC_PRIVATE_KEY) throw new HttpError(501, "OIDC is not configured on this Diffinery instance"); | |
| 110 | + const claims = await repoStub(c.env, repoId).oidcClaims(jobId, token); | |
| 111 | + if (!claims.allowed) throw new HttpError(403, "this job lacks permissions: id-token: write"); | |
| 112 | + const g = claims.github; | |
| 113 | + const nowS = Math.floor(Date.now() / 1000); | |
| 114 | + const value = await signEs256(JSON.parse(c.env.OIDC_PRIVATE_KEY), { | |
| 115 | + iss: c.env.PUBLIC_URL, | |
| 116 | + aud: c.req.query("audience") ?? c.env.PUBLIC_URL, | |
| 117 | + sub: `repo:${g.repository}:ref:${g.ref}`, | |
| 118 | + iat: nowS, | |
| 119 | + nbf: nowS, | |
| 120 | + exp: nowS + 600, | |
| 121 | + repository: g.repository, | |
| 122 | + repository_owner: g.repository_owner, | |
| 123 | + ref: g.ref, | |
| 124 | + sha: g.sha, | |
| 125 | + workflow: g.workflow, | |
| 126 | + event_name: g.event_name, | |
| 127 | + run_id: g.run_id, | |
| 128 | + actor: g.actor, | |
| 129 | + job_workflow_ref: g.workflow_ref, | |
| 130 | + }); | |
| 131 | + return c.json({ value }); | |
| 132 | +}; | |
| 133 | +runner.get("/runner/jobs/:jobId/oidc", oidc); | |
| 134 | +runner.post("/runner/jobs/:jobId/oidc", oidc); |
addedapps/diffinery/src/index.ts+209−0View file
| @@ -1,0 +1,209 @@ | ||
| 1 | +import { Hono } from "hono"; | |
| 2 | +import type { DiffineryEvent } from "@diffinery/shared"; | |
| 3 | +import { cronMatches } from "@diffinery/workflow-yaml"; | |
| 4 | +import type { Env } from "./env.js"; | |
| 5 | +import { resolvePrincipal, type AppVars } from "./auth/principal.js"; | |
| 6 | +import { Directory } from "./lib/directory.js"; | |
| 7 | +import { hmacSha256Hex, newId } from "./lib/util.js"; | |
| 8 | +import { errorResponse, loadRepo, repoStub } from "./http/common.js"; | |
| 9 | +import { git } from "./http/git.js"; | |
| 10 | +import { runner } from "./http/runner.js"; | |
| 11 | +import { api, wellKnown } from "./http/api.js"; | |
| 12 | +import { mcp } from "./mcp/server.js"; | |
| 13 | +import { web } from "./web/routes.js"; | |
| 14 | + | |
| 15 | +export { RepoDO } from "./repo/RepoDO.js"; | |
| 16 | +export { RunnerContainer, SandboxContainer } from "./runner/containers.js"; | |
| 17 | +export { RunWorkflow } from "./runner/workflow.js"; | |
| 18 | + | |
| 19 | +const app = new Hono<{ Bindings: Env; Variables: AppVars }>(); | |
| 20 | + | |
| 21 | +app.onError((e) => errorResponse(e)); | |
| 22 | + | |
| 23 | +// Security headers on every response, then identity: tokens, Access JWT, or anonymous. | |
| 24 | +app.use("*", async (c, next) => { | |
| 25 | + const dir = new Directory(c.env); | |
| 26 | + c.set("dir", dir); | |
| 27 | + c.set("principal", await resolvePrincipal(c.req.raw, c.env, dir)); | |
| 28 | + await next(); | |
| 29 | + if (c.res.status === 101) return; | |
| 30 | + const h = c.res.headers; | |
| 31 | + h.set("strict-transport-security", "max-age=63072000; includeSubDomains; preload"); | |
| 32 | + h.set("x-content-type-options", "nosniff"); | |
| 33 | + h.set("referrer-policy", "strict-origin-when-cross-origin"); | |
| 34 | + if (!h.has("x-frame-options")) h.set("x-frame-options", "DENY"); | |
| 35 | + h.set("permissions-policy", "camera=(), microphone=(), geolocation=()"); | |
| 36 | +}); | |
| 37 | + | |
| 38 | +app.get("/healthz", (c) => c.json({ ok: true })); | |
| 39 | + | |
| 40 | +app.route("/", wellKnown); | |
| 41 | +app.route("/", git); | |
| 42 | +app.route("/", runner); | |
| 43 | +app.route("/", api); | |
| 44 | +app.route("/", mcp); | |
| 45 | + | |
| 46 | +// Live updates: authorise in the Worker, then hand the WebSocket to the repository's Durable Object. | |
| 47 | +app.get("/:owner/:repo/live", async (c) => { | |
| 48 | + if (c.req.header("upgrade") !== "websocket") return c.text("expected websocket", 426); | |
| 49 | + const { stub } = await loadRepo(c, c.req.param("owner"), c.req.param("repo")); | |
| 50 | + return stub.fetch(c.req.raw); | |
| 51 | +}); | |
| 52 | + | |
| 53 | +app.get("/:owner/:repo/actions/runs/:runId/artifacts/:name", async (c) => { | |
| 54 | + const { stub } = await loadRepo(c, c.req.param("owner"), c.req.param("repo")); | |
| 55 | + const name = c.req.param("name"); | |
| 56 | + if (!(await stub.artifacts(c.req.param("runId"))).some((a) => a.name === name)) return c.json({ message: "artifact not found" }, 404); | |
| 57 | + const obj = await c.env.BUCKET.get(await stub.artifactKeyForRun(c.req.param("runId"), name)); | |
| 58 | + if (!obj) return c.json({ message: "artifact expired" }, 410); | |
| 59 | + return new Response(obj.body, { | |
| 60 | + headers: { | |
| 61 | + "content-type": "application/gzip", | |
| 62 | + "content-length": String(obj.size), | |
| 63 | + "content-disposition": `attachment; filename="${name.replace(/[^\w.-]+/g, "_")}.tar.gz"`, | |
| 64 | + }, | |
| 65 | + }); | |
| 66 | +}); | |
| 67 | + | |
| 68 | +app.route("/", web); | |
| 69 | + | |
| 70 | +// ---------------------------------------------------------------- events queue | |
| 71 | + | |
| 72 | +const GITHUB_EVENT: Record<DiffineryEvent["type"], string> = { | |
| 73 | + push: "push", | |
| 74 | + pull_request: "pull_request", | |
| 75 | + issues: "issues", | |
| 76 | + issue_comment: "issue_comment", | |
| 77 | + pull_request_review: "pull_request_review", | |
| 78 | + workflow_run: "workflow_run", | |
| 79 | + workflow_dispatch: "workflow_dispatch", | |
| 80 | + audit: "audit", | |
| 81 | +}; | |
| 82 | + | |
| 83 | +function webhookPayload(env: Env, ev: Exclude<DiffineryEvent, { type: "audit" }>) { | |
| 84 | + const repository = { id: ev.repoId, name: ev.repo, full_name: `${ev.owner}/${ev.repo}`, owner: { login: ev.owner }, html_url: `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}`, clone_url: `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}.git` }; | |
| 85 | + const sender = { id: ev.actor.id, login: ev.actor.login, type: ev.actor.kind === "agent" ? "Bot" : ev.actor.kind === "system" ? "Bot" : "User", on_behalf_of: ev.actor.onBehalfOf }; | |
| 86 | + const { type: _t, repoId: _r, owner: _o, repo: _n, actor: _a, ...rest } = ev; | |
| 87 | + const action = "action" in ev ? (ev.action === "merged" ? "closed" : ev.action) : undefined; | |
| 88 | + const extra: Record<string, unknown> = {}; | |
| 89 | + if (ev.type === "pull_request") { | |
| 90 | + extra.pull_request = { number: ev.number, html_url: `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}/pull/${ev.number}`, head: { ref: ev.headRef, sha: ev.headSha }, base: { ref: ev.baseRef }, merged: ev.action === "merged" }; | |
| 91 | + } | |
| 92 | + if (ev.type === "issues" || ev.type === "issue_comment") extra.issue = { number: ev.number, html_url: `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}/issues/${ev.number}` }; | |
| 93 | + return { ...rest, action, repository, sender, ...extra }; | |
| 94 | +} | |
| 95 | + | |
| 96 | +async function deliverWebhooks(env: Env, ev: Exclude<DiffineryEvent, { type: "audit" }>): Promise<void> { | |
| 97 | + const stub = repoStub(env, ev.repoId); | |
| 98 | + const hooks = (await stub.webhooks()).filter((h) => h.active && (h.events.includes("*") || h.events.includes(GITHUB_EVENT[ev.type]))); | |
| 99 | + if (!hooks.length) return; | |
| 100 | + const body = JSON.stringify(webhookPayload(env, ev)); | |
| 101 | + await Promise.all( | |
| 102 | + hooks.map(async (h) => { | |
| 103 | + const delivery = newId(); | |
| 104 | + let status = 0; | |
| 105 | + try { | |
| 106 | + const res = await fetch(h.url, { | |
| 107 | + method: "POST", | |
| 108 | + headers: { | |
| 109 | + "content-type": "application/json", | |
| 110 | + "user-agent": "Diffinery-Hookshot/1", | |
| 111 | + "x-github-event": GITHUB_EVENT[ev.type], | |
| 112 | + "x-github-delivery": delivery, | |
| 113 | + "x-diffinery-event": ev.type, | |
| 114 | + "x-hub-signature-256": `sha256=${await hmacSha256Hex(h.secret, body)}`, | |
| 115 | + }, | |
| 116 | + body, | |
| 117 | + redirect: "manual", | |
| 118 | + signal: AbortSignal.timeout(10_000), | |
| 119 | + }); | |
| 120 | + status = res.status; | |
| 121 | + } catch { | |
| 122 | + status = 0; | |
| 123 | + } | |
| 124 | + await stub.recordDelivery(h.id, status); | |
| 125 | + }), | |
| 126 | + ); | |
| 127 | +} | |
| 128 | + | |
| 129 | +/** People who should hear about an event: participants, assignees and requested reviewers, minus the actor. */ | |
| 130 | +async function notifyParticipants(env: Env, dir: Directory, ev: DiffineryEvent): Promise<void> { | |
| 131 | + if (ev.type === "workflow_run") { | |
| 132 | + if (ev.action !== "completed" || ev.conclusion !== "failure" || ev.actor.kind === "system") return; | |
| 133 | + await dir.notify([ev.actor.id], { repoId: ev.repoId, kind: "run_failed", title: `Workflow run failed in ${ev.owner}/${ev.repo}`, url: `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}/actions/runs/${ev.runId}` }); | |
| 134 | + return; | |
| 135 | + } | |
| 136 | + if (ev.type !== "pull_request" && ev.type !== "issues" && ev.type !== "issue_comment" && ev.type !== "pull_request_review") return; | |
| 137 | + if (ev.type === "pull_request" && ev.action === "synchronize") return; | |
| 138 | + const { issue, comments, reviews } = await repoStub(env, ev.repoId).issue(ev.number); | |
| 139 | + const ids = new Set<string>([issue.author.id, ...comments.map((c) => c.author.id), ...reviews.map((r) => r.author.id)]); | |
| 140 | + for (const login of [...issue.assignees, ...(issue.pull?.requestedReviewers ?? [])]) { | |
| 141 | + const a = await dir.accountByLogin(login); | |
| 142 | + if (a) ids.add(a.id); | |
| 143 | + } | |
| 144 | + ids.delete(ev.actor.id); | |
| 145 | + if (!ids.size) return; | |
| 146 | + const kind = issue.kind === "pull" ? "pull" : "issues"; | |
| 147 | + const verb = ev.type === "issue_comment" ? "commented on" : ev.type === "pull_request_review" ? `reviewed (${ev.state.toLowerCase()})` : ev.action; | |
| 148 | + await dir.notify([...ids].slice(0, 100), { | |
| 149 | + repoId: ev.repoId, | |
| 150 | + kind: ev.type, | |
| 151 | + title: `${ev.actor.login} ${verb} ${ev.owner}/${ev.repo}#${ev.number}: ${issue.title}`, | |
| 152 | + url: `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}/${kind}/${ev.number}`, | |
| 153 | + }); | |
| 154 | +} | |
| 155 | + | |
| 156 | +async function handleEvent(env: Env, dir: Directory, ev: DiffineryEvent): Promise<void> { | |
| 157 | + if (ev.type === "audit") { | |
| 158 | + await dir.audit({ actorId: ev.actor.id, actorLogin: ev.actor.login, onBehalfOf: ev.actor.onBehalfOf, action: ev.action, target: ev.target, detail: ev.detail, at: ev.at }); | |
| 159 | + return; | |
| 160 | + } | |
| 161 | + if (ev.type === "push") { | |
| 162 | + await dir.audit({ actorId: ev.actor.id, actorLogin: ev.actor.login, onBehalfOf: ev.actor.onBehalfOf, action: ev.forced ? "git.force_push" : ev.deleted ? "git.delete_ref" : "git.push", target: `${ev.owner}/${ev.repo}:${ev.ref}`, detail: { before: ev.before, after: ev.after }, at: ev.at }); | |
| 163 | + } | |
| 164 | + if (ev.type === "pull_request" && ev.action === "merged") { | |
| 165 | + await dir.audit({ actorId: ev.actor.id, actorLogin: ev.actor.login, onBehalfOf: ev.actor.onBehalfOf, action: "pull.merge", target: `${ev.owner}/${ev.repo}#${ev.number}`, at: ev.at }); | |
| 166 | + } | |
| 167 | + await Promise.all([deliverWebhooks(env, ev), notifyParticipants(env, dir, ev)]); | |
| 168 | +} | |
| 169 | + | |
| 170 | +async function queue(batch: MessageBatch<DiffineryEvent>, env: Env): Promise<void> { | |
| 171 | + const dir = new Directory(env); | |
| 172 | + // Keep an append-only copy of every event in R2 for long-term audit retention. | |
| 173 | + const day = new Date().toISOString().slice(0, 10).replaceAll("-", "/"); | |
| 174 | + await env.BUCKET.put(`events/${day}/${Date.now()}-${newId()}.jsonl`, batch.messages.map((m) => JSON.stringify(m.body)).join("\n")).catch((e) => console.error("event archive failed", e)); | |
| 175 | + for (const msg of batch.messages) { | |
| 176 | + try { | |
| 177 | + await handleEvent(env, dir, msg.body); | |
| 178 | + msg.ack(); | |
| 179 | + } catch (e) { | |
| 180 | + console.error("event handling failed", msg.body.type, e); | |
| 181 | + msg.retry({ delaySeconds: Math.min(300, 10 * 2 ** msg.attempts) }); | |
| 182 | + } | |
| 183 | + } | |
| 184 | +} | |
| 185 | + | |
| 186 | +// ---------------------------------------------------------------- cron: workflow schedules | |
| 187 | + | |
| 188 | +async function scheduled(controller: ScheduledController, env: Env): Promise<void> { | |
| 189 | + // The trigger runs every 5 minutes (GitHub's minimum schedule interval); fire anything due in the window. | |
| 190 | + const end = new Date(controller.scheduledTime); | |
| 191 | + end.setUTCSeconds(0, 0); | |
| 192 | + const minutes = Array.from({ length: 5 }, (_, i) => new Date(end.getTime() - i * 60_000)); | |
| 193 | + const { results } = await env.DB.prepare("SELECT s.repo_id, s.workflow_path, s.cron FROM schedules s JOIN repos r ON r.id = s.repo_id WHERE r.archived = 0").all<{ repo_id: string; workflow_path: string; cron: string }>(); | |
| 194 | + await Promise.all( | |
| 195 | + results.map(async (s) => { | |
| 196 | + try { | |
| 197 | + if (minutes.some((m) => cronMatches(s.cron, m))) await repoStub(env, s.repo_id).scheduleTick(s.workflow_path, s.cron); | |
| 198 | + } catch (e) { | |
| 199 | + console.error("schedule failed", s.repo_id, s.workflow_path, e); | |
| 200 | + } | |
| 201 | + }), | |
| 202 | + ); | |
| 203 | +} | |
| 204 | + | |
| 205 | +export default { | |
| 206 | + fetch: app.fetch, | |
| 207 | + queue, | |
| 208 | + scheduled, | |
| 209 | +} satisfies ExportedHandler<Env, DiffineryEvent>; |
modifiedapps/diffinery/src/lib/directory.ts+4−4View file
| @@ -195,7 +195,7 @@ | ||
| 195 | 195 | |
| 196 | 196 | async tokens(accountId: string): Promise<Array<{ id: string; name: string; prefix: string; scopes: string[]; repoIds: string[] | null; expiresAt: string; lastUsedAt: string | null; createdAt: string }>> { |
| 197 | 197 | const r = await this.db.prepare("SELECT * FROM tokens WHERE account_id = ? AND revoked_at IS NULL ORDER BY created_at DESC").bind(accountId).all<Record<string, string | null>>(); |
| 198 | - return r.results.map((t) => ({ id: t.id!, name: t.name!, prefix: t.prefix!, scopes: JSON.parse(t.scopes!), repoIds: t.repo_ids ? JSON.parse(t.repo_ids) : null, expiresAt: t.expires_at!, lastUsedAt: t.last_used_at, createdAt: t.created_at! })); | |
| 198 | + return r.results.map((t) => ({ id: t.id!, name: t.name!, prefix: t.prefix!, scopes: JSON.parse(t.scopes!), repoIds: t.repo_ids ? JSON.parse(t.repo_ids) : null, expiresAt: t.expires_at!, lastUsedAt: t.last_used_at ?? null, createdAt: t.created_at! })); | |
| 199 | 199 | } |
| 200 | 200 | |
| 201 | 201 | async revokeToken(accountId: string, id: string): Promise<void> { |
| @@ -230,7 +230,7 @@ | ||
| 230 | 230 | |
| 231 | 231 | async invites(filter: { orgId?: string }): Promise<Array<{ id: string; email: string; role: string; createdAt: string; expiresAt: string; acceptedAt: string | null }>> { |
| 232 | 232 | const r = await this.db.prepare("SELECT * FROM invites WHERE (? IS NULL OR org_id = ?) ORDER BY created_at DESC LIMIT 200").bind(filter.orgId ?? null, filter.orgId ?? null).all<Record<string, string | null>>(); |
| 233 | - return r.results.map((i) => ({ id: i.id!, email: i.email!, role: i.role!, createdAt: i.created_at!, expiresAt: i.expires_at!, acceptedAt: i.accepted_at })); | |
| 233 | + return r.results.map((i) => ({ id: i.id!, email: i.email!, role: i.role!, createdAt: i.created_at!, expiresAt: i.expires_at!, acceptedAt: i.accepted_at ?? null })); | |
| 234 | 234 | } |
| 235 | 235 | |
| 236 | 236 | async acceptInvite(token: string, user: Account): Promise<{ orgId: string | null; repoId: string | null }> { |
| @@ -238,7 +238,7 @@ | ||
| 238 | 238 | if (!inv || inv.accepted_at || inv.expires_at! < now()) throw new HttpError(404, "this invite is invalid or has expired"); |
| 239 | 239 | if (inv.email && user.email && inv.email.toLowerCase() !== user.email.toLowerCase()) throw new HttpError(403, `this invite was sent to ${inv.email}; sign in with that address`); |
| 240 | 240 | await this.applyInvite(inv, user); |
| 241 | - return { orgId: inv.org_id, repoId: inv.repo_id }; | |
| 241 | + return { orgId: inv.org_id ?? null, repoId: inv.repo_id ?? null }; | |
| 242 | 242 | } |
| 243 | 243 | |
| 244 | 244 | private async acceptInvitesForEmail(userId: string, email: string): Promise<void> { |
| @@ -383,7 +383,7 @@ | ||
| 383 | 383 | |
| 384 | 384 | async notifications(accountId: string, unreadOnly = false): Promise<Array<{ id: string; kind: string; title: string; url: string; createdAt: string; readAt: string | null }>> { |
| 385 | 385 | const r = await this.db.prepare(`SELECT * FROM notifications WHERE account_id = ? ${unreadOnly ? "AND read_at IS NULL" : ""} ORDER BY created_at DESC LIMIT 100`).bind(accountId).all<Record<string, string | null>>(); |
| 386 | - return r.results.map((n) => ({ id: n.id!, kind: n.kind!, title: n.title!, url: n.url!, createdAt: n.created_at!, readAt: n.read_at })); | |
| 386 | + return r.results.map((n) => ({ id: n.id!, kind: n.kind!, title: n.title!, url: n.url!, createdAt: n.created_at!, readAt: n.read_at ?? null })); | |
| 387 | 387 | } |
| 388 | 388 | |
| 389 | 389 | async markRead(accountId: string, id?: string): Promise<void> { |
addedapps/diffinery/src/mcp/server.ts+457−0View file
| @@ -1,0 +1,457 @@ | ||
| 1 | +import { Hono } from "hono"; | |
| 2 | +import type { Env } from "../env.js"; | |
| 3 | +import { requireAccount, type AppContext, type AppVars } from "../auth/principal.js"; | |
| 4 | +import { HttpError, b64, fromB64 } from "../lib/util.js"; | |
| 5 | +import type { Role } from "../repo/types.js"; | |
| 6 | +import { loadRepo } from "../http/common.js"; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * Model Context Protocol server (Streamable HTTP transport, JSON responses). | |
| 10 | + * Agents connect with a Diffinery token: `Authorization: Bearer dfy_…`. | |
| 11 | + * Every tool runs through the same permission checks, branch protection and | |
| 12 | + * agent guardrails as the REST API and git, so an agent can never do more | |
| 13 | + * than its token, its owner and the repository's rules allow. | |
| 14 | + */ | |
| 15 | +export const mcp = new Hono<{ Bindings: Env; Variables: AppVars }>(); | |
| 16 | + | |
| 17 | +const PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"]; | |
| 18 | + | |
| 19 | +type Args = Record<string, unknown>; | |
| 20 | +interface Tool { | |
| 21 | + description: string; | |
| 22 | + input: Record<string, { type: string; description?: string; items?: unknown; enum?: string[] }>; | |
| 23 | + required?: string[]; | |
| 24 | + readOnly?: boolean; | |
| 25 | + run: (c: AppContext, a: Args) => Promise<unknown>; | |
| 26 | +} | |
| 27 | + | |
| 28 | +const str = (a: Args, k: string, def?: string): string => { | |
| 29 | + const v = a[k]; | |
| 30 | + if (v === undefined || v === null || v === "") { | |
| 31 | + if (def !== undefined) return def; | |
| 32 | + throw new HttpError(422, `${k} is required`); | |
| 33 | + } | |
| 34 | + if (typeof v !== "string") throw new HttpError(422, `${k} must be a string`); | |
| 35 | + return v; | |
| 36 | +}; | |
| 37 | +const optStr = (a: Args, k: string) => (typeof a[k] === "string" && a[k] !== "" ? (a[k] as string) : undefined); | |
| 38 | +const int = (a: Args, k: string) => { | |
| 39 | + const n = Number(a[k]); | |
| 40 | + if (!Number.isInteger(n) || n < 1) throw new HttpError(422, `${k} must be a positive integer`); | |
| 41 | + return n; | |
| 42 | +}; | |
| 43 | +const repoOf = (c: AppContext, a: Args, min: Role = "read") => loadRepo(c, str(a, "owner"), str(a, "repo"), min); | |
| 44 | +const OWNER_REPO = { | |
| 45 | + owner: { type: "string", description: "Repository owner (user or org login)" }, | |
| 46 | + repo: { type: "string", description: "Repository name" }, | |
| 47 | +}; | |
| 48 | +const decoder = new TextDecoder(); | |
| 49 | + | |
| 50 | +const TOOLS: Record<string, Tool> = { | |
| 51 | + whoami: { | |
| 52 | + description: "Show the account this token acts as, including the human an agent works on behalf of.", | |
| 53 | + input: {}, | |
| 54 | + readOnly: true, | |
| 55 | + run: async (c) => { | |
| 56 | + const a = requireAccount(c); | |
| 57 | + const owner = a.owner_id ? await c.get("dir").account(a.owner_id) : null; | |
| 58 | + return { login: a.login, kind: a.kind, on_behalf_of: owner?.login ?? null }; | |
| 59 | + }, | |
| 60 | + }, | |
| 61 | + list_repos: { | |
| 62 | + description: "List repositories visible to you, optionally filtered by owner.", | |
| 63 | + input: { owner: { type: "string" } }, | |
| 64 | + readOnly: true, | |
| 65 | + run: async (c, a) => { | |
| 66 | + const me = requireAccount(c); | |
| 67 | + const dir = c.get("dir"); | |
| 68 | + const owner = optStr(a, "owner") ? await dir.accountByLogin(optStr(a, "owner")!) : null; | |
| 69 | + const repos = await dir.reposVisibleTo(me, owner?.id); | |
| 70 | + return repos.map((r) => ({ full_name: `${r.owner_login}/${r.name}`, description: r.description, default_branch: r.default_branch, visibility: r.visibility, pushed_at: r.pushed_at })); | |
| 71 | + }, | |
| 72 | + }, | |
| 73 | + list_tree: { | |
| 74 | + description: "List files and directories at a path in a repository.", | |
| 75 | + input: { ...OWNER_REPO, ref: { type: "string", description: "Branch, tag or commit (default: default branch)" }, path: { type: "string" } }, | |
| 76 | + required: ["owner", "repo"], | |
| 77 | + readOnly: true, | |
| 78 | + run: async (c, a) => { | |
| 79 | + const { stub } = await repoOf(c, a); | |
| 80 | + const t = await stub.tree(optStr(a, "ref") ?? "HEAD", optStr(a, "path") ?? ""); | |
| 81 | + if (!t) throw new HttpError(404, "path not found"); | |
| 82 | + return { sha: t.sha, items: t.items.map((i) => ({ path: i.path, type: i.type })) }; | |
| 83 | + }, | |
| 84 | + }, | |
| 85 | + get_file: { | |
| 86 | + description: "Read a file's contents.", | |
| 87 | + input: { ...OWNER_REPO, path: { type: "string" }, ref: { type: "string" } }, | |
| 88 | + required: ["owner", "repo", "path"], | |
| 89 | + readOnly: true, | |
| 90 | + run: async (c, a) => { | |
| 91 | + const { stub } = await repoOf(c, a); | |
| 92 | + const b = await stub.blob(optStr(a, "ref") ?? "HEAD", str(a, "path")); | |
| 93 | + if (!b) throw new HttpError(404, "file not found"); | |
| 94 | + if (b.binary) return { path: str(a, "path"), commit: b.sha, size: b.size, binary: true }; | |
| 95 | + return { path: str(a, "path"), commit: b.sha, size: b.size, content: decoder.decode(b.content) }; | |
| 96 | + }, | |
| 97 | + }, | |
| 98 | + list_branches: { | |
| 99 | + description: "List branches, marking the default and protected ones.", | |
| 100 | + input: { ...OWNER_REPO }, | |
| 101 | + required: ["owner", "repo"], | |
| 102 | + readOnly: true, | |
| 103 | + run: async (c, a) => (await repoOf(c, a)).stub.branches(), | |
| 104 | + }, | |
| 105 | + create_branch: { | |
| 106 | + description: "Create a branch from another branch, tag or commit.", | |
| 107 | + input: { ...OWNER_REPO, branch: { type: "string" }, from: { type: "string", description: "Default: the default branch" } }, | |
| 108 | + required: ["owner", "repo", "branch"], | |
| 109 | + run: async (c, a) => { | |
| 110 | + const { stub, caller } = await repoOf(c, a, "write"); | |
| 111 | + const sha = await stub.resolve(optStr(a, "from") ?? "HEAD"); | |
| 112 | + if (!sha) throw new HttpError(404, "source ref not found"); | |
| 113 | + await stub.createRef(`refs/heads/${str(a, "branch")}`, sha, caller); | |
| 114 | + return { branch: str(a, "branch"), sha }; | |
| 115 | + }, | |
| 116 | + }, | |
| 117 | + commit_files: { | |
| 118 | + description: | |
| 119 | + "Create one commit that writes and deletes files on a branch, without a git client. Pass expected_head_sha to avoid racing other writers; pass create_branch_from to start a new branch.", | |
| 120 | + input: { | |
| 121 | + ...OWNER_REPO, | |
| 122 | + branch: { type: "string" }, | |
| 123 | + message: { type: "string" }, | |
| 124 | + files: { type: "array", description: "[{ path, content, encoding?: 'utf-8'|'base64', executable?: boolean }]", items: { type: "object" } }, | |
| 125 | + deletes: { type: "array", items: { type: "string" } }, | |
| 126 | + expected_head_sha: { type: "string" }, | |
| 127 | + create_branch_from: { type: "string" }, | |
| 128 | + }, | |
| 129 | + required: ["owner", "repo", "branch", "message"], | |
| 130 | + run: async (c, a) => { | |
| 131 | + const { stub, caller } = await repoOf(c, a, "write"); | |
| 132 | + const files = Array.isArray(a.files) ? (a.files as Array<{ path: string; content?: string; encoding?: string; executable?: boolean }>) : []; | |
| 133 | + const deletes = Array.isArray(a.deletes) ? (a.deletes as string[]) : []; | |
| 134 | + if (!files.length && !deletes.length) throw new HttpError(422, "nothing to commit"); | |
| 135 | + const changes = [ | |
| 136 | + ...files.map((f) => ({ | |
| 137 | + path: String(f.path), | |
| 138 | + mode: f.executable ? ("100755" as const) : ("100644" as const), | |
| 139 | + content: f.encoding === "base64" ? fromB64(f.content ?? "") : new TextEncoder().encode(f.content ?? ""), | |
| 140 | + })), | |
| 141 | + ...deletes.map((p) => ({ path: String(p), delete: true })), | |
| 142 | + ]; | |
| 143 | + return stub.commitFiles({ branch: str(a, "branch"), message: str(a, "message"), changes, expectedHeadSha: optStr(a, "expected_head_sha"), createBranchFrom: optStr(a, "create_branch_from") }, caller); | |
| 144 | + }, | |
| 145 | + }, | |
| 146 | + compare: { | |
| 147 | + description: "Compare two refs: commits ahead/behind and a unified diff.", | |
| 148 | + input: { ...OWNER_REPO, base: { type: "string" }, head: { type: "string" } }, | |
| 149 | + required: ["owner", "repo", "base", "head"], | |
| 150 | + readOnly: true, | |
| 151 | + run: async (c, a) => (await repoOf(c, a)).stub.compare(str(a, "base"), str(a, "head")), | |
| 152 | + }, | |
| 153 | + list_pull_requests: { | |
| 154 | + description: "List pull requests.", | |
| 155 | + input: { ...OWNER_REPO, state: { type: "string", enum: ["open", "closed", "all"] } }, | |
| 156 | + required: ["owner", "repo"], | |
| 157 | + readOnly: true, | |
| 158 | + run: async (c, a) => { | |
| 159 | + const { stub } = await repoOf(c, a); | |
| 160 | + const { items, total } = await stub.issues({ kind: "pull", state: (optStr(a, "state") as "open") ?? "open", limit: 100 }); | |
| 161 | + return { total, items: items.map((i) => ({ number: i.number, title: i.title, state: i.state, author: i.author, head: i.pull?.head, base: i.pull?.base, draft: i.pull?.draft, merged: !!i.pull?.mergedAt })) }; | |
| 162 | + }, | |
| 163 | + }, | |
| 164 | + get_pull_request: { | |
| 165 | + description: "Get a pull request with its conversation, reviews, mergeability and (optionally) the full diff.", | |
| 166 | + input: { ...OWNER_REPO, number: { type: "integer" }, include_diff: { type: "boolean" } }, | |
| 167 | + required: ["owner", "repo", "number"], | |
| 168 | + readOnly: true, | |
| 169 | + run: async (c, a) => { | |
| 170 | + const { stub, caller } = await repoOf(c, a); | |
| 171 | + const n = int(a, "number"); | |
| 172 | + const [detail, mergeability] = await Promise.all([stub.issue(n), stub.mergeability(n, caller).catch(() => null)]); | |
| 173 | + const files = a.include_diff ? await stub.pullFiles(n) : undefined; | |
| 174 | + return { ...detail, mergeability, files }; | |
| 175 | + }, | |
| 176 | + }, | |
| 177 | + create_pull_request: { | |
| 178 | + description: "Open a pull request. Use `intent` to explain the goal for reviewers (agents should always set it).", | |
| 179 | + input: { ...OWNER_REPO, title: { type: "string" }, body: { type: "string" }, head: { type: "string" }, base: { type: "string" }, draft: { type: "boolean" }, intent: { type: "string" }, reviewers: { type: "array", items: { type: "string" } } }, | |
| 180 | + required: ["owner", "repo", "title", "head"], | |
| 181 | + run: async (c, a) => { | |
| 182 | + const { stub, caller } = await repoOf(c, a, "write"); | |
| 183 | + return stub.createPull( | |
| 184 | + { title: str(a, "title"), body: optStr(a, "body"), head: str(a, "head"), base: optStr(a, "base"), draft: !!a.draft, intent: optStr(a, "intent"), reviewers: Array.isArray(a.reviewers) ? (a.reviewers as string[]) : undefined }, | |
| 185 | + caller, | |
| 186 | + ); | |
| 187 | + }, | |
| 188 | + }, | |
| 189 | + comment: { | |
| 190 | + description: "Comment on an issue or pull request.", | |
| 191 | + input: { ...OWNER_REPO, number: { type: "integer" }, body: { type: "string" } }, | |
| 192 | + required: ["owner", "repo", "number", "body"], | |
| 193 | + run: async (c, a) => { | |
| 194 | + const { stub, caller } = await repoOf(c, a, "triage"); | |
| 195 | + return stub.addComment(int(a, "number"), str(a, "body"), caller); | |
| 196 | + }, | |
| 197 | + }, | |
| 198 | + review_pull_request: { | |
| 199 | + description: "Submit a review: APPROVE, REQUEST_CHANGES or COMMENT, with optional line comments [{ path, line, side?, body }]. Agent approvals never count toward required human approvals of agent-authored PRs.", | |
| 200 | + input: { ...OWNER_REPO, number: { type: "integer" }, event: { type: "string", enum: ["APPROVE", "REQUEST_CHANGES", "COMMENT"] }, body: { type: "string" }, comments: { type: "array", items: { type: "object" } } }, | |
| 201 | + required: ["owner", "repo", "number", "event"], | |
| 202 | + run: async (c, a) => { | |
| 203 | + const { stub, caller } = await repoOf(c, a, "read"); | |
| 204 | + return stub.submitReview(int(a, "number"), { event: str(a, "event") as "COMMENT", body: optStr(a, "body"), comments: Array.isArray(a.comments) ? (a.comments as []) : undefined }, caller); | |
| 205 | + }, | |
| 206 | + }, | |
| 207 | + merge_pull_request: { | |
| 208 | + description: "Merge a pull request when it is mergeable (checks, approvals and branch protection are enforced).", | |
| 209 | + input: { ...OWNER_REPO, number: { type: "integer" }, method: { type: "string", enum: ["merge", "squash", "rebase"] }, expected_head_sha: { type: "string" }, delete_branch: { type: "boolean" } }, | |
| 210 | + required: ["owner", "repo", "number"], | |
| 211 | + run: async (c, a) => { | |
| 212 | + const { stub, caller } = await repoOf(c, a, "write"); | |
| 213 | + return stub.mergePull(int(a, "number"), { method: (optStr(a, "method") as "merge") ?? "merge", expectedHeadSha: optStr(a, "expected_head_sha"), deleteBranch: !!a.delete_branch }, caller); | |
| 214 | + }, | |
| 215 | + }, | |
| 216 | + list_issues: { | |
| 217 | + description: "List or search issues.", | |
| 218 | + input: { ...OWNER_REPO, state: { type: "string", enum: ["open", "closed", "all"] }, q: { type: "string" }, label: { type: "string" } }, | |
| 219 | + required: ["owner", "repo"], | |
| 220 | + readOnly: true, | |
| 221 | + run: async (c, a) => { | |
| 222 | + const { stub } = await repoOf(c, a); | |
| 223 | + const { items, total } = await stub.issues({ kind: "issue", state: (optStr(a, "state") as "open") ?? "open", q: optStr(a, "q"), label: optStr(a, "label"), limit: 100 }); | |
| 224 | + return { total, items: items.map((i) => ({ number: i.number, title: i.title, state: i.state, author: i.author, labels: i.labels })) }; | |
| 225 | + }, | |
| 226 | + }, | |
| 227 | + get_issue: { | |
| 228 | + description: "Get an issue with its comments.", | |
| 229 | + input: { ...OWNER_REPO, number: { type: "integer" } }, | |
| 230 | + required: ["owner", "repo", "number"], | |
| 231 | + readOnly: true, | |
| 232 | + run: async (c, a) => (await repoOf(c, a)).stub.issue(int(a, "number")), | |
| 233 | + }, | |
| 234 | + create_issue: { | |
| 235 | + description: "Open an issue.", | |
| 236 | + input: { ...OWNER_REPO, title: { type: "string" }, body: { type: "string" }, labels: { type: "array", items: { type: "string" } } }, | |
| 237 | + required: ["owner", "repo", "title"], | |
| 238 | + run: async (c, a) => { | |
| 239 | + const { stub, caller } = await repoOf(c, a, "read"); | |
| 240 | + return stub.createIssue({ title: str(a, "title"), body: optStr(a, "body"), labels: Array.isArray(a.labels) ? (a.labels as string[]) : undefined }, caller); | |
| 241 | + }, | |
| 242 | + }, | |
| 243 | + update_issue: { | |
| 244 | + description: "Update an issue or pull request: title, body, state (open/closed), labels.", | |
| 245 | + input: { ...OWNER_REPO, number: { type: "integer" }, title: { type: "string" }, body: { type: "string" }, state: { type: "string", enum: ["open", "closed"] }, labels: { type: "array", items: { type: "string" } } }, | |
| 246 | + required: ["owner", "repo", "number"], | |
| 247 | + run: async (c, a) => { | |
| 248 | + const { stub, caller } = await repoOf(c, a, "triage"); | |
| 249 | + return stub.updateIssue(int(a, "number"), { title: optStr(a, "title"), body: optStr(a, "body"), state: optStr(a, "state") as "open" | undefined, labels: Array.isArray(a.labels) ? (a.labels as string[]) : undefined }, caller); | |
| 250 | + }, | |
| 251 | + }, | |
| 252 | + list_workflow_runs: { | |
| 253 | + description: "List workflow runs, newest first. Filter by branch, commit sha, PR number or status.", | |
| 254 | + input: { ...OWNER_REPO, branch: { type: "string" }, sha: { type: "string" }, pr_number: { type: "integer" }, status: { type: "string" } }, | |
| 255 | + required: ["owner", "repo"], | |
| 256 | + readOnly: true, | |
| 257 | + run: async (c, a) => { | |
| 258 | + const { stub } = await repoOf(c, a); | |
| 259 | + return stub.runs({ branch: optStr(a, "branch"), sha: optStr(a, "sha"), status: optStr(a, "status"), prNumber: a.pr_number ? int(a, "pr_number") : undefined, limit: 30 }); | |
| 260 | + }, | |
| 261 | + }, | |
| 262 | + get_workflow_run: { | |
| 263 | + description: "Get a run with its jobs, their status, conclusion and failure reason.", | |
| 264 | + input: { ...OWNER_REPO, run_id: { type: "string" } }, | |
| 265 | + required: ["owner", "repo", "run_id"], | |
| 266 | + readOnly: true, | |
| 267 | + run: async (c, a) => (await repoOf(c, a)).stub.run(str(a, "run_id")), | |
| 268 | + }, | |
| 269 | + get_job_logs: { | |
| 270 | + description: "Fetch a job's log as plain text. Use `tail` to limit to the last N lines (default 400).", | |
| 271 | + input: { ...OWNER_REPO, job_id: { type: "string" }, tail: { type: "integer" } }, | |
| 272 | + required: ["owner", "repo", "job_id"], | |
| 273 | + readOnly: true, | |
| 274 | + run: async (c, a) => { | |
| 275 | + const { stub } = await repoOf(c, a); | |
| 276 | + const { lines, complete } = await stub.jobLogs(str(a, "job_id")); | |
| 277 | + const tail = a.tail ? int(a, "tail") : 400; | |
| 278 | + return { complete, truncated: lines.length > tail, log: lines.slice(-tail).map((l) => l.text).join("\n") }; | |
| 279 | + }, | |
| 280 | + }, | |
| 281 | + rerun_workflow: { | |
| 282 | + description: "Re-run a workflow run.", | |
| 283 | + input: { ...OWNER_REPO, run_id: { type: "string" } }, | |
| 284 | + required: ["owner", "repo", "run_id"], | |
| 285 | + run: async (c, a) => { | |
| 286 | + const { stub, caller } = await repoOf(c, a, "write"); | |
| 287 | + return stub.rerun(str(a, "run_id"), caller); | |
| 288 | + }, | |
| 289 | + }, | |
| 290 | + cancel_workflow: { | |
| 291 | + description: "Cancel a queued or running workflow run.", | |
| 292 | + input: { ...OWNER_REPO, run_id: { type: "string" } }, | |
| 293 | + required: ["owner", "repo", "run_id"], | |
| 294 | + run: async (c, a) => { | |
| 295 | + const { stub, caller } = await repoOf(c, a, "write"); | |
| 296 | + await stub.cancelRun(str(a, "run_id"), caller); | |
| 297 | + return { cancelled: true }; | |
| 298 | + }, | |
| 299 | + }, | |
| 300 | + dispatch_workflow: { | |
| 301 | + description: "Start a workflow_dispatch run, e.g. workflow 'deploy.yml' on ref 'main' with inputs.", | |
| 302 | + input: { ...OWNER_REPO, workflow: { type: "string" }, ref: { type: "string" }, inputs: { type: "object" } }, | |
| 303 | + required: ["owner", "repo", "workflow"], | |
| 304 | + run: async (c, a) => { | |
| 305 | + const { stub, caller, repo } = await repoOf(c, a, "write"); | |
| 306 | + return stub.dispatchWorkflow(str(a, "workflow"), optStr(a, "ref") ?? repo.default_branch, (a.inputs as Record<string, unknown>) ?? {}, caller); | |
| 307 | + }, | |
| 308 | + }, | |
| 309 | + sandbox_create: { | |
| 310 | + description: "Start an isolated Linux sandbox with the repository checked out (git push from inside it uses your permissions). Returns a sandbox id.", | |
| 311 | + input: { ...OWNER_REPO, ref: { type: "string" } }, | |
| 312 | + required: ["owner", "repo"], | |
| 313 | + run: async (c, a) => { | |
| 314 | + const { stub, caller } = await repoOf(c, a, "write"); | |
| 315 | + return stub.createSandbox(caller, optStr(a, "ref")); | |
| 316 | + }, | |
| 317 | + }, | |
| 318 | + sandbox_exec: { | |
| 319 | + description: "Run a shell command in a sandbox. Returns exit code, stdout and stderr.", | |
| 320 | + input: { ...OWNER_REPO, sandbox_id: { type: "string" }, command: { type: "string" }, cwd: { type: "string" }, timeout_seconds: { type: "integer" } }, | |
| 321 | + required: ["owner", "repo", "sandbox_id", "command"], | |
| 322 | + run: (c, a) => sandboxCall(c, a, "/exec", { cmd: str(a, "command"), cwd: optStr(a, "cwd"), timeoutMs: a.timeout_seconds ? int(a, "timeout_seconds") * 1000 : undefined }), | |
| 323 | + }, | |
| 324 | + sandbox_read_file: { | |
| 325 | + description: "Read a file inside a sandbox.", | |
| 326 | + input: { ...OWNER_REPO, sandbox_id: { type: "string" }, path: { type: "string" } }, | |
| 327 | + required: ["owner", "repo", "sandbox_id", "path"], | |
| 328 | + run: async (c, a) => { | |
| 329 | + const r = (await sandboxCall(c, a, "/files/read", { path: str(a, "path") })) as { path: string; size: number; content: string }; | |
| 330 | + const bytes = fromB64(r.content); | |
| 331 | + const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }); | |
| 332 | + try { | |
| 333 | + return { path: r.path, size: r.size, content: text.decode(bytes) }; | |
| 334 | + } catch { | |
| 335 | + return { path: r.path, size: r.size, encoding: "base64", content: r.content }; | |
| 336 | + } | |
| 337 | + }, | |
| 338 | + }, | |
| 339 | + sandbox_write_file: { | |
| 340 | + description: "Write a file inside a sandbox.", | |
| 341 | + input: { ...OWNER_REPO, sandbox_id: { type: "string" }, path: { type: "string" }, content: { type: "string" } }, | |
| 342 | + required: ["owner", "repo", "sandbox_id", "path", "content"], | |
| 343 | + run: (c, a) => sandboxCall(c, a, "/files/write", { path: str(a, "path"), content: b64(new TextEncoder().encode(str(a, "content", ""))) }), | |
| 344 | + }, | |
| 345 | + sandbox_close: { | |
| 346 | + description: "Shut down a sandbox.", | |
| 347 | + input: { ...OWNER_REPO, sandbox_id: { type: "string" } }, | |
| 348 | + required: ["owner", "repo", "sandbox_id"], | |
| 349 | + run: async (c, a) => { | |
| 350 | + const { stub, caller } = await repoOf(c, a, "write"); | |
| 351 | + await stub.closeSandbox(str(a, "sandbox_id"), caller); | |
| 352 | + return { closed: true }; | |
| 353 | + }, | |
| 354 | + }, | |
| 355 | +}; | |
| 356 | + | |
| 357 | +async function sandboxCall(c: AppContext, a: Args, path: string, payload: unknown) { | |
| 358 | + const { stub, caller } = await repoOf(c, a, "write"); | |
| 359 | + const id = str(a, "sandbox_id"); | |
| 360 | + await stub.sandbox(id, caller); | |
| 361 | + const res = await c.env.SANDBOX.get(c.env.SANDBOX.idFromName(id)).call(path, JSON.stringify(payload)); | |
| 362 | + let out: unknown = res.body; | |
| 363 | + try { | |
| 364 | + out = JSON.parse(res.body); | |
| 365 | + } catch { | |
| 366 | + /* plain text */ | |
| 367 | + } | |
| 368 | + if (res.status >= 400) throw new HttpError(res.status, out && typeof out === "object" && "error" in out ? String((out as { error: unknown }).error) : res.body || "sandbox error"); | |
| 369 | + return out; | |
| 370 | +} | |
| 371 | + | |
| 372 | +interface RpcRequest { | |
| 373 | + jsonrpc: "2.0"; | |
| 374 | + id?: string | number | null; | |
| 375 | + method: string; | |
| 376 | + params?: Record<string, unknown>; | |
| 377 | +} | |
| 378 | + | |
| 379 | +const rpcError = (id: RpcRequest["id"], code: number, message: string) => ({ jsonrpc: "2.0" as const, id: id ?? null, error: { code, message } }); | |
| 380 | + | |
| 381 | +async function handle(c: AppContext, req: RpcRequest): Promise<unknown | null> { | |
| 382 | + const isNotification = req.id === undefined; | |
| 383 | + switch (req.method) { | |
| 384 | + case "initialize": { | |
| 385 | + const asked = String(req.params?.protocolVersion ?? ""); | |
| 386 | + return { | |
| 387 | + jsonrpc: "2.0", | |
| 388 | + id: req.id, | |
| 389 | + result: { | |
| 390 | + protocolVersion: PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0], | |
| 391 | + capabilities: { tools: { listChanged: false } }, | |
| 392 | + serverInfo: { name: "diffinery", title: "Diffinery", version: "0.1.0" }, | |
| 393 | + instructions: | |
| 394 | + "Diffinery is a git forge built for agents. Read code with get_file/list_tree, change it with commit_files on a branch, open a pull request with an `intent`, watch CI with list_workflow_runs/get_job_logs, and use sandboxes to build and test. Protected branches and required human reviews are enforced server-side.", | |
| 395 | + }, | |
| 396 | + }; | |
| 397 | + } | |
| 398 | + case "notifications/initialized": | |
| 399 | + case "notifications/cancelled": | |
| 400 | + return null; | |
| 401 | + case "ping": | |
| 402 | + return { jsonrpc: "2.0", id: req.id, result: {} }; | |
| 403 | + case "tools/list": | |
| 404 | + return { | |
| 405 | + jsonrpc: "2.0", | |
| 406 | + id: req.id, | |
| 407 | + result: { | |
| 408 | + tools: Object.entries(TOOLS).map(([name, t]) => ({ | |
| 409 | + name, | |
| 410 | + description: t.description, | |
| 411 | + inputSchema: { type: "object", properties: t.input, required: t.required ?? [] }, | |
| 412 | + annotations: { readOnlyHint: !!t.readOnly }, | |
| 413 | + })), | |
| 414 | + }, | |
| 415 | + }; | |
| 416 | + case "tools/call": { | |
| 417 | + const name = String(req.params?.name ?? ""); | |
| 418 | + const tool = TOOLS[name]; | |
| 419 | + if (!tool) return rpcError(req.id, -32602, `unknown tool: ${name}`); | |
| 420 | + try { | |
| 421 | + const out = await tool.run(c, (req.params?.arguments as Args) ?? {}); | |
| 422 | + return { jsonrpc: "2.0", id: req.id, result: { content: [{ type: "text", text: JSON.stringify(out, null, 2) }], structuredContent: out && typeof out === "object" && !Array.isArray(out) ? out : { result: out }, isError: false } }; | |
| 423 | + } catch (e) { | |
| 424 | + const msg = e instanceof HttpError ? e.detail : String((e as Error)?.message ?? e).replace(/^\d{3}: /, ""); | |
| 425 | + return { jsonrpc: "2.0", id: req.id, result: { content: [{ type: "text", text: msg }], isError: true } }; | |
| 426 | + } | |
| 427 | + } | |
| 428 | + default: | |
| 429 | + return isNotification ? null : rpcError(req.id, -32601, `method not found: ${req.method}`); | |
| 430 | + } | |
| 431 | +} | |
| 432 | + | |
| 433 | +mcp.get("/mcp", (c) => c.json({ message: "Diffinery MCP: POST JSON-RPC here with Authorization: Bearer <token>" }, 405, { allow: "POST" })); | |
| 434 | +mcp.delete("/mcp", (c) => c.body(null, 405)); | |
| 435 | +mcp.post("/mcp", async (c) => { | |
| 436 | + // Every MCP call needs an active account (a token for an agent or a person). | |
| 437 | + requireAccount(c); | |
| 438 | + let payload: unknown; | |
| 439 | + try { | |
| 440 | + payload = await c.req.json(); | |
| 441 | + } catch { | |
| 442 | + return c.json(rpcError(null, -32700, "parse error"), 400); | |
| 443 | + } | |
| 444 | + const batch = Array.isArray(payload); | |
| 445 | + const reqs = (batch ? payload : [payload]) as RpcRequest[]; | |
| 446 | + const out: unknown[] = []; | |
| 447 | + for (const r of reqs) { | |
| 448 | + if (!r || typeof r !== "object" || typeof r.method !== "string") { | |
| 449 | + out.push(rpcError(null, -32600, "invalid request")); | |
| 450 | + continue; | |
| 451 | + } | |
| 452 | + const res = await handle(c, r); | |
| 453 | + if (res) out.push(res); | |
| 454 | + } | |
| 455 | + if (!out.length) return c.body(null, 202); | |
| 456 | + return c.json(batch ? out : out[0]); | |
| 457 | +}); |
modifiedapps/diffinery/src/repo/RepoDO.ts+14−5View file
| @@ -45,16 +45,16 @@ | ||
| 45 | 45 | |
| 46 | 46 | const MAX_PUSH_BYTES = 100 * 1024 * 1024; |
| 47 | 47 | |
| 48 | -export interface TreeItem { | |
| 48 | +export type TreeItem = { | |
| 49 | 49 | name: string; |
| 50 | 50 | path: string; |
| 51 | 51 | type: "tree" | "blob" | "commit"; |
| 52 | 52 | mode: string; |
| 53 | 53 | oid: string; |
| 54 | 54 | size?: number; |
| 55 | -} | |
| 55 | +}; | |
| 56 | 56 | |
| 57 | -export interface CommitInfo { | |
| 57 | +export type CommitInfo = { | |
| 58 | 58 | sha: string; |
| 59 | 59 | tree: string; |
| 60 | 60 | parents: string[]; |
| @@ -62,7 +62,7 @@ | ||
| 62 | 62 | committer: Signature; |
| 63 | 63 | message: string; |
| 64 | 64 | signed: boolean; |
| 65 | -} | |
| 65 | +}; | |
| 66 | 66 | |
| 67 | 67 | /** |
| 68 | 68 | * One Durable Object per repository. It is the single writer for the repo's |
| @@ -83,7 +83,7 @@ | ||
| 83 | 83 | const meta = this.readMeta(); |
| 84 | 84 | if (meta) this.store = new RepoObjectStore(this.sql, env.BUCKET, meta.repoId); |
| 85 | 85 | this.collab = new Collab(this); |
| 86 | - this.actions = new Actions(this); | |
| 86 | + this.actions = new Actions(this, env); | |
| 87 | 87 | } |
| 88 | 88 | |
| 89 | 89 | // ---------------------------------------------------------------- meta |
| @@ -578,6 +578,12 @@ | ||
| 578 | 578 | runJobs(...a: Parameters<Actions["jobs"]>) { |
| 579 | 579 | return this.actions.jobs(...a); |
| 580 | 580 | } |
| 581 | + claimJob(...a: Parameters<Actions["claimJob"]>) { | |
| 582 | + return this.actions.claimJob(...a); | |
| 583 | + } | |
| 584 | + jobStatus(jobId: string): string { | |
| 585 | + return this.actions.getJob(jobId).status; | |
| 586 | + } | |
| 581 | 587 | job(...a: Parameters<Actions["getJob"]>) { |
| 582 | 588 | return this.actions.getJob(...a); |
| 583 | 589 | } |
| @@ -614,6 +620,9 @@ | ||
| 614 | 620 | finishRun(...a: Parameters<Actions["finishRun"]>) { |
| 615 | 621 | return this.actions.finishRun(...a); |
| 616 | 622 | } |
| 623 | + oidcClaims(...a: Parameters<Actions["oidcClaims"]>) { | |
| 624 | + return this.actions.oidcClaims(...a); | |
| 625 | + } | |
| 617 | 626 | runnerSpec(...a: Parameters<Actions["runnerSpec"]>) { |
| 618 | 627 | return this.actions.runnerSpec(...a); |
| 619 | 628 | } |
modifiedapps/diffinery/src/repo/actions.ts+62−17View file
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { ZERO_OID, diffTrees, fromUtf8, isTreeMode, readCommit, readTree, resolvePath } from "@diffinery/git-core"; |
| 2 | -import type { Actor, Conclusion, JobCompletion, JobSpec, LogLine, NeedsResult, StepReport } from "@diffinery/shared"; | |
| 2 | +import type { Actor, Conclusion, Json, JobCompletion, JobSpec, LogLine, NeedsResult, StepReport } from "@diffinery/shared"; | |
| 3 | 3 | import { |
| 4 | 4 | evaluateIf, |
| 5 | 5 | evaluateValue, |
| @@ -17,13 +17,14 @@ | ||
| 17 | 17 | } from "@diffinery/workflow-yaml"; |
| 18 | 18 | import { decryptSecret, encryptSecret } from "../lib/crypto.js"; |
| 19 | 19 | import { HttpError, newId, now, randomToken, sha256 } from "../lib/util.js"; |
| 20 | +import type { Env } from "../env.js"; | |
| 20 | 21 | import type { RepoDO } from "./RepoDO.js"; |
| 21 | 22 | import { atLeast, type Caller } from "./types.js"; |
| 22 | 23 | |
| 23 | 24 | const WORKFLOW_DIRS = [".github/workflows", ".diffinery/workflows"]; |
| 24 | 25 | const SUPPORTED_LABEL = /^(ubuntu-.*|linux|diffinery(-.*)?|self-hosted|x64|amd64)$/i; |
| 25 | 26 | |
| 26 | -export interface RunRow { | |
| 27 | +export type RunRow = { | |
| 27 | 28 | id: string; |
| 28 | 29 | run_number: number; |
| 29 | 30 | workflow_path: string; |
| @@ -42,9 +43,9 @@ | ||
| 42 | 43 | inputs: string; |
| 43 | 44 | concurrency_group: string | null; |
| 44 | 45 | display_title: string; |
| 45 | -} | |
| 46 | +}; | |
| 46 | 47 | |
| 47 | -export interface JobRow { | |
| 48 | +export type JobRow = { | |
| 48 | 49 | id: string; |
| 49 | 50 | run_id: string; |
| 50 | 51 | job_key: string; |
| @@ -62,9 +63,11 @@ | ||
| 62 | 63 | summary: string | null; |
| 63 | 64 | container: string | null; |
| 64 | 65 | timeout_minutes: number; |
| 65 | -} | |
| 66 | + /** runs-on labels for self-hosted jobs (JSON array); null for Diffinery-hosted containers. */ | |
| 67 | + labels: string | null; | |
| 68 | +}; | |
| 66 | 69 | |
| 67 | -export interface RunInfo { | |
| 70 | +export type RunInfo = { | |
| 68 | 71 | id: string; |
| 69 | 72 | number: number; |
| 70 | 73 | attempt: number; |
| @@ -80,23 +83,23 @@ | ||
| 80 | 83 | createdAt: string; |
| 81 | 84 | updatedAt: string; |
| 82 | 85 | prNumber: number | null; |
| 83 | -} | |
| 86 | +}; | |
| 84 | 87 | |
| 85 | -export interface JobInfo { | |
| 88 | +export type JobInfo = { | |
| 86 | 89 | id: string; |
| 87 | 90 | runId: string; |
| 88 | 91 | key: string; |
| 89 | 92 | name: string; |
| 90 | 93 | status: JobRow["status"]; |
| 91 | 94 | conclusion: JobRow["conclusion"]; |
| 92 | - matrix: Record<string, unknown>; | |
| 95 | + matrix: Record<string, Json>; | |
| 93 | 96 | startedAt: string | null; |
| 94 | 97 | completedAt: string | null; |
| 95 | 98 | outputs: Record<string, string>; |
| 96 | 99 | steps: Array<StepReport & { index: number }>; |
| 97 | 100 | failure: JobCompletion["failure"] | null; |
| 98 | 101 | summary: string | null; |
| 99 | -} | |
| 102 | +}; | |
| 100 | 103 | |
| 101 | 104 | const toRun = (r: RunRow): RunInfo => ({ |
| 102 | 105 | id: r.id, |
| @@ -145,14 +148,14 @@ | ||
| 145 | 148 | * each job's container reports back through the runner* methods. |
| 146 | 149 | */ |
| 147 | 150 | export class Actions { |
| 148 | - constructor(private repo: RepoDO) {} | |
| 151 | + constructor( | |
| 152 | + private repo: RepoDO, | |
| 153 | + private env: Env, | |
| 154 | + ) {} | |
| 149 | 155 | |
| 150 | 156 | private get sql() { |
| 151 | 157 | return this.repo.sql; |
| 152 | 158 | } |
| 153 | - private get env() { | |
| 154 | - return this.repo.env; | |
| 155 | - } | |
| 156 | 159 | |
| 157 | 160 | // ---------------------------------------------------------------- workflow discovery |
| 158 | 161 | |
| @@ -572,7 +575,8 @@ | ||
| 572 | 575 | if (job.uses) return fail(job.name ?? jobKey, `Reusable workflows (jobs.${jobKey}.uses) are not supported yet on Diffinery.`); |
| 573 | 576 | const labels = job.runsOn.map((l) => String(evaluateValue(l, baseCtx))); |
| 574 | 577 | const unsupported = labels.filter((l) => !SUPPORTED_LABEL.test(l)); |
| 575 | - if (unsupported.length) return fail(job.name ?? jobKey, `No Diffinery runner matches runs-on: ${unsupported.join(", ")}. Linux (ubuntu-*) runners are available today.`); | |
| 578 | + const selfHosted = labels.some((l) => l.toLowerCase() === "self-hosted"); | |
| 579 | + if (unsupported.length && !selfHosted) return fail(job.name ?? jobKey, `No Diffinery runner matches runs-on: ${unsupported.join(", ")}. Linux (ubuntu-*) runners are hosted; anything else needs runs-on: [self-hosted, ...] and a registered runner.`); | |
| 576 | 580 | if (job.container) return fail(job.name ?? jobKey, "Job containers (jobs.<id>.container) are not supported yet; steps run directly on the Ubuntu runner image."); |
| 577 | 581 | if (job.services && Object.keys(job.services).length) return fail(job.name ?? jobKey, "Service containers (jobs.<id>.services) are not supported yet on Diffinery runners."); |
| 578 | 582 | |
| @@ -593,7 +597,7 @@ | ||
| 593 | 597 | const timeout = Number(evaluateValue(job.timeoutMinutes, ctx)) || 360; |
| 594 | 598 | const id = newId(); |
| 595 | 599 | this.sql.exec( |
| 596 | - "INSERT INTO jobs (id, run_id, job_key, name, status, matrix, strategy, timeout_minutes) VALUES (?, ?, ?, ?, 'queued', ?, ?, ?)", | |
| 600 | + "INSERT INTO jobs (id, run_id, job_key, name, status, matrix, strategy, timeout_minutes, labels) VALUES (?, ?, ?, ?, 'queued', ?, ?, ?, ?)", | |
| 597 | 601 | id, |
| 598 | 602 | runId, |
| 599 | 603 | jobKey, |
| @@ -601,6 +605,7 @@ | ||
| 601 | 605 | JSON.stringify(matrix), |
| 602 | 606 | JSON.stringify(ctx.strategy), |
| 603 | 607 | Math.min(timeout, 360), |
| 608 | + selfHosted ? JSON.stringify(labels.map((l) => l.toLowerCase())) : null, | |
| 604 | 609 | ); |
| 605 | 610 | ids.push(id); |
| 606 | 611 | }); |
| @@ -617,6 +622,12 @@ | ||
| 617 | 622 | const job = this.jobRow(jobId); |
| 618 | 623 | const run = this.runRow(job.run_id); |
| 619 | 624 | const m = this.repo.meta; |
| 625 | + if (job.labels) { | |
| 626 | + // Self-hosted: park the job until a registered runner with matching labels claims it. | |
| 627 | + this.sql.exec("UPDATE jobs SET status = 'waiting', container = NULL WHERE id = ?", jobId); | |
| 628 | + this.repo.upsertCheck({ sha: run.sha, name: this.checkName(run, job), status: "queued", runId: run.id, jobId, detailsUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}/job/${jobId}` }); | |
| 629 | + return; | |
| 630 | + } | |
| 620 | 631 | const token = `djt_${m.repoId}_${jobId}_${randomToken(24)}`; |
| 621 | 632 | const container = `job-${jobId}`; |
| 622 | 633 | this.sql.exec("UPDATE jobs SET status = 'waiting', token_hash = ?, container = ? WHERE id = ?", await sha256(token), container, jobId); |
| @@ -642,8 +653,28 @@ | ||
| 642 | 653 | await this.completeJob(jobId, { conclusion: "failure", outputs: {}, failure: { message: `The runner stopped unexpectedly (exit code ${exitCode ?? "unknown"}).` } }); |
| 643 | 654 | } |
| 644 | 655 | |
| 656 | + /** | |
| 657 | + * A self-hosted runner asks for work. Returns the oldest waiting job whose | |
| 658 | + * runs-on labels are all offered by the runner, with a fresh job token. | |
| 659 | + */ | |
| 660 | + async claimJob(runnerName: string, runnerLabels: string[], caller: Caller): Promise<{ jobId: string; token: string; runId: string; name: string } | null> { | |
| 661 | + if (!atLeast(caller.role, "admin")) throw new HttpError(403, "admin access is required to run self-hosted jobs"); | |
| 662 | + const offered = new Set(["self-hosted", ...runnerLabels.map((l) => l.toLowerCase())]); | |
| 663 | + const waiting = this.sql | |
| 664 | + .exec<JobRow>("SELECT j.* FROM jobs j JOIN runs r ON r.id = j.run_id WHERE j.status = 'waiting' AND j.labels IS NOT NULL AND j.container IS NULL AND r.status != 'completed' ORDER BY r.created_at, j.rowid") | |
| 665 | + .toArray(); | |
| 666 | + const job = waiting.find((j) => (JSON.parse(j.labels!) as string[]).every((l) => offered.has(l))); | |
| 667 | + if (!job) return null; | |
| 668 | + const m = this.repo.meta; | |
| 669 | + const token = `djt_${m.repoId}_${job.id}_${randomToken(24)}`; | |
| 670 | + const container = `self-hosted:${runnerName.slice(0, 64)}`; | |
| 671 | + this.sql.exec("UPDATE jobs SET token_hash = ?, container = ? WHERE id = ? AND container IS NULL", await sha256(token), container, job.id); | |
| 672 | + this.repo.broadcast(`run:${job.run_id}`, { action: "job", jobId: job.id }); | |
| 673 | + return { jobId: job.id, token, runId: job.run_id, name: job.name }; | |
| 674 | + } | |
| 675 | + | |
| 645 | 676 | private async stopContainer(j: JobRow): Promise<void> { |
| 646 | - if (!j.container) return; | |
| 677 | + if (!j.container || j.container.startsWith("self-hosted:")) return; | |
| 647 | 678 | try { |
| 648 | 679 | await this.env.RUNNER.get(this.env.RUNNER.idFromName(j.container)).stopJob(); |
| 649 | 680 | } catch (e) { |
| @@ -735,6 +766,20 @@ | ||
| 735 | 766 | return this.jobRow(jobId); |
| 736 | 767 | } |
| 737 | 768 | |
| 769 | + /** Claims for an OIDC id token; side-effect free (unlike runnerSpec). */ | |
| 770 | + async oidcClaims(jobId: string, token: string): Promise<{ allowed: boolean; github: Record<string, string> }> { | |
| 771 | + const job = await this.authJob(jobId, token); | |
| 772 | + if (job.status !== "in_progress") throw new HttpError(409, "job is not running"); | |
| 773 | + const run = this.runRow(job.run_id); | |
| 774 | + const wf = (await this.loadWorkflows(run.sha)).find((w) => w.path === run.workflow_path)!.workflow!; | |
| 775 | + const def: Job = wf.jobs[job.job_key]!; | |
| 776 | + const allowed = wf.permissions === "write-all" || (typeof wf.permissions === "object" && wf.permissions["id-token"] === "write") || (typeof def.permissions === "object" && def.permissions["id-token"] === "write") || def.permissions === "write-all"; | |
| 777 | + const ctx = this.githubContext(run, job.job_key, token) as Record<string, Json>; | |
| 778 | + const github: Record<string, string> = {}; | |
| 779 | + for (const [k, v] of Object.entries(ctx)) if (typeof v === "string" || typeof v === "number") github[k] = String(v); | |
| 780 | + return { allowed, github }; | |
| 781 | + } | |
| 782 | + | |
| 738 | 783 | async runnerSpec(jobId: string, token: string): Promise<JobSpec> { |
| 739 | 784 | const job = await this.authJob(jobId, token); |
| 740 | 785 | const run = this.runRow(job.run_id); |
modifiedapps/diffinery/src/repo/collab.ts+21−21View file
| @@ -4,7 +4,7 @@ | ||
| 4 | 4 | import { toInfo } from "./RepoDO.js"; |
| 5 | 5 | import { atLeast, type Caller } from "./types.js"; |
| 6 | 6 | |
| 7 | -export interface IssueRow { | |
| 7 | +export type IssueRow = { | |
| 8 | 8 | number: number; |
| 9 | 9 | kind: "issue" | "pull"; |
| 10 | 10 | title: string; |
| @@ -20,9 +20,9 @@ | ||
| 20 | 20 | updated_at: string; |
| 21 | 21 | closed_at: string | null; |
| 22 | 22 | locked: number; |
| 23 | -} | |
| 23 | +}; | |
| 24 | 24 | |
| 25 | -export interface PullRow { | |
| 25 | +export type PullRow = { | |
| 26 | 26 | number: number; |
| 27 | 27 | head_ref: string; |
| 28 | 28 | head_sha: string; |
| @@ -34,9 +34,9 @@ | ||
| 34 | 34 | merge_commit: string | null; |
| 35 | 35 | requested_reviewers: string; |
| 36 | 36 | intent: string | null; |
| 37 | -} | |
| 37 | +}; | |
| 38 | 38 | |
| 39 | -export interface Issue { | |
| 39 | +export type Issue = { | |
| 40 | 40 | number: number; |
| 41 | 41 | kind: "issue" | "pull"; |
| 42 | 42 | title: string; |
| @@ -62,9 +62,9 @@ | ||
| 62 | 62 | requestedReviewers: string[]; |
| 63 | 63 | intent: string | null; |
| 64 | 64 | }; |
| 65 | -} | |
| 65 | +}; | |
| 66 | 66 | |
| 67 | -export interface Comment { | |
| 67 | +export type Comment = { | |
| 68 | 68 | id: string; |
| 69 | 69 | author: { id: string; login: string; kind: string }; |
| 70 | 70 | body: string; |
| @@ -77,18 +77,18 @@ | ||
| 77 | 77 | commitId: string | null; |
| 78 | 78 | inReplyTo: string | null; |
| 79 | 79 | resolved: boolean; |
| 80 | -} | |
| 80 | +}; | |
| 81 | 81 | |
| 82 | -export interface Review { | |
| 82 | +export type Review = { | |
| 83 | 83 | id: string; |
| 84 | 84 | author: { id: string; login: string; kind: string }; |
| 85 | 85 | state: "APPROVED" | "CHANGES_REQUESTED" | "COMMENTED" | "DISMISSED"; |
| 86 | 86 | body: string; |
| 87 | 87 | commitId: string; |
| 88 | 88 | submittedAt: string; |
| 89 | -} | |
| 89 | +}; | |
| 90 | 90 | |
| 91 | -export interface Check { | |
| 91 | +export type Check = { | |
| 92 | 92 | id: string; |
| 93 | 93 | sha: string; |
| 94 | 94 | name: string; |
| @@ -101,9 +101,9 @@ | ||
| 101 | 101 | app: string; |
| 102 | 102 | startedAt: string | null; |
| 103 | 103 | completedAt: string | null; |
| 104 | -} | |
| 104 | +}; | |
| 105 | 105 | |
| 106 | -export interface Mergeability { | |
| 106 | +export type Mergeability = { | |
| 107 | 107 | mergeable: boolean; |
| 108 | 108 | state: "clean" | "blocked" | "conflicting" | "behind" | "draft" | "merged" | "closed" | "unknown"; |
| 109 | 109 | conflicts: string[]; |
| @@ -113,7 +113,7 @@ | ||
| 113 | 113 | checks: { name: string; state: string }[]; |
| 114 | 114 | blockers: string[]; |
| 115 | 115 | mergeBase: string | null; |
| 116 | -} | |
| 116 | +}; | |
| 117 | 117 | |
| 118 | 118 | const actorOf = (c: Caller) => ({ id: c.id, login: c.login, kind: c.kind, onBehalfOf: c.onBehalfOf }); |
| 119 | 119 | |
| @@ -210,7 +210,7 @@ | ||
| 210 | 210 | get(number: number): { issue: Issue; comments: Comment[]; reviews: Review[] } { |
| 211 | 211 | const issue = this.toIssue(this.row(number)); |
| 212 | 212 | const comments = this.sql |
| 213 | - .exec<Record<string, unknown>>("SELECT * FROM comments WHERE number = ? ORDER BY created_at", number) | |
| 213 | + .exec<Record<string, SqlStorageValue>>("SELECT * FROM comments WHERE number = ? ORDER BY created_at", number) | |
| 214 | 214 | .toArray() |
| 215 | 215 | .map( |
| 216 | 216 | (c): Comment => ({ |
| @@ -485,14 +485,14 @@ | ||
| 485 | 485 | sha: c.sha!, |
| 486 | 486 | name: c.name!, |
| 487 | 487 | status: c.status as Check["status"], |
| 488 | - conclusion: c.conclusion, | |
| 489 | - runId: c.run_id, | |
| 490 | - jobId: c.job_id, | |
| 491 | - detailsUrl: c.details_url, | |
| 488 | + conclusion: c.conclusion ?? null, | |
| 489 | + runId: c.run_id ?? null, | |
| 490 | + jobId: c.job_id ?? null, | |
| 491 | + detailsUrl: c.details_url ?? null, | |
| 492 | 492 | output: c.output ? JSON.parse(c.output) : null, |
| 493 | 493 | app: c.app!, |
| 494 | - startedAt: c.started_at, | |
| 495 | - completedAt: c.completed_at, | |
| 494 | + startedAt: c.started_at ?? null, | |
| 495 | + completedAt: c.completed_at ?? null, | |
| 496 | 496 | })); |
| 497 | 497 | } |
| 498 | 498 |
modifiedapps/diffinery/src/repo/schema.ts+1−1View file
| @@ -56,7 +56,7 @@ | ||
| 56 | 56 | id TEXT PRIMARY KEY, run_id TEXT NOT NULL, job_key TEXT NOT NULL, name TEXT NOT NULL, status TEXT NOT NULL, |
| 57 | 57 | conclusion TEXT, matrix TEXT NOT NULL DEFAULT '{}', strategy TEXT NOT NULL DEFAULT '{}', started_at TEXT, completed_at TEXT, |
| 58 | 58 | outputs TEXT NOT NULL DEFAULT '{}', steps TEXT NOT NULL DEFAULT '[]', failure TEXT, token_hash TEXT, summary TEXT, |
| 59 | - container TEXT, timeout_minutes INTEGER NOT NULL DEFAULT 360 | |
| 59 | + container TEXT, timeout_minutes INTEGER NOT NULL DEFAULT 360, labels TEXT | |
| 60 | 60 | ); |
| 61 | 61 | CREATE INDEX IF NOT EXISTS jobs_run ON jobs(run_id); |
| 62 | 62 | CREATE TABLE IF NOT EXISTS logs (job_id TEXT NOT NULL, seq INTEGER NOT NULL, t TEXT NOT NULL, step INTEGER NOT NULL, text TEXT NOT NULL, PRIMARY KEY (job_id, seq)); |
modifiedapps/diffinery/src/runner/containers.ts+4−11View file
| @@ -53,22 +53,15 @@ | ||
| 53 | 53 | await this.startAndWaitForPorts({ ports: 8080, startOptions: { envVars, entrypoint: ["node", "/opt/diffinery/agent.mjs", "--sandbox"] } }); |
| 54 | 54 | } |
| 55 | 55 | |
| 56 | - /** Forward an API call to the sandbox's HTTP server with its secret attached. */ | |
| 57 | - async call(path: string, body: unknown): Promise<{ status: number; body: unknown }> { | |
| 56 | + /** Forward an API call (JSON text) to the sandbox's HTTP server with its secret attached. */ | |
| 57 | + async call(path: string, body: string): Promise<{ status: number; body: string; json: boolean }> { | |
| 58 | 58 | const secret = await this.ctx.storage.get<string>("secret"); |
| 59 | 59 | const res = await this.containerFetch(`http://sandbox${path}`, { |
| 60 | 60 | method: "POST", |
| 61 | 61 | headers: { "content-type": "application/json", authorization: `Bearer ${secret}` }, |
| 62 | - body: JSON.stringify(body ?? {}), | |
| 62 | + body: body || "{}", | |
| 63 | 63 | }); |
| 64 | - const text = await res.text(); | |
| 65 | - let parsed: unknown = text; | |
| 66 | - try { | |
| 67 | - parsed = JSON.parse(text); | |
| 68 | - } catch { | |
| 69 | - /* plain text */ | |
| 70 | - } | |
| 71 | - return { status: res.status, body: parsed }; | |
| 64 | + return { status: res.status, body: await res.text(), json: (res.headers.get("content-type") ?? "").includes("json") }; | |
| 72 | 65 | } |
| 73 | 66 | |
| 74 | 67 | async shutdown(): Promise<void> { |
modifiedapps/diffinery/src/runner/workflow.ts+2−2View file
| @@ -32,8 +32,8 @@ | ||
| 32 | 32 | const jobIds = started.flatMap((s) => s.jobIds); |
| 33 | 33 | await Promise.all( |
| 34 | 34 | jobIds.map(async (jobId) => { |
| 35 | - const job = await step.do(`job ${jobId} info`, () => repo().job(jobId)); | |
| 36 | - if (job.status === "completed") return; | |
| 35 | + const status = await step.do(`job ${jobId} info`, () => repo().jobStatus(jobId)); | |
| 36 | + if (status === "completed") return; | |
| 37 | 37 | try { |
| 38 | 38 | await step.waitForEvent(`wait ${jobId}`, { type: `job-${jobId}`, timeout: "6 hours" }); |
| 39 | 39 | } catch { |
addedapps/diffinery/src/web/account.tsx+1620−0View file
| @@ -1,0 +1,1620 @@ | ||
| 1 | +/** @jsxImportSource hono/jsx */ | |
| 2 | +import { raw } from "hono/html"; | |
| 3 | +import type { Child, FC } from "hono/jsx"; | |
| 4 | +import { requireAccount, type AppContext } from "../auth/principal.js"; | |
| 5 | +import type { Account, Repo, TokenScope } from "../lib/directory.js"; | |
| 6 | +import { loadRepo } from "../http/common.js"; | |
| 7 | +import { HttpError } from "../lib/util.js"; | |
| 8 | +import { ROLE_LEVEL, type Role } from "../repo/types.js"; | |
| 9 | +import { ICONS } from "./assets.js"; | |
| 10 | +import { csrfToken } from "./csrf.js"; | |
| 11 | +import { Actor, Avatar, CopyButton, Form, ROLES, Time, act, ago, post, publicUrl, render, repoUrl, type Stub, type WebApp } from "./ui.js"; | |
| 12 | + | |
| 13 | +// ---------------------------------------------------------------- shared bits | |
| 14 | + | |
| 15 | +const RepoList: FC<{ repos: Repo[]; showOwner?: boolean; empty?: Child }> = ({ repos, showOwner = true, empty }) => | |
| 16 | + repos.length ? ( | |
| 17 | + <ul class="list"> | |
| 18 | + {repos.map((r) => ( | |
| 19 | + <li> | |
| 20 | + <span class="st faint">{raw(r.visibility === "private" ? ICONS.lock : ICONS.repo)}</span> | |
| 21 | + <div class="grow"> | |
| 22 | + <a class="title" href={repoUrl(r)}> | |
| 23 | + {showOwner ? `${r.owner_login}/` : ""} | |
| 24 | + {r.name} | |
| 25 | + </a>{" "} | |
| 26 | + <span class="badge">{r.visibility}</span> {r.archived ? <span class="badge amber">archived</span> : null} | |
| 27 | + {r.description ? <div class="meta">{r.description}</div> : null} | |
| 28 | + </div> | |
| 29 | + <span class="small muted nowrap">Updated {ago(r.pushed_at ?? r.updated_at)}</span> | |
| 30 | + </li> | |
| 31 | + ))} | |
| 32 | + </ul> | |
| 33 | + ) : ( | |
| 34 | + <div class="empty">{empty ?? "No repositories yet."}</div> | |
| 35 | + ); | |
| 36 | + | |
| 37 | +function parsePolicy(a: Account): { maxRole?: Role } { | |
| 38 | + try { | |
| 39 | + return a.agent_policy ? (JSON.parse(a.agent_policy) as { maxRole?: Role }) : {}; | |
| 40 | + } catch { | |
| 41 | + return {}; | |
| 42 | + } | |
| 43 | +} | |
| 44 | + | |
| 45 | +async function adminOrgs(c: AppContext, me: Account): Promise<Array<Account & { role: string }>> { | |
| 46 | + return (await c.get("dir").orgsFor(me.id)).filter((o) => o.role === "owner" || o.role === "admin"); | |
| 47 | +} | |
| 48 | + | |
| 49 | +/** Whether `me` may manage an agent (its owner, an admin of its owning org, or a site admin). */ | |
| 50 | +async function canManageAgent(c: AppContext, me: Account, agent: Account): Promise<boolean> { | |
| 51 | + if (agent.kind !== "agent" || !agent.owner_id) return false; | |
| 52 | + if (me.site_admin || agent.owner_id === me.id) return true; | |
| 53 | + const role = await c.get("dir").orgRole(agent.owner_id, me.id); | |
| 54 | + return role === "owner" || role === "admin"; | |
| 55 | +} | |
| 56 | + | |
| 57 | +const SCOPES: TokenScope[] = ["read", "write", "admin"]; | |
| 58 | + | |
| 59 | +// ---------------------------------------------------------------- landing / dashboard | |
| 60 | + | |
| 61 | +const Landing: FC = () => ( | |
| 62 | + <div> | |
| 63 | + <section class="hero"> | |
| 64 | + <h1>Diffinery</h1> | |
| 65 | + <p>Code hosting and CI for the agentic era. Git repositories, pull requests with intent, GitHub Actions–compatible workflows and AI agents as first-class collaborators, running on Cloudflare.</p> | |
| 66 | + <p class="row" style="justify-content:center"> | |
| 67 | + <a class="btn primary" href="/settings"> | |
| 68 | + Sign in | |
| 69 | + </a> | |
| 70 | + </p> | |
| 71 | + <p class="muted small">Access is through Cloudflare Access. Sign in with the email address you were invited with; new accounts wait for an invite from an administrator.</p> | |
| 72 | + </section> | |
| 73 | + <div class="features"> | |
| 74 | + <div class="box"> | |
| 75 | + <h3>{raw(ICONS.repo)} Git, natively</h3> | |
| 76 | + <p class="muted m0">Clone, fetch and push over smart HTTP. Every repository is its own strongly consistent Durable Object.</p> | |
| 77 | + </div> | |
| 78 | + <div class="box"> | |
| 79 | + <h3>{raw(ICONS.pr)} Reviews with intent</h3> | |
| 80 | + <p class="muted m0">Pull requests carry what a change is meant to do, so human and AI reviewers check the right thing.</p> | |
| 81 | + </div> | |
| 82 | + <div class="box"> | |
| 83 | + <h3>{raw(ICONS.play)} Actions-compatible CI</h3> | |
| 84 | + <p class="muted m0">Bring your existing workflow YAML. Jobs run in containers with live logs, caches and artifacts.</p> | |
| 85 | + </div> | |
| 86 | + <div class="box"> | |
| 87 | + <h3>{raw(ICONS.bot)} Agents with guardrails</h3> | |
| 88 | + <p class="muted m0">Agents get their own accounts, tokens and policies. Protected branches keep a human in the loop.</p> | |
| 89 | + </div> | |
| 90 | + </div> | |
| 91 | + </div> | |
| 92 | +); | |
| 93 | + | |
| 94 | +export function registerAccount(web: WebApp): void { | |
| 95 | + web.get("/", async (c) => { | |
| 96 | + const p = c.get("principal"); | |
| 97 | + if (p.type !== "account") return render(c, { title: "" }, <Landing />); | |
| 98 | + const me = p.account; | |
| 99 | + const t = csrfToken(c); | |
| 100 | + if (me.status === "disabled") { | |
| 101 | + return render( | |
| 102 | + c, | |
| 103 | + { title: "Account disabled", status: 403 }, | |
| 104 | + <div class="blank" style="max-width:560px;margin:48px auto"> | |
| 105 | + <h1>Your account is disabled</h1> | |
| 106 | + <p class="muted m0">Contact an administrator of this Diffinery instance if you think this is a mistake.</p> | |
| 107 | + </div>, | |
| 108 | + ); | |
| 109 | + } | |
| 110 | + if (me.status === "pending") { | |
| 111 | + return render( | |
| 112 | + c, | |
| 113 | + { title: "Waiting for an invite" }, | |
| 114 | + <div class="box" style="max-width:600px;margin:48px auto"> | |
| 115 | + <div class="body"> | |
| 116 | + <h1>Waiting for an invite</h1> | |
| 117 | + <p class="muted"> | |
| 118 | + You are signed in as <b>{me.email ?? me.login}</b>, but this Diffinery instance is invite-only. Ask someone with access to invite your email address, or send a request to the administrators. | |
| 119 | + </p> | |
| 120 | + <Form action="/request-access" t={t}> | |
| 121 | + <div class="field"> | |
| 122 | + <label for="note">Note to the administrators</label> | |
| 123 | + <textarea id="note" name="note" maxlength={500} placeholder="Who are you, and what would you like to work on?" style="min-height:90px"></textarea> | |
| 124 | + </div> | |
| 125 | + <button class="btn primary">Request access</button> | |
| 126 | + </Form> | |
| 127 | + </div> | |
| 128 | + </div>, | |
| 129 | + ); | |
| 130 | + } | |
| 131 | + const dir = c.get("dir"); | |
| 132 | + const [repos, orgs, notes] = await Promise.all([dir.reposVisibleTo(me), dir.orgsFor(me.id), dir.notifications(me.id, true)]); | |
| 133 | + const mine = repos.filter((r) => r.owner_id === me.id || orgs.some((o) => o.id === r.owner_id)); | |
| 134 | + const others = repos.filter((r) => !mine.includes(r)); | |
| 135 | + return render( | |
| 136 | + c, | |
| 137 | + { title: "Dashboard" }, | |
| 138 | + <div class="grid left"> | |
| 139 | + <aside class="side"> | |
| 140 | + <section> | |
| 141 | + <div class="row between mb"> | |
| 142 | + <h3 class="m0">Your repositories</h3> | |
| 143 | + <a class="btn sm primary" href="/new"> | |
| 144 | + {raw(ICONS.plus)} New | |
| 145 | + </a> | |
| 146 | + </div> | |
| 147 | + {mine.length ? ( | |
| 148 | + <div class="nav"> | |
| 149 | + {mine.slice(0, 40).map((r) => ( | |
| 150 | + <a href={repoUrl(r)} class="nowrap" style="overflow:hidden;text-overflow:ellipsis"> | |
| 151 | + <Avatar login={r.owner_login} /> {r.owner_login}/<b>{r.name}</b> | |
| 152 | + </a> | |
| 153 | + ))} | |
| 154 | + </div> | |
| 155 | + ) : ( | |
| 156 | + <p class="muted small">You have no repositories yet.</p> | |
| 157 | + )} | |
| 158 | + </section> | |
| 159 | + <section> | |
| 160 | + <h3>Organizations</h3> | |
| 161 | + <div class="nav"> | |
| 162 | + {orgs.map((o) => ( | |
| 163 | + <a href={`/${o.login}`}> | |
| 164 | + <Avatar login={o.login} /> {o.login} <span class="faint small">{o.role}</span> | |
| 165 | + </a> | |
| 166 | + ))} | |
| 167 | + <a href="/settings/orgs" class="muted"> | |
| 168 | + {raw(ICONS.plus)} New organization | |
| 169 | + </a> | |
| 170 | + </div> | |
| 171 | + </section> | |
| 172 | + </aside> | |
| 173 | + <div> | |
| 174 | + {repos.length === 0 ? ( | |
| 175 | + <div class="box"> | |
| 176 | + <div class="body"> | |
| 177 | + <h2>Welcome to Diffinery, {me.name ?? me.login}</h2> | |
| 178 | + <ol class="muted" style="padding-left:20px"> | |
| 179 | + <li> | |
| 180 | + <a href="/new">Create a repository</a> (or an <a href="/settings/orgs">organization</a> to share work with friends). | |
| 181 | + </li> | |
| 182 | + <li> | |
| 183 | + <a href="/settings/tokens">Create a personal access token</a> — Git uses it as the password over HTTPS. | |
| 184 | + </li> | |
| 185 | + <li>Push your code, open pull requests and add workflows under <code>.github/workflows</code>.</li> | |
| 186 | + <li> | |
| 187 | + Invite people from an organization's <b>People</b> page or a repository's <b>Settings → Access</b>. | |
| 188 | + </li> | |
| 189 | + </ol> | |
| 190 | + </div> | |
| 191 | + </div> | |
| 192 | + ) : null} | |
| 193 | + {notes.length ? ( | |
| 194 | + <div class="box"> | |
| 195 | + <div class="head"> | |
| 196 | + <h2>{raw(ICONS.bell)} Unread notifications</h2> | |
| 197 | + <a class="small" href="/notifications" style="margin-left:auto"> | |
| 198 | + View all | |
| 199 | + </a> | |
| 200 | + </div> | |
| 201 | + <ul class="list"> | |
| 202 | + {notes.slice(0, 8).map((n) => ( | |
| 203 | + <li> | |
| 204 | + <div class="grow"> | |
| 205 | + <a class="title" href={`/notifications/${n.id}`}> | |
| 206 | + {n.title} | |
| 207 | + </a> | |
| 208 | + <div class="meta"> | |
| 209 | + {n.kind.replace(/_/g, " ")} · <Time at={n.createdAt} /> | |
| 210 | + </div> | |
| 211 | + </div> | |
| 212 | + </li> | |
| 213 | + ))} | |
| 214 | + </ul> | |
| 215 | + </div> | |
| 216 | + ) : null} | |
| 217 | + <div class="box"> | |
| 218 | + <div class="head"> | |
| 219 | + <h2>Repositories</h2> | |
| 220 | + <span class="muted small">{repos.length} you can access</span> | |
| 221 | + </div> | |
| 222 | + <RepoList repos={[...mine, ...others].slice(0, 100)} empty={<a href="/new">Create your first repository</a>} /> | |
| 223 | + </div> | |
| 224 | + </div> | |
| 225 | + </div>, | |
| 226 | + ); | |
| 227 | + }); | |
| 228 | + | |
| 229 | + post(web, "/request-access", async (c, f) => { | |
| 230 | + const p = c.get("principal"); | |
| 231 | + if (p.type !== "account") throw new HttpError(401, "sign in required"); | |
| 232 | + if (p.account.status !== "pending") return c.redirect("/", 303); | |
| 233 | + return act(c, "/", async () => c.get("dir").requestAccess(p.account.id, f.s("note")), "Request sent. An administrator will review it."); | |
| 234 | + }); | |
| 235 | + | |
| 236 | + // ---------------------------------------------------------------- new repository | |
| 237 | + | |
| 238 | + const NewRepoForm: FC<{ t: string; me: Account; orgs: Array<Account & { role: string }>; values: Record<string, string>; error?: string }> = ({ t, me, orgs, values, error }) => ( | |
| 239 | + <div style="max-width:720px;margin:0 auto"> | |
| 240 | + <h1>Create a new repository</h1> | |
| 241 | + <p class="muted">A repository holds your project's files, history, issues, pull requests and workflows.</p> | |
| 242 | + {error ? <div class="flash error">{error}</div> : null} | |
| 243 | + <Form action="/new" t={t} class="box"> | |
| 244 | + <div class="body"> | |
| 245 | + <div class="row" style="align-items:flex-end"> | |
| 246 | + <div class="field" style="min-width:180px"> | |
| 247 | + <label for="owner">Owner</label> | |
| 248 | + <select id="owner" name="owner"> | |
| 249 | + <option value={me.login} selected={values.owner === me.login}> | |
| 250 | + {me.login} | |
| 251 | + </option> | |
| 252 | + {orgs.map((o) => ( | |
| 253 | + <option value={o.login} selected={values.owner === o.login}> | |
| 254 | + {o.login} | |
| 255 | + </option> | |
| 256 | + ))} | |
| 257 | + </select> | |
| 258 | + </div> | |
| 259 | + <div class="field" style="padding-bottom:8px">/</div> | |
| 260 | + <div class="field grow"> | |
| 261 | + <label for="name">Repository name</label> | |
| 262 | + <input id="name" name="name" type="text" required pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,99}" value={values.name} autofocus autocomplete="off" /> | |
| 263 | + </div> | |
| 264 | + </div> | |
| 265 | + <div class="field"> | |
| 266 | + <label for="description"> | |
| 267 | + Description <span class="faint">(optional)</span> | |
| 268 | + </label> | |
| 269 | + <input id="description" name="description" type="text" value={values.description} maxlength={350} /> | |
| 270 | + </div> | |
| 271 | + <div class="field"> | |
| 272 | + <label>Visibility</label> | |
| 273 | + {( | |
| 274 | + [ | |
| 275 | + ["private", "Private", "Only you, collaborators and organization members can see it."], | |
| 276 | + ["internal", "Internal", "Everyone signed in to this Diffinery instance can see it."], | |
| 277 | + ["public", "Public", "Anyone on the internet can see and clone it."], | |
| 278 | + ] as const | |
| 279 | + ).map(([v, l, d]) => ( | |
| 280 | + <label class="inline" style="margin:4px 0"> | |
| 281 | + <input type="radio" name="visibility" value={v} checked={(values.visibility || "private") === v} /> <b>{l}</b> <span class="muted small">— {d}</span> | |
| 282 | + </label> | |
| 283 | + ))} | |
| 284 | + </div> | |
| 285 | + <div class="field" style="max-width:280px"> | |
| 286 | + <label for="branch">Default branch</label> | |
| 287 | + <input id="branch" name="default_branch" type="text" value={values.default_branch || "main"} /> | |
| 288 | + </div> | |
| 289 | + <button class="btn primary">Create repository</button> | |
| 290 | + </div> | |
| 291 | + </Form> | |
| 292 | + </div> | |
| 293 | + ); | |
| 294 | + | |
| 295 | + web.get("/new", async (c) => { | |
| 296 | + const me = requireAccount(c); | |
| 297 | + const orgs = await c.get("dir").orgsFor(me.id); | |
| 298 | + return render(c, { title: "New repository" }, <NewRepoForm t={csrfToken(c)} me={me} orgs={orgs} values={{ owner: c.req.query("owner") ?? me.login }} />); | |
| 299 | + }); | |
| 300 | + | |
| 301 | + post(web, "/new", async (c, f) => { | |
| 302 | + const me = requireAccount(c); | |
| 303 | + const dir = c.get("dir"); | |
| 304 | + const values = { owner: f.s("owner"), name: f.s("name").trim(), description: f.s("description").trim(), visibility: f.s("visibility"), default_branch: f.s("default_branch").trim() }; | |
| 305 | + try { | |
| 306 | + const owner = values.owner === me.login ? me : await dir.accountByLogin(values.owner); | |
| 307 | + if (!owner || owner.kind === "agent") throw new HttpError(422, "unknown owner"); | |
| 308 | + const visibility = (["private", "internal", "public"] as const).find((v) => v === values.visibility) ?? "private"; | |
| 309 | + const repo = await dir.createRepo(owner, me, { name: values.name, description: values.description, visibility, defaultBranch: values.default_branch || "main" }); | |
| 310 | + c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "repo.create", target: `${repo.owner_login}/${repo.name}` })); | |
| 311 | + return c.redirect(repoUrl(repo), 303); | |
| 312 | + } catch (e) { | |
| 313 | + if (!(e instanceof HttpError) || e.status >= 500) throw e; | |
| 314 | + const orgs = await dir.orgsFor(me.id); | |
| 315 | + return render(c, { title: "New repository", status: e.status }, <NewRepoForm t={csrfToken(c)} me={me} orgs={orgs} values={values} error={e.detail} />); | |
| 316 | + } | |
| 317 | + }); | |
| 318 | + | |
| 319 | + // ---------------------------------------------------------------- notifications | |
| 320 | + | |
| 321 | + web.get("/notifications", async (c) => { | |
| 322 | + const me = requireAccount(c); | |
| 323 | + const t = csrfToken(c); | |
| 324 | + const all = c.req.query("all") === "1"; | |
| 325 | + const notes = await c.get("dir").notifications(me.id, !all); | |
| 326 | + return render( | |
| 327 | + c, | |
| 328 | + { title: "Notifications" }, | |
| 329 | + <div style="max-width:900px;margin:0 auto"> | |
| 330 | + <div class="row between mb"> | |
| 331 | + <h1 class="m0">Notifications</h1> | |
| 332 | + <div class="row"> | |
| 333 | + <div class="subnav"> | |
| 334 | + <a href="/notifications" class={!all ? "on" : ""}> | |
| 335 | + Unread | |
| 336 | + </a> | |
| 337 | + <a href="/notifications?all=1" class={all ? "on" : ""}> | |
| 338 | + All | |
| 339 | + </a> | |
| 340 | + </div> | |
| 341 | + <Form action="/notifications/read" t={t} class="inline"> | |
| 342 | + <button class="btn sm">Mark all as read</button> | |
| 343 | + </Form> | |
| 344 | + </div> | |
| 345 | + </div> | |
| 346 | + <div class="box"> | |
| 347 | + {notes.length ? ( | |
| 348 | + <ul class="list"> | |
| 349 | + {notes.map((n) => ( | |
| 350 | + <li> | |
| 351 | + <span class={`st ${n.readAt ? "faint" : ""}`} style={n.readAt ? "" : "color:var(--accent)"}> | |
| 352 | + {raw(n.kind.startsWith("pull") ? ICONS.pr : n.kind.includes("run") || n.kind.includes("workflow") ? ICONS.play : ICONS.bell)} | |
| 353 | + </span> | |
| 354 | + <div class="grow"> | |
| 355 | + <a class="title" href={`/notifications/${n.id}`} style={n.readAt ? "font-weight:400" : ""}> | |
| 356 | + {n.title} | |
| 357 | + </a> | |
| 358 | + <div class="meta"> | |
| 359 | + {n.kind.replace(/_/g, " ")} · <Time at={n.createdAt} /> | |
| 360 | + </div> | |
| 361 | + </div> | |
| 362 | + {!n.readAt ? ( | |
| 363 | + <Form action="/notifications/read" t={t} class="inline"> | |
| 364 | + <input type="hidden" name="id" value={n.id} /> | |
| 365 | + <button class="btn sm" title="Mark as read"> | |
| 366 | + {raw(ICONS.check)} | |
| 367 | + </button> | |
| 368 | + </Form> | |
| 369 | + ) : null} | |
| 370 | + </li> | |
| 371 | + ))} | |
| 372 | + </ul> | |
| 373 | + ) : ( | |
| 374 | + <div class="empty">{all ? "No notifications." : "All caught up — no unread notifications."}</div> | |
| 375 | + )} | |
| 376 | + </div> | |
| 377 | + </div>, | |
| 378 | + ); | |
| 379 | + }); | |
| 380 | + | |
| 381 | + web.get("/notifications/:id", async (c) => { | |
| 382 | + const me = requireAccount(c); | |
| 383 | + const dir = c.get("dir"); | |
| 384 | + const id = c.req.param("id"); | |
| 385 | + const n = (await dir.notifications(me.id)).find((x) => x.id === id); | |
| 386 | + if (!n) throw new HttpError(404, "notification not found"); | |
| 387 | + await dir.markRead(me.id, id); | |
| 388 | + const base = publicUrl(c); | |
| 389 | + let to = n.url; | |
| 390 | + if (to.startsWith(base)) to = to.slice(base.length) || "/"; | |
| 391 | + if (!to.startsWith("/") || to.startsWith("//")) to = "/notifications"; | |
| 392 | + return c.redirect(to, 302); | |
| 393 | + }); | |
| 394 | + | |
| 395 | + post(web, "/notifications/read", async (c, f) => { | |
| 396 | + const me = requireAccount(c); | |
| 397 | + const id = f.s("id"); | |
| 398 | + return act(c, c.req.header("referer")?.includes("/notifications") ? "/notifications" : "/", async () => c.get("dir").markRead(me.id, id || undefined)); | |
| 399 | + }); | |
| 400 | + | |
| 401 | + // ---------------------------------------------------------------- invites | |
| 402 | + | |
| 403 | + web.get("/invite/:token", async (c) => { | |
| 404 | + const p = c.get("principal"); | |
| 405 | + if (p.type !== "account") throw new HttpError(401, "sign in to accept this invite"); | |
| 406 | + if (p.account.status === "disabled") throw new HttpError(403, "your account is disabled"); | |
| 407 | + return render( | |
| 408 | + c, | |
| 409 | + { title: "Accept invite" }, | |
| 410 | + <div class="box" style="max-width:560px;margin:48px auto"> | |
| 411 | + <div class="body"> | |
| 412 | + <h1>You have been invited</h1> | |
| 413 | + <p class="muted"> | |
| 414 | + Accept this invite as <b>{p.account.login}</b> ({p.account.email ?? "no email"}). Invites only work for the email address they were sent to. | |
| 415 | + </p> | |
| 416 | + <Form action={`/invite/${encodeURIComponent(c.req.param("token"))}`} t={csrfToken(c)}> | |
| 417 | + <button class="btn primary">Accept invite</button> | |
| 418 | + </Form> | |
| 419 | + </div> | |
| 420 | + </div>, | |
| 421 | + ); | |
| 422 | + }); | |
| 423 | + | |
| 424 | + post(web, "/invite/:token", async (c) => { | |
| 425 | + const p = c.get("principal"); | |
| 426 | + if (p.type !== "account") throw new HttpError(401, "sign in to accept this invite"); | |
| 427 | + if (p.account.status === "disabled") throw new HttpError(403, "your account is disabled"); | |
| 428 | + const dir = c.get("dir"); | |
| 429 | + const res = await dir.acceptInvite(c.req.param("token"), p.account); | |
| 430 | + let to = "/"; | |
| 431 | + if (res.repoId) { | |
| 432 | + const r = await dir.repoById(res.repoId); | |
| 433 | + if (r) to = repoUrl(r); | |
| 434 | + } else if (res.orgId) { | |
| 435 | + const o = await dir.account(res.orgId); | |
| 436 | + if (o) to = `/${o.login}`; | |
| 437 | + } | |
| 438 | + c.executionCtx.waitUntil(dir.audit({ actorId: p.account.id, actorLogin: p.account.login, action: "invite.accept", target: to })); | |
| 439 | + return c.redirect(to, 303); | |
| 440 | + }); | |
| 441 | + | |
| 442 | + // ---------------------------------------------------------------- palette data | |
| 443 | + | |
| 444 | + web.get("/-/palette.json", async (c) => { | |
| 445 | + const me = requireAccount(c); | |
| 446 | + const repos = (await c.get("dir").reposVisibleTo(me)).slice(0, 300).map((r) => `${r.owner_login}/${r.name}`); | |
| 447 | + const param = c.req.query("repo") ?? ""; | |
| 448 | + let files: string[] = []; | |
| 449 | + let ref: string | undefined; | |
| 450 | + const m = /^([A-Za-z0-9][\w.-]*)\/([A-Za-z0-9][\w.-]*)$/.exec(param); | |
| 451 | + if (m) { | |
| 452 | + try { | |
| 453 | + const { stub, repo } = await loadRepo(c, m[1]!, m[2]!, "read"); | |
| 454 | + ref = c.req.query("ref") || repo.default_branch; | |
| 455 | + files = await listFiles(stub, ref); | |
| 456 | + } catch { | |
| 457 | + files = []; | |
| 458 | + } | |
| 459 | + } | |
| 460 | + return c.json({ repos, files, ref }, 200, { "cache-control": "private, max-age=30" }); | |
| 461 | + }); | |
| 462 | + | |
| 463 | + // ---------------------------------------------------------------- personal settings | |
| 464 | + | |
| 465 | + registerSettings(web); | |
| 466 | + registerProfiles(web); | |
| 467 | +} | |
| 468 | + | |
| 469 | +/** Breadth-first file listing for "go to file" (bounded). */ | |
| 470 | +async function listFiles(stub: Stub, ref: string): Promise<string[]> { | |
| 471 | + const out: string[] = []; | |
| 472 | + let queue = [""]; | |
| 473 | + let reads = 0; | |
| 474 | + while (queue.length && reads < 120 && out.length < 5000) { | |
| 475 | + const batch = queue.slice(0, 12); | |
| 476 | + queue = queue.slice(12); | |
| 477 | + reads += batch.length; | |
| 478 | + const trees = await Promise.all(batch.map((p) => stub.tree(ref, p).catch(() => null))); | |
| 479 | + for (const t of trees) { | |
| 480 | + if (!t) continue; | |
| 481 | + for (const it of t.items) { | |
| 482 | + if (it.type === "tree") { | |
| 483 | + if (!/^(node_modules|\.git|vendor|dist|target)$/.test(it.name)) queue.push(it.path); | |
| 484 | + } else if (it.type === "blob") out.push(it.path); | |
| 485 | + } | |
| 486 | + } | |
| 487 | + } | |
| 488 | + return out.slice(0, 5000); | |
| 489 | +} | |
| 490 | + | |
| 491 | +// ---------------------------------------------------------------- /settings | |
| 492 | + | |
| 493 | +type SettingsTab = "profile" | "tokens" | "agents" | "orgs" | "admin" | "audit"; | |
| 494 | + | |
| 495 | +const SettingsNav: FC<{ me: Account; tab: SettingsTab }> = ({ me, tab }) => ( | |
| 496 | + <aside class="side"> | |
| 497 | + <section> | |
| 498 | + <div class="row mb"> | |
| 499 | + <Avatar login={me.login} size="lg" /> | |
| 500 | + <div> | |
| 501 | + <b>{me.name ?? me.login}</b> | |
| 502 | + <div class="muted small">{me.login}</div> | |
| 503 | + </div> | |
| 504 | + </div> | |
| 505 | + <div class="nav"> | |
| 506 | + <a href="/settings" class={tab === "profile" ? "on" : ""}> | |
| 507 | + Profile | |
| 508 | + </a> | |
| 509 | + <a href="/settings/tokens" class={tab === "tokens" ? "on" : ""}> | |
| 510 | + Access tokens | |
| 511 | + </a> | |
| 512 | + <a href="/settings/agents" class={tab === "agents" ? "on" : ""}> | |
| 513 | + Agents | |
| 514 | + </a> | |
| 515 | + <a href="/settings/orgs" class={tab === "orgs" ? "on" : ""}> | |
| 516 | + Organizations | |
| 517 | + </a> | |
| 518 | + <a href="/notifications">Notifications</a> | |
| 519 | + {me.site_admin ? ( | |
| 520 | + <> | |
| 521 | + <a href="/settings/admin" class={tab === "admin" ? "on" : ""}> | |
| 522 | + Site administration | |
| 523 | + </a> | |
| 524 | + <a href="/settings/audit" class={tab === "audit" ? "on" : ""}> | |
| 525 | + Audit log | |
| 526 | + </a> | |
| 527 | + </> | |
| 528 | + ) : null} | |
| 529 | + </div> | |
| 530 | + </section> | |
| 531 | + </aside> | |
| 532 | +); | |
| 533 | + | |
| 534 | +const TokenOnce: FC<{ token: string; title: string; c: AppContext; note?: Child }> = ({ token, title, c, note }) => ( | |
| 535 | + <div class="box" style="border-color:var(--amber)"> | |
| 536 | + <div class="body"> | |
| 537 | + <h3>{title}</h3> | |
| 538 | + <p class="muted">Copy it now — it will not be shown again.</p> | |
| 539 | + <div class="row"> | |
| 540 | + <div class="secret grow">{token}</div> | |
| 541 | + <CopyButton text={token} /> | |
| 542 | + </div> | |
| 543 | + {note} | |
| 544 | + <details class="mt"> | |
| 545 | + <summary class="small" style="cursor:pointer"> | |
| 546 | + How to use it | |
| 547 | + </summary> | |
| 548 | + <pre class="cmd mt"> | |
| 549 | + {`# Git over HTTPS (any username, the token as password)\ngit clone https://x:${token.slice(0, 8)}…@${new URL(publicUrl(c)).host}/OWNER/REPO.git\n\n# Or store it once in your credential helper\ngit config --global credential.helper store\n\n# REST API / MCP\ncurl -H "Authorization: Bearer <token>" ${publicUrl(c)}/api/...`} | |
| 550 | + </pre> | |
| 551 | + </details> | |
| 552 | + </div> | |
| 553 | + </div> | |
| 554 | +); | |
| 555 | + | |
| 556 | +function registerSettings(web: WebApp): void { | |
| 557 | + const page = (c: AppContext, me: Account, tab: SettingsTab, title: string, body: Child, status?: number) => | |
| 558 | + render( | |
| 559 | + c, | |
| 560 | + { title, status }, | |
| 561 | + <div class="grid left"> | |
| 562 | + <SettingsNav me={me} tab={tab} /> | |
| 563 | + <div>{body}</div> | |
| 564 | + </div>, | |
| 565 | + ); | |
| 566 | + | |
| 567 | + // Profile | |
| 568 | + web.get("/settings", async (c) => { | |
| 569 | + const me = requireAccount(c); | |
| 570 | + const t = csrfToken(c); | |
| 571 | + return page( | |
| 572 | + c, | |
| 573 | + me, | |
| 574 | + "profile", | |
| 575 | + "Settings", | |
| 576 | + <> | |
| 577 | + <h1>Profile</h1> | |
| 578 | + <Form action="/settings/profile" t={t} class="box"> | |
| 579 | + <div class="body"> | |
| 580 | + <div class="field"> | |
| 581 | + <label for="login">Username</label> | |
| 582 | + <input id="login" name="login" type="text" value={me.login} required pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,99}" /> | |
| 583 | + <div class="hint">Changing your username changes the URLs of your repositories. Existing clones need a new remote URL.</div> | |
| 584 | + </div> | |
| 585 | + <div class="field"> | |
| 586 | + <label for="name">Display name</label> | |
| 587 | + <input id="name" name="name" type="text" value={me.name ?? ""} /> | |
| 588 | + </div> | |
| 589 | + <dl class="kv mb"> | |
| 590 | + <dt>Email</dt> | |
| 591 | + <dd>{me.email ?? "—"}</dd> | |
| 592 | + <dt>Account type</dt> | |
| 593 | + <dd>{me.kind}</dd> | |
| 594 | + <dt>Joined</dt> | |
| 595 | + <dd> | |
| 596 | + <Time at={me.created_at} /> | |
| 597 | + </dd> | |
| 598 | + <dt>Site admin</dt> | |
| 599 | + <dd>{me.site_admin ? "yes" : "no"}</dd> | |
| 600 | + </dl> | |
| 601 | + <button class="btn primary">Save profile</button> | |
| 602 | + </div> | |
| 603 | + </Form> | |
| 604 | + </>, | |
| 605 | + ); | |
| 606 | + }); | |
| 607 | + | |
| 608 | + post(web, "/settings/profile", async (c, f) => { | |
| 609 | + const me = requireAccount(c); | |
| 610 | + return act( | |
| 611 | + c, | |
| 612 | + "/settings", | |
| 613 | + async () => { | |
| 614 | + const login = f.s("login").trim(); | |
| 615 | + await c.get("dir").updateAccount(me.id, { login: login && login !== me.login ? login : undefined, name: f.s("name").trim() }); | |
| 616 | + }, | |
| 617 | + "Profile saved.", | |
| 618 | + ); | |
| 619 | + }); | |
| 620 | + | |
| 621 | + // Tokens | |
| 622 | + const tokensPage = async (c: AppContext, me: Account, created?: { token: string }) => { | |
| 623 | + const dir = c.get("dir"); | |
| 624 | + const t = csrfToken(c); | |
| 625 | + const [tokens, repos] = await Promise.all([dir.tokens(me.id), dir.reposVisibleTo(me)]); | |
| 626 | + const repoName = new Map(repos.map((r) => [r.id, `${r.owner_login}/${r.name}`])); | |
| 627 | + return page( | |
| 628 | + c, | |
| 629 | + me, | |
| 630 | + "tokens", | |
| 631 | + "Access tokens", | |
| 632 | + <> | |
| 633 | + <h1>Personal access tokens</h1> | |
| 634 | + <p class="muted">Tokens authenticate Git over HTTPS, the REST API, the CLI and MCP clients. Use your username (or anything) as the Git username and the token as the password.</p> | |
| 635 | + {created ? <TokenOnce c={c} token={created.token} title="Your new token" /> : null} | |
| 636 | + <div class="box"> | |
| 637 | + <div class="head"> | |
| 638 | + <h2>Active tokens</h2> | |
| 639 | + </div> | |
| 640 | + {tokens.length ? ( | |
| 641 | + <table class="tbl"> | |
| 642 | + <thead> | |
| 643 | + <tr> | |
| 644 | + <th>Name</th> | |
| 645 | + <th>Scopes</th> | |
| 646 | + <th>Repositories</th> | |
| 647 | + <th>Expires</th> | |
| 648 | + <th>Last used</th> | |
| 649 | + <th /> | |
| 650 | + </tr> | |
| 651 | + </thead> | |
| 652 | + <tbody> | |
| 653 | + {tokens.map((k) => ( | |
| 654 | + <tr> | |
| 655 | + <td> | |
| 656 | + <b>{k.name}</b> | |
| 657 | + <div class="faint mono small">{k.prefix}…</div> | |
| 658 | + </td> | |
| 659 | + <td>{k.scopes.join(", ")}</td> | |
| 660 | + <td class="small">{k.repoIds ? k.repoIds.map((id) => repoName.get(id) ?? id.slice(0, 8)).join(", ") : <span class="muted">all</span>}</td> | |
| 661 | + <td class="small">{new Date(k.expiresAt).toLocaleDateString("en-US", { dateStyle: "medium" })}</td> | |
| 662 | + <td class="small">{k.lastUsedAt ? ago(k.lastUsedAt) : <span class="muted">never</span>}</td> | |
| 663 | + <td class="right"> | |
| 664 | + <Form action={`/settings/tokens/${k.id}/revoke`} t={t} class="inline" confirm={`Revoke token "${k.name}"? Anything using it stops working.`}> | |
| 665 | + <button class="btn sm danger">Revoke</button> | |
| 666 | + </Form> | |
| 667 | + </td> | |
| 668 | + </tr> | |
| 669 | + ))} | |
| 670 | + </tbody> | |
| 671 | + </table> | |
| 672 | + ) : ( | |
| 673 | + <div class="empty">No tokens yet.</div> | |
| 674 | + )} | |
| 675 | + </div> | |
| 676 | + <Form action="/settings/tokens" t={t} class="box"> | |
| 677 | + <div class="head"> | |
| 678 | + <h2>Create a token</h2> | |
| 679 | + </div> | |
| 680 | + <div class="body"> | |
| 681 | + <div class="row" style="align-items:flex-start"> | |
| 682 | + <div class="field grow"> | |
| 683 | + <label for="tname">Name</label> | |
| 684 | + <input id="tname" name="name" type="text" required placeholder="laptop, ci, claude-code…" maxlength={80} /> | |
| 685 | + </div> | |
| 686 | + <div class="field" style="width:140px"> | |
| 687 | + <label for="days">Expires in (days)</label> | |
| 688 | + <input id="days" name="days" type="number" min={1} max={366} value="90" /> | |
| 689 | + </div> | |
| 690 | + </div> | |
| 691 | + <div class="field"> | |
| 692 | + <label>Scope</label> | |
| 693 | + {SCOPES.map((s) => ( | |
| 694 | + <label class="inline" style="margin-right:16px"> | |
| 695 | + <input type="radio" name="scope" value={s} checked={s === "write"} /> {s} | |
| 696 | + </label> | |
| 697 | + ))} | |
| 698 | + <div class="hint">read: clone and view · write: push, issues, pull requests, runs (up to maintain) · admin: everything you can do, including settings.</div> | |
| 699 | + </div> | |
| 700 | + <div class="field"> | |
| 701 | + <label for="repos"> | |
| 702 | + Restrict to repositories <span class="faint">(optional)</span> | |
| 703 | + </label> | |
| 704 | + <select id="repos" name="repos" multiple size={Math.min(6, Math.max(3, repos.length))}> | |
| 705 | + {repos.map((r) => ( | |
| 706 | + <option value={r.id}> | |
| 707 | + {r.owner_login}/{r.name} | |
| 708 | + </option> | |
| 709 | + ))} | |
| 710 | + </select> | |
| 711 | + <div class="hint">Leave empty for access to every repository you can access.</div> | |
| 712 | + </div> | |
| 713 | + <button class="btn primary">Create token</button> | |
| 714 | + </div> | |
| 715 | + </Form> | |
| 716 | + </>, | |
| 717 | + ); | |
| 718 | + }; | |
| 719 | + | |
| 720 | + web.get("/settings/tokens", async (c) => tokensPage(c, requireAccount(c))); | |
| 721 | + | |
| 722 | + post(web, "/settings/tokens", async (c, f) => { | |
| 723 | + const me = requireAccount(c); | |
| 724 | + const dir = c.get("dir"); | |
| 725 | + const scope = SCOPES.find((s) => s === f.s("scope")) ?? "write"; | |
| 726 | + const visible = new Set((await dir.reposVisibleTo(me)).map((r) => r.id)); | |
| 727 | + const repoIds = f.all("repos").filter((id) => visible.has(id)); | |
| 728 | + const name = f.s("name").trim() || "token"; | |
| 729 | + const created = await dir.createToken(me, { name, scopes: [scope], repoIds: repoIds.length ? repoIds : null, expiresInDays: parseInt(f.s("days"), 10) || 90 }); | |
| 730 | + c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "token.create", target: me.login, detail: { name, scope, repos: repoIds.length } })); | |
| 731 | + return tokensPage(c, me, created); | |
| 732 | + }); | |
| 733 | + | |
| 734 | + post(web, "/settings/tokens/:id/revoke", async (c) => { | |
| 735 | + const me = requireAccount(c); | |
| 736 | + return act( | |
| 737 | + c, | |
| 738 | + "/settings/tokens", | |
| 739 | + async () => { | |
| 740 | + await c.get("dir").revokeToken(me.id, c.req.param("id")); | |
| 741 | + c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "token.revoke", target: c.req.param("id") })); | |
| 742 | + }, | |
| 743 | + "Token revoked.", | |
| 744 | + ); | |
| 745 | + }); | |
| 746 | + | |
| 747 | + // Agents | |
| 748 | + const agentsPage = async (c: AppContext, me: Account, created?: { token: string; agent: string }) => { | |
| 749 | + const dir = c.get("dir"); | |
| 750 | + const t = csrfToken(c); | |
| 751 | + const orgs = await adminOrgs(c, me); | |
| 752 | + const agents = await dir.agentsOwnedBy([me.id, ...orgs.map((o) => o.id)]); | |
| 753 | + const ownerName = new Map<string, string>([[me.id, me.login], ...orgs.map((o) => [o.id, o.login] as [string, string])]); | |
| 754 | + const tokenLists = await Promise.all(agents.map((a) => dir.tokens(a.id))); | |
| 755 | + return page( | |
| 756 | + c, | |
| 757 | + me, | |
| 758 | + "agents", | |
| 759 | + "Agents", | |
| 760 | + <> | |
| 761 | + <h1>Agents</h1> | |
| 762 | + <p class="muted"> | |
| 763 | + Agents are accounts for AI tools and automation. They act on behalf of their owner, are capped by a policy, cannot push to protected or default branches, and their pull requests need a human approval. Give each agent its own token. | |
| 764 | + </p> | |
| 765 | + {created ? <TokenOnce c={c} token={created.token} title={`Token for ${created.agent}`} /> : null} | |
| 766 | + <div class="box"> | |
| 767 | + <div class="head"> | |
| 768 | + <h2>Your agents</h2> | |
| 769 | + </div> | |
| 770 | + {agents.length ? ( | |
| 771 | + <ul class="list"> | |
| 772 | + {agents.map((a, i) => { | |
| 773 | + const policy = parsePolicy(a); | |
| 774 | + const toks = tokenLists[i] ?? []; | |
| 775 | + return ( | |
| 776 | + <li style="display:block"> | |
| 777 | + <div class="row"> | |
| 778 | + <Actor login={a.login} kind="agent" /> | |
| 779 | + <span class="muted small"> | |
| 780 | + owned by <b>{ownerName.get(a.owner_id ?? "") ?? "?"}</b> · max role <b>{policy.maxRole ?? "write"}</b> · {a.status} | |
| 781 | + </span> | |
| 782 | + <span class="grow" /> | |
| 783 | + <Form action={`/settings/agents/${a.id}/status`} t={t} class="inline"> | |
| 784 | + <input type="hidden" name="status" value={a.status === "active" ? "disabled" : "active"} /> | |
| 785 | + <button class={`btn sm ${a.status === "active" ? "danger" : ""}`}>{a.status === "active" ? "Disable" : "Enable"}</button> | |
| 786 | + </Form> | |
| 787 | + </div> | |
| 788 | + {toks.length ? ( | |
| 789 | + <div class="small muted mt" style="margin-top:6px"> | |
| 790 | + Tokens:{" "} | |
| 791 | + {toks.map((k) => ( | |
| 792 | + <span class="row" style="display:inline-flex;gap:4px;margin-right:10px"> | |
| 793 | + <span class="mono">{k.name}</span> ({k.scopes.join(",")}, last used {k.lastUsedAt ? ago(k.lastUsedAt) : "never"}) | |
| 794 | + <Form action={`/settings/agents/${a.id}/tokens/${k.id}/revoke`} t={t} class="inline" confirm="Revoke this agent token?"> | |
| 795 | + <button class="btn sm link bad">revoke</button> | |
| 796 | + </Form> | |
| 797 | + </span> | |
| 798 | + ))} | |
| 799 | + </div> | |
| 800 | + ) : null} | |
| 801 | + <details style="margin-top:6px"> | |
| 802 | + <summary class="small" style="cursor:pointer;color:var(--accent)"> | |
| 803 | + Create a token for {a.login} | |
| 804 | + </summary> | |
| 805 | + <Form action={`/settings/agents/${a.id}/token`} t={t} class="row mt" style="align-items:flex-end"> | |
| 806 | + <div class="field grow m0"> | |
| 807 | + <label>Name</label> | |
| 808 | + <input name="name" type="text" required value="default" /> | |
| 809 | + </div> | |
| 810 | + <div class="field m0"> | |
| 811 | + <label>Scope</label> | |
| 812 | + <select name="scope"> | |
| 813 | + {SCOPES.map((s) => ( | |
| 814 | + <option value={s} selected={s === "write"}> | |
| 815 | + {s} | |
| 816 | + </option> | |
| 817 | + ))} | |
| 818 | + </select> | |
| 819 | + </div> | |
| 820 | + <div class="field m0" style="width:110px"> | |
| 821 | + <label>Days</label> | |
| 822 | + <input name="days" type="number" min={1} max={366} value="30" /> | |
| 823 | + </div> | |
| 824 | + <button class="btn primary">Create token</button> | |
| 825 | + </Form> | |
| 826 | + </details> | |
| 827 | + </li> | |
| 828 | + ); | |
| 829 | + })} | |
| 830 | + </ul> | |
| 831 | + ) : ( | |
| 832 | + <div class="empty">No agents yet.</div> | |
| 833 | + )} | |
| 834 | + </div> | |
| 835 | + <Form action="/settings/agents" t={t} class="box"> | |
| 836 | + <div class="head"> | |
| 837 | + <h2>Create an agent</h2> | |
| 838 | + </div> | |
| 839 | + <div class="body"> | |
| 840 | + <div class="row" style="align-items:flex-start"> | |
| 841 | + <div class="field grow"> | |
| 842 | + <label for="alogin">Agent username</label> | |
| 843 | + <input id="alogin" name="login" type="text" required pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,99}" placeholder="review-bot" /> | |
| 844 | + </div> | |
| 845 | + <div class="field grow"> | |
| 846 | + <label for="aname">Display name</label> | |
| 847 | + <input id="aname" name="name" type="text" placeholder="Review bot" /> | |
| 848 | + </div> | |
| 849 | + </div> | |
| 850 | + <div class="row" style="align-items:flex-start"> | |
| 851 | + <div class="field grow"> | |
| 852 | + <label for="aowner">Owner</label> | |
| 853 | + <select id="aowner" name="owner"> | |
| 854 | + <option value="">{me.login} (you)</option> | |
| 855 | + {orgs.map((o) => ( | |
| 856 | + <option value={o.id}>{o.login} (organization)</option> | |
| 857 | + ))} | |
| 858 | + </select> | |
| 859 | + <div class="hint">An agent can never do more than its owner.</div> | |
| 860 | + </div> | |
| 861 | + <div class="field grow"> | |
| 862 | + <label for="arole">Maximum role</label> | |
| 863 | + <select id="arole" name="maxRole"> | |
| 864 | + {ROLES.map((r) => ( | |
| 865 | + <option value={r} selected={r === "write"}> | |
| 866 | + {r} | |
| 867 | + </option> | |
| 868 | + ))} | |
| 869 | + </select> | |
| 870 | + </div> | |
| 871 | + </div> | |
| 872 | + <button class="btn primary">Create agent</button> | |
| 873 | + </div> | |
| 874 | + </Form> | |
| 875 | + </>, | |
| 876 | + ); | |
| 877 | + }; | |
| 878 | + | |
| 879 | + web.get("/settings/agents", async (c) => agentsPage(c, requireAccount(c))); | |
| 880 | + | |
| 881 | + post(web, "/settings/agents", async (c, f) => { | |
| 882 | + const me = requireAccount(c); | |
| 883 | + const dir = c.get("dir"); | |
| 884 | + return act( | |
| 885 | + c, | |
| 886 | + "/settings/agents", | |
| 887 | + async () => { | |
| 888 | + const maxRole = ROLES.find((r) => r === f.s("maxRole")) ?? "write"; | |
| 889 | + const a = await dir.createAgent(me, { login: f.s("login").trim(), name: f.s("name").trim() || undefined, orgId: f.s("owner") || undefined, policy: { maxRole } }); | |
| 890 | + c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "agent.create", target: a.login, detail: { maxRole } })); | |
| 891 | + }, | |
| 892 | + "Agent created. Create a token for it below.", | |
| 893 | + ); | |
| 894 | + }); | |
| 895 | + | |
| 896 | + const loadAgent = async (c: AppContext, me: Account): Promise<Account> => { | |
| 897 | + const agent = await c.get("dir").account(c.req.param("id") ?? ""); | |
| 898 | + if (!agent || !(await canManageAgent(c, me, agent))) throw new HttpError(404, "agent not found"); | |
| 899 | + return agent; | |
| 900 | + }; | |
| 901 | + | |
| 902 | + post(web, "/settings/agents/:id/token", async (c, f) => { | |
| 903 | + const me = requireAccount(c); | |
| 904 | + const agent = await loadAgent(c, me); | |
| 905 | + const dir = c.get("dir"); | |
| 906 | + const scope = SCOPES.find((s) => s === f.s("scope")) ?? "write"; | |
| 907 | + const created = await dir.createToken(agent, { name: f.s("name").trim() || "agent", scopes: [scope], expiresInDays: parseInt(f.s("days"), 10) || 30 }); | |
| 908 | + c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "agent.token.create", target: agent.login, detail: { scope } })); | |
| 909 | + return agentsPage(c, me, { token: created.token, agent: agent.login }); | |
| 910 | + }); | |
| 911 | + | |
| 912 | + post(web, "/settings/agents/:id/tokens/:tid/revoke", async (c) => { | |
| 913 | + const me = requireAccount(c); | |
| 914 | + const agent = await loadAgent(c, me); | |
| 915 | + return act(c, "/settings/agents", async () => c.get("dir").revokeToken(agent.id, c.req.param("tid")), "Token revoked."); | |
| 916 | + }); | |
| 917 | + | |
| 918 | + post(web, "/settings/agents/:id/status", async (c, f) => { | |
| 919 | + const me = requireAccount(c); | |
| 920 | + const agent = await loadAgent(c, me); | |
| 921 | + const status = f.s("status") === "active" ? "active" : "disabled"; | |
| 922 | + return act(c, "/settings/agents", async () => { | |
| 923 | + await c.get("dir").setStatus(agent.id, status); | |
| 924 | + c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: `agent.${status === "active" ? "enable" : "disable"}`, target: agent.login })); | |
| 925 | + }); | |
| 926 | + }); | |
| 927 | + | |
| 928 | + // Organizations | |
| 929 | + web.get("/settings/orgs", async (c) => { | |
| 930 | + const me = requireAccount(c); | |
| 931 | + const t = csrfToken(c); | |
| 932 | + const orgs = await c.get("dir").orgsFor(me.id); | |
| 933 | + return page( | |
| 934 | + c, | |
| 935 | + me, | |
| 936 | + "orgs", | |
| 937 | + "Organizations", | |
| 938 | + <> | |
| 939 | + <h1>Organizations</h1> | |
| 940 | + <p class="muted">Organizations group people, agents and repositories. Members get write access to the organization's repositories; owners and admins manage everything.</p> | |
| 941 | + <div class="box"> | |
| 942 | + {orgs.length ? ( | |
| 943 | + <ul class="list"> | |
| 944 | + {orgs.map((o) => ( | |
| 945 | + <li> | |
| 946 | + <Avatar login={o.login} /> | |
| 947 | + <div class="grow"> | |
| 948 | + <a class="title" href={`/${o.login}`}> | |
| 949 | + {o.login} | |
| 950 | + </a> | |
| 951 | + {o.name && o.name !== o.login ? <span class="muted"> · {o.name}</span> : null} | |
| 952 | + </div> | |
| 953 | + <span class="badge">{o.role}</span> | |
| 954 | + </li> | |
| 955 | + ))} | |
| 956 | + </ul> | |
| 957 | + ) : ( | |
| 958 | + <div class="empty">You are not a member of any organization.</div> | |
| 959 | + )} | |
| 960 | + </div> | |
| 961 | + <Form action="/settings/orgs" t={t} class="box"> | |
| 962 | + <div class="head"> | |
| 963 | + <h2>New organization</h2> | |
| 964 | + </div> | |
| 965 | + <div class="body"> | |
| 966 | + <div class="row" style="align-items:flex-start"> | |
| 967 | + <div class="field grow"> | |
| 968 | + <label for="olog">Organization name</label> | |
| 969 | + <input id="olog" name="login" type="text" required pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,99}" placeholder="my-team" /> | |
| 970 | + </div> | |
| 971 | + <div class="field grow"> | |
| 972 | + <label for="oname">Display name</label> | |
| 973 | + <input id="oname" name="name" type="text" /> | |
| 974 | + </div> | |
| 975 | + </div> | |
| 976 | + <button class="btn primary">Create organization</button> | |
| 977 | + </div> | |
| 978 | + </Form> | |
| 979 | + </>, | |
| 980 | + ); | |
| 981 | + }); | |
| 982 | + | |
| 983 | + post(web, "/settings/orgs", async (c, f) => { | |
| 984 | + const me = requireAccount(c); | |
| 985 | + const dir = c.get("dir"); | |
| 986 | + return act(c, "/settings/orgs", async () => { | |
| 987 | + const org = await dir.createOrg(f.s("login").trim(), f.s("name").trim(), me); | |
| 988 | + c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.create", target: org.login })); | |
| 989 | + return `/${org.login}?tab=people`; | |
| 990 | + }); | |
| 991 | + }); | |
| 992 | + | |
| 993 | + // Site administration | |
| 994 | + const requireSiteAdmin = (c: AppContext): Account => { | |
| 995 | + const me = requireAccount(c); | |
| 996 | + if (!me.site_admin) throw new HttpError(403, "site administrators only"); | |
| 997 | + return me; | |
| 998 | + }; | |
| 999 | + | |
| 1000 | + const adminPage = async (c: AppContext, me: Account, invite?: { url: string; email: string }) => { | |
| 1001 | + const dir = c.get("dir"); | |
| 1002 | + const t = csrfToken(c); | |
| 1003 | + const [users, requests] = await Promise.all([dir.users(), dir.accessRequests()]); | |
| 1004 | + return page( | |
| 1005 | + c, | |
| 1006 | + me, | |
| 1007 | + "admin", | |
| 1008 | + "Site administration", | |
| 1009 | + <> | |
| 1010 | + <h1>Site administration</h1> | |
| 1011 | + {invite ? ( | |
| 1012 | + <div class="box" style="border-color:var(--amber)"> | |
| 1013 | + <div class="body"> | |
| 1014 | + <h3>Invite created for {invite.email}</h3> | |
| 1015 | + <p class="muted">Send this link. Signing in with that email through Cloudflare Access also activates the account automatically.</p> | |
| 1016 | + <div class="row"> | |
| 1017 | + <div class="secret grow">{invite.url}</div> | |
| 1018 | + <CopyButton text={invite.url} /> | |
| 1019 | + </div> | |
| 1020 | + </div> | |
| 1021 | + </div> | |
| 1022 | + ) : null} | |
| 1023 | + <div class="box"> | |
| 1024 | + <div class="head"> | |
| 1025 | + <h2>Pending access requests</h2> | |
| 1026 | + <span class="badge">{requests.length}</span> | |
| 1027 | + </div> | |
| 1028 | + {requests.length ? ( | |
| 1029 | + <ul class="list"> | |
| 1030 | + {requests.map((r) => ( | |
| 1031 | + <li> | |
| 1032 | + <Avatar login={r.account.login} /> | |
| 1033 | + <div class="grow"> | |
| 1034 | + <b>{r.account.email ?? r.account.login}</b> <span class="muted small">requested <Time at={r.createdAt} /></span> | |
| 1035 | + {r.note ? <div class="meta" style="white-space:pre-wrap">{r.note}</div> : null} | |
| 1036 | + </div> | |
| 1037 | + <Form action={`/settings/admin/users/${r.account.id}/status`} t={t} class="inline"> | |
| 1038 | + <input type="hidden" name="status" value="active" /> | |
| 1039 | + <button class="btn sm green">Approve</button> | |
| 1040 | + </Form> | |
| 1041 | + <Form action={`/settings/admin/users/${r.account.id}/status`} t={t} class="inline"> | |
| 1042 | + <input type="hidden" name="status" value="disabled" /> | |
| 1043 | + <button class="btn sm danger">Deny</button> | |
| 1044 | + </Form> | |
| 1045 | + </li> | |
| 1046 | + ))} | |
| 1047 | + </ul> | |
| 1048 | + ) : ( | |
| 1049 | + <div class="empty">No pending requests.</div> | |
| 1050 | + )} | |
| 1051 | + </div> | |
| 1052 | + <Form action="/settings/admin/invite" t={t} class="box"> | |
| 1053 | + <div class="head"> | |
| 1054 | + <h2>Invite someone to this instance</h2> | |
| 1055 | + </div> | |
| 1056 | + <div class="body row" style="align-items:flex-end"> | |
| 1057 | + <div class="field grow m0"> | |
| 1058 | + <label for="iemail">Email address</label> | |
| 1059 | + <input id="iemail" name="email" type="email" required placeholder="friend@example.com" /> | |
| 1060 | + <div class="hint">They also need to be allowed by your Cloudflare Access policy.</div> | |
| 1061 | + </div> | |
| 1062 | + <button class="btn primary">Create invite</button> | |
| 1063 | + </div> | |
| 1064 | + </Form> | |
| 1065 | + <div class="box"> | |
| 1066 | + <div class="head"> | |
| 1067 | + <h2>Users</h2> | |
| 1068 | + <span class="badge">{users.length}</span> | |
| 1069 | + </div> | |
| 1070 | + <table class="tbl"> | |
| 1071 | + <thead> | |
| 1072 | + <tr> | |
| 1073 | + <th>User</th> | |
| 1074 | + <th>Email</th> | |
| 1075 | + <th>Joined</th> | |
| 1076 | + <th>Status</th> | |
| 1077 | + </tr> | |
| 1078 | + </thead> | |
| 1079 | + <tbody> | |
| 1080 | + {users.map((u) => ( | |
| 1081 | + <tr> | |
| 1082 | + <td> | |
| 1083 | + <Actor login={u.login} kind="user" /> {u.site_admin ? <span class="badge blue">admin</span> : null} | |
| 1084 | + </td> | |
| 1085 | + <td class="small">{u.email ?? "—"}</td> | |
| 1086 | + <td class="small">{ago(u.created_at)}</td> | |
| 1087 | + <td> | |
| 1088 | + {u.id === me.id ? ( | |
| 1089 | + <span class="badge green">{u.status}</span> | |
| 1090 | + ) : ( | |
| 1091 | + <Form action={`/settings/admin/users/${u.id}/status`} t={t} class="row" style="gap:6px;flex-wrap:nowrap"> | |
| 1092 | + <select name="status" style="width:auto;padding:3px 6px"> | |
| 1093 | + {(["active", "pending", "disabled"] as const).map((s) => ( | |
| 1094 | + <option value={s} selected={u.status === s}> | |
| 1095 | + {s} | |
| 1096 | + </option> | |
| 1097 | + ))} | |
| 1098 | + </select> | |
| 1099 | + <button class="btn sm">Save</button> | |
| 1100 | + </Form> | |
| 1101 | + )} | |
| 1102 | + </td> | |
| 1103 | + </tr> | |
| 1104 | + ))} | |
| 1105 | + </tbody> | |
| 1106 | + </table> | |
| 1107 | + </div> | |
| 1108 | + </>, | |
| 1109 | + ); | |
| 1110 | + }; | |
| 1111 | + | |
| 1112 | + web.get("/settings/admin", async (c) => adminPage(c, requireSiteAdmin(c))); | |
| 1113 | + | |
| 1114 | + post(web, "/settings/admin/users/:id/status", async (c, f) => { | |
| 1115 | + const me = requireSiteAdmin(c); | |
| 1116 | + const dir = c.get("dir"); | |
| 1117 | + const status = (["active", "pending", "disabled"] as const).find((s) => s === f.s("status")); | |
| 1118 | + return act( | |
| 1119 | + c, | |
| 1120 | + "/settings/admin", | |
| 1121 | + async () => { | |
| 1122 | + if (!status) throw new HttpError(422, "invalid status"); | |
| 1123 | + const target = await dir.account(c.req.param("id")); | |
| 1124 | + if (!target || target.kind !== "user") throw new HttpError(404, "user not found"); | |
| 1125 | + if (target.id === me.id) throw new HttpError(422, "you cannot change your own status"); | |
| 1126 | + await dir.setStatus(target.id, status); | |
| 1127 | + c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: `user.${status}`, target: target.login })); | |
| 1128 | + }, | |
| 1129 | + "User updated.", | |
| 1130 | + ); | |
| 1131 | + }); | |
| 1132 | + | |
| 1133 | + post(web, "/settings/admin/invite", async (c, f) => { | |
| 1134 | + const me = requireSiteAdmin(c); | |
| 1135 | + const email = f.s("email").trim(); | |
| 1136 | + if (!/^[^\s@]+@[^\s@]+$/.test(email)) throw new HttpError(422, "enter a valid email address"); | |
| 1137 | + const inv = await c.get("dir").createInvite(me, { email, role: "read" }); | |
| 1138 | + c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "invite.create", target: email })); | |
| 1139 | + return adminPage(c, me, { url: inv.url, email }); | |
| 1140 | + }); | |
| 1141 | + | |
| 1142 | + web.get("/settings/audit", async (c) => { | |
| 1143 | + const me = requireSiteAdmin(c); | |
| 1144 | + const rows = await c.get("dir").auditLog(200); | |
| 1145 | + return page( | |
| 1146 | + c, | |
| 1147 | + me, | |
| 1148 | + "audit", | |
| 1149 | + "Audit log", | |
| 1150 | + <> | |
| 1151 | + <h1>Audit log</h1> | |
| 1152 | + <p class="muted">The 200 most recent security-relevant events.</p> | |
| 1153 | + <div class="box" style="overflow:auto"> | |
| 1154 | + <table class="tbl"> | |
| 1155 | + <thead> | |
| 1156 | + <tr> | |
| 1157 | + <th>When</th> | |
| 1158 | + <th>Actor</th> | |
| 1159 | + <th>Action</th> | |
| 1160 | + <th>Target</th> | |
| 1161 | + <th>Detail</th> | |
| 1162 | + </tr> | |
| 1163 | + </thead> | |
| 1164 | + <tbody> | |
| 1165 | + {rows.map((r) => ( | |
| 1166 | + <tr> | |
| 1167 | + <td class="small nowrap"> | |
| 1168 | + <Time at={r.at} /> | |
| 1169 | + </td> | |
| 1170 | + <td class="small"> | |
| 1171 | + {r.actor_login ?? "system"} | |
| 1172 | + {r.on_behalf_of ? <span class="muted"> for {r.on_behalf_of}</span> : null} | |
| 1173 | + </td> | |
| 1174 | + <td> | |
| 1175 | + <span class="badge">{r.action}</span> | |
| 1176 | + </td> | |
| 1177 | + <td class="small mono">{r.target ?? ""}</td> | |
| 1178 | + <td class="small mono muted" style="max-width:340px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title={r.detail ?? ""}> | |
| 1179 | + {r.detail ?? ""} | |
| 1180 | + </td> | |
| 1181 | + </tr> | |
| 1182 | + ))} | |
| 1183 | + </tbody> | |
| 1184 | + </table> | |
| 1185 | + {rows.length === 0 ? <div class="empty">No events yet.</div> : null} | |
| 1186 | + </div> | |
| 1187 | + </>, | |
| 1188 | + ); | |
| 1189 | + }); | |
| 1190 | +} | |
| 1191 | + | |
| 1192 | +// ---------------------------------------------------------------- /:owner profiles | |
| 1193 | + | |
| 1194 | +type OrgTab = "repositories" | "people" | "invites" | "agents" | "settings"; | |
| 1195 | + | |
| 1196 | +function registerProfiles(web: WebApp): void { | |
| 1197 | + const orgContext = async (c: AppContext) => { | |
| 1198 | + const me = requireAccount(c); | |
| 1199 | + const dir = c.get("dir"); | |
| 1200 | + const org = await dir.accountByLogin(c.req.param("owner") ?? ""); | |
| 1201 | + if (!org) throw new HttpError(404, "not found"); | |
| 1202 | + if (org.kind !== "org") throw new HttpError(404, "not an organization"); | |
| 1203 | + const role = await dir.orgRole(org.id, me.id); | |
| 1204 | + const isAdmin = !!me.site_admin || role === "owner" || role === "admin"; | |
| 1205 | + const isOwner = !!me.site_admin || role === "owner"; | |
| 1206 | + return { me, org, role, isAdmin, isOwner, dir }; | |
| 1207 | + }; | |
| 1208 | + | |
| 1209 | + const profile = async (c: AppContext, invite?: { url: string; email: string }) => { | |
| 1210 | + const me = requireAccount(c); | |
| 1211 | + const dir = c.get("dir"); | |
| 1212 | + const t = csrfToken(c); | |
| 1213 | + const acct = await dir.accountByLogin(c.req.param("owner") ?? ""); | |
| 1214 | + if (!acct) throw new HttpError(404, "not found"); | |
| 1215 | + if (acct.login !== c.req.param("owner")) return c.redirect(`/${acct.login}${new URL(c.req.url).search}`, 301); | |
| 1216 | + | |
| 1217 | + if (acct.kind === "agent") { | |
| 1218 | + const owner = acct.owner_id ? await dir.account(acct.owner_id) : null; | |
| 1219 | + const policy = parsePolicy(acct); | |
| 1220 | + return render( | |
| 1221 | + c, | |
| 1222 | + { title: acct.login }, | |
| 1223 | + <div style="max-width:720px;margin:24px auto" class="box"> | |
| 1224 | + <div class="body row" style="gap:18px"> | |
| 1225 | + <Avatar login={acct.login} kind="agent" size="xl" /> | |
| 1226 | + <div> | |
| 1227 | + <h1 class="m0"> | |
| 1228 | + {acct.name ?? acct.login} <span class="badge agent">agent</span> | |
| 1229 | + </h1> | |
| 1230 | + <p class="muted">{acct.login}</p> | |
| 1231 | + <dl class="kv"> | |
| 1232 | + <dt>Acts on behalf of</dt> | |
| 1233 | + <dd>{owner ? <a href={`/${owner.login}`}>{owner.login}</a> : "—"}</dd> | |
| 1234 | + <dt>Maximum role</dt> | |
| 1235 | + <dd>{policy.maxRole ?? "write"}</dd> | |
| 1236 | + <dt>Status</dt> | |
| 1237 | + <dd>{acct.status}</dd> | |
| 1238 | + <dt>Created</dt> | |
| 1239 | + <dd> | |
| 1240 | + <Time at={acct.created_at} /> | |
| 1241 | + </dd> | |
| 1242 | + </dl> | |
| 1243 | + </div> | |
| 1244 | + </div> | |
| 1245 | + </div>, | |
| 1246 | + ); | |
| 1247 | + } | |
| 1248 | + | |
| 1249 | + const repos = await dir.reposVisibleTo(me, acct.id); | |
| 1250 | + if (acct.kind === "user") { | |
| 1251 | + const orgs = await dir.orgsFor(acct.id); | |
| 1252 | + return render( | |
| 1253 | + c, | |
| 1254 | + { title: acct.login }, | |
| 1255 | + <div class="grid left"> | |
| 1256 | + <aside class="side"> | |
| 1257 | + <section> | |
| 1258 | + <Avatar login={acct.login} size="xl" /> | |
| 1259 | + <h1 class="mt m0">{acct.name ?? acct.login}</h1> | |
| 1260 | + <p class="muted">{acct.login}</p> | |
| 1261 | + {acct.id === me.id ? ( | |
| 1262 | + <a class="btn sm" href="/settings"> | |
| 1263 | + Edit profile | |
| 1264 | + </a> | |
| 1265 | + ) : null} | |
| 1266 | + </section> | |
| 1267 | + {orgs.length ? ( | |
| 1268 | + <section> | |
| 1269 | + <h3>Organizations</h3> | |
| 1270 | + <div class="nav"> | |
| 1271 | + {orgs.map((o) => ( | |
| 1272 | + <a href={`/${o.login}`}> | |
| 1273 | + <Avatar login={o.login} /> {o.login} | |
| 1274 | + </a> | |
| 1275 | + ))} | |
| 1276 | + </div> | |
| 1277 | + </section> | |
| 1278 | + ) : null} | |
| 1279 | + </aside> | |
| 1280 | + <div> | |
| 1281 | + <div class="row between mb"> | |
| 1282 | + <h2 class="m0">Repositories</h2> | |
| 1283 | + {acct.id === me.id ? ( | |
| 1284 | + <a class="btn sm primary" href="/new"> | |
| 1285 | + {raw(ICONS.plus)} New | |
| 1286 | + </a> | |
| 1287 | + ) : null} | |
| 1288 | + </div> | |
| 1289 | + <div class="box"> | |
| 1290 | + <RepoList repos={repos} showOwner={false} /> | |
| 1291 | + </div> | |
| 1292 | + </div> | |
| 1293 | + </div>, | |
| 1294 | + ); | |
| 1295 | + } | |
| 1296 | + | |
| 1297 | + // Organization | |
| 1298 | + const role = await dir.orgRole(acct.id, me.id); | |
| 1299 | + const isAdmin = !!me.site_admin || role === "owner" || role === "admin"; | |
| 1300 | + const isOwner = !!me.site_admin || role === "owner"; | |
| 1301 | + const requested = (c.req.query("tab") ?? "repositories") as OrgTab; | |
| 1302 | + const tab: OrgTab = invite ? "invites" : (["repositories", "people", "invites", "agents", "settings"] as const).includes(requested) ? requested : "repositories"; | |
| 1303 | + if ((tab === "invites" && !isAdmin) || (tab === "settings" && !isOwner)) throw new HttpError(403, "organization admins only"); | |
| 1304 | + let body: Child = null; | |
| 1305 | + const base = `/${acct.login}`; | |
| 1306 | + if (tab === "repositories") { | |
| 1307 | + body = ( | |
| 1308 | + <> | |
| 1309 | + <div class="row between mb"> | |
| 1310 | + <h2 class="m0">Repositories</h2> | |
| 1311 | + {role ? ( | |
| 1312 | + <a class="btn sm primary" href={`/new?owner=${encodeURIComponent(acct.login)}`}> | |
| 1313 | + {raw(ICONS.plus)} New repository | |
| 1314 | + </a> | |
| 1315 | + ) : null} | |
| 1316 | + </div> | |
| 1317 | + <div class="box"> | |
| 1318 | + <RepoList repos={repos} showOwner={false} /> | |
| 1319 | + </div> | |
| 1320 | + </> | |
| 1321 | + ); | |
| 1322 | + } else if (tab === "people") { | |
| 1323 | + const members = await dir.orgMembers(acct.id); | |
| 1324 | + body = ( | |
| 1325 | + <> | |
| 1326 | + <h2>People</h2> | |
| 1327 | + <div class="box"> | |
| 1328 | + <table class="tbl"> | |
| 1329 | + <thead> | |
| 1330 | + <tr> | |
| 1331 | + <th>Member</th> | |
| 1332 | + <th>Role</th> | |
| 1333 | + {isAdmin ? <th /> : null} | |
| 1334 | + </tr> | |
| 1335 | + </thead> | |
| 1336 | + <tbody> | |
| 1337 | + {members.map((m) => ( | |
| 1338 | + <tr> | |
| 1339 | + <td> | |
| 1340 | + <Actor login={m.login} kind="user" /> {m.name && m.name !== m.login ? <span class="muted small">{m.name}</span> : null} | |
| 1341 | + </td> | |
| 1342 | + <td> | |
| 1343 | + {isAdmin && (isOwner || m.role !== "owner") ? ( | |
| 1344 | + <Form action={`${base}/-/org/members`} t={t} class="row" style="gap:6px;flex-wrap:nowrap"> | |
| 1345 | + <input type="hidden" name="login" value={m.login} /> | |
| 1346 | + <select name="role" style="width:auto;padding:3px 6px"> | |
| 1347 | + {(isOwner ? ["owner", "admin", "member"] : ["admin", "member"]).map((r) => ( | |
| 1348 | + <option value={r} selected={m.role === r}> | |
| 1349 | + {r} | |
| 1350 | + </option> | |
| 1351 | + ))} | |
| 1352 | + </select> | |
| 1353 | + <button class="btn sm">Save</button> | |
| 1354 | + </Form> | |
| 1355 | + ) : ( | |
| 1356 | + <span class="badge">{m.role}</span> | |
| 1357 | + )} | |
| 1358 | + </td> | |
| 1359 | + {isAdmin ? ( | |
| 1360 | + <td class="right"> | |
| 1361 | + {isOwner || m.role !== "owner" ? ( | |
| 1362 | + <Form action={`${base}/-/org/members`} t={t} class="inline" confirm={`Remove ${m.login} from ${acct.login}?`}> | |
| 1363 | + <input type="hidden" name="login" value={m.login} /> | |
| 1364 | + <input type="hidden" name="role" value="remove" /> | |
| 1365 | + <button class="btn sm danger">Remove</button> | |
| 1366 | + </Form> | |
| 1367 | + ) : null} | |
| 1368 | + </td> | |
| 1369 | + ) : null} | |
| 1370 | + </tr> | |
| 1371 | + ))} | |
| 1372 | + </tbody> | |
| 1373 | + </table> | |
| 1374 | + </div> | |
| 1375 | + {isAdmin ? ( | |
| 1376 | + <div class="row" style="align-items:stretch"> | |
| 1377 | + <Form action={`${base}/-/org/members`} t={t} class="box grow"> | |
| 1378 | + <div class="head"> | |
| 1379 | + <h3>Add an existing user</h3> | |
| 1380 | + </div> | |
| 1381 | + <div class="body row" style="align-items:flex-end"> | |
| 1382 | + <div class="field grow m0"> | |
| 1383 | + <label>Username</label> | |
| 1384 | + <input name="login" type="text" required /> | |
| 1385 | + </div> | |
| 1386 | + <div class="field m0"> | |
| 1387 | + <label>Role</label> | |
| 1388 | + <select name="role"> | |
| 1389 | + <option value="member">member</option> | |
| 1390 | + <option value="admin">admin</option> | |
| 1391 | + {isOwner ? <option value="owner">owner</option> : null} | |
| 1392 | + </select> | |
| 1393 | + </div> | |
| 1394 | + <button class="btn primary">Add</button> | |
| 1395 | + </div> | |
| 1396 | + </Form> | |
| 1397 | + <div class="box grow"> | |
| 1398 | + <div class="head"> | |
| 1399 | + <h3>Invite by email</h3> | |
| 1400 | + </div> | |
| 1401 | + <div class="body"> | |
| 1402 | + <p class="muted small"> | |
| 1403 | + New people sign in through Cloudflare Access with their email and join automatically. <a href={`${base}?tab=invites`}>Create an invite →</a> | |
| 1404 | + </p> | |
| 1405 | + </div> | |
| 1406 | + </div> | |
| 1407 | + </div> | |
| 1408 | + ) : null} | |
| 1409 | + </> | |
| 1410 | + ); | |
| 1411 | + } else if (tab === "invites") { | |
| 1412 | + const invites = await dir.invites({ orgId: acct.id }); | |
| 1413 | + body = ( | |
| 1414 | + <> | |
| 1415 | + <h2>Invites</h2> | |
| 1416 | + {invite ? ( | |
| 1417 | + <div class="box" style="border-color:var(--amber)"> | |
| 1418 | + <div class="body"> | |
| 1419 | + <h3>Invite created for {invite.email}</h3> | |
| 1420 | + <p class="muted">Send them this link. It expires in 14 days and only works for that email address.</p> | |
| 1421 | + <div class="row"> | |
| 1422 | + <div class="secret grow">{invite.url}</div> | |
| 1423 | + <CopyButton text={invite.url} /> | |
| 1424 | + </div> | |
| 1425 | + </div> | |
| 1426 | + </div> | |
| 1427 | + ) : null} | |
| 1428 | + <Form action={`${base}/-/org/invites`} t={t} class="box"> | |
| 1429 | + <div class="head"> | |
| 1430 | + <h3>Invite someone</h3> | |
| 1431 | + </div> | |
| 1432 | + <div class="body row" style="align-items:flex-end"> | |
| 1433 | + <div class="field grow m0"> | |
| 1434 | + <label>Email address</label> | |
| 1435 | + <input name="email" type="email" required placeholder="friend@example.com" /> | |
| 1436 | + </div> | |
| 1437 | + <div class="field m0"> | |
| 1438 | + <label>Role</label> | |
| 1439 | + <select name="role"> | |
| 1440 | + <option value="member">member</option> | |
| 1441 | + <option value="admin">admin</option> | |
| 1442 | + {isOwner ? <option value="owner">owner</option> : null} | |
| 1443 | + </select> | |
| 1444 | + </div> | |
| 1445 | + <button class="btn primary">Create invite</button> | |
| 1446 | + </div> | |
| 1447 | + <div class="body muted small" style="padding-top:0">The person must also be allowed by your Cloudflare Access policy to reach this site.</div> | |
| 1448 | + </Form> | |
| 1449 | + <div class="box"> | |
| 1450 | + {invites.length ? ( | |
| 1451 | + <table class="tbl"> | |
| 1452 | + <thead> | |
| 1453 | + <tr> | |
| 1454 | + <th>Email</th> | |
| 1455 | + <th>Role</th> | |
| 1456 | + <th>Created</th> | |
| 1457 | + <th>Status</th> | |
| 1458 | + </tr> | |
| 1459 | + </thead> | |
| 1460 | + <tbody> | |
| 1461 | + {invites.map((i) => ( | |
| 1462 | + <tr> | |
| 1463 | + <td>{i.email}</td> | |
| 1464 | + <td>{i.role}</td> | |
| 1465 | + <td class="small">{ago(i.createdAt)}</td> | |
| 1466 | + <td class="small"> | |
| 1467 | + {i.acceptedAt ? ( | |
| 1468 | + <span class="badge green">accepted</span> | |
| 1469 | + ) : Date.parse(i.expiresAt) < Date.now() ? ( | |
| 1470 | + <span class="badge">expired</span> | |
| 1471 | + ) : ( | |
| 1472 | + <span class="badge amber">pending</span> | |
| 1473 | + )} | |
| 1474 | + </td> | |
| 1475 | + </tr> | |
| 1476 | + ))} | |
| 1477 | + </tbody> | |
| 1478 | + </table> | |
| 1479 | + ) : ( | |
| 1480 | + <div class="empty">No invites yet.</div> | |
| 1481 | + )} | |
| 1482 | + </div> | |
| 1483 | + </> | |
| 1484 | + ); | |
| 1485 | + } else if (tab === "agents") { | |
| 1486 | + const agents = await dir.agentsOwnedBy([acct.id]); | |
| 1487 | + body = ( | |
| 1488 | + <> | |
| 1489 | + <div class="row between mb"> | |
| 1490 | + <h2 class="m0">Agents</h2> | |
| 1491 | + {isAdmin ? ( | |
| 1492 | + <a class="btn sm primary" href="/settings/agents"> | |
| 1493 | + Manage agents | |
| 1494 | + </a> | |
| 1495 | + ) : null} | |
| 1496 | + </div> | |
| 1497 | + <div class="box"> | |
| 1498 | + {agents.length ? ( | |
| 1499 | + <ul class="list"> | |
| 1500 | + {agents.map((a) => ( | |
| 1501 | + <li> | |
| 1502 | + <Actor login={a.login} kind="agent" /> | |
| 1503 | + <span class="grow" /> | |
| 1504 | + <span class="muted small">max role {parsePolicy(a).maxRole ?? "write"}</span> | |
| 1505 | + <span class={`badge ${a.status === "active" ? "green" : ""}`}>{a.status}</span> | |
| 1506 | + </li> | |
| 1507 | + ))} | |
| 1508 | + </ul> | |
| 1509 | + ) : ( | |
| 1510 | + <div class="empty">This organization has no agents.</div> | |
| 1511 | + )} | |
| 1512 | + </div> | |
| 1513 | + </> | |
| 1514 | + ); | |
| 1515 | + } else { | |
| 1516 | + body = ( | |
| 1517 | + <> | |
| 1518 | + <h2>Settings</h2> | |
| 1519 | + <Form action={`${base}/-/org/settings`} t={t} class="box"> | |
| 1520 | + <div class="body"> | |
| 1521 | + <div class="field"> | |
| 1522 | + <label>Organization name</label> | |
| 1523 | + <input name="login" type="text" value={acct.login} required pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,99}" /> | |
| 1524 | + <div class="hint">Renaming changes every repository URL in this organization.</div> | |
| 1525 | + </div> | |
| 1526 | + <div class="field"> | |
| 1527 | + <label>Display name</label> | |
| 1528 | + <input name="name" type="text" value={acct.name ?? ""} /> | |
| 1529 | + </div> | |
| 1530 | + <button class="btn primary">Save</button> | |
| 1531 | + </div> | |
| 1532 | + </Form> | |
| 1533 | + </> | |
| 1534 | + ); | |
| 1535 | + } | |
| 1536 | + return render( | |
| 1537 | + c, | |
| 1538 | + { title: acct.login }, | |
| 1539 | + <> | |
| 1540 | + <div class="row mb" style="gap:16px"> | |
| 1541 | + <Avatar login={acct.login} size="lg" /> | |
| 1542 | + <div> | |
| 1543 | + <h1 class="m0">{acct.name ?? acct.login}</h1> | |
| 1544 | + <div class="muted small"> | |
| 1545 | + Organization · {acct.login} | |
| 1546 | + {role ? ` · you are ${role === "owner" ? "an owner" : `a${role === "admin" ? "n" : ""} ${role}`}` : ""} | |
| 1547 | + </div> | |
| 1548 | + </div> | |
| 1549 | + </div> | |
| 1550 | + <nav class="tabs mb" style="border-bottom:1px solid var(--border)"> | |
| 1551 | + {( | |
| 1552 | + [ | |
| 1553 | + ["repositories", "Repositories", true], | |
| 1554 | + ["people", "People", true], | |
| 1555 | + ["invites", "Invites", isAdmin], | |
| 1556 | + ["agents", "Agents", true], | |
| 1557 | + ["settings", "Settings", isOwner], | |
| 1558 | + ] as const | |
| 1559 | + ) | |
| 1560 | + .filter((x) => x[2]) | |
| 1561 | + .map(([k, label]) => ( | |
| 1562 | + <a href={`${base}?tab=${k}`} class={tab === k ? "on" : ""}> | |
| 1563 | + {label} | |
| 1564 | + </a> | |
| 1565 | + ))} | |
| 1566 | + </nav> | |
| 1567 | + {body} | |
| 1568 | + </>, | |
| 1569 | + ); | |
| 1570 | + }; | |
| 1571 | + | |
| 1572 | + web.get("/:owner", async (c) => profile(c)); | |
| 1573 | + | |
| 1574 | + post(web, "/:owner/-/org/invites", async (c, f) => { | |
| 1575 | + const { me, org, isAdmin, isOwner, dir } = await orgContext(c); | |
| 1576 | + if (!isAdmin) throw new HttpError(403, "organization admins only"); | |
| 1577 | + const email = f.s("email").trim(); | |
| 1578 | + const role = (["member", "admin", "owner"] as const).find((r) => r === f.s("role")) ?? "member"; | |
| 1579 | + if (role === "owner" && !isOwner) throw new HttpError(403, "only owners can invite owners"); | |
| 1580 | + if (!/^[^\s@]+@[^\s@]+$/.test(email)) { | |
| 1581 | + return act(c, `/${org.login}?tab=invites`, async () => { | |
| 1582 | + throw new HttpError(422, "enter a valid email address"); | |
| 1583 | + }); | |
| 1584 | + } | |
| 1585 | + const inv = await dir.createInvite(me, { email, orgId: org.id, role }); | |
| 1586 | + c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.invite", target: org.login, detail: { email, role } })); | |
| 1587 | + return profile(c, { url: inv.url, email }); | |
| 1588 | + }); | |
| 1589 | + | |
| 1590 | + post(web, "/:owner/-/org/members", async (c, f) => { | |
| 1591 | + const { me, org, isAdmin, isOwner, dir } = await orgContext(c); | |
| 1592 | + const back = `/${org.login}?tab=people`; | |
| 1593 | + return act(c, back, async () => { | |
| 1594 | + if (!isAdmin) throw new HttpError(403, "organization admins only"); | |
| 1595 | + const user = await dir.accountByLogin(f.s("login").trim()); | |
| 1596 | + if (!user || user.kind !== "user") throw new HttpError(404, "user not found"); | |
| 1597 | + const current = await dir.orgRole(org.id, user.id); | |
| 1598 | + const role = f.s("role"); | |
| 1599 | + if ((current === "owner" || role === "owner") && !isOwner) throw new HttpError(403, "only owners can manage owners"); | |
| 1600 | + if (role === "remove") await dir.setOrgMember(org.id, user.id, null); | |
| 1601 | + else if (role === "owner" || role === "admin" || role === "member") await dir.setOrgMember(org.id, user.id, role); | |
| 1602 | + else throw new HttpError(422, "invalid role"); | |
| 1603 | + c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: role === "remove" ? "org.member.remove" : "org.member.set", target: org.login, detail: { user: user.login, role } })); | |
| 1604 | + }); | |
| 1605 | + }); | |
| 1606 | + | |
| 1607 | + post(web, "/:owner/-/org/settings", async (c, f) => { | |
| 1608 | + const { me, org, isOwner, dir } = await orgContext(c); | |
| 1609 | + return act(c, `/${org.login}?tab=settings`, async () => { | |
| 1610 | + if (!isOwner) throw new HttpError(403, "organization owners only"); | |
| 1611 | + const login = f.s("login").trim(); | |
| 1612 | + await dir.updateAccount(org.id, { login: login && login !== org.login ? login : undefined, name: f.s("name").trim() }); | |
| 1613 | + c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.update", target: org.login, detail: { login } })); | |
| 1614 | + return `/${login || org.login}?tab=settings`; | |
| 1615 | + }, "Organization updated."); | |
| 1616 | + }); | |
| 1617 | +} | |
| 1618 | + | |
| 1619 | +/** Role helper for other modules. */ | |
| 1620 | +export const roleRank = (r: Role): number => ROLE_LEVEL[r]; |
addedapps/diffinery/src/web/assets.ts+356−0View file
| @@ -1,0 +1,356 @@ | ||
| 1 | +/** Inline stylesheet, client script and icons for the web UI (no external assets). */ | |
| 2 | + | |
| 3 | +export const CSS = ` | |
| 4 | +:root{color-scheme:light dark; | |
| 5 | +--bg:#ffffff;--bg2:#f7f8fa;--bg3:#eff1f4;--bg-hover:#f3f4f7;--fg:#1c2024;--fg2:#5b6370;--fg3:#8b929c; | |
| 6 | +--border:#dfe3e8;--border2:#ebeef1;--accent:#5e6ad2;--accent-hover:#4f5bc4;--accent-fg:#fff;--accent-soft:#eef0fc; | |
| 7 | +--green:#1a7f37;--green-soft:#e3f6e8;--red:#cf222e;--red-soft:#fdecec;--amber:#9a6700;--amber-soft:#fff6d6; | |
| 8 | +--purple:#8250df;--purple-soft:#f4edff;--blue:#0969da;--blue-soft:#e6f0fd; | |
| 9 | +--add-bg:#e9f8ee;--add-ln:#cdf0d7;--del-bg:#fdeeee;--del-ln:#fbd5d5;--hunk-bg:#eef3fb;--hl:#fff8c5; | |
| 10 | +--code-bg:#f5f6f8;--shadow:0 1px 2px rgba(20,24,31,.05),0 8px 24px rgba(20,24,31,.08);--r:8px; | |
| 11 | +--mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace; | |
| 12 | +--sans:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif} | |
| 13 | +@media (prefers-color-scheme:dark){:root{ | |
| 14 | +--bg:#0f1115;--bg2:#15181d;--bg3:#1c2027;--bg-hover:#1a1e24;--fg:#e6e8eb;--fg2:#a0a7b1;--fg3:#6f7782; | |
| 15 | +--border:#2a2f37;--border2:#22262d;--accent:#7c86e8;--accent-hover:#8e97ef;--accent-fg:#0f1115;--accent-soft:#232744; | |
| 16 | +--green:#3fb950;--green-soft:#15291c;--red:#f85149;--red-soft:#2d1517;--amber:#d29922;--amber-soft:#2b2312; | |
| 17 | +--purple:#a371f7;--purple-soft:#241a35;--blue:#58a6ff;--blue-soft:#14233a; | |
| 18 | +--add-bg:#12261a;--add-ln:#1a3a25;--del-bg:#2a1416;--del-ln:#45191d;--hunk-bg:#151f30;--hl:#3a3213; | |
| 19 | +--code-bg:#171a20;--shadow:0 1px 2px rgba(0,0,0,.3),0 8px 24px rgba(0,0,0,.35)}} | |
| 20 | +*{box-sizing:border-box} | |
| 21 | +html{-webkit-text-size-adjust:100%} | |
| 22 | +body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 var(--sans)} | |
| 23 | +a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline} | |
| 24 | +code,pre,kbd,.mono{font-family:var(--mono);font-size:12.5px} | |
| 25 | +kbd{border:1px solid var(--border);border-bottom-width:2px;border-radius:4px;padding:0 5px;background:var(--bg2);font-size:11px;color:var(--fg2)} | |
| 26 | +h1,h2,h3,h4{line-height:1.25;margin:0 0 8px}h1{font-size:22px}h2{font-size:18px}h3{font-size:15px} | |
| 27 | +p{margin:0 0 10px} | |
| 28 | +hr{border:0;border-top:1px solid var(--border);margin:16px 0} | |
| 29 | +.muted{color:var(--fg2)}.faint{color:var(--fg3)}.small{font-size:12px}.nowrap{white-space:nowrap} | |
| 30 | +.ok{color:var(--green)}.bad{color:var(--red)}.warn{color:var(--amber)}.run{color:var(--amber)}.merged{color:var(--purple)} | |
| 31 | +.top{position:sticky;top:0;z-index:20;background:color-mix(in srgb,var(--bg) 88%,transparent);backdrop-filter:saturate(1.6) blur(10px);border-bottom:1px solid var(--border)} | |
| 32 | +.top .wrap{display:flex;align-items:center;gap:14px;height:52px} | |
| 33 | +.brand{display:flex;align-items:center;gap:8px;font-weight:650;color:var(--fg);letter-spacing:-.01em} | |
| 34 | +.brand:hover{text-decoration:none}.brand svg{color:var(--accent)} | |
| 35 | +.top nav{display:flex;align-items:center;gap:4px;margin-left:auto} | |
| 36 | +.top nav a{color:var(--fg2);padding:6px 10px;border-radius:6px}.top nav a:hover{background:var(--bg-hover);color:var(--fg);text-decoration:none} | |
| 37 | +.pal-btn{display:flex;align-items:center;gap:8px;min-width:220px;padding:5px 10px;border:1px solid var(--border);border-radius:7px;background:var(--bg2);color:var(--fg3);font:inherit;cursor:pointer} | |
| 38 | +.pal-btn:hover{border-color:var(--fg3)}.pal-btn kbd{margin-left:auto} | |
| 39 | +.dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--accent);margin-left:2px;vertical-align:top} | |
| 40 | +.wrap{max-width:1280px;margin:0 auto;padding:0 20px} | |
| 41 | +main.wrap{padding-top:20px;padding-bottom:60px} | |
| 42 | +.flash{border:1px solid;border-radius:var(--r);padding:10px 14px;margin:0 0 16px} | |
| 43 | +.flash.error{border-color:color-mix(in srgb,var(--red) 40%,transparent);background:var(--red-soft);color:var(--red)} | |
| 44 | +.flash.notice{border-color:color-mix(in srgb,var(--green) 40%,transparent);background:var(--green-soft);color:var(--green)} | |
| 45 | +.flash.info{border-color:color-mix(in srgb,var(--blue) 40%,transparent);background:var(--blue-soft)} | |
| 46 | +.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:6px 12px;border:1px solid var(--border);border-radius:7px;background:var(--bg);color:var(--fg);font:500 13px/1.4 var(--sans);cursor:pointer;white-space:nowrap;text-decoration:none!important} | |
| 47 | +.btn:hover{background:var(--bg-hover);border-color:var(--fg3)} | |
| 48 | +.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--accent-fg)}.btn.primary:hover{background:var(--accent-hover)} | |
| 49 | +.btn.green{background:var(--green);border-color:var(--green);color:#fff} | |
| 50 | +.btn.danger{color:var(--red)}.btn.danger:hover{background:var(--red);border-color:var(--red);color:#fff} | |
| 51 | +.btn.sm{padding:3px 8px;font-size:12px}.btn.link{border-color:transparent;background:none;color:var(--accent);padding:0} | |
| 52 | +.btn[disabled]{opacity:.5;cursor:not-allowed} | |
| 53 | +input[type=text],input[type=search],input[type=email],input[type=number],input[type=url],input[type=password],input:not([type]),select,textarea{ | |
| 54 | +width:100%;padding:7px 10px;border:1px solid var(--border);border-radius:7px;background:var(--bg);color:var(--fg);font:inherit} | |
| 55 | +textarea{min-height:120px;resize:vertical;font-family:var(--sans)}textarea.code{font-family:var(--mono);font-size:12.5px;min-height:420px;tab-size:4;white-space:pre} | |
| 56 | +input:focus,select:focus,textarea:focus{outline:2px solid color-mix(in srgb,var(--accent) 45%,transparent);outline-offset:0;border-color:var(--accent)} | |
| 57 | +input[type=checkbox],input[type=radio]{accent-color:var(--accent)} | |
| 58 | +label{display:block;font-weight:550;margin:0 0 4px}label.inline{display:inline-flex;align-items:center;gap:6px;font-weight:400} | |
| 59 | +.field{margin:0 0 14px}.field .hint{font-size:12px;color:var(--fg2);margin-top:4px;font-weight:400} | |
| 60 | +.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.row.end{justify-content:flex-end}.row.between{justify-content:space-between} | |
| 61 | +.grow{flex:1;min-width:0} | |
| 62 | +.grid{display:grid;grid-template-columns:minmax(0,1fr) 296px;gap:24px} | |
| 63 | +.grid.left{grid-template-columns:260px minmax(0,1fr)} | |
| 64 | +@media (max-width:900px){.grid,.grid.left{grid-template-columns:minmax(0,1fr)}.pal-btn{min-width:0}.pal-btn span{display:none}} | |
| 65 | +.box{border:1px solid var(--border);border-radius:var(--r);background:var(--bg);margin:0 0 16px;overflow:hidden} | |
| 66 | +.box>.head{display:flex;align-items:center;gap:10px;padding:10px 14px;background:var(--bg2);border-bottom:1px solid var(--border);flex-wrap:wrap} | |
| 67 | +.box>.body{padding:14px} | |
| 68 | +.box>.head h2,.box>.head h3{margin:0;font-size:14px} | |
| 69 | +.list{list-style:none;margin:0;padding:0} | |
| 70 | +.list>li{display:flex;gap:10px;align-items:flex-start;padding:10px 14px;border-top:1px solid var(--border2)} | |
| 71 | +.list>li:first-child{border-top:0}.list>li:hover{background:var(--bg2)} | |
| 72 | +.list .title{font-weight:600;color:var(--fg)}.list .title:hover{color:var(--accent)} | |
| 73 | +.list .meta{font-size:12px;color:var(--fg2);margin-top:2px} | |
| 74 | +.empty{padding:32px 16px;text-align:center;color:var(--fg2)} | |
| 75 | +.blank{border:1px dashed var(--border);border-radius:var(--r);padding:32px;text-align:center;color:var(--fg2)} | |
| 76 | +.badge{display:inline-flex;align-items:center;gap:4px;padding:1px 8px;border:1px solid var(--border);border-radius:999px;font-size:12px;font-weight:500;color:var(--fg2);line-height:18px;white-space:nowrap;vertical-align:middle} | |
| 77 | +.badge.agent{border-color:color-mix(in srgb,var(--purple) 45%,transparent);color:var(--purple);background:var(--purple-soft)} | |
| 78 | +.badge.green{border-color:transparent;background:var(--green-soft);color:var(--green)} | |
| 79 | +.badge.red{border-color:transparent;background:var(--red-soft);color:var(--red)} | |
| 80 | +.badge.amber{border-color:transparent;background:var(--amber-soft);color:var(--amber)} | |
| 81 | +.badge.purple{border-color:transparent;background:var(--purple-soft);color:var(--purple)} | |
| 82 | +.badge.blue{border-color:transparent;background:var(--blue-soft);color:var(--blue)} | |
| 83 | +.state{display:inline-flex;align-items:center;gap:6px;padding:5px 12px;border-radius:999px;font-weight:600;font-size:13px;color:#fff} | |
| 84 | +.state.open{background:var(--green)}.state.closed{background:var(--red)}.state.merged{background:var(--purple)}.state.draft{background:var(--fg3)} | |
| 85 | +.lbl{display:inline-block;padding:0 8px;border-radius:999px;font-size:12px;font-weight:550;line-height:20px;background:color-mix(in srgb,var(--lc) 18%,transparent);color:color-mix(in srgb,var(--lc) 70%,var(--fg));border:1px solid color-mix(in srgb,var(--lc) 40%,transparent);margin-right:4px} | |
| 86 | +.av{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:50%;background:hsl(var(--h) 55% 55%);color:#fff;font-size:11px;font-weight:700;flex:none;vertical-align:middle} | |
| 87 | +.av.lg{width:40px;height:40px;font-size:18px}.av.xl{width:96px;height:96px;font-size:40px}.av.bot{border-radius:6px} | |
| 88 | +.who{display:inline-flex;align-items:center;gap:6px;font-weight:600;color:var(--fg)} | |
| 89 | +.repohead{padding:18px 0 0;border-bottom:1px solid var(--border);background:var(--bg2)} | |
| 90 | +.repohead h1{font-size:19px;font-weight:500;display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0 0 6px} | |
| 91 | +.repohead h1 a{color:var(--fg)}.repohead h1 strong a{font-weight:650} | |
| 92 | +.tabs{display:flex;gap:2px;overflow-x:auto;scrollbar-width:none;margin-top:10px} | |
| 93 | +.tabs a{display:inline-flex;align-items:center;gap:6px;padding:8px 12px;color:var(--fg2);border-bottom:2px solid transparent;white-space:nowrap} | |
| 94 | +.tabs a:hover{color:var(--fg);text-decoration:none;border-bottom-color:var(--border)} | |
| 95 | +.tabs a.on{color:var(--fg);font-weight:600;border-bottom-color:var(--accent)} | |
| 96 | +.tabs .n{background:var(--bg3);border-radius:999px;padding:0 7px;font-size:12px;color:var(--fg2)} | |
| 97 | +.subnav{display:flex;gap:0;border:1px solid var(--border);border-radius:7px;overflow:hidden;width:max-content;max-width:100%} | |
| 98 | +.subnav a{padding:5px 12px;color:var(--fg2);border-left:1px solid var(--border)}.subnav a:first-child{border-left:0} | |
| 99 | +.subnav a.on{background:var(--accent-soft);color:var(--accent);font-weight:600}.subnav a:hover{text-decoration:none;background:var(--bg-hover)} | |
| 100 | +.side h3{font-size:12px;color:var(--fg2);text-transform:uppercase;letter-spacing:.04em;margin:0 0 8px} | |
| 101 | +.side section{padding:0 0 16px;margin:0 0 16px;border-bottom:1px solid var(--border2)} | |
| 102 | +.side .nav a{display:block;padding:6px 10px;border-radius:6px;color:var(--fg)}.side .nav a.on{background:var(--accent-soft);color:var(--accent);font-weight:600}.side .nav a:hover{background:var(--bg-hover);text-decoration:none} | |
| 103 | +details.menu{position:relative;display:inline-block} | |
| 104 | +details.menu>summary{list-style:none;cursor:pointer}details.menu>summary::-webkit-details-marker{display:none} | |
| 105 | +details.menu>.pop{position:absolute;z-index:30;top:calc(100% + 4px);left:0;min-width:260px;max-height:360px;overflow:auto;background:var(--bg);border:1px solid var(--border);border-radius:var(--r);box-shadow:var(--shadow);padding:6px} | |
| 106 | +details.menu>.pop.right{left:auto;right:0} | |
| 107 | +details.menu .pop a{display:flex;gap:8px;align-items:center;padding:6px 8px;border-radius:5px;color:var(--fg)}details.menu .pop a:hover{background:var(--bg-hover);text-decoration:none} | |
| 108 | +details.menu .pop h4{font-size:11px;text-transform:uppercase;color:var(--fg3);margin:6px 8px 4px} | |
| 109 | +.crumbs{font-size:15px;display:flex;flex-wrap:wrap;align-items:center;gap:4px}.crumbs a{font-weight:600} | |
| 110 | +table.files{width:100%;border-collapse:collapse} | |
| 111 | +table.files td{padding:7px 14px;border-top:1px solid var(--border2)}table.files tr:first-child td{border-top:0} | |
| 112 | +table.files tr:hover td{background:var(--bg2)}table.files td.ic{width:20px;padding-right:0;color:var(--fg3)} | |
| 113 | +table.files td.sz{text-align:right;color:var(--fg2);width:100px;white-space:nowrap} | |
| 114 | +table.files a{color:var(--fg)}table.files a:hover{color:var(--accent)} | |
| 115 | +.ic-dir{color:var(--accent)!important} | |
| 116 | +.clone{display:flex;gap:6px;align-items:center} | |
| 117 | +.clone input{font-family:var(--mono);font-size:12px} | |
| 118 | +pre.cmd{background:var(--code-bg);border:1px solid var(--border2);border-radius:7px;padding:12px 14px;overflow:auto;margin:0 0 12px} | |
| 119 | +.code-wrap{overflow:auto} | |
| 120 | +table.blob{border-collapse:collapse;width:100%;font-family:var(--mono);font-size:12.5px;line-height:20px} | |
| 121 | +table.blob td{padding:0 12px;vertical-align:top;white-space:pre} | |
| 122 | +table.blob td.ln{width:1%;min-width:50px;text-align:right;color:var(--fg3);user-select:none;padding-right:10px} | |
| 123 | +table.blob td.ln a{color:inherit}table.blob tr:target td{background:var(--hl)} | |
| 124 | +table.diff{border-collapse:collapse;width:100%;font-family:var(--mono);font-size:12px;line-height:20px} | |
| 125 | +table.diff td{padding:0 8px;vertical-align:top} | |
| 126 | +table.diff td.ln{width:1%;min-width:44px;text-align:right;color:var(--fg3);user-select:none;white-space:nowrap} | |
| 127 | +table.diff td.code{white-space:pre-wrap;word-break:break-all;width:100%} | |
| 128 | +table.diff tr.add td{background:var(--add-bg)}table.diff tr.add td.ln{background:var(--add-ln)} | |
| 129 | +table.diff tr.del td{background:var(--del-bg)}table.diff tr.del td.ln{background:var(--del-ln)} | |
| 130 | +table.diff tr.hunk td{background:var(--hunk-bg);color:var(--fg2);padding-top:2px;padding-bottom:2px} | |
| 131 | +table.diff tr.meta td{color:var(--fg3);font-style:italic} | |
| 132 | +table.diff td.act{width:1%;padding:0 2px} | |
| 133 | +table.diff .addc{visibility:hidden;border:0;background:var(--accent);color:#fff;border-radius:4px;width:18px;height:18px;line-height:16px;cursor:pointer;font-weight:700;padding:0} | |
| 134 | +table.diff tr:hover .addc{visibility:visible} | |
| 135 | +table.diff tr.thread td{background:var(--bg2);padding:8px 12px;font-family:var(--sans);font-size:14px;white-space:normal} | |
| 136 | +.filehead{display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--bg2);border-bottom:1px solid var(--border);cursor:pointer;position:sticky;top:52px;z-index:5;flex-wrap:wrap} | |
| 137 | +details.file{border:1px solid var(--border);border-radius:var(--r);margin:0 0 14px;overflow:hidden} | |
| 138 | +details.file>summary{list-style:none}details.file>summary::-webkit-details-marker{display:none} | |
| 139 | +details.file:not([open])>.filehead{border-bottom:0} | |
| 140 | +.stat-add{color:var(--green);font-weight:600}.stat-del{color:var(--red);font-weight:600} | |
| 141 | +.bar{display:inline-flex;gap:1px;vertical-align:middle}.bar i{width:8px;height:8px;border-radius:1px;background:var(--bg3)}.bar i.a{background:var(--green)}.bar i.d{background:var(--red)} | |
| 142 | +.comment{border:1px solid var(--border);border-radius:var(--r);margin:0 0 16px;background:var(--bg)} | |
| 143 | +.comment>.head{display:flex;gap:8px;align-items:center;padding:8px 14px;background:var(--bg2);border-bottom:1px solid var(--border);border-radius:var(--r) var(--r) 0 0;flex-wrap:wrap;font-size:13px} | |
| 144 | +.comment>.body{padding:14px} | |
| 145 | +.comment.intent{border-color:color-mix(in srgb,var(--accent) 45%,transparent)}.comment.intent>.head{background:var(--accent-soft)} | |
| 146 | +.timeline{position:relative} | |
| 147 | +.event{display:flex;gap:10px;align-items:center;padding:6px 0 6px 14px;margin:0 0 16px;color:var(--fg2);font-size:13px} | |
| 148 | +.event .ic{display:inline-flex;width:28px;height:28px;border-radius:50%;align-items:center;justify-content:center;background:var(--bg3);flex:none} | |
| 149 | +.event.approved .ic{background:var(--green-soft);color:var(--green)}.event.changes .ic{background:var(--red-soft);color:var(--red)} | |
| 150 | +.linec{border-left:3px solid var(--border);padding:6px 10px;margin:8px 0;background:var(--bg2);border-radius:0 6px 6px 0} | |
| 151 | +.linec .where{font-family:var(--mono);font-size:12px;color:var(--fg2)} | |
| 152 | +.mergebox{border:1px solid var(--border);border-radius:var(--r);margin:0 0 16px;overflow:hidden} | |
| 153 | +.mergebox>div{padding:12px 14px;border-top:1px solid var(--border2)}.mergebox>div:first-child{border-top:0} | |
| 154 | +.mergebox.clean{border-color:color-mix(in srgb,var(--green) 50%,transparent)}.mergebox.blocked,.mergebox.conflicting{border-color:color-mix(in srgb,var(--amber) 55%,transparent)} | |
| 155 | +.mergebox.merged{border-color:color-mix(in srgb,var(--purple) 50%,transparent)} | |
| 156 | +.checks{list-style:none;margin:0;padding:0}.checks li{display:flex;gap:8px;align-items:center;padding:4px 0} | |
| 157 | +.st{display:inline-flex;width:16px;height:16px;flex:none;align-items:center;justify-content:center} | |
| 158 | +.st svg{width:16px;height:16px} | |
| 159 | +.st.spin svg{animation:spin 1.1s linear infinite}@keyframes spin{to{transform:rotate(360deg)}} | |
| 160 | +.job{display:flex;align-items:center;gap:10px;padding:8px 14px;border-top:1px solid var(--border2);color:var(--fg)} | |
| 161 | +.job:hover{background:var(--bg2);text-decoration:none}.job:first-of-type{border-top:0} | |
| 162 | +.jobgroup{margin:0 0 16px} | |
| 163 | +.failure{border:1px solid color-mix(in srgb,var(--red) 45%,transparent);background:var(--red-soft);border-radius:var(--r);padding:12px 14px;margin:0 0 16px} | |
| 164 | +.failure pre{white-space:pre-wrap;margin:6px 0 0;color:var(--fg)} | |
| 165 | +.log{background:#0d1014;color:#d5d9de;border-radius:var(--r);overflow:hidden;margin:0 0 16px;border:1px solid var(--border)} | |
| 166 | +.log details{border-top:1px solid #1d2229}.log details:first-child{border-top:0} | |
| 167 | +.log summary{display:flex;gap:10px;align-items:center;padding:8px 14px;cursor:pointer;list-style:none;color:#e6e8eb} | |
| 168 | +.log summary::-webkit-details-marker{display:none}.log summary:hover{background:#151a20} | |
| 169 | +.log summary .dur{margin-left:auto;color:#7f8792;font-size:12px} | |
| 170 | +.log pre{margin:0;padding:6px 0 10px;overflow:auto;font-size:12px;line-height:19px;counter-reset:l} | |
| 171 | +.log pre span{display:block;padding:0 14px 0 64px;position:relative;white-space:pre-wrap;word-break:break-all;min-height:19px} | |
| 172 | +.log pre span::before{counter-increment:l;content:counter(l);position:absolute;left:0;width:48px;text-align:right;color:#59616c} | |
| 173 | +.log pre span.grp{color:#8fb3ff;font-weight:600}.log pre span.err{color:#ff8c84}.log pre span.wrn{color:#e5c07b} | |
| 174 | +.markdown{line-height:1.65;word-wrap:break-word} | |
| 175 | +.markdown>*:first-child{margin-top:0}.markdown>*:last-child{margin-bottom:0} | |
| 176 | +.markdown h1,.markdown h2{padding-bottom:6px;border-bottom:1px solid var(--border2);margin:22px 0 12px} | |
| 177 | +.markdown h3,.markdown h4,.markdown h5,.markdown h6{margin:18px 0 8px} | |
| 178 | +.markdown h1 .anchor,.markdown h2 .anchor,.markdown h3 .anchor,.markdown h4 .anchor,.markdown h5 .anchor,.markdown h6 .anchor{float:left;margin-left:-18px;padding-right:4px;opacity:0;color:var(--fg3)} | |
| 179 | +.markdown :is(h1,h2,h3,h4,h5,h6):hover .anchor{opacity:1} | |
| 180 | +.markdown p,.markdown ul,.markdown ol,.markdown blockquote,.markdown pre,.markdown .table-wrap{margin:0 0 14px} | |
| 181 | +.markdown ul,.markdown ol{padding-left:2em}.markdown li+li{margin-top:3px}.markdown li.task{list-style:none;margin-left:-1.4em} | |
| 182 | +.markdown code{background:var(--code-bg);padding:.15em .4em;border-radius:5px;font-size:85%} | |
| 183 | +.markdown pre{background:var(--code-bg);padding:14px;border-radius:7px;overflow:auto;line-height:1.45;border:1px solid var(--border2)} | |
| 184 | +.markdown pre code{background:none;padding:0;font-size:12.5px} | |
| 185 | +.markdown blockquote{padding:0 1em;color:var(--fg2);border-left:3px solid var(--border)} | |
| 186 | +.markdown img{max-width:100%}.markdown hr{height:2px;background:var(--border2);border:0} | |
| 187 | +.markdown .table-wrap{overflow:auto}.markdown table{border-collapse:collapse} | |
| 188 | +.markdown th,.markdown td{border:1px solid var(--border);padding:6px 12px}.markdown th{background:var(--bg2);font-weight:600} | |
| 189 | +.readme{margin-top:16px}.readme>.body{padding:24px 32px} | |
| 190 | +@media (max-width:700px){.readme>.body{padding:16px}.wrap{padding:0 12px}} | |
| 191 | +dialog#palette{border:1px solid var(--border);border-radius:12px;padding:0;width:min(640px,94vw);background:var(--bg);color:var(--fg);box-shadow:var(--shadow);margin-top:12vh} | |
| 192 | +dialog#palette::backdrop{background:rgba(10,12,16,.45)} | |
| 193 | +#palette input{border:0;border-bottom:1px solid var(--border);border-radius:0;padding:14px 16px;font-size:15px;outline:none} | |
| 194 | +#palette ul{list-style:none;margin:0;padding:6px;max-height:min(420px,60vh);overflow:auto} | |
| 195 | +#palette li a{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:6px;color:var(--fg)} | |
| 196 | +#palette li a span{margin-left:auto;color:var(--fg3);font-size:12px} | |
| 197 | +#palette li.sel a,#palette li a:hover{background:var(--accent-soft);text-decoration:none} | |
| 198 | +#palette .foot{display:flex;gap:14px;padding:8px 14px;border-top:1px solid var(--border);font-size:12px;color:var(--fg3)} | |
| 199 | +.hero{padding:72px 0 40px;text-align:center} | |
| 200 | +.hero h1{font-size:44px;letter-spacing:-.03em;margin:0 0 14px} | |
| 201 | +.hero p{font-size:18px;color:var(--fg2);max-width:640px;margin:0 auto 26px} | |
| 202 | +.features{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:16px;margin:24px 0} | |
| 203 | +.features .box{padding:18px}.features h3{margin:0 0 6px} | |
| 204 | +.stats{display:flex;gap:18px;flex-wrap:wrap;color:var(--fg2);font-size:13px} | |
| 205 | +.stats b{color:var(--fg)} | |
| 206 | +.kv{display:grid;grid-template-columns:max-content 1fr;gap:6px 16px;font-size:13px}.kv dt{color:var(--fg2)}.kv dd{margin:0} | |
| 207 | +table.tbl{width:100%;border-collapse:collapse;font-size:13px} | |
| 208 | +table.tbl th{text-align:left;font-weight:600;color:var(--fg2);padding:8px 12px;border-bottom:1px solid var(--border);background:var(--bg2);white-space:nowrap} | |
| 209 | +table.tbl td{padding:8px 12px;border-top:1px solid var(--border2);vertical-align:middle} | |
| 210 | +.secret{font-family:var(--mono);background:var(--amber-soft);border:1px solid color-mix(in srgb,var(--amber) 45%,transparent);border-radius:7px;padding:10px 12px;word-break:break-all} | |
| 211 | +.pager{display:flex;gap:8px;justify-content:center;margin:16px 0} | |
| 212 | +.sha{font-family:var(--mono);font-size:12px;padding:1px 6px;border-radius:5px;background:var(--bg3);color:var(--fg)} | |
| 213 | +a.sha:hover{text-decoration:none;background:var(--accent-soft);color:var(--accent)} | |
| 214 | +.right{text-align:right}.mt{margin-top:16px}.mb{margin-bottom:16px}.m0{margin:0} | |
| 215 | +form.inline{display:inline} | |
| 216 | +.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)} | |
| 217 | +`; | |
| 218 | + | |
| 219 | +/** Client behaviour: palette, shortcuts, copy buttons, live updates, line comments. */ | |
| 220 | +export const JS = `(()=>{ | |
| 221 | +const d=document,b=d.body,$=(s,r=d)=>r.querySelector(s),$$=(s,r=d)=>[...r.querySelectorAll(s)]; | |
| 222 | +const repo=b.dataset.repo||'',ref=b.dataset.ref||''; | |
| 223 | +const enc=p=>p.split('/').map(encodeURIComponent).join('/'); | |
| 224 | +d.addEventListener('click',e=>{ | |
| 225 | + const c=e.target.closest('[data-copy]'); | |
| 226 | + if(c){e.preventDefault();const v=c.dataset.copy;(navigator.clipboard?navigator.clipboard.writeText(v):Promise.reject()).then(()=>{const o=c.textContent;c.textContent='Copied';setTimeout(()=>{c.textContent=o},1200)}).catch(()=>{window.prompt('Copy:',v)});return} | |
| 227 | + const p=e.target.closest('[data-palette]');if(p){e.preventDefault();openPalette();return} | |
| 228 | + const a=e.target.closest('[data-comment-line]');if(a){e.preventDefault();lineComment(a);return} | |
| 229 | + const x=e.target.closest('[data-cancel-line]');if(x){e.preventDefault();const tr=x.closest('tr');tr&&tr.remove()} | |
| 230 | + if(!e.target.closest('details.menu'))$$('details.menu[open]').forEach(m=>m.removeAttribute('open')); | |
| 231 | +}); | |
| 232 | +d.addEventListener('submit',e=>{const f=e.target;if(f.dataset.confirm&&!confirm(f.dataset.confirm)){e.preventDefault();return} | |
| 233 | + if(f.dataset.match){const i=f.querySelector('[name=confirm]');if(i&&i.value!==f.dataset.match){e.preventDefault();alert('Type '+f.dataset.match+' to confirm.');return}} | |
| 234 | + $$('button[type=submit],button:not([type])',f).forEach(x=>setTimeout(()=>{x.disabled=true},0));},true); | |
| 235 | +function lineComment(btn){ | |
| 236 | + const tpl=$('#line-comment-tpl');if(!tpl)return; | |
| 237 | + const tr=btn.closest('tr');const next=tr.nextElementSibling; | |
| 238 | + if(next&&next.classList.contains('newc')){next.querySelector('textarea').focus();return} | |
| 239 | + const row=d.createElement('tr');row.className='thread newc';const td=d.createElement('td');td.colSpan=tr.children.length; | |
| 240 | + td.append(tpl.content.cloneNode(true));row.append(td);tr.after(row); | |
| 241 | + row.querySelector('[name=path]').value=btn.dataset.path;row.querySelector('[name=line]').value=btn.dataset.commentLine; | |
| 242 | + row.querySelector('[name=side]').value=btn.dataset.side||'RIGHT';row.querySelector('.where').textContent=btn.dataset.path+':'+btn.dataset.commentLine; | |
| 243 | + row.querySelector('textarea').focus(); | |
| 244 | +} | |
| 245 | +// Palette | |
| 246 | +const dlg=$('#palette'),inp=dlg&&$('input',dlg),ul=dlg&&$('ul',dlg);let items=null,shown=[],sel=0,loading=false; | |
| 247 | +function base(){const a=[['Dashboard','/'],['New repository','/new'],['Notifications','/notifications'],['Settings','/settings'],['Tokens','/settings/tokens'],['Agents','/settings/agents']].map(([t,u])=>({t,u,k:'Go to'})); | |
| 248 | + if(repo){const r='/'+repo;[['Code',''],['Issues','/issues'],['New issue','/issues/new'],['Pull requests','/pulls'],['Actions','/actions'],['Branches','/branches'],['Tags','/tags'],['Commits','/commits/'+enc(ref||'HEAD')],['Compare','/compare'],['Settings','/settings']].forEach(([t,p])=>a.unshift({t:repo+' › '+t,u:r+p,k:'Repository'}))} | |
| 249 | + return a} | |
| 250 | +async function load(){if(items||loading)return;loading=true;items=base();draw(); | |
| 251 | + try{const r=await fetch('/-/palette.json'+(repo?'?repo='+encodeURIComponent(repo)+'&ref='+encodeURIComponent(ref):''),{credentials:'same-origin'}); | |
| 252 | + if(r.ok){const j=await r.json();(j.repos||[]).forEach(x=>items.push({t:x,u:'/'+x,k:'Repository'}));(j.files||[]).forEach(f=>items.push({t:f,u:'/'+repo+'/blob/'+enc(j.ref||ref||'HEAD')+'/'+enc(f),k:'File'}));draw()}}catch(_){}} | |
| 253 | +function score(q,s){if(!q)return 1;let i=0,sc=0,last=-2;const lo=s.toLowerCase();const at=lo.indexOf(q);if(at>=0)return 1000-at-(s.length/100); | |
| 254 | + for(const ch of q){const k=lo.indexOf(ch,i);if(k<0)return -1;sc+=k===last+1?5:1;if(k===0||'/ -_.'.includes(lo[k-1]))sc+=3;last=k;i=k+1}return sc-(s.length/100)} | |
| 255 | +function draw(){if(!ul)return;const q=inp.value.trim().toLowerCase();let list=(items||[]).map(x=>[score(q,x.t),x]).filter(a=>a[0]>=0).sort((a,b)=>b[0]-a[0]).slice(0,60).map(a=>a[1]); | |
| 256 | + const m=/^#?(\\d+)$/.exec(q);if(m&&repo)list.unshift({t:'Go to #'+m[1],u:'/'+repo+'/issues/'+m[1],k:'Issue / PR'}); | |
| 257 | + shown=list;if(sel>=shown.length)sel=Math.max(0,shown.length-1); | |
| 258 | + ul.replaceChildren(...shown.map((x,i)=>{const li=d.createElement('li');if(i===sel)li.className='sel';const a=d.createElement('a');a.href=x.u;a.textContent=x.t;const k=d.createElement('span');k.textContent=x.k;a.append(k);li.append(a);return li})); | |
| 259 | + const s=$('li.sel',ul);s&&s.scrollIntoView({block:'nearest'})} | |
| 260 | +function openPalette(){if(!dlg)return;if(!dlg.open)dlg.showModal();inp.value='';sel=0;load();draw();inp.focus()} | |
| 261 | +if(dlg){inp.addEventListener('input',()=>{sel=0;draw()}); | |
| 262 | + inp.addEventListener('keydown',e=>{if(e.key==='ArrowDown'){e.preventDefault();sel=Math.min(sel+1,shown.length-1);draw()}else if(e.key==='ArrowUp'){e.preventDefault();sel=Math.max(sel-1,0);draw()}else if(e.key==='Enter'){e.preventDefault();const x=shown[sel];if(x)location.href=x.u}}); | |
| 263 | + dlg.addEventListener('click',e=>{if(e.target===dlg)dlg.close()})} | |
| 264 | +// Shortcuts | |
| 265 | +let g=0; | |
| 266 | +d.addEventListener('keydown',e=>{ | |
| 267 | + if((e.metaKey||e.ctrlKey)&&!e.altKey&&e.key.toLowerCase()==='k'){e.preventDefault();openPalette();return} | |
| 268 | + const t=e.target;if(t&&t.closest&&t.closest('input,textarea,select,[contenteditable],dialog'))return; | |
| 269 | + if(e.metaKey||e.ctrlKey||e.altKey)return; | |
| 270 | + if(g&&Date.now()-g<1500){g=0;const m={c:'',i:'/issues',p:'/pulls',a:'/actions',s:'/settings'}[e.key];if(repo&&m!==undefined){e.preventDefault();location.href='/'+repo+m}else if(e.key==='d'){location.href='/'}else if(e.key==='n'){location.href='/notifications'}return} | |
| 271 | + if(e.key==='g'){g=Date.now();return} | |
| 272 | + if(e.key==='t'&&repo){e.preventDefault();openPalette();return} | |
| 273 | + if(e.key==='/'){const s=$('input[type=search]');if(s){e.preventDefault();s.focus()}} | |
| 274 | +}); | |
| 275 | +// Live updates | |
| 276 | +const live=b.dataset.live,mode=b.dataset.liveMode||'reload'; | |
| 277 | +if(live&&repo&&'WebSocket' in window){let ws,tries=0,timer=null,lastSeq=Number(b.dataset.lastSeq||'-1'),opened=false; | |
| 278 | + function connect(){ws=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/'+repo+'/live?topics='+encodeURIComponent(live)); | |
| 279 | + ws.onopen=()=>{if(opened)refresh();opened=true;tries=0}; | |
| 280 | + ws.onmessage=e=>{if(e.data==='pong')return;let m;try{m=JSON.parse(e.data)}catch(_){return}handle(m)}; | |
| 281 | + ws.onclose=()=>{setTimeout(connect,Math.min(30000,1000*Math.pow(2,tries++)))}} | |
| 282 | + setInterval(()=>{if(ws&&ws.readyState===1)ws.send('ping')},25000); | |
| 283 | + function handle(m){const a=m.data&&m.data.action; | |
| 284 | + if(mode==='log'&&String(m.topic).startsWith('job:')){ | |
| 285 | + if(a==='logs'){append(m.data.lines||[]);return} | |
| 286 | + if(a==='step'&&m.data.step){stepState(m.data.step);return} | |
| 287 | + if(a==='completed'){setTimeout(()=>location.reload(),700);return}} | |
| 288 | + clearTimeout(timer);timer=setTimeout(refresh,350)} | |
| 289 | + async function refresh(){const frags=$$('[data-fragment]');if(!frags.length){location.reload();return} | |
| 290 | + try{const r=await fetch(location.href,{credentials:'same-origin'});if(!r.ok)return;const doc=new DOMParser().parseFromString(await r.text(),'text/html'); | |
| 291 | + for(const f of frags){if(f.contains(d.activeElement)&&d.activeElement!==b)continue;const n=doc.querySelector('[data-fragment="'+f.dataset.fragment+'"]');if(n)f.replaceWith(d.importNode(n,true))}}catch(_){}} | |
| 292 | + function clean(s){return s.replace(/\\x1b\\[[0-9;?]*[A-Za-z]/g,'')} | |
| 293 | + function section(step){let s=$('.log [data-step="'+step+'"]');if(!s){const log=$('.log');if(!log)return null;s=d.createElement('details');s.open=true;s.dataset.step=step; | |
| 294 | + const sm=d.createElement('summary');sm.innerHTML='<span class="st spin run"></span>';sm.append(step<0?'Set up job':'Step '+(step+1));s.append(sm,d.createElement('pre'));log.append(s)} | |
| 295 | + return $('pre',s)} | |
| 296 | + function append(lines){const follow=(innerHeight+scrollY)>=d.body.scrollHeight-80; | |
| 297 | + for(const l of lines){if(l.seq<=lastSeq)continue;lastSeq=l.seq;const pre=section(l.step);if(!pre)continue;const sp=d.createElement('span');const t=clean(l.text); | |
| 298 | + if(t.startsWith('::group::')){sp.className='grp';sp.textContent='\\u25be '+t.slice(9)}else if(/^##\\[error\\]|^::error/.test(t)){sp.className='err';sp.textContent=t}else if(/^::warning/.test(t)){sp.className='wrn';sp.textContent=t}else sp.textContent=t; | |
| 299 | + pre.append(sp);const det=pre.parentElement;if(det&&!det.open)det.open=true} | |
| 300 | + if(follow)scrollTo(0,d.body.scrollHeight)} | |
| 301 | + function stepState(s){const sec=$('.log [data-step="'+s.index+'"]');if(!sec){section(s.index);return stepState2(s)}stepState2(s)} | |
| 302 | + function stepState2(s){const sec=$('.log [data-step="'+s.index+'"]');if(!sec)return;const sm=$('summary',sec);if(!sm)return;const icon=$('.st',sm); | |
| 303 | + if(s.status==='completed'&&icon){icon.className='st '+(s.conclusion==='success'?'ok':s.conclusion==='failure'?'bad':'faint');icon.textContent=s.conclusion==='success'?'\\u2713':s.conclusion==='failure'?'\\u2715':'\\u2212'} | |
| 304 | + if(s.name){const label=[...sm.childNodes].find(n=>n.nodeType===3);if(label)label.textContent=s.name}} | |
| 305 | + connect()} | |
| 306 | +})();`; | |
| 307 | + | |
| 308 | +/** Inline SVG icons (16×16, stroke-based). Static strings only. */ | |
| 309 | +const I = (body: string, extra = "") => `<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"${extra}>${body}</svg>`; | |
| 310 | + | |
| 311 | +export const ICONS = { | |
| 312 | + logo: `<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M6 8.5v7M8.3 7.2c4 1 7.4 2.2 7.6 3.3M8.3 16.8c4-1 7.4-2.2 7.6-3.3"/></svg>`, | |
| 313 | + success: I('<circle cx="8" cy="8" r="6.5"/><path d="M5.2 8.2l1.9 1.9 3.7-4"/>'), | |
| 314 | + failure: I('<circle cx="8" cy="8" r="6.5"/><path d="M5.8 5.8l4.4 4.4M10.2 5.8l-4.4 4.4"/>'), | |
| 315 | + running: I('<circle cx="8" cy="8" r="6.5" opacity=".25"/><path d="M8 1.5a6.5 6.5 0 0 1 6.5 6.5"/>'), | |
| 316 | + queued: I('<circle cx="8" cy="8" r="6.5"/><circle cx="8" cy="8" r="1.6" fill="currentColor" stroke="none"/>'), | |
| 317 | + cancelled: I('<circle cx="8" cy="8" r="6.5"/><path d="M3.4 12.6l9.2-9.2"/>'), | |
| 318 | + skipped: I('<circle cx="8" cy="8" r="6.5"/><path d="M5 8h6"/>'), | |
| 319 | + neutral: I('<circle cx="8" cy="8" r="6.5"/><path d="M5 8h6"/>'), | |
| 320 | + issueOpen: I('<circle cx="8" cy="8" r="6.5"/><circle cx="8" cy="8" r="1.4" fill="currentColor"/>'), | |
| 321 | + issueClosed: I('<circle cx="8" cy="8" r="6.5"/><path d="M5.2 8.2l1.9 1.9 3.7-4"/>'), | |
| 322 | + pr: I('<circle cx="4" cy="3.5" r="1.8"/><circle cx="4" cy="12.5" r="1.8"/><circle cx="12" cy="12.5" r="1.8"/><path d="M4 5.3v5.4M12 10.7V6.5a2 2 0 0 0-2-2H7.5M9 3l-1.5 1.5L9 6"/>'), | |
| 323 | + prMerged: I('<circle cx="4" cy="3.5" r="1.8"/><circle cx="4" cy="12.5" r="1.8"/><circle cx="12" cy="8" r="1.8"/><path d="M4 5.3v5.4M4 5.3c0 2 1.5 2.7 6.2 2.7"/>'), | |
| 324 | + prClosed: I('<circle cx="4" cy="3.5" r="1.8"/><circle cx="4" cy="12.5" r="1.8"/><circle cx="12" cy="12.5" r="1.8"/><path d="M4 5.3v5.4M12 10.7V8M10.3 2.3l3.4 3.4M13.7 2.3l-3.4 3.4"/>'), | |
| 325 | + dir: I('<path d="M1.8 3.5h4.3l1.5 1.6h6.6v7.4H1.8z" fill="currentColor" fill-opacity=".15"/>'), | |
| 326 | + file: I('<path d="M3.5 1.8h5.6l3.4 3.4v9H3.5z"/><path d="M9 1.8v3.5h3.5"/>'), | |
| 327 | + submodule: I('<rect x="2" y="3" width="12" height="10" rx="1.5"/><path d="M5 8h6"/>'), | |
| 328 | + branch: I('<circle cx="4.5" cy="3.5" r="1.7"/><circle cx="4.5" cy="12.5" r="1.7"/><circle cx="11.5" cy="5" r="1.7"/><path d="M4.5 5.2v5.6M11.5 6.7c0 3-7 2-7 4.1"/>'), | |
| 329 | + tag: I('<path d="M2 2.5h5.5l6.5 6.5-5 5-6.5-6.5z"/><circle cx="5" cy="5.5" r="1" fill="currentColor"/>'), | |
| 330 | + commit: I('<circle cx="8" cy="8" r="2.5"/><path d="M1 8h4.5M10.5 8H15"/>'), | |
| 331 | + lock: I('<rect x="3" y="7" width="10" height="7" rx="1.5"/><path d="M5.5 7V5a2.5 2.5 0 0 1 5 0v2"/>'), | |
| 332 | + globe: I('<circle cx="8" cy="8" r="6.5"/><path d="M1.5 8h13M8 1.5c2 2.2 2 10.8 0 13M8 1.5c-2 2.2-2 10.8 0 13"/>'), | |
| 333 | + search: I('<circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5l3.5 3.5"/>'), | |
| 334 | + bell: I('<path d="M4 11V7a4 4 0 0 1 8 0v4l1.5 1.5h-11zM6.5 14h3"/>'), | |
| 335 | + plus: I('<path d="M8 3v10M3 8h10"/>'), | |
| 336 | + gear: I('<circle cx="8" cy="8" r="2.2"/><path d="M8 1.5v2M8 12.5v2M1.5 8h2M12.5 8h2M3.4 3.4l1.4 1.4M11.2 11.2l1.4 1.4M3.4 12.6l1.4-1.4M11.2 4.8l1.4-1.4"/>'), | |
| 337 | + play: I('<path d="M5 3.5v9l7-4.5z"/>'), | |
| 338 | + code: I('<path d="M5.5 4.5L2 8l3.5 3.5M10.5 4.5L14 8l-3.5 3.5"/>'), | |
| 339 | + comment: I('<path d="M2.5 3h11v7.5H7L4 13v-2.5H2.5z"/>'), | |
| 340 | + eye: I('<path d="M1.5 8s2.5-4.5 6.5-4.5S14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8z"/><circle cx="8" cy="8" r="2"/>'), | |
| 341 | + bot: I('<rect x="2.5" y="5" width="11" height="8" rx="2"/><path d="M8 2.5V5M5.5 9h.01M10.5 9h.01"/>'), | |
| 342 | + history: I('<path d="M2 8a6 6 0 1 0 1.8-4.3M2 2.5v3h3"/><path d="M8 5v3l2 1.5"/>'), | |
| 343 | + copy: I('<rect x="5" y="5" width="8.5" height="8.5" rx="1.5"/><path d="M3 10.5H2.5v-8h8V3"/>'), | |
| 344 | + download: I('<path d="M8 2v8M4.5 7L8 10.5 11.5 7M2.5 13.5h11"/>'), | |
| 345 | + edit: I('<path d="M10.5 2.5l3 3-8 8H2.5v-3z"/>'), | |
| 346 | + x: I('<path d="M4 4l8 8M12 4l-8 8"/>'), | |
| 347 | + check: I('<path d="M3 8.5l3 3 7-7"/>'), | |
| 348 | + people: I('<circle cx="6" cy="5.5" r="2.3"/><path d="M1.8 13.5c0-2.4 1.9-4 4.2-4s4.2 1.6 4.2 4"/><path d="M10.5 3.4a2.3 2.3 0 0 1 0 4.4M12 9.7c1.3.5 2.2 1.9 2.2 3.8"/>'), | |
| 349 | + key: I('<circle cx="5" cy="11" r="3"/><path d="M7.2 8.8L13.5 2.5M11 5l2 2"/>'), | |
| 350 | + shield: I('<path d="M8 1.5l5.5 2v4c0 3.5-2.4 6-5.5 7-3.1-1-5.5-3.5-5.5-7v-4z"/>'), | |
| 351 | + repo: I('<path d="M3 2.5h9.5v9H4.5A1.5 1.5 0 0 0 3 13z"/><path d="M3 13a1.5 1.5 0 0 0 1.5 1.5h8v-3"/>'), | |
| 352 | + org: I('<rect x="2.5" y="2" width="11" height="12.5" rx="1"/><path d="M5.5 5h1M9.5 5h1M5.5 8h1M9.5 8h1M7 14.5V11.5h2v3"/>'), | |
| 353 | + zap: I('<path d="M9 1.5L3 9h4.5L7 14.5 13 7H8.5z"/>'), | |
| 354 | +} as const; | |
| 355 | + | |
| 356 | +export type IconName = keyof typeof ICONS; |
addedapps/diffinery/src/web/csrf.ts+74−0View file
| @@ -1,0 +1,74 @@ | ||
| 1 | +import { getCookie, setCookie } from "hono/cookie"; | |
| 2 | +import type { AppContext } from "../auth/principal.js"; | |
| 3 | +import { HttpError, randomToken, safeEqual } from "../lib/util.js"; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Double-submit cookie CSRF protection for the web UI. Every rendered page | |
| 7 | + * ensures a random `dfy_csrf` cookie (HttpOnly, SameSite=Strict, Secure) and | |
| 8 | + * every form carries the same value in a hidden `_csrf` field. | |
| 9 | + */ | |
| 10 | +export const CSRF_COOKIE = "dfy_csrf"; | |
| 11 | +export const CSRF_FIELD = "_csrf"; | |
| 12 | + | |
| 13 | +const memo = new WeakMap<Request, string>(); | |
| 14 | +const VALID = /^[A-Za-z0-9_-]{20,64}$/; | |
| 15 | + | |
| 16 | +/** The CSRF token for this request; sets the cookie when the browser has none. */ | |
| 17 | +export function csrfToken(c: AppContext): string { | |
| 18 | + const cached = memo.get(c.req.raw); | |
| 19 | + if (cached) return cached; | |
| 20 | + let token = getCookie(c, CSRF_COOKIE); | |
| 21 | + if (!token || !VALID.test(token)) { | |
| 22 | + token = randomToken(24); | |
| 23 | + setCookie(c, CSRF_COOKIE, token, { path: "/", httpOnly: true, secure: true, sameSite: "Strict", maxAge: 60 * 60 * 24 * 30 }); | |
| 24 | + } | |
| 25 | + memo.set(c.req.raw, token); | |
| 26 | + return token; | |
| 27 | +} | |
| 28 | + | |
| 29 | +export interface FormData { | |
| 30 | + /** First string value of a field ("" when absent). */ | |
| 31 | + s(name: string): string; | |
| 32 | + /** All string values of a (repeated) field. */ | |
| 33 | + all(name: string): string[]; | |
| 34 | + /** Checkbox semantics: present and not "false"/"0". */ | |
| 35 | + b(name: string): boolean; | |
| 36 | + /** Field names present in the form. */ | |
| 37 | + keys(): string[]; | |
| 38 | +} | |
| 39 | + | |
| 40 | +/** Parse an urlencoded/multipart form once per request (Hono caches the body). */ | |
| 41 | +export async function formData(c: AppContext): Promise<FormData> { | |
| 42 | + const raw = (await c.req.parseBody({ all: true })) as Record<string, string | File | Array<string | File>>; | |
| 43 | + const all = (name: string): string[] => { | |
| 44 | + const v = raw[name]; | |
| 45 | + const list = Array.isArray(v) ? v : v === undefined ? [] : [v]; | |
| 46 | + return list.filter((x): x is string => typeof x === "string"); | |
| 47 | + }; | |
| 48 | + return { | |
| 49 | + s: (name) => all(name)[0] ?? "", | |
| 50 | + all, | |
| 51 | + b: (name) => { | |
| 52 | + const v = all(name)[0]; | |
| 53 | + return v !== undefined && v !== "" && v !== "false" && v !== "0" && v !== "off"; | |
| 54 | + }, | |
| 55 | + keys: () => Object.keys(raw), | |
| 56 | + }; | |
| 57 | +} | |
| 58 | + | |
| 59 | +/** Reject state-changing requests without a matching token (and cross-origin requests). */ | |
| 60 | +export async function verifyCsrf(c: AppContext): Promise<FormData> { | |
| 61 | + const origin = c.req.header("origin"); | |
| 62 | + if (origin && origin !== "null") { | |
| 63 | + const self = new URL(c.req.url).origin; | |
| 64 | + const pub = c.env.PUBLIC_URL ? new URL(c.env.PUBLIC_URL).origin : null; | |
| 65 | + if (origin !== self && origin !== pub) throw new HttpError(403, "cross-origin form submission rejected"); | |
| 66 | + } | |
| 67 | + const type = c.req.header("content-type") ?? ""; | |
| 68 | + if (!/^(application\/x-www-form-urlencoded|multipart\/form-data)/i.test(type)) throw new HttpError(415, "expected a form submission"); | |
| 69 | + const form = await formData(c); | |
| 70 | + const cookie = getCookie(c, CSRF_COOKIE); | |
| 71 | + const sent = form.s(CSRF_FIELD); | |
| 72 | + if (!cookie || !sent || !safeEqual(cookie, sent)) throw new HttpError(403, "your session token expired or is missing; reload the page and try again"); | |
| 73 | + return form; | |
| 74 | +} |
addedapps/diffinery/src/web/markdown.ts+543−0View file
| @@ -1,0 +1,543 @@ | ||
| 1 | +/** | |
| 2 | + * A small, safe Markdown renderer for READMEs, issues, pull requests and job | |
| 3 | + * summaries. All raw HTML in the source is escaped; only http(s), mailto-free | |
| 4 | + * relative and fragment URLs survive in links and images. | |
| 5 | + * | |
| 6 | + * Supported: ATX headings, paragraphs, hard breaks, fenced code, thematic | |
| 7 | + * breaks, blockquotes, ordered/unordered/task lists (nested), tables, inline | |
| 8 | + * code, bold, italic, strikethrough, links, images and bare/auto links. | |
| 9 | + */ | |
| 10 | + | |
| 11 | +export interface MarkdownBase { | |
| 12 | + /** Repository owner and name, for resolving relative links. */ | |
| 13 | + owner: string; | |
| 14 | + repo: string; | |
| 15 | + /** Ref (branch, tag or sha) that relative links point at. */ | |
| 16 | + ref: string; | |
| 17 | + /** Directory of the document inside the repository ("" for the root). */ | |
| 18 | + dir: string; | |
| 19 | +} | |
| 20 | + | |
| 21 | +export interface MarkdownOptions { | |
| 22 | + base?: MarkdownBase; | |
| 23 | +} | |
| 24 | + | |
| 25 | +const MAX_SOURCE = 512 * 1024; | |
| 26 | +const MAX_DEPTH = 12; | |
| 27 | +const MAX_INLINE = 60_000; | |
| 28 | +const MAX_SCAN = 2000; | |
| 29 | + | |
| 30 | +const ESC: Record<string, string> = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }; | |
| 31 | +export const escapeHtml = (s: string): string => s.replace(/[&<>"']/g, (c) => ESC[c]!); | |
| 32 | + | |
| 33 | +const encPath = (p: string): string => p.split("/").map(encodeURIComponent).join("/"); | |
| 34 | + | |
| 35 | +export function slugify(text: string): string { | |
| 36 | + return text | |
| 37 | + .toLowerCase() | |
| 38 | + .replace(/<[^>]*>/g, "") | |
| 39 | + .replace(/[^\p{L}\p{N}\s_-]/gu, "") | |
| 40 | + .trim() | |
| 41 | + .replace(/\s+/g, "-") | |
| 42 | + .slice(0, 80); | |
| 43 | +} | |
| 44 | + | |
| 45 | +/** Normalise "a/./b/../c" style paths; returns null when the path escapes the root. */ | |
| 46 | +function normalise(path: string): string | null { | |
| 47 | + const out: string[] = []; | |
| 48 | + for (const seg of path.split("/")) { | |
| 49 | + if (!seg || seg === ".") continue; | |
| 50 | + if (seg === "..") { | |
| 51 | + if (!out.length) return null; | |
| 52 | + out.pop(); | |
| 53 | + } else out.push(seg); | |
| 54 | + } | |
| 55 | + return out.join("/"); | |
| 56 | +} | |
| 57 | + | |
| 58 | +/** | |
| 59 | + * Validate and resolve a URL from Markdown source. Returns null for anything | |
| 60 | + * that is not http(s), a fragment, or a relative path. | |
| 61 | + */ | |
| 62 | +export function safeUrl(url: string, kind: "link" | "image", opts: MarkdownOptions): string | null { | |
| 63 | + const u = url.trim(); | |
| 64 | + if (!u) return null; | |
| 65 | + // Browsers ignore control characters and whitespace when parsing schemes. | |
| 66 | + const probe = u.replace(/[\x00-\x20\x7f]/g, ""); | |
| 67 | + if (/^https?:\/\//i.test(probe)) return u; | |
| 68 | + if (/^[a-z][a-z0-9+.-]*:/i.test(probe) || probe.startsWith("//") || probe.startsWith("\\\\") || probe.startsWith("/\\")) return null; | |
| 69 | + if (u.startsWith("#")) return kind === "link" ? `#h-${slugify(decodeSafe(u.slice(1)))}` : null; | |
| 70 | + const base = opts.base; | |
| 71 | + if (!base) return u.startsWith("/") ? u : null; | |
| 72 | + const hashAt = u.search(/[?#]/); | |
| 73 | + const pathPart = hashAt >= 0 ? u.slice(0, hashAt) : u; | |
| 74 | + const suffix = hashAt >= 0 ? u.slice(hashAt) : ""; | |
| 75 | + const joined = pathPart.startsWith("/") ? pathPart : `${base.dir ? `${base.dir}/` : ""}${pathPart}`; | |
| 76 | + const norm = normalise(decodeSafe(joined)); | |
| 77 | + if (norm === null) return null; | |
| 78 | + const root = `/${base.owner}/${base.repo}`; | |
| 79 | + if (kind === "image") return `${root}/raw/${encPath(base.ref)}/${encPath(norm)}`; | |
| 80 | + const isDir = pathPart.endsWith("/") || norm === ""; | |
| 81 | + return `${root}/${isDir ? "tree" : "blob"}/${encPath(base.ref)}${norm ? `/${encPath(norm)}` : ""}${suffix.replace(/[<>"']/g, "")}`; | |
| 82 | +} | |
| 83 | + | |
| 84 | +function decodeSafe(s: string): string { | |
| 85 | + try { | |
| 86 | + return decodeURIComponent(s); | |
| 87 | + } catch { | |
| 88 | + return s; | |
| 89 | + } | |
| 90 | +} | |
| 91 | + | |
| 92 | +// ---------------------------------------------------------------- inline | |
| 93 | + | |
| 94 | +function findClosing(src: string, delim: string, from: number, wordy: boolean): number { | |
| 95 | + let at = from; | |
| 96 | + const limit = Math.min(src.length, from + MAX_SCAN * 2); | |
| 97 | + for (;;) { | |
| 98 | + const idx = src.indexOf(delim, at); | |
| 99 | + if (idx < 0 || idx >= limit) return -1; | |
| 100 | + const before = src[idx - 1]; | |
| 101 | + const after = src[idx + delim.length]; | |
| 102 | + if (idx > from && before !== undefined && !/\s/.test(before) && (!wordy || after === undefined || !/[\p{L}\p{N}]/u.test(after))) { | |
| 103 | + // When closing a single delimiter, skip over a double run ("**") that belongs to nested strong text. | |
| 104 | + if (delim.length === 1 && after === delim && src[idx + 2] !== delim) { | |
| 105 | + at = idx + 2; | |
| 106 | + continue; | |
| 107 | + } | |
| 108 | + return idx; | |
| 109 | + } | |
| 110 | + at = idx + 1; | |
| 111 | + } | |
| 112 | +} | |
| 113 | + | |
| 114 | +interface LinkMatch { | |
| 115 | + text: string; | |
| 116 | + url: string; | |
| 117 | + title: string | null; | |
| 118 | + end: number; | |
| 119 | +} | |
| 120 | + | |
| 121 | +/** Parse `[text](url "title")` starting at the "[" at `i`. */ | |
| 122 | +function parseLink(src: string, i: number): LinkMatch | null { | |
| 123 | + let depth = 0; | |
| 124 | + let j = i; | |
| 125 | + const limit = Math.min(src.length, i + MAX_SCAN); | |
| 126 | + for (; j < limit; j++) { | |
| 127 | + const ch = src[j]; | |
| 128 | + if (ch === "\\") { | |
| 129 | + j++; | |
| 130 | + continue; | |
| 131 | + } | |
| 132 | + if (ch === "`") { | |
| 133 | + const close = src.indexOf("`", j + 1); | |
| 134 | + if (close > 0 && close < limit) j = close; | |
| 135 | + continue; | |
| 136 | + } | |
| 137 | + if (ch === "[") depth++; | |
| 138 | + else if (ch === "]") { | |
| 139 | + depth--; | |
| 140 | + if (depth === 0) break; | |
| 141 | + } | |
| 142 | + } | |
| 143 | + if (depth !== 0 || src[j] !== "]" || src[j + 1] !== "(") return null; | |
| 144 | + const text = src.slice(i + 1, j); | |
| 145 | + let k = j + 2; | |
| 146 | + while (src[k] === " ") k++; | |
| 147 | + let url = ""; | |
| 148 | + if (src[k] === "<") { | |
| 149 | + const close = src.indexOf(">", k); | |
| 150 | + if (close < 0 || close - k > MAX_SCAN) return null; | |
| 151 | + url = src.slice(k + 1, close); | |
| 152 | + k = close + 1; | |
| 153 | + } else { | |
| 154 | + let parens = 0; | |
| 155 | + const start = k; | |
| 156 | + for (; k < src.length && k - start < MAX_SCAN; k++) { | |
| 157 | + const ch = src[k]!; | |
| 158 | + if (ch === "\\") { | |
| 159 | + k++; | |
| 160 | + continue; | |
| 161 | + } | |
| 162 | + if (/\s/.test(ch)) break; | |
| 163 | + if (ch === "(") parens++; | |
| 164 | + else if (ch === ")") { | |
| 165 | + if (parens === 0) break; | |
| 166 | + parens--; | |
| 167 | + } | |
| 168 | + } | |
| 169 | + url = src.slice(start, k).replace(/\\([()])/g, "$1"); | |
| 170 | + } | |
| 171 | + while (src[k] === " ") k++; | |
| 172 | + let title: string | null = null; | |
| 173 | + const q = src[k]; | |
| 174 | + if (q === '"' || q === "'" || q === "(") { | |
| 175 | + const closeCh = q === "(" ? ")" : q; | |
| 176 | + const close = src.indexOf(closeCh, k + 1); | |
| 177 | + if (close < 0) return null; | |
| 178 | + title = src.slice(k + 1, close); | |
| 179 | + k = close + 1; | |
| 180 | + while (src[k] === " ") k++; | |
| 181 | + } | |
| 182 | + if (src[k] !== ")") return null; | |
| 183 | + return { text, url, title, end: k + 1 }; | |
| 184 | +} | |
| 185 | + | |
| 186 | +const BARE_URL = /^https?:\/\/[^\s<>"'`]*[^\s<>"'`.,:;!?)\]}*_~]/i; | |
| 187 | + | |
| 188 | +function inline(src: string, opts: MarkdownOptions, depth = 0): string { | |
| 189 | + if (src.length > MAX_INLINE || depth > MAX_DEPTH) return escapeHtml(src); | |
| 190 | + let out = ""; | |
| 191 | + let i = 0; | |
| 192 | + const n = src.length; | |
| 193 | + while (i < n) { | |
| 194 | + const ch = src[i]!; | |
| 195 | + // Backslash escapes and hard line breaks. | |
| 196 | + if (ch === "\\") { | |
| 197 | + const next = src[i + 1]; | |
| 198 | + if (next === "\n") { | |
| 199 | + out += "<br>\n"; | |
| 200 | + i += 2; | |
| 201 | + continue; | |
| 202 | + } | |
| 203 | + if (next !== undefined && /[!-/:-@[-`{-~]/.test(next)) { | |
| 204 | + out += escapeHtml(next); | |
| 205 | + i += 2; | |
| 206 | + continue; | |
| 207 | + } | |
| 208 | + } | |
| 209 | + if (ch === "\n") { | |
| 210 | + if (out.endsWith(" ")) out = `${out.trimEnd()}<br>`; | |
| 211 | + out += "\n"; | |
| 212 | + i++; | |
| 213 | + continue; | |
| 214 | + } | |
| 215 | + // Code spans. | |
| 216 | + if (ch === "`") { | |
| 217 | + let run = 1; | |
| 218 | + while (src[i + run] === "`") run++; | |
| 219 | + const fence = "`".repeat(run); | |
| 220 | + let close = src.indexOf(fence, i + run); | |
| 221 | + while (close >= 0 && src[close + run] === "`") { | |
| 222 | + let skip = close; | |
| 223 | + while (src[skip] === "`") skip++; | |
| 224 | + close = src.indexOf(fence, skip); | |
| 225 | + } | |
| 226 | + if (close >= 0) { | |
| 227 | + let code = src.slice(i + run, close).replace(/\n/g, " "); | |
| 228 | + if (code.length > 2 && code.startsWith(" ") && code.endsWith(" ") && code.trim()) code = code.slice(1, -1); | |
| 229 | + out += `<code>${escapeHtml(code)}</code>`; | |
| 230 | + i = close + run; | |
| 231 | + continue; | |
| 232 | + } | |
| 233 | + out += fence; | |
| 234 | + i += run; | |
| 235 | + continue; | |
| 236 | + } | |
| 237 | + // Images. | |
| 238 | + if (ch === "!" && src[i + 1] === "[") { | |
| 239 | + const m = parseLink(src, i + 1); | |
| 240 | + if (m) { | |
| 241 | + const url = safeUrl(m.url, "image", opts); | |
| 242 | + const alt = escapeHtml(m.text.replace(/[\\*_`[\]]/g, "")); | |
| 243 | + if (url) out += `<img src="${escapeHtml(url)}" alt="${alt}"${m.title ? ` title="${escapeHtml(m.title)}"` : ""} loading="lazy" referrerpolicy="no-referrer">`; | |
| 244 | + else out += alt; | |
| 245 | + i = m.end; | |
| 246 | + continue; | |
| 247 | + } | |
| 248 | + } | |
| 249 | + // Links. | |
| 250 | + if (ch === "[") { | |
| 251 | + const m = parseLink(src, i); | |
| 252 | + if (m) { | |
| 253 | + const url = safeUrl(m.url, "link", opts); | |
| 254 | + const label = inline(m.text, opts, depth + 1); | |
| 255 | + if (url) { | |
| 256 | + const external = /^https?:/i.test(url); | |
| 257 | + out += `<a href="${escapeHtml(url)}"${m.title ? ` title="${escapeHtml(m.title)}"` : ""}${external ? ' rel="nofollow noopener noreferrer"' : ""}>${label}</a>`; | |
| 258 | + } else out += label; | |
| 259 | + i = m.end; | |
| 260 | + continue; | |
| 261 | + } | |
| 262 | + } | |
| 263 | + // Autolinks <https://…>. | |
| 264 | + if (ch === "<") { | |
| 265 | + const m = /^<(https?:\/\/[^\s<>]+)>/i.exec(src.slice(i, i + MAX_SCAN)); | |
| 266 | + if (m) { | |
| 267 | + const url = escapeHtml(m[1]!); | |
| 268 | + out += `<a href="${url}" rel="nofollow noopener noreferrer">${url}</a>`; | |
| 269 | + i += m[0].length; | |
| 270 | + continue; | |
| 271 | + } | |
| 272 | + } | |
| 273 | + // Bare URLs. | |
| 274 | + if ((ch === "h" || ch === "H") && (i === 0 || /[\s(]/.test(src[i - 1]!))) { | |
| 275 | + const m = BARE_URL.exec(src.slice(i, i + MAX_SCAN)); | |
| 276 | + if (m) { | |
| 277 | + const url = escapeHtml(m[0]); | |
| 278 | + out += `<a href="${url}" rel="nofollow noopener noreferrer">${url}</a>`; | |
| 279 | + i += m[0].length; | |
| 280 | + continue; | |
| 281 | + } | |
| 282 | + } | |
| 283 | + // Strikethrough. | |
| 284 | + if (ch === "~" && src[i + 1] === "~" && src[i + 2] && !/\s/.test(src[i + 2]!)) { | |
| 285 | + const close = findClosing(src, "~~", i + 2, false); | |
| 286 | + if (close > i + 2) { | |
| 287 | + out += `<del>${inline(src.slice(i + 2, close), opts, depth + 1)}</del>`; | |
| 288 | + i = close + 2; | |
| 289 | + continue; | |
| 290 | + } | |
| 291 | + } | |
| 292 | + // Emphasis. | |
| 293 | + if (ch === "*" || ch === "_") { | |
| 294 | + let run = 1; | |
| 295 | + while (src[i + run] === ch) run++; | |
| 296 | + const wordy = ch === "_"; | |
| 297 | + const prev = src[i - 1]; | |
| 298 | + const nextCh = src[i + run]; | |
| 299 | + const canOpen = nextCh !== undefined && !/\s/.test(nextCh) && !(wordy && prev !== undefined && /[\p{L}\p{N}]/u.test(prev)); | |
| 300 | + let matched = false; | |
| 301 | + if (canOpen && run <= 3) { | |
| 302 | + for (const size of run === 3 ? [3, 2, 1] : run === 2 ? [2, 1] : [1]) { | |
| 303 | + const delim = ch.repeat(size); | |
| 304 | + const start = i + run; | |
| 305 | + const close = findClosing(src, delim, start, wordy); | |
| 306 | + if (close > start) { | |
| 307 | + const inner = inline(src.slice(start, close), opts, depth + 1); | |
| 308 | + out += escapeHtml(ch.repeat(run - size)); | |
| 309 | + out += size === 3 ? `<strong><em>${inner}</em></strong>` : size === 2 ? `<strong>${inner}</strong>` : `<em>${inner}</em>`; | |
| 310 | + i = close + size; | |
| 311 | + matched = true; | |
| 312 | + break; | |
| 313 | + } | |
| 314 | + } | |
| 315 | + } | |
| 316 | + if (matched) continue; | |
| 317 | + out += escapeHtml(ch.repeat(run)); | |
| 318 | + i += run; | |
| 319 | + continue; | |
| 320 | + } | |
| 321 | + out += ESC[ch] ?? ch; | |
| 322 | + i++; | |
| 323 | + } | |
| 324 | + return out; | |
| 325 | +} | |
| 326 | + | |
| 327 | +// ---------------------------------------------------------------- blocks | |
| 328 | + | |
| 329 | +const FENCE = /^ {0,3}(`{3,}|~{3,})\s*([^`\s]*)[^`]*$/; | |
| 330 | +const HEADING = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$/; | |
| 331 | +const HR = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/; | |
| 332 | +const QUOTE = /^ {0,3}> ?(.*)$/; | |
| 333 | +const LIST_ITEM = /^( {0,12})([*+-]|\d{1,9}[.)])([ \t]+(.*))?$/; | |
| 334 | +const TABLE_SEP = /^ {0,3}\|?[ \t]*:?-+:?[ \t]*(\|[ \t]*:?-+:?[ \t]*)*\|?[ \t]*$/; | |
| 335 | + | |
| 336 | +function splitRow(line: string): string[] { | |
| 337 | + let s = line.trim(); | |
| 338 | + if (s.startsWith("|")) s = s.slice(1); | |
| 339 | + if (s.endsWith("|") && !s.endsWith("\\|")) s = s.slice(0, -1); | |
| 340 | + const cells: string[] = []; | |
| 341 | + let cur = ""; | |
| 342 | + let inCode = false; | |
| 343 | + for (let i = 0; i < s.length; i++) { | |
| 344 | + const ch = s[i]!; | |
| 345 | + if (ch === "\\" && s[i + 1] === "|") { | |
| 346 | + cur += "|"; | |
| 347 | + i++; | |
| 348 | + continue; | |
| 349 | + } | |
| 350 | + if (ch === "`") inCode = !inCode; | |
| 351 | + if (ch === "|" && !inCode) { | |
| 352 | + cells.push(cur.trim()); | |
| 353 | + cur = ""; | |
| 354 | + continue; | |
| 355 | + } | |
| 356 | + cur += ch; | |
| 357 | + } | |
| 358 | + cells.push(cur.trim()); | |
| 359 | + return cells; | |
| 360 | +} | |
| 361 | + | |
| 362 | +function startsBlock(line: string, next: string | undefined): boolean { | |
| 363 | + return ( | |
| 364 | + FENCE.test(line) || | |
| 365 | + HEADING.test(line) || | |
| 366 | + HR.test(line) || | |
| 367 | + QUOTE.test(line) || | |
| 368 | + /^ {0,3}([*+-]|1[.)])[ \t]+\S/.test(line) || | |
| 369 | + (line.includes("|") && next !== undefined && TABLE_SEP.test(next) && next.includes("-")) | |
| 370 | + ); | |
| 371 | +} | |
| 372 | + | |
| 373 | +function blocks(lines: string[], opts: MarkdownOptions, depth: number, tight = false): string { | |
| 374 | + if (depth > MAX_DEPTH) return `<p>${escapeHtml(lines.join("\n"))}</p>`; | |
| 375 | + let i = 0; | |
| 376 | + const parts: string[] = []; | |
| 377 | + while (i < lines.length) { | |
| 378 | + const line = lines[i]!; | |
| 379 | + if (!line.trim()) { | |
| 380 | + i++; | |
| 381 | + continue; | |
| 382 | + } | |
| 383 | + // Fenced code. | |
| 384 | + const fence = FENCE.exec(line); | |
| 385 | + if (fence) { | |
| 386 | + const marker = fence[1]!; | |
| 387 | + const lang = fence[2] ?? ""; | |
| 388 | + const indent = line.length - line.trimStart().length; | |
| 389 | + const body: string[] = []; | |
| 390 | + i++; | |
| 391 | + while (i < lines.length && !new RegExp(`^ {0,3}${marker[0] === "`" ? "`" : "~"}{${marker.length},}\\s*$`).test(lines[i]!)) { | |
| 392 | + body.push(lines[i]!.replace(new RegExp(`^ {0,${indent}}`), "")); | |
| 393 | + i++; | |
| 394 | + } | |
| 395 | + i++; | |
| 396 | + const cls = lang && /^[\w+#.-]{1,30}$/.test(lang) ? ` class="language-${escapeHtml(lang)}"` : ""; | |
| 397 | + parts.push(`<pre><code${cls}>${escapeHtml(body.join("\n"))}</code></pre>`); | |
| 398 | + continue; | |
| 399 | + } | |
| 400 | + // Indented code (4 spaces), only outside lists. | |
| 401 | + if (/^( {4}|\t)/.test(line) && !tight) { | |
| 402 | + const body: string[] = []; | |
| 403 | + while (i < lines.length && (/^( {4}|\t)/.test(lines[i]!) || !lines[i]!.trim())) { | |
| 404 | + body.push(lines[i]!.replace(/^( {4}|\t)/, "")); | |
| 405 | + i++; | |
| 406 | + } | |
| 407 | + while (body.length && !body[body.length - 1]!.trim()) body.pop(); | |
| 408 | + parts.push(`<pre><code>${escapeHtml(body.join("\n"))}</code></pre>`); | |
| 409 | + continue; | |
| 410 | + } | |
| 411 | + const h = HEADING.exec(line); | |
| 412 | + if (h) { | |
| 413 | + const level = h[1]!.length; | |
| 414 | + const text = h[2] ?? ""; | |
| 415 | + const html = inline(text, opts, depth); | |
| 416 | + const slug = slugify(text); | |
| 417 | + parts.push(`<h${level} id="h-${escapeHtml(slug)}"><a class="anchor" href="#h-${escapeHtml(slug)}" aria-hidden="true">#</a>${html}</h${level}>`); | |
| 418 | + i++; | |
| 419 | + continue; | |
| 420 | + } | |
| 421 | + if (HR.test(line)) { | |
| 422 | + parts.push("<hr>"); | |
| 423 | + i++; | |
| 424 | + continue; | |
| 425 | + } | |
| 426 | + if (QUOTE.test(line)) { | |
| 427 | + const body: string[] = []; | |
| 428 | + while (i < lines.length && lines[i]!.trim()) { | |
| 429 | + const m = QUOTE.exec(lines[i]!); | |
| 430 | + if (m) body.push(m[1]!); | |
| 431 | + else if (body.length && !startsBlock(lines[i]!, lines[i + 1])) body.push(lines[i]!); | |
| 432 | + else break; | |
| 433 | + i++; | |
| 434 | + } | |
| 435 | + parts.push(`<blockquote>${blocks(body, opts, depth + 1)}</blockquote>`); | |
| 436 | + continue; | |
| 437 | + } | |
| 438 | + // Tables. | |
| 439 | + if (line.includes("|") && i + 1 < lines.length && TABLE_SEP.test(lines[i + 1]!) && lines[i + 1]!.includes("-")) { | |
| 440 | + const head = splitRow(line); | |
| 441 | + const aligns = splitRow(lines[i + 1]!).map((c) => (c.startsWith(":") && c.endsWith(":") ? "center" : c.endsWith(":") ? "right" : c.startsWith(":") ? "left" : "")); | |
| 442 | + i += 2; | |
| 443 | + const rows: string[][] = []; | |
| 444 | + while (i < lines.length && lines[i]!.trim() && lines[i]!.includes("|")) { | |
| 445 | + rows.push(splitRow(lines[i]!)); | |
| 446 | + i++; | |
| 447 | + } | |
| 448 | + const cell = (tag: string, text: string, idx: number) => `<${tag}${aligns[idx] ? ` style="text-align:${aligns[idx]}"` : ""}>${inline(text, opts, depth)}</${tag}>`; | |
| 449 | + let t = `<div class="table-wrap"><table><thead><tr>${head.map((c, idx) => cell("th", c, idx)).join("")}</tr></thead>`; | |
| 450 | + if (rows.length) t += `<tbody>${rows.map((r) => `<tr>${head.map((_, idx) => cell("td", r[idx] ?? "", idx)).join("")}</tr>`).join("")}</tbody>`; | |
| 451 | + parts.push(`${t}</table></div>`); | |
| 452 | + continue; | |
| 453 | + } | |
| 454 | + // Lists. | |
| 455 | + const li = LIST_ITEM.exec(line); | |
| 456 | + if (li) { | |
| 457 | + const ordered = /\d/.test(li[2]!); | |
| 458 | + const baseIndent = li[1]!.length; | |
| 459 | + const start = ordered ? parseInt(li[2]!, 10) : 1; | |
| 460 | + const items: string[][] = []; | |
| 461 | + let loose = false; | |
| 462 | + while (i < lines.length) { | |
| 463 | + const m = LIST_ITEM.exec(lines[i]!); | |
| 464 | + if (!m || m[1]!.length > baseIndent + 1 || m[1]!.length < baseIndent || /\d/.test(m[2]!) !== ordered) break; | |
| 465 | + const contentIndent = m[1]!.length + m[2]!.length + Math.max(1, Math.min(4, (m[3] ?? " ").length - (m[4] ?? "").length)); | |
| 466 | + const item: string[] = [m[4] ?? ""]; | |
| 467 | + i++; | |
| 468 | + let sawBlank = false; | |
| 469 | + while (i < lines.length) { | |
| 470 | + const l = lines[i]!; | |
| 471 | + if (!l.trim()) { | |
| 472 | + sawBlank = true; | |
| 473 | + item.push(""); | |
| 474 | + i++; | |
| 475 | + continue; | |
| 476 | + } | |
| 477 | + const ind = l.length - l.trimStart().length; | |
| 478 | + if (ind >= contentIndent || (ind > baseIndent && LIST_ITEM.test(l))) { | |
| 479 | + item.push(l.slice(Math.min(ind, contentIndent))); | |
| 480 | + sawBlank = false; | |
| 481 | + i++; | |
| 482 | + continue; | |
| 483 | + } | |
| 484 | + if (!sawBlank && !startsBlock(l, lines[i + 1]) && !LIST_ITEM.test(l)) { | |
| 485 | + // Lazy continuation of the item's paragraph. | |
| 486 | + item.push(l.trim()); | |
| 487 | + i++; | |
| 488 | + continue; | |
| 489 | + } | |
| 490 | + break; | |
| 491 | + } | |
| 492 | + while (item.length && !item[item.length - 1]!.trim()) { | |
| 493 | + item.pop(); | |
| 494 | + if (i < lines.length && LIST_ITEM.test(lines[i]!)) loose = true; | |
| 495 | + } | |
| 496 | + if (item.slice(1).some((l, idx) => !l.trim() && item[idx + 2] !== undefined && !/^\s*([*+-]|\d+[.)])\s/.test(item[idx + 2]!))) loose = true; | |
| 497 | + items.push(item); | |
| 498 | + } | |
| 499 | + const rendered = items.map((item) => { | |
| 500 | + let first = item[0] ?? ""; | |
| 501 | + let task = ""; | |
| 502 | + const tm = /^\[([ xX])\][ \t]+/.exec(first); | |
| 503 | + if (tm) { | |
| 504 | + task = `<input type="checkbox" disabled${tm[1] !== " " ? " checked" : ""}> `; | |
| 505 | + first = first.slice(tm[0].length); | |
| 506 | + } | |
| 507 | + const body = blocks([first, ...item.slice(1)], opts, depth + 1, !loose); | |
| 508 | + return `<li${task ? ' class="task"' : ""}>${task}${body}</li>`; | |
| 509 | + }); | |
| 510 | + parts.push(ordered ? `<ol${start !== 1 ? ` start="${start}"` : ""}>${rendered.join("")}</ol>` : `<ul>${rendered.join("")}</ul>`); | |
| 511 | + continue; | |
| 512 | + } | |
| 513 | + // Paragraph. | |
| 514 | + const para: string[] = [line.trimStart()]; | |
| 515 | + i++; | |
| 516 | + while (i < lines.length && lines[i]!.trim() && !startsBlock(lines[i]!, lines[i + 1])) { | |
| 517 | + // Setext headings. | |
| 518 | + if (/^ {0,3}=+\s*$/.test(lines[i]!) || (/^ {0,3}-+\s*$/.test(lines[i]!) && para.length === 1)) { | |
| 519 | + const level = lines[i]!.includes("=") ? 1 : 2; | |
| 520 | + const text = para.join(" "); | |
| 521 | + const slug = slugify(text); | |
| 522 | + para.length = 0; | |
| 523 | + parts.push(`<h${level} id="h-${escapeHtml(slug)}"><a class="anchor" href="#h-${escapeHtml(slug)}" aria-hidden="true">#</a>${inline(text, opts, depth)}</h${level}>`); | |
| 524 | + i++; | |
| 525 | + break; | |
| 526 | + } | |
| 527 | + para.push(lines[i]!.trimStart()); | |
| 528 | + i++; | |
| 529 | + } | |
| 530 | + if (para.length) { | |
| 531 | + const html = inline(para.join("\n").replace(/[ \t]+$/, ""), opts, depth); | |
| 532 | + parts.push(tight ? html : `<p>${html}</p>`); | |
| 533 | + } | |
| 534 | + } | |
| 535 | + return parts.join("\n"); | |
| 536 | +} | |
| 537 | + | |
| 538 | +/** Render Markdown to sanitized HTML. */ | |
| 539 | +export function renderMarkdown(source: string, opts: MarkdownOptions = {}): string { | |
| 540 | + if (source.length > MAX_SOURCE) return `<pre>${escapeHtml(source)}</pre>`; | |
| 541 | + const lines = source.replace(/\r\n?/g, "\n").split("\n"); | |
| 542 | + return blocks(lines, opts, 0); | |
| 543 | +} |
addedapps/diffinery/src/web/ui.tsx+732−0View file
| @@ -1,0 +1,732 @@ | ||
| 1 | +/** @jsxImportSource hono/jsx */ | |
| 2 | +import type { Hono } from "hono"; | |
| 3 | +import { deleteCookie, getCookie, setCookie } from "hono/cookie"; | |
| 4 | +import { raw } from "hono/html"; | |
| 5 | +import type { Child, FC, PropsWithChildren } from "hono/jsx"; | |
| 6 | +import type { Account, Repo } from "../lib/directory.js"; | |
| 7 | +import type { Env } from "../env.js"; | |
| 8 | +import type { AppContext, AppVars } from "../auth/principal.js"; | |
| 9 | +import { HttpError, randomToken } from "../lib/util.js"; | |
| 10 | +import { atLeast, type Caller } from "../repo/types.js"; | |
| 11 | +import type { repoStub } from "../http/common.js"; | |
| 12 | +import { CSS, ICONS, JS, type IconName } from "./assets.js"; | |
| 13 | +import { CSRF_FIELD, csrfToken, verifyCsrf, type FormData } from "./csrf.js"; | |
| 14 | +import { renderMarkdown, type MarkdownBase } from "./markdown.js"; | |
| 15 | + | |
| 16 | +export type WebApp = Hono<{ Bindings: Env; Variables: AppVars }>; | |
| 17 | +export type Stub = ReturnType<typeof repoStub>; | |
| 18 | + | |
| 19 | +// ---------------------------------------------------------------- small helpers | |
| 20 | + | |
| 21 | +export const encPath = (p: string): string => p.split("/").map(encodeURIComponent).join("/"); | |
| 22 | +export const repoUrl = (r: Pick<Repo, "owner_login" | "name">): string => `/${r.owner_login}/${r.name}`; | |
| 23 | +export const shortSha = (sha: string): string => sha.slice(0, 7); | |
| 24 | +export const firstLine = (s: string): string => s.split("\n")[0] ?? ""; | |
| 25 | + | |
| 26 | +export function publicUrl(c: AppContext): string { | |
| 27 | + return (c.env.PUBLIC_URL || new URL(c.req.url).origin).replace(/\/$/, ""); | |
| 28 | +} | |
| 29 | + | |
| 30 | +export function cloneUrl(c: AppContext, repo: Repo): string { | |
| 31 | + return `${publicUrl(c)}/${repo.owner_login}/${repo.name}.git`; | |
| 32 | +} | |
| 33 | + | |
| 34 | +export function fmtBytes(n: number | undefined | null): string { | |
| 35 | + if (n === undefined || n === null) return ""; | |
| 36 | + if (n < 1024) return `${n} B`; | |
| 37 | + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; | |
| 38 | + if (n < 1024 ** 3) return `${(n / 1024 / 1024).toFixed(1)} MB`; | |
| 39 | + return `${(n / 1024 ** 3).toFixed(2)} GB`; | |
| 40 | +} | |
| 41 | + | |
| 42 | +export function fmtDuration(ms: number): string { | |
| 43 | + if (!Number.isFinite(ms) || ms < 0) return ""; | |
| 44 | + const s = Math.round(ms / 1000); | |
| 45 | + if (s < 60) return `${s}s`; | |
| 46 | + const m = Math.floor(s / 60); | |
| 47 | + if (m < 60) return `${m}m ${s % 60}s`; | |
| 48 | + return `${Math.floor(m / 60)}h ${m % 60}m`; | |
| 49 | +} | |
| 50 | + | |
| 51 | +export function duration(start: string | null | undefined, end: string | null | undefined): string { | |
| 52 | + if (!start) return ""; | |
| 53 | + return fmtDuration((end ? Date.parse(end) : Date.now()) - Date.parse(start)); | |
| 54 | +} | |
| 55 | + | |
| 56 | +export function ago(iso: string | null | undefined): string { | |
| 57 | + if (!iso) return ""; | |
| 58 | + const t = Date.parse(iso); | |
| 59 | + if (!Number.isFinite(t)) return iso; | |
| 60 | + const s = Math.round((Date.now() - t) / 1000); | |
| 61 | + if (s < 45) return "just now"; | |
| 62 | + const m = Math.round(s / 60); | |
| 63 | + if (m < 60) return `${m} minute${m === 1 ? "" : "s"} ago`; | |
| 64 | + const h = Math.round(m / 60); | |
| 65 | + if (h < 24) return `${h} hour${h === 1 ? "" : "s"} ago`; | |
| 66 | + const d = Math.round(h / 24); | |
| 67 | + if (d < 30) return `${d} day${d === 1 ? "" : "s"} ago`; | |
| 68 | + return new Date(t).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }); | |
| 69 | +} | |
| 70 | + | |
| 71 | +export const Time: FC<{ at: string | null | undefined }> = ({ at }) => (at ? <time datetime={at} title={at}>{ago(at)}</time> : <span />); | |
| 72 | + | |
| 73 | +export const Icon: FC<{ name: IconName; class?: string }> = ({ name, class: cls }) => <span class={`st ${cls ?? ""}`}>{raw(ICONS[name])}</span>; | |
| 74 | + | |
| 75 | +/** Status icon for runs, jobs, checks and steps. */ | |
| 76 | +export const Status: FC<{ status?: string | null; conclusion?: string | null }> = ({ status, conclusion }) => { | |
| 77 | + if (status && status !== "completed") { | |
| 78 | + if (status === "in_progress") return <span class="st spin run" title="In progress">{raw(ICONS.running)}</span>; | |
| 79 | + return <span class="st warn" title={status === "waiting" ? "Waiting" : "Queued"}>{raw(ICONS.queued)}</span>; | |
| 80 | + } | |
| 81 | + switch (conclusion) { | |
| 82 | + case "success": | |
| 83 | + return <span class="st ok" title="Success">{raw(ICONS.success)}</span>; | |
| 84 | + case "failure": | |
| 85 | + case "timed_out": | |
| 86 | + case "startup_failure": | |
| 87 | + return <span class="st bad" title={conclusion === "timed_out" ? "Timed out" : "Failed"}>{raw(ICONS.failure)}</span>; | |
| 88 | + case "cancelled": | |
| 89 | + return <span class="st faint" title="Cancelled">{raw(ICONS.cancelled)}</span>; | |
| 90 | + case "skipped": | |
| 91 | + return <span class="st faint" title="Skipped">{raw(ICONS.skipped)}</span>; | |
| 92 | + case "in_progress": | |
| 93 | + return <span class="st spin run" title="In progress">{raw(ICONS.running)}</span>; | |
| 94 | + case "queued": | |
| 95 | + case "waiting": | |
| 96 | + case "pending": | |
| 97 | + return <span class="st warn" title="Queued">{raw(ICONS.queued)}</span>; | |
| 98 | + default: | |
| 99 | + return <span class="st faint" title={conclusion ?? "Neutral"}>{raw(ICONS.neutral)}</span>; | |
| 100 | + } | |
| 101 | +}; | |
| 102 | + | |
| 103 | +function hue(s: string): number { | |
| 104 | + let h = 0; | |
| 105 | + for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; | |
| 106 | + return h % 360; | |
| 107 | +} | |
| 108 | + | |
| 109 | +export const Avatar: FC<{ login: string; kind?: string; size?: "lg" | "xl" }> = ({ login, kind, size }) => ( | |
| 110 | + <span class={`av ${size ?? ""} ${kind === "agent" || kind === "system" ? "bot" : ""}`} style={`--h:${hue(login)}`} aria-hidden="true"> | |
| 111 | + {(login.replace(/[^A-Za-z0-9]/g, "")[0] ?? "?").toUpperCase()} | |
| 112 | + </span> | |
| 113 | +); | |
| 114 | + | |
| 115 | +/** Linked actor name with an "agent" badge and the human it acts for. */ | |
| 116 | +export const Actor: FC<{ login: string; kind?: string; onBehalfOf?: string | null; avatar?: boolean }> = ({ login, kind, onBehalfOf, avatar = true }) => ( | |
| 117 | + <span class="who"> | |
| 118 | + {avatar ? <Avatar login={login} kind={kind} /> : null} | |
| 119 | + {kind === "system" ? <span>{login}</span> : <a href={`/${encodeURIComponent(login)}`} style="color:inherit">{login}</a>} | |
| 120 | + {kind === "agent" ? <span class="badge agent">agent</span> : null} | |
| 121 | + {kind === "system" ? <span class="badge">bot</span> : null} | |
| 122 | + {onBehalfOf ? ( | |
| 123 | + <span class="muted small" style="font-weight:400"> | |
| 124 | + on behalf of <a href={`/${encodeURIComponent(onBehalfOf)}`}>{onBehalfOf}</a> | |
| 125 | + </span> | |
| 126 | + ) : null} | |
| 127 | + </span> | |
| 128 | +); | |
| 129 | + | |
| 130 | +export const Label: FC<{ name: string; color?: string; href?: string }> = ({ name, color, href }) => { | |
| 131 | + const c = color && /^[0-9a-fA-F]{6}$/.test(color) ? `#${color}` : "#8b929c"; | |
| 132 | + return href ? ( | |
| 133 | + <a class="lbl" style={`--lc:${c}`} href={href}> | |
| 134 | + {name} | |
| 135 | + </a> | |
| 136 | + ) : ( | |
| 137 | + <span class="lbl" style={`--lc:${c}`}>{name}</span> | |
| 138 | + ); | |
| 139 | +}; | |
| 140 | + | |
| 141 | +export const Markdown: FC<{ text: string; base?: MarkdownBase; empty?: string }> = ({ text, base, empty }) => | |
| 142 | + text.trim() ? <div class="markdown">{raw(renderMarkdown(text, { base }))}</div> : <p class="muted m0"><em>{empty ?? "No description provided."}</em></p>; | |
| 143 | + | |
| 144 | +/** CSRF-protected POST form. */ | |
| 145 | +export const Form: FC<PropsWithChildren<{ action: string; t: string; class?: string; confirm?: string; match?: string; id?: string; style?: string }>> = (p) => ( | |
| 146 | + <form method="post" action={p.action} class={p.class} data-confirm={p.confirm} data-match={p.match} id={p.id} style={p.style}> | |
| 147 | + <input type="hidden" name={CSRF_FIELD} value={p.t} /> | |
| 148 | + {p.children} | |
| 149 | + </form> | |
| 150 | +); | |
| 151 | + | |
| 152 | +export const CopyButton: FC<{ text: string; label?: string; class?: string }> = ({ text, label, class: cls }) => ( | |
| 153 | + <button type="button" class={`btn sm ${cls ?? ""}`} data-copy={text} title="Copy to clipboard"> | |
| 154 | + {label ?? "Copy"} | |
| 155 | + </button> | |
| 156 | +); | |
| 157 | + | |
| 158 | +export const Pager: FC<{ page: number; hasNext: boolean; href: (page: number) => string }> = ({ page, hasNext, href }) => | |
| 159 | + page > 1 || hasNext ? ( | |
| 160 | + <div class="pager"> | |
| 161 | + {page > 1 ? <a class="btn sm" href={href(page - 1)}>← Newer</a> : <span class="btn sm" aria-disabled="true" style="opacity:.4">← Newer</span>} | |
| 162 | + <span class="muted small" style="align-self:center">Page {page}</span> | |
| 163 | + {hasNext ? <a class="btn sm" href={href(page + 1)}>Older →</a> : <span class="btn sm" aria-disabled="true" style="opacity:.4">Older →</span>} | |
| 164 | + </div> | |
| 165 | + ) : null; | |
| 166 | + | |
| 167 | +export function qs(params: Record<string, string | number | undefined | null | false>): string { | |
| 168 | + const u = new URLSearchParams(); | |
| 169 | + for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== "" && v !== false) u.set(k, String(v)); | |
| 170 | + const s = u.toString(); | |
| 171 | + return s ? `?${s}` : ""; | |
| 172 | +} | |
| 173 | + | |
| 174 | +export const pageNum = (c: AppContext): number => Math.max(1, Math.min(1000, parseInt(c.req.query("page") ?? "1", 10) || 1)); | |
| 175 | + | |
| 176 | +// ---------------------------------------------------------------- errors, flash and actions | |
| 177 | + | |
| 178 | +export function statusOf(e: unknown): { status: number; message: string } { | |
| 179 | + if (e instanceof HttpError) return { status: e.status, message: e.detail }; | |
| 180 | + const msg = (e as Error)?.message ?? String(e); | |
| 181 | + const m = /^(\d{3}): ([\s\S]*)$/.exec(msg); | |
| 182 | + if (m) return { status: Number(m[1]), message: m[2]! }; | |
| 183 | + return { status: 500, message: "internal error" }; | |
| 184 | +} | |
| 185 | + | |
| 186 | +const FLASH = "dfy_flash"; | |
| 187 | + | |
| 188 | +export function flash(c: AppContext, kind: "error" | "notice", message: string): void { | |
| 189 | + setCookie(c, FLASH, encodeURIComponent(JSON.stringify({ kind, message: message.slice(0, 600) })), { path: "/", httpOnly: true, secure: true, sameSite: "Strict", maxAge: 60 }); | |
| 190 | +} | |
| 191 | + | |
| 192 | +function takeFlash(c: AppContext): { kind: "error" | "notice"; message: string } | null { | |
| 193 | + const v = getCookie(c, FLASH); | |
| 194 | + if (!v) return null; | |
| 195 | + deleteCookie(c, FLASH, { path: "/", secure: true }); | |
| 196 | + try { | |
| 197 | + const f = JSON.parse(decodeURIComponent(v)) as { kind: string; message: string }; | |
| 198 | + if (typeof f.message !== "string") return null; | |
| 199 | + return { kind: f.kind === "notice" ? "notice" : "error", message: f.message }; | |
| 200 | + } catch { | |
| 201 | + return null; | |
| 202 | + } | |
| 203 | +} | |
| 204 | + | |
| 205 | +/** Run a state-changing action and redirect (303); client errors become a flash message on `back`. */ | |
| 206 | +export async function act(c: AppContext, back: string, fn: () => Promise<string | void>, notice?: string): Promise<Response> { | |
| 207 | + try { | |
| 208 | + const to = await fn(); | |
| 209 | + if (notice) flash(c, "notice", notice); | |
| 210 | + return c.redirect(to ?? back, 303); | |
| 211 | + } catch (e) { | |
| 212 | + const s = statusOf(e); | |
| 213 | + if (s.status >= 500) throw e; | |
| 214 | + flash(c, "error", s.message); | |
| 215 | + return c.redirect(back, 303); | |
| 216 | + } | |
| 217 | +} | |
| 218 | + | |
| 219 | +/** Register a CSRF-checked POST route. */ | |
| 220 | +export function post(app: WebApp, path: string, handler: (c: AppContext, form: FormData) => Promise<Response>): void { | |
| 221 | + app.post(path, async (c) => handler(c, await verifyCsrf(c))); | |
| 222 | +} | |
| 223 | + | |
| 224 | +export function principalAccount(c: AppContext): Account | null { | |
| 225 | + const p = c.get("principal"); | |
| 226 | + return p?.type === "account" ? p.account : null; | |
| 227 | +} | |
| 228 | + | |
| 229 | +// ---------------------------------------------------------------- layout | |
| 230 | + | |
| 231 | +export interface RepoCtx { | |
| 232 | + repo: Repo; | |
| 233 | + caller: Caller; | |
| 234 | + tab?: "code" | "issues" | "pulls" | "actions" | "settings" | ""; | |
| 235 | + /** Current ref, used by the palette's "go to file". */ | |
| 236 | + ref?: string; | |
| 237 | + counts?: { issues?: number; pulls?: number }; | |
| 238 | +} | |
| 239 | + | |
| 240 | +export interface PageOpts { | |
| 241 | + title: string; | |
| 242 | + repo?: RepoCtx; | |
| 243 | + /** WebSocket topics for live updates (e.g. "run:abc,pr:3"). */ | |
| 244 | + live?: string; | |
| 245 | + liveMode?: "reload" | "log"; | |
| 246 | + lastSeq?: number; | |
| 247 | + status?: number; | |
| 248 | + /** Full-width page (diffs, logs). */ | |
| 249 | + wide?: boolean; | |
| 250 | +} | |
| 251 | + | |
| 252 | +const RepoHeader: FC<{ r: RepoCtx }> = ({ r }) => { | |
| 253 | + const base = repoUrl(r.repo); | |
| 254 | + const tab = r.tab ?? ""; | |
| 255 | + const vis = r.repo.visibility; | |
| 256 | + return ( | |
| 257 | + <div class="repohead"> | |
| 258 | + <div class="wrap"> | |
| 259 | + <h1> | |
| 260 | + <span class="st faint">{raw(vis === "public" ? ICONS.globe : vis === "internal" ? ICONS.org : ICONS.lock)}</span> | |
| 261 | + <a href={`/${r.repo.owner_login}`}>{r.repo.owner_login}</a> | |
| 262 | + <span class="faint">/</span> | |
| 263 | + <strong> | |
| 264 | + <a href={base}>{r.repo.name}</a> | |
| 265 | + </strong> | |
| 266 | + <span class="badge">{vis}</span> | |
| 267 | + {r.repo.archived ? <span class="badge amber">archived</span> : null} | |
| 268 | + </h1> | |
| 269 | + {r.repo.description ? <p class="muted m0">{r.repo.description}</p> : null} | |
| 270 | + <nav class="tabs"> | |
| 271 | + <a href={base} class={tab === "code" ? "on" : ""}> | |
| 272 | + {raw(ICONS.code)} Code | |
| 273 | + </a> | |
| 274 | + <a href={`${base}/issues`} class={tab === "issues" ? "on" : ""}> | |
| 275 | + {raw(ICONS.issueOpen)} Issues {r.counts?.issues ? <span class="n">{r.counts.issues}</span> : null} | |
| 276 | + </a> | |
| 277 | + <a href={`${base}/pulls`} class={tab === "pulls" ? "on" : ""}> | |
| 278 | + {raw(ICONS.pr)} Pull requests {r.counts?.pulls ? <span class="n">{r.counts.pulls}</span> : null} | |
| 279 | + </a> | |
| 280 | + <a href={`${base}/actions`} class={tab === "actions" ? "on" : ""}> | |
| 281 | + {raw(ICONS.play)} Actions | |
| 282 | + </a> | |
| 283 | + {atLeast(r.caller.role, "admin") ? ( | |
| 284 | + <a href={`${base}/settings`} class={tab === "settings" ? "on" : ""}> | |
| 285 | + {raw(ICONS.gear)} Settings | |
| 286 | + </a> | |
| 287 | + ) : null} | |
| 288 | + </nav> | |
| 289 | + </div> | |
| 290 | + </div> | |
| 291 | + ); | |
| 292 | +}; | |
| 293 | + | |
| 294 | +function csp(nonce: string, c: AppContext): string { | |
| 295 | + const host = new URL(c.req.url).host; | |
| 296 | + return [ | |
| 297 | + "default-src 'self'", | |
| 298 | + `script-src 'nonce-${nonce}'`, | |
| 299 | + "style-src 'self' 'unsafe-inline'", | |
| 300 | + "img-src 'self' https: data:", | |
| 301 | + `connect-src 'self' wss://${host} ws://${host}`, | |
| 302 | + "font-src 'self'", | |
| 303 | + "object-src 'none'", | |
| 304 | + "base-uri 'none'", | |
| 305 | + "form-action 'self'", | |
| 306 | + "frame-ancestors 'none'", | |
| 307 | + ].join("; "); | |
| 308 | +} | |
| 309 | + | |
| 310 | +/** Render a full page. */ | |
| 311 | +export function render(c: AppContext, opts: PageOpts, body: Child): Response | Promise<Response> { | |
| 312 | + csrfToken(c); | |
| 313 | + const nonce = randomToken(16); | |
| 314 | + const fl = takeFlash(c); | |
| 315 | + const p = c.get("principal"); | |
| 316 | + const me = p?.type === "account" ? p.account : null; | |
| 317 | + const active = me?.status === "active"; | |
| 318 | + c.header("content-security-policy", csp(nonce, c)); | |
| 319 | + c.header("x-frame-options", "DENY"); | |
| 320 | + c.header("x-content-type-options", "nosniff"); | |
| 321 | + c.header("referrer-policy", "same-origin"); | |
| 322 | + c.header("cache-control", "private, no-store"); | |
| 323 | + const r = opts.repo; | |
| 324 | + const page = ( | |
| 325 | + <html lang="en"> | |
| 326 | + <head> | |
| 327 | + <meta charset="utf-8" /> | |
| 328 | + <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| 329 | + <meta name="color-scheme" content="light dark" /> | |
| 330 | + <title>{opts.title ? `${opts.title} · Diffinery` : "Diffinery"}</title> | |
| 331 | + <link rel="icon" href={`data:image/svg+xml,${encodeURIComponent(ICONS.logo.replace("currentColor", "#5e6ad2"))}`} /> | |
| 332 | + <style>{raw(CSS)}</style> | |
| 333 | + </head> | |
| 334 | + <body | |
| 335 | + data-repo={r ? `${r.repo.owner_login}/${r.repo.name}` : undefined} | |
| 336 | + data-ref={r?.ref ?? r?.repo.default_branch} | |
| 337 | + data-live={opts.live} | |
| 338 | + data-live-mode={opts.live ? (opts.liveMode ?? "reload") : undefined} | |
| 339 | + data-last-seq={opts.lastSeq !== undefined ? String(opts.lastSeq) : undefined} | |
| 340 | + > | |
| 341 | + <header class="top"> | |
| 342 | + <div class="wrap"> | |
| 343 | + <a class="brand" href="/"> | |
| 344 | + {raw(ICONS.logo)} Diffinery | |
| 345 | + </a> | |
| 346 | + {active ? ( | |
| 347 | + <button type="button" class="pal-btn" data-palette="1" aria-label="Open command palette"> | |
| 348 | + {raw(ICONS.search)} | |
| 349 | + <span>Jump to…</span> | |
| 350 | + <kbd>Ctrl K</kbd> | |
| 351 | + </button> | |
| 352 | + ) : null} | |
| 353 | + <nav> | |
| 354 | + {active && me ? ( | |
| 355 | + <> | |
| 356 | + <a href="/new" title="New repository"> | |
| 357 | + {raw(ICONS.plus)} | |
| 358 | + </a> | |
| 359 | + <a href="/notifications" title="Notifications"> | |
| 360 | + {raw(ICONS.bell)} | |
| 361 | + </a> | |
| 362 | + <a href="/settings" title="Settings" class="who"> | |
| 363 | + <Avatar login={me.login} /> <span>{me.login}</span> | |
| 364 | + </a> | |
| 365 | + {p?.type === "account" && p.via === "access" ? <a href="/cdn-cgi/access/logout">Sign out</a> : null} | |
| 366 | + </> | |
| 367 | + ) : null} | |
| 368 | + </nav> | |
| 369 | + </div> | |
| 370 | + </header> | |
| 371 | + {r ? <RepoHeader r={r} /> : null} | |
| 372 | + <main class="wrap" style={opts.wide ? "max-width:none" : undefined}> | |
| 373 | + {fl ? <div class={`flash ${fl.kind}`} role={fl.kind === "error" ? "alert" : "status"}>{fl.message}</div> : null} | |
| 374 | + {body} | |
| 375 | + </main> | |
| 376 | + {active ? ( | |
| 377 | + <dialog id="palette" aria-label="Command palette"> | |
| 378 | + <input type="text" placeholder="Jump to a repository, file, page or #number…" autocomplete="off" spellcheck={false} /> | |
| 379 | + <ul role="listbox"></ul> | |
| 380 | + <div class="foot"> | |
| 381 | + <span> | |
| 382 | + <kbd>↑</kbd> <kbd>↓</kbd> navigate | |
| 383 | + </span> | |
| 384 | + <span> | |
| 385 | + <kbd>Enter</kbd> open | |
| 386 | + </span> | |
| 387 | + <span> | |
| 388 | + <kbd>g</kbd> then <kbd>c</kbd>/<kbd>i</kbd>/<kbd>p</kbd>/<kbd>a</kbd> code, issues, pulls, actions | |
| 389 | + </span> | |
| 390 | + </div> | |
| 391 | + </dialog> | |
| 392 | + ) : null} | |
| 393 | + <script nonce={nonce}>{raw(JS)}</script> | |
| 394 | + </body> | |
| 395 | + </html> | |
| 396 | + ); | |
| 397 | + return c.html(`<!doctype html>${page}`, (opts.status ?? 200) as 200); | |
| 398 | +} | |
| 399 | + | |
| 400 | +/** Friendly error page for HttpErrors (and DO errors carrying a status prefix). */ | |
| 401 | +export function errorPage(c: AppContext, e: unknown): Response | Promise<Response> { | |
| 402 | + const s = statusOf(e); | |
| 403 | + if (s.status >= 500) console.error(e); | |
| 404 | + const titles: Record<number, string> = { 401: "Sign in required", 403: "Access denied", 404: "Not found", 405: "Not allowed", 409: "Conflict", 422: "Could not complete the request" }; | |
| 405 | + const title = titles[s.status] ?? (s.status >= 500 ? "Something went wrong" : "Request failed"); | |
| 406 | + const p = c.get("principal"); | |
| 407 | + return render( | |
| 408 | + c, | |
| 409 | + { title, status: s.status }, | |
| 410 | + <div class="blank" style="max-width:640px;margin:48px auto"> | |
| 411 | + <div class="faint" style="font-size:48px;font-weight:700;letter-spacing:-.03em">{s.status}</div> | |
| 412 | + <h1>{title}</h1> | |
| 413 | + {s.status === 401 ? ( | |
| 414 | + <p class="muted"> | |
| 415 | + Sign in through Cloudflare Access to continue. If you were just signed out, <a href={c.req.path}>reload this page</a> to start the Access sign-in flow. | |
| 416 | + </p> | |
| 417 | + ) : s.status === 404 ? ( | |
| 418 | + <p class="muted">The page or repository does not exist, or you do not have access to it.</p> | |
| 419 | + ) : ( | |
| 420 | + <p class="muted">{s.status >= 500 ? "An unexpected error occurred. Please try again." : s.message}</p> | |
| 421 | + )} | |
| 422 | + {s.status === 403 && p?.type === "account" && p.account.status === "pending" ? ( | |
| 423 | + <p> | |
| 424 | + <a class="btn primary" href="/"> | |
| 425 | + Request access | |
| 426 | + </a> | |
| 427 | + </p> | |
| 428 | + ) : ( | |
| 429 | + <p> | |
| 430 | + <a class="btn" href="/"> | |
| 431 | + Go to dashboard | |
| 432 | + </a> | |
| 433 | + </p> | |
| 434 | + )} | |
| 435 | + </div>, | |
| 436 | + ); | |
| 437 | +} | |
| 438 | + | |
| 439 | +// ---------------------------------------------------------------- refs and paths | |
| 440 | + | |
| 441 | +/** Path segments after a fixed number of leading segments, decoded. */ | |
| 442 | +export function restOfPath(c: AppContext, skip: number): string { | |
| 443 | + const parts = new URL(c.req.url).pathname.split("/").slice(skip + 1); | |
| 444 | + return parts | |
| 445 | + .filter((p) => p !== "") | |
| 446 | + .map((p) => { | |
| 447 | + try { | |
| 448 | + return decodeURIComponent(p); | |
| 449 | + } catch { | |
| 450 | + return p; | |
| 451 | + } | |
| 452 | + }) | |
| 453 | + .join("/"); | |
| 454 | +} | |
| 455 | + | |
| 456 | +/** Split "feature/x/src/a.ts" into the longest matching ref and the path inside it. */ | |
| 457 | +export async function splitRefPath(stub: Stub, rest: string): Promise<{ ref: string; path: string }> { | |
| 458 | + const segs = rest.split("/").filter(Boolean); | |
| 459 | + if (segs.length <= 1) return { ref: segs[0] ?? "", path: "" }; | |
| 460 | + const names = new Set((await stub.refs("refs/")).map((r) => r.name.replace(/^refs\/(heads|tags)\//, ""))); | |
| 461 | + for (let i = segs.length; i >= 1; i--) { | |
| 462 | + const cand = segs.slice(0, i).join("/"); | |
| 463 | + if (names.has(cand)) return { ref: cand, path: segs.slice(i).join("/") }; | |
| 464 | + } | |
| 465 | + return { ref: segs[0]!, path: segs.slice(1).join("/") }; | |
| 466 | +} | |
| 467 | + | |
| 468 | +/** Branch/tag switcher; `to` builds the target URL for a ref. */ | |
| 469 | +export const RefSwitcher: FC<{ current: string; branches: string[]; tags: string[]; to: (ref: string) => string }> = ({ current, branches, tags, to }) => ( | |
| 470 | + <details class="menu"> | |
| 471 | + <summary class="btn sm"> | |
| 472 | + {raw(/^[0-9a-f]{40}$/.test(current) ? ICONS.commit : tags.includes(current) && !branches.includes(current) ? ICONS.tag : ICONS.branch)} | |
| 473 | + <span class="mono">{/^[0-9a-f]{40}$/.test(current) ? shortSha(current) : current}</span> ▾ | |
| 474 | + </summary> | |
| 475 | + <div class="pop"> | |
| 476 | + <h4>Branches</h4> | |
| 477 | + {branches.length ? ( | |
| 478 | + branches.map((b) => ( | |
| 479 | + <a href={to(b)}> | |
| 480 | + {b === current ? raw(ICONS.check) : <span style="width:16px;display:inline-block" />} {b} | |
| 481 | + </a> | |
| 482 | + )) | |
| 483 | + ) : ( | |
| 484 | + <p class="muted small" style="margin:4px 8px">No branches</p> | |
| 485 | + )} | |
| 486 | + {tags.length ? <h4>Tags</h4> : null} | |
| 487 | + {tags.map((t) => ( | |
| 488 | + <a href={to(t)}> | |
| 489 | + {t === current ? raw(ICONS.check) : <span style="width:16px;display:inline-block" />} {t} | |
| 490 | + </a> | |
| 491 | + ))} | |
| 492 | + </div> | |
| 493 | + </details> | |
| 494 | +); | |
| 495 | + | |
| 496 | +export async function refNames(stub: Stub): Promise<{ branches: string[]; tags: string[] }> { | |
| 497 | + const refs = await stub.refs("refs/"); | |
| 498 | + return { | |
| 499 | + branches: refs.filter((r) => r.name.startsWith("refs/heads/")).map((r) => r.name.slice(11)), | |
| 500 | + tags: refs.filter((r) => r.name.startsWith("refs/tags/")).map((r) => r.name.slice(10)), | |
| 501 | + }; | |
| 502 | +} | |
| 503 | + | |
| 504 | +// ---------------------------------------------------------------- diffs | |
| 505 | + | |
| 506 | +export interface DiffHunk { | |
| 507 | + oldStart: number; | |
| 508 | + oldLines: number; | |
| 509 | + newStart: number; | |
| 510 | + newLines: number; | |
| 511 | + lines: string[]; | |
| 512 | +} | |
| 513 | + | |
| 514 | +export interface DiffFile { | |
| 515 | + path: string; | |
| 516 | + status: "added" | "modified" | "deleted"; | |
| 517 | + binary: boolean; | |
| 518 | + additions: number; | |
| 519 | + deletions: number; | |
| 520 | + hunks: DiffHunk[]; | |
| 521 | + truncated?: boolean; | |
| 522 | +} | |
| 523 | + | |
| 524 | +export interface LineThread { | |
| 525 | + side: "LEFT" | "RIGHT"; | |
| 526 | + line: number; | |
| 527 | + node: Child; | |
| 528 | +} | |
| 529 | + | |
| 530 | +const StatBar: FC<{ a: number; d: number }> = ({ a, d }) => { | |
| 531 | + const total = a + d; | |
| 532 | + const blocks = 5; | |
| 533 | + const ga = total ? Math.round((a / total) * blocks) : 0; | |
| 534 | + const gd = total ? Math.min(blocks - ga, Math.round((d / total) * blocks)) : 0; | |
| 535 | + return ( | |
| 536 | + <span class="bar" aria-hidden="true"> | |
| 537 | + {Array.from({ length: blocks }, (_, i) => ( | |
| 538 | + <i class={i < ga ? "a" : i < ga + gd ? "d" : ""} /> | |
| 539 | + ))} | |
| 540 | + </span> | |
| 541 | + ); | |
| 542 | +}; | |
| 543 | + | |
| 544 | +export const DiffStats: FC<{ files: number; additions: number; deletions: number }> = ({ files, additions, deletions }) => ( | |
| 545 | + <span class="row" style="gap:10px"> | |
| 546 | + <span> | |
| 547 | + <b>{files}</b> file{files === 1 ? "" : "s"} changed | |
| 548 | + </span> | |
| 549 | + <span class="stat-add">+{additions}</span> | |
| 550 | + <span class="stat-del">−{deletions}</span> | |
| 551 | + <StatBar a={additions} d={deletions} /> | |
| 552 | + </span> | |
| 553 | +); | |
| 554 | + | |
| 555 | +export interface DiffOptions { | |
| 556 | + /** Link to view the file at the new revision. */ | |
| 557 | + viewHref?: (path: string) => string | null; | |
| 558 | + /** Show "+" buttons for line comments (requires the line comment template on the page). */ | |
| 559 | + commentable?: boolean; | |
| 560 | + /** Existing review threads per file path. */ | |
| 561 | + threads?: Map<string, LineThread[]>; | |
| 562 | +} | |
| 563 | + | |
| 564 | +const LINE_BUDGET = 20000; | |
| 565 | + | |
| 566 | +/** Unified diff with line numbers; each file is collapsible. */ | |
| 567 | +export const DiffView: FC<{ files: DiffFile[]; opts?: DiffOptions }> = ({ files, opts = {} }) => { | |
| 568 | + let budget = LINE_BUDGET; | |
| 569 | + return ( | |
| 570 | + <div> | |
| 571 | + {files.map((f) => { | |
| 572 | + const lines = f.hunks.reduce((n, h) => n + h.lines.length, 0); | |
| 573 | + const big = lines > 800; | |
| 574 | + const render = budget > 0 && !f.binary && !f.truncated; | |
| 575 | + budget -= lines; | |
| 576 | + const threads = opts.threads?.get(f.path) ?? []; | |
| 577 | + const view = opts.viewHref?.(f.path); | |
| 578 | + return ( | |
| 579 | + <details class="file" open={!big && render} id={`diff-${encodeURIComponent(f.path)}`}> | |
| 580 | + <summary class="filehead"> | |
| 581 | + <span class={`badge ${f.status === "added" ? "green" : f.status === "deleted" ? "red" : "amber"}`}>{f.status}</span> | |
| 582 | + <span class="mono grow" style="font-weight:600;word-break:break-all">{f.path}</span> | |
| 583 | + <span class="stat-add small">+{f.additions}</span> | |
| 584 | + <span class="stat-del small">−{f.deletions}</span> | |
| 585 | + {view && f.status !== "deleted" ? ( | |
| 586 | + <a class="btn sm" href={view}> | |
| 587 | + View file | |
| 588 | + </a> | |
| 589 | + ) : null} | |
| 590 | + </summary> | |
| 591 | + {f.binary ? ( | |
| 592 | + <div class="empty">Binary file not shown.</div> | |
| 593 | + ) : f.truncated ? ( | |
| 594 | + <div class="empty">This diff is too large to display.</div> | |
| 595 | + ) : !render ? ( | |
| 596 | + <div class="empty">Diff not rendered to keep this page fast. {view ? <a href={view}>View the file</a> : null}</div> | |
| 597 | + ) : f.hunks.length === 0 ? ( | |
| 598 | + <div class="empty">{f.status === "added" ? "Empty file added." : f.status === "deleted" ? "Empty file deleted." : "Mode change only."}</div> | |
| 599 | + ) : ( | |
| 600 | + <div class="code-wrap"> | |
| 601 | + <table class="diff"> | |
| 602 | + {f.hunks.map((h) => { | |
| 603 | + let oldN = h.oldStart; | |
| 604 | + let newN = h.newStart; | |
| 605 | + return ( | |
| 606 | + <> | |
| 607 | + <tr class="hunk"> | |
| 608 | + {opts.commentable ? <td class="act" /> : null} | |
| 609 | + <td class="ln" /> | |
| 610 | + <td class="ln" /> | |
| 611 | + <td class="code"> | |
| 612 | + @@ -{h.oldStart},{h.oldLines} +{h.newStart},{h.newLines} @@ | |
| 613 | + </td> | |
| 614 | + </tr> | |
| 615 | + {h.lines.map((l) => { | |
| 616 | + const k = l[0]; | |
| 617 | + if (k === "\\") { | |
| 618 | + return ( | |
| 619 | + <tr class="meta"> | |
| 620 | + {opts.commentable ? <td class="act" /> : null} | |
| 621 | + <td class="ln" /> | |
| 622 | + <td class="ln" /> | |
| 623 | + <td class="code">{l}</td> | |
| 624 | + </tr> | |
| 625 | + ); | |
| 626 | + } | |
| 627 | + const cls = k === "+" ? "add" : k === "-" ? "del" : "ctx"; | |
| 628 | + const o = k === "+" ? null : oldN++; | |
| 629 | + const n = k === "-" ? null : newN++; | |
| 630 | + const side = k === "-" ? "LEFT" : "RIGHT"; | |
| 631 | + const lineNo = side === "LEFT" ? o : n; | |
| 632 | + const here = threads.filter((t) => t.side === side && t.line === lineNo); | |
| 633 | + return ( | |
| 634 | + <> | |
| 635 | + <tr class={cls}> | |
| 636 | + {opts.commentable ? ( | |
| 637 | + <td class="act"> | |
| 638 | + <button type="button" class="addc" title="Comment on this line" data-comment-line={String(lineNo)} data-path={f.path} data-side={side}> | |
| 639 | + + | |
| 640 | + </button> | |
| 641 | + </td> | |
| 642 | + ) : null} | |
| 643 | + <td class="ln">{o ?? ""}</td> | |
| 644 | + <td class="ln">{n ?? ""}</td> | |
| 645 | + <td class="code"> | |
| 646 | + {k === "+" || k === "-" ? k : " "} | |
| 647 | + {l.slice(1)} | |
| 648 | + </td> | |
| 649 | + </tr> | |
| 650 | + {here.length ? ( | |
| 651 | + <tr class="thread"> | |
| 652 | + <td colspan={opts.commentable ? 4 : 3}>{here.map((t) => t.node)}</td> | |
| 653 | + </tr> | |
| 654 | + ) : null} | |
| 655 | + </> | |
| 656 | + ); | |
| 657 | + })} | |
| 658 | + </> | |
| 659 | + ); | |
| 660 | + })} | |
| 661 | + </table> | |
| 662 | + </div> | |
| 663 | + )} | |
| 664 | + </details> | |
| 665 | + ); | |
| 666 | + })} | |
| 667 | + </div> | |
| 668 | + ); | |
| 669 | +}; | |
| 670 | + | |
| 671 | +/** Changed-files summary list with anchors into the diff. */ | |
| 672 | +export const FileSummary: FC<{ files: DiffFile[] }> = ({ files }) => | |
| 673 | + files.length > 3 ? ( | |
| 674 | + <details class="box"> | |
| 675 | + <summary class="head" style="cursor:pointer"> | |
| 676 | + <span>Files ({files.length})</span> | |
| 677 | + </summary> | |
| 678 | + <ul class="list"> | |
| 679 | + {files.map((f) => ( | |
| 680 | + <li style="padding:6px 14px"> | |
| 681 | + <a class="mono grow" href={`#diff-${encodeURIComponent(f.path)}`}> | |
| 682 | + {f.path} | |
| 683 | + </a> | |
| 684 | + <span class="stat-add small">+{f.additions}</span> | |
| 685 | + <span class="stat-del small">−{f.deletions}</span> | |
| 686 | + </li> | |
| 687 | + ))} | |
| 688 | + </ul> | |
| 689 | + </details> | |
| 690 | + ) : null; | |
| 691 | + | |
| 692 | +/** Commit list row. */ | |
| 693 | +export const CommitRow: FC<{ base: string; sha: string; message: string; author: { name: string; email?: string; timestamp: number }; signed?: boolean; extra?: Child }> = ({ base, sha, message, author, signed, extra }) => ( | |
| 694 | + <li> | |
| 695 | + <div class="grow"> | |
| 696 | + <a class="title" href={`${base}/commit/${sha}`}> | |
| 697 | + {firstLine(message)} | |
| 698 | + </a> | |
| 699 | + <div class="meta"> | |
| 700 | + <b>{author.name}</b> committed <Time at={new Date(author.timestamp * 1000).toISOString()} /> | |
| 701 | + {signed ? <span class="badge green" style="margin-left:6px">signed</span> : null} | |
| 702 | + </div> | |
| 703 | + </div> | |
| 704 | + {extra} | |
| 705 | + <CopyButton text={sha} label="Copy" /> | |
| 706 | + <a class="sha" href={`${base}/commit/${sha}`}> | |
| 707 | + {shortSha(sha)} | |
| 708 | + </a> | |
| 709 | + <a class="btn sm" href={`${base}/tree/${sha}`} title="Browse the repository at this point"> | |
| 710 | + {raw(ICONS.code)} | |
| 711 | + </a> | |
| 712 | + </li> | |
| 713 | +); | |
| 714 | + | |
| 715 | +/** Look up agents' owners for "on behalf of" labels. */ | |
| 716 | +export async function onBehalfOf(c: AppContext, actors: Array<{ login: string; kind: string }>): Promise<Map<string, string>> { | |
| 717 | + const out = new Map<string, string>(); | |
| 718 | + const dir = c.get("dir"); | |
| 719 | + const logins = [...new Set(actors.filter((a) => a.kind === "agent").map((a) => a.login))].slice(0, 20); | |
| 720 | + await Promise.all( | |
| 721 | + logins.map(async (login) => { | |
| 722 | + const a = await dir.accountByLogin(login); | |
| 723 | + if (a?.owner_id) { | |
| 724 | + const owner = await dir.account(a.owner_id); | |
| 725 | + if (owner) out.set(login, owner.login); | |
| 726 | + } | |
| 727 | + }), | |
| 728 | + ); | |
| 729 | + return out; | |
| 730 | +} | |
| 731 | + | |
| 732 | +export const ROLES = ["read", "triage", "write", "maintain", "admin"] as const; |
addedapps/diffinery/tsconfig.json+11−0View file
| @@ -1,0 +1,11 @@ | ||
| 1 | +{ | |
| 2 | + "extends": "../../tsconfig.base.json", | |
| 3 | + "compilerOptions": { | |
| 4 | + "noEmit": true, | |
| 5 | + "lib": ["ES2023"], | |
| 6 | + "types": ["@cloudflare/workers-types"], | |
| 7 | + "jsx": "react-jsx", | |
| 8 | + "jsxImportSource": "hono/jsx" | |
| 9 | + }, | |
| 10 | + "include": ["src"] | |
| 11 | +} |
addedcontainers/runner/.dockerignore+2−0View file
| @@ -1,0 +1,2 @@ | ||
| 1 | +* | |
| 2 | +!dist/agent.mjs |
addedcontainers/runner/Dockerfile+50−0View file
| @@ -1,0 +1,50 @@ | ||
| 1 | +# Diffinery runner image (Cloudflare Containers, linux/amd64). | |
| 2 | +# Build context: containers/runner. Run `pnpm build` first so dist/agent.mjs exists. | |
| 3 | +# | |
| 4 | +# docker build --platform linux/amd64 -t diffinery-runner containers/runner | |
| 5 | +FROM --platform=linux/amd64 ubuntu:24.04 | |
| 6 | + | |
| 7 | +ARG NODE_VERSION=22.22.0 | |
| 8 | + | |
| 9 | +ENV DEBIAN_FRONTEND=noninteractive \ | |
| 10 | + LANG=C.UTF-8 \ | |
| 11 | + LC_ALL=C.UTF-8 \ | |
| 12 | + DIFFINERY_WORK_ROOT=/workspace \ | |
| 13 | + RUNNER_TOOL_CACHE=/opt/hostedtoolcache \ | |
| 14 | + AGENT_TOOLSDIRECTORY=/opt/hostedtoolcache \ | |
| 15 | + COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ | |
| 16 | + # Ubuntu 24.04 marks the system Python as externally managed (PEP 668); | |
| 17 | + # CI scripts commonly `pip install` directly, so allow it like hosted runners do. | |
| 18 | + PIP_BREAK_SYSTEM_PACKAGES=1 | |
| 19 | + | |
| 20 | +RUN apt-get update \ | |
| 21 | + && apt-get install -y --no-install-recommends \ | |
| 22 | + ca-certificates curl git jq zip unzip xz-utils tar gzip \ | |
| 23 | + build-essential pkg-config openssh-client gnupg \ | |
| 24 | + python3 python3-pip python3-venv python-is-python3 \ | |
| 25 | + && git config --system --add safe.directory '*' \ | |
| 26 | + && apt-get clean \ | |
| 27 | + && rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* | |
| 28 | + | |
| 29 | +# Node.js LTS from the official tarball (checksum-verified), with corepack shims for pnpm/yarn. | |
| 30 | +RUN set -eux; \ | |
| 31 | + cd /tmp; \ | |
| 32 | + file="node-v${NODE_VERSION}-linux-x64.tar.xz"; \ | |
| 33 | + curl -fsSLO "https://nodejs.org/dist/v${NODE_VERSION}/${file}"; \ | |
| 34 | + curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/SHASUMS256.txt" | grep " ${file}\$" | sha256sum -c -; \ | |
| 35 | + tar -xJf "${file}" -C /usr/local --strip-components=1 --no-same-owner \ | |
| 36 | + --exclude='*/CHANGELOG.md' --exclude='*/README.md' --exclude='*/LICENSE' --exclude='*/include/*' --exclude='*/share/doc/*'; \ | |
| 37 | + rm -f "${file}"; \ | |
| 38 | + corepack enable; \ | |
| 39 | + corepack prepare pnpm@10.28.0 --activate; \ | |
| 40 | + corepack prepare yarn@1.22.22 --activate; \ | |
| 41 | + node --version; npm --version; pnpm --version; yarn --version; \ | |
| 42 | + npm cache clean --force; \ | |
| 43 | + rm -rf /root/.npm /tmp/* | |
| 44 | + | |
| 45 | +RUN mkdir -p /workspace /opt/hostedtoolcache /opt/diffinery | |
| 46 | +COPY dist/agent.mjs /opt/diffinery/agent.mjs | |
| 47 | + | |
| 48 | +WORKDIR /workspace | |
| 49 | +EXPOSE 8080 | |
| 50 | +ENTRYPOINT ["node", "/opt/diffinery/agent.mjs"] |
modifiedcontainers/runner/src/mask.ts+8−2View file
| @@ -1,3 +1,9 @@ | ||
| 1 | +/** | |
| 2 | + * Values shorter than this are not masked: masking e.g. a 1-character secret | |
| 3 | + * would replace that character everywhere and make logs unreadable. | |
| 4 | + */ | |
| 5 | +export const MIN_MASK_LENGTH = 3; | |
| 6 | + | |
| 1 | 7 | /** Replaces registered secret values with `***` in log output. */ |
| 2 | 8 | export class Masker { |
| 3 | 9 | private values = new Set<string>(); |
| @@ -6,7 +12,7 @@ | ||
| 6 | 12 | add(value: string | undefined | null): void { |
| 7 | 13 | if (value === undefined || value === null) return; |
| 8 | 14 | const v = String(value); |
| 9 | - if (v.trim() === "") return; | |
| 15 | + if (v.trim().length < MIN_MASK_LENGTH) return; | |
| 10 | 16 | const before = this.values.size; |
| 11 | 17 | this.values.add(v); |
| 12 | 18 | this.values.add(v.trim()); |
| @@ -14,7 +20,7 @@ | ||
| 14 | 20 | if (/\r?\n/.test(v)) { |
| 15 | 21 | for (const line of v.split(/\r?\n/)) { |
| 16 | 22 | const t = line.trim(); |
| 17 | - if (t.length >= 2) this.values.add(t); | |
| 23 | + if (t.length >= MIN_MASK_LENGTH) this.values.add(t); | |
| 18 | 24 | } |
| 19 | 25 | } |
| 20 | 26 | if (this.values.size !== before) { |
modifiedcontainers/runner/test/job.test.ts+98−10View file
| @@ -1,8 +1,10 @@ | ||
| 1 | -import { execFileSync } from "node:child_process"; | |
| 1 | +import { execFileSync, spawn } from "node:child_process"; | |
| 2 | +import { createServer } from "node:http"; | |
| 3 | +import type { AddressInfo } from "node:net"; | |
| 2 | 4 | import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; |
| 3 | 5 | import path from "node:path"; |
| 4 | 6 | import { afterEach, beforeEach, describe, expect, it } from "vitest"; |
| 5 | -import type { JobSpec } from "@diffinery/shared"; | |
| 7 | +import type { JobSpec, StepSpec } from "@diffinery/shared"; | |
| 6 | 8 | import { apiBaseFor, runAgentJob } from "../src/agent.js"; |
| 7 | 9 | import type { JobRunner } from "../src/job.js"; |
| 8 | 10 | import { makeSpec, startFakeApi, tmp, type FakeApi } from "./helpers.js"; |
| @@ -32,6 +34,22 @@ | ||
| 32 | 34 | |
| 33 | 35 | const workspace = () => path.join(workRoot, "widgets"); |
| 34 | 36 | |
| 37 | +/** Async spawn: the fake API lives in this process, so the event loop must stay free. */ | |
| 38 | +function spawnAsync(cmd: string, args: string[], env: NodeJS.ProcessEnv): Promise<{ status: number | null; stdout: string; stderr: string }> { | |
| 39 | + return new Promise((resolve) => { | |
| 40 | + const child = spawn(cmd, args, { env }); | |
| 41 | + let stdout = ""; | |
| 42 | + let stderr = ""; | |
| 43 | + child.stdout.on("data", (d) => (stdout += d)); | |
| 44 | + child.stderr.on("data", (d) => (stderr += d)); | |
| 45 | + const t = setTimeout(() => child.kill("SIGKILL"), 30_000); | |
| 46 | + child.on("close", (status) => { | |
| 47 | + clearTimeout(t); | |
| 48 | + resolve({ status, stdout, stderr }); | |
| 49 | + }); | |
| 50 | + }); | |
| 51 | +} | |
| 52 | + | |
| 35 | 53 | describe("job runner end-to-end", () => { |
| 36 | 54 | it("propagates env, outputs, path, summaries and job outputs", async () => { |
| 37 | 55 | const spec = makeSpec( |
| @@ -349,6 +367,58 @@ | ||
| 349 | 367 | expect(api.logText(1)).toContain("after"); |
| 350 | 368 | }); |
| 351 | 369 | |
| 370 | + it("downloads and runs remote actions (owner/repo/path@ref)", async () => { | |
| 371 | + // fake codeload: serves a tarball with a top-level folder, like GitHub does | |
| 372 | + const src = tmp("remote-action-"); | |
| 373 | + const top = path.join(src, "tools-abc123"); | |
| 374 | + mkdirSync(path.join(top, "hello"), { recursive: true }); | |
| 375 | + writeFileSync( | |
| 376 | + path.join(top, "hello/action.yml"), | |
| 377 | + "name: hello\ninputs:\n name:\n default: anon\noutputs:\n said:\n description: x\nruns:\n using: 'node24'\n main: 'main.js'\n", | |
| 378 | + ); | |
| 379 | + writeFileSync( | |
| 380 | + path.join(top, "hello/main.js"), | |
| 381 | + 'require("fs").appendFileSync(process.env.GITHUB_OUTPUT, `said=hi ${process.env.INPUT_NAME}\\n`); console.log("remote action ran in", process.env.GITHUB_ACTION_REPOSITORY, process.env.GITHUB_ACTION_REF);', | |
| 382 | + ); | |
| 383 | + const tarball = path.join(src, "a.tgz"); | |
| 384 | + execFileSync("tar", ["-czf", tarball, "-C", src, "tools-abc123"]); | |
| 385 | + const hits: string[] = []; | |
| 386 | + const server = createServer((req, res) => { | |
| 387 | + hits.push(req.url ?? ""); | |
| 388 | + if (req.url === "/acme/tools/tar.gz/v1") { | |
| 389 | + res.writeHead(200, { "content-type": "application/gzip" }); | |
| 390 | + res.end(readFileSync(tarball)); | |
| 391 | + } else { | |
| 392 | + res.writeHead(404); | |
| 393 | + res.end(); | |
| 394 | + } | |
| 395 | + }); | |
| 396 | + await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r())); | |
| 397 | + const prev = process.env.DIFFINERY_ACTIONS_DOWNLOAD_URL; | |
| 398 | + process.env.DIFFINERY_ACTIONS_DOWNLOAD_URL = `http://127.0.0.1:${(server.address() as AddressInfo).port}/{owner}/{repo}/tar.gz/{ref}`; | |
| 399 | + try { | |
| 400 | + const c = await run( | |
| 401 | + makeSpec(api, [ | |
| 402 | + { id: "h", uses: "acme/tools/hello@v1", with: { name: "bob" } }, | |
| 403 | + { uses: "acme/tools/hello@v1" }, | |
| 404 | + { run: 'echo "out=${{ steps.h.outputs.said }}"' }, | |
| 405 | + { uses: "acme/missing@v9" }, | |
| 406 | + ]), | |
| 407 | + ); | |
| 408 | + expect(api.logText(0)).toContain("remote action ran in acme/tools v1"); | |
| 409 | + expect(api.logText(2)).toContain("out=hi bob"); | |
| 410 | + expect(hits.filter((h) => h === "/acme/tools/tar.gz/v1")).toHaveLength(1); // cached after first download | |
| 411 | + expect(c.conclusion).toBe("failure"); | |
| 412 | + expect(c.failure?.step).toBe(3); | |
| 413 | + expect(c.failure?.message).toContain("Unable to resolve action 'acme/missing@v9'"); | |
| 414 | + } finally { | |
| 415 | + if (prev === undefined) delete process.env.DIFFINERY_ACTIONS_DOWNLOAD_URL; | |
| 416 | + else process.env.DIFFINERY_ACTIONS_DOWNLOAD_URL = prev; | |
| 417 | + await new Promise<void>((r) => server.close(() => r())); | |
| 418 | + rmSync(src, { recursive: true, force: true }); | |
| 419 | + } | |
| 420 | + }); | |
| 421 | + | |
| 352 | 422 | it("rejects docker actions and github-script with clear errors", async () => { |
| 353 | 423 | const c = await run(makeSpec(api, [{ uses: "docker://alpine:3" }])); |
| 354 | 424 | expect(c.failure?.message).toContain("Docker actions are not supported on Diffinery runners yet"); |
| @@ -390,7 +460,7 @@ | ||
| 390 | 460 | }); |
| 391 | 461 | |
| 392 | 462 | it("caches and restores via the runner cache endpoint, and round-trips artifacts", async () => { |
| 393 | - const steps = [ | |
| 463 | + const steps: StepSpec[] = [ | |
| 394 | 464 | { run: 'mkdir -p deps dist/sub && echo "${{ github.run_id }}" > deps/marker && echo a > dist/a.txt && echo b > dist/sub/b.txt' }, |
| 395 | 465 | { id: "cache", uses: "actions/cache@v4", with: { path: "deps", key: "deps-${{ hashFiles('**/lock.txt') }}-v1", "restore-keys": "deps-" } }, |
| 396 | 466 | { run: 'echo "hit=${{ steps.cache.outputs.cache-hit }}"; cat deps/marker' }, |
| @@ -499,10 +569,9 @@ | ||
| 499 | 569 | const spec = makeSpec(api, [{ run: 'echo "from bundle $GITHUB_JOB"' }], { outputs: { x: "y" } }); |
| 500 | 570 | const specFile = path.join(workRoot, "spec.json"); |
| 501 | 571 | writeFileSync(specFile, JSON.stringify(spec)); |
| 502 | - const env = { ...process.env, DIFFINERY_WORK_ROOT: path.join(workRoot, "w1"), DIFFINERY_API_URL: "" }; | |
| 572 | + const env: NodeJS.ProcessEnv = { ...process.env, DIFFINERY_WORK_ROOT: path.join(workRoot, "w1") }; | |
| 503 | 573 | delete env.DIFFINERY_API_URL; |
| 504 | - const { spawnSync } = await import("node:child_process"); | |
| 505 | - const r = spawnSync(process.execPath, [out, "--spec", specFile], { env, encoding: "utf8", timeout: 30_000 }); | |
| 574 | + const r = await spawnAsync(process.execPath, [out, "--spec", specFile], env); | |
| 506 | 575 | expect(r.status, r.stderr + r.stdout).toBe(0); |
| 507 | 576 | expect(api.completion).toMatchObject({ conclusion: "success", outputs: { x: "y" } }); |
| 508 | 577 | expect(api.logText(0)).toContain("from bundle build"); |
| @@ -510,13 +579,32 @@ | ||
| 510 | 579 | // env mode: fetch the spec from the API |
| 511 | 580 | api.completion = null; |
| 512 | 581 | api.specs.set("job-2", { ...spec, jobId: "job-2", steps: [{ run: "exit 4" }] }); |
| 513 | - const r2 = spawnSync(process.execPath, [out], { | |
| 514 | - env: { ...process.env, DIFFINERY_WORK_ROOT: path.join(workRoot, "w2"), DIFFINERY_API_URL: api.url, DIFFINERY_JOB_ID: "job-2", DIFFINERY_JOB_TOKEN: api.token }, | |
| 515 | - encoding: "utf8", | |
| 516 | - timeout: 30_000, | |
| 582 | + const r2 = await spawnAsync(process.execPath, [out], { | |
| 583 | + ...process.env, | |
| 584 | + DIFFINERY_WORK_ROOT: path.join(workRoot, "w2"), | |
| 585 | + DIFFINERY_API_URL: api.url, | |
| 586 | + DIFFINERY_JOB_ID: "job-2", | |
| 587 | + DIFFINERY_JOB_TOKEN: api.token, | |
| 517 | 588 | }); |
| 518 | 589 | expect(r2.status).toBe(1); |
| 519 | 590 | expect(api.completion).toMatchObject({ conclusion: "failure", failure: { step: 0 } }); |
| 520 | 591 | expect(readFileSync(path.join(workRoot, "w2/_temp/_github_workflow/event.json"), "utf8")).toContain("pusher"); |
| 592 | + | |
| 593 | + // SIGTERM: kill the running step's process group and report cancelled | |
| 594 | + api.completion = null; | |
| 595 | + api.steps = []; | |
| 596 | + api.specs.set("job-3", { ...spec, jobId: "job-3", steps: [{ run: "echo started; sleep 60" }, { run: "echo never" }] }); | |
| 597 | + const child = spawn(process.execPath, [out], { | |
| 598 | + env: { ...process.env, DIFFINERY_WORK_ROOT: path.join(workRoot, "w3"), DIFFINERY_API_URL: api.url, DIFFINERY_JOB_ID: "job-3", DIFFINERY_JOB_TOKEN: api.token }, | |
| 599 | + }); | |
| 600 | + const exited = new Promise<number | null>((r) => child.on("close", (code) => r(code))); | |
| 601 | + const t0 = Date.now(); | |
| 602 | + while (!api.logs.some((l) => l.text === "started") && Date.now() - t0 < 15_000) await new Promise((r) => setTimeout(r, 50)); | |
| 603 | + child.kill("SIGTERM"); | |
| 604 | + expect(await exited).toBe(1); | |
| 605 | + expect(Date.now() - t0).toBeLessThan(15_000); | |
| 606 | + expect(api.completion).toMatchObject({ conclusion: "cancelled" }); | |
| 607 | + expect(api.lastReport(0)?.conclusion).toBe("cancelled"); | |
| 608 | + expect(api.lastReport(1)?.conclusion).toBe("cancelled"); | |
| 521 | 609 | }, 60_000); |
| 522 | 610 | }); |
modifiedcontainers/runner/test/unit.test.ts+2−0View file
| @@ -131,6 +131,8 @@ | ||
| 131 | 131 | m.add("-----BEGIN KEY-----\nAAAABBBB\n-----END KEY-----"); |
| 132 | 132 | m.add(""); |
| 133 | 133 | m.add(" "); |
| 134 | + m.add("t"); // too short to mask | |
| 135 | + expect(m.mask("test")).toBe("test"); | |
| 134 | 136 | expect(m.mask("x abcdef y abc z")).toBe("x *** y *** z"); |
| 135 | 137 | expect(m.mask("AAAABBBB")).toBe("***"); |
| 136 | 138 | expect(m.mask("nothing here")).toBe("nothing here"); |