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.

Diffinery: git forge, pull requests and Actions-compatible CI on Cloudflare (#1)

Diffinery: git forge, pull requests and Actions-compatible CI on Cloudflare
Canberk Demirci authored · GitHub committed signed2 parents 3555f7d9593c9dcommit 5387f7994df5be8987796dfc384550e6ce7d167aBrowse files
151 files changed+31672−0
Files (151)
added.github/workflows/ci.yml+52−0View file
@@ -1,0 +1,52 @@
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, passkeys)
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 exec playwright install --with-deps chromium
52+ - 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
added.gitignore+9−0View file
@@ -1,0 +1,9 @@
1+node_modules/
2+dist/
3+.wrangler/
4+.turbo/
5+.dev.vars
6+*.log
7+coverage/
8+.DS_Store
9+wrangler.deploy.jsonc
addedREADME.md+173−0View file
@@ -1,0 +1,173 @@
1+# Diffinery
2+
3+A git forge for teams of people and AI agents, running entirely on Cloudflare.
4+Git hosting, pull requests and reviews, issues, GitHub-Actions-compatible CI,
5+an MCP server for agents, and sandboxes, with no servers to run.
6+
7+- **Drop-in for GitHub workflows.** `.github/workflows/*.yml` runs as-is: same
8+ triggers, expressions, matrices, `needs`, outputs, caches, artifacts, secrets,
9+ OIDC, `actions/checkout`, `setup-node/python/go`, and remote JavaScript and
10+ composite actions.
11+- **Built for agents.** Agents are first-class accounts owned by a person or
12+ team, with their own tokens and limits. They can't push or merge to protected
13+ branches, and their pull requests always need a human approval. Every commit
14+ they make is stamped with who they act for. They work through MCP
15+ (`/mcp`) or the REST API, and can start sandboxes to build and test.
16+- **Secure by default.** People sign in with passkeys, with no passwords to
17+ leak or phish. Invites, roles and branch protection decide what they can do. Pushes are scanned for
18+ leaked credentials. Secrets use envelope encryption, and every sensitive
19+ action goes to an append-only audit log.
20+
21+## How it fits on Cloudflare
22+
23+| Piece | Cloudflare product |
24+|---|---|
25+| HTTP, git smart HTTP, REST API, MCP, web UI | Workers (Hono) |
26+| One repository: refs, objects, PRs, issues, reviews, checks, runs | Durable Object with SQLite (`RepoDO`), one per repository |
27+| Large objects, Git LFS, logs, caches, artifacts, event archive | R2 |
28+| Accounts, orgs, tokens, invites, notifications, audit log | D1 |
29+| Webhooks, notifications, audit fan-out | Queues (with a dead-letter queue) |
30+| Workflow runs (durable, survive restarts, 6 h job timeout) | Workflows |
31+| Job runners and agent sandboxes | Containers (Ubuntu 24.04 image in `containers/runner`) |
32+| Scheduled workflows (`on: schedule`) | Cron Triggers |
33+| Live run logs and PR updates | Durable Object WebSockets (hibernation) |
34+| Sign-in | Passkeys (WebAuthn) and sessions in D1. Cloudflare Access guards only `/auth/access`, the email recovery path |
35+
36+```
37+apps/diffinery the Worker (src/index.ts), D1 migrations, wrangler config, e2e test
38+apps/cli the `diffinery` command-line client
39+containers/runner runner agent + sandbox server, and the container image
40+packages/git-core git objects, packfiles, deltas, diff/merge (pure TypeScript)
41+packages/workflow-yaml GitHub Actions workflow parser, expressions, matrix, cron
42+packages/shared types shared by the Worker and the runner
43+scripts bootstrap.sh, ensure-secrets.sh, render-config.mjs
44+```
45+
46+## Deploying
47+
48+Deploys run from GitHub Actions on every push to `main` (`.github/workflows/deploy.yml`).
49+The workflow needs two repository secrets:
50+
51+- `CLOUDFLARE_API_TOKEN`: account permissions Workers Scripts, D1, R2,
52+ Queues, Workflows, Containers and Access: Apps and Policies (all Edit), and
53+ zone permission Workers Routes Edit on `diffinery.com`.
54+- `CLOUDFLARE_ACCOUNT_ID`
55+
56+On each deploy, `scripts/bootstrap.sh` creates anything that is missing: the D1
57+database, R2 bucket, queues and one Access application that covers only
58+`/auth/access` (email-based account recovery). It then applies D1 migrations. `wrangler deploy`
59+builds and pushes the runner image. On the first deploy, `ensure-secrets.sh`
60+generates `MASTER_KEY` and the OIDC signing key inside the job, so neither
61+value is ever printed.
62+
63+If the token can't read your Zero Trust organization, set a repository
64+**variable** `ACCESS_TEAM_DOMAIN` (for example `codician.cloudflareaccess.com`).
65+
66+Email recovery uses Access's email one-time PIN, which works without any setup.
67+You can also add GitHub as a login method under Zero Trust → Settings → Authentication.
68+
69+## Signing in and inviting people
70+
71+Everyone signs in with a passkey. There are no passwords.
72+
73+1. **First sign-in for admins.** The emails in `BOOTSTRAP_ADMINS` (in
74+ `apps/diffinery/wrangler.jsonc`) become site admins. Open
75+ `https://diffinery.com/auth/access`, confirm your email with the one-time
76+ code, and add a passkey on the page that follows.
77+2. **Inviting someone.** An admin or org owner creates an invite from
78+ *Settings → Site administration*, an organization's *People* tab, or
79+ `diffinery invite friend@example.com --org codician`. The person opens the
80+ link, picks a username and creates a passkey, and they're in.
81+3. **Lost passkey.** A site admin clicks *Sign-in link* next to the person in
82+ *Settings → Site administration* and sends them the one-time link, which
83+ works for 24 hours. People can also recover through `/auth/access` by email.
84+4. **More devices.** Add passkeys under *Settings → Passkeys and sessions*, or
85+ keep one in a password manager that syncs. The same page signs out other
86+ browsers.
87+5. Repository and org roles (read, triage, write, maintain, admin) decide what
88+ each person can do.
89+
90+Nobody needs a Cloudflare account. Git and the CLI use a personal token from
91+*Settings → Tokens*.
92+
93+```sh
94+git clone https://diffinery.com/codician/website.git # username: anything, password: your dfy_ token
95+diffinery login && diffinery auth setup-git # or let the CLI handle credentials
96+```
97+
98+## Agents
99+
100+Create an agent under *Settings → Agents* or with `diffinery agent create reviewer-bot --org codician`.
101+Then point any MCP client at it:
102+
103+```json
104+{ "mcpServers": { "diffinery": { "type": "http", "url": "https://diffinery.com/mcp", "headers": { "Authorization": "Bearer dfy_…" } } } }
105+```
106+
107+Tools include `get_file`, `list_tree`, `commit_files` (no git client needed),
108+`create_branch`, `create_pull_request` (with an `intent` for reviewers),
109+`review_pull_request`, `merge_pull_request` (policy-guarded), issues,
110+`list_workflow_runs` and `get_job_logs`, `rerun_workflow`, and
111+`sandbox_create`/`sandbox_exec` for running builds and tests.
112+
113+Agent guardrails apply everywhere: git, REST, MCP and the web UI.
114+
115+- An agent's role is capped at write, and never exceeds its owner's role.
116+- Agents can't push to the default branch or protected branches, and can't
117+ merge there. Turn this on per branch with `agentsMayPush` or `agentsMayMerge`.
118+- A pull request opened by an agent needs at least one human approval, and
119+ approvals from agents don't count toward it.
120+- Agents can't change branch protection or read or set secrets.
121+
122+## Workflows
123+
124+Put workflow files in `.github/workflows/` (or `.diffinery/workflows/`).
125+Supported triggers are `push`, `pull_request`, `workflow_dispatch` (with
126+inputs) and `schedule`, with branch, tag and path filters. Supported workflow
127+features:
128+
129+- `needs` and job outputs, `if` with `success()`, `failure()`, `always()` and
130+ `cancelled()`, `strategy.matrix` with include and exclude, `fail-fast` and
131+ `max-parallel`, `concurrency` with `cancel-in-progress`, and `timeout-minutes`.
132+- `continue-on-error`, `defaults.run`, `env` at every level, `$GITHUB_OUTPUT`,
133+ `$GITHUB_ENV`, `$GITHUB_PATH`, `$GITHUB_STEP_SUMMARY`, and masking of
134+ secrets and `::add-mask::` values.
135+- `secrets.*`, `vars.*`, `GITHUB_TOKEN` (a per-job token), and
136+ `permissions: id-token: write` for OIDC (issuer `https://diffinery.com`).
137+- `runs-on: ubuntu-latest` (and any `ubuntu-*` or `linux` label) runs in a
138+ Cloudflare Container. `runs-on: [self-hosted, …]` jobs wait for a runner you
139+ start with `diffinery runner -R owner/repo --labels gpu,macos`. Use that for
140+ macOS, GPUs or private networks.
141+
142+**Not supported yet:** reusable workflows (`jobs.<id>.uses`), job
143+`container:` and `services:`, Docker container actions, `actions/github-script`,
144+Windows and macOS hosted runners, `pull_request_target`, `merge_group`, and
145+`deepen-not` / partial clone filters. Pull request runs use the head commit
146+rather than a test merge commit. A job that uses an unsupported feature fails
147+with a clear message instead of misbehaving.
148+
149+## Developing
150+
151+```sh
152+pnpm install
153+pnpm -r build && pnpm -r typecheck && pnpm -r test
154+pnpm --filter @diffinery/app db:migrate:local
155+pnpm dev # http://localhost:8787, signed in as dev@diffinery.local
156+pnpm --filter @diffinery/app e2e # wrangler dev + real git + the runner agent + Chromium
157+```
158+
159+The end-to-end test does the following:
160+
161+1. Pushes a repository with real `git`.
162+2. Runs its CI through the actual runner agent.
163+3. Checks secret scanning and branch protection.
164+4. Has an agent open a pull request that needs a human approval and a green
165+ check, then merges it.
166+5. Clones shallow, then deepens the clone.
167+6. Drives the MCP server.
168+7. In Chromium with a virtual authenticator, joins from an invite with a new
169+ passkey, adds and removes passkeys, signs out and in, and uses an admin
170+ sign-in link.
171+
172+Team preferences for product and design decisions are in
173+[docs/PREFERENCES.md](docs/PREFERENCES.md).
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/bin.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/bin.ts+16−0View file
@@ -1,0 +1,16 @@
1+import { main } from "./main.js";
2+
3+// EPIPE when piping into `head` and similar should end quietly.
4+process.stdout.on("error", (e: NodeJS.ErrnoException) => {
5+ if (e.code === "EPIPE") process.exit(0);
6+});
7+
8+main(process.argv.slice(2)).then(
9+ (code) => {
10+ process.exitCode = code;
11+ },
12+ (e: unknown) => {
13+ process.stderr.write(`error: ${String(e)}\n`);
14+ process.exitCode = 1;
15+ },
16+);
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, ...(o.short ? { short: o.short } : {}), ...(o.multiple ? { multiple: true } : {}) }])),
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+302−0View file
@@ -1,0 +1,302 @@
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: "List organization secrets instead", arg: "ORG" } },
43+ repo: true,
44+ json: true,
45+ maxArgs: 0,
46+ async run(ctx, o) {
47+ const org = str(o, "org");
48+ const path = org ? `/orgs/${enc(org)}/actions/secrets` : `${repoPath(await ctx.repo())}/actions/secrets`;
49+ const res = await (await ctx.authedApi()).get<{ secrets: Array<{ name: string; updated_at: string }> }>(path);
50+ if (ctx.json) return json(ctx.io, res.secrets);
51+ if (!res.secrets.length) return ctx.err("No secrets.");
52+ ctx.io.stdout.write(table(res.secrets.map((s) => [s.name, ctx.c.dim(`updated ${ago(s.updated_at)}`)])));
53+ },
54+};
55+
56+const secretDelete: Command = {
57+ name: "secret delete",
58+ summary: "Delete an Actions secret",
59+ args: "<NAME>",
60+ options: { org: { type: "string", short: "o", desc: "Delete an organization secret instead", arg: "ORG" } },
61+ repo: true,
62+ minArgs: 1,
63+ maxArgs: 1,
64+ async run(ctx, o, [name]) {
65+ const org = str(o, "org");
66+ await (await ctx.authedApi()).delete(org ? `/orgs/${enc(org)}/actions/secrets/${enc(name!)}` : `${repoPath(await ctx.repo())}/actions/secrets/${enc(name!)}`);
67+ ctx.out(`${ctx.c.green("✓")} Deleted secret ${name}`);
68+ },
69+};
70+
71+const variableSet: Command = {
72+ name: "variable set",
73+ summary: "Create or update an Actions variable",
74+ description: "The value comes from --body, or from stdin when piped.",
75+ args: "<NAME>",
76+ options: { body: { type: "string", short: "b", desc: "Variable value", arg: "VALUE" } },
77+ repo: true,
78+ minArgs: 1,
79+ maxArgs: 1,
80+ async run(ctx, o, [name]) {
81+ checkName(name!, "variable");
82+ let value = str(o, "body");
83+ if (value === undefined) {
84+ if (ctx.io.stdin.isTTY) throw new CliError("pass --body VALUE or pipe the value on stdin", 2);
85+ value = (await readAll(ctx.io.stdin)).replace(/\r?\n$/, "");
86+ }
87+ await (await ctx.authedApi()).put(`${repoPath(await ctx.repo())}/actions/variables/${enc(name!)}`, { value });
88+ ctx.out(`${ctx.c.green("✓")} Set variable ${name}`);
89+ },
90+};
91+
92+const variableList: Command = {
93+ name: "variable list",
94+ summary: "List Actions variables",
95+ repo: true,
96+ json: true,
97+ maxArgs: 0,
98+ async run(ctx) {
99+ const res = await (await ctx.api()).get<{ variables: Array<{ name: string; value: string }> }>(`${repoPath(await ctx.repo())}/actions/variables`);
100+ if (ctx.json) return json(ctx.io, res.variables);
101+ if (!res.variables.length) return ctx.err("No variables.");
102+ ctx.io.stdout.write(table(res.variables.map((v) => [v.name, v.value])));
103+ },
104+};
105+
106+// ---------------------------------------------------------------- tokens
107+
108+interface TokenRow {
109+ id: string;
110+ name: string;
111+ prefix: string;
112+ scopes: string[];
113+ repoIds: string[] | null;
114+ expiresAt: string;
115+ lastUsedAt: string | null;
116+ createdAt: string;
117+}
118+
119+const tokenCreate: Command = {
120+ name: "token create",
121+ summary: "Create a personal access token (printed once)",
122+ description: "Needs an admin-scoped token (or create tokens in the web UI).",
123+ options: {
124+ name: { type: "string", short: "n", desc: "Token name (required)", arg: "NAME" },
125+ scope: { type: "string", short: "s", multiple: true, desc: "read, write (default) or admin", arg: "SCOPE" },
126+ "expires-days": { type: "string", short: "e", desc: "Days until it expires (1-366, default 90)", arg: "DAYS" },
127+ repo: { type: "string", short: "R", multiple: true, desc: "Restrict the token to this repository", arg: "OWNER/NAME" },
128+ },
129+ json: true,
130+ maxArgs: 0,
131+ async run(ctx, o) {
132+ const name = str(o, "name");
133+ if (!name) throw new CliError("--name is required", 2);
134+ const scopes = list(o, "scope").flatMap((s) => s.split(",")).map((s) => s.trim()).filter(Boolean);
135+ for (const s of scopes) if (!["read", "write", "admin"].includes(s)) throw new CliError(`unknown scope "${s}" (read, write or admin)`, 2);
136+ const days = str(o, "expires-days") ? positiveInt(str(o, "expires-days"), "--expires-days") : undefined;
137+ const repos = list(o, "repo");
138+ 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 });
139+ if (ctx.json) return json(ctx.io, t);
140+ ctx.out(t.token);
141+ ctx.err(ctx.c.dim(`Token ${t.id} expires ${t.expiresAt.slice(0, 10)}. It will not be shown again.`));
142+ },
143+};
144+
145+const tokenList: Command = {
146+ name: "token list",
147+ summary: "List your tokens",
148+ json: true,
149+ maxArgs: 0,
150+ async run(ctx) {
151+ const rows = await (await ctx.authedApi()).get<TokenRow[]>("/diffinery/tokens");
152+ if (ctx.json) return json(ctx.io, rows);
153+ if (!rows.length) return ctx.err("No tokens.");
154+ const c = ctx.c;
155+ ctx.io.stdout.write(
156+ table([
157+ ["ID", "NAME", "PREFIX", "SCOPES", "EXPIRES", "LAST USED"].map((h) => c.dim(h)),
158+ ...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")]),
159+ ]),
160+ );
161+ },
162+};
163+
164+const tokenRevoke: Command = {
165+ name: "token revoke",
166+ summary: "Revoke a token",
167+ args: "<id>",
168+ minArgs: 1,
169+ maxArgs: 1,
170+ async run(ctx, _o, [id]) {
171+ await (await ctx.authedApi()).delete(`/diffinery/tokens/${enc(id!)}`);
172+ ctx.out(`${ctx.c.green("✓")} Revoked token ${id}`);
173+ },
174+};
175+
176+// ---------------------------------------------------------------- agents & invites
177+
178+const agentCreate: Command = {
179+ name: "agent create",
180+ summary: "Create an agent account and print its token once",
181+ args: "<login>",
182+ options: {
183+ org: { type: "string", short: "o", desc: "Owned by this organization instead of you", arg: "ORG" },
184+ name: { type: "string", short: "n", desc: "Display name", arg: "NAME" },
185+ scope: { type: "string", short: "s", multiple: true, desc: "Token scope: read, write (default) or admin", arg: "SCOPE" },
186+ "expires-days": { type: "string", short: "e", desc: "Token lifetime in days (default 90)", arg: "DAYS" },
187+ },
188+ json: true,
189+ minArgs: 1,
190+ maxArgs: 1,
191+ async run(ctx, o, [login]) {
192+ const scopes = list(o, "scope");
193+ const days = str(o, "expires-days") ? positiveInt(str(o, "expires-days"), "--expires-days") : undefined;
194+ const r = await (await ctx.authedApi()).post<{ agent: { login: string; html_url: string }; token: { token: string; id: string; expiresAt: string } }>("/diffinery/agents", {
195+ login,
196+ name: str(o, "name"),
197+ org: str(o, "org"),
198+ token: scopes.length || days ? { scopes: scopes.length ? scopes : undefined, expires_in_days: days } : undefined,
199+ });
200+ if (ctx.json) return json(ctx.io, r);
201+ ctx.out(`${ctx.c.green("✓")} Created agent ${ctx.c.bold(r.agent.login)}`);
202+ ctx.out(r.token.token);
203+ 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.`));
204+ },
205+};
206+
207+const agentList: Command = {
208+ name: "agent list",
209+ summary: "List agents you or your organizations own",
210+ json: true,
211+ maxArgs: 0,
212+ async run(ctx) {
213+ const rows = await (await ctx.authedApi()).get<Array<{ login: string; name: string | null; owner_id: string; policy: Record<string, unknown> }>>("/diffinery/agents");
214+ if (ctx.json) return json(ctx.io, rows);
215+ if (!rows.length) return ctx.err("No agents.");
216+ 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}` : "")])));
217+ },
218+};
219+
220+const agentDisable: Command = {
221+ name: "agent disable",
222+ summary: "Disable an agent (its tokens stop working)",
223+ args: "<login>",
224+ minArgs: 1,
225+ maxArgs: 1,
226+ async run(ctx, _o, [login]) {
227+ await (await ctx.authedApi()).delete(`/diffinery/agents/${enc(login!)}`);
228+ ctx.out(`${ctx.c.green("✓")} Disabled agent ${login}`);
229+ },
230+};
231+
232+const invite: Command = {
233+ name: "invite",
234+ summary: "Invite someone by email (to an organization, or to the site as an admin)",
235+ args: "<email>",
236+ options: {
237+ org: { type: "string", short: "o", desc: "Organization to join (omit for a site invite; site admins only)", arg: "ORG" },
238+ role: { type: "string", short: "r", desc: "member (default), admin or owner", arg: "ROLE" },
239+ },
240+ json: true,
241+ minArgs: 1,
242+ maxArgs: 1,
243+ async run(ctx, o, [email]) {
244+ const org = str(o, "org");
245+ const role = str(o, "role");
246+ if (!org && role) throw new CliError("--role needs --org", 2);
247+ if (role && !["member", "admin", "owner"].includes(role)) throw new CliError("--role must be member, admin or owner", 2);
248+ const r = await (await ctx.authedApi()).post<{ id: string; url: string }>(org ? `/orgs/${enc(org)}/invitations` : "/invitations", org ? { email, role } : { email });
249+ if (ctx.json) return json(ctx.io, r);
250+ ctx.out(`${ctx.c.green("✓")} Invited ${email}${org ? ` to ${org}` : ""}${role ? ` as ${role}` : ""}`);
251+ ctx.out(r.url);
252+ ctx.err(ctx.c.dim("Send them this link; it is valid for 14 days."));
253+ },
254+};
255+
256+// ---------------------------------------------------------------- raw API
257+
258+const apiCmd: Command = {
259+ name: "api",
260+ summary: "Make an authenticated API request and print the response",
261+ args: "<METHOD> <PATH>",
262+ description: `PATH is relative to /api/v3 (e.g. /user, /repos/OWNER/NAME/pulls) unless it starts with /api/.
263+--input sends a JSON body from a file, or from stdin with '-'.`,
264+ options: {
265+ input: { type: "string", short: "i", desc: "Request body file, or '-' for stdin", arg: "FILE" },
266+ field: { type: "string", short: "f", multiple: true, desc: "Add a string field to the JSON body (or query for GET)", arg: "KEY=VALUE" },
267+ },
268+ minArgs: 2,
269+ maxArgs: 2,
270+ async run(ctx, o, [method, p]) {
271+ const m = method!.toUpperCase();
272+ if (!/^[A-Z]+$/.test(m)) throw new CliError(`invalid method "${method}"`, 2);
273+ let body: string | undefined;
274+ const input = str(o, "input");
275+ if (input) body = input === "-" ? await readAll(ctx.io.stdin) : await readFile(path.resolve(ctx.io.cwd, input), "utf8");
276+ const fields: Record<string, string> = {};
277+ for (const f of list(o, "field")) {
278+ const i = f.indexOf("=");
279+ if (i <= 0) throw new CliError(`-f expects key=value, got "${f}"`, 2);
280+ fields[f.slice(0, i)] = f.slice(i + 1);
281+ }
282+ const hasFields = Object.keys(fields).length > 0;
283+ if (hasFields && m !== "GET" && m !== "HEAD") {
284+ if (body) throw new CliError("use either --input or -f, not both", 2);
285+ body = JSON.stringify(fields);
286+ }
287+ const api = await ctx.api();
288+ const res = await api.raw<unknown>(m, p!, { body, query: m === "GET" && hasFields ? fields : undefined, allow: Array.from({ length: 500 }, (_, i) => i + 100) });
289+ const out = typeof res.data === "string" ? res.data : res.data === null ? "" : JSON.stringify(res.data, null, 2);
290+ if (out) ctx.out(out);
291+ if (res.status >= 400) {
292+ ctx.err(ctx.c.red(`HTTP ${res.status}`));
293+ throw new ExitCode(1);
294+ }
295+ },
296+};
297+
298+export const secretGroup: Group = { name: "secret", summary: "Manage Actions secrets", commands: [secretSet, secretList, secretDelete] };
299+export const variableGroup: Group = { name: "variable", summary: "Manage Actions variables", commands: [variableSet, variableList] };
300+export const tokenGroup: Group = { name: "token", summary: "Manage personal access tokens", commands: [tokenCreate, tokenList, tokenRevoke] };
301+export const agentGroup: Group = { name: "agent", summary: "Manage agent accounts", commands: [agentCreate, agentList, agentDisable] };
302+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/migrate.ts+296−0View file
@@ -1,0 +1,296 @@
1+import { mkdtemp, rm } from "node:fs/promises";
2+import { tmpdir } from "node:os";
3+import path from "node:path";
4+import { ApiError, type Api } from "../api.js";
5+import { parseRepoArg, repoPath, str, type Command, type Ctx, type Group, type RepoRef } from "../cli.js";
6+import { basicAuthHeader, git, gitCapture, withGitConfig } from "../git.js";
7+import { GitHub, GitHubError } from "../github.js";
8+import { CliError } from "../io.js";
9+import type { IssueJson } from "./pr.js";
10+import type { RepoJson } from "./repo.js";
11+
12+interface GhRepo {
13+ full_name: string;
14+ name: string;
15+ private: boolean;
16+ visibility?: "public" | "private" | "internal";
17+ description: string | null;
18+ default_branch: string;
19+ clone_url: string;
20+ html_url: string;
21+}
22+
23+interface GhUser {
24+ login: string;
25+}
26+
27+interface GhIssue {
28+ number: number;
29+ title: string;
30+ body: string | null;
31+ state: "open" | "closed";
32+ state_reason?: string | null;
33+ user: GhUser | null;
34+ labels: Array<{ name: string } | string>;
35+ comments: number;
36+ created_at: string;
37+ pull_request?: unknown;
38+}
39+
40+interface GhPull {
41+ number: number;
42+ title: string;
43+ body: string | null;
44+ draft?: boolean;
45+ user: GhUser | null;
46+ created_at: string;
47+ head: { ref: string; repo: { full_name: string } | null };
48+ base: { ref: string };
49+}
50+
51+interface GhComment {
52+ body: string | null;
53+ user: GhUser | null;
54+ created_at: string;
55+}
56+
57+const day = (iso: string) => iso.slice(0, 10);
58+const who = (u: GhUser | null) => (u ? `@${u.login}` : "a deleted user");
59+
60+export function attribution(kind: "issue" | "pull request" | "comment", n: number, user: GhUser | null, createdAt: string, htmlBase: string): string {
61+ const link = `${htmlBase}/${kind === "pull request" ? "pull" : "issues"}/${n}`;
62+ if (kind === "comment") return `> Imported from GitHub: comment by ${who(user)} on ${day(createdAt)}`;
63+ return `> Imported from GitHub ${kind} [#${n}](${link}) by ${who(user)} on ${day(createdAt)}`;
64+}
65+
66+const withAttribution = (line: string, body: string | null) => `${line}\n\n${body ?? ""}`.trimEnd();
67+
68+interface Summary {
69+ repo: string;
70+ created: boolean;
71+ branches: number;
72+ tags: number;
73+ pushFailures: string[];
74+ defaultBranch: string | null;
75+ labels: number;
76+ issues: Array<{ from: number; to: number }>;
77+ pulls: Array<{ from: number; to: number }>;
78+ skippedPulls: Array<{ number: number; reason: string }>;
79+ comments: number;
80+ variables: string[];
81+ secrets: string[];
82+ warnings: string[];
83+}
84+
85+async function optional<T>(ctx: Ctx, s: Summary, what: string, f: () => Promise<T>): Promise<T | null> {
86+ try {
87+ return await f();
88+ } catch (e) {
89+ if (e instanceof GitHubError && (e.status === 403 || e.status === 404)) {
90+ s.warnings.push(`could not read ${what} from GitHub (${e.status}; the token may lack access)`);
91+ ctx.err(`${ctx.c.yellow("warning:")} could not read ${what} from GitHub (${e.status})`);
92+ return null;
93+ }
94+ throw e;
95+ }
96+}
97+
98+async function ensureRepo(ctx: Ctx, api: Api, to: RepoRef, me: string, gh: GhRepo, forcePrivate: boolean): Promise<{ repo: RepoJson; created: boolean }> {
99+ const existing = await api.raw<RepoJson>("GET", repoPath(to), { allow: [404] });
100+ if (existing.status !== 404) {
101+ ctx.out(`Using existing repository ${to.owner}/${to.name}`);
102+ return { repo: existing.data, created: false };
103+ }
104+ const visibility = forcePrivate ? "private" : (gh.visibility ?? (gh.private ? "private" : "public"));
105+ const body = { name: to.name, description: gh.description ?? undefined, visibility };
106+ const repo = await api.post<RepoJson>(to.owner === me ? "/user/repos" : `/orgs/${encodeURIComponent(to.owner)}/repos`, body);
107+ ctx.out(`${ctx.c.green("✓")} Created ${repo.full_name} (${repo.visibility})`);
108+ return { repo, created: true };
109+}
110+
111+async function migrateGit(ctx: Ctx, gh: GhRepo, ghToken: string | null, target: string, token: string | null, diffineryUrl: string, s: Summary): Promise<Set<string>> {
112+ const dir = await mkdtemp(path.join(tmpdir(), "diffinery-migrate-"));
113+ const mirror = path.join(dir, "repo.git");
114+ try {
115+ ctx.out(`Cloning ${gh.html_url} …`);
116+ const cloneEnv = withGitConfig(ctx.io.env, ghToken && /^https?:\/\//.test(gh.clone_url) ? [basicAuthHeader(new URL(gh.clone_url).origin, "x-access-token", ghToken)] : []);
117+ const cl = await gitCapture(ctx.io, ["clone", "--mirror", "--quiet", gh.clone_url, mirror], { env: { ...cloneEnv, GIT_TERMINAL_PROMPT: "0" } });
118+ if (cl.code !== 0) throw new CliError(`git clone --mirror failed: ${cl.stderr.trim()}`);
119+ const refs = (await git(ctx.io, ["for-each-ref", "--format=%(refname)", "refs/heads", "refs/tags"], { cwd: mirror })).split("\n").filter(Boolean);
120+ const branches = new Set(refs.filter((r) => r.startsWith("refs/heads/")).map((r) => r.slice("refs/heads/".length)));
121+ s.branches = branches.size;
122+ s.tags = refs.length - branches.size;
123+ if (!refs.length) {
124+ ctx.out("The GitHub repository is empty; nothing to push.");
125+ return branches;
126+ }
127+ ctx.out(`Pushing ${s.branches} branch${s.branches === 1 ? "" : "es"} and ${s.tags} tag${s.tags === 1 ? "" : "s"} to ${target} …`);
128+ const pushEnv = { ...withGitConfig(ctx.io.env, token ? [basicAuthHeader(diffineryUrl, "x-token", token)] : []), GIT_TERMINAL_PROMPT: "0" };
129+ const remote = `${diffineryUrl}/${target}.git`;
130+ const all = await gitCapture(ctx.io, ["push", "--quiet", remote, "refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"], { cwd: mirror, env: pushEnv });
131+ if (all.code !== 0) {
132+ // One big push can hit request size limits or a rejected ref; fall back to ref by ref.
133+ ctx.err(`${ctx.c.yellow("warning:")} bulk push failed (${all.stderr.trim().split("\n").pop()}); pushing refs one at a time`);
134+ for (const ref of refs) {
135+ const r = await gitCapture(ctx.io, ["push", "--quiet", remote, `${ref}:${ref}`], { cwd: mirror, env: pushEnv });
136+ if (r.code !== 0) {
137+ s.pushFailures.push(`${ref}: ${r.stderr.trim().split("\n").pop()}`);
138+ ctx.err(`${ctx.c.red("X")} ${ref}: ${r.stderr.trim().split("\n").pop()}`);
139+ }
140+ }
141+ }
142+ return branches;
143+ } finally {
144+ await rm(dir, { recursive: true, force: true });
145+ }
146+}
147+
148+async function migrateIssues(ctx: Ctx, gh: GitHub, api: Api, src: string, gr: GhRepo, to: RepoRef, branches: Set<string>, s: Summary): Promise<void> {
149+ const base = repoPath(to);
150+ const ghPath = `/repos/${src}`;
151+ // Labels
152+ const labels = (await optional(ctx, s, "labels", () => gh.paginate<{ name: string; color: string; description: string | null }>(`${ghPath}/labels`))) ?? [];
153+ for (const l of labels) {
154+ try {
155+ await api.post(`${base}/labels`, { name: l.name, color: l.color, description: l.description ?? "" });
156+ s.labels++;
157+ } catch (e) {
158+ s.warnings.push(`label ${l.name}: ${(e as Error).message}`);
159+ }
160+ }
161+ if (labels.length) ctx.out(`${ctx.c.green("✓")} Copied ${s.labels} label${s.labels === 1 ? "" : "s"}`);
162+
163+ const openPulls = new Map<number, GhPull>();
164+ for (const p of (await optional(ctx, s, "pull requests", () => gh.paginate<GhPull>(`${ghPath}/pulls`, { state: "open" }))) ?? []) openPulls.set(p.number, p);
165+ const items = ((await optional(ctx, s, "issues", () => gh.paginate<GhIssue>(`${ghPath}/issues`, { state: "all", sort: "created", direction: "asc" }))) ?? []).sort((a, b) => a.number - b.number);
166+ ctx.out(`Importing ${items.length} issues and pull requests …`);
167+
168+ for (const it of items) {
169+ let created: IssueJson | null = null;
170+ if (it.pull_request) {
171+ const p = openPulls.get(it.number);
172+ if (it.state !== "open" || !p) {
173+ s.skippedPulls.push({ number: it.number, reason: it.state === "open" ? "not found" : "closed or merged" });
174+ continue;
175+ }
176+ if (p.head.repo?.full_name?.toLowerCase() !== gr.full_name.toLowerCase()) {
177+ s.skippedPulls.push({ number: it.number, reason: "head branch is in a fork" });
178+ continue;
179+ }
180+ if (!branches.has(p.head.ref) || !branches.has(p.base.ref)) {
181+ s.skippedPulls.push({ number: it.number, reason: `branch ${branches.has(p.head.ref) ? p.base.ref : p.head.ref} missing` });
182+ continue;
183+ }
184+ try {
185+ created = await api.post<IssueJson>(`${base}/pulls`, { title: p.title, body: withAttribution(attribution("pull request", p.number, p.user, p.created_at, gr.html_url), p.body), head: p.head.ref, base: p.base.ref, draft: p.draft || undefined });
186+ s.pulls.push({ from: it.number, to: created.number });
187+ } catch (e) {
188+ s.skippedPulls.push({ number: it.number, reason: (e as Error).message });
189+ continue;
190+ }
191+ } else {
192+ const labelNames = it.labels.map((l) => (typeof l === "string" ? l : l.name)).filter(Boolean);
193+ const body = withAttribution(attribution("issue", it.number, it.user, it.created_at, gr.html_url), it.body);
194+ try {
195+ created = await api.post<IssueJson>(`${base}/issues`, { title: it.title, body, labels: labelNames.length ? labelNames : undefined });
196+ } catch (e) {
197+ if (!(e instanceof ApiError) || !labelNames.length) throw e;
198+ created = await api.post<IssueJson>(`${base}/issues`, { title: it.title, body });
199+ s.warnings.push(`issue #${it.number}: labels not applied (${e.message})`);
200+ }
201+ s.issues.push({ from: it.number, to: created.number });
202+ }
203+ if (it.comments > 0) {
204+ const comments = (await optional(ctx, s, `comments of #${it.number}`, () => gh.paginate<GhComment>(`${ghPath}/issues/${it.number}/comments`))) ?? [];
205+ for (const cm of comments) {
206+ await api.post(`${base}/issues/${created.number}/comments`, { body: withAttribution(attribution("comment", it.number, cm.user, cm.created_at, gr.html_url), cm.body) });
207+ s.comments++;
208+ }
209+ }
210+ if (it.state === "closed" && !it.pull_request) {
211+ await api.patch(`${base}/issues/${created.number}`, { state: "closed", state_reason: it.state_reason === "not_planned" ? "not_planned" : "completed" });
212+ }
213+ ctx.out(ctx.c.dim(` GitHub #${it.number} → #${created.number} ${it.title.slice(0, 60)}`));
214+ }
215+}
216+
217+const github: Command = {
218+ name: "migrate github",
219+ summary: "Import a GitHub repository (git data, and optionally issues)",
220+ args: "<GH-OWNER/GH-REPO>",
221+ description: `Creates the Diffinery repository if needed, mirrors all branches and tags (not refs/pull),
222+sets the default branch, copies Actions variables and lists Actions secret names to set.
223+With --issues, also copies labels, issues and comments (with attribution lines), and recreates
224+open pull requests whose branches were imported.
225+
226+GitHub access uses GH_TOKEN or GITHUB_TOKEN (needed for private repositories; GITHUB_API_URL
227+selects a GitHub Enterprise API).`,
228+ options: {
229+ to: { type: "string", short: "t", desc: "Diffinery repository to create or push into (default: <you>/<gh-repo>)", arg: "OWNER/NAME" },
230+ issues: { type: "boolean", short: "i", desc: "Also import labels, issues, comments and open pull requests" },
231+ private: { type: "boolean", short: "p", desc: "Create the repository as private regardless of GitHub's visibility" },
232+ },
233+ json: true,
234+ minArgs: 1,
235+ maxArgs: 1,
236+ async run(ctx, o, [srcArg]) {
237+ const src = parseRepoArg(srcArg!.replace(/^https?:\/\/github\.com\//, ""));
238+ const srcName = `${src.owner}/${src.name}`;
239+ const api = await ctx.authedApi();
240+ const cfg = await ctx.config();
241+ const ghToken = ctx.io.env.GH_TOKEN || ctx.io.env.GITHUB_TOKEN || null;
242+ const gh = new GitHub({ baseUrl: ctx.io.env.GITHUB_API_URL, token: ghToken, sleep: (ms) => ctx.io.sleep(ms), log: (m) => ctx.err(ctx.c.yellow(m)) });
243+ if (!ghToken) ctx.err(ctx.c.yellow("note: GH_TOKEN/GITHUB_TOKEN is not set; only public data can be read and GitHub rate limits are low"));
244+
245+ const me = await api.get<{ login: string }>("/user");
246+ const gr = await gh.get<GhRepo>(`/repos/${srcName}`);
247+ const to = str(o, "to") ? parseRepoArg(str(o, "to")!) : { owner: me.login, name: gr.name };
248+ const target = `${to.owner}/${to.name}`;
249+ const s: Summary = { repo: target, created: false, branches: 0, tags: 0, pushFailures: [], defaultBranch: null, labels: 0, issues: [], pulls: [], skippedPulls: [], comments: 0, variables: [], secrets: [], warnings: [] };
250+
251+ const { created } = await ensureRepo(ctx, api, to, me.login, gr, o.private === true);
252+ s.created = created;
253+ const branches = await migrateGit(ctx, gr, ghToken, target, cfg.token, cfg.url, s);
254+ if (branches.has(gr.default_branch)) {
255+ await api.patch(repoPath(to), { default_branch: gr.default_branch });
256+ s.defaultBranch = gr.default_branch;
257+ }
258+
259+ const vars = (await optional(ctx, s, "Actions variables", () => gh.paginate<{ name: string; value: string }>(`/repos/${srcName}/actions/variables`, {}, "variables"))) ?? [];
260+ for (const v of vars) {
261+ await api.put(`${repoPath(to)}/actions/variables/${encodeURIComponent(v.name)}`, { value: v.value });
262+ s.variables.push(v.name);
263+ }
264+ s.secrets = ((await optional(ctx, s, "Actions secret names", () => gh.paginate<{ name: string }>(`/repos/${srcName}/actions/secrets`, {}, "secrets"))) ?? []).map((x) => x.name);
265+
266+ if (o.issues) await migrateIssues(ctx, gh, api, srcName, gr, to, branches, s);
267+
268+ if (ctx.json) {
269+ ctx.out(JSON.stringify(s, null, 2));
270+ return s.pushFailures.length ? 1 : 0;
271+ }
272+ const c = ctx.c;
273+ ctx.out("");
274+ ctx.out(c.bold(`Imported ${srcName} → ${target}`));
275+ ctx.out(` git: ${s.branches} branches, ${s.tags} tags${s.pushFailures.length ? c.red(` (${s.pushFailures.length} refs failed)`) : ""}`);
276+ ctx.out(` default: ${s.defaultBranch ?? c.dim("unchanged")}`);
277+ ctx.out(` variables: ${s.variables.length}`);
278+ if (o.issues) {
279+ ctx.out(` labels: ${s.labels}`);
280+ ctx.out(` issues: ${s.issues.length} (${s.comments} comments)`);
281+ ctx.out(` pulls: ${s.pulls.length} recreated, ${s.skippedPulls.filter((p) => p.reason !== "closed or merged").length} skipped`);
282+ for (const p of s.skippedPulls.filter((x) => x.reason !== "closed or merged")) ctx.out(c.dim(` #${p.number}: ${p.reason}`));
283+ }
284+ if (s.secrets.length) {
285+ ctx.out("");
286+ ctx.out(`GitHub does not reveal secret values. Set these ${s.secrets.length} secret${s.secrets.length === 1 ? "" : "s"}:`);
287+ for (const n of s.secrets) ctx.out(` diffinery secret set ${n} -R ${target}`);
288+ }
289+ for (const w of s.warnings) ctx.err(`${c.yellow("warning:")} ${w}`);
290+ ctx.out("");
291+ ctx.out(`${cfg.url}/${target}`);
292+ return s.pushFailures.length ? 1 : 0;
293+ },
294+};
295+
296+export const migrateGroup: Group = { name: "migrate", summary: "Import repositories from other forges", commands: [github] };
addedapps/cli/src/commands/pr.ts+313−0View file
@@ -1,0 +1,313 @@
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"]) {
189+ ctx.io.stdout.write(files.map((f) => `${f.filename}\n`).join(""));
190+ return;
191+ }
192+ const c = ctx.c;
193+ for (const f of files) {
194+ const patch = f.patch ?? `diff --git a/${f.filename} b/${f.filename}\n`;
195+ if (!c.enabled) {
196+ ctx.io.stdout.write(patch.endsWith("\n") ? patch : `${patch}\n`);
197+ continue;
198+ }
199+ for (const line of patch.replace(/\n$/, "").split("\n")) {
200+ if (line.startsWith("diff --git") || line.startsWith("--- ") || line.startsWith("+++ ")) ctx.out(c.bold(line));
201+ else if (line.startsWith("@@")) ctx.out(c.cyan(line));
202+ else if (line.startsWith("+")) ctx.out(c.green(line));
203+ else if (line.startsWith("-")) ctx.out(c.red(line));
204+ else ctx.out(line);
205+ }
206+ }
207+ },
208+};
209+
210+const checks: Command = {
211+ name: "pr checks",
212+ summary: "Show check status for a pull request",
213+ description: "Exit status: 0 when all checks passed, 1 when any failed, 8 when some are still pending.",
214+ args: "<number>",
215+ repo: true,
216+ json: true,
217+ minArgs: 1,
218+ maxArgs: 1,
219+ async run(ctx, _o, [num]) {
220+ const { pr } = await getPull(ctx, positiveInt(num, "pull request number"));
221+ const list = pr.checks ?? [];
222+ if (ctx.json) json(ctx.io, list);
223+ else if (!list.length) ctx.err("No checks reported for this pull request.");
224+ 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)])));
225+ const failed = list.some((k) => ["failure", "cancelled", "timed_out", "action_required"].includes(k.state));
226+ const pending = list.some((k) => ["queued", "in_progress", "waiting", "pending", "requested"].includes(k.state));
227+ throw new ExitCode(failed ? 1 : pending ? 8 : 0);
228+ },
229+};
230+
231+const review: Command = {
232+ name: "pr review",
233+ summary: "Approve, request changes or comment on a pull request",
234+ args: "<number>",
235+ options: {
236+ approve: { type: "boolean", short: "a", desc: "Approve" },
237+ "request-changes": { type: "boolean", short: "r", desc: "Request changes" },
238+ comment: { type: "boolean", short: "c", desc: "Comment without approving" },
239+ ...bodyOpts,
240+ },
241+ repo: true,
242+ json: true,
243+ minArgs: 1,
244+ maxArgs: 1,
245+ async run(ctx, o, [num]) {
246+ const n = positiveInt(num, "pull request number");
247+ const which = exactlyOne(o, ["approve", "request-changes", "comment"]);
248+ const event = which === "approve" ? "APPROVE" : which === "request-changes" ? "REQUEST_CHANGES" : "COMMENT";
249+ const body = await bodyFrom(ctx, o);
250+ if (event !== "APPROVE" && !body) throw new CliError(`--body is required with --${which}`, 2);
251+ const repo = await ctx.repo();
252+ const r = await (await ctx.authedApi()).post<{ id: string; state: string }>(`${repoPath(repo)}/pulls/${n}/reviews`, { event, body });
253+ if (ctx.json) return json(ctx.io, r);
254+ const verb = event === "APPROVE" ? "Approved" : event === "REQUEST_CHANGES" ? "Requested changes on" : "Reviewed";
255+ ctx.out(`${ctx.c.green("✓")} ${verb} #${n}`);
256+ },
257+};
258+
259+const merge: Command = {
260+ name: "pr merge",
261+ summary: "Merge a pull request",
262+ args: "<number>",
263+ options: {
264+ merge: { type: "boolean", short: "m", desc: "Create a merge commit (default)" },
265+ squash: { type: "boolean", short: "s", desc: "Squash into one commit" },
266+ rebase: { type: "boolean", short: "r", desc: "Rebase the commits onto the base branch" },
267+ "delete-branch": { type: "boolean", short: "d", desc: "Delete the head branch after merging" },
268+ subject: { type: "string", short: "t", desc: "Commit title", arg: "TEXT" },
269+ ...bodyOpts,
270+ "match-head-commit": { type: "string", desc: "Only merge if the head is still at this SHA", arg: "SHA" },
271+ },
272+ repo: true,
273+ json: true,
274+ minArgs: 1,
275+ maxArgs: 1,
276+ async run(ctx, o, [num]) {
277+ const n = positiveInt(num, "pull request number");
278+ const method = exactlyOne(o, ["merge", "squash", "rebase"], "merge");
279+ const repo = await ctx.repo();
280+ const payload = {
281+ merge_method: method,
282+ delete_branch: o["delete-branch"] === true ? true : undefined,
283+ commit_title: str(o, "subject"),
284+ commit_message: await bodyFrom(ctx, o),
285+ sha: str(o, "match-head-commit"),
286+ };
287+ const r = await (await ctx.authedApi()).put<{ merged: boolean; sha: string; method: string }>(`${repoPath(repo)}/pulls/${n}/merge`, payload);
288+ if (ctx.json) return json(ctx.io, r);
289+ 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" : ""}`);
290+ },
291+};
292+
293+const comment: Command = {
294+ name: "pr comment",
295+ summary: "Comment on a pull request",
296+ args: "<number>",
297+ options: bodyOpts,
298+ repo: true,
299+ json: true,
300+ minArgs: 1,
301+ maxArgs: 1,
302+ async run(ctx, o, [num]) {
303+ const n = positiveInt(num, "pull request number");
304+ const body = await bodyFrom(ctx, o, true);
305+ if (!body?.trim()) throw new CliError("--body is required", 2);
306+ const repo = await ctx.repo();
307+ const r = await (await ctx.authedApi()).post<CommentJson>(`${repoPath(repo)}/issues/${n}/comments`, { body });
308+ if (ctx.json) return json(ctx.io, r);
309+ ctx.out(`${ctx.c.green("✓")} Commented on #${n}`);
310+ },
311+};
312+
313+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 ? `/users/${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/runner.ts+169−0View file
@@ -1,0 +1,169 @@
1+import { spawn, type ChildProcess } from "node:child_process";
2+import { existsSync } from "node:fs";
3+import { mkdir } from "node:fs/promises";
4+import { homedir, hostname } from "node:os";
5+import path from "node:path";
6+import { createInterface } from "node:readline";
7+import { ApiError, type Api } from "../api.js";
8+import { list, parseRepoArg, repoPath, str, type Command, type Ctx, type RepoRef } from "../cli.js";
9+import { CliError, type Io } from "../io.js";
10+
11+export interface ClaimedJob {
12+ job_id: string;
13+ run_id: string;
14+ name: string;
15+ token: string;
16+ api_url: string;
17+}
18+
19+const AGENT_HELP = `The runner needs the Diffinery runner agent (a single Node.js file). Build it from the
20+Diffinery repository with:
21+
22+ pnpm --filter @diffinery/runner build # produces containers/runner/dist/agent.mjs
23+
24+then pass --agent /path/to/agent.mjs or set DIFFINERY_RUNNER_AGENT.`;
25+
26+export function resolveAgentPath(io: Io, flag: string | undefined): string {
27+ const p = flag ?? io.env.DIFFINERY_RUNNER_AGENT;
28+ if (!p) throw new CliError(`no runner agent given.\n\n${AGENT_HELP}`);
29+ const abs = path.resolve(io.cwd, p);
30+ if (!existsSync(abs)) throw new CliError(`runner agent not found at ${abs}.\n\n${AGENT_HELP}`);
31+ return abs;
32+}
33+
34+/** POST .../actions/runners/claim: a job, or null when nothing is waiting (204). */
35+export async function claim(api: Api, repo: RepoRef, name: string, labels: string[]): Promise<ClaimedJob | null> {
36+ const res = await api.raw<ClaimedJob | null>("POST", `${repoPath(repo)}/actions/runners/claim`, { body: { name, labels } });
37+ if (res.status === 204 || !res.data || typeof res.data !== "object") return null;
38+ return res.data;
39+}
40+
41+/** Environment for the agent: the job's credentials, minus the CLI's own token. */
42+export function agentEnv(base: NodeJS.ProcessEnv, job: ClaimedJob, workRoot: string, echo: boolean): NodeJS.ProcessEnv {
43+ const env: NodeJS.ProcessEnv = { ...base };
44+ for (const k of ["DIFFINERY_TOKEN", "DIFFINERY_URL", "DIFFINERY_RUNNER_AGENT", "GH_TOKEN", "GITHUB_TOKEN"]) delete env[k];
45+ env.DIFFINERY_API_URL = job.api_url;
46+ env.DIFFINERY_JOB_ID = job.job_id;
47+ env.DIFFINERY_JOB_TOKEN = job.token;
48+ env.DIFFINERY_WORK_ROOT ??= workRoot;
49+ if (echo) env.DIFFINERY_ECHO_LOGS = "1";
50+ return env;
51+}
52+
53+function pipeLines(stream: NodeJS.ReadableStream, prefix: string, write: (s: string) => void): Promise<void> {
54+ return new Promise((resolve) => {
55+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
56+ rl.on("line", (l) => write(`${prefix}${l}\n`));
57+ rl.on("close", () => resolve());
58+ });
59+}
60+
61+async function runJob(ctx: Ctx, agent: string, job: ClaimedJob, repo: RepoRef, workRoot: string, echo: boolean, onChild: (c: ChildProcess | null) => void): Promise<number> {
62+ const c = ctx.c;
63+ const prefix = c.dim(`[${job.name}] `);
64+ ctx.out(`${c.cyan("→")} ${repo.owner}/${repo.name}: running job ${c.bold(job.name)} ${c.dim(`(run ${job.run_id}, job ${job.job_id})`)}`);
65+ await mkdir(workRoot, { recursive: true });
66+ const child = spawn(process.execPath, [agent], { cwd: workRoot, env: agentEnv(ctx.io.env, job, workRoot, echo), stdio: ["ignore", "pipe", "pipe"] });
67+ onChild(child);
68+ const done = Promise.all([pipeLines(child.stdout!, prefix, (s) => ctx.io.stdout.write(s)), pipeLines(child.stderr!, prefix, (s) => ctx.io.stderr.write(s))]);
69+ const code = await new Promise<number>((resolve) => {
70+ child.on("error", (e) => {
71+ ctx.err(`${c.red("error:")} could not start the agent: ${e.message}`);
72+ resolve(1);
73+ });
74+ child.on("exit", (code, signal) => resolve(code ?? (signal ? 128 + (signal === "SIGKILL" ? 9 : 15) : 1)));
75+ });
76+ await done;
77+ onChild(null);
78+ ctx.out(`${code === 0 ? c.green("✓") : c.red("X")} job ${job.name} finished${code === 0 ? "" : ` with exit code ${code}`}`);
79+ return code;
80+}
81+
82+export const runnerCommand: Command = {
83+ name: "runner",
84+ summary: "Run self-hosted workflow jobs on this machine",
85+ description: `Polls the given repositories for jobs with 'runs-on: [self-hosted, ...]' whose labels this
86+runner offers, and runs each with the Diffinery runner agent. Requires admin access to the
87+repositories. Jobs run directly on this machine with your user's permissions.
88+
89+${AGENT_HELP}`,
90+ options: {
91+ repo: { type: "string", short: "R", multiple: true, desc: "Repository to take jobs from (default: the git origin)", arg: "OWNER/NAME" },
92+ labels: { type: "string", short: "l", desc: "Extra labels this runner offers, comma separated ('self-hosted' is implied)", arg: "a,b" },
93+ name: { type: "string", short: "n", desc: "Runner name (default: host name)", arg: "NAME" },
94+ once: { type: "boolean", desc: "Run at most one job, then exit with its exit code" },
95+ agent: { type: "string", short: "a", desc: "Path to the runner agent (agent.mjs); default DIFFINERY_RUNNER_AGENT", arg: "PATH" },
96+ "work-dir": { type: "string", short: "w", desc: "Working directory for jobs (default ~/.cache/diffinery/runner/<name>)", arg: "DIR" },
97+ interval: { type: "string", desc: "Seconds between polls when idle (default 5)", arg: "SECONDS" },
98+ quiet: { type: "boolean", short: "q", desc: "Do not echo step output locally (logs still go to the server)" },
99+ },
100+ maxArgs: 0,
101+ async run(ctx, o) {
102+ const agent = resolveAgentPath(ctx.io, str(o, "agent"));
103+ const repos = list(o, "repo").length ? list(o, "repo").map(parseRepoArg) : [await ctx.repo()];
104+ const labels = (str(o, "labels") ?? "").split(",").map((s) => s.trim()).filter(Boolean);
105+ const name = str(o, "name") ?? hostname();
106+ const intervalMs = Math.max(0, Number(str(o, "interval") ?? 5)) * 1000;
107+ if (!Number.isFinite(intervalMs)) throw new CliError("--interval must be a number", 2);
108+ const cacheHome = ctx.io.env.XDG_CACHE_HOME && path.isAbsolute(ctx.io.env.XDG_CACHE_HOME) ? ctx.io.env.XDG_CACHE_HOME : path.join(ctx.io.env.HOME || homedir(), ".cache");
109+ const workRoot = path.resolve(ctx.io.cwd, str(o, "work-dir") ?? path.join(cacheHome, "diffinery", "runner", name.replace(/[^A-Za-z0-9._-]/g, "_")));
110+ const api = await ctx.authedApi();
111+ const c = ctx.c;
112+
113+ let stopping = false;
114+ let child: ChildProcess | null = null;
115+ const abort = new AbortController();
116+ let signals = 0;
117+ const onSignal = () => {
118+ signals++;
119+ stopping = true;
120+ abort.abort();
121+ if (child && child.exitCode === null) {
122+ if (signals === 1) {
123+ ctx.err(c.yellow("Stopping: asking the running job to cancel (press Ctrl-C again to kill it)…"));
124+ child.kill("SIGTERM");
125+ } else child.kill("SIGKILL");
126+ }
127+ };
128+ process.on("SIGINT", onSignal);
129+ process.on("SIGTERM", onSignal);
130+
131+ try {
132+ ctx.out(`Runner ${c.bold(name)} offering [${["self-hosted", ...labels].join(", ")}] for ${repos.map((r) => `${r.owner}/${r.name}`).join(", ")}`);
133+ if (!o.once) ctx.out(c.dim("Waiting for jobs. Press Ctrl-C to stop."));
134+ let lastError = "";
135+ while (!stopping) {
136+ let ran = false;
137+ for (const repo of repos) {
138+ if (stopping) break;
139+ let job: ClaimedJob | null;
140+ try {
141+ job = await claim(api, repo, name, labels);
142+ lastError = "";
143+ } catch (e) {
144+ if (o.once || (e instanceof ApiError && [401, 403, 404].includes(e.status))) throw e;
145+ const msg = (e as Error).message;
146+ if (msg !== lastError) ctx.err(`${c.yellow("warning:")} ${msg} (will retry)`);
147+ lastError = msg;
148+ continue;
149+ }
150+ if (!job) continue;
151+ ran = true;
152+ const code = await runJob(ctx, agent, job, repo, workRoot, !o.quiet, (ch) => (child = ch));
153+ if (o.once) return code;
154+ if (stopping) break;
155+ }
156+ if (o.once) {
157+ ctx.out("No jobs waiting.");
158+ return 0;
159+ }
160+ if (!ran && !stopping) await ctx.io.sleep(intervalMs, abort.signal);
161+ }
162+ ctx.out("Runner stopped.");
163+ return 0;
164+ } finally {
165+ process.off("SIGINT", onSignal);
166+ process.off("SIGTERM", onSignal);
167+ }
168+ },
169+};
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+103−0View file
@@ -1,0 +1,103 @@
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: [o.input === undefined ? "ignore" : "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+ if (child.stdin) {
26+ child.stdin.on("error", () => {}); // git may exit before reading its input
27+ child.stdin.end(o.input);
28+ }
29+ });
30+}
31+
32+/** Runs git with output going to the user's terminal (clone/push progress). */
33+export function gitInteractive(io: Io, args: string[], o: { cwd?: string; env?: NodeJS.ProcessEnv } = {}): Promise<number> {
34+ return new Promise((resolve, reject) => {
35+ const child = spawn("git", args, { cwd: o.cwd ?? io.cwd, env: o.env ?? io.env, stdio: ["inherit", "pipe", "pipe"] });
36+ child.stdout.on("data", (d: Buffer) => io.stdout.write(d.toString()));
37+ child.stderr.on("data", (d: Buffer) => io.stderr.write(d.toString()));
38+ child.on("error", (e) => reject(new CliError(`could not run git: ${e.message}`)));
39+ child.on("close", (code) => resolve(code ?? 1));
40+ });
41+}
42+
43+export async function git(io: Io, args: string[], o: { cwd?: string; env?: NodeJS.ProcessEnv } = {}): Promise<string> {
44+ const r = await gitCapture(io, args, o);
45+ if (r.code !== 0) throw new CliError(`git ${args[0]} failed: ${r.stderr.trim() || `exit ${r.code}`}`);
46+ return r.stdout.trim();
47+}
48+
49+/**
50+ * Environment that adds one-off git config for a single git invocation
51+ * (GIT_CONFIG_COUNT/KEY/VALUE, git 2.31+). Unlike `-c`, values do not show up
52+ * in the process list, and like `-c` they are never written to .git/config.
53+ */
54+export function withGitConfig(env: NodeJS.ProcessEnv, entries: Array<[string, string]>): NodeJS.ProcessEnv {
55+ const out = { ...env };
56+ let n = Number(out.GIT_CONFIG_COUNT ?? 0);
57+ if (!Number.isInteger(n) || n < 0) n = 0;
58+ for (const [k, v] of entries) {
59+ out[`GIT_CONFIG_KEY_${n}`] = k;
60+ out[`GIT_CONFIG_VALUE_${n}`] = v;
61+ n++;
62+ }
63+ out.GIT_CONFIG_COUNT = String(n);
64+ return out;
65+}
66+
67+/** `http.<url>.extraHeader` entry that authenticates git HTTP requests to `baseUrl` only. */
68+export function basicAuthHeader(baseUrl: string, user: string, password: string): [string, string] {
69+ return [`http.${baseUrl.replace(/\/$/, "")}/.extraHeader`, `Authorization: Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`];
70+}
71+
72+/** Parses owner/name out of a remote URL that points at `host` (https, ssh or scp-like). */
73+export function parseRemote(remote: string, host: string): { owner: string; name: string } | null {
74+ const r = remote.trim();
75+ let h: string;
76+ let p: string;
77+ const scp = /^(?:[^@/]+@)?([^:/]+):(?!\/\/)(.+)$/.exec(r);
78+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(r)) {
79+ try {
80+ const u = new URL(r);
81+ h = u.host;
82+ p = decodeURIComponent(u.pathname);
83+ if (u.protocol === "ssh:" && u.port) h = u.hostname; // ssh port differs from the web port
84+ } catch {
85+ return null;
86+ }
87+ } else if (scp) {
88+ h = scp[1]!;
89+ p = scp[2]!;
90+ } else return null;
91+ const want = host.toLowerCase();
92+ if (h.toLowerCase() !== want && h.toLowerCase() !== want.replace(/:\d+$/, "")) return null;
93+ const parts = p.replace(/^\/+/, "").replace(/\/+$/, "").split("/");
94+ if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
95+ const name = parts[1].replace(/\.git$/, "");
96+ return name ? { owner: parts[0], name } : null;
97+}
98+
99+export async function currentBranch(io: Io): Promise<string> {
100+ const r = await gitCapture(io, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
101+ 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");
102+ return r.stdout.trim();
103+}
addedapps/cli/src/github.ts+127−0View file
@@ -1,0 +1,127 @@
1+import { CliError } from "./io.js";
2+import { VERSION } from "./version.js";
3+
4+export class GitHubError extends CliError {
5+ constructor(
6+ readonly status: number,
7+ message: string,
8+ ) {
9+ super(message);
10+ }
11+}
12+
13+export interface GitHubOptions {
14+ baseUrl?: string;
15+ token?: string | null;
16+ sleep: (ms: number) => Promise<void>;
17+ log?: (msg: string) => void;
18+ /** Longest single wait for a rate-limit reset before giving up (default 1h). */
19+ maxWaitMs?: number;
20+ now?: () => number;
21+}
22+
23+/** Next-page URL from a GitHub `Link` header. */
24+export function nextLink(link: string | null): string | null {
25+ if (!link) return null;
26+ for (const part of link.split(",")) {
27+ const m = /<([^>]+)>\s*;\s*rel="?next"?/.exec(part);
28+ if (m) return m[1]!;
29+ }
30+ return null;
31+}
32+
33+/** Minimal GitHub REST client with pagination and rate-limit handling. */
34+export class GitHub {
35+ readonly baseUrl: string;
36+ constructor(private o: GitHubOptions) {
37+ this.baseUrl = (o.baseUrl ?? "https://api.github.com").replace(/\/+$/, "");
38+ }
39+
40+ private async fetchRetrying(url: string): Promise<Response> {
41+ const now = this.o.now ?? Date.now;
42+ const maxWait = this.o.maxWaitMs ?? 3600_000;
43+ for (let attempt = 0; ; attempt++) {
44+ const headers: Record<string, string> = { accept: "application/vnd.github+json", "x-github-api-version": "2022-11-28", "user-agent": `diffinery-cli/${VERSION}` };
45+ if (this.o.token) headers.authorization = `Bearer ${this.o.token}`;
46+ let res: Response;
47+ try {
48+ res = await fetch(url, { headers });
49+ } catch (e) {
50+ if (attempt < 3) {
51+ await this.o.sleep(1000 * 2 ** attempt);
52+ continue;
53+ }
54+ throw new CliError(`could not reach GitHub (${new URL(url).host}): ${(e as Error).message}`);
55+ }
56+ if ((res.status === 403 || res.status === 429) && attempt < 8) {
57+ const retryAfter = res.headers.get("retry-after");
58+ const remaining = res.headers.get("x-ratelimit-remaining");
59+ const reset = res.headers.get("x-ratelimit-reset");
60+ let wait: number | null = null;
61+ if (retryAfter && Number.isFinite(Number(retryAfter))) wait = Number(retryAfter) * 1000;
62+ else if (remaining === "0" && reset && Number.isFinite(Number(reset))) wait = Math.max(0, Number(reset) * 1000 - now()) + 1000;
63+ else if (res.status === 429) wait = 60_000;
64+ else {
65+ const text = await res.clone().text();
66+ if (/secondary rate limit|abuse/i.test(text)) wait = 60_000;
67+ }
68+ if (wait !== null) {
69+ if (wait > maxWait) throw new GitHubError(res.status, `GitHub rate limit: reset is ${Math.ceil(wait / 60000)} minutes away; try again later${this.o.token ? "" : " or set GH_TOKEN"}`);
70+ this.o.log?.(`GitHub rate limit reached; waiting ${Math.ceil(wait / 1000)}s…`);
71+ await res.body?.cancel();
72+ await this.o.sleep(wait);
73+ continue;
74+ }
75+ }
76+ if (res.status >= 500 && attempt < 3) {
77+ await res.body?.cancel();
78+ await this.o.sleep(1000 * 2 ** attempt);
79+ continue;
80+ }
81+ return res;
82+ }
83+ }
84+
85+ private url(path: string, query?: Record<string, string | number>): string {
86+ const u = new URL(/^https?:\/\//.test(path) ? path : `${this.baseUrl}${path}`);
87+ for (const [k, v] of Object.entries(query ?? {})) u.searchParams.set(k, String(v));
88+ return u.toString();
89+ }
90+
91+ private async check(res: Response, url: string): Promise<void> {
92+ if (res.ok) return;
93+ let msg = res.statusText;
94+ try {
95+ const b = (await res.json()) as { message?: string };
96+ if (b.message) msg = b.message;
97+ } catch {
98+ /* not JSON */
99+ }
100+ throw new GitHubError(res.status, `GitHub ${new URL(url).pathname}: ${res.status} ${msg}`);
101+ }
102+
103+ async get<T>(path: string, query?: Record<string, string | number>): Promise<T> {
104+ const url = this.url(path, query);
105+ const res = await this.fetchRetrying(url);
106+ await this.check(res, url);
107+ return (await res.json()) as T;
108+ }
109+
110+ /**
111+ * All pages of a list endpoint. `key` picks the array out of wrapped responses
112+ * such as `{ total_count, variables: [...] }`.
113+ */
114+ async paginate<T>(path: string, query: Record<string, string | number> = {}, key?: string): Promise<T[]> {
115+ const out: T[] = [];
116+ let url: string | null = this.url(path, { per_page: 100, ...query });
117+ while (url) {
118+ const res = await this.fetchRetrying(url);
119+ await this.check(res, url);
120+ const body = (await res.json()) as unknown;
121+ const items = key ? ((body as Record<string, unknown>)[key] as T[] | undefined) : (body as T[]);
122+ if (Array.isArray(items)) out.push(...items);
123+ url = nextLink(res.headers.get("link"));
124+ }
125+ return out;
126+ }
127+}
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/main.ts+30−0View file
@@ -1,0 +1,30 @@
1+import { dispatch, type Registry } from "./cli.js";
2+import { adminCommands, agentGroup, secretGroup, tokenGroup, variableGroup } from "./commands/admin.js";
3+import { authCommands, authGroup } from "./commands/auth.js";
4+import { issueGroup } from "./commands/issue.js";
5+import { migrateGroup } from "./commands/migrate.js";
6+import { prGroup } from "./commands/pr.js";
7+import { repoGroup } from "./commands/repo.js";
8+import { runGroup } from "./commands/run.js";
9+import { runnerCommand } from "./commands/runner.js";
10+import { workflowGroup } from "./commands/workflow.js";
11+import { CliError, processIo, type Io } from "./io.js";
12+
13+export const registry: Registry = {
14+ groups: [authGroup, repoGroup, prGroup, issueGroup, runGroup, workflowGroup, secretGroup, variableGroup, tokenGroup, agentGroup, migrateGroup],
15+ commands: [...authCommands, ...adminCommands, runnerCommand],
16+};
17+
18+/** Runs the CLI and returns its exit code (never calls process.exit). */
19+export async function main(argv: string[], io: Io = processIo()): Promise<number> {
20+ try {
21+ return await dispatch(registry, argv, io);
22+ } catch (e) {
23+ if (e instanceof CliError) {
24+ io.stderr.write(`error: ${e.message}\n`);
25+ return e.code;
26+ }
27+ io.stderr.write(`error: ${e instanceof Error ? (e.stack ?? e.message) : String(e)}\n`);
28+ return 1;
29+ }
30+}
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/test/config.test.ts+109−0View file
@@ -1,0 +1,109 @@
1+import { mkdirSync, statSync, writeFileSync, readFileSync } from "node:fs";
2+import path from "node:path";
3+import { afterEach, beforeEach, describe, expect, it } from "vitest";
4+import { configPath, normalizeUrl, resolveConfig, writeConfig } from "../src/config.js";
5+import type { Io } from "../src/io.js";
6+import { fakeServer, isolatedEnv, run, type FakeServer } from "./helpers.js";
7+
8+const ioWith = (env: NodeJS.ProcessEnv) => ({ env }) as unknown as Io;
9+
10+describe("config", () => {
11+ it("defaults to https://diffinery.com with no token", async () => {
12+ const cfg = await resolveConfig(ioWith(isolatedEnv()));
13+ expect(cfg).toMatchObject({ url: "https://diffinery.com", token: null, urlSource: "default", tokenSource: null });
14+ });
15+
16+ it("reads the file under XDG_CONFIG_HOME and lets env vars override it", async () => {
17+ const env = isolatedEnv();
18+ const file = path.join(env.XDG_CONFIG_HOME!, "diffinery", "config.json");
19+ expect(configPath(env)).toBe(file);
20+ mkdirSync(path.dirname(file), { recursive: true });
21+ writeFileSync(file, JSON.stringify({ url: "https://forge.example.com/", token: "dfy_file" }));
22+
23+ expect(await resolveConfig(ioWith(env))).toMatchObject({ url: "https://forge.example.com", token: "dfy_file", urlSource: "config", tokenSource: "config" });
24+ expect(await resolveConfig(ioWith({ ...env, DIFFINERY_TOKEN: "dfy_env" }))).toMatchObject({ url: "https://forge.example.com", token: "dfy_env", tokenSource: "env" });
25+ expect(await resolveConfig(ioWith({ ...env, DIFFINERY_URL: "other.example.com" }))).toMatchObject({ url: "https://other.example.com", token: "dfy_file", urlSource: "env" });
26+ });
27+
28+ it("falls back to ~/.config when XDG_CONFIG_HOME is unset", () => {
29+ const env = isolatedEnv();
30+ delete env.XDG_CONFIG_HOME;
31+ expect(configPath(env)).toBe(path.join(env.HOME!, ".config", "diffinery", "config.json"));
32+ });
33+
34+ it("writes the config with mode 0600", async () => {
35+ const env = isolatedEnv();
36+ const file = await writeConfig(env, { url: "https://a.example", token: "dfy_x" });
37+ expect(statSync(file).mode & 0o777).toBe(0o600);
38+ expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({ url: "https://a.example", token: "dfy_x" });
39+ });
40+
41+ it("normalizes URLs", () => {
42+ expect(normalizeUrl("forge.example.com/")).toBe("https://forge.example.com");
43+ expect(normalizeUrl("http://localhost:8787")).toBe("http://localhost:8787");
44+ expect(() => normalizeUrl("ftp://x")).toThrow();
45+ });
46+});
47+
48+describe("login / whoami / logout", () => {
49+ let srv: FakeServer;
50+ beforeEach(async () => {
51+ srv = await fakeServer();
52+ srv.on("GET", "/api/v3/user", (r) => (r.headers.authorization === "Bearer dfy_good" ? { body: { login: "alice", name: "Alice", type: "User" } } : { status: 401, body: { message: "bad credentials" } }));
53+ });
54+ afterEach(() => srv.close());
55+
56+ it("verifies the token, saves it 0600, and whoami uses it", async () => {
57+ const env = isolatedEnv();
58+ const r = await run(["login", "--url", srv.url, "--token", "dfy_good"], { env });
59+ expect(r.code).toBe(0);
60+ expect(r.stdout).toContain("Logged in to");
61+ expect(r.stdout).toContain("alice");
62+ expect(r.stdout).not.toContain("dfy_good");
63+ const file = configPath(env);
64+ expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({ url: srv.url, token: "dfy_good" });
65+ expect(statSync(file).mode & 0o777).toBe(0o600);
66+
67+ const who = await run(["whoami"], { env });
68+ expect(who.code).toBe(0);
69+ expect(who.stdout).toContain("alice");
70+ expect(srv.find("GET", "/api/v3/user").at(-1)!.headers.authorization).toBe("Bearer dfy_good");
71+
72+ const out = await run(["logout"], { env });
73+ expect(out.code).toBe(0);
74+ expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({ url: srv.url });
75+ expect((await run(["whoami"], { env })).code).toBe(1);
76+ });
77+
78+ it("reads the token from stdin when it is not a TTY", async () => {
79+ const env = isolatedEnv();
80+ const r = await run(["login", "--url", srv.url], { env, stdin: "dfy_good\n" });
81+ expect(r.code).toBe(0);
82+ expect(JSON.parse(readFileSync(configPath(env), "utf8")).token).toBe("dfy_good");
83+ });
84+
85+ it("refuses a token the server rejects and saves nothing", async () => {
86+ const env = isolatedEnv();
87+ const r = await run(["login", "--url", srv.url, "--token", "dfy_bad"], { env });
88+ expect(r.code).toBe(1);
89+ expect(r.stderr).toContain("bad credentials");
90+ expect(() => readFileSync(configPath(env))).toThrow();
91+ });
92+
93+ it("DIFFINERY_TOKEN/DIFFINERY_URL override the saved config", async () => {
94+ const env = isolatedEnv();
95+ await writeConfig(env, { url: "http://127.0.0.1:1", token: "dfy_saved" });
96+ const r = await run(["whoami", "--json"], { env: { ...env, DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "dfy_good" } });
97+ expect(r.code).toBe(0);
98+ expect(JSON.parse(r.stdout).login).toBe("alice");
99+ });
100+
101+ it("mcp-config prints a snippet without the token unless asked", async () => {
102+ const env = isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "dfy_good" });
103+ const r = await run(["mcp-config"], { env });
104+ const cfg = JSON.parse(r.stdout);
105+ expect(cfg.mcpServers.diffinery).toEqual({ type: "http", url: `${srv.url}/mcp`, headers: { Authorization: "Bearer ${DIFFINERY_TOKEN}" } });
106+ const shown = JSON.parse((await run(["mcp-config", "--show-token"], { env })).stdout);
107+ expect(shown.mcpServers.diffinery.headers.Authorization).toBe("Bearer dfy_good");
108+ });
109+});
addedapps/cli/test/credential.test.ts+54−0View file
@@ -1,0 +1,54 @@
1+import { execFileSync } from "node:child_process";
2+import { describe, expect, it } from "vitest";
3+import { parseCredentialInput } from "../src/commands/auth.js";
4+import { isolatedEnv, run } from "./helpers.js";
5+
6+describe("git credential helper", () => {
7+ const env = isolatedEnv({ DIFFINERY_URL: "https://forge.example.com", DIFFINERY_TOKEN: "dfy_secret" });
8+
9+ it("parses key=value lines up to the blank line", () => {
10+ expect(parseCredentialInput("protocol=https\nhost=a.b\npath=x/y.git\n\nignored=1\n")).toEqual({ protocol: "https", host: "a.b", path: "x/y.git" });
11+ });
12+
13+ it("answers get for the configured host", async () => {
14+ const r = await run(["auth", "git-credential", "get"], { env, stdin: "protocol=https\nhost=forge.example.com\n\n" });
15+ expect(r.code).toBe(0);
16+ expect(r.stdout).toBe("protocol=https\nhost=forge.example.com\nusername=x-token\npassword=dfy_secret\n");
17+ });
18+
19+ it("stays silent for other hosts, other protocols and other operations", async () => {
20+ for (const stdin of ["protocol=https\nhost=github.com\n\n", "protocol=http\nhost=forge.example.com\n\n"]) {
21+ const r = await run(["auth", "git-credential", "get"], { env, stdin });
22+ expect(r.code).toBe(0);
23+ expect(r.stdout).toBe("");
24+ }
25+ for (const op of ["store", "erase"]) {
26+ const r = await run(["auth", "git-credential", op], { env, stdin: "protocol=https\nhost=forge.example.com\nusername=x\npassword=y\n\n" });
27+ expect(r.code).toBe(0);
28+ expect(r.stdout).toBe("");
29+ }
30+ });
31+
32+ it("matches hosts with ports", async () => {
33+ const e = { ...env, DIFFINERY_URL: "http://127.0.0.1:8787" };
34+ const r = await run(["auth", "git-credential", "get"], { env: e, stdin: "protocol=http\nhost=127.0.0.1:8787\n" });
35+ expect(r.stdout).toContain("password=dfy_secret");
36+ });
37+
38+ it("prints nothing without a token", async () => {
39+ const e = { ...env };
40+ delete e.DIFFINERY_TOKEN;
41+ const r = await run(["auth", "git-credential", "get"], { env: e, stdin: "protocol=https\nhost=forge.example.com\n" });
42+ expect(r.stdout).toBe("");
43+ });
44+
45+ it("auth setup-git configures a URL-scoped helper in the global git config", async () => {
46+ const r = await run(["auth", "setup-git"], { env });
47+ expect(r.code).toBe(0);
48+ const helpers = execFileSync("git", ["config", "--global", "--get-all", "credential.https://forge.example.com.helper"], { env }).toString().split("\n");
49+ expect(helpers.slice(0, 2)).toEqual(["", "!diffinery auth git-credential"]);
50+ // Idempotent.
51+ await run(["auth", "setup-git"], { env });
52+ expect(execFileSync("git", ["config", "--global", "--get-all", "credential.https://forge.example.com.helper"], { env }).toString().split("\n").slice(0, -1)).toEqual(["", "!diffinery auth git-credential"]);
53+ });
54+});
addedapps/cli/test/helpers.ts+203−0View file
@@ -1,0 +1,203 @@
1+import { execFileSync, spawn } from "node:child_process";
2+import { mkdtempSync, writeFileSync } from "node:fs";
3+import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
4+import type { AddressInfo } from "node:net";
5+import { tmpdir } from "node:os";
6+import path from "node:path";
7+import { Readable } from "node:stream";
8+import type { Io } from "../src/io.js";
9+import { main } from "../src/main.js";
10+
11+export interface Recorded {
12+ method: string;
13+ path: string;
14+ query: Record<string, string>;
15+ headers: IncomingMessage["headers"];
16+ body: unknown;
17+}
18+
19+export interface Reply {
20+ status?: number;
21+ body?: unknown;
22+ headers?: Record<string, string>;
23+}
24+
25+type Handler = (req: Recorded) => Reply | Promise<Reply>;
26+
27+export interface FakeServer {
28+ url: string;
29+ host: string;
30+ requests: Recorded[];
31+ on(method: string, path: string | RegExp, h: Handler | Reply): void;
32+ find(method: string, path: string | RegExp): Recorded[];
33+ close(): Promise<void>;
34+}
35+
36+/**
37+ * A tiny programmable HTTP server: unmatched routes return 404 { message }.
38+ * With `gitRoot`, paths containing `.git/` are served by `git http-backend` from that directory.
39+ */
40+export async function fakeServer(o: { gitRoot?: string; gitAuth?: string } = {}): Promise<FakeServer> {
41+ const routes: Array<{ method: string; path: string | RegExp; h: Handler }> = [];
42+ const requests: Recorded[] = [];
43+ const matches = (p: string | RegExp, path: string) => (typeof p === "string" ? p === path : p.test(path));
44+ const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
45+ if (o.gitRoot && /\.git\//.test(req.url ?? "")) return gitBackend(o.gitRoot, o.gitAuth, req, res, requests);
46+ const chunks: Buffer[] = [];
47+ for await (const c of req) chunks.push(c as Buffer);
48+ const text = Buffer.concat(chunks).toString("utf8");
49+ let body: unknown = text || undefined;
50+ try {
51+ if (text) body = JSON.parse(text);
52+ } catch {
53+ /* keep text */
54+ }
55+ const u = new URL(req.url ?? "/", "http://x");
56+ const rec: Recorded = { method: req.method ?? "GET", path: u.pathname, query: Object.fromEntries(u.searchParams), headers: req.headers, body };
57+ requests.push(rec);
58+ const route = [...routes].reverse().find((r) => r.method === rec.method && matches(r.path, rec.path));
59+ const reply: Reply = route ? await route.h(rec) : { status: 404, body: { message: "not found" } };
60+ const status = reply.status ?? 200;
61+ const headers: Record<string, string> = { ...reply.headers };
62+ let out = "";
63+ if (reply.body !== undefined && status !== 204) {
64+ if (typeof reply.body === "string") {
65+ out = reply.body;
66+ headers["content-type"] ??= "text/plain";
67+ } else {
68+ out = JSON.stringify(reply.body);
69+ headers["content-type"] ??= "application/json";
70+ }
71+ }
72+ res.writeHead(status, headers);
73+ res.end(out);
74+ });
75+ await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
76+ const port = (server.address() as AddressInfo).port;
77+ return {
78+ url: `http://127.0.0.1:${port}`,
79+ host: `127.0.0.1:${port}`,
80+ requests,
81+ on(method, path, h) {
82+ routes.push({ method, path, h: typeof h === "function" ? h : () => h });
83+ },
84+ find(method, path) {
85+ return requests.filter((r) => r.method === method && matches(path, r.path));
86+ },
87+ close: () =>
88+ new Promise<void>((r) => {
89+ server.closeAllConnections();
90+ server.close(() => r());
91+ }),
92+ };
93+}
94+
95+export function tempDir(prefix = "diffinery-cli-"): string {
96+ return mkdtempSync(path.join(tmpdir(), prefix));
97+}
98+
99+export interface RunResult {
100+ code: number;
101+ stdout: string;
102+ stderr: string;
103+}
104+
105+export interface RunOptions {
106+ env?: NodeJS.ProcessEnv;
107+ cwd?: string;
108+ stdin?: string;
109+ stdinTTY?: boolean;
110+}
111+
112+/** An isolated environment: HOME/XDG in a temp dir, git with no global config. */
113+export function isolatedEnv(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
114+ const home = tempDir("diffinery-home-");
115+ const gitconfig = path.join(home, ".gitconfig");
116+ writeFileSync(gitconfig, "");
117+ return {
118+ PATH: process.env.PATH,
119+ HOME: home,
120+ XDG_CONFIG_HOME: path.join(home, ".config"),
121+ GIT_CONFIG_GLOBAL: gitconfig,
122+ GIT_CONFIG_NOSYSTEM: "1",
123+ GIT_AUTHOR_NAME: "Test",
124+ GIT_AUTHOR_EMAIL: "test@example.com",
125+ GIT_COMMITTER_NAME: "Test",
126+ GIT_COMMITTER_EMAIL: "test@example.com",
127+ ...extra,
128+ };
129+}
130+
131+export async function run(argv: string[], o: RunOptions = {}): Promise<RunResult> {
132+ let stdout = "";
133+ let stderr = "";
134+ const stdin = Readable.from(o.stdin !== undefined ? [Buffer.from(o.stdin)] : []) as Io["stdin"];
135+ stdin.isTTY = o.stdinTTY ?? false;
136+ const io: Io = {
137+ env: o.env ?? isolatedEnv(),
138+ cwd: o.cwd ?? tempDir(),
139+ stdin,
140+ stdout: { write: (s: string) => ((stdout += s), true) },
141+ stderr: { write: (s: string) => ((stderr += s), true) },
142+ sleep: () => new Promise((r) => setTimeout(r, 1)),
143+ };
144+ const code = await main(argv, io);
145+ return { code, stdout, stderr };
146+}
147+
148+/** Creates a git repository whose origin points at `remote`, on branch `branch`. */
149+export function gitRepo(env: NodeJS.ProcessEnv, remote: string, branch = "feature/x"): string {
150+ const dir = tempDir("diffinery-repo-");
151+ const g = (...args: string[]) => execFileSync("git", args, { cwd: dir, env, stdio: "pipe" });
152+ g("init", "-q", "-b", "main");
153+ g("remote", "add", "origin", remote);
154+ g("commit", "-q", "--allow-empty", "-m", "init");
155+ g("checkout", "-q", "-b", branch);
156+ return dir;
157+}
158+
159+/** Serves git smart HTTP via `git http-backend` (CGI), requiring `auth` as the Authorization header when set. */
160+async function gitBackend(root: string, auth: string | undefined, req: IncomingMessage, res: ServerResponse, requests: Recorded[]): Promise<void> {
161+ const u = new URL(req.url ?? "/", "http://x");
162+ requests.push({ method: req.method ?? "GET", path: u.pathname, query: Object.fromEntries(u.searchParams), headers: req.headers, body: undefined });
163+ if (auth && req.headers.authorization !== auth) {
164+ res.writeHead(401, { "www-authenticate": 'Basic realm="test"' });
165+ return void res.end();
166+ }
167+ const child = spawn("git", ["http-backend"], {
168+ env: {
169+ PATH: process.env.PATH,
170+ GIT_PROJECT_ROOT: root,
171+ GIT_HTTP_EXPORT_ALL: "1",
172+ GIT_CONFIG_NOSYSTEM: "1",
173+ GIT_CONFIG_GLOBAL: "/dev/null",
174+ REQUEST_METHOD: req.method ?? "GET",
175+ PATH_INFO: decodeURIComponent(u.pathname),
176+ QUERY_STRING: u.search.slice(1),
177+ CONTENT_TYPE: req.headers["content-type"] ?? "",
178+ HTTP_CONTENT_ENCODING: String(req.headers["content-encoding"] ?? ""),
179+ GIT_PROTOCOL: String(req.headers["git-protocol"] ?? ""),
180+ REMOTE_USER: "tester",
181+ REMOTE_ADDR: "127.0.0.1",
182+ },
183+ stdio: ["pipe", "pipe", "inherit"],
184+ });
185+ req.pipe(child.stdin);
186+ const chunks: Buffer[] = [];
187+ for await (const c of child.stdout) chunks.push(c as Buffer);
188+ const out = Buffer.concat(chunks);
189+ const sep = out.indexOf("\r\n\r\n");
190+ const head = out.subarray(0, sep).toString();
191+ const headers: Record<string, string> = {};
192+ let status = 200;
193+ for (const line of head.split("\r\n")) {
194+ const i = line.indexOf(":");
195+ if (i < 0) continue;
196+ const k = line.slice(0, i).trim();
197+ const v = line.slice(i + 1).trim();
198+ if (k.toLowerCase() === "status") status = Number(v.split(" ")[0]);
199+ else headers[k] = v;
200+ }
201+ res.writeHead(status, headers);
202+ res.end(out.subarray(sep + 4));
203+}
addedapps/cli/test/migrate.test.ts+134−0View file
@@ -1,0 +1,134 @@
1+import { execFileSync } from "node:child_process";
2+import { mkdirSync } from "node:fs";
3+import path from "node:path";
4+import { afterEach, beforeEach, describe, expect, it } from "vitest";
5+import { fakeServer, isolatedEnv, run, tempDir, type FakeServer } from "./helpers.js";
6+
7+describe("migrate github", () => {
8+ let srv: FakeServer;
9+ let env: NodeJS.ProcessEnv;
10+ let gitRoot: string;
11+ let srcBare: string;
12+
13+ beforeEach(async () => {
14+ env = isolatedEnv();
15+ const g = (cwd: string, ...args: string[]) => execFileSync("git", args, { cwd, env, stdio: "pipe" }).toString().trim();
16+ // Source repository standing in for GitHub: main, develop, a tag and a pull ref.
17+ const work = tempDir("diffinery-src-");
18+ g(work, "init", "-q", "-b", "main");
19+ g(work, "commit", "-q", "--allow-empty", "-m", "one");
20+ g(work, "tag", "v1");
21+ g(work, "checkout", "-q", "-b", "develop");
22+ g(work, "commit", "-q", "--allow-empty", "-m", "two");
23+ srcBare = path.join(tempDir("diffinery-gh-"), "widgets.git");
24+ g(work, "clone", "-q", "--bare", work, srcBare);
25+ g(srcBare, "update-ref", "refs/pull/2/head", g(work, "rev-parse", "HEAD"));
26+ // Target: an empty bare repository served over smart HTTP.
27+ gitRoot = tempDir("diffinery-git-");
28+ mkdirSync(path.join(gitRoot, "alice"));
29+ g(path.join(gitRoot, "alice"), "init", "-q", "--bare", "widgets.git");
30+
31+ srv = await fakeServer({ gitRoot, gitAuth: `Basic ${Buffer.from("x-token:dfy_t").toString("base64")}` });
32+ const now = "2024-05-01T10:00:00Z";
33+ const ghRepo = { full_name: "gh-org/widgets", name: "widgets", private: false, visibility: "public", description: "Widgets!", default_branch: "develop", clone_url: srcBare, html_url: "https://github.com/gh-org/widgets" };
34+ srv.on("GET", "/gh/repos/gh-org/widgets", { body: ghRepo });
35+ srv.on("GET", "/gh/repos/gh-org/widgets/actions/variables", { body: { total_count: 1, variables: [{ name: "REGION", value: "eu-west" }] } });
36+ srv.on("GET", "/gh/repos/gh-org/widgets/actions/secrets", { body: { total_count: 1, secrets: [{ name: "NPM_TOKEN" }] } });
37+ srv.on("GET", "/gh/repos/gh-org/widgets/labels", { body: [{ name: "bug", color: "d73a4a", description: "Broken" }] });
38+ srv.on("GET", "/gh/repos/gh-org/widgets/pulls", {
39+ body: [
40+ { number: 2, title: "Develop work", body: "PR body", draft: false, user: { login: "carol" }, created_at: now, head: { ref: "develop", repo: { full_name: "gh-org/widgets" } }, base: { ref: "main" } },
41+ { number: 4, title: "From a fork", body: null, user: { login: "mallory" }, created_at: now, head: { ref: "patch-1", repo: { full_name: "mallory/widgets" } }, base: { ref: "main" } },
42+ ],
43+ });
44+ srv.on("GET", "/gh/repos/gh-org/widgets/issues", {
45+ body: [
46+ { number: 5, title: "Open issue", body: "five", state: "open", user: { login: "dave" }, labels: [], comments: 0, created_at: now },
47+ { number: 1, title: "Old bug", body: "it breaks", state: "closed", state_reason: "not_planned", user: { login: "bob" }, labels: [{ name: "bug" }], comments: 1, created_at: now },
48+ { number: 2, title: "Develop work", body: "PR body", state: "open", user: { login: "carol" }, labels: [], comments: 0, created_at: now, pull_request: {} },
49+ { number: 3, title: "Merged PR", body: "", state: "closed", user: { login: "carol" }, labels: [], comments: 0, created_at: now, pull_request: {} },
50+ { number: 4, title: "From a fork", body: null, state: "open", user: { login: "mallory" }, labels: [], comments: 0, created_at: now, pull_request: {} },
51+ ],
52+ });
53+ srv.on("GET", "/gh/repos/gh-org/widgets/issues/1/comments", { body: [{ body: "me too", user: { login: "erin" }, created_at: "2024-05-02T00:00:00Z" }] });
54+
55+ let n = 0;
56+ srv.on("GET", "/api/v3/user", { body: { login: "alice" } });
57+ srv.on("POST", "/api/v3/user/repos", (r) => ({ status: 201, body: { full_name: "alice/widgets", visibility: (r.body as { visibility: string }).visibility } }));
58+ srv.on("PATCH", "/api/v3/repos/alice/widgets", { body: {} });
59+ srv.on("PUT", /\/actions\/variables\//, { status: 204 });
60+ srv.on("POST", "/api/v3/repos/alice/widgets/labels", (r) => ({ status: 201, body: r.body }));
61+ srv.on("POST", "/api/v3/repos/alice/widgets/issues", (r) => ({ status: 201, body: { number: ++n, title: (r.body as { title: string }).title } }));
62+ srv.on("POST", "/api/v3/repos/alice/widgets/pulls", (r) => ({ status: 201, body: { number: ++n, title: (r.body as { title: string }).title } }));
63+ srv.on("POST", /\/issues\/\d+\/comments$/, { status: 201, body: {} });
64+ srv.on("PATCH", /\/issues\/\d+$/, { body: {} });
65+ Object.assign(env, { DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "dfy_t", GH_TOKEN: "ght", GITHUB_API_URL: `${srv.url}/gh` });
66+ });
67+ afterEach(() => srv.close());
68+
69+ it("creates the repo, pushes branches and tags, and copies issues, PRs, variables and secret names", async () => {
70+ const r = await run(["migrate", "github", "gh-org/widgets", "--issues"], { env });
71+ expect(r.stderr).not.toContain("error");
72+ expect(r.code).toBe(0);
73+
74+ // Repository created with GitHub's visibility and description.
75+ expect(srv.find("POST", "/api/v3/user/repos")[0]!.body).toEqual({ name: "widgets", description: "Widgets!", visibility: "public" });
76+
77+ // Git data: heads and tags only.
78+ const refs = execFileSync("git", ["for-each-ref", "--format=%(refname)"], { cwd: path.join(gitRoot, "alice", "widgets.git"), env }).toString().trim().split("\n").sort();
79+ expect(refs).toEqual(["refs/heads/develop", "refs/heads/main", "refs/tags/v1"]);
80+ expect(srv.find("PATCH", "/api/v3/repos/alice/widgets")[0]!.body).toEqual({ default_branch: "develop" });
81+
82+ // Variables copied, secrets listed.
83+ expect(srv.find("PUT", /\/actions\/variables\//).map((q) => [q.path, q.body])).toEqual([["/api/v3/repos/alice/widgets/actions/variables/REGION", { value: "eu-west" }]]);
84+ expect(r.stdout).toContain("diffinery secret set NPM_TOKEN -R alice/widgets");
85+
86+ // Labels, then issues and PRs in GitHub number order.
87+ expect(srv.find("POST", "/api/v3/repos/alice/widgets/labels")[0]!.body).toEqual({ name: "bug", color: "d73a4a", description: "Broken" });
88+ const issues = srv.find("POST", "/api/v3/repos/alice/widgets/issues").map((q) => q.body as { title: string; body: string; labels?: string[] });
89+ expect(issues.map((i) => i.title)).toEqual(["Old bug", "Open issue"]);
90+ expect(issues[0]!.body).toBe("> Imported from GitHub issue [#1](https://github.com/gh-org/widgets/issues/1) by @bob on 2024-05-01\n\nit breaks");
91+ expect(issues[0]!.labels).toEqual(["bug"]);
92+ const comment = srv.find("POST", /\/issues\/1\/comments$/)[0]!.body as { body: string };
93+ expect(comment.body).toBe("> Imported from GitHub: comment by @erin on 2024-05-02\n\nme too");
94+ expect(srv.find("PATCH", /\/issues\/1$/)[0]!.body).toEqual({ state: "closed", state_reason: "not_planned" });
95+
96+ const pulls = srv.find("POST", "/api/v3/repos/alice/widgets/pulls").map((q) => q.body);
97+ expect(pulls).toEqual([{ title: "Develop work", body: "> Imported from GitHub pull request [#2](https://github.com/gh-org/widgets/pull/2) by @carol on 2024-05-01\n\nPR body", head: "develop", base: "main" }]);
98+ expect(r.stdout).toContain("#4: head branch is in a fork");
99+ expect(r.stdout).toContain("issues: 2 (1 comments)");
100+
101+ // The GitHub token went to GitHub only, the Diffinery token to Diffinery only.
102+ for (const q of srv.requests) {
103+ if (q.path.startsWith("/gh/")) expect(q.headers.authorization).toBe("Bearer ght");
104+ else if (q.path.startsWith("/api/")) expect(q.headers.authorization).toBe("Bearer dfy_t");
105+ }
106+ });
107+
108+ it("repo clone authenticates for that command only", async () => {
109+ await run(["migrate", "github", "gh-org/widgets"], { env });
110+ const cwd = tempDir("diffinery-clone-");
111+ const r = await run(["repo", "clone", "alice/widgets", "w"], { env, cwd });
112+ expect(r.code).toBe(0);
113+ const dir = path.join(cwd, "w");
114+ expect(execFileSync("git", ["branch", "-r"], { cwd: dir, env }).toString()).toContain("origin/develop");
115+ const config = execFileSync("git", ["config", "--list", "--local"], { cwd: dir, env }).toString();
116+ expect(config).toContain(`remote.origin.url=${srv.url}/alice/widgets.git`);
117+ expect(config).not.toMatch(/extraheader|dfy_t|Basic/i);
118+ // Without the header, the server refuses.
119+ const anon = await run(["repo", "clone", "alice/widgets", "w2"], { env: { ...env, DIFFINERY_TOKEN: "", GIT_TERMINAL_PROMPT: "0" }, cwd });
120+ expect(anon.code).not.toBe(0);
121+ });
122+
123+ it("--private and --to override the target", async () => {
124+ srv.on("GET", "/api/v3/repos/team/w2", { status: 404, body: { message: "repository not found" } });
125+ srv.on("POST", "/api/v3/orgs/team/repos", { status: 201, body: { full_name: "team/w2", visibility: "private" } });
126+ srv.on("PATCH", "/api/v3/repos/team/w2", { body: {} });
127+ mkdirSync(path.join(gitRoot, "team"));
128+ execFileSync("git", ["init", "-q", "--bare", "w2.git"], { cwd: path.join(gitRoot, "team"), env });
129+ const r = await run(["migrate", "github", "https://github.com/gh-org/widgets", "--to", "team/w2", "--private"], { env });
130+ expect(r.code).toBe(0);
131+ expect(srv.find("POST", "/api/v3/orgs/team/repos")[0]!.body).toMatchObject({ name: "w2", visibility: "private" });
132+ expect(srv.find("POST", /\/issues$/)).toHaveLength(0);
133+ });
134+});
addedapps/cli/test/misc.test.ts+138−0View file
@@ -1,0 +1,138 @@
1+import { afterEach, beforeEach, describe, expect, it } from "vitest";
2+import { GitHub, nextLink } from "../src/github.js";
3+import { withGitConfig } from "../src/git.js";
4+import { fakeServer, isolatedEnv, run, type FakeServer, type Reply } from "./helpers.js";
5+
6+describe("run watch", () => {
7+ let srv: FakeServer;
8+ beforeEach(async () => {
9+ srv = await fakeServer();
10+ });
11+ afterEach(() => srv.close());
12+
13+ const runBody = (status: string, conclusion: string | null, jobs: Array<[string, string, string | null]>) => ({
14+ id: "run1",
15+ run_number: 4,
16+ run_attempt: 1,
17+ name: "CI",
18+ display_title: "Fix bug",
19+ path: ".github/workflows/ci.yml",
20+ event: "push",
21+ head_branch: "main",
22+ head_sha: "abcdef1234",
23+ status,
24+ conclusion,
25+ actor: { login: "alice" },
26+ created_at: new Date().toISOString(),
27+ updated_at: new Date().toISOString(),
28+ html_url: "http://x/runs/run1",
29+ jobs: jobs.map(([id, s, c]) => ({ id, name: `job-${id}`, status: s, conclusion: c, startedAt: null, completedAt: null, steps: [], failure: c === "failure" ? { message: "exit 1" } : null })),
30+ });
31+
32+ it("prints job transitions and exits 1 when the run fails", async () => {
33+ const states = [
34+ runBody("queued", null, [["a", "queued", null]]),
35+ runBody("in_progress", null, [["a", "in_progress", null]]),
36+ runBody("in_progress", null, [["a", "in_progress", null]]),
37+ runBody("completed", "failure", [["a", "completed", "failure"]]),
38+ ];
39+ let i = 0;
40+ srv.on("GET", "/api/v3/repos/o/r/actions/runs/run1", () => ({ body: states[Math.min(i++, states.length - 1)] }));
41+ const r = await run(["run", "watch", "run1", "-R", "o/r", "--interval", "0"], { env: isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "t" }) });
42+ expect(r.code).toBe(1);
43+ const lines = r.stdout.split("\n").filter((l) => l.includes("job-a"));
44+ expect(lines).toHaveLength(3); // queued, in progress, failure (no duplicate for the repeated state)
45+ expect(r.stdout).toContain("completed: failure");
46+ });
47+
48+ it("exits 0 on success", async () => {
49+ srv.on("GET", "/api/v3/repos/o/r/actions/runs/run1", { body: runBody("completed", "success", [["a", "completed", "success"]]) });
50+ const r = await run(["run", "watch", "run1", "-R", "o/r"], { env: isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "t" }) });
51+ expect(r.code).toBe(0);
52+ });
53+});
54+
55+describe("GitHub client", () => {
56+ let srv: FakeServer;
57+ beforeEach(async () => {
58+ srv = await fakeServer();
59+ });
60+ afterEach(() => srv.close());
61+
62+ it("follows Link pagination and waits out rate limits", async () => {
63+ let hits = 0;
64+ srv.on("GET", "/repos/o/r/issues", (req): Reply => {
65+ hits++;
66+ if (hits === 1) return { status: 403, headers: { "retry-after": "2" }, body: { message: "secondary rate limit" } };
67+ if (req.query.page === "2") return { body: [{ number: 3 }] };
68+ return { body: [{ number: 1 }, { number: 2 }], headers: { link: `<${srv.url}/repos/o/r/issues?page=2&per_page=100>; rel="next", <${srv.url}/repos/o/r/issues?page=2>; rel="last"` } };
69+ });
70+ srv.on("GET", "/repos/o/r/actions/variables", (req) =>
71+ req.query.page === "2" ? { body: { total_count: 2, variables: [{ name: "B", value: "2" }] } } : { body: { total_count: 2, variables: [{ name: "A", value: "1" }] }, headers: { link: `<${srv.url}/repos/o/r/actions/variables?page=2>; rel="next"` } },
72+ );
73+ srv.on("GET", "/rate", () => (hits++ < 10 ? { status: 403, headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": "1000" }, body: { message: "API rate limit exceeded" } } : { body: { ok: true } }));
74+ const waits: number[] = [];
75+ const gh = new GitHub({ baseUrl: srv.url, token: "ght", sleep: async (ms) => void waits.push(ms), now: () => 990_000 });
76+ expect((await gh.paginate<{ number: number }>("/repos/o/r/issues", { state: "all" })).map((i) => i.number)).toEqual([1, 2, 3]);
77+ expect(waits).toEqual([2000]);
78+ expect(srv.requests[0]!.headers.authorization).toBe("Bearer ght");
79+ expect(srv.requests[0]!.query).toMatchObject({ state: "all", per_page: "100" });
80+ expect(await gh.paginate("/repos/o/r/actions/variables", {}, "variables")).toEqual([
81+ { name: "A", value: "1" },
82+ { name: "B", value: "2" },
83+ ]);
84+ hits = 5;
85+ waits.length = 0;
86+ expect(await gh.get("/rate")).toEqual({ ok: true });
87+ expect(waits.length).toBeGreaterThan(0);
88+ expect(waits[0]).toBe(11_000); // reset (1000s) - now (990s) + 1s
89+ });
90+
91+ it("parses Link headers", () => {
92+ expect(nextLink('<https://a/x?page=2>; rel="next", <https://a/x?page=9>; rel="last"')).toBe("https://a/x?page=2");
93+ expect(nextLink('<https://a/x?page=1>; rel="prev"')).toBeNull();
94+ expect(nextLink(null)).toBeNull();
95+ });
96+});
97+
98+describe("misc", () => {
99+ it("withGitConfig appends to an existing GIT_CONFIG_COUNT", () => {
100+ const env = withGitConfig({ GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: "a.b", GIT_CONFIG_VALUE_0: "c" }, [["http.extraHeader", "X: y"]]);
101+ expect(env).toMatchObject({ GIT_CONFIG_COUNT: "2", GIT_CONFIG_KEY_1: "http.extraHeader", GIT_CONFIG_VALUE_1: "X: y" });
102+ });
103+
104+ it("--help works for every command and --version prints the version", async () => {
105+ const { registry } = await import("../src/main.js");
106+ const env = isolatedEnv();
107+ for (const g of registry.groups) {
108+ expect((await run([g.name, "--help"], { env })).code).toBe(0);
109+ for (const c of g.commands) {
110+ const r = await run([...c.name.split(" "), "--help"], { env });
111+ expect(r.code, c.name).toBe(0);
112+ expect(r.stdout).toContain(`Usage: diffinery ${c.name}`);
113+ }
114+ }
115+ for (const c of registry.commands) expect((await run([c.name, "--help"], { env })).code).toBe(0);
116+ expect((await run(["--version"], { env })).stdout).toMatch(/^diffinery \d+\.\d+\.\d+/);
117+ expect((await run(["nope"], { env })).code).toBe(2);
118+ });
119+
120+ it("api prints JSON and exits 1 on HTTP errors", async () => {
121+ const srv = await fakeServer();
122+ try {
123+ srv.on("GET", "/api/v3/user", { body: { login: "alice" } });
124+ srv.on("POST", "/api/v3/repos/o/r/labels", (req) => ({ status: 201, body: req.body }));
125+ const env = isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "t" });
126+ const r = await run(["api", "get", "/user"], { env });
127+ expect(r.code).toBe(0);
128+ expect(JSON.parse(r.stdout)).toEqual({ login: "alice" });
129+ const p = await run(["api", "POST", "repos/o/r/labels", "--input", "-"], { env, stdin: '{"name":"bug"}' });
130+ expect(JSON.parse(p.stdout)).toEqual({ name: "bug" });
131+ const missing = await run(["api", "GET", "/nope"], { env });
132+ expect(missing.code).toBe(1);
133+ expect(JSON.parse(missing.stdout)).toEqual({ message: "not found" });
134+ } finally {
135+ await srv.close();
136+ }
137+ });
138+});
addedapps/cli/test/pr.test.ts+119−0View file
@@ -1,0 +1,119 @@
1+import { afterEach, beforeEach, describe, expect, it } from "vitest";
2+import { parseRemote } from "../src/git.js";
3+import { fakeServer, gitRepo, isolatedEnv, run, type FakeServer } from "./helpers.js";
4+
5+const pull = (n: number, extra: Record<string, unknown> = {}) => ({
6+ number: n,
7+ title: "Add thing",
8+ body: "",
9+ state: "open",
10+ user: { login: "alice", type: "User" },
11+ labels: [],
12+ assignees: [],
13+ comments: 0,
14+ created_at: new Date().toISOString(),
15+ updated_at: new Date().toISOString(),
16+ html_url: `http://x/acme/widgets/pull/${n}`,
17+ head: { ref: "feature/x", sha: "abc" },
18+ base: { ref: "main" },
19+ ...extra,
20+});
21+
22+describe("remote parsing", () => {
23+ it("recognizes https, ssh and scp-style remotes for the configured host", () => {
24+ expect(parseRemote("https://diffinery.com/acme/widgets.git", "diffinery.com")).toEqual({ owner: "acme", name: "widgets" });
25+ expect(parseRemote("http://127.0.0.1:8787/acme/widgets", "127.0.0.1:8787")).toEqual({ owner: "acme", name: "widgets" });
26+ expect(parseRemote("git@diffinery.com:acme/widgets.git", "diffinery.com")).toEqual({ owner: "acme", name: "widgets" });
27+ expect(parseRemote("https://github.com/acme/widgets.git", "diffinery.com")).toBeNull();
28+ });
29+});
30+
31+describe("pull requests", () => {
32+ let srv: FakeServer;
33+ let env: NodeJS.ProcessEnv;
34+ beforeEach(async () => {
35+ srv = await fakeServer();
36+ env = isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "dfy_t" });
37+ });
38+ afterEach(() => srv.close());
39+
40+ it("pr create infers the repo from origin and the head from the current branch", async () => {
41+ srv.on("POST", "/api/v3/repos/acme/widgets/pulls", { status: 201, body: pull(7) });
42+ const cwd = gitRepo(env, `${srv.url}/acme/widgets.git`, "feature/x");
43+ const r = await run(["pr", "create", "--title", "Add thing", "-b", "Body text", "--base", "main", "--draft", "--intent", "Make widgets faster"], { env, cwd });
44+ expect(r.stderr).toBe("");
45+ expect(r.code).toBe(0);
46+ expect(r.stdout).toContain("#7");
47+ const req = srv.find("POST", "/api/v3/repos/acme/widgets/pulls")[0]!;
48+ expect(req.headers.authorization).toBe("Bearer dfy_t");
49+ expect(req.body).toEqual({ title: "Add thing", body: "Body text", head: "feature/x", base: "main", draft: true, intent: "Make widgets faster" });
50+ });
51+
52+ it("pr create with -R and --head omits unset fields", async () => {
53+ srv.on("POST", "/api/v3/repos/o/r/pulls", { status: 201, body: pull(1) });
54+ const r = await run(["pr", "create", "-R", "o/r", "--title", "T", "--head", "topic"], { env });
55+ expect(r.code).toBe(0);
56+ expect(srv.find("POST", "/api/v3/repos/o/r/pulls")[0]!.body).toEqual({ title: "T", head: "topic" });
57+ });
58+
59+ it("pr create requires a title", async () => {
60+ const r = await run(["pr", "create", "-R", "o/r"], { env });
61+ expect(r.code).toBe(2);
62+ expect(r.stderr).toContain("--title is required");
63+ });
64+
65+ it("pr merge sends the method and delete_branch", async () => {
66+ srv.on("PUT", "/api/v3/repos/o/r/pulls/12/merge", { body: { merged: true, sha: "0123456789abcdef", method: "squash" } });
67+ const r = await run(["pr", "merge", "12", "-R", "o/r", "--squash", "--delete-branch"], { env });
68+ expect(r.code).toBe(0);
69+ expect(r.stdout).toContain("Merged #12");
70+ expect(srv.find("PUT", "/api/v3/repos/o/r/pulls/12/merge")[0]!.body).toEqual({ merge_method: "squash", delete_branch: true });
71+ });
72+
73+ it("pr merge defaults to a merge commit and rejects conflicting flags", async () => {
74+ srv.on("PUT", "/api/v3/repos/o/r/pulls/3/merge", { body: { merged: true, sha: "abc", method: "merge" } });
75+ expect((await run(["pr", "merge", "3", "-R", "o/r"], { env })).code).toBe(0);
76+ expect(srv.find("PUT", "/api/v3/repos/o/r/pulls/3/merge")[0]!.body).toEqual({ merge_method: "merge" });
77+ const bad = await run(["pr", "merge", "3", "-R", "o/r", "--squash", "--rebase"], { env });
78+ expect(bad.code).toBe(2);
79+ });
80+
81+ it("surfaces the server's { message } on errors", async () => {
82+ srv.on("PUT", "/api/v3/repos/o/r/pulls/4/merge", { status: 405, body: { message: "pull request is not mergeable: 1 approval required" } });
83+ const r = await run(["pr", "merge", "4", "-R", "o/r"], { env });
84+ expect(r.code).toBe(1);
85+ expect(r.stderr).toContain("1 approval required");
86+ });
87+
88+ it("pr review maps flags to review events", async () => {
89+ srv.on("POST", "/api/v3/repos/o/r/pulls/5/reviews", { status: 201, body: { id: "rv1", state: "APPROVED" } });
90+ expect((await run(["pr", "review", "5", "-R", "o/r", "--approve"], { env })).code).toBe(0);
91+ expect((await run(["pr", "review", "5", "-R", "o/r", "--request-changes", "-b", "fix it"], { env })).code).toBe(0);
92+ expect((await run(["pr", "review", "5", "-R", "o/r", "--comment"], { env })).code).toBe(2);
93+ expect(srv.find("POST", "/api/v3/repos/o/r/pulls/5/reviews").map((r) => r.body)).toEqual([{ event: "APPROVE" }, { event: "REQUEST_CHANGES", body: "fix it" }]);
94+ });
95+
96+ it("pr list and --json", async () => {
97+ srv.on("GET", "/api/v3/repos/o/r/pulls", { body: [pull(1), pull(2, { draft: true, title: "WIP" })] });
98+ const r = await run(["pr", "list", "-R", "o/r"], { env });
99+ expect(r.code).toBe(0);
100+ expect(r.stdout).toMatch(/#1\s+Add thing/);
101+ expect(r.stdout).toMatch(/#2\s+WIP.*draft/);
102+ const j = await run(["pr", "list", "-R", "o/r", "--json"], { env });
103+ expect(JSON.parse(j.stdout)).toHaveLength(2);
104+ expect(srv.find("GET", "/api/v3/repos/o/r/pulls")[0]!.query.state).toBe("open");
105+ });
106+
107+ it("pr checks exits 1 on failure", async () => {
108+ srv.on("GET", "/api/v3/repos/o/r/pulls/9", { body: { ...pull(9), checks: [{ name: "ci / test", state: "failure" }, { name: "lint", state: "success" }] } });
109+ const r = await run(["pr", "checks", "9", "-R", "o/r"], { env });
110+ expect(r.code).toBe(1);
111+ expect(r.stdout).toContain("ci / test");
112+ });
113+
114+ it("pr comment posts to the issue comments endpoint", async () => {
115+ srv.on("POST", "/api/v3/repos/o/r/issues/9/comments", { status: 201, body: { id: "c1", body: "hi", user: { login: "alice" }, created_at: "" } });
116+ expect((await run(["pr", "comment", "9", "-R", "o/r", "-b", "hi"], { env })).code).toBe(0);
117+ expect(srv.find("POST", "/api/v3/repos/o/r/issues/9/comments")[0]!.body).toEqual({ body: "hi" });
118+ });
119+});
addedapps/cli/test/runner.test.ts+94−0View file
@@ -1,0 +1,94 @@
1+import { existsSync, readFileSync, writeFileSync } from "node:fs";
2+import path from "node:path";
3+import { afterEach, beforeEach, describe, expect, it } from "vitest";
4+import { fakeServer, isolatedEnv, run, tempDir, type FakeServer } from "./helpers.js";
5+
6+/** A stand-in for containers/runner/dist/agent.mjs: records argv, env and cwd, then exits. */
7+function fakeAgent(exitCode = 0): { agent: string; record: string } {
8+ const dir = tempDir("diffinery-agent-");
9+ const record = path.join(dir, "record.json");
10+ const agent = path.join(dir, "agent.mjs");
11+ writeFileSync(
12+ agent,
13+ `import { writeFileSync } from "node:fs";
14+writeFileSync(${JSON.stringify(record)}, JSON.stringify({ argv: process.argv.slice(2), cwd: process.cwd(), env: process.env }));
15+console.log("hello from the agent");
16+console.error("agent stderr line");
17+process.exit(${exitCode});
18+`,
19+ );
20+ return { agent, record };
21+}
22+
23+describe("runner", () => {
24+ let srv: FakeServer;
25+ let env: NodeJS.ProcessEnv;
26+ beforeEach(async () => {
27+ srv = await fakeServer();
28+ env = isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "dfy_user_token" });
29+ });
30+ afterEach(() => srv.close());
31+
32+ it("--once claims a job, runs the agent with the job's env, and returns its exit code", async () => {
33+ let claims = 0;
34+ srv.on("POST", "/api/v3/repos/acme/app/actions/runners/claim", () =>
35+ ++claims === 1 ? { body: { job_id: "job42", run_id: "run7", name: "build (linux)", token: "djt_repo_job42_secret", api_url: srv.url } } : { status: 204 },
36+ );
37+ const { agent, record } = fakeAgent(3);
38+ const work = tempDir("diffinery-work-");
39+ const r = await run(["runner", "-R", "acme/app", "--once", "--agent", agent, "--labels", "gpu, linux", "--name", "box-1", "--work-dir", work], { env });
40+
41+ expect(r.code).toBe(3);
42+ const claimReq = srv.find("POST", "/api/v3/repos/acme/app/actions/runners/claim");
43+ expect(claimReq).toHaveLength(1);
44+ expect(claimReq[0]!.body).toEqual({ name: "box-1", labels: ["gpu", "linux"] });
45+ expect(claimReq[0]!.headers.authorization).toBe("Bearer dfy_user_token");
46+
47+ const rec = JSON.parse(readFileSync(record, "utf8")) as { argv: string[]; cwd: string; env: Record<string, string> };
48+ expect(rec.argv).toEqual([]);
49+ expect(rec.env.DIFFINERY_API_URL).toBe(srv.url);
50+ expect(rec.env.DIFFINERY_JOB_ID).toBe("job42");
51+ expect(rec.env.DIFFINERY_JOB_TOKEN).toBe("djt_repo_job42_secret");
52+ expect(rec.env.DIFFINERY_WORK_ROOT).toBe(work);
53+ expect(rec.env.DIFFINERY_TOKEN).toBeUndefined();
54+ expect(rec.env.DIFFINERY_URL).toBeUndefined();
55+
56+ expect(r.stdout).toContain("[build (linux)] hello from the agent");
57+ expect(r.stderr).toContain("[build (linux)] agent stderr line");
58+ expect(r.stdout).not.toContain("djt_repo_job42_secret");
59+ });
60+
61+ it("--once with no waiting job exits 0 without running the agent", async () => {
62+ srv.on("POST", /\/runners\/claim$/, { status: 204 });
63+ const { agent, record } = fakeAgent(0);
64+ const r = await run(["runner", "-R", "acme/app", "-R", "acme/other", "--once", "--agent", agent], { env });
65+ expect(r.code).toBe(0);
66+ expect(r.stdout).toContain("No jobs waiting");
67+ expect(existsSync(record)).toBe(false);
68+ expect(srv.find("POST", /runners\/claim$/).map((q) => q.path)).toEqual(["/api/v3/repos/acme/app/actions/runners/claim", "/api/v3/repos/acme/other/actions/runners/claim"]);
69+ });
70+
71+ it("uses DIFFINERY_RUNNER_AGENT and tries the next repository", async () => {
72+ srv.on("POST", "/api/v3/repos/acme/app/actions/runners/claim", { status: 204 });
73+ srv.on("POST", "/api/v3/repos/acme/other/actions/runners/claim", { body: { job_id: "j1", run_id: "r1", name: "test", token: "djt_x", api_url: srv.url } });
74+ const { agent, record } = fakeAgent(0);
75+ const r = await run(["runner", "-R", "acme/app", "-R", "acme/other", "--once", "--work-dir", tempDir()], { env: { ...env, DIFFINERY_RUNNER_AGENT: agent } });
76+ expect(r.code).toBe(0);
77+ expect(JSON.parse(readFileSync(record, "utf8")).env.DIFFINERY_JOB_ID).toBe("j1");
78+ });
79+
80+ it("explains how to build the agent when none is configured", async () => {
81+ const r = await run(["runner", "-R", "acme/app", "--once"], { env });
82+ expect(r.code).toBe(1);
83+ expect(r.stderr).toContain("pnpm --filter @diffinery/runner build");
84+ expect(r.stderr).toContain("containers/runner/dist/agent.mjs");
85+ });
86+
87+ it("fails fast on permission errors", async () => {
88+ srv.on("POST", "/api/v3/repos/acme/app/actions/runners/claim", { status: 403, body: { message: "admin access required" } });
89+ const { agent } = fakeAgent(0);
90+ const r = await run(["runner", "-R", "acme/app", "--agent", agent, "--interval", "0"], { env });
91+ expect(r.code).toBe(1);
92+ expect(r.stderr).toContain("admin access required");
93+ });
94+});
addedapps/cli/test/workflow.test.ts+98−0View file
@@ -1,0 +1,98 @@
1+import { mkdirSync, writeFileSync } from "node:fs";
2+import path from "node:path";
3+import { afterEach, beforeEach, describe, expect, it } from "vitest";
4+import { parseInputs } from "../src/commands/workflow.js";
5+import { fakeServer, isolatedEnv, run, tempDir, type FakeServer } from "./helpers.js";
6+
7+const GOOD = `name: CI
8+on: [push]
9+jobs:
10+ test:
11+ runs-on: ubuntu-latest
12+ steps:
13+ - run: echo hi
14+`;
15+
16+const BAD = `name: Broken
17+on: push
18+jobs:
19+ build:
20+ runs-on: ubuntu-latest
21+ steps:
22+ - run: echo hi
23+ bogus: true
24+ deploy:
25+ needs: missing-job
26+ runs-on: ubuntu-latest
27+ steps:
28+ - run: echo deploy
29+`;
30+
31+describe("workflow validate", () => {
32+ function project(files: Record<string, string>): string {
33+ const dir = tempDir("diffinery-wf-");
34+ for (const [p, content] of Object.entries(files)) {
35+ mkdirSync(path.dirname(path.join(dir, p)), { recursive: true });
36+ writeFileSync(path.join(dir, p), content);
37+ }
38+ return dir;
39+ }
40+
41+ it("passes valid files in both workflow directories", async () => {
42+ const cwd = project({ ".github/workflows/ci.yml": GOOD, ".diffinery/workflows/other.yaml": GOOD, ".github/workflows/README.md": "not yaml" });
43+ const r = await run(["workflow", "validate"], { cwd, env: isolatedEnv() });
44+ expect(r.code).toBe(0);
45+ expect(r.stdout).toContain(".github/workflows/ci.yml");
46+ expect(r.stdout).toContain(".diffinery/workflows/other.yaml");
47+ expect(r.stderr).toContain("2 files checked: no errors");
48+ });
49+
50+ it("reports errors with file:line and exits 1", async () => {
51+ const cwd = project({ ".github/workflows/ci.yml": GOOD, ".github/workflows/bad.yml": BAD });
52+ const r = await run(["workflow", "validate"], { cwd, env: isolatedEnv() });
53+ expect(r.code).toBe(1);
54+ expect(r.stdout).toMatch(/\.github\/workflows\/bad\.yml:10(:\d+)?: error: .*missing-job/);
55+ expect(r.stdout).toMatch(/\.github\/workflows\/bad\.yml:8(:\d+)?: (error|warning): .*bogus/);
56+ expect(r.stderr).toMatch(/2 files checked: \d+ errors?/);
57+ });
58+
59+ it("validates explicit files and reports YAML syntax errors", async () => {
60+ const cwd = project({ "x.yml": "jobs: [unclosed\n" });
61+ const r = await run(["workflow", "validate", "x.yml", "--json"], { cwd, env: isolatedEnv() });
62+ expect(r.code).toBe(1);
63+ const res = JSON.parse(r.stdout);
64+ expect(res[0].file).toBe("x.yml");
65+ expect(res[0].valid).toBe(false);
66+ expect(res[0].problems[0].message).toMatch(/YAML/);
67+ });
68+
69+ it("is fine with no workflow files", async () => {
70+ const r = await run(["workflow", "validate"], { cwd: project({}), env: isolatedEnv() });
71+ expect(r.code).toBe(0);
72+ expect(r.stderr).toContain("No workflow files");
73+ });
74+});
75+
76+describe("workflow run", () => {
77+ let srv: FakeServer;
78+ beforeEach(async () => {
79+ srv = await fakeServer();
80+ });
81+ afterEach(() => srv.close());
82+
83+ it("dispatches with inputs on the default branch", async () => {
84+ srv.on("GET", "/api/v3/repos/o/r", { body: { default_branch: "trunk" } });
85+ srv.on("POST", /\/actions\/workflows\/.+\/dispatches$/, { status: 201, body: { run_id: "run1", html_url: "http://x" } });
86+ const env = isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "dfy" });
87+ const r = await run(["workflow", "run", "deploy.yml", "-R", "o/r", "-f", "env=prod", "-f", "dry=true"], { env });
88+ expect(r.code).toBe(0);
89+ const req = srv.find("POST", /dispatches$/)[0]!;
90+ expect(req.path).toBe("/api/v3/repos/o/r/actions/workflows/deploy.yml/dispatches");
91+ expect(req.body).toEqual({ ref: "trunk", inputs: { env: "prod", dry: "true" } });
92+ });
93+
94+ it("parses -f pairs", () => {
95+ expect(parseInputs(["a=1", "b=x=y", "c="])).toEqual({ a: "1", b: "x=y", c: "" });
96+ expect(() => parseInputs(["nope"])).toThrow();
97+ });
98+});
addedapps/cli/tsconfig.json+1−0View file
@@ -1,0 +1,1 @@
1+{ "extends": "../../tsconfig.base.json", "compilerOptions": { "noEmit": true, "types": ["node"] }, "include": ["src", "test"] }
addedapps/diffinery/migrations/0001_init.sql+119−0View file
@@ -1,0 +1,119 @@
1+-- Global directory: accounts (users, orgs, agents), repos, access, tokens, invites.
2+CREATE TABLE accounts (
3+ id TEXT PRIMARY KEY,
4+ login TEXT NOT NULL UNIQUE COLLATE NOCASE,
5+ kind TEXT NOT NULL CHECK (kind IN ('user', 'org', 'agent')),
6+ name TEXT,
7+ email TEXT UNIQUE COLLATE NOCASE,
8+ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'pending', 'disabled')),
9+ site_admin INTEGER NOT NULL DEFAULT 0,
10+ owner_id TEXT REFERENCES accounts(id),
11+ agent_policy TEXT,
12+ created_at TEXT NOT NULL
13+);
14+CREATE INDEX accounts_owner ON accounts(owner_id);
15+
16+CREATE TABLE org_members (
17+ org_id TEXT NOT NULL REFERENCES accounts(id),
18+ user_id TEXT NOT NULL REFERENCES accounts(id),
19+ role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
20+ created_at TEXT NOT NULL,
21+ PRIMARY KEY (org_id, user_id)
22+);
23+CREATE INDEX org_members_user ON org_members(user_id);
24+
25+CREATE TABLE repos (
26+ id TEXT PRIMARY KEY,
27+ owner_id TEXT NOT NULL REFERENCES accounts(id),
28+ name TEXT NOT NULL COLLATE NOCASE,
29+ description TEXT NOT NULL DEFAULT '',
30+ visibility TEXT NOT NULL DEFAULT 'private' CHECK (visibility IN ('private', 'internal', 'public')),
31+ default_branch TEXT NOT NULL DEFAULT 'main',
32+ archived INTEGER NOT NULL DEFAULT 0,
33+ created_at TEXT NOT NULL,
34+ updated_at TEXT NOT NULL,
35+ pushed_at TEXT,
36+ UNIQUE (owner_id, name)
37+);
38+
39+CREATE TABLE repo_access (
40+ repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
41+ account_id TEXT NOT NULL REFERENCES accounts(id),
42+ role TEXT NOT NULL CHECK (role IN ('read', 'triage', 'write', 'maintain', 'admin')),
43+ PRIMARY KEY (repo_id, account_id)
44+);
45+CREATE INDEX repo_access_account ON repo_access(account_id);
46+
47+CREATE TABLE tokens (
48+ id TEXT PRIMARY KEY,
49+ account_id TEXT NOT NULL REFERENCES accounts(id),
50+ name TEXT NOT NULL,
51+ token_hash TEXT NOT NULL UNIQUE,
52+ prefix TEXT NOT NULL,
53+ scopes TEXT NOT NULL,
54+ repo_ids TEXT,
55+ expires_at TEXT NOT NULL,
56+ last_used_at TEXT,
57+ created_at TEXT NOT NULL,
58+ revoked_at TEXT
59+);
60+CREATE INDEX tokens_account ON tokens(account_id);
61+
62+CREATE TABLE invites (
63+ id TEXT PRIMARY KEY,
64+ org_id TEXT REFERENCES accounts(id),
65+ repo_id TEXT REFERENCES repos(id),
66+ email TEXT COLLATE NOCASE,
67+ role TEXT NOT NULL,
68+ token_hash TEXT NOT NULL UNIQUE,
69+ invited_by TEXT NOT NULL REFERENCES accounts(id),
70+ created_at TEXT NOT NULL,
71+ expires_at TEXT NOT NULL,
72+ accepted_at TEXT,
73+ accepted_by TEXT
74+);
75+
76+CREATE TABLE notifications (
77+ id TEXT PRIMARY KEY,
78+ account_id TEXT NOT NULL REFERENCES accounts(id),
79+ repo_id TEXT,
80+ kind TEXT NOT NULL,
81+ title TEXT NOT NULL,
82+ url TEXT NOT NULL,
83+ created_at TEXT NOT NULL,
84+ read_at TEXT
85+);
86+CREATE INDEX notifications_account ON notifications(account_id, read_at);
87+
88+CREATE TABLE audit_log (
89+ id TEXT PRIMARY KEY,
90+ at TEXT NOT NULL,
91+ actor_id TEXT,
92+ actor_login TEXT,
93+ on_behalf_of TEXT,
94+ action TEXT NOT NULL,
95+ target TEXT,
96+ detail TEXT
97+);
98+CREATE INDEX audit_at ON audit_log(at);
99+
100+CREATE TABLE schedules (
101+ repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
102+ workflow_path TEXT NOT NULL,
103+ cron TEXT NOT NULL,
104+ PRIMARY KEY (repo_id, workflow_path, cron)
105+);
106+
107+CREATE TABLE org_secrets (
108+ org_id TEXT NOT NULL REFERENCES accounts(id),
109+ name TEXT NOT NULL,
110+ ciphertext TEXT NOT NULL,
111+ updated_at TEXT NOT NULL,
112+ PRIMARY KEY (org_id, name)
113+);
114+
115+CREATE TABLE access_requests (
116+ account_id TEXT PRIMARY KEY REFERENCES accounts(id),
117+ note TEXT,
118+ created_at TEXT NOT NULL
119+);
addedapps/diffinery/migrations/0002_passkeys.sql+46−0View file
@@ -1,0 +1,46 @@
1+-- Passkey (WebAuthn) sign-in, browser sessions and one-time sign-in links.
2+
3+CREATE TABLE passkeys (
4+ id TEXT PRIMARY KEY, -- credential id, base64url
5+ account_id TEXT NOT NULL REFERENCES accounts(id),
6+ public_key TEXT NOT NULL, -- COSE public key, base64url
7+ counter INTEGER NOT NULL DEFAULT 0,
8+ transports TEXT, -- JSON array
9+ device_type TEXT NOT NULL, -- singleDevice | multiDevice
10+ backed_up INTEGER NOT NULL DEFAULT 0,
11+ name TEXT NOT NULL,
12+ created_at TEXT NOT NULL,
13+ last_used_at TEXT
14+);
15+CREATE INDEX passkeys_account ON passkeys(account_id);
16+
17+CREATE TABLE sessions (
18+ id TEXT PRIMARY KEY, -- sha256 of the cookie value
19+ account_id TEXT NOT NULL REFERENCES accounts(id),
20+ method TEXT NOT NULL, -- passkey | access | link
21+ passkey_id TEXT,
22+ user_agent TEXT,
23+ created_at TEXT NOT NULL,
24+ last_seen_at TEXT NOT NULL,
25+ expires_at TEXT NOT NULL
26+);
27+CREATE INDEX sessions_account ON sessions(account_id);
28+
29+CREATE TABLE webauthn_challenges (
30+ id TEXT PRIMARY KEY, -- sha256 of the browser cookie value
31+ purpose TEXT NOT NULL CHECK (purpose IN ('login', 'register')),
32+ challenge TEXT NOT NULL,
33+ subject TEXT NOT NULL, -- JSON: who the passkey is for
34+ expires_at TEXT NOT NULL
35+);
36+
37+-- Admin-issued, single-use links that let someone add a passkey to an existing account.
38+CREATE TABLE signin_links (
39+ id TEXT PRIMARY KEY,
40+ token_hash TEXT NOT NULL UNIQUE,
41+ account_id TEXT NOT NULL REFERENCES accounts(id),
42+ created_by TEXT NOT NULL REFERENCES accounts(id),
43+ created_at TEXT NOT NULL,
44+ expires_at TEXT NOT NULL,
45+ used_at TEXT
46+);
addedapps/diffinery/package.json+31−0View file
@@ -1,0 +1,31 @@
1+{
2+ "name": "@diffinery/app",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "module",
6+ "scripts": {
7+ "dev": "wrangler dev -e dev",
8+ "deploy": "wrangler deploy -c wrangler.deploy.jsonc",
9+ "typecheck": "tsc --noEmit",
10+ "test": "vitest run --passWithNoTests --exclude 'test/e2e/**'",
11+ "cf-typegen": "wrangler types",
12+ "e2e": "node --test --test-concurrency=1 --test-timeout=600000 test/e2e/e2e.test.mjs test/e2e/passkeys.test.mjs",
13+ "db:migrate:local": "wrangler d1 migrations apply diffinery-dev --local -e dev"
14+ },
15+ "dependencies": {
16+ "@cloudflare/containers": "^0.3.7",
17+ "@diffinery/git-core": "workspace:*",
18+ "@diffinery/shared": "workspace:*",
19+ "@diffinery/workflow-yaml": "workspace:*",
20+ "@simplewebauthn/server": "14.0.3",
21+ "hono": "^4.13.0"
22+ },
23+ "devDependencies": {
24+ "@cloudflare/vitest-pool-workers": "^0.23.0",
25+ "@cloudflare/workers-types": "^5.20261012.1",
26+ "playwright": "1.56.1",
27+ "typescript": "^5.9.3",
28+ "vitest": "^4.1.0",
29+ "wrangler": "^4.149.0"
30+ }
31+}
addedapps/diffinery/src/app.ts+206−0View file
@@ -1,0 +1,206 @@
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 { credentialFrom, 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+ // Browser sessions ride on cookies, so refuse state-changing requests that come from another site.
21+ // Token-authenticated calls (git, CLI, MCP, runners) carry no ambient credentials and are exempt.
22+ if (!["GET", "HEAD", "OPTIONS"].includes(c.req.method) && !credentialFrom(c.req.raw)) {
23+ const site = c.req.header("sec-fetch-site");
24+ const origin = c.req.header("origin");
25+ const ours = new URL(c.env.PUBLIC_URL).origin;
26+ if ((site && site !== "same-origin" && site !== "none") || (!site && origin && origin !== ours)) {
27+ return c.json({ message: "cross-site request refused" }, 403);
28+ }
29+ }
30+ const dir = new Directory(c.env);
31+ c.set("dir", dir);
32+ c.set("principal", await resolvePrincipal(c.req.raw, c.env, dir));
33+ await next();
34+ if (c.res.status === 101) return;
35+ const h = c.res.headers;
36+ h.set("strict-transport-security", "max-age=63072000; includeSubDomains; preload");
37+ h.set("x-content-type-options", "nosniff");
38+ h.set("referrer-policy", "strict-origin-when-cross-origin");
39+ if (!h.has("x-frame-options")) h.set("x-frame-options", "DENY");
40+ h.set("permissions-policy", "camera=(), microphone=(), geolocation=()");
41+});
42+
43+app.get("/healthz", (c) => c.json({ ok: true }));
44+
45+app.route("/", wellKnown);
46+app.route("/", git);
47+app.route("/", runner);
48+app.route("/", api);
49+app.route("/", mcp);
50+
51+// Live updates: authorise in the Worker, then hand the WebSocket to the repository's Durable Object.
52+app.get("/:owner/:repo/live", async (c) => {
53+ if (c.req.header("upgrade") !== "websocket") return c.text("expected websocket", 426);
54+ const { stub } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"));
55+ return stub.fetch(c.req.raw);
56+});
57+
58+app.get("/:owner/:repo/actions/runs/:runId/artifacts/:name", async (c) => {
59+ const { stub } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"));
60+ const name = c.req.param("name");
61+ if (!(await stub.artifacts(c.req.param("runId"))).some((a) => a.name === name)) return c.json({ message: "artifact not found" }, 404);
62+ const obj = await c.env.BUCKET.get(await stub.artifactKeyForRun(c.req.param("runId"), name));
63+ if (!obj) return c.json({ message: "artifact expired" }, 410);
64+ return new Response(obj.body, {
65+ headers: {
66+ "content-type": "application/gzip",
67+ "content-length": String(obj.size),
68+ "content-disposition": `attachment; filename="${name.replace(/[^\w.-]+/g, "_")}.tar.gz"`,
69+ },
70+ });
71+});
72+
73+// ---------------------------------------------------------------- events queue
74+
75+const GITHUB_EVENT: Record<DiffineryEvent["type"], string> = {
76+ push: "push",
77+ pull_request: "pull_request",
78+ issues: "issues",
79+ issue_comment: "issue_comment",
80+ pull_request_review: "pull_request_review",
81+ workflow_run: "workflow_run",
82+ workflow_dispatch: "workflow_dispatch",
83+ audit: "audit",
84+};
85+
86+function webhookPayload(env: Env, ev: Exclude<DiffineryEvent, { type: "audit" }>) {
87+ 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` };
88+ 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 };
89+ const { type: _t, repoId: _r, owner: _o, repo: _n, actor: _a, ...rest } = ev;
90+ const action = "action" in ev ? (ev.action === "merged" ? "closed" : ev.action) : undefined;
91+ const extra: Record<string, unknown> = {};
92+ if (ev.type === "pull_request") {
93+ 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" };
94+ }
95+ 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}` };
96+ return { ...rest, action, repository, sender, ...extra };
97+}
98+
99+async function deliverWebhooks(env: Env, ev: Exclude<DiffineryEvent, { type: "audit" }>): Promise<void> {
100+ const stub = repoStub(env, ev.repoId);
101+ const hooks = (await stub.webhooks()).filter((h) => h.active && (h.events.includes("*") || h.events.includes(GITHUB_EVENT[ev.type])));
102+ if (!hooks.length) return;
103+ const body = JSON.stringify(webhookPayload(env, ev));
104+ await Promise.all(
105+ hooks.map(async (h) => {
106+ const delivery = newId();
107+ let status = 0;
108+ try {
109+ const res = await fetch(h.url, {
110+ method: "POST",
111+ headers: {
112+ "content-type": "application/json",
113+ "user-agent": "Diffinery-Hookshot/1",
114+ "x-github-event": GITHUB_EVENT[ev.type],
115+ "x-github-delivery": delivery,
116+ "x-diffinery-event": ev.type,
117+ "x-hub-signature-256": `sha256=${await hmacSha256Hex(h.secret, body)}`,
118+ },
119+ body,
120+ redirect: "manual",
121+ signal: AbortSignal.timeout(10_000),
122+ });
123+ status = res.status;
124+ } catch {
125+ status = 0;
126+ }
127+ await stub.recordDelivery(h.id, status);
128+ }),
129+ );
130+}
131+
132+/** People who should hear about an event: participants, assignees and requested reviewers, minus the actor. */
133+async function notifyParticipants(env: Env, dir: Directory, ev: DiffineryEvent): Promise<void> {
134+ if (ev.type === "workflow_run") {
135+ if (ev.action !== "completed" || ev.conclusion !== "failure" || ev.actor.kind === "system") return;
136+ 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}` });
137+ return;
138+ }
139+ if (ev.type !== "pull_request" && ev.type !== "issues" && ev.type !== "issue_comment" && ev.type !== "pull_request_review") return;
140+ if (ev.type === "pull_request" && ev.action === "synchronize") return;
141+ const { issue, comments, reviews } = await repoStub(env, ev.repoId).issue(ev.number);
142+ const ids = new Set<string>([issue.author.id, ...comments.map((c) => c.author.id), ...reviews.map((r) => r.author.id)]);
143+ for (const login of [...issue.assignees, ...(issue.pull?.requestedReviewers ?? [])]) {
144+ const a = await dir.accountByLogin(login);
145+ if (a) ids.add(a.id);
146+ }
147+ ids.delete(ev.actor.id);
148+ if (!ids.size) return;
149+ const kind = issue.kind === "pull" ? "pull" : "issues";
150+ const verb = ev.type === "issue_comment" ? "commented on" : ev.type === "pull_request_review" ? `reviewed (${ev.state.toLowerCase()})` : ev.action;
151+ await dir.notify([...ids].slice(0, 100), {
152+ repoId: ev.repoId,
153+ kind: ev.type,
154+ title: `${ev.actor.login} ${verb} ${ev.owner}/${ev.repo}#${ev.number}: ${issue.title}`,
155+ url: `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}/${kind}/${ev.number}`,
156+ });
157+}
158+
159+async function handleEvent(env: Env, dir: Directory, ev: DiffineryEvent): Promise<void> {
160+ if (ev.type === "audit") {
161+ 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 });
162+ return;
163+ }
164+ if (ev.type === "push") {
165+ 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 });
166+ }
167+ if (ev.type === "pull_request" && ev.action === "merged") {
168+ 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 });
169+ }
170+ await Promise.all([deliverWebhooks(env, ev), notifyParticipants(env, dir, ev)]);
171+}
172+
173+export async function queue(batch: MessageBatch<DiffineryEvent>, env: Env): Promise<void> {
174+ const dir = new Directory(env);
175+ // Keep an append-only copy of every event in R2 for long-term audit retention.
176+ const day = new Date().toISOString().slice(0, 10).replaceAll("-", "/");
177+ 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));
178+ for (const msg of batch.messages) {
179+ try {
180+ await handleEvent(env, dir, msg.body);
181+ msg.ack();
182+ } catch (e) {
183+ console.error("event handling failed", msg.body.type, e);
184+ msg.retry({ delaySeconds: Math.min(300, 10 * 2 ** msg.attempts) });
185+ }
186+ }
187+}
188+
189+// ---------------------------------------------------------------- cron: workflow schedules
190+
191+export async function scheduled(controller: ScheduledController, env: Env): Promise<void> {
192+ // The trigger runs every 5 minutes (GitHub's minimum schedule interval); fire anything due in the window.
193+ const end = new Date(controller.scheduledTime);
194+ end.setUTCSeconds(0, 0);
195+ const minutes = Array.from({ length: 5 }, (_, i) => new Date(end.getTime() - i * 60_000));
196+ 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 }>();
197+ await Promise.all(
198+ results.map(async (s) => {
199+ try {
200+ if (minutes.some((m) => cronMatches(s.cron, m))) await repoStub(env, s.repo_id).scheduleTick(s.workflow_path, s.cron);
201+ } catch (e) {
202+ console.error("schedule failed", s.repo_id, s.workflow_path, e);
203+ }
204+ }),
205+ );
206+}
addedapps/diffinery/src/auth/principal.ts+113−0View file
@@ -1,0 +1,113 @@
1+import type { Context } from "hono";
2+import type { Env } from "../env.js";
3+import { Directory, type Account, type Repo, type TokenScope } from "../lib/directory.js";
4+import { Auth, SESSION_COOKIE, cookieFrom } from "./sessions.js";
5+import { HttpError, fromB64 } from "../lib/util.js";
6+import type { Caller, Role } from "../repo/types.js";
7+import { ROLE_LEVEL } from "../repo/types.js";
8+
9+export type Principal =
10+ | { type: "account"; account: Account; via: "session" | "token" | "dev"; scopes: TokenScope[]; repoIds: string[] | null; sessionId?: string }
11+ | { type: "repo-token"; repoId: string; token: string }
12+ | { type: "anonymous" };
13+
14+export interface AppVars {
15+ principal: Principal;
16+ dir: Directory;
17+}
18+
19+export type AppContext = Context<{ Bindings: Env; Variables: AppVars }>;
20+
21+/** Extract a bearer/basic credential, as git, curl, the CLI and MCP clients send it. */
22+export function credentialFrom(req: Request): string | null {
23+ const h = req.headers.get("authorization");
24+ if (!h) return null;
25+ const [scheme, value] = h.split(" ", 2);
26+ if (!value) return null;
27+ if (/^bearer$/i.test(scheme!) || /^token$/i.test(scheme!)) return value.trim();
28+ if (/^basic$/i.test(scheme!)) {
29+ const decoded = new TextDecoder().decode(fromB64(value.trim()));
30+ const idx = decoded.indexOf(":");
31+ const pass = idx >= 0 ? decoded.slice(idx + 1) : decoded;
32+ const user = idx >= 0 ? decoded.slice(0, idx) : "";
33+ return pass || user || null;
34+ }
35+ return null;
36+}
37+
38+/** The Cloudflare Access assertion, present only on the Access-protected recovery path. */
39+export function accessToken(req: Request): string | null {
40+ const h = req.headers.get("cf-access-jwt-assertion");
41+ if (h) return h;
42+ const cookie = req.headers.get("cookie") ?? "";
43+ const m = /(?:^|;\s*)CF_Authorization=([^;]+)/.exec(cookie);
44+ return m ? m[1]! : null;
45+}
46+
47+export async function resolvePrincipal(req: Request, env: Env, dir: Directory): Promise<Principal> {
48+ const cred = credentialFrom(req);
49+ if (cred) {
50+ if (cred.startsWith("dfy_")) {
51+ const v = await dir.verifyToken(cred);
52+ if (!v) throw new HttpError(401, "invalid, expired or revoked token");
53+ return { type: "account", account: v.account, via: "token", scopes: v.scopes, repoIds: v.repoIds };
54+ }
55+ if (cred.startsWith("djt_") || cred.startsWith("dst_")) {
56+ const repoId = cred.split("_")[1];
57+ if (!repoId) throw new HttpError(401, "malformed token");
58+ return { type: "repo-token", repoId, token: cred };
59+ }
60+ throw new HttpError(401, "unrecognised token; create one under Settings → Tokens");
61+ }
62+ // Browsers sign in with a passkey (or the Access recovery path) and carry a session cookie.
63+ const session = cookieFrom(req, SESSION_COOKIE);
64+ if (session) {
65+ const s = await new Auth(env).session(session);
66+ if (s) return { type: "account", account: s.account, via: "session", scopes: ["admin"], repoIds: null, sessionId: s.session.id };
67+ }
68+ if (env.DEV_AUTH && !env.ACCESS_AUD) {
69+ const account = await dir.userForEmail(env.DEV_AUTH);
70+ return { type: "account", account, via: "dev", scopes: ["admin"], repoIds: null };
71+ }
72+ return { type: "anonymous" };
73+}
74+
75+export function requireAccount(c: AppContext): Account {
76+ const p = c.get("principal");
77+ if (p.type !== "account") throw new HttpError(401, "sign in required");
78+ if (p.account.status === "pending") throw new HttpError(403, "your account is waiting for an invite");
79+ if (p.account.status === "disabled") throw new HttpError(403, "your account is disabled");
80+ return p.account;
81+}
82+
83+const SCOPE_CAP: Record<TokenScope, Role> = { read: "read", write: "maintain", admin: "admin" };
84+
85+/**
86+ * Resolve the caller's effective role on a repository, combining account
87+ * permissions with token scopes and repo restrictions. Returns null for no access.
88+ */
89+export async function callerFor(c: AppContext, repo: Repo): Promise<Caller | null> {
90+ const p = c.get("principal");
91+ const dir = c.get("dir");
92+ if (p.type === "repo-token") {
93+ if (p.repoId !== repo.id) return null;
94+ const stub = c.env.REPO.get(c.env.REPO.idFromName(repo.id));
95+ const auth = await stub.runnerAuth(p.token);
96+ if (!auth) return null;
97+ if (auth.kind === "job") return { id: `job:${auth.jobId}`, login: "diffinery-actions[bot]", kind: "system", role: "read" };
98+ const cap: Role = ROLE_LEVEL[auth.caller.role] > ROLE_LEVEL.write ? "write" : auth.caller.role;
99+ return { ...auth.caller, role: cap };
100+ }
101+ if (p.type !== "account") {
102+ return repo.visibility === "public" ? { id: "anonymous", login: "anonymous", kind: "user", role: "read" } : null;
103+ }
104+ if (p.repoIds && !p.repoIds.includes(repo.id)) return null;
105+ let role = await dir.roleFor(p.account, repo);
106+ if (!role) return null;
107+ const cap = p.scopes.reduce<Role>((best, s) => (ROLE_LEVEL[SCOPE_CAP[s]] > ROLE_LEVEL[best] ? SCOPE_CAP[s] : best), "read");
108+ if (ROLE_LEVEL[role] > ROLE_LEVEL[cap]) role = cap;
109+ const a = p.account;
110+ let onBehalfOf: string | undefined;
111+ if (a.kind === "agent" && a.owner_id) onBehalfOf = (await dir.account(a.owner_id))?.login;
112+ return { id: a.id, login: a.login, kind: a.kind === "agent" ? "agent" : "user", onBehalfOf, role };
113+}
addedapps/diffinery/src/auth/sessions.ts+209−0View file
@@ -1,0 +1,209 @@
1+import type { Env } from "../env.js";
2+import type { Account } from "../lib/directory.js";
3+import { newId, now, randomToken, sha256 } from "../lib/util.js";
4+
5+/** Browser session cookie. `__Host-` pins it to this origin: Secure, Path=/, no Domain. */
6+export const SESSION_COOKIE = "__Host-dfy_session";
7+/** Binds an in-flight WebAuthn ceremony to the browser that started it. */
8+export const CHALLENGE_COOKIE = "__Host-dfy_webauthn";
9+export const SESSION_DAYS = 30;
10+const CHALLENGE_MINUTES = 5;
11+const LINK_HOURS = 24;
12+
13+export type PasskeyRow = {
14+ id: string;
15+ account_id: string;
16+ public_key: string;
17+ counter: number;
18+ transports: string | null;
19+ device_type: string;
20+ backed_up: number;
21+ name: string;
22+ created_at: string;
23+ last_used_at: string | null;
24+};
25+
26+export type SessionRow = {
27+ id: string;
28+ account_id: string;
29+ method: string;
30+ passkey_id: string | null;
31+ user_agent: string | null;
32+ created_at: string;
33+ last_seen_at: string;
34+ expires_at: string;
35+};
36+
37+export type InviteRow = { id: string; email: string | null; org_id: string | null; repo_id: string | null; role: string };
38+
39+/** Who a passkey being registered will belong to. */
40+export type Subject =
41+ | { kind: "account"; accountId: string }
42+ | { kind: "invite"; inviteHash: string; email: string; login: string; name: string; accountId: string }
43+ | { kind: "link"; linkHash: string; accountId: string }
44+ | { kind: "login" };
45+
46+const days = (n: number): string => new Date(Date.now() + n * 86400_000).toISOString();
47+
48+/** Sessions, passkeys, WebAuthn challenges and sign-in links in D1. */
49+export class Auth {
50+ constructor(private env: Env) {}
51+
52+ private get db() {
53+ return this.env.DB;
54+ }
55+
56+ /** Relying party: passkeys are bound to the hostname of PUBLIC_URL. */
57+ rp(): { id: string; origin: string; name: string } {
58+ const u = new URL(this.env.PUBLIC_URL);
59+ return { id: u.hostname, origin: u.origin, name: "Diffinery" };
60+ }
61+
62+ // ---------------------------------------------------------------- sessions
63+
64+ async createSession(accountId: string, method: "passkey" | "access" | "link", passkeyId: string | null, userAgent: string | null): Promise<string> {
65+ const token = randomToken(32);
66+ const at = now();
67+ await this.db
68+ .prepare("INSERT INTO sessions (id, account_id, method, passkey_id, user_agent, created_at, last_seen_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
69+ .bind(await sha256(token), accountId, method, passkeyId, userAgent?.slice(0, 300) ?? null, at, at, days(SESSION_DAYS))
70+ .run();
71+ return token;
72+ }
73+
74+ /** Resolve a session cookie. Sessions slide: each day of use extends them by another 30 days. */
75+ async session(token: string): Promise<{ session: SessionRow; account: Account } | null> {
76+ if (!/^[A-Za-z0-9_-]{20,64}$/.test(token)) return null;
77+ const row = await this.db.prepare("SELECT * FROM sessions WHERE id = ?").bind(await sha256(token)).first<SessionRow>();
78+ if (!row || row.expires_at < now()) return null;
79+ const account = await this.db.prepare("SELECT * FROM accounts WHERE id = ?").bind(row.account_id).first<Account>();
80+ if (!account || account.kind !== "user") return null;
81+ if (Date.now() - Date.parse(row.last_seen_at) > 86400_000) {
82+ await this.db.prepare("UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE id = ?").bind(now(), days(SESSION_DAYS), row.id).run();
83+ }
84+ return { session: row, account };
85+ }
86+
87+ async endSession(token: string): Promise<void> {
88+ await this.db.prepare("DELETE FROM sessions WHERE id = ?").bind(await sha256(token)).run();
89+ }
90+
91+ async sessions(accountId: string): Promise<SessionRow[]> {
92+ const r = await this.db.prepare("SELECT * FROM sessions WHERE account_id = ? AND expires_at > ? ORDER BY last_seen_at DESC LIMIT 100").bind(accountId, now()).all<SessionRow>();
93+ return r.results;
94+ }
95+
96+ async endOtherSessions(accountId: string, keepId: string | null): Promise<void> {
97+ await this.db.prepare("DELETE FROM sessions WHERE account_id = ? AND id != ?").bind(accountId, keepId ?? "").run();
98+ }
99+
100+ async endAllSessions(accountId: string): Promise<void> {
101+ await this.db.prepare("DELETE FROM sessions WHERE account_id = ?").bind(accountId).run();
102+ }
103+
104+ // ---------------------------------------------------------------- passkeys
105+
106+ async passkeys(accountId: string): Promise<PasskeyRow[]> {
107+ const r = await this.db.prepare("SELECT * FROM passkeys WHERE account_id = ? ORDER BY created_at").bind(accountId).all<PasskeyRow>();
108+ return r.results;
109+ }
110+
111+ async passkey(id: string): Promise<PasskeyRow | null> {
112+ return this.db.prepare("SELECT * FROM passkeys WHERE id = ?").bind(id).first<PasskeyRow>();
113+ }
114+
115+ async addPasskey(p: Omit<PasskeyRow, "created_at" | "last_used_at">): Promise<void> {
116+ await this.db
117+ .prepare("INSERT INTO passkeys (id, account_id, public_key, counter, transports, device_type, backed_up, name, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
118+ .bind(p.id, p.account_id, p.public_key, p.counter, p.transports, p.device_type, p.backed_up, p.name.slice(0, 100), now())
119+ .run();
120+ }
121+
122+ async passkeyUsed(id: string, counter: number, backedUp: boolean): Promise<void> {
123+ await this.db.prepare("UPDATE passkeys SET counter = ?, backed_up = ?, last_used_at = ? WHERE id = ?").bind(counter, backedUp ? 1 : 0, now(), id).run();
124+ }
125+
126+ async renamePasskey(accountId: string, id: string, name: string): Promise<void> {
127+ await this.db.prepare("UPDATE passkeys SET name = ? WHERE id = ? AND account_id = ?").bind(name.slice(0, 100), id, accountId).run();
128+ }
129+
130+ /** Remove a passkey and every other session it signed in (a lost device's browsers). */
131+ async deletePasskey(accountId: string, id: string, keepSessionId: string | null): Promise<void> {
132+ await this.db.batch([
133+ this.db.prepare("DELETE FROM sessions WHERE account_id = ? AND passkey_id = ? AND id != ?").bind(accountId, id, keepSessionId ?? ""),
134+ this.db.prepare("DELETE FROM passkeys WHERE id = ? AND account_id = ?").bind(id, accountId),
135+ ]);
136+ }
137+
138+ // ---------------------------------------------------------------- WebAuthn challenges
139+
140+ /** Store a challenge; returns the value for the browser's challenge cookie. */
141+ async saveChallenge(purpose: "login" | "register", challenge: string, subject: Subject): Promise<string> {
142+ const token = randomToken(24);
143+ const expires = new Date(Date.now() + CHALLENGE_MINUTES * 60_000).toISOString();
144+ await this.db.batch([
145+ this.db.prepare("DELETE FROM webauthn_challenges WHERE expires_at < ?").bind(now()),
146+ this.db.prepare("INSERT INTO webauthn_challenges (id, purpose, challenge, subject, expires_at) VALUES (?, ?, ?, ?, ?)").bind(await sha256(token), purpose, challenge, JSON.stringify(subject), expires),
147+ ]);
148+ return token;
149+ }
150+
151+ /** Consume a challenge exactly once. */
152+ async takeChallenge(token: string | undefined, purpose: "login" | "register"): Promise<{ challenge: string; subject: Subject } | null> {
153+ if (!token) return null;
154+ const row = await this.db
155+ .prepare("DELETE FROM webauthn_challenges WHERE id = ? RETURNING purpose, challenge, subject, expires_at")
156+ .bind(await sha256(token))
157+ .first<{ purpose: string; challenge: string; subject: string; expires_at: string }>();
158+ if (!row || row.purpose !== purpose || row.expires_at < now()) return null;
159+ return { challenge: row.challenge, subject: JSON.parse(row.subject) as Subject };
160+ }
161+
162+ // ---------------------------------------------------------------- invites and sign-in links
163+
164+ /** An open invite by its secret token (null when used, expired or unknown). */
165+ async invite(token: string): Promise<(InviteRow & { hash: string }) | null> {
166+ const hash = await sha256(token);
167+ const row = await this.db.prepare("SELECT id, email, org_id, repo_id, role FROM invites WHERE token_hash = ? AND accepted_at IS NULL AND expires_at > ?").bind(hash, now()).first<InviteRow>();
168+ return row ? { ...row, hash } : null;
169+ }
170+
171+ async inviteByHash(hash: string): Promise<InviteRow | null> {
172+ return this.db.prepare("SELECT id, email, org_id, repo_id, role FROM invites WHERE token_hash = ? AND accepted_at IS NULL AND expires_at > ?").bind(hash, now()).first<InviteRow>();
173+ }
174+
175+ /** A single-use link that lets the holder add a passkey to `account` (account recovery, new devices). */
176+ async createSigninLink(by: Account, account: Account): Promise<{ url: string; expiresAt: string }> {
177+ const token = randomToken(32);
178+ const expiresAt = new Date(Date.now() + LINK_HOURS * 3600_000).toISOString();
179+ await this.db
180+ .prepare("INSERT INTO signin_links (id, token_hash, account_id, created_by, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)")
181+ .bind(newId(), await sha256(token), account.id, by.id, now(), expiresAt)
182+ .run();
183+ return { url: `${this.env.PUBLIC_URL}/signin/${token}`, expiresAt };
184+ }
185+
186+ async signinLink(token: string): Promise<{ hash: string; account: Account } | null> {
187+ const hash = await sha256(token);
188+ const row = await this.db.prepare("SELECT account_id FROM signin_links WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?").bind(hash, now()).first<{ account_id: string }>();
189+ if (!row) return null;
190+ const account = await this.db.prepare("SELECT * FROM accounts WHERE id = ?").bind(row.account_id).first<Account>();
191+ return account && account.kind === "user" ? { hash, account } : null;
192+ }
193+
194+ /** Mark a link used; false when someone else used it first. */
195+ async useSigninLink(hash: string): Promise<boolean> {
196+ const r = await this.db.prepare("UPDATE signin_links SET used_at = ? WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?").bind(now(), hash, now()).run();
197+ return (r.meta.changes ?? 0) > 0;
198+ }
199+}
200+
201+/** Read one cookie from a raw request. */
202+export function cookieFrom(req: Request, name: string): string | undefined {
203+ const cookie = req.headers.get("cookie") ?? "";
204+ for (const part of cookie.split(";")) {
205+ const i = part.indexOf("=");
206+ if (i > 0 && part.slice(0, i).trim() === name) return part.slice(i + 1).trim();
207+ }
208+ return undefined;
209+}
addedapps/diffinery/src/env.ts+31−0View file
@@ -1,0 +1,31 @@
1+import type { DiffineryEvent } from "@diffinery/shared";
2+import type { RepoDO } from "./repo/RepoDO.js";
3+import type { RunnerContainer, SandboxContainer } from "./runner/containers.js";
4+
5+export interface Env {
6+ DB: D1Database;
7+ BUCKET: R2Bucket;
8+ EVENTS: Queue<DiffineryEvent>;
9+ REPO: DurableObjectNamespace<RepoDO>;
10+ RUNNER: DurableObjectNamespace<RunnerContainer>;
11+ SANDBOX: DurableObjectNamespace<SandboxContainer>;
12+ RUN_WORKFLOW: Workflow<{ repoId: string; runId: string }>;
13+
14+ /** Canonical origin, e.g. https://diffinery.com */
15+ PUBLIC_URL: string;
16+ /** Comma-separated emails or logins that become site admins on first sign-in. */
17+ BOOTSTRAP_ADMINS: string;
18+ /** e.g. codician.cloudflareaccess.com — empty disables Access verification. */
19+ ACCESS_TEAM_DOMAIN: string;
20+ /** Access application audience tag. */
21+ ACCESS_AUD: string;
22+ /** Local development only: act as this email when Access is not configured. */
23+ DEV_AUTH?: string;
24+
25+ /** Secret: base64 32 bytes; root key for envelope encryption of secrets. */
26+ MASTER_KEY?: string;
27+ /** Secret: ES256 private JWK (JSON) used to sign OIDC tokens for jobs. */
28+ OIDC_PRIVATE_KEY?: string;
29+ /** Secret: optional GitHub token used by the importer. */
30+ GITHUB_IMPORT_TOKEN?: string;
31+}
addedapps/diffinery/src/git/protocol.ts+228−0View file
@@ -1,0 +1,228 @@
1+import { ByteWriter, FLUSH, DELIM, ZERO_OID, fromUtf8, planFetch, parseTag, pkt, pktText, readPkts, sideband, writePack, utf8, type ObjectReader } from "@diffinery/git-core";
2+import type { RawObject } from "../repo/objects.js";
3+
4+export const AGENT = "diffinery/0.1";
5+
6+export type RefEntry = {
7+ name: string;
8+ oid: string;
9+};
10+
11+export interface UploadContext {
12+ store: ObjectReader & { readRaw(oid: string): Promise<RawObject | null>; has(oid: string): boolean };
13+ refs: RefEntry[];
14+ /** e.g. refs/heads/main — HEAD points here even when the branch does not exist yet. */
15+ headTarget: string;
16+}
17+
18+// ------------------------------------------------------------------ advertisements
19+
20+export function advertiseUploadPackV2(): Uint8Array {
21+ const w = new ByteWriter();
22+ w.write(pkt("version 2\n"));
23+ w.write(pkt(`agent=${AGENT}\n`));
24+ w.write(pkt("ls-refs=unborn\n"));
25+ w.write(pkt("fetch=shallow wait-for-done\n"));
26+ w.write(pkt("server-option\n"));
27+ w.write(pkt("object-format=sha1\n"));
28+ w.write(FLUSH);
29+ return w.bytes();
30+}
31+
32+export function advertiseReceivePack(refs: RefEntry[]): Uint8Array {
33+ const w = new ByteWriter();
34+ w.write(pkt("# service=git-receive-pack\n"));
35+ w.write(FLUSH);
36+ const caps = `report-status report-status-v2 delete-refs side-band-64k quiet atomic ofs-delta push-options object-format=sha1 agent=${AGENT}`;
37+ if (refs.length === 0) {
38+ w.write(pkt(`${ZERO_OID} capabilities^{}\0${caps}\n`));
39+ } else {
40+ refs.forEach((r, i) => w.write(pkt(i === 0 ? `${r.oid} ${r.name}\0${caps}\n` : `${r.oid} ${r.name}\n`)));
41+ }
42+ w.write(FLUSH);
43+ return w.bytes();
44+}
45+
46+// ------------------------------------------------------------------ protocol v2: upload-pack
47+
48+interface V2Request {
49+ command: string;
50+ capabilities: string[];
51+ args: string[];
52+}
53+
54+function parseV2(body: Uint8Array): V2Request {
55+ const { pkts } = readPkts(body);
56+ const req: V2Request = { command: "", capabilities: [], args: [] };
57+ let section: "caps" | "args" = "caps";
58+ for (const p of pkts) {
59+ if (p.type === "delim") {
60+ section = "args";
61+ continue;
62+ }
63+ if (p.type === "flush") break;
64+ const line = pktText(p);
65+ if (section === "caps") {
66+ if (line.startsWith("command=")) req.command = line.slice(8);
67+ else req.capabilities.push(line);
68+ } else req.args.push(line);
69+ }
70+ return req;
71+}
72+
73+export async function handleUploadPackV2(body: Uint8Array, ctx: UploadContext): Promise<Uint8Array> {
74+ const req = parseV2(body);
75+ if (req.command === "ls-refs") return lsRefs(req, ctx);
76+ if (req.command === "fetch") return fetchCommand(req, ctx);
77+ throw new Error(`unsupported command: ${req.command || "(none)"}`);
78+}
79+
80+async function lsRefs(req: V2Request, ctx: UploadContext): Promise<Uint8Array> {
81+ const prefixes = req.args.filter((a) => a.startsWith("ref-prefix ")).map((a) => a.slice(11));
82+ const symrefs = req.args.includes("symrefs");
83+ const peel = req.args.includes("peel");
84+ const unborn = req.args.includes("unborn");
85+ const match = (name: string) => prefixes.length === 0 || prefixes.some((p) => name.startsWith(p));
86+ const w = new ByteWriter();
87+ const head = ctx.refs.find((r) => r.name === ctx.headTarget);
88+ if (match("HEAD")) {
89+ if (head) w.write(pkt(`${head.oid} HEAD${symrefs ? ` symref-target:${ctx.headTarget}` : ""}\n`));
90+ else if (unborn) w.write(pkt(`unborn HEAD${symrefs ? ` symref-target:${ctx.headTarget}` : ""}\n`));
91+ }
92+ for (const r of ctx.refs) {
93+ if (!match(r.name)) continue;
94+ let line = `${r.oid} ${r.name}`;
95+ if (peel && r.name.startsWith("refs/tags/")) {
96+ const o = await ctx.store.read(r.oid);
97+ if (o?.type === "tag") line += ` peeled:${parseTag(o.content).object}`;
98+ }
99+ w.write(pkt(`${line}\n`));
100+ }
101+ w.write(FLUSH);
102+ return w.bytes();
103+}
104+
105+async function fetchCommand(req: V2Request, ctx: UploadContext): Promise<Uint8Array> {
106+ const wants: string[] = [];
107+ const haves: string[] = [];
108+ let done = false;
109+ let noProgress = false;
110+ let depth: number | undefined;
111+ let since: number | undefined;
112+ const clientShallow = new Set<string>();
113+ for (const a of req.args) {
114+ if (a.startsWith("want ")) wants.push(a.slice(5));
115+ else if (a.startsWith("want-ref ")) {
116+ const ref = ctx.refs.find((r) => r.name === a.slice(9));
117+ if (ref) wants.push(ref.oid);
118+ } else if (a.startsWith("have ")) haves.push(a.slice(5));
119+ else if (a === "done") done = true;
120+ else if (a === "no-progress") noProgress = true;
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");
128+ }
129+ for (const w of wants) if (!ctx.store.has(w)) throw new Error(`not our ref ${w}`);
130+ const common = haves.filter((h) => ctx.store.has(h));
131+
132+ const out = new ByteWriter();
133+ if (!done) {
134+ out.write(pkt("acknowledgments\n"));
135+ if (common.length === 0) out.write(pkt("NAK\n"));
136+ for (const c of common) out.write(pkt(`ACK ${c}\n`));
137+ out.write(pkt("ready\n"));
138+ out.write(DELIM);
139+ }
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+ }
147+ out.write(pkt("packfile\n"));
148+ const oids = plan.oids;
149+ if (!noProgress) sideband(2, utf8(`Enumerating objects: ${oids.length}, done.\n`), out);
150+ const entries = [];
151+ for (const oid of oids) {
152+ const raw = await ctx.store.readRaw(oid);
153+ if (!raw) throw new Error(`missing object ${oid}`);
154+ entries.push({ type: raw.type, size: raw.size, deflated: raw.deflated });
155+ }
156+ sideband(1, await writePack(entries), out);
157+ if (!noProgress) sideband(2, utf8(`Total ${oids.length} (delta 0), reused ${oids.length} (delta 0)\n`), out);
158+ out.write(FLUSH);
159+ return out.bytes();
160+}
161+
162+// ------------------------------------------------------------------ receive-pack
163+
164+export interface RefCommand {
165+ oldOid: string;
166+ newOid: string;
167+ ref: string;
168+}
169+
170+export interface ReceiveRequest {
171+ commands: RefCommand[];
172+ capabilities: Set<string>;
173+ pushOptions: string[];
174+ pack: Uint8Array | null;
175+}
176+
177+export function parseReceivePack(body: Uint8Array): ReceiveRequest {
178+ const { pkts, offset } = readPkts(body, 0, true);
179+ const commands: RefCommand[] = [];
180+ let capabilities = new Set<string>();
181+ for (const p of pkts) {
182+ if (p.type !== "data") continue;
183+ let line = fromUtf8(p.data).replace(/\n$/, "");
184+ const nul = line.indexOf("\0");
185+ if (nul >= 0) {
186+ capabilities = new Set(line.slice(nul + 1).trim().split(" ").filter(Boolean));
187+ line = line.slice(0, nul);
188+ }
189+ if (line.startsWith("shallow ")) continue;
190+ const [oldOid, newOid, ref] = line.split(" ");
191+ if (!oldOid || !newOid || !ref) throw new Error(`bad command: ${line}`);
192+ commands.push({ oldOid, newOid, ref });
193+ }
194+ let rest = offset;
195+ const pushOptions: string[] = [];
196+ if (capabilities.has("push-options")) {
197+ const opts = readPkts(body, rest, true);
198+ for (const p of opts.pkts) if (p.type === "data") pushOptions.push(pktText(p));
199+ rest = opts.offset;
200+ }
201+ const pack = rest < body.length ? body.subarray(rest) : null;
202+ return { commands, capabilities, pushOptions, pack };
203+}
204+
205+export interface RefResult {
206+ ref: string;
207+ ok: boolean;
208+ reason?: string;
209+}
210+
211+export function reportStatus(req: ReceiveRequest, unpackError: string | null, results: RefResult[], messages: string[] = []): Uint8Array {
212+ const report = new ByteWriter();
213+ report.write(pkt(unpackError ? `unpack ${unpackError}\n` : "unpack ok\n"));
214+ for (const r of results) report.write(pkt(r.ok ? `ok ${r.ref}\n` : `ng ${r.ref} ${r.reason ?? "rejected"}\n`));
215+ report.write(FLUSH);
216+ if (!req.capabilities.has("side-band-64k")) return report.bytes();
217+ const out = new ByteWriter();
218+ for (const m of messages) sideband(2, utf8(`${m}\n`), out);
219+ sideband(1, report.bytes(), out);
220+ out.write(FLUSH);
221+ return out.bytes();
222+}
223+
224+export function errorPacket(message: string): Uint8Array {
225+ const w = new ByteWriter();
226+ w.write(pkt(`ERR ${message}\n`));
227+ return w.bytes();
228+}
addedapps/diffinery/src/git/secret-scan.ts+30−0View file
@@ -1,0 +1,30 @@
1+import { fromUtf8 } from "@diffinery/git-core";
2+
3+/** High-confidence credential patterns. Push protection blocks pushes that add these. */
4+const PATTERNS: Array<{ kind: string; re: RegExp }> = [
5+ { kind: "AWS access key", re: /\b(AKIA|ASIA)[0-9A-Z]{16}\b/ },
6+ { kind: "GitHub token", re: /\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b|\bgithub_pat_[A-Za-z0-9_]{82}\b/ },
7+ { kind: "Diffinery token", re: /\bdfy_[A-Za-z0-9_-]{32,}\b/ },
8+ { kind: "Slack token", re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/ },
9+ { kind: "Stripe live key", re: /\b[rs]k_live_[A-Za-z0-9]{20,}\b/ },
10+ { kind: "Google API key", re: /\bAIza[0-9A-Za-z_-]{35}\b/ },
11+ { kind: "Anthropic API key", re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/ },
12+ { kind: "OpenAI API key", re: /\bsk-(proj-)?[A-Za-z0-9_-]{40,}\b/ },
13+ { kind: "Private key", re: /-----BEGIN (RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY( BLOCK)?-----/ },
14+];
15+
16+export interface SecretHit {
17+ oid: string;
18+ kind: string;
19+}
20+
21+export function scanForSecrets(blobs: Array<{ oid: string; content: Uint8Array }>): SecretHit[] {
22+ const hits: SecretHit[] = [];
23+ for (const b of blobs) {
24+ if (b.content.includes(0)) continue; // binary
25+ const text = fromUtf8(b.content);
26+ if (/diffinery:allow-secret/.test(text)) continue; // explicit, auditable opt-out for test fixtures
27+ for (const p of PATTERNS) if (p.re.test(text)) hits.push({ oid: b.oid, kind: p.kind });
28+ }
29+ return hits;
30+}
addedapps/diffinery/src/http/api.ts+717−0View file
@@ -1,0 +1,717 @@
1+import { Hono } from "hono";
2+import { fromUtf8 } from "@diffinery/git-core";
3+import type { Env } from "../env.js";
4+import { requireAccount, type AppContext, type AppVars } from "../auth/principal.js";
5+import type { Account, Repo, TokenScope } from "../lib/directory.js";
6+import { encryptSecret } from "../lib/crypto.js";
7+import { publicJwk } from "../lib/jwt.js";
8+import { HttpError, b64, fromB64 } from "../lib/util.js";
9+import type { ProtectionRules, Role } from "../repo/types.js";
10+import type { RunInfo } from "../repo/actions.js";
11+import { loadRepo, repoStub } from "./common.js";
12+
13+/**
14+ * REST API. Paths and payloads follow GitHub's REST API v3 where practical so
15+ * existing scripts and tools work; Diffinery-only endpoints live under /api/v3/diffinery.
16+ */
17+export const api = new Hono<{ Bindings: Env; Variables: AppVars }>().basePath("/api/v3");
18+
19+const userJson = (c: AppContext, a: Pick<Account, "id" | "login" | "kind" | "name">) => ({
20+ id: a.id,
21+ login: a.login,
22+ name: a.name,
23+ type: a.kind === "org" ? "Organization" : a.kind === "agent" ? "Bot" : "User",
24+ html_url: `${c.env.PUBLIC_URL}/${a.login}`,
25+});
26+
27+const repoJson = (c: AppContext, r: Repo) => ({
28+ id: r.id,
29+ name: r.name,
30+ full_name: `${r.owner_login}/${r.name}`,
31+ owner: { login: r.owner_login, type: r.owner_kind === "org" ? "Organization" : "User" },
32+ private: r.visibility !== "public",
33+ visibility: r.visibility,
34+ description: r.description,
35+ default_branch: r.default_branch,
36+ archived: !!r.archived,
37+ html_url: `${c.env.PUBLIC_URL}/${r.owner_login}/${r.name}`,
38+ clone_url: `${c.env.PUBLIC_URL}/${r.owner_login}/${r.name}.git`,
39+ created_at: r.created_at,
40+ updated_at: r.updated_at,
41+ pushed_at: r.pushed_at,
42+});
43+
44+async function body<T>(c: AppContext): Promise<T> {
45+ try {
46+ return (await c.req.json()) as T;
47+ } catch {
48+ throw new HttpError(400, "request body must be JSON");
49+ }
50+}
51+
52+const P = (c: AppContext) => ({ owner: c.req.param("owner")!, repo: c.req.param("repo")! });
53+const num = (c: AppContext, name = "number") => {
54+ const n = Number(c.req.param(name));
55+ if (!Number.isInteger(n) || n < 1) throw new HttpError(404, "not found");
56+ return n;
57+};
58+
59+// ---------------------------------------------------------------- meta & users
60+
61+api.get("/", (c) => c.json({ name: "Diffinery", api: "v3", docs: `${c.env.PUBLIC_URL}/docs/api`, mcp: `${c.env.PUBLIC_URL}/mcp` }));
62+api.get("/meta", (c) => c.json({ name: "Diffinery", oidc: !!c.env.OIDC_PRIVATE_KEY }));
63+api.get("/user", (c) => {
64+ const a = requireAccount(c);
65+ return c.json({ ...userJson(c, a), email: a.email, site_admin: !!a.site_admin });
66+});
67+api.get("/users/:login", async (c) => {
68+ requireAccount(c);
69+ const a = await c.get("dir").accountByLogin(c.req.param("login"));
70+ if (!a) throw new HttpError(404, "not found");
71+ return c.json(userJson(c, a));
72+});
73+api.get("/user/orgs", async (c) => c.json((await c.get("dir").orgsFor(requireAccount(c).id)).map((o) => ({ ...userJson(c, o), role: o.role }))));
74+api.post("/user/orgs", async (c) => {
75+ const a = requireAccount(c);
76+ const b = await body<{ login: string; name?: string }>(c);
77+ return c.json(userJson(c, await c.get("dir").createOrg(b.login, b.name ?? b.login, a)), 201);
78+});
79+api.get("/orgs/:org/members", async (c) => {
80+ const a = requireAccount(c);
81+ const dir = c.get("dir");
82+ const org = await dir.accountByLogin(c.req.param("org"));
83+ if (!org || org.kind !== "org") throw new HttpError(404, "not found");
84+ if (!(await dir.orgRole(org.id, a.id)) && !a.site_admin) throw new HttpError(404, "not found");
85+ return c.json((await dir.orgMembers(org.id)).map((m) => ({ ...userJson(c, m), role: m.role })));
86+});
87+api.post("/orgs/:org/invitations", async (c) => {
88+ const a = requireAccount(c);
89+ const dir = c.get("dir");
90+ const org = await dir.accountByLogin(c.req.param("org"));
91+ if (!org || org.kind !== "org") throw new HttpError(404, "not found");
92+ const role = await dir.orgRole(org.id, a.id);
93+ if (role !== "owner" && role !== "admin" && !a.site_admin) throw new HttpError(403, "only organization owners and admins can invite");
94+ const b = await body<{ email: string; role?: "member" | "admin" | "owner" }>(c);
95+ if (!/^[^@\s]+@[^@\s]+$/.test(b.email ?? "")) throw new HttpError(422, "a valid email is required");
96+ const inv = await dir.createInvite(a, { email: b.email, orgId: org.id, role: b.role ?? "member" });
97+ c.executionCtx.waitUntil(dir.audit({ actorId: a.id, actorLogin: a.login, action: "org.invite", target: org.login, detail: { email: b.email, role: b.role ?? "member" } }));
98+ return c.json({ id: inv.id, url: inv.url }, 201);
99+});
100+
101+// Site-wide invitation (no organization): site admins only.
102+api.post("/invitations", async (c) => {
103+ const a = requireAccount(c);
104+ if (!a.site_admin) throw new HttpError(403, "only site admins can invite people without an organization");
105+ const b = await body<{ email: string }>(c);
106+ if (!/^[^@\s]+@[^@\s]+$/.test(b.email ?? "")) throw new HttpError(422, "a valid email is required");
107+ const dir = c.get("dir");
108+ const inv = await dir.createInvite(a, { email: b.email, role: "member" });
109+ c.executionCtx.waitUntil(dir.audit({ actorId: a.id, actorLogin: a.login, action: "site.invite", detail: { email: b.email } }));
110+ return c.json({ id: inv.id, url: inv.url }, 201);
111+});
112+
113+// ---------------------------------------------------------------- repositories
114+
115+api.get("/user/repos", async (c) => c.json((await c.get("dir").reposVisibleTo(requireAccount(c))).map((r) => repoJson(c, r))));
116+api.post("/user/repos", async (c) => {
117+ const a = requireAccount(c);
118+ const b = await body<{ name: string; description?: string; private?: boolean; visibility?: Repo["visibility"]; default_branch?: string }>(c);
119+ const owner = a.kind === "agent" ? (await c.get("dir").account(a.owner_id!))! : a;
120+ const r = await c.get("dir").createRepo(owner, a, { name: b.name, description: b.description, visibility: b.visibility ?? (b.private === false ? "internal" : "private"), defaultBranch: b.default_branch });
121+ return c.json(repoJson(c, r), 201);
122+});
123+api.get("/users/:login/repos", async (c) => {
124+ const a = requireAccount(c);
125+ const owner = await c.get("dir").accountByLogin(c.req.param("login"));
126+ if (!owner) throw new HttpError(404, "not found");
127+ return c.json((await c.get("dir").reposVisibleTo(a, owner.id)).map((r) => repoJson(c, r)));
128+});
129+api.get("/orgs/:org/repos", async (c) => {
130+ const a = requireAccount(c);
131+ const org = await c.get("dir").accountByLogin(c.req.param("org"));
132+ if (!org) throw new HttpError(404, "not found");
133+ return c.json((await c.get("dir").reposVisibleTo(a, org.id)).map((r) => repoJson(c, r)));
134+});
135+api.post("/orgs/:org/repos", async (c) => {
136+ const a = requireAccount(c);
137+ const org = await c.get("dir").accountByLogin(c.req.param("org"));
138+ if (!org || org.kind !== "org") throw new HttpError(404, "organization not found");
139+ const b = await body<{ name: string; description?: string; private?: boolean; visibility?: Repo["visibility"]; default_branch?: string }>(c);
140+ const r = await c.get("dir").createRepo(org, a, { name: b.name, description: b.description, visibility: b.visibility ?? (b.private === false ? "internal" : "private"), defaultBranch: b.default_branch });
141+ return c.json(repoJson(c, r), 201);
142+});
143+api.get("/repos/:owner/:repo", async (c) => {
144+ const { repo, caller, stub } = await loadRepo(c, P(c).owner, P(c).repo);
145+ return c.json({ ...repoJson(c, repo), permissions: { role: caller.role }, stats: await stub.stats() });
146+});
147+api.patch("/repos/:owner/:repo", async (c) => {
148+ const { repo } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
149+ const b = await body<{ name?: string; description?: string; visibility?: Repo["visibility"]; default_branch?: string; archived?: boolean }>(c);
150+ return c.json(repoJson(c, await c.get("dir").updateRepo(repo, { name: b.name, description: b.description, visibility: b.visibility, defaultBranch: b.default_branch, archived: b.archived })));
151+});
152+api.delete("/repos/:owner/:repo", async (c) => {
153+ const { repo, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
154+ if (caller.kind === "agent") throw new HttpError(403, "agents cannot delete repositories");
155+ await c.get("dir").deleteRepo(repo);
156+ return c.body(null, 204);
157+});
158+api.get("/repos/:owner/:repo/collaborators", async (c) => {
159+ const { repo } = await loadRepo(c, P(c).owner, P(c).repo);
160+ return c.json((await c.get("dir").collaborators(repo.id)).map((x) => ({ ...userJson(c, x.account), role_name: x.role })));
161+});
162+api.put("/repos/:owner/:repo/collaborators/:login", async (c) => {
163+ const { repo } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
164+ const who = await c.get("dir").accountByLogin(c.req.param("login"));
165+ if (!who) throw new HttpError(404, "user not found");
166+ const b = await body<{ permission?: Role | "pull" | "push" }>(c).catch(() => ({ permission: "write" as Role }));
167+ const map: Record<string, Role> = { pull: "read", push: "write" };
168+ await c.get("dir").setCollaborator(repo.id, who.id, map[b.permission ?? ""] ?? (b.permission as Role) ?? "write");
169+ return c.body(null, 204);
170+});
171+api.delete("/repos/:owner/:repo/collaborators/:login", async (c) => {
172+ const { repo } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
173+ const who = await c.get("dir").accountByLogin(c.req.param("login"));
174+ if (who) await c.get("dir").setCollaborator(repo.id, who.id, null);
175+ return c.body(null, 204);
176+});
177+
178+// ---------------------------------------------------------------- git data
179+
180+api.get("/repos/:owner/:repo/branches", async (c) => {
181+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
182+ return c.json((await stub.branches()).map((b) => ({ name: b.name, commit: { sha: b.sha }, protected: b.protected, default: b.isDefault })));
183+});
184+api.get("/repos/:owner/:repo/branches/:branch", async (c) => {
185+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
186+ const sha = await stub.resolve(c.req.param("branch"));
187+ if (!sha) throw new HttpError(404, "branch not found");
188+ return c.json({ name: c.req.param("branch"), commit: await stub.commitInfo(sha) });
189+});
190+api.get("/repos/:owner/:repo/tags", async (c) => {
191+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
192+ return c.json((await stub.tags()).map((t) => ({ name: t.name, commit: { sha: t.target } })));
193+});
194+api.get("/repos/:owner/:repo/git/refs/*", async (c) => {
195+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
196+ const prefix = `refs/${c.req.path.split("/git/refs/")[1] ?? ""}`;
197+ return c.json(stub.refs ? (await stub.refs(prefix)).map((r) => ({ ref: r.name, object: { sha: r.oid } })) : []);
198+});
199+api.post("/repos/:owner/:repo/git/refs", async (c) => {
200+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write");
201+ const b = await body<{ ref: string; sha: string }>(c);
202+ await stub.createRef(b.ref, b.sha, caller);
203+ return c.json({ ref: b.ref, object: { sha: b.sha } }, 201);
204+});
205+api.patch("/repos/:owner/:repo/git/refs/*", async (c) => {
206+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write");
207+ const ref = `refs/${c.req.path.split("/git/refs/")[1]}`;
208+ const b = await body<{ sha: string; force?: boolean }>(c);
209+ await stub.updateRef(ref, b.sha, !!b.force, caller);
210+ return c.json({ ref, object: { sha: b.sha } });
211+});
212+api.delete("/repos/:owner/:repo/git/refs/*", async (c) => {
213+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write");
214+ await stub.deleteRef(`refs/${c.req.path.split("/git/refs/")[1]}`, caller);
215+ return c.body(null, 204);
216+});
217+api.get("/repos/:owner/:repo/commits", async (c) => {
218+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
219+ const perPage = Number(c.req.query("per_page") ?? 30);
220+ const page = Number(c.req.query("page") ?? 1);
221+ return c.json(await stub.log(c.req.query("sha") ?? "HEAD", { path: c.req.query("path"), limit: perPage, skip: (page - 1) * perPage }));
222+});
223+api.get("/repos/:owner/:repo/commits/:ref", async (c) => {
224+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
225+ return c.json(await stub.commitWithDiff(c.req.param("ref")));
226+});
227+api.get("/repos/:owner/:repo/compare/:spec", async (c) => {
228+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
229+ const [base, head] = c.req.param("spec").split("...");
230+ if (!base || !head) throw new HttpError(422, "use base...head");
231+ return c.json(await stub.compare(base, head));
232+});
233+api.get("/repos/:owner/:repo/contents/*", async (c) => {
234+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
235+ const path = decodeURIComponent(c.req.path.split("/contents/")[1] ?? "");
236+ const ref = c.req.query("ref") ?? "HEAD";
237+ const tree = await stub.tree(ref, path);
238+ if (tree) return c.json(tree.items.map((i) => ({ name: i.name, path: i.path, sha: i.oid, size: i.size ?? 0, type: i.type === "tree" ? "dir" : i.type === "commit" ? "submodule" : "file" })));
239+ const blob = await stub.blob(ref, path);
240+ if (!blob) throw new HttpError(404, "not found");
241+ return c.json({ type: "file", name: path.split("/").pop(), path, sha: blob.oid, size: blob.size, encoding: "base64", content: b64(blob.content), commit: blob.sha });
242+});
243+api.get("/repos/:owner/:repo/contents", async (c) => {
244+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
245+ const tree = await stub.tree(c.req.query("ref") ?? "HEAD", "");
246+ if (!tree) return c.json([]);
247+ return c.json(tree.items.map((i) => ({ name: i.name, path: i.path, sha: i.oid, size: i.size ?? 0, type: i.type === "tree" ? "dir" : "file" })));
248+});
249+api.put("/repos/:owner/:repo/contents/*", async (c) => {
250+ const { stub, caller, repo } = await loadRepo(c, P(c).owner, P(c).repo, "write");
251+ const path = decodeURIComponent(c.req.path.split("/contents/")[1] ?? "");
252+ const b = await body<{ message: string; content: string; branch?: string; sha?: string }>(c);
253+ const res = await stub.commitFiles({ branch: b.branch ?? repo.default_branch, message: b.message, changes: [{ path, content: fromB64(b.content) }] }, caller);
254+ return c.json({ commit: { sha: res.sha }, content: { path } }, 201);
255+});
256+api.delete("/repos/:owner/:repo/contents/*", async (c) => {
257+ const { stub, caller, repo } = await loadRepo(c, P(c).owner, P(c).repo, "write");
258+ const path = decodeURIComponent(c.req.path.split("/contents/")[1] ?? "");
259+ const b = await body<{ message: string; branch?: string }>(c);
260+ const res = await stub.commitFiles({ branch: b.branch ?? repo.default_branch, message: b.message, changes: [{ path, delete: true }] }, caller);
261+ return c.json({ commit: { sha: res.sha } });
262+});
263+
264+/** Agent-friendly multi-file commit without a Git client. */
265+api.post("/repos/:owner/:repo/diffinery/commits", async (c) => {
266+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write");
267+ const b = await body<{ branch: string; message: string; base?: string; expected_head_sha?: string; files: Array<{ path: string; content?: string; encoding?: "utf-8" | "base64"; delete?: boolean; mode?: "100644" | "100755" }> }>(c);
268+ if (!Array.isArray(b.files) || b.files.length === 0) throw new HttpError(422, "files is required");
269+ const changes = b.files.map((f) => (f.delete ? { path: f.path, delete: true } : { path: f.path, mode: f.mode, content: f.encoding === "base64" ? fromB64(f.content ?? "") : new TextEncoder().encode(f.content ?? "") }));
270+ return c.json(await stub.commitFiles({ branch: b.branch, message: b.message, changes, createBranchFrom: b.base, expectedHeadSha: b.expected_head_sha }, caller), 201);
271+});
272+
273+api.get("/repos/:owner/:repo/raw/:ref/*", async (c) => {
274+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
275+ const path = decodeURIComponent(c.req.path.split(`/raw/${c.req.param("ref")}/`)[1] ?? "");
276+ const blob = await stub.blob(c.req.param("ref"), path);
277+ if (!blob) throw new HttpError(404, "not found");
278+ return new Response(blob.content, { headers: { "content-type": blob.binary ? "application/octet-stream" : "text/plain; charset=utf-8", "x-content-type-options": "nosniff", "content-security-policy": "sandbox" } });
279+});
280+
281+// ---------------------------------------------------------------- branch protection
282+
283+api.get("/repos/:owner/:repo/branches/:branch/protection", async (c) => {
284+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
285+ const rule = (await stub.listProtections()).find((p) => p.pattern === c.req.param("branch"));
286+ if (!rule) throw new HttpError(404, "branch not protected");
287+ return c.json(rule);
288+});
289+api.put("/repos/:owner/:repo/branches/:branch/protection", async (c) => {
290+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
291+ if (caller.kind === "agent") throw new HttpError(403, "agents cannot change branch protection");
292+ const rules = await body<ProtectionRules>(c);
293+ await stub.setProtection(c.req.param("branch"), rules);
294+ return c.json({ pattern: c.req.param("branch"), rules });
295+});
296+api.delete("/repos/:owner/:repo/branches/:branch/protection", async (c) => {
297+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
298+ if (caller.kind === "agent") throw new HttpError(403, "agents cannot change branch protection");
299+ await stub.setProtection(c.req.param("branch"), null);
300+ return c.body(null, 204);
301+});
302+
303+// ---------------------------------------------------------------- issues & pulls
304+
305+const issueJson = (c: AppContext, owner: string, repo: string, i: Awaited<ReturnType<ReturnType<typeof repoStub>["issue"]>>["issue"]) => ({
306+ number: i.number,
307+ title: i.title,
308+ body: i.body,
309+ state: i.pull?.merged ? "closed" : i.state,
310+ state_reason: i.stateReason,
311+ user: { login: i.author.login, type: i.author.kind === "agent" ? "Bot" : "User" },
312+ labels: i.labels.map((name) => ({ name })),
313+ assignees: i.assignees.map((login) => ({ login })),
314+ comments: i.comments,
315+ created_at: i.createdAt,
316+ updated_at: i.updatedAt,
317+ closed_at: i.closedAt,
318+ html_url: `${c.env.PUBLIC_URL}/${owner}/${repo}/${i.kind === "pull" ? "pull" : "issues"}/${i.number}`,
319+ ...(i.pull
320+ ? {
321+ pull_request: { url: `${c.env.PUBLIC_URL}/api/v3/repos/${owner}/${repo}/pulls/${i.number}` },
322+ head: { ref: i.pull.head, sha: i.pull.headSha },
323+ base: { ref: i.pull.base },
324+ draft: i.pull.draft,
325+ merged: i.pull.merged,
326+ merged_at: i.pull.mergedAt,
327+ merged_by: i.pull.mergedBy ? { login: i.pull.mergedBy } : null,
328+ merge_commit_sha: i.pull.mergeCommit,
329+ requested_reviewers: i.pull.requestedReviewers.map((login) => ({ login })),
330+ intent: i.pull.intent,
331+ }
332+ : {}),
333+});
334+
335+for (const kind of ["issues", "pulls"] as const) {
336+ api.get(`/repos/:owner/:repo/${kind}`, async (c) => {
337+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
338+ const perPage = Math.min(Number(c.req.query("per_page") ?? 30), 100);
339+ const page = Number(c.req.query("page") ?? 1);
340+ const res = await stub.issues({ kind: kind === "pulls" ? "pull" : undefined, state: (c.req.query("state") as "open" | "closed" | "all") ?? "open", label: c.req.query("labels"), author: c.req.query("creator"), q: c.req.query("q"), limit: perPage, offset: (page - 1) * perPage });
341+ c.header("x-total-count", String(res.total));
342+ return c.json(res.items.map((i) => issueJson(c, P(c).owner, P(c).repo, i)));
343+ });
344+}
345+api.post("/repos/:owner/:repo/issues", async (c) => {
346+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo);
347+ const b = await body<{ title: string; body?: string; labels?: string[]; assignees?: string[] }>(c);
348+ return c.json(issueJson(c, P(c).owner, P(c).repo, await stub.createIssue(b, caller)), 201);
349+});
350+api.get("/repos/:owner/:repo/issues/:number", async (c) => {
351+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
352+ return c.json(issueJson(c, P(c).owner, P(c).repo, (await stub.issue(num(c))).issue));
353+});
354+api.patch("/repos/:owner/:repo/issues/:number", async (c) => {
355+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo);
356+ const b = await body<{ title?: string; body?: string; state?: "open" | "closed"; state_reason?: string; labels?: string[]; assignees?: string[] }>(c);
357+ return c.json(issueJson(c, P(c).owner, P(c).repo, await stub.updateIssue(num(c), { ...b, stateReason: b.state_reason }, caller)));
358+});
359+api.get("/repos/:owner/:repo/issues/:number/comments", async (c) => {
360+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
361+ return c.json((await stub.issue(num(c))).comments.filter((x) => !x.reviewId).map((x) => ({ id: x.id, body: x.body, user: { login: x.author.login, type: x.author.kind === "agent" ? "Bot" : "User" }, created_at: x.createdAt, updated_at: x.updatedAt })));
362+});
363+api.post("/repos/:owner/:repo/issues/:number/comments", async (c) => {
364+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo);
365+ const b = await body<{ body: string }>(c);
366+ const x = await stub.addComment(num(c), b.body, caller);
367+ return c.json({ id: x.id, body: x.body, user: { login: x.author.login }, created_at: x.createdAt }, 201);
368+});
369+api.patch("/repos/:owner/:repo/issues/comments/:id", async (c) => {
370+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo);
371+ await stub.editComment(c.req.param("id"), (await body<{ body: string }>(c)).body, caller);
372+ return c.json({ id: c.req.param("id") });
373+});
374+api.get("/repos/:owner/:repo/labels", async (c) => {
375+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
376+ return c.json(await stub.labels());
377+});
378+api.post("/repos/:owner/:repo/labels", async (c) => {
379+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "triage");
380+ const b = await body<{ name: string; color?: string; description?: string }>(c);
381+ await stub.setLabel(b.name, b.color ?? "888888", b.description ?? "");
382+ return c.json(b, 201);
383+});
384+
385+api.post("/repos/:owner/:repo/pulls", async (c) => {
386+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write");
387+ const b = await body<{ title: string; body?: string; head: string; base?: string; draft?: boolean; intent?: string; reviewers?: string[] }>(c);
388+ return c.json(issueJson(c, P(c).owner, P(c).repo, await stub.createPull({ ...b, head: b.head.includes(":") ? b.head.split(":")[1]! : b.head }, caller)), 201);
389+});
390+api.get("/repos/:owner/:repo/pulls/:number", async (c) => {
391+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo);
392+ const n = num(c);
393+ const { issue } = await stub.issue(n);
394+ if (issue.kind !== "pull") throw new HttpError(404, "not a pull request");
395+ const m = await stub.mergeability(n, caller);
396+ return c.json({ ...issueJson(c, P(c).owner, P(c).repo, issue), mergeable: m.mergeable, mergeable_state: m.state, merge_blockers: m.blockers, approvals: m.approvals, required_approvals: m.requiredApprovals, checks: m.checks });
397+});
398+api.patch("/repos/:owner/:repo/pulls/:number", async (c) => {
399+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo);
400+ const b = await body<{ title?: string; body?: string; state?: "open" | "closed" }>(c);
401+ return c.json(issueJson(c, P(c).owner, P(c).repo, await stub.updateIssue(num(c), b, caller)));
402+});
403+api.get("/repos/:owner/:repo/pulls/:number/files", async (c) => {
404+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
405+ const res = await stub.pullFiles(num(c));
406+ return c.json(res.files.map((f) => ({ filename: f.path, status: f.status === "deleted" ? "removed" : f.status, additions: f.additions, deletions: f.deletions, changes: f.additions + f.deletions, patch: f.patch, binary: f.binary })));
407+});
408+api.get("/repos/:owner/:repo/pulls/:number/commits", async (c) => {
409+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
410+ return c.json(await stub.pullCommits(num(c)));
411+});
412+api.get("/repos/:owner/:repo/pulls/:number/merge", async (c) => {
413+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo);
414+ return c.json(await stub.mergeability(num(c), caller));
415+});
416+api.put("/repos/:owner/:repo/pulls/:number/merge", async (c) => {
417+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write");
418+ const b = await body<{ merge_method?: "merge" | "squash" | "rebase"; commit_title?: string; commit_message?: string; sha?: string; delete_branch?: boolean }>(c).catch(() => ({}) as Record<string, undefined>);
419+ return c.json(await stub.mergePull(num(c), { method: b.merge_method, title: b.commit_title, message: b.commit_message, expectedHeadSha: b.sha, deleteBranch: b.delete_branch }, caller));
420+});
421+api.get("/repos/:owner/:repo/pulls/:number/reviews", async (c) => {
422+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
423+ return c.json((await stub.issue(num(c))).reviews.map((r) => ({ id: r.id, state: r.state, body: r.body, commit_id: r.commitId, submitted_at: r.submittedAt, user: { login: r.author.login, type: r.author.kind === "agent" ? "Bot" : "User" } })));
424+});
425+api.post("/repos/:owner/:repo/pulls/:number/reviews", async (c) => {
426+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo);
427+ const b = await body<{ event?: "APPROVE" | "REQUEST_CHANGES" | "COMMENT"; body?: string; comments?: Array<{ path: string; line: number; side?: "LEFT" | "RIGHT"; body: string }> }>(c);
428+ const r = await stub.submitReview(num(c), { event: b.event ?? "COMMENT", body: b.body, comments: b.comments }, caller);
429+ return c.json({ id: r.id, state: r.state, body: r.body, commit_id: r.commitId, submitted_at: r.submittedAt }, 201);
430+});
431+api.get("/repos/:owner/:repo/pulls/:number/comments", async (c) => {
432+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
433+ return c.json((await stub.issue(num(c))).comments.filter((x) => x.reviewId).map((x) => ({ id: x.id, body: x.body, path: x.path, line: x.line, side: x.side, commit_id: x.commitId, in_reply_to_id: x.inReplyTo, user: { login: x.author.login }, created_at: x.createdAt, resolved: x.resolved })));
434+});
435+api.post("/repos/:owner/:repo/pulls/:number/requested_reviewers", async (c) => {
436+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo);
437+ const b = await body<{ reviewers: string[] }>(c);
438+ await stub.requestReviewers(num(c), b.reviewers ?? [], caller);
439+ return c.json({ requested_reviewers: b.reviewers }, 201);
440+});
441+
442+// ---------------------------------------------------------------- checks (for plugins and external CI)
443+
444+api.get("/repos/:owner/:repo/commits/:ref/check-runs", async (c) => {
445+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
446+ const sha = await stub.resolve(c.req.param("ref"));
447+ if (!sha) throw new HttpError(404, "ref not found");
448+ const runs = await stub.checks(sha);
449+ return c.json({ total_count: runs.length, check_runs: runs.map((r) => ({ id: r.id, name: r.name, head_sha: r.sha, status: r.status, conclusion: r.conclusion, details_url: r.detailsUrl, output: r.output, app: { slug: r.app }, started_at: r.startedAt, completed_at: r.completedAt })) });
450+});
451+api.post("/repos/:owner/:repo/check-runs", async (c) => {
452+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write");
453+ const b = await body<{ name: string; head_sha: string; status?: "queued" | "in_progress" | "completed"; conclusion?: string; details_url?: string; output?: { title?: string; summary?: string } }>(c);
454+ const check = await stub.upsertCheck({ sha: b.head_sha, name: b.name, status: b.status ?? "queued", conclusion: b.conclusion, detailsUrl: b.details_url, output: b.output, app: caller.login });
455+ return c.json(check, 201);
456+});
457+
458+// ---------------------------------------------------------------- actions
459+
460+const runJson = (c: AppContext, owner: string, repo: string, r: RunInfo) => ({
461+ id: r.id,
462+ run_number: r.number,
463+ run_attempt: r.attempt,
464+ name: r.workflowName,
465+ display_title: r.title,
466+ path: r.workflowPath,
467+ event: r.event,
468+ head_branch: r.ref.replace(/^refs\/heads\//, ""),
469+ head_sha: r.sha,
470+ status: r.status,
471+ conclusion: r.conclusion,
472+ actor: { login: r.actor.login, type: r.actor.kind === "agent" ? "Bot" : "User" },
473+ created_at: r.createdAt,
474+ updated_at: r.updatedAt,
475+ html_url: `${c.env.PUBLIC_URL}/${owner}/${repo}/actions/runs/${r.id}`,
476+ pull_request_number: r.prNumber,
477+});
478+
479+api.get("/repos/:owner/:repo/actions/workflows", async (c) => {
480+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
481+ const wfs = await stub.workflows();
482+ return c.json({ total_count: wfs.length, workflows: wfs.map((w) => ({ id: w.path, name: w.name, path: w.path, state: w.errors.length ? "invalid" : "active", errors: w.errors, triggers: w.triggers })) });
483+});
484+api.post("/repos/:owner/:repo/actions/workflows/:workflow/dispatches", async (c) => {
485+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write");
486+ const b = await body<{ ref: string; inputs?: Record<string, unknown> }>(c);
487+ const run = await stub.dispatchWorkflow(decodeURIComponent(c.req.param("workflow")), b.ref, b.inputs ?? {}, caller);
488+ return c.json({ run_id: run.id, html_url: `${c.env.PUBLIC_URL}/${P(c).owner}/${P(c).repo}/actions/runs/${run.id}` }, 201);
489+});
490+// Self-hosted runners poll this to pick up `runs-on: [self-hosted, ...]` jobs (see `diffinery runner`).
491+api.post("/repos/:owner/:repo/actions/runners/claim", async (c) => {
492+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
493+ const b = await body<{ name?: string; labels?: string[] }>(c);
494+ const job = await stub.claimJob(b.name ?? "runner", Array.isArray(b.labels) ? b.labels.map(String) : [], caller);
495+ if (!job) return c.body(null, 204);
496+ return c.json({ job_id: job.jobId, run_id: job.runId, name: job.name, token: job.token, api_url: new URL(c.req.url).origin });
497+});
498+api.get("/repos/:owner/:repo/actions/runs", async (c) => {
499+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
500+ const perPage = Math.min(Number(c.req.query("per_page") ?? 25), 100);
501+ const page = Number(c.req.query("page") ?? 1);
502+ const res = await stub.runs({ workflow: c.req.query("workflow"), branch: c.req.query("branch"), status: c.req.query("status"), event: c.req.query("event"), sha: c.req.query("head_sha"), limit: perPage, offset: (page - 1) * perPage });
503+ return c.json({ total_count: res.total, workflow_runs: res.runs.map((r) => runJson(c, P(c).owner, P(c).repo, r)) });
504+});
505+api.get("/repos/:owner/:repo/actions/runs/:id", async (c) => {
506+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
507+ const r = await stub.run(c.req.param("id"));
508+ return c.json({ ...runJson(c, P(c).owner, P(c).repo, r), jobs: r.jobs });
509+});
510+api.get("/repos/:owner/:repo/actions/runs/:id/jobs", async (c) => {
511+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
512+ const jobs = await stub.runJobs(c.req.param("id"));
513+ return c.json({ total_count: jobs.length, jobs });
514+});
515+api.post("/repos/:owner/:repo/actions/runs/:id/cancel", async (c) => {
516+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write");
517+ await stub.cancelRun(c.req.param("id"), caller);
518+ return c.json({}, 202);
519+});
520+for (const path of ["rerun", "rerun-failed-jobs"]) {
521+ api.post(`/repos/:owner/:repo/actions/runs/:id/${path}`, async (c) => {
522+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write");
523+ const r = await stub.rerun(c.req.param("id"), caller);
524+ return c.json({ run_id: r.id }, 201);
525+ });
526+}
527+api.get("/repos/:owner/:repo/actions/jobs/:id", async (c) => {
528+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
529+ return c.json(await stub.job(c.req.param("id")));
530+});
531+api.get("/repos/:owner/:repo/actions/jobs/:id/logs", async (c) => {
532+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
533+ const { lines } = await stub.jobLogs(c.req.param("id"), Number(c.req.query("after") ?? -1));
534+ if (c.req.query("format") === "json") return c.json(lines);
535+ return c.text(lines.map((l) => `${l.t} ${l.text}`).join("\n"));
536+});
537+api.get("/repos/:owner/:repo/actions/runs/:id/artifacts", async (c) => {
538+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
539+ const list = await stub.artifacts(c.req.param("id"));
540+ return c.json({ total_count: list.length, artifacts: list.map((a) => ({ name: a.name, size_in_bytes: a.size, created_at: a.createdAt, archive_download_url: `${c.env.PUBLIC_URL}/${P(c).owner}/${P(c).repo}/actions/runs/${c.req.param("id")}/artifacts/${encodeURIComponent(a.name)}` })) });
541+});
542+api.get("/repos/:owner/:repo/actions/secrets", async (c) => {
543+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
544+ const s = await stub.secretNames();
545+ return c.json({ total_count: s.length, secrets: s.map((x) => ({ name: x.name, updated_at: x.updatedAt })) });
546+});
547+api.put("/repos/:owner/:repo/actions/secrets/:name", async (c) => {
548+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
549+ if (caller.kind === "agent") throw new HttpError(403, "agents cannot manage secrets");
550+ const b = await body<{ value: string }>(c);
551+ if (typeof b.value !== "string") throw new HttpError(422, "value is required (send it over HTTPS; it is encrypted at rest)");
552+ await stub.setSecret(c.req.param("name"), b.value);
553+ return c.body(null, 204);
554+});
555+api.delete("/repos/:owner/:repo/actions/secrets/:name", async (c) => {
556+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
557+ await stub.deleteSecret(c.req.param("name"));
558+ return c.body(null, 204);
559+});
560+api.get("/repos/:owner/:repo/actions/variables", async (c) => {
561+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "write");
562+ return c.json({ variables: Object.entries(await stub.variables()).map(([name, value]) => ({ name, value })) });
563+});
564+api.delete("/repos/:owner/:repo/actions/variables/:name", async (c) => {
565+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
566+ await stub.setVariable(c.req.param("name"), null);
567+ return c.body(null, 204);
568+});
569+api.put("/repos/:owner/:repo/actions/variables/:name", async (c) => {
570+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
571+ await stub.setVariable(c.req.param("name"), (await body<{ value: string }>(c)).value);
572+ return c.body(null, 204);
573+});
574+async function orgAdmin(c: AppContext) {
575+ const a = requireAccount(c);
576+ if (a.kind === "agent") throw new HttpError(403, "agents cannot manage secrets");
577+ const dir = c.get("dir");
578+ const org = await dir.accountByLogin(c.req.param("org")!);
579+ if (!org || org.kind !== "org") throw new HttpError(404, "not found");
580+ const role = await dir.orgRole(org.id, a.id);
581+ if (role !== "owner" && role !== "admin" && !a.site_admin) throw new HttpError(403, "organization admins only");
582+ return { a, org };
583+}
584+api.get("/orgs/:org/actions/secrets", async (c) => {
585+ const { org } = await orgAdmin(c);
586+ const { results } = await c.env.DB.prepare("SELECT name, updated_at FROM org_secrets WHERE org_id = ? ORDER BY name").bind(org.id).all<{ name: string; updated_at: string }>();
587+ return c.json({ total_count: results.length, secrets: results });
588+});
589+api.delete("/orgs/:org/actions/secrets/:name", async (c) => {
590+ const { org } = await orgAdmin(c);
591+ await c.env.DB.prepare("DELETE FROM org_secrets WHERE org_id = ? AND name = ?").bind(org.id, c.req.param("name").toUpperCase()).run();
592+ return c.body(null, 204);
593+});
594+api.put("/orgs/:org/actions/secrets/:name", async (c) => {
595+ const { org } = await orgAdmin(c);
596+ const name = c.req.param("name").toUpperCase();
597+ if (!/^[A-Z_][A-Z0-9_]*$/.test(name)) throw new HttpError(422, "invalid secret name");
598+ const { value } = await body<{ value: string }>(c);
599+ await c.env.DB.prepare("INSERT OR REPLACE INTO org_secrets (org_id, name, ciphertext, updated_at) VALUES (?, ?, ?, ?)").bind(org.id, name, await encryptSecret(c.env.MASTER_KEY, `org:${org.id}`, value), new Date().toISOString()).run();
600+ return c.body(null, 204);
601+});
602+
603+// ---------------------------------------------------------------- webhooks
604+
605+api.get("/repos/:owner/:repo/hooks", async (c) => {
606+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
607+ return c.json((await stub.webhooks()).map(({ secret: _s, ...h }) => h));
608+});
609+api.post("/repos/:owner/:repo/hooks", async (c) => {
610+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
611+ const b = await body<{ config: { url: string; secret?: string }; events?: string[] }>(c);
612+ return c.json(await stub.createWebhook(b.config?.url, b.events ?? ["*"], b.config?.secret), 201);
613+});
614+api.delete("/repos/:owner/:repo/hooks/:id", async (c) => {
615+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
616+ await stub.deleteWebhook(c.req.param("id"));
617+ return c.body(null, 204);
618+});
619+
620+// ---------------------------------------------------------------- Diffinery: tokens, agents, sandboxes, notifications
621+
622+api.get("/diffinery/tokens", async (c) => c.json(await c.get("dir").tokens(requireAccount(c).id)));
623+api.post("/diffinery/tokens", async (c) => {
624+ const a = requireAccount(c);
625+ const p = c.get("principal");
626+ if (p.type === "account" && p.via === "token" && !p.scopes.includes("admin")) throw new HttpError(403, "creating tokens requires an admin-scoped token or a browser session");
627+ const b = await body<{ name: string; scopes?: TokenScope[]; repositories?: string[]; expires_in_days?: number }>(c);
628+ let repoIds: string[] | null = null;
629+ if (b.repositories?.length) {
630+ repoIds = [];
631+ for (const full of b.repositories) {
632+ const [o, n] = full.split("/");
633+ const r = o && n ? await c.get("dir").repo(o, n) : null;
634+ if (!r) throw new HttpError(422, `unknown repository ${full}`);
635+ repoIds.push(r.id);
636+ }
637+ }
638+ const t = await c.get("dir").createToken(a, { name: b.name, scopes: b.scopes, repoIds, expiresInDays: b.expires_in_days });
639+ c.executionCtx.waitUntil(c.get("dir").audit({ actorId: a.id, actorLogin: a.login, action: "token.create", detail: { name: b.name, scopes: b.scopes } }));
640+ return c.json(t, 201);
641+});
642+api.delete("/diffinery/tokens/:id", async (c) => {
643+ await c.get("dir").revokeToken(requireAccount(c).id, c.req.param("id"));
644+ return c.body(null, 204);
645+});
646+api.get("/diffinery/agents", async (c) => {
647+ const a = requireAccount(c);
648+ const orgs = (await c.get("dir").orgsFor(a.id)).filter((o) => o.role !== "member").map((o) => o.id);
649+ return c.json((await c.get("dir").agentsOwnedBy([a.id, ...orgs])).map((x) => ({ ...userJson(c, x), owner_id: x.owner_id, policy: x.agent_policy ? JSON.parse(x.agent_policy) : {} })));
650+});
651+api.post("/diffinery/agents", async (c) => {
652+ const a = requireAccount(c);
653+ if (a.kind !== "user") throw new HttpError(403, "only people can create agents");
654+ const b = await body<{ login: string; name?: string; org?: string; policy?: Record<string, unknown>; token?: { scopes?: TokenScope[]; expires_in_days?: number } }>(c);
655+ const dir = c.get("dir");
656+ const org = b.org ? await dir.accountByLogin(b.org) : null;
657+ const agent = await dir.createAgent(a, { login: b.login, name: b.name, orgId: org?.id, policy: b.policy });
658+ const token = await dir.createToken(agent, { name: "default", scopes: b.token?.scopes ?? ["write"], expiresInDays: b.token?.expires_in_days ?? 90 });
659+ c.executionCtx.waitUntil(dir.audit({ actorId: a.id, actorLogin: a.login, action: "agent.create", target: agent.login }));
660+ return c.json({ agent: userJson(c, agent), token }, 201);
661+});
662+api.post("/diffinery/agents/:login/tokens", async (c) => {
663+ const a = requireAccount(c);
664+ const dir = c.get("dir");
665+ const agent = await dir.accountByLogin(c.req.param("login"));
666+ if (!agent || agent.kind !== "agent") throw new HttpError(404, "agent not found");
667+ const orgRole = agent.owner_id && agent.owner_id !== a.id ? await dir.orgRole(agent.owner_id, a.id) : null;
668+ if (agent.owner_id !== a.id && orgRole !== "owner" && orgRole !== "admin") throw new HttpError(403, "not your agent");
669+ const b = await body<{ name?: string; scopes?: TokenScope[]; expires_in_days?: number }>(c).catch(() => ({}) as Record<string, undefined>);
670+ return c.json(await dir.createToken(agent, { name: b.name ?? "token", scopes: b.scopes, expiresInDays: b.expires_in_days }), 201);
671+});
672+api.delete("/diffinery/agents/:login", async (c) => {
673+ const a = requireAccount(c);
674+ const dir = c.get("dir");
675+ const agent = await dir.accountByLogin(c.req.param("login"));
676+ if (!agent || agent.kind !== "agent") throw new HttpError(404, "agent not found");
677+ const orgRole = agent.owner_id && agent.owner_id !== a.id ? await dir.orgRole(agent.owner_id, a.id) : null;
678+ if (agent.owner_id !== a.id && orgRole !== "owner" && orgRole !== "admin" && !a.site_admin) throw new HttpError(403, "not your agent");
679+ await dir.setStatus(agent.id, "disabled");
680+ c.executionCtx.waitUntil(dir.audit({ actorId: a.id, actorLogin: a.login, action: "agent.disable", target: agent.login }));
681+ return c.body(null, 204);
682+});
683+
684+api.post("/repos/:owner/:repo/diffinery/sandboxes", async (c) => {
685+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write");
686+ const b = await body<{ ref?: string }>(c).catch(() => ({}) as { ref?: string });
687+ return c.json(await stub.createSandbox(caller, b.ref), 201);
688+});
689+api.post("/repos/:owner/:repo/diffinery/sandboxes/:id/:action", async (c) => {
690+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write");
691+ const id = c.req.param("id");
692+ const action = c.req.param("action");
693+ await stub.sandbox(id, caller);
694+ if (action === "close") {
695+ await stub.closeSandbox(id, caller);
696+ return c.body(null, 204);
697+ }
698+ const map: Record<string, string> = { exec: "/exec", read: "/files/read", write: "/files/write" };
699+ if (!map[action]) throw new HttpError(404, "unknown sandbox action");
700+ const res = await c.env.SANDBOX.get(c.env.SANDBOX.idFromName(id)).call(map[action]!, JSON.stringify(await c.req.json().catch(() => ({}))));
701+ return new Response(res.body, { status: res.status, headers: { "content-type": res.json ? "application/json" : "text/plain; charset=utf-8" } });
702+});
703+
704+api.get("/notifications", async (c) => c.json(await c.get("dir").notifications(requireAccount(c).id, c.req.query("all") !== "true")));
705+api.put("/notifications", async (c) => {
706+ await c.get("dir").markRead(requireAccount(c).id);
707+ return c.body(null, 205);
708+});
709+
710+// OIDC discovery for workflow id tokens.
711+export const wellKnown = new Hono<{ Bindings: Env; Variables: AppVars }>();
712+wellKnown.get("/.well-known/openid-configuration", (c) =>
713+ c.json({ issuer: c.env.PUBLIC_URL, jwks_uri: `${c.env.PUBLIC_URL}/.well-known/jwks`, response_types_supported: ["id_token"], subject_types_supported: ["public"], id_token_signing_alg_values_supported: ["ES256"], claims_supported: ["sub", "aud", "iss", "repository", "ref", "sha", "workflow", "actor", "run_id"] }),
714+);
715+wellKnown.get("/.well-known/jwks", (c) => c.json({ keys: c.env.OIDC_PRIVATE_KEY ? [publicJwk(JSON.parse(c.env.OIDC_PRIVATE_KEY))] : [] }));
716+
717+export { fromUtf8 };
addedapps/diffinery/src/http/common.ts+35−0View file
@@ -1,0 +1,35 @@
1+import type { Env } from "../env.js";
2+import type { Repo } from "../lib/directory.js";
3+import { HttpError } from "../lib/util.js";
4+import { callerFor, type AppContext } from "../auth/principal.js";
5+import { atLeast, type Caller, type Role } from "../repo/types.js";
6+
7+export const repoStub = (env: Env, repoId: string) => env.REPO.get(env.REPO.idFromName(repoId));
8+
9+/** Load a repository and the caller's role, enforcing a minimum role. 404 hides private repos. */
10+export async function loadRepo(c: AppContext, owner: string, name: string, min: Role = "read"): Promise<{ repo: Repo; caller: Caller; stub: ReturnType<typeof repoStub> }> {
11+ const repo = await c.get("dir").repo(owner, name.replace(/\.git$/, ""));
12+ if (!repo) throw new HttpError(404, "repository not found");
13+ const caller = await callerFor(c, repo);
14+ if (!caller) {
15+ if (c.get("principal").type === "anonymous") throw new HttpError(401, "authentication required");
16+ throw new HttpError(404, "repository not found");
17+ }
18+ if (!atLeast(caller.role, min)) throw new HttpError(403, `${min} access required`);
19+ if (repo.archived && min !== "read" && min !== "admin") throw new HttpError(403, "repository is archived (read-only)");
20+ return { repo, caller, stub: repoStub(c.env, repo.id) };
21+}
22+
23+export function errorResponse(e: unknown): Response {
24+ if (e instanceof HttpError) {
25+ const headers: Record<string, string> = { "content-type": "application/json" };
26+ if (e.status === 401) headers["www-authenticate"] = 'Basic realm="Diffinery", charset="UTF-8"';
27+ return new Response(JSON.stringify({ message: e.detail, ...e.extra }), { status: e.status, headers });
28+ }
29+ // Errors thrown inside Durable Objects arrive as plain Errors; recover HttpError-like status when present.
30+ const msg = (e as Error)?.message ?? String(e);
31+ const remote = /^(\d{3}):/.exec(msg);
32+ if (remote) return new Response(JSON.stringify({ message: msg.slice(5) }), { status: Number(remote[1]), headers: { "content-type": "application/json" } });
33+ console.error(e);
34+ return new Response(JSON.stringify({ message: "internal error" }), { status: 500, headers: { "content-type": "application/json" } });
35+}
addedapps/diffinery/src/http/git.ts+92−0View file
@@ -1,0 +1,92 @@
1+import { Hono } from "hono";
2+import type { Env } from "../env.js";
3+import type { AppVars } from "../auth/principal.js";
4+import { HttpError } from "../lib/util.js";
5+import { loadRepo } from "./common.js";
6+
7+/**
8+ * Git smart HTTP (protocol v2 for fetch/clone, v0 for push) and the Git LFS
9+ * batch API, at https://diffinery.com/<owner>/<repo>.git
10+ */
11+export const git = new Hono<{ Bindings: Env; Variables: AppVars }>();
12+
13+const noCache = { "cache-control": "no-cache, max-age=0, must-revalidate", pragma: "no-cache" };
14+
15+git.get("/:owner/:repo/info/refs", async (c) => {
16+ const service = c.req.query("service");
17+ if (service !== "git-upload-pack" && service !== "git-receive-pack") throw new HttpError(403, "dumb HTTP protocol is not supported; update git");
18+ const { stub } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), service === "git-receive-pack" ? "write" : "read");
19+ if (service === "git-upload-pack" && !/version=2/.test(c.req.header("git-protocol") ?? "")) {
20+ throw new HttpError(400, "Diffinery requires Git protocol v2 (git 2.26 or newer, or run: git config --global protocol.version 2)");
21+ }
22+ const body = await stub.gitInfoRefs(service);
23+ return new Response(body, { headers: { "content-type": `application/x-${service}-advertisement`, ...noCache } });
24+});
25+
26+git.post("/:owner/:repo/git-upload-pack", async (c) => {
27+ const { stub } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), "read");
28+ const body = await readBody(c.req.raw);
29+ const out = await stub.gitUploadPack(body);
30+ return new Response(out, { headers: { "content-type": "application/x-git-upload-pack-result", ...noCache } });
31+});
32+
33+git.post("/:owner/:repo/git-receive-pack", async (c) => {
34+ const { stub, caller, repo } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), "write");
35+ const body = await readBody(c.req.raw);
36+ const out = await stub.gitReceivePack(body, caller);
37+ c.executionCtx.waitUntil(c.get("dir").touchPushed(repo.id));
38+ return new Response(out, { headers: { "content-type": "application/x-git-receive-pack-result", ...noCache } });
39+});
40+
41+async function readBody(req: Request): Promise<Uint8Array> {
42+ let stream = req.body;
43+ if (!stream) return new Uint8Array(0);
44+ if (req.headers.get("content-encoding") === "gzip") stream = stream.pipeThrough(new DecompressionStream("gzip"));
45+ return new Uint8Array(await new Response(stream).arrayBuffer());
46+}
47+
48+// ---------------------------------------------------------------- Git LFS
49+
50+const lfsKey = (repoId: string, oid: string) => `repos/${repoId}/lfs/${oid.slice(0, 2)}/${oid.slice(2, 4)}/${oid}`;
51+const LFS_TYPE = "application/vnd.git-lfs+json";
52+
53+git.post("/:owner/:repo/info/lfs/objects/batch", async (c) => {
54+ const req = (await c.req.json()) as { operation: "upload" | "download"; objects: Array<{ oid: string; size: number }> };
55+ const { repo } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), req.operation === "upload" ? "write" : "read");
56+ const base = `${c.env.PUBLIC_URL}/${c.req.param("owner")}/${c.req.param("repo")}/info/lfs/objects`;
57+ const auth = c.req.header("authorization");
58+ const objects = [];
59+ for (const o of req.objects.slice(0, 1000)) {
60+ if (!/^[0-9a-f]{64}$/.test(o.oid)) {
61+ objects.push({ oid: o.oid, size: o.size, error: { code: 422, message: "invalid oid" } });
62+ continue;
63+ }
64+ const head = await c.env.BUCKET.head(lfsKey(repo.id, o.oid));
65+ if (req.operation === "download") {
66+ if (!head) objects.push({ oid: o.oid, size: o.size, error: { code: 404, message: "object not found" } });
67+ else objects.push({ oid: o.oid, size: head.size, authenticated: true, actions: { download: { href: `${base}/${o.oid}`, header: auth ? { Authorization: auth } : {}, expires_in: 3600 } } });
68+ } else {
69+ objects.push(head ? { oid: o.oid, size: o.size, authenticated: true } : { oid: o.oid, size: o.size, authenticated: true, actions: { upload: { href: `${base}/${o.oid}`, header: auth ? { Authorization: auth } : {}, expires_in: 3600 } } });
70+ }
71+ }
72+ return c.body(JSON.stringify({ transfer: "basic", objects, hash_algo: "sha256" }), 200, { "content-type": LFS_TYPE });
73+});
74+
75+git.get("/:owner/:repo/info/lfs/objects/:oid", async (c) => {
76+ const { repo } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), "read");
77+ const obj = await c.env.BUCKET.get(lfsKey(repo.id, c.req.param("oid")));
78+ if (!obj) throw new HttpError(404, "object not found");
79+ return new Response(obj.body, { headers: { "content-type": "application/octet-stream", "content-length": String(obj.size) } });
80+});
81+
82+git.put("/:owner/:repo/info/lfs/objects/:oid", async (c) => {
83+ const { repo } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), "write");
84+ const oid = c.req.param("oid");
85+ if (!/^[0-9a-f]{64}$/.test(oid)) throw new HttpError(422, "invalid oid");
86+ // Verify the content hash while storing: R2 checks the SHA-256 we pass.
87+ const body = await c.req.arrayBuffer();
88+ const digest = [...new Uint8Array(await crypto.subtle.digest("SHA-256", body))].map((b) => b.toString(16).padStart(2, "0")).join("");
89+ if (digest !== oid) throw new HttpError(422, "content does not match oid");
90+ await c.env.BUCKET.put(lfsKey(repo.id, oid), body);
91+ return c.body(null, 200);
92+});
addedapps/diffinery/src/http/runner.ts+134−0View file
@@ -1,0 +1,134 @@
1+import { Hono } from "hono";
2+import type { JobCompletion, LogLine, StepReport } from "@diffinery/shared";
3+import type { Env } from "../env.js";
4+import type { AppContext, AppVars } from "../auth/principal.js";
5+import { credentialFrom } from "../auth/principal.js";
6+import { signEs256 } from "../lib/jwt.js";
7+import { HttpError } from "../lib/util.js";
8+import { repoStub } from "./common.js";
9+
10+/** Endpoints the runner agent inside a job container calls. Auth: the job token. */
11+export const runner = new Hono<{ Bindings: Env; Variables: AppVars }>();
12+
13+function jobAuth(req: Request, jobId: string): { token: string; repoId: string } {
14+ const token = credentialFrom(req);
15+ if (!token?.startsWith("djt_")) throw new HttpError(401, "job token required");
16+ const [, repoId, tokJob] = token.split("_");
17+ if (!repoId || tokJob !== jobId) throw new HttpError(401, "token does not match job");
18+ return { token, repoId };
19+}
20+
21+runner.get("/runner/jobs/:jobId", async (c) => {
22+ const jobId = c.req.param("jobId");
23+ const { token, repoId } = jobAuth(c.req.raw, jobId);
24+ return c.json(await repoStub(c.env, repoId).runnerSpec(jobId, token));
25+});
26+
27+runner.post("/runner/jobs/:jobId/logs", async (c) => {
28+ const jobId = c.req.param("jobId");
29+ const { token, repoId } = jobAuth(c.req.raw, jobId);
30+ const { lines } = (await c.req.json()) as { lines: LogLine[] };
31+ await repoStub(c.env, repoId).runnerLogs(jobId, token, lines ?? []);
32+ return c.json({ ok: true });
33+});
34+
35+runner.post("/runner/jobs/:jobId/steps/:index", async (c) => {
36+ const jobId = c.req.param("jobId");
37+ const { token, repoId } = jobAuth(c.req.raw, jobId);
38+ await repoStub(c.env, repoId).runnerStep(jobId, token, Number(c.req.param("index")), (await c.req.json()) as StepReport);
39+ return c.json({ ok: true });
40+});
41+
42+runner.post("/runner/jobs/:jobId/complete", async (c) => {
43+ const jobId = c.req.param("jobId");
44+ const { token, repoId } = jobAuth(c.req.raw, jobId);
45+ await repoStub(c.env, repoId).runnerComplete(jobId, token, (await c.req.json()) as JobCompletion);
46+ return c.json({ ok: true });
47+});
48+
49+// ---- cache (actions/cache compatible semantics, backed by R2)
50+
51+runner.get("/runner/jobs/:jobId/cache", async (c) => {
52+ const jobId = c.req.param("jobId");
53+ const { token, repoId } = jobAuth(c.req.raw, jobId);
54+ const key = c.req.query("key") ?? "";
55+ const restore = (c.req.query("restore") ?? "").split(",").map((s) => s.trim()).filter(Boolean);
56+ const hit = await repoStub(c.env, repoId).cacheLookup(jobId, token, key, restore);
57+ if (!hit) return c.json({ message: "cache miss" }, 404);
58+ return c.json({ key: hit.key, url: `${new URL(c.req.url).origin}/runner/jobs/${jobId}/cache/blob/${encodeURIComponent(hit.key)}` });
59+});
60+
61+runner.get("/runner/jobs/:jobId/cache/blob/:key", async (c) => {
62+ const jobId = c.req.param("jobId");
63+ const { repoId } = jobAuth(c.req.raw, jobId);
64+ const r2Key = await repoStub(c.env, repoId).cacheKeyFor(c.req.param("key"));
65+ const obj = await c.env.BUCKET.get(r2Key);
66+ if (!obj) throw new HttpError(404, "cache entry missing");
67+ return new Response(obj.body, { headers: { "content-type": "application/gzip", "content-length": String(obj.size) } });
68+});
69+
70+runner.put("/runner/jobs/:jobId/cache/:key", async (c) => {
71+ const jobId = c.req.param("jobId");
72+ const { token, repoId } = jobAuth(c.req.raw, jobId);
73+ const key = c.req.param("key");
74+ if (key.length > 512) throw new HttpError(422, "cache key too long");
75+ const stub = repoStub(c.env, repoId);
76+ const r2Key = await stub.cacheKeyFor(key);
77+ if (await c.env.BUCKET.head(r2Key)) return c.json({ message: "cache entry already exists" }, 409);
78+ const put = await c.env.BUCKET.put(r2Key, c.req.raw.body!, { httpMetadata: { contentType: "application/gzip" } });
79+ const evicted = await stub.cacheSaved(jobId, token, key, put?.size ?? 0);
80+ if (evicted.length) await c.env.BUCKET.delete(evicted);
81+ return c.json({ ok: true });
82+});
83+
84+// ---- artifacts
85+
86+runner.put("/runner/jobs/:jobId/artifacts/:name", async (c) => {
87+ const jobId = c.req.param("jobId");
88+ const { token, repoId } = jobAuth(c.req.raw, jobId);
89+ const stub = repoStub(c.env, repoId);
90+ const r2Key = await stub.artifactKeyFor(jobId, c.req.param("name"));
91+ const put = await c.env.BUCKET.put(r2Key, c.req.raw.body!, { httpMetadata: { contentType: "application/gzip" } });
92+ await stub.artifactSaved(jobId, token, c.req.param("name"), put?.size ?? 0);
93+ return c.json({ ok: true });
94+});
95+
96+runner.get("/runner/jobs/:jobId/artifacts/:name", async (c) => {
97+ const jobId = c.req.param("jobId");
98+ const { repoId } = jobAuth(c.req.raw, jobId);
99+ const obj = await c.env.BUCKET.get(await repoStub(c.env, repoId).artifactKeyFor(jobId, c.req.param("name")));
100+ if (!obj) throw new HttpError(404, "artifact not found");
101+ return new Response(obj.body, { headers: { "content-type": "application/gzip" } });
102+});
103+
104+// ---- OIDC id tokens (actions/core getIDToken compatible: GET ?audience= → { value })
105+
106+const oidc = async (c: AppContext) => {
107+ const jobId = c.req.param("jobId")!;
108+ const { token, repoId } = jobAuth(c.req.raw, jobId);
109+ if (!c.env.OIDC_PRIVATE_KEY) throw new HttpError(501, "OIDC is not configured on this Diffinery instance");
110+ const claims = await repoStub(c.env, repoId).oidcClaims(jobId, token);
111+ if (!claims.allowed) throw new HttpError(403, "this job lacks permissions: id-token: write");
112+ const g = claims.github;
113+ const nowS = Math.floor(Date.now() / 1000);
114+ const value = await signEs256(JSON.parse(c.env.OIDC_PRIVATE_KEY), {
115+ iss: c.env.PUBLIC_URL,
116+ aud: c.req.query("audience") ?? c.env.PUBLIC_URL,
117+ sub: `repo:${g.repository}:ref:${g.ref}`,
118+ iat: nowS,
119+ nbf: nowS,
120+ exp: nowS + 600,
121+ repository: g.repository,
122+ repository_owner: g.repository_owner,
123+ ref: g.ref,
124+ sha: g.sha,
125+ workflow: g.workflow,
126+ event_name: g.event_name,
127+ run_id: g.run_id,
128+ actor: g.actor,
129+ job_workflow_ref: g.workflow_ref,
130+ });
131+ return c.json({ value });
132+};
133+runner.get("/runner/jobs/:jobId/oidc", oidc);
134+runner.post("/runner/jobs/:jobId/oidc", oidc);
addedapps/diffinery/src/index.ts+17−0View file
@@ -1,0 +1,17 @@
1+import type { DiffineryEvent } from "@diffinery/shared";
2+import type { Env } from "./env.js";
3+import { app, queue, scheduled } from "./app.js";
4+import { web } from "./web/routes.js";
5+
6+export { RepoDO } from "./repo/RepoDO.js";
7+export { RunnerContainer, SandboxContainer } from "./runner/containers.js";
8+export { RunWorkflow } from "./runner/workflow.js";
9+
10+// The web UI is mounted last so API, git and runner routes take precedence.
11+app.route("/", web);
12+
13+export default {
14+ fetch: app.fetch,
15+ queue,
16+ scheduled,
17+} satisfies ExportedHandler<Env, DiffineryEvent>;
addedapps/diffinery/src/lib/crypto.ts+34−0View file
@@ -1,0 +1,34 @@
1+import { b64, fromB64 } from "./util.js";
2+
3+/**
4+ * Envelope encryption for secrets. Each scope (repo or org) gets its own AES-GCM
5+ * key derived from MASTER_KEY with HKDF, so a leaked ciphertext from one repo
6+ * cannot be replayed into another.
7+ */
8+async function scopeKey(masterKeyB64: string, scope: string): Promise<CryptoKey> {
9+ const raw = await crypto.subtle.importKey("raw", fromB64(masterKeyB64) as BufferSource, "HKDF", false, ["deriveKey"]);
10+ return crypto.subtle.deriveKey(
11+ { name: "HKDF", hash: "SHA-256", salt: new TextEncoder().encode("diffinery-secrets-v1"), info: new TextEncoder().encode(scope) },
12+ raw,
13+ { name: "AES-GCM", length: 256 },
14+ false,
15+ ["encrypt", "decrypt"],
16+ );
17+}
18+
19+export async function encryptSecret(masterKey: string | undefined, scope: string, plaintext: string): Promise<string> {
20+ if (!masterKey) throw new Error("MASTER_KEY is not configured");
21+ const iv = crypto.getRandomValues(new Uint8Array(12));
22+ const key = await scopeKey(masterKey, scope);
23+ const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: new TextEncoder().encode(scope) }, key, new TextEncoder().encode(plaintext)));
24+ return `v1.${b64(iv)}.${b64(ct)}`;
25+}
26+
27+export async function decryptSecret(masterKey: string | undefined, scope: string, envelope: string): Promise<string> {
28+ if (!masterKey) throw new Error("MASTER_KEY is not configured");
29+ const [v, ivB, ctB] = envelope.split(".");
30+ if (v !== "v1" || !ivB || !ctB) throw new Error("bad secret envelope");
31+ const key = await scopeKey(masterKey, scope);
32+ const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromB64(ivB) as BufferSource, additionalData: new TextEncoder().encode(scope) }, key, fromB64(ctB) as BufferSource);
33+ return new TextDecoder().decode(pt);
34+}
addedapps/diffinery/src/lib/directory.ts+403−0View file
@@ -1,0 +1,403 @@
1+import type { Env } from "../env.js";
2+import type { RepoMeta, Role } from "../repo/types.js";
3+import { ROLE_LEVEL } from "../repo/types.js";
4+import { HttpError, RESERVED_LOGINS, isValidLogin, isValidName, newId, now, randomToken, sha256 } from "./util.js";
5+
6+export interface Account {
7+ id: string;
8+ login: string;
9+ kind: "user" | "org" | "agent";
10+ name: string | null;
11+ email: string | null;
12+ status: "active" | "pending" | "disabled";
13+ site_admin: number;
14+ owner_id: string | null;
15+ agent_policy: string | null;
16+ created_at: string;
17+}
18+
19+export interface Repo {
20+ id: string;
21+ owner_id: string;
22+ owner_login: string;
23+ owner_kind: "user" | "org" | "agent";
24+ name: string;
25+ description: string;
26+ visibility: "private" | "internal" | "public";
27+ default_branch: string;
28+ archived: number;
29+ created_at: string;
30+ updated_at: string;
31+ pushed_at: string | null;
32+}
33+
34+export type TokenScope = "read" | "write" | "admin";
35+
36+/** Data access for the global directory in D1. */
37+export class Directory {
38+ constructor(private env: Env) {}
39+
40+ private get db() {
41+ return this.env.DB;
42+ }
43+
44+ // ---------------------------------------------------------------- accounts
45+
46+ async account(id: string): Promise<Account | null> {
47+ return this.db.prepare("SELECT * FROM accounts WHERE id = ?").bind(id).first<Account>();
48+ }
49+
50+ async accountByLogin(login: string): Promise<Account | null> {
51+ return this.db.prepare("SELECT * FROM accounts WHERE login = ?").bind(login).first<Account>();
52+ }
53+
54+ async accountByEmail(email: string): Promise<Account | null> {
55+ return this.db.prepare("SELECT * FROM accounts WHERE email = ? AND kind = 'user'").bind(email).first<Account>();
56+ }
57+
58+ private isBootstrapAdmin(email: string, login?: string): boolean {
59+ const list = (this.env.BOOTSTRAP_ADMINS ?? "").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
60+ return list.includes(email.toLowerCase()) || (!!login && list.includes(login.toLowerCase()));
61+ }
62+
63+ private async uniqueLogin(base: string): Promise<string> {
64+ let candidate = base.replace(/[^A-Za-z0-9-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 39) || "user";
65+ for (let i = 0; i < 50; i++) {
66+ const tryLogin = i === 0 ? candidate : `${candidate}-${i + 1}`;
67+ if (!RESERVED_LOGINS.has(tryLogin.toLowerCase()) && !(await this.accountByLogin(tryLogin))) return tryLogin;
68+ }
69+ candidate = `${candidate}-${randomToken(3)}`;
70+ return candidate;
71+ }
72+
73+ /** Find or create the user behind a verified email (Access recovery, invite links, dev sign-in). */
74+ async userForEmail(email: string, hints: { name?: string; login?: string; id?: string } = {}): Promise<Account> {
75+ const existing = await this.accountByEmail(email);
76+ if (existing) {
77+ if (existing.status === "pending" && this.isBootstrapAdmin(email, existing.login)) {
78+ await this.db.prepare("UPDATE accounts SET status = 'active', site_admin = 1 WHERE id = ?").bind(existing.id).run();
79+ return { ...existing, status: "active", site_admin: 1 };
80+ }
81+ return existing;
82+ }
83+ const admin = this.isBootstrapAdmin(email, hints.login);
84+ const login = await this.uniqueLogin(hints.login ?? email.split("@")[0]!);
85+ const id = hints.id ?? newId();
86+ const hasInvite = await this.db.prepare("SELECT 1 FROM invites WHERE email = ? AND accepted_at IS NULL AND expires_at > ?").bind(email, now()).first();
87+ await this.db
88+ .prepare("INSERT INTO accounts (id, login, kind, name, email, status, site_admin, created_at) VALUES (?, ?, 'user', ?, ?, ?, ?, ?)")
89+ .bind(id, login, hints.name ?? null, email, admin || hasInvite ? "active" : "pending", admin ? 1 : 0, now())
90+ .run();
91+ if (hasInvite) await this.acceptInvitesForEmail(id, email);
92+ return (await this.account(id))!;
93+ }
94+
95+ async updateAccount(id: string, patch: { login?: string; name?: string }): Promise<void> {
96+ if (patch.login && !isValidLogin(patch.login)) throw new HttpError(422, "invalid or reserved login");
97+ if (patch.login) {
98+ const clash = await this.accountByLogin(patch.login);
99+ if (clash && clash.id !== id) throw new HttpError(422, "login already taken");
100+ }
101+ await this.db.prepare("UPDATE accounts SET login = COALESCE(?, login), name = COALESCE(?, name) WHERE id = ?").bind(patch.login ?? null, patch.name ?? null, id).run();
102+ if (patch.login) {
103+ // Repos keep their ids; their Durable Objects cache the owner login, so refresh them.
104+ const repos = await this.db.prepare("SELECT id FROM repos WHERE owner_id = ?").bind(id).all<{ id: string }>();
105+ for (const r of repos.results) await this.env.REPO.get(this.env.REPO.idFromName(r.id)).updateMeta({ owner: patch.login });
106+ }
107+ }
108+
109+ async createOrg(login: string, name: string, creator: Account): Promise<Account> {
110+ if (!isValidLogin(login)) throw new HttpError(422, "invalid or reserved organization name");
111+ if (await this.accountByLogin(login)) throw new HttpError(422, "name already taken");
112+ const id = newId();
113+ const t = now();
114+ await this.db.batch([
115+ this.db.prepare("INSERT INTO accounts (id, login, kind, name, status, created_at) VALUES (?, ?, 'org', ?, 'active', ?)").bind(id, login, name || login, t),
116+ this.db.prepare("INSERT INTO org_members (org_id, user_id, role, created_at) VALUES (?, ?, 'owner', ?)").bind(id, creator.id, t),
117+ ]);
118+ return (await this.account(id))!;
119+ }
120+
121+ async orgRole(orgId: string, userId: string): Promise<"owner" | "admin" | "member" | null> {
122+ const r = await this.db.prepare("SELECT role FROM org_members WHERE org_id = ? AND user_id = ?").bind(orgId, userId).first<{ role: "owner" | "admin" | "member" }>();
123+ return r?.role ?? null;
124+ }
125+
126+ async orgsFor(userId: string): Promise<Array<Account & { role: string }>> {
127+ const r = await this.db.prepare("SELECT a.*, m.role FROM org_members m JOIN accounts a ON a.id = m.org_id WHERE m.user_id = ? ORDER BY a.login").bind(userId).all<Account & { role: string }>();
128+ return r.results;
129+ }
130+
131+ async orgMembers(orgId: string): Promise<Array<Account & { role: string }>> {
132+ const r = await this.db.prepare("SELECT a.*, m.role FROM org_members m JOIN accounts a ON a.id = m.user_id WHERE m.org_id = ? ORDER BY a.login").bind(orgId).all<Account & { role: string }>();
133+ return r.results;
134+ }
135+
136+ async setOrgMember(orgId: string, userId: string, role: "owner" | "admin" | "member" | null): Promise<void> {
137+ if (role === null) {
138+ const owners = await this.db.prepare("SELECT count(*) AS n FROM org_members WHERE org_id = ? AND role = 'owner' AND user_id != ?").bind(orgId, userId).first<{ n: number }>();
139+ if ((await this.orgRole(orgId, userId)) === "owner" && !owners?.n) throw new HttpError(422, "an organization needs at least one owner");
140+ await this.db.prepare("DELETE FROM org_members WHERE org_id = ? AND user_id = ?").bind(orgId, userId).run();
141+ // Removing someone revokes their tokens' reach immediately: roles are computed per request.
142+ return;
143+ }
144+ await this.db.prepare("INSERT INTO org_members (org_id, user_id, role, created_at) VALUES (?, ?, ?, ?) ON CONFLICT(org_id, user_id) DO UPDATE SET role = excluded.role").bind(orgId, userId, role, now()).run();
145+ }
146+
147+ async users(): Promise<Account[]> {
148+ return (await this.db.prepare("SELECT * FROM accounts WHERE kind = 'user' ORDER BY login").all<Account>()).results;
149+ }
150+
151+ async setStatus(id: string, status: Account["status"]): Promise<void> {
152+ await this.db.prepare("UPDATE accounts SET status = ? WHERE id = ?").bind(status, id).run();
153+ if (status === "disabled") await this.db.prepare("UPDATE tokens SET revoked_at = ? WHERE account_id = ? AND revoked_at IS NULL").bind(now(), id).run();
154+ }
155+
156+ // ---------------------------------------------------------------- agents
157+
158+ async createAgent(owner: Account, input: { login: string; name?: string; orgId?: string; policy?: Record<string, unknown> }): Promise<Account> {
159+ if (!isValidLogin(input.login)) throw new HttpError(422, "invalid or reserved agent name");
160+ const login = input.login.endsWith("[bot]") ? input.login : `${input.login}`;
161+ if (await this.accountByLogin(login)) throw new HttpError(422, "name already taken");
162+ const id = newId();
163+ const ownerId = input.orgId ?? owner.id;
164+ if (input.orgId) {
165+ const role = await this.orgRole(input.orgId, owner.id);
166+ if (role !== "owner" && role !== "admin") throw new HttpError(403, "only organization admins can create organization agents");
167+ }
168+ const policy = { maxRole: "write", canMergeProtected: false, budgetUsdMonthly: null, ...input.policy };
169+ await this.db
170+ .prepare("INSERT INTO accounts (id, login, kind, name, status, owner_id, agent_policy, created_at) VALUES (?, ?, 'agent', ?, 'active', ?, ?, ?)")
171+ .bind(id, login, input.name ?? login, ownerId, JSON.stringify(policy), now())
172+ .run();
173+ return (await this.account(id))!;
174+ }
175+
176+ async agentsOwnedBy(ownerIds: string[]): Promise<Account[]> {
177+ if (!ownerIds.length) return [];
178+ const q = `SELECT * FROM accounts WHERE kind = 'agent' AND owner_id IN (${ownerIds.map(() => "?").join(",")}) ORDER BY login`;
179+ return (await this.db.prepare(q).bind(...ownerIds).all<Account>()).results;
180+ }
181+
182+ // ---------------------------------------------------------------- tokens
183+
184+ async createToken(account: Account, input: { name: string; scopes?: TokenScope[]; repoIds?: string[] | null; expiresInDays?: number }): Promise<{ token: string; id: string; expiresAt: string }> {
185+ const days = Math.min(Math.max(input.expiresInDays ?? 90, 1), 366);
186+ const token = `dfy_${randomToken(30)}`;
187+ const id = newId();
188+ const expiresAt = new Date(Date.now() + days * 86400_000).toISOString();
189+ await this.db
190+ .prepare("INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, repo_ids, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
191+ .bind(id, account.id, input.name || "token", await sha256(token), token.slice(0, 10), JSON.stringify(input.scopes?.length ? input.scopes : ["write"]), input.repoIds ? JSON.stringify(input.repoIds) : null, expiresAt, now())
192+ .run();
193+ return { token, id, expiresAt };
194+ }
195+
196+ async tokens(accountId: string): Promise<Array<{ id: string; name: string; prefix: string; scopes: string[]; repoIds: string[] | null; expiresAt: string; lastUsedAt: string | null; createdAt: string }>> {
197+ const r = await this.db.prepare("SELECT * FROM tokens WHERE account_id = ? AND revoked_at IS NULL ORDER BY created_at DESC").bind(accountId).all<Record<string, string | null>>();
198+ return r.results.map((t) => ({ id: t.id!, name: t.name!, prefix: t.prefix!, scopes: JSON.parse(t.scopes!), repoIds: t.repo_ids ? JSON.parse(t.repo_ids) : null, expiresAt: t.expires_at!, lastUsedAt: t.last_used_at ?? null, createdAt: t.created_at! }));
199+ }
200+
201+ async revokeToken(accountId: string, id: string): Promise<void> {
202+ await this.db.prepare("UPDATE tokens SET revoked_at = ? WHERE id = ? AND account_id = ?").bind(now(), id, accountId).run();
203+ }
204+
205+ async verifyToken(token: string): Promise<{ account: Account; scopes: TokenScope[]; repoIds: string[] | null } | null> {
206+ const row = await this.db.prepare("SELECT * FROM tokens WHERE token_hash = ?").bind(await sha256(token)).first<Record<string, string | null>>();
207+ if (!row || row.revoked_at || row.expires_at! < now()) return null;
208+ const account = await this.account(row.account_id!);
209+ if (!account || account.status !== "active") return null;
210+ if (!row.last_used_at || Date.now() - Date.parse(row.last_used_at) > 3600_000) {
211+ await this.db.prepare("UPDATE tokens SET last_used_at = ? WHERE id = ?").bind(now(), row.id!).run();
212+ }
213+ return { account, scopes: JSON.parse(row.scopes!), repoIds: row.repo_ids ? JSON.parse(row.repo_ids) : null };
214+ }
215+
216+ // ---------------------------------------------------------------- invites
217+
218+ async createInvite(inviter: Account, input: { email: string; orgId?: string; repoId?: string; role: string }): Promise<{ id: string; url: string; token: string }> {
219+ const token = randomToken(24);
220+ const id = newId();
221+ await this.db
222+ .prepare("INSERT INTO invites (id, org_id, repo_id, email, role, token_hash, invited_by, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
223+ .bind(id, input.orgId ?? null, input.repoId ?? null, input.email, input.role, await sha256(token), inviter.id, now(), new Date(Date.now() + 14 * 86400_000).toISOString())
224+ .run();
225+ // If the person already exists, activate them now so the invite also works from the dashboard.
226+ const existing = await this.accountByEmail(input.email);
227+ if (existing?.status === "pending") await this.setStatus(existing.id, "active");
228+ return { id, url: `${this.env.PUBLIC_URL}/invite/${token}`, token };
229+ }
230+
231+ async invites(filter: { orgId?: string }): Promise<Array<{ id: string; email: string; role: string; createdAt: string; expiresAt: string; acceptedAt: string | null }>> {
232+ const r = await this.db.prepare("SELECT * FROM invites WHERE (? IS NULL OR org_id = ?) ORDER BY created_at DESC LIMIT 200").bind(filter.orgId ?? null, filter.orgId ?? null).all<Record<string, string | null>>();
233+ return r.results.map((i) => ({ id: i.id!, email: i.email!, role: i.role!, createdAt: i.created_at!, expiresAt: i.expires_at!, acceptedAt: i.accepted_at ?? null }));
234+ }
235+
236+ async acceptInvite(token: string, user: Account): Promise<{ orgId: string | null; repoId: string | null }> {
237+ const inv = await this.db.prepare("SELECT * FROM invites WHERE token_hash = ?").bind(await sha256(token)).first<Record<string, string | null>>();
238+ if (!inv || inv.accepted_at || inv.expires_at! < now()) throw new HttpError(404, "this invite is invalid or has expired");
239+ if (inv.email && user.email && inv.email.toLowerCase() !== user.email.toLowerCase()) throw new HttpError(403, `this invite was sent to ${inv.email}; sign in with that address`);
240+ await this.applyInvite(inv, user);
241+ return { orgId: inv.org_id ?? null, repoId: inv.repo_id ?? null };
242+ }
243+
244+ private async acceptInvitesForEmail(userId: string, email: string): Promise<void> {
245+ const user = (await this.account(userId))!;
246+ const r = await this.db.prepare("SELECT * FROM invites WHERE email = ? AND accepted_at IS NULL AND expires_at > ?").bind(email, now()).all<Record<string, string | null>>();
247+ for (const inv of r.results) await this.applyInvite(inv, user);
248+ }
249+
250+ private async applyInvite(inv: Record<string, string | null>, user: Account): Promise<void> {
251+ const stmts = [this.db.prepare("UPDATE invites SET accepted_at = ?, accepted_by = ? WHERE id = ?").bind(now(), user.id, inv.id!), this.db.prepare("UPDATE accounts SET status = 'active' WHERE id = ? AND status = 'pending'").bind(user.id)];
252+ if (inv.org_id) stmts.push(this.db.prepare("INSERT INTO org_members (org_id, user_id, role, created_at) VALUES (?, ?, ?, ?) ON CONFLICT(org_id, user_id) DO NOTHING").bind(inv.org_id, user.id, inv.role === "admin" || inv.role === "owner" ? inv.role : "member", now()));
253+ if (inv.repo_id) stmts.push(this.db.prepare("INSERT OR REPLACE INTO repo_access (repo_id, account_id, role) VALUES (?, ?, ?)").bind(inv.repo_id, user.id, inv.role));
254+ await this.db.batch(stmts);
255+ }
256+
257+ async requestAccess(accountId: string, note: string): Promise<void> {
258+ await this.db.prepare("INSERT OR REPLACE INTO access_requests (account_id, note, created_at) VALUES (?, ?, ?)").bind(accountId, note.slice(0, 500), now()).run();
259+ }
260+
261+ async accessRequests(): Promise<Array<{ account: Account; note: string; createdAt: string }>> {
262+ const r = await this.db.prepare("SELECT a.*, r.note AS req_note, r.created_at AS req_at FROM access_requests r JOIN accounts a ON a.id = r.account_id WHERE a.status = 'pending' ORDER BY r.created_at").all<Account & { req_note: string; req_at: string }>();
263+ return r.results.map((x) => ({ account: x, note: x.req_note, createdAt: x.req_at }));
264+ }
265+
266+ // ---------------------------------------------------------------- repositories
267+
268+ async repo(owner: string, name: string): Promise<Repo | null> {
269+ return this.db
270+ .prepare("SELECT r.*, a.login AS owner_login, a.kind AS owner_kind FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE a.login = ? AND r.name = ?")
271+ .bind(owner, name.replace(/\.git$/, ""))
272+ .first<Repo>();
273+ }
274+
275+ async repoById(id: string): Promise<Repo | null> {
276+ return this.db.prepare("SELECT r.*, a.login AS owner_login, a.kind AS owner_kind FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE r.id = ?").bind(id).first<Repo>();
277+ }
278+
279+ /** Repositories an account can see. */
280+ async reposVisibleTo(account: Account, ownerId?: string): Promise<Repo[]> {
281+ const params: unknown[] = [];
282+ let q = "SELECT r.*, a.login AS owner_login, a.kind AS owner_kind FROM repos r JOIN accounts a ON a.id = r.owner_id";
283+ const subjectId = account.kind === "agent" ? account.owner_id! : account.id;
284+ if (!account.site_admin) {
285+ q += ` WHERE (r.visibility IN ('internal', 'public') OR r.owner_id = ? OR r.owner_id IN (SELECT org_id FROM org_members WHERE user_id = ?) OR r.id IN (SELECT repo_id FROM repo_access WHERE account_id IN (?, ?)))`;
286+ params.push(subjectId, subjectId, account.id, subjectId);
287+ } else q += " WHERE 1 = 1";
288+ if (ownerId) {
289+ q += " AND r.owner_id = ?";
290+ params.push(ownerId);
291+ }
292+ q += " ORDER BY COALESCE(r.pushed_at, r.updated_at) DESC LIMIT 500";
293+ return (await this.db.prepare(q).bind(...params).all<Repo>()).results;
294+ }
295+
296+ async createRepo(owner: Account, creator: Account, input: { name: string; description?: string; visibility?: Repo["visibility"]; defaultBranch?: string }): Promise<Repo> {
297+ if (!isValidName(input.name)) throw new HttpError(422, "invalid repository name: use letters, numbers, '.', '-' and '_'");
298+ if (owner.kind === "org") {
299+ const role = await this.orgRole(owner.id, creator.kind === "agent" ? creator.owner_id! : creator.id);
300+ if (!role && !creator.site_admin) throw new HttpError(403, "you are not a member of this organization");
301+ } else if (owner.id !== creator.id && !creator.site_admin) throw new HttpError(403, "you can only create repositories for yourself or your organizations");
302+ if (await this.repo(owner.login, input.name)) throw new HttpError(422, "a repository with this name already exists");
303+ const id = newId();
304+ const t = now();
305+ const branch = input.defaultBranch || "main";
306+ await this.db
307+ .prepare("INSERT INTO repos (id, owner_id, name, description, visibility, default_branch, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
308+ .bind(id, owner.id, input.name, input.description ?? "", input.visibility ?? "private", branch, t, t)
309+ .run();
310+ const meta: RepoMeta = { repoId: id, ownerId: owner.id, owner: owner.login, name: input.name, defaultBranch: branch, ownerKind: owner.kind === "org" ? "org" : "user" };
311+ await this.env.REPO.get(this.env.REPO.idFromName(id)).init(meta);
312+ return (await this.repoById(id))!;
313+ }
314+
315+ async updateRepo(repo: Repo, patch: { name?: string; description?: string; visibility?: Repo["visibility"]; defaultBranch?: string; archived?: boolean }): Promise<Repo> {
316+ if (patch.name && !isValidName(patch.name)) throw new HttpError(422, "invalid repository name");
317+ await this.db
318+ .prepare("UPDATE repos SET name = COALESCE(?, name), description = COALESCE(?, description), visibility = COALESCE(?, visibility), default_branch = COALESCE(?, default_branch), archived = COALESCE(?, archived), updated_at = ? WHERE id = ?")
319+ .bind(patch.name ?? null, patch.description ?? null, patch.visibility ?? null, patch.defaultBranch ?? null, patch.archived === undefined ? null : patch.archived ? 1 : 0, now(), repo.id)
320+ .run();
321+ if (patch.name || patch.defaultBranch) await this.env.REPO.get(this.env.REPO.idFromName(repo.id)).updateMeta({ name: patch.name ?? repo.name, defaultBranch: patch.defaultBranch ?? repo.default_branch });
322+ return (await this.repoById(repo.id))!;
323+ }
324+
325+ async deleteRepo(repo: Repo): Promise<void> {
326+ await this.db.batch([this.db.prepare("DELETE FROM repo_access WHERE repo_id = ?").bind(repo.id), this.db.prepare("DELETE FROM schedules WHERE repo_id = ?").bind(repo.id), this.db.prepare("DELETE FROM repos WHERE id = ?").bind(repo.id)]);
327+ // R2 objects under repos/<id>/ are removed by the cleanup job; the DO's storage is wiped here.
328+ await this.env.REPO.get(this.env.REPO.idFromName(repo.id)).destroy();
329+ }
330+
331+ async touchPushed(repoId: string): Promise<void> {
332+ await this.db.prepare("UPDATE repos SET pushed_at = ?, updated_at = ? WHERE id = ?").bind(now(), now(), repoId).run();
333+ }
334+
335+ async collaborators(repoId: string): Promise<Array<{ account: Account; role: Role }>> {
336+ const r = await this.db.prepare("SELECT a.*, x.role AS access_role FROM repo_access x JOIN accounts a ON a.id = x.account_id WHERE x.repo_id = ? ORDER BY a.login").bind(repoId).all<Account & { access_role: Role }>();
337+ return r.results.map((x) => ({ account: x, role: x.access_role }));
338+ }
339+
340+ async setCollaborator(repoId: string, accountId: string, role: Role | null): Promise<void> {
341+ if (role) await this.db.prepare("INSERT OR REPLACE INTO repo_access (repo_id, account_id, role) VALUES (?, ?, ?)").bind(repoId, accountId, role).run();
342+ else await this.db.prepare("DELETE FROM repo_access WHERE repo_id = ? AND account_id = ?").bind(repoId, accountId).run();
343+ }
344+
345+ /** Effective role of an account on a repository (null = no access). */
346+ async roleFor(account: Account, repo: Repo): Promise<Role | null> {
347+ if (account.status !== "active") return null;
348+ if (account.kind === "agent") {
349+ if (!account.owner_id) return null;
350+ const owner = await this.account(account.owner_id);
351+ if (!owner || owner.status !== "active") return null;
352+ const ownerRole = owner.kind === "org" ? (repo.owner_id === owner.id ? "admin" : null) : await this.roleFor(owner, repo);
353+ if (!ownerRole) return null;
354+ const explicit = await this.db.prepare("SELECT role FROM repo_access WHERE repo_id = ? AND account_id = ?").bind(repo.id, account.id).first<{ role: Role }>();
355+ const policy = account.agent_policy ? (JSON.parse(account.agent_policy) as { maxRole?: Role }) : {};
356+ const cap: Role = explicit?.role ?? policy.maxRole ?? "write";
357+ return ROLE_LEVEL[ownerRole] < ROLE_LEVEL[cap] ? ownerRole : cap;
358+ }
359+ if (account.site_admin) return "admin";
360+ let best: Role | null = null;
361+ const bump = (r: Role | null) => {
362+ if (r && (!best || ROLE_LEVEL[r] > ROLE_LEVEL[best])) best = r;
363+ };
364+ if (repo.owner_id === account.id) bump("admin");
365+ if (repo.owner_kind === "org") {
366+ const orgRole = await this.orgRole(repo.owner_id, account.id);
367+ if (orgRole === "owner" || orgRole === "admin") bump("admin");
368+ else if (orgRole === "member") bump("write");
369+ }
370+ const explicit = await this.db.prepare("SELECT role FROM repo_access WHERE repo_id = ? AND account_id = ?").bind(repo.id, account.id).first<{ role: Role }>();
371+ bump(explicit?.role ?? null);
372+ if (repo.visibility !== "private") bump("read");
373+ return best;
374+ }
375+
376+ // ---------------------------------------------------------------- notifications and audit
377+
378+ async notify(accountIds: string[], n: { repoId?: string; kind: string; title: string; url: string }): Promise<void> {
379+ const unique = [...new Set(accountIds)];
380+ if (!unique.length) return;
381+ await this.db.batch(unique.map((id) => this.db.prepare("INSERT INTO notifications (id, account_id, repo_id, kind, title, url, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)").bind(newId(), id, n.repoId ?? null, n.kind, n.title, n.url, now())));
382+ }
383+
384+ async notifications(accountId: string, unreadOnly = false): Promise<Array<{ id: string; kind: string; title: string; url: string; createdAt: string; readAt: string | null }>> {
385+ const r = await this.db.prepare(`SELECT * FROM notifications WHERE account_id = ? ${unreadOnly ? "AND read_at IS NULL" : ""} ORDER BY created_at DESC LIMIT 100`).bind(accountId).all<Record<string, string | null>>();
386+ return r.results.map((n) => ({ id: n.id!, kind: n.kind!, title: n.title!, url: n.url!, createdAt: n.created_at!, readAt: n.read_at ?? null }));
387+ }
388+
389+ async markRead(accountId: string, id?: string): Promise<void> {
390+ await this.db.prepare(`UPDATE notifications SET read_at = ? WHERE account_id = ? ${id ? "AND id = ?" : ""} AND read_at IS NULL`).bind(...(id ? [now(), accountId, id] : [now(), accountId])).run();
391+ }
392+
393+ async audit(entry: { actorId?: string; actorLogin?: string; onBehalfOf?: string; action: string; target?: string; detail?: unknown; at?: string }): Promise<void> {
394+ await this.db
395+ .prepare("INSERT INTO audit_log (id, at, actor_id, actor_login, on_behalf_of, action, target, detail) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
396+ .bind(newId(), entry.at ?? now(), entry.actorId ?? null, entry.actorLogin ?? null, entry.onBehalfOf ?? null, entry.action, entry.target ?? null, entry.detail ? JSON.stringify(entry.detail) : null)
397+ .run();
398+ }
399+
400+ async auditLog(limit = 200): Promise<Array<Record<string, string | null>>> {
401+ return (await this.db.prepare("SELECT * FROM audit_log ORDER BY at DESC LIMIT ?").bind(limit).all<Record<string, string | null>>()).results;
402+ }
403+}
addedapps/diffinery/src/lib/jwt.ts+50−0View file
@@ -1,0 +1,50 @@
1+import { b64url, fromB64url } from "./util.js";
2+
3+interface Jwk extends JsonWebKey {
4+ kid?: string;
5+}
6+
7+const certCache = new Map<string, { keys: Jwk[]; at: number }>();
8+
9+/** Verify a Cloudflare Access JWT (RS256) and return its claims. */
10+export async function verifyAccessJwt(token: string, teamDomain: string, aud: string): Promise<Record<string, unknown>> {
11+ const [h, p, s] = token.split(".");
12+ if (!h || !p || !s) throw new Error("malformed JWT");
13+ const header = JSON.parse(new TextDecoder().decode(fromB64url(h))) as { kid?: string; alg?: string };
14+ if (header.alg !== "RS256") throw new Error("unexpected alg");
15+ const certsUrl = `https://${teamDomain}/cdn-cgi/access/certs`;
16+ let cached = certCache.get(certsUrl);
17+ if (!cached || Date.now() - cached.at > 10 * 60_000 || !cached.keys.some((k) => k.kid === header.kid)) {
18+ const res = await fetch(certsUrl);
19+ if (!res.ok) throw new Error(`failed to fetch Access certs: ${res.status}`);
20+ cached = { keys: ((await res.json()) as { keys: Jwk[] }).keys, at: Date.now() };
21+ certCache.set(certsUrl, cached);
22+ }
23+ const jwk = cached.keys.find((k) => k.kid === header.kid);
24+ if (!jwk) throw new Error("unknown signing key");
25+ const key = await crypto.subtle.importKey("jwk", jwk, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["verify"]);
26+ const ok = await crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, fromB64url(s) as BufferSource, new TextEncoder().encode(`${h}.${p}`));
27+ if (!ok) throw new Error("bad signature");
28+ const claims = JSON.parse(new TextDecoder().decode(fromB64url(p))) as Record<string, unknown>;
29+ const now = Math.floor(Date.now() / 1000);
30+ if (typeof claims.exp === "number" && claims.exp < now) throw new Error("token expired");
31+ const auds = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
32+ if (!auds.includes(aud)) throw new Error("audience mismatch");
33+ if (claims.iss !== `https://${teamDomain}`) throw new Error("issuer mismatch");
34+ return claims;
35+}
36+
37+/** Sign an ES256 JWT with a private JWK (used for job OIDC tokens). */
38+export async function signEs256(privateJwk: Jwk, claims: Record<string, unknown>): Promise<string> {
39+ const key = await crypto.subtle.importKey("jwk", privateJwk, { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"]);
40+ const enc = (o: unknown) => b64url(new TextEncoder().encode(JSON.stringify(o)));
41+ const head = enc({ alg: "ES256", typ: "JWT", kid: privateJwk.kid ?? "diffinery-1" });
42+ const body = enc(claims);
43+ const sig = new Uint8Array(await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, key, new TextEncoder().encode(`${head}.${body}`)));
44+ return `${head}.${body}.${b64url(sig)}`;
45+}
46+
47+export function publicJwk(privateJwk: Jwk): Jwk {
48+ const { d: _d, key_ops: _ops, ...pub } = privateJwk;
49+ return { ...pub, use: "sig", alg: "ES256", kid: privateJwk.kid ?? "diffinery-1" };
50+}
addedapps/diffinery/src/lib/util.ts+80−0View file
@@ -1,0 +1,80 @@
1+export const now = (): string => new Date().toISOString();
2+
3+export function newId(): string {
4+ return crypto.randomUUID().replace(/-/g, "");
5+}
6+
7+export function randomToken(bytes = 24): string {
8+ const b = crypto.getRandomValues(new Uint8Array(bytes));
9+ return b64url(b);
10+}
11+
12+export function b64url(b: Uint8Array): string {
13+ let s = "";
14+ for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]!);
15+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
16+}
17+
18+export function fromB64url(s: string): Uint8Array {
19+ const pad = s.length % 4 ? "=".repeat(4 - (s.length % 4)) : "";
20+ const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/") + pad);
21+ const out = new Uint8Array(bin.length);
22+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
23+ return out;
24+}
25+
26+export function b64(b: Uint8Array): string {
27+ let s = "";
28+ for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]!);
29+ return btoa(s);
30+}
31+
32+export function fromB64(s: string): Uint8Array {
33+ const bin = atob(s);
34+ const out = new Uint8Array(bin.length);
35+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
36+ return out;
37+}
38+
39+export async function sha256(s: string): Promise<string> {
40+ const d = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s));
41+ return [...new Uint8Array(d)].map((x) => x.toString(16).padStart(2, "0")).join("");
42+}
43+
44+export async function hmacSha256Hex(secret: string, body: string | Uint8Array): Promise<string> {
45+ const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
46+ const data = typeof body === "string" ? new TextEncoder().encode(body) : body;
47+ const sig = await crypto.subtle.sign("HMAC", key, data as BufferSource);
48+ return [...new Uint8Array(sig)].map((x) => x.toString(16).padStart(2, "0")).join("");
49+}
50+
51+/** Constant-time string comparison. */
52+export function safeEqual(a: string, b: string): boolean {
53+ if (a.length !== b.length) return false;
54+ let r = 0;
55+ for (let i = 0; i < a.length; i++) r |= a.charCodeAt(i) ^ b.charCodeAt(i);
56+ return r === 0;
57+}
58+
59+export class HttpError extends Error {
60+ /** The message without the status prefix. */
61+ readonly detail: string;
62+ constructor(public status: number, message: string, public extra?: Record<string, unknown>) {
63+ // The status is part of the message so it survives Durable Object RPC boundaries.
64+ super(`${status}: ${message}`);
65+ this.detail = message;
66+ }
67+}
68+
69+/** Top-level paths the app serves; no account may use them as its login. */
70+export const RESERVED_LOGINS = new Set(["api", "mcp", "runner", "settings", "new", "notifications", "invite", "request-access", "healthz", "login", "logout", "admin", "explore", "favicon.ico", "robots.txt", "about", "docs", "static", "assets", "www", "git", "diffinery", "auth", "signin", "signup", "security", "-"]);
71+
72+export const isValidLogin = (s: string): boolean => isValidName(s) && !s.includes(".") && !RESERVED_LOGINS.has(s.toLowerCase());
73+
74+export const isValidName = (s: string): boolean => /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(s) && !s.endsWith(".git") && s !== "." && s !== "..";
75+
76+export function isValidRefName(ref: string): boolean {
77+ if (!ref.startsWith("refs/")) return false;
78+ if (/(^|\/)\.|\.\.|[\x00-\x20~^:?*[\\\x7f]|@\{|\/\/|\/$|\.lock(\/|$)|\.$/.test(ref)) return false;
79+ return true;
80+}
addedapps/diffinery/src/mcp/server.ts+457−0View file
@@ -1,0 +1,457 @@
1+import { Hono } from "hono";
2+import type { Env } from "../env.js";
3+import { requireAccount, type AppContext, type AppVars } from "../auth/principal.js";
4+import { HttpError, b64, fromB64 } from "../lib/util.js";
5+import type { Role } from "../repo/types.js";
6+import { loadRepo } from "../http/common.js";
7+
8+/**
9+ * Model Context Protocol server (Streamable HTTP transport, JSON responses).
10+ * Agents connect with a Diffinery token: `Authorization: Bearer dfy_…`.
11+ * Every tool runs through the same permission checks, branch protection and
12+ * agent guardrails as the REST API and git, so an agent can never do more
13+ * than its token, its owner and the repository's rules allow.
14+ */
15+export const mcp = new Hono<{ Bindings: Env; Variables: AppVars }>();
16+
17+const PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
18+
19+type Args = Record<string, unknown>;
20+interface Tool {
21+ description: string;
22+ input: Record<string, { type: string; description?: string; items?: unknown; enum?: string[] }>;
23+ required?: string[];
24+ readOnly?: boolean;
25+ run: (c: AppContext, a: Args) => Promise<unknown>;
26+}
27+
28+const str = (a: Args, k: string, def?: string): string => {
29+ const v = a[k];
30+ if (v === undefined || v === null || v === "") {
31+ if (def !== undefined) return def;
32+ throw new HttpError(422, `${k} is required`);
33+ }
34+ if (typeof v !== "string") throw new HttpError(422, `${k} must be a string`);
35+ return v;
36+};
37+const optStr = (a: Args, k: string) => (typeof a[k] === "string" && a[k] !== "" ? (a[k] as string) : undefined);
38+const int = (a: Args, k: string) => {
39+ const n = Number(a[k]);
40+ if (!Number.isInteger(n) || n < 1) throw new HttpError(422, `${k} must be a positive integer`);
41+ return n;
42+};
43+const repoOf = (c: AppContext, a: Args, min: Role = "read") => loadRepo(c, str(a, "owner"), str(a, "repo"), min);
44+const OWNER_REPO = {
45+ owner: { type: "string", description: "Repository owner (user or org login)" },
46+ repo: { type: "string", description: "Repository name" },
47+};
48+const decoder = new TextDecoder();
49+
50+const TOOLS: Record<string, Tool> = {
51+ whoami: {
52+ description: "Show the account this token acts as, including the human an agent works on behalf of.",
53+ input: {},
54+ readOnly: true,
55+ run: async (c) => {
56+ const a = requireAccount(c);
57+ const owner = a.owner_id ? await c.get("dir").account(a.owner_id) : null;
58+ return { login: a.login, kind: a.kind, on_behalf_of: owner?.login ?? null };
59+ },
60+ },
61+ list_repos: {
62+ description: "List repositories visible to you, optionally filtered by owner.",
63+ input: { owner: { type: "string" } },
64+ readOnly: true,
65+ run: async (c, a) => {
66+ const me = requireAccount(c);
67+ const dir = c.get("dir");
68+ const owner = optStr(a, "owner") ? await dir.accountByLogin(optStr(a, "owner")!) : null;
69+ const repos = await dir.reposVisibleTo(me, owner?.id);
70+ return repos.map((r) => ({ full_name: `${r.owner_login}/${r.name}`, description: r.description, default_branch: r.default_branch, visibility: r.visibility, pushed_at: r.pushed_at }));
71+ },
72+ },
73+ list_tree: {
74+ description: "List files and directories at a path in a repository.",
75+ input: { ...OWNER_REPO, ref: { type: "string", description: "Branch, tag or commit (default: default branch)" }, path: { type: "string" } },
76+ required: ["owner", "repo"],
77+ readOnly: true,
78+ run: async (c, a) => {
79+ const { stub } = await repoOf(c, a);
80+ const t = await stub.tree(optStr(a, "ref") ?? "HEAD", optStr(a, "path") ?? "");
81+ if (!t) throw new HttpError(404, "path not found");
82+ return { sha: t.sha, items: t.items.map((i) => ({ path: i.path, type: i.type })) };
83+ },
84+ },
85+ get_file: {
86+ description: "Read a file's contents.",
87+ input: { ...OWNER_REPO, path: { type: "string" }, ref: { type: "string" } },
88+ required: ["owner", "repo", "path"],
89+ readOnly: true,
90+ run: async (c, a) => {
91+ const { stub } = await repoOf(c, a);
92+ const b = await stub.blob(optStr(a, "ref") ?? "HEAD", str(a, "path"));
93+ if (!b) throw new HttpError(404, "file not found");
94+ if (b.binary) return { path: str(a, "path"), commit: b.sha, size: b.size, binary: true };
95+ return { path: str(a, "path"), commit: b.sha, size: b.size, content: decoder.decode(b.content) };
96+ },
97+ },
98+ list_branches: {
99+ description: "List branches, marking the default and protected ones.",
100+ input: { ...OWNER_REPO },
101+ required: ["owner", "repo"],
102+ readOnly: true,
103+ run: async (c, a) => (await repoOf(c, a)).stub.branches(),
104+ },
105+ create_branch: {
106+ description: "Create a branch from another branch, tag or commit.",
107+ input: { ...OWNER_REPO, branch: { type: "string" }, from: { type: "string", description: "Default: the default branch" } },
108+ required: ["owner", "repo", "branch"],
109+ run: async (c, a) => {
110+ const { stub, caller } = await repoOf(c, a, "write");
111+ const sha = await stub.resolve(optStr(a, "from") ?? "HEAD");
112+ if (!sha) throw new HttpError(404, "source ref not found");
113+ await stub.createRef(`refs/heads/${str(a, "branch")}`, sha, caller);
114+ return { branch: str(a, "branch"), sha };
115+ },
116+ },
117+ commit_files: {
118+ description:
119+ "Create one commit that writes and deletes files on a branch, without a git client. Pass expected_head_sha to avoid racing other writers; pass create_branch_from to start a new branch.",
120+ input: {
121+ ...OWNER_REPO,
122+ branch: { type: "string" },
123+ message: { type: "string" },
124+ files: { type: "array", description: "[{ path, content, encoding?: 'utf-8'|'base64', executable?: boolean }]", items: { type: "object" } },
125+ deletes: { type: "array", items: { type: "string" } },
126+ expected_head_sha: { type: "string" },
127+ create_branch_from: { type: "string" },
128+ },
129+ required: ["owner", "repo", "branch", "message"],
130+ run: async (c, a) => {
131+ const { stub, caller } = await repoOf(c, a, "write");
132+ const files = Array.isArray(a.files) ? (a.files as Array<{ path: string; content?: string; encoding?: string; executable?: boolean }>) : [];
133+ const deletes = Array.isArray(a.deletes) ? (a.deletes as string[]) : [];
134+ if (!files.length && !deletes.length) throw new HttpError(422, "nothing to commit");
135+ const changes = [
136+ ...files.map((f) => ({
137+ path: String(f.path),
138+ mode: f.executable ? ("100755" as const) : ("100644" as const),
139+ content: f.encoding === "base64" ? fromB64(f.content ?? "") : new TextEncoder().encode(f.content ?? ""),
140+ })),
141+ ...deletes.map((p) => ({ path: String(p), delete: true })),
142+ ];
143+ return stub.commitFiles({ branch: str(a, "branch"), message: str(a, "message"), changes, expectedHeadSha: optStr(a, "expected_head_sha"), createBranchFrom: optStr(a, "create_branch_from") }, caller);
144+ },
145+ },
146+ compare: {
147+ description: "Compare two refs: commits ahead/behind and a unified diff.",
148+ input: { ...OWNER_REPO, base: { type: "string" }, head: { type: "string" } },
149+ required: ["owner", "repo", "base", "head"],
150+ readOnly: true,
151+ run: async (c, a) => (await repoOf(c, a)).stub.compare(str(a, "base"), str(a, "head")),
152+ },
153+ list_pull_requests: {
154+ description: "List pull requests.",
155+ input: { ...OWNER_REPO, state: { type: "string", enum: ["open", "closed", "all"] } },
156+ required: ["owner", "repo"],
157+ readOnly: true,
158+ run: async (c, a) => {
159+ const { stub } = await repoOf(c, a);
160+ const { items, total } = await stub.issues({ kind: "pull", state: (optStr(a, "state") as "open") ?? "open", limit: 100 });
161+ return { total, items: items.map((i) => ({ number: i.number, title: i.title, state: i.state, author: i.author, head: i.pull?.head, base: i.pull?.base, draft: i.pull?.draft, merged: !!i.pull?.mergedAt })) };
162+ },
163+ },
164+ get_pull_request: {
165+ description: "Get a pull request with its conversation, reviews, mergeability and (optionally) the full diff.",
166+ input: { ...OWNER_REPO, number: { type: "integer" }, include_diff: { type: "boolean" } },
167+ required: ["owner", "repo", "number"],
168+ readOnly: true,
169+ run: async (c, a) => {
170+ const { stub, caller } = await repoOf(c, a);
171+ const n = int(a, "number");
172+ const [detail, mergeability] = await Promise.all([stub.issue(n), stub.mergeability(n, caller).catch(() => null)]);
173+ const files = a.include_diff ? await stub.pullFiles(n) : undefined;
174+ return { ...detail, mergeability, files };
175+ },
176+ },
177+ create_pull_request: {
178+ description: "Open a pull request. Use `intent` to explain the goal for reviewers (agents should always set it).",
179+ input: { ...OWNER_REPO, title: { type: "string" }, body: { type: "string" }, head: { type: "string" }, base: { type: "string" }, draft: { type: "boolean" }, intent: { type: "string" }, reviewers: { type: "array", items: { type: "string" } } },
180+ required: ["owner", "repo", "title", "head"],
181+ run: async (c, a) => {
182+ const { stub, caller } = await repoOf(c, a, "write");
183+ return stub.createPull(
184+ { title: str(a, "title"), body: optStr(a, "body"), head: str(a, "head"), base: optStr(a, "base"), draft: !!a.draft, intent: optStr(a, "intent"), reviewers: Array.isArray(a.reviewers) ? (a.reviewers as string[]) : undefined },
185+ caller,
186+ );
187+ },
188+ },
189+ comment: {
190+ description: "Comment on an issue or pull request.",
191+ input: { ...OWNER_REPO, number: { type: "integer" }, body: { type: "string" } },
192+ required: ["owner", "repo", "number", "body"],
193+ run: async (c, a) => {
194+ const { stub, caller } = await repoOf(c, a, "triage");
195+ return stub.addComment(int(a, "number"), str(a, "body"), caller);
196+ },
197+ },
198+ review_pull_request: {
199+ description: "Submit a review: APPROVE, REQUEST_CHANGES or COMMENT, with optional line comments [{ path, line, side?, body }]. Agent approvals never count toward required human approvals of agent-authored PRs.",
200+ input: { ...OWNER_REPO, number: { type: "integer" }, event: { type: "string", enum: ["APPROVE", "REQUEST_CHANGES", "COMMENT"] }, body: { type: "string" }, comments: { type: "array", items: { type: "object" } } },
201+ required: ["owner", "repo", "number", "event"],
202+ run: async (c, a) => {
203+ const { stub, caller } = await repoOf(c, a, "read");
204+ return stub.submitReview(int(a, "number"), { event: str(a, "event") as "COMMENT", body: optStr(a, "body"), comments: Array.isArray(a.comments) ? (a.comments as []) : undefined }, caller);
205+ },
206+ },
207+ merge_pull_request: {
208+ description: "Merge a pull request when it is mergeable (checks, approvals and branch protection are enforced).",
209+ input: { ...OWNER_REPO, number: { type: "integer" }, method: { type: "string", enum: ["merge", "squash", "rebase"] }, expected_head_sha: { type: "string" }, delete_branch: { type: "boolean" } },
210+ required: ["owner", "repo", "number"],
211+ run: async (c, a) => {
212+ const { stub, caller } = await repoOf(c, a, "write");
213+ return stub.mergePull(int(a, "number"), { method: (optStr(a, "method") as "merge") ?? "merge", expectedHeadSha: optStr(a, "expected_head_sha"), deleteBranch: !!a.delete_branch }, caller);
214+ },
215+ },
216+ list_issues: {
217+ description: "List or search issues.",
218+ input: { ...OWNER_REPO, state: { type: "string", enum: ["open", "closed", "all"] }, q: { type: "string" }, label: { type: "string" } },
219+ required: ["owner", "repo"],
220+ readOnly: true,
221+ run: async (c, a) => {
222+ const { stub } = await repoOf(c, a);
223+ const { items, total } = await stub.issues({ kind: "issue", state: (optStr(a, "state") as "open") ?? "open", q: optStr(a, "q"), label: optStr(a, "label"), limit: 100 });
224+ return { total, items: items.map((i) => ({ number: i.number, title: i.title, state: i.state, author: i.author, labels: i.labels })) };
225+ },
226+ },
227+ get_issue: {
228+ description: "Get an issue with its comments.",
229+ input: { ...OWNER_REPO, number: { type: "integer" } },
230+ required: ["owner", "repo", "number"],
231+ readOnly: true,
232+ run: async (c, a) => (await repoOf(c, a)).stub.issue(int(a, "number")),
233+ },
234+ create_issue: {
235+ description: "Open an issue.",
236+ input: { ...OWNER_REPO, title: { type: "string" }, body: { type: "string" }, labels: { type: "array", items: { type: "string" } } },
237+ required: ["owner", "repo", "title"],
238+ run: async (c, a) => {
239+ const { stub, caller } = await repoOf(c, a, "read");
240+ return stub.createIssue({ title: str(a, "title"), body: optStr(a, "body"), labels: Array.isArray(a.labels) ? (a.labels as string[]) : undefined }, caller);
241+ },
242+ },
243+ update_issue: {
244+ description: "Update an issue or pull request: title, body, state (open/closed), labels.",
245+ input: { ...OWNER_REPO, number: { type: "integer" }, title: { type: "string" }, body: { type: "string" }, state: { type: "string", enum: ["open", "closed"] }, labels: { type: "array", items: { type: "string" } } },
246+ required: ["owner", "repo", "number"],
247+ run: async (c, a) => {
248+ const { stub, caller } = await repoOf(c, a, "triage");
249+ return stub.updateIssue(int(a, "number"), { title: optStr(a, "title"), body: optStr(a, "body"), state: optStr(a, "state") as "open" | undefined, labels: Array.isArray(a.labels) ? (a.labels as string[]) : undefined }, caller);
250+ },
251+ },
252+ list_workflow_runs: {
253+ description: "List workflow runs, newest first. Filter by branch, commit sha, PR number or status.",
254+ input: { ...OWNER_REPO, branch: { type: "string" }, sha: { type: "string" }, pr_number: { type: "integer" }, status: { type: "string" } },
255+ required: ["owner", "repo"],
256+ readOnly: true,
257+ run: async (c, a) => {
258+ const { stub } = await repoOf(c, a);
259+ return stub.runs({ branch: optStr(a, "branch"), sha: optStr(a, "sha"), status: optStr(a, "status"), prNumber: a.pr_number ? int(a, "pr_number") : undefined, limit: 30 });
260+ },
261+ },
262+ get_workflow_run: {
263+ description: "Get a run with its jobs, their status, conclusion and failure reason.",
264+ input: { ...OWNER_REPO, run_id: { type: "string" } },
265+ required: ["owner", "repo", "run_id"],
266+ readOnly: true,
267+ run: async (c, a) => (await repoOf(c, a)).stub.run(str(a, "run_id")),
268+ },
269+ get_job_logs: {
270+ description: "Fetch a job's log as plain text. Use `tail` to limit to the last N lines (default 400).",
271+ input: { ...OWNER_REPO, job_id: { type: "string" }, tail: { type: "integer" } },
272+ required: ["owner", "repo", "job_id"],
273+ readOnly: true,
274+ run: async (c, a) => {
275+ const { stub } = await repoOf(c, a);
276+ const { lines, complete } = await stub.jobLogs(str(a, "job_id"));
277+ const tail = a.tail ? int(a, "tail") : 400;
278+ return { complete, truncated: lines.length > tail, log: lines.slice(-tail).map((l) => l.text).join("\n") };
279+ },
280+ },
281+ rerun_workflow: {
282+ description: "Re-run a workflow run.",
283+ input: { ...OWNER_REPO, run_id: { type: "string" } },
284+ required: ["owner", "repo", "run_id"],
285+ run: async (c, a) => {
286+ const { stub, caller } = await repoOf(c, a, "write");
287+ return stub.rerun(str(a, "run_id"), caller);
288+ },
289+ },
290+ cancel_workflow: {
291+ description: "Cancel a queued or running workflow run.",
292+ input: { ...OWNER_REPO, run_id: { type: "string" } },
293+ required: ["owner", "repo", "run_id"],
294+ run: async (c, a) => {
295+ const { stub, caller } = await repoOf(c, a, "write");
296+ await stub.cancelRun(str(a, "run_id"), caller);
297+ return { cancelled: true };
298+ },
299+ },
300+ dispatch_workflow: {
301+ description: "Start a workflow_dispatch run, e.g. workflow 'deploy.yml' on ref 'main' with inputs.",
302+ input: { ...OWNER_REPO, workflow: { type: "string" }, ref: { type: "string" }, inputs: { type: "object" } },
303+ required: ["owner", "repo", "workflow"],
304+ run: async (c, a) => {
305+ const { stub, caller, repo } = await repoOf(c, a, "write");
306+ return stub.dispatchWorkflow(str(a, "workflow"), optStr(a, "ref") ?? repo.default_branch, (a.inputs as Record<string, unknown>) ?? {}, caller);
307+ },
308+ },
309+ sandbox_create: {
310+ description: "Start an isolated Linux sandbox with the repository checked out (git push from inside it uses your permissions). Returns a sandbox id.",
311+ input: { ...OWNER_REPO, ref: { type: "string" } },
312+ required: ["owner", "repo"],
313+ run: async (c, a) => {
314+ const { stub, caller } = await repoOf(c, a, "write");
315+ return stub.createSandbox(caller, optStr(a, "ref"));
316+ },
317+ },
318+ sandbox_exec: {
319+ description: "Run a shell command in a sandbox. Returns exit code, stdout and stderr.",
320+ input: { ...OWNER_REPO, sandbox_id: { type: "string" }, command: { type: "string" }, cwd: { type: "string" }, timeout_seconds: { type: "integer" } },
321+ required: ["owner", "repo", "sandbox_id", "command"],
322+ run: (c, a) => sandboxCall(c, a, "/exec", { cmd: str(a, "command"), cwd: optStr(a, "cwd"), timeoutMs: a.timeout_seconds ? int(a, "timeout_seconds") * 1000 : undefined }),
323+ },
324+ sandbox_read_file: {
325+ description: "Read a file inside a sandbox.",
326+ input: { ...OWNER_REPO, sandbox_id: { type: "string" }, path: { type: "string" } },
327+ required: ["owner", "repo", "sandbox_id", "path"],
328+ run: async (c, a) => {
329+ const r = (await sandboxCall(c, a, "/files/read", { path: str(a, "path") })) as { path: string; size: number; content: string };
330+ const bytes = fromB64(r.content);
331+ const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: false });
332+ try {
333+ return { path: r.path, size: r.size, content: text.decode(bytes) };
334+ } catch {
335+ return { path: r.path, size: r.size, encoding: "base64", content: r.content };
336+ }
337+ },
338+ },
339+ sandbox_write_file: {
340+ description: "Write a file inside a sandbox.",
341+ input: { ...OWNER_REPO, sandbox_id: { type: "string" }, path: { type: "string" }, content: { type: "string" } },
342+ required: ["owner", "repo", "sandbox_id", "path", "content"],
343+ run: (c, a) => sandboxCall(c, a, "/files/write", { path: str(a, "path"), content: b64(new TextEncoder().encode(str(a, "content", ""))) }),
344+ },
345+ sandbox_close: {
346+ description: "Shut down a sandbox.",
347+ input: { ...OWNER_REPO, sandbox_id: { type: "string" } },
348+ required: ["owner", "repo", "sandbox_id"],
349+ run: async (c, a) => {
350+ const { stub, caller } = await repoOf(c, a, "write");
351+ await stub.closeSandbox(str(a, "sandbox_id"), caller);
352+ return { closed: true };
353+ },
354+ },
355+};
356+
357+async function sandboxCall(c: AppContext, a: Args, path: string, payload: unknown) {
358+ const { stub, caller } = await repoOf(c, a, "write");
359+ const id = str(a, "sandbox_id");
360+ await stub.sandbox(id, caller);
361+ const res = await c.env.SANDBOX.get(c.env.SANDBOX.idFromName(id)).call(path, JSON.stringify(payload));
362+ let out: unknown = res.body;
363+ try {
364+ out = JSON.parse(res.body);
365+ } catch {
366+ /* plain text */
367+ }
368+ if (res.status >= 400) throw new HttpError(res.status, out && typeof out === "object" && "error" in out ? String((out as { error: unknown }).error) : res.body || "sandbox error");
369+ return out;
370+}
371+
372+interface RpcRequest {
373+ jsonrpc: "2.0";
374+ id?: string | number | null;
375+ method: string;
376+ params?: Record<string, unknown>;
377+}
378+
379+const rpcError = (id: RpcRequest["id"], code: number, message: string) => ({ jsonrpc: "2.0" as const, id: id ?? null, error: { code, message } });
380+
381+async function handle(c: AppContext, req: RpcRequest): Promise<unknown | null> {
382+ const isNotification = req.id === undefined;
383+ switch (req.method) {
384+ case "initialize": {
385+ const asked = String(req.params?.protocolVersion ?? "");
386+ return {
387+ jsonrpc: "2.0",
388+ id: req.id,
389+ result: {
390+ protocolVersion: PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0],
391+ capabilities: { tools: { listChanged: false } },
392+ serverInfo: { name: "diffinery", title: "Diffinery", version: "0.1.0" },
393+ instructions:
394+ "Diffinery is a git forge built for agents. Read code with get_file/list_tree, change it with commit_files on a branch, open a pull request with an `intent`, watch CI with list_workflow_runs/get_job_logs, and use sandboxes to build and test. Protected branches and required human reviews are enforced server-side.",
395+ },
396+ };
397+ }
398+ case "notifications/initialized":
399+ case "notifications/cancelled":
400+ return null;
401+ case "ping":
402+ return { jsonrpc: "2.0", id: req.id, result: {} };
403+ case "tools/list":
404+ return {
405+ jsonrpc: "2.0",
406+ id: req.id,
407+ result: {
408+ tools: Object.entries(TOOLS).map(([name, t]) => ({
409+ name,
410+ description: t.description,
411+ inputSchema: { type: "object", properties: t.input, required: t.required ?? [] },
412+ annotations: { readOnlyHint: !!t.readOnly },
413+ })),
414+ },
415+ };
416+ case "tools/call": {
417+ const name = String(req.params?.name ?? "");
418+ const tool = TOOLS[name];
419+ if (!tool) return rpcError(req.id, -32602, `unknown tool: ${name}`);
420+ try {
421+ const out = await tool.run(c, (req.params?.arguments as Args) ?? {});
422+ return { jsonrpc: "2.0", id: req.id, result: { content: [{ type: "text", text: JSON.stringify(out, null, 2) }], structuredContent: out && typeof out === "object" && !Array.isArray(out) ? out : { result: out }, isError: false } };
423+ } catch (e) {
424+ const msg = e instanceof HttpError ? e.detail : String((e as Error)?.message ?? e).replace(/^\d{3}: /, "");
425+ return { jsonrpc: "2.0", id: req.id, result: { content: [{ type: "text", text: msg }], isError: true } };
426+ }
427+ }
428+ default:
429+ return isNotification ? null : rpcError(req.id, -32601, `method not found: ${req.method}`);
430+ }
431+}
432+
433+mcp.get("/mcp", (c) => c.json({ message: "Diffinery MCP: POST JSON-RPC here with Authorization: Bearer <token>" }, 405, { allow: "POST" }));
434+mcp.delete("/mcp", (c) => c.body(null, 405));
435+mcp.post("/mcp", async (c) => {
436+ // Every MCP call needs an active account (a token for an agent or a person).
437+ requireAccount(c);
438+ let payload: unknown;
439+ try {
440+ payload = await c.req.json();
441+ } catch {
442+ return c.json(rpcError(null, -32700, "parse error"), 400);
443+ }
444+ const batch = Array.isArray(payload);
445+ const reqs = (batch ? payload : [payload]) as RpcRequest[];
446+ const out: unknown[] = [];
447+ for (const r of reqs) {
448+ if (!r || typeof r !== "object" || typeof r.method !== "string") {
449+ out.push(rpcError(null, -32600, "invalid request"));
450+ continue;
451+ }
452+ const res = await handle(c, r);
453+ if (res) out.push(res);
454+ }
455+ if (!out.length) return c.body(null, 202);
456+ return c.json(batch ? out : out[0]);
457+});
addedapps/diffinery/src/repo/RepoDO.ts+732−0View file
@@ -1,0 +1,732 @@
1+import { DurableObject } from "cloudflare:workers";
2+import {
3+ EMPTY_TREE_OID,
4+ ZERO_OID,
5+ applyChanges,
6+ diffTrees,
7+ fileDiff,
8+ formatUnified,
9+ fromUtf8,
10+ isAncestor,
11+ isBinary,
12+ isOid,
13+ isTreeMode,
14+ mergeBase,
15+ parsePack,
16+ peelToCommit,
17+ readCommit,
18+ readTree,
19+ resolvePath,
20+ serializeCommit,
21+ walkCommits,
22+ type Commit,
23+ type FileChange,
24+ type Signature,
25+} from "@diffinery/git-core";
26+import type { DiffineryEvent } from "@diffinery/shared";
27+import type { Env } from "../env.js";
28+import {
29+ advertiseReceivePack,
30+ advertiseUploadPackV2,
31+ errorPacket,
32+ handleUploadPackV2,
33+ parseReceivePack,
34+ reportStatus,
35+ type RefEntry,
36+ type RefResult,
37+} from "../git/protocol.js";
38+import { scanForSecrets } from "../git/secret-scan.js";
39+import { HttpError, isValidRefName, now } from "../lib/util.js";
40+import { Actions } from "./actions.js";
41+import { Collab } from "./collab.js";
42+import { RepoObjectStore } from "./objects.js";
43+import { REPO_SCHEMA } from "./schema.js";
44+import { atLeast, globToRegExp, type Caller, type ProtectionRules, type RepoMeta } from "./types.js";
45+
46+const MAX_PUSH_BYTES = 100 * 1024 * 1024;
47+
48+export type TreeItem = {
49+ name: string;
50+ path: string;
51+ type: "tree" | "blob" | "commit";
52+ mode: string;
53+ oid: string;
54+ size?: number;
55+};
56+
57+export type CommitInfo = {
58+ sha: string;
59+ tree: string;
60+ parents: string[];
61+ author: Signature;
62+ committer: Signature;
63+ message: string;
64+ signed: boolean;
65+};
66+
67+/**
68+ * One Durable Object per repository. It is the single writer for the repo's
69+ * refs, Git objects, issues, pull requests, reviews, checks and workflow runs,
70+ * which gives strongly consistent ref updates and merges without locks.
71+ */
72+export class RepoDO extends DurableObject<Env> {
73+ readonly sql: SqlStorage;
74+ store!: RepoObjectStore;
75+ readonly collab: Collab;
76+ readonly actions: Actions;
77+ private metaCache: RepoMeta | null = null;
78+
79+ constructor(ctx: DurableObjectState, env: Env) {
80+ super(ctx, env);
81+ this.sql = ctx.storage.sql;
82+ this.sql.exec(REPO_SCHEMA);
83+ const meta = this.readMeta();
84+ if (meta) this.store = new RepoObjectStore(this.sql, env.BUCKET, meta.repoId);
85+ this.collab = new Collab(this);
86+ this.actions = new Actions(this, env);
87+ }
88+
89+ // ---------------------------------------------------------------- meta
90+
91+ private readMeta(): RepoMeta | null {
92+ const row = this.sql.exec<{ value: string }>("SELECT value FROM meta WHERE key = 'meta'").toArray()[0];
93+ this.metaCache = row ? (JSON.parse(row.value) as RepoMeta) : null;
94+ return this.metaCache;
95+ }
96+
97+ get meta(): RepoMeta {
98+ const m = this.metaCache ?? this.readMeta();
99+ if (!m) throw new HttpError(404, "repository not initialised");
100+ return m;
101+ }
102+
103+ async init(meta: RepoMeta): Promise<void> {
104+ this.sql.exec("INSERT OR REPLACE INTO meta (key, value) VALUES ('meta', ?)", JSON.stringify(meta));
105+ this.metaCache = meta;
106+ this.store = new RepoObjectStore(this.sql, this.env.BUCKET, meta.repoId);
107+ }
108+
109+ async updateMeta(patch: Partial<RepoMeta>): Promise<RepoMeta> {
110+ const next = { ...this.meta, ...patch };
111+ await this.init(next);
112+ return next;
113+ }
114+
115+ /** Wipe all storage (repository deletion). */
116+ async destroy(): Promise<void> {
117+ await this.ctx.storage.deleteAll();
118+ this.metaCache = null;
119+ }
120+
121+ async stats(): Promise<{ objects: number; bytes: number; refs: number; openIssues: number; openPulls: number }> {
122+ const q = (s: string) => this.sql.exec<{ n: number }>(s).one().n;
123+ return {
124+ objects: this.store.count(),
125+ bytes: this.store.sizeBytes(),
126+ refs: q("SELECT count(*) AS n FROM refs"),
127+ openIssues: q("SELECT count(*) AS n FROM issues WHERE kind = 'issue' AND state = 'open'"),
128+ openPulls: q("SELECT count(*) AS n FROM issues WHERE kind = 'pull' AND state = 'open'"),
129+ };
130+ }
131+
132+ emit(event: DiffineryEvent): void {
133+ this.ctx.waitUntil(this.env.EVENTS.send(event).catch((e) => console.error("event send failed", e)));
134+ this.broadcast("repo", { kind: "event", event });
135+ }
136+
137+ // ---------------------------------------------------------------- refs
138+
139+ refs(prefix = "refs/"): RefEntry[] {
140+ return this.sql.exec<RefEntry>("SELECT name, oid FROM refs WHERE name >= ? AND name < ? ORDER BY name", prefix, `${prefix}￿`).toArray();
141+ }
142+
143+ getRef(name: string): string | null {
144+ return this.sql.exec<{ oid: string }>("SELECT oid FROM refs WHERE name = ?", name).toArray()[0]?.oid ?? null;
145+ }
146+
147+ /** Compare-and-swap a ref. `expected` = ZERO_OID means "must not exist". */
148+ casRef(name: string, expected: string | null, next: string | null): boolean {
149+ const current = this.getRef(name);
150+ if (expected !== null && (current ?? ZERO_OID) !== expected) return false;
151+ if (next === null || next === ZERO_OID) this.sql.exec("DELETE FROM refs WHERE name = ?", name);
152+ else this.sql.exec("INSERT OR REPLACE INTO refs (name, oid) VALUES (?, ?)", name, next);
153+ return true;
154+ }
155+
156+ get headRef(): string {
157+ return `refs/heads/${this.meta.defaultBranch}`;
158+ }
159+
160+ /** Resolve a branch, tag, full ref, HEAD or (abbreviated) SHA to a commit SHA. */
161+ async resolve(rev: string | undefined | null): Promise<string | null> {
162+ const r = rev && rev.length ? rev : "HEAD";
163+ let oid: string | null = null;
164+ if (r === "HEAD") oid = this.getRef(this.headRef);
165+ else if (isOid(r)) oid = this.store.has(r) ? r : null;
166+ else if (r.startsWith("refs/")) oid = this.getRef(r);
167+ else oid = this.getRef(`refs/heads/${r}`) ?? this.getRef(`refs/tags/${r}`);
168+ if (!oid && /^[0-9a-f]{7,39}$/.test(r)) {
169+ const rows = this.sql.exec<{ oid: string }>("SELECT oid FROM objects WHERE oid >= ? AND oid < ? LIMIT 2", r, `${r}g`).toArray();
170+ if (rows.length === 1) oid = rows[0]!.oid;
171+ }
172+ if (!oid) return null;
173+ return peelToCommit(this.store, oid);
174+ }
175+
176+ async branches(): Promise<Array<{ name: string; sha: string; protected: boolean; isDefault: boolean }>> {
177+ return this.refs("refs/heads/").map((r) => {
178+ const name = r.name.slice(11);
179+ return { name, sha: r.oid, protected: !!this.protectionFor(name), isDefault: name === this.meta.defaultBranch };
180+ });
181+ }
182+
183+ async tags(): Promise<Array<{ name: string; sha: string; target: string }>> {
184+ const out = [];
185+ for (const r of this.refs("refs/tags/")) out.push({ name: r.name.slice(10), sha: r.oid, target: await peelToCommit(this.store, r.oid).catch(() => r.oid) });
186+ return out;
187+ }
188+
189+ // ---------------------------------------------------------------- git smart HTTP
190+
191+ async gitInfoRefs(service: "git-upload-pack" | "git-receive-pack"): Promise<Uint8Array> {
192+ if (service === "git-upload-pack") return advertiseUploadPackV2();
193+ return advertiseReceivePack(this.refs());
194+ }
195+
196+ async gitUploadPack(body: Uint8Array): Promise<Uint8Array> {
197+ try {
198+ return await handleUploadPackV2(body, { store: this.store, refs: this.refs(), headTarget: this.headRef });
199+ } catch (e) {
200+ return errorPacket((e as Error).message);
201+ }
202+ }
203+
204+ async gitReceivePack(body: Uint8Array, caller: Caller): Promise<Uint8Array> {
205+ if (body.length > MAX_PUSH_BYTES) {
206+ const req = { commands: [], capabilities: new Set<string>(), pushOptions: [], pack: null };
207+ return reportStatus(req, "push exceeds 100 MB; split it into smaller pushes", []);
208+ }
209+ const req = parseReceivePack(body);
210+ const messages: string[] = [];
211+ let unpackError: string | null = null;
212+ const newBlobs: Array<{ oid: string; content: Uint8Array }> = [];
213+ if (req.pack && req.pack.length > 0) {
214+ try {
215+ const objects = await parsePack(req.pack, (oid) => this.store.read(oid));
216+ for (const o of objects) {
217+ await this.store.writeKnown(o.oid, o.type, o.content);
218+ if (o.type === "blob" && o.content.length < 1024 * 1024) newBlobs.push({ oid: o.oid, content: o.content });
219+ }
220+ } catch (e) {
221+ unpackError = (e as Error).message;
222+ }
223+ }
224+ const results: RefResult[] = [];
225+ if (unpackError) {
226+ for (const c of req.commands) results.push({ ref: c.ref, ok: false, reason: "unpacker error" });
227+ return reportStatus(req, unpackError, results);
228+ }
229+
230+ const leaks = scanForSecrets(newBlobs);
231+ if (leaks.length) {
232+ messages.push("Push blocked: possible secrets detected.");
233+ for (const l of leaks.slice(0, 10)) messages.push(` ${l.kind} in blob ${l.oid.slice(0, 10)}`);
234+ messages.push("Remove the secret from your commits, or rotate it and contact an admin.");
235+ for (const c of req.commands) results.push({ ref: c.ref, ok: false, reason: "secret detected (push protection)" });
236+ this.emit({ type: "audit", action: "push.blocked_secret", actor: caller, target: `${this.meta.owner}/${this.meta.name}`, detail: { kinds: leaks.map((l) => l.kind) }, at: now() });
237+ return reportStatus(req, null, results, messages);
238+ }
239+
240+ // Validate every command before applying any, so `atomic` is honoured.
241+ const checks: Array<RefResult & { cmd: (typeof req.commands)[number] }> = [];
242+ for (const cmd of req.commands) checks.push({ cmd, ...(await this.checkRefUpdate(cmd.ref, cmd.oldOid, cmd.newOid, caller)) });
243+ const atomic = req.capabilities.has("atomic");
244+ const anyFailed = checks.some((c) => !c.ok);
245+ for (const c of checks) {
246+ if (!c.ok) {
247+ results.push({ ref: c.ref, ok: false, reason: c.reason });
248+ continue;
249+ }
250+ if (atomic && anyFailed) {
251+ results.push({ ref: c.ref, ok: false, reason: "atomic transaction failed" });
252+ continue;
253+ }
254+ const applied = this.casRef(c.cmd.ref, c.cmd.oldOid, c.cmd.newOid);
255+ results.push(applied ? { ref: c.ref, ok: true } : { ref: c.ref, ok: false, reason: "fetch first (stale info)" });
256+ if (applied) await this.afterRefUpdate(c.cmd.ref, c.cmd.oldOid, c.cmd.newOid, caller, messages);
257+ }
258+ return reportStatus(req, null, results, messages);
259+ }
260+
261+ protectionFor(branch: string): ProtectionRules | null {
262+ for (const row of this.sql.exec<{ pattern: string; rules: string }>("SELECT pattern, rules FROM protections").toArray()) {
263+ if (globToRegExp(row.pattern).test(branch)) return JSON.parse(row.rules) as ProtectionRules;
264+ }
265+ return null;
266+ }
267+
268+ /** Server-side rules for a ref update: permissions, protection, fast-forward, agent guardrails. */
269+ async checkRefUpdate(ref: string, oldOid: string, newOid: string, caller: Caller): Promise<RefResult> {
270+ if (!isValidRefName(ref)) return { ref, ok: false, reason: "invalid ref name" };
271+ if (!atLeast(caller.role, "write")) return { ref, ok: false, reason: "permission denied" };
272+ const deleting = newOid === ZERO_OID;
273+ if (!deleting && !this.store.has(newOid)) return { ref, ok: false, reason: "missing objects" };
274+ if (!ref.startsWith("refs/heads/")) {
275+ if (ref.startsWith("refs/tags/") && oldOid !== ZERO_OID && !atLeast(caller.role, "maintain")) return { ref, ok: false, reason: "only maintainers may move tags" };
276+ return { ref, ok: true };
277+ }
278+ const branch = ref.slice(11);
279+ const rules = this.protectionFor(branch);
280+ const isDefault = branch === this.meta.defaultBranch;
281+ if (caller.kind === "agent" && (isDefault || rules) && !rules?.agentsMayPush) {
282+ return { ref, ok: false, reason: "agents may not push to protected or default branches; open a pull request" };
283+ }
284+ if (deleting && isDefault) return { ref, ok: false, reason: "cannot delete the default branch" };
285+ if (!rules) return { ref, ok: true };
286+ const bypass = caller.role === "admin" && !rules.enforceAdmins;
287+ if (bypass) return { ref, ok: true };
288+ if (deleting) return rules.allowDeletion ? { ref, ok: true } : { ref, ok: false, reason: "protected branch: deletion not allowed" };
289+ if (rules.requirePullRequest) return { ref, ok: false, reason: "protected branch: changes must be made through a pull request" };
290+ if (oldOid !== ZERO_OID && !rules.allowForcePush && !(await isAncestor(this.store, oldOid, newOid))) {
291+ return { ref, ok: false, reason: "protected branch: force push not allowed" };
292+ }
293+ if (rules.requireLinearHistory) {
294+ const fresh = await walkCommits(this.store, [newOid], { stop: new Set(oldOid === ZERO_OID ? [] : [oldOid]), limit: 1000 });
295+ if (fresh.some((c) => c.commit.parents.length > 1)) return { ref, ok: false, reason: "protected branch: merge commits not allowed (linear history)" };
296+ }
297+ return { ref, ok: true };
298+ }
299+
300+ /** Side effects after a ref moved: events, PR sync, workflow triggers. */
301+ async afterRefUpdate(ref: string, oldOid: string, newOid: string, caller: Caller, messages: string[] = []): Promise<void> {
302+ const at = now();
303+ const forced = oldOid !== ZERO_OID && newOid !== ZERO_OID && !(await isAncestor(this.store, oldOid, newOid));
304+ const ev: DiffineryEvent = {
305+ type: "push",
306+ repoId: this.meta.repoId,
307+ owner: this.meta.owner,
308+ repo: this.meta.name,
309+ ref,
310+ before: oldOid,
311+ after: newOid,
312+ actor: { id: caller.id, login: caller.login, kind: caller.kind, onBehalfOf: caller.onBehalfOf },
313+ forced,
314+ created: oldOid === ZERO_OID,
315+ deleted: newOid === ZERO_OID,
316+ at,
317+ };
318+ this.emit(ev);
319+ if (ref.startsWith("refs/heads/") && newOid !== ZERO_OID) {
320+ const branch = ref.slice(11);
321+ const synced = await this.collab.syncPullsForBranch(branch, newOid, caller);
322+ for (const n of synced) messages.push(`Updated pull request #${n}: ${this.env.PUBLIC_URL}/${this.meta.owner}/${this.meta.name}/pull/${n}`);
323+ if (oldOid === ZERO_OID && branch !== this.meta.defaultBranch && synced.length === 0) {
324+ messages.push(`Create a pull request for '${branch}':`);
325+ messages.push(` ${this.env.PUBLIC_URL}/${this.meta.owner}/${this.meta.name}/compare/${encodeURIComponent(branch)}`);
326+ }
327+ }
328+ if (newOid !== ZERO_OID) await this.actions.onPush(ev, caller).catch((e) => messages.push(`Workflow trigger error: ${(e as Error).message}`));
329+ }
330+
331+ // ---------------------------------------------------------------- browsing
332+
333+ async commitInfo(sha: string): Promise<CommitInfo> {
334+ const c = await readCommit(this.store, sha);
335+ return toInfo(sha, c);
336+ }
337+
338+ async tree(rev: string, path = ""): Promise<{ sha: string; items: TreeItem[] } | null> {
339+ const sha = await this.resolve(rev);
340+ if (!sha) return null;
341+ const c = await readCommit(this.store, sha);
342+ const entry = await resolvePath(this.store, c.tree, path);
343+ if (!entry || !isTreeMode(entry.mode)) return null;
344+ const items: TreeItem[] = [];
345+ for (const e of await readTree(this.store, entry.oid)) {
346+ const type = isTreeMode(e.mode) ? "tree" : e.mode === "160000" ? "commit" : "blob";
347+ const raw = type === "blob" ? this.sql.exec<{ size: number }>("SELECT size FROM objects WHERE oid = ?", e.oid).toArray()[0] : undefined;
348+ items.push({ name: e.name, path: path ? `${path}/${e.name}` : e.name, type, mode: e.mode, oid: e.oid, size: raw?.size });
349+ }
350+ items.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === "tree" ? -1 : 1));
351+ return { sha, items };
352+ }
353+
354+ async blob(rev: string, path: string): Promise<{ sha: string; oid: string; mode: string; size: number; binary: boolean; content: Uint8Array } | null> {
355+ const sha = await this.resolve(rev);
356+ if (!sha) return null;
357+ const c = await readCommit(this.store, sha);
358+ const entry = await resolvePath(this.store, c.tree, path);
359+ if (!entry || isTreeMode(entry.mode)) return null;
360+ const o = await this.store.read(entry.oid);
361+ if (!o) return null;
362+ return { sha, oid: entry.oid, mode: entry.mode, size: o.content.length, binary: isBinary(o.content), content: o.content };
363+ }
364+
365+ async log(rev: string, opts: { path?: string; limit?: number; skip?: number } = {}): Promise<CommitInfo[]> {
366+ const sha = await this.resolve(rev);
367+ if (!sha) return [];
368+ const limit = Math.min(opts.limit ?? 30, 100);
369+ const skip = opts.skip ?? 0;
370+ if (!opts.path) {
371+ const commits = await walkCommits(this.store, [sha], { limit: limit + skip });
372+ return commits.slice(skip).map((c) => toInfo(c.oid, c.commit));
373+ }
374+ // Path history: commits whose tree entry at `path` differs from their first parent's.
375+ const out: CommitInfo[] = [];
376+ let skipped = 0;
377+ for (const c of await walkCommits(this.store, [sha], { limit: 2000 })) {
378+ const mine = await resolvePath(this.store, c.commit.tree, opts.path);
379+ const parent = c.commit.parents[0] ? await readCommit(this.store, c.commit.parents[0]).catch(() => null) : null;
380+ const theirs = parent ? await resolvePath(this.store, parent.tree, opts.path) : null;
381+ if ((mine?.oid ?? null) === (theirs?.oid ?? null)) continue;
382+ if (skipped++ < skip) continue;
383+ out.push(toInfo(c.oid, c.commit));
384+ if (out.length >= limit) break;
385+ }
386+ return out;
387+ }
388+
389+ /** Diff between two commits (or a commit and its first parent). */
390+ async diff(baseSha: string | null, headSha: string, opts: { patch?: boolean; maxFiles?: number } = {}) {
391+ const head = await readCommit(this.store, headSha);
392+ const baseTree = baseSha ? (await readCommit(this.store, baseSha)).tree : null;
393+ const changes = await diffTrees(this.store, baseTree, head.tree);
394+ const max = opts.maxFiles ?? 300;
395+ const files = [];
396+ for (const ch of changes.slice(0, max)) {
397+ const d = await fileDiff(this.store, ch);
398+ files.push({ ...d, patch: opts.patch === false ? undefined : formatUnified(d) });
399+ }
400+ return {
401+ files,
402+ totalFiles: changes.length,
403+ additions: files.reduce((s, f) => s + f.additions, 0),
404+ deletions: files.reduce((s, f) => s + f.deletions, 0),
405+ truncated: changes.length > max,
406+ };
407+ }
408+
409+ async commitWithDiff(rev: string) {
410+ const sha = await this.resolve(rev);
411+ if (!sha) throw new HttpError(404, "commit not found");
412+ const info = await this.commitInfo(sha);
413+ return { ...info, ...(await this.diff(info.parents[0] ?? null, sha)) };
414+ }
415+
416+ async compare(base: string, head: string) {
417+ const b = await this.resolve(base);
418+ const h = await this.resolve(head);
419+ if (!b || !h) throw new HttpError(404, "unknown base or head");
420+ const mb = await mergeBase(this.store, b, h);
421+ const commits = (await walkCommits(this.store, [h], { stop: new Set(mb ? (await walkCommits(this.store, [mb], { limit: 100000 })).map((c) => c.oid) : []), limit: 250 })).map((c) => toInfo(c.oid, c.commit));
422+ const ahead = commits.length;
423+ const behind = mb ? (await walkCommits(this.store, [b], { stop: new Set([mb]), limit: 1000 })).filter((c) => c.oid !== mb).length : 0;
424+ return { baseSha: b, headSha: h, mergeBase: mb, aheadBy: ahead, behindBy: behind, commits: commits.reverse(), ...(await this.diff(mb, h)) };
425+ }
426+
427+ /**
428+ * Create a commit from file changes without a Git client (used by the web
429+ * editor, the REST contents API and AI agents through MCP).
430+ */
431+ async commitFiles(input: {
432+ branch: string;
433+ message: string;
434+ changes: FileChange[];
435+ expectedHeadSha?: string;
436+ createBranchFrom?: string;
437+ author?: { name: string; email: string };
438+ }, caller: Caller): Promise<{ sha: string; branch: string }> {
439+ const ref = `refs/heads/${input.branch}`;
440+ let parent = this.getRef(ref);
441+ if (!parent) {
442+ if (input.createBranchFrom) parent = await this.resolve(input.createBranchFrom);
443+ else if (this.refs("refs/heads/").length > 0) throw new HttpError(404, `branch ${input.branch} not found`);
444+ }
445+ if (input.expectedHeadSha && parent !== input.expectedHeadSha) throw new HttpError(409, "branch moved; refresh and retry", { currentSha: parent });
446+ const baseTree = parent ? (await readCommit(this.store, parent)).tree : null;
447+ const tree = await applyChanges(this.store, baseTree, input.changes);
448+ const ts = Math.floor(Date.now() / 1000);
449+ const committer = { name: caller.login, email: `${caller.login}@users.noreply.diffinery.com`, timestamp: ts, timezone: "+0000" };
450+ const author = input.author ? { ...input.author, timestamp: ts, timezone: "+0000" } : committer;
451+ const extra: [string, string][] = caller.kind === "agent" ? [["diffinery-agent", `${caller.login}${caller.onBehalfOf ? ` on-behalf-of ${caller.onBehalfOf}` : ""}`]] : [];
452+ const sha = await this.store.write(
453+ "commit",
454+ serializeCommit({ tree: tree === EMPTY_TREE_OID ? tree : tree, parents: parent ? [parent] : [], author, committer, message: input.message.endsWith("\n") ? input.message : `${input.message}\n`, extraHeaders: extra }),
455+ );
456+ const old = parent && this.getRef(ref) ? parent : ZERO_OID;
457+ const check = await this.checkRefUpdate(ref, old, sha, caller);
458+ if (!check.ok) throw new HttpError(403, check.reason ?? "rejected");
459+ if (!this.casRef(ref, old, sha)) throw new HttpError(409, "branch moved; refresh and retry");
460+ await this.afterRefUpdate(ref, old, sha, caller);
461+ return { sha, branch: input.branch };
462+ }
463+
464+ async createRef(ref: string, sha: string, caller: Caller): Promise<void> {
465+ if (!this.store.has(sha)) throw new HttpError(422, "object does not exist");
466+ const check = await this.checkRefUpdate(ref, ZERO_OID, sha, caller);
467+ if (!check.ok) throw new HttpError(403, check.reason ?? "rejected");
468+ if (!this.casRef(ref, ZERO_OID, sha)) throw new HttpError(422, "reference already exists");
469+ await this.afterRefUpdate(ref, ZERO_OID, sha, caller);
470+ }
471+
472+ async updateRef(ref: string, sha: string, force: boolean, caller: Caller): Promise<void> {
473+ const old = this.getRef(ref);
474+ if (!old) throw new HttpError(404, "reference not found");
475+ if (!force && !(await isAncestor(this.store, old, sha))) throw new HttpError(422, "update is not a fast-forward");
476+ const check = await this.checkRefUpdate(ref, old, sha, caller);
477+ if (!check.ok) throw new HttpError(403, check.reason ?? "rejected");
478+ if (!this.casRef(ref, old, sha)) throw new HttpError(409, "reference moved");
479+ await this.afterRefUpdate(ref, old, sha, caller);
480+ }
481+
482+ async deleteRef(ref: string, caller: Caller): Promise<void> {
483+ const old = this.getRef(ref);
484+ if (!old) throw new HttpError(404, "reference not found");
485+ const check = await this.checkRefUpdate(ref, old, ZERO_OID, caller);
486+ if (!check.ok) throw new HttpError(403, check.reason ?? "rejected");
487+ this.casRef(ref, old, null);
488+ await this.afterRefUpdate(ref, old, ZERO_OID, caller);
489+ }
490+
491+ // ---------------------------------------------------------------- protections
492+
493+ listProtections(): Array<{ pattern: string; rules: ProtectionRules }> {
494+ return this.sql.exec<{ pattern: string; rules: string }>("SELECT pattern, rules FROM protections ORDER BY pattern").toArray().map((r) => ({ pattern: r.pattern, rules: JSON.parse(r.rules) }));
495+ }
496+
497+ setProtection(pattern: string, rules: ProtectionRules | null): void {
498+ if (rules) this.sql.exec("INSERT OR REPLACE INTO protections (pattern, rules) VALUES (?, ?)", pattern, JSON.stringify(rules));
499+ else this.sql.exec("DELETE FROM protections WHERE pattern = ?", pattern);
500+ }
501+
502+ // ---------------------------------------------------------------- RPC passthroughs (collab + actions)
503+ // Workers RPC only exposes methods on the class itself, so the sub-modules are wrapped here.
504+
505+ issues(...a: Parameters<Collab["list"]>) {
506+ return this.collab.list(...a);
507+ }
508+ issue(...a: Parameters<Collab["get"]>) {
509+ return this.collab.get(...a);
510+ }
511+ createIssue(...a: Parameters<Collab["createIssue"]>) {
512+ return this.collab.createIssue(...a);
513+ }
514+ updateIssue(...a: Parameters<Collab["update"]>) {
515+ return this.collab.update(...a);
516+ }
517+ addComment(...a: Parameters<Collab["addComment"]>) {
518+ return this.collab.addComment(...a);
519+ }
520+ editComment(...a: Parameters<Collab["editComment"]>) {
521+ return this.collab.editComment(...a);
522+ }
523+ createPull(...a: Parameters<Collab["createPull"]>) {
524+ return this.collab.createPull(...a);
525+ }
526+ pullFiles(...a: Parameters<Collab["pullFiles"]>) {
527+ return this.collab.pullFiles(...a);
528+ }
529+ pullCommits(...a: Parameters<Collab["pullCommits"]>) {
530+ return this.collab.pullCommits(...a);
531+ }
532+ submitReview(...a: Parameters<Collab["submitReview"]>) {
533+ return this.collab.submitReview(...a);
534+ }
535+ resolveThread(...a: Parameters<Collab["resolveThread"]>) {
536+ return this.collab.resolveThread(...a);
537+ }
538+ mergeability(...a: Parameters<Collab["mergeability"]>) {
539+ return this.collab.mergeability(...a);
540+ }
541+ mergePull(...a: Parameters<Collab["merge"]>) {
542+ return this.collab.merge(...a);
543+ }
544+ requestReviewers(...a: Parameters<Collab["requestReviewers"]>) {
545+ return this.collab.requestReviewers(...a);
546+ }
547+ labels(...a: Parameters<Collab["labels"]>) {
548+ return this.collab.labels(...a);
549+ }
550+ setLabel(...a: Parameters<Collab["setLabel"]>) {
551+ return this.collab.setLabel(...a);
552+ }
553+ checks(...a: Parameters<Collab["checks"]>) {
554+ return this.collab.checks(...a);
555+ }
556+ upsertCheck(...a: Parameters<Collab["upsertCheck"]>) {
557+ return this.collab.upsertCheck(...a);
558+ }
559+ webhooks(...a: Parameters<Collab["webhooks"]>) {
560+ return this.collab.webhooks(...a);
561+ }
562+ createWebhook(...a: Parameters<Collab["createWebhook"]>) {
563+ return this.collab.createWebhook(...a);
564+ }
565+ deleteWebhook(...a: Parameters<Collab["deleteWebhook"]>) {
566+ return this.collab.deleteWebhook(...a);
567+ }
568+ recordDelivery(...a: Parameters<Collab["recordDelivery"]>) {
569+ return this.collab.recordDelivery(...a);
570+ }
571+
572+ runs(...a: Parameters<Actions["listRuns"]>) {
573+ return this.actions.listRuns(...a);
574+ }
575+ run(...a: Parameters<Actions["getRun"]>) {
576+ return this.actions.getRun(...a);
577+ }
578+ runJobs(...a: Parameters<Actions["jobs"]>) {
579+ return this.actions.jobs(...a);
580+ }
581+ claimJob(...a: Parameters<Actions["claimJob"]>) {
582+ return this.actions.claimJob(...a);
583+ }
584+ jobStatus(jobId: string): string {
585+ return this.actions.getJob(jobId).status;
586+ }
587+ job(...a: Parameters<Actions["getJob"]>) {
588+ return this.actions.getJob(...a);
589+ }
590+ jobLogs(...a: Parameters<Actions["logs"]>) {
591+ return this.actions.logs(...a);
592+ }
593+ cancelRun(...a: Parameters<Actions["cancel"]>) {
594+ return this.actions.cancel(...a);
595+ }
596+ rerun(...a: Parameters<Actions["rerun"]>) {
597+ return this.actions.rerun(...a);
598+ }
599+ dispatchWorkflow(...a: Parameters<Actions["dispatch"]>) {
600+ return this.actions.dispatch(...a);
601+ }
602+ scheduleTick(...a: Parameters<Actions["schedule"]>) {
603+ return this.actions.schedule(...a);
604+ }
605+ onPullRequestEvent(...a: Parameters<Actions["onPullRequest"]>) {
606+ return this.actions.onPullRequest(...a);
607+ }
608+ workflows(...a: Parameters<Actions["listWorkflows"]>) {
609+ return this.actions.listWorkflows(...a);
610+ }
611+ runPlan(...a: Parameters<Actions["plan"]>) {
612+ return this.actions.plan(...a);
613+ }
614+ startJobKey(...a: Parameters<Actions["startJobKey"]>) {
615+ return this.actions.startJobKey(...a);
616+ }
617+ jobTimedOut(...a: Parameters<Actions["jobTimedOut"]>) {
618+ return this.actions.jobTimedOut(...a);
619+ }
620+ finishRun(...a: Parameters<Actions["finishRun"]>) {
621+ return this.actions.finishRun(...a);
622+ }
623+ oidcClaims(...a: Parameters<Actions["oidcClaims"]>) {
624+ return this.actions.oidcClaims(...a);
625+ }
626+ runnerSpec(...a: Parameters<Actions["runnerSpec"]>) {
627+ return this.actions.runnerSpec(...a);
628+ }
629+ runnerLogs(...a: Parameters<Actions["runnerLogs"]>) {
630+ return this.actions.runnerLogs(...a);
631+ }
632+ runnerStep(...a: Parameters<Actions["runnerStep"]>) {
633+ return this.actions.runnerStep(...a);
634+ }
635+ runnerComplete(...a: Parameters<Actions["runnerComplete"]>) {
636+ return this.actions.runnerComplete(...a);
637+ }
638+ runnerAuth(...a: Parameters<Actions["runnerAuth"]>) {
639+ return this.actions.runnerAuth(...a);
640+ }
641+ containerStopped(...a: Parameters<Actions["containerStopped"]>) {
642+ return this.actions.containerStopped(...a);
643+ }
644+ setSecret(...a: Parameters<Actions["setSecret"]>) {
645+ return this.actions.setSecret(...a);
646+ }
647+ deleteSecret(...a: Parameters<Actions["deleteSecret"]>) {
648+ return this.actions.deleteSecret(...a);
649+ }
650+ secretNames(...a: Parameters<Actions["secretNames"]>) {
651+ return this.actions.secretNames(...a);
652+ }
653+ setVariable(...a: Parameters<Actions["setVariable"]>) {
654+ return this.actions.setVariable(...a);
655+ }
656+ variables(...a: Parameters<Actions["variables"]>) {
657+ return this.actions.variables(...a);
658+ }
659+ cacheLookup(...a: Parameters<Actions["cacheLookup"]>) {
660+ return this.actions.cacheLookup(...a);
661+ }
662+ cacheSaved(...a: Parameters<Actions["cacheSaved"]>) {
663+ return this.actions.cacheSaved(...a);
664+ }
665+ artifactSaved(...a: Parameters<Actions["artifactSaved"]>) {
666+ return this.actions.artifactSaved(...a);
667+ }
668+ artifacts(...a: Parameters<Actions["artifacts"]>) {
669+ return this.actions.artifacts(...a);
670+ }
671+ createSandbox(...a: Parameters<Actions["createSandbox"]>) {
672+ return this.actions.createSandbox(...a);
673+ }
674+ sandbox(...a: Parameters<Actions["getSandbox"]>) {
675+ return this.actions.getSandbox(...a);
676+ }
677+ closeSandbox(...a: Parameters<Actions["closeSandbox"]>) {
678+ return this.actions.closeSandbox(...a);
679+ }
680+
681+ plannedRunError(runId: string, message: string) {
682+ return this.actions.plannedError(runId, message);
683+ }
684+ cacheKeyFor(key: string) {
685+ return this.actions.cacheR2Key(key);
686+ }
687+ artifactKeyFor(jobId: string, name: string) {
688+ return this.actions.artifactR2Key(this.actions.runIdForJob(jobId), name);
689+ }
690+ artifactKeyForRun(runId: string, name: string) {
691+ return this.actions.artifactR2Key(runId, name);
692+ }
693+
694+ // ---------------------------------------------------------------- live updates (WebSockets with hibernation)
695+
696+ override async fetch(request: Request): Promise<Response> {
697+ if (request.headers.get("Upgrade") !== "websocket") return new Response("expected websocket", { status: 426 });
698+ const topics = (new URL(request.url).searchParams.get("topics") ?? "repo").split(",").filter((t) => /^[a-z]+(:[A-Za-z0-9_-]+)?$/.test(t)).slice(0, 10);
699+ const pair = new WebSocketPair();
700+ this.ctx.acceptWebSocket(pair[1], topics.length ? topics : ["repo"]);
701+ return new Response(null, { status: 101, webSocket: pair[0] });
702+ }
703+
704+ override async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
705+ if (message === "ping") ws.send("pong");
706+ }
707+
708+ override async webSocketClose(ws: WebSocket, code: number): Promise<void> {
709+ try {
710+ ws.close(code, "bye");
711+ } catch {
712+ /* already closed */
713+ }
714+ }
715+
716+ broadcast(topic: string, data: unknown): void {
717+ const msg = JSON.stringify({ topic, data });
718+ for (const ws of this.ctx.getWebSockets(topic)) {
719+ try {
720+ ws.send(msg);
721+ } catch {
722+ /* client went away */
723+ }
724+ }
725+ }
726+}
727+
728+export function toInfo(sha: string, c: Commit): CommitInfo {
729+ return { sha, tree: c.tree, parents: c.parents, author: c.author, committer: c.committer, message: c.message, signed: !!c.gpgsig };
730+}
731+
732+export { fromUtf8 };
addedapps/diffinery/src/repo/actions.ts+1048−0View file
@@ -1,0 +1,1048 @@
1+import { ZERO_OID, diffTrees, fromUtf8, isTreeMode, readCommit, readTree, resolvePath } from "@diffinery/git-core";
2+import type { Actor, Conclusion, Json, JobCompletion, JobSpec, LogLine, NeedsResult, StepReport } from "@diffinery/shared";
3+import {
4+ evaluateIf,
5+ evaluateValue,
6+ expandMatrix,
7+ interpolate,
8+ isWarning,
9+ jobOrder,
10+ matchesTrigger,
11+ parseWorkflow,
12+ type ExprContext,
13+ type Job,
14+ type StatusFns,
15+ type TriggerEvent,
16+ type Workflow,
17+} from "@diffinery/workflow-yaml";
18+import { decryptSecret, encryptSecret } from "../lib/crypto.js";
19+import { HttpError, newId, now, randomToken, sha256 } from "../lib/util.js";
20+import type { Env } from "../env.js";
21+import type { RepoDO } from "./RepoDO.js";
22+import { atLeast, type Caller } from "./types.js";
23+
24+const WORKFLOW_DIRS = [".github/workflows", ".diffinery/workflows"];
25+const SUPPORTED_LABEL = /^(ubuntu-.*|linux|diffinery(-.*)?|self-hosted|x64|amd64)$/i;
26+
27+export type RunRow = {
28+ id: string;
29+ run_number: number;
30+ workflow_path: string;
31+ workflow_name: string;
32+ event: string;
33+ ref: string;
34+ sha: string;
35+ actor: string;
36+ status: "queued" | "in_progress" | "completed" | "waiting";
37+ conclusion: Conclusion | "timed_out" | null;
38+ created_at: string;
39+ updated_at: string;
40+ pr_number: number | null;
41+ attempt: number;
42+ event_payload: string;
43+ inputs: string;
44+ concurrency_group: string | null;
45+ display_title: string;
46+};
47+
48+export type JobRow = {
49+ id: string;
50+ run_id: string;
51+ job_key: string;
52+ name: string;
53+ status: "queued" | "waiting" | "in_progress" | "completed";
54+ conclusion: Conclusion | "timed_out" | null;
55+ matrix: string;
56+ strategy: string;
57+ started_at: string | null;
58+ completed_at: string | null;
59+ outputs: string;
60+ steps: string;
61+ failure: string | null;
62+ token_hash: string | null;
63+ summary: string | null;
64+ container: string | null;
65+ timeout_minutes: number;
66+ /** runs-on labels for self-hosted jobs (JSON array); null for Diffinery-hosted containers. */
67+ labels: string | null;
68+};
69+
70+export type RunInfo = {
71+ id: string;
72+ number: number;
73+ attempt: number;
74+ workflowPath: string;
75+ workflowName: string;
76+ title: string;
77+ event: string;
78+ ref: string;
79+ sha: string;
80+ actor: Actor;
81+ status: RunRow["status"];
82+ conclusion: RunRow["conclusion"];
83+ createdAt: string;
84+ updatedAt: string;
85+ prNumber: number | null;
86+};
87+
88+export type JobInfo = {
89+ id: string;
90+ runId: string;
91+ key: string;
92+ name: string;
93+ status: JobRow["status"];
94+ conclusion: JobRow["conclusion"];
95+ matrix: Record<string, Json>;
96+ startedAt: string | null;
97+ completedAt: string | null;
98+ outputs: Record<string, string>;
99+ steps: Array<StepReport & { index: number }>;
100+ failure: JobCompletion["failure"] | null;
101+ summary: string | null;
102+};
103+
104+const toRun = (r: RunRow): RunInfo => ({
105+ id: r.id,
106+ number: r.run_number,
107+ attempt: r.attempt,
108+ workflowPath: r.workflow_path,
109+ workflowName: r.workflow_name,
110+ title: r.display_title,
111+ event: r.event,
112+ ref: r.ref,
113+ sha: r.sha,
114+ actor: JSON.parse(r.actor),
115+ status: r.status,
116+ conclusion: r.conclusion,
117+ createdAt: r.created_at,
118+ updatedAt: r.updated_at,
119+ prNumber: r.pr_number,
120+});
121+
122+const toJob = (j: JobRow): JobInfo => ({
123+ id: j.id,
124+ runId: j.run_id,
125+ key: j.job_key,
126+ name: j.name,
127+ status: j.status,
128+ conclusion: j.conclusion,
129+ matrix: JSON.parse(j.matrix),
130+ startedAt: j.started_at,
131+ completedAt: j.completed_at,
132+ outputs: JSON.parse(j.outputs),
133+ steps: JSON.parse(j.steps),
134+ failure: j.failure ? JSON.parse(j.failure) : null,
135+ summary: j.summary,
136+});
137+
138+const statusFor = (needs: NeedsResult[], cancelled: boolean): StatusFns => ({
139+ success: () => !cancelled && needs.every((n) => n.result === "success" || n.result === "skipped"),
140+ failure: () => needs.some((n) => n.result === "failure"),
141+ cancelled: () => cancelled,
142+ always: () => true,
143+});
144+
145+/**
146+ * GitHub Actions compatible run orchestration state. The Cloudflare Workflow
147+ * (`RunWorkflow`) drives a run through these methods; the runner agent inside
148+ * each job's container reports back through the runner* methods.
149+ */
150+export class Actions {
151+ constructor(
152+ private repo: RepoDO,
153+ private env: Env,
154+ ) {}
155+
156+ private get sql() {
157+ return this.repo.sql;
158+ }
159+
160+ // ---------------------------------------------------------------- workflow discovery
161+
162+ async loadWorkflows(sha: string): Promise<Array<{ path: string; workflow?: Workflow; errors: string[] }>> {
163+ const commit = await readCommit(this.repo.store, sha);
164+ const out: Array<{ path: string; workflow?: Workflow; errors: string[] }> = [];
165+ for (const dir of WORKFLOW_DIRS) {
166+ const entry = await resolvePath(this.repo.store, commit.tree, dir);
167+ if (!entry || !isTreeMode(entry.mode)) continue;
168+ for (const f of await readTree(this.repo.store, entry.oid)) {
169+ if (!/\.ya?ml$/.test(f.name) || isTreeMode(f.mode)) continue;
170+ const path = `${dir}/${f.name}`;
171+ const o = await this.repo.store.read(f.oid);
172+ if (!o) continue;
173+ const { workflow, errors } = parseWorkflow(fromUtf8(o.content), path);
174+ out.push({ path, workflow, errors: errors.filter((e) => !isWarning(e)).map((e) => `${e.path}${e.line ? `:${e.line}` : ""}: ${e.message}`) });
175+ }
176+ }
177+ return out;
178+ }
179+
180+ async listWorkflows(): Promise<Array<{ path: string; name: string; errors: string[]; triggers: string[] }>> {
181+ const sha = await this.repo.resolve("HEAD");
182+ if (!sha) return [];
183+ return (await this.loadWorkflows(sha)).map((w) => ({ path: w.path, name: w.workflow?.name ?? w.path.split("/").pop()!, errors: w.errors, triggers: w.workflow ? Object.keys(w.workflow.on) : [] }));
184+ }
185+
186+ private repositoryPayload() {
187+ const m = this.repo.meta;
188+ return { id: m.repoId, name: m.name, full_name: `${m.owner}/${m.name}`, owner: { login: m.owner }, default_branch: m.defaultBranch, private: true, html_url: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}`, clone_url: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}.git` };
189+ }
190+
191+ // ---------------------------------------------------------------- triggers
192+
193+ async onPush(ev: { ref: string; before: string; after: string; forced: boolean; created: boolean; deleted: boolean }, caller: Caller): Promise<void> {
194+ if (ev.deleted) return;
195+ const workflows = await this.loadWorkflows(ev.after);
196+ let changedFiles: string[] | undefined;
197+ if (ev.before !== ZERO_OID && this.repo.store.has(ev.before)) {
198+ const [a, b] = await Promise.all([readCommit(this.repo.store, ev.before), readCommit(this.repo.store, ev.after)]);
199+ changedFiles = (await diffTrees(this.repo.store, a.tree, b.tree)).map((c) => c.path);
200+ }
201+ const head = await readCommit(this.repo.store, ev.after);
202+ const payload = {
203+ ref: ev.ref,
204+ before: ev.before,
205+ after: ev.after,
206+ created: ev.created,
207+ deleted: ev.deleted,
208+ forced: ev.forced,
209+ repository: this.repositoryPayload(),
210+ pusher: { name: caller.login },
211+ sender: { login: caller.login, type: caller.kind === "agent" ? "Bot" : "User" },
212+ head_commit: { id: ev.after, message: head.message, timestamp: new Date(head.committer.timestamp * 1000).toISOString(), author: { name: head.author.name, email: head.author.email } },
213+ commits: [{ id: ev.after, message: head.message }],
214+ };
215+ if (ev.ref === `refs/heads/${this.repo.meta.defaultBranch}`) await this.syncSchedules(workflows);
216+ for (const w of workflows) {
217+ if (!w.workflow) {
218+ if (w.errors.length) await this.createFailedRun(w.path, "push", ev.ref, ev.after, caller, w.errors);
219+ continue;
220+ }
221+ if (!matchesTrigger(w.workflow, { name: "push", ref: ev.ref, changedFiles })) continue;
222+ await this.createRun(w.workflow, { event: "push", ref: ev.ref, sha: ev.after, payload, caller, title: head.message.split("\n")[0] ?? "" });
223+ }
224+ }
225+
226+ async onPullRequest(action: string, number: number, caller: Caller): Promise<void> {
227+ const { issue } = this.repo.collab.get(number);
228+ const pull = issue.pull!;
229+ const files = await this.repo.collab.pullFiles(number);
230+ const workflows = await this.loadWorkflows(pull.headSha);
231+ const baseSha = this.repo.getRef(`refs/heads/${pull.base}`);
232+ const payload = {
233+ action,
234+ number,
235+ pull_request: {
236+ number,
237+ title: issue.title,
238+ body: issue.body,
239+ draft: pull.draft,
240+ user: { login: issue.author.login },
241+ head: { ref: pull.head, sha: pull.headSha, repo: this.repositoryPayload() },
242+ base: { ref: pull.base, sha: baseSha, repo: this.repositoryPayload() },
243+ html_url: `${this.env.PUBLIC_URL}/${this.repo.meta.owner}/${this.repo.meta.name}/pull/${number}`,
244+ },
245+ repository: this.repositoryPayload(),
246+ sender: { login: caller.login },
247+ };
248+ for (const w of workflows) {
249+ if (!w.workflow) continue;
250+ for (const name of ["pull_request", "pull_request_target"] as const) {
251+ const ev: TriggerEvent = { name, action, baseRef: pull.base, changedFiles: files.files.map((f) => f.path) };
252+ if (!matchesTrigger(w.workflow, ev)) continue;
253+ await this.createRun(w.workflow, {
254+ event: name,
255+ ref: `refs/pull/${number}/head`,
256+ sha: pull.headSha,
257+ payload,
258+ caller,
259+ prNumber: number,
260+ headRef: pull.head,
261+ baseRef: pull.base,
262+ title: issue.title,
263+ });
264+ }
265+ }
266+ }
267+
268+ async dispatch(workflowFile: string, ref: string, inputs: Record<string, unknown>, caller: Caller): Promise<RunInfo> {
269+ if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to run workflows");
270+ const sha = await this.repo.resolve(ref);
271+ if (!sha) throw new HttpError(422, `unknown ref ${ref}`);
272+ const all = await this.loadWorkflows(sha);
273+ const w = all.find((x) => x.path === workflowFile || x.path.endsWith(`/${workflowFile}`));
274+ if (!w?.workflow) throw new HttpError(404, `workflow ${workflowFile} not found at ${ref}${w?.errors.length ? `: ${w.errors.join("; ")}` : ""}`);
275+ if (!w.workflow.on.workflow_dispatch) throw new HttpError(422, "workflow does not have a workflow_dispatch trigger");
276+ const defs = w.workflow.on.workflow_dispatch.inputs ?? {};
277+ const resolved: Record<string, unknown> = {};
278+ for (const [k, d] of Object.entries(defs)) {
279+ const v = inputs[k] ?? d.default;
280+ if (d.required && (v === undefined || v === "")) throw new HttpError(422, `input '${k}' is required`);
281+ if (d.type === "choice" && d.options && v !== undefined && !d.options.includes(String(v))) throw new HttpError(422, `input '${k}' must be one of ${d.options.join(", ")}`);
282+ resolved[k] = d.type === "boolean" ? v === true || v === "true" : d.type === "number" ? Number(v) : v;
283+ }
284+ const fullRef = ref.startsWith("refs/") ? ref : this.repo.getRef(`refs/heads/${ref}`) ? `refs/heads/${ref}` : `refs/tags/${ref}`;
285+ const payload = { inputs: resolved, ref: fullRef, repository: this.repositoryPayload(), sender: { login: caller.login }, workflow: w.path };
286+ return (await this.createRun(w.workflow, { event: "workflow_dispatch", ref: fullRef, sha, payload, caller, inputs: resolved, title: w.workflow.name ?? w.path }))!;
287+ }
288+
289+ async schedule(workflowPath: string, cron: string): Promise<void> {
290+ const sha = await this.repo.resolve("HEAD");
291+ if (!sha) return;
292+ const w = (await this.loadWorkflows(sha)).find((x) => x.path === workflowPath);
293+ if (!w?.workflow || !matchesTrigger(w.workflow, { name: "schedule", cron })) return;
294+ const system: Caller = { id: "system", login: "diffinery-scheduler", kind: "system", role: "write" };
295+ await this.createRun(w.workflow, { event: "schedule", ref: this.repo.headRef, sha, payload: { schedule: cron, repository: this.repositoryPayload() }, caller: system, title: w.workflow.name ?? w.path });
296+ }
297+
298+ private async syncSchedules(workflows: Array<{ path: string; workflow?: Workflow }>): Promise<void> {
299+ const repoId = this.repo.meta.repoId;
300+ const stmts = [this.env.DB.prepare("DELETE FROM schedules WHERE repo_id = ?").bind(repoId)];
301+ for (const w of workflows) for (const s of w.workflow?.on.schedule ?? []) stmts.push(this.env.DB.prepare("INSERT OR IGNORE INTO schedules (repo_id, workflow_path, cron) VALUES (?, ?, ?)").bind(repoId, w.path, s.cron));
302+ await this.env.DB.batch(stmts);
303+ }
304+
305+ // ---------------------------------------------------------------- runs
306+
307+ private nextRunNumber(): number {
308+ this.sql.exec("INSERT INTO counters (name, value) VALUES ('run', 1) ON CONFLICT(name) DO UPDATE SET value = value + 1");
309+ return this.sql.exec<{ value: number }>("SELECT value FROM counters WHERE name = 'run'").one().value;
310+ }
311+
312+ private githubContext(run: RunRow, jobKey: string, token: string): Record<string, unknown> {
313+ const m = this.repo.meta;
314+ const payload = JSON.parse(run.event_payload) as Record<string, unknown>;
315+ const actor = JSON.parse(run.actor) as Actor;
316+ const refName = run.ref.replace(/^refs\/(heads|tags)\//, "").replace(/^refs\/pull\/(\d+)\/head$/, "$1/merge");
317+ const pr = payload.pull_request as { head?: { ref?: string }; base?: { ref?: string } } | undefined;
318+ return {
319+ event_name: run.event,
320+ event: payload,
321+ sha: run.sha,
322+ ref: run.ref,
323+ ref_name: refName,
324+ ref_type: run.ref.startsWith("refs/tags/") ? "tag" : "branch",
325+ ref_protected: false,
326+ head_ref: pr?.head?.ref ?? "",
327+ base_ref: pr?.base?.ref ?? "",
328+ repository: `${m.owner}/${m.name}`,
329+ repository_id: m.repoId,
330+ repository_owner: m.owner,
331+ repositoryUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}.git`,
332+ actor: actor.login,
333+ triggering_actor: actor.login,
334+ run_id: run.id,
335+ run_number: String(run.run_number),
336+ run_attempt: String(run.attempt),
337+ workflow: run.workflow_name,
338+ workflow_ref: `${m.owner}/${m.name}/${run.workflow_path}@${run.ref}`,
339+ job: jobKey,
340+ server_url: this.env.PUBLIC_URL,
341+ api_url: `${this.env.PUBLIC_URL}/api/v3`,
342+ graphql_url: `${this.env.PUBLIC_URL}/api/graphql`,
343+ token,
344+ retention_days: "90",
345+ secret_source: "Actions",
346+ };
347+ }
348+
349+ private async createFailedRun(path: string, event: string, ref: string, sha: string, caller: Caller, errors: string[]): Promise<void> {
350+ const id = newId();
351+ const t = now();
352+ this.sql.exec(
353+ "INSERT INTO runs (id, run_number, workflow_path, workflow_name, event, ref, sha, actor, status, conclusion, created_at, updated_at, event_payload, display_title) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'completed', 'failure', ?, ?, ?, ?)",
354+ id,
355+ this.nextRunNumber(),
356+ path,
357+ path,
358+ event,
359+ ref,
360+ sha,
361+ JSON.stringify({ id: caller.id, login: caller.login, kind: caller.kind }),
362+ t,
363+ t,
364+ "{}",
365+ `Invalid workflow file: ${errors[0] ?? path}`,
366+ );
367+ const jobId = newId();
368+ this.sql.exec(
369+ "INSERT INTO jobs (id, run_id, job_key, name, status, conclusion, started_at, completed_at, failure) VALUES (?, ?, 'invalid', 'Invalid workflow file', 'completed', 'failure', ?, ?, ?)",
370+ jobId,
371+ id,
372+ t,
373+ t,
374+ JSON.stringify({ message: errors.join("\n") }),
375+ );
376+ }
377+
378+ async createRun(
379+ wf: Workflow,
380+ o: { event: string; ref: string; sha: string; payload: Record<string, unknown>; caller: Caller | { id: string; login: string; kind: Actor["kind"] }; prNumber?: number; inputs?: Record<string, unknown>; headRef?: string; baseRef?: string; title: string },
381+ ): Promise<RunInfo | null> {
382+ const id = newId();
383+ const t = now();
384+ const run: RunRow = {
385+ id,
386+ run_number: this.nextRunNumber(),
387+ workflow_path: wf.path,
388+ workflow_name: wf.name ?? wf.path,
389+ event: o.event,
390+ ref: o.ref,
391+ sha: o.sha,
392+ actor: JSON.stringify({ id: o.caller.id, login: o.caller.login, kind: o.caller.kind }),
393+ status: "queued",
394+ conclusion: null,
395+ created_at: t,
396+ updated_at: t,
397+ pr_number: o.prNumber ?? null,
398+ attempt: 1,
399+ event_payload: JSON.stringify(o.payload),
400+ inputs: JSON.stringify(o.inputs ?? {}),
401+ concurrency_group: null,
402+ display_title: o.title,
403+ };
404+ const ctx: ExprContext = { github: this.githubContext(run, "", ""), inputs: o.inputs ?? {}, vars: this.variables() };
405+ try {
406+ if (wf.runName) run.display_title = interpolate(wf.runName, ctx);
407+ if (wf.concurrency) run.concurrency_group = interpolate(wf.concurrency.group, ctx);
408+ } catch (e) {
409+ console.warn("run-name/concurrency evaluation failed", e);
410+ }
411+ this.sql.exec(
412+ "INSERT INTO runs (id, run_number, workflow_path, workflow_name, event, ref, sha, actor, status, created_at, updated_at, pr_number, attempt, event_payload, inputs, concurrency_group, display_title) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
413+ run.id,
414+ run.run_number,
415+ run.workflow_path,
416+ run.workflow_name,
417+ run.event,
418+ run.ref,
419+ run.sha,
420+ run.actor,
421+ run.status,
422+ run.created_at,
423+ run.updated_at,
424+ run.pr_number,
425+ run.attempt,
426+ run.event_payload,
427+ run.inputs,
428+ run.concurrency_group,
429+ run.display_title,
430+ );
431+ if (run.concurrency_group && wf.concurrency) {
432+ const cancelInProgress = evaluateValue(wf.concurrency.cancelInProgress, ctx);
433+ const others = this.sql.exec<{ id: string }>("SELECT id FROM runs WHERE concurrency_group = ? AND status != 'completed' AND id != ?", run.concurrency_group, id).toArray();
434+ for (const other of others) if (cancelInProgress === true || cancelInProgress === "true") await this.cancelInternal(other.id, "cancelled by a newer run in the same concurrency group");
435+ }
436+ await this.env.RUN_WORKFLOW.create({ id, params: { repoId: this.repo.meta.repoId, runId: id } });
437+ const m = this.repo.meta;
438+ this.repo.emit({ type: "workflow_run", action: "requested", repoId: m.repoId, owner: m.owner, repo: m.name, runId: id, actor: JSON.parse(run.actor), at: t });
439+ this.repo.broadcast("runs", { action: "created", runId: id });
440+ return toRun(run);
441+ }
442+
443+ private runRow(id: string): RunRow {
444+ const r = this.sql.exec<RunRow>("SELECT * FROM runs WHERE id = ?", id).toArray()[0];
445+ if (!r) throw new HttpError(404, "run not found");
446+ return r;
447+ }
448+
449+ private jobRow(id: string): JobRow {
450+ const j = this.sql.exec<JobRow>("SELECT * FROM jobs WHERE id = ?", id).toArray()[0];
451+ if (!j) throw new HttpError(404, "job not found");
452+ return j;
453+ }
454+
455+ listRuns(opts: { workflow?: string; branch?: string; status?: string; event?: string; sha?: string; prNumber?: number; limit?: number; offset?: number } = {}): { runs: RunInfo[]; total: number } {
456+ const where: string[] = [];
457+ const args: unknown[] = [];
458+ if (opts.workflow) {
459+ where.push("(workflow_path = ? OR workflow_path LIKE ?)");
460+ args.push(opts.workflow, `%/${opts.workflow}`);
461+ }
462+ if (opts.branch) {
463+ where.push("ref = ?");
464+ args.push(`refs/heads/${opts.branch}`);
465+ }
466+ if (opts.status) {
467+ where.push(opts.status === "completed" || opts.status === "queued" || opts.status === "in_progress" ? "status = ?" : "conclusion = ?");
468+ args.push(opts.status);
469+ }
470+ if (opts.event) {
471+ where.push("event = ?");
472+ args.push(opts.event);
473+ }
474+ if (opts.sha) {
475+ where.push("sha = ?");
476+ args.push(opts.sha);
477+ }
478+ if (opts.prNumber) {
479+ where.push("pr_number = ?");
480+ args.push(opts.prNumber);
481+ }
482+ const w = where.length ? `WHERE ${where.join(" AND ")}` : "";
483+ const total = this.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM runs ${w}`, ...args).one().n;
484+ const rows = this.sql.exec<RunRow>(`SELECT * FROM runs ${w} ORDER BY created_at DESC LIMIT ? OFFSET ?`, ...args, Math.min(opts.limit ?? 25, 100), opts.offset ?? 0).toArray();
485+ return { runs: rows.map(toRun), total };
486+ }
487+
488+ getRun(id: string): RunInfo & { jobs: JobInfo[] } {
489+ return { ...toRun(this.runRow(id)), jobs: this.jobs(id) };
490+ }
491+
492+ jobs(runId: string): JobInfo[] {
493+ return this.sql.exec<JobRow>("SELECT * FROM jobs WHERE run_id = ? ORDER BY rowid", runId).toArray().map(toJob);
494+ }
495+
496+ getJob(id: string): JobInfo {
497+ return toJob(this.jobRow(id));
498+ }
499+
500+ async logs(jobId: string, afterSeq = -1): Promise<{ lines: Array<LogLine & { seq: number }>; complete: boolean }> {
501+ const j = this.jobRow(jobId);
502+ const rows = this.sql.exec<LogLine & { seq: number }>("SELECT seq, t, step, text FROM logs WHERE job_id = ? AND seq > ? ORDER BY seq LIMIT 5000", jobId, afterSeq).toArray();
503+ if (rows.length === 0 && j.status === "completed") {
504+ const archived = await this.env.BUCKET.get(this.logKey(jobId));
505+ if (archived && afterSeq < 0) return { lines: (await archived.json()) as Array<LogLine & { seq: number }>, complete: true };
506+ }
507+ return { lines: rows, complete: j.status === "completed" };
508+ }
509+
510+ private logKey(jobId: string) {
511+ return `repos/${this.repo.meta.repoId}/logs/${jobId}.json`;
512+ }
513+
514+ // ---------------------------------------------------------------- orchestration (called by RunWorkflow)
515+
516+ async plan(runId: string): Promise<{ levels: string[][]; error?: string; concurrencyBlocked: boolean }> {
517+ const run = this.runRow(runId);
518+ if (run.status === "completed") return { levels: [], error: "run already completed", concurrencyBlocked: false };
519+ const wf = (await this.loadWorkflows(run.sha)).find((w) => w.path === run.workflow_path);
520+ if (!wf?.workflow) return { levels: [], error: `workflow file not found or invalid: ${wf?.errors.join("; ") ?? run.workflow_path}`, concurrencyBlocked: false };
521+ const { order, errors } = jobOrder(wf.workflow);
522+ if (errors.length) return { levels: [], error: errors.map((e) => e.message).join("; "), concurrencyBlocked: false };
523+ const blocked =
524+ !!run.concurrency_group &&
525+ this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM runs WHERE concurrency_group = ? AND status = 'in_progress' AND id != ?", run.concurrency_group, runId).one().n > 0;
526+ if (!blocked) this.sql.exec("UPDATE runs SET status = 'in_progress', updated_at = ? WHERE id = ? AND status = 'queued'", now(), runId);
527+ return { levels: order, concurrencyBlocked: blocked };
528+ }
529+
530+ private needsResults(runId: string, keys: string[]): Record<string, NeedsResult> {
531+ const out: Record<string, NeedsResult> = {};
532+ for (const key of keys) {
533+ const rows = this.sql.exec<JobRow>("SELECT * FROM jobs WHERE run_id = ? AND job_key = ?", runId, key).toArray();
534+ let result: Conclusion = "success";
535+ const outputs: Record<string, string> = {};
536+ if (rows.length === 0) result = "skipped";
537+ for (const r of rows) {
538+ Object.assign(outputs, JSON.parse(r.outputs));
539+ const c = r.conclusion === "timed_out" ? "failure" : (r.conclusion ?? "failure");
540+ if (c === "failure") result = "failure";
541+ else if (c === "cancelled" && result !== "failure") result = "cancelled";
542+ else if (c === "skipped" && result === "success" && rows.length === 1) result = "skipped";
543+ }
544+ out[key] = { result, outputs };
545+ }
546+ return out;
547+ }
548+
549+ /**
550+ * Start every matrix instance of a job. Returns the ids the workflow must wait
551+ * for (instances beyond max-parallel are queued and started as others finish).
552+ */
553+ async startJobKey(runId: string, jobKey: string): Promise<{ jobIds: string[] }> {
554+ const run = this.runRow(runId);
555+ const wf = (await this.loadWorkflows(run.sha)).find((w) => w.path === run.workflow_path)!.workflow!;
556+ const job = wf.jobs[jobKey]!;
557+ const cancelled = run.conclusion === "cancelled";
558+ const needs = this.needsResults(runId, job.needs);
559+ const status = statusFor(Object.values(needs), cancelled);
560+ const baseCtx: ExprContext = { github: this.githubContext(run, jobKey, ""), needs, inputs: JSON.parse(run.inputs), vars: this.variables(), env: wf.env };
561+ const fail = (name: string, message: string, conclusion: Conclusion = "failure") => {
562+ const id = newId();
563+ const t = now();
564+ this.sql.exec("INSERT INTO jobs (id, run_id, job_key, name, status, conclusion, started_at, completed_at, failure) VALUES (?, ?, ?, ?, 'completed', ?, ?, ?, ?)", id, runId, jobKey, name, conclusion, t, t, conclusion === "failure" ? JSON.stringify({ message }) : null);
565+ this.repo.broadcast(`run:${runId}`, { action: "job", jobId: id });
566+ return { jobIds: [] as string[] };
567+ };
568+ let shouldRun: boolean;
569+ try {
570+ shouldRun = evaluateIf(job.if, baseCtx, status);
571+ } catch (e) {
572+ return fail(job.name ?? jobKey, `Error evaluating 'if': ${(e as Error).message}`);
573+ }
574+ if (!shouldRun) return fail(job.name ?? jobKey, "", "skipped");
575+ if (job.uses) return fail(job.name ?? jobKey, `Reusable workflows (jobs.${jobKey}.uses) are not supported yet on Diffinery.`);
576+ const labels = job.runsOn.map((l) => String(evaluateValue(l, baseCtx)));
577+ const unsupported = labels.filter((l) => !SUPPORTED_LABEL.test(l));
578+ const selfHosted = labels.some((l) => l.toLowerCase() === "self-hosted");
579+ if (unsupported.length && !selfHosted) return fail(job.name ?? jobKey, `No Diffinery runner matches runs-on: ${unsupported.join(", ")}. Linux (ubuntu-*) runners are hosted; anything else needs runs-on: [self-hosted, ...] and a registered runner.`);
580+ if (job.container) return fail(job.name ?? jobKey, "Job containers (jobs.<id>.container) are not supported yet; steps run directly on the Ubuntu runner image.");
581+ if (job.services && Object.keys(job.services).length) return fail(job.name ?? jobKey, "Service containers (jobs.<id>.services) are not supported yet on Diffinery runners.");
582+
583+ let combos: Array<Record<string, unknown>>;
584+ try {
585+ combos = expandMatrix(job.strategy?.matrix, baseCtx);
586+ } catch (e) {
587+ return fail(job.name ?? jobKey, `Error expanding matrix: ${(e as Error).message}`);
588+ }
589+ if (combos.length > 256) return fail(job.name ?? jobKey, "A matrix may produce at most 256 jobs.");
590+ const failFast = evaluateValue(job.strategy?.failFast ?? true, baseCtx) !== false;
591+ const maxParallel = Number(evaluateValue(job.strategy?.maxParallel ?? combos.length, baseCtx)) || combos.length;
592+ const ids: string[] = [];
593+ combos.forEach((matrix, i) => {
594+ const ctx = { ...baseCtx, matrix, strategy: { "fail-fast": failFast, "job-index": i, "job-total": combos.length, "max-parallel": maxParallel } };
595+ let name = job.name ? interpolate(job.name, ctx) : jobKey;
596+ if (!job.name && Object.keys(matrix).length) name = `${jobKey} (${Object.values(matrix).map((v) => (typeof v === "object" ? JSON.stringify(v) : String(v))).join(", ")})`;
597+ const timeout = Number(evaluateValue(job.timeoutMinutes, ctx)) || 360;
598+ const id = newId();
599+ this.sql.exec(
600+ "INSERT INTO jobs (id, run_id, job_key, name, status, matrix, strategy, timeout_minutes, labels) VALUES (?, ?, ?, ?, 'queued', ?, ?, ?, ?)",
601+ id,
602+ runId,
603+ jobKey,
604+ name,
605+ JSON.stringify(matrix),
606+ JSON.stringify(ctx.strategy),
607+ Math.min(timeout, 360),
608+ selfHosted ? JSON.stringify(labels.map((l) => l.toLowerCase())) : null,
609+ );
610+ ids.push(id);
611+ });
612+ for (const id of ids.slice(0, maxParallel)) await this.launch(id);
613+ this.repo.broadcast(`run:${runId}`, { action: "jobs" });
614+ return { jobIds: ids };
615+ }
616+
617+ private checkName(run: RunRow, job: JobRow): string {
618+ return `${run.workflow_name} / ${job.name}`;
619+ }
620+
621+ private async launch(jobId: string): Promise<void> {
622+ const job = this.jobRow(jobId);
623+ const run = this.runRow(job.run_id);
624+ const m = this.repo.meta;
625+ if (job.labels) {
626+ // Self-hosted: park the job until a registered runner with matching labels claims it.
627+ this.sql.exec("UPDATE jobs SET status = 'waiting', container = NULL WHERE id = ?", jobId);
628+ this.repo.upsertCheck({ sha: run.sha, name: this.checkName(run, job), status: "queued", runId: run.id, jobId, detailsUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}/job/${jobId}` });
629+ return;
630+ }
631+ const token = `djt_${m.repoId}_${jobId}_${randomToken(24)}`;
632+ const container = `job-${jobId}`;
633+ this.sql.exec("UPDATE jobs SET status = 'waiting', token_hash = ?, container = ? WHERE id = ?", await sha256(token), container, jobId);
634+ this.repo.upsertCheck({ sha: run.sha, name: this.checkName(run, job), status: "queued", runId: run.id, jobId, detailsUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}/job/${jobId}` });
635+ try {
636+ const stub = this.env.RUNNER.get(this.env.RUNNER.idFromName(container));
637+ await stub.startJob({ repoId: m.repoId, jobId, token, apiUrl: this.env.PUBLIC_URL });
638+ } catch (e) {
639+ await this.completeJob(jobId, { conclusion: "failure", outputs: {}, failure: { message: `Could not start a runner: ${(e as Error).message}` } });
640+ }
641+ }
642+
643+ async jobTimedOut(jobId: string): Promise<void> {
644+ const j = this.jobRow(jobId);
645+ if (j.status === "completed") return;
646+ await this.stopContainer(j);
647+ await this.completeJob(jobId, { conclusion: "failure", outputs: {}, failure: { message: `The job exceeded its timeout of ${j.timeout_minutes} minutes.` } }, "timed_out");
648+ }
649+
650+ async containerStopped(jobId: string, exitCode: number | null): Promise<void> {
651+ const j = this.sql.exec<JobRow>("SELECT * FROM jobs WHERE id = ?", jobId).toArray()[0];
652+ if (!j || j.status === "completed") return;
653+ await this.completeJob(jobId, { conclusion: "failure", outputs: {}, failure: { message: `The runner stopped unexpectedly (exit code ${exitCode ?? "unknown"}).` } });
654+ }
655+
656+ /**
657+ * A self-hosted runner asks for work. Returns the oldest waiting job whose
658+ * runs-on labels are all offered by the runner, with a fresh job token.
659+ */
660+ async claimJob(runnerName: string, runnerLabels: string[], caller: Caller): Promise<{ jobId: string; token: string; runId: string; name: string } | null> {
661+ if (!atLeast(caller.role, "admin")) throw new HttpError(403, "admin access is required to run self-hosted jobs");
662+ const offered = new Set(["self-hosted", ...runnerLabels.map((l) => l.toLowerCase())]);
663+ const waiting = this.sql
664+ .exec<JobRow>("SELECT j.* FROM jobs j JOIN runs r ON r.id = j.run_id WHERE j.status = 'waiting' AND j.labels IS NOT NULL AND j.container IS NULL AND r.status != 'completed' ORDER BY r.created_at, j.rowid")
665+ .toArray();
666+ const job = waiting.find((j) => (JSON.parse(j.labels!) as string[]).every((l) => offered.has(l)));
667+ if (!job) return null;
668+ const m = this.repo.meta;
669+ const token = `djt_${m.repoId}_${job.id}_${randomToken(24)}`;
670+ const container = `self-hosted:${runnerName.slice(0, 64)}`;
671+ this.sql.exec("UPDATE jobs SET token_hash = ?, container = ? WHERE id = ? AND container IS NULL", await sha256(token), container, job.id);
672+ this.repo.broadcast(`run:${job.run_id}`, { action: "job", jobId: job.id });
673+ return { jobId: job.id, token, runId: job.run_id, name: job.name };
674+ }
675+
676+ private async stopContainer(j: JobRow): Promise<void> {
677+ if (!j.container || j.container.startsWith("self-hosted:")) return;
678+ try {
679+ await this.env.RUNNER.get(this.env.RUNNER.idFromName(j.container)).stopJob();
680+ } catch (e) {
681+ console.warn("stop container failed", e);
682+ }
683+ }
684+
685+ async finishRun(runId: string): Promise<RunInfo> {
686+ const run = this.runRow(runId);
687+ const jobs = this.jobs(runId);
688+ let conclusion: RunRow["conclusion"] = run.conclusion === "cancelled" ? "cancelled" : "success";
689+ if (conclusion !== "cancelled") {
690+ if (jobs.some((j) => j.conclusion === "failure" || j.conclusion === "timed_out")) conclusion = "failure";
691+ else if (jobs.some((j) => j.conclusion === "cancelled")) conclusion = "cancelled";
692+ }
693+ const t = now();
694+ this.sql.exec("UPDATE runs SET status = 'completed', conclusion = ?, updated_at = ? WHERE id = ?", conclusion, t, runId);
695+ const m = this.repo.meta;
696+ this.repo.emit({ type: "workflow_run", action: "completed", repoId: m.repoId, owner: m.owner, repo: m.name, runId, conclusion: conclusion ?? undefined, actor: JSON.parse(run.actor), at: t });
697+ this.repo.broadcast(`run:${runId}`, { action: "completed", conclusion });
698+ this.repo.broadcast("runs", { action: "completed", runId });
699+ return toRun(this.runRow(runId));
700+ }
701+
702+ async plannedError(runId: string, message: string): Promise<void> {
703+ const t = now();
704+ this.sql.exec("INSERT INTO jobs (id, run_id, job_key, name, status, conclusion, started_at, completed_at, failure) VALUES (?, ?, 'setup', 'Set up workflow', 'completed', 'failure', ?, ?, ?)", newId(), runId, t, t, JSON.stringify({ message }));
705+ }
706+
707+ // ---------------------------------------------------------------- cancellation and re-runs
708+
709+ private async cancelInternal(runId: string, reason: string): Promise<void> {
710+ const run = this.runRow(runId);
711+ if (run.status === "completed") return;
712+ this.sql.exec("UPDATE runs SET conclusion = 'cancelled', updated_at = ? WHERE id = ?", now(), runId);
713+ for (const j of this.sql.exec<JobRow>("SELECT * FROM jobs WHERE run_id = ? AND status != 'completed'", runId).toArray()) {
714+ await this.stopContainer(j);
715+ await this.completeJob(j.id, { conclusion: "cancelled", outputs: {}, failure: { message: reason } });
716+ }
717+ }
718+
719+ async cancel(runId: string, caller: Caller): Promise<void> {
720+ if (!atLeast(caller.role, "write")) throw new HttpError(403, "permission denied");
721+ await this.cancelInternal(runId, `Cancelled by ${caller.login}`);
722+ }
723+
724+ async rerun(runId: string, caller: Caller): Promise<RunInfo> {
725+ if (!atLeast(caller.role, "write")) throw new HttpError(403, "permission denied");
726+ const old = this.runRow(runId);
727+ const wf = (await this.loadWorkflows(old.sha)).find((w) => w.path === old.workflow_path)?.workflow;
728+ if (!wf) throw new HttpError(422, "workflow file no longer valid at this commit");
729+ const run = await this.createRun(wf, {
730+ event: old.event,
731+ ref: old.ref,
732+ sha: old.sha,
733+ payload: JSON.parse(old.event_payload),
734+ caller,
735+ prNumber: old.pr_number ?? undefined,
736+ inputs: JSON.parse(old.inputs),
737+ title: old.display_title,
738+ });
739+ this.sql.exec("UPDATE runs SET attempt = ?, run_number = ? WHERE id = ?", old.attempt + 1, old.run_number, run!.id);
740+ return toRun(this.runRow(run!.id));
741+ }
742+
743+ // ---------------------------------------------------------------- runner protocol
744+
745+ /** Validate a job or sandbox token. Returns what it grants. */
746+ async runnerAuth(token: string): Promise<{ kind: "job"; jobId: string; runId: string } | { kind: "sandbox"; sandboxId: string; caller: Caller } | null> {
747+ const parts = token.split("_");
748+ if (parts.length < 4) return null;
749+ const hash = await sha256(token);
750+ if (parts[0] === "djt") {
751+ const j = this.sql.exec<JobRow>("SELECT * FROM jobs WHERE id = ?", parts[2]!).toArray()[0];
752+ if (!j || j.token_hash !== hash || j.status === "completed") return null;
753+ return { kind: "job", jobId: j.id, runId: j.run_id };
754+ }
755+ if (parts[0] === "dst") {
756+ const s = this.sql.exec<{ id: string; secret: string; created_by: string; status: string }>("SELECT * FROM sandboxes WHERE id = ?", parts[2]!).toArray()[0];
757+ if (!s || s.secret !== hash || s.status !== "running") return null;
758+ return { kind: "sandbox", sandboxId: s.id, caller: JSON.parse(s.created_by) as Caller };
759+ }
760+ return null;
761+ }
762+
763+ private async authJob(jobId: string, token: string): Promise<JobRow> {
764+ const auth = await this.runnerAuth(token);
765+ if (!auth || auth.kind !== "job" || auth.jobId !== jobId) throw new HttpError(401, "invalid job token");
766+ return this.jobRow(jobId);
767+ }
768+
769+ /** Claims for an OIDC id token; side-effect free (unlike runnerSpec). */
770+ async oidcClaims(jobId: string, token: string): Promise<{ allowed: boolean; github: Record<string, string> }> {
771+ const job = await this.authJob(jobId, token);
772+ if (job.status !== "in_progress") throw new HttpError(409, "job is not running");
773+ const run = this.runRow(job.run_id);
774+ const wf = (await this.loadWorkflows(run.sha)).find((w) => w.path === run.workflow_path)!.workflow!;
775+ const def: Job = wf.jobs[job.job_key]!;
776+ const allowed = wf.permissions === "write-all" || (typeof wf.permissions === "object" && wf.permissions["id-token"] === "write") || (typeof def.permissions === "object" && def.permissions["id-token"] === "write") || def.permissions === "write-all";
777+ const ctx = this.githubContext(run, job.job_key, token) as Record<string, Json>;
778+ const github: Record<string, string> = {};
779+ for (const [k, v] of Object.entries(ctx)) if (typeof v === "string" || typeof v === "number") github[k] = String(v);
780+ return { allowed, github };
781+ }
782+
783+ async runnerSpec(jobId: string, token: string): Promise<JobSpec> {
784+ const job = await this.authJob(jobId, token);
785+ const run = this.runRow(job.run_id);
786+ const wf = (await this.loadWorkflows(run.sha)).find((w) => w.path === run.workflow_path)!.workflow!;
787+ const def: Job = wf.jobs[job.job_key]!;
788+ const m = this.repo.meta;
789+ const t = now();
790+ this.sql.exec("UPDATE jobs SET status = 'in_progress', started_at = ? WHERE id = ?", t, jobId);
791+ this.repo.upsertCheck({ sha: run.sha, name: this.checkName(run, job), status: "in_progress", runId: run.id, jobId, detailsUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}/job/${jobId}` });
792+ this.repo.broadcast(`run:${run.id}`, { action: "job", jobId });
793+ const secrets: Record<string, string> = { ...(await this.orgSecrets()), ...(await this.repoSecrets()), GITHUB_TOKEN: token, DIFFINERY_TOKEN: token };
794+ const idToken = wf.permissions === "write-all" || (typeof wf.permissions === "object" && wf.permissions["id-token"] === "write") || (typeof def.permissions === "object" && def.permissions["id-token"] === "write") || def.permissions === "write-all";
795+ return {
796+ runId: run.id,
797+ jobId,
798+ jobKey: job.job_key,
799+ name: job.name,
800+ repo: { owner: m.owner, name: m.name, cloneUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}.git`, defaultBranch: m.defaultBranch },
801+ sha: run.sha,
802+ ref: run.ref,
803+ workflowPath: run.workflow_path,
804+ github: this.githubContext(run, job.job_key, token),
805+ env: { ...wf.env, ...def.env },
806+ defaults: { run: { ...wf.defaults.run, ...def.defaults.run } },
807+ matrix: JSON.parse(job.matrix),
808+ strategy: JSON.parse(job.strategy),
809+ needs: this.needsResults(run.id, def.needs),
810+ inputs: JSON.parse(run.inputs),
811+ vars: this.variables(),
812+ secrets,
813+ outputs: def.outputs,
814+ steps: def.steps.map((s) => ({ ...s, "timeout-minutes": s["timeout-minutes"] === undefined ? undefined : Number(s["timeout-minutes"]) })),
815+ timeoutMinutes: job.timeout_minutes,
816+ continueOnError: def.continueOnError === true || def.continueOnError === "true",
817+ idToken: !!idToken,
818+ apiUrl: this.env.PUBLIC_URL,
819+ token,
820+ };
821+ }
822+
823+ async runnerLogs(jobId: string, token: string, lines: LogLine[]): Promise<void> {
824+ await this.authJob(jobId, token);
825+ let seq = this.sql.exec<{ n: number | null }>("SELECT max(seq) AS n FROM logs WHERE job_id = ?", jobId).one().n ?? -1;
826+ const out: Array<LogLine & { seq: number }> = [];
827+ for (const l of lines.slice(0, 5000)) {
828+ seq++;
829+ const text = l.text.length > 16384 ? `${l.text.slice(0, 16384)}…` : l.text;
830+ this.sql.exec("INSERT INTO logs (job_id, seq, t, step, text) VALUES (?, ?, ?, ?, ?)", jobId, seq, l.t, l.step, text);
831+ out.push({ ...l, text, seq });
832+ }
833+ this.repo.broadcast(`job:${jobId}`, { action: "logs", lines: out });
834+ }
835+
836+ async runnerStep(jobId: string, token: string, index: number, report: StepReport): Promise<void> {
837+ const job = await this.authJob(jobId, token);
838+ const steps = JSON.parse(job.steps) as Array<StepReport & { index: number }>;
839+ const existing = steps.findIndex((s) => s.index === index);
840+ const next = { ...(existing >= 0 ? steps[existing] : {}), ...report, index };
841+ if (existing >= 0) steps[existing] = next;
842+ else steps.push(next);
843+ steps.sort((a, b) => a.index - b.index);
844+ this.sql.exec("UPDATE jobs SET steps = ? WHERE id = ?", JSON.stringify(steps), jobId);
845+ this.repo.broadcast(`job:${jobId}`, { action: "step", step: next });
846+ this.repo.broadcast(`run:${job.run_id}`, { action: "job", jobId });
847+ }
848+
849+ async runnerComplete(jobId: string, token: string, completion: JobCompletion): Promise<void> {
850+ await this.authJob(jobId, token);
851+ await this.completeJob(jobId, completion);
852+ }
853+
854+ private async completeJob(jobId: string, completion: JobCompletion, override?: "timed_out"): Promise<void> {
855+ const job = this.jobRow(jobId);
856+ if (job.status === "completed") return;
857+ const run = this.runRow(job.run_id);
858+ const t = now();
859+ const conclusion = override ?? completion.conclusion;
860+ this.sql.exec(
861+ "UPDATE jobs SET status = 'completed', conclusion = ?, completed_at = ?, outputs = ?, failure = ?, summary = ?, token_hash = NULL, started_at = COALESCE(started_at, ?) WHERE id = ?",
862+ conclusion,
863+ t,
864+ JSON.stringify(completion.outputs ?? {}),
865+ completion.failure ? JSON.stringify(completion.failure) : null,
866+ completion.summary ?? null,
867+ t,
868+ jobId,
869+ );
870+ const m = this.repo.meta;
871+ this.repo.upsertCheck({
872+ sha: run.sha,
873+ name: this.checkName(run, job),
874+ status: "completed",
875+ conclusion: conclusion === "timed_out" ? "timed_out" : conclusion,
876+ runId: run.id,
877+ jobId,
878+ detailsUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}/job/${jobId}`,
879+ output: completion.failure ? { title: "Failed", summary: completion.failure.message } : { title: conclusion },
880+ });
881+ // Archive logs to R2 and free the Durable Object's storage.
882+ const lines = this.sql.exec<LogLine & { seq: number }>("SELECT seq, t, step, text FROM logs WHERE job_id = ? ORDER BY seq", jobId).toArray();
883+ if (lines.length) {
884+ await this.env.BUCKET.put(this.logKey(jobId), JSON.stringify(lines), { httpMetadata: { contentType: "application/json" } });
885+ this.sql.exec("DELETE FROM logs WHERE job_id = ?", jobId);
886+ }
887+ this.repo.broadcast(`job:${jobId}`, { action: "completed", conclusion });
888+ this.repo.broadcast(`run:${run.id}`, { action: "job", jobId });
889+
890+ // fail-fast: cancel unfinished siblings of a failed matrix job.
891+ const siblings = this.sql.exec<JobRow>("SELECT * FROM jobs WHERE run_id = ? AND job_key = ? AND id != ? AND status != 'completed'", run.id, job.job_key, jobId).toArray();
892+ const strategy = JSON.parse(job.strategy || "{}") as { "fail-fast"?: boolean };
893+ if (conclusion === "failure" && strategy["fail-fast"] !== false && siblings.length) {
894+ for (const s of siblings) {
895+ await this.stopContainer(s);
896+ await this.completeJob(s.id, { conclusion: "cancelled", outputs: {}, failure: { message: `Cancelled because ${job.name} failed (fail-fast).` } });
897+ }
898+ } else {
899+ const queued = siblings.find((s) => s.status === "queued");
900+ if (queued) await this.launch(queued.id);
901+ }
902+ try {
903+ const instance = await this.env.RUN_WORKFLOW.get(run.id);
904+ await instance.sendEvent({ type: `job-${jobId}`, payload: { conclusion } });
905+ } catch (e) {
906+ console.error("failed to notify workflow", e);
907+ }
908+ }
909+
910+ // ---------------------------------------------------------------- secrets and variables
911+
912+ private async repoSecrets(): Promise<Record<string, string>> {
913+ const out: Record<string, string> = {};
914+ for (const r of this.sql.exec<{ name: string; ciphertext: string }>("SELECT name, ciphertext FROM secrets").toArray()) {
915+ out[r.name] = await decryptSecret(this.env.MASTER_KEY, `repo:${this.repo.meta.repoId}`, r.ciphertext);
916+ }
917+ return out;
918+ }
919+
920+ private async orgSecrets(): Promise<Record<string, string>> {
921+ const m = this.repo.meta;
922+ if (m.ownerKind !== "org") return {};
923+ const rows = await this.env.DB.prepare("SELECT name, ciphertext FROM org_secrets WHERE org_id = ?").bind(m.ownerId).all<{ name: string; ciphertext: string }>();
924+ const out: Record<string, string> = {};
925+ for (const r of rows.results) out[r.name] = await decryptSecret(this.env.MASTER_KEY, `org:${m.ownerId}`, r.ciphertext);
926+ return out;
927+ }
928+
929+ async setSecret(name: string, value: string): Promise<void> {
930+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || /^GITHUB_/i.test(name)) throw new HttpError(422, "invalid secret name");
931+ this.sql.exec("INSERT OR REPLACE INTO secrets (name, ciphertext, updated_at) VALUES (?, ?, ?)", name.toUpperCase(), await encryptSecret(this.env.MASTER_KEY, `repo:${this.repo.meta.repoId}`, value), now());
932+ }
933+
934+ deleteSecret(name: string): void {
935+ this.sql.exec("DELETE FROM secrets WHERE name = ?", name.toUpperCase());
936+ }
937+
938+ secretNames(): Array<{ name: string; updatedAt: string }> {
939+ return this.sql.exec<{ name: string; updated_at: string }>("SELECT name, updated_at FROM secrets ORDER BY name").toArray().map((r) => ({ name: r.name, updatedAt: r.updated_at }));
940+ }
941+
942+ setVariable(name: string, value: string | null): void {
943+ if (value === null) this.sql.exec("DELETE FROM variables WHERE name = ?", name);
944+ else this.sql.exec("INSERT OR REPLACE INTO variables (name, value, updated_at) VALUES (?, ?, ?)", name.toUpperCase(), value, now());
945+ }
946+
947+ variables(): Record<string, string> {
948+ return Object.fromEntries(this.sql.exec<{ name: string; value: string }>("SELECT name, value FROM variables").toArray().map((r) => [r.name, r.value]));
949+ }
950+
951+ // ---------------------------------------------------------------- cache and artifacts
952+
953+ async cacheLookup(jobId: string, token: string, key: string, restoreKeys: string[]): Promise<{ key: string; r2Key: string } | null> {
954+ await this.authJob(jobId, token);
955+ const exact = this.sql.exec<{ key: string }>("SELECT key FROM caches WHERE key = ?", key).toArray()[0];
956+ let hit = exact?.key;
957+ if (!hit) {
958+ for (const prefix of restoreKeys) {
959+ const r = this.sql.exec<{ key: string }>("SELECT key FROM caches WHERE key >= ? AND key < ? ORDER BY created_at DESC LIMIT 1", prefix, `${prefix}￿`).toArray()[0];
960+ if (r) {
961+ hit = r.key;
962+ break;
963+ }
964+ }
965+ }
966+ if (!hit) return null;
967+ this.sql.exec("UPDATE caches SET last_used = ? WHERE key = ?", now(), hit);
968+ return { key: hit, r2Key: this.cacheR2Key(hit) };
969+ }
970+
971+ cacheR2Key(key: string): string {
972+ return `repos/${this.repo.meta.repoId}/cache/${encodeURIComponent(key)}`;
973+ }
974+
975+ async cacheSaved(jobId: string, token: string, key: string, size: number): Promise<string[]> {
976+ await this.authJob(jobId, token);
977+ this.sql.exec("INSERT OR REPLACE INTO caches (key, size, created_at, last_used) VALUES (?, ?, ?, ?)", key, size, now(), now());
978+ // Evict least recently used entries above 10 GB per repository.
979+ const evicted: string[] = [];
980+ let total = this.sql.exec<{ n: number | null }>("SELECT sum(size) AS n FROM caches").one().n ?? 0;
981+ while (total > 10 * 1024 ** 3) {
982+ const old = this.sql.exec<{ key: string; size: number }>("SELECT key, size FROM caches ORDER BY last_used LIMIT 1").one();
983+ this.sql.exec("DELETE FROM caches WHERE key = ?", old.key);
984+ evicted.push(this.cacheR2Key(old.key));
985+ total -= old.size;
986+ }
987+ return evicted;
988+ }
989+
990+ async artifactSaved(jobId: string, token: string, name: string, size: number): Promise<{ r2Key: string }> {
991+ const job = await this.authJob(jobId, token);
992+ this.sql.exec("INSERT OR REPLACE INTO artifacts (id, run_id, name, size, created_at) VALUES (?, ?, ?, ?, ?)", newId(), job.run_id, name, size, now());
993+ return { r2Key: this.artifactR2Key(job.run_id, name) };
994+ }
995+
996+ artifactR2Key(runId: string, name: string): string {
997+ return `repos/${this.repo.meta.repoId}/artifacts/${runId}/${encodeURIComponent(name)}`;
998+ }
999+
1000+ artifacts(runId: string): Array<{ name: string; size: number; createdAt: string; r2Key: string }> {
1001+ return this.sql.exec<{ name: string; size: number; created_at: string }>("SELECT name, size, created_at FROM artifacts WHERE run_id = ? ORDER BY name", runId).toArray().map((a) => ({ name: a.name, size: a.size, createdAt: a.created_at, r2Key: this.artifactR2Key(runId, a.name) }));
1002+ }
1003+
1004+ /** Runs of the same commit; lets a job download artifacts uploaded by earlier jobs. */
1005+ runIdForJob(jobId: string): string {
1006+ return this.jobRow(jobId).run_id;
1007+ }
1008+
1009+ // ---------------------------------------------------------------- sandboxes for agents
1010+
1011+ async createSandbox(caller: Caller, ref?: string): Promise<{ id: string; ref: string }> {
1012+ if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to create a sandbox");
1013+ const sha = await this.repo.resolve(ref ?? "HEAD");
1014+ if (!sha) throw new HttpError(422, "unknown ref");
1015+ const id = newId();
1016+ const m = this.repo.meta;
1017+ const token = `dst_${m.repoId}_${id}_${randomToken(24)}`;
1018+ const secret = randomToken(32);
1019+ this.sql.exec("INSERT INTO sandboxes (id, secret, created_by, ref, created_at, status) VALUES (?, ?, ?, ?, ?, 'running')", id, await sha256(token), JSON.stringify(caller), ref ?? m.defaultBranch, now());
1020+ const stub = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(id));
1021+ await stub.boot({
1022+ DIFFINERY_CLONE_URL: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}.git`,
1023+ DIFFINERY_REF: ref ?? m.defaultBranch,
1024+ DIFFINERY_JOB_TOKEN: token,
1025+ DIFFINERY_SANDBOX_SECRET: secret,
1026+ DIFFINERY_API_URL: this.env.PUBLIC_URL,
1027+ GIT_AUTHOR_NAME: caller.login,
1028+ GIT_AUTHOR_EMAIL: `${caller.login}@users.noreply.diffinery.com`,
1029+ GIT_COMMITTER_NAME: caller.login,
1030+ GIT_COMMITTER_EMAIL: `${caller.login}@users.noreply.diffinery.com`,
1031+ }, secret);
1032+ return { id, ref: ref ?? m.defaultBranch };
1033+ }
1034+
1035+ getSandbox(id: string, caller: Caller): { id: string; ref: string; status: string; createdAt: string } {
1036+ const s = this.sql.exec<{ id: string; ref: string; status: string; created_at: string; created_by: string }>("SELECT * FROM sandboxes WHERE id = ?", id).toArray()[0];
1037+ if (!s) throw new HttpError(404, "sandbox not found");
1038+ const owner = JSON.parse(s.created_by) as Caller;
1039+ if (owner.id !== caller.id && !atLeast(caller.role, "admin")) throw new HttpError(403, "not your sandbox");
1040+ return { id: s.id, ref: s.ref, status: s.status, createdAt: s.created_at };
1041+ }
1042+
1043+ async closeSandbox(id: string, caller: Caller): Promise<void> {
1044+ this.getSandbox(id, caller);
1045+ this.sql.exec("UPDATE sandboxes SET status = 'closed' WHERE id = ?", id);
1046+ await this.env.SANDBOX.get(this.env.SANDBOX.idFromName(id)).shutdown();
1047+ }
1048+}
addedapps/diffinery/src/repo/collab.ts+665−0View file
@@ -1,0 +1,665 @@
1+import { ZERO_OID, isAncestor, mergeBase, mergeTrees, readCommit, serializeCommit, walkCommits } from "@diffinery/git-core";
2+import { HttpError, newId, now, randomToken } from "../lib/util.js";
3+import type { RepoDO } from "./RepoDO.js";
4+import { toInfo } from "./RepoDO.js";
5+import { atLeast, type Caller } from "./types.js";
6+
7+export type IssueRow = {
8+ number: number;
9+ kind: "issue" | "pull";
10+ title: string;
11+ body: string;
12+ state: "open" | "closed";
13+ state_reason: string | null;
14+ author_id: string;
15+ author_login: string;
16+ author_kind: string;
17+ assignees: string;
18+ labels: string;
19+ created_at: string;
20+ updated_at: string;
21+ closed_at: string | null;
22+ locked: number;
23+};
24+
25+export type PullRow = {
26+ number: number;
27+ head_ref: string;
28+ head_sha: string;
29+ base_ref: string;
30+ draft: number;
31+ merged: number;
32+ merged_at: string | null;
33+ merged_by: string | null;
34+ merge_commit: string | null;
35+ requested_reviewers: string;
36+ intent: string | null;
37+};
38+
39+export type Issue = {
40+ number: number;
41+ kind: "issue" | "pull";
42+ title: string;
43+ body: string;
44+ state: "open" | "closed";
45+ stateReason: string | null;
46+ author: { id: string; login: string; kind: string };
47+ assignees: string[];
48+ labels: string[];
49+ createdAt: string;
50+ updatedAt: string;
51+ closedAt: string | null;
52+ comments: number;
53+ pull?: {
54+ head: string;
55+ headSha: string;
56+ base: string;
57+ draft: boolean;
58+ merged: boolean;
59+ mergedAt: string | null;
60+ mergedBy: string | null;
61+ mergeCommit: string | null;
62+ requestedReviewers: string[];
63+ intent: string | null;
64+ };
65+};
66+
67+export type Comment = {
68+ id: string;
69+ author: { id: string; login: string; kind: string };
70+ body: string;
71+ createdAt: string;
72+ updatedAt: string;
73+ reviewId: string | null;
74+ path: string | null;
75+ line: number | null;
76+ side: string | null;
77+ commitId: string | null;
78+ inReplyTo: string | null;
79+ resolved: boolean;
80+};
81+
82+export type Review = {
83+ id: string;
84+ author: { id: string; login: string; kind: string };
85+ state: "APPROVED" | "CHANGES_REQUESTED" | "COMMENTED" | "DISMISSED";
86+ body: string;
87+ commitId: string;
88+ submittedAt: string;
89+};
90+
91+export type Check = {
92+ id: string;
93+ sha: string;
94+ name: string;
95+ status: "queued" | "in_progress" | "completed";
96+ conclusion: string | null;
97+ runId: string | null;
98+ jobId: string | null;
99+ detailsUrl: string | null;
100+ output: { title?: string; summary?: string } | null;
101+ app: string;
102+ startedAt: string | null;
103+ completedAt: string | null;
104+};
105+
106+export type Mergeability = {
107+ mergeable: boolean;
108+ state: "clean" | "blocked" | "conflicting" | "behind" | "draft" | "merged" | "closed" | "unknown";
109+ conflicts: string[];
110+ approvals: number;
111+ requiredApprovals: number;
112+ changesRequested: string[];
113+ checks: { name: string; state: string }[];
114+ blockers: string[];
115+ mergeBase: string | null;
116+};
117+
118+const actorOf = (c: Caller) => ({ id: c.id, login: c.login, kind: c.kind, onBehalfOf: c.onBehalfOf });
119+
120+export class Collab {
121+ constructor(private repo: RepoDO) {}
122+
123+ private get sql() {
124+ return this.repo.sql;
125+ }
126+
127+ private nextNumber(): number {
128+ this.sql.exec("INSERT INTO counters (name, value) VALUES ('issue', 1) ON CONFLICT(name) DO UPDATE SET value = value + 1");
129+ return this.sql.exec<{ value: number }>("SELECT value FROM counters WHERE name = 'issue'").one().value;
130+ }
131+
132+ private row(number: number): IssueRow {
133+ const r = this.sql.exec<IssueRow>("SELECT * FROM issues WHERE number = ?", number).toArray()[0];
134+ if (!r) throw new HttpError(404, `#${number} not found`);
135+ return r;
136+ }
137+
138+ private pullRow(number: number): PullRow {
139+ const r = this.sql.exec<PullRow>("SELECT * FROM pulls WHERE number = ?", number).toArray()[0];
140+ if (!r) throw new HttpError(404, `pull request #${number} not found`);
141+ return r;
142+ }
143+
144+ private toIssue(r: IssueRow): Issue {
145+ const comments = this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM comments WHERE number = ? AND review_id IS NULL", r.number).one().n;
146+ const issue: Issue = {
147+ number: r.number,
148+ kind: r.kind,
149+ title: r.title,
150+ body: r.body,
151+ state: r.state,
152+ stateReason: r.state_reason,
153+ author: { id: r.author_id, login: r.author_login, kind: r.author_kind },
154+ assignees: JSON.parse(r.assignees),
155+ labels: JSON.parse(r.labels),
156+ createdAt: r.created_at,
157+ updatedAt: r.updated_at,
158+ closedAt: r.closed_at,
159+ comments,
160+ };
161+ if (r.kind === "pull") {
162+ const p = this.pullRow(r.number);
163+ issue.pull = {
164+ head: p.head_ref,
165+ headSha: p.head_sha,
166+ base: p.base_ref,
167+ draft: !!p.draft,
168+ merged: !!p.merged,
169+ mergedAt: p.merged_at,
170+ mergedBy: p.merged_by,
171+ mergeCommit: p.merge_commit,
172+ requestedReviewers: JSON.parse(p.requested_reviewers),
173+ intent: p.intent,
174+ };
175+ }
176+ return issue;
177+ }
178+
179+ // ---------------------------------------------------------------- issues
180+
181+ list(opts: { kind?: "issue" | "pull"; state?: "open" | "closed" | "all"; label?: string; author?: string; limit?: number; offset?: number; q?: string } = {}): { items: Issue[]; total: number } {
182+ const where: string[] = [];
183+ const args: unknown[] = [];
184+ if (opts.kind) {
185+ where.push("kind = ?");
186+ args.push(opts.kind);
187+ }
188+ if (opts.state && opts.state !== "all") {
189+ where.push("state = ?");
190+ args.push(opts.state);
191+ }
192+ if (opts.author) {
193+ where.push("author_login = ? COLLATE NOCASE");
194+ args.push(opts.author);
195+ }
196+ if (opts.label) {
197+ where.push("EXISTS (SELECT 1 FROM json_each(labels) WHERE value = ?)");
198+ args.push(opts.label);
199+ }
200+ if (opts.q) {
201+ where.push("(title LIKE ? OR body LIKE ?)");
202+ args.push(`%${opts.q}%`, `%${opts.q}%`);
203+ }
204+ const w = where.length ? `WHERE ${where.join(" AND ")}` : "";
205+ const total = this.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM issues ${w}`, ...args).one().n;
206+ const rows = this.sql.exec<IssueRow>(`SELECT * FROM issues ${w} ORDER BY number DESC LIMIT ? OFFSET ?`, ...args, Math.min(opts.limit ?? 30, 100), opts.offset ?? 0).toArray();
207+ return { items: rows.map((r) => this.toIssue(r)), total };
208+ }
209+
210+ get(number: number): { issue: Issue; comments: Comment[]; reviews: Review[] } {
211+ const issue = this.toIssue(this.row(number));
212+ const comments = this.sql
213+ .exec<Record<string, SqlStorageValue>>("SELECT * FROM comments WHERE number = ? ORDER BY created_at", number)
214+ .toArray()
215+ .map(
216+ (c): Comment => ({
217+ id: c.id as string,
218+ author: { id: c.author_id as string, login: c.author_login as string, kind: c.author_kind as string },
219+ body: c.body as string,
220+ createdAt: c.created_at as string,
221+ updatedAt: c.updated_at as string,
222+ reviewId: (c.review_id as string) ?? null,
223+ path: (c.path as string) ?? null,
224+ line: (c.line as number) ?? null,
225+ side: (c.side as string) ?? null,
226+ commitId: (c.commit_id as string) ?? null,
227+ inReplyTo: (c.in_reply_to as string) ?? null,
228+ resolved: !!c.resolved,
229+ }),
230+ );
231+ const reviews = this.reviews(number);
232+ return { issue, comments, reviews };
233+ }
234+
235+ private reviews(number: number): Review[] {
236+ return this.sql
237+ .exec<Record<string, string>>("SELECT * FROM reviews WHERE number = ? ORDER BY submitted_at", number)
238+ .toArray()
239+ .map((r) => ({
240+ id: r.id!,
241+ author: { id: r.author_id!, login: r.author_login!, kind: r.author_kind! },
242+ state: r.state as Review["state"],
243+ body: r.body!,
244+ commitId: r.commit_id!,
245+ submittedAt: r.submitted_at!,
246+ }));
247+ }
248+
249+ createIssue(input: { title: string; body?: string; labels?: string[]; assignees?: string[] }, caller: Caller): Issue {
250+ if (!input.title?.trim()) throw new HttpError(422, "title is required");
251+ const n = this.nextNumber();
252+ const t = now();
253+ this.sql.exec(
254+ "INSERT INTO issues (number, kind, title, body, author_id, author_login, author_kind, labels, assignees, created_at, updated_at) VALUES (?, 'issue', ?, ?, ?, ?, ?, ?, ?, ?, ?)",
255+ n,
256+ input.title.trim(),
257+ input.body ?? "",
258+ caller.id,
259+ caller.login,
260+ caller.kind,
261+ JSON.stringify(input.labels ?? []),
262+ JSON.stringify(input.assignees ?? []),
263+ t,
264+ t,
265+ );
266+ const m = this.repo.meta;
267+ this.repo.emit({ type: "issues", action: "opened", repoId: m.repoId, owner: m.owner, repo: m.name, number: n, actor: actorOf(caller), at: t });
268+ this.repo.broadcast("issues", { number: n, action: "opened" });
269+ return this.toIssue(this.row(n));
270+ }
271+
272+ update(number: number, patch: { title?: string; body?: string; state?: "open" | "closed"; stateReason?: string; labels?: string[]; assignees?: string[] }, caller: Caller): Issue {
273+ const r = this.row(number);
274+ const isAuthor = r.author_id === caller.id;
275+ if (!isAuthor && !atLeast(caller.role, "triage")) throw new HttpError(403, "permission denied");
276+ if (r.kind === "pull" && patch.state === "open" && this.pullRow(number).merged) throw new HttpError(422, "a merged pull request cannot be reopened");
277+ const t = now();
278+ const state = patch.state ?? r.state;
279+ this.sql.exec(
280+ "UPDATE issues SET title = ?, body = ?, state = ?, state_reason = ?, labels = ?, assignees = ?, updated_at = ?, closed_at = ? WHERE number = ?",
281+ patch.title ?? r.title,
282+ patch.body ?? r.body,
283+ state,
284+ patch.state === "closed" ? (patch.stateReason ?? "completed") : patch.state === "open" ? null : r.state_reason,
285+ JSON.stringify(patch.labels ?? JSON.parse(r.labels)),
286+ JSON.stringify(patch.assignees ?? JSON.parse(r.assignees)),
287+ t,
288+ state === "closed" ? (r.closed_at ?? t) : null,
289+ number,
290+ );
291+ const m = this.repo.meta;
292+ if (patch.state && patch.state !== r.state) {
293+ const action = patch.state === "closed" ? "closed" : "reopened";
294+ if (r.kind === "pull") {
295+ const p = this.pullRow(number);
296+ this.repo.emit({ type: "pull_request", action, repoId: m.repoId, owner: m.owner, repo: m.name, number, headRef: p.head_ref, headSha: p.head_sha, baseRef: p.base_ref, actor: actorOf(caller), at: t });
297+ } else this.repo.emit({ type: "issues", action, repoId: m.repoId, owner: m.owner, repo: m.name, number, actor: actorOf(caller), at: t });
298+ }
299+ this.repo.broadcast(`pr:${number}`, { action: "updated" });
300+ return this.toIssue(this.row(number));
301+ }
302+
303+ addComment(number: number, body: string, caller: Caller): Comment {
304+ const r = this.row(number);
305+ if (r.locked && !atLeast(caller.role, "triage")) throw new HttpError(403, "conversation is locked");
306+ if (!body?.trim()) throw new HttpError(422, "body is required");
307+ const id = newId();
308+ const t = now();
309+ this.sql.exec("INSERT INTO comments (id, number, author_id, author_login, author_kind, body, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", id, number, caller.id, caller.login, caller.kind, body, t, t);
310+ this.sql.exec("UPDATE issues SET updated_at = ? WHERE number = ?", t, number);
311+ const m = this.repo.meta;
312+ this.repo.emit({ type: "issue_comment", action: "created", repoId: m.repoId, owner: m.owner, repo: m.name, number, commentId: id, actor: actorOf(caller), at: t });
313+ this.repo.broadcast(`pr:${number}`, { action: "comment", id });
314+ return this.get(number).comments.find((c) => c.id === id)!;
315+ }
316+
317+ editComment(id: string, body: string, caller: Caller): void {
318+ const c = this.sql.exec<{ author_id: string }>("SELECT author_id FROM comments WHERE id = ?", id).toArray()[0];
319+ if (!c) throw new HttpError(404, "comment not found");
320+ if (c.author_id !== caller.id && !atLeast(caller.role, "maintain")) throw new HttpError(403, "permission denied");
321+ this.sql.exec("UPDATE comments SET body = ?, updated_at = ? WHERE id = ?", body, now(), id);
322+ }
323+
324+ labels(): Array<{ name: string; color: string; description: string }> {
325+ return this.sql.exec<{ name: string; color: string; description: string }>("SELECT * FROM labels ORDER BY name").toArray();
326+ }
327+
328+ setLabel(name: string, color: string | null, description = ""): void {
329+ if (color === null) this.sql.exec("DELETE FROM labels WHERE name = ?", name);
330+ else this.sql.exec("INSERT OR REPLACE INTO labels (name, color, description) VALUES (?, ?, ?)", name, color.replace(/^#/, ""), description);
331+ }
332+
333+ // ---------------------------------------------------------------- pull requests
334+
335+ async createPull(input: { title: string; body?: string; head: string; base?: string; draft?: boolean; intent?: string; reviewers?: string[] }, caller: Caller): Promise<Issue> {
336+ const base = input.base ?? this.repo.meta.defaultBranch;
337+ if (input.head === base) throw new HttpError(422, "head and base must be different branches");
338+ const headSha = this.repo.getRef(`refs/heads/${input.head}`);
339+ const baseSha = this.repo.getRef(`refs/heads/${base}`);
340+ if (!headSha) throw new HttpError(422, `head branch '${input.head}' does not exist`);
341+ if (!baseSha) throw new HttpError(422, `base branch '${base}' does not exist`);
342+ if (await isAncestor(this.repo.store, headSha, baseSha)) throw new HttpError(422, `no commits between ${base} and ${input.head}`);
343+ const dup = this.sql.exec<{ number: number }>("SELECT p.number FROM pulls p JOIN issues i ON i.number = p.number WHERE p.head_ref = ? AND p.base_ref = ? AND i.state = 'open'", input.head, base).toArray()[0];
344+ if (dup) throw new HttpError(422, `a pull request already exists for ${input.head}: #${dup.number}`, { number: dup.number });
345+ if (!input.title?.trim()) throw new HttpError(422, "title is required");
346+ const n = this.nextNumber();
347+ const t = now();
348+ this.sql.exec(
349+ "INSERT INTO issues (number, kind, title, body, author_id, author_login, author_kind, created_at, updated_at) VALUES (?, 'pull', ?, ?, ?, ?, ?, ?, ?)",
350+ n,
351+ input.title.trim(),
352+ input.body ?? "",
353+ caller.id,
354+ caller.login,
355+ caller.kind,
356+ t,
357+ t,
358+ );
359+ this.sql.exec(
360+ "INSERT INTO pulls (number, head_ref, head_sha, base_ref, draft, requested_reviewers, intent) VALUES (?, ?, ?, ?, ?, ?, ?)",
361+ n,
362+ input.head,
363+ headSha,
364+ base,
365+ input.draft ? 1 : 0,
366+ JSON.stringify(input.reviewers ?? []),
367+ input.intent ?? null,
368+ );
369+ const m = this.repo.meta;
370+ this.repo.emit({ type: "pull_request", action: "opened", repoId: m.repoId, owner: m.owner, repo: m.name, number: n, headRef: input.head, headSha, baseRef: base, actor: actorOf(caller), at: t });
371+ await this.repo.actions.onPullRequest("opened", n, caller).catch((e) => console.error("pr workflow trigger", e));
372+ return this.toIssue(this.row(n));
373+ }
374+
375+ requestReviewers(number: number, reviewers: string[], caller: Caller): void {
376+ if (!atLeast(caller.role, "triage") && this.row(number).author_id !== caller.id) throw new HttpError(403, "permission denied");
377+ const p = this.pullRow(number);
378+ const set = new Set<string>([...JSON.parse(p.requested_reviewers), ...reviewers]);
379+ this.sql.exec("UPDATE pulls SET requested_reviewers = ? WHERE number = ?", JSON.stringify([...set]), number);
380+ }
381+
382+ /** Called after a branch moves: refresh open pull requests whose head is that branch. */
383+ async syncPullsForBranch(branch: string, sha: string, caller: Caller): Promise<number[]> {
384+ const rows = this.sql.exec<PullRow>("SELECT p.* FROM pulls p JOIN issues i ON i.number = p.number WHERE p.head_ref = ? AND i.state = 'open'", branch).toArray();
385+ const m = this.repo.meta;
386+ const out: number[] = [];
387+ for (const p of rows) {
388+ if (p.head_sha === sha) continue;
389+ this.sql.exec("UPDATE pulls SET head_sha = ? WHERE number = ?", sha, p.number);
390+ this.sql.exec("UPDATE issues SET updated_at = ? WHERE number = ?", now(), p.number);
391+ const prefs = this.repo.protectionFor(p.base_ref);
392+ if (prefs?.dismissStaleReviews) this.sql.exec("UPDATE reviews SET state = 'DISMISSED' WHERE number = ? AND state = 'APPROVED' AND commit_id != ?", p.number, sha);
393+ this.repo.emit({ type: "pull_request", action: "synchronize", repoId: m.repoId, owner: m.owner, repo: m.name, number: p.number, headRef: branch, headSha: sha, baseRef: p.base_ref, actor: actorOf(caller), at: now() });
394+ await this.repo.actions.onPullRequest("synchronize", p.number, caller).catch((e) => console.error("pr workflow trigger", e));
395+ this.repo.broadcast(`pr:${p.number}`, { action: "synchronize", sha });
396+ out.push(p.number);
397+ }
398+ return out;
399+ }
400+
401+ async pullFiles(number: number) {
402+ const p = this.pullRow(number);
403+ const baseSha = this.repo.getRef(`refs/heads/${p.base_ref}`) ?? p.head_sha;
404+ const head = p.merged && p.merge_commit ? p.head_sha : p.head_sha;
405+ const mb = await mergeBase(this.repo.store, baseSha, head);
406+ return { mergeBase: mb, headSha: head, ...(await this.repo.diff(mb, head)) };
407+ }
408+
409+ async pullCommits(number: number) {
410+ const p = this.pullRow(number);
411+ const baseSha = this.repo.getRef(`refs/heads/${p.base_ref}`);
412+ const mb = baseSha ? await mergeBase(this.repo.store, baseSha, p.head_sha) : null;
413+ const stop = new Set<string>();
414+ if (mb) for (const c of await walkCommits(this.repo.store, [mb], { limit: 100000 })) stop.add(c.oid);
415+ return (await walkCommits(this.repo.store, [p.head_sha], { stop, limit: 250 })).map((c) => toInfo(c.oid, c.commit)).reverse();
416+ }
417+
418+ submitReview(
419+ number: number,
420+ input: { event: "APPROVE" | "REQUEST_CHANGES" | "COMMENT"; body?: string; comments?: Array<{ path: string; line: number; side?: "LEFT" | "RIGHT"; body: string; inReplyTo?: string }> },
421+ caller: Caller,
422+ ): Review {
423+ const r = this.row(number);
424+ const p = this.pullRow(number);
425+ if (r.state !== "open") throw new HttpError(422, "pull request is closed");
426+ if (input.event !== "COMMENT" && r.author_id === caller.id) throw new HttpError(422, "you cannot approve or request changes on your own pull request");
427+ if (input.event !== "COMMENT" && !atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to approve or request changes");
428+ if (input.event === "COMMENT" && !input.body?.trim() && !input.comments?.length) throw new HttpError(422, "a comment review needs a body or line comments");
429+ const id = newId();
430+ const t = now();
431+ const state = input.event === "APPROVE" ? "APPROVED" : input.event === "REQUEST_CHANGES" ? "CHANGES_REQUESTED" : "COMMENTED";
432+ this.sql.exec(
433+ "INSERT INTO reviews (id, number, author_id, author_login, author_kind, state, body, commit_id, submitted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
434+ id,
435+ number,
436+ caller.id,
437+ caller.login,
438+ caller.kind,
439+ state,
440+ input.body ?? "",
441+ p.head_sha,
442+ t,
443+ );
444+ for (const c of input.comments ?? []) {
445+ this.sql.exec(
446+ "INSERT INTO comments (id, number, author_id, author_login, author_kind, body, created_at, updated_at, review_id, path, line, side, commit_id, in_reply_to) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
447+ newId(),
448+ number,
449+ caller.id,
450+ caller.login,
451+ caller.kind,
452+ c.body,
453+ t,
454+ t,
455+ id,
456+ c.path,
457+ c.line,
458+ c.side ?? "RIGHT",
459+ p.head_sha,
460+ c.inReplyTo ?? null,
461+ );
462+ }
463+ const reviewers = (JSON.parse(p.requested_reviewers) as string[]).filter((x) => x.toLowerCase() !== caller.login.toLowerCase());
464+ this.sql.exec("UPDATE pulls SET requested_reviewers = ? WHERE number = ?", JSON.stringify(reviewers), number);
465+ this.sql.exec("UPDATE issues SET updated_at = ? WHERE number = ?", t, number);
466+ const m = this.repo.meta;
467+ this.repo.emit({ type: "pull_request_review", action: "submitted", repoId: m.repoId, owner: m.owner, repo: m.name, number, state, actor: actorOf(caller), at: t });
468+ this.repo.broadcast(`pr:${number}`, { action: "review", state });
469+ return this.reviews(number).find((x) => x.id === id)!;
470+ }
471+
472+ resolveThread(commentId: string, resolved: boolean, caller: Caller): void {
473+ if (!atLeast(caller.role, "write")) throw new HttpError(403, "permission denied");
474+ this.sql.exec("UPDATE comments SET resolved = ? WHERE id = ? OR in_reply_to = ?", resolved ? 1 : 0, commentId, commentId);
475+ }
476+
477+ // ---------------------------------------------------------------- checks
478+
479+ checks(sha: string): Check[] {
480+ return this.sql
481+ .exec<Record<string, string | null>>("SELECT * FROM checks WHERE sha = ? ORDER BY name", sha)
482+ .toArray()
483+ .map((c) => ({
484+ id: c.id!,
485+ sha: c.sha!,
486+ name: c.name!,
487+ status: c.status as Check["status"],
488+ conclusion: c.conclusion ?? null,
489+ runId: c.run_id ?? null,
490+ jobId: c.job_id ?? null,
491+ detailsUrl: c.details_url ?? null,
492+ output: c.output ? JSON.parse(c.output) : null,
493+ app: c.app!,
494+ startedAt: c.started_at ?? null,
495+ completedAt: c.completed_at ?? null,
496+ }));
497+ }
498+
499+ /** Create or update a check run, keyed by (sha, name, app). */
500+ upsertCheck(input: { sha: string; name: string; status: Check["status"]; conclusion?: string | null; runId?: string; jobId?: string; detailsUrl?: string; output?: Check["output"]; app?: string }): Check {
501+ const app = input.app ?? "diffinery-actions";
502+ const existing = this.sql.exec<{ id: string; started_at: string | null }>("SELECT id, started_at FROM checks WHERE sha = ? AND name = ? AND app = ?", input.sha, input.name, app).toArray()[0];
503+ const t = now();
504+ const id = existing?.id ?? newId();
505+ this.sql.exec(
506+ "INSERT OR REPLACE INTO checks (id, sha, name, status, conclusion, run_id, job_id, details_url, output, app, started_at, completed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
507+ id,
508+ input.sha,
509+ input.name,
510+ input.status,
511+ input.conclusion ?? null,
512+ input.runId ?? null,
513+ input.jobId ?? null,
514+ input.detailsUrl ?? null,
515+ input.output ? JSON.stringify(input.output) : null,
516+ app,
517+ existing?.started_at ?? (input.status !== "queued" ? t : null),
518+ input.status === "completed" ? t : null,
519+ );
520+ for (const p of this.sql.exec<{ number: number }>("SELECT number FROM pulls WHERE head_sha = ?", input.sha).toArray()) this.repo.broadcast(`pr:${p.number}`, { action: "checks" });
521+ return this.checks(input.sha).find((c) => c.id === id)!;
522+ }
523+
524+ // ---------------------------------------------------------------- merging
525+
526+ async mergeability(number: number, caller?: Caller): Promise<Mergeability> {
527+ const r = this.row(number);
528+ const p = this.pullRow(number);
529+ const result: Mergeability = { mergeable: false, state: "unknown", conflicts: [], approvals: 0, requiredApprovals: 0, changesRequested: [], checks: [], blockers: [], mergeBase: null };
530+ if (p.merged) return { ...result, state: "merged", blockers: ["already merged"] };
531+ if (r.state === "closed") return { ...result, state: "closed", blockers: ["pull request is closed"] };
532+ const baseSha = this.repo.getRef(`refs/heads/${p.base_ref}`);
533+ if (!baseSha) return { ...result, blockers: [`base branch ${p.base_ref} no longer exists`] };
534+ const mb = await mergeBase(this.repo.store, baseSha, p.head_sha);
535+ result.mergeBase = mb;
536+ const [bt, ht, mt] = await Promise.all([readCommit(this.repo.store, baseSha), readCommit(this.repo.store, p.head_sha), mb ? readCommit(this.repo.store, mb) : null]);
537+ const merged = await mergeTrees(this.repo.store, mt?.tree ?? null, bt.tree, ht.tree);
538+ result.conflicts = merged.conflicts;
539+ const rules = this.repo.protectionFor(p.base_ref) ?? {};
540+ result.requiredApprovals = rules.requiredApprovals ?? 0;
541+ const latest = new Map<string, Review>();
542+ for (const rv of this.reviews(number)) if (rv.state !== "COMMENTED") latest.set(rv.author.id, rv);
543+ for (const rv of latest.values()) {
544+ // Agent reviews are advisory: only people's approvals count toward requirements.
545+ if (rv.state === "APPROVED" && rv.author.kind !== "agent" && (!rules.dismissStaleReviews || rv.commitId === p.head_sha)) result.approvals++;
546+ if (rv.state === "CHANGES_REQUESTED") result.changesRequested.push(rv.author.login);
547+ }
548+ const checks = this.checks(p.head_sha);
549+ result.checks = checks.map((c) => ({ name: c.name, state: c.status === "completed" ? (c.conclusion ?? "neutral") : c.status }));
550+ if (r.author_kind === "agent" && result.requiredApprovals < 1) {
551+ // Agent-authored changes always need one human approval unless a rule says otherwise.
552+ result.requiredApprovals = 1;
553+ }
554+ if (p.draft) result.blockers.push("pull request is a draft");
555+ if (result.conflicts.length) result.blockers.push(`merge conflicts in ${result.conflicts.length} file(s)`);
556+ if (result.approvals < result.requiredApprovals) result.blockers.push(`needs ${result.requiredApprovals - result.approvals} more approving review(s)`);
557+ if (result.changesRequested.length) result.blockers.push(`changes requested by ${result.changesRequested.join(", ")}`);
558+ for (const name of rules.requiredChecks ?? []) {
559+ const c = checks.find((x) => x.name === name);
560+ if (!c) result.blockers.push(`required check '${name}' has not run`);
561+ else if (c.status !== "completed") result.blockers.push(`required check '${name}' is ${c.status}`);
562+ else if (c.conclusion !== "success" && c.conclusion !== "skipped" && c.conclusion !== "neutral") result.blockers.push(`required check '${name}' ${c.conclusion}`);
563+ }
564+ if (rules.requireLinearHistory && mb !== baseSha) result.blockers.push("branch is behind its base; update it (linear history required)");
565+ if (caller) {
566+ if (!atLeast(caller.role, "write")) result.blockers.push("write access is required to merge");
567+ const isDefault = p.base_ref === this.repo.meta.defaultBranch;
568+ if (caller.kind === "agent" && (isDefault || Object.keys(rules).length) && !rules.agentsMayMerge) result.blockers.push("agents may not merge into this branch; a human must merge");
569+ }
570+ result.mergeable = result.blockers.length === 0;
571+ result.state = p.draft ? "draft" : result.conflicts.length ? "conflicting" : result.mergeable ? "clean" : "blocked";
572+ return result;
573+ }
574+
575+ async merge(number: number, input: { method?: "merge" | "squash" | "rebase"; title?: string; message?: string; expectedHeadSha?: string; deleteBranch?: boolean }, caller: Caller) {
576+ const p = this.pullRow(number);
577+ const r = this.row(number);
578+ if (input.expectedHeadSha && input.expectedHeadSha !== p.head_sha) throw new HttpError(409, "head branch was modified; review and try again");
579+ const m = await this.mergeability(number, caller);
580+ const rules = this.repo.protectionFor(p.base_ref) ?? {};
581+ const adminBypass = caller.role === "admin" && !rules.enforceAdmins && caller.kind === "user" && m.conflicts.length === 0 && !p.draft;
582+ if (!m.mergeable && !adminBypass) throw new HttpError(405, `pull request is not mergeable: ${m.blockers.join("; ")}`, { blockers: m.blockers });
583+ const baseRef = `refs/heads/${p.base_ref}`;
584+ const baseSha = this.repo.getRef(baseRef)!;
585+ const store = this.repo.store;
586+ const method = input.method ?? (rules.requireLinearHistory ? "squash" : "merge");
587+ let newSha: string;
588+ const ts = Math.floor(Date.now() / 1000);
589+ const sig = { name: caller.login, email: `${caller.login}@users.noreply.diffinery.com`, timestamp: ts, timezone: "+0000" };
590+ if (method === "rebase") {
591+ if (!(await isAncestor(store, baseSha, p.head_sha))) throw new HttpError(405, "rebase merge requires the branch to be up to date with its base (fast-forward only for now)");
592+ newSha = p.head_sha;
593+ } else {
594+ const [bt, ht, mt] = await Promise.all([readCommit(store, baseSha), readCommit(store, p.head_sha), m.mergeBase ? readCommit(store, m.mergeBase) : null]);
595+ const merged = await mergeTrees(store, mt?.tree ?? null, bt.tree, ht.tree);
596+ if (merged.conflicts.length || !merged.tree) throw new HttpError(405, "merge conflicts");
597+ const title = input.title ?? (method === "squash" ? `${r.title} (#${number})` : `Merge pull request #${number} from ${p.head_ref}`);
598+ const body = input.message ?? (method === "squash" ? (await this.pullCommits(number)).map((c) => `* ${c.message.split("\n")[0]}`).join("\n") : r.title);
599+ const author = method === "squash" ? { ...sig, name: r.author_login, email: `${r.author_login}@users.noreply.diffinery.com` } : sig;
600+ newSha = await store.write(
601+ "commit",
602+ serializeCommit({ tree: merged.tree, parents: method === "squash" ? [baseSha] : [baseSha, p.head_sha], author, committer: sig, message: `${title}\n\n${body}\n` }),
603+ );
604+ }
605+ if (!this.repo.casRef(baseRef, baseSha, newSha)) throw new HttpError(409, "base branch moved during merge; try again");
606+ const t = now();
607+ this.sql.exec("UPDATE pulls SET merged = 1, merged_at = ?, merged_by = ?, merge_commit = ? WHERE number = ?", t, caller.login, newSha, number);
608+ this.sql.exec("UPDATE issues SET state = 'closed', state_reason = 'merged', closed_at = ?, updated_at = ? WHERE number = ?", t, t, number);
609+ const meta = this.repo.meta;
610+ this.repo.emit({ type: "pull_request", action: "merged", repoId: meta.repoId, owner: meta.owner, repo: meta.name, number, headRef: p.head_ref, headSha: p.head_sha, baseRef: p.base_ref, actor: actorOf(caller), at: t });
611+ // A merge is a push to the base branch from the server's point of view.
612+ const serverCaller: Caller = { ...caller, role: "admin" };
613+ await this.repo.afterRefUpdate(baseRef, baseSha, newSha, serverCaller);
614+ // Close keywords: "fixes #12", "closes #3", "resolves #7".
615+ for (const match of `${r.title}\n${r.body}`.matchAll(/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/gi)) {
616+ const n = Number(match[1]);
617+ try {
618+ const target = this.row(n);
619+ if (target.kind === "issue" && target.state === "open") this.update(n, { state: "closed", stateReason: "completed" }, serverCaller);
620+ } catch {
621+ /* unknown issue number */
622+ }
623+ }
624+ if (input.deleteBranch && p.head_ref !== meta.defaultBranch) {
625+ const headRef = `refs/heads/${p.head_ref}`;
626+ if (this.repo.getRef(headRef) === p.head_sha) this.repo.casRef(headRef, p.head_sha, ZERO_OID);
627+ }
628+ this.repo.broadcast(`pr:${number}`, { action: "merged", sha: newSha });
629+ return { merged: true, sha: newSha, method };
630+ }
631+
632+ // ---------------------------------------------------------------- webhooks
633+
634+ webhooks(): Array<{ id: string; url: string; events: string[]; active: boolean; createdAt: string; lastStatus: number | null; lastDeliveryAt: string | null; secret: string }> {
635+ return this.sql
636+ .exec<Record<string, string | number | null>>("SELECT * FROM webhooks ORDER BY created_at")
637+ .toArray()
638+ .map((w) => ({
639+ id: w.id as string,
640+ url: w.url as string,
641+ events: JSON.parse(w.events as string),
642+ active: !!w.active,
643+ createdAt: w.created_at as string,
644+ lastStatus: (w.last_status as number) ?? null,
645+ lastDeliveryAt: (w.last_delivery_at as string) ?? null,
646+ secret: w.secret as string,
647+ }));
648+ }
649+
650+ createWebhook(url: string, events: string[], secret?: string): { id: string; secret: string } {
651+ if (!/^https:\/\//.test(url)) throw new HttpError(422, "webhook URL must use https");
652+ const id = newId();
653+ const s = secret ?? randomToken(24);
654+ this.sql.exec("INSERT INTO webhooks (id, url, secret, events, created_at) VALUES (?, ?, ?, ?, ?)", id, url, s, JSON.stringify(events.length ? events : ["*"]), now());
655+ return { id, secret: s };
656+ }
657+
658+ deleteWebhook(id: string): void {
659+ this.sql.exec("DELETE FROM webhooks WHERE id = ?", id);
660+ }
661+
662+ recordDelivery(id: string, status: number): void {
663+ this.sql.exec("UPDATE webhooks SET last_status = ?, last_delivery_at = ? WHERE id = ?", status, now(), id);
664+ }
665+}
addedapps/diffinery/src/repo/objects.ts+97−0View file
@@ -1,0 +1,97 @@
1+import { CODE_TYPES, TYPE_CODES, deflate, hashObject, inflate, type GitObject, type ObjectStore, type ObjectType } from "@diffinery/git-core";
2+
3+/** Objects larger than this (compressed) go to R2 instead of SQLite (rows are capped at 2 MB). */
4+const INLINE_LIMIT = 512 * 1024;
5+const CACHE_BYTES = 24 * 1024 * 1024;
6+
7+export interface RawObject {
8+ type: ObjectType;
9+ size: number;
10+ deflated: Uint8Array;
11+}
12+
13+/**
14+ * Git object storage for one repository: SQLite inside the Durable Object for
15+ * small objects, R2 for large ones, with an in-memory LRU of inflated objects.
16+ */
17+export class RepoObjectStore implements ObjectStore {
18+ private cache = new Map<string, GitObject>();
19+ private cacheBytes = 0;
20+
21+ constructor(private sql: SqlStorage, private bucket: R2Bucket, private repoId: string) {}
22+
23+ private r2Key(oid: string) {
24+ return `repos/${this.repoId}/objects/${oid.slice(0, 2)}/${oid.slice(2)}`;
25+ }
26+
27+ private remember(oid: string, obj: GitObject) {
28+ if (obj.content.length > 4 * 1024 * 1024) return;
29+ this.cache.set(oid, obj);
30+ this.cacheBytes += obj.content.length;
31+ while (this.cacheBytes > CACHE_BYTES) {
32+ const [k, v] = this.cache.entries().next().value as [string, GitObject];
33+ this.cache.delete(k);
34+ this.cacheBytes -= v.content.length;
35+ }
36+ }
37+
38+ has(oid: string): boolean {
39+ return this.sql.exec("SELECT 1 FROM objects WHERE oid = ?", oid).toArray().length > 0;
40+ }
41+
42+ async readRaw(oid: string): Promise<RawObject | null> {
43+ const row = this.sql.exec<{ type: number; size: number; data: ArrayBuffer | null; r2: number }>("SELECT type, size, data, r2 FROM objects WHERE oid = ?", oid).toArray()[0];
44+ if (!row) return null;
45+ let deflated: Uint8Array;
46+ if (row.r2) {
47+ const o = await this.bucket.get(this.r2Key(oid));
48+ if (!o) throw new Error(`object ${oid} missing from R2`);
49+ deflated = new Uint8Array(await o.arrayBuffer());
50+ } else {
51+ deflated = new Uint8Array(row.data!);
52+ }
53+ return { type: CODE_TYPES[row.type]!, size: row.size, deflated };
54+ }
55+
56+ async read(oid: string): Promise<GitObject | null> {
57+ const hit = this.cache.get(oid);
58+ if (hit) {
59+ this.cache.delete(oid);
60+ this.cache.set(oid, hit);
61+ return hit;
62+ }
63+ const raw = await this.readRaw(oid);
64+ if (!raw) return null;
65+ const obj = { type: raw.type, content: inflate(raw.deflated) };
66+ this.remember(oid, obj);
67+ return obj;
68+ }
69+
70+ async write(type: ObjectType, content: Uint8Array): Promise<string> {
71+ const oid = await hashObject(type, content);
72+ await this.writeKnown(oid, type, content);
73+ return oid;
74+ }
75+
76+ /** Store an object whose oid is already verified. */
77+ async writeKnown(oid: string, type: ObjectType, content: Uint8Array): Promise<void> {
78+ if (this.has(oid)) return;
79+ const deflated = deflate(content);
80+ if (deflated.length > INLINE_LIMIT) {
81+ await this.bucket.put(this.r2Key(oid), deflated);
82+ this.sql.exec("INSERT OR IGNORE INTO objects (oid, type, size, data, r2) VALUES (?, ?, ?, NULL, 1)", oid, TYPE_CODES[type], content.length);
83+ } else {
84+ this.sql.exec("INSERT OR IGNORE INTO objects (oid, type, size, data, r2) VALUES (?, ?, ?, ?, 0)", oid, TYPE_CODES[type], content.length, deflated);
85+ }
86+ this.remember(oid, { type, content });
87+ }
88+
89+ count(): number {
90+ return this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM objects").one().n;
91+ }
92+
93+ /** Approximate stored bytes (compressed). */
94+ sizeBytes(): number {
95+ return this.sql.exec<{ n: number | null }>("SELECT sum(length(data)) AS n FROM objects").one().n ?? 0;
96+ }
97+}
addedapps/diffinery/src/repo/schema.ts+66−0View file
@@ -1,0 +1,66 @@
1+/** SQLite schema of a repository Durable Object. Applied idempotently on construction. */
2+export const REPO_SCHEMA = `
3+CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
4+CREATE TABLE IF NOT EXISTS refs (name TEXT PRIMARY KEY, oid TEXT NOT NULL);
5+CREATE TABLE IF NOT EXISTS objects (
6+ oid TEXT PRIMARY KEY, type INTEGER NOT NULL, size INTEGER NOT NULL, data BLOB, r2 INTEGER NOT NULL DEFAULT 0
7+);
8+CREATE TABLE IF NOT EXISTS counters (name TEXT PRIMARY KEY, value INTEGER NOT NULL);
9+
10+CREATE TABLE IF NOT EXISTS issues (
11+ number INTEGER PRIMARY KEY, kind TEXT NOT NULL, title TEXT NOT NULL, body TEXT NOT NULL DEFAULT '',
12+ state TEXT NOT NULL DEFAULT 'open', state_reason TEXT, author_id TEXT NOT NULL, author_login TEXT NOT NULL,
13+ author_kind TEXT NOT NULL DEFAULT 'user', assignees TEXT NOT NULL DEFAULT '[]', labels TEXT NOT NULL DEFAULT '[]',
14+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL, closed_at TEXT, locked INTEGER NOT NULL DEFAULT 0
15+);
16+CREATE TABLE IF NOT EXISTS pulls (
17+ number INTEGER PRIMARY KEY, head_ref TEXT NOT NULL, head_sha TEXT NOT NULL, base_ref TEXT NOT NULL,
18+ draft INTEGER NOT NULL DEFAULT 0, merged INTEGER NOT NULL DEFAULT 0, merged_at TEXT, merged_by TEXT,
19+ merge_commit TEXT, requested_reviewers TEXT NOT NULL DEFAULT '[]', intent TEXT
20+);
21+CREATE INDEX IF NOT EXISTS pulls_head ON pulls(head_ref);
22+CREATE TABLE IF NOT EXISTS comments (
23+ id TEXT PRIMARY KEY, number INTEGER NOT NULL, author_id TEXT NOT NULL, author_login TEXT NOT NULL,
24+ author_kind TEXT NOT NULL DEFAULT 'user', body TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
25+ review_id TEXT, path TEXT, line INTEGER, side TEXT, commit_id TEXT, in_reply_to TEXT, resolved INTEGER NOT NULL DEFAULT 0
26+);
27+CREATE INDEX IF NOT EXISTS comments_number ON comments(number);
28+CREATE TABLE IF NOT EXISTS reviews (
29+ id TEXT PRIMARY KEY, number INTEGER NOT NULL, author_id TEXT NOT NULL, author_login TEXT NOT NULL,
30+ author_kind TEXT NOT NULL DEFAULT 'user', state TEXT NOT NULL, body TEXT NOT NULL DEFAULT '', commit_id TEXT NOT NULL,
31+ submitted_at TEXT NOT NULL
32+);
33+CREATE INDEX IF NOT EXISTS reviews_number ON reviews(number);
34+CREATE TABLE IF NOT EXISTS labels (name TEXT PRIMARY KEY, color TEXT NOT NULL, description TEXT NOT NULL DEFAULT '');
35+CREATE TABLE IF NOT EXISTS protections (pattern TEXT PRIMARY KEY, rules TEXT NOT NULL);
36+CREATE TABLE IF NOT EXISTS secrets (name TEXT PRIMARY KEY, ciphertext TEXT NOT NULL, updated_at TEXT NOT NULL);
37+CREATE TABLE IF NOT EXISTS variables (name TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL);
38+CREATE TABLE IF NOT EXISTS webhooks (
39+ id TEXT PRIMARY KEY, url TEXT NOT NULL, secret TEXT NOT NULL, events TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1,
40+ created_at TEXT NOT NULL, last_status INTEGER, last_delivery_at TEXT
41+);
42+CREATE TABLE IF NOT EXISTS checks (
43+ id TEXT PRIMARY KEY, sha TEXT NOT NULL, name TEXT NOT NULL, status TEXT NOT NULL, conclusion TEXT,
44+ run_id TEXT, job_id TEXT, details_url TEXT, output TEXT, app TEXT NOT NULL DEFAULT 'diffinery-actions',
45+ started_at TEXT, completed_at TEXT
46+);
47+CREATE INDEX IF NOT EXISTS checks_sha ON checks(sha);
48+CREATE TABLE IF NOT EXISTS runs (
49+ id TEXT PRIMARY KEY, run_number INTEGER NOT NULL, workflow_path TEXT NOT NULL, workflow_name TEXT NOT NULL,
50+ event TEXT NOT NULL, ref TEXT NOT NULL, sha TEXT NOT NULL, actor TEXT NOT NULL, status TEXT NOT NULL,
51+ conclusion TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, pr_number INTEGER, attempt INTEGER NOT NULL DEFAULT 1,
52+ event_payload TEXT NOT NULL, inputs TEXT NOT NULL DEFAULT '{}', concurrency_group TEXT, display_title TEXT NOT NULL DEFAULT ''
53+);
54+CREATE INDEX IF NOT EXISTS runs_created ON runs(created_at);
55+CREATE TABLE IF NOT EXISTS jobs (
56+ id TEXT PRIMARY KEY, run_id TEXT NOT NULL, job_key TEXT NOT NULL, name TEXT NOT NULL, status TEXT NOT NULL,
57+ conclusion TEXT, matrix TEXT NOT NULL DEFAULT '{}', strategy TEXT NOT NULL DEFAULT '{}', started_at TEXT, completed_at TEXT,
58+ outputs TEXT NOT NULL DEFAULT '{}', steps TEXT NOT NULL DEFAULT '[]', failure TEXT, token_hash TEXT, summary TEXT,
59+ container TEXT, timeout_minutes INTEGER NOT NULL DEFAULT 360, labels TEXT
60+);
61+CREATE INDEX IF NOT EXISTS jobs_run ON jobs(run_id);
62+CREATE TABLE IF NOT EXISTS logs (job_id TEXT NOT NULL, seq INTEGER NOT NULL, t TEXT NOT NULL, step INTEGER NOT NULL, text TEXT NOT NULL, PRIMARY KEY (job_id, seq));
63+CREATE TABLE IF NOT EXISTS caches (key TEXT PRIMARY KEY, size INTEGER NOT NULL, created_at TEXT NOT NULL, last_used TEXT NOT NULL);
64+CREATE TABLE IF NOT EXISTS artifacts (id TEXT PRIMARY KEY, run_id TEXT NOT NULL, name TEXT NOT NULL, size INTEGER NOT NULL, created_at TEXT NOT NULL, UNIQUE(run_id, name));
65+CREATE TABLE IF NOT EXISTS sandboxes (id TEXT PRIMARY KEY, secret TEXT NOT NULL, created_by TEXT NOT NULL, ref TEXT NOT NULL, created_at TEXT NOT NULL, status TEXT NOT NULL);
66+`;
addedapps/diffinery/src/repo/types.ts+51−0View file
@@ -1,0 +1,51 @@
1+import type { Actor } from "@diffinery/shared";
2+
3+export type Role = "read" | "triage" | "write" | "maintain" | "admin";
4+export const ROLE_LEVEL: Record<Role, number> = { read: 1, triage: 2, write: 3, maintain: 4, admin: 5 };
5+export const atLeast = (role: Role | null | undefined, needed: Role): boolean => !!role && ROLE_LEVEL[role] >= ROLE_LEVEL[needed];
6+
7+/** The authenticated caller, with their effective role on this repository. */
8+export interface Caller extends Actor {
9+ role: Role;
10+}
11+
12+export interface RepoMeta {
13+ repoId: string;
14+ ownerId: string;
15+ owner: string;
16+ name: string;
17+ defaultBranch: string;
18+ /** Owner kind decides whether org secrets apply. */
19+ ownerKind: "user" | "org";
20+}
21+
22+export interface ProtectionRules {
23+ requirePullRequest?: boolean;
24+ requiredApprovals?: number;
25+ dismissStaleReviews?: boolean;
26+ requiredChecks?: string[];
27+ allowForcePush?: boolean;
28+ allowDeletion?: boolean;
29+ requireLinearHistory?: boolean;
30+ /** When false (default), admins may bypass the rules. */
31+ enforceAdmins?: boolean;
32+ /** Agents may push directly to matching branches. Default false. */
33+ agentsMayPush?: boolean;
34+ /** Agents may merge pull requests into matching branches. Default false. */
35+ agentsMayMerge?: boolean;
36+}
37+
38+export function globToRegExp(pattern: string): RegExp {
39+ let re = "";
40+ for (let i = 0; i < pattern.length; i++) {
41+ const c = pattern[i]!;
42+ if (c === "*") {
43+ if (pattern[i + 1] === "*") {
44+ re += ".*";
45+ i++;
46+ } else re += "[^/]*";
47+ } else if (c === "?") re += "[^/]";
48+ else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
49+ }
50+ return new RegExp(`^${re}$`);
51+}
addedapps/diffinery/src/runner/containers.ts+70−0View file
@@ -1,0 +1,70 @@
1+import { Container } from "@cloudflare/containers";
2+import type { StopParams } from "@cloudflare/containers";
3+import type { Env } from "../env.js";
4+
5+/**
6+ * One container per workflow job. The runner agent inside fetches its job spec
7+ * from the Diffinery API, runs the steps and reports back, then exits.
8+ */
9+export class RunnerContainer extends Container<Env> {
10+ // Jobs make no inbound requests, so activity-based sleep must not stop them.
11+ override sleepAfter = "6h";
12+ override enableInternet = true;
13+
14+ async startJob(input: { repoId: string; jobId: string; token: string; apiUrl: string }): Promise<void> {
15+ await this.ctx.storage.put("job", { repoId: input.repoId, jobId: input.jobId });
16+ await this.start({
17+ envVars: {
18+ DIFFINERY_API_URL: input.apiUrl,
19+ DIFFINERY_JOB_ID: input.jobId,
20+ DIFFINERY_JOB_TOKEN: input.token,
21+ },
22+ });
23+ }
24+
25+ async stopJob(): Promise<void> {
26+ if (this.ctx.container?.running) await this.stop("SIGTERM");
27+ }
28+
29+ override async onStop(params: StopParams): Promise<void> {
30+ const job = await this.ctx.storage.get<{ repoId: string; jobId: string }>("job");
31+ if (!job) return;
32+ const repo = this.env.REPO.get(this.env.REPO.idFromName(job.repoId));
33+ await repo.containerStopped(job.jobId, params.exitCode ?? null);
34+ await this.ctx.storage.delete("job");
35+ }
36+
37+ override onError(error: unknown): void {
38+ console.error("runner container error", error);
39+ }
40+}
41+
42+/**
43+ * Long-lived task sandbox for AI agents: the repo checked out with toolchains,
44+ * and an HTTP API (exec, read/write files) on port 8080.
45+ */
46+export class SandboxContainer extends Container<Env> {
47+ override defaultPort = 8080;
48+ override sleepAfter = "30m";
49+ override enableInternet = true;
50+
51+ async boot(envVars: Record<string, string>, secret: string): Promise<void> {
52+ await this.ctx.storage.put("secret", secret);
53+ await this.startAndWaitForPorts({ ports: 8080, startOptions: { envVars, entrypoint: ["node", "/opt/diffinery/agent.mjs", "--sandbox"] } });
54+ }
55+
56+ /** Forward an API call (JSON text) to the sandbox's HTTP server with its secret attached. */
57+ async call(path: string, body: string): Promise<{ status: number; body: string; json: boolean }> {
58+ const secret = await this.ctx.storage.get<string>("secret");
59+ const res = await this.containerFetch(`http://sandbox${path}`, {
60+ method: "POST",
61+ headers: { "content-type": "application/json", authorization: `Bearer ${secret}` },
62+ body: body || "{}",
63+ });
64+ return { status: res.status, body: await res.text(), json: (res.headers.get("content-type") ?? "").includes("json") };
65+ }
66+
67+ async shutdown(): Promise<void> {
68+ if (this.ctx.container?.running) await this.stop();
69+ }
70+}
addedapps/diffinery/src/runner/workflow.ts+48−0View file
@@ -1,0 +1,48 @@
1+import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from "cloudflare:workers";
2+import type { Env } from "../env.js";
3+
4+export interface RunParams {
5+ repoId: string;
6+ runId: string;
7+}
8+
9+/**
10+ * Durable orchestration of one workflow run: resolves the job graph level by
11+ * level, starts each job's containers, and waits for the runner to report
12+ * completion. Retries, sleeps and waits survive restarts of the platform.
13+ */
14+export class RunWorkflow extends WorkflowEntrypoint<Env, RunParams> {
15+ override async run(event: WorkflowEvent<RunParams>, step: WorkflowStep): Promise<unknown> {
16+ const { repoId, runId } = event.payload;
17+ const repo = () => this.env.REPO.get(this.env.REPO.idFromName(repoId));
18+
19+ let plan = await step.do("plan", () => repo().runPlan(runId));
20+ // Concurrency groups without cancel-in-progress: wait for the running run to finish.
21+ for (let i = 0; plan.concurrencyBlocked && i < 360; i++) {
22+ await step.sleep(`concurrency-wait-${i}`, "1 minute");
23+ plan = await step.do(`plan-${i}`, () => repo().runPlan(runId));
24+ }
25+ if (plan.error) {
26+ await step.do("record-error", () => repo().plannedRunError(runId, plan.error!));
27+ return step.do("finish", () => repo().finishRun(runId));
28+ }
29+
30+ for (const [levelIndex, level] of plan.levels.entries()) {
31+ const started = await Promise.all(level.map((key) => step.do(`start ${key}`, { retries: { limit: 3, delay: "10 seconds", backoff: "exponential" } }, () => repo().startJobKey(runId, key))));
32+ const jobIds = started.flatMap((s) => s.jobIds);
33+ await Promise.all(
34+ jobIds.map(async (jobId) => {
35+ const status = await step.do(`job ${jobId} info`, () => repo().jobStatus(jobId));
36+ if (status === "completed") return;
37+ try {
38+ await step.waitForEvent(`wait ${jobId}`, { type: `job-${jobId}`, timeout: "6 hours" });
39+ } catch {
40+ await step.do(`timeout ${jobId}`, () => repo().jobTimedOut(jobId));
41+ }
42+ }),
43+ );
44+ void levelIndex;
45+ }
46+ return step.do("finish", () => repo().finishRun(runId));
47+ }
48+}
addedapps/diffinery/src/web/account.tsx+1760−0View file
@@ -1,0 +1,1760 @@
1+/** @jsxImportSource hono/jsx */
2+import { raw } from "hono/html";
3+import type { Child, FC } from "hono/jsx";
4+import { requireAccount, type AppContext } from "../auth/principal.js";
5+import type { Account, Repo, TokenScope } from "../lib/directory.js";
6+import { loadRepo } from "../http/common.js";
7+import { HttpError } from "../lib/util.js";
8+import type { Role } from "../repo/types.js";
9+import { ICONS } from "./assets.js";
10+import { csrfToken } from "./csrf.js";
11+import { Auth } from "../auth/sessions.js";
12+import { browserAccount, deviceLabel, invitePasskeyPage } from "./auth.js";
13+import { Actor, Avatar, CopyButton, Form, ROLES, Time, act, ago, post, publicUrl, render, repoUrl, type Stub, type WebApp, prm } from "./ui.js";
14+
15+// ---------------------------------------------------------------- shared bits
16+
17+const RepoList: FC<{ repos: Repo[]; showOwner?: boolean; empty?: Child }> = ({ repos, showOwner = true, empty }) =>
18+ repos.length ? (
19+ <ul class="list">
20+ {repos.map((r) => (
21+ <li>
22+ <span class="st faint">{raw(r.visibility === "private" ? ICONS.lock : ICONS.repo)}</span>
23+ <div class="grow">
24+ <a class="title" href={repoUrl(r)}>
25+ {showOwner ? `${r.owner_login}/` : ""}
26+ {r.name}
27+ </a>{" "}
28+ <span class="badge">{r.visibility}</span> {r.archived ? <span class="badge amber">archived</span> : null}
29+ {r.description ? <div class="meta">{r.description}</div> : null}
30+ </div>
31+ <span class="small muted nowrap">Updated {ago(r.pushed_at ?? r.updated_at)}</span>
32+ </li>
33+ ))}
34+ </ul>
35+ ) : (
36+ <div class="empty">{empty ?? "No repositories yet."}</div>
37+ );
38+
39+function parsePolicy(a: Account): { maxRole?: Role } {
40+ try {
41+ return a.agent_policy ? (JSON.parse(a.agent_policy) as { maxRole?: Role }) : {};
42+ } catch {
43+ return {};
44+ }
45+}
46+
47+async function adminOrgs(c: AppContext, me: Account): Promise<Array<Account & { role: string }>> {
48+ return (await c.get("dir").orgsFor(me.id)).filter((o) => o.role === "owner" || o.role === "admin");
49+}
50+
51+/** Whether `me` may manage an agent (its owner, an admin of its owning org, or a site admin). */
52+async function canManageAgent(c: AppContext, me: Account, agent: Account): Promise<boolean> {
53+ if (agent.kind !== "agent" || !agent.owner_id) return false;
54+ if (me.site_admin || agent.owner_id === me.id) return true;
55+ const role = await c.get("dir").orgRole(agent.owner_id, me.id);
56+ return role === "owner" || role === "admin";
57+}
58+
59+const SCOPES: TokenScope[] = ["read", "write", "admin"];
60+
61+// ---------------------------------------------------------------- landing / dashboard
62+
63+const Landing: FC = () => (
64+ <div>
65+ <section class="hero">
66+ <h1>Diffinery</h1>
67+ <p>Code hosting and CI for the agentic era. Git repositories, pull requests with intent, GitHub Actions–compatible workflows and AI agents as first-class collaborators, running on Cloudflare.</p>
68+ <p class="row" style="justify-content:center">
69+ <a class="btn primary" href="/login">
70+ Sign in with a passkey
71+ </a>
72+ </p>
73+ <p class="muted small">Diffinery is invite-only. Open the invite link someone sent you to create your passkey; there are no passwords.</p>
74+ </section>
75+ <div class="features">
76+ <div class="box">
77+ <h3>{raw(ICONS.repo)} Git, natively</h3>
78+ <p class="muted m0">Clone, fetch and push over smart HTTP. Every repository is its own strongly consistent Durable Object.</p>
79+ </div>
80+ <div class="box">
81+ <h3>{raw(ICONS.pr)} Reviews with intent</h3>
82+ <p class="muted m0">Pull requests carry what a change is meant to do, so human and AI reviewers check the right thing.</p>
83+ </div>
84+ <div class="box">
85+ <h3>{raw(ICONS.play)} Actions-compatible CI</h3>
86+ <p class="muted m0">Bring your existing workflow YAML. Jobs run in containers with live logs, caches and artifacts.</p>
87+ </div>
88+ <div class="box">
89+ <h3>{raw(ICONS.bot)} Agents with guardrails</h3>
90+ <p class="muted m0">Agents get their own accounts, tokens and policies. Protected branches keep a human in the loop.</p>
91+ </div>
92+ </div>
93+ </div>
94+);
95+
96+export function registerAccount(web: WebApp): void {
97+ web.get("/", async (c) => {
98+ const p = c.get("principal");
99+ if (p.type !== "account") return render(c, { title: "" }, <Landing />);
100+ const me = p.account;
101+ const t = csrfToken(c);
102+ if (me.status === "disabled") {
103+ return render(
104+ c,
105+ { title: "Account disabled", status: 403 },
106+ <div class="blank" style="max-width:560px;margin:48px auto">
107+ <h1>Your account is disabled</h1>
108+ <p class="muted m0">Contact an administrator of this Diffinery instance if you think this is a mistake.</p>
109+ </div>,
110+ );
111+ }
112+ if (me.status === "pending") {
113+ return render(
114+ c,
115+ { title: "Waiting for an invite" },
116+ <div class="box" style="max-width:600px;margin:48px auto">
117+ <div class="body">
118+ <h1>Waiting for an invite</h1>
119+ <p class="muted">
120+ You are signed in as <b>{me.email ?? me.login}</b>, but this Diffinery instance is invite-only. Ask someone with access to invite your email address, or send a request to the administrators.
121+ </p>
122+ <Form action="/request-access" t={t}>
123+ <div class="field">
124+ <label for="note">Note to the administrators</label>
125+ <textarea id="note" name="note" maxlength={500} placeholder="Who are you, and what would you like to work on?" style="min-height:90px"></textarea>
126+ </div>
127+ <button class="btn primary">Request access</button>
128+ </Form>
129+ </div>
130+ </div>,
131+ );
132+ }
133+ const dir = c.get("dir");
134+ const [repos, orgs, notes] = await Promise.all([dir.reposVisibleTo(me), dir.orgsFor(me.id), dir.notifications(me.id, true)]);
135+ const mine = repos.filter((r) => r.owner_id === me.id || orgs.some((o) => o.id === r.owner_id));
136+ const others = repos.filter((r) => !mine.includes(r));
137+ return render(
138+ c,
139+ { title: "Dashboard" },
140+ <div class="grid left">
141+ <aside class="side">
142+ <section>
143+ <div class="row between mb">
144+ <h3 class="m0">Your repositories</h3>
145+ <a class="btn sm primary" href="/new">
146+ {raw(ICONS.plus)} New
147+ </a>
148+ </div>
149+ {mine.length ? (
150+ <div class="nav">
151+ {mine.slice(0, 40).map((r) => (
152+ <a href={repoUrl(r)} class="nowrap" style="overflow:hidden;text-overflow:ellipsis">
153+ <Avatar login={r.owner_login} /> {r.owner_login}/<b>{r.name}</b>
154+ </a>
155+ ))}
156+ </div>
157+ ) : (
158+ <p class="muted small">You have no repositories yet.</p>
159+ )}
160+ </section>
161+ <section>
162+ <h3>Organizations</h3>
163+ <div class="nav">
164+ {orgs.map((o) => (
165+ <a href={`/${o.login}`}>
166+ <Avatar login={o.login} /> {o.login} <span class="faint small">{o.role}</span>
167+ </a>
168+ ))}
169+ <a href="/settings/orgs" class="muted">
170+ {raw(ICONS.plus)} New organization
171+ </a>
172+ </div>
173+ </section>
174+ </aside>
175+ <div>
176+ {repos.length === 0 ? (
177+ <div class="box">
178+ <div class="body">
179+ <h2>Welcome to Diffinery, {me.name ?? me.login}</h2>
180+ <ol class="muted" style="padding-left:20px">
181+ <li>
182+ <a href="/new">Create a repository</a> (or an <a href="/settings/orgs">organization</a> to share work with friends).
183+ </li>
184+ <li>
185+ <a href="/settings/tokens">Create a personal access token</a> — Git uses it as the password over HTTPS.
186+ </li>
187+ <li>Push your code, open pull requests and add workflows under <code>.github/workflows</code>.</li>
188+ <li>
189+ Invite people from an organization's <b>People</b> page or a repository's <b>Settings → Access</b>.
190+ </li>
191+ </ol>
192+ </div>
193+ </div>
194+ ) : null}
195+ {notes.length ? (
196+ <div class="box">
197+ <div class="head">
198+ <h2>{raw(ICONS.bell)} Unread notifications</h2>
199+ <a class="small" href="/notifications" style="margin-left:auto">
200+ View all
201+ </a>
202+ </div>
203+ <ul class="list">
204+ {notes.slice(0, 8).map((n) => (
205+ <li>
206+ <div class="grow">
207+ <a class="title" href={`/notifications/${n.id}`}>
208+ {n.title}
209+ </a>
210+ <div class="meta">
211+ {n.kind.replace(/_/g, " ")} · <Time at={n.createdAt} />
212+ </div>
213+ </div>
214+ </li>
215+ ))}
216+ </ul>
217+ </div>
218+ ) : null}
219+ <div class="box">
220+ <div class="head">
221+ <h2>Repositories</h2>
222+ <span class="muted small">{repos.length} you can access</span>
223+ </div>
224+ <RepoList repos={[...mine, ...others].slice(0, 100)} empty={<a href="/new">Create your first repository</a>} />
225+ </div>
226+ </div>
227+ </div>,
228+ );
229+ });
230+
231+ post(web, "/request-access", async (c, f) => {
232+ const p = c.get("principal");
233+ if (p.type !== "account") throw new HttpError(401, "sign in required");
234+ if (p.account.status !== "pending") return c.redirect("/", 303);
235+ return act(c, "/", async () => c.get("dir").requestAccess(p.account.id, f.s("note")), "Request sent. An administrator will review it.");
236+ });
237+
238+ // ---------------------------------------------------------------- new repository
239+
240+ const NewRepoForm: FC<{ t: string; me: Account; orgs: Array<Account & { role: string }>; values: Record<string, string>; error?: string }> = ({ t, me, orgs, values, error }) => (
241+ <div style="max-width:720px;margin:0 auto">
242+ <h1>Create a new repository</h1>
243+ <p class="muted">A repository holds your project's files, history, issues, pull requests and workflows.</p>
244+ {error ? <div class="flash error">{error}</div> : null}
245+ <Form action="/new" t={t} class="box">
246+ <div class="body">
247+ <div class="row" style="align-items:flex-end">
248+ <div class="field" style="min-width:180px">
249+ <label for="owner">Owner</label>
250+ <select id="owner" name="owner">
251+ <option value={me.login} selected={values.owner === me.login}>
252+ {me.login}
253+ </option>
254+ {orgs.map((o) => (
255+ <option value={o.login} selected={values.owner === o.login}>
256+ {o.login}
257+ </option>
258+ ))}
259+ </select>
260+ </div>
261+ <div class="field" style="padding-bottom:8px">/</div>
262+ <div class="field grow">
263+ <label for="name">Repository name</label>
264+ <input id="name" name="name" type="text" required pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,99}" value={values.name} autofocus autocomplete="off" />
265+ </div>
266+ </div>
267+ <div class="field">
268+ <label for="description">
269+ Description <span class="faint">(optional)</span>
270+ </label>
271+ <input id="description" name="description" type="text" value={values.description} maxlength={350} />
272+ </div>
273+ <div class="field">
274+ <label>Visibility</label>
275+ {(
276+ [
277+ ["private", "Private", "Only you, collaborators and organization members can see it."],
278+ ["internal", "Internal", "Everyone signed in to this Diffinery instance can see it."],
279+ ["public", "Public", "Anyone on the internet can see and clone it."],
280+ ] as const
281+ ).map(([v, l, d]) => (
282+ <label class="inline" style="margin:4px 0">
283+ <input type="radio" name="visibility" value={v} checked={(values.visibility || "private") === v} /> <b>{l}</b> <span class="muted small">— {d}</span>
284+ </label>
285+ ))}
286+ </div>
287+ <div class="field" style="max-width:280px">
288+ <label for="branch">Default branch</label>
289+ <input id="branch" name="default_branch" type="text" value={values.default_branch || "main"} />
290+ </div>
291+ <button class="btn primary">Create repository</button>
292+ </div>
293+ </Form>
294+ </div>
295+ );
296+
297+ web.get("/new", async (c) => {
298+ const me = requireAccount(c);
299+ const orgs = await c.get("dir").orgsFor(me.id);
300+ return render(c, { title: "New repository" }, <NewRepoForm t={csrfToken(c)} me={me} orgs={orgs} values={{ owner: c.req.query("owner") ?? me.login }} />);
301+ });
302+
303+ post(web, "/new", async (c, f) => {
304+ const me = requireAccount(c);
305+ const dir = c.get("dir");
306+ const values = { owner: f.s("owner"), name: f.s("name").trim(), description: f.s("description").trim(), visibility: f.s("visibility"), default_branch: f.s("default_branch").trim() };
307+ try {
308+ const owner = values.owner === me.login ? me : await dir.accountByLogin(values.owner);
309+ if (!owner || owner.kind === "agent") throw new HttpError(422, "unknown owner");
310+ const visibility = (["private", "internal", "public"] as const).find((v) => v === values.visibility) ?? "private";
311+ const repo = await dir.createRepo(owner, me, { name: values.name, description: values.description, visibility, defaultBranch: values.default_branch || "main" });
312+ c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "repo.create", target: `${repo.owner_login}/${repo.name}` }));
313+ return c.redirect(repoUrl(repo), 303);
314+ } catch (e) {
315+ if (!(e instanceof HttpError) || e.status >= 500) throw e;
316+ const orgs = await dir.orgsFor(me.id);
317+ return render(c, { title: "New repository", status: e.status }, <NewRepoForm t={csrfToken(c)} me={me} orgs={orgs} values={values} error={e.detail} />);
318+ }
319+ });
320+
321+ // ---------------------------------------------------------------- notifications
322+
323+ web.get("/notifications", async (c) => {
324+ const me = requireAccount(c);
325+ const t = csrfToken(c);
326+ const all = c.req.query("all") === "1";
327+ const notes = await c.get("dir").notifications(me.id, !all);
328+ return render(
329+ c,
330+ { title: "Notifications" },
331+ <div style="max-width:900px;margin:0 auto">
332+ <div class="row between mb">
333+ <h1 class="m0">Notifications</h1>
334+ <div class="row">
335+ <div class="subnav">
336+ <a href="/notifications" class={!all ? "on" : ""}>
337+ Unread
338+ </a>
339+ <a href="/notifications?all=1" class={all ? "on" : ""}>
340+ All
341+ </a>
342+ </div>
343+ <Form action="/notifications/read" t={t} class="inline">
344+ <button class="btn sm">Mark all as read</button>
345+ </Form>
346+ </div>
347+ </div>
348+ <div class="box">
349+ {notes.length ? (
350+ <ul class="list">
351+ {notes.map((n) => (
352+ <li>
353+ <span class={`st ${n.readAt ? "faint" : ""}`} style={n.readAt ? "" : "color:var(--accent)"}>
354+ {raw(n.kind.startsWith("pull") ? ICONS.pr : n.kind.includes("run") || n.kind.includes("workflow") ? ICONS.play : ICONS.bell)}
355+ </span>
356+ <div class="grow">
357+ <a class="title" href={`/notifications/${n.id}`} style={n.readAt ? "font-weight:400" : ""}>
358+ {n.title}
359+ </a>
360+ <div class="meta">
361+ {n.kind.replace(/_/g, " ")} · <Time at={n.createdAt} />
362+ </div>
363+ </div>
364+ {!n.readAt ? (
365+ <Form action="/notifications/read" t={t} class="inline">
366+ <input type="hidden" name="id" value={n.id} />
367+ <button class="btn sm" title="Mark as read">
368+ {raw(ICONS.check)}
369+ </button>
370+ </Form>
371+ ) : null}
372+ </li>
373+ ))}
374+ </ul>
375+ ) : (
376+ <div class="empty">{all ? "No notifications." : "All caught up — no unread notifications."}</div>
377+ )}
378+ </div>
379+ </div>,
380+ );
381+ });
382+
383+ web.get("/notifications/:id", async (c) => {
384+ const me = requireAccount(c);
385+ const dir = c.get("dir");
386+ const id = prm(c, "id");
387+ const n = (await dir.notifications(me.id)).find((x) => x.id === id);
388+ if (!n) throw new HttpError(404, "notification not found");
389+ await dir.markRead(me.id, id);
390+ const base = publicUrl(c);
391+ let to = n.url;
392+ if (to.startsWith(base)) to = to.slice(base.length) || "/";
393+ if (!to.startsWith("/") || to.startsWith("//")) to = "/notifications";
394+ return c.redirect(to, 302);
395+ });
396+
397+ post(web, "/notifications/read", async (c, f) => {
398+ const me = requireAccount(c);
399+ const id = f.s("id");
400+ return act(c, c.req.header("referer")?.includes("/notifications") ? "/notifications" : "/", async () => c.get("dir").markRead(me.id, id || undefined));
401+ });
402+
403+ // ---------------------------------------------------------------- invites
404+
405+ web.get("/invite/:token", async (c) => {
406+ const p = c.get("principal");
407+ if (p.type !== "account") return invitePasskeyPage(c, prm(c, "token"));
408+ if (p.account.status === "disabled") throw new HttpError(403, "your account is disabled");
409+ return render(
410+ c,
411+ { title: "Accept invite" },
412+ <div class="box" style="max-width:560px;margin:48px auto">
413+ <div class="body">
414+ <h1>You have been invited</h1>
415+ <p class="muted">
416+ 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.
417+ </p>
418+ <Form action={`/invite/${encodeURIComponent(prm(c, "token"))}`} t={csrfToken(c)}>
419+ <button class="btn primary">Accept invite</button>
420+ </Form>
421+ </div>
422+ </div>,
423+ );
424+ });
425+
426+ post(web, "/invite/:token", async (c) => {
427+ const p = c.get("principal");
428+ if (p.type !== "account") throw new HttpError(401, "sign in to accept this invite");
429+ if (p.account.status === "disabled") throw new HttpError(403, "your account is disabled");
430+ const dir = c.get("dir");
431+ const res = await dir.acceptInvite(prm(c, "token"), p.account);
432+ let to = "/";
433+ if (res.repoId) {
434+ const r = await dir.repoById(res.repoId);
435+ if (r) to = repoUrl(r);
436+ } else if (res.orgId) {
437+ const o = await dir.account(res.orgId);
438+ if (o) to = `/${o.login}`;
439+ }
440+ c.executionCtx.waitUntil(dir.audit({ actorId: p.account.id, actorLogin: p.account.login, action: "invite.accept", target: to }));
441+ return c.redirect(to, 303);
442+ });
443+
444+ // ---------------------------------------------------------------- palette data
445+
446+ web.get("/-/palette.json", async (c) => {
447+ const me = requireAccount(c);
448+ const repos = (await c.get("dir").reposVisibleTo(me)).slice(0, 300).map((r) => `${r.owner_login}/${r.name}`);
449+ const param = c.req.query("repo") ?? "";
450+ let files: string[] = [];
451+ let ref: string | undefined;
452+ const m = /^([A-Za-z0-9][\w.-]*)\/([A-Za-z0-9][\w.-]*)$/.exec(param);
453+ if (m) {
454+ try {
455+ const { stub, repo } = await loadRepo(c, m[1]!, m[2]!, "read");
456+ ref = c.req.query("ref") || repo.default_branch;
457+ files = await listFiles(stub, ref);
458+ } catch {
459+ files = [];
460+ }
461+ }
462+ return c.json({ repos, files, ref }, 200, { "cache-control": "private, max-age=30" });
463+ });
464+
465+ // ---------------------------------------------------------------- personal settings
466+
467+ registerSettings(web);
468+ registerProfiles(web);
469+}
470+
471+/** Breadth-first file listing for "go to file" (bounded). */
472+async function listFiles(stub: Stub, ref: string): Promise<string[]> {
473+ const out: string[] = [];
474+ let queue = [""];
475+ let reads = 0;
476+ while (queue.length && reads < 120 && out.length < 5000) {
477+ const batch = queue.slice(0, 12);
478+ queue = queue.slice(12);
479+ reads += batch.length;
480+ const trees = await Promise.all(batch.map((p) => stub.tree(ref, p).catch(() => null)));
481+ for (const t of trees) {
482+ if (!t) continue;
483+ for (const it of t.items) {
484+ if (it.type === "tree") {
485+ if (!/^(node_modules|\.git|vendor|dist|target)$/.test(it.name)) queue.push(it.path);
486+ } else if (it.type === "blob") out.push(it.path);
487+ }
488+ }
489+ }
490+ return out.slice(0, 5000);
491+}
492+
493+// ---------------------------------------------------------------- /settings
494+
495+type SettingsTab = "profile" | "security" | "tokens" | "agents" | "orgs" | "admin" | "audit";
496+
497+const SettingsNav: FC<{ me: Account; tab: SettingsTab }> = ({ me, tab }) => (
498+ <aside class="side">
499+ <section>
500+ <div class="row mb">
501+ <Avatar login={me.login} size="lg" />
502+ <div>
503+ <b>{me.name ?? me.login}</b>
504+ <div class="muted small">{me.login}</div>
505+ </div>
506+ </div>
507+ <div class="nav">
508+ <a href="/settings" class={tab === "profile" ? "on" : ""}>
509+ Profile
510+ </a>
511+ <a href="/settings/security" class={tab === "security" ? "on" : ""}>
512+ Passkeys and sessions
513+ </a>
514+ <a href="/settings/tokens" class={tab === "tokens" ? "on" : ""}>
515+ Access tokens
516+ </a>
517+ <a href="/settings/agents" class={tab === "agents" ? "on" : ""}>
518+ Agents
519+ </a>
520+ <a href="/settings/orgs" class={tab === "orgs" ? "on" : ""}>
521+ Organizations
522+ </a>
523+ <a href="/notifications">Notifications</a>
524+ {me.site_admin ? (
525+ <>
526+ <a href="/settings/admin" class={tab === "admin" ? "on" : ""}>
527+ Site administration
528+ </a>
529+ <a href="/settings/audit" class={tab === "audit" ? "on" : ""}>
530+ Audit log
531+ </a>
532+ </>
533+ ) : null}
534+ </div>
535+ </section>
536+ </aside>
537+);
538+
539+const TokenOnce: FC<{ token: string; title: string; c: AppContext; note?: Child }> = ({ token, title, c, note }) => (
540+ <div class="box" style="border-color:var(--amber)">
541+ <div class="body">
542+ <h3>{title}</h3>
543+ <p class="muted">Copy it now — it will not be shown again.</p>
544+ <div class="row">
545+ <div class="secret grow">{token}</div>
546+ <CopyButton text={token} />
547+ </div>
548+ {note}
549+ <details class="mt">
550+ <summary class="small" style="cursor:pointer">
551+ How to use it
552+ </summary>
553+ <pre class="cmd mt">
554+ {`# 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>`}
555+ </pre>
556+ </details>
557+ </div>
558+ </div>
559+);
560+
561+function registerSettings(web: WebApp): void {
562+ const page = (c: AppContext, me: Account, tab: SettingsTab, title: string, body: Child, status?: number) =>
563+ render(
564+ c,
565+ { title, status },
566+ <div class="grid left">
567+ <SettingsNav me={me} tab={tab} />
568+ <div>{body}</div>
569+ </div>,
570+ );
571+
572+ // Profile
573+ web.get("/settings", async (c) => {
574+ const me = requireAccount(c);
575+ const t = csrfToken(c);
576+ return page(
577+ c,
578+ me,
579+ "profile",
580+ "Settings",
581+ <>
582+ <h1>Profile</h1>
583+ <Form action="/settings/profile" t={t} class="box">
584+ <div class="body">
585+ <div class="field">
586+ <label for="login">Username</label>
587+ <input id="login" name="login" type="text" value={me.login} required pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,99}" />
588+ <div class="hint">Changing your username changes the URLs of your repositories. Existing clones need a new remote URL.</div>
589+ </div>
590+ <div class="field">
591+ <label for="name">Display name</label>
592+ <input id="name" name="name" type="text" value={me.name ?? ""} />
593+ </div>
594+ <dl class="kv mb">
595+ <dt>Email</dt>
596+ <dd>{me.email ?? "—"}</dd>
597+ <dt>Account type</dt>
598+ <dd>{me.kind}</dd>
599+ <dt>Joined</dt>
600+ <dd>
601+ <Time at={me.created_at} />
602+ </dd>
603+ <dt>Site admin</dt>
604+ <dd>{me.site_admin ? "yes" : "no"}</dd>
605+ </dl>
606+ <button class="btn primary">Save profile</button>
607+ </div>
608+ </Form>
609+ </>,
610+ );
611+ });
612+
613+ post(web, "/settings/profile", async (c, f) => {
614+ const me = requireAccount(c);
615+ return act(
616+ c,
617+ "/settings",
618+ async () => {
619+ const login = f.s("login").trim();
620+ await c.get("dir").updateAccount(me.id, { login: login && login !== me.login ? login : undefined, name: f.s("name").trim() });
621+ },
622+ "Profile saved.",
623+ );
624+ });
625+
626+ // Passkeys and sessions
627+ web.get("/settings/security", async (c) => {
628+ const me = requireAccount(c);
629+ const t = csrfToken(c);
630+ const auth = new Auth(c.env);
631+ const p = c.get("principal");
632+ const current = p.type === "account" ? p.sessionId : undefined;
633+ const [keys, sessions] = await Promise.all([auth.passkeys(me.id), auth.sessions(me.id)]);
634+ const recovery = !!(c.env.ACCESS_TEAM_DOMAIN && c.env.ACCESS_AUD);
635+ return page(
636+ c,
637+ me,
638+ "security",
639+ "Passkeys and sessions",
640+ <>
641+ <h1>Passkeys and sessions</h1>
642+ <p class="muted">You sign in to Diffinery with passkeys instead of a password. A passkey lives on your device or in your password manager and is unlocked with your fingerprint, face or screen lock.</p>
643+ <div class="box">
644+ <div class="head">
645+ <h2>Passkeys</h2>
646+ <span class="badge">{keys.length}</span>
647+ </div>
648+ {keys.length ? (
649+ <ul class="list">
650+ {keys.map((k) => (
651+ <li>
652+ <span class="st faint">{raw(ICONS.key)}</span>
653+ <div class="grow">
654+ <b>{k.name}</b> {k.backed_up ? <span class="badge blue" title="Backed up by your password manager or platform, so it syncs to your other devices">synced</span> : <span class="badge" title="Stored only on one device or security key">this device only</span>}
655+ <div class="meta">
656+ Added <Time at={k.created_at} /> · {k.last_used_at ? <>last used <Time at={k.last_used_at} /></> : "never used to sign in"}
657+ </div>
658+ </div>
659+ {keys.length > 1 ? (
660+ <Form action={`/settings/security/passkeys/${encodeURIComponent(k.id)}/delete`} t={t} class="inline" confirm={`Remove the passkey "${k.name}"? Sessions it signed in are signed out.`}>
661+ <button class="btn sm danger">Remove</button>
662+ </Form>
663+ ) : (
664+ <span class="small faint" title="Add another passkey before removing this one">Only passkey</span>
665+ )}
666+ </li>
667+ ))}
668+ </ul>
669+ ) : (
670+ <div class="empty">No passkeys yet. Add one so you can sign in.</div>
671+ )}
672+ <div class="body" style="border-top:1px solid var(--border)">
673+ <form data-passkey="register" data-next="/settings/security" class="row" style="align-items:flex-end">
674+ <div class="field grow m0">
675+ <label for="pklabel">Add a passkey</label>
676+ <input id="pklabel" name="label" type="text" maxlength={100} placeholder={deviceLabel(c.req.header("user-agent"))} />
677+ <div class="hint">Add one on each device you use, or keep it in a password manager that syncs.</div>
678+ </div>
679+ <button class="btn primary">Add a passkey</button>
680+ </form>
681+ <div class="mt">
682+ <div class="flash error" data-passkey-msg role="alert" hidden></div>
683+ </div>
684+ </div>
685+ </div>
686+ <div class="box">
687+ <div class="head">
688+ <h2>Signed-in browsers</h2>
689+ <span class="badge">{sessions.length}</span>
690+ </div>
691+ {sessions.length ? (
692+ <ul class="list">
693+ {sessions.map((s) => (
694+ <li>
695+ <div class="grow">
696+ <b>{deviceLabel(s.user_agent ?? undefined)}</b> {s.id === current ? <span class="badge green">this browser</span> : null}
697+ <div class="meta">
698+ Signed in <Time at={s.created_at} /> with {s.method === "access" ? "email recovery" : s.method === "link" ? "a sign-in link" : "a passkey"} · active <Time at={s.last_seen_at} />
699+ </div>
700+ </div>
701+ </li>
702+ ))}
703+ </ul>
704+ ) : (
705+ <div class="empty">No browser sessions.</div>
706+ )}
707+ {sessions.some((s) => s.id !== current) ? (
708+ <div class="body" style="border-top:1px solid var(--border)">
709+ <Form action="/settings/security/sessions/others" t={t} class="inline" confirm="Sign out every other browser?">
710+ <button class="btn">Sign out everywhere else</button>
711+ </Form>
712+ </div>
713+ ) : null}
714+ </div>
715+ <p class="muted small">
716+ Lost every passkey? {recovery ? <>Use <a href="/auth/access">email recovery</a>, then add a new passkey here.</> : "Ask a site administrator for a one-time sign-in link."} Git, the CLI and agents use <a href="/settings/tokens">access tokens</a> instead of passkeys.
717+ </p>
718+ </>,
719+ );
720+ });
721+
722+ post(web, "/settings/security/passkeys/:id/delete", async (c) => {
723+ const me = browserAccount(c);
724+ const auth = new Auth(c.env);
725+ const id = prm(c, "id");
726+ return act(
727+ c,
728+ "/settings/security",
729+ async () => {
730+ const keys = await auth.passkeys(me.id);
731+ const key = keys.find((k) => k.id === id);
732+ if (!key) throw new HttpError(404, "passkey not found");
733+ if (keys.length < 2) throw new HttpError(422, "Add another passkey before removing your last one.");
734+ const p = c.get("principal");
735+ await auth.deletePasskey(me.id, id, p.type === "account" ? (p.sessionId ?? null) : null);
736+ c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "passkey.remove", detail: { name: key.name } }));
737+ },
738+ "Passkey removed.",
739+ );
740+ });
741+
742+ post(web, "/settings/security/sessions/others", async (c) => {
743+ const me = browserAccount(c);
744+ const p = c.get("principal");
745+ return act(c, "/settings/security", async () => new Auth(c.env).endOtherSessions(me.id, p.type === "account" ? (p.sessionId ?? null) : null), "Signed out of every other browser.");
746+ });
747+
748+ // Tokens
749+ const tokensPage = async (c: AppContext, me: Account, created?: { token: string }) => {
750+ const dir = c.get("dir");
751+ const t = csrfToken(c);
752+ const [tokens, repos] = await Promise.all([dir.tokens(me.id), dir.reposVisibleTo(me)]);
753+ const repoName = new Map(repos.map((r) => [r.id, `${r.owner_login}/${r.name}`]));
754+ return page(
755+ c,
756+ me,
757+ "tokens",
758+ "Access tokens",
759+ <>
760+ <h1>Personal access tokens</h1>
761+ <p class="muted">Tokens authenticate Git over HTTPS, the REST API, the CLI and MCP clients. Use your username (or anything) as the Git username and the token as the password.</p>
762+ {created ? <TokenOnce c={c} token={created.token} title="Your new token" /> : null}
763+ <div class="box">
764+ <div class="head">
765+ <h2>Active tokens</h2>
766+ </div>
767+ {tokens.length ? (
768+ <table class="tbl">
769+ <thead>
770+ <tr>
771+ <th>Name</th>
772+ <th>Scopes</th>
773+ <th>Repositories</th>
774+ <th>Expires</th>
775+ <th>Last used</th>
776+ <th />
777+ </tr>
778+ </thead>
779+ <tbody>
780+ {tokens.map((k) => (
781+ <tr>
782+ <td>
783+ <b>{k.name}</b>
784+ <div class="faint mono small">{k.prefix}…</div>
785+ </td>
786+ <td>{k.scopes.join(", ")}</td>
787+ <td class="small">{k.repoIds ? k.repoIds.map((id) => repoName.get(id) ?? id.slice(0, 8)).join(", ") : <span class="muted">all</span>}</td>
788+ <td class="small">{new Date(k.expiresAt).toLocaleDateString("en-US", { dateStyle: "medium" })}</td>
789+ <td class="small">{k.lastUsedAt ? ago(k.lastUsedAt) : <span class="muted">never</span>}</td>
790+ <td class="right">
791+ <Form action={`/settings/tokens/${k.id}/revoke`} t={t} class="inline" confirm={`Revoke token "${k.name}"? Anything using it stops working.`}>
792+ <button class="btn sm danger">Revoke</button>
793+ </Form>
794+ </td>
795+ </tr>
796+ ))}
797+ </tbody>
798+ </table>
799+ ) : (
800+ <div class="empty">No tokens yet.</div>
801+ )}
802+ </div>
803+ <Form action="/settings/tokens" t={t} class="box">
804+ <div class="head">
805+ <h2>Create a token</h2>
806+ </div>
807+ <div class="body">
808+ <div class="row" style="align-items:flex-start">
809+ <div class="field grow">
810+ <label for="tname">Name</label>
811+ <input id="tname" name="name" type="text" required placeholder="laptop, ci, claude-code…" maxlength={80} />
812+ </div>
813+ <div class="field" style="width:140px">
814+ <label for="days">Expires in (days)</label>
815+ <input id="days" name="days" type="number" min={1} max={366} value="90" />
816+ </div>
817+ </div>
818+ <div class="field">
819+ <label>Scope</label>
820+ {SCOPES.map((s) => (
821+ <label class="inline" style="margin-right:16px">
822+ <input type="radio" name="scope" value={s} checked={s === "write"} /> {s}
823+ </label>
824+ ))}
825+ <div class="hint">read: clone and view · write: push, issues, pull requests, runs (up to maintain) · admin: everything you can do, including settings.</div>
826+ </div>
827+ <div class="field">
828+ <label for="repos">
829+ Restrict to repositories <span class="faint">(optional)</span>
830+ </label>
831+ <select id="repos" name="repos" multiple size={Math.min(6, Math.max(3, repos.length))}>
832+ {repos.map((r) => (
833+ <option value={r.id}>
834+ {r.owner_login}/{r.name}
835+ </option>
836+ ))}
837+ </select>
838+ <div class="hint">Leave empty for access to every repository you can access.</div>
839+ </div>
840+ <button class="btn primary">Create token</button>
841+ </div>
842+ </Form>
843+ </>,
844+ );
845+ };
846+
847+ web.get("/settings/tokens", async (c) => tokensPage(c, requireAccount(c)));
848+
849+ post(web, "/settings/tokens", async (c, f) => {
850+ const me = requireAccount(c);
851+ const dir = c.get("dir");
852+ const scope = SCOPES.find((s) => s === f.s("scope")) ?? "write";
853+ const visible = new Set((await dir.reposVisibleTo(me)).map((r) => r.id));
854+ const repoIds = f.all("repos").filter((id) => visible.has(id));
855+ const name = f.s("name").trim() || "token";
856+ const created = await dir.createToken(me, { name, scopes: [scope], repoIds: repoIds.length ? repoIds : null, expiresInDays: parseInt(f.s("days"), 10) || 90 });
857+ c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "token.create", target: me.login, detail: { name, scope, repos: repoIds.length } }));
858+ return tokensPage(c, me, created);
859+ });
860+
861+ post(web, "/settings/tokens/:id/revoke", async (c) => {
862+ const me = requireAccount(c);
863+ return act(
864+ c,
865+ "/settings/tokens",
866+ async () => {
867+ await c.get("dir").revokeToken(me.id, prm(c, "id"));
868+ c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "token.revoke", target: prm(c, "id") }));
869+ },
870+ "Token revoked.",
871+ );
872+ });
873+
874+ // Agents
875+ const agentsPage = async (c: AppContext, me: Account, created?: { token: string; agent: string }) => {
876+ const dir = c.get("dir");
877+ const t = csrfToken(c);
878+ const orgs = await adminOrgs(c, me);
879+ const agents = await dir.agentsOwnedBy([me.id, ...orgs.map((o) => o.id)]);
880+ const ownerName = new Map<string, string>([[me.id, me.login], ...orgs.map((o) => [o.id, o.login] as [string, string])]);
881+ const tokenLists = await Promise.all(agents.map((a) => dir.tokens(a.id)));
882+ return page(
883+ c,
884+ me,
885+ "agents",
886+ "Agents",
887+ <>
888+ <h1>Agents</h1>
889+ <p class="muted">
890+ Agents are accounts for AI tools and automation. They act on behalf of their owner, are capped by a policy, cannot push to protected or default branches, and their pull requests need a human approval. Give each agent its own token.
891+ </p>
892+ {created ? <TokenOnce c={c} token={created.token} title={`Token for ${created.agent}`} /> : null}
893+ <div class="box">
894+ <div class="head">
895+ <h2>Your agents</h2>
896+ </div>
897+ {agents.length ? (
898+ <ul class="list">
899+ {agents.map((a, i) => {
900+ const policy = parsePolicy(a);
901+ const toks = tokenLists[i] ?? [];
902+ return (
903+ <li style="display:block">
904+ <div class="row">
905+ <Actor login={a.login} kind="agent" />
906+ <span class="muted small">
907+ owned by <b>{ownerName.get(a.owner_id ?? "") ?? "?"}</b> · max role <b>{policy.maxRole ?? "write"}</b> · {a.status}
908+ </span>
909+ <span class="grow" />
910+ <Form action={`/settings/agents/${a.id}/status`} t={t} class="inline">
911+ <input type="hidden" name="status" value={a.status === "active" ? "disabled" : "active"} />
912+ <button class={`btn sm ${a.status === "active" ? "danger" : ""}`}>{a.status === "active" ? "Disable" : "Enable"}</button>
913+ </Form>
914+ </div>
915+ {toks.length ? (
916+ <div class="small muted mt" style="margin-top:6px">
917+ Tokens:{" "}
918+ {toks.map((k) => (
919+ <span class="row" style="display:inline-flex;gap:4px;margin-right:10px">
920+ <span class="mono">{k.name}</span> ({k.scopes.join(",")}, last used {k.lastUsedAt ? ago(k.lastUsedAt) : "never"})
921+ <Form action={`/settings/agents/${a.id}/tokens/${k.id}/revoke`} t={t} class="inline" confirm="Revoke this agent token?">
922+ <button class="btn sm link bad">revoke</button>
923+ </Form>
924+ </span>
925+ ))}
926+ </div>
927+ ) : null}
928+ <details style="margin-top:6px">
929+ <summary class="small" style="cursor:pointer;color:var(--accent)">
930+ Create a token for {a.login}
931+ </summary>
932+ <Form action={`/settings/agents/${a.id}/token`} t={t} class="row mt" style="align-items:flex-end">
933+ <div class="field grow m0">
934+ <label>Name</label>
935+ <input name="name" type="text" required value="default" />
936+ </div>
937+ <div class="field m0">
938+ <label>Scope</label>
939+ <select name="scope">
940+ {SCOPES.map((s) => (
941+ <option value={s} selected={s === "write"}>
942+ {s}
943+ </option>
944+ ))}
945+ </select>
946+ </div>
947+ <div class="field m0" style="width:110px">
948+ <label>Days</label>
949+ <input name="days" type="number" min={1} max={366} value="30" />
950+ </div>
951+ <button class="btn primary">Create token</button>
952+ </Form>
953+ </details>
954+ </li>
955+ );
956+ })}
957+ </ul>
958+ ) : (
959+ <div class="empty">No agents yet.</div>
960+ )}
961+ </div>
962+ <Form action="/settings/agents" t={t} class="box">
963+ <div class="head">
964+ <h2>Create an agent</h2>
965+ </div>
966+ <div class="body">
967+ <div class="row" style="align-items:flex-start">
968+ <div class="field grow">
969+ <label for="alogin">Agent username</label>
970+ <input id="alogin" name="login" type="text" required pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,99}" placeholder="review-bot" />
971+ </div>
972+ <div class="field grow">
973+ <label for="aname">Display name</label>
974+ <input id="aname" name="name" type="text" placeholder="Review bot" />
975+ </div>
976+ </div>
977+ <div class="row" style="align-items:flex-start">
978+ <div class="field grow">
979+ <label for="aowner">Owner</label>
980+ <select id="aowner" name="owner">
981+ <option value="">{me.login} (you)</option>
982+ {orgs.map((o) => (
983+ <option value={o.id}>{o.login} (organization)</option>
984+ ))}
985+ </select>
986+ <div class="hint">An agent can never do more than its owner.</div>
987+ </div>
988+ <div class="field grow">
989+ <label for="arole">Maximum role</label>
990+ <select id="arole" name="maxRole">
991+ {ROLES.map((r) => (
992+ <option value={r} selected={r === "write"}>
993+ {r}
994+ </option>
995+ ))}
996+ </select>
997+ </div>
998+ </div>
999+ <button class="btn primary">Create agent</button>
1000+ </div>
1001+ </Form>
1002+ </>,
1003+ );
1004+ };
1005+
1006+ web.get("/settings/agents", async (c) => agentsPage(c, requireAccount(c)));
1007+
1008+ post(web, "/settings/agents", async (c, f) => {
1009+ const me = requireAccount(c);
1010+ const dir = c.get("dir");
1011+ return act(
1012+ c,
1013+ "/settings/agents",
1014+ async () => {
1015+ const maxRole = ROLES.find((r) => r === f.s("maxRole")) ?? "write";
1016+ const a = await dir.createAgent(me, { login: f.s("login").trim(), name: f.s("name").trim() || undefined, orgId: f.s("owner") || undefined, policy: { maxRole } });
1017+ c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "agent.create", target: a.login, detail: { maxRole } }));
1018+ },
1019+ "Agent created. Create a token for it below.",
1020+ );
1021+ });
1022+
1023+ const loadAgent = async (c: AppContext, me: Account): Promise<Account> => {
1024+ const agent = await c.get("dir").account(prm(c, "id"));
1025+ if (!agent || !(await canManageAgent(c, me, agent))) throw new HttpError(404, "agent not found");
1026+ return agent;
1027+ };
1028+
1029+ post(web, "/settings/agents/:id/token", async (c, f) => {
1030+ const me = requireAccount(c);
1031+ const agent = await loadAgent(c, me);
1032+ const dir = c.get("dir");
1033+ const scope = SCOPES.find((s) => s === f.s("scope")) ?? "write";
1034+ const created = await dir.createToken(agent, { name: f.s("name").trim() || "agent", scopes: [scope], expiresInDays: parseInt(f.s("days"), 10) || 30 });
1035+ c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "agent.token.create", target: agent.login, detail: { scope } }));
1036+ return agentsPage(c, me, { token: created.token, agent: agent.login });
1037+ });
1038+
1039+ post(web, "/settings/agents/:id/tokens/:tid/revoke", async (c) => {
1040+ const me = requireAccount(c);
1041+ const agent = await loadAgent(c, me);
1042+ return act(c, "/settings/agents", async () => c.get("dir").revokeToken(agent.id, prm(c, "tid")), "Token revoked.");
1043+ });
1044+
1045+ post(web, "/settings/agents/:id/status", async (c, f) => {
1046+ const me = requireAccount(c);
1047+ const agent = await loadAgent(c, me);
1048+ const status = f.s("status") === "active" ? "active" : "disabled";
1049+ return act(c, "/settings/agents", async () => {
1050+ await c.get("dir").setStatus(agent.id, status);
1051+ c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: `agent.${status === "active" ? "enable" : "disable"}`, target: agent.login }));
1052+ });
1053+ });
1054+
1055+ // Organizations
1056+ web.get("/settings/orgs", async (c) => {
1057+ const me = requireAccount(c);
1058+ const t = csrfToken(c);
1059+ const orgs = await c.get("dir").orgsFor(me.id);
1060+ return page(
1061+ c,
1062+ me,
1063+ "orgs",
1064+ "Organizations",
1065+ <>
1066+ <h1>Organizations</h1>
1067+ <p class="muted">Organizations group people, agents and repositories. Members get write access to the organization's repositories; owners and admins manage everything.</p>
1068+ <div class="box">
1069+ {orgs.length ? (
1070+ <ul class="list">
1071+ {orgs.map((o) => (
1072+ <li>
1073+ <Avatar login={o.login} />
1074+ <div class="grow">
1075+ <a class="title" href={`/${o.login}`}>
1076+ {o.login}
1077+ </a>
1078+ {o.name && o.name !== o.login ? <span class="muted"> · {o.name}</span> : null}
1079+ </div>
1080+ <span class="badge">{o.role}</span>
1081+ </li>
1082+ ))}
1083+ </ul>
1084+ ) : (
1085+ <div class="empty">You are not a member of any organization.</div>
1086+ )}
1087+ </div>
1088+ <Form action="/settings/orgs" t={t} class="box">
1089+ <div class="head">
1090+ <h2>New organization</h2>
1091+ </div>
1092+ <div class="body">
1093+ <div class="row" style="align-items:flex-start">
1094+ <div class="field grow">
1095+ <label for="olog">Organization name</label>
1096+ <input id="olog" name="login" type="text" required pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,99}" placeholder="my-team" />
1097+ </div>
1098+ <div class="field grow">
1099+ <label for="oname">Display name</label>
1100+ <input id="oname" name="name" type="text" />
1101+ </div>
1102+ </div>
1103+ <button class="btn primary">Create organization</button>
1104+ </div>
1105+ </Form>
1106+ </>,
1107+ );
1108+ });
1109+
1110+ post(web, "/settings/orgs", async (c, f) => {
1111+ const me = requireAccount(c);
1112+ const dir = c.get("dir");
1113+ return act(c, "/settings/orgs", async () => {
1114+ const org = await dir.createOrg(f.s("login").trim(), f.s("name").trim(), me);
1115+ c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.create", target: org.login }));
1116+ return `/${org.login}?tab=people`;
1117+ });
1118+ });
1119+
1120+ // Site administration
1121+ const requireSiteAdmin = (c: AppContext): Account => {
1122+ const me = requireAccount(c);
1123+ if (!me.site_admin) throw new HttpError(403, "site administrators only");
1124+ return me;
1125+ };
1126+
1127+ const adminPage = async (c: AppContext, me: Account, invite?: { url: string; title: string; note: string }) => {
1128+ const dir = c.get("dir");
1129+ const t = csrfToken(c);
1130+ const [users, requests] = await Promise.all([dir.users(), dir.accessRequests()]);
1131+ return page(
1132+ c,
1133+ me,
1134+ "admin",
1135+ "Site administration",
1136+ <>
1137+ <h1>Site administration</h1>
1138+ {invite ? (
1139+ <div class="box" style="border-color:var(--amber)">
1140+ <div class="body">
1141+ <h3>{invite.title}</h3>
1142+ <p class="muted">{invite.note}</p>
1143+ <div class="row">
1144+ <div class="secret grow">{invite.url}</div>
1145+ <CopyButton text={invite.url} />
1146+ </div>
1147+ </div>
1148+ </div>
1149+ ) : null}
1150+ <div class="box">
1151+ <div class="head">
1152+ <h2>Pending access requests</h2>
1153+ <span class="badge">{requests.length}</span>
1154+ </div>
1155+ {requests.length ? (
1156+ <ul class="list">
1157+ {requests.map((r) => (
1158+ <li>
1159+ <Avatar login={r.account.login} />
1160+ <div class="grow">
1161+ <b>{r.account.email ?? r.account.login}</b> <span class="muted small">requested <Time at={r.createdAt} /></span>
1162+ {r.note ? <div class="meta" style="white-space:pre-wrap">{r.note}</div> : null}
1163+ </div>
1164+ <Form action={`/settings/admin/users/${r.account.id}/status`} t={t} class="inline">
1165+ <input type="hidden" name="status" value="active" />
1166+ <button class="btn sm green">Approve</button>
1167+ </Form>
1168+ <Form action={`/settings/admin/users/${r.account.id}/status`} t={t} class="inline">
1169+ <input type="hidden" name="status" value="disabled" />
1170+ <button class="btn sm danger">Deny</button>
1171+ </Form>
1172+ </li>
1173+ ))}
1174+ </ul>
1175+ ) : (
1176+ <div class="empty">No pending requests.</div>
1177+ )}
1178+ </div>
1179+ <Form action="/settings/admin/invite" t={t} class="box">
1180+ <div class="head">
1181+ <h2>Invite someone to this instance</h2>
1182+ </div>
1183+ <div class="body row" style="align-items:flex-end">
1184+ <div class="field grow m0">
1185+ <label for="iemail">Email address</label>
1186+ <input id="iemail" name="email" type="email" required placeholder="friend@example.com" />
1187+ <div class="hint">Opening the link lets them pick a username and create a passkey.</div>
1188+ </div>
1189+ <button class="btn primary">Create invite</button>
1190+ </div>
1191+ </Form>
1192+ <div class="box">
1193+ <div class="head">
1194+ <h2>Users</h2>
1195+ <span class="badge">{users.length}</span>
1196+ </div>
1197+ <table class="tbl">
1198+ <thead>
1199+ <tr>
1200+ <th>User</th>
1201+ <th>Email</th>
1202+ <th>Joined</th>
1203+ <th>Status</th>
1204+ <th />
1205+ </tr>
1206+ </thead>
1207+ <tbody>
1208+ {users.map((u) => (
1209+ <tr>
1210+ <td>
1211+ <Actor login={u.login} kind="user" /> {u.site_admin ? <span class="badge blue">admin</span> : null}
1212+ </td>
1213+ <td class="small">{u.email ?? "—"}</td>
1214+ <td class="small">{ago(u.created_at)}</td>
1215+ <td>
1216+ {u.id === me.id ? (
1217+ <span class="badge green">{u.status}</span>
1218+ ) : (
1219+ <Form action={`/settings/admin/users/${u.id}/status`} t={t} class="row" style="gap:6px;flex-wrap:nowrap">
1220+ <select name="status" style="width:auto;padding:3px 6px">
1221+ {(["active", "pending", "disabled"] as const).map((s) => (
1222+ <option value={s} selected={u.status === s}>
1223+ {s}
1224+ </option>
1225+ ))}
1226+ </select>
1227+ <button class="btn sm">Save</button>
1228+ </Form>
1229+ )}
1230+ </td>
1231+ <td class="right">
1232+ <Form action={`/settings/admin/users/${u.id}/signin-link`} t={t} class="inline" confirm={`Create a one-time link that adds a passkey to ${u.login}? Only send it to them.`}>
1233+ <button class="btn sm" title="For a lost passkey or a first sign-in">Sign-in link</button>
1234+ </Form>
1235+ </td>
1236+ </tr>
1237+ ))}
1238+ </tbody>
1239+ </table>
1240+ </div>
1241+ </>,
1242+ );
1243+ };
1244+
1245+ web.get("/settings/admin", async (c) => adminPage(c, requireSiteAdmin(c)));
1246+
1247+ post(web, "/settings/admin/users/:id/status", async (c, f) => {
1248+ const me = requireSiteAdmin(c);
1249+ const dir = c.get("dir");
1250+ const status = (["active", "pending", "disabled"] as const).find((s) => s === f.s("status"));
1251+ return act(
1252+ c,
1253+ "/settings/admin",
1254+ async () => {
1255+ if (!status) throw new HttpError(422, "invalid status");
1256+ const target = await dir.account(prm(c, "id"));
1257+ if (!target || target.kind !== "user") throw new HttpError(404, "user not found");
1258+ if (target.id === me.id) throw new HttpError(422, "you cannot change your own status");
1259+ await dir.setStatus(target.id, status);
1260+ c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: `user.${status}`, target: target.login }));
1261+ },
1262+ "User updated.",
1263+ );
1264+ });
1265+
1266+ post(web, "/settings/admin/users/:id/signin-link", async (c) => {
1267+ const me = requireSiteAdmin(c);
1268+ const dir = c.get("dir");
1269+ const target = await dir.account(prm(c, "id"));
1270+ if (!target || target.kind !== "user") throw new HttpError(404, "user not found");
1271+ if (target.status === "disabled") throw new HttpError(422, "enable this account first");
1272+ const link = await new Auth(c.env).createSigninLink(me, target);
1273+ c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "signin_link.create", target: target.login }));
1274+ return adminPage(c, me, { url: link.url, title: `Sign-in link for ${target.login}`, note: "Send this only to them. It works once within 24 hours and adds a passkey to their account." });
1275+ });
1276+
1277+ post(web, "/settings/admin/invite", async (c, f) => {
1278+ const me = requireSiteAdmin(c);
1279+ const email = f.s("email").trim();
1280+ if (!/^[^\s@]+@[^\s@]+$/.test(email)) throw new HttpError(422, "enter a valid email address");
1281+ const inv = await c.get("dir").createInvite(me, { email, role: "read" });
1282+ c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "invite.create", target: email }));
1283+ return adminPage(c, me, { url: inv.url, title: `Invite created for ${email}`, note: "Send them this link. It expires in 14 days and works once: they pick a username and create a passkey." });
1284+ });
1285+
1286+ web.get("/settings/audit", async (c) => {
1287+ const me = requireSiteAdmin(c);
1288+ const rows = await c.get("dir").auditLog(200);
1289+ return page(
1290+ c,
1291+ me,
1292+ "audit",
1293+ "Audit log",
1294+ <>
1295+ <h1>Audit log</h1>
1296+ <p class="muted">The 200 most recent security-relevant events.</p>
1297+ <div class="box" style="overflow:auto">
1298+ <table class="tbl">
1299+ <thead>
1300+ <tr>
1301+ <th>When</th>
1302+ <th>Actor</th>
1303+ <th>Action</th>
1304+ <th>Target</th>
1305+ <th>Detail</th>
1306+ </tr>
1307+ </thead>
1308+ <tbody>
1309+ {rows.map((r) => (
1310+ <tr>
1311+ <td class="small nowrap">
1312+ <Time at={r.at} />
1313+ </td>
1314+ <td class="small">
1315+ {r.actor_login ?? "system"}
1316+ {r.on_behalf_of ? <span class="muted"> for {r.on_behalf_of}</span> : null}
1317+ </td>
1318+ <td>
1319+ <span class="badge">{r.action}</span>
1320+ </td>
1321+ <td class="small mono">{r.target ?? ""}</td>
1322+ <td class="small mono muted" style="max-width:340px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title={r.detail ?? ""}>
1323+ {r.detail ?? ""}
1324+ </td>
1325+ </tr>
1326+ ))}
1327+ </tbody>
1328+ </table>
1329+ {rows.length === 0 ? <div class="empty">No events yet.</div> : null}
1330+ </div>
1331+ </>,
1332+ );
1333+ });
1334+}
1335+
1336+// ---------------------------------------------------------------- /:owner profiles
1337+
1338+type OrgTab = "repositories" | "people" | "invites" | "agents" | "settings";
1339+
1340+function registerProfiles(web: WebApp): void {
1341+ const orgContext = async (c: AppContext) => {
1342+ const me = requireAccount(c);
1343+ const dir = c.get("dir");
1344+ const org = await dir.accountByLogin(prm(c, "owner"));
1345+ if (!org) throw new HttpError(404, "not found");
1346+ if (org.kind !== "org") throw new HttpError(404, "not an organization");
1347+ const role = await dir.orgRole(org.id, me.id);
1348+ const isAdmin = !!me.site_admin || role === "owner" || role === "admin";
1349+ const isOwner = !!me.site_admin || role === "owner";
1350+ return { me, org, role, isAdmin, isOwner, dir };
1351+ };
1352+
1353+ const profile = async (c: AppContext, invite?: { url: string; email: string }) => {
1354+ const me = requireAccount(c);
1355+ const dir = c.get("dir");
1356+ const t = csrfToken(c);
1357+ const acct = await dir.accountByLogin(prm(c, "owner"));
1358+ if (!acct) throw new HttpError(404, "not found");
1359+ if (acct.login !== prm(c, "owner")) return c.redirect(`/${acct.login}${new URL(c.req.url).search}`, 301);
1360+
1361+ if (acct.kind === "agent") {
1362+ const owner = acct.owner_id ? await dir.account(acct.owner_id) : null;
1363+ const policy = parsePolicy(acct);
1364+ return render(
1365+ c,
1366+ { title: acct.login },
1367+ <div style="max-width:720px;margin:24px auto" class="box">
1368+ <div class="body row" style="gap:18px">
1369+ <Avatar login={acct.login} kind="agent" size="xl" />
1370+ <div>
1371+ <h1 class="m0">
1372+ {acct.name ?? acct.login} <span class="badge agent">agent</span>
1373+ </h1>
1374+ <p class="muted">{acct.login}</p>
1375+ <dl class="kv">
1376+ <dt>Acts on behalf of</dt>
1377+ <dd>{owner ? <a href={`/${owner.login}`}>{owner.login}</a> : "—"}</dd>
1378+ <dt>Maximum role</dt>
1379+ <dd>{policy.maxRole ?? "write"}</dd>
1380+ <dt>Status</dt>
1381+ <dd>{acct.status}</dd>
1382+ <dt>Created</dt>
1383+ <dd>
1384+ <Time at={acct.created_at} />
1385+ </dd>
1386+ </dl>
1387+ </div>
1388+ </div>
1389+ </div>,
1390+ );
1391+ }
1392+
1393+ const repos = await dir.reposVisibleTo(me, acct.id);
1394+ if (acct.kind === "user") {
1395+ const orgs = await dir.orgsFor(acct.id);
1396+ return render(
1397+ c,
1398+ { title: acct.login },
1399+ <div class="grid left">
1400+ <aside class="side">
1401+ <section>
1402+ <Avatar login={acct.login} size="xl" />
1403+ <h1 class="mt m0">{acct.name ?? acct.login}</h1>
1404+ <p class="muted">{acct.login}</p>
1405+ {acct.id === me.id ? (
1406+ <a class="btn sm" href="/settings">
1407+ Edit profile
1408+ </a>
1409+ ) : null}
1410+ </section>
1411+ {orgs.length ? (
1412+ <section>
1413+ <h3>Organizations</h3>
1414+ <div class="nav">
1415+ {orgs.map((o) => (
1416+ <a href={`/${o.login}`}>
1417+ <Avatar login={o.login} /> {o.login}
1418+ </a>
1419+ ))}
1420+ </div>
1421+ </section>
1422+ ) : null}
1423+ </aside>
1424+ <div>
1425+ <div class="row between mb">
1426+ <h2 class="m0">Repositories</h2>
1427+ {acct.id === me.id ? (
1428+ <a class="btn sm primary" href="/new">
1429+ {raw(ICONS.plus)} New
1430+ </a>
1431+ ) : null}
1432+ </div>
1433+ <div class="box">
1434+ <RepoList repos={repos} showOwner={false} />
1435+ </div>
1436+ </div>
1437+ </div>,
1438+ );
1439+ }
1440+
1441+ // Organization
1442+ const role = await dir.orgRole(acct.id, me.id);
1443+ const isAdmin = !!me.site_admin || role === "owner" || role === "admin";
1444+ const isOwner = !!me.site_admin || role === "owner";
1445+ const requested = (c.req.query("tab") ?? "repositories") as OrgTab;
1446+ const tab: OrgTab = invite ? "invites" : (["repositories", "people", "invites", "agents", "settings"] as const).includes(requested) ? requested : "repositories";
1447+ if ((tab === "invites" && !isAdmin) || (tab === "settings" && !isOwner)) throw new HttpError(403, "organization admins only");
1448+ let body: Child = null;
1449+ const base = `/${acct.login}`;
1450+ if (tab === "repositories") {
1451+ body = (
1452+ <>
1453+ <div class="row between mb">
1454+ <h2 class="m0">Repositories</h2>
1455+ {role ? (
1456+ <a class="btn sm primary" href={`/new?owner=${encodeURIComponent(acct.login)}`}>
1457+ {raw(ICONS.plus)} New repository
1458+ </a>
1459+ ) : null}
1460+ </div>
1461+ <div class="box">
1462+ <RepoList repos={repos} showOwner={false} />
1463+ </div>
1464+ </>
1465+ );
1466+ } else if (tab === "people") {
1467+ const members = await dir.orgMembers(acct.id);
1468+ body = (
1469+ <>
1470+ <h2>People</h2>
1471+ <div class="box">
1472+ <table class="tbl">
1473+ <thead>
1474+ <tr>
1475+ <th>Member</th>
1476+ <th>Role</th>
1477+ {isAdmin ? <th /> : null}
1478+ </tr>
1479+ </thead>
1480+ <tbody>
1481+ {members.map((m) => (
1482+ <tr>
1483+ <td>
1484+ <Actor login={m.login} kind="user" /> {m.name && m.name !== m.login ? <span class="muted small">{m.name}</span> : null}
1485+ </td>
1486+ <td>
1487+ {isAdmin && (isOwner || m.role !== "owner") ? (
1488+ <Form action={`${base}/-/org/members`} t={t} class="row" style="gap:6px;flex-wrap:nowrap">
1489+ <input type="hidden" name="login" value={m.login} />
1490+ <select name="role" style="width:auto;padding:3px 6px">
1491+ {(isOwner ? ["owner", "admin", "member"] : ["admin", "member"]).map((r) => (
1492+ <option value={r} selected={m.role === r}>
1493+ {r}
1494+ </option>
1495+ ))}
1496+ </select>
1497+ <button class="btn sm">Save</button>
1498+ </Form>
1499+ ) : (
1500+ <span class="badge">{m.role}</span>
1501+ )}
1502+ </td>
1503+ {isAdmin ? (
1504+ <td class="right">
1505+ {isOwner || m.role !== "owner" ? (
1506+ <Form action={`${base}/-/org/members`} t={t} class="inline" confirm={`Remove ${m.login} from ${acct.login}?`}>
1507+ <input type="hidden" name="login" value={m.login} />
1508+ <input type="hidden" name="role" value="remove" />
1509+ <button class="btn sm danger">Remove</button>
1510+ </Form>
1511+ ) : null}
1512+ </td>
1513+ ) : null}
1514+ </tr>
1515+ ))}
1516+ </tbody>
1517+ </table>
1518+ </div>
1519+ {isAdmin ? (
1520+ <div class="row" style="align-items:stretch">
1521+ <Form action={`${base}/-/org/members`} t={t} class="box grow">
1522+ <div class="head">
1523+ <h3>Add an existing user</h3>
1524+ </div>
1525+ <div class="body row" style="align-items:flex-end">
1526+ <div class="field grow m0">
1527+ <label>Username</label>
1528+ <input name="login" type="text" required />
1529+ </div>
1530+ <div class="field m0">
1531+ <label>Role</label>
1532+ <select name="role">
1533+ <option value="member">member</option>
1534+ <option value="admin">admin</option>
1535+ {isOwner ? <option value="owner">owner</option> : null}
1536+ </select>
1537+ </div>
1538+ <button class="btn primary">Add</button>
1539+ </div>
1540+ </Form>
1541+ <div class="box grow">
1542+ <div class="head">
1543+ <h3>Invite by email</h3>
1544+ </div>
1545+ <div class="body">
1546+ <p class="muted small">
1547+ New people join from an invite link, where they create a passkey. <a href={`${base}?tab=invites`}>Create an invite →</a>
1548+ </p>
1549+ </div>
1550+ </div>
1551+ </div>
1552+ ) : null}
1553+ </>
1554+ );
1555+ } else if (tab === "invites") {
1556+ const invites = await dir.invites({ orgId: acct.id });
1557+ body = (
1558+ <>
1559+ <h2>Invites</h2>
1560+ {invite ? (
1561+ <div class="box" style="border-color:var(--amber)">
1562+ <div class="body">
1563+ <h3>Invite created for {invite.email}</h3>
1564+ <p class="muted">Send them this link. It expires in 14 days. Opening it lets them create a passkey and join.</p>
1565+ <div class="row">
1566+ <div class="secret grow">{invite.url}</div>
1567+ <CopyButton text={invite.url} />
1568+ </div>
1569+ </div>
1570+ </div>
1571+ ) : null}
1572+ <Form action={`${base}/-/org/invites`} t={t} class="box">
1573+ <div class="head">
1574+ <h3>Invite someone</h3>
1575+ </div>
1576+ <div class="body row" style="align-items:flex-end">
1577+ <div class="field grow m0">
1578+ <label>Email address</label>
1579+ <input name="email" type="email" required placeholder="friend@example.com" />
1580+ </div>
1581+ <div class="field m0">
1582+ <label>Role</label>
1583+ <select name="role">
1584+ <option value="member">member</option>
1585+ <option value="admin">admin</option>
1586+ {isOwner ? <option value="owner">owner</option> : null}
1587+ </select>
1588+ </div>
1589+ <button class="btn primary">Create invite</button>
1590+ </div>
1591+ </Form>
1592+ <div class="box">
1593+ {invites.length ? (
1594+ <table class="tbl">
1595+ <thead>
1596+ <tr>
1597+ <th>Email</th>
1598+ <th>Role</th>
1599+ <th>Created</th>
1600+ <th>Status</th>
1601+ </tr>
1602+ </thead>
1603+ <tbody>
1604+ {invites.map((i) => (
1605+ <tr>
1606+ <td>{i.email}</td>
1607+ <td>{i.role}</td>
1608+ <td class="small">{ago(i.createdAt)}</td>
1609+ <td class="small">
1610+ {i.acceptedAt ? (
1611+ <span class="badge green">accepted</span>
1612+ ) : Date.parse(i.expiresAt) < Date.now() ? (
1613+ <span class="badge">expired</span>
1614+ ) : (
1615+ <span class="badge amber">pending</span>
1616+ )}
1617+ </td>
1618+ </tr>
1619+ ))}
1620+ </tbody>
1621+ </table>
1622+ ) : (
1623+ <div class="empty">No invites yet.</div>
1624+ )}
1625+ </div>
1626+ </>
1627+ );
1628+ } else if (tab === "agents") {
1629+ const agents = await dir.agentsOwnedBy([acct.id]);
1630+ body = (
1631+ <>
1632+ <div class="row between mb">
1633+ <h2 class="m0">Agents</h2>
1634+ {isAdmin ? (
1635+ <a class="btn sm primary" href="/settings/agents">
1636+ Manage agents
1637+ </a>
1638+ ) : null}
1639+ </div>
1640+ <div class="box">
1641+ {agents.length ? (
1642+ <ul class="list">
1643+ {agents.map((a) => (
1644+ <li>
1645+ <Actor login={a.login} kind="agent" />
1646+ <span class="grow" />
1647+ <span class="muted small">max role {parsePolicy(a).maxRole ?? "write"}</span>
1648+ <span class={`badge ${a.status === "active" ? "green" : ""}`}>{a.status}</span>
1649+ </li>
1650+ ))}
1651+ </ul>
1652+ ) : (
1653+ <div class="empty">This organization has no agents.</div>
1654+ )}
1655+ </div>
1656+ </>
1657+ );
1658+ } else {
1659+ body = (
1660+ <>
1661+ <h2>Settings</h2>
1662+ <Form action={`${base}/-/org/settings`} t={t} class="box">
1663+ <div class="body">
1664+ <div class="field">
1665+ <label>Organization name</label>
1666+ <input name="login" type="text" value={acct.login} required pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,99}" />
1667+ <div class="hint">Renaming changes every repository URL in this organization.</div>
1668+ </div>
1669+ <div class="field">
1670+ <label>Display name</label>
1671+ <input name="name" type="text" value={acct.name ?? ""} />
1672+ </div>
1673+ <button class="btn primary">Save</button>
1674+ </div>
1675+ </Form>
1676+ </>
1677+ );
1678+ }
1679+ return render(
1680+ c,
1681+ { title: acct.login },
1682+ <>
1683+ <div class="row mb" style="gap:16px">
1684+ <Avatar login={acct.login} size="lg" />
1685+ <div>
1686+ <h1 class="m0">{acct.name ?? acct.login}</h1>
1687+ <div class="muted small">
1688+ Organization · {acct.login}
1689+ {role ? ` · you are ${role === "owner" ? "an owner" : `a${role === "admin" ? "n" : ""} ${role}`}` : ""}
1690+ </div>
1691+ </div>
1692+ </div>
1693+ <nav class="tabs mb" style="border-bottom:1px solid var(--border)">
1694+ {(
1695+ [
1696+ ["repositories", "Repositories", true],
1697+ ["people", "People", true],
1698+ ["invites", "Invites", isAdmin],
1699+ ["agents", "Agents", true],
1700+ ["settings", "Settings", isOwner],
1701+ ] as const
1702+ )
1703+ .filter((x) => x[2])
1704+ .map(([k, label]) => (
1705+ <a href={`${base}?tab=${k}`} class={tab === k ? "on" : ""}>
1706+ {label}
1707+ </a>
1708+ ))}
1709+ </nav>
1710+ {body}
1711+ </>,
1712+ );
1713+ };
1714+
1715+ web.get("/:owner", async (c) => profile(c));
1716+
1717+ post(web, "/:owner/-/org/invites", async (c, f) => {
1718+ const { me, org, isAdmin, isOwner, dir } = await orgContext(c);
1719+ if (!isAdmin) throw new HttpError(403, "organization admins only");
1720+ const email = f.s("email").trim();
1721+ const role = (["member", "admin", "owner"] as const).find((r) => r === f.s("role")) ?? "member";
1722+ if (role === "owner" && !isOwner) throw new HttpError(403, "only owners can invite owners");
1723+ if (!/^[^\s@]+@[^\s@]+$/.test(email)) {
1724+ return act(c, `/${org.login}?tab=invites`, async () => {
1725+ throw new HttpError(422, "enter a valid email address");
1726+ });
1727+ }
1728+ const inv = await dir.createInvite(me, { email, orgId: org.id, role });
1729+ c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.invite", target: org.login, detail: { email, role } }));
1730+ return profile(c, { url: inv.url, email });
1731+ });
1732+
1733+ post(web, "/:owner/-/org/members", async (c, f) => {
1734+ const { me, org, isAdmin, isOwner, dir } = await orgContext(c);
1735+ const back = `/${org.login}?tab=people`;
1736+ return act(c, back, async () => {
1737+ if (!isAdmin) throw new HttpError(403, "organization admins only");
1738+ const user = await dir.accountByLogin(f.s("login").trim());
1739+ if (!user || user.kind !== "user") throw new HttpError(404, "user not found");
1740+ const current = await dir.orgRole(org.id, user.id);
1741+ const role = f.s("role");
1742+ if ((current === "owner" || role === "owner") && !isOwner) throw new HttpError(403, "only owners can manage owners");
1743+ if (role === "remove") await dir.setOrgMember(org.id, user.id, null);
1744+ else if (role === "owner" || role === "admin" || role === "member") await dir.setOrgMember(org.id, user.id, role);
1745+ else throw new HttpError(422, "invalid role");
1746+ c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: role === "remove" ? "org.member.remove" : "org.member.set", target: org.login, detail: { user: user.login, role } }));
1747+ });
1748+ });
1749+
1750+ post(web, "/:owner/-/org/settings", async (c, f) => {
1751+ const { me, org, isOwner, dir } = await orgContext(c);
1752+ return act(c, `/${org.login}?tab=settings`, async () => {
1753+ if (!isOwner) throw new HttpError(403, "organization owners only");
1754+ const login = f.s("login").trim();
1755+ await dir.updateAccount(org.id, { login: login && login !== org.login ? login : undefined, name: f.s("name").trim() });
1756+ c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.update", target: org.login, detail: { login } }));
1757+ return `/${login || org.login}?tab=settings`;
1758+ }, "Organization updated.");
1759+ });
1760+}
addedapps/diffinery/src/web/actions.tsx+579−0View file
@@ -1,0 +1,579 @@
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 { loadRepo } from "../http/common.js";
6+import { HttpError } from "../lib/util.js";
7+import type { JobInfo, RunInfo } from "../repo/actions.js";
8+import { atLeast } from "../repo/types.js";
9+import { ICONS } from "./assets.js";
10+import { csrfToken } from "./csrf.js";
11+import { Actor, Form, Markdown, Pager, Status, Time, act, duration, encPath, fmtBytes, pageNum, post, qs, refNames, render, repoUrl, shortSha, type WebApp, prm } from "./ui.js";
12+
13+type Loaded = Awaited<ReturnType<typeof loadRepo>>;
14+type LogLine = { seq: number; t: string; step: number; text: string };
15+/** Plain (non-RPC-wrapped) shape of a run, which keeps type checking shallow. */
16+type RunWithJobs = RunInfo & { jobs: JobInfo[] };
17+
18+const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g;
19+const MAX_LOG_LINES = 20000;
20+
21+const refLabel = (ref: string): string => ref.replace(/^refs\/(heads|tags)\//, "");
22+
23+function runDuration(r: Pick<RunInfo, "status" | "createdAt" | "updatedAt">): string {
24+ return duration(r.createdAt, r.status === "completed" ? r.updatedAt : null);
25+}
26+
27+function matrixLabel(m: Record<string, unknown>): string {
28+ const entries = Object.entries(m ?? {});
29+ if (!entries.length) return "";
30+ return entries.map(([k, v]) => `${k}: ${typeof v === "object" ? JSON.stringify(v) : String(v)}`).join(", ");
31+}
32+
33+const RunRow: FC<{ base: string; r: RunInfo }> = ({ base, r }) => (
34+ <li>
35+ <Status status={r.status} conclusion={r.conclusion} />
36+ <div class="grow">
37+ <a class="title" href={`${base}/actions/runs/${r.id}`}>
38+ {r.title || r.workflowName}
39+ </a>
40+ <div class="meta">
41+ <b>{r.workflowName}</b> #{r.number}
42+ {r.attempt > 1 ? ` (attempt ${r.attempt})` : ""}: {r.event.replace(/_/g, " ")}
43+ {r.prNumber ? (
44+ <>
45+ {" "}
46+ for <a href={`${base}/pull/${r.prNumber}`}>#{r.prNumber}</a>
47+ </>
48+ ) : null}{" "}
49+ by {r.actor.login}
50+ {r.actor.kind === "agent" ? <span class="badge agent" style="margin-left:4px">agent</span> : null}
51+ </div>
52+ </div>
53+ <span class="badge mono nowrap" title={r.ref}>
54+ {refLabel(r.ref)}
55+ </span>
56+ <div class="right small muted nowrap" style="min-width:110px">
57+ <div>
58+ <Time at={r.createdAt} />
59+ </div>
60+ <div>{runDuration(r)}</div>
61+ </div>
62+ </li>
63+);
64+
65+/** Parse workflow_dispatch input definitions from the workflow file on the default branch. */
66+async function dispatchInputs(l: Loaded, path: string): Promise<Record<string, DispatchInput> | null> {
67+ try {
68+ const blob = await l.stub.blob(l.repo.default_branch, path);
69+ if (!blob || blob.binary) return null;
70+ const { workflow } = parseWorkflow(new TextDecoder().decode(blob.content), path);
71+ const d = workflow?.on.workflow_dispatch;
72+ return d ? (d.inputs ?? {}) : null;
73+ } catch {
74+ return null;
75+ }
76+}
77+
78+function classify(text: string): { cls: string; text: string } {
79+ const t = text.replace(ANSI, "");
80+ if (t.startsWith("::group::")) return { cls: "grp", text: `▾ ${t.slice(9)}` };
81+ if (t.startsWith("::endgroup::")) return { cls: "grp", text: "" };
82+ if (/^(##\[error\]|::error)/.test(t)) return { cls: "err", text: t };
83+ if (/^(##\[warning\]|::warning)/.test(t)) return { cls: "wrn", text: t };
84+ return { cls: "", text: t };
85+}
86+
87+async function allLogs(l: Loaded, jobId: string): Promise<{ lines: LogLine[]; complete: boolean; truncated: boolean }> {
88+ const out: LogLine[] = [];
89+ let after = -1;
90+ let complete = false;
91+ for (let i = 0; i < 5; i++) {
92+ const page = await l.stub.jobLogs(jobId, after);
93+ complete = page.complete;
94+ out.push(...page.lines);
95+ if (page.lines.length < 5000 || out.length >= MAX_LOG_LINES) break;
96+ after = page.lines[page.lines.length - 1]!.seq;
97+ }
98+ return { lines: out.slice(0, MAX_LOG_LINES), complete, truncated: out.length >= MAX_LOG_LINES };
99+}
100+
101+export function registerActions(web: WebApp): void {
102+ web.get("/:owner/:repo/actions", async (c) => {
103+ const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
104+ const { repo, caller, stub } = l;
105+ const base = repoUrl(repo);
106+ const workflow = c.req.query("workflow") ?? "";
107+ const branch = c.req.query("branch") ?? "";
108+ const status = c.req.query("status") ?? "";
109+ const page = pageNum(c);
110+ const per = 25;
111+ const [runs, workflows, names] = await Promise.all([
112+ stub.runs({ workflow: workflow || undefined, branch: branch || undefined, status: status || undefined, limit: per, offset: (page - 1) * per }),
113+ stub.workflows().catch(() => [] as Array<{ path: string; name: string; errors: string[]; triggers: string[] }>),
114+ refNames(stub),
115+ ]);
116+ const selected = workflow ? workflows.find((w) => w.path === workflow || w.path.endsWith(`/${workflow}`)) : undefined;
117+ const canWrite = atLeast(caller.role, "write") && c.get("principal").type === "account" && !repo.archived;
118+ const inputs = selected && canWrite && selected.triggers.includes("workflow_dispatch") ? await dispatchInputs(l, selected.path) : null;
119+ const t = csrfToken(c);
120+ const href = (p: Record<string, string | number | undefined>) => `${base}/actions${qs({ workflow: workflow || undefined, branch: branch || undefined, status: status || undefined, ...p })}`;
121+ return render(
122+ c,
123+ { title: `Actions · ${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "actions" }, live: "runs" },
124+ <div class="grid left">
125+ <aside class="side">
126+ <section>
127+ <h3>Workflows</h3>
128+ <div class="nav">
129+ <a href={`${base}/actions`} class={!workflow ? "on" : ""}>
130+ All workflows
131+ </a>
132+ {workflows.map((w) => (
133+ <a href={`${base}/actions${qs({ workflow: w.path })}`} class={selected?.path === w.path ? "on" : ""} title={w.path}>
134+ {w.errors.length ? <span class="bad">{raw(ICONS.failure)}</span> : null} {w.name}
135+ </a>
136+ ))}
137+ </div>
138+ {workflows.length === 0 ? (
139+ <p class="muted small">
140+ No workflows on <span class="mono">{repo.default_branch}</span>. Add a YAML file under <code>.github/workflows/</code>.
141+ </p>
142+ ) : null}
143+ </section>
144+ <section>
145+ <h3>Help</h3>
146+ <p class="small muted m0">Workflows use GitHub Actions syntax. Jobs run on Linux containers; secrets and variables are configured in repository settings.</p>
147+ </section>
148+ </aside>
149+ <div>
150+ <div class="row between mb">
151+ <h1 class="m0">{selected ? selected.name : "All workflows"}</h1>
152+ {selected ? <span class="mono muted small">{selected.path}</span> : null}
153+ </div>
154+ {selected?.errors.length ? (
155+ <div class="failure">
156+ <b>This workflow file has errors</b>
157+ <pre>{selected.errors.join("\n")}</pre>
158+ </div>
159+ ) : null}
160+ {!selected && workflows.some((w) => w.errors.length) ? (
161+ <div class="failure">
162+ <b>Some workflow files have errors</b>
163+ <pre>{workflows.filter((w) => w.errors.length).map((w) => `${w.path}\n ${w.errors.join("\n ")}`).join("\n")}</pre>
164+ </div>
165+ ) : null}
166+ {selected && inputs ? (
167+ <details class="box">
168+ <summary class="head" style="cursor:pointer">
169+ {raw(ICONS.play)} <b>Run workflow</b>
170+ <span class="muted small">This workflow has a workflow_dispatch trigger.</span>
171+ </summary>
172+ <Form action={`${base}/actions/dispatch`} t={t} class="body">
173+ <input type="hidden" name="workflow" value={selected.path} />
174+ <div class="field" style="max-width:320px">
175+ <label>Use workflow from</label>
176+ <select name="ref">
177+ {names.branches.map((b) => (
178+ <option value={b} selected={b === repo.default_branch}>
179+ {b}
180+ </option>
181+ ))}
182+ {names.tags.map((tg) => (
183+ <option value={tg}>tag: {tg}</option>
184+ ))}
185+ </select>
186+ </div>
187+ {Object.entries(inputs).map(([name, d]) => (
188+ <div class="field" style="max-width:520px">
189+ {d.type === "boolean" ? (
190+ <>
191+ <input type="hidden" name={`in_${name}`} value="false" />
192+ <label class="inline">
193+ <input type="checkbox" name={`in_${name}`} value="true" checked={d.default === true || d.default === "true"} /> {d.description ?? name}
194+ </label>
195+ </>
196+ ) : (
197+ <>
198+ <label>
199+ {d.description ?? name} {d.required ? <span class="bad">*</span> : null}
200+ </label>
201+ {d.type === "choice" && d.options?.length ? (
202+ <select name={`in_${name}`}>
203+ {d.options.map((o) => (
204+ <option value={o} selected={String(d.default ?? "") === o}>
205+ {o}
206+ </option>
207+ ))}
208+ </select>
209+ ) : (
210+ <input type={d.type === "number" ? "number" : "text"} name={`in_${name}`} value={d.default === undefined || d.default === null ? "" : String(d.default)} required={!!d.required} />
211+ )}
212+ </>
213+ )}
214+ {d.description && d.type !== "boolean" ? <div class="hint mono">{name}</div> : null}
215+ </div>
216+ ))}
217+ <button class="btn green">{raw(ICONS.play)} Run workflow</button>
218+ </Form>
219+ </details>
220+ ) : null}
221+ <div class="box" data-fragment="runs">
222+ <div class="head">
223+ <b>
224+ {runs.total} workflow run{runs.total === 1 ? "" : "s"}
225+ </b>
226+ <span class="grow" />
227+ <form method="get" action={`${base}/actions`} class="row" style="gap:6px">
228+ {workflow ? <input type="hidden" name="workflow" value={workflow} /> : null}
229+ <select name="branch" style="width:auto;padding:3px 6px">
230+ <option value="">All branches</option>
231+ {names.branches.map((b) => (
232+ <option value={b} selected={b === branch}>
233+ {b}
234+ </option>
235+ ))}
236+ </select>
237+ <select name="status" style="width:auto;padding:3px 6px">
238+ <option value="">Any status</option>
239+ {["queued", "in_progress", "completed", "success", "failure", "cancelled"].map((s) => (
240+ <option value={s} selected={s === status}>
241+ {s.replace("_", " ")}
242+ </option>
243+ ))}
244+ </select>
245+ <button class="btn sm">Filter</button>
246+ </form>
247+ </div>
248+ {runs.runs.length ? (
249+ <ul class="list">
250+ {runs.runs.map((r) => (
251+ <RunRow base={base} r={r} />
252+ ))}
253+ </ul>
254+ ) : (
255+ <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>
256+ )}
257+ </div>
258+ <Pager page={page} hasNext={page * per < runs.total} href={(p) => href({ page: p > 1 ? p : undefined })} />
259+ </div>
260+ </div>,
261+ );
262+ });
263+
264+ post(web, "/:owner/:repo/actions/dispatch", async (c, f) => {
265+ const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write");
266+ if (c.get("principal").type !== "account") throw new HttpError(401, "sign in required");
267+ const base = repoUrl(l.repo);
268+ const workflow = f.s("workflow");
269+ const inputs: Record<string, unknown> = {};
270+ for (const key of f.keys()) if (key.startsWith("in_")) inputs[key.slice(3)] = f.all(key).pop();
271+ return act(c, `${base}/actions${qs({ workflow })}`, async () => {
272+ const run = await l.stub.dispatchWorkflow(workflow, f.s("ref") || l.repo.default_branch, inputs, l.caller);
273+ return `${base}/actions/runs/${run.id}`;
274+ });
275+ });
276+
277+ web.get("/:owner/:repo/actions/runs/:runId", async (c) => {
278+ const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
279+ const { repo, caller, stub } = l;
280+ const base = repoUrl(repo);
281+ const runId = prm(c, "runId");
282+ const run = (await stub.run(runId)) as unknown as RunWithJobs;
283+ const artifacts = await stub.artifacts(runId).catch(() => [] as Array<{ name: string; size: number; createdAt: string }>);
284+ const groups = new Map<string, JobInfo[]>();
285+ for (const j of run.jobs) groups.set(j.key, [...(groups.get(j.key) ?? []), j]);
286+ const canWrite = atLeast(caller.role, "write") && c.get("principal").type === "account" && !repo.archived;
287+ const t = csrfToken(c);
288+ const behalf = run.actor.onBehalfOf;
289+ return render(
290+ c,
291+ { title: `${run.title || run.workflowName} · Run #${run.number}`, repo: { repo, caller, tab: "actions" }, live: run.status !== "completed" ? `run:${run.id}` : undefined },
292+ <div data-fragment="run">
293+ <div class="row between mb" style="align-items:flex-start">
294+ <div class="grow">
295+ <div class="muted small">
296+ <a href={`${base}/actions${qs({ workflow: run.workflowPath })}`}>{run.workflowName}</a> · run #{run.number}
297+ {run.attempt > 1 ? ` · attempt ${run.attempt}` : ""}
298+ </div>
299+ <h1 class="row" style="gap:8px;margin-top:4px">
300+ <Status status={run.status} conclusion={run.conclusion} /> {run.title || run.workflowName}
301+ </h1>
302+ </div>
303+ <div class="row">
304+ {canWrite && run.status !== "completed" ? (
305+ <Form action={`${base}/actions/runs/${run.id}/cancel`} t={t} class="inline" confirm="Cancel this run?">
306+ <button class="btn danger">{raw(ICONS.cancelled)} Cancel run</button>
307+ </Form>
308+ ) : null}
309+ {canWrite && run.status === "completed" ? (
310+ <Form action={`${base}/actions/runs/${run.id}/rerun`} t={t} class="inline">
311+ <button class="btn">{raw(ICONS.history)} Re-run all jobs</button>
312+ </Form>
313+ ) : null}
314+ </div>
315+ </div>
316+ <div class="box">
317+ <div class="body">
318+ <dl class="kv">
319+ <dt>Status</dt>
320+ <dd>
321+ <b>{run.status === "completed" ? (run.conclusion ?? "completed") : run.status.replace("_", " ")}</b>
322+ {run.status === "completed" ? <span class="muted"> in {runDuration(run)}</span> : <span class="muted"> for {runDuration(run)}</span>}
323+ </dd>
324+ <dt>Triggered</dt>
325+ <dd>
326+ {run.event.replace(/_/g, " ")} by <Actor login={run.actor.login} kind={run.actor.kind} onBehalfOf={behalf} avatar={false} /> <Time at={run.createdAt} />
327+ </dd>
328+ <dt>Commit</dt>
329+ <dd>
330+ <a class="sha" href={`${base}/commit/${run.sha}`}>
331+ {shortSha(run.sha)}
332+ </a>{" "}
333+ on{" "}
334+ <a class="mono" href={`${base}/tree/${encPath(refLabel(run.ref))}`}>
335+ {refLabel(run.ref)}
336+ </a>
337+ {run.prNumber ? (
338+ <>
339+ {" "}
340+ · pull request <a href={`${base}/pull/${run.prNumber}`}>#{run.prNumber}</a>
341+ </>
342+ ) : null}
343+ </dd>
344+ <dt>Workflow file</dt>
345+ <dd>
346+ <a class="mono" href={`${base}/blob/${run.sha}/${encPath(run.workflowPath)}`}>
347+ {run.workflowPath}
348+ </a>
349+ </dd>
350+ </dl>
351+ </div>
352+ </div>
353+ {[...groups.entries()].map(([key, jobs]) => (
354+ <div class="box jobgroup">
355+ <div class="head">
356+ <b class="mono">{key}</b>
357+ {jobs.length > 1 ? <span class="muted small">{jobs.length} jobs (matrix)</span> : null}
358+ </div>
359+ {jobs.map((j) => (
360+ <a class="job" href={`${base}/actions/runs/${run.id}/job/${j.id}`}>
361+ <Status status={j.status} conclusion={j.conclusion} />
362+ <div class="grow">
363+ <b>{j.name}</b>
364+ {matrixLabel(j.matrix) && !j.name.includes("(") ? <span class="muted small"> ({matrixLabel(j.matrix)})</span> : null}
365+ {j.failure ? <div class="small bad">{j.failure.message.split("\n")[0]}</div> : null}
366+ </div>
367+ <span class="muted small nowrap">{j.startedAt ? duration(j.startedAt, j.completedAt) : j.status}</span>
368+ </a>
369+ ))}
370+ </div>
371+ ))}
372+ {run.jobs.length === 0 ? <div class="blank mb">{run.status === "completed" ? "This run has no jobs." : "Waiting for jobs to be scheduled…"}</div> : null}
373+ {artifacts.length ? (
374+ <div class="box">
375+ <div class="head">
376+ <h3>{raw(ICONS.download)} Artifacts</h3>
377+ </div>
378+ <ul class="list">
379+ {artifacts.map((a) => (
380+ <li>
381+ <div class="grow">
382+ <a class="title" href={`${base}/actions/runs/${run.id}/artifacts/${encodeURIComponent(a.name)}`}>
383+ {a.name}
384+ </a>
385+ <div class="meta">
386+ {fmtBytes(a.size)} · <Time at={a.createdAt} />
387+ </div>
388+ </div>
389+ </li>
390+ ))}
391+ </ul>
392+ </div>
393+ ) : null}
394+ </div>,
395+ );
396+ });
397+
398+ post(web, "/:owner/:repo/actions/runs/:runId/cancel", async (c) => {
399+ const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write");
400+ const back = `${repoUrl(l.repo)}/actions/runs/${prm(c, "runId")}`;
401+ return act(c, back, async () => l.stub.cancelRun(prm(c, "runId"), l.caller), "Cancellation requested.");
402+ });
403+
404+ post(web, "/:owner/:repo/actions/runs/:runId/rerun", async (c) => {
405+ const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write");
406+ const base = repoUrl(l.repo);
407+ return act(c, `${base}/actions/runs/${prm(c, "runId")}`, async () => {
408+ const run = await l.stub.rerun(prm(c, "runId"), l.caller);
409+ return `${base}/actions/runs/${run.id}`;
410+ });
411+ });
412+
413+ web.get("/:owner/:repo/actions/runs/:runId/job/:jobId", async (c) => {
414+ const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
415+ const { repo, caller, stub } = l;
416+ const base = repoUrl(repo);
417+ const runId = prm(c, "runId");
418+ const jobId = prm(c, "jobId");
419+ const run = (await stub.run(runId)) as unknown as RunWithJobs;
420+ const job = (await stub.job(jobId)) as unknown as JobInfo;
421+ if (job.runId !== run.id) throw new HttpError(404, "job not found");
422+ const logs = await allLogs(l, jobId);
423+ const byStep = new Map<number, LogLine[]>();
424+ for (const line of logs.lines) byStep.set(line.step, [...(byStep.get(line.step) ?? []), line]);
425+ const stepIdx = new Set<number>([...job.steps.map((s) => s.index), ...byStep.keys()]);
426+ const sections = [...stepIdx].sort((a, b) => a - b);
427+ const stepOf = new Map(job.steps.map((s) => [s.index, s]));
428+ const lastSeq = logs.lines.length ? logs.lines[logs.lines.length - 1]!.seq : -1;
429+ const live = job.status !== "completed";
430+ const failedStep = job.failure?.step;
431+ return render(
432+ c,
433+ { title: `${job.name} · ${run.title || run.workflowName}`, repo: { repo, caller, tab: "actions" }, live: live ? `job:${job.id}` : undefined, liveMode: "log", lastSeq, wide: true },
434+ <div class="grid left">
435+ <aside class="side">
436+ <section>
437+ <a href={`${base}/actions/runs/${run.id}`} class="small">
438+ ← {run.workflowName} #{run.number}
439+ </a>
440+ <h3 class="mt">Jobs</h3>
441+ <div class="nav">
442+ {run.jobs.map((j) => (
443+ <a href={`${base}/actions/runs/${run.id}/job/${j.id}`} class={j.id === job.id ? "on" : ""} style="display:flex;gap:6px;align-items:center">
444+ <Status status={j.status} conclusion={j.conclusion} /> <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{j.name}</span>
445+ </a>
446+ ))}
447+ </div>
448+ </section>
449+ </aside>
450+ <div>
451+ <div class="row between mb">
452+ <h1 class="row m0" style="gap:8px">
453+ <Status status={job.status} conclusion={job.conclusion} /> {job.name}
454+ </h1>
455+ <div class="row">
456+ <span class="muted small">
457+ {job.startedAt ? (
458+ <>
459+ {job.status === "completed" ? "took" : "running for"} {duration(job.startedAt, job.completedAt)}
460+ </>
461+ ) : (
462+ job.status
463+ )}
464+ </span>
465+ <a class="btn sm" href={`${base}/actions/runs/${run.id}/job/${job.id}/logs`}>
466+ {raw(ICONS.download)} Raw logs
467+ </a>
468+ </div>
469+ </div>
470+ {matrixLabel(job.matrix) ? <p class="muted small">Matrix: {matrixLabel(job.matrix)}</p> : null}
471+ {job.failure ? (
472+ <div class="failure">
473+ <b>
474+ {raw(ICONS.failure)} {job.conclusion === "cancelled" ? "Cancelled" : job.conclusion === "timed_out" ? "Timed out" : "Failed"}
475+ {failedStep !== undefined && failedStep !== null ? (
476+ <>
477+ {" "}
478+ at{" "}
479+ <a href={`#step-${failedStep}`} class="bad">
480+ {stepOf.get(failedStep)?.name ?? `step ${failedStep + 1}`}
481+ </a>
482+ </>
483+ ) : null}
484+ </b>
485+ <pre>{job.failure.message}</pre>
486+ </div>
487+ ) : null}
488+ {logs.truncated ? (
489+ <div class="flash info">
490+ 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>.
491+ </div>
492+ ) : null}
493+ <div class="log">
494+ {sections.length === 0 ? <div style="padding:14px;color:#9aa3ad">{live ? "Waiting for the runner to start…" : "No log output."}</div> : null}
495+ {sections.map((idx) => {
496+ const s = stepOf.get(idx);
497+ const lines = byStep.get(idx) ?? [];
498+ const failed = s?.conclusion === "failure" || failedStep === idx;
499+ const running = s?.status === "in_progress";
500+ return (
501+ <details data-step={String(idx)} id={`step-${idx}`} open={failed || running || (live && idx === sections[sections.length - 1])}>
502+ <summary>
503+ {s ? (
504+ <Status status={s.status} conclusion={s.conclusion} />
505+ ) : idx < 0 ? (
506+ <Status conclusion={job.startedAt ? "success" : "queued"} />
507+ ) : (
508+ <Status conclusion="neutral" />
509+ )}
510+ {idx < 0 ? "Set up job" : (s?.name ?? `Step ${idx + 1}`)}
511+ <span class="dur">{s?.startedAt ? duration(s.startedAt, s.completedAt) : ""}</span>
512+ </summary>
513+ <pre>
514+ {lines.map((ln) => {
515+ const k = classify(ln.text);
516+ return <span class={k.cls || undefined}>{k.text}</span>;
517+ })}
518+ </pre>
519+ </details>
520+ );
521+ })}
522+ </div>
523+ {job.summary ? (
524+ <div class="box">
525+ <div class="head">
526+ <h3>Job summary</h3>
527+ </div>
528+ <div class="body">
529+ <Markdown text={job.summary} />
530+ </div>
531+ </div>
532+ ) : null}
533+ {Object.keys(job.outputs ?? {}).length ? (
534+ <div class="box">
535+ <div class="head">
536+ <h3>Outputs</h3>
537+ </div>
538+ <div class="body">
539+ <dl class="kv">
540+ {Object.entries(job.outputs).map(([k, v]) => (
541+ <>
542+ <dt class="mono">{k}</dt>
543+ <dd class="mono" style="word-break:break-all">
544+ {v}
545+ </dd>
546+ </>
547+ ))}
548+ </dl>
549+ </div>
550+ </div>
551+ ) : null}
552+ </div>
553+ </div>,
554+ );
555+ });
556+
557+ web.get("/:owner/:repo/actions/runs/:runId/job/:jobId/logs", async (c) => {
558+ const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
559+ const job = await l.stub.job(prm(c, "jobId"));
560+ if (job.runId !== prm(c, "runId")) throw new HttpError(404, "job not found");
561+ const out: string[] = [];
562+ let after = -1;
563+ for (let i = 0; i < 40; i++) {
564+ const page = await l.stub.jobLogs(job.id, after);
565+ for (const ln of page.lines) out.push(`${ln.t} ${ln.text.replace(ANSI, "")}`);
566+ if (page.lines.length < 5000) break;
567+ after = page.lines[page.lines.length - 1]!.seq;
568+ }
569+ return new Response(`${out.join("\n")}\n`, {
570+ headers: {
571+ "content-type": "text/plain; charset=utf-8",
572+ "x-content-type-options": "nosniff",
573+ "content-security-policy": "sandbox",
574+ "content-disposition": `inline; filename*=UTF-8''${encodeURIComponent(`${job.name}.log`)}`,
575+ "cache-control": "private, no-store",
576+ },
577+ });
578+ });
579+}
addedapps/diffinery/src/web/assets.ts+391−0View file
@@ -1,0 +1,391 @@
1+/** Inline stylesheet, client script and icons for the web UI (no external assets). */
2+
3+export const CSS = `
4+:root{color-scheme:light dark;
5+/* Colors, radii and type follow Cloudflare's Kumo design system tokens (kumo-ui.com, MIT). */
6+--canvas:oklch(98.75% 0 0);--bg:#fff;--bg2:oklch(98% 0 0);--bg3:oklch(96% 0 0);--bg-hover:oklch(97% 0 0);
7+--fg:oklch(21% 0.006 285.885);--fg2:oklch(55.6% 0 0);--fg3:oklch(70.8% 0 0);
8+--border:oklch(14.5% 0 0 / .1);--border2:oklch(93.5% 0 0);
9+--accent:oklch(48.8% 0.243 264.376);--accent-hover:oklch(54.6% 0.245 262.881);--accent-fg:#fff;--accent-soft:oklch(93.2% 0.032 255.6 / .45);
10+--link:oklch(42.4% 0.199 265.638);--focus:oklch(15% 0 0);
11+--green:oklch(43.2% 0.095 166.913);--green-soft:oklch(96.2% 0.043 156.7 / .57);--red:oklch(50.5% 0.213 27.518);--red-soft:oklch(93.6% 0.032 17.7 / .42);
12+--amber:oklch(59.7% 0.144 57.5);--amber-soft:oklch(93.1% 0.107 94.6 / .2);
13+--purple:oklch(55.8% 0.288 302.321);--purple-soft:oklch(55.8% 0.288 302.321 / .1);--blue:oklch(42.4% 0.199 265.638);--blue-soft:oklch(93.2% 0.032 255.6 / .45);
14+--brand:#f6821f;
15+--add-bg:oklch(96.2% 0.043 156.7 / .57);--add-ln:oklch(90.5% 0.093 164.15 / .7);--del-bg:oklch(93.6% 0.032 17.7 / .42);--del-ln:oklch(88.5% 0.062 18.334 / .7);--hunk-bg:oklch(93.2% 0.032 255.6 / .45);--hl:oklch(97.3% 0.071 103.193);
16+--code-bg:oklch(96% 0 0);--shadow:0 0 0 1px oklch(0% 0 0 / .12),0 8px 24px oklch(0% 0 0 / .08);--r:.5rem;--r-sm:.375rem;
17+--mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;
18+--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"}
19+@media (prefers-color-scheme:dark){:root{
20+--canvas:oklch(10% 0 0);--bg:oklch(17% 0 0);--bg2:oklch(12% 0 0);--bg3:oklch(15% 0 0);--bg-hover:oklch(26.9% 0 0);
21+--fg:oklch(97% 0 0);--fg2:oklch(70.8% 0 0);--fg3:oklch(55.6% 0 0);
22+--border:oklch(32% 0 0);--border2:oklch(26.9% 0 0);
23+--accent:oklch(48.8% 0.243 264.376);--accent-hover:oklch(54.6% 0.245 262.881);--accent-fg:#fff;--accent-soft:oklch(38% 0.145 265.5 / .22);
24+--link:oklch(70.7% 0.165 254.624);--focus:oklch(93.5% 0 0);
25+--green:oklch(76.5% 0.177 163.223);--green-soft:oklch(39.3% 0.096 152.3 / .2);--red:oklch(70.4% 0.191 22.216);--red-soft:oklch(42.9% 0.176 28.7 / .17);
26+--amber:oklch(75% 0.183 55.934);--amber-soft:oklch(35.3% 0.079 65 / .37);
27+--purple:oklch(71.4% 0.203 305.504);--purple-soft:oklch(49.6% 0.265 301.924 / .2);--blue:oklch(70.7% 0.165 254.624);--blue-soft:oklch(38% 0.145 265.5 / .22);
28+--add-bg:oklch(39.3% 0.096 152.3 / .2);--add-ln:oklch(39.3% 0.096 152.3 / .4);--del-bg:oklch(42.9% 0.176 28.7 / .17);--del-ln:oklch(42.9% 0.176 28.7 / .35);--hunk-bg:oklch(38% 0.145 265.5 / .22);--hl:oklch(55.4% 0.135 66.442 / .5);
29+--code-bg:oklch(15% 0 0);--shadow:0 0 0 1px oklch(100% 0 0 / .1),0 8px 24px oklch(0% 0 0 / .3)}}
30+*{box-sizing:border-box}
31+html{-webkit-text-size-adjust:100%}
32+body{margin:0;background:var(--canvas);color:var(--fg);font:14px/1.5 var(--sans)}
33+a{color:var(--link);text-decoration:none}a:hover{text-decoration:underline}
34+code,pre,kbd,.mono{font-family:var(--mono);font-size:12.5px}
35+kbd{border:1px solid var(--border);border-bottom-width:2px;border-radius:4px;padding:0 5px;background:var(--bg2);font-size:11px;color:var(--fg2)}
36+h1,h2,h3,h4{line-height:1.25;margin:0 0 8px}h1{font-size:22px}h2{font-size:18px}h3{font-size:15px}
37+p{margin:0 0 10px}
38+hr{border:0;border-top:1px solid var(--border);margin:16px 0}
39+.muted{color:var(--fg2)}.faint{color:var(--fg3)}.small{font-size:12px}.nowrap{white-space:nowrap}
40+.ok{color:var(--green)}.bad{color:var(--red)}.warn{color:var(--amber)}.run{color:var(--amber)}.merged{color:var(--purple)}
41+.top{position:sticky;top:0;z-index:20;background:color-mix(in srgb,var(--bg) 88%,transparent);backdrop-filter:saturate(1.6) blur(10px);border-bottom:1px solid var(--border)}
42+.top .wrap{display:flex;align-items:center;gap:14px;height:52px}
43+.brand{display:flex;align-items:center;gap:8px;font-weight:650;color:var(--fg);letter-spacing:-.01em}
44+.brand:hover{text-decoration:none}.brand svg{color:var(--brand)}
45+.top nav{display:flex;align-items:center;gap:4px;margin-left:auto}
46+.top nav a{color:var(--fg2);padding:6px 10px;border-radius:6px}.top nav a:hover{background:var(--bg-hover);color:var(--fg);text-decoration:none}
47+.pal-btn{display:flex;align-items:center;gap:8px;min-width:220px;padding:5px 10px;border:1px solid var(--border);border-radius:7px;background:var(--bg2);color:var(--fg3);font:inherit;cursor:pointer}
48+.pal-btn:hover{border-color:var(--fg3)}.pal-btn kbd{margin-left:auto}
49+.dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--accent);margin-left:2px;vertical-align:top}
50+.wrap{max-width:1280px;margin:0 auto;padding:0 20px}
51+main.wrap{padding-top:20px;padding-bottom:60px}
52+.flash{border:1px solid;border-radius:var(--r);padding:10px 14px;margin:0 0 16px}
53+.flash.error{border-color:color-mix(in srgb,var(--red) 40%,transparent);background:var(--red-soft);color:var(--red)}
54+.flash.notice{border-color:color-mix(in srgb,var(--green) 40%,transparent);background:var(--green-soft);color:var(--green)}
55+.flash.info{border-color:color-mix(in srgb,var(--blue) 40%,transparent);background:var(--blue-soft)}
56+.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:6px 12px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg);color:var(--fg);font:500 13px/1.4 var(--sans);cursor:pointer;white-space:nowrap;text-decoration:none!important}
57+.btn:hover{background:var(--bg-hover);border-color:var(--fg3)}
58+.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--accent-fg);box-shadow:inset 0 1px 0 0 oklch(100% 0 0 / .15)}.btn.primary:hover{background:var(--accent-hover)}
59+.btn.green{background:var(--accent);border-color:var(--accent);color:var(--accent-fg)}.btn.green:hover{background:var(--accent-hover)}
60+.btn.danger{color:var(--red)}.btn.danger:hover{background:var(--red);border-color:var(--red);color:#fff}
61+.btn.sm{padding:3px 8px;font-size:12px}.btn.link{border-color:transparent;background:none;color:var(--accent);padding:0}
62+.btn[disabled]{opacity:.5;cursor:not-allowed}
63+input[type=text],input[type=search],input[type=email],input[type=number],input[type=url],input[type=password],input:not([type]),select,textarea{
64+width:100%;padding:7px 10px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg);color:var(--fg);font:inherit}
65+textarea{min-height:120px;resize:vertical;font-family:var(--sans)}textarea.code{font-family:var(--mono);font-size:12.5px;min-height:420px;tab-size:4;white-space:pre}
66+input:focus,select:focus,textarea:focus{outline:2px solid color-mix(in srgb,var(--focus) 25%,transparent);outline-offset:0;border-color:color-mix(in srgb,var(--focus) 50%,transparent)}
67+:focus-visible{outline:2px solid color-mix(in srgb,var(--focus) 35%,transparent);outline-offset:2px}
68+input[type=checkbox],input[type=radio]{accent-color:var(--accent)}
69+label{display:block;font-weight:550;margin:0 0 4px}label.inline{display:inline-flex;align-items:center;gap:6px;font-weight:400}
70+.field{margin:0 0 14px}.field .hint{font-size:12px;color:var(--fg2);margin-top:4px;font-weight:400}
71+.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.row.end{justify-content:flex-end}.row.between{justify-content:space-between}
72+.grow{flex:1;min-width:0}
73+.grid{display:grid;grid-template-columns:minmax(0,1fr) 296px;gap:24px}
74+.grid.left{grid-template-columns:260px minmax(0,1fr)}
75+@media (max-width:900px){.grid,.grid.left{grid-template-columns:minmax(0,1fr)}.pal-btn{min-width:0}.pal-btn span{display:none}}
76+.box{border:1px solid var(--border);border-radius:var(--r);background:var(--bg);margin:0 0 16px;overflow:hidden}
77+.box>.head{display:flex;align-items:center;gap:10px;padding:10px 14px;background:var(--bg2);border-bottom:1px solid var(--border);flex-wrap:wrap}
78+.box>.body{padding:14px}
79+.box>.head h2,.box>.head h3{margin:0;font-size:14px}
80+.list{list-style:none;margin:0;padding:0}
81+.list>li{display:flex;gap:10px;align-items:flex-start;padding:10px 14px;border-top:1px solid var(--border2)}
82+.list>li:first-child{border-top:0}.list>li:hover{background:var(--bg2)}
83+.list .title{font-weight:600;color:var(--fg)}.list .title:hover{color:var(--accent)}
84+.list .meta{font-size:12px;color:var(--fg2);margin-top:2px}
85+.empty{padding:32px 16px;text-align:center;color:var(--fg2)}
86+.blank{border:1px dashed var(--border);border-radius:var(--r);padding:32px;text-align:center;color:var(--fg2)}
87+.badge{display:inline-flex;align-items:center;gap:4px;padding:1px 8px;border:1px solid var(--border);border-radius:999px;font-size:12px;font-weight:500;color:var(--fg2);line-height:18px;white-space:nowrap;vertical-align:middle}
88+.badge.agent{border-color:color-mix(in srgb,var(--purple) 45%,transparent);color:var(--purple);background:var(--purple-soft)}
89+.badge.green{border-color:transparent;background:var(--green-soft);color:var(--green)}
90+.badge.red{border-color:transparent;background:var(--red-soft);color:var(--red)}
91+.badge.amber{border-color:transparent;background:var(--amber-soft);color:var(--amber)}
92+.badge.purple{border-color:transparent;background:var(--purple-soft);color:var(--purple)}
93+.badge.blue{border-color:transparent;background:var(--blue-soft);color:var(--blue)}
94+.state{display:inline-flex;align-items:center;gap:6px;padding:5px 12px;border-radius:999px;font-weight:600;font-size:13px;color:#fff}
95+.state.open{background:var(--green)}.state.closed{background:var(--red)}.state.merged{background:var(--purple)}.state.draft{background:var(--fg3)}
96+.lbl{display:inline-block;padding:0 8px;border-radius:999px;font-size:12px;font-weight:550;line-height:20px;background:color-mix(in srgb,var(--lc) 18%,transparent);color:color-mix(in srgb,var(--lc) 70%,var(--fg));border:1px solid color-mix(in srgb,var(--lc) 40%,transparent);margin-right:4px}
97+.av{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:50%;background:hsl(var(--h) 55% 55%);color:#fff;font-size:11px;font-weight:700;flex:none;vertical-align:middle}
98+.av.lg{width:40px;height:40px;font-size:18px}.av.xl{width:96px;height:96px;font-size:40px}.av.bot{border-radius:6px}
99+.who{display:inline-flex;align-items:center;gap:6px;font-weight:600;color:var(--fg)}
100+.repohead{padding:18px 0 0;border-bottom:1px solid var(--border);background:var(--bg2)}
101+.repohead h1{font-size:19px;font-weight:500;display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0 0 6px}
102+.repohead h1 a{color:var(--fg)}.repohead h1 strong a{font-weight:650}
103+.tabs{display:flex;gap:2px;overflow-x:auto;scrollbar-width:none;margin-top:10px}
104+.tabs a{display:inline-flex;align-items:center;gap:6px;padding:8px 12px;color:var(--fg2);border-bottom:2px solid transparent;white-space:nowrap}
105+.tabs a:hover{color:var(--fg);text-decoration:none;border-bottom-color:var(--border)}
106+.tabs a.on{color:var(--fg);font-weight:600;border-bottom-color:var(--accent)}
107+.tabs .n{background:var(--bg3);border-radius:999px;padding:0 7px;font-size:12px;color:var(--fg2)}
108+.subnav{display:flex;gap:0;border:1px solid var(--border);border-radius:7px;overflow:hidden;width:max-content;max-width:100%}
109+.subnav a{padding:5px 12px;color:var(--fg2);border-left:1px solid var(--border)}.subnav a:first-child{border-left:0}
110+.subnav a.on{background:var(--accent-soft);color:var(--accent);font-weight:600}.subnav a:hover{text-decoration:none;background:var(--bg-hover)}
111+.side h3{font-size:12px;color:var(--fg2);text-transform:uppercase;letter-spacing:.04em;margin:0 0 8px}
112+.side section{padding:0 0 16px;margin:0 0 16px;border-bottom:1px solid var(--border2)}
113+.side .nav a{display:block;padding:6px 10px;border-radius:6px;color:var(--fg)}.side .nav a.on{background:var(--accent-soft);color:var(--accent);font-weight:600}.side .nav a:hover{background:var(--bg-hover);text-decoration:none}
114+details.menu{position:relative;display:inline-block}
115+details.menu>summary{list-style:none;cursor:pointer}details.menu>summary::-webkit-details-marker{display:none}
116+details.menu>.pop{position:absolute;z-index:30;top:calc(100% + 4px);left:0;min-width:260px;max-height:360px;overflow:auto;background:var(--bg);border:1px solid var(--border);border-radius:var(--r);box-shadow:var(--shadow);padding:6px}
117+details.menu>.pop.right{left:auto;right:0}
118+details.menu .pop a{display:flex;gap:8px;align-items:center;padding:6px 8px;border-radius:5px;color:var(--fg)}details.menu .pop a:hover{background:var(--bg-hover);text-decoration:none}
119+details.menu .pop h4{font-size:11px;text-transform:uppercase;color:var(--fg3);margin:6px 8px 4px}
120+.crumbs{font-size:15px;display:flex;flex-wrap:wrap;align-items:center;gap:4px}.crumbs a{font-weight:600}
121+table.files{width:100%;border-collapse:collapse}
122+table.files td{padding:7px 14px;border-top:1px solid var(--border2)}table.files tr:first-child td{border-top:0}
123+table.files tr:hover td{background:var(--bg2)}table.files td.ic{width:20px;padding-right:0;color:var(--fg3)}
124+table.files td.sz{text-align:right;color:var(--fg2);width:100px;white-space:nowrap}
125+table.files a{color:var(--fg)}table.files a:hover{color:var(--accent)}
126+.ic-dir{color:var(--accent)!important}
127+.clone{display:flex;gap:6px;align-items:center}
128+.clone input{font-family:var(--mono);font-size:12px}
129+pre.cmd{background:var(--code-bg);border:1px solid var(--border2);border-radius:7px;padding:12px 14px;overflow:auto;margin:0 0 12px}
130+.code-wrap{overflow:auto}
131+table.blob{border-collapse:collapse;width:100%;font-family:var(--mono);font-size:12.5px;line-height:20px}
132+table.blob td{padding:0 12px;vertical-align:top;white-space:pre}
133+table.blob td.ln{width:1%;min-width:50px;text-align:right;color:var(--fg3);user-select:none;padding-right:10px}
134+table.blob td.ln a{color:inherit}table.blob tr:target td{background:var(--hl)}
135+table.diff{border-collapse:collapse;width:100%;font-family:var(--mono);font-size:12px;line-height:20px}
136+table.diff td{padding:0 8px;vertical-align:top}
137+table.diff td.ln{width:1%;min-width:44px;text-align:right;color:var(--fg3);user-select:none;white-space:nowrap}
138+table.diff td.code{white-space:pre-wrap;word-break:break-all;width:100%}
139+table.diff tr.add td{background:var(--add-bg)}table.diff tr.add td.ln{background:var(--add-ln)}
140+table.diff tr.del td{background:var(--del-bg)}table.diff tr.del td.ln{background:var(--del-ln)}
141+table.diff tr.hunk td{background:var(--hunk-bg);color:var(--fg2);padding-top:2px;padding-bottom:2px}
142+table.diff tr.meta td{color:var(--fg3);font-style:italic}
143+table.diff td.act{width:1%;padding:0 2px}
144+table.diff .addc{visibility:hidden;border:0;background:var(--accent);color:#fff;border-radius:4px;width:18px;height:18px;line-height:16px;cursor:pointer;font-weight:700;padding:0}
145+table.diff tr:hover .addc{visibility:visible}
146+table.diff tr.thread td{background:var(--bg2);padding:8px 12px;font-family:var(--sans);font-size:14px;white-space:normal}
147+.filehead{display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--bg2);border-bottom:1px solid var(--border);cursor:pointer;position:sticky;top:52px;z-index:5;flex-wrap:wrap}
148+details.file{border:1px solid var(--border);border-radius:var(--r);margin:0 0 14px;overflow:hidden}
149+details.file>summary{list-style:none}details.file>summary::-webkit-details-marker{display:none}
150+details.file:not([open])>.filehead{border-bottom:0}
151+.stat-add{color:var(--green);font-weight:600}.stat-del{color:var(--red);font-weight:600}
152+.bar{display:inline-flex;gap:1px;vertical-align:middle}.bar i{width:8px;height:8px;border-radius:1px;background:var(--bg3)}.bar i.a{background:var(--green)}.bar i.d{background:var(--red)}
153+.comment{border:1px solid var(--border);border-radius:var(--r);margin:0 0 16px;background:var(--bg)}
154+.comment>.head{display:flex;gap:8px;align-items:center;padding:8px 14px;background:var(--bg2);border-bottom:1px solid var(--border);border-radius:var(--r) var(--r) 0 0;flex-wrap:wrap;font-size:13px}
155+.comment>.body{padding:14px}
156+.comment.intent{border-color:color-mix(in srgb,var(--accent) 45%,transparent)}.comment.intent>.head{background:var(--accent-soft)}
157+.timeline{position:relative}
158+.event{display:flex;gap:10px;align-items:center;padding:6px 0 6px 14px;margin:0 0 16px;color:var(--fg2);font-size:13px}
159+.event .ic{display:inline-flex;width:28px;height:28px;border-radius:50%;align-items:center;justify-content:center;background:var(--bg3);flex:none}
160+.event.approved .ic{background:var(--green-soft);color:var(--green)}.event.changes .ic{background:var(--red-soft);color:var(--red)}
161+.linec{border-left:3px solid var(--border);padding:6px 10px;margin:8px 0;background:var(--bg2);border-radius:0 6px 6px 0}
162+.linec .where{font-family:var(--mono);font-size:12px;color:var(--fg2)}
163+.mergebox{border:1px solid var(--border);border-radius:var(--r);margin:0 0 16px;overflow:hidden}
164+.mergebox>div{padding:12px 14px;border-top:1px solid var(--border2)}.mergebox>div:first-child{border-top:0}
165+.mergebox.clean{border-color:color-mix(in srgb,var(--green) 50%,transparent)}.mergebox.blocked,.mergebox.conflicting{border-color:color-mix(in srgb,var(--amber) 55%,transparent)}
166+.mergebox.merged{border-color:color-mix(in srgb,var(--purple) 50%,transparent)}
167+.checks{list-style:none;margin:0;padding:0}.checks li{display:flex;gap:8px;align-items:center;padding:4px 0}
168+.st{display:inline-flex;width:16px;height:16px;flex:none;align-items:center;justify-content:center}
169+.st svg{width:16px;height:16px}
170+.st.spin svg{animation:spin 1.1s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}
171+.job{display:flex;align-items:center;gap:10px;padding:8px 14px;border-top:1px solid var(--border2);color:var(--fg)}
172+.job:hover{background:var(--bg2);text-decoration:none}.job:first-of-type{border-top:0}
173+.jobgroup{margin:0 0 16px}
174+.failure{border:1px solid color-mix(in srgb,var(--red) 45%,transparent);background:var(--red-soft);border-radius:var(--r);padding:12px 14px;margin:0 0 16px}
175+.failure pre{white-space:pre-wrap;margin:6px 0 0;color:var(--fg)}
176+.log{background:#0d1014;color:#d5d9de;border-radius:var(--r);overflow:hidden;margin:0 0 16px;border:1px solid var(--border)}
177+.log details{border-top:1px solid #1d2229}.log details:first-child{border-top:0}
178+.log summary{display:flex;gap:10px;align-items:center;padding:8px 14px;cursor:pointer;list-style:none;color:#e6e8eb}
179+.log summary::-webkit-details-marker{display:none}.log summary:hover{background:#151a20}
180+.log summary .dur{margin-left:auto;color:#7f8792;font-size:12px}
181+.log pre{margin:0;padding:6px 0 10px;overflow:auto;font-size:12px;line-height:19px;counter-reset:l}
182+.log pre span{display:block;padding:0 14px 0 64px;position:relative;white-space:pre-wrap;word-break:break-all;min-height:19px}
183+.log pre span::before{counter-increment:l;content:counter(l);position:absolute;left:0;width:48px;text-align:right;color:#59616c}
184+.log pre span.grp{color:#8fb3ff;font-weight:600}.log pre span.err{color:#ff8c84}.log pre span.wrn{color:#e5c07b}
185+.markdown{line-height:1.65;word-wrap:break-word}
186+.markdown>*:first-child{margin-top:0}.markdown>*:last-child{margin-bottom:0}
187+.markdown h1,.markdown h2{padding-bottom:6px;border-bottom:1px solid var(--border2);margin:22px 0 12px}
188+.markdown h3,.markdown h4,.markdown h5,.markdown h6{margin:18px 0 8px}
189+.markdown h1 .anchor,.markdown h2 .anchor,.markdown h3 .anchor,.markdown h4 .anchor,.markdown h5 .anchor,.markdown h6 .anchor{float:left;margin-left:-18px;padding-right:4px;opacity:0;color:var(--fg3)}
190+.markdown :is(h1,h2,h3,h4,h5,h6):hover .anchor{opacity:1}
191+.markdown p,.markdown ul,.markdown ol,.markdown blockquote,.markdown pre,.markdown .table-wrap{margin:0 0 14px}
192+.markdown ul,.markdown ol{padding-left:2em}.markdown li+li{margin-top:3px}.markdown li.task{list-style:none;margin-left:-1.4em}
193+.markdown code{background:var(--code-bg);padding:.15em .4em;border-radius:5px;font-size:85%}
194+.markdown pre{background:var(--code-bg);padding:14px;border-radius:7px;overflow:auto;line-height:1.45;border:1px solid var(--border2)}
195+.markdown pre code{background:none;padding:0;font-size:12.5px}
196+.markdown blockquote{padding:0 1em;color:var(--fg2);border-left:3px solid var(--border)}
197+.markdown img{max-width:100%}.markdown hr{height:2px;background:var(--border2);border:0}
198+.markdown .table-wrap{overflow:auto}.markdown table{border-collapse:collapse}
199+.markdown th,.markdown td{border:1px solid var(--border);padding:6px 12px}.markdown th{background:var(--bg2);font-weight:600}
200+.readme{margin-top:16px}.readme>.body{padding:24px 32px}
201+@media (max-width:700px){.readme>.body{padding:16px}.wrap{padding:0 12px}}
202+dialog#palette{border:1px solid var(--border);border-radius:12px;padding:0;width:min(640px,94vw);background:var(--bg);color:var(--fg);box-shadow:var(--shadow);margin-top:12vh}
203+dialog#palette::backdrop{background:rgba(10,12,16,.45)}
204+#palette input{border:0;border-bottom:1px solid var(--border);border-radius:0;padding:14px 16px;font-size:15px;outline:none}
205+#palette ul{list-style:none;margin:0;padding:6px;max-height:min(420px,60vh);overflow:auto}
206+#palette li a{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:6px;color:var(--fg)}
207+#palette li a span{margin-left:auto;color:var(--fg3);font-size:12px}
208+#palette li.sel a,#palette li a:hover{background:var(--accent-soft);text-decoration:none}
209+#palette .foot{display:flex;gap:14px;padding:8px 14px;border-top:1px solid var(--border);font-size:12px;color:var(--fg3)}
210+.hero{padding:72px 0 40px;text-align:center}
211+.hero h1{font-size:44px;letter-spacing:-.03em;margin:0 0 14px}
212+.hero p{font-size:18px;color:var(--fg2);max-width:640px;margin:0 auto 26px}
213+.features{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:16px;margin:24px 0}
214+.features .box{padding:18px}.features h3{margin:0 0 6px}
215+.stats{display:flex;gap:18px;flex-wrap:wrap;color:var(--fg2);font-size:13px}
216+.stats b{color:var(--fg)}
217+.kv{display:grid;grid-template-columns:max-content 1fr;gap:6px 16px;font-size:13px}.kv dt{color:var(--fg2)}.kv dd{margin:0}
218+table.tbl{width:100%;border-collapse:collapse;font-size:13px}
219+table.tbl th{text-align:left;font-weight:600;color:var(--fg2);padding:8px 12px;border-bottom:1px solid var(--border);background:var(--bg2);white-space:nowrap}
220+table.tbl td{padding:8px 12px;border-top:1px solid var(--border2);vertical-align:middle}
221+.secret{font-family:var(--mono);background:var(--amber-soft);border:1px solid color-mix(in srgb,var(--amber) 45%,transparent);border-radius:7px;padding:10px 12px;word-break:break-all}
222+.pager{display:flex;gap:8px;justify-content:center;margin:16px 0}
223+.sha{font-family:var(--mono);font-size:12px;padding:1px 6px;border-radius:5px;background:var(--bg3);color:var(--fg)}
224+a.sha:hover{text-decoration:none;background:var(--accent-soft);color:var(--accent)}
225+.right{text-align:right}.mt{margin-top:16px}.mb{margin-bottom:16px}.m0{margin:0}
226+form.inline{display:inline}
227+.navbtn{background:none;border:0;color:var(--fg2);padding:6px 10px;border-radius:6px;font:inherit;cursor:pointer}.navbtn:hover{background:var(--bg-hover);color:var(--fg)}
228+.btn.block{display:flex;width:100%;justify-content:center}
229+.authcard{max-width:440px;margin:56px auto}.authcard .body{padding:28px}.authcard h1{font-size:24px;margin:12px 0 8px}
230+.authmark{width:44px;height:44px;border-radius:var(--r);display:grid;place-items:center;background:color-mix(in srgb,var(--brand) 14%,transparent);color:var(--brand)}.authmark svg{width:22px;height:22px}
231+.authfoot{margin-top:20px;padding-top:14px;border-top:1px solid var(--border)}.authfoot p{margin:0 0 6px}
232+.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
233+`;
234+
235+/** Client behaviour: palette, shortcuts, copy buttons, live updates, line comments. */
236+export const JS = `(()=>{
237+const d=document,b=d.body,$=(s,r=d)=>r.querySelector(s),$$=(s,r=d)=>[...r.querySelectorAll(s)];
238+const repo=b.dataset.repo||'',ref=b.dataset.ref||'';
239+const enc=p=>p.split('/').map(encodeURIComponent).join('/');
240+d.addEventListener('click',e=>{
241+ const c=e.target.closest('[data-copy]');
242+ if(c){e.preventDefault();const v=c.dataset.copy;(navigator.clipboard?navigator.clipboard.writeText(v):Promise.reject()).then(()=>{const o=c.textContent;c.textContent='Copied';setTimeout(()=>{c.textContent=o},1200)}).catch(()=>{window.prompt('Copy:',v)});return}
243+ const p=e.target.closest('[data-palette]');if(p){e.preventDefault();openPalette();return}
244+ const a=e.target.closest('[data-comment-line]');if(a){e.preventDefault();lineComment(a);return}
245+ const x=e.target.closest('[data-cancel-line]');if(x){e.preventDefault();const tr=x.closest('tr');tr&&tr.remove()}
246+ if(!e.target.closest('details.menu'))$$('details.menu[open]').forEach(m=>m.removeAttribute('open'));
247+});
248+d.addEventListener('submit',e=>{const f=e.target;if(f.dataset.passkey){e.preventDefault();passkey(f);return}
249+ if(f.dataset.confirm&&!confirm(f.dataset.confirm)){e.preventDefault();return}
250+ if(f.dataset.match){const i=f.querySelector('[name=confirm]');if(i&&i.value!==f.dataset.match){e.preventDefault();alert('Type '+f.dataset.match+' to confirm.');return}}
251+ $$('button[type=submit],button:not([type])',f).forEach(x=>setTimeout(()=>{x.disabled=true},0));},true);
252+// Passkeys (WebAuthn): forms with data-passkey="login" or "register"
253+const b2u=buf=>{let s='';new Uint8Array(buf).forEach(x=>{s+=String.fromCharCode(x)});return btoa(s).replaceAll('+','-').replaceAll('/','_').replaceAll('=','')};
254+const u2b=s=>{s=s.replaceAll('-','+').replaceAll('_','/');while(s.length%4)s+='=';const bin=atob(s);const out=new Uint8Array(bin.length);for(let i=0;i<bin.length;i++)out[i]=bin.charCodeAt(i);return out.buffer};
255+async function pkPost(url,body){const r=await fetch(url,{method:'POST',credentials:'same-origin',headers:{'content-type':'application/json'},body:JSON.stringify(body)});
256+ let j={};try{j=await r.json()}catch(_){}if(!r.ok)throw new Error(j.message||('Request failed ('+r.status+')'));return j}
257+async function passkey(f){const mode=f.dataset.passkey,msg=$('[data-passkey-msg]',f)||$('[data-passkey-msg]'),btns=$$('button',f);
258+ const show=t=>{if(msg){msg.textContent=t;msg.hidden=!t}else if(t)alert(t)};
259+ if(!window.PublicKeyCredential||!navigator.credentials){show('This browser does not support passkeys. Try a current version of Chrome, Safari, Edge or Firefox.');return}
260+ show('');btns.forEach(x=>{x.disabled=true});
261+ const data={};new FormData(f).forEach((v,k)=>{if(typeof v==='string')data[k]=v});data.next=f.dataset.next||'/';
262+ try{const o=await pkPost('/auth/passkey/'+mode+'/options',data);o.challenge=u2b(o.challenge);let cred,res;
263+ if(mode==='login'){(o.allowCredentials||[]).forEach(c=>{c.id=u2b(c.id)});cred=await navigator.credentials.get({publicKey:o});const r=cred.response;
264+ res={authenticatorData:b2u(r.authenticatorData),clientDataJSON:b2u(r.clientDataJSON),signature:b2u(r.signature)};if(r.userHandle)res.userHandle=b2u(r.userHandle)}
265+ else{o.user.id=u2b(o.user.id);(o.excludeCredentials||[]).forEach(c=>{c.id=u2b(c.id)});cred=await navigator.credentials.create({publicKey:o});const r=cred.response;
266+ res={attestationObject:b2u(r.attestationObject),clientDataJSON:b2u(r.clientDataJSON),transports:r.getTransports?r.getTransports():[]}}
267+ const v=await pkPost('/auth/passkey/'+mode+'/verify',Object.assign({},data,{response:{id:cred.id,rawId:b2u(cred.rawId),type:cred.type,response:res,clientExtensionResults:cred.getClientExtensionResults(),authenticatorAttachment:cred.authenticatorAttachment||undefined}}));
268+ location.assign(v.redirect||'/')}
269+ catch(e){btns.forEach(x=>{x.disabled=false});
270+ show(e&&e.name==='NotAllowedError'?'The passkey prompt was closed or timed out. Try again when you are ready.':e&&e.name==='InvalidStateError'?'This device already has a passkey for this account.':(e&&e.message)||String(e))}}
271+function lineComment(btn){
272+ const tpl=$('#line-comment-tpl');if(!tpl)return;
273+ const tr=btn.closest('tr');const next=tr.nextElementSibling;
274+ if(next&&next.classList.contains('newc')){next.querySelector('textarea').focus();return}
275+ const row=d.createElement('tr');row.className='thread newc';const td=d.createElement('td');td.colSpan=tr.children.length;
276+ td.append(tpl.content.cloneNode(true));row.append(td);tr.after(row);
277+ row.querySelector('[name=path]').value=btn.dataset.path;row.querySelector('[name=line]').value=btn.dataset.commentLine;
278+ row.querySelector('[name=side]').value=btn.dataset.side||'RIGHT';row.querySelector('.where').textContent=btn.dataset.path+':'+btn.dataset.commentLine;
279+ row.querySelector('textarea').focus();
280+}
281+// Palette
282+const dlg=$('#palette'),inp=dlg&&$('input',dlg),ul=dlg&&$('ul',dlg);let items=null,shown=[],sel=0,loading=false;
283+function base(){const a=[['Dashboard','/'],['New repository','/new'],['Notifications','/notifications'],['Settings','/settings'],['Tokens','/settings/tokens'],['Agents','/settings/agents']].map(([t,u])=>({t,u,k:'Go to'}));
284+ if(repo){const r='/'+repo;[['Code',''],['Issues','/issues'],['New issue','/issues/new'],['Pull requests','/pulls'],['Actions','/actions'],['Branches','/branches'],['Tags','/tags'],['Commits','/commits/'+enc(ref||'HEAD')],['Compare','/compare'],['Settings','/settings']].forEach(([t,p])=>a.unshift({t:repo+' › '+t,u:r+p,k:'Repository'}))}
285+ return a}
286+async function load(){if(items||loading)return;loading=true;items=base();draw();
287+ try{const r=await fetch('/-/palette.json'+(repo?'?repo='+encodeURIComponent(repo)+'&ref='+encodeURIComponent(ref):''),{credentials:'same-origin'});
288+ if(r.ok){const j=await r.json();(j.repos||[]).forEach(x=>items.push({t:x,u:'/'+x,k:'Repository'}));(j.files||[]).forEach(f=>items.push({t:f,u:'/'+repo+'/blob/'+enc(j.ref||ref||'HEAD')+'/'+enc(f),k:'File'}));draw()}}catch(_){}}
289+function score(q,s){if(!q)return 1;let i=0,sc=0,last=-2;const lo=s.toLowerCase();const at=lo.indexOf(q);if(at>=0)return 1000-at-(s.length/100);
290+ for(const ch of q){const k=lo.indexOf(ch,i);if(k<0)return -1;sc+=k===last+1?5:1;if(k===0||'/ -_.'.includes(lo[k-1]))sc+=3;last=k;i=k+1}return sc-(s.length/100)}
291+function draw(){if(!ul)return;const q=inp.value.trim().toLowerCase();let list=(items||[]).map(x=>[score(q,x.t),x]).filter(a=>a[0]>=0).sort((a,b)=>b[0]-a[0]).slice(0,60).map(a=>a[1]);
292+ const m=/^#?(\\d+)$/.exec(q);if(m&&repo)list.unshift({t:'Go to #'+m[1],u:'/'+repo+'/issues/'+m[1],k:'Issue / PR'});
293+ shown=list;if(sel>=shown.length)sel=Math.max(0,shown.length-1);
294+ ul.replaceChildren(...shown.map((x,i)=>{const li=d.createElement('li');if(i===sel)li.className='sel';const a=d.createElement('a');a.href=x.u;a.textContent=x.t;const k=d.createElement('span');k.textContent=x.k;a.append(k);li.append(a);return li}));
295+ const s=$('li.sel',ul);s&&s.scrollIntoView({block:'nearest'})}
296+function openPalette(){if(!dlg)return;if(!dlg.open)dlg.showModal();inp.value='';sel=0;load();draw();inp.focus()}
297+if(dlg){inp.addEventListener('input',()=>{sel=0;draw()});
298+ inp.addEventListener('keydown',e=>{if(e.key==='ArrowDown'){e.preventDefault();sel=Math.min(sel+1,shown.length-1);draw()}else if(e.key==='ArrowUp'){e.preventDefault();sel=Math.max(sel-1,0);draw()}else if(e.key==='Enter'){e.preventDefault();const x=shown[sel];if(x)location.href=x.u}});
299+ dlg.addEventListener('click',e=>{if(e.target===dlg)dlg.close()})}
300+// Shortcuts
301+let g=0;
302+d.addEventListener('keydown',e=>{
303+ if((e.metaKey||e.ctrlKey)&&!e.altKey&&e.key.toLowerCase()==='k'){e.preventDefault();openPalette();return}
304+ const t=e.target;if(t&&t.closest&&t.closest('input,textarea,select,[contenteditable],dialog'))return;
305+ if(e.metaKey||e.ctrlKey||e.altKey)return;
306+ if(g&&Date.now()-g<1500){g=0;const m={c:'',i:'/issues',p:'/pulls',a:'/actions',s:'/settings'}[e.key];if(repo&&m!==undefined){e.preventDefault();location.href='/'+repo+m}else if(e.key==='d'){location.href='/'}else if(e.key==='n'){location.href='/notifications'}return}
307+ if(e.key==='g'){g=Date.now();return}
308+ if(e.key==='t'&&repo){e.preventDefault();openPalette();return}
309+ if(e.key==='/'){const s=$('input[type=search]');if(s){e.preventDefault();s.focus()}}
310+});
311+// Live updates
312+const live=b.dataset.live,mode=b.dataset.liveMode||'reload';
313+if(live&&repo&&'WebSocket' in window){let ws,tries=0,timer=null,lastSeq=Number(b.dataset.lastSeq||'-1'),opened=false;
314+ function connect(){ws=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/'+repo+'/live?topics='+encodeURIComponent(live));
315+ ws.onopen=()=>{if(opened){if(mode==='log')location.reload();else refresh()}opened=true;tries=0};
316+ ws.onmessage=e=>{if(e.data==='pong')return;let m;try{m=JSON.parse(e.data)}catch(_){return}handle(m)};
317+ ws.onclose=()=>{setTimeout(connect,Math.min(30000,1000*Math.pow(2,tries++)))}}
318+ setInterval(()=>{if(ws&&ws.readyState===1)ws.send('ping')},25000);
319+ function handle(m){const a=m.data&&m.data.action;
320+ if(mode==='log'){if(!String(m.topic).startsWith('job:'))return;
321+ if(a==='logs'){append(m.data.lines||[]);return}
322+ if(a==='step'&&m.data.step){stepState(m.data.step);return}
323+ if(a==='completed'){setTimeout(()=>location.reload(),700)}return}
324+ clearTimeout(timer);timer=setTimeout(refresh,350)}
325+ async function refresh(){const frags=$$('[data-fragment]');if(!frags.length){location.reload();return}
326+ try{const r=await fetch(location.href,{credentials:'same-origin'});if(!r.ok)return;const doc=new DOMParser().parseFromString(await r.text(),'text/html');
327+ for(const f of frags){if(f.contains(d.activeElement)&&d.activeElement!==b)continue;const n=doc.querySelector('[data-fragment="'+f.dataset.fragment+'"]');if(n)f.replaceWith(d.importNode(n,true))}}catch(_){}}
328+ function clean(s){return s.replace(/\\x1b\\[[0-9;?]*[A-Za-z]/g,'')}
329+ function section(step){let s=$('.log [data-step="'+step+'"]');if(!s){const log=$('.log');if(!log)return null;s=d.createElement('details');s.open=true;s.dataset.step=step;
330+ const sm=d.createElement('summary');sm.innerHTML='<span class="st spin run"></span>';sm.append(step<0?'Set up job':'Step '+(step+1));s.append(sm,d.createElement('pre'));log.append(s)}
331+ return $('pre',s)}
332+ function append(lines){const follow=(innerHeight+scrollY)>=d.body.scrollHeight-80;
333+ for(const l of lines){if(l.seq<=lastSeq)continue;lastSeq=l.seq;const pre=section(l.step);if(!pre)continue;const sp=d.createElement('span');const t=clean(l.text);
334+ if(t.startsWith('::group::')){sp.className='grp';sp.textContent='\\u25be '+t.slice(9)}else if(/^##\\[error\\]|^::error/.test(t)){sp.className='err';sp.textContent=t}else if(/^::warning/.test(t)){sp.className='wrn';sp.textContent=t}else sp.textContent=t;
335+ pre.append(sp);const det=pre.parentElement;if(det&&!det.open)det.open=true}
336+ if(follow)scrollTo(0,d.body.scrollHeight)}
337+ function stepState(s){if(!$('.log [data-step="'+s.index+'"]'))section(s.index);const sec=$('.log [data-step="'+s.index+'"]');if(!sec)return;const sm=$('summary',sec);if(!sm)return;const icon=$('.st',sm);
338+ if(s.status==='completed'&&icon){icon.className='st '+(s.conclusion==='success'?'ok':s.conclusion==='failure'?'bad':'faint');icon.textContent=s.conclusion==='success'?'\\u2713':s.conclusion==='failure'?'\\u2715':'\\u2212'}
339+ if(s.name){const label=[...sm.childNodes].find(n=>n.nodeType===3);if(label)label.textContent=s.name}}
340+ connect()}
341+})();`;
342+
343+/** Inline SVG icons (16×16, stroke-based). Static strings only. */
344+const I = (body: string, extra = "") => `<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"${extra}>${body}</svg>`;
345+
346+export const ICONS = {
347+ logo: `<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M6 8.5v7M8.3 7.2c4 1 7.4 2.2 7.6 3.3M8.3 16.8c4-1 7.4-2.2 7.6-3.3"/></svg>`,
348+ success: I('<circle cx="8" cy="8" r="6.5"/><path d="M5.2 8.2l1.9 1.9 3.7-4"/>'),
349+ failure: I('<circle cx="8" cy="8" r="6.5"/><path d="M5.8 5.8l4.4 4.4M10.2 5.8l-4.4 4.4"/>'),
350+ running: I('<circle cx="8" cy="8" r="6.5" opacity=".25"/><path d="M8 1.5a6.5 6.5 0 0 1 6.5 6.5"/>'),
351+ queued: I('<circle cx="8" cy="8" r="6.5"/><circle cx="8" cy="8" r="1.6" fill="currentColor" stroke="none"/>'),
352+ cancelled: I('<circle cx="8" cy="8" r="6.5"/><path d="M3.4 12.6l9.2-9.2"/>'),
353+ skipped: I('<circle cx="8" cy="8" r="6.5"/><path d="M5 8h6"/>'),
354+ neutral: I('<circle cx="8" cy="8" r="6.5"/><path d="M5 8h6"/>'),
355+ issueOpen: I('<circle cx="8" cy="8" r="6.5"/><circle cx="8" cy="8" r="1.4" fill="currentColor"/>'),
356+ issueClosed: I('<circle cx="8" cy="8" r="6.5"/><path d="M5.2 8.2l1.9 1.9 3.7-4"/>'),
357+ pr: I('<circle cx="4" cy="3.5" r="1.8"/><circle cx="4" cy="12.5" r="1.8"/><circle cx="12" cy="12.5" r="1.8"/><path d="M4 5.3v5.4M12 10.7V6.5a2 2 0 0 0-2-2H7.5M9 3l-1.5 1.5L9 6"/>'),
358+ prMerged: I('<circle cx="4" cy="3.5" r="1.8"/><circle cx="4" cy="12.5" r="1.8"/><circle cx="12" cy="8" r="1.8"/><path d="M4 5.3v5.4M4 5.3c0 2 1.5 2.7 6.2 2.7"/>'),
359+ prClosed: I('<circle cx="4" cy="3.5" r="1.8"/><circle cx="4" cy="12.5" r="1.8"/><circle cx="12" cy="12.5" r="1.8"/><path d="M4 5.3v5.4M12 10.7V8M10.3 2.3l3.4 3.4M13.7 2.3l-3.4 3.4"/>'),
360+ dir: I('<path d="M1.8 3.5h4.3l1.5 1.6h6.6v7.4H1.8z" fill="currentColor" fill-opacity=".15"/>'),
361+ file: I('<path d="M3.5 1.8h5.6l3.4 3.4v9H3.5z"/><path d="M9 1.8v3.5h3.5"/>'),
362+ submodule: I('<rect x="2" y="3" width="12" height="10" rx="1.5"/><path d="M5 8h6"/>'),
363+ branch: I('<circle cx="4.5" cy="3.5" r="1.7"/><circle cx="4.5" cy="12.5" r="1.7"/><circle cx="11.5" cy="5" r="1.7"/><path d="M4.5 5.2v5.6M11.5 6.7c0 3-7 2-7 4.1"/>'),
364+ tag: I('<path d="M2 2.5h5.5l6.5 6.5-5 5-6.5-6.5z"/><circle cx="5" cy="5.5" r="1" fill="currentColor"/>'),
365+ commit: I('<circle cx="8" cy="8" r="2.5"/><path d="M1 8h4.5M10.5 8H15"/>'),
366+ lock: I('<rect x="3" y="7" width="10" height="7" rx="1.5"/><path d="M5.5 7V5a2.5 2.5 0 0 1 5 0v2"/>'),
367+ globe: I('<circle cx="8" cy="8" r="6.5"/><path d="M1.5 8h13M8 1.5c2 2.2 2 10.8 0 13M8 1.5c-2 2.2-2 10.8 0 13"/>'),
368+ search: I('<circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5l3.5 3.5"/>'),
369+ bell: I('<path d="M4 11V7a4 4 0 0 1 8 0v4l1.5 1.5h-11zM6.5 14h3"/>'),
370+ plus: I('<path d="M8 3v10M3 8h10"/>'),
371+ gear: I('<circle cx="8" cy="8" r="2.2"/><path d="M8 1.5v2M8 12.5v2M1.5 8h2M12.5 8h2M3.4 3.4l1.4 1.4M11.2 11.2l1.4 1.4M3.4 12.6l1.4-1.4M11.2 4.8l1.4-1.4"/>'),
372+ play: I('<path d="M5 3.5v9l7-4.5z"/>'),
373+ code: I('<path d="M5.5 4.5L2 8l3.5 3.5M10.5 4.5L14 8l-3.5 3.5"/>'),
374+ comment: I('<path d="M2.5 3h11v7.5H7L4 13v-2.5H2.5z"/>'),
375+ eye: I('<path d="M1.5 8s2.5-4.5 6.5-4.5S14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8z"/><circle cx="8" cy="8" r="2"/>'),
376+ bot: I('<rect x="2.5" y="5" width="11" height="8" rx="2"/><path d="M8 2.5V5M5.5 9h.01M10.5 9h.01"/>'),
377+ history: I('<path d="M2 8a6 6 0 1 0 1.8-4.3M2 2.5v3h3"/><path d="M8 5v3l2 1.5"/>'),
378+ copy: I('<rect x="5" y="5" width="8.5" height="8.5" rx="1.5"/><path d="M3 10.5H2.5v-8h8V3"/>'),
379+ download: I('<path d="M8 2v8M4.5 7L8 10.5 11.5 7M2.5 13.5h11"/>'),
380+ edit: I('<path d="M10.5 2.5l3 3-8 8H2.5v-3z"/>'),
381+ x: I('<path d="M4 4l8 8M12 4l-8 8"/>'),
382+ check: I('<path d="M3 8.5l3 3 7-7"/>'),
383+ people: I('<circle cx="6" cy="5.5" r="2.3"/><path d="M1.8 13.5c0-2.4 1.9-4 4.2-4s4.2 1.6 4.2 4"/><path d="M10.5 3.4a2.3 2.3 0 0 1 0 4.4M12 9.7c1.3.5 2.2 1.9 2.2 3.8"/>'),
384+ key: I('<circle cx="5" cy="11" r="3"/><path d="M7.2 8.8L13.5 2.5M11 5l2 2"/>'),
385+ shield: I('<path d="M8 1.5l5.5 2v4c0 3.5-2.4 6-5.5 7-3.1-1-5.5-3.5-5.5-7v-4z"/>'),
386+ repo: I('<path d="M3 2.5h9.5v9H4.5A1.5 1.5 0 0 0 3 13z"/><path d="M3 13a1.5 1.5 0 0 0 1.5 1.5h8v-3"/>'),
387+ org: I('<rect x="2.5" y="2" width="11" height="12.5" rx="1"/><path d="M5.5 5h1M9.5 5h1M5.5 8h1M9.5 8h1M7 14.5V11.5h2v3"/>'),
388+ zap: I('<path d="M9 1.5L3 9h4.5L7 14.5 13 7H8.5z"/>'),
389+} as const;
390+
391+export type IconName = keyof typeof ICONS;
addedapps/diffinery/src/web/auth.tsx+424−0View file
@@ -1,0 +1,424 @@
1+/** @jsxImportSource hono/jsx */
2+import {
3+ generateAuthenticationOptions,
4+ generateRegistrationOptions,
5+ verifyAuthenticationResponse,
6+ verifyRegistrationResponse,
7+ type AuthenticationResponseJSON,
8+ type AuthenticatorTransport,
9+ type RegistrationResponseJSON,
10+} from "@simplewebauthn/server";
11+import { deleteCookie, getCookie, setCookie } from "hono/cookie";
12+import { raw } from "hono/html";
13+import type { FC, PropsWithChildren } from "hono/jsx";
14+import { accessToken, type AppContext } from "../auth/principal.js";
15+import { Auth, CHALLENGE_COOKIE, SESSION_COOKIE, SESSION_DAYS, type PasskeyRow, type Subject } from "../auth/sessions.js";
16+import type { Account } from "../lib/directory.js";
17+import { verifyAccessJwt } from "../lib/jwt.js";
18+import { HttpError, b64url, fromB64url, isValidLogin, newId } from "../lib/util.js";
19+import { ICONS } from "./assets.js";
20+import { flash, post, prm, render, statusOf, type WebApp } from "./ui.js";
21+
22+/**
23+ * Passkey (WebAuthn) sign-in. Browsers sign in with a passkey and get a
24+ * session cookie. New people create their passkey from an invite link, and
25+ * admins can issue one-time sign-in links to add a passkey to an existing
26+ * account. Cloudflare Access, when configured, protects only /auth/access and
27+ * serves as an email-based recovery path.
28+ */
29+
30+const CEREMONY_TIMEOUT = 120_000;
31+
32+/** Only same-site relative paths are allowed as post-sign-in destinations. */
33+export function safeNext(next: unknown): string {
34+ return typeof next === "string" && next.startsWith("/") && !next.startsWith("//") && !next.startsWith("/\\") ? next : "/";
35+}
36+
37+const str = (v: unknown): string => (typeof v === "string" ? v : "");
38+
39+/** A login suggestion from an email address. */
40+export function loginFromEmail(email: string): string {
41+ return (email.split("@")[0] ?? "").replace(/[^A-Za-z0-9-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 39) || "user";
42+}
43+
44+/** A friendly default name for a new passkey, from the browser's user agent. */
45+export function deviceLabel(ua: string | undefined): string {
46+ const u = ua ?? "";
47+ const os = /iPhone/.test(u) ? "iPhone" : /iPad/.test(u) ? "iPad" : /Android/.test(u) ? "Android" : /Mac OS X|Macintosh/.test(u) ? "Mac" : /Windows/.test(u) ? "Windows" : /CrOS/.test(u) ? "ChromeOS" : /Linux/.test(u) ? "Linux" : "";
48+ const browser = /Edg\//.test(u) ? "Edge" : /Firefox\//.test(u) ? "Firefox" : /Chrome\//.test(u) ? "Chrome" : /Safari\//.test(u) ? "Safari" : "";
49+ return [os, browser].filter(Boolean).join(" · ") || "Passkey";
50+}
51+
52+async function startSession(c: AppContext, auth: Auth, accountId: string, method: "passkey" | "access" | "link", passkeyId: string | null): Promise<void> {
53+ const old = getCookie(c, SESSION_COOKIE);
54+ if (old) await auth.endSession(old);
55+ const token = await auth.createSession(accountId, method, passkeyId, c.req.header("user-agent") ?? null);
56+ setCookie(c, SESSION_COOKIE, token, { path: "/", httpOnly: true, secure: true, sameSite: "Lax", maxAge: SESSION_DAYS * 86400 });
57+}
58+
59+function setChallenge(c: AppContext, token: string): void {
60+ setCookie(c, CHALLENGE_COOKIE, token, { path: "/", httpOnly: true, secure: true, sameSite: "Strict", maxAge: 300 });
61+}
62+
63+function takeChallengeCookie(c: AppContext): string | undefined {
64+ const v = getCookie(c, CHALLENGE_COOKIE);
65+ deleteCookie(c, CHALLENGE_COOKIE, { path: "/", secure: true });
66+ return v;
67+}
68+
69+/** JSON endpoint for the browser's passkey script: JSON in, JSON out, JSON errors. */
70+function jsonPost(web: WebApp, path: string, handler: (c: AppContext, body: Record<string, unknown>) => Promise<Response>): void {
71+ web.post(path, async (c) => {
72+ try {
73+ // A JSON content type forces a CORS preflight, so other sites cannot drive these endpoints.
74+ if (!/^application\/json\b/i.test(c.req.header("content-type") ?? "")) throw new HttpError(415, "expected JSON");
75+ const body = await c.req.json<Record<string, unknown>>().catch(() => {
76+ throw new HttpError(400, "invalid JSON");
77+ });
78+ if (!body || typeof body !== "object") throw new HttpError(400, "invalid JSON");
79+ return await handler(c, body);
80+ } catch (e) {
81+ const s = statusOf(e);
82+ if (s.status >= 500) console.error(e);
83+ return c.json({ message: s.status >= 500 ? "Something went wrong. Please try again." : s.message }, s.status as 400);
84+ }
85+ });
86+}
87+
88+const transportsOf = (k: PasskeyRow): AuthenticatorTransport[] | undefined => (k.transports ? (JSON.parse(k.transports) as AuthenticatorTransport[]) : undefined);
89+
90+// ---------------------------------------------------------------- pages
91+
92+const KeyMark: FC = () => (
93+ <div class="authmark" aria-hidden="true">
94+ {raw(ICONS.key)}
95+ </div>
96+);
97+
98+/** Error slot that the passkey script fills in. */
99+const PasskeyMessage: FC = () => <div class="flash error" data-passkey-msg role="alert" hidden></div>;
100+
101+const AuthCard: FC<PropsWithChildren<{ title: string }>> = ({ title, children }) => (
102+ <div class="authcard box">
103+ <div class="body">
104+ <KeyMark />
105+ <h1>{title}</h1>
106+ {children}
107+ </div>
108+ </div>
109+);
110+
111+function accessConfigured(c: AppContext): boolean {
112+ return !!(c.env.ACCESS_TEAM_DOMAIN && c.env.ACCESS_AUD);
113+}
114+
115+/** Invite landing for someone without an account: create a passkey and join in one step. */
116+export async function invitePasskeyPage(c: AppContext, token: string): Promise<Response> {
117+ const auth = new Auth(c.env);
118+ const inv = await auth.invite(token);
119+ if (!inv) throw new HttpError(404, "this invite is invalid, already used, or expired");
120+ const exists = inv.email ? await c.get("dir").accountByEmail(inv.email) : null;
121+ const next = `/invite/${encodeURIComponent(token)}`;
122+ if (!inv.email || exists) {
123+ return render(
124+ c,
125+ { title: "Accept invite" },
126+ <AuthCard title="You have been invited">
127+ <p class="muted">
128+ {inv.email ? (
129+ <>
130+ <b>{inv.email}</b> already has a Diffinery account. Sign in with its passkey, and this page will let you accept the invite.
131+ </>
132+ ) : (
133+ "Sign in to accept this invite."
134+ )}
135+ </p>
136+ <a class="btn primary block" href={`/login?next=${encodeURIComponent(next)}`}>
137+ Sign in
138+ </a>
139+ </AuthCard>,
140+ );
141+ }
142+ return render(
143+ c,
144+ { title: "Join Diffinery" },
145+ <AuthCard title="Join Diffinery">
146+ <p class="muted">
147+ You were invited as <b>{inv.email}</b>. Pick a username, then create a passkey. Your device keeps it and unlocks it with your fingerprint, face or screen lock, so there is no password to remember.
148+ </p>
149+ <form data-passkey="register" data-next="/">
150+ <input type="hidden" name="invite" value={token} />
151+ <div class="field">
152+ <label for="login">Username</label>
153+ <input id="login" name="login" type="text" value={loginFromEmail(inv.email)} required pattern="[A-Za-z0-9][A-Za-z0-9_\-]{0,38}" autocomplete="username" />
154+ <div class="hint">Your repositories live at /{"{username}"}/…</div>
155+ </div>
156+ <div class="field">
157+ <label for="name">Display name</label>
158+ <input id="name" name="name" type="text" maxlength={100} autocomplete="name" />
159+ </div>
160+ <PasskeyMessage />
161+ <button class="btn primary block">Create a passkey and join</button>
162+ </form>
163+ </AuthCard>,
164+ );
165+}
166+
167+const PasskeyUnsupported: FC = () => (
168+ <noscript>
169+ <p class="flash error">Passkeys need JavaScript.</p>
170+ </noscript>
171+);
172+
173+export function registerAuth(web: WebApp): void {
174+ web.get("/login", async (c) => {
175+ const next = safeNext(c.req.query("next"));
176+ const p = c.get("principal");
177+ if (p.type === "account" && p.via !== "dev") return c.redirect(next, 302);
178+ return render(
179+ c,
180+ { title: "Sign in" },
181+ <AuthCard title="Sign in to Diffinery">
182+ <p class="muted">Use the passkey saved on this device, your phone or your password manager.</p>
183+ <PasskeyUnsupported />
184+ <form data-passkey="login" data-next={next}>
185+ <PasskeyMessage />
186+ <button class="btn primary block">Sign in with a passkey</button>
187+ </form>
188+ <div class="authfoot muted small">
189+ <p>New here? Diffinery is invite-only. Open the invite link someone sent you to create your passkey.</p>
190+ {accessConfigured(c) ? (
191+ <p>
192+ Lost your passkey? <a href={`/auth/access?next=${encodeURIComponent(next)}`}>Recover your account by email</a>.
193+ </p>
194+ ) : (
195+ <p>Lost your passkey? Ask an administrator for a sign-in link.</p>
196+ )}
197+ </div>
198+ </AuthCard>,
199+ );
200+ });
201+
202+ // Admin-issued sign-in link: add a passkey to an existing account.
203+ web.get("/signin/:token", async (c) => {
204+ const token = prm(c, "token");
205+ const link = await new Auth(c.env).signinLink(token);
206+ if (!link) throw new HttpError(404, "this sign-in link is invalid, already used, or expired");
207+ return render(
208+ c,
209+ { title: "Add a passkey" },
210+ <AuthCard title="Add a passkey">
211+ <p class="muted">
212+ This link lets you add a passkey to <b>{link.account.login}</b>
213+ {link.account.email ? ` (${link.account.email})` : ""}. It works once. After that, sign in with the passkey.
214+ </p>
215+ <form data-passkey="register" data-next="/settings/security">
216+ <input type="hidden" name="link" value={token} />
217+ <div class="field">
218+ <label for="label">Name for this passkey</label>
219+ <input id="label" name="label" type="text" maxlength={100} placeholder={deviceLabel(c.req.header("user-agent"))} />
220+ </div>
221+ <PasskeyMessage />
222+ <button class="btn primary block">Create a passkey</button>
223+ </form>
224+ </AuthCard>,
225+ );
226+ });
227+
228+ // ------------------------------------------------------------ sign in
229+
230+ jsonPost(web, "/auth/passkey/login/options", async (c) => {
231+ const auth = new Auth(c.env);
232+ const rp = auth.rp();
233+ // No allowCredentials: the browser offers any passkey it holds for this site (discoverable credentials).
234+ const opts = await generateAuthenticationOptions({ rpID: rp.id, userVerification: "required", timeout: CEREMONY_TIMEOUT });
235+ setChallenge(c, await auth.saveChallenge("login", opts.challenge, { kind: "login" }));
236+ return c.json(opts);
237+ });
238+
239+ jsonPost(web, "/auth/passkey/login/verify", async (c, body) => {
240+ const auth = new Auth(c.env);
241+ const dir = c.get("dir");
242+ const rp = auth.rp();
243+ const ch = await auth.takeChallenge(takeChallengeCookie(c), "login");
244+ if (!ch) throw new HttpError(400, "This sign-in attempt expired. Please try again.");
245+ const response = body.response as AuthenticationResponseJSON | undefined;
246+ if (!response || typeof response.id !== "string") throw new HttpError(400, "missing passkey response");
247+ const key = await auth.passkey(response.id);
248+ if (!key) throw new HttpError(401, "This passkey is not registered with Diffinery. It may have been removed.");
249+ const result = await verifyAuthenticationResponse({
250+ response,
251+ expectedChallenge: ch.challenge,
252+ expectedOrigin: rp.origin,
253+ expectedRPID: rp.id,
254+ requireUserVerification: true,
255+ credential: { id: key.id, publicKey: new Uint8Array(fromB64url(key.public_key)), counter: key.counter, transports: transportsOf(key) },
256+ }).catch((e: Error) => {
257+ throw new HttpError(401, `Passkey check failed: ${e.message}`);
258+ });
259+ if (!result.verified) throw new HttpError(401, "Passkey check failed.");
260+ const account = await dir.account(key.account_id);
261+ if (!account) throw new HttpError(401, "This passkey's account no longer exists.");
262+ if (account.status === "disabled") throw new HttpError(403, "Your account is disabled.");
263+ await auth.passkeyUsed(key.id, result.authenticationInfo.newCounter, result.authenticationInfo.credentialBackedUp);
264+ await startSession(c, auth, account.id, "passkey", key.id);
265+ c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "session.create", detail: { method: "passkey", passkey: key.name } }));
266+ return c.json({ redirect: safeNext(body.next) });
267+ });
268+
269+ // ------------------------------------------------------------ register a passkey
270+
271+ jsonPost(web, "/auth/passkey/register/options", async (c, body) => {
272+ const auth = new Auth(c.env);
273+ const dir = c.get("dir");
274+ const rp = auth.rp();
275+ let subject: Subject;
276+ let user: { id: string; name: string; displayName: string };
277+ let existing: PasskeyRow[] = [];
278+ const inviteToken = str(body.invite);
279+ const linkToken = str(body.link);
280+ if (inviteToken) {
281+ const inv = await auth.invite(inviteToken);
282+ if (!inv) throw new HttpError(404, "This invite is invalid, already used, or expired.");
283+ if (!inv.email) throw new HttpError(422, "This invite has no email address. Sign in first, then open it again.");
284+ if (await dir.accountByEmail(inv.email)) throw new HttpError(409, `${inv.email} already has an account. Sign in with its passkey, then open this invite again.`);
285+ const login = str(body.login).trim() || loginFromEmail(inv.email);
286+ if (!isValidLogin(login) || login.length > 39) throw new HttpError(422, "Usernames use letters, numbers and dashes (up to 39), and some names are reserved.");
287+ if (await dir.accountByLogin(login)) throw new HttpError(409, `The username ${login} is taken. Pick another one.`);
288+ const name = str(body.name).trim().slice(0, 100);
289+ const id = newId();
290+ subject = { kind: "invite", inviteHash: inv.hash, email: inv.email, login, name, accountId: id };
291+ user = { id, name: login, displayName: name || login };
292+ } else if (linkToken) {
293+ const link = await auth.signinLink(linkToken);
294+ if (!link) throw new HttpError(404, "This sign-in link is invalid, already used, or expired.");
295+ if (link.account.status === "disabled") throw new HttpError(403, "This account is disabled.");
296+ subject = { kind: "link", linkHash: link.hash, accountId: link.account.id };
297+ user = { id: link.account.id, name: link.account.login, displayName: link.account.name ?? link.account.login };
298+ existing = await auth.passkeys(link.account.id);
299+ } else {
300+ const me = browserAccount(c);
301+ subject = { kind: "account", accountId: me.id };
302+ user = { id: me.id, name: me.login, displayName: me.name ?? me.login };
303+ existing = await auth.passkeys(me.id);
304+ }
305+ const opts = await generateRegistrationOptions({
306+ rpName: rp.name,
307+ rpID: rp.id,
308+ userName: user.name,
309+ userDisplayName: user.displayName,
310+ userID: new Uint8Array(new TextEncoder().encode(user.id)),
311+ attestationType: "none",
312+ excludeCredentials: existing.map((k) => ({ id: k.id, transports: transportsOf(k) })),
313+ authenticatorSelection: { residentKey: "required", userVerification: "required" },
314+ timeout: CEREMONY_TIMEOUT,
315+ });
316+ setChallenge(c, await auth.saveChallenge("register", opts.challenge, subject));
317+ return c.json(opts);
318+ });
319+
320+ jsonPost(web, "/auth/passkey/register/verify", async (c, body) => {
321+ const auth = new Auth(c.env);
322+ const dir = c.get("dir");
323+ const rp = auth.rp();
324+ const ch = await auth.takeChallenge(takeChallengeCookie(c), "register");
325+ if (!ch) throw new HttpError(400, "This attempt expired. Please try again.");
326+ const response = body.response as RegistrationResponseJSON | undefined;
327+ if (!response || typeof response.id !== "string") throw new HttpError(400, "missing passkey response");
328+ const result = await verifyRegistrationResponse({ response, expectedChallenge: ch.challenge, expectedOrigin: rp.origin, expectedRPID: rp.id, requireUserVerification: true }).catch((e: Error) => {
329+ throw new HttpError(400, `The passkey could not be verified: ${e.message}`);
330+ });
331+ if (!result.verified) throw new HttpError(400, "The passkey could not be verified.");
332+ const info = result.registrationInfo;
333+ if (await auth.passkey(info.credential.id)) throw new HttpError(409, "This passkey is already registered.");
334+ const label = str(body.label).trim() || deviceLabel(c.req.header("user-agent"));
335+ const key = {
336+ id: info.credential.id,
337+ public_key: b64url(info.credential.publicKey),
338+ counter: info.credential.counter,
339+ transports: info.credential.transports?.length ? JSON.stringify(info.credential.transports) : null,
340+ device_type: info.credentialDeviceType,
341+ backed_up: info.credentialBackedUp ? 1 : 0,
342+ name: label,
343+ };
344+ const s = ch.subject;
345+ let account: Account;
346+ let redirect: string;
347+ if (s.kind === "account") {
348+ const me = browserAccount(c);
349+ if (me.id !== s.accountId) throw new HttpError(403, "You signed in as someone else while adding this passkey.");
350+ await auth.addPasskey({ ...key, account_id: me.id });
351+ c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "passkey.add", detail: { name: label } }));
352+ flash(c, "notice", `Passkey "${label}" added.`);
353+ return c.json({ redirect: "/settings/security" });
354+ } else if (s.kind === "invite") {
355+ // Re-check: the invite or the username may have been taken while the browser was busy.
356+ if (!(await auth.inviteByHash(s.inviteHash))) throw new HttpError(404, "This invite was used or expired in the meantime.");
357+ if (await dir.accountByEmail(s.email)) throw new HttpError(409, `${s.email} already has an account.`);
358+ if (await dir.accountByLogin(s.login)) throw new HttpError(409, `The username ${s.login} was just taken. Pick another one.`);
359+ // Creating the account accepts every open invite for this email, including this one.
360+ account = await dir.userForEmail(s.email, { login: s.login, name: s.name || undefined, id: s.accountId });
361+ await auth.addPasskey({ ...key, account_id: account.id });
362+ c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "invite.accept", detail: { method: "passkey" } }));
363+ flash(c, "notice", `Welcome to Diffinery, ${account.login}. You can sign in with this passkey from now on.`);
364+ redirect = "/";
365+ } else if (s.kind === "link") {
366+ if (!(await auth.useSigninLink(s.linkHash))) throw new HttpError(404, "This sign-in link was used or expired in the meantime.");
367+ const found = await dir.account(s.accountId);
368+ if (!found || found.status === "disabled") throw new HttpError(403, "This account is disabled.");
369+ account = found;
370+ await auth.addPasskey({ ...key, account_id: account.id });
371+ c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "passkey.add", detail: { name: label, via: "signin-link" } }));
372+ flash(c, "notice", `Passkey "${label}" added. Use it to sign in from now on.`);
373+ redirect = "/settings/security";
374+ } else {
375+ throw new HttpError(400, "unexpected sign-in attempt");
376+ }
377+ await startSession(c, auth, account.id, s.kind === "link" ? "link" : "passkey", key.id);
378+ return c.json({ redirect });
379+ });
380+
381+ // ------------------------------------------------------------ sign out
382+
383+ post(web, "/auth/logout", async (c) => {
384+ const token = getCookie(c, SESSION_COOKIE);
385+ if (token) await new Auth(c.env).endSession(token);
386+ deleteCookie(c, SESSION_COOKIE, { path: "/", secure: true });
387+ return c.redirect("/login", 303);
388+ });
389+
390+ // ------------------------------------------------------------ recovery through Cloudflare Access
391+
392+ // Cloudflare Access protects only this path. It verifies the person's email,
393+ // and this handler turns that into a session so they can add a new passkey.
394+ web.get("/auth/access", async (c) => {
395+ if (!accessConfigured(c)) throw new HttpError(404, "Account recovery by email is not set up on this instance. Ask an administrator for a sign-in link.");
396+ const jwt = accessToken(c.req.raw);
397+ if (!jwt) throw new HttpError(401, "Cloudflare Access did not sign this request. Open this page again to start the email check.");
398+ const claims = await verifyAccessJwt(jwt, c.env.ACCESS_TEAM_DOMAIN, c.env.ACCESS_AUD).catch((e: Error) => {
399+ throw new HttpError(401, `Cloudflare Access sign-in was rejected: ${e.message}`);
400+ });
401+ const email = typeof claims.email === "string" ? claims.email : null;
402+ if (!email) throw new HttpError(401, "Cloudflare Access did not provide an email address.");
403+ const dir = c.get("dir");
404+ const account = await dir.userForEmail(email, { name: typeof claims.name === "string" ? claims.name : undefined });
405+ if (account.status === "disabled") throw new HttpError(403, "Your account is disabled.");
406+ const auth = new Auth(c.env);
407+ await startSession(c, auth, account.id, "access", null);
408+ c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "session.create", detail: { method: "access" } }));
409+ if (account.status === "pending") return c.redirect("/", 302);
410+ const keys = await auth.passkeys(account.id);
411+ if (keys.length) return c.redirect(safeNext(c.req.query("next")), 302);
412+ flash(c, "notice", "You're signed in. Add a passkey now so you can sign in directly next time.");
413+ return c.redirect("/settings/security", 302);
414+ });
415+}
416+
417+/** The account behind a browser session (or dev sign-in). API tokens cannot manage passkeys. */
418+export function browserAccount(c: AppContext): Account {
419+ const p = c.get("principal");
420+ if (p.type !== "account" || p.via === "token") throw new HttpError(401, "Sign in with your passkey first.");
421+ if (p.account.kind !== "user") throw new HttpError(403, "only people can have passkeys");
422+ if (p.account.status === "disabled") throw new HttpError(403, "your account is disabled");
423+ return p.account;
424+}
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/csrf.ts+74−0View file
@@ -1,0 +1,74 @@
1+import { getCookie, setCookie } from "hono/cookie";
2+import type { AppContext } from "../auth/principal.js";
3+import { HttpError, randomToken, safeEqual } from "../lib/util.js";
4+
5+/**
6+ * Double-submit cookie CSRF protection for the web UI. Every rendered page
7+ * ensures a random `dfy_csrf` cookie (HttpOnly, SameSite=Strict, Secure) and
8+ * every form carries the same value in a hidden `_csrf` field.
9+ */
10+export const CSRF_COOKIE = "dfy_csrf";
11+export const CSRF_FIELD = "_csrf";
12+
13+const memo = new WeakMap<Request, string>();
14+const VALID = /^[A-Za-z0-9_-]{20,64}$/;
15+
16+/** The CSRF token for this request; sets the cookie when the browser has none. */
17+export function csrfToken(c: AppContext): string {
18+ const cached = memo.get(c.req.raw);
19+ if (cached) return cached;
20+ let token = getCookie(c, CSRF_COOKIE);
21+ if (!token || !VALID.test(token)) {
22+ token = randomToken(24);
23+ setCookie(c, CSRF_COOKIE, token, { path: "/", httpOnly: true, secure: true, sameSite: "Strict", maxAge: 60 * 60 * 24 * 30 });
24+ }
25+ memo.set(c.req.raw, token);
26+ return token;
27+}
28+
29+export interface FormData {
30+ /** First string value of a field ("" when absent). */
31+ s(name: string): string;
32+ /** All string values of a (repeated) field. */
33+ all(name: string): string[];
34+ /** Checkbox semantics: present and not "false"/"0". */
35+ b(name: string): boolean;
36+ /** Field names present in the form. */
37+ keys(): string[];
38+}
39+
40+/** Parse an urlencoded/multipart form once per request (Hono caches the body). */
41+export async function formData(c: AppContext): Promise<FormData> {
42+ const raw = (await c.req.parseBody({ all: true })) as Record<string, string | File | Array<string | File>>;
43+ const all = (name: string): string[] => {
44+ const v = raw[name];
45+ const list = Array.isArray(v) ? v : v === undefined ? [] : [v];
46+ return list.filter((x): x is string => typeof x === "string");
47+ };
48+ return {
49+ s: (name) => all(name)[0] ?? "",
50+ all,
51+ b: (name) => {
52+ const v = all(name)[0];
53+ return v !== undefined && v !== "" && v !== "false" && v !== "0" && v !== "off";
54+ },
55+ keys: () => Object.keys(raw),
56+ };
57+}
58+
59+/** Reject state-changing requests without a matching token (and cross-origin requests). */
60+export async function verifyCsrf(c: AppContext): Promise<FormData> {
61+ const origin = c.req.header("origin");
62+ if (origin && origin !== "null") {
63+ const self = new URL(c.req.url).origin;
64+ const pub = c.env.PUBLIC_URL ? new URL(c.env.PUBLIC_URL).origin : null;
65+ if (origin !== self && origin !== pub) throw new HttpError(403, "cross-origin form submission rejected");
66+ }
67+ const type = c.req.header("content-type") ?? "";
68+ if (!/^(application\/x-www-form-urlencoded|multipart\/form-data)/i.test(type)) throw new HttpError(415, "expected a form submission");
69+ const form = await formData(c);
70+ const cookie = getCookie(c, CSRF_COOKIE);
71+ const sent = form.s(CSRF_FIELD);
72+ if (!cookie || !sent || !safeEqual(cookie, sent)) throw new HttpError(403, "your session token expired or is missing; reload the page and try again");
73+ return form;
74+}
addedapps/diffinery/src/web/markdown.ts+544−0View file
@@ -1,0 +1,544 @@
1+/**
2+ * A small, safe Markdown renderer for READMEs, issues, pull requests and job
3+ * summaries. All raw HTML in the source is escaped; only http(s), mailto-free
4+ * relative and fragment URLs survive in links and images.
5+ *
6+ * Supported: ATX headings, paragraphs, hard breaks, fenced code, thematic
7+ * breaks, blockquotes, ordered/unordered/task lists (nested), tables, inline
8+ * code, bold, italic, strikethrough, links, images and bare/auto links.
9+ */
10+
11+export interface MarkdownBase {
12+ /** Repository owner and name, for resolving relative links. */
13+ owner: string;
14+ repo: string;
15+ /** Ref (branch, tag or sha) that relative links point at. */
16+ ref: string;
17+ /** Directory of the document inside the repository ("" for the root). */
18+ dir: string;
19+}
20+
21+export interface MarkdownOptions {
22+ base?: MarkdownBase;
23+}
24+
25+const MAX_SOURCE = 512 * 1024;
26+const MAX_DEPTH = 12;
27+const MAX_INLINE = 60_000;
28+const MAX_SCAN = 2000;
29+
30+const ESC: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
31+export const escapeHtml = (s: string): string => s.replace(/[&<>"']/g, (c) => ESC[c]!);
32+
33+const encPath = (p: string): string => p.split("/").map(encodeURIComponent).join("/");
34+
35+export function slugify(text: string): string {
36+ return text
37+ .toLowerCase()
38+ .replace(/<[^>]*>/g, "")
39+ .replace(/[^\p{L}\p{N}\s_-]/gu, "")
40+ .trim()
41+ .replace(/\s+/g, "-")
42+ .slice(0, 80);
43+}
44+
45+/** Normalise "a/./b/../c" style paths; returns null when the path escapes the root. */
46+function normalise(path: string): string | null {
47+ const out: string[] = [];
48+ for (const seg of path.split("/")) {
49+ if (!seg || seg === ".") continue;
50+ if (seg === "..") {
51+ if (!out.length) return null;
52+ out.pop();
53+ } else out.push(seg);
54+ }
55+ return out.join("/");
56+}
57+
58+/**
59+ * Validate and resolve a URL from Markdown source. Returns null for anything
60+ * that is not http(s), a fragment, or a relative path.
61+ */
62+export function safeUrl(url: string, kind: "link" | "image", opts: MarkdownOptions): string | null {
63+ const u = url.trim();
64+ if (!u) return null;
65+ // Browsers ignore control characters and whitespace when parsing schemes.
66+ const probe = u.replace(/[\x00-\x20\x7f]/g, "");
67+ if (/^https?:\/\//i.test(probe)) return u;
68+ if (/^[a-z][a-z0-9+.-]*:/i.test(probe) || probe.startsWith("//") || probe.startsWith("\\\\") || probe.startsWith("/\\")) return null;
69+ if (u.startsWith("#")) return kind === "link" ? `#h-${slugify(decodeSafe(u.slice(1)))}` : null;
70+ const base = opts.base;
71+ if (!base) return u.startsWith("/") ? u : null;
72+ const hashAt = u.search(/[?#]/);
73+ const pathPart = hashAt >= 0 ? u.slice(0, hashAt) : u;
74+ const suffix = hashAt >= 0 ? u.slice(hashAt) : "";
75+ const joined = pathPart.startsWith("/") ? pathPart : `${base.dir ? `${base.dir}/` : ""}${pathPart}`;
76+ const norm = normalise(decodeSafe(joined));
77+ if (norm === null) return null;
78+ const root = `/${base.owner}/${base.repo}`;
79+ if (kind === "image") return `${root}/raw/${encPath(base.ref)}/${encPath(norm)}`;
80+ const isDir = pathPart.endsWith("/") || norm === "";
81+ return `${root}/${isDir ? "tree" : "blob"}/${encPath(base.ref)}${norm ? `/${encPath(norm)}` : ""}${suffix.replace(/[<>"']/g, "")}`;
82+}
83+
84+function decodeSafe(s: string): string {
85+ try {
86+ return decodeURIComponent(s);
87+ } catch {
88+ return s;
89+ }
90+}
91+
92+// ---------------------------------------------------------------- inline
93+
94+function findClosing(src: string, delim: string, from: number, wordy: boolean): number {
95+ let at = from;
96+ const limit = Math.min(src.length, from + MAX_SCAN * 2);
97+ for (;;) {
98+ const idx = src.indexOf(delim, at);
99+ if (idx < 0 || idx >= limit) return -1;
100+ const before = src[idx - 1];
101+ const after = src[idx + delim.length];
102+ if (idx > from && before !== undefined && !/\s/.test(before) && (!wordy || after === undefined || !/[\p{L}\p{N}]/u.test(after))) {
103+ // When closing a single delimiter, skip over a double run ("**") that belongs to nested strong text.
104+ if (delim.length === 1 && after === delim && src[idx + 2] !== delim) {
105+ at = idx + 2;
106+ continue;
107+ }
108+ return idx;
109+ }
110+ at = idx + 1;
111+ }
112+}
113+
114+interface LinkMatch {
115+ text: string;
116+ url: string;
117+ title: string | null;
118+ end: number;
119+}
120+
121+/** Parse `[text](url "title")` starting at the "[" at `i`. */
122+function parseLink(src: string, i: number): LinkMatch | null {
123+ let depth = 0;
124+ let j = i;
125+ const limit = Math.min(src.length, i + MAX_SCAN);
126+ for (; j < limit; j++) {
127+ const ch = src[j];
128+ if (ch === "\\") {
129+ j++;
130+ continue;
131+ }
132+ if (ch === "`") {
133+ const close = src.indexOf("`", j + 1);
134+ if (close > 0 && close < limit) j = close;
135+ continue;
136+ }
137+ if (ch === "[") depth++;
138+ else if (ch === "]") {
139+ depth--;
140+ if (depth === 0) break;
141+ }
142+ }
143+ if (depth !== 0 || src[j] !== "]" || src[j + 1] !== "(") return null;
144+ const text = src.slice(i + 1, j);
145+ let k = j + 2;
146+ while (src[k] === " ") k++;
147+ let url = "";
148+ if (src[k] === "<") {
149+ const close = src.indexOf(">", k);
150+ if (close < 0 || close - k > MAX_SCAN) return null;
151+ url = src.slice(k + 1, close);
152+ k = close + 1;
153+ } else {
154+ let parens = 0;
155+ const start = k;
156+ for (; k < src.length && k - start < MAX_SCAN; k++) {
157+ const ch = src[k]!;
158+ if (ch === "\\") {
159+ k++;
160+ continue;
161+ }
162+ if (/\s/.test(ch)) break;
163+ if (ch === "(") parens++;
164+ else if (ch === ")") {
165+ if (parens === 0) break;
166+ parens--;
167+ }
168+ }
169+ url = src.slice(start, k).replace(/\\([()])/g, "$1");
170+ }
171+ while (src[k] === " ") k++;
172+ let title: string | null = null;
173+ const q = src[k];
174+ if (q === '"' || q === "'" || q === "(") {
175+ const closeCh = q === "(" ? ")" : q;
176+ const close = src.indexOf(closeCh, k + 1);
177+ if (close < 0) return null;
178+ title = src.slice(k + 1, close);
179+ k = close + 1;
180+ while (src[k] === " ") k++;
181+ }
182+ if (src[k] !== ")") return null;
183+ return { text, url, title, end: k + 1 };
184+}
185+
186+const BARE_URL = /^https?:\/\/[^\s<>"'`]*[^\s<>"'`.,:;!?)\]}*_~]/i;
187+
188+function inline(src: string, opts: MarkdownOptions, depth = 0): string {
189+ if (src.length > MAX_INLINE || depth > MAX_DEPTH) return escapeHtml(src);
190+ let out = "";
191+ let i = 0;
192+ const n = src.length;
193+ while (i < n) {
194+ const ch = src[i]!;
195+ // Backslash escapes and hard line breaks.
196+ if (ch === "\\") {
197+ const next = src[i + 1];
198+ if (next === "\n") {
199+ out += "<br>\n";
200+ i += 2;
201+ continue;
202+ }
203+ if (next !== undefined && /[!-/:-@[-`{-~]/.test(next)) {
204+ out += escapeHtml(next);
205+ i += 2;
206+ continue;
207+ }
208+ }
209+ if (ch === "\n") {
210+ if (out.endsWith(" ")) out = `${out.trimEnd()}<br>`;
211+ out += "\n";
212+ i++;
213+ continue;
214+ }
215+ // Code spans.
216+ if (ch === "`") {
217+ let run = 1;
218+ while (src[i + run] === "`") run++;
219+ const fence = "`".repeat(run);
220+ let close = src.indexOf(fence, i + run);
221+ while (close >= 0 && src[close + run] === "`") {
222+ let skip = close;
223+ while (src[skip] === "`") skip++;
224+ close = src.indexOf(fence, skip);
225+ }
226+ if (close >= 0) {
227+ let code = src.slice(i + run, close).replace(/\n/g, " ");
228+ if (code.length > 2 && code.startsWith(" ") && code.endsWith(" ") && code.trim()) code = code.slice(1, -1);
229+ out += `<code>${escapeHtml(code)}</code>`;
230+ i = close + run;
231+ continue;
232+ }
233+ out += fence;
234+ i += run;
235+ continue;
236+ }
237+ // Images.
238+ if (ch === "!" && src[i + 1] === "[") {
239+ const m = parseLink(src, i + 1);
240+ if (m) {
241+ const url = safeUrl(m.url, "image", opts);
242+ const alt = escapeHtml(m.text.replace(/[\\*_`[\]]/g, ""));
243+ if (url) out += `<img src="${escapeHtml(url)}" alt="${alt}"${m.title ? ` title="${escapeHtml(m.title)}"` : ""} loading="lazy" referrerpolicy="no-referrer">`;
244+ else out += alt;
245+ i = m.end;
246+ continue;
247+ }
248+ }
249+ // Links.
250+ if (ch === "[") {
251+ const m = parseLink(src, i);
252+ if (m) {
253+ const url = safeUrl(m.url, "link", opts);
254+ const label = inline(m.text, opts, depth + 1);
255+ if (url) {
256+ const external = /^https?:/i.test(url);
257+ out += `<a href="${escapeHtml(url)}"${m.title ? ` title="${escapeHtml(m.title)}"` : ""}${external ? ' rel="nofollow noopener noreferrer"' : ""}>${label}</a>`;
258+ } else out += label;
259+ i = m.end;
260+ continue;
261+ }
262+ }
263+ // Autolinks <https://…>.
264+ if (ch === "<") {
265+ const m = /^<(https?:\/\/[^\s<>]+)>/i.exec(src.slice(i, i + MAX_SCAN));
266+ if (m) {
267+ const url = escapeHtml(m[1]!);
268+ out += `<a href="${url}" rel="nofollow noopener noreferrer">${url}</a>`;
269+ i += m[0].length;
270+ continue;
271+ }
272+ }
273+ // Bare URLs.
274+ if ((ch === "h" || ch === "H") && (i === 0 || /[\s(]/.test(src[i - 1]!))) {
275+ const m = BARE_URL.exec(src.slice(i, i + MAX_SCAN));
276+ if (m) {
277+ const url = escapeHtml(m[0]);
278+ out += `<a href="${url}" rel="nofollow noopener noreferrer">${url}</a>`;
279+ i += m[0].length;
280+ continue;
281+ }
282+ }
283+ // Strikethrough.
284+ if (ch === "~" && src[i + 1] === "~" && src[i + 2] && !/\s/.test(src[i + 2]!)) {
285+ const close = findClosing(src, "~~", i + 2, false);
286+ if (close > i + 2) {
287+ out += `<del>${inline(src.slice(i + 2, close), opts, depth + 1)}</del>`;
288+ i = close + 2;
289+ continue;
290+ }
291+ }
292+ // Emphasis.
293+ if (ch === "*" || ch === "_") {
294+ let run = 1;
295+ while (src[i + run] === ch) run++;
296+ const wordy = ch === "_";
297+ const prev = src[i - 1];
298+ const nextCh = src[i + run];
299+ const canOpen = nextCh !== undefined && !/\s/.test(nextCh) && !(wordy && prev !== undefined && /[\p{L}\p{N}]/u.test(prev));
300+ let matched = false;
301+ if (canOpen && run <= 3) {
302+ for (const size of run === 3 ? [3, 2, 1] : run === 2 ? [2, 1] : [1]) {
303+ const delim = ch.repeat(size);
304+ const start = i + run;
305+ const close = findClosing(src, delim, start, wordy);
306+ if (close > start) {
307+ const inner = inline(src.slice(start, close), opts, depth + 1);
308+ out += escapeHtml(ch.repeat(run - size));
309+ out += size === 3 ? `<strong><em>${inner}</em></strong>` : size === 2 ? `<strong>${inner}</strong>` : `<em>${inner}</em>`;
310+ i = close + size;
311+ matched = true;
312+ break;
313+ }
314+ }
315+ }
316+ if (matched) continue;
317+ out += escapeHtml(ch.repeat(run));
318+ i += run;
319+ continue;
320+ }
321+ out += ESC[ch] ?? ch;
322+ i++;
323+ }
324+ return out;
325+}
326+
327+// ---------------------------------------------------------------- blocks
328+
329+const FENCE = /^ {0,3}(`{3,}|~{3,})\s*([^`\s]*)[^`]*$/;
330+const HEADING = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$/;
331+const HR = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/;
332+const QUOTE = /^ {0,3}> ?(.*)$/;
333+const LIST_ITEM = /^( {0,12})([*+-]|\d{1,9}[.)])([ \t]+(.*))?$/;
334+const TABLE_SEP = /^ {0,3}\|?[ \t]*:?-+:?[ \t]*(\|[ \t]*:?-+:?[ \t]*)*\|?[ \t]*$/;
335+
336+function splitRow(line: string): string[] {
337+ let s = line.trim();
338+ if (s.startsWith("|")) s = s.slice(1);
339+ if (s.endsWith("|") && !s.endsWith("\\|")) s = s.slice(0, -1);
340+ const cells: string[] = [];
341+ let cur = "";
342+ let inCode = false;
343+ for (let i = 0; i < s.length; i++) {
344+ const ch = s[i]!;
345+ if (ch === "\\" && s[i + 1] === "|") {
346+ cur += "|";
347+ i++;
348+ continue;
349+ }
350+ if (ch === "`") inCode = !inCode;
351+ if (ch === "|" && !inCode) {
352+ cells.push(cur.trim());
353+ cur = "";
354+ continue;
355+ }
356+ cur += ch;
357+ }
358+ cells.push(cur.trim());
359+ return cells;
360+}
361+
362+function startsBlock(line: string, next: string | undefined): boolean {
363+ return (
364+ FENCE.test(line) ||
365+ HEADING.test(line) ||
366+ HR.test(line) ||
367+ QUOTE.test(line) ||
368+ /^ {0,3}([*+-]|1[.)])[ \t]+\S/.test(line) ||
369+ (line.includes("|") && next !== undefined && TABLE_SEP.test(next) && next.includes("-"))
370+ );
371+}
372+
373+function blocks(lines: string[], opts: MarkdownOptions, depth: number, tight = false): string {
374+ if (depth > MAX_DEPTH) return `<p>${escapeHtml(lines.join("\n"))}</p>`;
375+ let i = 0;
376+ const parts: string[] = [];
377+ while (i < lines.length) {
378+ const line = lines[i]!;
379+ if (!line.trim()) {
380+ i++;
381+ continue;
382+ }
383+ // Fenced code.
384+ const fence = FENCE.exec(line);
385+ if (fence) {
386+ const marker = fence[1]!;
387+ const lang = fence[2] ?? "";
388+ const indent = line.length - line.trimStart().length;
389+ const body: string[] = [];
390+ i++;
391+ while (i < lines.length && !new RegExp(`^ {0,3}${marker[0] === "`" ? "`" : "~"}{${marker.length},}\\s*$`).test(lines[i]!)) {
392+ body.push(lines[i]!.replace(new RegExp(`^ {0,${indent}}`), ""));
393+ i++;
394+ }
395+ i++;
396+ const cls = lang && /^[\w+#.-]{1,30}$/.test(lang) ? ` class="language-${escapeHtml(lang)}"` : "";
397+ parts.push(`<pre><code${cls}>${escapeHtml(body.join("\n"))}</code></pre>`);
398+ continue;
399+ }
400+ // Indented code (4 spaces), only outside lists.
401+ if (/^( {4}|\t)/.test(line) && !tight) {
402+ const body: string[] = [];
403+ while (i < lines.length && (/^( {4}|\t)/.test(lines[i]!) || !lines[i]!.trim())) {
404+ body.push(lines[i]!.replace(/^( {4}|\t)/, ""));
405+ i++;
406+ }
407+ while (body.length && !body[body.length - 1]!.trim()) body.pop();
408+ parts.push(`<pre><code>${escapeHtml(body.join("\n"))}</code></pre>`);
409+ continue;
410+ }
411+ const h = HEADING.exec(line);
412+ if (h) {
413+ const level = h[1]!.length;
414+ const text = h[2] ?? "";
415+ const html = inline(text, opts, depth);
416+ const slug = slugify(text);
417+ parts.push(`<h${level} id="h-${escapeHtml(slug)}"><a class="anchor" href="#h-${escapeHtml(slug)}" aria-hidden="true">#</a>${html}</h${level}>`);
418+ i++;
419+ continue;
420+ }
421+ if (HR.test(line)) {
422+ parts.push("<hr>");
423+ i++;
424+ continue;
425+ }
426+ if (QUOTE.test(line)) {
427+ const body: string[] = [];
428+ while (i < lines.length && lines[i]!.trim()) {
429+ const m = QUOTE.exec(lines[i]!);
430+ if (m) body.push(m[1]!);
431+ else if (body.length && !startsBlock(lines[i]!, lines[i + 1])) body.push(lines[i]!);
432+ else break;
433+ i++;
434+ }
435+ parts.push(`<blockquote>${blocks(body, opts, depth + 1)}</blockquote>`);
436+ continue;
437+ }
438+ // Tables.
439+ if (line.includes("|") && i + 1 < lines.length && TABLE_SEP.test(lines[i + 1]!) && lines[i + 1]!.includes("-")) {
440+ const head = splitRow(line);
441+ const aligns = splitRow(lines[i + 1]!).map((c) => (c.startsWith(":") && c.endsWith(":") ? "center" : c.endsWith(":") ? "right" : c.startsWith(":") ? "left" : ""));
442+ i += 2;
443+ const rows: string[][] = [];
444+ while (i < lines.length && lines[i]!.trim() && lines[i]!.includes("|")) {
445+ rows.push(splitRow(lines[i]!));
446+ i++;
447+ }
448+ const cell = (tag: string, text: string, idx: number) => `<${tag}${aligns[idx] ? ` style="text-align:${aligns[idx]}"` : ""}>${inline(text, opts, depth)}</${tag}>`;
449+ let t = `<div class="table-wrap"><table><thead><tr>${head.map((c, idx) => cell("th", c, idx)).join("")}</tr></thead>`;
450+ if (rows.length) t += `<tbody>${rows.map((r) => `<tr>${head.map((_, idx) => cell("td", r[idx] ?? "", idx)).join("")}</tr>`).join("")}</tbody>`;
451+ parts.push(`${t}</table></div>`);
452+ continue;
453+ }
454+ // Lists.
455+ const li = LIST_ITEM.exec(line);
456+ if (li) {
457+ const ordered = /\d/.test(li[2]!);
458+ const baseIndent = li[1]!.length;
459+ const start = ordered ? parseInt(li[2]!, 10) : 1;
460+ const items: string[][] = [];
461+ let loose = false;
462+ while (i < lines.length) {
463+ const m = LIST_ITEM.exec(lines[i]!);
464+ if (!m || m[1]!.length > baseIndent + 1 || m[1]!.length < baseIndent || /\d/.test(m[2]!) !== ordered) break;
465+ const contentIndent = m[1]!.length + m[2]!.length + Math.max(1, Math.min(4, (m[3] ?? " ").length - (m[4] ?? "").length));
466+ const item: string[] = [m[4] ?? ""];
467+ i++;
468+ let sawBlank = false;
469+ while (i < lines.length) {
470+ const l = lines[i]!;
471+ if (!l.trim()) {
472+ sawBlank = true;
473+ item.push("");
474+ i++;
475+ continue;
476+ }
477+ const ind = l.length - l.trimStart().length;
478+ if (ind >= contentIndent || (ind > baseIndent && LIST_ITEM.test(l))) {
479+ item.push(l.slice(Math.min(ind, contentIndent)));
480+ sawBlank = false;
481+ i++;
482+ continue;
483+ }
484+ if (!sawBlank && !startsBlock(l, lines[i + 1]) && !LIST_ITEM.test(l)) {
485+ // Lazy continuation of the item's paragraph.
486+ item.push(l.trim());
487+ i++;
488+ continue;
489+ }
490+ break;
491+ }
492+ while (item.length && !item[item.length - 1]!.trim()) {
493+ item.pop();
494+ if (i < lines.length && LIST_ITEM.test(lines[i]!)) loose = true;
495+ }
496+ if (item.slice(1).some((l, idx) => !l.trim() && item[idx + 2] !== undefined && !/^\s*([*+-]|\d+[.)])\s/.test(item[idx + 2]!))) loose = true;
497+ items.push(item);
498+ }
499+ const rendered = items.map((item) => {
500+ let first = item[0] ?? "";
501+ let task = "";
502+ const tm = /^\[([ xX])\][ \t]+/.exec(first);
503+ if (tm) {
504+ task = `<input type="checkbox" disabled${tm[1] !== " " ? " checked" : ""}> `;
505+ first = first.slice(tm[0].length);
506+ }
507+ const body = blocks([first, ...item.slice(1)], opts, depth + 1, !loose);
508+ return `<li${task ? ' class="task"' : ""}>${task}${body}</li>`;
509+ });
510+ parts.push(ordered ? `<ol${start !== 1 ? ` start="${start}"` : ""}>${rendered.join("")}</ol>` : `<ul>${rendered.join("")}</ul>`);
511+ continue;
512+ }
513+ // Paragraph.
514+ const para: string[] = [line.trimStart()];
515+ i++;
516+ while (i < lines.length && lines[i]!.trim()) {
517+ // Setext headings (checked before "---" is taken as a thematic break).
518+ if (/^ {0,3}=+\s*$/.test(lines[i]!) || /^ {0,3}-+\s*$/.test(lines[i]!)) {
519+ const level = lines[i]!.includes("=") ? 1 : 2;
520+ const text = para.join(" ");
521+ const slug = slugify(text);
522+ para.length = 0;
523+ parts.push(`<h${level} id="h-${escapeHtml(slug)}"><a class="anchor" href="#h-${escapeHtml(slug)}" aria-hidden="true">#</a>${inline(text, opts, depth)}</h${level}>`);
524+ i++;
525+ break;
526+ }
527+ if (startsBlock(lines[i]!, lines[i + 1])) break;
528+ para.push(lines[i]!.trimStart());
529+ i++;
530+ }
531+ if (para.length) {
532+ const html = inline(para.join("\n").replace(/[ \t]+$/, ""), opts, depth);
533+ parts.push(tight ? html : `<p>${html}</p>`);
534+ }
535+ }
536+ return parts.join("\n");
537+}
538+
539+/** Render Markdown to sanitized HTML. */
540+export function renderMarkdown(source: string, opts: MarkdownOptions = {}): string {
541+ if (source.length > MAX_SOURCE) return `<pre>${escapeHtml(source)}</pre>`;
542+ const lines = source.replace(/\r\n?/g, "\n").split("\n");
543+ return blocks(lines, opts, 0);
544+}
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. Opening it lets them create a passkey and join." /> : 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+37−0View file
@@ -1,0 +1,37 @@
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 { registerAuth } from "./auth.js";
7+import { registerActions } from "./actions.js";
8+import { registerCode } from "./code.js";
9+import { registerCollab } from "./collab.js";
10+import { registerRepoSettings } from "./repo-settings.js";
11+import { ICONS } from "./assets.js";
12+import { errorPage } from "./ui.js";
13+
14+/**
15+ * Server-rendered web UI. Mount at "/" after the API, Git and runner routes:
16+ * it owns `/`, `/login`, `/auth/*`, `/signin/*`, `/new`, `/settings/*`, `/notifications/*`, `/invite/*`, `/-/*`,
17+ * `/:owner` and `/:owner/:repo/...` (including `/:owner/:repo/raw/*`).
18+ *
19+ * Expects the `principal` and `dir` variables to be set by an earlier middleware.
20+ * Every POST is CSRF-checked (double-submit cookie, see ./csrf.ts).
21+ */
22+export const web = new Hono<{ Bindings: Env; Variables: AppVars }>();
23+
24+web.onError((err, c) => errorPage(c, err));
25+
26+// Browsers and crawlers ask for these on every origin; keep them off the /:owner route.
27+web.get("/favicon.ico", (c) => c.body(ICONS.logo.replace(/currentColor/g, "#f6821f"), 200, { "content-type": "image/svg+xml", "cache-control": "public, max-age=86400" }));
28+web.get("/robots.txt", (c) => c.text("User-agent: *\nDisallow: /\n", 200, { "cache-control": "public, max-age=86400" }));
29+
30+// Order matters: fixed paths (/settings/tokens, /notifications/:id, …) must be
31+// registered before the /:owner/:repo patterns they would otherwise match.
32+registerAuth(web);
33+registerAccount(web);
34+registerRepoSettings(web);
35+registerActions(web);
36+registerCollab(web);
37+registerCode(web);
addedapps/diffinery/src/web/ui.tsx+761−0View file
@@ -1,0 +1,761 @@
1+/** @jsxImportSource hono/jsx */
2+import type { Hono } from "hono";
3+import { deleteCookie, getCookie, setCookie } from "hono/cookie";
4+import { raw } from "hono/html";
5+import type { Child, FC, PropsWithChildren } from "hono/jsx";
6+import type { Account, Repo } from "../lib/directory.js";
7+import type { Env } from "../env.js";
8+import type { AppContext, AppVars } from "../auth/principal.js";
9+import { HttpError, randomToken } from "../lib/util.js";
10+import { atLeast, type Caller } from "../repo/types.js";
11+import type { repoStub } from "../http/common.js";
12+import { CSS, ICONS, JS, type IconName } from "./assets.js";
13+import { CSRF_FIELD, csrfToken, verifyCsrf, type FormData } from "./csrf.js";
14+import { renderMarkdown, type MarkdownBase } from "./markdown.js";
15+
16+export type WebApp = Hono<{ Bindings: Env; Variables: AppVars }>;
17+export type Stub = ReturnType<typeof repoStub>;
18+
19+// ---------------------------------------------------------------- small helpers
20+
21+export const encPath = (p: string): string => p.split("/").map(encodeURIComponent).join("/");
22+export const repoUrl = (r: Pick<Repo, "owner_login" | "name">): string => `/${r.owner_login}/${r.name}`;
23+export const shortSha = (sha: string): string => sha.slice(0, 7);
24+export const firstLine = (s: string): string => s.split("\n")[0] ?? "";
25+
26+export function publicUrl(c: AppContext): string {
27+ return (c.env.PUBLIC_URL || new URL(c.req.url).origin).replace(/\/$/, "");
28+}
29+
30+export function cloneUrl(c: AppContext, repo: Repo): string {
31+ return `${publicUrl(c)}/${repo.owner_login}/${repo.name}.git`;
32+}
33+
34+export function fmtBytes(n: number | undefined | null): string {
35+ if (n === undefined || n === null) return "";
36+ if (n < 1024) return `${n} B`;
37+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
38+ if (n < 1024 ** 3) return `${(n / 1024 / 1024).toFixed(1)} MB`;
39+ return `${(n / 1024 ** 3).toFixed(2)} GB`;
40+}
41+
42+export function fmtDuration(ms: number): string {
43+ if (!Number.isFinite(ms) || ms < 0) return "";
44+ const s = Math.round(ms / 1000);
45+ if (s < 60) return `${s}s`;
46+ const m = Math.floor(s / 60);
47+ if (m < 60) return `${m}m ${s % 60}s`;
48+ return `${Math.floor(m / 60)}h ${m % 60}m`;
49+}
50+
51+export function duration(start: string | null | undefined, end: string | null | undefined): string {
52+ if (!start) return "";
53+ return fmtDuration((end ? Date.parse(end) : Date.now()) - Date.parse(start));
54+}
55+
56+export function ago(iso: string | null | undefined): string {
57+ if (!iso) return "";
58+ const t = Date.parse(iso);
59+ if (!Number.isFinite(t)) return iso;
60+ const s = Math.round((Date.now() - t) / 1000);
61+ if (s < 45) return "just now";
62+ const m = Math.round(s / 60);
63+ if (m < 60) return `${m} minute${m === 1 ? "" : "s"} ago`;
64+ const h = Math.round(m / 60);
65+ if (h < 24) return `${h} hour${h === 1 ? "" : "s"} ago`;
66+ const d = Math.round(h / 24);
67+ if (d < 30) return `${d} day${d === 1 ? "" : "s"} ago`;
68+ return new Date(t).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
69+}
70+
71+export const Time: FC<{ at: string | null | undefined }> = ({ at }) => (at ? <time datetime={at} title={at}>{ago(at)}</time> : <span />);
72+
73+export const Icon: FC<{ name: IconName; class?: string }> = ({ name, class: cls }) => <span class={`st ${cls ?? ""}`}>{raw(ICONS[name])}</span>;
74+
75+/** Status icon for runs, jobs, checks and steps. */
76+export const Status: FC<{ status?: string | null; conclusion?: string | null }> = ({ status, conclusion }) => {
77+ if (status && status !== "completed") {
78+ if (status === "in_progress") return <span class="st spin run" title="In progress">{raw(ICONS.running)}</span>;
79+ return <span class="st warn" title={status === "waiting" ? "Waiting" : "Queued"}>{raw(ICONS.queued)}</span>;
80+ }
81+ switch (conclusion) {
82+ case "success":
83+ return <span class="st ok" title="Success">{raw(ICONS.success)}</span>;
84+ case "failure":
85+ case "timed_out":
86+ case "startup_failure":
87+ return <span class="st bad" title={conclusion === "timed_out" ? "Timed out" : "Failed"}>{raw(ICONS.failure)}</span>;
88+ case "cancelled":
89+ return <span class="st faint" title="Cancelled">{raw(ICONS.cancelled)}</span>;
90+ case "skipped":
91+ return <span class="st faint" title="Skipped">{raw(ICONS.skipped)}</span>;
92+ case "in_progress":
93+ return <span class="st spin run" title="In progress">{raw(ICONS.running)}</span>;
94+ case "queued":
95+ case "waiting":
96+ case "pending":
97+ return <span class="st warn" title="Queued">{raw(ICONS.queued)}</span>;
98+ default:
99+ return <span class="st faint" title={conclusion ?? "Neutral"}>{raw(ICONS.neutral)}</span>;
100+ }
101+};
102+
103+function hue(s: string): number {
104+ let h = 0;
105+ for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
106+ return h % 360;
107+}
108+
109+export const Avatar: FC<{ login: string; kind?: string; size?: "lg" | "xl" }> = ({ login, kind, size }) => (
110+ <span class={`av ${size ?? ""} ${kind === "agent" || kind === "system" ? "bot" : ""}`} style={`--h:${hue(login)}`} aria-hidden="true">
111+ {(login.replace(/[^A-Za-z0-9]/g, "")[0] ?? "?").toUpperCase()}
112+ </span>
113+);
114+
115+/** Linked actor name with an "agent" badge and the human it acts for. */
116+export const Actor: FC<{ login: string; kind?: string; onBehalfOf?: string | null; avatar?: boolean }> = ({ login, kind, onBehalfOf, avatar = true }) => (
117+ <span class="who">
118+ {avatar ? <Avatar login={login} kind={kind} /> : null}
119+ {kind === "system" ? <span>{login}</span> : <a href={`/${encodeURIComponent(login)}`} style="color:inherit">{login}</a>}
120+ {kind === "agent" ? <span class="badge agent">agent</span> : null}
121+ {kind === "system" ? <span class="badge">bot</span> : null}
122+ {onBehalfOf ? (
123+ <span class="muted small" style="font-weight:400">
124+ on behalf of <a href={`/${encodeURIComponent(onBehalfOf)}`}>{onBehalfOf}</a>
125+ </span>
126+ ) : null}
127+ </span>
128+);
129+
130+export const Label: FC<{ name: string; color?: string; href?: string }> = ({ name, color, href }) => {
131+ const c = color && /^[0-9a-fA-F]{6}$/.test(color) ? `#${color}` : "#8b929c";
132+ return href ? (
133+ <a class="lbl" style={`--lc:${c}`} href={href}>
134+ {name}
135+ </a>
136+ ) : (
137+ <span class="lbl" style={`--lc:${c}`}>{name}</span>
138+ );
139+};
140+
141+export const Markdown: FC<{ text: string; base?: MarkdownBase; empty?: string }> = ({ text, base, empty }) =>
142+ text.trim() ? <div class="markdown">{raw(renderMarkdown(text, { base }))}</div> : <p class="muted m0"><em>{empty ?? "No description provided."}</em></p>;
143+
144+/** CSRF-protected POST form. */
145+export const Form: FC<PropsWithChildren<{ action: string; t: string; class?: string; confirm?: string; match?: string; id?: string; style?: string }>> = (p) => (
146+ <form method="post" action={p.action} class={p.class} data-confirm={p.confirm} data-match={p.match} id={p.id} style={p.style}>
147+ <input type="hidden" name={CSRF_FIELD} value={p.t} />
148+ {p.children}
149+ </form>
150+);
151+
152+export const CopyButton: FC<{ text: string; label?: string; class?: string }> = ({ text, label, class: cls }) => (
153+ <button type="button" class={`btn sm ${cls ?? ""}`} data-copy={text} title="Copy to clipboard">
154+ {label ?? "Copy"}
155+ </button>
156+);
157+
158+export const Pager: FC<{ page: number; hasNext: boolean; href: (page: number) => string }> = ({ page, hasNext, href }) =>
159+ page > 1 || hasNext ? (
160+ <div class="pager">
161+ {page > 1 ? <a class="btn sm" href={href(page - 1)}>← Newer</a> : <span class="btn sm" aria-disabled="true" style="opacity:.4">← Newer</span>}
162+ <span class="muted small" style="align-self:center">Page {page}</span>
163+ {hasNext ? <a class="btn sm" href={href(page + 1)}>Older →</a> : <span class="btn sm" aria-disabled="true" style="opacity:.4">Older →</span>}
164+ </div>
165+ ) : null;
166+
167+export function qs(params: Record<string, string | number | undefined | null | false>): string {
168+ const u = new URLSearchParams();
169+ for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== "" && v !== false) u.set(k, String(v));
170+ const s = u.toString();
171+ return s ? `?${s}` : "";
172+}
173+
174+/** 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+
181+export const pageNum = (c: AppContext): number => Math.max(1, Math.min(1000, parseInt(c.req.query("page") ?? "1", 10) || 1));
182+
183+// ---------------------------------------------------------------- errors, flash and actions
184+
185+export function statusOf(e: unknown): { status: number; message: string } {
186+ if (e instanceof HttpError) return { status: e.status, message: e.detail };
187+ const msg = (e as Error)?.message ?? String(e);
188+ const m = /^(\d{3}): ([\s\S]*)$/.exec(msg);
189+ if (m) return { status: Number(m[1]), message: m[2]! };
190+ return { status: 500, message: "internal error" };
191+}
192+
193+const FLASH = "dfy_flash";
194+
195+export function flash(c: AppContext, kind: "error" | "notice", message: string): void {
196+ setCookie(c, FLASH, encodeURIComponent(JSON.stringify({ kind, message: message.slice(0, 600) })), { path: "/", httpOnly: true, secure: true, sameSite: "Strict", maxAge: 60 });
197+}
198+
199+function takeFlash(c: AppContext): { kind: "error" | "notice"; message: string } | null {
200+ const v = getCookie(c, FLASH);
201+ if (!v) return null;
202+ deleteCookie(c, FLASH, { path: "/", secure: true });
203+ try {
204+ const f = JSON.parse(decodeURIComponent(v)) as { kind: string; message: string };
205+ if (typeof f.message !== "string") return null;
206+ return { kind: f.kind === "notice" ? "notice" : "error", message: f.message };
207+ } catch {
208+ return null;
209+ }
210+}
211+
212+/** Run a state-changing action and redirect (303); client errors become a flash message on `back`. */
213+export async function act(c: AppContext, back: string, fn: () => Promise<string | void>, notice?: string): Promise<Response> {
214+ try {
215+ const to = await fn();
216+ if (notice) flash(c, "notice", notice);
217+ return c.redirect(to ?? back, 303);
218+ } catch (e) {
219+ const s = statusOf(e);
220+ if (s.status >= 500) throw e;
221+ flash(c, "error", s.message);
222+ return c.redirect(back, 303);
223+ }
224+}
225+
226+/** Register a CSRF-checked POST route. */
227+export function post(app: WebApp, path: string, handler: (c: AppContext, form: FormData) => Promise<Response>): void {
228+ app.post(path, async (c) => handler(c, await verifyCsrf(c)));
229+}
230+
231+export function principalAccount(c: AppContext): Account | null {
232+ const p = c.get("principal");
233+ return p?.type === "account" ? p.account : null;
234+}
235+
236+// ---------------------------------------------------------------- layout
237+
238+export interface RepoCtx {
239+ repo: Repo;
240+ caller: Caller;
241+ tab?: "code" | "issues" | "pulls" | "actions" | "settings" | "";
242+ /** Current ref, used by the palette's "go to file". */
243+ ref?: string;
244+ counts?: { issues?: number; pulls?: number };
245+}
246+
247+export interface PageOpts {
248+ title: string;
249+ repo?: RepoCtx;
250+ /** WebSocket topics for live updates (e.g. "run:abc,pr:3"). */
251+ live?: string;
252+ liveMode?: "reload" | "log";
253+ lastSeq?: number;
254+ status?: number;
255+ /** Full-width page (diffs, logs). */
256+ wide?: boolean;
257+}
258+
259+const RepoHeader: FC<{ r: RepoCtx }> = ({ r }) => {
260+ const base = repoUrl(r.repo);
261+ const tab = r.tab ?? "";
262+ const vis = r.repo.visibility;
263+ return (
264+ <div class="repohead">
265+ <div class="wrap">
266+ <h1>
267+ <span class="st faint">{raw(vis === "public" ? ICONS.globe : vis === "internal" ? ICONS.org : ICONS.lock)}</span>
268+ <a href={`/${r.repo.owner_login}`}>{r.repo.owner_login}</a>
269+ <span class="faint">/</span>
270+ <strong>
271+ <a href={base}>{r.repo.name}</a>
272+ </strong>
273+ <span class="badge">{vis}</span>
274+ {r.repo.archived ? <span class="badge amber">archived</span> : null}
275+ </h1>
276+ {r.repo.description ? <p class="muted m0">{r.repo.description}</p> : null}
277+ <nav class="tabs">
278+ <a href={base} class={tab === "code" ? "on" : ""}>
279+ {raw(ICONS.code)} Code
280+ </a>
281+ <a href={`${base}/issues`} class={tab === "issues" ? "on" : ""}>
282+ {raw(ICONS.issueOpen)} Issues {r.counts?.issues ? <span class="n">{r.counts.issues}</span> : null}
283+ </a>
284+ <a href={`${base}/pulls`} class={tab === "pulls" ? "on" : ""}>
285+ {raw(ICONS.pr)} Pull requests {r.counts?.pulls ? <span class="n">{r.counts.pulls}</span> : null}
286+ </a>
287+ <a href={`${base}/actions`} class={tab === "actions" ? "on" : ""}>
288+ {raw(ICONS.play)} Actions
289+ </a>
290+ {atLeast(r.caller.role, "admin") ? (
291+ <a href={`${base}/settings`} class={tab === "settings" ? "on" : ""}>
292+ {raw(ICONS.gear)} Settings
293+ </a>
294+ ) : null}
295+ </nav>
296+ </div>
297+ </div>
298+ );
299+};
300+
301+function csp(nonce: string, c: AppContext): string {
302+ const host = new URL(c.req.url).host;
303+ return [
304+ "default-src 'self'",
305+ `script-src 'nonce-${nonce}'`,
306+ "style-src 'self' 'unsafe-inline'",
307+ "img-src 'self' https: data:",
308+ `connect-src 'self' wss://${host} ws://${host}`,
309+ "font-src 'self'",
310+ "object-src 'none'",
311+ "base-uri 'none'",
312+ "form-action 'self'",
313+ "frame-ancestors 'none'",
314+ ].join("; ");
315+}
316+
317+/** Render a full page. */
318+export function render(c: AppContext, opts: PageOpts, body: Child): Response | Promise<Response> {
319+ csrfToken(c);
320+ const nonce = randomToken(16);
321+ const fl = takeFlash(c);
322+ const p = c.get("principal");
323+ const me = p?.type === "account" ? p.account : null;
324+ const active = me?.status === "active";
325+ c.header("content-security-policy", csp(nonce, c));
326+ c.header("x-frame-options", "DENY");
327+ c.header("x-content-type-options", "nosniff");
328+ c.header("referrer-policy", "same-origin");
329+ c.header("cache-control", "private, no-store");
330+ const r = opts.repo;
331+ const page = (
332+ <html lang="en">
333+ <head>
334+ <meta charset="utf-8" />
335+ <meta name="viewport" content="width=device-width, initial-scale=1" />
336+ <meta name="color-scheme" content="light dark" />
337+ <title>{opts.title ? `${opts.title} · Diffinery` : "Diffinery"}</title>
338+ <link rel="icon" href={`data:image/svg+xml,${encodeURIComponent(ICONS.logo.replace("currentColor", "#f6821f"))}`} />
339+ <style>{raw(CSS)}</style>
340+ </head>
341+ <body
342+ data-repo={r ? `${r.repo.owner_login}/${r.repo.name}` : undefined}
343+ data-ref={r?.ref ?? r?.repo.default_branch}
344+ data-live={opts.live}
345+ data-live-mode={opts.live ? (opts.liveMode ?? "reload") : undefined}
346+ data-last-seq={opts.lastSeq !== undefined ? String(opts.lastSeq) : undefined}
347+ >
348+ <header class="top">
349+ <div class="wrap">
350+ <a class="brand" href="/">
351+ {raw(ICONS.logo)} Diffinery
352+ </a>
353+ {active ? (
354+ <button type="button" class="pal-btn" data-palette="1" aria-label="Open command palette">
355+ {raw(ICONS.search)}
356+ <span>Jump to…</span>
357+ <kbd>Ctrl K</kbd>
358+ </button>
359+ ) : null}
360+ <nav>
361+ {active && me ? (
362+ <>
363+ <a href="/new" title="New repository">
364+ {raw(ICONS.plus)}
365+ </a>
366+ <a href="/notifications" title="Notifications">
367+ {raw(ICONS.bell)}
368+ </a>
369+ <a href="/settings" title="Settings" class="who">
370+ <Avatar login={me.login} /> <span>{me.login}</span>
371+ </a>
372+ </>
373+ ) : null}
374+ {p?.type === "account" && p.via === "session" ? (
375+ <form method="post" action="/auth/logout" class="inline">
376+ <input type="hidden" name={CSRF_FIELD} value={csrfToken(c)} />
377+ <button class="navbtn">Sign out</button>
378+ </form>
379+ ) : null}
380+ {!p || p.type === "anonymous" ? (
381+ c.req.path === "/login" ? null : (
382+ <a href={`/login${c.req.path === "/" ? "" : `?next=${encodeURIComponent(c.req.path)}`}`} class="btn primary sm">
383+ Sign in
384+ </a>
385+ )
386+ ) : null}
387+ </nav>
388+ </div>
389+ </header>
390+ {r ? <RepoHeader r={r} /> : null}
391+ <main class="wrap" style={opts.wide ? "max-width:none" : undefined}>
392+ {fl ? <div class={`flash ${fl.kind}`} role={fl.kind === "error" ? "alert" : "status"}>{fl.message}</div> : null}
393+ {body}
394+ </main>
395+ {active ? (
396+ <dialog id="palette" aria-label="Command palette">
397+ <input type="text" placeholder="Jump to a repository, file, page or #number…" autocomplete="off" spellcheck={false} />
398+ <ul role="listbox"></ul>
399+ <div class="foot">
400+ <span>
401+ <kbd>↑</kbd> <kbd>↓</kbd> navigate
402+ </span>
403+ <span>
404+ <kbd>Enter</kbd> open
405+ </span>
406+ <span>
407+ <kbd>g</kbd> then <kbd>c</kbd>/<kbd>i</kbd>/<kbd>p</kbd>/<kbd>a</kbd> code, issues, pulls, actions
408+ </span>
409+ </div>
410+ </dialog>
411+ ) : null}
412+ <script nonce={nonce}>{raw(JS)}</script>
413+ </body>
414+ </html>
415+ );
416+ return c.html(`<!doctype html>${page}`, (opts.status ?? 200) as 200);
417+}
418+
419+/** Friendly error page for HttpErrors (and DO errors carrying a status prefix). */
420+export function errorPage(c: AppContext, e: unknown): Response | Promise<Response> {
421+ const s = statusOf(e);
422+ if (s.status >= 500) console.error(e);
423+ // Signed-out visitors who open a page go straight to sign-in, and come back afterwards.
424+ if (s.status === 401 && c.req.method === "GET" && c.get("principal")?.type === "anonymous" && !c.req.path.startsWith("/auth/")) {
425+ const url = new URL(c.req.url);
426+ return c.redirect(`/login?next=${encodeURIComponent(url.pathname + url.search)}`, 302);
427+ }
428+ const titles: Record<number, string> = { 401: "Sign in required", 403: "Access denied", 404: "Not found", 405: "Not allowed", 409: "Conflict", 422: "Could not complete the request" };
429+ const title = titles[s.status] ?? (s.status >= 500 ? "Something went wrong" : "Request failed");
430+ const p = c.get("principal");
431+ return render(
432+ c,
433+ { title, status: s.status },
434+ <div class="blank" style="max-width:640px;margin:48px auto">
435+ <div class="faint" style="font-size:48px;font-weight:700;letter-spacing:-.03em">{s.status}</div>
436+ <h1>{title}</h1>
437+ {s.status === 401 ? (
438+ <>
439+ <p class="muted">{p?.type === "account" ? s.message : "Sign in with your passkey to continue."}</p>
440+ <p>
441+ <a class="btn primary" href={`/login?next=${encodeURIComponent(c.req.path)}`}>
442+ Sign in
443+ </a>
444+ </p>
445+ </>
446+ ) : s.status === 404 ? (
447+ <p class="muted">The page or repository does not exist, or you do not have access to it.</p>
448+ ) : (
449+ <p class="muted">{s.status >= 500 ? "An unexpected error occurred. Please try again." : s.message}</p>
450+ )}
451+ {s.status === 403 && p?.type === "account" && p.account.status === "pending" ? (
452+ <p>
453+ <a class="btn primary" href="/">
454+ Request access
455+ </a>
456+ </p>
457+ ) : s.status === 401 ? null : (
458+ <p>
459+ <a class="btn" href="/">
460+ Go to dashboard
461+ </a>
462+ </p>
463+ )}
464+ </div>,
465+ );
466+}
467+
468+// ---------------------------------------------------------------- refs and paths
469+
470+/** Path segments after a fixed number of leading segments, decoded. */
471+export function restOfPath(c: AppContext, skip: number): string {
472+ const parts = new URL(c.req.url).pathname.split("/").slice(skip + 1);
473+ return parts
474+ .filter((p) => p !== "")
475+ .map((p) => {
476+ try {
477+ return decodeURIComponent(p);
478+ } catch {
479+ return p;
480+ }
481+ })
482+ .join("/");
483+}
484+
485+/** Split "feature/x/src/a.ts" into the longest matching ref and the path inside it. */
486+export async function splitRefPath(stub: Stub, rest: string): Promise<{ ref: string; path: string }> {
487+ const segs = rest.split("/").filter(Boolean);
488+ if (segs.length <= 1) return { ref: segs[0] ?? "", path: "" };
489+ const names = new Set((await stub.refs("refs/")).map((r) => r.name.replace(/^refs\/(heads|tags)\//, "")));
490+ for (let i = segs.length; i >= 1; i--) {
491+ const cand = segs.slice(0, i).join("/");
492+ if (names.has(cand)) return { ref: cand, path: segs.slice(i).join("/") };
493+ }
494+ return { ref: segs[0]!, path: segs.slice(1).join("/") };
495+}
496+
497+/** Branch/tag switcher; `to` builds the target URL for a ref. */
498+export const RefSwitcher: FC<{ current: string; branches: string[]; tags: string[]; to: (ref: string) => string }> = ({ current, branches, tags, to }) => (
499+ <details class="menu">
500+ <summary class="btn sm">
501+ {raw(/^[0-9a-f]{40}$/.test(current) ? ICONS.commit : tags.includes(current) && !branches.includes(current) ? ICONS.tag : ICONS.branch)}
502+ <span class="mono">{/^[0-9a-f]{40}$/.test(current) ? shortSha(current) : current}</span> ▾
503+ </summary>
504+ <div class="pop">
505+ <h4>Branches</h4>
506+ {branches.length ? (
507+ branches.map((b) => (
508+ <a href={to(b)}>
509+ {b === current ? raw(ICONS.check) : <span style="width:16px;display:inline-block" />} {b}
510+ </a>
511+ ))
512+ ) : (
513+ <p class="muted small" style="margin:4px 8px">No branches</p>
514+ )}
515+ {tags.length ? <h4>Tags</h4> : null}
516+ {tags.map((t) => (
517+ <a href={to(t)}>
518+ {t === current ? raw(ICONS.check) : <span style="width:16px;display:inline-block" />} {t}
519+ </a>
520+ ))}
521+ </div>
522+ </details>
523+);
524+
525+export async function refNames(stub: Stub): Promise<{ branches: string[]; tags: string[] }> {
526+ const refs = await stub.refs("refs/");
527+ return {
528+ branches: refs.filter((r) => r.name.startsWith("refs/heads/")).map((r) => r.name.slice(11)),
529+ tags: refs.filter((r) => r.name.startsWith("refs/tags/")).map((r) => r.name.slice(10)),
530+ };
531+}
532+
533+// ---------------------------------------------------------------- diffs
534+
535+export interface DiffHunk {
536+ oldStart: number;
537+ oldLines: number;
538+ newStart: number;
539+ newLines: number;
540+ lines: string[];
541+}
542+
543+export interface DiffFile {
544+ path: string;
545+ status: "added" | "modified" | "deleted";
546+ binary: boolean;
547+ additions: number;
548+ deletions: number;
549+ hunks: DiffHunk[];
550+ truncated?: boolean;
551+}
552+
553+export interface LineThread {
554+ side: "LEFT" | "RIGHT";
555+ line: number;
556+ node: Child;
557+}
558+
559+const StatBar: FC<{ a: number; d: number }> = ({ a, d }) => {
560+ const total = a + d;
561+ const blocks = 5;
562+ const ga = total ? Math.round((a / total) * blocks) : 0;
563+ const gd = total ? Math.min(blocks - ga, Math.round((d / total) * blocks)) : 0;
564+ return (
565+ <span class="bar" aria-hidden="true">
566+ {Array.from({ length: blocks }, (_, i) => (
567+ <i class={i < ga ? "a" : i < ga + gd ? "d" : ""} />
568+ ))}
569+ </span>
570+ );
571+};
572+
573+export const DiffStats: FC<{ files: number; additions: number; deletions: number }> = ({ files, additions, deletions }) => (
574+ <span class="row" style="gap:10px">
575+ <span>
576+ <b>{files}</b> file{files === 1 ? "" : "s"} changed
577+ </span>
578+ <span class="stat-add">+{additions}</span>
579+ <span class="stat-del">−{deletions}</span>
580+ <StatBar a={additions} d={deletions} />
581+ </span>
582+);
583+
584+export interface DiffOptions {
585+ /** Link to view the file at the new revision. */
586+ viewHref?: (path: string) => string | null;
587+ /** Show "+" buttons for line comments (requires the line comment template on the page). */
588+ commentable?: boolean;
589+ /** Existing review threads per file path. */
590+ threads?: Map<string, LineThread[]>;
591+}
592+
593+const LINE_BUDGET = 20000;
594+
595+/** Unified diff with line numbers; each file is collapsible. */
596+export const DiffView: FC<{ files: DiffFile[]; opts?: DiffOptions }> = ({ files, opts = {} }) => {
597+ let budget = LINE_BUDGET;
598+ return (
599+ <div>
600+ {files.map((f) => {
601+ const lines = f.hunks.reduce((n, h) => n + h.lines.length, 0);
602+ const big = lines > 800;
603+ const render = budget > 0 && !f.binary && !f.truncated;
604+ budget -= lines;
605+ const threads = opts.threads?.get(f.path) ?? [];
606+ const view = opts.viewHref?.(f.path);
607+ return (
608+ <details class="file" open={!big && render} id={`diff-${encodeURIComponent(f.path)}`}>
609+ <summary class="filehead">
610+ <span class={`badge ${f.status === "added" ? "green" : f.status === "deleted" ? "red" : "amber"}`}>{f.status}</span>
611+ <span class="mono grow" style="font-weight:600;word-break:break-all">{f.path}</span>
612+ <span class="stat-add small">+{f.additions}</span>
613+ <span class="stat-del small">−{f.deletions}</span>
614+ {view && f.status !== "deleted" ? (
615+ <a class="btn sm" href={view}>
616+ View file
617+ </a>
618+ ) : null}
619+ </summary>
620+ {f.binary ? (
621+ <div class="empty">Binary file not shown.</div>
622+ ) : f.truncated ? (
623+ <div class="empty">This diff is too large to display.</div>
624+ ) : !render ? (
625+ <div class="empty">Diff not rendered to keep this page fast. {view ? <a href={view}>View the file</a> : null}</div>
626+ ) : f.hunks.length === 0 ? (
627+ <div class="empty">{f.status === "added" ? "Empty file added." : f.status === "deleted" ? "Empty file deleted." : "Mode change only."}</div>
628+ ) : (
629+ <div class="code-wrap">
630+ <table class="diff">
631+ {f.hunks.map((h) => {
632+ let oldN = h.oldStart;
633+ let newN = h.newStart;
634+ return (
635+ <>
636+ <tr class="hunk">
637+ {opts.commentable ? <td class="act" /> : null}
638+ <td class="ln" />
639+ <td class="ln" />
640+ <td class="code">
641+ @@ -{h.oldStart},{h.oldLines} +{h.newStart},{h.newLines} @@
642+ </td>
643+ </tr>
644+ {h.lines.map((l) => {
645+ const k = l[0];
646+ if (k === "\\") {
647+ return (
648+ <tr class="meta">
649+ {opts.commentable ? <td class="act" /> : null}
650+ <td class="ln" />
651+ <td class="ln" />
652+ <td class="code">{l}</td>
653+ </tr>
654+ );
655+ }
656+ const cls = k === "+" ? "add" : k === "-" ? "del" : "ctx";
657+ const o = k === "+" ? null : oldN++;
658+ const n = k === "-" ? null : newN++;
659+ const side = k === "-" ? "LEFT" : "RIGHT";
660+ const lineNo = side === "LEFT" ? o : n;
661+ const here = threads.filter((t) => t.side === side && t.line === lineNo);
662+ return (
663+ <>
664+ <tr class={cls}>
665+ {opts.commentable ? (
666+ <td class="act">
667+ <button type="button" class="addc" title="Comment on this line" data-comment-line={String(lineNo)} data-path={f.path} data-side={side}>
668+ +
669+ </button>
670+ </td>
671+ ) : null}
672+ <td class="ln">{o ?? ""}</td>
673+ <td class="ln">{n ?? ""}</td>
674+ <td class="code">
675+ {k === "+" || k === "-" ? k : " "}
676+ {l.slice(1)}
677+ </td>
678+ </tr>
679+ {here.length ? (
680+ <tr class="thread">
681+ <td colspan={opts.commentable ? 4 : 3}>{here.map((t) => t.node)}</td>
682+ </tr>
683+ ) : null}
684+ </>
685+ );
686+ })}
687+ </>
688+ );
689+ })}
690+ </table>
691+ </div>
692+ )}
693+ </details>
694+ );
695+ })}
696+ </div>
697+ );
698+};
699+
700+/** Changed-files summary list with anchors into the diff. */
701+export const FileSummary: FC<{ files: DiffFile[] }> = ({ files }) =>
702+ files.length > 3 ? (
703+ <details class="box">
704+ <summary class="head" style="cursor:pointer">
705+ <span>Files ({files.length})</span>
706+ </summary>
707+ <ul class="list">
708+ {files.map((f) => (
709+ <li style="padding:6px 14px">
710+ <a class="mono grow" href={`#diff-${encodeURIComponent(f.path)}`}>
711+ {f.path}
712+ </a>
713+ <span class="stat-add small">+{f.additions}</span>
714+ <span class="stat-del small">−{f.deletions}</span>
715+ </li>
716+ ))}
717+ </ul>
718+ </details>
719+ ) : null;
720+
721+/** Commit list row. */
722+export const CommitRow: FC<{ base: string; sha: string; message: string; author: { name: string; email?: string; timestamp: number }; signed?: boolean; extra?: Child }> = ({ base, sha, message, author, signed, extra }) => (
723+ <li>
724+ <div class="grow">
725+ <a class="title" href={`${base}/commit/${sha}`}>
726+ {firstLine(message)}
727+ </a>
728+ <div class="meta">
729+ <b>{author.name}</b> committed <Time at={new Date(author.timestamp * 1000).toISOString()} />
730+ {signed ? <span class="badge green" style="margin-left:6px">signed</span> : null}
731+ </div>
732+ </div>
733+ {extra}
734+ <CopyButton text={sha} label="Copy" />
735+ <a class="sha" href={`${base}/commit/${sha}`}>
736+ {shortSha(sha)}
737+ </a>
738+ <a class="btn sm" href={`${base}/tree/${sha}`} title="Browse the repository at this point">
739+ {raw(ICONS.code)}
740+ </a>
741+ </li>
742+);
743+
744+/** Look up agents' owners for "on behalf of" labels. */
745+export async function onBehalfOf(c: AppContext, actors: Array<{ login: string; kind: string }>): Promise<Map<string, string>> {
746+ const out = new Map<string, string>();
747+ const dir = c.get("dir");
748+ const logins = [...new Set(actors.filter((a) => a.kind === "agent").map((a) => a.login))].slice(0, 20);
749+ await Promise.all(
750+ logins.map(async (login) => {
751+ const a = await dir.accountByLogin(login);
752+ if (a?.owner_id) {
753+ const owner = await dir.account(a.owner_id);
754+ if (owner) out.set(login, owner.login);
755+ }
756+ }),
757+ );
758+ return out;
759+}
760+
761+export const ROLES = ["read", "triage", "write", "maintain", "admin"] as const;
addedapps/diffinery/test/e2e/e2e.test.mjs+342−0View file
@@ -1,0 +1,342 @@
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 jobEnv = { DIFFINERY_API_URL: claim.api_url, DIFFINERY_JOB_ID: claim.job_id, DIFFINERY_JOB_TOKEN: claim.token };
96+ // E2E_RUNNER_IMAGE runs the job inside the real runner image instead of the local agent bundle.
97+ const image = process.env.E2E_RUNNER_IMAGE;
98+ const r = image
99+ ? spawnSync("docker", ["run", "--rm", "--network", "host", ...Object.entries(jobEnv).flatMap(([k, v]) => ["-e", `${k}=${v}`]), image], { encoding: "utf8", timeout: 300_000 })
100+ : spawnSync(process.execPath, [AGENT], { encoding: "utf8", env: { ...process.env, ...jobEnv, DIFFINERY_WORK_ROOT: join(work, `job-${claim.job_id}`) }, timeout: 120_000 });
101+ const out = `${r.stdout}${r.stderr}`;
102+ if (r.status !== 0) {
103+ const log = await (await fetch(`${base}/api/v3/repos/${owner}/${repo}/actions/jobs/${claim.job_id}/logs`, { headers: { authorization: `Bearer ${token}` } })).text();
104+ const job = await api("GET", `/repos/${owner}/${repo}/actions/jobs/${claim.job_id}`);
105+ assert.fail(`runner agent exited ${r.status}:\n${out}\n--- job ---\n${JSON.stringify(job.json, null, 2)}\n--- job log ---\n${log}`);
106+ }
107+ return { claim, out };
108+}
109+
110+before(async () => {
111+ assert.ok(existsSync(AGENT), `build the runner first: pnpm --filter @diffinery/runner build (missing ${AGENT})`);
112+ const port = await freePort();
113+ base = `http://127.0.0.1:${port}`;
114+ const persist = join(work, "state");
115+ execFileSync("pnpm", ["exec", "wrangler", "d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
116+ // Until the web UI exists, run the same app through the API-only entry.
117+ const entry = existsSync(join(APP, "src/web/routes.tsx")) ? [] : ["test/e2e/entry.ts"];
118+ 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"], {
119+ cwd: APP,
120+ env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" },
121+ stdio: ["ignore", "pipe", "pipe"],
122+ detached: true,
123+ });
124+ dev.stdout.on("data", (d) => (devLog += d));
125+ dev.stderr.on("data", (d) => (devLog += d));
126+ await until("wrangler dev", async () => {
127+ try {
128+ return (await fetch(`${base}/healthz`)).ok;
129+ } catch {
130+ return false;
131+ }
132+ }, 120_000);
133+ // DEV_AUTH signs requests without credentials in as dev@diffinery.local; mint a real token for everything else.
134+ const res = await fetch(`${base}/api/v3/diffinery/tokens`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "e2e", scopes: ["admin"] }) });
135+ assert.equal(res.status, 201, await res.clone().text());
136+ token = (await res.json()).token;
137+});
138+
139+after(() => {
140+ if (dev?.pid) {
141+ try {
142+ process.kill(-dev.pid, "SIGTERM");
143+ } catch {}
144+ }
145+ if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true });
146+});
147+
148+test("git hosting, Actions, protected PR flow and MCP", async () => {
149+ const me = await ok("GET", "/user");
150+ const owner = me.login;
151+ assert.ok(me.site_admin, "dev user is a bootstrap admin");
152+
153+ // ------------------------------------------------------------ repo + first push
154+ const repo = await ok("POST", "/user/repos", { name: "demo", description: "e2e" });
155+ assert.equal(repo.full_name, `${owner}/demo`);
156+ const src = join(work, "src");
157+ mkdirSync(join(src, ".github/workflows"), { recursive: true });
158+ writeFileSync(join(src, "README.md"), "# demo\n");
159+ writeFileSync(join(src, "app.txt"), "one\ntwo\nthree\n");
160+ writeFileSync(
161+ join(src, ".github/workflows/ci.yml"),
162+ `name: CI
163+on:
164+ push:
165+ branches: [main]
166+ pull_request:
167+jobs:
168+ build:
169+ runs-on: [self-hosted, linux]
170+ steps:
171+ - uses: actions/checkout@v4
172+ - name: Check files
173+ run: |
174+ test -f README.md
175+ echo "event=\${{ github.event_name }} sha=\${{ github.sha }}"
176+ grep -q bad app.txt && { echo "found bad line"; exit 1; } || true
177+ - id: out
178+ run: echo "lines=$(wc -l < app.txt | tr -d ' ')" >> "$GITHUB_OUTPUT"
179+ - run: echo "app.txt has \${{ steps.out.outputs.lines }} lines"
180+`,
181+ );
182+ git(src, ["init", "-q", "-b", "main"]);
183+ git(src, ["add", "-A"]);
184+ git(src, ["commit", "-q", "-m", "initial"]);
185+ git(src, ["remote", "add", "origin", `${base}/${owner}/demo.git`]);
186+ git(src, ["push", "-q", "origin", "main"]);
187+ const mainSha = git(src, ["rev-parse", "HEAD"]).trim();
188+
189+ const branch = await ok("GET", `/repos/${owner}/demo/branches/main`);
190+ assert.equal(branch.commit.sha, mainSha);
191+
192+ // ------------------------------------------------------------ the push ran CI on a self-hosted runner
193+ const pushRun = await until("push run", async () => (await ok("GET", `/repos/${owner}/demo/actions/runs?event=push`)).workflow_runs[0]);
194+ assert.equal(pushRun.head_sha, mainSha);
195+ const job1 = await runOneJob(owner, "demo");
196+ const finished = await until("push run to complete", async () => {
197+ const r = await ok("GET", `/repos/${owner}/demo/actions/runs/${pushRun.id}`);
198+ return r.status === "completed" ? r : null;
199+ });
200+ assert.equal(finished.conclusion, "success", job1.out);
201+ const logs = await (await fetch(`${base}/api/v3/repos/${owner}/demo/actions/jobs/${job1.claim.job_id}/logs`, { headers: { authorization: `Bearer ${token}` } })).text();
202+ assert.match(logs, /event=push/);
203+ assert.match(logs, /app\.txt has 3 lines/);
204+ const checks = await ok("GET", `/repos/${owner}/demo/commits/${mainSha}/check-runs`);
205+ assert.equal(checks.check_runs.find((c) => c.name === "CI / build")?.conclusion, "success");
206+
207+ // ------------------------------------------------------------ clone over protocol v2
208+ const clone = join(work, "clone");
209+ git(work, ["clone", "-q", `${base}/${owner}/demo.git`, clone]);
210+ assert.equal(readFileSync(join(clone, "app.txt"), "utf8"), "one\ntwo\nthree\n");
211+
212+ // ------------------------------------------------------------ secrets are blocked on push
213+ writeFileSync(join(clone, "config.env"), "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPL3\n");
214+ git(clone, ["add", "-A"]);
215+ git(clone, ["commit", "-q", "-m", "oops"]);
216+ assert.match(gitFails(clone, ["push", "origin", "main"]), /secret|AWS/i);
217+ git(clone, ["reset", "-q", "--hard", "origin/main"]);
218+
219+ // ------------------------------------------------------------ protect main: PR + green CI + review
220+ await ok("PUT", `/repos/${owner}/demo/branches/main/protection`, {
221+ requirePullRequest: true,
222+ requiredApprovals: 0,
223+ dismissStaleReviews: true,
224+ requiredChecks: ["CI / build"],
225+ allowForcePush: false,
226+ allowDeletion: false,
227+ requireLinearHistory: false,
228+ enforceAdmins: true,
229+ agentsMayPush: false,
230+ agentsMayMerge: false,
231+ });
232+ writeFileSync(join(clone, "app.txt"), "one\ntwo\nthree\nfour\n");
233+ git(clone, ["commit", "-q", "-am", "direct push"]);
234+ assert.match(gitFails(clone, ["push", "origin", "main"]), /pull request|protected/i);
235+ git(clone, ["reset", "-q", "--hard", "origin/main"]);
236+
237+ // ------------------------------------------------------------ an AI agent opens a PR
238+ const created = await ok("POST", "/diffinery/agents", { login: "e2e-bot", name: "E2E Bot" });
239+ agentToken = created.token.token;
240+ const agentClone = join(work, "agent");
241+ git(work, ["clone", "-q", `${base}/${owner}/demo.git`, agentClone], agentToken);
242+ git(agentClone, ["checkout", "-q", "-b", "feature"], agentToken);
243+ writeFileSync(join(agentClone, "app.txt"), "one\ntwo\nthree\nfour\n");
244+ git(agentClone, ["commit", "-q", "-am", "Add a fourth line"], agentToken);
245+ assert.match(gitFails(agentClone, ["push", "origin", "HEAD:main"], agentToken), /agent|protected|pull request/i);
246+ git(agentClone, ["push", "-q", "origin", "feature"], agentToken);
247+ 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);
248+ assert.equal(pr.number, 1);
249+
250+ // The pull_request run executes; until then the PR is blocked on the required check.
251+ let m = await ok("GET", `/repos/${owner}/demo/pulls/1/merge`);
252+ assert.equal(m.mergeable, false);
253+ await runOneJob(owner, "demo");
254+ await until("PR check to pass", async () => {
255+ const r = await ok("GET", `/repos/${owner}/demo/pulls/1/merge`);
256+ return r.checks?.length > 0 && r.checks.every((c) => c.state === "success") ? r : null;
257+ });
258+ m = await ok("GET", `/repos/${owner}/demo/pulls/1/merge`);
259+ assert.equal(m.mergeable, false, "agent-authored PRs need a human approval");
260+ assert.ok(JSON.stringify(m.blockers).match(/approv|human/i), JSON.stringify(m.blockers));
261+
262+ // The agent cannot approve or merge its own work.
263+ const agentMerge = await api("PUT", `/repos/${owner}/demo/pulls/1/merge`, { merge_method: "squash" }, agentToken);
264+ assert.ok([403, 405, 409].includes(agentMerge.status), JSON.stringify(agentMerge));
265+
266+ // Another agent's approval is advisory and does not unblock the merge.
267+ const reviewer = (await ok("POST", "/diffinery/agents", { login: "e2e-reviewer" })).token.token;
268+ await ok("POST", `/repos/${owner}/demo/pulls/1/reviews`, { event: "APPROVE", body: "bot says ok" }, reviewer);
269+ assert.equal((await ok("GET", `/repos/${owner}/demo/pulls/1/merge`)).mergeable, false);
270+
271+ // A human reviews (line comment + approval) and merges.
272+ await ok("POST", `/repos/${owner}/demo/pulls/1/reviews`, { event: "APPROVE", body: "LGTM", comments: [{ path: "app.txt", line: 4, body: "nice line" }] });
273+ m = await ok("GET", `/repos/${owner}/demo/pulls/1/merge`);
274+ assert.equal(m.mergeable, true, JSON.stringify(m));
275+ const merged = await ok("PUT", `/repos/${owner}/demo/pulls/1/merge`, { merge_method: "squash", delete_branch: true });
276+ assert.ok(merged.sha);
277+ const prAfter = await ok("GET", `/repos/${owner}/demo/pulls/1`);
278+ assert.equal(prAfter.merged, true);
279+
280+ // ------------------------------------------------------------ fetch the merge, read through REST
281+ git(clone, ["pull", "-q", "--ff-only", "origin", "main"]);
282+ assert.equal(readFileSync(join(clone, "app.txt"), "utf8"), "one\ntwo\nthree\nfour\n");
283+ const content = await ok("GET", `/repos/${owner}/demo/contents/app.txt`);
284+ assert.equal(Buffer.from(content.content, "base64").toString(), "one\ntwo\nthree\nfour\n");
285+ const branches = await ok("GET", `/repos/${owner}/demo/branches`);
286+ assert.ok(!branches.some((b) => b.name === "feature"), "head branch deleted after merge");
287+
288+ // ------------------------------------------------------------ shallow clone, then deepen
289+ const shallow = join(work, "shallow");
290+ git(work, ["clone", "-q", "--depth", "1", `${base}/${owner}/demo.git`, shallow]);
291+ assert.equal(git(shallow, ["rev-list", "--count", "HEAD"]).trim(), "1");
292+ assert.equal(readFileSync(join(shallow, "app.txt"), "utf8"), "one\ntwo\nthree\nfour\n");
293+ git(shallow, ["fetch", "-q", "--unshallow"]);
294+ assert.equal(git(shallow, ["rev-list", "--count", "HEAD"]).trim(), "2");
295+ git(shallow, ["fsck", "--no-progress"]);
296+
297+ // ------------------------------------------------------------ MCP: the agent works without git
298+ const rpc = async (method, params, id = 1) => {
299+ 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 }) });
300+ assert.equal(r.status, 200);
301+ return (await r.json()).result;
302+ };
303+ const init = await rpc("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "e2e", version: "1" } });
304+ assert.equal(init.serverInfo.name, "diffinery");
305+ const tools = await rpc("tools/list", {});
306+ assert.ok(tools.tools.some((t) => t.name === "commit_files"));
307+ const file = await rpc("tools/call", { name: "get_file", arguments: { owner, repo: "demo", path: "README.md" } });
308+ assert.equal(file.isError, false);
309+ assert.equal(file.structuredContent.content, "# demo\n");
310+ 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" }] } });
311+ assert.equal(commit.isError, false, JSON.stringify(commit));
312+ const blocked = await rpc("tools/call", { name: "commit_files", arguments: { owner, repo: "demo", branch: "main", message: "sneaky", files: [{ path: "x", content: "y" }] } });
313+ assert.equal(blocked.isError, true);
314+ const opened = await rpc("tools/call", { name: "create_pull_request", arguments: { owner, repo: "demo", title: "Notes", head: "mcp-change", intent: "Add notes" } });
315+ assert.equal(opened.isError, false, JSON.stringify(opened));
316+ assert.equal(opened.structuredContent.number, 2);
317+
318+ // ------------------------------------------------------------ web UI renders the same data (signed in via DEV_AUTH)
319+ for (const path of ["/", `/${owner}`, `/${owner}/demo`, `/${owner}/demo/blob/main/app.txt`, `/${owner}/demo/commits/main`, `/${owner}/demo/pulls`, `/${owner}/demo/pull/1`, `/${owner}/demo/pull/1/files`, `/${owner}/demo/actions`, `/${owner}/demo/actions/runs/${pushRun.id}`, `/${owner}/demo/actions/runs/${pushRun.id}/job/${job1.claim.job_id}`, `/${owner}/demo/settings/branches`, "/settings/tokens"]) {
320+ const res = await fetch(`${base}${path}`);
321+ const html = await res.text();
322+ assert.equal(res.status, 200, `${path} -> ${res.status}\n${html.slice(0, 500)}`);
323+ assert.match(res.headers.get("content-type") ?? "", /text\/html/, path);
324+ }
325+ assert.match(await (await fetch(`${base}/${owner}/demo/pull/1`)).text(), /Add a fourth line/);
326+ const forged = await fetch(`${base}/${owner}/demo/issues/1/comment`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", origin: "https://evil.example", "sec-fetch-site": "cross-site" }, body: "body=hi" });
327+ assert.equal(forged.status, 403);
328+
329+ // ------------------------------------------------------------ the CLI against the same server
330+ const CLI = resolve(APP, "../cli/dist/diffinery.mjs");
331+ if (existsSync(CLI)) {
332+ const cli = (...args) => execFileSync(process.execPath, [CLI, ...args], { encoding: "utf8", cwd: clone, env: { ...process.env, DIFFINERY_URL: base, DIFFINERY_TOKEN: token, NO_COLOR: "1", XDG_CONFIG_HOME: join(work, "cfg") } });
333+ assert.match(cli("whoami"), new RegExp(owner));
334+ const prs = JSON.parse(cli("pr", "list", "--state", "all", "--json"));
335+ assert.ok(prs.some((p) => p.number === 1));
336+ assert.match(cli("workflow", "validate"), /ci\.yml/);
337+ }
338+
339+ // The commit made through MCP carries the agent's identity.
340+ const agentCommit = await ok("GET", `/repos/${owner}/demo/commits/mcp-change`);
341+ assert.match(JSON.stringify(agentCommit), /e2e-bot/);
342+});
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/test/e2e/passkeys.test.mjs+236−0View file
@@ -1,0 +1,236 @@
1+// End-to-end passkey sign-in: a real `wrangler dev` Worker and a real Chromium
2+// with a virtual WebAuthn authenticator (Chrome DevTools Protocol).
3+//
4+// pnpm --filter @diffinery/app e2e
5+//
6+// Covers: signed-out redirects, joining from an invite link with a new passkey
7+// (which also applies the invite's org membership), adding and removing
8+// passkeys, signing out and back in, admin-issued one-time sign-in links,
9+// single-use invites and links, and refusing cross-site and token-driven calls.
10+import { spawn, execFileSync } from "node:child_process";
11+import { createHash } from "node:crypto";
12+import { mkdtempSync, rmSync } from "node:fs";
13+import { tmpdir } from "node:os";
14+import { join, dirname, resolve } from "node:path";
15+import { fileURLToPath } from "node:url";
16+import { createServer } from "node:net";
17+import { test, before, after } from "node:test";
18+import assert from "node:assert/strict";
19+import { chromium } from "playwright";
20+
21+const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
22+const work = mkdtempSync(join(tmpdir(), "diffinery-passkeys-"));
23+let base;
24+let dev;
25+let devLog = "";
26+let browser;
27+
28+const INVITE = "invite-token-for-pal-0123456789abcdef";
29+const ADMIN_LINK = "signin-link-for-boss-0123456789abcdef";
30+const hash = (s) => createHash("sha256").update(s).digest("hex");
31+
32+const freePort = () =>
33+ new Promise((res) => {
34+ const s = createServer();
35+ s.listen(0, "127.0.0.1", () => {
36+ const { port } = s.address();
37+ s.close(() => res(port));
38+ });
39+ });
40+
41+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
42+async function until(what, fn, timeoutMs = 60_000) {
43+ const end = Date.now() + timeoutMs;
44+ while (Date.now() < end) {
45+ const v = await fn();
46+ if (v) return v;
47+ await sleep(500);
48+ }
49+ assert.fail(`timed out waiting for ${what}\n--- wrangler log tail ---\n${devLog.slice(-4000)}`);
50+}
51+
52+/** A fresh browser profile with its own virtual platform authenticator (Touch ID-like, user-verifying). */
53+async function person() {
54+ const context = await browser.newContext();
55+ const page = await context.newPage();
56+ page.on("dialog", (d) => d.accept());
57+ const cdp = await context.newCDPSession(page);
58+ await cdp.send("WebAuthn.enable");
59+ const addAuthenticator = async () =>
60+ (
61+ await cdp.send("WebAuthn.addVirtualAuthenticator", {
62+ options: { protocol: "ctap2", transport: "internal", hasResidentKey: true, hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true },
63+ })
64+ ).authenticatorId;
65+ let authenticatorId = await addAuthenticator();
66+ return {
67+ context,
68+ page,
69+ /** Swap in a new, empty authenticator (another device). */
70+ async newDevice() {
71+ await cdp.send("WebAuthn.removeVirtualAuthenticator", { authenticatorId });
72+ authenticatorId = await addAuthenticator();
73+ },
74+ async credentials() {
75+ return (await cdp.send("WebAuthn.getCredentials", { authenticatorId })).credentials;
76+ },
77+ };
78+}
79+
80+const text = async (page) => (await page.textContent("main")) ?? "";
81+
82+before(async () => {
83+ const port = await freePort();
84+ // WebAuthn needs a secure context and a domain RP ID: localhost qualifies, 127.0.0.1 does not.
85+ base = `http://localhost:${port}`;
86+ const persist = join(work, "state");
87+ const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
88+ wrangler("d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist);
89+ const at = new Date().toISOString();
90+ const later = new Date(Date.now() + 86400_000).toISOString();
91+ wrangler(
92+ "d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command",
93+ [
94+ `INSERT INTO accounts (id, login, kind, name, email, status, site_admin, created_at) VALUES ('boss1', 'boss', 'user', 'The Boss', 'boss@example.com', 'active', 1, '${at}')`,
95+ `INSERT INTO accounts (id, login, kind, name, status, created_at) VALUES ('crew1', 'crew', 'org', 'Crew', 'active', '${at}')`,
96+ `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('crew1', 'boss1', 'owner', '${at}')`,
97+ `INSERT INTO invites (id, org_id, email, role, token_hash, invited_by, created_at, expires_at) VALUES ('inv1', 'crew1', 'pal@example.com', 'member', '${hash(INVITE)}', 'boss1', '${at}', '${later}')`,
98+ `INSERT INTO signin_links (id, token_hash, account_id, created_by, created_at, expires_at) VALUES ('link1', '${hash(ADMIN_LINK)}', 'boss1', 'boss1', '${at}', '${later}')`,
99+ ].join("; "),
100+ );
101+ dev = spawn(
102+ "pnpm",
103+ ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--var", "DEV_AUTH:", "--show-interactive-dev-session=false"],
104+ { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true },
105+ );
106+ dev.stdout.on("data", (d) => (devLog += d));
107+ dev.stderr.on("data", (d) => (devLog += d));
108+ await until(
109+ "wrangler dev",
110+ async () => {
111+ try {
112+ return (await fetch(`http://127.0.0.1:${port}/healthz`)).ok;
113+ } catch {
114+ return false;
115+ }
116+ },
117+ 120_000,
118+ );
119+ browser = await chromium.launch();
120+});
121+
122+after(async () => {
123+ await browser?.close();
124+ if (dev?.pid) {
125+ try {
126+ process.kill(-dev.pid, "SIGTERM");
127+ } catch {}
128+ }
129+ if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true });
130+});
131+
132+test("passkey sign-in, invites and sign-in links", async () => {
133+ // ------------------------------------------------------------ signed out
134+ const pal = await person();
135+ const { page } = pal;
136+ await page.goto(`${base}/settings`);
137+ assert.equal(new URL(page.url()).pathname, "/login");
138+ assert.equal(new URL(page.url()).searchParams.get("next"), "/settings");
139+ assert.match(await text(page), /Sign in with a passkey/);
140+
141+ // A passkey nobody registered is refused.
142+ await page.click("text=Sign in with a passkey");
143+ await page.waitForSelector("[data-passkey-msg]:not([hidden])");
144+ assert.match(await page.textContent("[data-passkey-msg]"), /passkey|cancelled|closed/i);
145+
146+ // ------------------------------------------------------------ join from an invite link
147+ await page.goto(`${base}/invite/${INVITE}`);
148+ assert.match(await text(page), /invited as pal@example\.com/);
149+ await page.fill("#login", "pal");
150+ await page.fill("#name", "Pal Friend");
151+ await Promise.all([page.waitForURL(`${base}/`), page.click("text=Create a passkey and join")]);
152+ assert.match(await text(page), /Welcome to Diffinery, pal/);
153+ assert.equal((await pal.credentials()).length, 1);
154+ const cookies = await pal.context.cookies(base);
155+ const session = cookies.find((c) => c.name === "__Host-dfy_session");
156+ assert.ok(session, `session cookie missing: ${JSON.stringify(cookies.map((c) => c.name))}`);
157+ assert.equal(session.httpOnly, true);
158+ assert.equal(session.sameSite, "Lax");
159+ // The invite's org membership was applied.
160+ await page.goto(`${base}/settings/orgs`);
161+ assert.match(await text(page), /crew/);
162+
163+ // Invites work once.
164+ const stranger = await person();
165+ const reused = await stranger.page.goto(`${base}/invite/${INVITE}`);
166+ assert.equal(reused.status(), 404);
167+
168+ // ------------------------------------------------------------ manage passkeys
169+ await page.goto(`${base}/settings/security`);
170+ let body = await text(page);
171+ assert.match(body, /Passkeys and sessions/);
172+ assert.match(body, /this browser/);
173+ assert.match(body, /Only passkey/);
174+ await pal.newDevice();
175+ await page.fill("#pklabel", "Laptop");
176+ await Promise.all([page.waitForLoadState("load"), page.click("button:has-text('Add a passkey')")]);
177+ await page.waitForSelector("text=Passkey \"Laptop\" added.");
178+ body = await text(page);
179+ assert.match(body, /Laptop/);
180+ assert.equal(await page.locator("button:has-text('Remove')").count(), 2);
181+ // Remove the first device's passkey (the one this browser no longer holds). This browser stays signed in.
182+ await Promise.all([page.waitForURL(`${base}/settings/security`), page.locator("li:not(:has-text('Laptop')) button:has-text('Remove')").first().click()]);
183+ await page.waitForSelector("text=Passkey removed.");
184+ body = await text(page);
185+ assert.match(body, /Only passkey/);
186+ assert.match(body, /Laptop/);
187+
188+ // ------------------------------------------------------------ sign out, then in with the remaining passkey
189+ await Promise.all([page.waitForURL(/\/login$/), page.click("header button:has-text('Sign out')")]);
190+ await page.goto(`${base}/settings`);
191+ assert.equal(new URL(page.url()).pathname, "/login");
192+ await page.goto(`${base}/login?next=/settings/security`);
193+ await Promise.all([page.waitForURL(`${base}/settings/security`), page.click("text=Sign in with a passkey")]);
194+ assert.match(await text(page), /Laptop/);
195+
196+ // ------------------------------------------------------------ admin sign-in links
197+ const boss = await person();
198+ await boss.page.goto(`${base}/signin/${ADMIN_LINK}`);
199+ assert.match(await text(boss.page), /add a passkey to boss/);
200+ await Promise.all([boss.page.waitForURL(`${base}/settings/security`), boss.page.click("button:has-text('Create a passkey')")]);
201+ assert.match(await text(boss.page), /Use it to sign in from now on/);
202+ const usedLink = await stranger.page.goto(`${base}/signin/${ADMIN_LINK}`);
203+ assert.equal(usedLink.status(), 404);
204+
205+ // The admin issues a link for pal (say pal lost the laptop), and pal uses it on a new device.
206+ await boss.page.goto(`${base}/settings/admin`);
207+ await Promise.all([boss.page.waitForLoadState("load"), boss.page.locator("tr:has-text('pal@example.com') button:has-text('Sign-in link')").click()]);
208+ await boss.page.waitForSelector("text=Sign-in link for pal");
209+ const link = (await boss.page.textContent(".secret")).trim();
210+ assert.match(link, new RegExp(`^${base}/signin/`));
211+ await pal.newDevice();
212+ await page.goto(link);
213+ await Promise.all([page.waitForURL(`${base}/settings/security`), page.click("button:has-text('Create a passkey')")]);
214+ assert.equal(await page.locator("button:has-text('Remove')").count(), 2);
215+
216+ // ------------------------------------------------------------ refused requests
217+ const cross = await fetch(`${base}/auth/passkey/register/options`, { method: "POST", headers: { "content-type": "application/json", origin: "https://evil.example", "sec-fetch-site": "cross-site" }, body: "{}" });
218+ assert.equal(cross.status, 403);
219+ const form = await fetch(`${base}/auth/passkey/login/verify`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: "a=b" });
220+ assert.equal(form.status, 415);
221+ const noChallenge = await fetch(`${base}/auth/passkey/login/verify`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ response: { id: "x" } }) });
222+ assert.equal(noChallenge.status, 400);
223+ const anonymous = await fetch(`${base}/auth/passkey/register/options`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" });
224+ assert.equal(anonymous.status, 401);
225+
226+ // Screenshots for review (kept with E2E_KEEP=1).
227+ if (process.env.E2E_SCREENSHOTS) {
228+ const out = process.env.E2E_SCREENSHOTS;
229+ await stranger.page.goto(`${base}/login`);
230+ await stranger.page.screenshot({ path: join(out, "login.png"), fullPage: true });
231+ await page.goto(`${base}/settings/security`);
232+ await page.screenshot({ path: join(out, "security.png"), fullPage: true });
233+ await boss.page.goto(`${base}/settings/admin`);
234+ await boss.page.screenshot({ path: join(out, "admin.png"), fullPage: true });
235+ }
236+});
addedapps/diffinery/tsconfig.json+11−0View file
@@ -1,0 +1,11 @@
1+{
2+ "extends": "../../tsconfig.base.json",
3+ "compilerOptions": {
4+ "noEmit": true,
5+ "lib": ["ES2023"],
6+ "types": ["@cloudflare/workers-types"],
7+ "jsx": "react-jsx",
8+ "jsxImportSource": "hono/jsx"
9+ },
10+ "include": ["src"]
11+}
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+}
addedcontainers/runner/.dockerignore+2−0View file
@@ -1,0 +1,2 @@
1+*
2+!dist/agent.mjs
addedcontainers/runner/Dockerfile+51−0View file
@@ -1,0 +1,51 @@
1+# Diffinery runner image (Cloudflare Containers, linux/amd64).
2+# Build context: containers/runner. Run `pnpm build` first so dist/agent.mjs exists.
3+#
4+# docker build --platform linux/amd64 -t diffinery-runner containers/runner
5+ARG BASE_IMAGE=ubuntu:24.04
6+FROM ${BASE_IMAGE}
7+
8+ARG NODE_VERSION=22.22.0
9+
10+ENV DEBIAN_FRONTEND=noninteractive \
11+ LANG=C.UTF-8 \
12+ LC_ALL=C.UTF-8 \
13+ DIFFINERY_WORK_ROOT=/workspace \
14+ RUNNER_TOOL_CACHE=/opt/hostedtoolcache \
15+ AGENT_TOOLSDIRECTORY=/opt/hostedtoolcache \
16+ COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
17+ # Ubuntu 24.04 marks the system Python as externally managed (PEP 668);
18+ # CI scripts commonly `pip install` directly, so allow it like hosted runners do.
19+ PIP_BREAK_SYSTEM_PACKAGES=1
20+
21+RUN apt-get update \
22+ && apt-get install -y --no-install-recommends \
23+ ca-certificates curl git jq zip unzip xz-utils tar gzip \
24+ build-essential pkg-config openssh-client gnupg \
25+ python3 python3-pip python3-venv python-is-python3 \
26+ && git config --system --add safe.directory '*' \
27+ && apt-get clean \
28+ && rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
29+
30+# Node.js LTS from the official tarball (checksum-verified), with corepack shims for pnpm/yarn.
31+RUN set -eux; \
32+ cd /tmp; \
33+ file="node-v${NODE_VERSION}-linux-x64.tar.xz"; \
34+ curl -fsSLO "https://nodejs.org/dist/v${NODE_VERSION}/${file}"; \
35+ curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/SHASUMS256.txt" | grep " ${file}\$" | sha256sum -c -; \
36+ tar -xJf "${file}" -C /usr/local --strip-components=1 --no-same-owner \
37+ --exclude='*/CHANGELOG.md' --exclude='*/README.md' --exclude='*/LICENSE' --exclude='*/include/*' --exclude='*/share/doc/*'; \
38+ rm -f "${file}"; \
39+ corepack enable; \
40+ corepack prepare pnpm@10.28.0 --activate; \
41+ corepack prepare yarn@1.22.22 --activate; \
42+ node --version; npm --version; pnpm --version; yarn --version; \
43+ npm cache clean --force; \
44+ rm -rf /root/.npm /tmp/*
45+
46+RUN mkdir -p /workspace /opt/hostedtoolcache /opt/diffinery
47+COPY dist/agent.mjs /opt/diffinery/agent.mjs
48+
49+WORKDIR /workspace
50+EXPOSE 8080
51+ENTRYPOINT ["node", "/opt/diffinery/agent.mjs"]
addedcontainers/runner/package.json+13−0View file
@@ -1,0 +1,13 @@
1+{
2+ "name": "@diffinery/runner",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "module",
6+ "scripts": {
7+ "build": "esbuild src/main.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/agent.mjs \"--banner:js=import { createRequire as __diffineryCreateRequire } from 'node:module'; const require = __diffineryCreateRequire(import.meta.url);\"",
8+ "typecheck": "tsc --noEmit",
9+ "test": "vitest run"
10+ },
11+ "dependencies": { "@diffinery/shared": "workspace:*", "@diffinery/workflow-yaml": "workspace:*" },
12+ "devDependencies": { "typescript": "^5.9.3", "vitest": "^4.1.0", "esbuild": "^0.25.0", "@types/node": "^22.0.0" }
13+}
addedcontainers/runner/src/agent.ts+65−0View file
@@ -1,0 +1,65 @@
1+import type { JobCompletion, JobSpec } from "@diffinery/shared";
2+import { LogBatcher, RunnerApi } from "./api.js";
3+import { JobRunner, errMsg } from "./job.js";
4+
5+export interface AgentOptions {
6+ /** `${apiUrl}/runner/jobs/${jobId}`, or null for offline mode (logs to stdout, no reports). */
7+ apiBase: string | null;
8+ workRoot: string;
9+ toolCache?: string;
10+ echoLogs?: boolean;
11+ /** Install a SIGTERM/SIGINT handler that cancels the job. */
12+ handleSignals?: boolean;
13+ /** Exposes the runner (e.g. so tests can cancel). */
14+ onRunner?: (r: JobRunner) => void;
15+ logIntervalMs?: number;
16+ baseEnv?: NodeJS.ProcessEnv;
17+}
18+
19+export function apiBaseFor(apiUrl: string | undefined | null, jobId: string): string | null {
20+ if (!apiUrl) return null;
21+ return `${apiUrl.replace(/\/$/, "")}/runner/jobs/${encodeURIComponent(jobId)}`;
22+}
23+
24+/** Runs one job end-to-end: execute steps, ship logs, POST completion. */
25+export async function runAgentJob(spec: JobSpec, o: AgentOptions): Promise<JobCompletion> {
26+ const api = new RunnerApi(o.apiBase, spec.token);
27+ const batcher = api.online
28+ ? new LogBatcher((lines) => api.postLogs(lines), {
29+ intervalMs: o.logIntervalMs ?? 500,
30+ onError: (e) => process.stderr.write(`[diffinery-runner] log upload failed: ${errMsg(e)}\n`),
31+ })
32+ : null;
33+ const runner = new JobRunner(spec, api, null, { workRoot: o.workRoot, toolCache: o.toolCache, echoLogs: o.echoLogs ?? !api.online, baseEnv: o.baseEnv }, batcher);
34+ o.onRunner?.(runner);
35+ const onSignal = (sig: string) => {
36+ runner.logger.line(-1, `##[error]Received ${sig}; cancelling the job.`);
37+ runner.cancel("cancelled");
38+ };
39+ const sigterm = () => onSignal("SIGTERM");
40+ const sigint = () => onSignal("SIGINT");
41+ if (o.handleSignals) {
42+ process.on("SIGTERM", sigterm);
43+ process.on("SIGINT", sigint);
44+ }
45+ let completion: JobCompletion;
46+ try {
47+ completion = await runner.run();
48+ } catch (e) {
49+ completion = { conclusion: "failure", outputs: {}, failure: { message: `Runner error: ${errMsg(e)}` } };
50+ }
51+ if (batcher) {
52+ const ok = await batcher.close(120_000);
53+ if (!ok) process.stderr.write(`[diffinery-runner] gave up shipping ${batcher.pending} log lines\n`);
54+ }
55+ try {
56+ await api.complete(completion);
57+ } catch (e) {
58+ process.stderr.write(`[diffinery-runner] failed to report completion: ${errMsg(e)}\n`);
59+ }
60+ if (o.handleSignals) {
61+ process.off("SIGTERM", sigterm);
62+ process.off("SIGINT", sigint);
63+ }
64+ return completion;
65+}
addedcontainers/runner/src/api.ts+222−0View file
@@ -1,0 +1,222 @@
1+import { createReadStream } from "node:fs";
2+import { stat } from "node:fs/promises";
3+import { Readable } from "node:stream";
4+import type { JobCompletion, JobSpec, LogLine, StepReport } from "@diffinery/shared";
5+
6+export class HttpError extends Error {
7+ constructor(
8+ public status: number,
9+ message: string,
10+ public body?: string,
11+ ) {
12+ super(message);
13+ }
14+}
15+
16+const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
17+
18+export interface RequestOptions {
19+ method?: string;
20+ /** JSON body, or a factory producing a raw body (re-invoked on retry). */
21+ json?: unknown;
22+ body?: () => Promise<{ body: NonNullable<RequestInit["body"]>; length?: number; contentType?: string }>;
23+ retries?: number;
24+ /** Return the response for these statuses instead of throwing. */
25+ allow?: number[];
26+ headers?: Record<string, string>;
27+}
28+
29+/**
30+ * Client for `${apiUrl}/runner/jobs/${jobId}` endpoints. When `base` is null
31+ * (offline `--spec` mode) all calls are no-ops.
32+ */
33+export class RunnerApi {
34+ constructor(
35+ public readonly base: string | null,
36+ private readonly token: string,
37+ private readonly opts: { minBackoffMs?: number; maxBackoffMs?: number } = {},
38+ ) {}
39+
40+ get online(): boolean {
41+ return this.base !== null;
42+ }
43+
44+ url(path: string): string {
45+ return `${this.base}${path}`;
46+ }
47+
48+ authHeaders(): Record<string, string> {
49+ return { authorization: `Bearer ${this.token}` };
50+ }
51+
52+ async request(path: string, o: RequestOptions = {}): Promise<Response> {
53+ if (!this.base) throw new Error("runner API not configured");
54+ return this.requestUrl(this.url(path), o);
55+ }
56+
57+ async requestUrl(url: string, o: RequestOptions = {}, withAuth = true): Promise<Response> {
58+ const retries = o.retries ?? 6;
59+ const min = this.opts.minBackoffMs ?? 500;
60+ const max = this.opts.maxBackoffMs ?? 15000;
61+ let lastErr: unknown;
62+ for (let attempt = 0; attempt <= retries; attempt++) {
63+ if (attempt > 0) await sleep(Math.min(max, min * 2 ** (attempt - 1)));
64+ try {
65+ const headers: Record<string, string> = { ...(withAuth ? this.authHeaders() : {}), ...(o.headers ?? {}) };
66+ const init: RequestInit & { duplex?: string } = { method: o.method ?? "GET", headers };
67+ if (o.json !== undefined) {
68+ headers["content-type"] = "application/json";
69+ init.body = JSON.stringify(o.json);
70+ } else if (o.body) {
71+ const b = await o.body();
72+ init.body = b.body;
73+ if (b.contentType) headers["content-type"] = b.contentType;
74+ if (b.length !== undefined) headers["content-length"] = String(b.length);
75+ init.duplex = "half";
76+ }
77+ const res = await fetch(url, init);
78+ if (res.ok || o.allow?.includes(res.status)) return res;
79+ const text = await res.text().catch(() => "");
80+ const err = new HttpError(res.status, `${init.method} ${redactUrl(url)} -> ${res.status} ${text.slice(0, 300)}`, text);
81+ if (res.status === 408 || res.status === 429 || res.status >= 500) {
82+ lastErr = err;
83+ continue;
84+ }
85+ throw err;
86+ } catch (e) {
87+ if (e instanceof HttpError && !(e.status === 408 || e.status === 429 || e.status >= 500)) throw e;
88+ lastErr = e;
89+ }
90+ }
91+ throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
92+ }
93+
94+ async getSpec(): Promise<JobSpec> {
95+ const res = await this.request("", { retries: 8 });
96+ return (await res.json()) as JobSpec;
97+ }
98+
99+ async postLogs(lines: LogLine[]): Promise<void> {
100+ if (!this.base) return;
101+ await this.request("/logs", { method: "POST", json: { lines }, retries: 0 });
102+ }
103+
104+ async postStep(index: number, report: StepReport): Promise<void> {
105+ if (!this.base) return;
106+ await this.request(`/steps/${index}`, { method: "POST", json: report });
107+ }
108+
109+ async complete(c: JobCompletion): Promise<void> {
110+ if (!this.base) return;
111+ await this.request("/complete", { method: "POST", json: c, retries: 10 });
112+ }
113+
114+ /** Looks up a cache entry. Returns null on miss. */
115+ async cacheLookup(key: string, restoreKeys: string[]): Promise<{ key: string; url: string } | null> {
116+ const q = new URLSearchParams({ key });
117+ if (restoreKeys.length) q.set("restore", restoreKeys.join(","));
118+ const res = await this.request(`/cache?${q}`, { allow: [404, 204] });
119+ if (res.status === 404 || res.status === 204) return null;
120+ const j = (await res.json()) as { key?: string; url?: string } | null;
121+ if (!j || !j.key) return null;
122+ return { key: j.key, url: j.url ?? `${this.base}/cache/${encodeURIComponent(j.key)}` };
123+ }
124+
125+ /** Downloads a URL (relative URLs resolve against the API; auth sent only to the API origin). */
126+ async download(url: string): Promise<Response> {
127+ const abs = new URL(url, this.base ?? undefined).toString();
128+ const sameOrigin = this.base ? new URL(abs).origin === new URL(this.base).origin : false;
129+ return this.requestUrl(abs, {}, sameOrigin);
130+ }
131+
132+ async putFile(path: string, file: string, contentType = "application/gzip"): Promise<Response> {
133+ return this.request(path, {
134+ method: "PUT",
135+ body: async () => {
136+ const s = await stat(file);
137+ return { body: Readable.toWeb(createReadStream(file)) as unknown as NonNullable<RequestInit["body"]>, length: s.size, contentType };
138+ },
139+ allow: [409],
140+ });
141+ }
142+}
143+
144+function redactUrl(u: string): string {
145+ return u.replace(/([?&](?:token|sig|signature)=)[^&]+/gi, "$1***");
146+}
147+
148+/**
149+ * Batches log lines and ships them every `intervalMs` or when `maxLines`
150+ * accumulate. Lines are never dropped: failed batches are retried with
151+ * exponential backoff and kept in order.
152+ */
153+export class LogBatcher {
154+ private queue: LogLine[] = [];
155+ private timer: NodeJS.Timeout | null = null;
156+ private inflight: Promise<void> | null = null;
157+ private failures = 0;
158+ private nextAttemptAt = 0;
159+ private closed = false;
160+
161+ constructor(
162+ private readonly sink: (lines: LogLine[]) => Promise<void>,
163+ private readonly opts: { intervalMs?: number; maxLines?: number; maxBatch?: number; maxBackoffMs?: number; onError?: (e: unknown) => void } = {},
164+ ) {
165+ this.timer = setInterval(() => void this.tick(), this.opts.intervalMs ?? 500);
166+ this.timer.unref?.();
167+ }
168+
169+ push(line: LogLine): void {
170+ this.queue.push(line);
171+ if (this.queue.length >= (this.opts.maxLines ?? 200)) void this.tick();
172+ }
173+
174+ get pending(): number {
175+ return this.queue.length;
176+ }
177+
178+ private tick(): Promise<void> {
179+ if (this.inflight) return this.inflight;
180+ if (this.queue.length === 0) return Promise.resolve();
181+ if (Date.now() < this.nextAttemptAt) return Promise.resolve();
182+ const batch = this.queue.slice(0, this.opts.maxBatch ?? 1000);
183+ this.inflight = this.sink(batch)
184+ .then(() => {
185+ this.queue.splice(0, batch.length);
186+ this.failures = 0;
187+ this.nextAttemptAt = 0;
188+ })
189+ .catch((e) => {
190+ this.failures++;
191+ this.nextAttemptAt = Date.now() + Math.min(this.opts.maxBackoffMs ?? 30000, 250 * 2 ** this.failures);
192+ this.opts.onError?.(e);
193+ })
194+ .finally(() => {
195+ this.inflight = null;
196+ });
197+ return this.inflight;
198+ }
199+
200+ /** Flushes everything, retrying until `deadlineMs` elapses. */
201+ async flush(deadlineMs = 120000): Promise<boolean> {
202+ const until = Date.now() + deadlineMs;
203+ while (this.queue.length > 0 || this.inflight) {
204+ if (Date.now() > until) return false;
205+ if (this.inflight) {
206+ await this.inflight;
207+ continue;
208+ }
209+ const wait = this.nextAttemptAt - Date.now();
210+ if (wait > 0) await sleep(Math.min(wait, Math.max(0, until - Date.now())));
211+ await this.tick();
212+ }
213+ return true;
214+ }
215+
216+ async close(deadlineMs?: number): Promise<boolean> {
217+ if (this.closed) return true;
218+ this.closed = true;
219+ if (this.timer) clearInterval(this.timer);
220+ return this.flush(deadlineMs);
221+ }
222+}
addedcontainers/runner/src/builtins/artifacts.ts+96−0View file
@@ -1,0 +1,96 @@
1+import { randomUUID } from "node:crypto";
2+import { mkdir, rm, stat, writeFile } from "node:fs/promises";
3+import path from "node:path";
4+import { errMsg, failure, StepFailure } from "../job.js";
5+import { globPaths, inputList, truthyInput } from "../util.js";
6+import { downloadTo } from "./cache.js";
7+import type { Builtin } from "./index.js";
8+
9+function lca(dirs: string[]): string {
10+ if (dirs.length === 0) return "/";
11+ let parts = dirs[0]!.split("/");
12+ for (const d of dirs.slice(1)) {
13+ const p = d.split("/");
14+ let i = 0;
15+ while (i < parts.length && i < p.length && parts[i] === p[i]) i++;
16+ parts = parts.slice(0, i);
17+ }
18+ return parts.join("/") || "/";
19+}
20+
21+export const artifactUpload: Builtin = async (c) => {
22+ const name = c.input("name", "artifact");
23+ if (/[":<>|*?\\/\r\n]/.test(name)) throw failure(`The artifact name is not valid: ${name}. Contains invalid characters.`);
24+ const patterns = inputList(c.input("path"));
25+ if (!patterns.length) throw failure("Input required and not supplied: path");
26+ const ifNone = c.input("if-no-files-found", "warn").toLowerCase();
27+ const includeHidden = truthyInput(c.input("include-hidden-files"), false);
28+ let files = globPaths(c.job.workspace, patterns, { filesOnly: true });
29+ if (!includeHidden) files = files.filter((f) => !path.relative(c.job.workspace, f).split("/").some((s) => s.startsWith(".") && s !== ".."));
30+ if (files.length === 0) {
31+ const msg = `No files were found with the provided path: ${patterns.join(", ")}. No artifacts will be uploaded.`;
32+ if (ifNone === "error") throw failure(msg);
33+ if (ifNone === "warn") c.warn(msg);
34+ else c.log(msg);
35+ return;
36+ }
37+ if (!c.job.api.online) {
38+ c.warn("Artifact service is not available in offline mode; skipping upload.");
39+ return;
40+ }
41+ const root = lca(files.map((f) => path.dirname(f)));
42+ const rel = files.map((f) => path.relative(root, f));
43+ c.log(`With the provided path, there will be ${files.length} file(s) uploaded (root: ${root})`);
44+ const list = path.join(c.job.tempDir, `artifact-${randomUUID()}.list`);
45+ const archive = path.join(c.job.tempDir, `artifact-${randomUUID()}.tgz`);
46+ try {
47+ await writeFile(list, rel.join("\0") + "\0");
48+ await c.run("tar", ["-czf", archive, "-C", root, "--null", "-T", list], { silent: true });
49+ const size = (await stat(archive)).size;
50+ const q = new URLSearchParams();
51+ const retention = c.input("retention-days");
52+ if (retention) q.set("retention-days", retention);
53+ if (truthyInput(c.input("overwrite"), false)) q.set("overwrite", "true");
54+ const qs = q.toString();
55+ const res = await c.job.api.putFile(`/artifacts/${encodeURIComponent(name)}${qs ? `?${qs}` : ""}`, archive);
56+ if (res.status === 409) throw failure(`An artifact with the name '${name}' already exists. Set 'overwrite: true' to replace it.`);
57+ let body: { id?: string | number; url?: string } = {};
58+ try {
59+ body = (await res.json()) as typeof body;
60+ } catch {
61+ /* no body */
62+ }
63+ c.log(`Artifact '${name}' uploaded (${size} bytes compressed).`);
64+ if (body.id !== undefined) c.setOutput("artifact-id", String(body.id));
65+ if (body.url) c.setOutput("artifact-url", body.url);
66+ } catch (e) {
67+ if (e instanceof StepFailure) throw e;
68+ throw failure(`Failed to upload artifact '${name}': ${errMsg(e)}`);
69+ } finally {
70+ await rm(list, { force: true });
71+ await rm(archive, { force: true });
72+ }
73+};
74+
75+export const artifactDownload: Builtin = async (c) => {
76+ const name = c.input("name");
77+ if (!name) {
78+ throw failure("download-artifact without `name` (download all / `pattern`) is not supported on Diffinery runners yet; specify `name`.");
79+ }
80+ if (!c.job.api.online) throw failure("Artifact service is not available in offline mode.");
81+ const dest = path.resolve(c.job.workspace, c.input("path", "."));
82+ await mkdir(dest, { recursive: true });
83+ const archive = path.join(c.job.tempDir, `artifact-${randomUUID()}.tgz`);
84+ try {
85+ try {
86+ await downloadTo(c, c.job.api.url(`/artifacts/${encodeURIComponent(name)}`), archive);
87+ } catch (e) {
88+ throw failure(`Unable to download artifact '${name}': ${errMsg(e)}`);
89+ }
90+ await c.run("tar", ["-xzf", archive, "-C", dest], { silent: true });
91+ c.log(`Artifact '${name}' downloaded to ${dest}`);
92+ c.setOutput("download-path", dest);
93+ } finally {
94+ await rm(archive, { force: true });
95+ }
96+};
addedcontainers/runner/src/builtins/cache.ts+130−0View file
@@ -1,0 +1,130 @@
1+import { randomUUID } from "node:crypto";
2+import { createWriteStream } from "node:fs";
3+import { rm, stat } from "node:fs/promises";
4+import path from "node:path";
5+import { Readable } from "node:stream";
6+import { pipeline } from "node:stream/promises";
7+import type { ReadableStream as WebReadableStream } from "node:stream/web";
8+import { errMsg, failure } from "../job.js";
9+import { globPaths, inputList, truthyInput } from "../util.js";
10+import type { Builtin, BuiltinContext } from "./index.js";
11+
12+export async function downloadTo(c: BuiltinContext, url: string, file: string): Promise<void> {
13+ const res = await c.job.api.download(url);
14+ if (!res.body) throw new Error("empty response body");
15+ await pipeline(Readable.fromWeb(res.body as unknown as WebReadableStream), createWriteStream(file));
16+}
17+
18+export interface RestoreResult {
19+ matchedKey?: string;
20+ exact: boolean;
21+}
22+
23+/** Restores a cache entry; never throws for transport errors (warns instead). */
24+export async function restoreCache(c: BuiltinContext, paths: string[], key: string, restoreKeys: string[], lookupOnly = false): Promise<RestoreResult> {
25+ if (!key) throw failure("Input required and not supplied: key");
26+ if (paths.length === 0) throw failure("Input required and not supplied: path");
27+ if (!c.job.api.online) {
28+ c.warn("Cache service is not available in offline mode; skipping restore.");
29+ return { exact: false };
30+ }
31+ let hit: { key: string; url: string } | null;
32+ try {
33+ hit = await c.job.api.cacheLookup(key, restoreKeys);
34+ } catch (e) {
35+ c.warn(`Failed to look up cache: ${errMsg(e)}`);
36+ return { exact: false };
37+ }
38+ if (!hit) {
39+ c.log(`Cache not found for input keys: ${[key, ...restoreKeys].join(", ")}`);
40+ return { exact: false };
41+ }
42+ const exact = hit.key === key;
43+ if (lookupOnly) {
44+ c.log(`Cache found and can be restored from key: ${hit.key}`);
45+ return { matchedKey: hit.key, exact };
46+ }
47+ const file = path.join(c.job.tempDir, `cache-${randomUUID()}.tgz`);
48+ try {
49+ await downloadTo(c, hit.url, file);
50+ const size = (await stat(file)).size;
51+ c.log(`Cache Size: ~${Math.round(size / 1024 / 1024)} MB (${size} B)`);
52+ await c.run("tar", ["-xzPf", file], { silent: true });
53+ c.log(`Cache restored from key: ${hit.key}`);
54+ return { matchedKey: hit.key, exact };
55+ } catch (e) {
56+ c.warn(`Failed to restore cache: ${errMsg(e)}`);
57+ return { exact: false };
58+ } finally {
59+ await rm(file, { force: true });
60+ }
61+}
62+
63+/** Archives `paths` (tar.gz, absolute paths) and uploads it under `key`. */
64+export async function saveCache(c: BuiltinContext, paths: string[], key: string): Promise<boolean> {
65+ if (!c.job.api.online) {
66+ c.warn("Cache service is not available in offline mode; skipping save.");
67+ return false;
68+ }
69+ const resolved = globPaths(c.job.workspace, paths);
70+ if (resolved.length === 0) {
71+ c.warn("Path Validation Error: Path(s) specified in the action for caching do(es) not exist, hence no cache is being saved.");
72+ return false;
73+ }
74+ const file = path.join(c.job.tempDir, `cache-${randomUUID()}.tgz`);
75+ try {
76+ await c.run("tar", ["-czPf", file, "--", ...resolved], { silent: true });
77+ const size = (await stat(file)).size;
78+ c.log(`Cache Size: ~${Math.round(size / 1024 / 1024)} MB (${size} B)`);
79+ const res = await c.job.api.putFile(`/cache/${encodeURIComponent(key)}`, file);
80+ if (res.status === 409) {
81+ c.log(`Cache entry with key ${key} already exists; not saving.`);
82+ return false;
83+ }
84+ c.log(`Cache saved with key: ${key}`);
85+ return true;
86+ } catch (e) {
87+ c.warn(`Failed to save cache: ${errMsg(e)}`);
88+ return false;
89+ } finally {
90+ await rm(file, { force: true });
91+ }
92+}
93+
94+export const cacheAction: Builtin = async (c) => {
95+ const key = c.input("key");
96+ const paths = inputList(c.input("path"));
97+ const restoreKeys = inputList(c.input("restore-keys"));
98+ const r = await restoreCache(c, paths, key, restoreKeys, truthyInput(c.input("lookup-only"), false));
99+ c.setOutput("cache-hit", r.exact ? "true" : "false");
100+ c.setOutput("cache-primary-key", key);
101+ c.setOutput("cache-matched-key", r.matchedKey ?? "");
102+ if (!r.matchedKey && truthyInput(c.input("fail-on-cache-miss"), false)) {
103+ throw failure(`Failed to restore cache entry. Exiting as fail-on-cache-miss is set. Input key: ${key}`);
104+ }
105+ if (r.exact) return;
106+ const saveAlways = truthyInput(c.input("save-always"), false);
107+ c.registerPost(`Post ${c.name}`, saveAlways ? "always()" : "success()", async (pc) => {
108+ await saveCache(pc, paths, key);
109+ });
110+};
111+
112+export const cacheRestoreAction: Builtin = async (c) => {
113+ const key = c.input("key");
114+ const paths = inputList(c.input("path"));
115+ const r = await restoreCache(c, paths, key, inputList(c.input("restore-keys")), truthyInput(c.input("lookup-only"), false));
116+ c.setOutput("cache-hit", r.exact ? "true" : "false");
117+ c.setOutput("cache-primary-key", key);
118+ c.setOutput("cache-matched-key", r.matchedKey ?? "");
119+ if (!r.matchedKey && truthyInput(c.input("fail-on-cache-miss"), false)) {
120+ throw failure(`Failed to restore cache entry. Exiting as fail-on-cache-miss is set. Input key: ${key}`);
121+ }
122+};
123+
124+export const cacheSaveAction: Builtin = async (c) => {
125+ const key = c.input("key");
126+ if (!key) throw failure("Input required and not supplied: key");
127+ const paths = inputList(c.input("path"));
128+ if (!paths.length) throw failure("Input required and not supplied: path");
129+ await saveCache(c, paths, key);
130+};
addedcontainers/runner/src/builtins/checkout.ts+207−0View file
@@ -1,0 +1,207 @@
1+import { existsSync } from "node:fs";
2+import { mkdir, readdir, rm } from "node:fs/promises";
3+import path from "node:path";
4+import { failure } from "../job.js";
5+import { truthyInput } from "../util.js";
6+import type { Builtin, BuiltinContext } from "./index.js";
7+
8+export function basicAuthHeader(token: string): string {
9+ return `AUTHORIZATION: basic ${Buffer.from(`x-access-token:${token}`).toString("base64")}`;
10+}
11+
12+/** Derives the clone URL of `owner/repo` from the job's own clone URL. */
13+export function cloneUrlFor(spec: { repo: { owner: string; name: string; cloneUrl: string } }, repository: string): string {
14+ const own = `${spec.repo.owner}/${spec.repo.name}`;
15+ if (repository.toLowerCase() === own.toLowerCase()) return spec.repo.cloneUrl;
16+ const u = spec.repo.cloneUrl;
17+ const idx = u.toLowerCase().lastIndexOf(own.toLowerCase());
18+ if (idx >= 0) return u.slice(0, idx) + repository + u.slice(idx + own.length);
19+ try {
20+ const parsed = new URL(u);
21+ return `${parsed.origin}/${repository}.git`;
22+ } catch {
23+ return u;
24+ }
25+}
26+
27+function isHttp(url: string): boolean {
28+ return /^https?:\/\//i.test(url);
29+}
30+
31+export const checkout: Builtin = async (c) => {
32+ const spec = c.job.spec;
33+ const ownRepo = `${spec.repo.owner}/${spec.repo.name}`;
34+ const repository = c.input("repository", ownRepo);
35+ const isOwn = repository.toLowerCase() === ownRepo.toLowerCase();
36+ const token = c.input("token", spec.token);
37+ const refInput = c.input("ref");
38+ const depthRaw = Number(c.input("fetch-depth", "1"));
39+ const fetchDepth = Number.isFinite(depthRaw) && depthRaw >= 0 ? Math.floor(depthRaw) : 1;
40+ const dest = path.resolve(c.job.workspace, c.input("path", "."));
41+ const clean = truthyInput(c.input("clean"), true);
42+ const submodules = c.input("submodules", "false").toLowerCase();
43+ const persist = truthyInput(c.input("persist-credentials"), true);
44+ const fetchTags = truthyInput(c.input("fetch-tags"), false);
45+ if (truthyInput(c.input("lfs"), false)) c.warn("Git LFS is not supported by the Diffinery checkout built-in; LFS pointers will not be resolved.");
46+ if (c.input("sparse-checkout")) c.warn("sparse-checkout is not supported by the Diffinery checkout built-in; doing a full checkout.");
47+
48+ const url = cloneUrlFor(spec, repository);
49+ const header = basicAuthHeader(token);
50+ c.job.masker.add(token);
51+ c.job.masker.add(header.slice("AUTHORIZATION: basic ".length));
52+ const authArgs = isHttp(url) && token ? ["-c", `http.extraheader=${header}`] : [];
53+ const env = c.toolEnv({ GIT_TERMINAL_PROMPT: "0", GIT_LFS_SKIP_SMUDGE: "1" });
54+ const git = (args: string[], o: { silent?: boolean; allowFail?: boolean } = {}) =>
55+ o.allowFail ? c.exec("git", args, { cwd: dest, env, capture: true, silent: o.silent }) : c.run("git", args, { cwd: dest, env, silent: o.silent });
56+
57+ c.log(`Syncing repository: ${repository}`);
58+ await mkdir(dest, { recursive: true });
59+ const hasGit = existsSync(path.join(dest, ".git"));
60+ if (hasGit && clean) {
61+ await git(["clean", "-ffdx"], { allowFail: true });
62+ await git(["reset", "--hard", "HEAD"], { allowFail: true, silent: true });
63+ } else if (!hasGit) {
64+ const entries = await readdir(dest);
65+ if (entries.length && clean) {
66+ c.log(`Deleting the contents of '${dest}'`);
67+ await Promise.all(entries.map((e) => rm(path.join(dest, e), { recursive: true, force: true })));
68+ }
69+ }
70+ if (!hasGit) await git(["init", "-q", dest]);
71+ const setUrl = await git(["remote", "set-url", "origin", url], { allowFail: true, silent: true });
72+ if (setUrl.exitCode !== 0) await git(["remote", "add", "origin", url]);
73+ await git(["config", "--local", "gc.auto", "0"]);
74+
75+ // Decide what to fetch
76+ let sha: string | undefined;
77+ let ref: string | undefined;
78+ if (refInput) {
79+ if (/^[0-9a-f]{40}$/i.test(refInput)) sha = refInput;
80+ else ref = refInput;
81+ } else if (isOwn) {
82+ sha = spec.sha;
83+ ref = spec.ref;
84+ } else {
85+ ref = "HEAD";
86+ }
87+
88+ const depthArgs = fetchDepth > 0 ? [`--depth=${fetchDepth}`] : [];
89+ const tagArgs = fetchDepth === 0 || fetchTags ? [] : ["--no-tags"];
90+ const fetch = (refspecs: string[], allowFail = false) =>
91+ git([...authArgs, "fetch", ...tagArgs, "--prune", "--no-recurse-submodules", ...depthArgs, "origin", ...refspecs], { allowFail });
92+
93+ let checkoutTarget: string | undefined;
94+ let branch: string | undefined;
95+ if (ref?.startsWith("refs/heads/")) branch = ref.slice("refs/heads/".length);
96+
97+ if (fetchDepth === 0) {
98+ c.log("Fetching the repository (full history)");
99+ await fetch(["+refs/heads/*:refs/remotes/origin/*", "+refs/tags/*:refs/tags/*"]);
100+ if (ref && !ref.startsWith("refs/heads/") && !ref.startsWith("refs/tags/") && ref !== "HEAD") {
101+ if (ref.startsWith("refs/")) await fetch([`+${ref}:${ref.replace(/^refs\//, "refs/remotes/")}`], true);
102+ }
103+ checkoutTarget = sha ?? (await resolveRef(git, ref!, (b) => (branch = b)));
104+ } else {
105+ c.log(`Fetching the repository (depth ${fetchDepth})`);
106+ let fetched = false;
107+ if (sha) {
108+ const r = await fetch([`+${sha}:refs/remotes/origin/${branch ?? "diffinery-checkout"}`], true);
109+ fetched = r.exitCode === 0;
110+ if (!fetched) c.log("Fetching by commit SHA failed; falling back to fetching the ref.");
111+ }
112+ if (!fetched && ref) {
113+ const specs = refspecsFor(ref);
114+ let ok = false;
115+ for (const rs of specs) {
116+ const r = await fetch([rs.refspec], true);
117+ if (r.exitCode === 0) {
118+ ok = true;
119+ if (rs.branch) branch = rs.branch;
120+ break;
121+ }
122+ }
123+ if (!ok) throw failure(`Failed to fetch ref '${ref}' from ${repository}. Check that the ref exists and the token has read access.`);
124+ if (!sha) {
125+ const target = specs.find((s) => s.branch === branch)?.local ?? "FETCH_HEAD";
126+ checkoutTarget = target;
127+ }
128+ } else if (!fetched) {
129+ throw failure(`Failed to fetch commit ${sha} from ${repository}.`);
130+ }
131+ checkoutTarget ??= sha ?? "FETCH_HEAD";
132+ }
133+
134+ if (branch) await git(["checkout", "--progress", "--force", "-B", branch, checkoutTarget!]);
135+ else await git(["checkout", "--progress", "--force", checkoutTarget!]);
136+ const head = (await git(["rev-parse", "HEAD"], { silent: true })).stdout.trim();
137+ if (sha && /^[0-9a-f]{40}$/i.test(sha) && head.toLowerCase() !== sha.toLowerCase()) {
138+ throw failure(`Checked out ${head} but expected ${sha}.`);
139+ }
140+ c.log(`HEAD is now at ${head}`);
141+ c.setOutput("commit", head);
142+ c.setOutput("ref", ref && ref !== "HEAD" ? ref : branch ? `refs/heads/${branch}` : "");
143+
144+ if (submodules === "true" || submodules === "recursive") {
145+ c.log("Fetching submodules");
146+ await git(["submodule", "sync", ...(submodules === "recursive" ? ["--recursive"] : [])], { allowFail: true });
147+ const r = await git(
148+ [...authArgs, "submodule", "update", "--init", "--force", ...(fetchDepth > 0 ? [`--depth=${fetchDepth}`] : []), ...(submodules === "recursive" ? ["--recursive"] : [])],
149+ { allowFail: true },
150+ );
151+ if (r.exitCode !== 0) c.warn("Failed to update submodules (best effort).");
152+ }
153+
154+ if (persist && authArgs.length) {
155+ const key = `http.${new URL(url).origin}/.extraheader`;
156+ await git(["config", "--local", key, header], { silent: true });
157+ c.registerPost(`Post ${c.name}`, "always()", async (pc: BuiltinContext) => {
158+ if (!existsSync(path.join(dest, ".git"))) return;
159+ pc.log("Removing persisted credentials");
160+ await pc.exec("git", ["config", "--local", "--unset-all", key], { cwd: dest, capture: true, silent: true });
161+ });
162+ }
163+};
164+
165+function refspecsFor(ref: string): Array<{ refspec: string; local: string; branch?: string }> {
166+ if (ref === "HEAD") return [{ refspec: "HEAD", local: "FETCH_HEAD" }];
167+ if (ref.startsWith("refs/heads/")) {
168+ const b = ref.slice(11);
169+ return [{ refspec: `+${ref}:refs/remotes/origin/${b}`, local: `refs/remotes/origin/${b}`, branch: b }];
170+ }
171+ if (ref.startsWith("refs/tags/")) return [{ refspec: `+${ref}:${ref}`, local: ref }];
172+ if (ref.startsWith("refs/")) {
173+ const local = ref.replace(/^refs\//, "refs/remotes/");
174+ return [{ refspec: `+${ref}:${local}`, local }];
175+ }
176+ return [
177+ { refspec: `+refs/heads/${ref}:refs/remotes/origin/${ref}`, local: `refs/remotes/origin/${ref}`, branch: ref },
178+ { refspec: `+refs/tags/${ref}:refs/tags/${ref}`, local: `refs/tags/${ref}` },
179+ ];
180+}
181+
182+async function resolveRef(
183+ git: (args: string[], o?: { silent?: boolean; allowFail?: boolean }) => Promise<{ exitCode: number; stdout: string }>,
184+ ref: string,
185+ setBranch: (b: string) => void,
186+): Promise<string> {
187+ if (ref === "HEAD") {
188+ const r = await git(["symbolic-ref", "refs/remotes/origin/HEAD"], { allowFail: true, silent: true });
189+ if (r.exitCode === 0) return r.stdout.trim();
190+ }
191+ const short = ref.replace(/^refs\/heads\//, "");
192+ const candidates = ref.startsWith("refs/tags/")
193+ ? [ref]
194+ : ref.startsWith("refs/heads/")
195+ ? [`refs/remotes/origin/${short}`]
196+ : ref.startsWith("refs/")
197+ ? [ref.replace(/^refs\//, "refs/remotes/")]
198+ : [`refs/remotes/origin/${ref}`, `refs/tags/${ref}`];
199+ for (const cand of candidates) {
200+ const r = await git(["rev-parse", "--verify", "--quiet", cand], { allowFail: true, silent: true });
201+ if (r.exitCode === 0) {
202+ if (cand.startsWith("refs/remotes/origin/")) setBranch(cand.slice("refs/remotes/origin/".length));
203+ return cand;
204+ }
205+ }
206+ throw failure(`A branch or tag with the name '${ref}' could not be found`);
207+}
addedcontainers/runner/src/builtins/index.ts+114−0View file
@@ -1,0 +1,114 @@
1+import { failure, type JobRunner, type Scope, type StepIO } from "../job.js";
2+import type { StepExec } from "../steps/run.js";
3+import type { ExecResult } from "../util.js";
4+import { artifactDownload, artifactUpload } from "./artifacts.js";
5+import { cacheAction, cacheRestoreAction, cacheSaveAction } from "./cache.js";
6+import { checkout } from "./checkout.js";
7+import { setupGo } from "./setup-go.js";
8+import { setupNode } from "./setup-node.js";
9+import { setupPython } from "./setup-python.js";
10+
11+export interface BuiltinContext {
12+ job: JobRunner;
13+ io: StepIO;
14+ scope: Scope;
15+ ctx: Record<string, unknown>;
16+ envCtx: Record<string, string>;
17+ uses: string;
18+ name: string;
19+ /** Raw `with:` values (already interpolated); lookups are case-insensitive. */
20+ inputs: Record<string, string>;
21+ input(name: string, dflt?: string): string;
22+ log(msg: string): void;
23+ warn(msg: string): void;
24+ setOutput(name: string, value: string): void;
25+ exportVariable(name: string, value: string): void;
26+ addPath(dir: string): void;
27+ /** Env for child tools (job env + GITHUB_ENV + PATH additions). */
28+ toolEnv(extra?: Record<string, string>): NodeJS.ProcessEnv;
29+ exec(cmd: string, args: string[], o?: { cwd?: string; env?: NodeJS.ProcessEnv; capture?: boolean; silent?: boolean; input?: string }): Promise<ExecResult>;
30+ /** Like exec but throws on non-zero exit. */
31+ run(cmd: string, args: string[], o?: { cwd?: string; env?: NodeJS.ProcessEnv; capture?: boolean; silent?: boolean; input?: string }): Promise<ExecResult>;
32+ registerPost(name: string, condition: string, fn: (c: BuiltinContext) => Promise<void>): void;
33+}
34+
35+export type Builtin = (c: BuiltinContext) => Promise<void>;
36+
37+const githubScript: Builtin = async () => {
38+ throw failure("actions/github-script is not supported on Diffinery runners yet. Use a `run:` step with `node` or `curl` against $GITHUB_API_URL instead.");
39+};
40+
41+const BUILTINS: Record<string, Builtin> = {
42+ "actions/checkout": checkout,
43+ "actions/cache": cacheAction,
44+ "actions/cache/restore": cacheRestoreAction,
45+ "actions/cache/save": cacheSaveAction,
46+ "actions/upload-artifact": artifactUpload,
47+ "actions/download-artifact": artifactDownload,
48+ "actions/setup-node": setupNode,
49+ "actions/setup-python": setupPython,
50+ "actions/setup-go": setupGo,
51+ "actions/github-script": githubScript,
52+};
53+
54+export function getBuiltin(name: string, _ref: string): Builtin | null {
55+ if (process.env.DIFFINERY_DISABLE_BUILTINS === "1") return null;
56+ return BUILTINS[name.toLowerCase()] ?? null;
57+}
58+
59+export function makeBuiltinContext(job: JobRunner, x: StepExec, inputs: Record<string, string>, uses: string): BuiltinContext {
60+ const lower = new Map(Object.entries(inputs).map(([k, v]) => [k.toLowerCase(), v]));
61+ const make = (io: StepIO, envCtx: Record<string, string>, name: string): BuiltinContext => {
62+ const toolEnv = (extra: Record<string, string> = {}) => job.processEnv({ ...envCtx, ...job.envAccum }, extra);
63+ const c: BuiltinContext = {
64+ job,
65+ io,
66+ scope: x.scope,
67+ ctx: x.ctx,
68+ envCtx,
69+ uses,
70+ name,
71+ inputs,
72+ input: (n, d = "") => {
73+ const v = lower.get(n.toLowerCase());
74+ return v === undefined || v === "" ? d : v;
75+ },
76+ log: (m) => job.logger.line(io.logIndex, m),
77+ warn: (m) => {
78+ job.logger.line(io.logIndex, `##[warning]${m}`);
79+ job.addAnnotation(io.logIndex, { level: "warning", message: m });
80+ },
81+ setOutput: (k, v) => {
82+ io.outputs[k] = v;
83+ },
84+ exportVariable: (k, v) => {
85+ job.envAccum[k] = v;
86+ envCtx[k] = v;
87+ },
88+ addPath: (d) => job.addPath(d),
89+ toolEnv,
90+ exec: (cmd, args, o = {}) => job.tool(cmd, args, { io, cwd: o.cwd, env: o.env ?? toolEnv(), capture: o.capture, silent: o.silent, input: o.input }),
91+ run: async (cmd, args, o = {}) => {
92+ const r = await c.exec(cmd, args, { ...o, capture: o.capture ?? true });
93+ if (job.isStopping) throw failure("cancelled");
94+ if (r.exitCode !== 0) {
95+ const detail = o.silent ? ` ${r.stderr.trim().split("\n").slice(-5).join("\n")}` : "";
96+ throw failure(`The process '${cmd}' failed with exit code ${r.exitCode}${detail ? ":" + detail : ""}`);
97+ }
98+ return r;
99+ },
100+ registerPost: (pname, condition, fn) => {
101+ job.registerPost({
102+ name: pname,
103+ condition,
104+ run: async (logIndex) => {
105+ const pio: StepIO = { logIndex, outputs: {}, state: {} };
106+ await fn(make(pio, { ...envCtx, ...job.envAccum }, pname));
107+ },
108+ });
109+ },
110+ };
111+ return c;
112+ };
113+ return make(x.io, x.envCtx, x.name);
114+}
addedcontainers/runner/src/builtins/setup-go.ts+73−0View file
@@ -1,0 +1,73 @@
1+import { existsSync } from "node:fs";
2+import path from "node:path";
3+import { errMsg, failure } from "../job.js";
4+import { maxSatisfying, satisfies, truthyInput } from "../util.js";
5+import type { Builtin } from "./index.js";
6+import { fetchJson, installTarball, isCached, packageCache, probe, readVersionFile, toolArch, toolDir } from "./tools.js";
7+
8+interface GoRelease {
9+ version: string; // "go1.22.3"
10+ stable: boolean;
11+ files: Array<{ filename: string; os: string; arch: string; kind: string }>;
12+}
13+
14+export const setupGo: Builtin = async (c) => {
15+ let spec = c.input("go-version");
16+ const file = c.input("go-version-file");
17+ if (!spec && file) spec = await readVersionFile(c, file, "go");
18+ spec = spec.trim().replace(/^go/, "");
19+ const installedRaw = await probe(c, "go", ["version"]);
20+ const installed = /go(\d+\.\d+(?:\.\d+)?)/.exec(installedRaw ?? "")?.[1] ?? null;
21+ let version = installed;
22+
23+ if (!spec && !installed) throw failure("No go-version specified and Go is not installed on this runner.");
24+ const useInstalled = installed && (!spec || (!/^(stable|oldstable)$/.test(spec) && satisfies(installed, spec))) && !truthyInput(c.input("check-latest"), false);
25+ if (useInstalled) {
26+ c.log(`Using pre-installed Go ${installed}`);
27+ } else {
28+ let releases: GoRelease[];
29+ try {
30+ releases = await fetchJson<GoRelease[]>(process.env.DIFFINERY_GO_INDEX ?? "https://go.dev/dl/?mode=json&include=all");
31+ } catch (e) {
32+ throw failure(`Unable to fetch the Go versions index: ${errMsg(e)}`);
33+ }
34+ const arch = toolArch("go");
35+ const stable = releases.filter((r) => r.stable && r.files.some((f) => f.os === "linux" && f.arch === arch && f.kind === "archive"));
36+ let v: string | null;
37+ if (spec === "stable") v = maxSatisfying(stable.map((r) => r.version.replace(/^go/, "")), "*");
38+ else if (spec === "oldstable") {
39+ const sorted = stable.map((r) => r.version.replace(/^go/, ""));
40+ const latest = maxSatisfying(sorted, "*");
41+ const latestMinor = latest ? latest.split(".").slice(0, 2).join(".") : "";
42+ v = maxSatisfying(sorted.filter((x) => !x.startsWith(latestMinor + ".") && x !== latestMinor), "*");
43+ } else v = maxSatisfying(stable.map((r) => r.version.replace(/^go/, "")), spec);
44+ if (!v) throw failure(`Unable to find Go version '${spec}' for platform linux and architecture ${arch}.`);
45+ const rel = stable.find((r) => r.version === `go${v}`)!;
46+ const f = rel.files.find((x) => x.os === "linux" && x.arch === arch && x.kind === "archive")!;
47+ const dir = toolDir(c, "go", v, arch);
48+ if (!isCached(dir)) await installTarball(c, `https://go.dev/dl/${f.filename}`, dir);
49+ else c.log(`Found in cache @ ${dir}`);
50+ c.addPath(path.join(dir, "bin"));
51+ version = v;
52+ }
53+ const gopath = await probe(c, "go", ["env", "GOPATH"], c.toolEnv());
54+ if (gopath) c.addPath(path.join(gopath, "bin"));
55+ if (version) c.setOutput("go-version", version);
56+
57+ if (truthyInput(c.input("cache"), true)) {
58+ const deps = c.input("cache-dependency-path");
59+ if (!deps && !existsSync(path.join(c.job.workspace, "go.sum"))) {
60+ c.log("go.sum not found; skipping dependency cache.");
61+ return;
62+ }
63+ const env = c.toolEnv();
64+ const modcache = await probe(c, "go", ["env", "GOMODCACHE"], env);
65+ const buildcache = await probe(c, "go", ["env", "GOCACHE"], env);
66+ await packageCache(c, {
67+ prefix: `setup-go-Linux-${toolArch("go")}-go-${version}`,
68+ paths: [modcache ?? "", buildcache ?? ""],
69+ lockPatterns: ["**/go.sum"],
70+ what: "go",
71+ });
72+ }
73+};
addedcontainers/runner/src/builtins/setup-node.ts+125−0View file
@@ -1,0 +1,125 @@
1+import path from "node:path";
2+import { errMsg, failure } from "../job.js";
3+import { maxSatisfying, parseVer, satisfies, truthyInput } from "../util.js";
4+import type { Builtin } from "./index.js";
5+import { fetchJson, installTarball, isCached, packageCache, probe, readVersionFile, toolArch, toolDir } from "./tools.js";
6+
7+interface NodeDistEntry {
8+ version: string; // "v22.11.0"
9+ lts: string | false;
10+ files: string[];
11+}
12+
13+export function nodeMirror(): string {
14+ return (process.env.DIFFINERY_NODE_MIRROR ?? "https://nodejs.org/dist").replace(/\/$/, "");
15+}
16+
17+/** Resolves a setup-node version spec against nodejs.org's index. */
18+export function resolveNodeVersion(index: NodeDistEntry[], spec: string): string | null {
19+ const s = spec.trim().toLowerCase();
20+ const arch = `linux-${toolArch("node")}`;
21+ const avail = index.filter((e) => e.files.includes(arch) || e.files.length === 0);
22+ if (s.startsWith("lts/")) {
23+ const code = s.slice(4);
24+ const lts = avail.filter((e) => e.lts && (code === "*" || String(e.lts).toLowerCase() === code));
25+ if (code.startsWith("-")) {
26+ // lts/-1: previous LTS line
27+ const n = Number(code.slice(1));
28+ const majors = [...new Set(avail.filter((e) => e.lts).map((e) => parseVer(e.version)![0]))].sort((a, b) => b - a);
29+ const major = majors[n];
30+ return major === undefined ? null : maxSatisfying(avail.filter((e) => e.lts).map((e) => e.version), String(major))?.replace(/^v/, "") ?? null;
31+ }
32+ return maxSatisfying(lts.map((e) => e.version), "*")?.replace(/^v/, "") ?? null;
33+ }
34+ if (s === "latest" || s === "current" || s === "node") return maxSatisfying(avail.map((e) => e.version), "*")?.replace(/^v/, "") ?? null;
35+ return maxSatisfying(avail.map((e) => e.version), s.replace(/^v/, ""))?.replace(/^v/, "") ?? null;
36+}
37+
38+export const setupNode: Builtin = async (c) => {
39+ let spec = c.input("node-version");
40+ const file = c.input("node-version-file");
41+ if (!spec && file) spec = await readVersionFile(c, file, "node");
42+ const installed = (await probe(c, "node", ["--version"]))?.replace(/^v/, "") ?? null;
43+ let version = installed;
44+
45+ if (spec) {
46+ const s = spec.trim().replace(/^v(?=\d)/, "");
47+ let ok = false;
48+ if (installed && !truthyInput(c.input("check-latest"), false)) {
49+ if (/^(lts\/|latest|current|node$)/i.test(s)) {
50+ try {
51+ const target = resolveNodeVersion(await fetchJson<NodeDistEntry[]>(`${nodeMirror()}/index.json`), s);
52+ ok = !!target && parseVer(target)![0] === parseVer(installed)![0];
53+ } catch (e) {
54+ c.warn(`Could not resolve '${s}' (${errMsg(e)}); using installed node ${installed}.`);
55+ ok = true;
56+ }
57+ } else {
58+ const want = parseVer(s);
59+ ok = satisfies(installed, s) || (!!want && /^\d/.test(s) && want[0] === parseVer(installed)![0]);
60+ }
61+ }
62+ if (ok) {
63+ c.log(`Found in cache @ ${installed} (pre-installed node satisfies '${s}')`);
64+ } else {
65+ let index: NodeDistEntry[];
66+ try {
67+ index = await fetchJson<NodeDistEntry[]>(`${nodeMirror()}/index.json`);
68+ } catch (e) {
69+ throw failure(`Unable to fetch the Node.js version index: ${errMsg(e)}`);
70+ }
71+ const v = resolveNodeVersion(index, s);
72+ if (!v) throw failure(`Unable to find Node version '${s}' for platform linux and architecture ${toolArch("node")}.`);
73+ const dir = toolDir(c, "node", v, toolArch("node"));
74+ if (!isCached(dir)) {
75+ await installTarball(c, `${nodeMirror()}/v${v}/node-v${v}-linux-${toolArch("node")}.tar.gz`, dir);
76+ } else c.log(`Found in cache @ ${dir}`);
77+ c.addPath(path.join(dir, "bin"));
78+ version = v;
79+ }
80+ } else {
81+ c.log(`No node-version specified; using the pre-installed node ${installed ?? "(none)"}.`);
82+ }
83+ if (version) c.setOutput("node-version", `v${version}`);
84+
85+ const registry = c.input("registry-url");
86+ if (registry) {
87+ // Minimal setup-node `registry-url` support: write an .npmrc that reads NODE_AUTH_TOKEN.
88+ const { writeFile, mkdir } = await import("node:fs/promises");
89+ const npmrc = path.join(c.job.tempDir, ".npmrc");
90+ await mkdir(c.job.tempDir, { recursive: true });
91+ const url = registry.endsWith("/") ? registry : `${registry}/`;
92+ const scope = c.input("scope");
93+ const authLine = `${url.replace(/^\w+:/, "")}:_authToken=\${NODE_AUTH_TOKEN}`;
94+ const regLine = scope ? `${scope.startsWith("@") ? scope : "@" + scope}:registry=${url}` : `registry=${url}`;
95+ await writeFile(npmrc, `${authLine}\n${regLine}\n`);
96+ c.exportVariable("NPM_CONFIG_USERCONFIG", npmrc);
97+ c.exportVariable("NODE_AUTH_TOKEN", "XXXXX-XXXXX-XXXXX-XXXXX");
98+ }
99+
100+ const pm = c.input("cache").toLowerCase();
101+ if (pm) {
102+ const env = c.toolEnv();
103+ let dir: string | null = null;
104+ let locks: string[];
105+ if (pm === "npm") {
106+ dir = await probe(c, "npm", ["config", "get", "cache"], env);
107+ locks = ["**/package-lock.json", "**/npm-shrinkwrap.json"];
108+ } else if (pm === "pnpm") {
109+ dir = await probe(c, "pnpm", ["store", "path", "--silent"], env);
110+ locks = ["**/pnpm-lock.yaml"];
111+ } else if (pm === "yarn") {
112+ const ver = await probe(c, "yarn", ["--version"], env);
113+ dir = ver && parseVer(ver)![0] >= 2 ? await probe(c, "yarn", ["config", "get", "cacheFolder"], env) : await probe(c, "yarn", ["cache", "dir"], env);
114+ locks = ["**/yarn.lock"];
115+ } else {
116+ throw failure(`Caching for '${pm}' is not supported`);
117+ }
118+ await packageCache(c, {
119+ prefix: `node-cache-Linux-${toolArch("node")}-${pm}`,
120+ paths: dir ? [dir] : [],
121+ lockPatterns: locks,
122+ what: pm,
123+ });
124+ }
125+};
addedcontainers/runner/src/builtins/setup-python.ts+101−0View file
@@ -1,0 +1,101 @@
1+import { existsSync, realpathSync } from "node:fs";
2+import { mkdir, rm, symlink } from "node:fs/promises";
3+import { randomUUID } from "node:crypto";
4+import path from "node:path";
5+import { errMsg, failure } from "../job.js";
6+import { maxSatisfying, satisfies, which } from "../util.js";
7+import type { Builtin } from "./index.js";
8+import { fetchJson, fetchToFile, packageCache, probe, readVersionFile, toolArch } from "./tools.js";
9+
10+interface PyManifestEntry {
11+ version: string;
12+ stable: boolean;
13+ files: Array<{ filename: string; arch: string; platform: string; platform_version?: string; download_url: string }>;
14+}
15+
16+const MANIFEST = "https://raw.githubusercontent.com/actions/python-versions/main/versions-manifest.json";
17+
18+export const setupPython: Builtin = async (c) => {
19+ let spec = c.input("python-version");
20+ const file = c.input("python-version-file");
21+ if (!spec && file) spec = await readVersionFile(c, file, "python");
22+ if (!spec && existsSync(path.join(c.job.workspace, ".python-version"))) spec = await readVersionFile(c, ".python-version", "python");
23+ spec = spec.trim();
24+ if (/^pypy|graalpy/i.test(spec)) throw failure(`'${spec}' is not supported by the Diffinery setup-python built-in.`);
25+ const env = c.toolEnv();
26+ const installedRaw = await probe(c, "python3", ["--version"], env);
27+ const installed = installedRaw?.replace(/^Python\s+/, "") ?? null;
28+
29+ let binDir: string;
30+ let version: string;
31+ const want = spec.replace(/-dev$/, "").replace(/\.x$/, "");
32+ if (installed && (!spec || satisfies(installed, want))) {
33+ c.log(`Using pre-installed Python ${installed}`);
34+ const py3 = which("python3", env.PATH)!;
35+ binDir = path.dirname(realpathSync(py3));
36+ version = installed;
37+ // make sure `python`/`pip` resolve to python3
38+ if (!which("python", env.PATH) || !which("pip", env.PATH)) {
39+ const shim = path.join(c.job.tempDir, "_python_shim");
40+ await mkdir(shim, { recursive: true });
41+ for (const [n, t] of [["python", "python3"], ["pip", "pip3"]] as const) {
42+ const target = which(t, env.PATH);
43+ const link = path.join(shim, n);
44+ if (target && !existsSync(link)) await symlink(target, link).catch(() => {});
45+ }
46+ c.addPath(shim);
47+ }
48+ } else {
49+ let manifest: PyManifestEntry[];
50+ try {
51+ manifest = await fetchJson<PyManifestEntry[]>(process.env.DIFFINERY_PYTHON_MANIFEST ?? MANIFEST);
52+ } catch (e) {
53+ throw failure(`Unable to fetch the Python versions manifest: ${errMsg(e)}`);
54+ }
55+ const arch = toolArch("python");
56+ const candidates = manifest.filter((m) => m.stable && m.files.some((f) => f.platform === "linux" && f.arch === arch));
57+ const v = maxSatisfying(candidates.map((m) => m.version), want);
58+ if (!v) throw failure(`The version '${spec}' with architecture '${arch}' was not found for this operating system.`);
59+ const entry = candidates.find((m) => m.version === v)!;
60+ const files = entry.files.filter((f) => f.platform === "linux" && f.arch === arch);
61+ const f = files.find((x) => x.platform_version === "24.04") ?? files.find((x) => x.platform_version === "22.04") ?? files[0]!;
62+ const installDir = path.join(c.job.toolCache, "Python", v, arch);
63+ if (!existsSync(`${installDir}.complete`)) {
64+ const work = path.join(c.job.tempDir, `python-${randomUUID()}`);
65+ await mkdir(work, { recursive: true });
66+ try {
67+ c.log(`Downloading ${f.download_url}`);
68+ const tarball = path.join(work, "python.tar.gz");
69+ await fetchToFile(f.download_url, tarball);
70+ await c.run("tar", ["-xzf", tarball, "-C", work], { silent: true });
71+ c.log("Running python installer (setup.sh)");
72+ await c.run("bash", ["./setup.sh"], { cwd: work, env: c.toolEnv({ RUNNER_TOOL_CACHE: c.job.toolCache, AGENT_TOOLSDIRECTORY: c.job.toolCache }) });
73+ } finally {
74+ await rm(work, { recursive: true, force: true });
75+ }
76+ }
77+ binDir = path.join(installDir, "bin");
78+ version = v;
79+ c.addPath(installDir);
80+ c.addPath(binDir);
81+ c.exportVariable("pythonLocation", installDir);
82+ c.exportVariable("Python_ROOT_DIR", installDir);
83+ c.exportVariable("Python3_ROOT_DIR", installDir);
84+ c.exportVariable("PKG_CONFIG_PATH", path.join(installDir, "lib", "pkgconfig"));
85+ c.exportVariable("LD_LIBRARY_PATH", [path.join(installDir, "lib"), process.env.LD_LIBRARY_PATH].filter(Boolean).join(":"));
86+ }
87+ c.setOutput("python-version", version);
88+ c.setOutput("python-path", path.join(binDir, existsSync(path.join(binDir, "python")) ? "python" : "python3"));
89+
90+ const cache = c.input("cache").toLowerCase();
91+ if (cache) {
92+ if (cache !== "pip") throw failure(`Caching for '${cache}' is not supported by the Diffinery setup-python built-in (only pip).`);
93+ const dir = await probe(c, "python3", ["-m", "pip", "cache", "dir"], c.toolEnv());
94+ await packageCache(c, {
95+ prefix: `setup-python-Linux-${toolArch("python")}-python-${version}-pip`,
96+ paths: dir ? [dir] : [],
97+ lockPatterns: ["**/requirements*.txt", "**/pyproject.toml"],
98+ what: "pip",
99+ });
100+ }
101+};
addedcontainers/runner/src/builtins/tools.ts+148−0View file
@@ -1,0 +1,148 @@
1+import { randomUUID } from "node:crypto";
2+import { createWriteStream, existsSync } from "node:fs";
3+import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
4+import path from "node:path";
5+import { Readable } from "node:stream";
6+import { pipeline } from "node:stream/promises";
7+import type { ReadableStream as WebReadableStream } from "node:stream/web";
8+import { failure } from "../job.js";
9+import { hashFiles, inputList } from "../util.js";
10+import { restoreCache, saveCache } from "./cache.js";
11+import type { BuiltinContext } from "./index.js";
12+
13+/** Downloads a public URL to a file with a few retries. */
14+export async function fetchToFile(url: string, file: string): Promise<void> {
15+ let last: unknown;
16+ for (let i = 0; i < 4; i++) {
17+ if (i) await new Promise((r) => setTimeout(r, 1000 * 2 ** i));
18+ try {
19+ const res = await fetch(url, { headers: { "user-agent": "diffinery-runner" }, redirect: "follow" });
20+ if (res.status === 404) throw failure(`Not found: ${url}`);
21+ if (!res.ok || !res.body) throw new Error(`HTTP ${res.status} for ${url}`);
22+ await pipeline(Readable.fromWeb(res.body as unknown as WebReadableStream), createWriteStream(file));
23+ return;
24+ } catch (e) {
25+ if (e instanceof Error && e.message.startsWith("Not found:")) throw e;
26+ last = e;
27+ }
28+ }
29+ throw last instanceof Error ? last : new Error(String(last));
30+}
31+
32+export async function fetchJson<T>(url: string): Promise<T> {
33+ let last: unknown;
34+ for (let i = 0; i < 3; i++) {
35+ if (i) await new Promise((r) => setTimeout(r, 1000 * 2 ** i));
36+ try {
37+ const res = await fetch(url, { headers: { "user-agent": "diffinery-runner", accept: "application/json" } });
38+ if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
39+ return (await res.json()) as T;
40+ } catch (e) {
41+ last = e;
42+ }
43+ }
44+ throw last instanceof Error ? last : new Error(String(last));
45+}
46+
47+export function toolArch(kind: "node" | "go" | "python"): string {
48+ const a = process.arch;
49+ if (kind === "go") return a === "arm64" ? "arm64" : a === "arm" ? "armv6l" : "amd64";
50+ return a === "arm64" ? "arm64" : a === "arm" ? "armv7l" : "x64";
51+}
52+
53+export function toolDir(c: BuiltinContext, tool: string, version: string, arch: string): string {
54+ return path.join(c.job.toolCache, tool, version, arch);
55+}
56+
57+export function isCached(dir: string): boolean {
58+ return existsSync(`${dir}.complete`) && existsSync(dir);
59+}
60+
61+/** Downloads a tarball and extracts it into `dir` (stripping the top-level folder). */
62+export async function installTarball(c: BuiltinContext, url: string, dir: string, strip = 1): Promise<void> {
63+ c.log(`Downloading ${url}`);
64+ const file = path.join(c.job.tempDir, `tool-${randomUUID()}${url.endsWith(".xz") ? ".tar.xz" : ".tar.gz"}`);
65+ try {
66+ await fetchToFile(url, file);
67+ await rm(dir, { recursive: true, force: true });
68+ await mkdir(dir, { recursive: true });
69+ c.log(`Extracting to ${dir}`);
70+ await c.run("tar", [url.endsWith(".xz") ? "-xJf" : "-xzf", file, "-C", dir, `--strip-components=${strip}`], { silent: true });
71+ await writeFile(`${dir}.complete`, "");
72+ } finally {
73+ await rm(file, { force: true });
74+ }
75+}
76+
77+/** Runs `cmd args` and returns trimmed stdout, or null if it fails. */
78+export async function probe(c: BuiltinContext, cmd: string, args: string[], env?: NodeJS.ProcessEnv): Promise<string | null> {
79+ const r = await c.exec(cmd, args, { capture: true, silent: true, env });
80+ if (r.exitCode !== 0) return null;
81+ return (r.stdout || r.stderr).trim();
82+}
83+
84+export async function readVersionFile(c: BuiltinContext, file: string, kind: "node" | "python" | "go"): Promise<string> {
85+ const p = path.resolve(c.job.workspace, file);
86+ if (!existsSync(p)) throw failure(`The specified ${kind} version file at: ${p} does not exist`);
87+ const content = await readFile(p, "utf8");
88+ const base = path.basename(p);
89+ if (base === "package.json") {
90+ try {
91+ const j = JSON.parse(content) as { volta?: { node?: string }; engines?: { node?: string }; devEngines?: { runtime?: { name?: string; version?: string } } };
92+ const v = j.volta?.node ?? j.devEngines?.runtime?.version ?? j.engines?.node;
93+ if (v) return v;
94+ } catch {
95+ /* fallthrough */
96+ }
97+ throw failure(`No node version found in ${p}`);
98+ }
99+ if (base === "go.mod" || base === "go.work") {
100+ const tc = /^toolchain\s+go(\S+)/m.exec(content);
101+ const g = /^go\s+(\d+(?:\.\d+){0,2})/m.exec(content);
102+ const v = tc?.[1] ?? g?.[1];
103+ if (!v) throw failure(`No go version found in ${p}`);
104+ return v;
105+ }
106+ if (base === ".tool-versions") {
107+ const names = kind === "node" ? ["nodejs", "node"] : kind === "python" ? ["python"] : ["golang", "go"];
108+ for (const line of content.split("\n")) {
109+ const [n, v] = line.trim().split(/\s+/);
110+ if (n && v && names.includes(n)) return v;
111+ }
112+ throw failure(`No ${kind} version found in ${p}`);
113+ }
114+ if (base === "pyproject.toml") {
115+ const m = /requires-python\s*=\s*["']([^"']+)["']/.exec(content);
116+ if (m) return m[1]!.replace(/,/g, " ");
117+ throw failure(`No python version found in ${p}`);
118+ }
119+ const first = content.split("\n").map((l) => l.trim()).find((l) => l && !l.startsWith("#"));
120+ if (!first) throw failure(`The ${kind} version file at ${p} is empty`);
121+ return first.replace(/^v(?=\d)/, "");
122+}
123+
124+/**
125+ * Restores a package-manager cache keyed by lockfile hash and registers a
126+ * post-step save, mirroring setup-node/setup-python/setup-go `cache:`.
127+ */
128+export async function packageCache(c: BuiltinContext, o: { prefix: string; paths: string[]; lockPatterns: string[]; what: string }): Promise<void> {
129+ const patterns = inputList(c.input("cache-dependency-path")).length ? inputList(c.input("cache-dependency-path")) : o.lockPatterns;
130+ const hash = hashFiles(c.job.workspace, patterns);
131+ if (!hash) {
132+ throw failure(`Dependencies lock file is not found in ${c.job.workspace}. Supported file patterns: ${patterns.join(", ")}`);
133+ }
134+ const key = `${o.prefix}-${hash}`;
135+ const paths = o.paths.filter(Boolean);
136+ if (!paths.length) {
137+ c.warn(`Could not determine the ${o.what} cache directory; skipping cache.`);
138+ return;
139+ }
140+ const r = await restoreCache(c, paths, key, []);
141+ c.setOutput("cache-hit", r.exact ? "true" : "false");
142+ if (r.exact) return;
143+ c.registerPost(`Post ${c.name}`, "success()", async (pc) => {
144+ for (const p of paths) await mkdir(p, { recursive: true }).catch(() => {});
145+ await saveCache(pc, paths, key);
146+ });
147+}
148+
addedcontainers/runner/src/commands.ts+89−0View file
@@ -1,0 +1,89 @@
1+/**
2+ * GitHub Actions workflow commands (`::name k=v::message` on stdout) and file
3+ * commands (`$GITHUB_ENV`, `$GITHUB_OUTPUT`, ... files).
4+ */
5+
6+export interface WorkflowCommand {
7+ command: string;
8+ properties: Record<string, string>;
9+ message: string;
10+}
11+
12+export function unescapeData(s: string): string {
13+ return s.replace(/%0D/gi, "\r").replace(/%0A/gi, "\n").replace(/%25/g, "%");
14+}
15+
16+export function unescapeProperty(s: string): string {
17+ return s
18+ .replace(/%0D/gi, "\r")
19+ .replace(/%0A/gi, "\n")
20+ .replace(/%3A/gi, ":")
21+ .replace(/%2C/gi, ",")
22+ .replace(/%25/g, "%");
23+}
24+
25+/** Parses a `::command props::message` line. Returns null if not a command. */
26+export function parseCommand(line: string): WorkflowCommand | null {
27+ const trimmed = line.replace(/^\s+/, "");
28+ if (!trimmed.startsWith("::")) return null;
29+ const end = trimmed.indexOf("::", 2);
30+ if (end < 0) return null;
31+ const head = trimmed.slice(2, end);
32+ const message = trimmed.slice(end + 2);
33+ const sp = head.indexOf(" ");
34+ const command = (sp < 0 ? head : head.slice(0, sp)).trim();
35+ if (!command || !/^[A-Za-z0-9_.-]+$/.test(command)) return null;
36+ const properties: Record<string, string> = {};
37+ if (sp >= 0) {
38+ for (const part of head.slice(sp + 1).split(",")) {
39+ const eq = part.indexOf("=");
40+ if (eq <= 0) continue;
41+ properties[part.slice(0, eq).trim()] = unescapeProperty(part.slice(eq + 1));
42+ }
43+ }
44+ return { command, properties, message: unescapeData(message) };
45+}
46+
47+/**
48+ * Parses an env-file style file command (GITHUB_ENV / GITHUB_OUTPUT /
49+ * GITHUB_STATE): `name=value` lines and `name<<DELIM` heredocs.
50+ */
51+export function parseKeyValueFile(content: string): Array<[string, string]> {
52+ const out: Array<[string, string]> = [];
53+ if (!content) return out;
54+ const lines = content.split(/\r?\n/);
55+ for (let i = 0; i < lines.length; i++) {
56+ const line = lines[i]!;
57+ if (line === "") continue;
58+ const eq = line.indexOf("=");
59+ const hd = line.indexOf("<<");
60+ if (hd > 0 && (eq < 0 || hd < eq)) {
61+ const name = line.slice(0, hd);
62+ const delim = line.slice(hd + 2);
63+ if (!name || !delim) throw new Error(`Invalid file command format: ${line}`);
64+ const buf: string[] = [];
65+ let found = false;
66+ for (i++; i < lines.length; i++) {
67+ if (lines[i] === delim) {
68+ found = true;
69+ break;
70+ }
71+ buf.push(lines[i]!);
72+ }
73+ if (!found) throw new Error(`Matching delimiter not found '${delim}'`);
74+ out.push([name, buf.join("\n")]);
75+ continue;
76+ }
77+ if (eq <= 0) throw new Error(`Invalid file command format: ${line}`);
78+ out.push([line.slice(0, eq), line.slice(eq + 1)]);
79+ }
80+ return out;
81+}
82+
83+/** GITHUB_PATH: one directory per line. */
84+export function parsePathFile(content: string): string[] {
85+ return content
86+ .split(/\r?\n/)
87+ .map((l) => l.trim())
88+ .filter(Boolean);
89+}
addedcontainers/runner/src/job.ts+678−0View file
@@ -1,0 +1,678 @@
1+import type { ChildProcess } from "node:child_process";
2+import { randomUUID } from "node:crypto";
3+import { existsSync } from "node:fs";
4+import { mkdir, readFile, writeFile } from "node:fs/promises";
5+import path from "node:path";
6+import type { Conclusion, JobCompletion, JobSpec, StepReport, StepSpec } from "@diffinery/shared";
7+import { evaluate, evaluateIf, interpolate, type StatusFns } from "@diffinery/workflow-yaml";
8+import type { RunnerApi } from "./api.js";
9+import { parseKeyValueFile, parsePathFile } from "./commands.js";
10+import { JobLogger, OutputProcessor, type Annotation, type OutputSink } from "./log.js";
11+import { Masker } from "./mask.js";
12+import { runAction } from "./steps/actions.js";
13+import { runScript } from "./steps/run.js";
14+import { exec, hashFiles, killTree, nowIso, runnerArch, type ExecResult } from "./util.js";
15+
16+export type Outcome = "success" | "failure" | "cancelled" | "skipped";
17+
18+export interface StepContextEntry {
19+ outputs: Record<string, string>;
20+ outcome: Outcome;
21+ conclusion: Outcome;
22+}
23+
24+/** Execution scope: the job itself, or the inside of a composite action. */
25+export interface Scope {
26+ steps: Record<string, StepContextEntry>;
27+ inputs: Record<string, unknown>;
28+ /** A step in this scope failed (without continue-on-error). */
29+ failed: boolean;
30+ /** Env inherited from the calling step (composite actions). */
31+ inheritedEnv: Record<string, string>;
32+ composite: boolean;
33+ action?: { path: string; repository: string; ref: string };
34+ depth: number;
35+}
36+
37+export interface StepIO extends OutputSink {
38+ /** Short reason recorded when the step fails (e.g. exit code). */
39+ reason?: string;
40+}
41+
42+export interface StepResult {
43+ outcome: Outcome;
44+ conclusion: Outcome;
45+ outputs: Record<string, string>;
46+}
47+
48+export interface PostStep {
49+ name: string;
50+ /** Expression evaluated with job status (e.g. `always()`, `success()`). */
51+ condition: string;
52+ run: (logIndex: number) => Promise<void>;
53+}
54+
55+export interface RunnerOptions {
56+ workRoot: string;
57+ /** Tool cache dir; defaults to `${workRoot}/_tool` (or RUNNER_TOOL_CACHE). */
58+ toolCache?: string;
59+ echoLogs?: boolean;
60+ /** Base env for child processes (defaults to process.env). */
61+ baseEnv?: NodeJS.ProcessEnv;
62+}
63+
64+export class StepFailure extends Error {}
65+
66+const SENSITIVE_ENV = ["DIFFINERY_JOB_TOKEN", "DIFFINERY_SANDBOX_SECRET"];
67+
68+export class JobRunner {
69+ readonly masker = new Masker();
70+ readonly logger: JobLogger;
71+ readonly workspace: string;
72+ readonly tempDir: string;
73+ readonly toolCache: string;
74+ readonly actionsDir: string;
75+ readonly fileCmdDir: string;
76+ readonly eventPath: string;
77+ readonly debug: boolean;
78+
79+ /** Accumulated GITHUB_ENV values. */
80+ envAccum: Record<string, string> = {};
81+ /** Accumulated GITHUB_PATH entries, most recent first. */
82+ pathAccum: string[] = [];
83+ jobEnv: Record<string, string> = {};
84+ readonly summaries: string[] = [];
85+ readonly posts: PostStep[] = [];
86+ readonly children = new Set<ChildProcess>();
87+ readonly stepErrors = new Map<number, Annotation[]>();
88+ readonly stepReasons = new Map<number, string>();
89+
90+ private stopping: null | "cancelled" | "timeout" = null;
91+ private firstFailure: { step?: number; message: string } | null = null;
92+ private githubCtx: Record<string, unknown> = {};
93+ private defaultEnv: Record<string, string> = {};
94+ private baseEnv: NodeJS.ProcessEnv;
95+ private secrets: Record<string, string>;
96+
97+ constructor(
98+ readonly spec: JobSpec,
99+ readonly api: RunnerApi,
100+ logger: JobLogger | null,
101+ readonly opts: RunnerOptions,
102+ batcher: import("./api.js").LogBatcher | null = null,
103+ ) {
104+ this.logger = logger ?? new JobLogger(this.masker, batcher, !!opts.echoLogs);
105+ this.workspace = path.join(opts.workRoot, spec.repo.name);
106+ this.tempDir = path.join(opts.workRoot, "_temp");
107+ this.toolCache = opts.toolCache ?? process.env.RUNNER_TOOL_CACHE ?? path.join(opts.workRoot, "_tool");
108+ this.actionsDir = path.join(opts.workRoot, "_actions");
109+ this.fileCmdDir = path.join(this.tempDir, "_runner_file_commands");
110+ this.eventPath = path.join(this.tempDir, "_github_workflow", "event.json");
111+ this.secrets = { ...spec.secrets, GITHUB_TOKEN: spec.token };
112+ this.debug = this.secrets.ACTIONS_STEP_DEBUG === "true" || spec.vars?.ACTIONS_STEP_DEBUG === "true";
113+ const base = { ...(opts.baseEnv ?? process.env) };
114+ for (const k of SENSITIVE_ENV) delete base[k];
115+ this.baseEnv = base;
116+ for (const v of Object.values(this.secrets)) this.masker.add(v);
117+ }
118+
119+ // ------------------------------------------------------------ public
120+
121+ get isStopping(): boolean {
122+ return this.stopping !== null;
123+ }
124+
125+ /** SIGTERM / external cancellation. */
126+ cancel(reason: "cancelled" | "timeout" = "cancelled"): void {
127+ if (this.stopping) return;
128+ this.stopping = reason;
129+ for (const c of this.children) killTree(c);
130+ }
131+
132+ async run(): Promise<JobCompletion> {
133+ const spec = this.spec;
134+ let jobTimer: NodeJS.Timeout | undefined;
135+ if (spec.timeoutMinutes > 0) {
136+ jobTimer = setTimeout(() => {
137+ this.logger.line(-1, `##[error]The job running on runner has exceeded the maximum execution time of ${spec.timeoutMinutes} minutes.`);
138+ this.cancel("timeout");
139+ }, spec.timeoutMinutes * 60_000);
140+ jobTimer.unref();
141+ }
142+ const top: Scope = { steps: {}, inputs: spec.inputs ?? {}, failed: false, inheritedEnv: {}, composite: false, depth: 0 };
143+ try {
144+ await this.setup();
145+ for (let i = 0; i < spec.steps.length; i++) {
146+ const step = spec.steps[i]!;
147+ const fallbackName = defaultStepName(step);
148+ if (this.stopping) {
149+ await this.report(i, { status: "completed", conclusion: "cancelled", name: safeName(step, fallbackName), completedAt: nowIso() });
150+ continue;
151+ }
152+ const startedAt = nowIso();
153+ let started = false;
154+ const res = await this.executeStep(step, top, i, async (name) => {
155+ started = true;
156+ await this.report(i, { status: "in_progress", name, startedAt });
157+ });
158+ await this.report(i, {
159+ status: "completed",
160+ conclusion: res.conclusion,
161+ name: res.name ?? fallbackName,
162+ ...(started ? { startedAt } : {}),
163+ completedAt: nowIso(),
164+ });
165+ }
166+ if (!this.stopping) await this.runPosts(top);
167+ } catch (e) {
168+ const msg = e instanceof Error ? e.message : String(e);
169+ this.logger.line(-1, `##[error]${msg}`);
170+ top.failed = true;
171+ this.firstFailure ??= { message: msg };
172+ } finally {
173+ if (jobTimer) clearTimeout(jobTimer);
174+ }
175+
176+ let conclusion: Conclusion = top.failed ? "failure" : "success";
177+ if (this.stopping === "cancelled") conclusion = "cancelled";
178+ if (this.stopping === "timeout") {
179+ conclusion = "failure";
180+ this.firstFailure = { ...(this.firstFailure ?? {}), message: `The job exceeded the maximum execution time of ${spec.timeoutMinutes} minutes.` };
181+ }
182+ const outputs = this.jobOutputs(top);
183+ const completion: JobCompletion = { conclusion, outputs };
184+ const summary = this.summaries.filter((s) => s.trim() !== "").join("\n");
185+ if (summary) completion.summary = this.masker.mask(summary);
186+ if (conclusion === "failure" && this.firstFailure) completion.failure = { ...this.firstFailure, message: this.masker.mask(this.firstFailure.message) };
187+ if (conclusion === "cancelled") completion.failure = { message: "The job was cancelled." };
188+ this.logger.line(-1, `Job completed with conclusion: ${conclusion}`);
189+ return completion;
190+ }
191+
192+ // ------------------------------------------------------------ setup
193+
194+ private async setup(): Promise<void> {
195+ const spec = this.spec;
196+ await mkdir(this.workspace, { recursive: true });
197+ await mkdir(this.fileCmdDir, { recursive: true });
198+ await mkdir(this.toolCache, { recursive: true });
199+ await mkdir(this.actionsDir, { recursive: true });
200+ await mkdir(path.dirname(this.eventPath), { recursive: true });
201+ const g = spec.github ?? {};
202+ const refName = String(g.ref_name ?? spec.ref.replace(/^refs\/(heads|tags)\//, ""));
203+ const refType = String(g.ref_type ?? (spec.ref.startsWith("refs/tags/") ? "tag" : "branch"));
204+ const server = spec.apiUrl.replace(/\/$/, "");
205+ const repoFull = `${spec.repo.owner}/${spec.repo.name}`;
206+ this.githubCtx = {
207+ repository: repoFull,
208+ repository_owner: spec.repo.owner,
209+ sha: spec.sha,
210+ ref: spec.ref,
211+ ref_name: refName,
212+ ref_type: refType,
213+ run_id: spec.runId,
214+ run_number: g.run_number ?? "1",
215+ run_attempt: g.run_attempt ?? "1",
216+ job: spec.jobKey,
217+ workflow: spec.workflowPath,
218+ event_name: "push",
219+ event: {},
220+ actor: "diffinery",
221+ server_url: server,
222+ api_url: `${server}/api/v3`,
223+ graphql_url: `${server}/api/graphql`,
224+ ...g,
225+ workspace: this.workspace,
226+ token: spec.token,
227+ action: "",
228+ action_path: "",
229+ };
230+ await writeFile(this.eventPath, JSON.stringify(this.githubCtx.event ?? {}, null, 2));
231+ const str = (v: unknown) => (v === undefined || v === null ? "" : String(v));
232+ const d: Record<string, string> = {
233+ CI: "true",
234+ GITHUB_ACTIONS: "true",
235+ DIFFINERY: "true",
236+ GITHUB_REPOSITORY: repoFull,
237+ GITHUB_REPOSITORY_OWNER: spec.repo.owner,
238+ GITHUB_SHA: spec.sha,
239+ GITHUB_REF: spec.ref,
240+ GITHUB_REF_NAME: refName,
241+ GITHUB_REF_TYPE: refType,
242+ GITHUB_HEAD_REF: str(g.head_ref),
243+ GITHUB_BASE_REF: str(g.base_ref),
244+ GITHUB_RUN_ID: spec.runId,
245+ GITHUB_RUN_NUMBER: str(this.githubCtx.run_number),
246+ GITHUB_RUN_ATTEMPT: str(this.githubCtx.run_attempt),
247+ GITHUB_JOB: spec.jobKey,
248+ GITHUB_ACTOR: str(this.githubCtx.actor),
249+ GITHUB_TRIGGERING_ACTOR: str(g.triggering_actor ?? this.githubCtx.actor),
250+ GITHUB_EVENT_NAME: str(this.githubCtx.event_name),
251+ GITHUB_EVENT_PATH: this.eventPath,
252+ GITHUB_SERVER_URL: server,
253+ GITHUB_API_URL: `${server}/api/v3`,
254+ GITHUB_GRAPHQL_URL: `${server}/api/graphql`,
255+ GITHUB_WORKFLOW: str(g.workflow ?? spec.workflowPath),
256+ GITHUB_WORKFLOW_REF: str(g.workflow_ref ?? `${repoFull}/${spec.workflowPath}@${spec.ref}`),
257+ GITHUB_WORKSPACE: this.workspace,
258+ GITHUB_RETENTION_DAYS: "90",
259+ RUNNER_OS: "Linux",
260+ RUNNER_ARCH: runnerArch(),
261+ RUNNER_NAME: "diffinery",
262+ RUNNER_ENVIRONMENT: "diffinery-hosted",
263+ RUNNER_TEMP: this.tempDir,
264+ RUNNER_TOOL_CACHE: this.toolCache,
265+ RUNNER_WORKSPACE: path.dirname(this.workspace),
266+ AGENT_TOOLSDIRECTORY: this.toolCache,
267+ };
268+ if (this.debug) d.RUNNER_DEBUG = "1";
269+ if (spec.idToken && this.api.online) {
270+ // @actions/core appends `&audience=...`, so the URL must already carry a query string.
271+ d.ACTIONS_ID_TOKEN_REQUEST_URL = `${this.api.url("/oidc")}?api-version=2.0`;
272+ d.ACTIONS_ID_TOKEN_REQUEST_TOKEN = spec.token;
273+ }
274+ this.defaultEnv = d;
275+ this.logger.line(-1, `Diffinery runner: job ${spec.jobKey} (${spec.name}) for ${repoFull}@${spec.sha.slice(0, 12)}`);
276+ this.logger.line(-1, `Workspace: ${this.workspace}`);
277+ // job-level env
278+ const ctx = this.contexts({ steps: {}, inputs: spec.inputs ?? {}, failed: false, inheritedEnv: {}, composite: false, depth: 0 }, {});
279+ for (const [k, v] of Object.entries(spec.env ?? {})) {
280+ this.jobEnv[k] = this.interp(String(v ?? ""), ctx);
281+ }
282+ }
283+
284+ // ------------------------------------------------------------ contexts
285+
286+ statusFns(scope: Scope): StatusFns {
287+ return {
288+ success: () => !scope.failed && this.stopping === null,
289+ failure: () => scope.failed,
290+ cancelled: () => this.stopping !== null,
291+ always: () => true,
292+ };
293+ }
294+
295+ extra(): { hashFiles: (...p: string[]) => string } {
296+ return { hashFiles: (...patterns: string[]) => hashFiles(this.workspace, patterns) };
297+ }
298+
299+ /** Env context visible to a step before its own `env:` is applied. */
300+ baseEnvContext(scope: Scope): Record<string, string> {
301+ return { ...this.jobEnv, ...this.envAccum, ...scope.inheritedEnv };
302+ }
303+
304+ contexts(scope: Scope, env: Record<string, string>): Record<string, unknown> {
305+ const spec = this.spec;
306+ const github = { ...this.githubCtx };
307+ if (scope.action) {
308+ github.action_path = scope.action.path;
309+ github.action_repository = scope.action.repository;
310+ github.action_ref = scope.action.ref;
311+ }
312+ return {
313+ github,
314+ env,
315+ vars: spec.vars ?? {},
316+ secrets: this.secrets,
317+ matrix: spec.matrix ?? {},
318+ needs: spec.needs ?? {},
319+ steps: scope.steps,
320+ runner: {
321+ os: "Linux",
322+ arch: runnerArch(),
323+ name: "diffinery",
324+ temp: this.tempDir,
325+ tool_cache: this.toolCache,
326+ workspace: path.dirname(this.workspace),
327+ debug: this.debug ? "1" : "",
328+ environment: "diffinery-hosted",
329+ },
330+ inputs: scope.inputs,
331+ job: { status: this.stopping ? "cancelled" : scope.failed ? "failure" : "success", container: {}, services: {} },
332+ strategy: spec.strategy ?? {},
333+ };
334+ }
335+
336+ interp(template: string, ctx: Record<string, unknown>, scope?: Scope): string {
337+ if (!template.includes("${{")) return template;
338+ const status = scope ? this.statusFns(scope) : undefined;
339+ return interpolate(template, ctx, status, this.extra());
340+ }
341+
342+ evalIf(cond: string | undefined, ctx: Record<string, unknown>, scope: Scope): boolean {
343+ return evaluateIf(cond, ctx, this.statusFns(scope), this.extra());
344+ }
345+
346+ evalExpr(expr: string, ctx: Record<string, unknown>, scope: Scope): unknown {
347+ return evaluate(expr, ctx, this.statusFns(scope), this.extra());
348+ }
349+
350+ /** Builds the environment for a child process. */
351+ processEnv(envCtx: Record<string, string>, extra: Record<string, string> = {}): NodeJS.ProcessEnv {
352+ const env: NodeJS.ProcessEnv = { ...this.baseEnv, ...envCtx, ...this.defaultEnv, ...extra };
353+ const basePath = envCtx.PATH ?? this.baseEnv.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
354+ env.PATH = [...this.pathAccum, basePath].filter(Boolean).join(path.delimiter);
355+ return env;
356+ }
357+
358+ // ------------------------------------------------------------ steps
359+
360+ /**
361+ * Evaluates `if`, env, name; runs the step; updates the scope's steps
362+ * context and failure state.
363+ */
364+ async executeStep(step: StepSpec, scope: Scope, logIndex: number, onStart?: (name: string) => Promise<void>): Promise<StepResult & { name: string }> {
365+ const baseEnv = this.baseEnvContext(scope);
366+ let ctx = this.contexts(scope, baseEnv);
367+ let name = defaultStepName(step);
368+ try {
369+ name = step.name !== undefined ? this.interp(String(step.name), ctx, scope) : name;
370+ } catch {
371+ /* keep default */
372+ }
373+ const id = step.id;
374+ const record = (outcome: Outcome, conclusion: Outcome, outputs: Record<string, string>) => {
375+ if (id) scope.steps[id] = { outputs, outcome, conclusion };
376+ };
377+
378+ let run: boolean;
379+ try {
380+ run = evaluateIf(step.if, ctx, this.statusFns(scope), this.extra());
381+ } catch (e) {
382+ this.logger.line(logIndex, `##[error]Error evaluating 'if' condition: ${errMsg(e)}`);
383+ this.noteFailure(logIndex, `Error evaluating 'if' condition of step '${name}': ${errMsg(e)}`);
384+ scope.failed = true;
385+ record("failure", "failure", {});
386+ return { outcome: "failure", conclusion: "failure", outputs: {}, name };
387+ }
388+ if (!run) {
389+ if (scope.depth > 0) this.logger.line(logIndex, `Skipping step '${name}' (condition not met)`);
390+ record("skipped", "skipped", {});
391+ return { outcome: "skipped", conclusion: "skipped", outputs: {}, name };
392+ }
393+ await onStart?.(name);
394+
395+ const io: StepIO = { logIndex, outputs: {}, state: {} };
396+ let outcome: Outcome = "success";
397+ let continueOnError = false;
398+ try {
399+ continueOnError = this.evalBoolish(step["continue-on-error"], ctx, scope);
400+ const stepEnv: Record<string, string> = {};
401+ const envCtx = { ...baseEnv };
402+ ctx = this.contexts(scope, envCtx);
403+ for (const [k, v] of Object.entries(step.env ?? {})) {
404+ stepEnv[k] = this.interp(String(v ?? ""), ctx, scope);
405+ envCtx[k] = stepEnv[k]!;
406+ }
407+ const timeoutMin = this.evalNumber(step["timeout-minutes"], ctx, scope);
408+ const timeoutMs = timeoutMin > 0 ? timeoutMin * 60_000 : 0;
409+ if (step.run !== undefined) {
410+ await runScript(this, { step, scope, ctx, envCtx, io, timeoutMs, name });
411+ } else if (step.uses) {
412+ await runAction(this, { step, scope, ctx, envCtx, io, timeoutMs, name });
413+ } else {
414+ throw new StepFailure("Step has neither `run` nor `uses`.");
415+ }
416+ if (this.stopping) outcome = "cancelled";
417+ } catch (e) {
418+ if (this.stopping) {
419+ outcome = "cancelled";
420+ } else {
421+ outcome = "failure";
422+ const msg = errMsg(e);
423+ if (!(e instanceof StepFailure && (e as StepFailure & { logged?: boolean }).logged)) {
424+ this.logger.line(logIndex, `##[error]${msg}`);
425+ this.addAnnotation(logIndex, { level: "error", message: msg });
426+ }
427+ io.reason ??= msg;
428+ }
429+ }
430+
431+ let conclusion: Outcome = outcome;
432+ if (outcome === "failure") {
433+ if (continueOnError) {
434+ conclusion = "success";
435+ this.logger.line(logIndex, "##[warning]Step failed but continue-on-error is set; continuing.");
436+ } else {
437+ scope.failed = true;
438+ this.noteFailure(logIndex, io.reason);
439+ }
440+ }
441+ record(outcome, conclusion, io.outputs);
442+ return { outcome, conclusion, outputs: io.outputs, name };
443+ }
444+
445+ private evalBoolish(v: unknown, ctx: Record<string, unknown>, scope: Scope): boolean {
446+ if (v === undefined || v === null || v === false) return false;
447+ if (v === true) return true;
448+ let s = String(v).trim();
449+ if (s.includes("${{")) s = this.interp(s, ctx, scope);
450+ return s === "true";
451+ }
452+
453+ private evalNumber(v: unknown, ctx: Record<string, unknown>, scope: Scope): number {
454+ if (v === undefined || v === null || v === "") return 0;
455+ if (typeof v === "number") return v;
456+ let s = String(v);
457+ if (s.includes("${{")) s = this.interp(s, ctx, scope);
458+ const n = Number(s);
459+ return Number.isFinite(n) ? n : 0;
460+ }
461+
462+ addAnnotation(logIndex: number, a: Annotation): void {
463+ let list = this.stepErrors.get(logIndex);
464+ if (!list) this.stepErrors.set(logIndex, (list = []));
465+ list.push(a);
466+ }
467+
468+ private noteFailure(logIndex: number, reason?: string): void {
469+ if (this.firstFailure) return;
470+ const errs = (this.stepErrors.get(logIndex) ?? []).filter((a) => a.level === "error");
471+ // prefer annotations emitted by the step itself over our generic "exit code" message
472+ const userErr = [...errs].reverse().find((a) => a.message !== reason);
473+ let message: string;
474+ if (userErr) {
475+ const loc = userErr.file ? `${userErr.file}${userErr.line ? `:${userErr.line}` : ""}: ` : "";
476+ message = `${loc}${userErr.title ? `${userErr.title}: ` : ""}${userErr.message}`;
477+ } else {
478+ const tail = this.logger.tail(logIndex, 20).filter((l) => !l.startsWith("##[error]"));
479+ message = [reason ?? "Step failed", ...(tail.length ? ["", ...tail] : [])].join("\n");
480+ }
481+ this.firstFailure = { step: logIndex >= 0 ? logIndex : undefined, message };
482+ if (this.firstFailure.step === undefined) delete this.firstFailure.step;
483+ }
484+
485+ /**
486+ * Runs a process for a step with fresh file-command files, interpreting
487+ * workflow commands, then applies GITHUB_ENV/PATH/OUTPUT/STATE/SUMMARY.
488+ */
489+ async runStepProcess(
490+ argv: string[],
491+ o: { cwd: string; envCtx: Record<string, string>; io: StepIO; timeoutMs?: number; extraEnv?: Record<string, string>; timeoutLabel?: string },
492+ ): Promise<ExecResult> {
493+ const id = randomUUID();
494+ const files = {
495+ GITHUB_ENV: path.join(this.fileCmdDir, `set_env_${id}`),
496+ GITHUB_PATH: path.join(this.fileCmdDir, `add_path_${id}`),
497+ GITHUB_OUTPUT: path.join(this.fileCmdDir, `set_output_${id}`),
498+ GITHUB_STATE: path.join(this.fileCmdDir, `save_state_${id}`),
499+ GITHUB_STEP_SUMMARY: path.join(this.fileCmdDir, `step_summary_${id}`),
500+ };
501+ await mkdir(this.fileCmdDir, { recursive: true });
502+ await Promise.all(Object.values(files).map((f) => writeFile(f, "")));
503+ const proc = this.processor(o.io);
504+ if (!existsSync(o.cwd)) {
505+ throw new StepFailure(`An error occurred trying to start process '${argv[0]}' with working directory '${o.cwd}'. No such file or directory`);
506+ }
507+ const res = await exec(argv[0]!, argv.slice(1), {
508+ cwd: o.cwd,
509+ env: this.processEnv(o.envCtx, { ...(o.extraEnv ?? {}), ...files }),
510+ timeoutMs: o.timeoutMs,
511+ onLine: (l) => proc.handle(l),
512+ onSpawn: (c) => {
513+ this.children.add(c);
514+ c.once("close", () => this.children.delete(c));
515+ },
516+ });
517+ await this.applyFileCommands(files, o.io);
518+ if (res.timedOut) {
519+ const mins = (o.timeoutMs ?? 0) / 60000;
520+ const msg = `The ${o.timeoutLabel ?? "step"} has timed out after ${Number(mins.toFixed(2))} minutes.`;
521+ this.logger.line(o.io.logIndex, `##[error]${msg}`);
522+ this.addAnnotation(o.io.logIndex, { level: "error", message: msg });
523+ o.io.reason = msg;
524+ }
525+ return res;
526+ }
527+
528+ processor(io: StepIO): OutputProcessor {
529+ return new OutputProcessor(this.logger, this.masker, io, {
530+ debug: this.debug,
531+ onAnnotation: (a) => this.addAnnotation(io.logIndex, a),
532+ });
533+ }
534+
535+ private async applyFileCommands(files: Record<string, string>, io: StepIO): Promise<void> {
536+ const read = async (f: string) => {
537+ try {
538+ return await readFile(f, "utf8");
539+ } catch {
540+ return "";
541+ }
542+ };
543+ const [envC, pathC, outC, stateC, sumC] = await Promise.all([
544+ read(files.GITHUB_ENV!),
545+ read(files.GITHUB_PATH!),
546+ read(files.GITHUB_OUTPUT!),
547+ read(files.GITHUB_STATE!),
548+ read(files.GITHUB_STEP_SUMMARY!),
549+ ]);
550+ const safe = (label: string, fn: () => void) => {
551+ try {
552+ fn();
553+ } catch (e) {
554+ this.logger.line(io.logIndex, `##[error]Unable to process file command '${label}' successfully: ${errMsg(e)}`);
555+ this.addAnnotation(io.logIndex, { level: "error", message: `Unable to process file command '${label}': ${errMsg(e)}` });
556+ }
557+ };
558+ safe("env", () => {
559+ for (const [k, v] of parseKeyValueFile(envC)) {
560+ if (k === "NODE_OPTIONS" && /--require|-r\b/.test(v)) continue;
561+ this.envAccum[k] = v;
562+ }
563+ });
564+ safe("path", () => {
565+ for (const p of parsePathFile(pathC)) this.addPath(p);
566+ });
567+ safe("output", () => {
568+ for (const [k, v] of parseKeyValueFile(outC)) io.outputs[k] = v;
569+ });
570+ safe("state", () => {
571+ for (const [k, v] of parseKeyValueFile(stateC)) io.state[k] = v;
572+ });
573+ if (sumC.trim()) this.summaries.push(sumC);
574+ }
575+
576+ addPath(p: string): void {
577+ this.pathAccum = [p, ...this.pathAccum.filter((x) => x !== p)];
578+ }
579+
580+ /** Runs an internal tool (git, tar, ...) streaming its output into the step log. */
581+ async tool(cmd: string, args: string[], o: { io: StepIO; cwd?: string; env?: NodeJS.ProcessEnv; capture?: boolean; silent?: boolean; input?: string }): Promise<ExecResult> {
582+ return exec(cmd, args, {
583+ cwd: o.cwd ?? this.workspace,
584+ env: o.env ?? this.processEnv(this.baseEnvContext({ steps: {}, inputs: {}, failed: false, inheritedEnv: {}, composite: false, depth: 0 })),
585+ capture: o.capture,
586+ input: o.input,
587+ onLine: o.silent ? undefined : (l) => this.logger.line(o.io.logIndex, l),
588+ onSpawn: (c) => {
589+ this.children.add(c);
590+ c.once("close", () => this.children.delete(c));
591+ },
592+ });
593+ }
594+
595+ registerPost(p: PostStep): void {
596+ this.posts.push(p);
597+ }
598+
599+ private async runPosts(top: Scope): Promise<void> {
600+ const posts = [...this.posts].reverse();
601+ let index = this.spec.steps.length;
602+ for (const p of posts) {
603+ const logIndex = index++;
604+ if (this.stopping) break;
605+ let ok: boolean;
606+ try {
607+ ok = evaluateIf(p.condition, this.contexts(top, this.baseEnvContext(top)), this.statusFns(top), this.extra());
608+ } catch {
609+ ok = false;
610+ }
611+ if (!ok) continue;
612+ const startedAt = nowIso();
613+ await this.report(logIndex, { status: "in_progress", name: p.name, startedAt });
614+ let conclusion: Conclusion = "success";
615+ try {
616+ await p.run(logIndex);
617+ } catch (e) {
618+ conclusion = "failure";
619+ const msg = errMsg(e);
620+ if (!(e instanceof StepFailure && (e as StepFailure & { logged?: boolean }).logged)) {
621+ this.logger.line(logIndex, `##[error]${msg}`);
622+ this.addAnnotation(logIndex, { level: "error", message: msg });
623+ }
624+ top.failed = true;
625+ this.noteFailure(logIndex, msg);
626+ }
627+ if (this.stopping) conclusion = "cancelled";
628+ await this.report(logIndex, { status: "completed", conclusion, name: p.name, startedAt, completedAt: nowIso() });
629+ }
630+ }
631+
632+ private jobOutputs(top: Scope): Record<string, string> {
633+ const out: Record<string, string> = {};
634+ const ctx = this.contexts(top, this.baseEnvContext(top));
635+ for (const [k, expr] of Object.entries(this.spec.outputs ?? {})) {
636+ try {
637+ const v = this.interp(String(expr), ctx, top);
638+ if (this.masker.containsSecret(v)) {
639+ this.logger.line(-1, `##[warning]Skip output '${k}' since it may contain secret.`);
640+ continue;
641+ }
642+ out[k] = v;
643+ } catch (e) {
644+ this.logger.line(-1, `##[warning]Failed to evaluate job output '${k}': ${errMsg(e)}`);
645+ }
646+ }
647+ return out;
648+ }
649+
650+ private async report(index: number, r: StepReport): Promise<void> {
651+ try {
652+ await this.api.postStep(index, { ...r, name: this.masker.mask(r.name) });
653+ } catch (e) {
654+ this.logger.line(-1, `##[warning]Failed to report step ${index}: ${errMsg(e)}`);
655+ }
656+ }
657+}
658+
659+export function errMsg(e: unknown): string {
660+ return e instanceof Error ? e.message : String(e);
661+}
662+
663+export function failure(message: string, logged = false): StepFailure {
664+ const f = new StepFailure(message) as StepFailure & { logged?: boolean };
665+ f.logged = logged;
666+ return f;
667+}
668+
669+export function defaultStepName(step: StepSpec): string {
670+ if (step.name) return String(step.name);
671+ if (step.uses) return `Run ${step.uses}`;
672+ if (step.run) return `Run ${String(step.run).trim().split("\n")[0]}`;
673+ return "Step";
674+}
675+
676+function safeName(step: StepSpec, fallback: string): string {
677+ return step.name && !String(step.name).includes("${{") ? String(step.name) : fallback;
678+}
addedcontainers/runner/src/log.ts+135−0View file
Diff not rendered to keep this page fast. View the file
addedcontainers/runner/src/main.ts+106−0View file
Diff not rendered to keep this page fast. View the file
addedcontainers/runner/src/mask.ts+49−0View file
Diff not rendered to keep this page fast. View the file
addedcontainers/runner/src/sandbox.ts+217−0View file
Diff not rendered to keep this page fast. View the file
addedcontainers/runner/src/steps/actions.ts+333−0View file
Diff not rendered to keep this page fast. View the file
addedcontainers/runner/src/steps/run.ts+114−0View file
Diff not rendered to keep this page fast. View the file
addedcontainers/runner/src/util.ts+440−0View file
Diff not rendered to keep this page fast. View the file
addedcontainers/runner/src/yaml.ts+393−0View file
Diff not rendered to keep this page fast. View the file
addedcontainers/runner/test/helpers.ts+157−0View file
Diff not rendered to keep this page fast. View the file
addedcontainers/runner/test/job.test.ts+610−0View file
Diff not rendered to keep this page fast. View the file
addedcontainers/runner/test/sandbox.test.ts+91−0View file
Diff not rendered to keep this page fast. View the file
addedcontainers/runner/test/unit.test.ts+230−0View file
Diff not rendered to keep this page fast. View the file
addedcontainers/runner/tsconfig.json+1−0View file
Diff not rendered to keep this page fast. View the file
addeddocs/PREFERENCES.md+24−0View file
Diff not rendered to keep this page fast. View the file
addedpackage.json+22−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/git-core/package.json+10−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/git-core/src/bytes.ts+79−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/git-core/src/delta.ts+47−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/git-core/src/diff.ts+227−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/git-core/src/index.ts+10−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/git-core/src/memory-store.ts+18−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/git-core/src/objects.ts+165−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/git-core/src/pack.ts+156−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/git-core/src/pktline.ts+55−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/git-core/src/sha1.ts+15−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/git-core/src/store.ts+373−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/git-core/src/zlib.ts+33−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/git-core/test/pack.test.ts+139−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/git-core/tsconfig.json+1−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/shared/package.json+9−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/shared/src/events.ts+19−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/shared/src/index.ts+2−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/shared/src/runner.ts+109−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/shared/tsconfig.json+1−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/workflow-yaml/package.json+10−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/workflow-yaml/src/cron.ts+171−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/workflow-yaml/src/expr/coerce.ts+143−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/workflow-yaml/src/expr/evaluate.ts+292−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/workflow-yaml/src/expr/lexer.ts+143−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/workflow-yaml/src/expr/parser.ts+216−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/workflow-yaml/src/expr/template.ts+68−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/workflow-yaml/src/filters.ts+194−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/workflow-yaml/src/graph.ts+73−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/workflow-yaml/src/index.ts+50−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/workflow-yaml/src/matrix.ts+120−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/workflow-yaml/src/model.ts+174−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/workflow-yaml/src/parse.ts+736−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/workflow-yaml/test/cron.test.ts+82−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/workflow-yaml/test/expr.test.ts+356−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/workflow-yaml/test/filters.test.ts+230−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/workflow-yaml/test/matrix.test.ts+149−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/workflow-yaml/test/parse.test.ts+600−0View file
Diff not rendered to keep this page fast. View the file
addedpackages/workflow-yaml/tsconfig.json+1−0View file
Diff not rendered to keep this page fast. View the file
addedpnpm-lock.yaml+3067−0View file
Diff not rendered to keep this page fast. View the file
addedpnpm-workspace.yaml+4−0View file
Diff not rendered to keep this page fast. View the file
addedscripts/bootstrap.sh+126−0View file
Diff not rendered to keep this page fast. View the file
addedscripts/ensure-secrets.sh+29−0View file
Diff not rendered to keep this page fast. View the file
addedscripts/render-config.mjs+43−0View file
Diff not rendered to keep this page fast. View the file
addedtsconfig.base.json+17−0View file
Diff not rendered to keep this page fast. View the file
addedturbo.json+8−0View file
Diff not rendered to keep this page fast. View the file