Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

Shallow fetch support, deploy tooling, and an end-to-end test with real git

- git: protocol v2 fetch handles deepen, deepen-since and shallow clients
- scripts/bootstrap.sh creates D1/R2/queues/Access apps idempotently;
  ensure-secrets.sh generates MASTER_KEY and the OIDC key once
- CI and deploy workflows (deploy runs only from main)
- e2e: wrangler dev + git CLI + runner agent: push, CI via self-hosted
  runner, push protection, agent PR needing human approval, merge,
  shallow clone, MCP

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL
Claude authored signed1 parent 263cb1ecommit 3fcb4d840ea5c2cc00f723cf7e65dc15664b9611Browse files
37 files changed+6796−228
Files (37)
added.github/workflows/ci.yml+51−0View file
@@ -1,0 +1,51 @@
1+name: CI
2+
3+on:
4+ push:
5+ branches: [main]
6+ pull_request:
7+
8+permissions:
9+ contents: read
10+
11+concurrency:
12+ group: ci-${{ github.ref }}
13+ cancel-in-progress: true
14+
15+jobs:
16+ check:
17+ name: Typecheck and test
18+ runs-on: ubuntu-latest
19+ timeout-minutes: 20
20+ steps:
21+ - uses: actions/checkout@v4
22+ - uses: pnpm/action-setup@v4
23+ - uses: actions/setup-node@v4
24+ with:
25+ node-version: 22
26+ cache: pnpm
27+ - run: pnpm install --frozen-lockfile
28+ - name: Build
29+ run: pnpm -r --if-present build
30+ - name: Typecheck
31+ run: pnpm -r --if-present typecheck
32+ - name: Unit tests
33+ run: pnpm -r --if-present test
34+ - name: Bundle the Worker (dry run)
35+ working-directory: apps/diffinery
36+ run: pnpm exec wrangler deploy --dry-run --env dev --outdir dist
37+
38+ e2e:
39+ name: End-to-end (git, PRs, Actions)
40+ runs-on: ubuntu-latest
41+ timeout-minutes: 20
42+ steps:
43+ - uses: actions/checkout@v4
44+ - uses: pnpm/action-setup@v4
45+ - uses: actions/setup-node@v4
46+ with:
47+ node-version: 22
48+ cache: pnpm
49+ - run: pnpm install --frozen-lockfile
50+ - run: pnpm -r --if-present build
51+ - run: pnpm --filter @diffinery/app e2e
added.github/workflows/deploy.yml+66−0View file
@@ -1,0 +1,66 @@
1+name: Deploy
2+
3+# Deploys diffinery.com from main. Uses the repository secrets
4+# CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID; optional repository variable
5+# ACCESS_TEAM_DOMAIN (e.g. codician.cloudflareaccess.com).
6+on:
7+ push:
8+ branches: [main]
9+ workflow_dispatch:
10+
11+permissions:
12+ contents: read
13+
14+concurrency:
15+ group: deploy-production
16+ cancel-in-progress: false
17+
18+jobs:
19+ deploy:
20+ name: Deploy to Cloudflare
21+ runs-on: ubuntu-latest
22+ timeout-minutes: 45
23+ environment:
24+ name: production
25+ url: https://diffinery.com
26+ env:
27+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
28+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
29+ ACCESS_TEAM_DOMAIN: ${{ vars.ACCESS_TEAM_DOMAIN }}
30+ steps:
31+ - uses: actions/checkout@v4
32+ - uses: pnpm/action-setup@v4
33+ - uses: actions/setup-node@v4
34+ with:
35+ node-version: 22
36+ cache: pnpm
37+ - run: pnpm install --frozen-lockfile
38+ - name: Build runner agent
39+ run: pnpm --filter @diffinery/runner build
40+ - name: Typecheck and test
41+ run: pnpm -r --if-present typecheck && pnpm -r --if-present test
42+
43+ - name: Create or update Cloudflare resources
44+ id: bootstrap
45+ run: scripts/bootstrap.sh
46+
47+ - name: Deploy Worker and runner image
48+ working-directory: apps/diffinery
49+ run: pnpm exec wrangler deploy -c wrangler.deploy.jsonc
50+
51+ - name: Generate Worker secrets (first deploy only)
52+ run: scripts/ensure-secrets.sh
53+
54+ - name: Finish Access setup on first deploy
55+ if: steps.bootstrap.outputs.access_team == ''
56+ run: |
57+ scripts/bootstrap.sh
58+ cd apps/diffinery && pnpm exec wrangler deploy -c wrangler.deploy.jsonc --containers-rollout none
59+
60+ - name: Smoke test
61+ run: |
62+ for i in 1 2 3 4 5 6; do
63+ code=$(curl -s -o /dev/null -w '%{http_code}' https://diffinery.com/healthz) && [ "$code" = 200 ] && exit 0
64+ sleep 10
65+ done
66+ echo "::error::https://diffinery.com/healthz did not return 200 (last: $code)"; exit 1
addedapps/cli/package.json+17−0View file
@@ -1,0 +1,17 @@
1+{
2+ "name": "@diffinery/cli",
3+ "version": "0.1.0",
4+ "private": true,
5+ "description": "Command-line client for Diffinery",
6+ "type": "module",
7+ "bin": { "diffinery": "dist/diffinery.mjs" },
8+ "files": ["dist"],
9+ "engines": { "node": ">=22" },
10+ "scripts": {
11+ "build": "esbuild src/main.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/diffinery.mjs \"--banner:js=#!/usr/bin/env node\nimport { createRequire as __diffineryCreateRequire } from 'node:module'; const require = __diffineryCreateRequire(import.meta.url);\"",
12+ "typecheck": "tsc --noEmit",
13+ "test": "vitest run"
14+ },
15+ "dependencies": { "@diffinery/workflow-yaml": "workspace:*" },
16+ "devDependencies": { "typescript": "^5.9.3", "vitest": "^4.1.0", "esbuild": "^0.25.0", "@types/node": "^22.0.0" }
17+}
addedapps/cli/src/api.ts+95−0View file
@@ -1,0 +1,95 @@
1+import { CliError } from "./io.js";
2+import { VERSION } from "./version.js";
3+
4+export class ApiError extends CliError {
5+ constructor(
6+ readonly status: number,
7+ message: string,
8+ readonly body: unknown,
9+ ) {
10+ super(message);
11+ }
12+}
13+
14+export interface RequestOptions {
15+ body?: unknown;
16+ query?: Record<string, string | number | boolean | undefined | null>;
17+ /** Return the response text instead of parsing JSON. */
18+ text?: boolean;
19+ /** Treat these statuses as success (e.g. 404 when probing existence). */
20+ allow?: number[];
21+ headers?: Record<string, string>;
22+}
23+
24+export interface ApiResponse<T> {
25+ status: number;
26+ data: T;
27+ headers: Headers;
28+}
29+
30+/** Thin client for the Diffinery REST API (`/api/v3`, GitHub-shaped). */
31+export class Api {
32+ constructor(
33+ readonly baseUrl: string,
34+ readonly token: string | null,
35+ ) {}
36+
37+ url(path: string, query?: RequestOptions["query"]): string {
38+ const p = /^https?:\/\//.test(path) ? path : `${this.baseUrl}${path.startsWith("/api/") || path === "/mcp" ? "" : "/api/v3"}${path.startsWith("/") ? path : `/${path}`}`;
39+ const u = new URL(p);
40+ for (const [k, v] of Object.entries(query ?? {})) if (v !== undefined && v !== null && v !== "") u.searchParams.set(k, String(v));
41+ return u.toString();
42+ }
43+
44+ async raw<T = unknown>(method: string, path: string, o: RequestOptions = {}): Promise<ApiResponse<T>> {
45+ const headers: Record<string, string> = { accept: "application/json", "user-agent": `diffinery-cli/${VERSION}`, ...o.headers };
46+ if (this.token) headers.authorization = `Bearer ${this.token}`;
47+ let body: string | undefined;
48+ if (o.body !== undefined) {
49+ body = typeof o.body === "string" ? o.body : JSON.stringify(o.body);
50+ headers["content-type"] ??= "application/json";
51+ }
52+ let res: Response;
53+ const url = this.url(path, o.query);
54+ try {
55+ res = await fetch(url, { method, headers, body });
56+ } catch (e) {
57+ const cause = (e as { cause?: { code?: string; message?: string } }).cause;
58+ throw new CliError(`could not reach ${new URL(url).origin}: ${cause?.code ?? cause?.message ?? (e as Error).message}`);
59+ }
60+ const text = await res.text();
61+ let data: unknown = text;
62+ if (!o.text && text && (res.headers.get("content-type") ?? "").includes("json")) {
63+ try {
64+ data = JSON.parse(text);
65+ } catch {
66+ /* leave as text */
67+ }
68+ } else if (!o.text && !text) data = null;
69+ if (!res.ok && !o.allow?.includes(res.status)) {
70+ const msg = data && typeof data === "object" && "message" in data ? String((data as { message: unknown }).message) : text.slice(0, 300) || res.statusText;
71+ let hint = "";
72+ if (res.status === 401) hint = this.token ? " (token invalid or expired; run `diffinery login`)" : " (not logged in; run `diffinery login`)";
73+ throw new ApiError(res.status, `${method} ${new URL(url).pathname}: ${res.status} ${msg}${hint}`, data);
74+ }
75+ return { status: res.status, data: data as T, headers: res.headers };
76+ }
77+
78+ async get<T = unknown>(path: string, query?: RequestOptions["query"]): Promise<T> {
79+ return (await this.raw<T>("GET", path, { query })).data;
80+ }
81+ async post<T = unknown>(path: string, body?: unknown): Promise<T> {
82+ return (await this.raw<T>("POST", path, { body: body ?? {} })).data;
83+ }
84+ async put<T = unknown>(path: string, body?: unknown): Promise<T> {
85+ return (await this.raw<T>("PUT", path, { body: body ?? {} })).data;
86+ }
87+ async patch<T = unknown>(path: string, body: unknown): Promise<T> {
88+ return (await this.raw<T>("PATCH", path, { body })).data;
89+ }
90+ async delete<T = unknown>(path: string): Promise<T> {
91+ return (await this.raw<T>("DELETE", path)).data;
92+ }
93+}
94+
95+export const enc = encodeURIComponent;
addedapps/cli/src/cli.ts+260−0View file
@@ -1,0 +1,260 @@
1+import { parseArgs } from "node:util";
2+import { Api } from "./api.js";
3+import { resolveConfig, type ResolvedConfig } from "./config.js";
4+import { gitCapture, parseRemote } from "./git.js";
5+import { CliError, ExitCode, type Io } from "./io.js";
6+import { colors, type Colors } from "./output.js";
7+import { VERSION } from "./version.js";
8+
9+export interface OptSpec {
10+ type: "string" | "boolean";
11+ short?: string;
12+ multiple?: boolean;
13+ desc: string;
14+ /** Placeholder shown in help, e.g. "NAME". */
15+ arg?: string;
16+}
17+
18+export type Values = Record<string, string | boolean | string[] | undefined>;
19+
20+export interface Command {
21+ /** Full command path, e.g. "pr create". */
22+ name: string;
23+ summary: string;
24+ /** Positional part of the usage line, e.g. "<number>". */
25+ args?: string;
26+ description?: string;
27+ options?: Record<string, OptSpec>;
28+ /** Accepts -R/--repo OWNER/NAME (defaults to the git origin). */
29+ repo?: boolean;
30+ /** Accepts --json. */
31+ json?: boolean;
32+ minArgs?: number;
33+ maxArgs?: number;
34+ run(ctx: Ctx, o: Values, args: string[]): Promise<number | void>;
35+}
36+
37+export interface RepoRef {
38+ owner: string;
39+ name: string;
40+}
41+
42+export class Ctx {
43+ readonly c: Colors;
44+ private cfg?: Promise<ResolvedConfig>;
45+ constructor(
46+ readonly io: Io,
47+ readonly opts: Values,
48+ ) {
49+ this.c = colors(io);
50+ }
51+
52+ get json(): boolean {
53+ return this.opts.json === true;
54+ }
55+
56+ config(): Promise<ResolvedConfig> {
57+ return (this.cfg ??= resolveConfig(this.io));
58+ }
59+
60+ async api(): Promise<Api> {
61+ const cfg = await this.config();
62+ return new Api(cfg.url, cfg.token);
63+ }
64+
65+ /** Requires a token (for commands that make no sense anonymously). */
66+ async authedApi(): Promise<Api> {
67+ const cfg = await this.config();
68+ if (!cfg.token) throw new CliError("not logged in: run `diffinery login` or set DIFFINERY_TOKEN");
69+ return new Api(cfg.url, cfg.token);
70+ }
71+
72+ out(s: string): void {
73+ this.io.stdout.write(s.endsWith("\n") ? s : `${s}\n`);
74+ }
75+
76+ err(s: string): void {
77+ this.io.stderr.write(s.endsWith("\n") ? s : `${s}\n`);
78+ }
79+
80+ /** -R owner/name, else the current git repository's origin when it points at the configured host. */
81+ async repo(flag?: string): Promise<RepoRef> {
82+ const v = flag ?? (typeof this.opts.repo === "string" ? this.opts.repo : undefined);
83+ if (v) return parseRepoArg(v);
84+ const cfg = await this.config();
85+ const host = new URL(cfg.url).host;
86+ const r = await gitCapture(this.io, ["remote", "get-url", "origin"]);
87+ if (r.code === 0) {
88+ const parsed = parseRemote(r.stdout, host);
89+ if (parsed) return parsed;
90+ throw new CliError(`the origin remote (${r.stdout.trim()}) does not point at ${host}; pass -R OWNER/NAME`);
91+ }
92+ throw new CliError("no repository given: pass -R OWNER/NAME or run inside a clone of a Diffinery repository");
93+ }
94+}
95+
96+export function parseRepoArg(v: string): RepoRef {
97+ const s = v.replace(/^https?:\/\/[^/]+\//, "").replace(/\.git$/, "").replace(/\/+$/, "");
98+ const [owner, name, extra] = s.split("/");
99+ if (!owner || !name || extra !== undefined) throw new CliError(`expected OWNER/NAME, got "${v}"`, 2);
100+ return { owner, name };
101+}
102+
103+export const repoPath = (r: RepoRef) => `/repos/${encodeURIComponent(r.owner)}/${encodeURIComponent(r.name)}`;
104+
105+// ---------------------------------------------------------------- help
106+
107+const COMMON: Record<string, OptSpec> = {
108+ repo: { type: "string", short: "R", desc: "Repository (default: inferred from the git origin remote)", arg: "OWNER/NAME" },
109+ json: { type: "boolean", desc: "Print raw JSON" },
110+ help: { type: "boolean", short: "h", desc: "Show help" },
111+};
112+
113+export function optionsFor(cmd: Command): Record<string, OptSpec> {
114+ return { ...(cmd.repo ? { repo: COMMON.repo! } : {}), ...cmd.options, ...(cmd.json ? { json: COMMON.json! } : {}), help: COMMON.help! };
115+}
116+
117+export function commandHelp(cmd: Command): string {
118+ const opts = optionsFor(cmd);
119+ const lines = [`${cmd.summary}`, "", `Usage: diffinery ${cmd.name}${cmd.args ? ` ${cmd.args}` : ""}${Object.keys(opts).length > 1 ? " [options]" : ""}`];
120+ if (cmd.description) lines.push("", cmd.description.trim());
121+ const rows = Object.entries(opts).map(([k, o]) => {
122+ const flag = `${o.short ? `-${o.short}, ` : " "}--${k}${o.type === "string" ? ` ${o.arg ?? "VALUE"}` : ""}`;
123+ return [flag, o.desc + (o.multiple ? " (repeatable)" : "")];
124+ });
125+ const w = Math.max(...rows.map((r) => r[0]!.length));
126+ lines.push("", "Options:", ...rows.map(([f, d]) => ` ${f!.padEnd(w)} ${d}`));
127+ return lines.join("\n") + "\n";
128+}
129+
130+export function groupHelp(group: string, summary: string, cmds: Command[]): string {
131+ const w = Math.max(...cmds.map((c) => c.name.length));
132+ return [summary, "", `Usage: diffinery ${group} <command> [options]`, "", "Commands:", ...cmds.map((c) => ` ${c.name.padEnd(w)} ${c.summary}`), "", `Run 'diffinery ${group} <command> --help' for details.`].join("\n") + "\n";
133+}
134+
135+// ---------------------------------------------------------------- dispatch
136+
137+export interface Group {
138+ name: string;
139+ summary: string;
140+ commands: Command[];
141+}
142+
143+export interface Registry {
144+ groups: Group[];
145+ commands: Command[];
146+}
147+
148+export function topHelp(reg: Registry): string {
149+ const rows: Array<[string, string]> = [...reg.groups.map((g) => [g.name, g.summary] as [string, string]), ...reg.commands.map((c) => [c.name, c.summary] as [string, string])];
150+ rows.sort((a, b) => a[0].localeCompare(b[0]));
151+ const w = Math.max(...rows.map((r) => r[0].length));
152+ return [
153+ `diffinery ${VERSION}: command-line client for Diffinery`,
154+ "",
155+ "Usage: diffinery <command> [subcommand] [options]",
156+ "",
157+ "Commands:",
158+ ...rows.map(([n, s]) => ` ${n.padEnd(w)} ${s}`),
159+ "",
160+ "Global:",
161+ " --help, -h Show help for any command",
162+ " --version Print the version",
163+ "",
164+ "Configuration: ~/.config/diffinery/config.json (respects XDG_CONFIG_HOME);",
165+ "DIFFINERY_URL and DIFFINERY_TOKEN override it.",
166+ ].join("\n") + "\n";
167+}
168+
169+export async function dispatch(reg: Registry, argv: string[], io: Io): Promise<number> {
170+ const [first, ...rest] = argv;
171+ if (!first || first === "--help" || first === "-h" || first === "help") {
172+ if (first === "help" && rest.length) return dispatch(reg, [...rest, "--help"], io);
173+ io.stdout.write(topHelp(reg));
174+ return first ? 0 : argv.length ? 0 : 2;
175+ }
176+ if (first === "--version" || first === "-v" || first === "version") {
177+ io.stdout.write(`diffinery ${VERSION}\n`);
178+ return 0;
179+ }
180+ const group = reg.groups.find((g) => g.name === first);
181+ if (group) {
182+ const [sub, ...args] = rest;
183+ if (!sub || sub === "--help" || sub === "-h") {
184+ io.stdout.write(groupHelp(group.name, group.summary, group.commands));
185+ return sub ? 0 : 2;
186+ }
187+ const cmd = group.commands.find((c) => c.name === `${group.name} ${sub}`);
188+ if (!cmd) {
189+ io.stderr.write(`unknown command "${group.name} ${sub}"\n\n${groupHelp(group.name, group.summary, group.commands)}`);
190+ return 2;
191+ }
192+ return runCommand(cmd, args, io);
193+ }
194+ const cmd = reg.commands.find((c) => c.name === first);
195+ if (!cmd) {
196+ io.stderr.write(`unknown command "${first}"; run 'diffinery --help'\n`);
197+ return 2;
198+ }
199+ return runCommand(cmd, rest, io);
200+}
201+
202+export async function runCommand(cmd: Command, argv: string[], io: Io): Promise<number> {
203+ const specs = optionsFor(cmd);
204+ let values: Values;
205+ let positionals: string[];
206+ try {
207+ const parsed = parseArgs({
208+ args: argv,
209+ options: Object.fromEntries(Object.entries(specs).map(([k, o]) => [k, { type: o.type, short: o.short, multiple: o.multiple }])),
210+ allowPositionals: true,
211+ strict: true,
212+ });
213+ values = parsed.values as Values;
214+ positionals = parsed.positionals;
215+ } catch (e) {
216+ io.stderr.write(`error: ${(e as Error).message.replace(/^TypeError \[[A-Z_]+\]: /, "")}\n\n${commandHelp(cmd)}`);
217+ return 2;
218+ }
219+ if (values.help) {
220+ io.stdout.write(commandHelp(cmd));
221+ return 0;
222+ }
223+ if ((cmd.minArgs !== undefined && positionals.length < cmd.minArgs) || (cmd.maxArgs !== undefined && positionals.length > cmd.maxArgs)) {
224+ io.stderr.write(`error: ${positionals.length < (cmd.minArgs ?? 0) ? "missing arguments" : `unexpected argument "${positionals[cmd.maxArgs ?? 0]}"`}\n\n${commandHelp(cmd)}`);
225+ return 2;
226+ }
227+ const ctx = new Ctx(io, values);
228+ try {
229+ const code = await cmd.run(ctx, values, positionals);
230+ return typeof code === "number" ? code : 0;
231+ } catch (e) {
232+ if (e instanceof ExitCode) return e.code;
233+ if (e instanceof CliError) {
234+ io.stderr.write(`${ctx.c.red("error:")} ${e.message}\n`);
235+ return e.code;
236+ }
237+ throw e;
238+ }
239+}
240+
241+// ---------------------------------------------------------------- small helpers for commands
242+
243+export const str = (o: Values, k: string): string | undefined => (typeof o[k] === "string" ? (o[k] as string) : undefined);
244+export const list = (o: Values, k: string): string[] => (Array.isArray(o[k]) ? (o[k] as string[]) : typeof o[k] === "string" ? [o[k] as string] : []);
245+
246+export function positiveInt(v: string | undefined, what: string): number {
247+ const n = Number(String(v ?? "").replace(/^#/, ""));
248+ if (!Number.isInteger(n) || n < 1) throw new CliError(`${what} must be a positive number, got "${v ?? ""}"`, 2);
249+ return n;
250+}
251+
252+export function exactlyOne(o: Values, keys: string[], fallback?: string): string {
253+ const set = keys.filter((k) => o[k] === true);
254+ if (set.length > 1) throw new CliError(`use only one of ${keys.map((k) => `--${k}`).join(", ")}`, 2);
255+ if (set.length === 0) {
256+ if (fallback) return fallback;
257+ throw new CliError(`one of ${keys.map((k) => `--${k}`).join(", ")} is required`, 2);
258+ }
259+ return set[0]!;
260+}
addedapps/cli/src/commands/admin.ts+301−0View file
@@ -1,0 +1,301 @@
1+import { readFile } from "node:fs/promises";
2+import path from "node:path";
3+import { enc } from "../api.js";
4+import { list, positiveInt, repoPath, str, type Command, type Group } from "../cli.js";
5+import { CliError, ExitCode, readAll, valueFromFlagOrStdin } from "../io.js";
6+import { ago, json, table } from "../output.js";
7+
8+const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
9+function checkName(name: string, what: string): string {
10+ if (!NAME_RE.test(name)) throw new CliError(`invalid ${what} name "${name}": use letters, digits and _, not starting with a digit`, 2);
11+ return name;
12+}
13+
14+// ---------------------------------------------------------------- secrets
15+
16+const secretSet: Command = {
17+ name: "secret set",
18+ summary: "Create or update an Actions secret",
19+ description: "The value comes from --body, from stdin when piped, or a hidden prompt.\nRepository level by default; --org sets an organization secret.",
20+ args: "<NAME>",
21+ options: {
22+ body: { type: "string", short: "b", desc: "Secret value", arg: "VALUE" },
23+ org: { type: "string", short: "o", desc: "Set an organization secret instead", arg: "ORG" },
24+ },
25+ repo: true,
26+ minArgs: 1,
27+ maxArgs: 1,
28+ async run(ctx, o, [name]) {
29+ checkName(name!, "secret");
30+ const org = str(o, "org");
31+ const target = org ? `/orgs/${enc(org)}/actions/secrets/${enc(name!)}` : `${repoPath(await ctx.repo())}/actions/secrets/${enc(name!)}`;
32+ const value = await valueFromFlagOrStdin(ctx.io, str(o, "body"), `Value for ${name}: `);
33+ if (!value) throw new CliError("secret value is empty");
34+ await (await ctx.authedApi()).put(target, { value });
35+ ctx.out(`${ctx.c.green("✓")} Set secret ${name}${org ? ` for ${org}` : ""}`);
36+ },
37+};
38+
39+const secretList: Command = {
40+ name: "secret list",
41+ summary: "List Actions secret names",
42+ options: { org: { type: "string", short: "o", desc: "Organization secrets (not supported by the server yet)", arg: "ORG" } },
43+ repo: true,
44+ json: true,
45+ maxArgs: 0,
46+ async run(ctx, o) {
47+ if (str(o, "org")) throw new CliError("listing organization secrets is not supported by this Diffinery server (no GET /orgs/:org/actions/secrets)");
48+ const res = await (await ctx.authedApi()).get<{ secrets: Array<{ name: string; updated_at: string }> }>(`${repoPath(await ctx.repo())}/actions/secrets`);
49+ if (ctx.json) return json(ctx.io, res.secrets);
50+ if (!res.secrets.length) return ctx.err("No secrets.");
51+ ctx.io.stdout.write(table(res.secrets.map((s) => [s.name, ctx.c.dim(`updated ${ago(s.updated_at)}`)])));
52+ },
53+};
54+
55+const secretDelete: Command = {
56+ name: "secret delete",
57+ summary: "Delete an Actions secret",
58+ args: "<NAME>",
59+ options: { org: { type: "string", short: "o", desc: "Organization secret (not supported by the server yet)", arg: "ORG" } },
60+ repo: true,
61+ minArgs: 1,
62+ maxArgs: 1,
63+ async run(ctx, o, [name]) {
64+ if (str(o, "org")) throw new CliError("deleting organization secrets is not supported by this Diffinery server (no DELETE /orgs/:org/actions/secrets/:name)");
65+ await (await ctx.authedApi()).delete(`${repoPath(await ctx.repo())}/actions/secrets/${enc(name!)}`);
66+ ctx.out(`${ctx.c.green("✓")} Deleted secret ${name}`);
67+ },
68+};
69+
70+const variableSet: Command = {
71+ name: "variable set",
72+ summary: "Create or update an Actions variable",
73+ description: "The value comes from --body, or from stdin when piped.",
74+ args: "<NAME>",
75+ options: { body: { type: "string", short: "b", desc: "Variable value", arg: "VALUE" } },
76+ repo: true,
77+ minArgs: 1,
78+ maxArgs: 1,
79+ async run(ctx, o, [name]) {
80+ checkName(name!, "variable");
81+ let value = str(o, "body");
82+ if (value === undefined) {
83+ if (ctx.io.stdin.isTTY) throw new CliError("pass --body VALUE or pipe the value on stdin", 2);
84+ value = (await readAll(ctx.io.stdin)).replace(/\r?\n$/, "");
85+ }
86+ await (await ctx.authedApi()).put(`${repoPath(await ctx.repo())}/actions/variables/${enc(name!)}`, { value });
87+ ctx.out(`${ctx.c.green("✓")} Set variable ${name}`);
88+ },
89+};
90+
91+const variableList: Command = {
92+ name: "variable list",
93+ summary: "List Actions variables",
94+ repo: true,
95+ json: true,
96+ maxArgs: 0,
97+ async run(ctx) {
98+ const res = await (await ctx.api()).get<{ variables: Array<{ name: string; value: string }> }>(`${repoPath(await ctx.repo())}/actions/variables`);
99+ if (ctx.json) return json(ctx.io, res.variables);
100+ if (!res.variables.length) return ctx.err("No variables.");
101+ ctx.io.stdout.write(table(res.variables.map((v) => [v.name, v.value])));
102+ },
103+};
104+
105+// ---------------------------------------------------------------- tokens
106+
107+interface TokenRow {
108+ id: string;
109+ name: string;
110+ prefix: string;
111+ scopes: string[];
112+ repoIds: string[] | null;
113+ expiresAt: string;
114+ lastUsedAt: string | null;
115+ createdAt: string;
116+}
117+
118+const tokenCreate: Command = {
119+ name: "token create",
120+ summary: "Create a personal access token (printed once)",
121+ description: "Needs an admin-scoped token (or create tokens in the web UI).",
122+ options: {
123+ name: { type: "string", short: "n", desc: "Token name (required)", arg: "NAME" },
124+ scope: { type: "string", short: "s", multiple: true, desc: "read, write (default) or admin", arg: "SCOPE" },
125+ "expires-days": { type: "string", short: "e", desc: "Days until it expires (1-366, default 90)", arg: "DAYS" },
126+ repo: { type: "string", short: "R", multiple: true, desc: "Restrict the token to this repository", arg: "OWNER/NAME" },
127+ },
128+ json: true,
129+ maxArgs: 0,
130+ async run(ctx, o) {
131+ const name = str(o, "name");
132+ if (!name) throw new CliError("--name is required", 2);
133+ const scopes = list(o, "scope").flatMap((s) => s.split(",")).map((s) => s.trim()).filter(Boolean);
134+ for (const s of scopes) if (!["read", "write", "admin"].includes(s)) throw new CliError(`unknown scope "${s}" (read, write or admin)`, 2);
135+ const days = str(o, "expires-days") ? positiveInt(str(o, "expires-days"), "--expires-days") : undefined;
136+ const repos = list(o, "repo");
137+ const t = await (await ctx.authedApi()).post<{ token: string; id: string; expiresAt: string }>("/diffinery/tokens", { name, scopes: scopes.length ? scopes : undefined, expires_in_days: days, repositories: repos.length ? repos : undefined });
138+ if (ctx.json) return json(ctx.io, t);
139+ ctx.out(t.token);
140+ ctx.err(ctx.c.dim(`Token ${t.id} expires ${t.expiresAt.slice(0, 10)}. It will not be shown again.`));
141+ },
142+};
143+
144+const tokenList: Command = {
145+ name: "token list",
146+ summary: "List your tokens",
147+ json: true,
148+ maxArgs: 0,
149+ async run(ctx) {
150+ const rows = await (await ctx.authedApi()).get<TokenRow[]>("/diffinery/tokens");
151+ if (ctx.json) return json(ctx.io, rows);
152+ if (!rows.length) return ctx.err("No tokens.");
153+ const c = ctx.c;
154+ ctx.io.stdout.write(
155+ table([
156+ ["ID", "NAME", "PREFIX", "SCOPES", "EXPIRES", "LAST USED"].map((h) => c.dim(h)),
157+ ...rows.map((t) => [t.id, t.name, `${t.prefix}…`, t.scopes.join(",") + (t.repoIds ? ` (${t.repoIds.length} repo${t.repoIds.length === 1 ? "" : "s"})` : ""), t.expiresAt.slice(0, 10), t.lastUsedAt ? ago(t.lastUsedAt) : c.dim("never")]),
158+ ]),
159+ );
160+ },
161+};
162+
163+const tokenRevoke: Command = {
164+ name: "token revoke",
165+ summary: "Revoke a token",
166+ args: "<id>",
167+ minArgs: 1,
168+ maxArgs: 1,
169+ async run(ctx, _o, [id]) {
170+ await (await ctx.authedApi()).delete(`/diffinery/tokens/${enc(id!)}`);
171+ ctx.out(`${ctx.c.green("✓")} Revoked token ${id}`);
172+ },
173+};
174+
175+// ---------------------------------------------------------------- agents & invites
176+
177+const agentCreate: Command = {
178+ name: "agent create",
179+ summary: "Create an agent account and print its token once",
180+ args: "<login>",
181+ options: {
182+ org: { type: "string", short: "o", desc: "Owned by this organization instead of you", arg: "ORG" },
183+ name: { type: "string", short: "n", desc: "Display name", arg: "NAME" },
184+ scope: { type: "string", short: "s", multiple: true, desc: "Token scope: read, write (default) or admin", arg: "SCOPE" },
185+ "expires-days": { type: "string", short: "e", desc: "Token lifetime in days (default 90)", arg: "DAYS" },
186+ },
187+ json: true,
188+ minArgs: 1,
189+ maxArgs: 1,
190+ async run(ctx, o, [login]) {
191+ const scopes = list(o, "scope");
192+ const days = str(o, "expires-days") ? positiveInt(str(o, "expires-days"), "--expires-days") : undefined;
193+ const r = await (await ctx.authedApi()).post<{ agent: { login: string; html_url: string }; token: { token: string; id: string; expiresAt: string } }>("/diffinery/agents", {
194+ login,
195+ name: str(o, "name"),
196+ org: str(o, "org"),
197+ token: scopes.length || days ? { scopes: scopes.length ? scopes : undefined, expires_in_days: days } : undefined,
198+ });
199+ if (ctx.json) return json(ctx.io, r);
200+ ctx.out(`${ctx.c.green("✓")} Created agent ${ctx.c.bold(r.agent.login)}`);
201+ ctx.out(r.token.token);
202+ ctx.err(ctx.c.dim(`This token (expires ${r.token.expiresAt.slice(0, 10)}) will not be shown again. Give it to the agent as DIFFINERY_TOKEN.`));
203+ },
204+};
205+
206+const agentList: Command = {
207+ name: "agent list",
208+ summary: "List agents you or your organizations own",
209+ json: true,
210+ maxArgs: 0,
211+ async run(ctx) {
212+ const rows = await (await ctx.authedApi()).get<Array<{ login: string; name: string | null; owner_id: string; policy: Record<string, unknown> }>>("/diffinery/agents");
213+ if (ctx.json) return json(ctx.io, rows);
214+ if (!rows.length) return ctx.err("No agents.");
215+ ctx.io.stdout.write(table(rows.map((a) => [ctx.c.bold(a.login), a.name && a.name !== a.login ? a.name : "", ctx.c.dim(typeof a.policy?.maxRole === "string" ? `max role ${a.policy.maxRole}` : "")])));
216+ },
217+};
218+
219+const agentDisable: Command = {
220+ name: "agent disable",
221+ summary: "Disable an agent (its tokens stop working)",
222+ args: "<login>",
223+ minArgs: 1,
224+ maxArgs: 1,
225+ async run(ctx, _o, [login]) {
226+ await (await ctx.authedApi()).delete(`/diffinery/agents/${enc(login!)}`);
227+ ctx.out(`${ctx.c.green("✓")} Disabled agent ${login}`);
228+ },
229+};
230+
231+const invite: Command = {
232+ name: "invite",
233+ summary: "Invite someone to an organization by email",
234+ args: "<email>",
235+ options: {
236+ org: { type: "string", short: "o", desc: "Organization (required)", arg: "ORG" },
237+ role: { type: "string", short: "r", desc: "member (default), admin or owner", arg: "ROLE" },
238+ },
239+ json: true,
240+ minArgs: 1,
241+ maxArgs: 1,
242+ async run(ctx, o, [email]) {
243+ const org = str(o, "org");
244+ if (!org) throw new CliError("--org is required (the server only supports organization invitations)", 2);
245+ const role = str(o, "role");
246+ if (role && !["member", "admin", "owner"].includes(role)) throw new CliError("--role must be member, admin or owner", 2);
247+ const r = await (await ctx.authedApi()).post<{ id: string; url: string }>(`/orgs/${enc(org)}/invitations`, { email, role });
248+ if (ctx.json) return json(ctx.io, r);
249+ ctx.out(`${ctx.c.green("✓")} Invited ${email} to ${org}${role ? ` as ${role}` : ""}`);
250+ ctx.out(r.url);
251+ ctx.err(ctx.c.dim("Send them this link; it is valid for 14 days."));
252+ },
253+};
254+
255+// ---------------------------------------------------------------- raw API
256+
257+const apiCmd: Command = {
258+ name: "api",
259+ summary: "Make an authenticated API request and print the response",
260+ args: "<METHOD> <PATH>",
261+ description: `PATH is relative to /api/v3 (e.g. /user, /repos/OWNER/NAME/pulls) unless it starts with /api/.
262+--input sends a JSON body from a file, or from stdin with '-'.`,
263+ options: {
264+ input: { type: "string", short: "i", desc: "Request body file, or '-' for stdin", arg: "FILE" },
265+ field: { type: "string", short: "f", multiple: true, desc: "Add a string field to the JSON body (or query for GET)", arg: "KEY=VALUE" },
266+ },
267+ minArgs: 2,
268+ maxArgs: 2,
269+ async run(ctx, o, [method, p]) {
270+ const m = method!.toUpperCase();
271+ if (!/^[A-Z]+$/.test(m)) throw new CliError(`invalid method "${method}"`, 2);
272+ let body: string | undefined;
273+ const input = str(o, "input");
274+ if (input) body = input === "-" ? await readAll(ctx.io.stdin) : await readFile(path.resolve(ctx.io.cwd, input), "utf8");
275+ const fields: Record<string, string> = {};
276+ for (const f of list(o, "field")) {
277+ const i = f.indexOf("=");
278+ if (i <= 0) throw new CliError(`-f expects key=value, got "${f}"`, 2);
279+ fields[f.slice(0, i)] = f.slice(i + 1);
280+ }
281+ const hasFields = Object.keys(fields).length > 0;
282+ if (hasFields && m !== "GET" && m !== "HEAD") {
283+ if (body) throw new CliError("use either --input or -f, not both", 2);
284+ body = JSON.stringify(fields);
285+ }
286+ const api = await ctx.api();
287+ const res = await api.raw<unknown>(m, p!, { body, query: m === "GET" && hasFields ? fields : undefined, allow: Array.from({ length: 500 }, (_, i) => i + 100) });
288+ const out = typeof res.data === "string" ? res.data : res.data === null ? "" : JSON.stringify(res.data, null, 2);
289+ if (out) ctx.out(out);
290+ if (res.status >= 400) {
291+ ctx.err(ctx.c.red(`HTTP ${res.status}`));
292+ throw new ExitCode(1);
293+ }
294+ },
295+};
296+
297+export const secretGroup: Group = { name: "secret", summary: "Manage Actions secrets", commands: [secretSet, secretList, secretDelete] };
298+export const variableGroup: Group = { name: "variable", summary: "Manage Actions variables", commands: [variableSet, variableList] };
299+export const tokenGroup: Group = { name: "token", summary: "Manage personal access tokens", commands: [tokenCreate, tokenList, tokenRevoke] };
300+export const agentGroup: Group = { name: "agent", summary: "Manage agent accounts", commands: [agentCreate, agentList, agentDisable] };
301+export const adminCommands: Command[] = [invite, apiCmd];
addedapps/cli/src/commands/auth.ts+139−0View file
@@ -1,0 +1,139 @@
1+import { Api } from "../api.js";
2+import { str, type Command, type Group } from "../cli.js";
3+import { normalizeUrl, readConfig, resolveConfig, writeConfig } from "../config.js";
4+import { gitCapture } from "../git.js";
5+import { CliError, readAll, valueFromFlagOrStdin } from "../io.js";
6+import { json } from "../output.js";
7+
8+interface User {
9+ login: string;
10+ name?: string | null;
11+ type?: string;
12+ email?: string | null;
13+ html_url?: string;
14+}
15+
16+const login: Command = {
17+ name: "login",
18+ summary: "Log in with a personal or agent token",
19+ description: `Stores the server URL and token in the config file (mode 0600).
20+The token is read from --token, from stdin when piped, or prompted for.
21+Create a token in the web UI (Settings > Tokens) or with 'diffinery token create'.`,
22+ options: {
23+ url: { type: "string", desc: "Server URL (default: DIFFINERY_URL, the saved URL, or https://diffinery.com)", arg: "URL" },
24+ token: { type: "string", desc: "Token (dfy_...)", arg: "TOKEN" },
25+ },
26+ maxArgs: 0,
27+ async run(ctx, o) {
28+ const stored = await readConfig(ctx.io.env);
29+ const cfg = await resolveConfig(ctx.io);
30+ const url = str(o, "url") ? normalizeUrl(str(o, "url")!) : cfg.url;
31+ const token = (await valueFromFlagOrStdin(ctx.io, str(o, "token"), `Paste a token for ${url}: `)).trim();
32+ if (!token) throw new CliError("no token given");
33+ const me = await new Api(url, token).get<User>("/user");
34+ const file = await writeConfig(ctx.io.env, { ...stored, url, token });
35+ ctx.out(`${ctx.c.green("✓")} Logged in to ${url} as ${ctx.c.bold(me.login)}${me.type === "Bot" ? " (agent)" : ""}`);
36+ ctx.err(ctx.c.dim(`Saved to ${file}`));
37+ if (ctx.io.env.DIFFINERY_TOKEN) ctx.err(ctx.c.yellow("note: DIFFINERY_TOKEN is set and overrides the saved token"));
38+ },
39+};
40+
41+const logout: Command = {
42+ name: "logout",
43+ summary: "Remove the saved token",
44+ maxArgs: 0,
45+ async run(ctx) {
46+ const stored = await readConfig(ctx.io.env);
47+ if (!stored.token) ctx.out("Not logged in.");
48+ else {
49+ await writeConfig(ctx.io.env, { url: stored.url });
50+ ctx.out(`Logged out of ${stored.url ?? "Diffinery"}.`);
51+ }
52+ if (ctx.io.env.DIFFINERY_TOKEN) ctx.err(ctx.c.yellow("note: DIFFINERY_TOKEN is still set in your environment"));
53+ },
54+};
55+
56+const whoami: Command = {
57+ name: "whoami",
58+ summary: "Show the account the current token acts as",
59+ json: true,
60+ maxArgs: 0,
61+ async run(ctx) {
62+ const cfg = await ctx.config();
63+ const me = await (await ctx.authedApi()).get<User>("/user");
64+ if (ctx.json) return json(ctx.io, me);
65+ ctx.out(`${ctx.c.bold(me.login)}${me.name && me.name !== me.login ? ` (${me.name})` : ""}${me.type === "Bot" ? " [agent]" : ""}`);
66+ ctx.out(ctx.c.dim(`${cfg.url} · token from ${cfg.tokenSource === "env" ? "DIFFINERY_TOKEN" : cfg.file}`));
67+ },
68+};
69+
70+const setupGit: Command = {
71+ name: "auth setup-git",
72+ summary: "Make git use your Diffinery token for HTTPS remotes",
73+ description: `Runs: git config --global credential.<url>.helper "!diffinery auth git-credential"
74+(after resetting other helpers for that URL), so clone/fetch/push to the Diffinery host
75+authenticate with the token from the config file or DIFFINERY_TOKEN.`,
76+ maxArgs: 0,
77+ async run(ctx) {
78+ const cfg = await ctx.config();
79+ const key = `credential.${cfg.url}.helper`;
80+ await gitCapture(ctx.io, ["config", "--global", "--unset-all", key]);
81+ // An empty helper resets the list so a generic helper (e.g. a keychain) does not answer first.
82+ for (const value of ["", "!diffinery auth git-credential"]) {
83+ const r = await gitCapture(ctx.io, ["config", "--global", "--add", key, value]);
84+ if (r.code !== 0) throw new CliError(`git config failed: ${r.stderr.trim()}`);
85+ }
86+ ctx.out(`${ctx.c.green("✓")} git will use diffinery for ${cfg.url}`);
87+ },
88+};
89+
90+/** Parses the git credential helper protocol's key=value lines. */
91+export function parseCredentialInput(text: string): Record<string, string> {
92+ const out: Record<string, string> = {};
93+ for (const line of text.split(/\r?\n/)) {
94+ if (!line) break;
95+ const i = line.indexOf("=");
96+ if (i > 0) out[line.slice(0, i)] = line.slice(i + 1);
97+ }
98+ return out;
99+}
100+
101+const gitCredential: Command = {
102+ name: "auth git-credential",
103+ summary: "Git credential helper (used by 'auth setup-git')",
104+ args: "<get|store|erase>",
105+ minArgs: 1,
106+ maxArgs: 1,
107+ async run(ctx, _o, [op]) {
108+ const input = parseCredentialInput(await readAll(ctx.io.stdin));
109+ if (op !== "get") return; // store/erase: the token lives in the config file
110+ const cfg = await ctx.config();
111+ if (!cfg.token) return;
112+ const want = new URL(cfg.url);
113+ const proto = (input.protocol ?? "").toLowerCase();
114+ const host = (input.host ?? "").toLowerCase();
115+ if (proto !== want.protocol.replace(/:$/, "") || host !== want.host.toLowerCase()) return;
116+ ctx.io.stdout.write(`protocol=${proto}\nhost=${input.host}\nusername=x-token\npassword=${cfg.token}\n`);
117+ },
118+};
119+
120+const mcpConfig: Command = {
121+ name: "mcp-config",
122+ summary: "Print an MCP server config snippet for Claude Code and other MCP clients",
123+ description: `By default the token is written as \${DIFFINERY_TOKEN} (expanded by clients such as
124+Claude Code from the environment). Pass --show-token to embed the saved token instead.`,
125+ options: { "show-token": { type: "boolean", desc: "Embed the actual token in the snippet" } },
126+ maxArgs: 0,
127+ async run(ctx, o) {
128+ const cfg = await ctx.config();
129+ let token = "${DIFFINERY_TOKEN}";
130+ if (o["show-token"]) {
131+ if (!cfg.token) throw new CliError("not logged in: run `diffinery login` or set DIFFINERY_TOKEN");
132+ token = cfg.token;
133+ }
134+ json(ctx.io, { mcpServers: { diffinery: { type: "http", url: `${cfg.url}/mcp`, headers: { Authorization: `Bearer ${token}` } } } });
135+ },
136+};
137+
138+export const authCommands: Command[] = [login, logout, whoami, mcpConfig];
139+export const authGroup: Group = { name: "auth", summary: "Git credential integration", commands: [setupGit, gitCredential] };
addedapps/cli/src/commands/issue.ts+131−0View file
@@ -1,0 +1,131 @@
1+import { list, positiveInt, repoPath, str, type Command, type Ctx, type Group } from "../cli.js";
2+import { CliError } from "../io.js";
3+import { ago, json, table, truncate } from "../output.js";
4+import { bodyFrom, bodyOpts, type CommentJson, type IssueJson } from "./pr.js";
5+
6+const issueState = (c: Ctx["c"], i: IssueJson) => (i.state === "open" ? c.green("open") : c.dim(i.state_reason === "not_planned" ? "not planned" : "closed"));
7+
8+const create: Command = {
9+ name: "issue create",
10+ summary: "Open an issue",
11+ options: {
12+ title: { type: "string", short: "t", desc: "Title (required)", arg: "TEXT" },
13+ ...bodyOpts,
14+ label: { type: "string", short: "l", multiple: true, desc: "Add a label", arg: "NAME" },
15+ assignee: { type: "string", short: "a", multiple: true, desc: "Assign a login", arg: "LOGIN" },
16+ },
17+ repo: true,
18+ json: true,
19+ maxArgs: 0,
20+ async run(ctx, o) {
21+ const title = str(o, "title");
22+ if (!title) throw new CliError("--title is required", 2);
23+ const repo = await ctx.repo();
24+ const labels = list(o, "label");
25+ const assignees = list(o, "assignee");
26+ const i = await (await ctx.authedApi()).post<IssueJson>(`${repoPath(repo)}/issues`, { title, body: await bodyFrom(ctx, o), labels: labels.length ? labels : undefined, assignees: assignees.length ? assignees : undefined });
27+ if (ctx.json) return json(ctx.io, i);
28+ ctx.out(`${ctx.c.green("✓")} Opened issue #${i.number} ${i.title}`);
29+ ctx.out(i.html_url);
30+ },
31+};
32+
33+const listCmd: Command = {
34+ name: "issue list",
35+ summary: "List issues",
36+ options: {
37+ state: { type: "string", short: "s", desc: "open, closed or all (default: open)", arg: "STATE" },
38+ label: { type: "string", short: "l", desc: "Only issues with this label", arg: "NAME" },
39+ author: { type: "string", short: "A", desc: "Only issues opened by this login", arg: "LOGIN" },
40+ search: { type: "string", short: "S", desc: "Search text", arg: "QUERY" },
41+ limit: { type: "string", short: "L", desc: "Maximum number to show (default 30)", arg: "N" },
42+ },
43+ repo: true,
44+ json: true,
45+ maxArgs: 0,
46+ async run(ctx, o) {
47+ const repo = await ctx.repo();
48+ const state = str(o, "state") ?? "open";
49+ if (!["open", "closed", "all"].includes(state)) throw new CliError("--state must be open, closed or all", 2);
50+ const limit = str(o, "limit") ? positiveInt(str(o, "limit"), "--limit") : 30;
51+ const items = await (await ctx.api()).get<IssueJson[]>(`${repoPath(repo)}/issues`, { state, labels: str(o, "label"), creator: str(o, "author"), q: str(o, "search"), per_page: Math.min(100, limit + 20) });
52+ // The issues endpoint can include pull requests (as on GitHub); show only issues.
53+ const issues = items.filter((i) => !i.pull_request).slice(0, limit);
54+ if (ctx.json) return json(ctx.io, issues);
55+ if (!issues.length) return ctx.err(`No ${state === "all" ? "" : `${state} `}issues in ${repo.owner}/${repo.name}.`);
56+ const c = ctx.c;
57+ ctx.io.stdout.write(table(issues.map((i) => [c.bold(`#${i.number}`), truncate(i.title, 60), issueState(c, i), c.cyan(i.labels.map((l) => l.name).join(", ")), c.dim(i.user.login), c.dim(ago(i.updated_at))])));
58+ },
59+};
60+
61+const view: Command = {
62+ name: "issue view",
63+ summary: "Show an issue and its comments",
64+ args: "<number>",
65+ repo: true,
66+ json: true,
67+ minArgs: 1,
68+ maxArgs: 1,
69+ async run(ctx, _o, [num]) {
70+ const n = positiveInt(num, "issue number");
71+ const repo = await ctx.repo();
72+ const api = await ctx.api();
73+ const [i, comments] = await Promise.all([api.get<IssueJson>(`${repoPath(repo)}/issues/${n}`), api.get<CommentJson[]>(`${repoPath(repo)}/issues/${n}/comments`)]);
74+ if (ctx.json) return json(ctx.io, { ...i, comment_list: comments });
75+ const c = ctx.c;
76+ ctx.out(`${c.bold(i.title)} ${c.dim(`#${i.number}`)}`);
77+ ctx.out(`${issueState(c, i)} · opened by ${i.user.login} ${ago(i.created_at)}${i.labels.length ? ` · ${c.cyan(i.labels.map((l) => l.name).join(", "))}` : ""}${i.assignees.length ? ` · assigned to ${i.assignees.map((a) => a.login).join(", ")}` : ""}`);
78+ if (i.body?.trim()) ctx.out(`\n${i.body.trim()}`);
79+ for (const cm of comments) ctx.out(`\n${c.bold(cm.user.login)} ${c.dim(ago(cm.created_at))}\n${cm.body.trim()}`);
80+ ctx.out(`\n${c.dim(i.html_url)}`);
81+ },
82+};
83+
84+const comment: Command = {
85+ name: "issue comment",
86+ summary: "Comment on an issue",
87+ args: "<number>",
88+ options: bodyOpts,
89+ repo: true,
90+ json: true,
91+ minArgs: 1,
92+ maxArgs: 1,
93+ async run(ctx, o, [num]) {
94+ const n = positiveInt(num, "issue number");
95+ const body = await bodyFrom(ctx, o, true);
96+ if (!body?.trim()) throw new CliError("--body is required", 2);
97+ const repo = await ctx.repo();
98+ const r = await (await ctx.authedApi()).post<CommentJson>(`${repoPath(repo)}/issues/${n}/comments`, { body });
99+ if (ctx.json) return json(ctx.io, r);
100+ ctx.out(`${ctx.c.green("✓")} Commented on #${n}`);
101+ },
102+};
103+
104+function setState(state: "open" | "closed"): Command {
105+ return {
106+ name: state === "closed" ? "issue close" : "issue reopen",
107+ summary: state === "closed" ? "Close an issue" : "Reopen an issue",
108+ args: "<number>",
109+ options: {
110+ ...(state === "closed" ? { reason: { type: "string" as const, short: "r", desc: "completed (default) or not_planned", arg: "REASON" } } : {}),
111+ comment: { type: "string", short: "c", desc: "Leave a comment too", arg: "TEXT" },
112+ },
113+ repo: true,
114+ json: true,
115+ minArgs: 1,
116+ maxArgs: 1,
117+ async run(ctx, o, [num]) {
118+ const n = positiveInt(num, "issue number");
119+ const reason = str(o, "reason")?.replace(/[\s-]+/g, "_");
120+ if (reason && !["completed", "not_planned"].includes(reason)) throw new CliError("--reason must be completed or not_planned", 2);
121+ const repo = await ctx.repo();
122+ const api = await ctx.authedApi();
123+ if (str(o, "comment")) await api.post(`${repoPath(repo)}/issues/${n}/comments`, { body: str(o, "comment") });
124+ const i = await api.patch<IssueJson>(`${repoPath(repo)}/issues/${n}`, { state, state_reason: state === "closed" ? (reason ?? "completed") : undefined });
125+ if (ctx.json) return json(ctx.io, i);
126+ ctx.out(`${ctx.c.green("✓")} ${state === "closed" ? "Closed" : "Reopened"} #${n} ${i.title}`);
127+ },
128+ };
129+}
130+
131+export const issueGroup: Group = { name: "issue", summary: "Work with issues", commands: [create, listCmd, view, comment, setState("closed"), setState("open")] };
addedapps/cli/src/commands/pr.ts+310−0View file
@@ -1,0 +1,310 @@
1+import { readFile } from "node:fs/promises";
2+import path from "node:path";
3+import { exactlyOne, list, positiveInt, repoPath, str, type Command, type Ctx, type Group, type Values } from "../cli.js";
4+import { currentBranch } from "../git.js";
5+import { CliError, ExitCode, readAll } from "../io.js";
6+import { ago, json, statusText, symbol, table, truncate } from "../output.js";
7+
8+export interface IssueJson {
9+ number: number;
10+ title: string;
11+ body: string;
12+ state: "open" | "closed";
13+ state_reason: string | null;
14+ user: { login: string; type: string };
15+ labels: Array<{ name: string }>;
16+ assignees: Array<{ login: string }>;
17+ comments: number;
18+ created_at: string;
19+ updated_at: string;
20+ closed_at: string | null;
21+ html_url: string;
22+ pull_request?: { url: string };
23+ head?: { ref: string; sha: string };
24+ base?: { ref: string };
25+ draft?: boolean;
26+ merged?: boolean;
27+ merged_at?: string | null;
28+ merged_by?: { login: string } | null;
29+ intent?: string | null;
30+}
31+
32+interface PullDetail extends IssueJson {
33+ mergeable: boolean;
34+ mergeable_state: string;
35+ merge_blockers: string[];
36+ approvals: number;
37+ required_approvals: number;
38+ checks: Array<{ name: string; state: string }>;
39+}
40+
41+export interface CommentJson {
42+ id: string;
43+ body: string;
44+ user: { login: string };
45+ created_at: string;
46+}
47+
48+/** -b/--body, else --body-file (or "-" for stdin), else piped stdin when `stdinFallback`. */
49+export async function bodyFrom(ctx: Ctx, o: Values, stdinFallback = false): Promise<string | undefined> {
50+ const b = str(o, "body");
51+ if (b !== undefined) return b;
52+ const f = str(o, "body-file");
53+ if (f !== undefined) {
54+ if (f === "-") return readAll(ctx.io.stdin);
55+ return readFile(path.resolve(ctx.io.cwd, f), "utf8");
56+ }
57+ if (stdinFallback && !ctx.io.stdin.isTTY) return readAll(ctx.io.stdin);
58+ return undefined;
59+}
60+
61+export const bodyOpts = {
62+ body: { type: "string" as const, short: "b", desc: "Body text (Markdown)", arg: "TEXT" },
63+ "body-file": { type: "string" as const, desc: "Read the body from a file ('-' for stdin)", arg: "FILE" },
64+};
65+
66+export function prState(c: Ctx["c"], p: IssueJson): string {
67+ if (p.merged) return c.magenta("merged");
68+ if (p.state === "closed") return c.red("closed");
69+ if (p.draft) return c.dim("draft");
70+ return c.green("open");
71+}
72+
73+const create: Command = {
74+ name: "pr create",
75+ summary: "Open a pull request",
76+ options: {
77+ title: { type: "string", short: "t", desc: "Title (required)", arg: "TEXT" },
78+ ...bodyOpts,
79+ head: { type: "string", short: "H", desc: "Branch with your changes (default: current branch)", arg: "BRANCH" },
80+ base: { type: "string", short: "B", desc: "Branch to merge into (default: the repository's default branch)", arg: "BRANCH" },
81+ draft: { type: "boolean", short: "d", desc: "Open as a draft" },
82+ intent: { type: "string", desc: "What this change is for, for reviewers (agents should always set it)", arg: "TEXT" },
83+ reviewer: { type: "string", short: "r", multiple: true, desc: "Request a review from this login", arg: "LOGIN" },
84+ },
85+ repo: true,
86+ json: true,
87+ maxArgs: 0,
88+ async run(ctx, o) {
89+ const title = str(o, "title");
90+ if (!title) throw new CliError("--title is required", 2);
91+ const repo = await ctx.repo();
92+ const head = str(o, "head") ?? (await currentBranch(ctx.io));
93+ const reviewers = list(o, "reviewer");
94+ const payload = {
95+ title,
96+ body: await bodyFrom(ctx, o),
97+ head,
98+ base: str(o, "base"),
99+ draft: o.draft === true ? true : undefined,
100+ intent: str(o, "intent"),
101+ reviewers: reviewers.length ? reviewers : undefined,
102+ };
103+ const pr = await (await ctx.authedApi()).post<IssueJson>(`${repoPath(repo)}/pulls`, payload);
104+ if (ctx.json) return json(ctx.io, pr);
105+ ctx.out(`${ctx.c.green("✓")} Opened #${pr.number} ${pr.title} (${pr.head?.ref} → ${pr.base?.ref})`);
106+ ctx.out(pr.html_url);
107+ },
108+};
109+
110+const listCmd: Command = {
111+ name: "pr list",
112+ summary: "List pull requests",
113+ options: {
114+ state: { type: "string", short: "s", desc: "open, closed, merged or all (default: open)", arg: "STATE" },
115+ limit: { type: "string", short: "L", desc: "Maximum number to show (default 30)", arg: "N" },
116+ author: { type: "string", short: "A", desc: "Only pull requests opened by this login", arg: "LOGIN" },
117+ },
118+ repo: true,
119+ json: true,
120+ maxArgs: 0,
121+ async run(ctx, o) {
122+ const repo = await ctx.repo();
123+ const state = str(o, "state") ?? "open";
124+ if (!["open", "closed", "merged", "all"].includes(state)) throw new CliError("--state must be open, closed, merged or all", 2);
125+ const limit = str(o, "limit") ? positiveInt(str(o, "limit"), "--limit") : 30;
126+ const api = await ctx.api();
127+ let prs = await api.get<IssueJson[]>(`${repoPath(repo)}/pulls`, { state: state === "merged" ? "closed" : state, per_page: Math.min(100, state === "merged" ? 100 : limit), creator: str(o, "author") });
128+ if (state === "merged") prs = prs.filter((p) => p.merged);
129+ prs = prs.slice(0, limit);
130+ if (ctx.json) return json(ctx.io, prs);
131+ if (!prs.length) return ctx.err(`No ${state === "all" ? "" : `${state} `}pull requests in ${repo.owner}/${repo.name}.`);
132+ const c = ctx.c;
133+ ctx.io.stdout.write(table(prs.map((p) => [c.bold(`#${p.number}`), truncate(p.title, 60), c.cyan(`${p.head?.ref ?? ""}`), prState(c, p), c.dim(p.user.login), c.dim(ago(p.updated_at))])));
134+ },
135+};
136+
137+async function getPull(ctx: Ctx, n: number) {
138+ const repo = await ctx.repo();
139+ const api = await ctx.api();
140+ return { repo, api, pr: await api.get<PullDetail>(`${repoPath(repo)}/pulls/${n}`) };
141+}
142+
143+const view: Command = {
144+ name: "pr view",
145+ summary: "Show a pull request",
146+ args: "<number>",
147+ options: { comments: { type: "boolean", short: "c", desc: "Include the conversation" } },
148+ repo: true,
149+ json: true,
150+ minArgs: 1,
151+ maxArgs: 1,
152+ async run(ctx, o, [num]) {
153+ const n = positiveInt(num, "pull request number");
154+ const { pr, api, repo } = await getPull(ctx, n);
155+ const comments = o.comments ? await api.get<CommentJson[]>(`${repoPath(repo)}/issues/${n}/comments`) : undefined;
156+ if (ctx.json) return json(ctx.io, comments ? { ...pr, comment_list: comments } : pr);
157+ const c = ctx.c;
158+ ctx.out(`${c.bold(pr.title)} ${c.dim(`#${pr.number}`)}`);
159+ ctx.out(`${prState(c, pr)} · ${pr.user.login} wants to merge ${c.cyan(pr.head?.ref ?? "?")} into ${c.cyan(pr.base?.ref ?? "?")} · ${ago(pr.created_at)}`);
160+ if (pr.intent) ctx.out(`${c.dim("intent:")} ${pr.intent}`);
161+ if (pr.state === "open") {
162+ ctx.out(`${c.dim("mergeable:")} ${statusText(c, pr.mergeable_state)} · approvals ${pr.approvals}/${pr.required_approvals}`);
163+ for (const b of pr.merge_blockers ?? []) ctx.out(` ${c.red("•")} ${b}`);
164+ }
165+ if (pr.checks?.length) ctx.out(`${c.dim("checks:")} ${pr.checks.map((k) => `${checkSymbol(ctx, k.state)} ${k.name}`).join(" ")}`);
166+ if (pr.body?.trim()) ctx.out(`\n${pr.body.trim()}\n`);
167+ for (const cm of comments ?? []) ctx.out(`\n${c.bold(cm.user.login)} ${c.dim(ago(cm.created_at))}\n${cm.body.trim()}`);
168+ ctx.out(c.dim(pr.html_url));
169+ },
170+};
171+
172+function checkSymbol(ctx: Ctx, state: string): string {
173+ const done = ["success", "failure", "neutral", "cancelled", "skipped", "timed_out", "action_required", "stale"];
174+ return done.includes(state) ? symbol(ctx.c, "completed", state) : symbol(ctx.c, state);
175+}
176+
177+const diff: Command = {
178+ name: "pr diff",
179+ summary: "Show a pull request's changes as a unified diff",
180+ args: "<number>",
181+ options: { "name-only": { type: "boolean", desc: "Only list changed file names" } },
182+ repo: true,
183+ minArgs: 1,
184+ maxArgs: 1,
185+ async run(ctx, o, [num]) {
186+ const repo = await ctx.repo();
187+ const files = await (await ctx.api()).get<Array<{ filename: string; status: string; patch?: string; binary?: boolean }>>(`${repoPath(repo)}/pulls/${positiveInt(num, "pull request number")}/files`);
188+ if (o["name-only"]) return ctx.io.stdout.write(files.map((f) => `${f.filename}\n`).join(""));
189+ const c = ctx.c;
190+ for (const f of files) {
191+ const patch = f.patch ?? `diff --git a/${f.filename} b/${f.filename}\n`;
192+ if (!c.enabled) {
193+ ctx.io.stdout.write(patch.endsWith("\n") ? patch : `${patch}\n`);
194+ continue;
195+ }
196+ for (const line of patch.replace(/\n$/, "").split("\n")) {
197+ if (line.startsWith("diff --git") || line.startsWith("--- ") || line.startsWith("+++ ")) ctx.out(c.bold(line));
198+ else if (line.startsWith("@@")) ctx.out(c.cyan(line));
199+ else if (line.startsWith("+")) ctx.out(c.green(line));
200+ else if (line.startsWith("-")) ctx.out(c.red(line));
201+ else ctx.out(line);
202+ }
203+ }
204+ },
205+};
206+
207+const checks: Command = {
208+ name: "pr checks",
209+ summary: "Show check status for a pull request",
210+ description: "Exit status: 0 when all checks passed, 1 when any failed, 8 when some are still pending.",
211+ args: "<number>",
212+ repo: true,
213+ json: true,
214+ minArgs: 1,
215+ maxArgs: 1,
216+ async run(ctx, _o, [num]) {
217+ const { pr } = await getPull(ctx, positiveInt(num, "pull request number"));
218+ const list = pr.checks ?? [];
219+ if (ctx.json) json(ctx.io, list);
220+ else if (!list.length) ctx.err("No checks reported for this pull request.");
221+ else ctx.io.stdout.write(table(list.map((k) => [checkSymbol(ctx, k.state), k.name, statusText(ctx.c, ["success", "failure", "neutral", "cancelled", "skipped", "timed_out", "action_required"].includes(k.state) ? "completed" : k.state, k.state)])));
222+ const failed = list.some((k) => ["failure", "cancelled", "timed_out", "action_required"].includes(k.state));
223+ const pending = list.some((k) => ["queued", "in_progress", "waiting", "pending", "requested"].includes(k.state));
224+ throw new ExitCode(failed ? 1 : pending ? 8 : 0);
225+ },
226+};
227+
228+const review: Command = {
229+ name: "pr review",
230+ summary: "Approve, request changes or comment on a pull request",
231+ args: "<number>",
232+ options: {
233+ approve: { type: "boolean", short: "a", desc: "Approve" },
234+ "request-changes": { type: "boolean", short: "r", desc: "Request changes" },
235+ comment: { type: "boolean", short: "c", desc: "Comment without approving" },
236+ ...bodyOpts,
237+ },
238+ repo: true,
239+ json: true,
240+ minArgs: 1,
241+ maxArgs: 1,
242+ async run(ctx, o, [num]) {
243+ const n = positiveInt(num, "pull request number");
244+ const which = exactlyOne(o, ["approve", "request-changes", "comment"]);
245+ const event = which === "approve" ? "APPROVE" : which === "request-changes" ? "REQUEST_CHANGES" : "COMMENT";
246+ const body = await bodyFrom(ctx, o);
247+ if (event !== "APPROVE" && !body) throw new CliError(`--body is required with --${which}`, 2);
248+ const repo = await ctx.repo();
249+ const r = await (await ctx.authedApi()).post<{ id: string; state: string }>(`${repoPath(repo)}/pulls/${n}/reviews`, { event, body });
250+ if (ctx.json) return json(ctx.io, r);
251+ const verb = event === "APPROVE" ? "Approved" : event === "REQUEST_CHANGES" ? "Requested changes on" : "Reviewed";
252+ ctx.out(`${ctx.c.green("✓")} ${verb} #${n}`);
253+ },
254+};
255+
256+const merge: Command = {
257+ name: "pr merge",
258+ summary: "Merge a pull request",
259+ args: "<number>",
260+ options: {
261+ merge: { type: "boolean", short: "m", desc: "Create a merge commit (default)" },
262+ squash: { type: "boolean", short: "s", desc: "Squash into one commit" },
263+ rebase: { type: "boolean", short: "r", desc: "Rebase the commits onto the base branch" },
264+ "delete-branch": { type: "boolean", short: "d", desc: "Delete the head branch after merging" },
265+ subject: { type: "string", short: "t", desc: "Commit title", arg: "TEXT" },
266+ ...bodyOpts,
267+ "match-head-commit": { type: "string", desc: "Only merge if the head is still at this SHA", arg: "SHA" },
268+ },
269+ repo: true,
270+ json: true,
271+ minArgs: 1,
272+ maxArgs: 1,
273+ async run(ctx, o, [num]) {
274+ const n = positiveInt(num, "pull request number");
275+ const method = exactlyOne(o, ["merge", "squash", "rebase"], "merge");
276+ const repo = await ctx.repo();
277+ const payload = {
278+ merge_method: method,
279+ delete_branch: o["delete-branch"] === true ? true : undefined,
280+ commit_title: str(o, "subject"),
281+ commit_message: await bodyFrom(ctx, o),
282+ sha: str(o, "match-head-commit"),
283+ };
284+ const r = await (await ctx.authedApi()).put<{ merged: boolean; sha: string; method: string }>(`${repoPath(repo)}/pulls/${n}/merge`, payload);
285+ if (ctx.json) return json(ctx.io, r);
286+ ctx.out(`${ctx.c.green("✓")} Merged #${n} (${r.method ?? method}) ${ctx.c.dim(String(r.sha ?? "").slice(0, 12))}${payload.delete_branch ? " and deleted the branch" : ""}`);
287+ },
288+};
289+
290+const comment: Command = {
291+ name: "pr comment",
292+ summary: "Comment on a pull request",
293+ args: "<number>",
294+ options: bodyOpts,
295+ repo: true,
296+ json: true,
297+ minArgs: 1,
298+ maxArgs: 1,
299+ async run(ctx, o, [num]) {
300+ const n = positiveInt(num, "pull request number");
301+ const body = await bodyFrom(ctx, o, true);
302+ if (!body?.trim()) throw new CliError("--body is required", 2);
303+ const repo = await ctx.repo();
304+ const r = await (await ctx.authedApi()).post<CommentJson>(`${repoPath(repo)}/issues/${n}/comments`, { body });
305+ if (ctx.json) return json(ctx.io, r);
306+ ctx.out(`${ctx.c.green("✓")} Commented on #${n}`);
307+ },
308+};
309+
310+export const prGroup: Group = { name: "pr", summary: "Work with pull requests", commands: [create, listCmd, view, diff, checks, review, merge, comment] };
addedapps/cli/src/commands/repo.ts+114−0View file
@@ -1,0 +1,114 @@
1+import path from "node:path";
2+import { enc } from "../api.js";
3+import { exactlyOne, parseRepoArg, repoPath, str, type Command, type Group } from "../cli.js";
4+import { basicAuthHeader, gitInteractive, withGitConfig } from "../git.js";
5+import { CliError } from "../io.js";
6+import { ago, json, table, truncate } from "../output.js";
7+
8+export interface RepoJson {
9+ id: string;
10+ name: string;
11+ full_name: string;
12+ owner: { login: string; type: string };
13+ private: boolean;
14+ visibility: "public" | "internal" | "private";
15+ description: string | null;
16+ default_branch: string;
17+ archived: boolean;
18+ html_url: string;
19+ clone_url: string;
20+ pushed_at: string | null;
21+ updated_at: string;
22+ permissions?: { role: string };
23+ stats?: Record<string, unknown>;
24+}
25+
26+const create: Command = {
27+ name: "repo create",
28+ summary: "Create a repository",
29+ args: "<name>",
30+ options: {
31+ org: { type: "string", desc: "Create it in this organization", arg: "ORG" },
32+ private: { type: "boolean", desc: "Only collaborators can see it (default)" },
33+ internal: { type: "boolean", desc: "Every signed-in member of this Diffinery can see it" },
34+ public: { type: "boolean", desc: "Anyone can see it" },
35+ description: { type: "string", short: "d", desc: "Description", arg: "TEXT" },
36+ "default-branch": { type: "string", desc: "Default branch name", arg: "BRANCH" },
37+ },
38+ json: true,
39+ minArgs: 1,
40+ maxArgs: 1,
41+ async run(ctx, o, [name]) {
42+ const visibility = exactlyOne(o, ["private", "internal", "public"], "private");
43+ const api = await ctx.authedApi();
44+ const org = str(o, "org");
45+ const r = await api.post<RepoJson>(org ? `/orgs/${enc(org)}/repos` : "/user/repos", { name, description: str(o, "description"), visibility, default_branch: str(o, "default-branch") });
46+ if (ctx.json) return json(ctx.io, r);
47+ ctx.out(`${ctx.c.green("✓")} Created ${ctx.c.bold(r.full_name)} (${r.visibility})`);
48+ ctx.out(r.html_url);
49+ ctx.err(ctx.c.dim(`git remote add origin ${r.clone_url}`));
50+ },
51+};
52+
53+const listCmd: Command = {
54+ name: "repo list",
55+ summary: "List repositories you can see",
56+ options: { owner: { type: "string", desc: "Only repositories owned by this user or organization", arg: "OWNER" } },
57+ json: true,
58+ maxArgs: 0,
59+ async run(ctx, o) {
60+ const api = await ctx.api();
61+ const owner = str(o, "owner");
62+ // The server has no /users/:login/repos; /orgs/:owner/repos accepts any account login.
63+ const repos = await api.get<RepoJson[]>(owner ? `/orgs/${enc(owner)}/repos` : "/user/repos");
64+ if (ctx.json) return json(ctx.io, repos);
65+ if (!repos.length) return ctx.err("No repositories.");
66+ ctx.io.stdout.write(table(repos.map((r) => [ctx.c.bold(r.full_name), r.archived ? ctx.c.dim("archived") : r.visibility, truncate(r.description, 50), ctx.c.dim(ago(r.pushed_at ?? r.updated_at))])));
67+ },
68+};
69+
70+const view: Command = {
71+ name: "repo view",
72+ summary: "Show a repository",
73+ args: "[OWNER/NAME]",
74+ repo: true,
75+ json: true,
76+ maxArgs: 1,
77+ async run(ctx, _o, [arg]) {
78+ const ref = arg ? parseRepoArg(arg) : await ctx.repo();
79+ const r = await (await ctx.api()).get<RepoJson>(repoPath(ref));
80+ if (ctx.json) return json(ctx.io, r);
81+ const c = ctx.c;
82+ ctx.out(`${c.bold(r.full_name)} ${c.dim(r.visibility)}${r.archived ? c.yellow(" archived") : ""}`);
83+ if (r.description) ctx.out(r.description);
84+ const rows: string[][] = [
85+ ["default branch", r.default_branch],
86+ ["web", r.html_url],
87+ ["clone", r.clone_url],
88+ ];
89+ if (r.permissions?.role) rows.push(["your role", r.permissions.role]);
90+ if (r.pushed_at) rows.push(["last push", ago(r.pushed_at)]);
91+ for (const [k, v] of Object.entries(r.stats ?? {})) if (typeof v === "number" || typeof v === "string") rows.push([k.replace(/_/g, " "), String(v)]);
92+ ctx.io.stdout.write(table(rows.map(([k, v]) => [c.dim(k!), v!])));
93+ },
94+};
95+
96+const clone: Command = {
97+ name: "repo clone",
98+ summary: "Clone a repository",
99+ description: "The token is passed to this one git command as an HTTP header and is never written to .git/config.\nRun 'diffinery auth setup-git' so later fetches and pushes authenticate too.",
100+ args: "<OWNER/NAME> [directory]",
101+ minArgs: 1,
102+ maxArgs: 2,
103+ async run(ctx, _o, [arg, dir]) {
104+ const ref = parseRepoArg(arg!);
105+ const cfg = await ctx.config();
106+ const url = `${cfg.url}/${ref.owner}/${ref.name}.git`;
107+ const env = cfg.token ? withGitConfig(ctx.io.env, [basicAuthHeader(cfg.url, "x-token", cfg.token), ["protocol.version", "2"]]) : withGitConfig(ctx.io.env, [["protocol.version", "2"]]);
108+ const code = await gitInteractive(ctx.io, ["clone", url, ...(dir ? [dir] : [])], { env });
109+ if (code !== 0) throw new CliError(`git clone exited with ${code}`, code);
110+ ctx.err(ctx.c.dim(`Cloned into ${dir ?? path.basename(ref.name)}. Tip: 'diffinery auth setup-git' lets plain git push/fetch authenticate.`));
111+ },
112+};
113+
114+export const repoGroup: Group = { name: "repo", summary: "Create, list, view and clone repositories", commands: [create, listCmd, view, clone] };
addedapps/cli/src/commands/run.ts+213−0View file
@@ -1,0 +1,213 @@
1+import { enc } from "../api.js";
2+import { positiveInt, repoPath, str, type Command, type Ctx, type Group } from "../cli.js";
3+import { CliError, ExitCode } from "../io.js";
4+import { ago, json, statusText, symbol, table, truncate } from "../output.js";
5+
6+export interface RunJson {
7+ id: string;
8+ run_number: number;
9+ run_attempt: number;
10+ name: string;
11+ display_title: string;
12+ path: string;
13+ event: string;
14+ head_branch: string;
15+ head_sha: string;
16+ status: string;
17+ conclusion: string | null;
18+ actor: { login: string };
19+ created_at: string;
20+ updated_at: string;
21+ html_url: string;
22+ pull_request_number: number | null;
23+}
24+
25+export interface JobJson {
26+ id: string;
27+ runId: string;
28+ key: string;
29+ name: string;
30+ status: string;
31+ conclusion: string | null;
32+ startedAt: string | null;
33+ completedAt: string | null;
34+ steps: Array<{ index: number; name?: string; status?: string; conclusion?: string | null }>;
35+ failure: { message?: string } | null;
36+}
37+
38+type RunDetail = RunJson & { jobs: JobJson[] };
39+
40+function duration(a: string | null, b: string | null): string {
41+ if (!a) return "";
42+ const ms = (b ? Date.parse(b) : Date.now()) - Date.parse(a);
43+ if (!Number.isFinite(ms) || ms < 0) return "";
44+ const s = Math.round(ms / 1000);
45+ return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`;
46+}
47+
48+const listCmd: Command = {
49+ name: "run list",
50+ summary: "List workflow runs",
51+ options: {
52+ branch: { type: "string", short: "b", desc: "Only runs for this branch", arg: "BRANCH" },
53+ workflow: { type: "string", short: "w", desc: "Only runs of this workflow (file name or path)", arg: "FILE" },
54+ status: { type: "string", short: "s", desc: "Only runs with this status (queued, in_progress, completed, ...)", arg: "STATUS" },
55+ event: { type: "string", short: "e", desc: "Only runs triggered by this event", arg: "EVENT" },
56+ commit: { type: "string", short: "c", desc: "Only runs for this commit SHA", arg: "SHA" },
57+ limit: { type: "string", short: "L", desc: "Maximum number to show (default 20)", arg: "N" },
58+ },
59+ repo: true,
60+ json: true,
61+ maxArgs: 0,
62+ async run(ctx, o) {
63+ const repo = await ctx.repo();
64+ const limit = str(o, "limit") ? positiveInt(str(o, "limit"), "--limit") : 20;
65+ const res = await (await ctx.api()).get<{ total_count: number; workflow_runs: RunJson[] }>(`${repoPath(repo)}/actions/runs`, {
66+ branch: str(o, "branch"),
67+ workflow: str(o, "workflow"),
68+ status: str(o, "status"),
69+ event: str(o, "event"),
70+ head_sha: str(o, "commit"),
71+ per_page: Math.min(limit, 100),
72+ });
73+ if (ctx.json) return json(ctx.io, res.workflow_runs);
74+ if (!res.workflow_runs.length) return ctx.err("No runs found.");
75+ const c = ctx.c;
76+ ctx.io.stdout.write(
77+ table([
78+ ["", "TITLE", "WORKFLOW", "BRANCH", "EVENT", "ID", "AGE"].map((h) => c.dim(h)),
79+ ...res.workflow_runs.map((r) => [symbol(c, r.status, r.conclusion), truncate(r.display_title, 50), r.name, c.cyan(r.head_branch), r.event, r.id, c.dim(ago(r.created_at))]),
80+ ]),
81+ );
82+ },
83+};
84+
85+function printRun(ctx: Ctx, r: RunDetail): void {
86+ const c = ctx.c;
87+ ctx.out(`${symbol(c, r.status, r.conclusion)} ${c.bold(r.display_title)} ${c.dim(`${r.name} #${r.run_number}${r.run_attempt > 1 ? ` (attempt ${r.run_attempt})` : ""}`)}`);
88+ ctx.out(`${statusText(c, r.status, r.conclusion)} · ${r.event} on ${c.cyan(r.head_branch)} ${c.dim(r.head_sha.slice(0, 7))} · ${r.actor.login} · ${ago(r.created_at)}`);
89+ ctx.out("");
90+ ctx.io.stdout.write(table(r.jobs.map((j) => [symbol(c, j.status, j.conclusion), j.name, statusText(c, j.status, j.conclusion), c.dim(duration(j.startedAt, j.completedAt)), c.dim(`job ${j.id}`)])));
91+ for (const j of r.jobs) if (j.failure?.message) ctx.out(`${c.red("X")} ${j.name}: ${j.failure.message}`);
92+ ctx.out(c.dim(r.html_url));
93+}
94+
95+const view: Command = {
96+ name: "run view",
97+ summary: "Show a run and its jobs",
98+ args: "<run-id>",
99+ repo: true,
100+ json: true,
101+ minArgs: 1,
102+ maxArgs: 1,
103+ async run(ctx, _o, [id]) {
104+ const repo = await ctx.repo();
105+ const r = await (await ctx.api()).get<RunDetail>(`${repoPath(repo)}/actions/runs/${enc(id!)}`);
106+ if (ctx.json) return json(ctx.io, r);
107+ printRun(ctx, r);
108+ },
109+};
110+
111+const watch: Command = {
112+ name: "run watch",
113+ summary: "Follow a run until it completes",
114+ description: "Polls every 3 seconds and prints job status changes. Exits non-zero unless the run succeeds.",
115+ args: "<run-id>",
116+ options: { interval: { type: "string", short: "i", desc: "Seconds between polls (default 3)", arg: "SECONDS" } },
117+ repo: true,
118+ minArgs: 1,
119+ maxArgs: 1,
120+ async run(ctx, o, [id]) {
121+ const repo = await ctx.repo();
122+ const api = await ctx.api();
123+ const interval = Math.max(0, Number(str(o, "interval") ?? 3)) * 1000;
124+ if (!Number.isFinite(interval)) throw new CliError("--interval must be a number", 2);
125+ const c = ctx.c;
126+ const seen = new Map<string, string>();
127+ let first = true;
128+ let failures = 0;
129+ for (;;) {
130+ let r: RunDetail;
131+ try {
132+ r = await api.get<RunDetail>(`${repoPath(repo)}/actions/runs/${enc(id!)}`);
133+ failures = 0;
134+ } catch (e) {
135+ // Ride out transient network/server errors; give up on client errors.
136+ const status = (e as { status?: number }).status;
137+ if ((status && status < 500 && status !== 429) || ++failures > 5) throw e;
138+ await ctx.io.sleep(interval);
139+ continue;
140+ }
141+ if (first) {
142+ ctx.out(`${c.bold(r.display_title)} ${c.dim(`${r.name} #${r.run_number} · ${r.html_url}`)}`);
143+ first = false;
144+ }
145+ for (const j of r.jobs) {
146+ const key = `${j.status}/${j.conclusion ?? ""}`;
147+ if (seen.get(j.id) === key) continue;
148+ seen.set(j.id, key);
149+ const extra = j.status === "completed" ? ` ${c.dim(duration(j.startedAt, j.completedAt))}` : "";
150+ ctx.out(`${symbol(c, j.status, j.conclusion)} ${j.name}: ${statusText(c, j.status, j.conclusion)}${extra}${j.failure?.message && j.status === "completed" ? ` ${c.dim(`(${j.failure.message})`)}` : ""}`);
151+ }
152+ if (r.status === "completed") {
153+ ctx.out(`${symbol(c, r.status, r.conclusion)} Run ${r.id} completed: ${statusText(c, r.status, r.conclusion)}`);
154+ if (r.conclusion !== "success") {
155+ const failed = r.jobs.find((j) => j.conclusion === "failure" || j.conclusion === "timed_out");
156+ if (failed) ctx.err(c.dim(`See the log: diffinery run logs ${failed.id} -R ${repo.owner}/${repo.name}`));
157+ }
158+ throw new ExitCode(r.conclusion === "success" ? 0 : 1);
159+ }
160+ await ctx.io.sleep(interval);
161+ }
162+ },
163+};
164+
165+const logs: Command = {
166+ name: "run logs",
167+ summary: "Print a job's log",
168+ args: "<job-id>",
169+ repo: true,
170+ json: true,
171+ minArgs: 1,
172+ maxArgs: 1,
173+ async run(ctx, _o, [id]) {
174+ const repo = await ctx.repo();
175+ const api = await ctx.api();
176+ if (ctx.json) return json(ctx.io, await api.get(`${repoPath(repo)}/actions/jobs/${enc(id!)}/logs`, { format: "json" }));
177+ const res = await api.raw<string>("GET", `${repoPath(repo)}/actions/jobs/${enc(id!)}/logs`, { text: true });
178+ ctx.io.stdout.write(res.data.endsWith("\n") || !res.data ? res.data : `${res.data}\n`);
179+ },
180+};
181+
182+const rerun: Command = {
183+ name: "run rerun",
184+ summary: "Re-run a workflow run",
185+ args: "<run-id>",
186+ options: { failed: { type: "boolean", desc: "Only re-run failed jobs (where supported)" } },
187+ repo: true,
188+ json: true,
189+ minArgs: 1,
190+ maxArgs: 1,
191+ async run(ctx, o, [id]) {
192+ const repo = await ctx.repo();
193+ const r = await (await ctx.authedApi()).post<{ run_id: string }>(`${repoPath(repo)}/actions/runs/${enc(id!)}/${o.failed ? "rerun-failed-jobs" : "rerun"}`);
194+ if (ctx.json) return json(ctx.io, r);
195+ ctx.out(`${ctx.c.green("✓")} Re-running as run ${r.run_id}`);
196+ },
197+};
198+
199+const cancel: Command = {
200+ name: "run cancel",
201+ summary: "Cancel a queued or running run",
202+ args: "<run-id>",
203+ repo: true,
204+ minArgs: 1,
205+ maxArgs: 1,
206+ async run(ctx, _o, [id]) {
207+ const repo = await ctx.repo();
208+ await (await ctx.authedApi()).post(`${repoPath(repo)}/actions/runs/${enc(id!)}/cancel`);
209+ ctx.out(`${ctx.c.green("✓")} Cancellation requested for run ${id}`);
210+ },
211+};
212+
213+export const runGroup: Group = { name: "run", summary: "Inspect and control workflow runs", commands: [listCmd, view, watch, logs, rerun, cancel] };
addedapps/cli/src/commands/workflow.ts+140−0View file
@@ -1,0 +1,140 @@
1+import { readdir, readFile, stat } from "node:fs/promises";
2+import path from "node:path";
3+import { isWarning, parseWorkflow } from "@diffinery/workflow-yaml";
4+import { enc } from "../api.js";
5+import { list, repoPath, str, type Command, type Group } from "../cli.js";
6+import { gitCapture } from "../git.js";
7+import { CliError } from "../io.js";
8+import { json, table } from "../output.js";
9+import type { RepoJson } from "./repo.js";
10+
11+export const WORKFLOW_DIRS = [".github/workflows", ".diffinery/workflows"];
12+const isYaml = (f: string) => /\.ya?ml$/i.test(f);
13+
14+/** Parses -f key=value pairs into workflow_dispatch inputs. */
15+export function parseInputs(pairs: string[]): Record<string, string> {
16+ const out: Record<string, string> = {};
17+ for (const p of pairs) {
18+ const i = p.indexOf("=");
19+ if (i <= 0) throw new CliError(`-f expects key=value, got "${p}"`, 2);
20+ out[p.slice(0, i)] = p.slice(i + 1);
21+ }
22+ return out;
23+}
24+
25+const run: Command = {
26+ name: "workflow run",
27+ summary: "Start a workflow_dispatch run",
28+ args: "<workflow-file>",
29+ description: "The workflow is matched by path (.github/workflows/deploy.yml) or file name (deploy.yml).",
30+ options: {
31+ ref: { type: "string", short: "r", desc: "Branch or tag to run on (default: the default branch)", arg: "REF" },
32+ field: { type: "string", short: "f", multiple: true, desc: "Input value", arg: "KEY=VALUE" },
33+ },
34+ repo: true,
35+ json: true,
36+ minArgs: 1,
37+ maxArgs: 1,
38+ async run(ctx, o, [file]) {
39+ const inputs = parseInputs(list(o, "field"));
40+ const repo = await ctx.repo();
41+ const api = await ctx.authedApi();
42+ const ref = str(o, "ref") ?? (await api.get<RepoJson>(repoPath(repo))).default_branch;
43+ const wf = file!.replace(/^\.\//, "");
44+ const r = await api.post<{ run_id: string; html_url: string }>(`${repoPath(repo)}/actions/workflows/${enc(wf)}/dispatches`, { ref, inputs });
45+ if (ctx.json) return json(ctx.io, r);
46+ ctx.out(`${ctx.c.green("✓")} Started run ${r.run_id} of ${wf} on ${ref}`);
47+ ctx.out(r.html_url);
48+ ctx.err(ctx.c.dim(`Follow it: diffinery run watch ${r.run_id} -R ${repo.owner}/${repo.name}`));
49+ },
50+};
51+
52+const listCmd: Command = {
53+ name: "workflow list",
54+ summary: "List the repository's workflows",
55+ repo: true,
56+ json: true,
57+ maxArgs: 0,
58+ async run(ctx) {
59+ const repo = await ctx.repo();
60+ const res = await (await ctx.api()).get<{ workflows: Array<{ name: string; path: string; state: string; errors: string[]; triggers?: string[] }> }>(`${repoPath(repo)}/actions/workflows`);
61+ if (ctx.json) return json(ctx.io, res.workflows);
62+ if (!res.workflows.length) return ctx.err("No workflows.");
63+ const c = ctx.c;
64+ ctx.io.stdout.write(table(res.workflows.map((w) => [w.name, c.dim(w.path), w.state === "active" ? c.green(w.state) : c.red(w.state), c.dim((w.triggers ?? []).join(", "))])));
65+ },
66+};
67+
68+async function collectFiles(cwd: string, root: string, args: string[]): Promise<string[]> {
69+ const out: string[] = [];
70+ const addDir = async (dir: string, explicit: boolean) => {
71+ let entries: string[];
72+ try {
73+ entries = await readdir(dir);
74+ } catch (e) {
75+ if (!explicit && (e as NodeJS.ErrnoException).code === "ENOENT") return;
76+ throw new CliError(`cannot read ${dir}: ${(e as Error).message}`);
77+ }
78+ for (const f of entries.sort()) if (isYaml(f)) out.push(path.join(dir, f));
79+ };
80+ if (!args.length) {
81+ for (const d of WORKFLOW_DIRS) await addDir(path.join(root, d), false);
82+ return out;
83+ }
84+ for (const a of args) {
85+ const p = path.resolve(cwd, a);
86+ let s;
87+ try {
88+ s = await stat(p);
89+ } catch {
90+ throw new CliError(`no such file: ${a}`);
91+ }
92+ if (s.isDirectory()) await addDir(p, true);
93+ else out.push(p);
94+ }
95+ return out;
96+}
97+
98+const validate: Command = {
99+ name: "workflow validate",
100+ summary: "Check workflow files for errors",
101+ args: "[files or directories...]",
102+ description: `Default: every .yml/.yaml file in .github/workflows and .diffinery/workflows of the
103+current repository. Uses the same parser as the server. Exits 1 when any file has errors.`,
104+ json: true,
105+ async run(ctx, _o, args) {
106+ const top = await gitCapture(ctx.io, ["rev-parse", "--show-toplevel"]);
107+ const root = top.code === 0 && top.stdout.trim() ? top.stdout.trim() : ctx.io.cwd;
108+ const files = await collectFiles(ctx.io.cwd, root, args);
109+ if (!files.length) {
110+ if (ctx.json) return json(ctx.io, []);
111+ ctx.err(`No workflow files found in ${WORKFLOW_DIRS.join(" or ")}.`);
112+ return 0;
113+ }
114+ const c = ctx.c;
115+ const results = [];
116+ let errors = 0;
117+ let warnings = 0;
118+ for (const f of files) {
119+ const rel = path.relative(ctx.io.cwd, f) || f;
120+ const relRoot = path.relative(root, f).split(path.sep).join("/");
121+ const source = await readFile(f, "utf8");
122+ const res = parseWorkflow(source, relRoot.startsWith("..") ? rel : relRoot);
123+ const items = res.errors.map((e) => ({ ...e, severity: isWarning(e) ? "warning" : "error", message: isWarning(e) ? e.message.replace(/^warning:\s*/, "") : e.message }));
124+ results.push({ file: rel, valid: !items.some((i) => i.severity === "error"), problems: items });
125+ for (const i of items) {
126+ if (i.severity === "error") errors++;
127+ else warnings++;
128+ if (ctx.json) continue;
129+ const loc = `${rel}${i.line ? `:${i.line}${i.col ? `:${i.col}` : ""}` : ""}`;
130+ ctx.out(`${c.bold(loc)}: ${i.severity === "error" ? c.red("error") : c.yellow("warning")}: ${i.message}${i.path ? c.dim(` (${i.path})`) : ""}`);
131+ }
132+ if (!ctx.json && !items.length) ctx.out(`${c.green("✓")} ${rel}`);
133+ }
134+ if (ctx.json) json(ctx.io, results);
135+ else ctx.err(`${files.length} file${files.length === 1 ? "" : "s"} checked: ${errors ? c.red(`${errors} error${errors === 1 ? "" : "s"}`) : "no errors"}, ${warnings} warning${warnings === 1 ? "" : "s"}`);
136+ return errors ? 1 : 0;
137+ },
138+};
139+
140+export const workflowGroup: Group = { name: "workflow", summary: "Run and validate workflows", commands: [run, listCmd, validate] };
addedapps/cli/src/config.ts+89−0View file
@@ -1,0 +1,89 @@
1+import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
2+import { homedir } from "node:os";
3+import path from "node:path";
4+import { CliError, type Io } from "./io.js";
5+
6+export const DEFAULT_URL = "https://diffinery.com";
7+
8+export interface StoredConfig {
9+ url?: string;
10+ token?: string;
11+}
12+
13+export interface ResolvedConfig {
14+ url: string;
15+ token: string | null;
16+ /** Where the token came from, for messages. */
17+ tokenSource: "env" | "config" | null;
18+ urlSource: "env" | "config" | "default";
19+ file: string;
20+}
21+
22+export function configDir(env: NodeJS.ProcessEnv): string {
23+ const xdg = env.XDG_CONFIG_HOME;
24+ const base = xdg && path.isAbsolute(xdg) ? xdg : path.join(env.HOME || homedir(), ".config");
25+ return path.join(base, "diffinery");
26+}
27+
28+export const configPath = (env: NodeJS.ProcessEnv) => path.join(configDir(env), "config.json");
29+
30+export async function readConfig(env: NodeJS.ProcessEnv): Promise<StoredConfig> {
31+ const file = configPath(env);
32+ let text: string;
33+ try {
34+ text = await readFile(file, "utf8");
35+ } catch (e) {
36+ if ((e as NodeJS.ErrnoException).code === "ENOENT") return {};
37+ throw e;
38+ }
39+ try {
40+ const v = JSON.parse(text) as unknown;
41+ if (!v || typeof v !== "object") return {};
42+ const o = v as Record<string, unknown>;
43+ return { url: typeof o.url === "string" ? o.url : undefined, token: typeof o.token === "string" ? o.token : undefined };
44+ } catch {
45+ throw new CliError(`${file} is not valid JSON; fix or delete it`);
46+ }
47+}
48+
49+export async function writeConfig(env: NodeJS.ProcessEnv, cfg: StoredConfig): Promise<string> {
50+ const dir = configDir(env);
51+ await mkdir(dir, { recursive: true, mode: 0o700 });
52+ const file = configPath(env);
53+ const clean: StoredConfig = {};
54+ if (cfg.url) clean.url = cfg.url;
55+ if (cfg.token) clean.token = cfg.token;
56+ await writeFile(file, JSON.stringify(clean, null, 2) + "\n", { mode: 0o600 });
57+ await chmod(file, 0o600); // writeFile's mode only applies on create
58+ return file;
59+}
60+
61+/** Normalizes a user-typed server URL: adds https://, drops trailing slashes and any path. */
62+export function normalizeUrl(u: string): string {
63+ let s = u.trim();
64+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) s = `https://${s}`;
65+ let parsed: URL;
66+ try {
67+ parsed = new URL(s);
68+ } catch {
69+ throw new CliError(`invalid URL: ${u}`);
70+ }
71+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw new CliError(`URL must be http(s): ${u}`);
72+ return `${parsed.protocol}//${parsed.host}${parsed.pathname.replace(/\/+$/, "")}`;
73+}
74+
75+/** Env vars override the config file; the URL falls back to the public instance. */
76+export async function resolveConfig(io: Io): Promise<ResolvedConfig> {
77+ const stored = await readConfig(io.env);
78+ const envUrl = io.env.DIFFINERY_URL?.trim();
79+ const envToken = io.env.DIFFINERY_TOKEN?.trim();
80+ const url = envUrl ? normalizeUrl(envUrl) : stored.url ? normalizeUrl(stored.url) : DEFAULT_URL;
81+ const token = envToken || stored.token || null;
82+ return {
83+ url,
84+ token,
85+ tokenSource: envToken ? "env" : stored.token ? "config" : null,
86+ urlSource: envUrl ? "env" : stored.url ? "config" : "default",
87+ file: configPath(io.env),
88+ };
89+}
addedapps/cli/src/git.ts+100−0View file
@@ -1,0 +1,100 @@
1+import { spawn } from "node:child_process";
2+import { CliError, type Io } from "./io.js";
3+
4+export interface GitResult {
5+ code: number;
6+ stdout: string;
7+ stderr: string;
8+}
9+
10+/** Runs git and captures its output. */
11+export function gitCapture(io: Io, args: string[], o: { cwd?: string; env?: NodeJS.ProcessEnv; input?: string } = {}): Promise<GitResult> {
12+ return new Promise((resolve) => {
13+ let child;
14+ try {
15+ child = spawn("git", args, { cwd: o.cwd ?? io.cwd, env: o.env ?? io.env, stdio: ["pipe", "pipe", "pipe"] });
16+ } catch (e) {
17+ return resolve({ code: 127, stdout: "", stderr: String((e as Error).message) });
18+ }
19+ let stdout = "";
20+ let stderr = "";
21+ child.stdout.on("data", (d: Buffer) => (stdout += d.toString()));
22+ child.stderr.on("data", (d: Buffer) => (stderr += d.toString()));
23+ child.on("error", (e) => resolve({ code: 127, stdout, stderr: stderr + e.message }));
24+ child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
25+ child.stdin.end(o.input ?? "");
26+ });
27+}
28+
29+/** Runs git with output going to the user's terminal (clone/push progress). */
30+export function gitInteractive(io: Io, args: string[], o: { cwd?: string; env?: NodeJS.ProcessEnv } = {}): Promise<number> {
31+ return new Promise((resolve, reject) => {
32+ const child = spawn("git", args, { cwd: o.cwd ?? io.cwd, env: o.env ?? io.env, stdio: ["inherit", "pipe", "pipe"] });
33+ child.stdout.on("data", (d: Buffer) => io.stdout.write(d.toString()));
34+ child.stderr.on("data", (d: Buffer) => io.stderr.write(d.toString()));
35+ child.on("error", (e) => reject(new CliError(`could not run git: ${e.message}`)));
36+ child.on("close", (code) => resolve(code ?? 1));
37+ });
38+}
39+
40+export async function git(io: Io, args: string[], o: { cwd?: string; env?: NodeJS.ProcessEnv } = {}): Promise<string> {
41+ const r = await gitCapture(io, args, o);
42+ if (r.code !== 0) throw new CliError(`git ${args[0]} failed: ${r.stderr.trim() || `exit ${r.code}`}`);
43+ return r.stdout.trim();
44+}
45+
46+/**
47+ * Environment that adds one-off git config for a single git invocation
48+ * (GIT_CONFIG_COUNT/KEY/VALUE, git 2.31+). Unlike `-c`, values do not show up
49+ * in the process list, and like `-c` they are never written to .git/config.
50+ */
51+export function withGitConfig(env: NodeJS.ProcessEnv, entries: Array<[string, string]>): NodeJS.ProcessEnv {
52+ const out = { ...env };
53+ let n = Number(out.GIT_CONFIG_COUNT ?? 0);
54+ if (!Number.isInteger(n) || n < 0) n = 0;
55+ for (const [k, v] of entries) {
56+ out[`GIT_CONFIG_KEY_${n}`] = k;
57+ out[`GIT_CONFIG_VALUE_${n}`] = v;
58+ n++;
59+ }
60+ out.GIT_CONFIG_COUNT = String(n);
61+ return out;
62+}
63+
64+/** `http.<url>.extraHeader` entry that authenticates git HTTP requests to `baseUrl` only. */
65+export function basicAuthHeader(baseUrl: string, user: string, password: string): [string, string] {
66+ return [`http.${baseUrl.replace(/\/$/, "")}/.extraHeader`, `Authorization: Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`];
67+}
68+
69+/** Parses owner/name out of a remote URL that points at `host` (https, ssh or scp-like). */
70+export function parseRemote(remote: string, host: string): { owner: string; name: string } | null {
71+ const r = remote.trim();
72+ let h: string;
73+ let p: string;
74+ const scp = /^(?:[^@/]+@)?([^:/]+):(?!\/\/)(.+)$/.exec(r);
75+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(r)) {
76+ try {
77+ const u = new URL(r);
78+ h = u.host;
79+ p = decodeURIComponent(u.pathname);
80+ if (u.protocol === "ssh:" && u.port) h = u.hostname; // ssh port differs from the web port
81+ } catch {
82+ return null;
83+ }
84+ } else if (scp) {
85+ h = scp[1]!;
86+ p = scp[2]!;
87+ } else return null;
88+ const want = host.toLowerCase();
89+ if (h.toLowerCase() !== want && h.toLowerCase() !== want.replace(/:\d+$/, "")) return null;
90+ const parts = p.replace(/^\/+/, "").replace(/\/+$/, "").split("/");
91+ if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
92+ const name = parts[1].replace(/\.git$/, "");
93+ return name ? { owner: parts[0], name } : null;
94+}
95+
96+export async function currentBranch(io: Io): Promise<string> {
97+ const r = await gitCapture(io, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
98+ if (r.code !== 0 || !r.stdout.trim()) throw new CliError("could not determine the current branch (detached HEAD or not a git repository); pass --head");
99+ return r.stdout.trim();
100+}
addedapps/cli/src/io.ts+89−0View file
@@ -1,0 +1,89 @@
1+import type { Readable } from "node:stream";
2+
3+/** Everything a command touches in the outside world, so tests can run the CLI in-process. */
4+export interface Io {
5+ env: NodeJS.ProcessEnv;
6+ cwd: string;
7+ stdin: Readable & { isTTY?: boolean };
8+ stdout: { write(s: string): unknown; isTTY?: boolean };
9+ stderr: { write(s: string): unknown; isTTY?: boolean };
10+ /** Overridable for tests (polling loops). */
11+ sleep(ms: number, signal?: AbortSignal): Promise<void>;
12+}
13+
14+export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
15+ return new Promise((resolve) => {
16+ if (signal?.aborted) return resolve();
17+ const t = setTimeout(done, ms);
18+ function done() {
19+ clearTimeout(t);
20+ signal?.removeEventListener("abort", done);
21+ resolve();
22+ }
23+ signal?.addEventListener("abort", done, { once: true });
24+ });
25+}
26+
27+export function processIo(): Io {
28+ return { env: process.env, cwd: process.cwd(), stdin: process.stdin, stdout: process.stdout, stderr: process.stderr, sleep };
29+}
30+
31+/** A user-facing failure: printed as `error: message`, exits with `code`. */
32+export class CliError extends Error {
33+ constructor(
34+ message: string,
35+ readonly code = 1,
36+ ) {
37+ super(message);
38+ }
39+}
40+
41+/** Exit silently with a code (the command already printed what it wanted). */
42+export class ExitCode extends Error {
43+ constructor(readonly code: number) {
44+ super(`exit ${code}`);
45+ }
46+}
47+
48+export async function readAll(stream: Readable): Promise<string> {
49+ const chunks: Buffer[] = [];
50+ for await (const c of stream) chunks.push(typeof c === "string" ? Buffer.from(c) : (c as Buffer));
51+ return Buffer.concat(chunks).toString("utf8");
52+}
53+
54+/** Reads one line from a TTY without echoing it (for tokens and secret values). */
55+export async function promptHidden(io: Io, question: string): Promise<string> {
56+ const stdin = io.stdin as NodeJS.ReadStream;
57+ io.stderr.write(question);
58+ return new Promise((resolve, reject) => {
59+ let value = "";
60+ const raw = typeof stdin.setRawMode === "function";
61+ if (raw) stdin.setRawMode(true);
62+ stdin.resume();
63+ stdin.setEncoding("utf8");
64+ const finish = (err?: Error) => {
65+ stdin.off("data", onData);
66+ if (raw) stdin.setRawMode(false);
67+ stdin.pause();
68+ io.stderr.write("\n");
69+ if (err) reject(err);
70+ else resolve(value);
71+ };
72+ const onData = (chunk: string) => {
73+ for (const ch of chunk) {
74+ if (ch === "\r" || ch === "\n" || ch === "\u0004") return finish();
75+ if (ch === "\u0003") return finish(new CliError("cancelled", 130));
76+ if (ch === "\u007f" || ch === "\b") value = value.slice(0, -1);
77+ else value += ch;
78+ }
79+ };
80+ stdin.on("data", onData);
81+ });
82+}
83+
84+/** Value from a flag, else stdin when piped, else a hidden prompt. */
85+export async function valueFromFlagOrStdin(io: Io, flag: string | undefined, prompt: string): Promise<string> {
86+ if (flag !== undefined) return flag;
87+ if (!io.stdin.isTTY) return (await readAll(io.stdin)).replace(/\r?\n$/, "");
88+ return promptHidden(io, prompt);
89+}
addedapps/cli/src/output.ts+67−0View file
@@ -1,0 +1,67 @@
1+import type { Io } from "./io.js";
2+
3+export interface Colors {
4+ enabled: boolean;
5+ bold(s: string): string;
6+ dim(s: string): string;
7+ red(s: string): string;
8+ green(s: string): string;
9+ yellow(s: string): string;
10+ cyan(s: string): string;
11+ magenta(s: string): string;
12+}
13+
14+export function colors(io: Io): Colors {
15+ const on = !!io.stdout.isTTY && io.env.NO_COLOR === undefined && io.env.TERM !== "dumb";
16+ const wrap = (open: number, close: number) => (s: string) => (on ? `\x1b[${open}m${s}\x1b[${close}m` : s);
17+ return { enabled: on, bold: wrap(1, 22), dim: wrap(2, 22), red: wrap(31, 39), green: wrap(32, 39), yellow: wrap(33, 39), cyan: wrap(36, 39), magenta: wrap(35, 39) };
18+}
19+
20+const ANSI = /\x1b\[[0-9;]*m/g;
21+const width = (s: string) => [...s.replace(ANSI, "")].length;
22+
23+/** Left-aligned columns separated by two spaces; the last column is not padded. */
24+export function table(rows: string[][]): string {
25+ if (!rows.length) return "";
26+ const cols = Math.max(...rows.map((r) => r.length));
27+ const widths = Array.from({ length: cols }, (_, i) => Math.max(...rows.map((r) => width(r[i] ?? ""))));
28+ return rows.map((r) => r.map((cell, i) => (i === r.length - 1 ? cell : cell + " ".repeat(widths[i]! - width(cell)))).join(" ").trimEnd()).join("\n") + "\n";
29+}
30+
31+export function json(io: Io, v: unknown): void {
32+ io.stdout.write(JSON.stringify(v, null, 2) + "\n");
33+}
34+
35+export function truncate(s: string | null | undefined, n: number): string {
36+ const t = (s ?? "").replace(/\s+/g, " ").trim();
37+ return t.length > n ? `${t.slice(0, n - 1)}…` : t;
38+}
39+
40+export function ago(iso: string | null | undefined, now = Date.now()): string {
41+ if (!iso) return "";
42+ const t = Date.parse(iso);
43+ if (Number.isNaN(t)) return iso;
44+ const s = Math.max(0, Math.round((now - t) / 1000));
45+ if (s < 60) return `${s}s ago`;
46+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
47+ if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
48+ if (s < 86400 * 30) return `${Math.floor(s / 86400)}d ago`;
49+ return iso.slice(0, 10);
50+}
51+
52+/** Colors a status/conclusion word. */
53+export function statusText(c: Colors, status: string | null | undefined, conclusion?: string | null): string {
54+ if (status && status !== "completed") return c.yellow(status.replace(/_/g, " "));
55+ const v = conclusion ?? status ?? "unknown";
56+ if (v === "success" || v === "merged" || v === "open" || v === "clean") return c.green(v);
57+ if (v === "failure" || v === "timed_out" || v === "cancelled" || v === "action_required") return c.red(v.replace(/_/g, " "));
58+ if (v === "closed" || v === "skipped" || v === "neutral") return c.dim(v);
59+ return c.yellow(v.replace(/_/g, " "));
60+}
61+
62+export function symbol(c: Colors, status: string | null | undefined, conclusion?: string | null): string {
63+ if (status && status !== "completed") return c.yellow("*");
64+ if (conclusion === "success") return c.green("✓");
65+ if (conclusion === "skipped" || conclusion === "neutral") return c.dim("-");
66+ return c.red("X");
67+}
addedapps/cli/src/version.ts+1−0View file
@@ -1,0 +1,1 @@
1+export const VERSION = "0.1.0";
addedapps/cli/tsconfig.json+1−0View file
@@ -1,0 +1,1 @@
1+{ "extends": "../../tsconfig.base.json", "compilerOptions": { "noEmit": true, "types": ["node"] }, "include": ["src", "test"] }
modifiedapps/diffinery/package.json+6−4View file
@@ -4,11 +4,13 @@
44 "private": true,
55 "type": "module",
66 "scripts": {
7- "dev": "wrangler dev",
8- "deploy": "wrangler deploy",
7+ "dev": "wrangler dev -e dev",
8+ "deploy": "wrangler deploy -c wrangler.deploy.jsonc",
99 "typecheck": "tsc --noEmit",
10- "test": "vitest run",
11- "cf-typegen": "wrangler types"
10+ "test": "vitest run --passWithNoTests",
11+ "cf-typegen": "wrangler types",
12+ "e2e": "node --test --test-timeout=600000 test/e2e/e2e.test.mjs",
13+ "db:migrate:local": "wrangler d1 migrations apply diffinery-dev --local -e dev"
1214 },
1315 "dependencies": {
1416 "@cloudflare/containers": "^0.3.7",
addedapps/diffinery/src/app.ts+196−0View file
@@ -1,0 +1,196 @@
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+
14+export const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
15+
16+app.onError((e) => errorResponse(e));
17+
18+// Security headers on every response, then identity: tokens, Access JWT, or anonymous.
19+app.use("*", async (c, next) => {
20+ const dir = new Directory(c.env);
21+ c.set("dir", dir);
22+ c.set("principal", await resolvePrincipal(c.req.raw, c.env, dir));
23+ await next();
24+ if (c.res.status === 101) return;
25+ const h = c.res.headers;
26+ h.set("strict-transport-security", "max-age=63072000; includeSubDomains; preload");
27+ h.set("x-content-type-options", "nosniff");
28+ h.set("referrer-policy", "strict-origin-when-cross-origin");
29+ if (!h.has("x-frame-options")) h.set("x-frame-options", "DENY");
30+ h.set("permissions-policy", "camera=(), microphone=(), geolocation=()");
31+});
32+
33+app.get("/healthz", (c) => c.json({ ok: true }));
34+
35+app.route("/", wellKnown);
36+app.route("/", git);
37+app.route("/", runner);
38+app.route("/", api);
39+app.route("/", mcp);
40+
41+// Live updates: authorise in the Worker, then hand the WebSocket to the repository's Durable Object.
42+app.get("/:owner/:repo/live", async (c) => {
43+ if (c.req.header("upgrade") !== "websocket") return c.text("expected websocket", 426);
44+ const { stub } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"));
45+ return stub.fetch(c.req.raw);
46+});
47+
48+app.get("/:owner/:repo/actions/runs/:runId/artifacts/:name", async (c) => {
49+ const { stub } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"));
50+ const name = c.req.param("name");
51+ if (!(await stub.artifacts(c.req.param("runId"))).some((a) => a.name === name)) return c.json({ message: "artifact not found" }, 404);
52+ const obj = await c.env.BUCKET.get(await stub.artifactKeyForRun(c.req.param("runId"), name));
53+ if (!obj) return c.json({ message: "artifact expired" }, 410);
54+ return new Response(obj.body, {
55+ headers: {
56+ "content-type": "application/gzip",
57+ "content-length": String(obj.size),
58+ "content-disposition": `attachment; filename="${name.replace(/[^\w.-]+/g, "_")}.tar.gz"`,
59+ },
60+ });
61+});
62+
63+// ---------------------------------------------------------------- events queue
64+
65+const GITHUB_EVENT: Record<DiffineryEvent["type"], string> = {
66+ push: "push",
67+ pull_request: "pull_request",
68+ issues: "issues",
69+ issue_comment: "issue_comment",
70+ pull_request_review: "pull_request_review",
71+ workflow_run: "workflow_run",
72+ workflow_dispatch: "workflow_dispatch",
73+ audit: "audit",
74+};
75+
76+function webhookPayload(env: Env, ev: Exclude<DiffineryEvent, { type: "audit" }>) {
77+ 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` };
78+ 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 };
79+ const { type: _t, repoId: _r, owner: _o, repo: _n, actor: _a, ...rest } = ev;
80+ const action = "action" in ev ? (ev.action === "merged" ? "closed" : ev.action) : undefined;
81+ const extra: Record<string, unknown> = {};
82+ if (ev.type === "pull_request") {
83+ 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" };
84+ }
85+ 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}` };
86+ return { ...rest, action, repository, sender, ...extra };
87+}
88+
89+async function deliverWebhooks(env: Env, ev: Exclude<DiffineryEvent, { type: "audit" }>): Promise<void> {
90+ const stub = repoStub(env, ev.repoId);
91+ const hooks = (await stub.webhooks()).filter((h) => h.active && (h.events.includes("*") || h.events.includes(GITHUB_EVENT[ev.type])));
92+ if (!hooks.length) return;
93+ const body = JSON.stringify(webhookPayload(env, ev));
94+ await Promise.all(
95+ hooks.map(async (h) => {
96+ const delivery = newId();
97+ let status = 0;
98+ try {
99+ const res = await fetch(h.url, {
100+ method: "POST",
101+ headers: {
102+ "content-type": "application/json",
103+ "user-agent": "Diffinery-Hookshot/1",
104+ "x-github-event": GITHUB_EVENT[ev.type],
105+ "x-github-delivery": delivery,
106+ "x-diffinery-event": ev.type,
107+ "x-hub-signature-256": `sha256=${await hmacSha256Hex(h.secret, body)}`,
108+ },
109+ body,
110+ redirect: "manual",
111+ signal: AbortSignal.timeout(10_000),
112+ });
113+ status = res.status;
114+ } catch {
115+ status = 0;
116+ }
117+ await stub.recordDelivery(h.id, status);
118+ }),
119+ );
120+}
121+
122+/** People who should hear about an event: participants, assignees and requested reviewers, minus the actor. */
123+async function notifyParticipants(env: Env, dir: Directory, ev: DiffineryEvent): Promise<void> {
124+ if (ev.type === "workflow_run") {
125+ if (ev.action !== "completed" || ev.conclusion !== "failure" || ev.actor.kind === "system") return;
126+ 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}` });
127+ return;
128+ }
129+ if (ev.type !== "pull_request" && ev.type !== "issues" && ev.type !== "issue_comment" && ev.type !== "pull_request_review") return;
130+ if (ev.type === "pull_request" && ev.action === "synchronize") return;
131+ const { issue, comments, reviews } = await repoStub(env, ev.repoId).issue(ev.number);
132+ const ids = new Set<string>([issue.author.id, ...comments.map((c) => c.author.id), ...reviews.map((r) => r.author.id)]);
133+ for (const login of [...issue.assignees, ...(issue.pull?.requestedReviewers ?? [])]) {
134+ const a = await dir.accountByLogin(login);
135+ if (a) ids.add(a.id);
136+ }
137+ ids.delete(ev.actor.id);
138+ if (!ids.size) return;
139+ const kind = issue.kind === "pull" ? "pull" : "issues";
140+ const verb = ev.type === "issue_comment" ? "commented on" : ev.type === "pull_request_review" ? `reviewed (${ev.state.toLowerCase()})` : ev.action;
141+ await dir.notify([...ids].slice(0, 100), {
142+ repoId: ev.repoId,
143+ kind: ev.type,
144+ title: `${ev.actor.login} ${verb} ${ev.owner}/${ev.repo}#${ev.number}: ${issue.title}`,
145+ url: `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}/${kind}/${ev.number}`,
146+ });
147+}
148+
149+async function handleEvent(env: Env, dir: Directory, ev: DiffineryEvent): Promise<void> {
150+ if (ev.type === "audit") {
151+ 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 });
152+ return;
153+ }
154+ if (ev.type === "push") {
155+ 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 });
156+ }
157+ if (ev.type === "pull_request" && ev.action === "merged") {
158+ 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 });
159+ }
160+ await Promise.all([deliverWebhooks(env, ev), notifyParticipants(env, dir, ev)]);
161+}
162+
163+export async function queue(batch: MessageBatch<DiffineryEvent>, env: Env): Promise<void> {
164+ const dir = new Directory(env);
165+ // Keep an append-only copy of every event in R2 for long-term audit retention.
166+ const day = new Date().toISOString().slice(0, 10).replaceAll("-", "/");
167+ 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));
168+ for (const msg of batch.messages) {
169+ try {
170+ await handleEvent(env, dir, msg.body);
171+ msg.ack();
172+ } catch (e) {
173+ console.error("event handling failed", msg.body.type, e);
174+ msg.retry({ delaySeconds: Math.min(300, 10 * 2 ** msg.attempts) });
175+ }
176+ }
177+}
178+
179+// ---------------------------------------------------------------- cron: workflow schedules
180+
181+export async function scheduled(controller: ScheduledController, env: Env): Promise<void> {
182+ // The trigger runs every 5 minutes (GitHub's minimum schedule interval); fire anything due in the window.
183+ const end = new Date(controller.scheduledTime);
184+ end.setUTCSeconds(0, 0);
185+ const minutes = Array.from({ length: 5 }, (_, i) => new Date(end.getTime() - i * 60_000));
186+ 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 }>();
187+ await Promise.all(
188+ results.map(async (s) => {
189+ try {
190+ if (minutes.some((m) => cronMatches(s.cron, m))) await repoStub(env, s.repo_id).scheduleTick(s.workflow_path, s.cron);
191+ } catch (e) {
192+ console.error("schedule failed", s.repo_id, s.workflow_path, e);
193+ }
194+ }),
195+ );
196+}
modifiedapps/diffinery/src/git/protocol.ts+20−6View file
@@ -1,4 +1,4 @@
1-import { ByteWriter, FLUSH, DELIM, ZERO_OID, fromUtf8, objectsForFetch, parseTag, pkt, pktText, readPkts, sideband, writePack, utf8, type ObjectReader } from "@diffinery/git-core";
1+import { ByteWriter, FLUSH, DELIM, ZERO_OID, fromUtf8, planFetch, parseTag, pkt, pktText, readPkts, sideband, writePack, utf8, type ObjectReader } from "@diffinery/git-core";
22 import type { RawObject } from "../repo/objects.js";
33
44 export const AGENT = "diffinery/0.1";
@@ -22,7 +22,7 @@
2222 w.write(pkt("version 2\n"));
2323 w.write(pkt(`agent=${AGENT}\n`));
2424 w.write(pkt("ls-refs=unborn\n"));
25- w.write(pkt("fetch=wait-for-done\n"));
25+ w.write(pkt("fetch=shallow wait-for-done\n"));
2626 w.write(pkt("server-option\n"));
2727 w.write(pkt("object-format=sha1\n"));
2828 w.write(FLUSH);
@@ -107,6 +107,9 @@
107107 const haves: string[] = [];
108108 let done = false;
109109 let noProgress = false;
110+ let depth: number | undefined;
111+ let since: number | undefined;
112+ const clientShallow = new Set<string>();
110113 for (const a of req.args) {
111114 if (a.startsWith("want ")) wants.push(a.slice(5));
112115 else if (a.startsWith("want-ref ")) {
@@ -115,9 +118,13 @@
115118 } else if (a.startsWith("have ")) haves.push(a.slice(5));
116119 else if (a === "done") done = true;
117120 else if (a === "no-progress") noProgress = true;
118- else if (a.startsWith("deepen") || a.startsWith("shallow ") || a.startsWith("filter ")) {
119- throw new Error("shallow and partial clones are not supported yet; clone without --depth/--filter");
120- }
121+ else if (a.startsWith("shallow ")) clientShallow.add(a.slice(8));
122+ else if (a.startsWith("deepen ")) depth = Math.max(1, Number.parseInt(a.slice(7), 10) || 1);
123+ else if (a.startsWith("deepen-since ")) since = Number.parseInt(a.slice(13), 10);
124+ else if (a === "deepen-relative") {
125+ // Approximation: treat relative deepening as absolute depth from the wants.
126+ } else if (a.startsWith("deepen-not ")) throw new Error("--shallow-exclude is not supported; use --depth or --shallow-since");
127+ else if (a.startsWith("filter ")) throw new Error("partial clones (--filter) are not supported yet");
121128 }
122129 for (const w of wants) if (!ctx.store.has(w)) throw new Error(`not our ref ${w}`);
123130 const common = haves.filter((h) => ctx.store.has(h));
@@ -130,8 +137,15 @@
130137 out.write(pkt("ready\n"));
131138 out.write(DELIM);
132139 }
140+ const plan = await planFetch(ctx.store, wants, common, { depth, since, clientShallow });
141+ if (depth !== undefined || since !== undefined || clientShallow.size) {
142+ out.write(pkt("shallow-info\n"));
143+ for (const oid of plan.shallow) out.write(pkt(`shallow ${oid}\n`));
144+ for (const oid of plan.unshallow) out.write(pkt(`unshallow ${oid}\n`));
145+ out.write(DELIM);
146+ }
133147 out.write(pkt("packfile\n"));
134- const oids = await objectsForFetch(ctx.store, wants, common);
148+ const oids = plan.oids;
135149 if (!noProgress) sideband(2, utf8(`Enumerating objects: ${oids.length}, done.\n`), out);
136150 const entries = [];
137151 for (const oid of oids) {
modifiedapps/diffinery/src/index.ts+2−194View file
@@ -1,207 +1,15 @@
1-import { Hono } from "hono";
21 import type { DiffineryEvent } from "@diffinery/shared";
3-import { cronMatches } from "@diffinery/workflow-yaml";
42 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";
3+import { app, queue, scheduled } from "./app.js";
134 import { web } from "./web/routes.js";
145
156 export { RepoDO } from "./repo/RepoDO.js";
167 export { RunnerContainer, SandboxContainer } from "./runner/containers.js";
178 export { RunWorkflow } from "./runner/workflow.js";
189
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-
10+// The web UI is mounted last so API, git and runner routes take precedence.
6811 app.route("/", web);
6912
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-
20513 export default {
20614 fetch: app.fetch,
20715 queue,
modifiedapps/diffinery/src/web/account.tsx+14−17View file
@@ -5,10 +5,10 @@
55 import type { Account, Repo, TokenScope } from "../lib/directory.js";
66 import { loadRepo } from "../http/common.js";
77 import { HttpError } from "../lib/util.js";
8-import { ROLE_LEVEL, type Role } from "../repo/types.js";
8+import type { Role } from "../repo/types.js";
99 import { ICONS } from "./assets.js";
1010 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";
11+import { Actor, Avatar, CopyButton, Form, ROLES, Time, act, ago, post, publicUrl, render, repoUrl, type Stub, type WebApp, prm } from "./ui.js";
1212
1313 // ---------------------------------------------------------------- shared bits
1414
@@ -381,7 +381,7 @@
381381 web.get("/notifications/:id", async (c) => {
382382 const me = requireAccount(c);
383383 const dir = c.get("dir");
384- const id = c.req.param("id");
384+ const id = prm(c, "id");
385385 const n = (await dir.notifications(me.id)).find((x) => x.id === id);
386386 if (!n) throw new HttpError(404, "notification not found");
387387 await dir.markRead(me.id, id);
@@ -413,7 +413,7 @@
413413 <p class="muted">
414414 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.
415415 </p>
416- <Form action={`/invite/${encodeURIComponent(c.req.param("token"))}`} t={csrfToken(c)}>
416+ <Form action={`/invite/${encodeURIComponent(prm(c, "token"))}`} t={csrfToken(c)}>
417417 <button class="btn primary">Accept invite</button>
418418 </Form>
419419 </div>
@@ -426,7 +426,7 @@
426426 if (p.type !== "account") throw new HttpError(401, "sign in to accept this invite");
427427 if (p.account.status === "disabled") throw new HttpError(403, "your account is disabled");
428428 const dir = c.get("dir");
429- const res = await dir.acceptInvite(c.req.param("token"), p.account);
429+ const res = await dir.acceptInvite(prm(c, "token"), p.account);
430430 let to = "/";
431431 if (res.repoId) {
432432 const r = await dir.repoById(res.repoId);
@@ -546,7 +546,7 @@
546546 How to use it
547547 </summary>
548548 <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/...`}
549+ {`# Git over HTTPS: use your username and this token as the password\ngit clone ${publicUrl(c)}/OWNER/REPO.git\n\n# Remember it (or use your OS keychain helper)\ngit config --global credential.helper store\n\n# HTTP APIs and MCP clients\nAuthorization: Bearer <token>`}
550550 </pre>
551551 </details>
552552 </div>
@@ -737,8 +737,8 @@
737737 c,
738738 "/settings/tokens",
739739 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") }));
740+ await c.get("dir").revokeToken(me.id, prm(c, "id"));
741+ c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "token.revoke", target: prm(c, "id") }));
742742 },
743743 "Token revoked.",
744744 );
@@ -894,7 +894,7 @@
894894 });
895895
896896 const loadAgent = async (c: AppContext, me: Account): Promise<Account> => {
897- const agent = await c.get("dir").account(c.req.param("id") ?? "");
897+ const agent = await c.get("dir").account(prm(c, "id"));
898898 if (!agent || !(await canManageAgent(c, me, agent))) throw new HttpError(404, "agent not found");
899899 return agent;
900900 };
@@ -912,7 +912,7 @@
912912 post(web, "/settings/agents/:id/tokens/:tid/revoke", async (c) => {
913913 const me = requireAccount(c);
914914 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.");
915+ return act(c, "/settings/agents", async () => c.get("dir").revokeToken(agent.id, prm(c, "tid")), "Token revoked.");
916916 });
917917
918918 post(web, "/settings/agents/:id/status", async (c, f) => {
@@ -1120,7 +1120,7 @@
11201120 "/settings/admin",
11211121 async () => {
11221122 if (!status) throw new HttpError(422, "invalid status");
1123- const target = await dir.account(c.req.param("id"));
1123+ const target = await dir.account(prm(c, "id"));
11241124 if (!target || target.kind !== "user") throw new HttpError(404, "user not found");
11251125 if (target.id === me.id) throw new HttpError(422, "you cannot change your own status");
11261126 await dir.setStatus(target.id, status);
@@ -1197,7 +1197,7 @@
11971197 const orgContext = async (c: AppContext) => {
11981198 const me = requireAccount(c);
11991199 const dir = c.get("dir");
1200- const org = await dir.accountByLogin(c.req.param("owner") ?? "");
1200+ const org = await dir.accountByLogin(prm(c, "owner"));
12011201 if (!org) throw new HttpError(404, "not found");
12021202 if (org.kind !== "org") throw new HttpError(404, "not an organization");
12031203 const role = await dir.orgRole(org.id, me.id);
@@ -1210,9 +1210,9 @@
12101210 const me = requireAccount(c);
12111211 const dir = c.get("dir");
12121212 const t = csrfToken(c);
1213- const acct = await dir.accountByLogin(c.req.param("owner") ?? "");
1213+ const acct = await dir.accountByLogin(prm(c, "owner"));
12141214 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);
1215+ if (acct.login !== prm(c, "owner")) return c.redirect(`/${acct.login}${new URL(c.req.url).search}`, 301);
12161216
12171217 if (acct.kind === "agent") {
12181218 const owner = acct.owner_id ? await dir.account(acct.owner_id) : null;
@@ -1615,6 +1615,3 @@
16151615 }, "Organization updated.");
16161616 });
16171617 }
1618-
1619-/** Role helper for other modules. */
1620-export const roleRank = (r: Role): number => ROLE_LEVEL[r];
addedapps/diffinery/src/web/actions.tsx+578−0View file
@@ -1,0 +1,578 @@
1+/** @jsxImportSource hono/jsx */
2+import { parseWorkflow, type DispatchInput } from "@diffinery/workflow-yaml";
3+import { raw } from "hono/html";
4+import type { FC } from "hono/jsx";
5+import type { AppContext } from "../auth/principal.js";
6+import { loadRepo } from "../http/common.js";
7+import { HttpError } from "../lib/util.js";
8+import type { JobInfo, RunInfo } from "../repo/actions.js";
9+import { atLeast } from "../repo/types.js";
10+import { ICONS } from "./assets.js";
11+import { csrfToken } from "./csrf.js";
12+import { Actor, Form, Markdown, Pager, Status, Time, act, duration, encPath, fmtBytes, pageNum, post, qs, refNames, render, repoUrl, shortSha, type WebApp, prm } from "./ui.js";
13+
14+type Loaded = Awaited<ReturnType<typeof loadRepo>>;
15+type LogLine = { seq: number; t: string; step: number; text: string };
16+
17+const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g;
18+const MAX_LOG_LINES = 20000;
19+
20+const refLabel = (ref: string): string => ref.replace(/^refs\/(heads|tags)\//, "");
21+
22+function runDuration(r: Pick<RunInfo, "status" | "createdAt" | "updatedAt">): string {
23+ return duration(r.createdAt, r.status === "completed" ? r.updatedAt : null);
24+}
25+
26+function matrixLabel(m: Record<string, unknown>): string {
27+ const entries = Object.entries(m ?? {});
28+ if (!entries.length) return "";
29+ return entries.map(([k, v]) => `${k}: ${typeof v === "object" ? JSON.stringify(v) : String(v)}`).join(", ");
30+}
31+
32+const RunRow: FC<{ base: string; r: RunInfo }> = ({ base, r }) => (
33+ <li>
34+ <Status status={r.status} conclusion={r.conclusion} />
35+ <div class="grow">
36+ <a class="title" href={`${base}/actions/runs/${r.id}`}>
37+ {r.title || r.workflowName}
38+ </a>
39+ <div class="meta">
40+ <b>{r.workflowName}</b> #{r.number}
41+ {r.attempt > 1 ? ` (attempt ${r.attempt})` : ""}: {r.event.replace(/_/g, " ")}
42+ {r.prNumber ? (
43+ <>
44+ {" "}
45+ for <a href={`${base}/pull/${r.prNumber}`}>#{r.prNumber}</a>
46+ </>
47+ ) : null}{" "}
48+ by {r.actor.login}
49+ {r.actor.kind === "agent" ? <span class="badge agent" style="margin-left:4px">agent</span> : null}
50+ </div>
51+ </div>
52+ <span class="badge mono nowrap" title={r.ref}>
53+ {refLabel(r.ref)}
54+ </span>
55+ <div class="right small muted nowrap" style="min-width:110px">
56+ <div>
57+ <Time at={r.createdAt} />
58+ </div>
59+ <div>{runDuration(r)}</div>
60+ </div>
61+ </li>
62+);
63+
64+/** Parse workflow_dispatch input definitions from the workflow file on the default branch. */
65+async function dispatchInputs(l: Loaded, path: string): Promise<Record<string, DispatchInput> | null> {
66+ try {
67+ const blob = await l.stub.blob(l.repo.default_branch, path);
68+ if (!blob || blob.binary) return null;
69+ const { workflow } = parseWorkflow(new TextDecoder().decode(blob.content), path);
70+ const d = workflow?.on.workflow_dispatch;
71+ return d ? (d.inputs ?? {}) : null;
72+ } catch {
73+ return null;
74+ }
75+}
76+
77+function classify(text: string): { cls: string; text: string } {
78+ const t = text.replace(ANSI, "");
79+ if (t.startsWith("::group::")) return { cls: "grp", text: `▾ ${t.slice(9)}` };
80+ if (t.startsWith("::endgroup::")) return { cls: "grp", text: "" };
81+ if (/^(##\[error\]|::error)/.test(t)) return { cls: "err", text: t };
82+ if (/^(##\[warning\]|::warning)/.test(t)) return { cls: "wrn", text: t };
83+ return { cls: "", text: t };
84+}
85+
86+async function allLogs(l: Loaded, jobId: string): Promise<{ lines: LogLine[]; complete: boolean; truncated: boolean }> {
87+ const out: LogLine[] = [];
88+ let after = -1;
89+ let complete = false;
90+ for (let i = 0; i < 5; i++) {
91+ const page = await l.stub.jobLogs(jobId, after);
92+ complete = page.complete;
93+ out.push(...page.lines);
94+ if (page.lines.length < 5000 || out.length >= MAX_LOG_LINES) break;
95+ after = page.lines[page.lines.length - 1]!.seq;
96+ }
97+ return { lines: out.slice(0, MAX_LOG_LINES), complete, truncated: out.length >= MAX_LOG_LINES };
98+}
99+
100+export function registerActions(web: WebApp): void {
101+ web.get("/:owner/:repo/actions", async (c) => {
102+ const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
103+ const { repo, caller, stub } = l;
104+ const base = repoUrl(repo);
105+ const workflow = c.req.query("workflow") ?? "";
106+ const branch = c.req.query("branch") ?? "";
107+ const status = c.req.query("status") ?? "";
108+ const page = pageNum(c);
109+ const per = 25;
110+ const [runs, workflows, names] = await Promise.all([
111+ stub.runs({ workflow: workflow || undefined, branch: branch || undefined, status: status || undefined, limit: per, offset: (page - 1) * per }),
112+ stub.workflows().catch(() => [] as Array<{ path: string; name: string; errors: string[]; triggers: string[] }>),
113+ refNames(stub),
114+ ]);
115+ const selected = workflow ? workflows.find((w) => w.path === workflow || w.path.endsWith(`/${workflow}`)) : undefined;
116+ const canWrite = atLeast(caller.role, "write") && c.get("principal").type === "account" && !repo.archived;
117+ const inputs = selected && canWrite && selected.triggers.includes("workflow_dispatch") ? await dispatchInputs(l, selected.path) : null;
118+ const t = csrfToken(c);
119+ const href = (p: Record<string, string | number | undefined>) => `${base}/actions${qs({ workflow: workflow || undefined, branch: branch || undefined, status: status || undefined, ...p })}`;
120+ return render(
121+ c,
122+ { title: `Actions · ${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "actions" }, live: "runs" },
123+ <div class="grid left">
124+ <aside class="side">
125+ <section>
126+ <h3>Workflows</h3>
127+ <div class="nav">
128+ <a href={`${base}/actions`} class={!workflow ? "on" : ""}>
129+ All workflows
130+ </a>
131+ {workflows.map((w) => (
132+ <a href={`${base}/actions${qs({ workflow: w.path })}`} class={selected?.path === w.path ? "on" : ""} title={w.path}>
133+ {w.errors.length ? <span class="bad">{raw(ICONS.failure)}</span> : null} {w.name}
134+ </a>
135+ ))}
136+ </div>
137+ {workflows.length === 0 ? (
138+ <p class="muted small">
139+ No workflows on <span class="mono">{repo.default_branch}</span>. Add a YAML file under <code>.github/workflows/</code>.
140+ </p>
141+ ) : null}
142+ </section>
143+ <section>
144+ <h3>Help</h3>
145+ <p class="small muted m0">Workflows use GitHub Actions syntax. Jobs run on Linux containers; secrets and variables are configured in repository settings.</p>
146+ </section>
147+ </aside>
148+ <div>
149+ <div class="row between mb">
150+ <h1 class="m0">{selected ? selected.name : "All workflows"}</h1>
151+ {selected ? <span class="mono muted small">{selected.path}</span> : null}
152+ </div>
153+ {selected?.errors.length ? (
154+ <div class="failure">
155+ <b>This workflow file has errors</b>
156+ <pre>{selected.errors.join("\n")}</pre>
157+ </div>
158+ ) : null}
159+ {!selected && workflows.some((w) => w.errors.length) ? (
160+ <div class="failure">
161+ <b>Some workflow files have errors</b>
162+ <pre>{workflows.filter((w) => w.errors.length).map((w) => `${w.path}\n ${w.errors.join("\n ")}`).join("\n")}</pre>
163+ </div>
164+ ) : null}
165+ {selected && inputs ? (
166+ <details class="box">
167+ <summary class="head" style="cursor:pointer">
168+ {raw(ICONS.play)} <b>Run workflow</b>
169+ <span class="muted small">This workflow has a workflow_dispatch trigger.</span>
170+ </summary>
171+ <Form action={`${base}/actions/dispatch`} t={t} class="body">
172+ <input type="hidden" name="workflow" value={selected.path} />
173+ <div class="field" style="max-width:320px">
174+ <label>Use workflow from</label>
175+ <select name="ref">
176+ {names.branches.map((b) => (
177+ <option value={b} selected={b === repo.default_branch}>
178+ {b}
179+ </option>
180+ ))}
181+ {names.tags.map((tg) => (
182+ <option value={tg}>tag: {tg}</option>
183+ ))}
184+ </select>
185+ </div>
186+ {Object.entries(inputs).map(([name, d]) => (
187+ <div class="field" style="max-width:520px">
188+ {d.type === "boolean" ? (
189+ <>
190+ <input type="hidden" name={`in_${name}`} value="false" />
191+ <label class="inline">
192+ <input type="checkbox" name={`in_${name}`} value="true" checked={d.default === true || d.default === "true"} /> {d.description ?? name}
193+ </label>
194+ </>
195+ ) : (
196+ <>
197+ <label>
198+ {d.description ?? name} {d.required ? <span class="bad">*</span> : null}
199+ </label>
200+ {d.type === "choice" && d.options?.length ? (
201+ <select name={`in_${name}`}>
202+ {d.options.map((o) => (
203+ <option value={o} selected={String(d.default ?? "") === o}>
204+ {o}
205+ </option>
206+ ))}
207+ </select>
208+ ) : (
209+ <input type={d.type === "number" ? "number" : "text"} name={`in_${name}`} value={d.default === undefined || d.default === null ? "" : String(d.default)} required={!!d.required} />
210+ )}
211+ </>
212+ )}
213+ {d.description && d.type !== "boolean" ? <div class="hint mono">{name}</div> : null}
214+ </div>
215+ ))}
216+ <button class="btn green">{raw(ICONS.play)} Run workflow</button>
217+ </Form>
218+ </details>
219+ ) : null}
220+ <div class="box" data-fragment="runs">
221+ <div class="head">
222+ <b>
223+ {runs.total} workflow run{runs.total === 1 ? "" : "s"}
224+ </b>
225+ <span class="grow" />
226+ <form method="get" action={`${base}/actions`} class="row" style="gap:6px">
227+ {workflow ? <input type="hidden" name="workflow" value={workflow} /> : null}
228+ <select name="branch" style="width:auto;padding:3px 6px">
229+ <option value="">All branches</option>
230+ {names.branches.map((b) => (
231+ <option value={b} selected={b === branch}>
232+ {b}
233+ </option>
234+ ))}
235+ </select>
236+ <select name="status" style="width:auto;padding:3px 6px">
237+ <option value="">Any status</option>
238+ {["queued", "in_progress", "completed", "success", "failure", "cancelled"].map((s) => (
239+ <option value={s} selected={s === status}>
240+ {s.replace("_", " ")}
241+ </option>
242+ ))}
243+ </select>
244+ <button class="btn sm">Filter</button>
245+ </form>
246+ </div>
247+ {runs.runs.length ? (
248+ <ul class="list">
249+ {runs.runs.map((r) => (
250+ <RunRow base={base} r={r} />
251+ ))}
252+ </ul>
253+ ) : (
254+ <div class="empty">{workflow || branch || status ? "No runs match these filters." : "No workflow runs yet. Push a commit with a workflow file to start one."}</div>
255+ )}
256+ </div>
257+ <Pager page={page} hasNext={page * per < runs.total} href={(p) => href({ page: p > 1 ? p : undefined })} />
258+ </div>
259+ </div>,
260+ );
261+ });
262+
263+ post(web, "/:owner/:repo/actions/dispatch", async (c, f) => {
264+ const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write");
265+ if (c.get("principal").type !== "account") throw new HttpError(401, "sign in required");
266+ const base = repoUrl(l.repo);
267+ const workflow = f.s("workflow");
268+ const inputs: Record<string, unknown> = {};
269+ for (const key of f.keys()) if (key.startsWith("in_")) inputs[key.slice(3)] = f.all(key).pop();
270+ return act(c, `${base}/actions${qs({ workflow })}`, async () => {
271+ const run = await l.stub.dispatchWorkflow(workflow, f.s("ref") || l.repo.default_branch, inputs, l.caller);
272+ return `${base}/actions/runs/${run.id}`;
273+ });
274+ });
275+
276+ web.get("/:owner/:repo/actions/runs/:runId", async (c) => {
277+ const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
278+ const { repo, caller, stub } = l;
279+ const base = repoUrl(repo);
280+ const runId = prm(c, "runId");
281+ const run = await stub.run(runId);
282+ const artifacts = await stub.artifacts(runId).catch(() => [] as Array<{ name: string; size: number; createdAt: string }>);
283+ const groups = new Map<string, JobInfo[]>();
284+ for (const j of run.jobs) groups.set(j.key, [...(groups.get(j.key) ?? []), j]);
285+ const canWrite = atLeast(caller.role, "write") && c.get("principal").type === "account" && !repo.archived;
286+ const t = csrfToken(c);
287+ const behalf = run.actor.onBehalfOf;
288+ return render(
289+ c,
290+ { title: `${run.title || run.workflowName} · Run #${run.number}`, repo: { repo, caller, tab: "actions" }, live: run.status !== "completed" ? `run:${run.id}` : undefined },
291+ <div data-fragment="run">
292+ <div class="row between mb" style="align-items:flex-start">
293+ <div class="grow">
294+ <div class="muted small">
295+ <a href={`${base}/actions${qs({ workflow: run.workflowPath })}`}>{run.workflowName}</a> · run #{run.number}
296+ {run.attempt > 1 ? ` · attempt ${run.attempt}` : ""}
297+ </div>
298+ <h1 class="row" style="gap:8px;margin-top:4px">
299+ <Status status={run.status} conclusion={run.conclusion} /> {run.title || run.workflowName}
300+ </h1>
301+ </div>
302+ <div class="row">
303+ {canWrite && run.status !== "completed" ? (
304+ <Form action={`${base}/actions/runs/${run.id}/cancel`} t={t} class="inline" confirm="Cancel this run?">
305+ <button class="btn danger">{raw(ICONS.cancelled)} Cancel run</button>
306+ </Form>
307+ ) : null}
308+ {canWrite && run.status === "completed" ? (
309+ <Form action={`${base}/actions/runs/${run.id}/rerun`} t={t} class="inline">
310+ <button class="btn">{raw(ICONS.history)} Re-run all jobs</button>
311+ </Form>
312+ ) : null}
313+ </div>
314+ </div>
315+ <div class="box">
316+ <div class="body">
317+ <dl class="kv">
318+ <dt>Status</dt>
319+ <dd>
320+ <b>{run.status === "completed" ? (run.conclusion ?? "completed") : run.status.replace("_", " ")}</b>
321+ {run.status === "completed" ? <span class="muted"> in {runDuration(run)}</span> : <span class="muted"> for {runDuration(run)}</span>}
322+ </dd>
323+ <dt>Triggered</dt>
324+ <dd>
325+ {run.event.replace(/_/g, " ")} by <Actor login={run.actor.login} kind={run.actor.kind} onBehalfOf={behalf} avatar={false} /> <Time at={run.createdAt} />
326+ </dd>
327+ <dt>Commit</dt>
328+ <dd>
329+ <a class="sha" href={`${base}/commit/${run.sha}`}>
330+ {shortSha(run.sha)}
331+ </a>{" "}
332+ on{" "}
333+ <a class="mono" href={`${base}/tree/${encPath(refLabel(run.ref))}`}>
334+ {refLabel(run.ref)}
335+ </a>
336+ {run.prNumber ? (
337+ <>
338+ {" "}
339+ · pull request <a href={`${base}/pull/${run.prNumber}`}>#{run.prNumber}</a>
340+ </>
341+ ) : null}
342+ </dd>
343+ <dt>Workflow file</dt>
344+ <dd>
345+ <a class="mono" href={`${base}/blob/${run.sha}/${encPath(run.workflowPath)}`}>
346+ {run.workflowPath}
347+ </a>
348+ </dd>
349+ </dl>
350+ </div>
351+ </div>
352+ {[...groups.entries()].map(([key, jobs]) => (
353+ <div class="box jobgroup">
354+ <div class="head">
355+ <b class="mono">{key}</b>
356+ {jobs.length > 1 ? <span class="muted small">{jobs.length} jobs (matrix)</span> : null}
357+ </div>
358+ {jobs.map((j) => (
359+ <a class="job" href={`${base}/actions/runs/${run.id}/job/${j.id}`}>
360+ <Status status={j.status} conclusion={j.conclusion} />
361+ <div class="grow">
362+ <b>{j.name}</b>
363+ {matrixLabel(j.matrix) && !j.name.includes("(") ? <span class="muted small"> ({matrixLabel(j.matrix)})</span> : null}
364+ {j.failure ? <div class="small bad">{j.failure.message.split("\n")[0]}</div> : null}
365+ </div>
366+ <span class="muted small nowrap">{j.startedAt ? duration(j.startedAt, j.completedAt) : j.status}</span>
367+ </a>
368+ ))}
369+ </div>
370+ ))}
371+ {run.jobs.length === 0 ? <div class="blank mb">{run.status === "completed" ? "This run has no jobs." : "Waiting for jobs to be scheduled…"}</div> : null}
372+ {artifacts.length ? (
373+ <div class="box">
374+ <div class="head">
375+ <h3>{raw(ICONS.download)} Artifacts</h3>
376+ </div>
377+ <ul class="list">
378+ {artifacts.map((a) => (
379+ <li>
380+ <div class="grow">
381+ <a class="title" href={`${base}/actions/runs/${run.id}/artifacts/${encodeURIComponent(a.name)}`}>
382+ {a.name}
383+ </a>
384+ <div class="meta">
385+ {fmtBytes(a.size)} · <Time at={a.createdAt} />
386+ </div>
387+ </div>
388+ </li>
389+ ))}
390+ </ul>
391+ </div>
392+ ) : null}
393+ </div>,
394+ );
395+ });
396+
397+ post(web, "/:owner/:repo/actions/runs/:runId/cancel", async (c) => {
398+ const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write");
399+ const back = `${repoUrl(l.repo)}/actions/runs/${prm(c, "runId")}`;
400+ return act(c, back, async () => l.stub.cancelRun(prm(c, "runId"), l.caller), "Cancellation requested.");
401+ });
402+
403+ post(web, "/:owner/:repo/actions/runs/:runId/rerun", async (c) => {
404+ const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write");
405+ const base = repoUrl(l.repo);
406+ return act(c, `${base}/actions/runs/${prm(c, "runId")}`, async () => {
407+ const run = await l.stub.rerun(prm(c, "runId"), l.caller);
408+ return `${base}/actions/runs/${run.id}`;
409+ });
410+ });
411+
412+ web.get("/:owner/:repo/actions/runs/:runId/job/:jobId", async (c) => {
413+ const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
414+ const { repo, caller, stub } = l;
415+ const base = repoUrl(repo);
416+ const runId = prm(c, "runId");
417+ const jobId = prm(c, "jobId");
418+ const run = await stub.run(runId);
419+ const job = await stub.job(jobId);
420+ if (job.runId !== run.id) throw new HttpError(404, "job not found");
421+ const logs = await allLogs(l, jobId);
422+ const byStep = new Map<number, LogLine[]>();
423+ for (const line of logs.lines) byStep.set(line.step, [...(byStep.get(line.step) ?? []), line]);
424+ const stepIdx = new Set<number>([...job.steps.map((s) => s.index), ...byStep.keys()]);
425+ const sections = [...stepIdx].sort((a, b) => a - b);
426+ const stepOf = new Map(job.steps.map((s) => [s.index, s]));
427+ const lastSeq = logs.lines.length ? logs.lines[logs.lines.length - 1]!.seq : -1;
428+ const live = job.status !== "completed";
429+ const failedStep = job.failure?.step;
430+ return render(
431+ c,
432+ { title: `${job.name} · ${run.title || run.workflowName}`, repo: { repo, caller, tab: "actions" }, live: live ? `job:${job.id},run:${run.id}` : undefined, liveMode: "log", lastSeq, wide: true },
433+ <div class="grid left">
434+ <aside class="side">
435+ <section>
436+ <a href={`${base}/actions/runs/${run.id}`} class="small">
437+ ← {run.workflowName} #{run.number}
438+ </a>
439+ <h3 class="mt">Jobs</h3>
440+ <div class="nav">
441+ {run.jobs.map((j) => (
442+ <a href={`${base}/actions/runs/${run.id}/job/${j.id}`} class={j.id === job.id ? "on" : ""} style="display:flex;gap:6px;align-items:center">
443+ <Status status={j.status} conclusion={j.conclusion} /> <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{j.name}</span>
444+ </a>
445+ ))}
446+ </div>
447+ </section>
448+ </aside>
449+ <div>
450+ <div class="row between mb">
451+ <h1 class="row m0" style="gap:8px">
452+ <Status status={job.status} conclusion={job.conclusion} /> {job.name}
453+ </h1>
454+ <div class="row">
455+ <span class="muted small">
456+ {job.startedAt ? (
457+ <>
458+ {job.status === "completed" ? "took" : "running for"} {duration(job.startedAt, job.completedAt)}
459+ </>
460+ ) : (
461+ job.status
462+ )}
463+ </span>
464+ <a class="btn sm" href={`${base}/actions/runs/${run.id}/job/${job.id}/logs`}>
465+ {raw(ICONS.download)} Raw logs
466+ </a>
467+ </div>
468+ </div>
469+ {matrixLabel(job.matrix) ? <p class="muted small">Matrix: {matrixLabel(job.matrix)}</p> : null}
470+ {job.failure ? (
471+ <div class="failure">
472+ <b>
473+ {raw(ICONS.failure)} {job.conclusion === "cancelled" ? "Cancelled" : job.conclusion === "timed_out" ? "Timed out" : "Failed"}
474+ {failedStep !== undefined && failedStep !== null ? (
475+ <>
476+ {" "}
477+ at{" "}
478+ <a href={`#step-${failedStep}`} class="bad">
479+ {stepOf.get(failedStep)?.name ?? `step ${failedStep + 1}`}
480+ </a>
481+ </>
482+ ) : null}
483+ </b>
484+ <pre>{job.failure.message}</pre>
485+ </div>
486+ ) : null}
487+ {logs.truncated ? (
488+ <div class="flash info">
489+ Showing the first {MAX_LOG_LINES.toLocaleString("en-US")} lines. <a href={`${base}/actions/runs/${run.id}/job/${job.id}/logs`}>Download the full log</a>.
490+ </div>
491+ ) : null}
492+ <div class="log">
493+ {sections.length === 0 ? <div style="padding:14px;color:#9aa3ad">{live ? "Waiting for the runner to start…" : "No log output."}</div> : null}
494+ {sections.map((idx) => {
495+ const s = stepOf.get(idx);
496+ const lines = byStep.get(idx) ?? [];
497+ const failed = s?.conclusion === "failure" || failedStep === idx;
498+ const running = s?.status === "in_progress";
499+ return (
500+ <details data-step={String(idx)} id={`step-${idx}`} open={failed || running || (live && idx === sections[sections.length - 1])}>
501+ <summary>
502+ {s ? (
503+ <Status status={s.status} conclusion={s.conclusion} />
504+ ) : idx < 0 ? (
505+ <Status conclusion={job.startedAt ? "success" : "queued"} />
506+ ) : (
507+ <Status conclusion="neutral" />
508+ )}
509+ {idx < 0 ? "Set up job" : (s?.name ?? `Step ${idx + 1}`)}
510+ <span class="dur">{s?.startedAt ? duration(s.startedAt, s.completedAt) : ""}</span>
511+ </summary>
512+ <pre>
513+ {lines.map((ln) => {
514+ const k = classify(ln.text);
515+ return <span class={k.cls || undefined}>{k.text}</span>;
516+ })}
517+ </pre>
518+ </details>
519+ );
520+ })}
521+ </div>
522+ {job.summary ? (
523+ <div class="box">
524+ <div class="head">
525+ <h3>Job summary</h3>
526+ </div>
527+ <div class="body">
528+ <Markdown text={job.summary} />
529+ </div>
530+ </div>
531+ ) : null}
532+ {Object.keys(job.outputs ?? {}).length ? (
533+ <div class="box">
534+ <div class="head">
535+ <h3>Outputs</h3>
536+ </div>
537+ <div class="body">
538+ <dl class="kv">
539+ {Object.entries(job.outputs).map(([k, v]) => (
540+ <>
541+ <dt class="mono">{k}</dt>
542+ <dd class="mono" style="word-break:break-all">
543+ {v}
544+ </dd>
545+ </>
546+ ))}
547+ </dl>
548+ </div>
549+ </div>
550+ ) : null}
551+ </div>
552+ </div>,
553+ );
554+ });
555+
556+ web.get("/:owner/:repo/actions/runs/:runId/job/:jobId/logs", async (c) => {
557+ const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
558+ const job = await l.stub.job(prm(c, "jobId"));
559+ if (job.runId !== prm(c, "runId")) throw new HttpError(404, "job not found");
560+ const out: string[] = [];
561+ let after = -1;
562+ for (let i = 0; i < 40; i++) {
563+ const page = await l.stub.jobLogs(job.id, after);
564+ for (const ln of page.lines) out.push(`${ln.t} ${ln.text.replace(ANSI, "")}`);
565+ if (page.lines.length < 5000) break;
566+ after = page.lines[page.lines.length - 1]!.seq;
567+ }
568+ return new Response(`${out.join("\n")}\n`, {
569+ headers: {
570+ "content-type": "text/plain; charset=utf-8",
571+ "x-content-type-options": "nosniff",
572+ "content-security-policy": "sandbox",
573+ "content-disposition": `inline; filename*=UTF-8''${encodeURIComponent(`${job.name}.log`)}`,
574+ "cache-control": "private, no-store",
575+ },
576+ });
577+ });
578+}
addedapps/diffinery/src/web/code.tsx+1107−0View file
@@ -1,0 +1,1107 @@
1+/** @jsxImportSource hono/jsx */
2+import { raw } from "hono/html";
3+import type { Child, FC } from "hono/jsx";
4+import type { AppContext } from "../auth/principal.js";
5+import { loadRepo } from "../http/common.js";
6+import { HttpError } from "../lib/util.js";
7+import { atLeast, globToRegExp } from "../repo/types.js";
8+import { ICONS } from "./assets.js";
9+import { csrfToken, type FormData } from "./csrf.js";
10+import {
11+ CommitRow,
12+ CopyButton,
13+ DiffStats,
14+ DiffView,
15+ FileSummary,
16+ Form,
17+ Markdown,
18+ Pager,
19+ RefSwitcher,
20+ Time,
21+ act,
22+ cloneUrl,
23+ encPath,
24+ firstLine,
25+ fmtBytes,
26+ pageNum,
27+ post,
28+ qs,
29+ refNames,
30+ render,
31+ repoUrl,
32+ restOfPath,
33+ shortSha,
34+ splitRefPath,
35+ statusOf,
36+ Status,
37+ type DiffFile,
38+ type WebApp,
39+ prm,
40+} from "./ui.js";
41+
42+type Loaded = Awaited<ReturnType<typeof loadRepo>>;
43+
44+const IMAGE_TYPES: Record<string, string> = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", avif: "image/avif", ico: "image/x-icon", bmp: "image/bmp", svg: "image/svg+xml" };
45+const MAX_RENDER = 1024 * 1024;
46+
47+const extOf = (path: string): string => (/\.([A-Za-z0-9]+)$/.exec(path)?.[1] ?? "").toLowerCase();
48+const isMarkdown = (path: string): boolean => /\.(md|markdown|mdown|mkd)$/i.test(path);
49+const decoder = () => new TextDecoder();
50+
51+/** Normalise a user-supplied repository path; null when invalid. */
52+function cleanPath(p: string): string | null {
53+ const segs = p.replace(/\\/g, "/").split("/").filter((s) => s && s !== ".");
54+ if (!segs.length || segs.some((s) => s === ".." || s === ".git")) return null;
55+ return segs.join("/");
56+}
57+
58+const Crumbs: FC<{ base: string; ref: string; path: string; last?: boolean }> = ({ base, ref, path, last }) => {
59+ const segs = path ? path.split("/") : [];
60+ return (
61+ <div class="crumbs">
62+ <a href={`${base}/tree/${encPath(ref)}`}>{base.split("/")[2]}</a>
63+ {segs.map((s, i) => (
64+ <>
65+ <span class="faint">/</span>
66+ {i === segs.length - 1 && last !== false ? <b>{s}</b> : <a href={`${base}/tree/${encPath(ref)}/${encPath(segs.slice(0, i + 1).join("/"))}`}>{s}</a>}
67+ </>
68+ ))}
69+ </div>
70+ );
71+};
72+
73+const CloneBox: FC<{ url: string }> = ({ url }) => (
74+ <div class="clone">
75+ <input type="text" readonly value={url} aria-label="Clone URL" />
76+ <CopyButton text={url} />
77+ </div>
78+);
79+
80+const QuickSetup: FC<{ c: AppContext; l: Loaded }> = ({ c, l }) => {
81+ const url = cloneUrl(c, l.repo);
82+ const b = l.repo.default_branch;
83+ const base = repoUrl(l.repo);
84+ const canWrite = atLeast(l.caller.role, "write");
85+ return (
86+ <div style="max-width:900px">
87+ <div class="box">
88+ <div class="head">
89+ <h2>Quick setup</h2>
90+ <span class="muted small">This repository is empty.</span>
91+ </div>
92+ <div class="body">
93+ <CloneBox url={url} />
94+ {canWrite ? (
95+ <p class="mt muted">
96+ Get started by <a href={`${base}/new/${encPath(b)}?filename=README.md`}>creating a README on the web</a>, or push an existing repository from the command line.
97+ </p>
98+ ) : null}
99+ <h3 class="mt">…create a new repository on the command line</h3>
100+ <pre class="cmd">{`echo "# ${l.repo.name}" >> README.md\ngit init -b ${b}\ngit add README.md\ngit commit -m "first commit"\ngit remote add origin ${url}\ngit push -u origin ${b}`}</pre>
101+ <h3>…or push an existing repository</h3>
102+ <pre class="cmd">{`git remote add origin ${url}\ngit push -u origin ${b}`}</pre>
103+ <h3>Authentication</h3>
104+ <p class="muted">
105+ When Git asks for credentials, use your username and a <a href="/settings/tokens">personal access token</a> as the password. To avoid typing it every time, run <code>git config --global credential.helper store</code> (or use
106+ your OS keychain helper). Git 2.26 or newer is required.
107+ </p>
108+ <p class="muted m0">
109+ Workflows in <code>.github/workflows/*.yml</code> run automatically on push. AI agents should use their own <a href="/settings/agents">agent account and token</a>.
110+ </p>
111+ </div>
112+ </div>
113+ </div>
114+ );
115+};
116+
117+async function treePage(c: AppContext, l: Loaded, ref: string, path: string, root: boolean): Promise<Response> {
118+ const { repo, caller, stub } = l;
119+ const base = repoUrl(repo);
120+ const [tree, names, stats, last] = await Promise.all([
121+ stub.tree(ref, path),
122+ refNames(stub),
123+ root && !path ? stub.stats() : Promise.resolve(null),
124+ !path ? stub.log(ref, { limit: 1 }).catch(() => []) : Promise.resolve([]),
125+ ]);
126+ const counts = stats ? { issues: stats.openIssues, pulls: stats.openPulls } : undefined;
127+ if (!tree) {
128+ if (!names.branches.length && !names.tags.length) {
129+ return render(c, { title: `${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "code", counts } }, <QuickSetup c={c} l={l} />);
130+ }
131+ const blob = path ? await stub.blob(ref, path).catch(() => null) : null;
132+ if (blob) return c.redirect(`${base}/blob/${encPath(ref)}/${encPath(path)}`, 302);
133+ throw new HttpError(404, "path not found");
134+ }
135+ const readme = tree.items.find((i) => i.type === "blob" && /^readme(\.(md|markdown|txt))?$/i.test(i.name));
136+ const readmeBlob = readme && (readme.size ?? 0) < MAX_RENDER ? await stub.blob(ref, readme.path).catch(() => null) : null;
137+ const head = last[0];
138+ const canWrite = atLeast(caller.role, "write") && names.branches.includes(ref);
139+ const main = (
140+ <div>
141+ <div class="row between mb">
142+ <div class="row">
143+ <RefSwitcher current={ref} branches={names.branches} tags={names.tags} to={(r) => `${base}/tree/${encPath(r)}${path ? `/${encPath(path)}` : ""}`} />
144+ {path ? <Crumbs base={base} ref={ref} path={path} /> : null}
145+ {!path ? (
146+ <span class="muted small">
147+ <a href={`${base}/branches`}>
148+ {raw(ICONS.branch)} {names.branches.length} branch{names.branches.length === 1 ? "" : "es"}
149+ </a>{" "}
150+ ·{" "}
151+ <a href={`${base}/tags`}>
152+ {raw(ICONS.tag)} {names.tags.length} tag{names.tags.length === 1 ? "" : "s"}
153+ </a>
154+ </span>
155+ ) : null}
156+ </div>
157+ <div class="row">
158+ {canWrite ? (
159+ <a class="btn sm" href={`${base}/new/${encPath(ref)}${path ? `/${encPath(path)}` : ""}`}>
160+ {raw(ICONS.plus)} Add file
161+ </a>
162+ ) : null}
163+ <a class="btn sm" href={`${base}/commits/${encPath(ref)}${path ? qs({ path }) : ""}`}>
164+ {raw(ICONS.history)} History
165+ </a>
166+ </div>
167+ </div>
168+ <div class="box">
169+ {head ? (
170+ <div class="head small">
171+ <b>{head.author.name}</b>
172+ <a href={`${base}/commit/${head.sha}`} class="grow" style="color:var(--fg);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
173+ {firstLine(head.message)}
174+ </a>
175+ <a class="sha" href={`${base}/commit/${head.sha}`}>
176+ {shortSha(head.sha)}
177+ </a>
178+ <Time at={new Date(head.committer.timestamp * 1000).toISOString()} />
179+ </div>
180+ ) : null}
181+ <table class="files">
182+ {path ? (
183+ <tr>
184+ <td class="ic" />
185+ <td colspan={2}>
186+ <a href={`${base}/tree/${encPath(ref)}${path.includes("/") ? `/${encPath(path.split("/").slice(0, -1).join("/"))}` : ""}`}>..</a>
187+ </td>
188+ </tr>
189+ ) : null}
190+ {tree.items.map((it) => (
191+ <tr>
192+ <td class={`ic ${it.type === "tree" ? "ic-dir" : ""}`}>{raw(it.type === "tree" ? ICONS.dir : it.type === "commit" ? ICONS.submodule : ICONS.file)}</td>
193+ <td>
194+ {it.type === "commit" ? (
195+ <span>
196+ {it.name} <span class="faint mono small">@ {shortSha(it.oid)}</span>
197+ </span>
198+ ) : (
199+ <a href={`${base}/${it.type === "tree" ? "tree" : "blob"}/${encPath(ref)}/${encPath(it.path)}`}>{it.name}</a>
200+ )}
201+ </td>
202+ <td class="sz">{it.type === "blob" ? fmtBytes(it.size) : ""}</td>
203+ </tr>
204+ ))}
205+ </table>
206+ {tree.items.length === 0 ? <div class="empty">This directory is empty.</div> : null}
207+ </div>
208+ {readmeBlob && !readmeBlob.binary ? (
209+ <div class="box readme">
210+ <div class="head">
211+ {raw(ICONS.file)} <b>{readme!.name}</b>
212+ </div>
213+ <div class="body">
214+ {isMarkdown(readme!.name) ? (
215+ <Markdown text={decoder().decode(readmeBlob.content)} base={{ owner: repo.owner_login, repo: repo.name, ref, dir: path }} />
216+ ) : (
217+ <pre style="white-space:pre-wrap;margin:0">{decoder().decode(readmeBlob.content)}</pre>
218+ )}
219+ </div>
220+ </div>
221+ ) : null}
222+ </div>
223+ );
224+ if (path) return render(c, { title: `${path} at ${ref} · ${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "code", ref } }, main);
225+ return render(
226+ c,
227+ { title: `${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "code", ref, counts } },
228+ <div class="grid">
229+ {main}
230+ <aside class="side">
231+ <section>
232+ <h3>Clone</h3>
233+ <CloneBox url={cloneUrl(c, repo)} />
234+ <p class="small muted" style="margin-top:6px">
235+ Use a <a href="/settings/tokens">token</a> as the password.
236+ </p>
237+ </section>
238+ <section>
239+ <h3>About</h3>
240+ <p class={repo.description ? "" : "muted"}>{repo.description || "No description."}</p>
241+ {stats ? (
242+ <dl class="kv">
243+ <dt>Default branch</dt>
244+ <dd class="mono">{repo.default_branch}</dd>
245+ <dt>Open issues</dt>
246+ <dd>
247+ <a href={`${base}/issues`}>{stats.openIssues}</a>
248+ </dd>
249+ <dt>Open pull requests</dt>
250+ <dd>
251+ <a href={`${base}/pulls`}>{stats.openPulls}</a>
252+ </dd>
253+ <dt>Refs</dt>
254+ <dd>{stats.refs}</dd>
255+ <dt>Objects</dt>
256+ <dd>{stats.objects.toLocaleString("en-US")}</dd>
257+ <dt>Size</dt>
258+ <dd>{fmtBytes(stats.bytes)}</dd>
259+ <dt>Last push</dt>
260+ <dd>{repo.pushed_at ? <Time at={repo.pushed_at} /> : "never"}</dd>
261+ </dl>
262+ ) : null}
263+ </section>
264+ <section>
265+ <h3>Shortcuts</h3>
266+ <p class="small muted m0">
267+ <kbd>Ctrl</kbd> <kbd>K</kbd> jump anywhere · <kbd>t</kbd> find a file · <kbd>g</kbd> <kbd>i</kbd> issues · <kbd>g</kbd> <kbd>p</kbd> pull requests · <kbd>g</kbd> <kbd>a</kbd> actions
268+ </p>
269+ </section>
270+ </aside>
271+ </div>,
272+ );
273+}
274+
275+// ---------------------------------------------------------------- editor
276+
277+interface EditorValues {
278+ path: string;
279+ content: string;
280+ message: string;
281+ description: string;
282+ choice: "direct" | "branch";
283+ newBranch: string;
284+ head: string;
285+}
286+
287+const Editor: FC<{ c: AppContext; l: Loaded; ref: string; mode: "edit" | "new"; orig: string; dir: string; v: EditorValues; error?: string; canDirect: boolean }> = ({ c, l, ref, mode, orig, dir, v, error, canDirect }) => {
288+ const base = repoUrl(l.repo);
289+ const t = csrfToken(c);
290+ const action = mode === "edit" ? `${base}/edit/${encPath(ref)}/${encPath(orig)}` : `${base}/new/${encPath(ref)}${dir ? `/${encPath(dir)}` : ""}`;
291+ return (
292+ <div>
293+ {error ? <div class="flash error">{error}</div> : null}
294+ <Form action={action} t={t}>
295+ <input type="hidden" name="head" value={v.head} />
296+ <div class="row mb">
297+ <span class="badge">{raw(ICONS.branch)} {ref}</span>
298+ <div class="grow">
299+ <input type="text" name="path" value={v.path} required placeholder="path/to/file.ext" aria-label="File path" class="mono" />
300+ </div>
301+ {mode === "edit" ? (
302+ <a class="btn sm" href={`${base}/blob/${encPath(ref)}/${encPath(orig)}`}>
303+ Cancel
304+ </a>
305+ ) : null}
306+ </div>
307+ <div class="box">
308+ <textarea name="content" class="code" spellcheck={false} style="border:0;border-radius:0;min-height:480px" aria-label="File contents">
309+ {`\n${v.content}`}
310+ </textarea>
311+ </div>
312+ <div class="box">
313+ <div class="head">
314+ <h3>Commit changes</h3>
315+ </div>
316+ <div class="body">
317+ <div class="field">
318+ <input type="text" name="message" value={v.message} required aria-label="Commit message" />
319+ </div>
320+ <div class="field">
321+ <textarea name="description" placeholder="Add an optional extended description…" style="min-height:70px">
322+ {v.description}
323+ </textarea>
324+ </div>
325+ <div class="field">
326+ {canDirect ? (
327+ <label class="inline" style="margin-bottom:6px">
328+ <input type="radio" name="choice" value="direct" checked={v.choice === "direct"} /> Commit directly to the <code>{ref}</code> branch
329+ </label>
330+ ) : (
331+ <p class="muted small">
332+ {raw(ICONS.shield)} <code>{ref}</code> is protected; your change goes to a new branch and a pull request.
333+ </p>
334+ )}
335+ <label class="inline">
336+ <input type="radio" name="choice" value="branch" checked={v.choice === "branch" || !canDirect} /> Create a new branch for this commit and start a pull request:
337+ </label>
338+ <input type="text" name="new_branch" value={v.newBranch} style="max-width:320px;margin:6px 0 0 24px" class="mono" aria-label="New branch name" />
339+ </div>
340+ <button class="btn primary">Commit changes</button>
341+ </div>
342+ </div>
343+ </Form>
344+ {mode === "edit" ? (
345+ <Form action={action} t={t} class="box" confirm={`Delete ${orig}?`}>
346+ <input type="hidden" name="_delete" value="1" />
347+ <input type="hidden" name="head" value={v.head} />
348+ <div class="body row between">
349+ <span class="muted">Delete this file in a new commit on {ref}.</span>
350+ <button class="btn sm danger">Delete file</button>
351+ </div>
352+ </Form>
353+ ) : null}
354+ </div>
355+ );
356+};
357+
358+// ---------------------------------------------------------------- compare and pull request creation
359+
360+export interface PullFormValues {
361+ title: string;
362+ body: string;
363+ intent: string;
364+ draft: boolean;
365+}
366+
367+async function comparePage(c: AppContext, l: Loaded, baseRef: string, headRef: string, form?: { values: PullFormValues; error: string }): Promise<Response> {
368+ const { repo, caller, stub } = l;
369+ const base = repoUrl(repo);
370+ const t = csrfToken(c);
371+ const names = await refNames(stub);
372+ const all = [...names.branches, ...names.tags.filter((x) => !names.branches.includes(x))];
373+ let cmp: Awaited<ReturnType<typeof stub.compare>> | null = null;
374+ let error: string | null = null;
375+ try {
376+ cmp = await stub.compare(baseRef, headRef);
377+ } catch (e) {
378+ const s = statusOf(e);
379+ if (s.status >= 500) throw e;
380+ error = s.message;
381+ }
382+ const bothBranches = names.branches.includes(baseRef) && names.branches.includes(headRef);
383+ const canPr = c.get("principal").type === "account" && atLeast(caller.role, "write") && bothBranches && !!cmp && cmp.aheadBy > 0;
384+ let existing: number | null = null;
385+ if (canPr) {
386+ const open = await stub.issues({ kind: "pull", state: "open", limit: 100 });
387+ existing = open.items.find((i) => i.pull?.head === headRef && i.pull?.base === baseRef)?.number ?? null;
388+ }
389+ const defaultTitle = cmp && cmp.commits.length === 1 ? firstLine(cmp.commits[0]!.message) : headRef.replace(/[-_/]+/g, " ").replace(/^\w/, (m) => m.toUpperCase());
390+ const v = form?.values ?? { title: defaultTitle, body: cmp && cmp.commits.length === 1 ? cmp.commits[0]!.message.split("\n").slice(1).join("\n").trim() : "", intent: "", draft: false };
391+ return render(
392+ c,
393+ { title: `Comparing ${baseRef}...${headRef}`, repo: { repo, caller, tab: "pulls", ref: headRef }, wide: !!cmp && cmp.files.length > 0 },
394+ <div>
395+ <h1>Compare changes</h1>
396+ <p class="muted">Choose two branches to see what changed or to start a new pull request.</p>
397+ <form method="get" action={`${base}/compare`} class="box">
398+ <div class="body row">
399+ {raw(ICONS.branch)}
400+ <label class="inline">
401+ base:
402+ <select name="base" style="width:auto">
403+ {all.map((n) => (
404+ <option value={n} selected={n === baseRef}>
405+ {n}
406+ </option>
407+ ))}
408+ </select>
409+ </label>
410+ <span class="faint">←</span>
411+ <label class="inline">
412+ compare:
413+ <select name="head" style="width:auto">
414+ {all.map((n) => (
415+ <option value={n} selected={n === headRef}>
416+ {n}
417+ </option>
418+ ))}
419+ </select>
420+ </label>
421+ <button class="btn sm">Compare</button>
422+ {cmp ? (
423+ <span class="muted small" style="margin-left:auto">
424+ {cmp.aheadBy} commit{cmp.aheadBy === 1 ? "" : "s"} ahead, {cmp.behindBy} behind {baseRef}
425+ </span>
426+ ) : null}
427+ </div>
428+ </form>
429+ {error ? <div class="flash error">{error}</div> : null}
430+ {cmp && cmp.aheadBy === 0 ? (
431+ <div class="blank mb">
432+ <h3>There isn't anything to compare.</h3>
433+ <p class="m0">
434+ <b>{baseRef}</b> is up to date with all commits from <b>{headRef}</b>.
435+ </p>
436+ </div>
437+ ) : null}
438+ {existing ? (
439+ <div class="flash info">
440+ A pull request for these branches already exists:{" "}
441+ <a href={`${base}/pull/${existing}`}>
442+ #{existing}
443+ </a>
444+ </div>
445+ ) : canPr ? (
446+ <Form action={`${base}/pulls`} t={t} class="box" style="max-width:980px">
447+ <input type="hidden" name="base" value={baseRef} />
448+ <input type="hidden" name="head" value={headRef} />
449+ <div class="head">
450+ <h2>Open a pull request</h2>
451+ <span class="muted small">
452+ {headRef} → {baseRef}
453+ </span>
454+ </div>
455+ <div class="body">
456+ {form?.error ? <div class="flash error">{form.error}</div> : null}
457+ <div class="field">
458+ <label for="pt">Title</label>
459+ <input id="pt" name="title" type="text" required value={v.title} />
460+ </div>
461+ <div class="field">
462+ <label for="pi">Intent (what this change is meant to do — helps reviewers and AI reviewers)</label>
463+ <textarea id="pi" name="intent" style="min-height:80px" placeholder="e.g. Fix the race in token refresh without changing the public API.">
464+ {v.intent}
465+ </textarea>
466+ </div>
467+ <div class="field">
468+ <label for="pb">Description</label>
469+ <textarea id="pb" name="body" placeholder="Markdown is supported.">
470+ {v.body}
471+ </textarea>
472+ </div>
473+ <div class="row between">
474+ <label class="inline">
475+ <input type="checkbox" name="draft" value="1" checked={v.draft} /> Create as draft
476+ </label>
477+ <button class="btn green">Create pull request</button>
478+ </div>
479+ </div>
480+ </Form>
481+ ) : null}
482+ {cmp && cmp.commits.length ? (
483+ <div class="box">
484+ <div class="head">
485+ <h3>
486+ {raw(ICONS.commit)} {cmp.commits.length} commit{cmp.commits.length === 1 ? "" : "s"}
487+ </h3>
488+ </div>
489+ <ul class="list">
490+ {cmp.commits.map((cm) => (
491+ <CommitRow base={base} sha={cm.sha} message={cm.message} author={cm.author} signed={cm.signed} />
492+ ))}
493+ </ul>
494+ </div>
495+ ) : null}
496+ {cmp && cmp.files.length ? (
497+ <>
498+ <div class="row between mb">
499+ <DiffStats files={cmp.totalFiles} additions={cmp.additions} deletions={cmp.deletions} />
500+ {cmp.truncated ? <span class="badge amber">showing the first {cmp.files.length} files</span> : null}
501+ </div>
502+ <FileSummary files={cmp.files as DiffFile[]} />
503+ <DiffView files={cmp.files as DiffFile[]} opts={{ viewHref: (p) => `${base}/blob/${encPath(headRef)}/${encPath(p)}` }} />
504+ </>
505+ ) : null}
506+ </div>,
507+ );
508+}
509+
510+// ---------------------------------------------------------------- routes
511+
512+export function registerCode(web: WebApp): void {
513+ web.get("/:owner/:repo", async (c) => {
514+ const name = prm(c, "repo");
515+ if (name.endsWith(".git")) return c.redirect(`/${prm(c, "owner")}/${name.slice(0, -4)}`, 301);
516+ const l = await loadRepo(c, prm(c, "owner"), name, "read");
517+ if (l.repo.owner_login !== prm(c, "owner") || l.repo.name !== name) return c.redirect(repoUrl(l.repo), 301);
518+ return treePage(c, l, l.repo.default_branch, "", true);
519+ });
520+
521+ web.get("/:owner/:repo/tree/*", async (c) => {
522+ const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
523+ const rest = restOfPath(c, 3);
524+ if (!rest) return c.redirect(repoUrl(l.repo), 302);
525+ const { ref, path } = await splitRefPath(l.stub, rest);
526+ return treePage(c, l, ref, path, !path);
527+ });
528+
529+ web.get("/:owner/:repo/blob/*", async (c) => {
530+ const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
531+ const { repo, caller, stub } = l;
532+ const base = repoUrl(repo);
533+ const { ref, path } = await splitRefPath(stub, restOfPath(c, 3));
534+ if (!path) return c.redirect(`${base}/tree/${encPath(ref)}`, 302);
535+ const [blob, names] = await Promise.all([stub.blob(ref, path), refNames(stub)]);
536+ if (!blob) {
537+ const tree = await stub.tree(ref, path);
538+ if (tree) return c.redirect(`${base}/tree/${encPath(ref)}/${encPath(path)}`, 302);
539+ throw new HttpError(404, "file not found");
540+ }
541+ const ext = extOf(path);
542+ const rawUrl = `${base}/raw/${encPath(ref)}/${encPath(path)}`;
543+ const plain = c.req.query("plain") === "1";
544+ const canEdit = atLeast(caller.role, "write") && names.branches.includes(ref) && !blob.binary && blob.size <= MAX_RENDER && !repo.archived;
545+ let content: Child;
546+ let lineCount = 0;
547+ if (IMAGE_TYPES[ext] && ext !== "svg") {
548+ content = (
549+ <div class="body" style="text-align:center;background:var(--bg2)">
550+ <img src={rawUrl} alt={path} style="max-width:100%" />
551+ </div>
552+ );
553+ } else if (blob.binary) {
554+ content = (
555+ <div class="empty">
556+ Binary file, {fmtBytes(blob.size)} ({blob.size.toLocaleString("en-US")} bytes).{" "}
557+ <a class="btn sm" href={rawUrl}>
558+ {raw(ICONS.download)} Download
559+ </a>
560+ </div>
561+ );
562+ } else if (blob.size > MAX_RENDER) {
563+ content = (
564+ <div class="empty">
565+ This file is too large to display ({fmtBytes(blob.size)}). <a href={rawUrl}>View raw</a>
566+ </div>
567+ );
568+ } else {
569+ const text = decoder().decode(blob.content);
570+ const lines = text.split("\n");
571+ if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
572+ lineCount = lines.length;
573+ if (isMarkdown(path) && !plain) {
574+ content = (
575+ <div class="body readme" style="margin:0;padding:24px 32px">
576+ <Markdown text={text} base={{ owner: repo.owner_login, repo: repo.name, ref, dir: path.split("/").slice(0, -1).join("/") }} empty="Empty file." />
577+ </div>
578+ );
579+ } else {
580+ content = (
581+ <div class="code-wrap">
582+ <table class="blob">
583+ {lines.map((line, i) => (
584+ <tr id={`L${i + 1}`}>
585+ <td class="ln">
586+ <a href={`#L${i + 1}`}>{i + 1}</a>
587+ </td>
588+ <td>{line.replace(/\r$/, "")}</td>
589+ </tr>
590+ ))}
591+ </table>
592+ </div>
593+ );
594+ }
595+ }
596+ return render(
597+ c,
598+ { title: `${path} at ${ref} · ${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "code", ref } },
599+ <div>
600+ <div class="row between mb">
601+ <div class="row">
602+ <RefSwitcher current={ref} branches={names.branches} tags={names.tags} to={(r) => `${base}/blob/${encPath(r)}/${encPath(path)}`} />
603+ <Crumbs base={base} ref={ref} path={path} />
604+ <CopyButton text={path} label="Copy path" />
605+ </div>
606+ </div>
607+ <div class="box">
608+ <div class="head small">
609+ <span class="muted">
610+ {lineCount ? `${lineCount} line${lineCount === 1 ? "" : "s"} · ` : ""}
611+ {fmtBytes(blob.size)}
612+ {blob.mode === "100755" ? " · executable" : ""}
613+ </span>
614+ <span class="grow" />
615+ {isMarkdown(path) && !blob.binary ? (
616+ <div class="subnav">
617+ <a href={`?`} class={!plain ? "on" : ""}>
618+ Preview
619+ </a>
620+ <a href={`?plain=1`} class={plain ? "on" : ""}>
621+ Code
622+ </a>
623+ </div>
624+ ) : null}
625+ <a class="btn sm" href={rawUrl}>
626+ Raw
627+ </a>
628+ <a class="btn sm" href={`${base}/commits/${encPath(ref)}${qs({ path })}`}>
629+ {raw(ICONS.history)} History
630+ </a>
631+ {canEdit ? (
632+ <a class="btn sm" href={`${base}/edit/${encPath(ref)}/${encPath(path)}`}>
633+ {raw(ICONS.edit)} Edit
634+ </a>
635+ ) : null}
636+ </div>
637+ {content}
638+ </div>
639+ </div>,
640+ );
641+ });
642+
643+ web.get("/:owner/:repo/raw/*", async (c) => {
644+ const { stub } = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
645+ const { ref, path } = await splitRefPath(stub, restOfPath(c, 3));
646+ const blob = path ? await stub.blob(ref, path) : null;
647+ if (!blob) throw new HttpError(404, "file not found");
648+ const ext = extOf(path);
649+ const type = IMAGE_TYPES[ext] ?? (blob.binary ? "application/octet-stream" : "text/plain; charset=utf-8");
650+ const name = path.split("/").pop() ?? "file";
651+ const headers: Record<string, string> = {
652+ "content-type": type,
653+ "x-content-type-options": "nosniff",
654+ "content-security-policy": "sandbox; default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'",
655+ "cache-control": /^[0-9a-f]{40}$/.test(ref) ? "private, max-age=86400, immutable" : "private, max-age=60",
656+ "content-disposition": `${type === "application/octet-stream" ? "attachment" : "inline"}; filename*=UTF-8''${encodeURIComponent(name)}`,
657+ "x-frame-options": "DENY",
658+ };
659+ return new Response(blob.content, { headers });
660+ });
661+
662+ // Editor (edit an existing file / create a new one).
663+ const editorGet = async (c: AppContext, mode: "edit" | "new") => {
664+ const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write");
665+ const { repo, caller, stub } = l;
666+ const base = repoUrl(repo);
667+ const names = await refNames(stub);
668+ const empty = !names.branches.length && !names.tags.length;
669+ const rest = restOfPath(c, 3);
670+ const split = empty ? { ref: rest.split("/")[0] || repo.default_branch, path: rest.split("/").slice(1).join("/") } : await splitRefPath(stub, rest);
671+ const ref = split.ref;
672+ if (!empty && !names.branches.includes(ref)) throw new HttpError(422, "files can only be edited on a branch");
673+ const meName = caller.login;
674+ const patchBranch = `${meName}-patch-${Math.floor(Date.now() / 1000) % 100000}`;
675+ const canDirect = await canCommitDirect(l, ref);
676+ if (mode === "edit") {
677+ const blob = split.path ? await stub.blob(ref, split.path) : null;
678+ if (!blob) throw new HttpError(404, "file not found");
679+ if (blob.binary) throw new HttpError(422, "binary files cannot be edited in the browser");
680+ return render(
681+ c,
682+ { title: `Editing ${split.path}`, repo: { repo, caller, tab: "code", ref } },
683+ <Editor
684+ c={c}
685+ l={l}
686+ ref={ref}
687+ mode="edit"
688+ orig={split.path}
689+ dir=""
690+ canDirect={canDirect}
691+ v={{ path: split.path, content: decoder().decode(blob.content), message: `Update ${split.path.split("/").pop()}`, description: "", choice: canDirect ? "direct" : "branch", newBranch: patchBranch, head: blob.sha }}
692+ />,
693+ );
694+ }
695+ const headSha = empty ? "" : ((await stub.resolve(ref)) ?? "");
696+ const filename = c.req.query("filename") ?? "";
697+ const dir = split.path;
698+ return render(
699+ c,
700+ { title: `New file · ${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "code", ref } },
701+ <>
702+ <p class="muted">
703+ Create a new file in <a href={`${base}/tree/${encPath(ref)}${dir ? `/${encPath(dir)}` : ""}`}>{dir || repo.name}</a>.
704+ </p>
705+ <Editor
706+ c={c}
707+ l={l}
708+ ref={ref}
709+ mode="new"
710+ orig=""
711+ dir={dir}
712+ canDirect={canDirect || empty}
713+ v={{ path: `${dir ? `${dir}/` : ""}${cleanPath(filename) ?? ""}`, content: "", message: filename ? `Create ${filename}` : "Create new file", description: "", choice: canDirect || empty ? "direct" : "branch", newBranch: patchBranch, head: headSha }}
714+ />
715+ </>,
716+ );
717+ };
718+
719+ const editorPost = async (c: AppContext, mode: "edit" | "new", f: FormData) => {
720+ const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write");
721+ const { repo, caller, stub } = l;
722+ const base = repoUrl(repo);
723+ const names = await refNames(stub);
724+ const empty = !names.branches.length && !names.tags.length;
725+ const rest = restOfPath(c, 3);
726+ const split = empty ? { ref: rest.split("/")[0] || repo.default_branch, path: rest.split("/").slice(1).join("/") } : await splitRefPath(stub, rest);
727+ const ref = split.ref;
728+ const orig = mode === "edit" ? split.path : "";
729+ const head = f.s("head");
730+
731+ if (mode === "edit" && f.b("_delete")) {
732+ return act(c, `${base}/blob/${encPath(ref)}/${encPath(orig)}`, async () => {
733+ await stub.commitFiles({ branch: ref, message: `Delete ${orig}`, changes: [{ path: orig, delete: true }], expectedHeadSha: head || undefined }, caller);
734+ const parent = orig.split("/").slice(0, -1).join("/");
735+ return `${base}/tree/${encPath(ref)}${parent ? `/${encPath(parent)}` : ""}`;
736+ });
737+ }
738+
739+ let content = f.s("content");
740+ if (content.startsWith("\r\n")) content = content.slice(2);
741+ else if (content.startsWith("\n")) content = content.slice(1);
742+ let mode100: "100644" | "100755" | "120000" | undefined;
743+ if (mode === "edit") {
744+ const old = await stub.blob(ref, orig);
745+ const crlf = !!old && decoder().decode(old.content.slice(0, 65536)).includes("\r\n");
746+ if (!crlf) content = content.replace(/\r\n/g, "\n");
747+ if (old && (old.mode === "100755" || old.mode === "120000")) mode100 = old.mode;
748+ } else content = content.replace(/\r\n/g, "\n");
749+ const values: EditorValues = {
750+ path: f.s("path"),
751+ content,
752+ message: f.s("message"),
753+ description: f.s("description"),
754+ choice: f.s("choice") === "direct" ? "direct" : "branch",
755+ newBranch: f.s("new_branch").trim(),
756+ head,
757+ };
758+ const canDirect = empty || (await canCommitDirect(l, ref));
759+ try {
760+ const path = cleanPath(values.path);
761+ if (!path) throw new HttpError(422, "enter a valid file path");
762+ if (!values.message.trim()) throw new HttpError(422, "a commit message is required");
763+ const toBranch = values.choice === "direct" && canDirect ? ref : values.newBranch;
764+ if (!toBranch) throw new HttpError(422, "enter a branch name");
765+ const changes: Array<{ path: string; content?: Uint8Array; delete?: boolean; mode?: "100644" | "100755" | "120000" }> = [];
766+ if (mode === "edit" && path !== orig) changes.push({ path: orig, delete: true });
767+ if (mode === "new" && !empty && (await stub.blob(ref, path))) throw new HttpError(422, `${path} already exists`);
768+ changes.push({ path, content: new TextEncoder().encode(content), mode: mode100 });
769+ const message = values.description.trim() ? `${values.message.trim()}\n\n${values.description.trim()}` : values.message.trim();
770+ const newBranch = toBranch !== ref;
771+ if (newBranch && names.branches.includes(toBranch)) throw new HttpError(422, `branch ${toBranch} already exists`);
772+ await stub.commitFiles({ branch: toBranch, message, changes, expectedHeadSha: !newBranch && head ? head : undefined, createBranchFrom: newBranch ? ref : undefined }, caller);
773+ c.executionCtx.waitUntil(c.get("dir").touchPushed(repo.id));
774+ if (newBranch) return c.redirect(`${base}/compare/${encPath(ref)}...${encPath(toBranch)}`, 303);
775+ return c.redirect(`${base}/blob/${encPath(ref)}/${encPath(path)}`, 303);
776+ } catch (e) {
777+ const s = statusOf(e);
778+ if (s.status >= 500) throw e;
779+ return render(
780+ c,
781+ { title: mode === "edit" ? `Editing ${orig}` : "New file", repo: { repo, caller, tab: "code", ref }, status: s.status },
782+ <Editor c={c} l={l} ref={ref} mode={mode} orig={orig} dir={mode === "new" ? split.path : ""} canDirect={canDirect} v={values} error={s.message} />,
783+ );
784+ }
785+ };
786+
787+ web.get("/:owner/:repo/edit/*", (c) => editorGet(c, "edit"));
788+ post(web, "/:owner/:repo/edit/*", (c, f) => editorPost(c, "edit", f));
789+ web.get("/:owner/:repo/new/*", (c) => editorGet(c, "new"));
790+ post(web, "/:owner/:repo/new/*", (c, f) => editorPost(c, "new", f));
791+
792+ // History
793+ web.get("/:owner/:repo/commits/*", async (c) => {
794+ const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
795+ const { repo, caller, stub } = l;
796+ const base = repoUrl(repo);
797+ const ref = restOfPath(c, 3) || repo.default_branch;
798+ const path = c.req.query("path") ?? "";
799+ const page = pageNum(c);
800+ const per = 30;
801+ const [commits, names] = await Promise.all([stub.log(ref, { path: path || undefined, limit: per + 1, skip: (page - 1) * per }), refNames(stub)]);
802+ const hasNext = commits.length > per;
803+ const shown = commits.slice(0, per);
804+ const groups = new Map<string, typeof shown>();
805+ for (const cm of shown) {
806+ const day = new Date(cm.committer.timestamp * 1000).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
807+ groups.set(day, [...(groups.get(day) ?? []), cm]);
808+ }
809+ return render(
810+ c,
811+ { title: `Commits · ${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "code", ref } },
812+ <div>
813+ <div class="row between mb">
814+ <div class="row">
815+ <h1 class="m0">Commits</h1>
816+ <RefSwitcher current={ref} branches={names.branches} tags={names.tags} to={(r) => `${base}/commits/${encPath(r)}${qs({ path })}`} />
817+ {path ? (
818+ <span class="muted">
819+ for <span class="mono">{path}</span> · <a href={`${base}/commits/${encPath(ref)}`}>all files</a>
820+ </span>
821+ ) : null}
822+ </div>
823+ </div>
824+ {shown.length === 0 ? <div class="blank">No commits found.</div> : null}
825+ {[...groups.entries()].map(([day, list]) => (
826+ <div class="mb">
827+ <h3 class="muted small" style="font-weight:500">
828+ {raw(ICONS.commit)} Commits on {day}
829+ </h3>
830+ <div class="box">
831+ <ul class="list">
832+ {list.map((cm) => (
833+ <CommitRow base={base} sha={cm.sha} message={cm.message} author={cm.author} signed={cm.signed} />
834+ ))}
835+ </ul>
836+ </div>
837+ </div>
838+ ))}
839+ <Pager page={page} hasNext={hasNext} href={(p) => `${base}/commits/${encPath(ref)}${qs({ path, page: p > 1 ? p : undefined })}`} />
840+ </div>,
841+ );
842+ });
843+ web.get("/:owner/:repo/commits", (c) => c.redirect(`/${prm(c, "owner")}/${prm(c, "repo")}/commits/HEAD`, 302));
844+
845+ web.get("/:owner/:repo/commit/:sha", async (c) => {
846+ const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
847+ const { repo, caller, stub } = l;
848+ const base = repoUrl(repo);
849+ const cm = await stub.commitWithDiff(prm(c, "sha"));
850+ const checks = await stub.checks(cm.sha).catch(() => []);
851+ const [title, ...rest] = cm.message.split("\n");
852+ const body = rest.join("\n").trim();
853+ const files = cm.files as DiffFile[];
854+ return render(
855+ c,
856+ { title: `${firstLine(cm.message)} · ${shortSha(cm.sha)}`, repo: { repo, caller, tab: "code", ref: cm.sha }, wide: true },
857+ <div>
858+ <div class="box">
859+ <div class="body">
860+ <h2 style="font-size:18px">{title}</h2>
861+ {body ? <pre style="white-space:pre-wrap;margin:0 0 12px;font-size:13px">{body}</pre> : null}
862+ <div class="row small muted">
863+ <b style="color:var(--fg)">{cm.author.name}</b> authored <Time at={new Date(cm.author.timestamp * 1000).toISOString()} />
864+ {cm.committer.name !== cm.author.name ? (
865+ <span>
866+ · <b style="color:var(--fg)">{cm.committer.name}</b> committed <Time at={new Date(cm.committer.timestamp * 1000).toISOString()} />
867+ </span>
868+ ) : null}
869+ {cm.signed ? <span class="badge green">signed</span> : null}
870+ <span class="grow" />
871+ <span>
872+ {cm.parents.length} parent{cm.parents.length === 1 ? "" : "s"}{" "}
873+ {cm.parents.map((p) => (
874+ <a class="sha" href={`${base}/commit/${p}`} style="margin-left:4px">
875+ {shortSha(p)}
876+ </a>
877+ ))}
878+ </span>
879+ <span>
880+ commit <span class="sha">{cm.sha}</span>
881+ </span>
882+ <CopyButton text={cm.sha} />
883+ <a class="btn sm" href={`${base}/tree/${cm.sha}`}>
884+ Browse files
885+ </a>
886+ </div>
887+ {checks.length ? (
888+ <ul class="checks mt">
889+ {checks.map((ch) => (
890+ <li>
891+ <Status status={ch.status} conclusion={ch.conclusion} />
892+ {ch.runId ? <a href={`${base}/actions/runs/${ch.runId}${ch.jobId ? `/job/${ch.jobId}` : ""}`}>{ch.name}</a> : <span>{ch.name}</span>}
893+ <span class="muted small">{ch.output?.title ?? ""}</span>
894+ </li>
895+ ))}
896+ </ul>
897+ ) : null}
898+ </div>
899+ </div>
900+ <div class="row between mb">
901+ <DiffStats files={cm.totalFiles} additions={cm.additions} deletions={cm.deletions} />
902+ {cm.truncated ? <span class="badge amber">showing the first {files.length} files</span> : null}
903+ </div>
904+ <FileSummary files={files} />
905+ <DiffView files={files} opts={{ viewHref: (p) => `${base}/blob/${cm.sha}/${encPath(p)}` }} />
906+ </div>,
907+ );
908+ });
909+
910+ // Branches and tags
911+ web.get("/:owner/:repo/branches", async (c) => {
912+ const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
913+ const { repo, caller, stub } = l;
914+ const base = repoUrl(repo);
915+ const t = csrfToken(c);
916+ const branches = await stub.branches();
917+ branches.sort((a, b) => (a.isDefault ? -1 : b.isDefault ? 1 : a.name.localeCompare(b.name)));
918+ const infos = await Promise.all(branches.slice(0, 40).map((b) => stub.commitInfo(b.sha).catch(() => null)));
919+ const canWrite = atLeast(caller.role, "write") && !repo.archived;
920+ return render(
921+ c,
922+ { title: `Branches · ${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "code" } },
923+ <div>
924+ <div class="row between mb">
925+ <h1 class="m0">Branches</h1>
926+ </div>
927+ <div class="box">
928+ {branches.length ? (
929+ <ul class="list">
930+ {branches.map((b, i) => {
931+ const info = infos[i];
932+ return (
933+ <li>
934+ <span class="st faint">{raw(ICONS.branch)}</span>
935+ <div class="grow">
936+ <a class="title mono" href={`${base}/tree/${encPath(b.name)}`}>
937+ {b.name}
938+ </a>{" "}
939+ {b.isDefault ? <span class="badge blue">default</span> : null} {b.protected ? <span class="badge">{raw(ICONS.shield)} protected</span> : null}
940+ {info ? (
941+ <div class="meta">
942+ {firstLine(info.message)} · {info.author.name} · <Time at={new Date(info.committer.timestamp * 1000).toISOString()} />
943+ </div>
944+ ) : null}
945+ </div>
946+ <a class="sha" href={`${base}/commit/${b.sha}`}>
947+ {shortSha(b.sha)}
948+ </a>
949+ {!b.isDefault ? (
950+ <a class="btn sm" href={`${base}/compare/${encPath(repo.default_branch)}...${encPath(b.name)}`}>
951+ {raw(ICONS.pr)} Compare
952+ </a>
953+ ) : null}
954+ {canWrite && !b.isDefault ? (
955+ <Form action={`${base}/branches/delete`} t={t} class="inline" confirm={`Delete branch ${b.name}?`}>
956+ <input type="hidden" name="name" value={b.name} />
957+ <button class="btn sm danger" title="Delete branch">
958+ {raw(ICONS.x)}
959+ </button>
960+ </Form>
961+ ) : null}
962+ </li>
963+ );
964+ })}
965+ </ul>
966+ ) : (
967+ <div class="empty">No branches yet.</div>
968+ )}
969+ </div>
970+ {canWrite && branches.length ? (
971+ <Form action={`${base}/branches`} t={t} class="box">
972+ <div class="head">
973+ <h3>Create a branch</h3>
974+ </div>
975+ <div class="body row" style="align-items:flex-end">
976+ <div class="field grow m0">
977+ <label>Branch name</label>
978+ <input name="name" type="text" required class="mono" />
979+ </div>
980+ <div class="field m0">
981+ <label>From</label>
982+ <select name="from">
983+ {branches.map((b) => (
984+ <option value={b.name} selected={b.isDefault}>
985+ {b.name}
986+ </option>
987+ ))}
988+ </select>
989+ </div>
990+ <button class="btn primary">Create branch</button>
991+ </div>
992+ </Form>
993+ ) : null}
994+ </div>,
995+ );
996+ });
997+
998+ post(web, "/:owner/:repo/branches", async (c, f) => {
999+ const { repo, caller, stub } = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write");
1000+ const base = repoUrl(repo);
1001+ return act(c, `${base}/branches`, async () => {
1002+ const name = f.s("name").trim();
1003+ const sha = await stub.resolve(f.s("from"));
1004+ if (!sha) throw new HttpError(422, "unknown source branch");
1005+ await stub.createRef(`refs/heads/${name}`, sha, caller);
1006+ return `${base}/tree/${encPath(name)}`;
1007+ });
1008+ });
1009+
1010+ post(web, "/:owner/:repo/branches/delete", async (c, f) => {
1011+ const { repo, caller, stub } = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write");
1012+ return act(c, `${repoUrl(repo)}/branches`, async () => stub.deleteRef(`refs/heads/${f.s("name")}`, caller), "Branch deleted.");
1013+ });
1014+
1015+ web.get("/:owner/:repo/tags", async (c) => {
1016+ const { repo, caller, stub } = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
1017+ const base = repoUrl(repo);
1018+ const tags = (await stub.tags()).reverse();
1019+ const infos = await Promise.all(tags.slice(0, 40).map((tg) => stub.commitInfo(tg.target).catch(() => null)));
1020+ return render(
1021+ c,
1022+ { title: `Tags · ${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "code" } },
1023+ <div>
1024+ <h1>Tags</h1>
1025+ <div class="box">
1026+ {tags.length ? (
1027+ <ul class="list">
1028+ {tags.map((tg, i) => (
1029+ <li>
1030+ <span class="st faint">{raw(ICONS.tag)}</span>
1031+ <div class="grow">
1032+ <a class="title mono" href={`${base}/tree/${encPath(tg.name)}`}>
1033+ {tg.name}
1034+ </a>
1035+ {infos[i] ? (
1036+ <div class="meta">
1037+ {firstLine(infos[i]!.message)} · <Time at={new Date(infos[i]!.committer.timestamp * 1000).toISOString()} />
1038+ </div>
1039+ ) : null}
1040+ </div>
1041+ <a class="sha" href={`${base}/commit/${tg.target}`}>
1042+ {shortSha(tg.target)}
1043+ </a>
1044+ <a class="btn sm" href={`${base}/compare/${encPath(tg.name)}...${encPath(repo.default_branch)}`}>
1045+ Compare
1046+ </a>
1047+ </li>
1048+ ))}
1049+ </ul>
1050+ ) : (
1051+ <div class="empty">
1052+ No tags yet. Create one with <code>git tag v1.0.0 && git push --tags</code>.
1053+ </div>
1054+ )}
1055+ </div>
1056+ </div>,
1057+ );
1058+ });
1059+
1060+ // Compare
1061+ web.get("/:owner/:repo/compare", async (c) => {
1062+ const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
1063+ const base = repoUrl(l.repo);
1064+ const b = c.req.query("base");
1065+ const h = c.req.query("head");
1066+ if (b && h) return c.redirect(`${base}/compare/${encPath(b)}...${encPath(h)}`, 302);
1067+ const names = await refNames(l.stub);
1068+ const other = names.branches.find((x) => x !== l.repo.default_branch);
1069+ return comparePage(c, l, l.repo.default_branch, h ?? other ?? l.repo.default_branch);
1070+ });
1071+
1072+ web.get("/:owner/:repo/compare/*", async (c) => {
1073+ const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
1074+ const spec = restOfPath(c, 3);
1075+ const idx = spec.indexOf("...");
1076+ const baseRef = idx >= 0 ? spec.slice(0, idx) : l.repo.default_branch;
1077+ const headRef = idx >= 0 ? spec.slice(idx + 3) : spec;
1078+ if (!headRef) return c.redirect(`${repoUrl(l.repo)}/compare`, 302);
1079+ return comparePage(c, l, baseRef || l.repo.default_branch, headRef);
1080+ });
1081+
1082+ post(web, "/:owner/:repo/pulls", async (c, f) => {
1083+ const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write");
1084+ if (c.get("principal").type !== "account") throw new HttpError(401, "sign in required");
1085+ const values: PullFormValues = { title: f.s("title").trim(), body: f.s("body").replace(/\r\n/g, "\n"), intent: f.s("intent").replace(/\r\n/g, "\n").trim(), draft: f.b("draft") };
1086+ const baseRef = f.s("base") || l.repo.default_branch;
1087+ const headRef = f.s("head");
1088+ try {
1089+ const pr = await l.stub.createPull({ title: values.title, body: values.body, head: headRef, base: baseRef, draft: values.draft, intent: values.intent || undefined }, l.caller);
1090+ return c.redirect(`${repoUrl(l.repo)}/pull/${pr.number}`, 303);
1091+ } catch (e) {
1092+ const s = statusOf(e);
1093+ if (s.status >= 500) throw e;
1094+ return comparePage(c, l, baseRef, headRef, { values, error: s.message });
1095+ }
1096+ });
1097+}
1098+
1099+/** Whether a web commit straight to `branch` would pass protection rules for this caller. */
1100+async function canCommitDirect(l: Loaded, branch: string): Promise<boolean> {
1101+ const rules = (await l.stub.listProtections()).find((p) => globToRegExp(p.pattern).test(branch))?.rules;
1102+ if (l.caller.kind === "agent" && (branch === l.repo.default_branch || rules) && !rules?.agentsMayPush) return false;
1103+ if (!rules) return true;
1104+ if (l.caller.role === "admin" && !rules.enforceAdmins) return true;
1105+ return !rules.requirePullRequest;
1106+}
1107+
addedapps/diffinery/src/web/collab.tsx+1198−0View file
@@ -1,0 +1,1198 @@
1+/** @jsxImportSource hono/jsx */
2+import { raw } from "hono/html";
3+import type { Child, FC } from "hono/jsx";
4+import type { AppContext } from "../auth/principal.js";
5+import { loadRepo } from "../http/common.js";
6+import { HttpError } from "../lib/util.js";
7+import type { Check, Comment, Issue, Mergeability, Review } from "../repo/collab.js";
8+import { atLeast, type Role } from "../repo/types.js";
9+import { ICONS } from "./assets.js";
10+import { csrfToken } from "./csrf.js";
11+import {
12+ Actor,
13+ CommitRow,
14+ DiffStats,
15+ DiffView,
16+ FileSummary,
17+ Form,
18+ Label,
19+ Markdown,
20+ Pager,
21+ Status,
22+ Time,
23+ act,
24+ duration,
25+ encPath,
26+ onBehalfOf,
27+ pageNum,
28+ post,
29+ qs,
30+ render,
31+ repoUrl,
32+ shortSha,
33+ statusOf,
34+ type DiffFile,
35+ type LineThread,
36+ type WebApp,
37+ prm,
38+} from "./ui.js";
39+
40+type Loaded = Awaited<ReturnType<typeof loadRepo>>;
41+type LabelDef = { name: string; color: string; description: string };
42+
43+/** Load a repo for a state-changing action by a signed-in account. */
44+async function forAction(c: AppContext, min: Role = "read"): Promise<Loaded> {
45+ if (c.get("principal").type !== "account") throw new HttpError(401, "sign in required");
46+ const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), min);
47+ if (l.repo.archived) throw new HttpError(403, "repository is archived (read-only)");
48+ return l;
49+}
50+
51+function issueNumber(c: AppContext): number {
52+ const n = Number(prm(c, "n"));
53+ if (!Number.isInteger(n) || n <= 0) throw new HttpError(404, "not found");
54+ return n;
55+}
56+
57+const kindPath = (i: Pick<Issue, "kind">) => (i.kind === "pull" ? "pull" : "issues");
58+
59+const StateBadge: FC<{ i: Issue }> = ({ i }) => {
60+ if (i.kind === "pull") {
61+ if (i.pull?.merged)
62+ return (
63+ <span class="state merged">
64+ {raw(ICONS.prMerged)} Merged
65+ </span>
66+ );
67+ if (i.state === "closed")
68+ return (
69+ <span class="state closed">
70+ {raw(ICONS.prClosed)} Closed
71+ </span>
72+ );
73+ if (i.pull?.draft)
74+ return (
75+ <span class="state draft">
76+ {raw(ICONS.pr)} Draft
77+ </span>
78+ );
79+ return (
80+ <span class="state open">
81+ {raw(ICONS.pr)} Open
82+ </span>
83+ );
84+ }
85+ return i.state === "open" ? (
86+ <span class="state open">
87+ {raw(ICONS.issueOpen)} Open
88+ </span>
89+ ) : (
90+ <span class="state merged" style={i.stateReason === "not_planned" ? "background:var(--fg3)" : ""}>
91+ {raw(ICONS.issueClosed)} Closed
92+ </span>
93+ );
94+};
95+
96+const StateIcon: FC<{ i: Issue }> = ({ i }) => {
97+ if (i.kind === "pull") {
98+ if (i.pull?.merged) return <span class="st merged">{raw(ICONS.prMerged)}</span>;
99+ if (i.state === "closed") return <span class="st bad">{raw(ICONS.prClosed)}</span>;
100+ return <span class={`st ${i.pull?.draft ? "faint" : "ok"}`}>{raw(ICONS.pr)}</span>;
101+ }
102+ return i.state === "open" ? <span class="st ok">{raw(ICONS.issueOpen)}</span> : <span class="st merged">{raw(ICONS.issueClosed)}</span>;
103+};
104+
105+// ---------------------------------------------------------------- lists
106+
107+async function listPage(c: AppContext, kind: "issue" | "pull"): Promise<Response> {
108+ const { repo, caller, stub } = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
109+ const base = repoUrl(repo);
110+ const state = c.req.query("state") === "closed" ? "closed" : "open";
111+ const label = c.req.query("label") ?? "";
112+ const q = (c.req.query("q") ?? "").slice(0, 200);
113+ const author = c.req.query("author") ?? "";
114+ const page = pageNum(c);
115+ const per = 25;
116+ const filter = { kind, label: label || undefined, q: q || undefined, author: author || undefined };
117+ const [list, other, labels] = await Promise.all([
118+ stub.issues({ ...filter, state, limit: per, offset: (page - 1) * per }),
119+ stub.issues({ ...filter, state: state === "open" ? "closed" : "open", limit: 1 }),
120+ stub.labels(),
121+ ]);
122+ const colors = new Map(labels.map((l) => [l.name, l.color]));
123+ const path = kind === "pull" ? "pulls" : "issues";
124+ const href = (p: Record<string, string | number | undefined>) => `${base}/${path}${qs({ state: state === "closed" ? "closed" : undefined, label: label || undefined, q: q || undefined, author: author || undefined, ...p })}`;
125+ const openCount = state === "open" ? list.total : other.total;
126+ const closedCount = state === "closed" ? list.total : other.total;
127+ const isAccount = c.get("principal").type === "account";
128+ const behalf = await onBehalfOf(c, list.items.map((i) => i.author));
129+ return render(
130+ c,
131+ { title: `${kind === "pull" ? "Pull requests" : "Issues"} · ${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: kind === "pull" ? "pulls" : "issues" } },
132+ <div>
133+ <div class="row between mb">
134+ <form method="get" action={`${base}/${path}`} class="row grow" style="max-width:640px">
135+ <input type="hidden" name="state" value={state === "closed" ? "closed" : ""} />
136+ {label ? <input type="hidden" name="label" value={label} /> : null}
137+ <input type="search" name="q" value={q} placeholder={`Search ${kind === "pull" ? "pull requests" : "issues"}… ( / )`} class="grow" style="flex:1" />
138+ </form>
139+ <div class="row">
140+ <a class="btn sm" href={`${base}/labels`}>
141+ Labels
142+ </a>
143+ {kind === "pull" ? (
144+ <a class="btn sm primary" href={`${base}/compare`}>
145+ New pull request
146+ </a>
147+ ) : isAccount && !repo.archived ? (
148+ <a class="btn sm primary" href={`${base}/issues/new`}>
149+ New issue
150+ </a>
151+ ) : null}
152+ </div>
153+ </div>
154+ <div class="box">
155+ <div class="head">
156+ <a href={href({ state: undefined, page: undefined })} style={state === "open" ? "font-weight:600;color:var(--fg)" : "color:var(--fg2)"}>
157+ {raw(kind === "pull" ? ICONS.pr : ICONS.issueOpen)} {openCount} Open
158+ </a>
159+ <a href={href({ state: "closed", page: undefined })} style={state === "closed" ? "font-weight:600;color:var(--fg)" : "color:var(--fg2)"}>
160+ {raw(ICONS.check)} {closedCount} Closed
161+ </a>
162+ <span class="grow" />
163+ {label ? (
164+ <span class="small">
165+ Label: <Label name={label} color={colors.get(label)} /> <a href={`${base}/${path}${qs({ state: state === "closed" ? "closed" : undefined, q: q || undefined })}`}>clear</a>
166+ </span>
167+ ) : null}
168+ {author ? (
169+ <span class="small">
170+ Author: <b>{author}</b> <a href={href({ author: undefined })}>clear</a>
171+ </span>
172+ ) : null}
173+ {labels.length ? (
174+ <details class="menu">
175+ <summary class="btn sm">Label ▾</summary>
176+ <div class="pop right">
177+ {labels.map((l) => (
178+ <a href={href({ label: l.name, page: undefined })}>
179+ <Label name={l.name} color={l.color} />
180+ <span class="muted small">{l.description}</span>
181+ </a>
182+ ))}
183+ </div>
184+ </details>
185+ ) : null}
186+ </div>
187+ {list.items.length ? (
188+ <ul class="list">
189+ {list.items.map((i) => (
190+ <li>
191+ <StateIcon i={i} />
192+ <div class="grow">
193+ <a class="title" href={`${base}/${kindPath(i)}/${i.number}`}>
194+ {i.title}
195+ </a>{" "}
196+ {i.labels.map((l) => (
197+ <Label name={l} color={colors.get(l)} href={href({ label: l, page: undefined })} />
198+ ))}
199+ {i.pull?.draft && i.state === "open" ? <span class="badge">draft</span> : null}
200+ <div class="meta">
201+ #{i.number} {i.state === "open" ? "opened" : i.pull?.merged ? "merged" : "closed"} <Time at={i.state === "open" ? i.createdAt : (i.pull?.mergedAt ?? i.closedAt ?? i.updatedAt)} /> by{" "}
202+ <a href={href({ author: i.author.login, page: undefined })}>{i.author.login}</a>
203+ {i.author.kind === "agent" ? (
204+ <>
205+ {" "}
206+ <span class="badge agent">agent</span>
207+ {behalf.get(i.author.login) ? <span> on behalf of {behalf.get(i.author.login)}</span> : null}
208+ </>
209+ ) : null}
210+ {i.pull ? (
211+ <span class="mono">
212+ {" "}
213+ · {i.pull.head} → {i.pull.base}
214+ </span>
215+ ) : null}
216+ </div>
217+ </div>
218+ {i.comments ? (
219+ <a class="muted small nowrap" href={`${base}/${kindPath(i)}/${i.number}`}>
220+ {raw(ICONS.comment)} {i.comments}
221+ </a>
222+ ) : null}
223+ </li>
224+ ))}
225+ </ul>
226+ ) : (
227+ <div class="empty">
228+ {q || label || author ? "No results match your filters." : state === "open" ? `There are no open ${kind === "pull" ? "pull requests" : "issues"}.` : `No closed ${kind === "pull" ? "pull requests" : "issues"}.`}
229+ </div>
230+ )}
231+ </div>
232+ <Pager page={page} hasNext={page * per < list.total} href={(p) => href({ page: p > 1 ? p : undefined })} />
233+ </div>,
234+ );
235+}
236+
237+// ---------------------------------------------------------------- conversation pieces
238+
239+interface ConvCtx {
240+ c: AppContext;
241+ l: Loaded;
242+ issue: Issue;
243+ comments: Comment[];
244+ reviews: Review[];
245+ labels: LabelDef[];
246+ behalf: Map<string, string>;
247+}
248+
249+const CommentCard: FC<{ x: ConvCtx; author: { login: string; kind: string }; at: string; body: string; verb?: string; id?: string; extra?: Child; cls?: string }> = ({ x, author, at, body, verb, id, extra, cls }) => (
250+ <div class={`comment ${cls ?? ""}`} id={id}>
251+ <div class="head">
252+ <Actor login={author.login} kind={author.kind} onBehalfOf={x.behalf.get(author.login)} />
253+ <span class="muted">
254+ {verb ?? "commented"} <Time at={at} />
255+ </span>
256+ <span class="grow" />
257+ {author.login === x.issue.author.login ? <span class="badge">Author</span> : null}
258+ {extra}
259+ </div>
260+ <div class="body">
261+ <Markdown text={body} base={{ owner: x.l.repo.owner_login, repo: x.l.repo.name, ref: x.l.repo.default_branch, dir: "" }} />
262+ </div>
263+ </div>
264+);
265+
266+const LineComments: FC<{ x: ConvCtx; list: Comment[] }> = ({ x, list }) => {
267+ const base = repoUrl(x.l.repo);
268+ return (
269+ <div>
270+ {list.map((cm) => (
271+ <div class="linec" id={`c-${cm.id}`}>
272+ <div class="row small" style="gap:8px">
273+ <a class="where" href={`${base}/pull/${x.issue.number}/files#diff-${encodeURIComponent(cm.path ?? "")}`}>
274+ {cm.path}
275+ {cm.line ? `:${cm.line}` : ""}
276+ </a>
277+ {cm.commitId && x.issue.pull && cm.commitId !== x.issue.pull.headSha ? <span class="badge">outdated</span> : null}
278+ {cm.resolved ? <span class="badge green">resolved</span> : null}
279+ <span class="grow" />
280+ <Actor login={cm.author.login} kind={cm.author.kind} avatar={false} />
281+ <Time at={cm.createdAt} />
282+ </div>
283+ <Markdown text={cm.body} />
284+ </div>
285+ ))}
286+ </div>
287+ );
288+};
289+
290+const Timeline: FC<{ x: ConvCtx }> = ({ x }) => {
291+ type Item = { at: string; node: Child };
292+ const items: Item[] = [];
293+ for (const cm of x.comments.filter((c) => !c.reviewId)) {
294+ items.push({ at: cm.createdAt, node: <CommentCard x={x} author={cm.author} at={cm.createdAt} body={cm.body} id={`c-${cm.id}`} /> });
295+ }
296+ for (const rv of x.reviews) {
297+ const lines = x.comments.filter((c) => c.reviewId === rv.id);
298+ const cls = rv.state === "APPROVED" ? "approved" : rv.state === "CHANGES_REQUESTED" ? "changes" : "";
299+ const verb = rv.state === "APPROVED" ? "approved these changes" : rv.state === "CHANGES_REQUESTED" ? "requested changes" : rv.state === "DISMISSED" ? "reviewed (dismissed)" : "reviewed";
300+ items.push({
301+ at: rv.submittedAt,
302+ node: (
303+ <div>
304+ <div class={`event ${cls}`}>
305+ <span class="ic">{raw(rv.state === "APPROVED" ? ICONS.check : rv.state === "CHANGES_REQUESTED" ? ICONS.x : ICONS.eye)}</span>
306+ <Actor login={rv.author.login} kind={rv.author.kind} onBehalfOf={x.behalf.get(rv.author.login)} avatar={false} />
307+ <span>
308+ {verb} <Time at={rv.submittedAt} />
309+ </span>
310+ <span class="faint mono small">on {shortSha(rv.commitId)}</span>
311+ </div>
312+ {rv.body.trim() || lines.length ? (
313+ <div class="comment" style="margin-left:24px">
314+ <div class="body">
315+ {rv.body.trim() ? <Markdown text={rv.body} /> : null}
316+ {lines.length ? <LineComments x={x} list={lines} /> : null}
317+ </div>
318+ </div>
319+ ) : null}
320+ </div>
321+ ),
322+ });
323+ }
324+ if (x.issue.state === "closed" && x.issue.closedAt) {
325+ items.push({
326+ at: x.issue.closedAt,
327+ node: (
328+ <div class="event">
329+ <span class="ic" style={x.issue.pull?.merged ? "color:var(--purple)" : ""}>
330+ {raw(x.issue.pull?.merged ? ICONS.prMerged : ICONS.issueClosed)}
331+ </span>
332+ {x.issue.pull?.merged ? (
333+ <span>
334+ <b>{x.issue.pull.mergedBy}</b> merged commit{" "}
335+ {x.issue.pull.mergeCommit ? (
336+ <a class="sha" href={`${repoUrl(x.l.repo)}/commit/${x.issue.pull.mergeCommit}`}>
337+ {shortSha(x.issue.pull.mergeCommit)}
338+ </a>
339+ ) : null}{" "}
340+ into <span class="mono">{x.issue.pull.base}</span> <Time at={x.issue.pull.mergedAt} />
341+ </span>
342+ ) : (
343+ <span>
344+ Closed{x.issue.stateReason && x.issue.stateReason !== "completed" ? ` as ${x.issue.stateReason.replace(/_/g, " ")}` : ""} <Time at={x.issue.closedAt} />
345+ </span>
346+ )}
347+ </div>
348+ ),
349+ });
350+ }
351+ items.sort((a, b) => a.at.localeCompare(b.at));
352+ return <div class="timeline">{items.map((i) => i.node)}</div>;
353+};
354+
355+const CommentForm: FC<{ x: ConvCtx }> = ({ x }) => {
356+ const p = x.c.get("principal");
357+ if (p.type !== "account") return <div class="blank">Sign in to join the conversation.</div>;
358+ if (x.l.repo.archived) return <div class="blank">This repository is archived; the conversation is read-only.</div>;
359+ const base = repoUrl(x.l.repo);
360+ const canState = x.issue.author.id === x.l.caller.id || atLeast(x.l.caller.role, "triage");
361+ const merged = !!x.issue.pull?.merged;
362+ return (
363+ <Form action={`${base}/issues/${x.issue.number}/comment`} t={csrfToken(x.c)} class="comment">
364+ <div class="head">
365+ <b>Add a comment</b>
366+ <span class="muted small">Markdown supported</span>
367+ </div>
368+ <div class="body">
369+ <textarea name="body" placeholder="Leave a comment" aria-label="Comment"></textarea>
370+ <div class="row end mt">
371+ {canState && !merged ? (
372+ x.issue.state === "open" ? (
373+ <>
374+ {x.issue.kind === "issue" ? (
375+ <button class="btn" name="action" value="not_planned">
376+ Close as not planned
377+ </button>
378+ ) : null}
379+ <button class="btn" name="action" value="close">
380+ {raw(ICONS.issueClosed)} Close {x.issue.kind === "pull" ? "pull request" : "issue"}
381+ </button>
382+ </>
383+ ) : (
384+ <button class="btn" name="action" value="reopen">
385+ Reopen
386+ </button>
387+ )
388+ ) : null}
389+ <button class="btn primary" name="action" value="comment">
390+ Comment
391+ </button>
392+ </div>
393+ </div>
394+ </Form>
395+ );
396+};
397+
398+const Sidebar: FC<{ x: ConvCtx }> = ({ x }) => {
399+ const base = repoUrl(x.l.repo);
400+ const t = csrfToken(x.c);
401+ const canTriage = atLeast(x.l.caller.role, "triage") && !x.l.repo.archived && x.c.get("principal").type === "account";
402+ const colors = new Map(x.labels.map((l) => [l.name, l.color]));
403+ const participants = [...new Map([x.issue.author, ...x.comments.map((c) => c.author), ...x.reviews.map((r) => r.author)].map((a) => [a.login, a])).values()];
404+ return (
405+ <aside class="side">
406+ {x.issue.pull ? (
407+ <section>
408+ <h3>Reviewers</h3>
409+ {x.reviews.length || x.issue.pull.requestedReviewers.length ? (
410+ <div>
411+ {[...new Map(x.reviews.filter((r) => r.state !== "COMMENTED").map((r) => [r.author.login, r])).values()].map((r) => (
412+ <div class="row small" style="margin:4px 0">
413+ <Actor login={r.author.login} kind={r.author.kind} />
414+ <span class="grow" />
415+ <span class={r.state === "APPROVED" ? "ok" : r.state === "CHANGES_REQUESTED" ? "bad" : "faint"}>{r.state === "APPROVED" ? "approved" : r.state === "CHANGES_REQUESTED" ? "changes requested" : "dismissed"}</span>
416+ </div>
417+ ))}
418+ {x.issue.pull.requestedReviewers.map((r) => (
419+ <div class="row small" style="margin:4px 0">
420+ <Actor login={r} />
421+ <span class="grow" />
422+ <span class="warn">pending</span>
423+ </div>
424+ ))}
425+ </div>
426+ ) : (
427+ <p class="muted small m0">No reviews yet.</p>
428+ )}
429+ </section>
430+ ) : null}
431+ <section>
432+ <h3>Labels</h3>
433+ {x.issue.labels.length ? (
434+ <div>
435+ {x.issue.labels.map((l) => (
436+ <Label name={l} color={colors.get(l)} href={`${base}/${x.issue.kind === "pull" ? "pulls" : "issues"}${qs({ label: l })}`} />
437+ ))}
438+ </div>
439+ ) : (
440+ <p class="muted small m0">None yet</p>
441+ )}
442+ {canTriage ? (
443+ <details class="mt" style="margin-top:8px">
444+ <summary class="small" style="cursor:pointer;color:var(--accent)">
445+ Edit labels
446+ </summary>
447+ <Form action={`${base}/issues/${x.issue.number}/labels`} t={t} class="mt" style="margin-top:8px">
448+ {x.labels.map((l) => (
449+ <label class="inline" style="margin:3px 0">
450+ <input type="checkbox" name="labels" value={l.name} checked={x.issue.labels.includes(l.name)} /> <Label name={l.name} color={l.color} />
451+ </label>
452+ ))}
453+ {x.issue.labels
454+ .filter((l) => !colors.has(l))
455+ .map((l) => (
456+ <label class="inline" style="margin:3px 0">
457+ <input type="checkbox" name="labels" value={l} checked /> <Label name={l} />
458+ </label>
459+ ))}
460+ <input type="text" name="new_label" placeholder="New label…" style="margin:6px 0" />
461+ <button class="btn sm">Apply</button>
462+ </Form>
463+ </details>
464+ ) : null}
465+ </section>
466+ <section>
467+ <h3>Assignees</h3>
468+ {x.issue.assignees.length ? (
469+ x.issue.assignees.map((a) => (
470+ <div style="margin:4px 0">
471+ <Actor login={a} />
472+ </div>
473+ ))
474+ ) : (
475+ <p class="muted small m0">No one assigned</p>
476+ )}
477+ {canTriage ? (
478+ <details style="margin-top:8px">
479+ <summary class="small" style="cursor:pointer;color:var(--accent)">
480+ Edit assignees
481+ </summary>
482+ <Form action={`${base}/issues/${x.issue.number}/assignees`} t={t} style="margin-top:8px">
483+ <input type="text" name="assignees" value={x.issue.assignees.join(", ")} placeholder="login, login" />
484+ <button class="btn sm" style="margin-top:6px">
485+ Save
486+ </button>
487+ </Form>
488+ </details>
489+ ) : null}
490+ </section>
491+ <section>
492+ <h3>Participants</h3>
493+ <div class="row" style="gap:4px">
494+ {participants.map((a) => (
495+ <a href={`/${encodeURIComponent(a.login)}`} title={a.login}>
496+ <span class={`av ${a.kind === "agent" ? "bot" : ""}`} style={`--h:${[...a.login].reduce((h, ch) => (h * 31 + ch.charCodeAt(0)) >>> 0, 0) % 360}`}>
497+ {(a.login.replace(/[^A-Za-z0-9]/g, "")[0] ?? "?").toUpperCase()}
498+ </span>
499+ </a>
500+ ))}
501+ </div>
502+ </section>
503+ </aside>
504+ );
505+};
506+
507+const TitleEditor: FC<{ x: ConvCtx }> = ({ x }) => {
508+ const can = x.c.get("principal").type === "account" && !x.l.repo.archived && (x.issue.author.id === x.l.caller.id || atLeast(x.l.caller.role, "triage"));
509+ if (!can) return null;
510+ return (
511+ <details class="menu">
512+ <summary class="btn sm">Edit</summary>
513+ <div class="pop right" style="width:min(560px,90vw)">
514+ <Form action={`${repoUrl(x.l.repo)}/issues/${x.issue.number}/edit`} t={csrfToken(x.c)} style="padding:8px">
515+ <div class="field">
516+ <label>Title</label>
517+ <input type="text" name="title" value={x.issue.title} required />
518+ </div>
519+ <div class="field">
520+ <label>Description</label>
521+ <textarea name="body">{x.issue.body}</textarea>
522+ </div>
523+ <button class="btn primary sm">Save</button>
524+ </Form>
525+ </div>
526+ </details>
527+ );
528+};
529+
530+const IssueHeader: FC<{ x: ConvCtx }> = ({ x }) => {
531+ const i = x.issue;
532+ const base = repoUrl(x.l.repo);
533+ return (
534+ <div class="mb" data-fragment="header">
535+ <div class="row between" style="align-items:flex-start">
536+ <h1 class="grow" style="font-size:24px;font-weight:500">
537+ {i.title} <span class="faint">#{i.number}</span>
538+ </h1>
539+ <div class="row">
540+ <TitleEditor x={x} />
541+ {i.kind === "issue" ? (
542+ <a class="btn sm primary" href={`${base}/issues/new`}>
543+ New issue
544+ </a>
545+ ) : null}
546+ </div>
547+ </div>
548+ <div class="row" style="gap:8px">
549+ <StateBadge i={i} />
550+ <Actor login={i.author.login} kind={i.author.kind} onBehalfOf={x.behalf.get(i.author.login)} avatar={false} />
551+ {i.pull ? (
552+ <span class="muted">
553+ {i.pull.merged ? "merged" : "wants to merge"} into <a class="sha" href={`${base}/tree/${encPath(i.pull.base)}`}>{i.pull.base}</a> from{" "}
554+ <a class="sha" href={`${base}/tree/${encPath(i.pull.head)}`}>
555+ {i.pull.head}
556+ </a>
557+ </span>
558+ ) : (
559+ <span class="muted">
560+ opened this issue <Time at={i.createdAt} /> · {i.comments} comment{i.comments === 1 ? "" : "s"}
561+ </span>
562+ )}
563+ </div>
564+ </div>
565+ );
566+};
567+
568+const PullTabs: FC<{ x: ConvCtx; tab: "conversation" | "commits" | "files" | "checks" }> = ({ x, tab }) => {
569+ const b = `${repoUrl(x.l.repo)}/pull/${x.issue.number}`;
570+ return (
571+ <nav class="tabs mb" style="border-bottom:1px solid var(--border);margin-top:0">
572+ <a href={b} class={tab === "conversation" ? "on" : ""}>
573+ {raw(ICONS.comment)} Conversation <span class="n">{x.issue.comments}</span>
574+ </a>
575+ <a href={`${b}/commits`} class={tab === "commits" ? "on" : ""}>
576+ {raw(ICONS.commit)} Commits
577+ </a>
578+ <a href={`${b}/checks`} class={tab === "checks" ? "on" : ""}>
579+ {raw(ICONS.play)} Checks
580+ </a>
581+ <a href={`${b}/files`} class={tab === "files" ? "on" : ""}>
582+ {raw(ICONS.file)} Files changed
583+ </a>
584+ </nav>
585+ );
586+};
587+
588+// ---------------------------------------------------------------- merge box and review
589+
590+const MergeBox: FC<{ x: ConvCtx; m: Mergeability | null; checks: Check[] }> = ({ x, m, checks }) => {
591+ const i = x.issue;
592+ const p = i.pull!;
593+ const base = repoUrl(x.l.repo);
594+ const caller = x.l.caller;
595+ const isAccount = x.c.get("principal").type === "account";
596+ if (p.merged) {
597+ return (
598+ <div class="mergebox merged" data-fragment="merge">
599+ <div class="row">
600+ <span class="st merged">{raw(ICONS.prMerged)}</span>
601+ <div>
602+ <b>Pull request successfully merged and closed</b>
603+ <div class="muted small">
604+ {p.mergedBy} merged {p.mergeCommit ? <a href={`${base}/commit/${p.mergeCommit}`}>{shortSha(p.mergeCommit)}</a> : null} into {p.base} <Time at={p.mergedAt} />
605+ </div>
606+ </div>
607+ </div>
608+ </div>
609+ );
610+ }
611+ if (i.state === "closed") {
612+ return (
613+ <div class="mergebox" data-fragment="merge">
614+ <div>
615+ <b>This pull request is closed.</b>
616+ <span class="muted"> Reopen it to continue.</span>
617+ </div>
618+ </div>
619+ );
620+ }
621+ if (!m) return <div class="mergebox" data-fragment="merge" />;
622+ const headline =
623+ m.state === "clean"
624+ ? "This branch has no conflicts with the base branch"
625+ : m.state === "conflicting"
626+ ? "This branch has conflicts that must be resolved"
627+ : m.state === "draft"
628+ ? "This pull request is still a work in progress"
629+ : "Merging is blocked";
630+ const bypass = !m.mergeable && caller.role === "admin" && caller.kind === "user" && m.conflicts.length === 0 && !p.draft;
631+ const showChecks = checks.length ? checks : m.checks.map((ch) => ({ name: ch.name, status: ch.state === "queued" || ch.state === "in_progress" ? ch.state : "completed", conclusion: ch.state, runId: null, jobId: null, output: null }) as unknown as Check);
632+ const failing = showChecks.filter((ch) => ch.status === "completed" && ch.conclusion !== "success" && ch.conclusion !== "skipped" && ch.conclusion !== "neutral").length;
633+ const pending = showChecks.filter((ch) => ch.status !== "completed").length;
634+ return (
635+ <div class={`mergebox ${m.state}`} data-fragment="merge">
636+ {showChecks.length ? (
637+ <div>
638+ <div class="row mb" style="margin-bottom:6px">
639+ <Status status={pending ? "in_progress" : "completed"} conclusion={failing ? "failure" : "success"} />
640+ <b>{pending ? "Some checks haven't completed yet" : failing ? `${failing} failing check${failing === 1 ? "" : "s"}` : "All checks have passed"}</b>
641+ <span class="muted small">
642+ {showChecks.length} check{showChecks.length === 1 ? "" : "s"}
643+ </span>
644+ </div>
645+ <ul class="checks">
646+ {showChecks.map((ch) => (
647+ <li>
648+ <Status status={ch.status} conclusion={ch.conclusion} />
649+ {ch.runId ? <a href={`${base}/actions/runs/${ch.runId}${ch.jobId ? `/job/${ch.jobId}` : ""}`}>{ch.name}</a> : <span>{ch.name}</span>}
650+ <span class="muted small grow">{ch.output?.title && ch.output.title !== ch.conclusion ? ch.output.title : ""}</span>
651+ <span class="muted small">{ch.startedAt ? duration(ch.startedAt, ch.completedAt) : ""}</span>
652+ </li>
653+ ))}
654+ </ul>
655+ </div>
656+ ) : null}
657+ <div>
658+ <div class="row">
659+ <Status conclusion={m.approvals >= m.requiredApprovals && !m.changesRequested.length ? "success" : "pending"} />
660+ <span>
661+ <b>
662+ {m.approvals} approval{m.approvals === 1 ? "" : "s"}
663+ </b>
664+ {m.requiredApprovals ? <span class="muted"> of {m.requiredApprovals} required</span> : null}
665+ {m.changesRequested.length ? <span class="bad"> · changes requested by {m.changesRequested.join(", ")}</span> : null}
666+ </span>
667+ </div>
668+ </div>
669+ <div>
670+ <div class="row">
671+ <Status conclusion={m.mergeable ? "success" : m.state === "conflicting" ? "failure" : "pending"} />
672+ <b>{headline}</b>
673+ </div>
674+ {m.conflicts.length ? (
675+ <ul class="small mono" style="margin:6px 0 0 24px">
676+ {m.conflicts.map((f) => (
677+ <li>{f}</li>
678+ ))}
679+ </ul>
680+ ) : null}
681+ {m.blockers.length ? (
682+ <ul class="small" style="margin:6px 0 0 24px;color:var(--fg2)">
683+ {m.blockers.map((b) => (
684+ <li>{b}</li>
685+ ))}
686+ </ul>
687+ ) : null}
688+ </div>
689+ {isAccount && atLeast(caller.role, "write") && !x.l.repo.archived ? (
690+ <div>
691+ <Form action={`${base}/pull/${i.number}/merge`} t={csrfToken(x.c)} class="row" confirm={bypass ? "Merge without meeting the branch requirements?" : undefined}>
692+ <input type="hidden" name="expected_head" value={p.headSha} />
693+ <select name="method" style="width:auto">
694+ <option value="merge">Create a merge commit</option>
695+ <option value="squash">Squash and merge</option>
696+ <option value="rebase">Rebase and merge (fast-forward)</option>
697+ </select>
698+ <label class="inline">
699+ <input type="checkbox" name="delete_branch" value="1" checked /> Delete <span class="mono">{p.head}</span> after merge
700+ </label>
701+ <span class="grow" />
702+ {m.mergeable ? (
703+ <button class="btn green">Merge pull request</button>
704+ ) : bypass ? (
705+ <button class="btn danger">Merge without waiting for requirements (bypass)</button>
706+ ) : (
707+ <button class="btn" disabled>
708+ Merge pull request
709+ </button>
710+ )}
711+ </Form>
712+ </div>
713+ ) : null}
714+ </div>
715+ );
716+};
717+
718+const ReviewForm: FC<{ x: ConvCtx }> = ({ x }) => {
719+ if (x.c.get("principal").type !== "account" || x.issue.state !== "open" || x.l.repo.archived) return null;
720+ const own = x.issue.author.id === x.l.caller.id;
721+ const canApprove = !own && atLeast(x.l.caller.role, "write");
722+ return (
723+ <details class="box">
724+ <summary class="head" style="cursor:pointer">
725+ {raw(ICONS.eye)} <b>Review changes</b>
726+ <span class="muted small">{own ? "You can comment on your own pull request." : "Approve, request changes or leave a review comment."}</span>
727+ </summary>
728+ <Form action={`${repoUrl(x.l.repo)}/pull/${x.issue.number}/review`} t={csrfToken(x.c)} class="body">
729+ <div class="field">
730+ <textarea name="body" placeholder="Review summary (Markdown)"></textarea>
731+ </div>
732+ <div class="field">
733+ <label class="inline">
734+ <input type="radio" name="event" value="COMMENT" checked /> Comment
735+ </label>
736+ <label class="inline">
737+ <input type="radio" name="event" value="APPROVE" disabled={!canApprove} /> <span class="ok">Approve</span>
738+ </label>
739+ <label class="inline">
740+ <input type="radio" name="event" value="REQUEST_CHANGES" disabled={!canApprove} /> <span class="bad">Request changes</span>
741+ </label>
742+ </div>
743+ <button class="btn primary">Submit review</button>
744+ </Form>
745+ </details>
746+ );
747+};
748+
749+// ---------------------------------------------------------------- pages
750+
751+async function loadConv(c: AppContext): Promise<ConvCtx> {
752+ const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
753+ const n = issueNumber(c);
754+ const [data, labels] = await Promise.all([l.stub.issue(n), l.stub.labels()]);
755+ const behalf = await onBehalfOf(c, [data.issue.author, ...data.comments.map((x) => x.author), ...data.reviews.map((x) => x.author)]);
756+ return { c, l, issue: data.issue, comments: data.comments, reviews: data.reviews, labels, behalf };
757+}
758+
759+async function issuePage(c: AppContext): Promise<Response> {
760+ const x = await loadConv(c);
761+ const base = repoUrl(x.l.repo);
762+ if (x.issue.kind === "pull") return c.redirect(`${base}/pull/${x.issue.number}${new URL(c.req.url).search}`, 302);
763+ return render(
764+ c,
765+ { title: `${x.issue.title} · Issue #${x.issue.number}`, repo: { repo: x.l.repo, caller: x.l.caller, tab: "issues" }, live: `pr:${x.issue.number}` },
766+ <div>
767+ <IssueHeader x={x} />
768+ <div class="grid">
769+ <div>
770+ <div data-fragment="timeline">
771+ <CommentCard x={x} author={x.issue.author} at={x.issue.createdAt} body={x.issue.body} verb="opened" />
772+ <Timeline x={x} />
773+ </div>
774+ <CommentForm x={x} />
775+ </div>
776+ <Sidebar x={x} />
777+ </div>
778+ </div>,
779+ );
780+}
781+
782+async function pullPage(c: AppContext, tab: "conversation" | "commits" | "files" | "checks"): Promise<Response> {
783+ const x = await loadConv(c);
784+ const { repo, caller, stub } = x.l;
785+ const base = repoUrl(repo);
786+ const i = x.issue;
787+ if (i.kind !== "pull" || !i.pull) return c.redirect(`${base}/issues/${i.number}`, 302);
788+ const n = i.number;
789+ const page = (body: Child, wide = false) =>
790+ render(
791+ c,
792+ { title: `${i.title} · Pull request #${n}`, repo: { repo, caller, tab: "pulls", ref: i.pull!.head }, live: `pr:${n}`, wide },
793+ <div>
794+ <IssueHeader x={x} />
795+ <PullTabs x={x} tab={tab} />
796+ {body}
797+ </div>,
798+ );
799+
800+ if (tab === "conversation") {
801+ const [m, checks] = await Promise.all([i.state === "open" ? stub.mergeability(n, caller).catch(() => null) : Promise.resolve(null), stub.checks(i.pull.headSha).catch(() => [] as Check[])]);
802+ return page(
803+ <div class="grid">
804+ <div>
805+ <div data-fragment="timeline">
806+ <CommentCard x={x} author={i.author} at={i.createdAt} body={i.body} verb="opened" />
807+ {i.pull.intent ? (
808+ <div class="comment intent">
809+ <div class="head">
810+ {raw(ICONS.zap)} <b>Intent</b>
811+ <span class="muted small">what this change is meant to do</span>
812+ </div>
813+ <div class="body">
814+ <Markdown text={i.pull.intent} />
815+ </div>
816+ </div>
817+ ) : null}
818+ <Timeline x={x} />
819+ </div>
820+ <MergeBox x={x} m={m} checks={checks} />
821+ <ReviewForm x={x} />
822+ <CommentForm x={x} />
823+ </div>
824+ <Sidebar x={x} />
825+ </div>,
826+ );
827+ }
828+
829+ if (tab === "commits") {
830+ const commits = await stub.pullCommits(n).catch(() => []);
831+ return page(
832+ <div class="box">
833+ {commits.length ? (
834+ <ul class="list">
835+ {commits.map((cm) => (
836+ <CommitRow base={base} sha={cm.sha} message={cm.message} author={cm.author} signed={cm.signed} />
837+ ))}
838+ </ul>
839+ ) : (
840+ <div class="empty">No commits.</div>
841+ )}
842+ </div>,
843+ );
844+ }
845+
846+ if (tab === "checks") {
847+ const [checks, runs] = await Promise.all([stub.checks(i.pull.headSha).catch(() => [] as Check[]), stub.runs({ prNumber: n, limit: 30 }).catch(() => ({ runs: [], total: 0 }))]);
848+ return page(
849+ <div data-fragment="checks">
850+ <div class="box">
851+ <div class="head">
852+ <h3>Checks for {shortSha(i.pull.headSha)}</h3>
853+ </div>
854+ {checks.length ? (
855+ <ul class="list">
856+ {checks.map((ch) => (
857+ <li>
858+ <Status status={ch.status} conclusion={ch.conclusion} />
859+ <div class="grow">
860+ {ch.runId ? (
861+ <a class="title" href={`${base}/actions/runs/${ch.runId}${ch.jobId ? `/job/${ch.jobId}` : ""}`}>
862+ {ch.name}
863+ </a>
864+ ) : (
865+ <span class="title">{ch.name}</span>
866+ )}
867+ <div class="meta">
868+ {ch.app} · {ch.status === "completed" ? (ch.conclusion ?? "neutral") : ch.status.replace("_", " ")}
869+ {ch.output?.summary ? ` · ${ch.output.summary.slice(0, 200)}` : ""}
870+ </div>
871+ </div>
872+ <span class="muted small">{duration(ch.startedAt, ch.completedAt)}</span>
873+ </li>
874+ ))}
875+ </ul>
876+ ) : (
877+ <div class="empty">No checks have reported for the latest commit.</div>
878+ )}
879+ </div>
880+ <div class="box">
881+ <div class="head">
882+ <h3>Workflow runs</h3>
883+ </div>
884+ {runs.runs.length ? (
885+ <ul class="list">
886+ {runs.runs.map((r) => (
887+ <li>
888+ <Status status={r.status} conclusion={r.conclusion} />
889+ <div class="grow">
890+ <a class="title" href={`${base}/actions/runs/${r.id}`}>
891+ {r.title}
892+ </a>
893+ <div class="meta">
894+ {r.workflowName} #{r.number} · {r.event} · {shortSha(r.sha)} · <Time at={r.createdAt} />
895+ </div>
896+ </div>
897+ </li>
898+ ))}
899+ </ul>
900+ ) : (
901+ <div class="empty">No workflow runs for this pull request.</div>
902+ )}
903+ </div>
904+ </div>,
905+ );
906+ }
907+
908+ // Files changed
909+ const diff = await stub.pullFiles(n);
910+ const threads = new Map<string, LineThread[]>();
911+ const byKey = new Map<string, Comment[]>();
912+ for (const cm of x.comments) {
913+ if (!cm.path || !cm.line) continue;
914+ const key = `${cm.path}\u0000${cm.side === "LEFT" ? "LEFT" : "RIGHT"}\u0000${cm.line}`;
915+ byKey.set(key, [...(byKey.get(key) ?? []), cm]);
916+ }
917+ for (const [key, list] of byKey) {
918+ const [path, side, line] = key.split("\u0000") as [string, "LEFT" | "RIGHT", string];
919+ threads.set(path, [...(threads.get(path) ?? []), { side, line: Number(line), node: <LineComments x={x} list={list} /> }]);
920+ }
921+ const canComment = c.get("principal").type === "account" && i.state === "open" && !repo.archived;
922+ const files = diff.files as DiffFile[];
923+ return page(
924+ <div>
925+ <div class="row between mb">
926+ <DiffStats files={diff.totalFiles} additions={diff.additions} deletions={diff.deletions} />
927+ <span class="muted small">
928+ {diff.mergeBase ? (
929+ <>
930+ base <span class="sha">{shortSha(diff.mergeBase)}</span> →{" "}
931+ </>
932+ ) : null}
933+ head <span class="sha">{shortSha(diff.headSha)}</span>
934+ </span>
935+ </div>
936+ {canComment ? (
937+ <template id="line-comment-tpl">
938+ <Form action={`${base}/pull/${n}/line-comment`} t={csrfToken(c)}>
939+ <input type="hidden" name="path" />
940+ <input type="hidden" name="line" />
941+ <input type="hidden" name="side" />
942+ <div class="small muted mb" style="margin-bottom:6px">
943+ Comment on <span class="where mono" />
944+ </div>
945+ <textarea name="body" required placeholder="Leave a comment on this line" style="min-height:80px"></textarea>
946+ <div class="row end" style="margin-top:6px">
947+ <button type="button" class="btn sm" data-cancel-line="1">
948+ Cancel
949+ </button>
950+ <button class="btn sm primary">Add comment</button>
951+ </div>
952+ </Form>
953+ </template>
954+ ) : null}
955+ <FileSummary files={files} />
956+ <DiffView files={files} opts={{ commentable: canComment, threads, viewHref: (p) => `${base}/blob/${diff.headSha}/${encPath(p)}` }} />
957+ {diff.truncated ? <div class="flash info">Only the first {files.length} files are shown.</div> : null}
958+ </div>,
959+ true,
960+ );
961+}
962+
963+// ---------------------------------------------------------------- routes
964+
965+export function registerCollab(web: WebApp): void {
966+ web.get("/:owner/:repo/issues", (c) => listPage(c, "issue"));
967+ web.get("/:owner/:repo/pulls", (c) => listPage(c, "pull"));
968+
969+ const NewIssue: FC<{ c: AppContext; l: Loaded; labels: LabelDef[]; v: { title: string; body: string; labels: string[] }; error?: string }> = ({ c, l, labels, v, error }) => (
970+ <div style="max-width:900px">
971+ <h1>New issue</h1>
972+ {error ? <div class="flash error">{error}</div> : null}
973+ <Form action={`${repoUrl(l.repo)}/issues`} t={csrfToken(c)} class="box">
974+ <div class="body">
975+ <div class="field">
976+ <label for="it">Title</label>
977+ <input id="it" name="title" type="text" required value={v.title} autofocus />
978+ </div>
979+ <div class="field">
980+ <label for="ib">Description</label>
981+ <textarea id="ib" name="body" style="min-height:220px" placeholder="Markdown is supported.">
982+ {v.body}
983+ </textarea>
984+ </div>
985+ {labels.length && atLeast(l.caller.role, "triage") ? (
986+ <div class="field">
987+ <label>Labels</label>
988+ {labels.map((lb) => (
989+ <label class="inline" style="margin-right:12px">
990+ <input type="checkbox" name="labels" value={lb.name} checked={v.labels.includes(lb.name)} /> <Label name={lb.name} color={lb.color} />
991+ </label>
992+ ))}
993+ </div>
994+ ) : null}
995+ <button class="btn green">Submit new issue</button>
996+ </div>
997+ </Form>
998+ </div>
999+ );
1000+
1001+ web.get("/:owner/:repo/issues/new", async (c) => {
1002+ const l = await forAction(c);
1003+ const labels = await l.stub.labels();
1004+ return render(c, { title: `New issue · ${l.repo.owner_login}/${l.repo.name}`, repo: { repo: l.repo, caller: l.caller, tab: "issues" } }, <NewIssue c={c} l={l} labels={labels} v={{ title: c.req.query("title") ?? "", body: "", labels: [] }} />);
1005+ });
1006+
1007+ post(web, "/:owner/:repo/issues", async (c, f) => {
1008+ const l = await forAction(c);
1009+ const v = { title: f.s("title").trim(), body: f.s("body").replace(/\r\n/g, "\n"), labels: atLeast(l.caller.role, "triage") ? f.all("labels") : [] };
1010+ try {
1011+ const issue = await l.stub.createIssue({ title: v.title, body: v.body, labels: v.labels }, l.caller);
1012+ return c.redirect(`${repoUrl(l.repo)}/issues/${issue.number}`, 303);
1013+ } catch (e) {
1014+ const s = statusOf(e);
1015+ if (s.status >= 500) throw e;
1016+ const labels = await l.stub.labels();
1017+ return render(c, { title: "New issue", repo: { repo: l.repo, caller: l.caller, tab: "issues" }, status: s.status }, <NewIssue c={c} l={l} labels={labels} v={v} error={s.message} />);
1018+ }
1019+ });
1020+
1021+ web.get("/:owner/:repo/issues/:n", (c) => issuePage(c));
1022+ web.get("/:owner/:repo/pull/:n", (c) => pullPage(c, "conversation"));
1023+ web.get("/:owner/:repo/pull/:n/commits", (c) => pullPage(c, "commits"));
1024+ web.get("/:owner/:repo/pull/:n/files", (c) => pullPage(c, "files"));
1025+ web.get("/:owner/:repo/pull/:n/checks", (c) => pullPage(c, "checks"));
1026+ web.get("/:owner/:repo/pulls/:n", (c) => c.redirect(`/${prm(c, "owner")}/${prm(c, "repo")}/pull/${prm(c, "n")}`, 301));
1027+
1028+ const backTo = async (l: Loaded, n: number): Promise<string> => {
1029+ const { issue } = await l.stub.issue(n);
1030+ return `${repoUrl(l.repo)}/${kindPath(issue)}/${n}`;
1031+ };
1032+
1033+ post(web, "/:owner/:repo/issues/:n/comment", async (c, f) => {
1034+ const l = await forAction(c);
1035+ const n = issueNumber(c);
1036+ const back = await backTo(l, n);
1037+ const body = f.s("body").replace(/\r\n/g, "\n");
1038+ const action = f.s("action");
1039+ return act(c, back, async () => {
1040+ if (body.trim()) await l.stub.addComment(n, body, l.caller);
1041+ else if (action === "comment") throw new HttpError(422, "write a comment first");
1042+ if (action === "close" || action === "not_planned") await l.stub.updateIssue(n, { state: "closed", stateReason: action === "not_planned" ? "not_planned" : "completed" }, l.caller);
1043+ if (action === "reopen") await l.stub.updateIssue(n, { state: "open" }, l.caller);
1044+ });
1045+ });
1046+
1047+ post(web, "/:owner/:repo/issues/:n/edit", async (c, f) => {
1048+ const l = await forAction(c);
1049+ const n = issueNumber(c);
1050+ const back = await backTo(l, n);
1051+ return act(c, back, async () => {
1052+ const title = f.s("title").trim();
1053+ if (!title) throw new HttpError(422, "title is required");
1054+ await l.stub.updateIssue(n, { title, body: f.s("body").replace(/\r\n/g, "\n") }, l.caller);
1055+ });
1056+ });
1057+
1058+ post(web, "/:owner/:repo/issues/:n/labels", async (c, f) => {
1059+ const l = await forAction(c, "triage");
1060+ const n = issueNumber(c);
1061+ const back = await backTo(l, n);
1062+ return act(c, back, async () => {
1063+ const labels = new Set(f.all("labels").map((x) => x.trim()).filter(Boolean));
1064+ const fresh = f.s("new_label").trim().slice(0, 50);
1065+ if (fresh) {
1066+ const existing = await l.stub.labels();
1067+ if (!existing.some((x) => x.name === fresh)) await l.stub.setLabel(fresh, LABEL_COLORS[existing.length % LABEL_COLORS.length]!, "");
1068+ labels.add(fresh);
1069+ }
1070+ await l.stub.updateIssue(n, { labels: [...labels] }, l.caller);
1071+ });
1072+ });
1073+
1074+ post(web, "/:owner/:repo/issues/:n/assignees", async (c, f) => {
1075+ const l = await forAction(c, "triage");
1076+ const n = issueNumber(c);
1077+ const back = await backTo(l, n);
1078+ return act(c, back, async () => {
1079+ const assignees = [...new Set(f.s("assignees").split(/[\s,]+/).map((s) => s.replace(/^@/, "")).filter(Boolean))].slice(0, 10);
1080+ await l.stub.updateIssue(n, { assignees }, l.caller);
1081+ });
1082+ });
1083+
1084+ post(web, "/:owner/:repo/pull/:n/review", async (c, f) => {
1085+ const l = await forAction(c);
1086+ const n = issueNumber(c);
1087+ const event = (["APPROVE", "REQUEST_CHANGES", "COMMENT"] as const).find((e) => e === f.s("event")) ?? "COMMENT";
1088+ return act(c, `${repoUrl(l.repo)}/pull/${n}`, async () => {
1089+ await l.stub.submitReview(n, { event, body: f.s("body").replace(/\r\n/g, "\n") }, l.caller);
1090+ });
1091+ });
1092+
1093+ post(web, "/:owner/:repo/pull/:n/line-comment", async (c, f) => {
1094+ const l = await forAction(c);
1095+ const n = issueNumber(c);
1096+ const line = parseInt(f.s("line"), 10);
1097+ const side = f.s("side") === "LEFT" ? "LEFT" : "RIGHT";
1098+ return act(c, `${repoUrl(l.repo)}/pull/${n}/files`, async () => {
1099+ const body = f.s("body").replace(/\r\n/g, "\n");
1100+ if (!body.trim()) throw new HttpError(422, "comment is empty");
1101+ if (!Number.isInteger(line) || line <= 0 || !f.s("path")) throw new HttpError(422, "invalid line");
1102+ await l.stub.submitReview(n, { event: "COMMENT", comments: [{ path: f.s("path"), line, side, body }] }, l.caller);
1103+ });
1104+ });
1105+
1106+ post(web, "/:owner/:repo/pull/:n/merge", async (c, f) => {
1107+ const l = await forAction(c, "write");
1108+ const n = issueNumber(c);
1109+ const method = (["merge", "squash", "rebase"] as const).find((m) => m === f.s("method")) ?? "merge";
1110+ return act(
1111+ c,
1112+ `${repoUrl(l.repo)}/pull/${n}`,
1113+ async () => {
1114+ await l.stub.mergePull(n, { method, deleteBranch: f.b("delete_branch"), expectedHeadSha: f.s("expected_head") || undefined }, l.caller);
1115+ c.executionCtx.waitUntil(c.get("dir").touchPushed(l.repo.id));
1116+ },
1117+ "Pull request merged.",
1118+ );
1119+ });
1120+
1121+ // Labels
1122+ web.get("/:owner/:repo/labels", async (c) => {
1123+ const { repo, caller, stub } = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
1124+ const base = repoUrl(repo);
1125+ const t = csrfToken(c);
1126+ const labels = await stub.labels();
1127+ const canEdit = atLeast(caller.role, "triage") && !repo.archived && c.get("principal").type === "account";
1128+ return render(
1129+ c,
1130+ { title: `Labels · ${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "issues" } },
1131+ <div style="max-width:900px">
1132+ <h1>Labels</h1>
1133+ <div class="box">
1134+ {labels.length ? (
1135+ <ul class="list">
1136+ {labels.map((lb) => (
1137+ <li style="align-items:center">
1138+ <div style="width:220px">
1139+ <Label name={lb.name} color={lb.color} href={`${base}/issues${qs({ label: lb.name })}`} />
1140+ </div>
1141+ <span class="grow muted small">{lb.description}</span>
1142+ {canEdit ? (
1143+ <Form action={`${base}/labels/delete`} t={t} class="inline" confirm={`Delete label ${lb.name}?`}>
1144+ <input type="hidden" name="name" value={lb.name} />
1145+ <button class="btn sm danger">Delete</button>
1146+ </Form>
1147+ ) : null}
1148+ </li>
1149+ ))}
1150+ </ul>
1151+ ) : (
1152+ <div class="empty">No labels yet.</div>
1153+ )}
1154+ </div>
1155+ {canEdit ? (
1156+ <Form action={`${base}/labels`} t={t} class="box">
1157+ <div class="head">
1158+ <h3>New label</h3>
1159+ </div>
1160+ <div class="body row" style="align-items:flex-end">
1161+ <div class="field m0">
1162+ <label>Name</label>
1163+ <input name="name" type="text" required maxlength={50} />
1164+ </div>
1165+ <div class="field grow m0">
1166+ <label>Description</label>
1167+ <input name="description" type="text" maxlength={100} />
1168+ </div>
1169+ <div class="field m0">
1170+ <label>Color</label>
1171+ <input name="color" type="color" value={`#${LABEL_COLORS[labels.length % LABEL_COLORS.length]}`} style="width:56px;height:36px;padding:2px" />
1172+ </div>
1173+ <button class="btn primary">Create label</button>
1174+ </div>
1175+ </Form>
1176+ ) : null}
1177+ </div>,
1178+ );
1179+ });
1180+
1181+ post(web, "/:owner/:repo/labels", async (c, f) => {
1182+ const l = await forAction(c, "triage");
1183+ return act(c, `${repoUrl(l.repo)}/labels`, async () => {
1184+ const name = f.s("name").trim().slice(0, 50);
1185+ const color = f.s("color").replace(/^#/, "");
1186+ if (!name) throw new HttpError(422, "name is required");
1187+ if (!/^[0-9a-fA-F]{6}$/.test(color)) throw new HttpError(422, "invalid color");
1188+ await l.stub.setLabel(name, color, f.s("description").trim().slice(0, 100));
1189+ });
1190+ });
1191+
1192+ post(web, "/:owner/:repo/labels/delete", async (c, f) => {
1193+ const l = await forAction(c, "triage");
1194+ return act(c, `${repoUrl(l.repo)}/labels`, async () => l.stub.setLabel(f.s("name"), null));
1195+ });
1196+}
1197+
1198+const LABEL_COLORS = ["d73a4a", "0075ca", "a2eeef", "7057ff", "008672", "e4e669", "d876e3", "fbca04", "0e8a16", "5319e7", "b60205", "1d76db"];
addedapps/diffinery/src/web/repo-settings.tsx+744−0View file
@@ -1,0 +1,744 @@
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 { loadRepo } from "../http/common.js";
6+import { HttpError } from "../lib/util.js";
7+import type { ProtectionRules, Role } from "../repo/types.js";
8+import { ICONS } from "./assets.js";
9+import { csrfToken, type FormData } from "./csrf.js";
10+import { Actor, CopyButton, Form, ROLES, Time, act, ago, post, refNames, render, repoUrl, statusOf, type WebApp, prm } from "./ui.js";
11+
12+type Loaded = Awaited<ReturnType<typeof loadRepo>>;
13+type Tab = "general" | "access" | "branches" | "secrets" | "webhooks";
14+
15+const WEBHOOK_EVENTS = ["push", "pull_request", "pull_request_review", "issues", "issue_comment", "workflow_run", "workflow_dispatch"];
16+
17+const load = (c: AppContext) => loadRepo(c, prm(c, "owner"), prm(c, "repo"), "admin");
18+
19+function audit(c: AppContext, l: Loaded, action: string, detail?: Record<string, unknown>): void {
20+ const p = c.get("principal");
21+ const actor = p.type === "account" ? p.account : null;
22+ c.executionCtx.waitUntil(
23+ c
24+ .get("dir")
25+ .audit({ actorId: actor?.id, actorLogin: actor?.login, onBehalfOf: l.caller.onBehalfOf, action, target: `${l.repo.owner_login}/${l.repo.name}`, detail })
26+ .catch((e) => console.error("audit failed", e)),
27+ );
28+}
29+
30+function page(c: AppContext, l: Loaded, tab: Tab, body: Child, status?: number) {
31+ const base = `${repoUrl(l.repo)}/settings`;
32+ const nav: Array<[Tab, string, string]> = [
33+ ["general", "General", base],
34+ ["access", "Collaborators", `${base}/access`],
35+ ["branches", "Branch protection", `${base}/branches`],
36+ ["secrets", "Secrets and variables", `${base}/secrets`],
37+ ["webhooks", "Webhooks", `${base}/webhooks`],
38+ ];
39+ return render(
40+ c,
41+ { title: `Settings · ${l.repo.owner_login}/${l.repo.name}`, repo: { repo: l.repo, caller: l.caller, tab: "settings" }, status },
42+ <div class="grid left">
43+ <aside class="side">
44+ <section>
45+ <div class="nav">
46+ {nav.map(([k, label, href]) => (
47+ <a href={href} class={tab === k ? "on" : ""}>
48+ {label}
49+ </a>
50+ ))}
51+ </div>
52+ </section>
53+ </aside>
54+ <div>{body}</div>
55+ </div>,
56+ );
57+}
58+
59+const Once: FC<{ title: string; value: string; note: string }> = ({ title, value, note }) => (
60+ <div class="box" style="border-color:var(--amber)">
61+ <div class="body">
62+ <h3>{title}</h3>
63+ <p class="muted">{note}</p>
64+ <div class="row">
65+ <div class="secret grow">{value}</div>
66+ <CopyButton text={value} />
67+ </div>
68+ </div>
69+ </div>
70+);
71+
72+// ---------------------------------------------------------------- protection form
73+
74+const RULE_FLAGS: Array<[keyof ProtectionRules, string, string]> = [
75+ ["requirePullRequest", "Require a pull request before merging", "Direct pushes are rejected; changes land through reviewed pull requests."],
76+ ["dismissStaleReviews", "Dismiss stale approvals when new commits are pushed", ""],
77+ ["requireLinearHistory", "Require linear history", "Merge commits are rejected; use squash or rebase merges."],
78+ ["allowForcePush", "Allow force pushes", "Off by default for protected branches."],
79+ ["allowDeletion", "Allow deletion", ""],
80+ ["enforceAdmins", "Do not allow administrators to bypass these rules", ""],
81+ ["agentsMayPush", "Allow agents to push directly", "By default agents must open pull requests against protected and default branches."],
82+ ["agentsMayMerge", "Allow agents to merge pull requests", "By default a human must merge into protected and default branches."],
83+];
84+
85+const RuleForm: FC<{ c: AppContext; base: string; pattern?: string; rules?: ProtectionRules }> = ({ c, base, pattern, rules = {} }) => (
86+ <Form action={`${base}/settings/branches`} t={csrfToken(c)} class="body">
87+ <input type="hidden" name="original" value={pattern ?? ""} />
88+ <div class="row" style="align-items:flex-start">
89+ <div class="field grow">
90+ <label>Branch name pattern</label>
91+ <input name="pattern" type="text" required value={pattern ?? ""} placeholder="main, release/*, **" class="mono" />
92+ <div class="hint">
93+ <code>*</code> matches within a path segment, <code>**</code> across segments.
94+ </div>
95+ </div>
96+ <div class="field" style="width:180px">
97+ <label>Required approvals</label>
98+ <input name="requiredApprovals" type="number" min={0} max={10} value={String(rules.requiredApprovals ?? 0)} />
99+ </div>
100+ </div>
101+ <div class="field">
102+ <label>Required status checks</label>
103+ <input name="requiredChecks" type="text" value={(rules.requiredChecks ?? []).join(", ")} placeholder="ci / test, lint" />
104+ <div class="hint">Comma-separated check names, exactly as they appear on pull requests.</div>
105+ </div>
106+ <div class="field">
107+ {RULE_FLAGS.map(([key, label, hint]) => (
108+ <label class="inline" style="margin:4px 0;align-items:flex-start">
109+ <input type="checkbox" name={key} value="1" checked={!!rules[key]} style="margin-top:4px" />
110+ <span>
111+ {label}
112+ {hint ? <span class="muted small"> — {hint}</span> : null}
113+ </span>
114+ </label>
115+ ))}
116+ </div>
117+ <button class="btn primary">{pattern ? "Save rule" : "Create rule"}</button>
118+ </Form>
119+);
120+
121+function rulesFrom(f: FormData): ProtectionRules {
122+ const rules: ProtectionRules = {};
123+ for (const [key] of RULE_FLAGS) if (f.b(key)) (rules as Record<string, unknown>)[key] = true;
124+ const approvals = Math.max(0, Math.min(10, parseInt(f.s("requiredApprovals"), 10) || 0));
125+ if (approvals) rules.requiredApprovals = approvals;
126+ const checks = f
127+ .s("requiredChecks")
128+ .split(/[,\n]/)
129+ .map((s) => s.trim())
130+ .filter(Boolean);
131+ if (checks.length) rules.requiredChecks = checks;
132+ return rules;
133+}
134+
135+// ---------------------------------------------------------------- pages
136+
137+async function generalPage(c: AppContext, l: Loaded) {
138+ const { repo } = l;
139+ const base = repoUrl(repo);
140+ const t = csrfToken(c);
141+ const names = await refNames(l.stub);
142+ const full = `${repo.owner_login}/${repo.name}`;
143+ return page(
144+ c,
145+ l,
146+ "general",
147+ <>
148+ <h1>General</h1>
149+ <Form action={`${base}/settings/general`} t={t} class="box">
150+ <div class="body">
151+ <div class="field">
152+ <label>Repository name</label>
153+ <input name="name" type="text" value={repo.name} required pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,99}" />
154+ <div class="hint">Renaming changes the clone URL; update your remotes afterwards.</div>
155+ </div>
156+ <div class="field">
157+ <label>Description</label>
158+ <input name="description" type="text" value={repo.description} maxlength={350} />
159+ </div>
160+ <div class="field" style="max-width:320px">
161+ <label>Default branch</label>
162+ {names.branches.length ? (
163+ <select name="default_branch">
164+ {names.branches.map((b) => (
165+ <option value={b} selected={b === repo.default_branch}>
166+ {b}
167+ </option>
168+ ))}
169+ </select>
170+ ) : (
171+ <input name="default_branch" type="text" value={repo.default_branch} />
172+ )}
173+ </div>
174+ <div class="field">
175+ <label>Visibility</label>
176+ {(["private", "internal", "public"] as const).map((v) => (
177+ <label class="inline" style="margin-right:16px">
178+ <input type="radio" name="visibility" value={v} checked={repo.visibility === v} /> {v}
179+ </label>
180+ ))}
181+ <div class="hint">Internal repositories are visible to everyone signed in to this instance; public ones to anyone.</div>
182+ </div>
183+ <button class="btn primary">Save changes</button>
184+ </div>
185+ </Form>
186+ <div class="box" style="border-color:color-mix(in srgb,var(--red) 45%,transparent)">
187+ <div class="head" style="background:var(--red-soft)">
188+ <h2 class="bad">Danger zone</h2>
189+ </div>
190+ <div class="body">
191+ <Form action={`${base}/settings/archive`} t={t} class="row between mb" confirm={repo.archived ? "Unarchive this repository?" : "Archive this repository? It becomes read-only."}>
192+ <input type="hidden" name="archived" value={repo.archived ? "0" : "1"} />
193+ <div>
194+ <b>{repo.archived ? "Unarchive this repository" : "Archive this repository"}</b>
195+ <div class="muted small">{repo.archived ? "Make the repository writable again." : "Mark it read-only: no pushes, issues, pull requests or runs."}</div>
196+ </div>
197+ <button class="btn danger">{repo.archived ? "Unarchive" : "Archive"}</button>
198+ </Form>
199+ <hr />
200+ <Form action={`${base}/settings/delete`} t={t} match={full}>
201+ <b>Delete this repository</b>
202+ <p class="muted small">
203+ This permanently deletes the repository, its history, issues, pull requests, runs and secrets. Type <b class="mono">{full}</b> to confirm.
204+ </p>
205+ <div class="row">
206+ <input name="confirm" type="text" required autocomplete="off" style="max-width:320px" aria-label="Confirm repository name" />
207+ <button class="btn danger">Delete this repository</button>
208+ </div>
209+ </Form>
210+ </div>
211+ </div>
212+ </>,
213+ );
214+}
215+
216+async function accessPage(c: AppContext, l: Loaded, invite?: { url: string; email: string }) {
217+ const { repo } = l;
218+ const base = repoUrl(repo);
219+ const t = csrfToken(c);
220+ const collaborators = await c.get("dir").collaborators(repo.id);
221+ return page(
222+ c,
223+ l,
224+ "access",
225+ <>
226+ <h1>Collaborators</h1>
227+ <p class="muted">
228+ {repo.owner_kind === "org" ? (
229+ <>
230+ Members of <a href={`/${repo.owner_login}`}>{repo.owner_login}</a> have write access (owners and admins: admin). Add people here for extra or narrower access.
231+ </>
232+ ) : (
233+ <>
234+ <b>{repo.owner_login}</b> owns this repository. Add collaborators to share it with friends or agents.
235+ </>
236+ )}
237+ </p>
238+ {invite ? <Once title={`Invite created for ${invite.email}`} value={invite.url} note="Send this link. It expires in 14 days and only works for that email address. The person must also be allowed by your Cloudflare Access policy." /> : null}
239+ <div class="box">
240+ {collaborators.length ? (
241+ <table class="tbl">
242+ <thead>
243+ <tr>
244+ <th>Account</th>
245+ <th>Role</th>
246+ <th />
247+ </tr>
248+ </thead>
249+ <tbody>
250+ {collaborators.map((x) => (
251+ <tr>
252+ <td>
253+ <Actor login={x.account.login} kind={x.account.kind} />
254+ </td>
255+ <td>
256+ <Form action={`${base}/settings/access`} t={t} class="row" style="gap:6px;flex-wrap:nowrap">
257+ <input type="hidden" name="login" value={x.account.login} />
258+ <select name="role" style="width:auto;padding:3px 6px">
259+ {ROLES.map((r) => (
260+ <option value={r} selected={x.role === r}>
261+ {r}
262+ </option>
263+ ))}
264+ </select>
265+ <button class="btn sm">Save</button>
266+ </Form>
267+ </td>
268+ <td class="right">
269+ <Form action={`${base}/settings/access`} t={t} class="inline" confirm={`Remove ${x.account.login}'s access?`}>
270+ <input type="hidden" name="login" value={x.account.login} />
271+ <input type="hidden" name="role" value="remove" />
272+ <button class="btn sm danger">Remove</button>
273+ </Form>
274+ </td>
275+ </tr>
276+ ))}
277+ </tbody>
278+ </table>
279+ ) : (
280+ <div class="empty">No direct collaborators.</div>
281+ )}
282+ </div>
283+ <div class="row" style="align-items:stretch">
284+ <Form action={`${base}/settings/access`} t={t} class="box grow">
285+ <div class="head">
286+ <h3>Add by username</h3>
287+ </div>
288+ <div class="body">
289+ <div class="field">
290+ <label>Username (person or agent)</label>
291+ <input name="login" type="text" required />
292+ </div>
293+ <div class="field">
294+ <label>Role</label>
295+ <select name="role">
296+ {ROLES.map((r) => (
297+ <option value={r} selected={r === "write"}>
298+ {r}
299+ </option>
300+ ))}
301+ </select>
302+ </div>
303+ <button class="btn primary">Add collaborator</button>
304+ </div>
305+ </Form>
306+ <Form action={`${base}/settings/access/invite`} t={t} class="box grow">
307+ <div class="head">
308+ <h3>Invite by email</h3>
309+ </div>
310+ <div class="body">
311+ <div class="field">
312+ <label>Email address</label>
313+ <input name="email" type="email" required placeholder="friend@example.com" />
314+ </div>
315+ <div class="field">
316+ <label>Role</label>
317+ <select name="role">
318+ {ROLES.map((r) => (
319+ <option value={r} selected={r === "write"}>
320+ {r}
321+ </option>
322+ ))}
323+ </select>
324+ </div>
325+ <button class="btn primary">Create invite link</button>
326+ </div>
327+ </Form>
328+ </div>
329+ <div class="box">
330+ <div class="body small muted">
331+ <b>Roles:</b> read — view and clone · triage — manage issues and labels · write — push, open and merge pull requests, run workflows · maintain — also move tags · admin — settings, secrets and access.
332+ </div>
333+ </div>
334+ </>,
335+ );
336+}
337+
338+async function branchesPage(c: AppContext, l: Loaded) {
339+ const base = repoUrl(l.repo);
340+ const t = csrfToken(c);
341+ const rules = await l.stub.listProtections();
342+ return page(
343+ c,
344+ l,
345+ "branches",
346+ <>
347+ <h1>Branch protection</h1>
348+ <p class="muted">
349+ Rules protect matching branches from force pushes, deletion and unreviewed changes. The default branch (<span class="mono">{l.repo.default_branch}</span>) is always protected from agents.
350+ </p>
351+ {rules.map((r) => (
352+ <details class="box">
353+ <summary class="head" style="cursor:pointer">
354+ {raw(ICONS.shield)} <b class="mono">{r.pattern}</b>
355+ <span class="muted small grow">
356+ {[
357+ r.rules.requirePullRequest ? "PR required" : "",
358+ r.rules.requiredApprovals ? `${r.rules.requiredApprovals} approval${r.rules.requiredApprovals === 1 ? "" : "s"}` : "",
359+ r.rules.requiredChecks?.length ? `${r.rules.requiredChecks.length} required check${r.rules.requiredChecks.length === 1 ? "" : "s"}` : "",
360+ r.rules.requireLinearHistory ? "linear history" : "",
361+ r.rules.enforceAdmins ? "admins included" : "",
362+ ]
363+ .filter(Boolean)
364+ .join(" · ") || "basic protection"}
365+ </span>
366+ <Form action={`${base}/settings/branches/delete`} t={t} class="inline" confirm={`Delete the rule for ${r.pattern}?`}>
367+ <input type="hidden" name="pattern" value={r.pattern} />
368+ <button class="btn sm danger">Delete</button>
369+ </Form>
370+ </summary>
371+ <RuleForm c={c} base={base} pattern={r.pattern} rules={r.rules} />
372+ </details>
373+ ))}
374+ {rules.length === 0 ? <div class="blank mb">No branch protection rules yet.</div> : null}
375+ <div class="box">
376+ <div class="head">
377+ <h3>Add a rule</h3>
378+ </div>
379+ <RuleForm c={c} base={base} rules={{ requirePullRequest: true, requiredApprovals: 1 }} />
380+ </div>
381+ </>,
382+ );
383+}
384+
385+async function secretsPage(c: AppContext, l: Loaded) {
386+ const base = repoUrl(l.repo);
387+ const t = csrfToken(c);
388+ const [secrets, vars] = await Promise.all([l.stub.secretNames(), l.stub.variables()]);
389+ return page(
390+ c,
391+ l,
392+ "secrets",
393+ <>
394+ <h1>Secrets and variables</h1>
395+ <p class="muted">
396+ Use them in workflows as <code>{"${{ secrets.NAME }}"}</code> and <code>{"${{ vars.NAME }}"}</code>. Secret values are encrypted and never shown again; they are masked in logs.
397+ </p>
398+ <div class="box">
399+ <div class="head">
400+ <h2>{raw(ICONS.key)} Repository secrets</h2>
401+ </div>
402+ {secrets.length ? (
403+ <ul class="list">
404+ {secrets.map((s) => (
405+ <li style="align-items:center">
406+ <span class="mono grow">
407+ <b>{s.name}</b>
408+ </span>
409+ <span class="muted small">updated {ago(s.updatedAt)}</span>
410+ <Form action={`${base}/settings/secrets/delete`} t={t} class="inline" confirm={`Delete secret ${s.name}?`}>
411+ <input type="hidden" name="name" value={s.name} />
412+ <button class="btn sm danger">Delete</button>
413+ </Form>
414+ </li>
415+ ))}
416+ </ul>
417+ ) : (
418+ <div class="empty">No secrets yet.</div>
419+ )}
420+ <Form action={`${base}/settings/secrets`} t={t} class="body" style="border-top:1px solid var(--border2)">
421+ <div class="field" style="max-width:360px">
422+ <label>Name</label>
423+ <input name="name" type="text" required pattern="[A-Za-z_][A-Za-z0-9_]*" class="mono" placeholder="API_TOKEN" autocomplete="off" />
424+ </div>
425+ <div class="field">
426+ <label>Value</label>
427+ <textarea name="value" required class="mono" style="min-height:80px" autocomplete="off" spellcheck={false}></textarea>
428+ <div class="hint">Setting an existing name replaces its value.</div>
429+ </div>
430+ <button class="btn primary">Save secret</button>
431+ </Form>
432+ </div>
433+ <div class="box">
434+ <div class="head">
435+ <h2>Variables</h2>
436+ </div>
437+ {Object.keys(vars).length ? (
438+ <table class="tbl">
439+ <tbody>
440+ {Object.entries(vars)
441+ .sort(([a], [b]) => a.localeCompare(b))
442+ .map(([k, v]) => (
443+ <tr>
444+ <td class="mono">
445+ <b>{k}</b>
446+ </td>
447+ <td class="mono small" style="word-break:break-all">
448+ {v}
449+ </td>
450+ <td class="right">
451+ <Form action={`${base}/settings/variables/delete`} t={t} class="inline" confirm={`Delete variable ${k}?`}>
452+ <input type="hidden" name="name" value={k} />
453+ <button class="btn sm danger">Delete</button>
454+ </Form>
455+ </td>
456+ </tr>
457+ ))}
458+ </tbody>
459+ </table>
460+ ) : (
461+ <div class="empty">No variables yet.</div>
462+ )}
463+ <Form action={`${base}/settings/variables`} t={t} class="body row" style="align-items:flex-end;border-top:1px solid var(--border2)">
464+ <div class="field m0" style="min-width:200px">
465+ <label>Name</label>
466+ <input name="name" type="text" required pattern="[A-Za-z_][A-Za-z0-9_]*" class="mono" />
467+ </div>
468+ <div class="field grow m0">
469+ <label>Value</label>
470+ <input name="value" type="text" class="mono" />
471+ </div>
472+ <button class="btn primary">Save variable</button>
473+ </Form>
474+ </div>
475+ </>,
476+ );
477+}
478+
479+async function webhooksPage(c: AppContext, l: Loaded, created?: { secret: string; url: string }) {
480+ const base = repoUrl(l.repo);
481+ const t = csrfToken(c);
482+ const hooks = await l.stub.webhooks();
483+ return page(
484+ c,
485+ l,
486+ "webhooks",
487+ <>
488+ <h1>Webhooks</h1>
489+ <p class="muted">
490+ Diffinery POSTs JSON events to these URLs, signed with an HMAC-SHA256 of the body using the webhook's secret.
491+ </p>
492+ {created ? <Once title={`Webhook created for ${created.url}`} value={created.secret} note="This is the signing secret. Copy it now; it will not be shown again." /> : null}
493+ <div class="box">
494+ {hooks.length ? (
495+ <ul class="list">
496+ {hooks.map((h) => (
497+ <li style="align-items:center">
498+ <span class={`st ${h.lastStatus === null ? "faint" : h.lastStatus >= 200 && h.lastStatus < 300 ? "ok" : "bad"}`}>{raw(h.lastStatus === null ? ICONS.queued : h.lastStatus >= 200 && h.lastStatus < 300 ? ICONS.success : ICONS.failure)}</span>
499+ <div class="grow">
500+ <b class="mono" style="word-break:break-all">
501+ {h.url}
502+ </b>
503+ <div class="meta">
504+ {h.events.join(", ")} · {h.active ? "active" : "inactive"}
505+ {h.lastDeliveryAt ? (
506+ <>
507+ {" "}
508+ · last delivery {h.lastStatus ?? "?"} <Time at={h.lastDeliveryAt} />
509+ </>
510+ ) : (
511+ " · no deliveries yet"
512+ )}
513+ </div>
514+ </div>
515+ <Form action={`${base}/settings/webhooks/delete`} t={t} class="inline" confirm="Delete this webhook?">
516+ <input type="hidden" name="id" value={h.id} />
517+ <button class="btn sm danger">Delete</button>
518+ </Form>
519+ </li>
520+ ))}
521+ </ul>
522+ ) : (
523+ <div class="empty">No webhooks yet.</div>
524+ )}
525+ </div>
526+ <Form action={`${base}/settings/webhooks`} t={t} class="box">
527+ <div class="head">
528+ <h3>Add a webhook</h3>
529+ </div>
530+ <div class="body">
531+ <div class="field">
532+ <label>Payload URL</label>
533+ <input name="url" type="url" required placeholder="https://example.com/hooks/diffinery" pattern="https://.*" />
534+ </div>
535+ <div class="field">
536+ <label>Events</label>
537+ <label class="inline" style="margin-right:14px">
538+ <input type="checkbox" name="events" value="*" checked /> Everything
539+ </label>
540+ {WEBHOOK_EVENTS.map((e) => (
541+ <label class="inline" style="margin-right:14px">
542+ <input type="checkbox" name="events" value={e} /> <span class="mono small">{e}</span>
543+ </label>
544+ ))}
545+ </div>
546+ <div class="field" style="max-width:420px">
547+ <label>
548+ Secret <span class="faint">(optional — generated when empty)</span>
549+ </label>
550+ <input name="secret" type="text" autocomplete="off" class="mono" />
551+ </div>
552+ <button class="btn primary">Add webhook</button>
553+ </div>
554+ </Form>
555+ </>,
556+ );
557+}
558+
559+// ---------------------------------------------------------------- routes
560+
561+export function registerRepoSettings(web: WebApp): void {
562+ web.get("/:owner/:repo/settings", async (c) => generalPage(c, await load(c)));
563+ web.get("/:owner/:repo/settings/access", async (c) => accessPage(c, await load(c)));
564+ web.get("/:owner/:repo/settings/branches", async (c) => branchesPage(c, await load(c)));
565+ web.get("/:owner/:repo/settings/secrets", async (c) => secretsPage(c, await load(c)));
566+ web.get("/:owner/:repo/settings/webhooks", async (c) => webhooksPage(c, await load(c)));
567+
568+ post(web, "/:owner/:repo/settings/general", async (c, f) => {
569+ const l = await load(c);
570+ const dir = c.get("dir");
571+ return act(
572+ c,
573+ `${repoUrl(l.repo)}/settings`,
574+ async () => {
575+ const name = f.s("name").trim();
576+ const visibility = (["private", "internal", "public"] as const).find((v) => v === f.s("visibility"));
577+ const defaultBranch = f.s("default_branch").trim();
578+ if (name && name !== l.repo.name && (await dir.repo(l.repo.owner_login, name))) throw new HttpError(422, "a repository with that name already exists");
579+ const updated = await dir.updateRepo(l.repo, {
580+ name: name && name !== l.repo.name ? name : undefined,
581+ description: f.s("description").trim(),
582+ visibility: visibility && visibility !== l.repo.visibility ? visibility : undefined,
583+ defaultBranch: defaultBranch && defaultBranch !== l.repo.default_branch ? defaultBranch : undefined,
584+ });
585+ audit(c, l, "repo.update", { name: updated.name, visibility: updated.visibility, defaultBranch: updated.default_branch });
586+ return `${repoUrl(updated)}/settings`;
587+ },
588+ "Settings saved.",
589+ );
590+ });
591+
592+ post(web, "/:owner/:repo/settings/archive", async (c, f) => {
593+ const l = await load(c);
594+ const archived = f.s("archived") === "1";
595+ return act(
596+ c,
597+ `${repoUrl(l.repo)}/settings`,
598+ async () => {
599+ await c.get("dir").updateRepo(l.repo, { archived });
600+ audit(c, l, archived ? "repo.archive" : "repo.unarchive");
601+ },
602+ archived ? "Repository archived." : "Repository unarchived.",
603+ );
604+ });
605+
606+ post(web, "/:owner/:repo/settings/delete", async (c, f) => {
607+ const l = await load(c);
608+ requireAccount(c);
609+ const full = `${l.repo.owner_login}/${l.repo.name}`;
610+ return act(
611+ c,
612+ `${repoUrl(l.repo)}/settings`,
613+ async () => {
614+ if (f.s("confirm").trim() !== full) throw new HttpError(422, `type ${full} to confirm deletion`);
615+ await c.get("dir").deleteRepo(l.repo);
616+ audit(c, l, "repo.delete");
617+ return "/";
618+ },
619+ `Deleted ${full}.`,
620+ );
621+ });
622+
623+ post(web, "/:owner/:repo/settings/access", async (c, f) => {
624+ const l = await load(c);
625+ const dir = c.get("dir");
626+ return act(c, `${repoUrl(l.repo)}/settings/access`, async () => {
627+ const who = await dir.accountByLogin(f.s("login").trim());
628+ if (!who || who.kind === "org") throw new HttpError(404, "no person or agent with that username");
629+ const roleIn = f.s("role");
630+ const role: Role | null = roleIn === "remove" ? null : (ROLES.find((r) => r === roleIn) ?? null);
631+ if (roleIn !== "remove" && !role) throw new HttpError(422, "invalid role");
632+ await dir.setCollaborator(l.repo.id, who.id, role);
633+ audit(c, l, role ? "repo.collaborator.set" : "repo.collaborator.remove", { login: who.login, role });
634+ });
635+ });
636+
637+ post(web, "/:owner/:repo/settings/access/invite", async (c, f) => {
638+ const l = await load(c);
639+ const me = requireAccount(c);
640+ const email = f.s("email").trim();
641+ const role = ROLES.find((r) => r === f.s("role")) ?? "write";
642+ if (!/^[^\s@]+@[^\s@]+$/.test(email)) throw new HttpError(422, "enter a valid email address");
643+ const inv = await c.get("dir").createInvite(me, { email, repoId: l.repo.id, role });
644+ audit(c, l, "repo.invite", { email, role });
645+ return accessPage(c, l, { url: inv.url, email });
646+ });
647+
648+ post(web, "/:owner/:repo/settings/branches", async (c, f) => {
649+ const l = await load(c);
650+ return act(
651+ c,
652+ `${repoUrl(l.repo)}/settings/branches`,
653+ async () => {
654+ const pattern = f.s("pattern").trim();
655+ if (!pattern || /\s/.test(pattern)) throw new HttpError(422, "enter a branch name pattern");
656+ const original = f.s("original").trim();
657+ const rules = rulesFrom(f);
658+ if (original && original !== pattern) await l.stub.setProtection(original, null);
659+ await l.stub.setProtection(pattern, rules);
660+ audit(c, l, "repo.protection.set", { pattern, rules });
661+ },
662+ "Branch protection saved.",
663+ );
664+ });
665+
666+ post(web, "/:owner/:repo/settings/branches/delete", async (c, f) => {
667+ const l = await load(c);
668+ return act(c, `${repoUrl(l.repo)}/settings/branches`, async () => {
669+ await l.stub.setProtection(f.s("pattern"), null);
670+ audit(c, l, "repo.protection.delete", { pattern: f.s("pattern") });
671+ });
672+ });
673+
674+ post(web, "/:owner/:repo/settings/secrets", async (c, f) => {
675+ const l = await load(c);
676+ return act(
677+ c,
678+ `${repoUrl(l.repo)}/settings/secrets`,
679+ async () => {
680+ const name = f.s("name").trim();
681+ try {
682+ await l.stub.setSecret(name, f.s("value"));
683+ } catch (e) {
684+ const s = statusOf(e);
685+ if (s.status < 500) throw e;
686+ throw new HttpError(422, `could not store the secret: ${(e as Error).message || "encryption unavailable"} (is MASTER_KEY configured?)`);
687+ }
688+ audit(c, l, "repo.secret.set", { name: name.toUpperCase() });
689+ },
690+ "Secret saved.",
691+ );
692+ });
693+
694+ post(web, "/:owner/:repo/settings/secrets/delete", async (c, f) => {
695+ const l = await load(c);
696+ return act(c, `${repoUrl(l.repo)}/settings/secrets`, async () => {
697+ await l.stub.deleteSecret(f.s("name"));
698+ audit(c, l, "repo.secret.delete", { name: f.s("name") });
699+ });
700+ });
701+
702+ post(web, "/:owner/:repo/settings/variables", async (c, f) => {
703+ const l = await load(c);
704+ return act(c, `${repoUrl(l.repo)}/settings/secrets`, async () => {
705+ const name = f.s("name").trim();
706+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new HttpError(422, "invalid variable name");
707+ await l.stub.setVariable(name, f.s("value"));
708+ audit(c, l, "repo.variable.set", { name });
709+ });
710+ });
711+
712+ post(web, "/:owner/:repo/settings/variables/delete", async (c, f) => {
713+ const l = await load(c);
714+ return act(c, `${repoUrl(l.repo)}/settings/secrets`, async () => {
715+ await l.stub.setVariable(f.s("name"), null);
716+ audit(c, l, "repo.variable.delete", { name: f.s("name") });
717+ });
718+ });
719+
720+ post(web, "/:owner/:repo/settings/webhooks", async (c, f) => {
721+ const l = await load(c);
722+ const url = f.s("url").trim();
723+ const events = f.all("events").filter((e) => e === "*" || WEBHOOK_EVENTS.includes(e));
724+ try {
725+ const hook = await l.stub.createWebhook(url, events.includes("*") ? ["*"] : events, f.s("secret").trim() || undefined);
726+ audit(c, l, "repo.webhook.create", { url, events });
727+ return webhooksPage(c, l, { secret: hook.secret, url });
728+ } catch (e) {
729+ const s = statusOf(e);
730+ if (s.status >= 500) throw e;
731+ return act(c, `${repoUrl(l.repo)}/settings/webhooks`, async () => {
732+ throw e;
733+ });
734+ }
735+ });
736+
737+ post(web, "/:owner/:repo/settings/webhooks/delete", async (c, f) => {
738+ const l = await load(c);
739+ return act(c, `${repoUrl(l.repo)}/settings/webhooks`, async () => {
740+ await l.stub.deleteWebhook(f.s("id"));
741+ audit(c, l, "repo.webhook.delete", { id: f.s("id") });
742+ });
743+ });
744+}
addedapps/diffinery/src/web/routes.tsx+30−0View file
@@ -1,0 +1,30 @@
1+/** @jsxImportSource hono/jsx */
2+import { Hono } from "hono";
3+import type { Env } from "../env.js";
4+import type { AppVars } from "../auth/principal.js";
5+import { registerAccount } from "./account.js";
6+import { registerActions } from "./actions.js";
7+import { registerCode } from "./code.js";
8+import { registerCollab } from "./collab.js";
9+import { registerRepoSettings } from "./repo-settings.js";
10+import { errorPage } from "./ui.js";
11+
12+/**
13+ * Server-rendered web UI. Mount at "/" after the API, Git and runner routes:
14+ * it owns `/`, `/new`, `/settings/*`, `/notifications/*`, `/invite/*`, `/-/*`,
15+ * `/:owner` and `/:owner/:repo/...` (including `/:owner/:repo/raw/*`).
16+ *
17+ * Expects the `principal` and `dir` variables to be set by an earlier middleware.
18+ * Every POST is CSRF-checked (double-submit cookie, see ./csrf.ts).
19+ */
20+export const web = new Hono<{ Bindings: Env; Variables: AppVars }>();
21+
22+web.onError((err, c) => errorPage(c, err));
23+
24+// Order matters: fixed paths (/settings/tokens, /notifications/:id, …) must be
25+// registered before the /:owner/:repo patterns they would otherwise match.
26+registerAccount(web);
27+registerRepoSettings(web);
28+registerActions(web);
29+registerCollab(web);
30+registerCode(web);
modifiedapps/diffinery/src/web/ui.tsx+7−0View file
@@ -171,6 +171,13 @@
171171 return s ? `?${s}` : "";
172172 }
173173
174+/** A required route parameter (404 when missing). */
175+export function prm(c: AppContext, name: string): string {
176+ const v = c.req.param(name);
177+ if (!v) throw new HttpError(404, "not found");
178+ return v;
179+}
180+
174181 export const pageNum = (c: AppContext): number => Math.max(1, Math.min(1000, parseInt(c.req.query("page") ?? "1", 10) || 1));
175182
176183 // ---------------------------------------------------------------- errors, flash and actions
addedapps/diffinery/test/e2e/e2e.test.mjs+315−0View file
@@ -1,0 +1,315 @@
1+// End-to-end: a real `wrangler dev` Worker, the real git CLI and the real runner agent.
2+//
3+// pnpm --filter @diffinery/runner build && pnpm --filter @diffinery/app e2e
4+//
5+// Covers: token auth, repo creation, git push/clone/fetch (protocol v2), push
6+// protection, workflows triggered by push and pull_request and executed by a
7+// self-hosted runner (the same agent that runs in Cloudflare Containers),
8+// check runs, branch protection with required checks and human review for
9+// agent-authored PRs, merging, the REST contents API and the MCP server.
10+import { spawn, spawnSync, execFileSync } from "node:child_process";
11+import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, existsSync, rmSync } from "node:fs";
12+import { tmpdir } from "node:os";
13+import { join, dirname, resolve } from "node:path";
14+import { fileURLToPath } from "node:url";
15+import { createServer } from "node:net";
16+import { test, before, after } from "node:test";
17+import assert from "node:assert/strict";
18+
19+const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
20+const AGENT = resolve(APP, "../../containers/runner/dist/agent.mjs");
21+const work = mkdtempSync(join(tmpdir(), "diffinery-e2e-"));
22+let base;
23+let dev;
24+let devLog = "";
25+let token;
26+let agentToken;
27+
28+const freePort = () =>
29+ new Promise((res) => {
30+ const s = createServer();
31+ s.listen(0, "127.0.0.1", () => {
32+ const { port } = s.address();
33+ s.close(() => res(port));
34+ });
35+ });
36+
37+async function api(method, path, body, tok = token) {
38+ const res = await fetch(`${base}/api/v3${path}`, {
39+ method,
40+ headers: { authorization: `Bearer ${tok}`, "content-type": "application/json" },
41+ body: body === undefined ? undefined : JSON.stringify(body),
42+ });
43+ const text = await res.text();
44+ let json = null;
45+ try {
46+ json = text ? JSON.parse(text) : null;
47+ } catch {
48+ json = text;
49+ }
50+ return { status: res.status, json };
51+}
52+async function ok(method, path, body, tok) {
53+ const r = await api(method, path, body, tok);
54+ assert.ok(r.status >= 200 && r.status < 300, `${method} ${path} -> ${r.status} ${JSON.stringify(r.json)}`);
55+ return r.json;
56+}
57+
58+function git(cwd, args, tok = token, opts = {}) {
59+ const auth = Buffer.from(`x-token:${tok}`).toString("base64");
60+ return execFileSync("git", ["-c", `http.extraHeader=Authorization: Basic ${auth}`, "-c", "protocol.version=2", ...args], {
61+ cwd,
62+ encoding: "utf8",
63+ stdio: ["ignore", "pipe", "pipe"],
64+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_AUTHOR_NAME: "E2E", GIT_AUTHOR_EMAIL: "e2e@example.com", GIT_COMMITTER_NAME: "E2E", GIT_COMMITTER_EMAIL: "e2e@example.com" },
65+ ...opts,
66+ });
67+}
68+function gitFails(cwd, args, tok) {
69+ try {
70+ git(cwd, args, tok);
71+ } catch (e) {
72+ return `${e.stdout ?? ""}${e.stderr ?? ""}`;
73+ }
74+ assert.fail(`expected git ${args.join(" ")} to fail`);
75+}
76+
77+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
78+async function until(what, fn, timeoutMs = 60_000) {
79+ const end = Date.now() + timeoutMs;
80+ let last;
81+ while (Date.now() < end) {
82+ last = await fn();
83+ if (last) return last;
84+ await sleep(500);
85+ }
86+ assert.fail(`timed out waiting for ${what}\n--- wrangler log tail ---\n${devLog.slice(-4000)}`);
87+}
88+
89+/** Claim one waiting self-hosted job and run it with the real agent. */
90+async function runOneJob(owner, repo) {
91+ const claim = await until("a job to claim", async () => {
92+ const r = await api("POST", `/repos/${owner}/${repo}/actions/runners/claim`, { name: "e2e", labels: ["linux"] });
93+ return r.status === 200 ? r.json : null;
94+ });
95+ const r = spawnSync(process.execPath, [AGENT], {
96+ encoding: "utf8",
97+ env: { ...process.env, DIFFINERY_API_URL: claim.api_url, DIFFINERY_JOB_ID: claim.job_id, DIFFINERY_JOB_TOKEN: claim.token, DIFFINERY_WORK_ROOT: join(work, `job-${claim.job_id}`) },
98+ timeout: 120_000,
99+ });
100+ const out = `${r.stdout}${r.stderr}`;
101+ if (r.status !== 0) {
102+ const log = await (await fetch(`${base}/api/v3/repos/${owner}/${repo}/actions/jobs/${claim.job_id}/logs`, { headers: { authorization: `Bearer ${token}` } })).text();
103+ const job = await api("GET", `/repos/${owner}/${repo}/actions/jobs/${claim.job_id}`);
104+ assert.fail(`runner agent exited ${r.status}:\n${out}\n--- job ---\n${JSON.stringify(job.json, null, 2)}\n--- job log ---\n${log}`);
105+ }
106+ return { claim, out };
107+}
108+
109+before(async () => {
110+ assert.ok(existsSync(AGENT), `build the runner first: pnpm --filter @diffinery/runner build (missing ${AGENT})`);
111+ const port = await freePort();
112+ base = `http://127.0.0.1:${port}`;
113+ const persist = join(work, "state");
114+ execFileSync("pnpm", ["exec", "wrangler", "d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
115+ // Until the web UI exists, run the same app through the API-only entry.
116+ const entry = existsSync(join(APP, "src/web/routes.tsx")) ? [] : ["test/e2e/entry.ts"];
117+ dev = spawn("pnpm", ["exec", "wrangler", "dev", ...entry, "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--show-interactive-dev-session=false"], {
118+ cwd: APP,
119+ env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" },
120+ stdio: ["ignore", "pipe", "pipe"],
121+ detached: true,
122+ });
123+ dev.stdout.on("data", (d) => (devLog += d));
124+ dev.stderr.on("data", (d) => (devLog += d));
125+ await until("wrangler dev", async () => {
126+ try {
127+ return (await fetch(`${base}/healthz`)).ok;
128+ } catch {
129+ return false;
130+ }
131+ }, 120_000);
132+ // DEV_AUTH signs requests without credentials in as dev@diffinery.local; mint a real token for everything else.
133+ const res = await fetch(`${base}/api/v3/diffinery/tokens`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "e2e", scopes: ["admin"] }) });
134+ assert.equal(res.status, 201, await res.clone().text());
135+ token = (await res.json()).token;
136+});
137+
138+after(() => {
139+ if (dev?.pid) {
140+ try {
141+ process.kill(-dev.pid, "SIGTERM");
142+ } catch {}
143+ }
144+ if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true });
145+});
146+
147+test("git hosting, Actions, protected PR flow and MCP", async () => {
148+ const me = await ok("GET", "/user");
149+ const owner = me.login;
150+ assert.ok(me.site_admin, "dev user is a bootstrap admin");
151+
152+ // ------------------------------------------------------------ repo + first push
153+ const repo = await ok("POST", "/user/repos", { name: "demo", description: "e2e" });
154+ assert.equal(repo.full_name, `${owner}/demo`);
155+ const src = join(work, "src");
156+ mkdirSync(join(src, ".github/workflows"), { recursive: true });
157+ writeFileSync(join(src, "README.md"), "# demo\n");
158+ writeFileSync(join(src, "app.txt"), "one\ntwo\nthree\n");
159+ writeFileSync(
160+ join(src, ".github/workflows/ci.yml"),
161+ `name: CI
162+on:
163+ push:
164+ branches: [main]
165+ pull_request:
166+jobs:
167+ build:
168+ runs-on: [self-hosted, linux]
169+ steps:
170+ - uses: actions/checkout@v4
171+ - name: Check files
172+ run: |
173+ test -f README.md
174+ echo "event=\${{ github.event_name }} sha=\${{ github.sha }}"
175+ grep -q bad app.txt && { echo "found bad line"; exit 1; } || true
176+ - id: out
177+ run: echo "lines=$(wc -l < app.txt | tr -d ' ')" >> "$GITHUB_OUTPUT"
178+ - run: echo "app.txt has \${{ steps.out.outputs.lines }} lines"
179+`,
180+ );
181+ git(src, ["init", "-q", "-b", "main"]);
182+ git(src, ["add", "-A"]);
183+ git(src, ["commit", "-q", "-m", "initial"]);
184+ git(src, ["remote", "add", "origin", `${base}/${owner}/demo.git`]);
185+ git(src, ["push", "-q", "origin", "main"]);
186+ const mainSha = git(src, ["rev-parse", "HEAD"]).trim();
187+
188+ const branch = await ok("GET", `/repos/${owner}/demo/branches/main`);
189+ assert.equal(branch.commit.sha, mainSha);
190+
191+ // ------------------------------------------------------------ the push ran CI on a self-hosted runner
192+ const pushRun = await until("push run", async () => (await ok("GET", `/repos/${owner}/demo/actions/runs?event=push`)).workflow_runs[0]);
193+ assert.equal(pushRun.head_sha, mainSha);
194+ const job1 = await runOneJob(owner, "demo");
195+ const finished = await until("push run to complete", async () => {
196+ const r = await ok("GET", `/repos/${owner}/demo/actions/runs/${pushRun.id}`);
197+ return r.status === "completed" ? r : null;
198+ });
199+ assert.equal(finished.conclusion, "success", job1.out);
200+ const logs = await (await fetch(`${base}/api/v3/repos/${owner}/demo/actions/jobs/${job1.claim.job_id}/logs`, { headers: { authorization: `Bearer ${token}` } })).text();
201+ assert.match(logs, /event=push/);
202+ assert.match(logs, /app\.txt has 3 lines/);
203+ const checks = await ok("GET", `/repos/${owner}/demo/commits/${mainSha}/check-runs`);
204+ assert.equal(checks.check_runs.find((c) => c.name === "CI / build")?.conclusion, "success");
205+
206+ // ------------------------------------------------------------ clone over protocol v2
207+ const clone = join(work, "clone");
208+ git(work, ["clone", "-q", `${base}/${owner}/demo.git`, clone]);
209+ assert.equal(readFileSync(join(clone, "app.txt"), "utf8"), "one\ntwo\nthree\n");
210+
211+ // ------------------------------------------------------------ secrets are blocked on push
212+ writeFileSync(join(clone, "config.env"), "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPL3\n");
213+ git(clone, ["add", "-A"]);
214+ git(clone, ["commit", "-q", "-m", "oops"]);
215+ assert.match(gitFails(clone, ["push", "origin", "main"]), /secret|AWS/i);
216+ git(clone, ["reset", "-q", "--hard", "origin/main"]);
217+
218+ // ------------------------------------------------------------ protect main: PR + green CI + review
219+ await ok("PUT", `/repos/${owner}/demo/branches/main/protection`, {
220+ requirePullRequest: true,
221+ requiredApprovals: 0,
222+ dismissStaleReviews: true,
223+ requiredChecks: ["CI / build"],
224+ allowForcePush: false,
225+ allowDeletion: false,
226+ requireLinearHistory: false,
227+ enforceAdmins: true,
228+ agentsMayPush: false,
229+ agentsMayMerge: false,
230+ });
231+ writeFileSync(join(clone, "app.txt"), "one\ntwo\nthree\nfour\n");
232+ git(clone, ["commit", "-q", "-am", "direct push"]);
233+ assert.match(gitFails(clone, ["push", "origin", "main"]), /pull request|protected/i);
234+ git(clone, ["reset", "-q", "--hard", "origin/main"]);
235+
236+ // ------------------------------------------------------------ an AI agent opens a PR
237+ const created = await ok("POST", "/diffinery/agents", { login: "e2e-bot", name: "E2E Bot" });
238+ agentToken = created.token.token;
239+ const agentClone = join(work, "agent");
240+ git(work, ["clone", "-q", `${base}/${owner}/demo.git`, agentClone], agentToken);
241+ git(agentClone, ["checkout", "-q", "-b", "feature"], agentToken);
242+ writeFileSync(join(agentClone, "app.txt"), "one\ntwo\nthree\nfour\n");
243+ git(agentClone, ["commit", "-q", "-am", "Add a fourth line"], agentToken);
244+ assert.match(gitFails(agentClone, ["push", "origin", "HEAD:main"], agentToken), /agent|protected|pull request/i);
245+ git(agentClone, ["push", "-q", "origin", "feature"], agentToken);
246+ const pr = await ok("POST", `/repos/${owner}/demo/pulls`, { title: "Add a fourth line", head: "feature", base: "main", intent: "Extend app.txt for the e2e test" }, agentToken);
247+ assert.equal(pr.number, 1);
248+
249+ // The pull_request run executes; until then the PR is blocked on the required check.
250+ let m = await ok("GET", `/repos/${owner}/demo/pulls/1/merge`);
251+ assert.equal(m.mergeable, false);
252+ await runOneJob(owner, "demo");
253+ await until("PR check to pass", async () => {
254+ const r = await ok("GET", `/repos/${owner}/demo/pulls/1/merge`);
255+ return r.checks?.length > 0 && r.checks.every((c) => c.state === "success") ? r : null;
256+ });
257+ m = await ok("GET", `/repos/${owner}/demo/pulls/1/merge`);
258+ assert.equal(m.mergeable, false, "agent-authored PRs need a human approval");
259+ assert.ok(JSON.stringify(m.blockers).match(/approv|human/i), JSON.stringify(m.blockers));
260+
261+ // The agent cannot approve or merge its own work.
262+ const agentMerge = await api("PUT", `/repos/${owner}/demo/pulls/1/merge`, { merge_method: "squash" }, agentToken);
263+ assert.ok([403, 405, 409].includes(agentMerge.status), JSON.stringify(agentMerge));
264+
265+ // A human reviews (line comment + approval) and merges.
266+ await ok("POST", `/repos/${owner}/demo/pulls/1/reviews`, { event: "APPROVE", body: "LGTM", comments: [{ path: "app.txt", line: 4, body: "nice line" }] });
267+ m = await ok("GET", `/repos/${owner}/demo/pulls/1/merge`);
268+ assert.equal(m.mergeable, true, JSON.stringify(m));
269+ const merged = await ok("PUT", `/repos/${owner}/demo/pulls/1/merge`, { merge_method: "squash", delete_branch: true });
270+ assert.ok(merged.sha);
271+ const prAfter = await ok("GET", `/repos/${owner}/demo/pulls/1`);
272+ assert.equal(prAfter.merged, true);
273+
274+ // ------------------------------------------------------------ fetch the merge, read through REST
275+ git(clone, ["pull", "-q", "--ff-only", "origin", "main"]);
276+ assert.equal(readFileSync(join(clone, "app.txt"), "utf8"), "one\ntwo\nthree\nfour\n");
277+ const content = await ok("GET", `/repos/${owner}/demo/contents/app.txt`);
278+ assert.equal(Buffer.from(content.content, "base64").toString(), "one\ntwo\nthree\nfour\n");
279+ const branches = await ok("GET", `/repos/${owner}/demo/branches`);
280+ assert.ok(!branches.some((b) => b.name === "feature"), "head branch deleted after merge");
281+
282+ // ------------------------------------------------------------ shallow clone, then deepen
283+ const shallow = join(work, "shallow");
284+ git(work, ["clone", "-q", "--depth", "1", `${base}/${owner}/demo.git`, shallow]);
285+ assert.equal(git(shallow, ["rev-list", "--count", "HEAD"]).trim(), "1");
286+ assert.equal(readFileSync(join(shallow, "app.txt"), "utf8"), "one\ntwo\nthree\nfour\n");
287+ git(shallow, ["fetch", "-q", "--unshallow"]);
288+ assert.equal(git(shallow, ["rev-list", "--count", "HEAD"]).trim(), "2");
289+ git(shallow, ["fsck", "--no-progress"]);
290+
291+ // ------------------------------------------------------------ MCP: the agent works without git
292+ const rpc = async (method, params, id = 1) => {
293+ const r = await fetch(`${base}/mcp`, { method: "POST", headers: { authorization: `Bearer ${agentToken}`, "content-type": "application/json", accept: "application/json, text/event-stream" }, body: JSON.stringify({ jsonrpc: "2.0", id, method, params }) });
294+ assert.equal(r.status, 200);
295+ return (await r.json()).result;
296+ };
297+ const init = await rpc("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "e2e", version: "1" } });
298+ assert.equal(init.serverInfo.name, "diffinery");
299+ const tools = await rpc("tools/list", {});
300+ assert.ok(tools.tools.some((t) => t.name === "commit_files"));
301+ const file = await rpc("tools/call", { name: "get_file", arguments: { owner, repo: "demo", path: "README.md" } });
302+ assert.equal(file.isError, false);
303+ assert.equal(file.structuredContent.content, "# demo\n");
304+ const commit = await rpc("tools/call", { name: "commit_files", arguments: { owner, repo: "demo", branch: "mcp-change", create_branch_from: "main", message: "Edit via MCP", files: [{ path: "docs/notes.md", content: "hello\n" }] } });
305+ assert.equal(commit.isError, false, JSON.stringify(commit));
306+ const blocked = await rpc("tools/call", { name: "commit_files", arguments: { owner, repo: "demo", branch: "main", message: "sneaky", files: [{ path: "x", content: "y" }] } });
307+ assert.equal(blocked.isError, true);
308+ const opened = await rpc("tools/call", { name: "create_pull_request", arguments: { owner, repo: "demo", title: "Notes", head: "mcp-change", intent: "Add notes" } });
309+ assert.equal(opened.isError, false, JSON.stringify(opened));
310+ assert.equal(opened.structuredContent.number, 2);
311+
312+ // The commit made through MCP carries the agent's identity.
313+ const agentCommit = await ok("GET", `/repos/${owner}/demo/commits/mcp-change`);
314+ assert.match(JSON.stringify(agentCommit), /e2e-bot/);
315+});
addedapps/diffinery/test/e2e/entry.ts+10−0View file
@@ -1,0 +1,10 @@
1+// Worker entry for end-to-end tests while the web UI is optional: same app, no HTML routes.
2+import type { DiffineryEvent } from "@diffinery/shared";
3+import type { Env } from "../../src/env.js";
4+import { app, queue, scheduled } from "../../src/app.js";
5+
6+export { RepoDO } from "../../src/repo/RepoDO.js";
7+export { RunnerContainer, SandboxContainer } from "../../src/runner/containers.js";
8+export { RunWorkflow } from "../../src/runner/workflow.js";
9+
10+export default { fetch: app.fetch, queue, scheduled } satisfies ExportedHandler<Env, DiffineryEvent>;
addedapps/diffinery/wrangler.jsonc+78−0View file
@@ -1,0 +1,78 @@
1+// Diffinery: one Worker, everything on Cloudflare.
2+// `scripts/bootstrap.sh` creates the account resources and writes
3+// wrangler.deploy.jsonc (this file plus real IDs) for CI deploys.
4+{
5+ "$schema": "node_modules/wrangler/config-schema.json",
6+ "name": "diffinery",
7+ "main": "src/index.ts",
8+ "compatibility_date": "2026-10-01",
9+ "compatibility_flags": ["nodejs_compat"],
10+ "workers_dev": false,
11+ "routes": [{ "pattern": "diffinery.com", "custom_domain": true }],
12+ "observability": { "enabled": true, "head_sampling_rate": 1 },
13+ "upload_source_maps": true,
14+ "limits": { "cpu_ms": 300000 },
15+
16+ "vars": {
17+ "PUBLIC_URL": "https://diffinery.com",
18+ "BOOTSTRAP_ADMINS": "cnbrkdmrci,iletisim@canberkdemirci.com,canberkdemirci@icloud.com,iletisim@altugankarali.com",
19+ "ACCESS_TEAM_DOMAIN": "",
20+ "ACCESS_AUD": ""
21+ },
22+
23+ "d1_databases": [
24+ { "binding": "DB", "database_name": "diffinery", "database_id": "00000000-0000-0000-0000-000000000000", "migrations_dir": "migrations" }
25+ ],
26+ "r2_buckets": [{ "binding": "BUCKET", "bucket_name": "diffinery-data" }],
27+ "queues": {
28+ "producers": [{ "binding": "EVENTS", "queue": "diffinery-events" }],
29+ "consumers": [
30+ { "queue": "diffinery-events", "max_batch_size": 50, "max_batch_timeout": 2, "max_retries": 8, "dead_letter_queue": "diffinery-events-dlq" }
31+ ]
32+ },
33+ "durable_objects": {
34+ "bindings": [
35+ { "name": "REPO", "class_name": "RepoDO" },
36+ { "name": "RUNNER", "class_name": "RunnerContainer" },
37+ { "name": "SANDBOX", "class_name": "SandboxContainer" }
38+ ]
39+ },
40+ "migrations": [{ "tag": "v1", "new_sqlite_classes": ["RepoDO", "RunnerContainer", "SandboxContainer"] }],
41+ "workflows": [{ "name": "diffinery-run", "binding": "RUN_WORKFLOW", "class_name": "RunWorkflow" }],
42+ "containers": [
43+ { "class_name": "RunnerContainer", "image": "../../containers/runner/Dockerfile", "image_build_context": "../../containers/runner", "instance_type": "standard-2", "max_instances": 100 },
44+ { "class_name": "SandboxContainer", "image": "../../containers/runner/Dockerfile", "image_build_context": "../../containers/runner", "instance_type": "standard-1", "max_instances": 50 }
45+ ],
46+ "triggers": { "crons": ["*/5 * * * *"] },
47+
48+ "env": {
49+ // Local development and end-to-end tests: no Access, a fixed dev identity, no containers.
50+ "dev": {
51+ "routes": [],
52+ "workers_dev": true,
53+ "vars": {
54+ "PUBLIC_URL": "http://localhost:8787",
55+ "BOOTSTRAP_ADMINS": "dev@diffinery.local",
56+ "ACCESS_TEAM_DOMAIN": "",
57+ "ACCESS_AUD": "",
58+ "DEV_AUTH": "dev@diffinery.local"
59+ },
60+ "d1_databases": [{ "binding": "DB", "database_name": "diffinery-dev", "database_id": "00000000-0000-0000-0000-000000000000", "migrations_dir": "migrations" }],
61+ "r2_buckets": [{ "binding": "BUCKET", "bucket_name": "diffinery-data-dev" }],
62+ "queues": {
63+ "producers": [{ "binding": "EVENTS", "queue": "diffinery-events-dev" }],
64+ "consumers": [{ "queue": "diffinery-events-dev", "max_batch_size": 50, "max_batch_timeout": 1 }]
65+ },
66+ "durable_objects": {
67+ "bindings": [
68+ { "name": "REPO", "class_name": "RepoDO" },
69+ { "name": "RUNNER", "class_name": "RunnerContainer" },
70+ { "name": "SANDBOX", "class_name": "SandboxContainer" }
71+ ]
72+ },
73+ "workflows": [{ "name": "diffinery-run-dev", "binding": "RUN_WORKFLOW", "class_name": "RunWorkflow" }],
74+ "triggers": { "crons": [] },
75+ "containers": []
76+ }
77+ }
78+}
modifiedpackages/git-core/src/store.ts+83−7View file
@@ -177,11 +177,46 @@
177177 * Objects a client needs given what it wants and what it already has, in an
178178 * order suitable for a pack (commits, then trees and blobs, then tags).
179179 */
180+export interface FetchOptions {
181+ /** `deepen <n>`: keep at most n commits of history from each want. */
182+ depth?: number;
183+ /** `deepen-since <unix time>`: keep commits committed at or after this time. */
184+ since?: number;
185+ /** Commits the client reported as its shallow boundary (`shallow <oid>`). */
186+ clientShallow?: ReadonlySet<string>;
187+}
188+
189+export interface FetchPlan {
190+ oids: string[];
191+ /** Commits that become the client's new shallow boundary. */
192+ shallow: string[];
193+ /** Client shallow commits whose parents are now being sent. */
194+ unshallow: string[];
195+}
196+
197+/** Objects to send for a fetch: everything reachable from wants and not from haves. */
180198 export async function objectsForFetch(store: ObjectReader, wants: readonly string[], haves: readonly string[]): Promise<string[]> {
199+ return (await planFetch(store, wants, haves)).oids;
200+}
201+
202+export async function planFetch(store: ObjectReader, wants: readonly string[], haves: readonly string[], opts: FetchOptions = {}): Promise<FetchPlan> {
203+ const clientShallow = opts.clientShallow ?? new Set<string>();
204+ // History the client has, cut at its shallow boundary (it lacks those commits' parents).
181205 const haveCommits = new Set<string>();
206+ const haveTips: string[] = [];
182207 for (const h of haves) {
183208 const o = await store.read(h);
184- if (o?.type === "commit") for (const a of await ancestors(store, h)) haveCommits.add(a);
209+ if (o?.type !== "commit") continue;
210+ haveTips.push(h);
211+ const stack = [h];
212+ while (stack.length) {
213+ const cur = stack.pop()!;
214+ if (haveCommits.has(cur)) continue;
215+ const c = await store.read(cur);
216+ if (!c || c.type !== "commit") continue;
217+ haveCommits.add(cur);
218+ if (!clientShallow.has(cur)) stack.push(...parseCommit(c.content).parents);
219+ }
185220 }
186221 const tags: string[] = [];
187222 const commitTips: string[] = [];
@@ -200,11 +235,52 @@
200235 break;
201236 }
202237 }
203- const commits = await walkCommits(store, commitTips, { stop: haveCommits });
204238
205- // Trees and blobs the client already has: everything under the boundary commits' trees.
239+ const shallow: string[] = [];
240+ const unshallow: string[] = [];
241+ let commits: Array<{ oid: string; commit: Commit }>;
242+ if (opts.depth !== undefined || opts.since !== undefined) {
243+ // Breadth-first by depth so each commit gets its shortest distance from a want.
244+ const depthLimit = opts.depth ?? Number.POSITIVE_INFINITY;
245+ const since = opts.since ?? Number.NEGATIVE_INFINITY;
246+ const best = new Map<string, number>();
247+ const parsed = new Map<string, Commit>();
248+ let frontier = commitTips.map((oid) => ({ oid, depth: 1 }));
249+ const order: string[] = [];
250+ while (frontier.length) {
251+ const next: Array<{ oid: string; depth: number }> = [];
252+ for (const { oid, depth } of frontier) {
253+ if ((best.get(oid) ?? Number.POSITIVE_INFINITY) <= depth) continue;
254+ const o = await store.read(oid);
255+ if (!o || o.type !== "commit") continue;
256+ const commit = parsed.get(oid) ?? parseCommit(o.content);
257+ if (commit.committer.timestamp < since && depth > 1) continue;
258+ if (!best.has(oid)) order.push(oid);
259+ best.set(oid, depth);
260+ parsed.set(oid, commit);
261+ if (depth < depthLimit) for (const p of commit.parents) next.push({ oid: p, depth: depth + 1 });
262+ }
263+ frontier = next;
264+ }
265+ commits = [];
266+ for (const oid of order) {
267+ const commit = parsed.get(oid)!;
268+ const depth = best.get(oid)!;
269+ let cut = false;
270+ for (const p of commit.parents) {
271+ if (!best.has(p)) cut = true;
272+ }
273+ if (cut && commit.parents.length) shallow.push(oid);
274+ else if (clientShallow.has(oid) && commit.parents.length && depth < depthLimit) unshallow.push(oid);
275+ if (!haveCommits.has(oid) || unshallow.includes(oid)) commits.push({ oid, commit });
276+ }
277+ } else {
278+ commits = await walkCommits(store, commitTips, { stop: haveCommits });
279+ }
280+
281+ // Trees and blobs the client already has: everything under the trees of commits it has at the edge.
206282 const uninteresting = new Set<string>();
207- const boundary = new Set<string>();
283+ const boundary = new Set<string>(haveTips);
208284 for (const { commit } of commits) for (const p of commit.parents) if (haveCommits.has(p)) boundary.add(p);
209285 for (const b of boundary) await markTree(store, (await readCommit(store, b)).tree, uninteresting);
210286
@@ -216,7 +292,7 @@
216292 out.push(oid);
217293 }
218294 };
219- for (const { oid } of commits) add(oid);
295+ for (const { oid } of commits) if (!haveCommits.has(oid)) add(oid);
220296 const walkTree = async (treeOid: string) => {
221297 if (added.has(treeOid) || uninteresting.has(treeOid)) return;
222298 add(treeOid);
@@ -226,14 +302,14 @@
226302 else add(e.oid);
227303 }
228304 };
229- for (const { commit } of commits) await walkTree(commit.tree);
305+ for (const { oid, commit } of commits) if (!haveCommits.has(oid)) await walkTree(commit.tree);
230306 for (const oid of extra) {
231307 const o = await mustRead(store, oid);
232308 if (o.type === "tree") await walkTree(oid);
233309 else add(oid);
234310 }
235311 for (const t of tags) add(t);
236- return out;
312+ return { oids: out, shallow, unshallow };
237313 }
238314
239315 // ---------------------------------------------------------------- writing trees
modifiedpnpm-lock.yaml+19−0View file
@@ -15,6 +15,25 @@
1515 specifier: ^5.9.3
1616 version: 5.9.3
1717
18+ apps/cli:
19+ dependencies:
20+ '@diffinery/workflow-yaml':
21+ specifier: workspace:*
22+ version: link:../../packages/workflow-yaml
23+ devDependencies:
24+ '@types/node':
25+ specifier: ^22.0.0
26+ version: 22.20.5
27+ esbuild:
28+ specifier: ^0.25.0
29+ version: 0.25.12
30+ typescript:
31+ specifier: ^5.9.3
32+ version: 5.9.3
33+ vitest:
34+ specifier: ^4.1.0
35+ version: 4.1.11(@types/node@22.20.5)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.25.12)(yaml@2.9.1))
36+
1837 apps/diffinery:
1938 dependencies:
2039 '@cloudflare/containers':
addedscripts/bootstrap.sh+133−0View file
@@ -1,0 +1,133 @@
1+#!/usr/bin/env bash
2+# Idempotent production setup for Diffinery on a Cloudflare account.
3+#
4+# Creates (only when missing): the D1 database, R2 bucket, event queues and the
5+# Cloudflare Access applications that put sign-in in front of the web UI while
6+# letting git, the API, MCP and runners authenticate with Diffinery tokens.
7+# Then renders apps/diffinery/wrangler.deploy.jsonc and applies D1 migrations.
8+#
9+# Requires: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, node, jq, curl.
10+# Optional: DIFFINERY_DOMAIN (default diffinery.com), ACCESS_TEAM_DOMAIN
11+# (e.g. codician.cloudflareaccess.com) when the token cannot read it.
12+set -euo pipefail
13+
14+: "${CLOUDFLARE_API_TOKEN:?set CLOUDFLARE_API_TOKEN}"
15+: "${CLOUDFLARE_ACCOUNT_ID:?set CLOUDFLARE_ACCOUNT_ID}"
16+DOMAIN="${DIFFINERY_DOMAIN:-diffinery.com}"
17+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
18+APP="$ROOT/apps/diffinery"
19+WRANGLER=(pnpm --dir "$APP" exec wrangler)
20+API="https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID"
21+
22+cf() { # cf METHOD PATH [JSON]
23+ local method="$1" path="$2" body="${3:-}"
24+ if [[ -n "$body" ]]; then
25+ curl -sS -X "$method" "$API$path" -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json" --data "$body"
26+ else
27+ curl -sS -X "$method" "$API$path" -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN"
28+ fi
29+}
30+log() { echo "::group::$*"; }
31+endlog() { echo "::endgroup::"; }
32+warn() { echo "::warning::$*" >&2; }
33+
34+# ------------------------------------------------------------------ D1
35+log "D1 database"
36+D1_ID="$(cf GET "/d1/database?name=diffinery&per_page=100" | jq -r '.result[]? | select(.name=="diffinery") | .uuid' | head -n1)"
37+if [[ -z "$D1_ID" ]]; then
38+ D1_ID="$(cf POST /d1/database '{"name":"diffinery"}' | jq -r '.result.uuid // empty')"
39+ [[ -n "$D1_ID" ]] || { echo "could not create D1 database (does the token have D1: Edit?)" >&2; exit 1; }
40+ echo "created D1 database $D1_ID"
41+else
42+ echo "D1 database exists: $D1_ID"
43+fi
44+endlog
45+
46+# ------------------------------------------------------------------ R2
47+log "R2 bucket"
48+if cf GET "/r2/buckets/diffinery-data" | jq -e '.success' >/dev/null; then
49+ echo "R2 bucket exists"
50+else
51+ cf POST /r2/buckets '{"name":"diffinery-data"}' | jq -e '.success' >/dev/null || { echo "could not create R2 bucket (R2 enabled? token has R2: Edit?)" >&2; exit 1; }
52+ echo "created R2 bucket diffinery-data"
53+fi
54+# Expire transient data: run logs/caches are rewritten on use, artifacts and event archives age out.
55+cf PUT "/r2/buckets/diffinery-data/lifecycle" '{"rules":[
56+ {"id":"artifacts-90d","enabled":true,"conditions":{"prefix":"repos/"},"deleteObjectsTransition":{"condition":{"type":"Age","maxAge":7776000}},"abortMultipartUploadsTransition":{"condition":{"type":"Age","maxAge":86400}}},
57+ {"id":"events-400d","enabled":true,"conditions":{"prefix":"events/"},"deleteObjectsTransition":{"condition":{"type":"Age","maxAge":34560000}}}
58+]}' >/dev/null || true
59+endlog
60+
61+# ------------------------------------------------------------------ Queues
62+log "Queues"
63+EXISTING_QUEUES="$(cf GET "/queues?per_page=100" | jq -r '.result[]?.queue_name')"
64+for q in diffinery-events diffinery-events-dlq; do
65+ if grep -qx "$q" <<<"$EXISTING_QUEUES"; then
66+ echo "queue $q exists"
67+ else
68+ cf POST /queues "{\"queue_name\":\"$q\"}" | jq -e '.success' >/dev/null || { echo "could not create queue $q (token has Queues: Edit?)" >&2; exit 1; }
69+ echo "created queue $q"
70+ fi
71+done
72+endlog
73+
74+# ------------------------------------------------------------------ Access
75+log "Cloudflare Access"
76+TEAM="${ACCESS_TEAM_DOMAIN:-}"
77+if [[ -z "$TEAM" ]]; then
78+ TEAM="$(cf GET /access/organizations | jq -r '.result.auth_domain // empty' || true)"
79+fi
80+APPS="$(cf GET "/access/apps?per_page=100" || echo '{}')"
81+AUD=""
82+if ! jq -e '.success' >/dev/null <<<"$APPS"; then
83+ warn "Could not read Access applications (token needs Access: Apps and Policies Edit). Sign-in stays disabled until Access is configured."
84+else
85+ WEB_APP="$(jq -c --arg n "Diffinery" '.result[] | select(.name==$n)' <<<"$APPS" | head -n1)"
86+ if [[ -z "$WEB_APP" ]]; then
87+ WEB_APP="$(cf POST /access/apps "$(jq -nc --arg d "$DOMAIN" '{
88+ name: "Diffinery", type: "self_hosted", domain: $d,
89+ destinations: [{type: "public", uri: $d}],
90+ session_duration: "168h", app_launcher_visible: true, http_only_cookie_attribute: true, same_site_cookie_attribute: "lax",
91+ policies: [{name: "Anyone who signs in (Diffinery invites decide access)", decision: "allow", include: [{everyone: {}}], precedence: 1}]
92+ }')" | jq -c '.result // empty')"
93+ [[ -n "$WEB_APP" ]] && echo "created Access app for $DOMAIN" || warn "Could not create the Access application for $DOMAIN."
94+ else
95+ echo "Access app exists"
96+ fi
97+ AUD="$(jq -r '.aud // empty' <<<"${WEB_APP:-{}}")"
98+
99+ # Machine endpoints authenticate with Diffinery tokens, so Access must not intercept them.
100+ BYPASS_DESTS="$(jq -nc --arg d "$DOMAIN" '[
101+ "/api/*", "/mcp", "/mcp/*", "/runner/*", "/.well-known/*", "/healthz",
102+ "/*/*/info/refs", "/*/*/git-upload-pack", "/*/*/git-receive-pack", "/*/*/info/lfs/*",
103+ "/*/*/actions/runs/*/artifacts/*"
104+ ] | map({type: "public", uri: ($d + .)})')"
105+ BYPASS_BODY="$(jq -nc --arg d "$DOMAIN" --argjson dests "$BYPASS_DESTS" '{
106+ name: "Diffinery machine endpoints", type: "self_hosted", domain: ($d + "/api/*"), destinations: $dests,
107+ app_launcher_visible: false,
108+ policies: [{name: "Bypass: Diffinery verifies tokens itself", decision: "bypass", include: [{everyone: {}}], precedence: 1}]
109+ }')"
110+ BYPASS_ID="$(jq -r '.result[] | select(.name=="Diffinery machine endpoints") | .id' <<<"$APPS" | head -n1)"
111+ if [[ -z "$BYPASS_ID" ]]; then
112+ cf POST /access/apps "$BYPASS_BODY" | jq -e '.success' >/dev/null && echo "created Access bypass for git/API/MCP/runner paths" || warn "Could not create the Access bypass application; git and API calls will be redirected to sign-in."
113+ else
114+ cf PUT "/access/apps/$BYPASS_ID" "$(jq -c 'del(.policies)' <<<"$BYPASS_BODY")" | jq -e '.success' >/dev/null && echo "updated Access bypass paths" || warn "Could not update the Access bypass application."
115+ fi
116+fi
117+if [[ -z "$TEAM" ]]; then
118+ # Fall back to the sign-in redirect, which names the team domain.
119+ TEAM="$(curl -sS -o /dev/null -w '%{redirect_url}' "https://$DOMAIN/" | sed -nE 's#^https://([^/]+\.cloudflareaccess\.com)/.*#\1#p' || true)"
120+fi
121+[[ -n "$TEAM" ]] || warn "Access team domain unknown; set the ACCESS_TEAM_DOMAIN repository variable (Zero Trust → Settings → Custom pages shows it)."
122+echo "team=${TEAM:-?} aud=${AUD:+set}"
123+endlog
124+
125+# ------------------------------------------------------------------ config + migrations
126+log "Render config and migrate D1"
127+node "$ROOT/scripts/render-config.mjs" --d1 "$D1_ID" ${TEAM:+--team "$TEAM"} ${AUD:+--aud "$AUD"} --public-url "https://$DOMAIN"
128+"${WRANGLER[@]}" d1 migrations apply diffinery --remote -c wrangler.deploy.jsonc
129+endlog
130+
131+if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
132+ { echo "d1_id=$D1_ID"; echo "access_team=$TEAM"; echo "access_aud_set=${AUD:+true}"; } >>"$GITHUB_OUTPUT"
133+fi
addedscripts/ensure-secrets.sh+29−0View file
@@ -1,0 +1,29 @@
1+#!/usr/bin/env bash
2+# Generate the Worker's own secrets once (never rotated automatically):
3+# MASTER_KEY root key for encrypting Actions secrets
4+# OIDC_PRIVATE_KEY ES256 key that signs workflow OIDC id tokens
5+# Values are created on the runner and sent straight to Cloudflare; they are never printed.
6+set -euo pipefail
7+APP="$(cd "$(dirname "$0")/../apps/diffinery" && pwd)"
8+WRANGLER=(pnpm --dir "$APP" exec wrangler)
9+
10+# Fail closed: if listing fails we must not mistake "unknown" for "missing" and
11+# overwrite MASTER_KEY, which would make every stored Actions secret unreadable.
12+LIST="$("${WRANGLER[@]}" secret list -c wrangler.deploy.jsonc --format json)"
13+EXISTING="$(jq -er '.[].name' <<<"$LIST" || true)"
14+jq -e 'type == "array"' >/dev/null <<<"$LIST" || { echo "unexpected output from wrangler secret list" >&2; exit 1; }
15+
16+if ! grep -qx MASTER_KEY <<<"$EXISTING"; then
17+ openssl rand -base64 32 | tr -d '\n' | "${WRANGLER[@]}" secret put MASTER_KEY -c wrangler.deploy.jsonc >/dev/null
18+ echo "set MASTER_KEY"
19+fi
20+if ! grep -qx OIDC_PRIVATE_KEY <<<"$EXISTING"; then
21+ node --input-type=module -e '
22+ const { subtle } = globalThis.crypto;
23+ const k = await subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]);
24+ const jwk = await subtle.exportKey("jwk", k.privateKey);
25+ jwk.kid = "diffinery-" + new Date().toISOString().slice(0, 10);
26+ process.stdout.write(JSON.stringify(jwk));
27+ ' | "${WRANGLER[@]}" secret put OIDC_PRIVATE_KEY -c wrangler.deploy.jsonc >/dev/null
28+ echo "set OIDC_PRIVATE_KEY"
29+fi
addedscripts/render-config.mjs+43−0View file
@@ -1,0 +1,43 @@
1+#!/usr/bin/env node
2+// Render apps/diffinery/wrangler.deploy.jsonc from wrangler.jsonc plus values
3+// discovered by scripts/bootstrap.sh (real D1 id, Access team domain and AUD).
4+//
5+// node scripts/render-config.mjs --d1 <uuid> [--team <x.cloudflareaccess.com>] [--aud <tag>]
6+import { readFileSync, writeFileSync } from "node:fs";
7+import { dirname, join } from "node:path";
8+import { fileURLToPath } from "node:url";
9+import { parseArgs } from "node:util";
10+
11+const { values } = parseArgs({ options: { d1: { type: "string" }, team: { type: "string" }, aud: { type: "string" }, "public-url": { type: "string" } } });
12+if (!values.d1) throw new Error("--d1 <database id> is required");
13+
14+/** Strip // and /* *\/ comments and trailing commas from JSONC, leaving strings intact. */
15+export function parseJsonc(text) {
16+ let out = "";
17+ for (let i = 0; i < text.length; i++) {
18+ const ch = text[i];
19+ if (ch === '"') {
20+ let j = i + 1;
21+ while (j < text.length && text[j] !== '"') j += text[j] === "\\" ? 2 : 1;
22+ out += text.slice(i, j + 1);
23+ i = j;
24+ } else if (ch === "/" && text[i + 1] === "/") {
25+ while (i < text.length && text[i] !== "\n") i++;
26+ out += "\n";
27+ } else if (ch === "/" && text[i + 1] === "*") {
28+ i = text.indexOf("*/", i + 2) + 1;
29+ } else out += ch;
30+ }
31+ return JSON.parse(out.replace(/,(\s*[}\]])/g, "$1"));
32+}
33+
34+const appDir = join(dirname(fileURLToPath(import.meta.url)), "..", "apps", "diffinery");
35+const cfg = parseJsonc(readFileSync(join(appDir, "wrangler.jsonc"), "utf8"));
36+delete cfg.env;
37+delete cfg.$schema;
38+cfg.d1_databases[0].database_id = values.d1;
39+if (values.team) cfg.vars.ACCESS_TEAM_DOMAIN = values.team;
40+if (values.aud) cfg.vars.ACCESS_AUD = values.aud;
41+if (values["public-url"]) cfg.vars.PUBLIC_URL = values["public-url"];
42+writeFileSync(join(appDir, "wrangler.deploy.jsonc"), `// Generated by scripts/render-config.mjs; do not edit.\n${JSON.stringify(cfg, null, 2)}\n`);
43+console.log(`wrote ${join(appDir, "wrangler.deploy.jsonc")}`);