Shallow fetch support, deploy tooling, and an end-to-end test with real git
- git: protocol v2 fetch handles deepen, deepen-since and shallow clients - scripts/bootstrap.sh creates D1/R2/queues/Access apps idempotently; ensure-secrets.sh generates MASTER_KEY and the OIDC key once - CI and deploy workflows (deploy runs only from main) - e2e: wrangler dev + git CLI + runner agent: push, CI via self-hosted runner, push protection, agent PR needing human approval, merge, shallow clone, MCP Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL
37 files changed+6796−228
Files (37)
- .github/workflows/ci.yml+51−0
- .github/workflows/deploy.yml+66−0
- apps/cli/package.json+17−0
- apps/cli/src/api.ts+95−0
- apps/cli/src/cli.ts+260−0
- apps/cli/src/commands/admin.ts+301−0
- apps/cli/src/commands/auth.ts+139−0
- apps/cli/src/commands/issue.ts+131−0
- apps/cli/src/commands/pr.ts+310−0
- apps/cli/src/commands/repo.ts+114−0
- apps/cli/src/commands/run.ts+213−0
- apps/cli/src/commands/workflow.ts+140−0
- apps/cli/src/config.ts+89−0
- apps/cli/src/git.ts+100−0
- apps/cli/src/io.ts+89−0
- apps/cli/src/output.ts+67−0
- apps/cli/src/version.ts+1−0
- apps/cli/tsconfig.json+1−0
- apps/diffinery/package.json+6−4
- apps/diffinery/src/app.ts+196−0
- apps/diffinery/src/git/protocol.ts+20−6
- apps/diffinery/src/index.ts+2−194
- apps/diffinery/src/web/account.tsx+14−17
- apps/diffinery/src/web/actions.tsx+578−0
- apps/diffinery/src/web/code.tsx+1107−0
- apps/diffinery/src/web/collab.tsx+1198−0
- apps/diffinery/src/web/repo-settings.tsx+744−0
- apps/diffinery/src/web/routes.tsx+30−0
- apps/diffinery/src/web/ui.tsx+7−0
- apps/diffinery/test/e2e/e2e.test.mjs+315−0
- apps/diffinery/test/e2e/entry.ts+10−0
- apps/diffinery/wrangler.jsonc+78−0
- packages/git-core/src/store.ts+83−7
- pnpm-lock.yaml+19−0
- scripts/bootstrap.sh+133−0
- scripts/ensure-secrets.sh+29−0
- scripts/render-config.mjs+43−0
added.github/workflows/ci.yml+51−0View file
| @@ -1,0 +1,51 @@ | ||
| 1 | +name: CI | |
| 2 | + | |
| 3 | +on: | |
| 4 | + push: | |
| 5 | + branches: [main] | |
| 6 | + pull_request: | |
| 7 | + | |
| 8 | +permissions: | |
| 9 | + contents: read | |
| 10 | + | |
| 11 | +concurrency: | |
| 12 | + group: ci-${{ github.ref }} | |
| 13 | + cancel-in-progress: true | |
| 14 | + | |
| 15 | +jobs: | |
| 16 | + check: | |
| 17 | + name: Typecheck and test | |
| 18 | + runs-on: ubuntu-latest | |
| 19 | + timeout-minutes: 20 | |
| 20 | + steps: | |
| 21 | + - uses: actions/checkout@v4 | |
| 22 | + - uses: pnpm/action-setup@v4 | |
| 23 | + - uses: actions/setup-node@v4 | |
| 24 | + with: | |
| 25 | + node-version: 22 | |
| 26 | + cache: pnpm | |
| 27 | + - run: pnpm install --frozen-lockfile | |
| 28 | + - name: Build | |
| 29 | + run: pnpm -r --if-present build | |
| 30 | + - name: Typecheck | |
| 31 | + run: pnpm -r --if-present typecheck | |
| 32 | + - name: Unit tests | |
| 33 | + run: pnpm -r --if-present test | |
| 34 | + - name: Bundle the Worker (dry run) | |
| 35 | + working-directory: apps/diffinery | |
| 36 | + run: pnpm exec wrangler deploy --dry-run --env dev --outdir dist | |
| 37 | + | |
| 38 | + e2e: | |
| 39 | + name: End-to-end (git, PRs, Actions) | |
| 40 | + runs-on: ubuntu-latest | |
| 41 | + timeout-minutes: 20 | |
| 42 | + steps: | |
| 43 | + - uses: actions/checkout@v4 | |
| 44 | + - uses: pnpm/action-setup@v4 | |
| 45 | + - uses: actions/setup-node@v4 | |
| 46 | + with: | |
| 47 | + node-version: 22 | |
| 48 | + cache: pnpm | |
| 49 | + - run: pnpm install --frozen-lockfile | |
| 50 | + - run: pnpm -r --if-present build | |
| 51 | + - run: pnpm --filter @diffinery/app e2e |
added.github/workflows/deploy.yml+66−0View file
| @@ -1,0 +1,66 @@ | ||
| 1 | +name: Deploy | |
| 2 | + | |
| 3 | +# Deploys diffinery.com from main. Uses the repository secrets | |
| 4 | +# CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID; optional repository variable | |
| 5 | +# ACCESS_TEAM_DOMAIN (e.g. codician.cloudflareaccess.com). | |
| 6 | +on: | |
| 7 | + push: | |
| 8 | + branches: [main] | |
| 9 | + workflow_dispatch: | |
| 10 | + | |
| 11 | +permissions: | |
| 12 | + contents: read | |
| 13 | + | |
| 14 | +concurrency: | |
| 15 | + group: deploy-production | |
| 16 | + cancel-in-progress: false | |
| 17 | + | |
| 18 | +jobs: | |
| 19 | + deploy: | |
| 20 | + name: Deploy to Cloudflare | |
| 21 | + runs-on: ubuntu-latest | |
| 22 | + timeout-minutes: 45 | |
| 23 | + environment: | |
| 24 | + name: production | |
| 25 | + url: https://diffinery.com | |
| 26 | + env: | |
| 27 | + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| 28 | + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| 29 | + ACCESS_TEAM_DOMAIN: ${{ vars.ACCESS_TEAM_DOMAIN }} | |
| 30 | + steps: | |
| 31 | + - uses: actions/checkout@v4 | |
| 32 | + - uses: pnpm/action-setup@v4 | |
| 33 | + - uses: actions/setup-node@v4 | |
| 34 | + with: | |
| 35 | + node-version: 22 | |
| 36 | + cache: pnpm | |
| 37 | + - run: pnpm install --frozen-lockfile | |
| 38 | + - name: Build runner agent | |
| 39 | + run: pnpm --filter @diffinery/runner build | |
| 40 | + - name: Typecheck and test | |
| 41 | + run: pnpm -r --if-present typecheck && pnpm -r --if-present test | |
| 42 | + | |
| 43 | + - name: Create or update Cloudflare resources | |
| 44 | + id: bootstrap | |
| 45 | + run: scripts/bootstrap.sh | |
| 46 | + | |
| 47 | + - name: Deploy Worker and runner image | |
| 48 | + working-directory: apps/diffinery | |
| 49 | + run: pnpm exec wrangler deploy -c wrangler.deploy.jsonc | |
| 50 | + | |
| 51 | + - name: Generate Worker secrets (first deploy only) | |
| 52 | + run: scripts/ensure-secrets.sh | |
| 53 | + | |
| 54 | + - name: Finish Access setup on first deploy | |
| 55 | + if: steps.bootstrap.outputs.access_team == '' | |
| 56 | + run: | | |
| 57 | + scripts/bootstrap.sh | |
| 58 | + cd apps/diffinery && pnpm exec wrangler deploy -c wrangler.deploy.jsonc --containers-rollout none | |
| 59 | + | |
| 60 | + - name: Smoke test | |
| 61 | + run: | | |
| 62 | + for i in 1 2 3 4 5 6; do | |
| 63 | + code=$(curl -s -o /dev/null -w '%{http_code}' https://diffinery.com/healthz) && [ "$code" = 200 ] && exit 0 | |
| 64 | + sleep 10 | |
| 65 | + done | |
| 66 | + echo "::error::https://diffinery.com/healthz did not return 200 (last: $code)"; exit 1 |
addedapps/cli/package.json+17−0View file
| @@ -1,0 +1,17 @@ | ||
| 1 | +{ | |
| 2 | + "name": "@diffinery/cli", | |
| 3 | + "version": "0.1.0", | |
| 4 | + "private": true, | |
| 5 | + "description": "Command-line client for Diffinery", | |
| 6 | + "type": "module", | |
| 7 | + "bin": { "diffinery": "dist/diffinery.mjs" }, | |
| 8 | + "files": ["dist"], | |
| 9 | + "engines": { "node": ">=22" }, | |
| 10 | + "scripts": { | |
| 11 | + "build": "esbuild src/main.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/diffinery.mjs \"--banner:js=#!/usr/bin/env node\nimport { createRequire as __diffineryCreateRequire } from 'node:module'; const require = __diffineryCreateRequire(import.meta.url);\"", | |
| 12 | + "typecheck": "tsc --noEmit", | |
| 13 | + "test": "vitest run" | |
| 14 | + }, | |
| 15 | + "dependencies": { "@diffinery/workflow-yaml": "workspace:*" }, | |
| 16 | + "devDependencies": { "typescript": "^5.9.3", "vitest": "^4.1.0", "esbuild": "^0.25.0", "@types/node": "^22.0.0" } | |
| 17 | +} |
addedapps/cli/src/api.ts+95−0View file
| @@ -1,0 +1,95 @@ | ||
| 1 | +import { CliError } from "./io.js"; | |
| 2 | +import { VERSION } from "./version.js"; | |
| 3 | + | |
| 4 | +export class ApiError extends CliError { | |
| 5 | + constructor( | |
| 6 | + readonly status: number, | |
| 7 | + message: string, | |
| 8 | + readonly body: unknown, | |
| 9 | + ) { | |
| 10 | + super(message); | |
| 11 | + } | |
| 12 | +} | |
| 13 | + | |
| 14 | +export interface RequestOptions { | |
| 15 | + body?: unknown; | |
| 16 | + query?: Record<string, string | number | boolean | undefined | null>; | |
| 17 | + /** Return the response text instead of parsing JSON. */ | |
| 18 | + text?: boolean; | |
| 19 | + /** Treat these statuses as success (e.g. 404 when probing existence). */ | |
| 20 | + allow?: number[]; | |
| 21 | + headers?: Record<string, string>; | |
| 22 | +} | |
| 23 | + | |
| 24 | +export interface ApiResponse<T> { | |
| 25 | + status: number; | |
| 26 | + data: T; | |
| 27 | + headers: Headers; | |
| 28 | +} | |
| 29 | + | |
| 30 | +/** Thin client for the Diffinery REST API (`/api/v3`, GitHub-shaped). */ | |
| 31 | +export class Api { | |
| 32 | + constructor( | |
| 33 | + readonly baseUrl: string, | |
| 34 | + readonly token: string | null, | |
| 35 | + ) {} | |
| 36 | + | |
| 37 | + url(path: string, query?: RequestOptions["query"]): string { | |
| 38 | + const p = /^https?:\/\//.test(path) ? path : `${this.baseUrl}${path.startsWith("/api/") || path === "/mcp" ? "" : "/api/v3"}${path.startsWith("/") ? path : `/${path}`}`; | |
| 39 | + const u = new URL(p); | |
| 40 | + for (const [k, v] of Object.entries(query ?? {})) if (v !== undefined && v !== null && v !== "") u.searchParams.set(k, String(v)); | |
| 41 | + return u.toString(); | |
| 42 | + } | |
| 43 | + | |
| 44 | + async raw<T = unknown>(method: string, path: string, o: RequestOptions = {}): Promise<ApiResponse<T>> { | |
| 45 | + const headers: Record<string, string> = { accept: "application/json", "user-agent": `diffinery-cli/${VERSION}`, ...o.headers }; | |
| 46 | + if (this.token) headers.authorization = `Bearer ${this.token}`; | |
| 47 | + let body: string | undefined; | |
| 48 | + if (o.body !== undefined) { | |
| 49 | + body = typeof o.body === "string" ? o.body : JSON.stringify(o.body); | |
| 50 | + headers["content-type"] ??= "application/json"; | |
| 51 | + } | |
| 52 | + let res: Response; | |
| 53 | + const url = this.url(path, o.query); | |
| 54 | + try { | |
| 55 | + res = await fetch(url, { method, headers, body }); | |
| 56 | + } catch (e) { | |
| 57 | + const cause = (e as { cause?: { code?: string; message?: string } }).cause; | |
| 58 | + throw new CliError(`could not reach ${new URL(url).origin}: ${cause?.code ?? cause?.message ?? (e as Error).message}`); | |
| 59 | + } | |
| 60 | + const text = await res.text(); | |
| 61 | + let data: unknown = text; | |
| 62 | + if (!o.text && text && (res.headers.get("content-type") ?? "").includes("json")) { | |
| 63 | + try { | |
| 64 | + data = JSON.parse(text); | |
| 65 | + } catch { | |
| 66 | + /* leave as text */ | |
| 67 | + } | |
| 68 | + } else if (!o.text && !text) data = null; | |
| 69 | + if (!res.ok && !o.allow?.includes(res.status)) { | |
| 70 | + const msg = data && typeof data === "object" && "message" in data ? String((data as { message: unknown }).message) : text.slice(0, 300) || res.statusText; | |
| 71 | + let hint = ""; | |
| 72 | + if (res.status === 401) hint = this.token ? " (token invalid or expired; run `diffinery login`)" : " (not logged in; run `diffinery login`)"; | |
| 73 | + throw new ApiError(res.status, `${method} ${new URL(url).pathname}: ${res.status} ${msg}${hint}`, data); | |
| 74 | + } | |
| 75 | + return { status: res.status, data: data as T, headers: res.headers }; | |
| 76 | + } | |
| 77 | + | |
| 78 | + async get<T = unknown>(path: string, query?: RequestOptions["query"]): Promise<T> { | |
| 79 | + return (await this.raw<T>("GET", path, { query })).data; | |
| 80 | + } | |
| 81 | + async post<T = unknown>(path: string, body?: unknown): Promise<T> { | |
| 82 | + return (await this.raw<T>("POST", path, { body: body ?? {} })).data; | |
| 83 | + } | |
| 84 | + async put<T = unknown>(path: string, body?: unknown): Promise<T> { | |
| 85 | + return (await this.raw<T>("PUT", path, { body: body ?? {} })).data; | |
| 86 | + } | |
| 87 | + async patch<T = unknown>(path: string, body: unknown): Promise<T> { | |
| 88 | + return (await this.raw<T>("PATCH", path, { body })).data; | |
| 89 | + } | |
| 90 | + async delete<T = unknown>(path: string): Promise<T> { | |
| 91 | + return (await this.raw<T>("DELETE", path)).data; | |
| 92 | + } | |
| 93 | +} | |
| 94 | + | |
| 95 | +export const enc = encodeURIComponent; |
addedapps/cli/src/cli.ts+260−0View file
| @@ -1,0 +1,260 @@ | ||
| 1 | +import { parseArgs } from "node:util"; | |
| 2 | +import { Api } from "./api.js"; | |
| 3 | +import { resolveConfig, type ResolvedConfig } from "./config.js"; | |
| 4 | +import { gitCapture, parseRemote } from "./git.js"; | |
| 5 | +import { CliError, ExitCode, type Io } from "./io.js"; | |
| 6 | +import { colors, type Colors } from "./output.js"; | |
| 7 | +import { VERSION } from "./version.js"; | |
| 8 | + | |
| 9 | +export interface OptSpec { | |
| 10 | + type: "string" | "boolean"; | |
| 11 | + short?: string; | |
| 12 | + multiple?: boolean; | |
| 13 | + desc: string; | |
| 14 | + /** Placeholder shown in help, e.g. "NAME". */ | |
| 15 | + arg?: string; | |
| 16 | +} | |
| 17 | + | |
| 18 | +export type Values = Record<string, string | boolean | string[] | undefined>; | |
| 19 | + | |
| 20 | +export interface Command { | |
| 21 | + /** Full command path, e.g. "pr create". */ | |
| 22 | + name: string; | |
| 23 | + summary: string; | |
| 24 | + /** Positional part of the usage line, e.g. "<number>". */ | |
| 25 | + args?: string; | |
| 26 | + description?: string; | |
| 27 | + options?: Record<string, OptSpec>; | |
| 28 | + /** Accepts -R/--repo OWNER/NAME (defaults to the git origin). */ | |
| 29 | + repo?: boolean; | |
| 30 | + /** Accepts --json. */ | |
| 31 | + json?: boolean; | |
| 32 | + minArgs?: number; | |
| 33 | + maxArgs?: number; | |
| 34 | + run(ctx: Ctx, o: Values, args: string[]): Promise<number | void>; | |
| 35 | +} | |
| 36 | + | |
| 37 | +export interface RepoRef { | |
| 38 | + owner: string; | |
| 39 | + name: string; | |
| 40 | +} | |
| 41 | + | |
| 42 | +export class Ctx { | |
| 43 | + readonly c: Colors; | |
| 44 | + private cfg?: Promise<ResolvedConfig>; | |
| 45 | + constructor( | |
| 46 | + readonly io: Io, | |
| 47 | + readonly opts: Values, | |
| 48 | + ) { | |
| 49 | + this.c = colors(io); | |
| 50 | + } | |
| 51 | + | |
| 52 | + get json(): boolean { | |
| 53 | + return this.opts.json === true; | |
| 54 | + } | |
| 55 | + | |
| 56 | + config(): Promise<ResolvedConfig> { | |
| 57 | + return (this.cfg ??= resolveConfig(this.io)); | |
| 58 | + } | |
| 59 | + | |
| 60 | + async api(): Promise<Api> { | |
| 61 | + const cfg = await this.config(); | |
| 62 | + return new Api(cfg.url, cfg.token); | |
| 63 | + } | |
| 64 | + | |
| 65 | + /** Requires a token (for commands that make no sense anonymously). */ | |
| 66 | + async authedApi(): Promise<Api> { | |
| 67 | + const cfg = await this.config(); | |
| 68 | + if (!cfg.token) throw new CliError("not logged in: run `diffinery login` or set DIFFINERY_TOKEN"); | |
| 69 | + return new Api(cfg.url, cfg.token); | |
| 70 | + } | |
| 71 | + | |
| 72 | + out(s: string): void { | |
| 73 | + this.io.stdout.write(s.endsWith("\n") ? s : `${s}\n`); | |
| 74 | + } | |
| 75 | + | |
| 76 | + err(s: string): void { | |
| 77 | + this.io.stderr.write(s.endsWith("\n") ? s : `${s}\n`); | |
| 78 | + } | |
| 79 | + | |
| 80 | + /** -R owner/name, else the current git repository's origin when it points at the configured host. */ | |
| 81 | + async repo(flag?: string): Promise<RepoRef> { | |
| 82 | + const v = flag ?? (typeof this.opts.repo === "string" ? this.opts.repo : undefined); | |
| 83 | + if (v) return parseRepoArg(v); | |
| 84 | + const cfg = await this.config(); | |
| 85 | + const host = new URL(cfg.url).host; | |
| 86 | + const r = await gitCapture(this.io, ["remote", "get-url", "origin"]); | |
| 87 | + if (r.code === 0) { | |
| 88 | + const parsed = parseRemote(r.stdout, host); | |
| 89 | + if (parsed) return parsed; | |
| 90 | + throw new CliError(`the origin remote (${r.stdout.trim()}) does not point at ${host}; pass -R OWNER/NAME`); | |
| 91 | + } | |
| 92 | + throw new CliError("no repository given: pass -R OWNER/NAME or run inside a clone of a Diffinery repository"); | |
| 93 | + } | |
| 94 | +} | |
| 95 | + | |
| 96 | +export function parseRepoArg(v: string): RepoRef { | |
| 97 | + const s = v.replace(/^https?:\/\/[^/]+\//, "").replace(/\.git$/, "").replace(/\/+$/, ""); | |
| 98 | + const [owner, name, extra] = s.split("/"); | |
| 99 | + if (!owner || !name || extra !== undefined) throw new CliError(`expected OWNER/NAME, got "${v}"`, 2); | |
| 100 | + return { owner, name }; | |
| 101 | +} | |
| 102 | + | |
| 103 | +export const repoPath = (r: RepoRef) => `/repos/${encodeURIComponent(r.owner)}/${encodeURIComponent(r.name)}`; | |
| 104 | + | |
| 105 | +// ---------------------------------------------------------------- help | |
| 106 | + | |
| 107 | +const COMMON: Record<string, OptSpec> = { | |
| 108 | + repo: { type: "string", short: "R", desc: "Repository (default: inferred from the git origin remote)", arg: "OWNER/NAME" }, | |
| 109 | + json: { type: "boolean", desc: "Print raw JSON" }, | |
| 110 | + help: { type: "boolean", short: "h", desc: "Show help" }, | |
| 111 | +}; | |
| 112 | + | |
| 113 | +export function optionsFor(cmd: Command): Record<string, OptSpec> { | |
| 114 | + return { ...(cmd.repo ? { repo: COMMON.repo! } : {}), ...cmd.options, ...(cmd.json ? { json: COMMON.json! } : {}), help: COMMON.help! }; | |
| 115 | +} | |
| 116 | + | |
| 117 | +export function commandHelp(cmd: Command): string { | |
| 118 | + const opts = optionsFor(cmd); | |
| 119 | + const lines = [`${cmd.summary}`, "", `Usage: diffinery ${cmd.name}${cmd.args ? ` ${cmd.args}` : ""}${Object.keys(opts).length > 1 ? " [options]" : ""}`]; | |
| 120 | + if (cmd.description) lines.push("", cmd.description.trim()); | |
| 121 | + const rows = Object.entries(opts).map(([k, o]) => { | |
| 122 | + const flag = `${o.short ? `-${o.short}, ` : " "}--${k}${o.type === "string" ? ` ${o.arg ?? "VALUE"}` : ""}`; | |
| 123 | + return [flag, o.desc + (o.multiple ? " (repeatable)" : "")]; | |
| 124 | + }); | |
| 125 | + const w = Math.max(...rows.map((r) => r[0]!.length)); | |
| 126 | + lines.push("", "Options:", ...rows.map(([f, d]) => ` ${f!.padEnd(w)} ${d}`)); | |
| 127 | + return lines.join("\n") + "\n"; | |
| 128 | +} | |
| 129 | + | |
| 130 | +export function groupHelp(group: string, summary: string, cmds: Command[]): string { | |
| 131 | + const w = Math.max(...cmds.map((c) => c.name.length)); | |
| 132 | + return [summary, "", `Usage: diffinery ${group} <command> [options]`, "", "Commands:", ...cmds.map((c) => ` ${c.name.padEnd(w)} ${c.summary}`), "", `Run 'diffinery ${group} <command> --help' for details.`].join("\n") + "\n"; | |
| 133 | +} | |
| 134 | + | |
| 135 | +// ---------------------------------------------------------------- dispatch | |
| 136 | + | |
| 137 | +export interface Group { | |
| 138 | + name: string; | |
| 139 | + summary: string; | |
| 140 | + commands: Command[]; | |
| 141 | +} | |
| 142 | + | |
| 143 | +export interface Registry { | |
| 144 | + groups: Group[]; | |
| 145 | + commands: Command[]; | |
| 146 | +} | |
| 147 | + | |
| 148 | +export function topHelp(reg: Registry): string { | |
| 149 | + const rows: Array<[string, string]> = [...reg.groups.map((g) => [g.name, g.summary] as [string, string]), ...reg.commands.map((c) => [c.name, c.summary] as [string, string])]; | |
| 150 | + rows.sort((a, b) => a[0].localeCompare(b[0])); | |
| 151 | + const w = Math.max(...rows.map((r) => r[0].length)); | |
| 152 | + return [ | |
| 153 | + `diffinery ${VERSION}: command-line client for Diffinery`, | |
| 154 | + "", | |
| 155 | + "Usage: diffinery <command> [subcommand] [options]", | |
| 156 | + "", | |
| 157 | + "Commands:", | |
| 158 | + ...rows.map(([n, s]) => ` ${n.padEnd(w)} ${s}`), | |
| 159 | + "", | |
| 160 | + "Global:", | |
| 161 | + " --help, -h Show help for any command", | |
| 162 | + " --version Print the version", | |
| 163 | + "", | |
| 164 | + "Configuration: ~/.config/diffinery/config.json (respects XDG_CONFIG_HOME);", | |
| 165 | + "DIFFINERY_URL and DIFFINERY_TOKEN override it.", | |
| 166 | + ].join("\n") + "\n"; | |
| 167 | +} | |
| 168 | + | |
| 169 | +export async function dispatch(reg: Registry, argv: string[], io: Io): Promise<number> { | |
| 170 | + const [first, ...rest] = argv; | |
| 171 | + if (!first || first === "--help" || first === "-h" || first === "help") { | |
| 172 | + if (first === "help" && rest.length) return dispatch(reg, [...rest, "--help"], io); | |
| 173 | + io.stdout.write(topHelp(reg)); | |
| 174 | + return first ? 0 : argv.length ? 0 : 2; | |
| 175 | + } | |
| 176 | + if (first === "--version" || first === "-v" || first === "version") { | |
| 177 | + io.stdout.write(`diffinery ${VERSION}\n`); | |
| 178 | + return 0; | |
| 179 | + } | |
| 180 | + const group = reg.groups.find((g) => g.name === first); | |
| 181 | + if (group) { | |
| 182 | + const [sub, ...args] = rest; | |
| 183 | + if (!sub || sub === "--help" || sub === "-h") { | |
| 184 | + io.stdout.write(groupHelp(group.name, group.summary, group.commands)); | |
| 185 | + return sub ? 0 : 2; | |
| 186 | + } | |
| 187 | + const cmd = group.commands.find((c) => c.name === `${group.name} ${sub}`); | |
| 188 | + if (!cmd) { | |
| 189 | + io.stderr.write(`unknown command "${group.name} ${sub}"\n\n${groupHelp(group.name, group.summary, group.commands)}`); | |
| 190 | + return 2; | |
| 191 | + } | |
| 192 | + return runCommand(cmd, args, io); | |
| 193 | + } | |
| 194 | + const cmd = reg.commands.find((c) => c.name === first); | |
| 195 | + if (!cmd) { | |
| 196 | + io.stderr.write(`unknown command "${first}"; run 'diffinery --help'\n`); | |
| 197 | + return 2; | |
| 198 | + } | |
| 199 | + return runCommand(cmd, rest, io); | |
| 200 | +} | |
| 201 | + | |
| 202 | +export async function runCommand(cmd: Command, argv: string[], io: Io): Promise<number> { | |
| 203 | + const specs = optionsFor(cmd); | |
| 204 | + let values: Values; | |
| 205 | + let positionals: string[]; | |
| 206 | + try { | |
| 207 | + const parsed = parseArgs({ | |
| 208 | + args: argv, | |
| 209 | + options: Object.fromEntries(Object.entries(specs).map(([k, o]) => [k, { type: o.type, short: o.short, multiple: o.multiple }])), | |
| 210 | + allowPositionals: true, | |
| 211 | + strict: true, | |
| 212 | + }); | |
| 213 | + values = parsed.values as Values; | |
| 214 | + positionals = parsed.positionals; | |
| 215 | + } catch (e) { | |
| 216 | + io.stderr.write(`error: ${(e as Error).message.replace(/^TypeError \[[A-Z_]+\]: /, "")}\n\n${commandHelp(cmd)}`); | |
| 217 | + return 2; | |
| 218 | + } | |
| 219 | + if (values.help) { | |
| 220 | + io.stdout.write(commandHelp(cmd)); | |
| 221 | + return 0; | |
| 222 | + } | |
| 223 | + if ((cmd.minArgs !== undefined && positionals.length < cmd.minArgs) || (cmd.maxArgs !== undefined && positionals.length > cmd.maxArgs)) { | |
| 224 | + io.stderr.write(`error: ${positionals.length < (cmd.minArgs ?? 0) ? "missing arguments" : `unexpected argument "${positionals[cmd.maxArgs ?? 0]}"`}\n\n${commandHelp(cmd)}`); | |
| 225 | + return 2; | |
| 226 | + } | |
| 227 | + const ctx = new Ctx(io, values); | |
| 228 | + try { | |
| 229 | + const code = await cmd.run(ctx, values, positionals); | |
| 230 | + return typeof code === "number" ? code : 0; | |
| 231 | + } catch (e) { | |
| 232 | + if (e instanceof ExitCode) return e.code; | |
| 233 | + if (e instanceof CliError) { | |
| 234 | + io.stderr.write(`${ctx.c.red("error:")} ${e.message}\n`); | |
| 235 | + return e.code; | |
| 236 | + } | |
| 237 | + throw e; | |
| 238 | + } | |
| 239 | +} | |
| 240 | + | |
| 241 | +// ---------------------------------------------------------------- small helpers for commands | |
| 242 | + | |
| 243 | +export const str = (o: Values, k: string): string | undefined => (typeof o[k] === "string" ? (o[k] as string) : undefined); | |
| 244 | +export const list = (o: Values, k: string): string[] => (Array.isArray(o[k]) ? (o[k] as string[]) : typeof o[k] === "string" ? [o[k] as string] : []); | |
| 245 | + | |
| 246 | +export function positiveInt(v: string | undefined, what: string): number { | |
| 247 | + const n = Number(String(v ?? "").replace(/^#/, "")); | |
| 248 | + if (!Number.isInteger(n) || n < 1) throw new CliError(`${what} must be a positive number, got "${v ?? ""}"`, 2); | |
| 249 | + return n; | |
| 250 | +} | |
| 251 | + | |
| 252 | +export function exactlyOne(o: Values, keys: string[], fallback?: string): string { | |
| 253 | + const set = keys.filter((k) => o[k] === true); | |
| 254 | + if (set.length > 1) throw new CliError(`use only one of ${keys.map((k) => `--${k}`).join(", ")}`, 2); | |
| 255 | + if (set.length === 0) { | |
| 256 | + if (fallback) return fallback; | |
| 257 | + throw new CliError(`one of ${keys.map((k) => `--${k}`).join(", ")} is required`, 2); | |
| 258 | + } | |
| 259 | + return set[0]!; | |
| 260 | +} |
addedapps/cli/src/commands/admin.ts+301−0View file
| @@ -1,0 +1,301 @@ | ||
| 1 | +import { readFile } from "node:fs/promises"; | |
| 2 | +import path from "node:path"; | |
| 3 | +import { enc } from "../api.js"; | |
| 4 | +import { list, positiveInt, repoPath, str, type Command, type Group } from "../cli.js"; | |
| 5 | +import { CliError, ExitCode, readAll, valueFromFlagOrStdin } from "../io.js"; | |
| 6 | +import { ago, json, table } from "../output.js"; | |
| 7 | + | |
| 8 | +const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; | |
| 9 | +function checkName(name: string, what: string): string { | |
| 10 | + if (!NAME_RE.test(name)) throw new CliError(`invalid ${what} name "${name}": use letters, digits and _, not starting with a digit`, 2); | |
| 11 | + return name; | |
| 12 | +} | |
| 13 | + | |
| 14 | +// ---------------------------------------------------------------- secrets | |
| 15 | + | |
| 16 | +const secretSet: Command = { | |
| 17 | + name: "secret set", | |
| 18 | + summary: "Create or update an Actions secret", | |
| 19 | + description: "The value comes from --body, from stdin when piped, or a hidden prompt.\nRepository level by default; --org sets an organization secret.", | |
| 20 | + args: "<NAME>", | |
| 21 | + options: { | |
| 22 | + body: { type: "string", short: "b", desc: "Secret value", arg: "VALUE" }, | |
| 23 | + org: { type: "string", short: "o", desc: "Set an organization secret instead", arg: "ORG" }, | |
| 24 | + }, | |
| 25 | + repo: true, | |
| 26 | + minArgs: 1, | |
| 27 | + maxArgs: 1, | |
| 28 | + async run(ctx, o, [name]) { | |
| 29 | + checkName(name!, "secret"); | |
| 30 | + const org = str(o, "org"); | |
| 31 | + const target = org ? `/orgs/${enc(org)}/actions/secrets/${enc(name!)}` : `${repoPath(await ctx.repo())}/actions/secrets/${enc(name!)}`; | |
| 32 | + const value = await valueFromFlagOrStdin(ctx.io, str(o, "body"), `Value for ${name}: `); | |
| 33 | + if (!value) throw new CliError("secret value is empty"); | |
| 34 | + await (await ctx.authedApi()).put(target, { value }); | |
| 35 | + ctx.out(`${ctx.c.green("✓")} Set secret ${name}${org ? ` for ${org}` : ""}`); | |
| 36 | + }, | |
| 37 | +}; | |
| 38 | + | |
| 39 | +const secretList: Command = { | |
| 40 | + name: "secret list", | |
| 41 | + summary: "List Actions secret names", | |
| 42 | + options: { org: { type: "string", short: "o", desc: "Organization secrets (not supported by the server yet)", arg: "ORG" } }, | |
| 43 | + repo: true, | |
| 44 | + json: true, | |
| 45 | + maxArgs: 0, | |
| 46 | + async run(ctx, o) { | |
| 47 | + if (str(o, "org")) throw new CliError("listing organization secrets is not supported by this Diffinery server (no GET /orgs/:org/actions/secrets)"); | |
| 48 | + const res = await (await ctx.authedApi()).get<{ secrets: Array<{ name: string; updated_at: string }> }>(`${repoPath(await ctx.repo())}/actions/secrets`); | |
| 49 | + if (ctx.json) return json(ctx.io, res.secrets); | |
| 50 | + if (!res.secrets.length) return ctx.err("No secrets."); | |
| 51 | + ctx.io.stdout.write(table(res.secrets.map((s) => [s.name, ctx.c.dim(`updated ${ago(s.updated_at)}`)]))); | |
| 52 | + }, | |
| 53 | +}; | |
| 54 | + | |
| 55 | +const secretDelete: Command = { | |
| 56 | + name: "secret delete", | |
| 57 | + summary: "Delete an Actions secret", | |
| 58 | + args: "<NAME>", | |
| 59 | + options: { org: { type: "string", short: "o", desc: "Organization secret (not supported by the server yet)", arg: "ORG" } }, | |
| 60 | + repo: true, | |
| 61 | + minArgs: 1, | |
| 62 | + maxArgs: 1, | |
| 63 | + async run(ctx, o, [name]) { | |
| 64 | + if (str(o, "org")) throw new CliError("deleting organization secrets is not supported by this Diffinery server (no DELETE /orgs/:org/actions/secrets/:name)"); | |
| 65 | + await (await ctx.authedApi()).delete(`${repoPath(await ctx.repo())}/actions/secrets/${enc(name!)}`); | |
| 66 | + ctx.out(`${ctx.c.green("✓")} Deleted secret ${name}`); | |
| 67 | + }, | |
| 68 | +}; | |
| 69 | + | |
| 70 | +const variableSet: Command = { | |
| 71 | + name: "variable set", | |
| 72 | + summary: "Create or update an Actions variable", | |
| 73 | + description: "The value comes from --body, or from stdin when piped.", | |
| 74 | + args: "<NAME>", | |
| 75 | + options: { body: { type: "string", short: "b", desc: "Variable value", arg: "VALUE" } }, | |
| 76 | + repo: true, | |
| 77 | + minArgs: 1, | |
| 78 | + maxArgs: 1, | |
| 79 | + async run(ctx, o, [name]) { | |
| 80 | + checkName(name!, "variable"); | |
| 81 | + let value = str(o, "body"); | |
| 82 | + if (value === undefined) { | |
| 83 | + if (ctx.io.stdin.isTTY) throw new CliError("pass --body VALUE or pipe the value on stdin", 2); | |
| 84 | + value = (await readAll(ctx.io.stdin)).replace(/\r?\n$/, ""); | |
| 85 | + } | |
| 86 | + await (await ctx.authedApi()).put(`${repoPath(await ctx.repo())}/actions/variables/${enc(name!)}`, { value }); | |
| 87 | + ctx.out(`${ctx.c.green("✓")} Set variable ${name}`); | |
| 88 | + }, | |
| 89 | +}; | |
| 90 | + | |
| 91 | +const variableList: Command = { | |
| 92 | + name: "variable list", | |
| 93 | + summary: "List Actions variables", | |
| 94 | + repo: true, | |
| 95 | + json: true, | |
| 96 | + maxArgs: 0, | |
| 97 | + async run(ctx) { | |
| 98 | + const res = await (await ctx.api()).get<{ variables: Array<{ name: string; value: string }> }>(`${repoPath(await ctx.repo())}/actions/variables`); | |
| 99 | + if (ctx.json) return json(ctx.io, res.variables); | |
| 100 | + if (!res.variables.length) return ctx.err("No variables."); | |
| 101 | + ctx.io.stdout.write(table(res.variables.map((v) => [v.name, v.value]))); | |
| 102 | + }, | |
| 103 | +}; | |
| 104 | + | |
| 105 | +// ---------------------------------------------------------------- tokens | |
| 106 | + | |
| 107 | +interface TokenRow { | |
| 108 | + id: string; | |
| 109 | + name: string; | |
| 110 | + prefix: string; | |
| 111 | + scopes: string[]; | |
| 112 | + repoIds: string[] | null; | |
| 113 | + expiresAt: string; | |
| 114 | + lastUsedAt: string | null; | |
| 115 | + createdAt: string; | |
| 116 | +} | |
| 117 | + | |
| 118 | +const tokenCreate: Command = { | |
| 119 | + name: "token create", | |
| 120 | + summary: "Create a personal access token (printed once)", | |
| 121 | + description: "Needs an admin-scoped token (or create tokens in the web UI).", | |
| 122 | + options: { | |
| 123 | + name: { type: "string", short: "n", desc: "Token name (required)", arg: "NAME" }, | |
| 124 | + scope: { type: "string", short: "s", multiple: true, desc: "read, write (default) or admin", arg: "SCOPE" }, | |
| 125 | + "expires-days": { type: "string", short: "e", desc: "Days until it expires (1-366, default 90)", arg: "DAYS" }, | |
| 126 | + repo: { type: "string", short: "R", multiple: true, desc: "Restrict the token to this repository", arg: "OWNER/NAME" }, | |
| 127 | + }, | |
| 128 | + json: true, | |
| 129 | + maxArgs: 0, | |
| 130 | + async run(ctx, o) { | |
| 131 | + const name = str(o, "name"); | |
| 132 | + if (!name) throw new CliError("--name is required", 2); | |
| 133 | + const scopes = list(o, "scope").flatMap((s) => s.split(",")).map((s) => s.trim()).filter(Boolean); | |
| 134 | + for (const s of scopes) if (!["read", "write", "admin"].includes(s)) throw new CliError(`unknown scope "${s}" (read, write or admin)`, 2); | |
| 135 | + const days = str(o, "expires-days") ? positiveInt(str(o, "expires-days"), "--expires-days") : undefined; | |
| 136 | + const repos = list(o, "repo"); | |
| 137 | + const t = await (await ctx.authedApi()).post<{ token: string; id: string; expiresAt: string }>("/diffinery/tokens", { name, scopes: scopes.length ? scopes : undefined, expires_in_days: days, repositories: repos.length ? repos : undefined }); | |
| 138 | + if (ctx.json) return json(ctx.io, t); | |
| 139 | + ctx.out(t.token); | |
| 140 | + ctx.err(ctx.c.dim(`Token ${t.id} expires ${t.expiresAt.slice(0, 10)}. It will not be shown again.`)); | |
| 141 | + }, | |
| 142 | +}; | |
| 143 | + | |
| 144 | +const tokenList: Command = { | |
| 145 | + name: "token list", | |
| 146 | + summary: "List your tokens", | |
| 147 | + json: true, | |
| 148 | + maxArgs: 0, | |
| 149 | + async run(ctx) { | |
| 150 | + const rows = await (await ctx.authedApi()).get<TokenRow[]>("/diffinery/tokens"); | |
| 151 | + if (ctx.json) return json(ctx.io, rows); | |
| 152 | + if (!rows.length) return ctx.err("No tokens."); | |
| 153 | + const c = ctx.c; | |
| 154 | + ctx.io.stdout.write( | |
| 155 | + table([ | |
| 156 | + ["ID", "NAME", "PREFIX", "SCOPES", "EXPIRES", "LAST USED"].map((h) => c.dim(h)), | |
| 157 | + ...rows.map((t) => [t.id, t.name, `${t.prefix}…`, t.scopes.join(",") + (t.repoIds ? ` (${t.repoIds.length} repo${t.repoIds.length === 1 ? "" : "s"})` : ""), t.expiresAt.slice(0, 10), t.lastUsedAt ? ago(t.lastUsedAt) : c.dim("never")]), | |
| 158 | + ]), | |
| 159 | + ); | |
| 160 | + }, | |
| 161 | +}; | |
| 162 | + | |
| 163 | +const tokenRevoke: Command = { | |
| 164 | + name: "token revoke", | |
| 165 | + summary: "Revoke a token", | |
| 166 | + args: "<id>", | |
| 167 | + minArgs: 1, | |
| 168 | + maxArgs: 1, | |
| 169 | + async run(ctx, _o, [id]) { | |
| 170 | + await (await ctx.authedApi()).delete(`/diffinery/tokens/${enc(id!)}`); | |
| 171 | + ctx.out(`${ctx.c.green("✓")} Revoked token ${id}`); | |
| 172 | + }, | |
| 173 | +}; | |
| 174 | + | |
| 175 | +// ---------------------------------------------------------------- agents & invites | |
| 176 | + | |
| 177 | +const agentCreate: Command = { | |
| 178 | + name: "agent create", | |
| 179 | + summary: "Create an agent account and print its token once", | |
| 180 | + args: "<login>", | |
| 181 | + options: { | |
| 182 | + org: { type: "string", short: "o", desc: "Owned by this organization instead of you", arg: "ORG" }, | |
| 183 | + name: { type: "string", short: "n", desc: "Display name", arg: "NAME" }, | |
| 184 | + scope: { type: "string", short: "s", multiple: true, desc: "Token scope: read, write (default) or admin", arg: "SCOPE" }, | |
| 185 | + "expires-days": { type: "string", short: "e", desc: "Token lifetime in days (default 90)", arg: "DAYS" }, | |
| 186 | + }, | |
| 187 | + json: true, | |
| 188 | + minArgs: 1, | |
| 189 | + maxArgs: 1, | |
| 190 | + async run(ctx, o, [login]) { | |
| 191 | + const scopes = list(o, "scope"); | |
| 192 | + const days = str(o, "expires-days") ? positiveInt(str(o, "expires-days"), "--expires-days") : undefined; | |
| 193 | + const r = await (await ctx.authedApi()).post<{ agent: { login: string; html_url: string }; token: { token: string; id: string; expiresAt: string } }>("/diffinery/agents", { | |
| 194 | + login, | |
| 195 | + name: str(o, "name"), | |
| 196 | + org: str(o, "org"), | |
| 197 | + token: scopes.length || days ? { scopes: scopes.length ? scopes : undefined, expires_in_days: days } : undefined, | |
| 198 | + }); | |
| 199 | + if (ctx.json) return json(ctx.io, r); | |
| 200 | + ctx.out(`${ctx.c.green("✓")} Created agent ${ctx.c.bold(r.agent.login)}`); | |
| 201 | + ctx.out(r.token.token); | |
| 202 | + ctx.err(ctx.c.dim(`This token (expires ${r.token.expiresAt.slice(0, 10)}) will not be shown again. Give it to the agent as DIFFINERY_TOKEN.`)); | |
| 203 | + }, | |
| 204 | +}; | |
| 205 | + | |
| 206 | +const agentList: Command = { | |
| 207 | + name: "agent list", | |
| 208 | + summary: "List agents you or your organizations own", | |
| 209 | + json: true, | |
| 210 | + maxArgs: 0, | |
| 211 | + async run(ctx) { | |
| 212 | + const rows = await (await ctx.authedApi()).get<Array<{ login: string; name: string | null; owner_id: string; policy: Record<string, unknown> }>>("/diffinery/agents"); | |
| 213 | + if (ctx.json) return json(ctx.io, rows); | |
| 214 | + if (!rows.length) return ctx.err("No agents."); | |
| 215 | + ctx.io.stdout.write(table(rows.map((a) => [ctx.c.bold(a.login), a.name && a.name !== a.login ? a.name : "", ctx.c.dim(typeof a.policy?.maxRole === "string" ? `max role ${a.policy.maxRole}` : "")]))); | |
| 216 | + }, | |
| 217 | +}; | |
| 218 | + | |
| 219 | +const agentDisable: Command = { | |
| 220 | + name: "agent disable", | |
| 221 | + summary: "Disable an agent (its tokens stop working)", | |
| 222 | + args: "<login>", | |
| 223 | + minArgs: 1, | |
| 224 | + maxArgs: 1, | |
| 225 | + async run(ctx, _o, [login]) { | |
| 226 | + await (await ctx.authedApi()).delete(`/diffinery/agents/${enc(login!)}`); | |
| 227 | + ctx.out(`${ctx.c.green("✓")} Disabled agent ${login}`); | |
| 228 | + }, | |
| 229 | +}; | |
| 230 | + | |
| 231 | +const invite: Command = { | |
| 232 | + name: "invite", | |
| 233 | + summary: "Invite someone to an organization by email", | |
| 234 | + args: "<email>", | |
| 235 | + options: { | |
| 236 | + org: { type: "string", short: "o", desc: "Organization (required)", arg: "ORG" }, | |
| 237 | + role: { type: "string", short: "r", desc: "member (default), admin or owner", arg: "ROLE" }, | |
| 238 | + }, | |
| 239 | + json: true, | |
| 240 | + minArgs: 1, | |
| 241 | + maxArgs: 1, | |
| 242 | + async run(ctx, o, [email]) { | |
| 243 | + const org = str(o, "org"); | |
| 244 | + if (!org) throw new CliError("--org is required (the server only supports organization invitations)", 2); | |
| 245 | + const role = str(o, "role"); | |
| 246 | + if (role && !["member", "admin", "owner"].includes(role)) throw new CliError("--role must be member, admin or owner", 2); | |
| 247 | + const r = await (await ctx.authedApi()).post<{ id: string; url: string }>(`/orgs/${enc(org)}/invitations`, { email, role }); | |
| 248 | + if (ctx.json) return json(ctx.io, r); | |
| 249 | + ctx.out(`${ctx.c.green("✓")} Invited ${email} to ${org}${role ? ` as ${role}` : ""}`); | |
| 250 | + ctx.out(r.url); | |
| 251 | + ctx.err(ctx.c.dim("Send them this link; it is valid for 14 days.")); | |
| 252 | + }, | |
| 253 | +}; | |
| 254 | + | |
| 255 | +// ---------------------------------------------------------------- raw API | |
| 256 | + | |
| 257 | +const apiCmd: Command = { | |
| 258 | + name: "api", | |
| 259 | + summary: "Make an authenticated API request and print the response", | |
| 260 | + args: "<METHOD> <PATH>", | |
| 261 | + description: `PATH is relative to /api/v3 (e.g. /user, /repos/OWNER/NAME/pulls) unless it starts with /api/. | |
| 262 | +--input sends a JSON body from a file, or from stdin with '-'.`, | |
| 263 | + options: { | |
| 264 | + input: { type: "string", short: "i", desc: "Request body file, or '-' for stdin", arg: "FILE" }, | |
| 265 | + field: { type: "string", short: "f", multiple: true, desc: "Add a string field to the JSON body (or query for GET)", arg: "KEY=VALUE" }, | |
| 266 | + }, | |
| 267 | + minArgs: 2, | |
| 268 | + maxArgs: 2, | |
| 269 | + async run(ctx, o, [method, p]) { | |
| 270 | + const m = method!.toUpperCase(); | |
| 271 | + if (!/^[A-Z]+$/.test(m)) throw new CliError(`invalid method "${method}"`, 2); | |
| 272 | + let body: string | undefined; | |
| 273 | + const input = str(o, "input"); | |
| 274 | + if (input) body = input === "-" ? await readAll(ctx.io.stdin) : await readFile(path.resolve(ctx.io.cwd, input), "utf8"); | |
| 275 | + const fields: Record<string, string> = {}; | |
| 276 | + for (const f of list(o, "field")) { | |
| 277 | + const i = f.indexOf("="); | |
| 278 | + if (i <= 0) throw new CliError(`-f expects key=value, got "${f}"`, 2); | |
| 279 | + fields[f.slice(0, i)] = f.slice(i + 1); | |
| 280 | + } | |
| 281 | + const hasFields = Object.keys(fields).length > 0; | |
| 282 | + if (hasFields && m !== "GET" && m !== "HEAD") { | |
| 283 | + if (body) throw new CliError("use either --input or -f, not both", 2); | |
| 284 | + body = JSON.stringify(fields); | |
| 285 | + } | |
| 286 | + const api = await ctx.api(); | |
| 287 | + const res = await api.raw<unknown>(m, p!, { body, query: m === "GET" && hasFields ? fields : undefined, allow: Array.from({ length: 500 }, (_, i) => i + 100) }); | |
| 288 | + const out = typeof res.data === "string" ? res.data : res.data === null ? "" : JSON.stringify(res.data, null, 2); | |
| 289 | + if (out) ctx.out(out); | |
| 290 | + if (res.status >= 400) { | |
| 291 | + ctx.err(ctx.c.red(`HTTP ${res.status}`)); | |
| 292 | + throw new ExitCode(1); | |
| 293 | + } | |
| 294 | + }, | |
| 295 | +}; | |
| 296 | + | |
| 297 | +export const secretGroup: Group = { name: "secret", summary: "Manage Actions secrets", commands: [secretSet, secretList, secretDelete] }; | |
| 298 | +export const variableGroup: Group = { name: "variable", summary: "Manage Actions variables", commands: [variableSet, variableList] }; | |
| 299 | +export const tokenGroup: Group = { name: "token", summary: "Manage personal access tokens", commands: [tokenCreate, tokenList, tokenRevoke] }; | |
| 300 | +export const agentGroup: Group = { name: "agent", summary: "Manage agent accounts", commands: [agentCreate, agentList, agentDisable] }; | |
| 301 | +export const adminCommands: Command[] = [invite, apiCmd]; |
addedapps/cli/src/commands/auth.ts+139−0View file
| @@ -1,0 +1,139 @@ | ||
| 1 | +import { Api } from "../api.js"; | |
| 2 | +import { str, type Command, type Group } from "../cli.js"; | |
| 3 | +import { normalizeUrl, readConfig, resolveConfig, writeConfig } from "../config.js"; | |
| 4 | +import { gitCapture } from "../git.js"; | |
| 5 | +import { CliError, readAll, valueFromFlagOrStdin } from "../io.js"; | |
| 6 | +import { json } from "../output.js"; | |
| 7 | + | |
| 8 | +interface User { | |
| 9 | + login: string; | |
| 10 | + name?: string | null; | |
| 11 | + type?: string; | |
| 12 | + email?: string | null; | |
| 13 | + html_url?: string; | |
| 14 | +} | |
| 15 | + | |
| 16 | +const login: Command = { | |
| 17 | + name: "login", | |
| 18 | + summary: "Log in with a personal or agent token", | |
| 19 | + description: `Stores the server URL and token in the config file (mode 0600). | |
| 20 | +The token is read from --token, from stdin when piped, or prompted for. | |
| 21 | +Create a token in the web UI (Settings > Tokens) or with 'diffinery token create'.`, | |
| 22 | + options: { | |
| 23 | + url: { type: "string", desc: "Server URL (default: DIFFINERY_URL, the saved URL, or https://diffinery.com)", arg: "URL" }, | |
| 24 | + token: { type: "string", desc: "Token (dfy_...)", arg: "TOKEN" }, | |
| 25 | + }, | |
| 26 | + maxArgs: 0, | |
| 27 | + async run(ctx, o) { | |
| 28 | + const stored = await readConfig(ctx.io.env); | |
| 29 | + const cfg = await resolveConfig(ctx.io); | |
| 30 | + const url = str(o, "url") ? normalizeUrl(str(o, "url")!) : cfg.url; | |
| 31 | + const token = (await valueFromFlagOrStdin(ctx.io, str(o, "token"), `Paste a token for ${url}: `)).trim(); | |
| 32 | + if (!token) throw new CliError("no token given"); | |
| 33 | + const me = await new Api(url, token).get<User>("/user"); | |
| 34 | + const file = await writeConfig(ctx.io.env, { ...stored, url, token }); | |
| 35 | + ctx.out(`${ctx.c.green("✓")} Logged in to ${url} as ${ctx.c.bold(me.login)}${me.type === "Bot" ? " (agent)" : ""}`); | |
| 36 | + ctx.err(ctx.c.dim(`Saved to ${file}`)); | |
| 37 | + if (ctx.io.env.DIFFINERY_TOKEN) ctx.err(ctx.c.yellow("note: DIFFINERY_TOKEN is set and overrides the saved token")); | |
| 38 | + }, | |
| 39 | +}; | |
| 40 | + | |
| 41 | +const logout: Command = { | |
| 42 | + name: "logout", | |
| 43 | + summary: "Remove the saved token", | |
| 44 | + maxArgs: 0, | |
| 45 | + async run(ctx) { | |
| 46 | + const stored = await readConfig(ctx.io.env); | |
| 47 | + if (!stored.token) ctx.out("Not logged in."); | |
| 48 | + else { | |
| 49 | + await writeConfig(ctx.io.env, { url: stored.url }); | |
| 50 | + ctx.out(`Logged out of ${stored.url ?? "Diffinery"}.`); | |
| 51 | + } | |
| 52 | + if (ctx.io.env.DIFFINERY_TOKEN) ctx.err(ctx.c.yellow("note: DIFFINERY_TOKEN is still set in your environment")); | |
| 53 | + }, | |
| 54 | +}; | |
| 55 | + | |
| 56 | +const whoami: Command = { | |
| 57 | + name: "whoami", | |
| 58 | + summary: "Show the account the current token acts as", | |
| 59 | + json: true, | |
| 60 | + maxArgs: 0, | |
| 61 | + async run(ctx) { | |
| 62 | + const cfg = await ctx.config(); | |
| 63 | + const me = await (await ctx.authedApi()).get<User>("/user"); | |
| 64 | + if (ctx.json) return json(ctx.io, me); | |
| 65 | + ctx.out(`${ctx.c.bold(me.login)}${me.name && me.name !== me.login ? ` (${me.name})` : ""}${me.type === "Bot" ? " [agent]" : ""}`); | |
| 66 | + ctx.out(ctx.c.dim(`${cfg.url} · token from ${cfg.tokenSource === "env" ? "DIFFINERY_TOKEN" : cfg.file}`)); | |
| 67 | + }, | |
| 68 | +}; | |
| 69 | + | |
| 70 | +const setupGit: Command = { | |
| 71 | + name: "auth setup-git", | |
| 72 | + summary: "Make git use your Diffinery token for HTTPS remotes", | |
| 73 | + description: `Runs: git config --global credential.<url>.helper "!diffinery auth git-credential" | |
| 74 | +(after resetting other helpers for that URL), so clone/fetch/push to the Diffinery host | |
| 75 | +authenticate with the token from the config file or DIFFINERY_TOKEN.`, | |
| 76 | + maxArgs: 0, | |
| 77 | + async run(ctx) { | |
| 78 | + const cfg = await ctx.config(); | |
| 79 | + const key = `credential.${cfg.url}.helper`; | |
| 80 | + await gitCapture(ctx.io, ["config", "--global", "--unset-all", key]); | |
| 81 | + // An empty helper resets the list so a generic helper (e.g. a keychain) does not answer first. | |
| 82 | + for (const value of ["", "!diffinery auth git-credential"]) { | |
| 83 | + const r = await gitCapture(ctx.io, ["config", "--global", "--add", key, value]); | |
| 84 | + if (r.code !== 0) throw new CliError(`git config failed: ${r.stderr.trim()}`); | |
| 85 | + } | |
| 86 | + ctx.out(`${ctx.c.green("✓")} git will use diffinery for ${cfg.url}`); | |
| 87 | + }, | |
| 88 | +}; | |
| 89 | + | |
| 90 | +/** Parses the git credential helper protocol's key=value lines. */ | |
| 91 | +export function parseCredentialInput(text: string): Record<string, string> { | |
| 92 | + const out: Record<string, string> = {}; | |
| 93 | + for (const line of text.split(/\r?\n/)) { | |
| 94 | + if (!line) break; | |
| 95 | + const i = line.indexOf("="); | |
| 96 | + if (i > 0) out[line.slice(0, i)] = line.slice(i + 1); | |
| 97 | + } | |
| 98 | + return out; | |
| 99 | +} | |
| 100 | + | |
| 101 | +const gitCredential: Command = { | |
| 102 | + name: "auth git-credential", | |
| 103 | + summary: "Git credential helper (used by 'auth setup-git')", | |
| 104 | + args: "<get|store|erase>", | |
| 105 | + minArgs: 1, | |
| 106 | + maxArgs: 1, | |
| 107 | + async run(ctx, _o, [op]) { | |
| 108 | + const input = parseCredentialInput(await readAll(ctx.io.stdin)); | |
| 109 | + if (op !== "get") return; // store/erase: the token lives in the config file | |
| 110 | + const cfg = await ctx.config(); | |
| 111 | + if (!cfg.token) return; | |
| 112 | + const want = new URL(cfg.url); | |
| 113 | + const proto = (input.protocol ?? "").toLowerCase(); | |
| 114 | + const host = (input.host ?? "").toLowerCase(); | |
| 115 | + if (proto !== want.protocol.replace(/:$/, "") || host !== want.host.toLowerCase()) return; | |
| 116 | + ctx.io.stdout.write(`protocol=${proto}\nhost=${input.host}\nusername=x-token\npassword=${cfg.token}\n`); | |
| 117 | + }, | |
| 118 | +}; | |
| 119 | + | |
| 120 | +const mcpConfig: Command = { | |
| 121 | + name: "mcp-config", | |
| 122 | + summary: "Print an MCP server config snippet for Claude Code and other MCP clients", | |
| 123 | + description: `By default the token is written as \${DIFFINERY_TOKEN} (expanded by clients such as | |
| 124 | +Claude Code from the environment). Pass --show-token to embed the saved token instead.`, | |
| 125 | + options: { "show-token": { type: "boolean", desc: "Embed the actual token in the snippet" } }, | |
| 126 | + maxArgs: 0, | |
| 127 | + async run(ctx, o) { | |
| 128 | + const cfg = await ctx.config(); | |
| 129 | + let token = "${DIFFINERY_TOKEN}"; | |
| 130 | + if (o["show-token"]) { | |
| 131 | + if (!cfg.token) throw new CliError("not logged in: run `diffinery login` or set DIFFINERY_TOKEN"); | |
| 132 | + token = cfg.token; | |
| 133 | + } | |
| 134 | + json(ctx.io, { mcpServers: { diffinery: { type: "http", url: `${cfg.url}/mcp`, headers: { Authorization: `Bearer ${token}` } } } }); | |
| 135 | + }, | |
| 136 | +}; | |
| 137 | + | |
| 138 | +export const authCommands: Command[] = [login, logout, whoami, mcpConfig]; | |
| 139 | +export const authGroup: Group = { name: "auth", summary: "Git credential integration", commands: [setupGit, gitCredential] }; |
addedapps/cli/src/commands/issue.ts+131−0View file
| @@ -1,0 +1,131 @@ | ||
| 1 | +import { list, positiveInt, repoPath, str, type Command, type Ctx, type Group } from "../cli.js"; | |
| 2 | +import { CliError } from "../io.js"; | |
| 3 | +import { ago, json, table, truncate } from "../output.js"; | |
| 4 | +import { bodyFrom, bodyOpts, type CommentJson, type IssueJson } from "./pr.js"; | |
| 5 | + | |
| 6 | +const issueState = (c: Ctx["c"], i: IssueJson) => (i.state === "open" ? c.green("open") : c.dim(i.state_reason === "not_planned" ? "not planned" : "closed")); | |
| 7 | + | |
| 8 | +const create: Command = { | |
| 9 | + name: "issue create", | |
| 10 | + summary: "Open an issue", | |
| 11 | + options: { | |
| 12 | + title: { type: "string", short: "t", desc: "Title (required)", arg: "TEXT" }, | |
| 13 | + ...bodyOpts, | |
| 14 | + label: { type: "string", short: "l", multiple: true, desc: "Add a label", arg: "NAME" }, | |
| 15 | + assignee: { type: "string", short: "a", multiple: true, desc: "Assign a login", arg: "LOGIN" }, | |
| 16 | + }, | |
| 17 | + repo: true, | |
| 18 | + json: true, | |
| 19 | + maxArgs: 0, | |
| 20 | + async run(ctx, o) { | |
| 21 | + const title = str(o, "title"); | |
| 22 | + if (!title) throw new CliError("--title is required", 2); | |
| 23 | + const repo = await ctx.repo(); | |
| 24 | + const labels = list(o, "label"); | |
| 25 | + const assignees = list(o, "assignee"); | |
| 26 | + const i = await (await ctx.authedApi()).post<IssueJson>(`${repoPath(repo)}/issues`, { title, body: await bodyFrom(ctx, o), labels: labels.length ? labels : undefined, assignees: assignees.length ? assignees : undefined }); | |
| 27 | + if (ctx.json) return json(ctx.io, i); | |
| 28 | + ctx.out(`${ctx.c.green("✓")} Opened issue #${i.number} ${i.title}`); | |
| 29 | + ctx.out(i.html_url); | |
| 30 | + }, | |
| 31 | +}; | |
| 32 | + | |
| 33 | +const listCmd: Command = { | |
| 34 | + name: "issue list", | |
| 35 | + summary: "List issues", | |
| 36 | + options: { | |
| 37 | + state: { type: "string", short: "s", desc: "open, closed or all (default: open)", arg: "STATE" }, | |
| 38 | + label: { type: "string", short: "l", desc: "Only issues with this label", arg: "NAME" }, | |
| 39 | + author: { type: "string", short: "A", desc: "Only issues opened by this login", arg: "LOGIN" }, | |
| 40 | + search: { type: "string", short: "S", desc: "Search text", arg: "QUERY" }, | |
| 41 | + limit: { type: "string", short: "L", desc: "Maximum number to show (default 30)", arg: "N" }, | |
| 42 | + }, | |
| 43 | + repo: true, | |
| 44 | + json: true, | |
| 45 | + maxArgs: 0, | |
| 46 | + async run(ctx, o) { | |
| 47 | + const repo = await ctx.repo(); | |
| 48 | + const state = str(o, "state") ?? "open"; | |
| 49 | + if (!["open", "closed", "all"].includes(state)) throw new CliError("--state must be open, closed or all", 2); | |
| 50 | + const limit = str(o, "limit") ? positiveInt(str(o, "limit"), "--limit") : 30; | |
| 51 | + const items = await (await ctx.api()).get<IssueJson[]>(`${repoPath(repo)}/issues`, { state, labels: str(o, "label"), creator: str(o, "author"), q: str(o, "search"), per_page: Math.min(100, limit + 20) }); | |
| 52 | + // The issues endpoint can include pull requests (as on GitHub); show only issues. | |
| 53 | + const issues = items.filter((i) => !i.pull_request).slice(0, limit); | |
| 54 | + if (ctx.json) return json(ctx.io, issues); | |
| 55 | + if (!issues.length) return ctx.err(`No ${state === "all" ? "" : `${state} `}issues in ${repo.owner}/${repo.name}.`); | |
| 56 | + const c = ctx.c; | |
| 57 | + ctx.io.stdout.write(table(issues.map((i) => [c.bold(`#${i.number}`), truncate(i.title, 60), issueState(c, i), c.cyan(i.labels.map((l) => l.name).join(", ")), c.dim(i.user.login), c.dim(ago(i.updated_at))]))); | |
| 58 | + }, | |
| 59 | +}; | |
| 60 | + | |
| 61 | +const view: Command = { | |
| 62 | + name: "issue view", | |
| 63 | + summary: "Show an issue and its comments", | |
| 64 | + args: "<number>", | |
| 65 | + repo: true, | |
| 66 | + json: true, | |
| 67 | + minArgs: 1, | |
| 68 | + maxArgs: 1, | |
| 69 | + async run(ctx, _o, [num]) { | |
| 70 | + const n = positiveInt(num, "issue number"); | |
| 71 | + const repo = await ctx.repo(); | |
| 72 | + const api = await ctx.api(); | |
| 73 | + const [i, comments] = await Promise.all([api.get<IssueJson>(`${repoPath(repo)}/issues/${n}`), api.get<CommentJson[]>(`${repoPath(repo)}/issues/${n}/comments`)]); | |
| 74 | + if (ctx.json) return json(ctx.io, { ...i, comment_list: comments }); | |
| 75 | + const c = ctx.c; | |
| 76 | + ctx.out(`${c.bold(i.title)} ${c.dim(`#${i.number}`)}`); | |
| 77 | + ctx.out(`${issueState(c, i)} · opened by ${i.user.login} ${ago(i.created_at)}${i.labels.length ? ` · ${c.cyan(i.labels.map((l) => l.name).join(", "))}` : ""}${i.assignees.length ? ` · assigned to ${i.assignees.map((a) => a.login).join(", ")}` : ""}`); | |
| 78 | + if (i.body?.trim()) ctx.out(`\n${i.body.trim()}`); | |
| 79 | + for (const cm of comments) ctx.out(`\n${c.bold(cm.user.login)} ${c.dim(ago(cm.created_at))}\n${cm.body.trim()}`); | |
| 80 | + ctx.out(`\n${c.dim(i.html_url)}`); | |
| 81 | + }, | |
| 82 | +}; | |
| 83 | + | |
| 84 | +const comment: Command = { | |
| 85 | + name: "issue comment", | |
| 86 | + summary: "Comment on an issue", | |
| 87 | + args: "<number>", | |
| 88 | + options: bodyOpts, | |
| 89 | + repo: true, | |
| 90 | + json: true, | |
| 91 | + minArgs: 1, | |
| 92 | + maxArgs: 1, | |
| 93 | + async run(ctx, o, [num]) { | |
| 94 | + const n = positiveInt(num, "issue number"); | |
| 95 | + const body = await bodyFrom(ctx, o, true); | |
| 96 | + if (!body?.trim()) throw new CliError("--body is required", 2); | |
| 97 | + const repo = await ctx.repo(); | |
| 98 | + const r = await (await ctx.authedApi()).post<CommentJson>(`${repoPath(repo)}/issues/${n}/comments`, { body }); | |
| 99 | + if (ctx.json) return json(ctx.io, r); | |
| 100 | + ctx.out(`${ctx.c.green("✓")} Commented on #${n}`); | |
| 101 | + }, | |
| 102 | +}; | |
| 103 | + | |
| 104 | +function setState(state: "open" | "closed"): Command { | |
| 105 | + return { | |
| 106 | + name: state === "closed" ? "issue close" : "issue reopen", | |
| 107 | + summary: state === "closed" ? "Close an issue" : "Reopen an issue", | |
| 108 | + args: "<number>", | |
| 109 | + options: { | |
| 110 | + ...(state === "closed" ? { reason: { type: "string" as const, short: "r", desc: "completed (default) or not_planned", arg: "REASON" } } : {}), | |
| 111 | + comment: { type: "string", short: "c", desc: "Leave a comment too", arg: "TEXT" }, | |
| 112 | + }, | |
| 113 | + repo: true, | |
| 114 | + json: true, | |
| 115 | + minArgs: 1, | |
| 116 | + maxArgs: 1, | |
| 117 | + async run(ctx, o, [num]) { | |
| 118 | + const n = positiveInt(num, "issue number"); | |
| 119 | + const reason = str(o, "reason")?.replace(/[\s-]+/g, "_"); | |
| 120 | + if (reason && !["completed", "not_planned"].includes(reason)) throw new CliError("--reason must be completed or not_planned", 2); | |
| 121 | + const repo = await ctx.repo(); | |
| 122 | + const api = await ctx.authedApi(); | |
| 123 | + if (str(o, "comment")) await api.post(`${repoPath(repo)}/issues/${n}/comments`, { body: str(o, "comment") }); | |
| 124 | + const i = await api.patch<IssueJson>(`${repoPath(repo)}/issues/${n}`, { state, state_reason: state === "closed" ? (reason ?? "completed") : undefined }); | |
| 125 | + if (ctx.json) return json(ctx.io, i); | |
| 126 | + ctx.out(`${ctx.c.green("✓")} ${state === "closed" ? "Closed" : "Reopened"} #${n} ${i.title}`); | |
| 127 | + }, | |
| 128 | + }; | |
| 129 | +} | |
| 130 | + | |
| 131 | +export const issueGroup: Group = { name: "issue", summary: "Work with issues", commands: [create, listCmd, view, comment, setState("closed"), setState("open")] }; |
addedapps/cli/src/commands/pr.ts+310−0View file
| @@ -1,0 +1,310 @@ | ||
| 1 | +import { readFile } from "node:fs/promises"; | |
| 2 | +import path from "node:path"; | |
| 3 | +import { exactlyOne, list, positiveInt, repoPath, str, type Command, type Ctx, type Group, type Values } from "../cli.js"; | |
| 4 | +import { currentBranch } from "../git.js"; | |
| 5 | +import { CliError, ExitCode, readAll } from "../io.js"; | |
| 6 | +import { ago, json, statusText, symbol, table, truncate } from "../output.js"; | |
| 7 | + | |
| 8 | +export interface IssueJson { | |
| 9 | + number: number; | |
| 10 | + title: string; | |
| 11 | + body: string; | |
| 12 | + state: "open" | "closed"; | |
| 13 | + state_reason: string | null; | |
| 14 | + user: { login: string; type: string }; | |
| 15 | + labels: Array<{ name: string }>; | |
| 16 | + assignees: Array<{ login: string }>; | |
| 17 | + comments: number; | |
| 18 | + created_at: string; | |
| 19 | + updated_at: string; | |
| 20 | + closed_at: string | null; | |
| 21 | + html_url: string; | |
| 22 | + pull_request?: { url: string }; | |
| 23 | + head?: { ref: string; sha: string }; | |
| 24 | + base?: { ref: string }; | |
| 25 | + draft?: boolean; | |
| 26 | + merged?: boolean; | |
| 27 | + merged_at?: string | null; | |
| 28 | + merged_by?: { login: string } | null; | |
| 29 | + intent?: string | null; | |
| 30 | +} | |
| 31 | + | |
| 32 | +interface PullDetail extends IssueJson { | |
| 33 | + mergeable: boolean; | |
| 34 | + mergeable_state: string; | |
| 35 | + merge_blockers: string[]; | |
| 36 | + approvals: number; | |
| 37 | + required_approvals: number; | |
| 38 | + checks: Array<{ name: string; state: string }>; | |
| 39 | +} | |
| 40 | + | |
| 41 | +export interface CommentJson { | |
| 42 | + id: string; | |
| 43 | + body: string; | |
| 44 | + user: { login: string }; | |
| 45 | + created_at: string; | |
| 46 | +} | |
| 47 | + | |
| 48 | +/** -b/--body, else --body-file (or "-" for stdin), else piped stdin when `stdinFallback`. */ | |
| 49 | +export async function bodyFrom(ctx: Ctx, o: Values, stdinFallback = false): Promise<string | undefined> { | |
| 50 | + const b = str(o, "body"); | |
| 51 | + if (b !== undefined) return b; | |
| 52 | + const f = str(o, "body-file"); | |
| 53 | + if (f !== undefined) { | |
| 54 | + if (f === "-") return readAll(ctx.io.stdin); | |
| 55 | + return readFile(path.resolve(ctx.io.cwd, f), "utf8"); | |
| 56 | + } | |
| 57 | + if (stdinFallback && !ctx.io.stdin.isTTY) return readAll(ctx.io.stdin); | |
| 58 | + return undefined; | |
| 59 | +} | |
| 60 | + | |
| 61 | +export const bodyOpts = { | |
| 62 | + body: { type: "string" as const, short: "b", desc: "Body text (Markdown)", arg: "TEXT" }, | |
| 63 | + "body-file": { type: "string" as const, desc: "Read the body from a file ('-' for stdin)", arg: "FILE" }, | |
| 64 | +}; | |
| 65 | + | |
| 66 | +export function prState(c: Ctx["c"], p: IssueJson): string { | |
| 67 | + if (p.merged) return c.magenta("merged"); | |
| 68 | + if (p.state === "closed") return c.red("closed"); | |
| 69 | + if (p.draft) return c.dim("draft"); | |
| 70 | + return c.green("open"); | |
| 71 | +} | |
| 72 | + | |
| 73 | +const create: Command = { | |
| 74 | + name: "pr create", | |
| 75 | + summary: "Open a pull request", | |
| 76 | + options: { | |
| 77 | + title: { type: "string", short: "t", desc: "Title (required)", arg: "TEXT" }, | |
| 78 | + ...bodyOpts, | |
| 79 | + head: { type: "string", short: "H", desc: "Branch with your changes (default: current branch)", arg: "BRANCH" }, | |
| 80 | + base: { type: "string", short: "B", desc: "Branch to merge into (default: the repository's default branch)", arg: "BRANCH" }, | |
| 81 | + draft: { type: "boolean", short: "d", desc: "Open as a draft" }, | |
| 82 | + intent: { type: "string", desc: "What this change is for, for reviewers (agents should always set it)", arg: "TEXT" }, | |
| 83 | + reviewer: { type: "string", short: "r", multiple: true, desc: "Request a review from this login", arg: "LOGIN" }, | |
| 84 | + }, | |
| 85 | + repo: true, | |
| 86 | + json: true, | |
| 87 | + maxArgs: 0, | |
| 88 | + async run(ctx, o) { | |
| 89 | + const title = str(o, "title"); | |
| 90 | + if (!title) throw new CliError("--title is required", 2); | |
| 91 | + const repo = await ctx.repo(); | |
| 92 | + const head = str(o, "head") ?? (await currentBranch(ctx.io)); | |
| 93 | + const reviewers = list(o, "reviewer"); | |
| 94 | + const payload = { | |
| 95 | + title, | |
| 96 | + body: await bodyFrom(ctx, o), | |
| 97 | + head, | |
| 98 | + base: str(o, "base"), | |
| 99 | + draft: o.draft === true ? true : undefined, | |
| 100 | + intent: str(o, "intent"), | |
| 101 | + reviewers: reviewers.length ? reviewers : undefined, | |
| 102 | + }; | |
| 103 | + const pr = await (await ctx.authedApi()).post<IssueJson>(`${repoPath(repo)}/pulls`, payload); | |
| 104 | + if (ctx.json) return json(ctx.io, pr); | |
| 105 | + ctx.out(`${ctx.c.green("✓")} Opened #${pr.number} ${pr.title} (${pr.head?.ref} → ${pr.base?.ref})`); | |
| 106 | + ctx.out(pr.html_url); | |
| 107 | + }, | |
| 108 | +}; | |
| 109 | + | |
| 110 | +const listCmd: Command = { | |
| 111 | + name: "pr list", | |
| 112 | + summary: "List pull requests", | |
| 113 | + options: { | |
| 114 | + state: { type: "string", short: "s", desc: "open, closed, merged or all (default: open)", arg: "STATE" }, | |
| 115 | + limit: { type: "string", short: "L", desc: "Maximum number to show (default 30)", arg: "N" }, | |
| 116 | + author: { type: "string", short: "A", desc: "Only pull requests opened by this login", arg: "LOGIN" }, | |
| 117 | + }, | |
| 118 | + repo: true, | |
| 119 | + json: true, | |
| 120 | + maxArgs: 0, | |
| 121 | + async run(ctx, o) { | |
| 122 | + const repo = await ctx.repo(); | |
| 123 | + const state = str(o, "state") ?? "open"; | |
| 124 | + if (!["open", "closed", "merged", "all"].includes(state)) throw new CliError("--state must be open, closed, merged or all", 2); | |
| 125 | + const limit = str(o, "limit") ? positiveInt(str(o, "limit"), "--limit") : 30; | |
| 126 | + const api = await ctx.api(); | |
| 127 | + let prs = await api.get<IssueJson[]>(`${repoPath(repo)}/pulls`, { state: state === "merged" ? "closed" : state, per_page: Math.min(100, state === "merged" ? 100 : limit), creator: str(o, "author") }); | |
| 128 | + if (state === "merged") prs = prs.filter((p) => p.merged); | |
| 129 | + prs = prs.slice(0, limit); | |
| 130 | + if (ctx.json) return json(ctx.io, prs); | |
| 131 | + if (!prs.length) return ctx.err(`No ${state === "all" ? "" : `${state} `}pull requests in ${repo.owner}/${repo.name}.`); | |
| 132 | + const c = ctx.c; | |
| 133 | + ctx.io.stdout.write(table(prs.map((p) => [c.bold(`#${p.number}`), truncate(p.title, 60), c.cyan(`${p.head?.ref ?? ""}`), prState(c, p), c.dim(p.user.login), c.dim(ago(p.updated_at))]))); | |
| 134 | + }, | |
| 135 | +}; | |
| 136 | + | |
| 137 | +async function getPull(ctx: Ctx, n: number) { | |
| 138 | + const repo = await ctx.repo(); | |
| 139 | + const api = await ctx.api(); | |
| 140 | + return { repo, api, pr: await api.get<PullDetail>(`${repoPath(repo)}/pulls/${n}`) }; | |
| 141 | +} | |
| 142 | + | |
| 143 | +const view: Command = { | |
| 144 | + name: "pr view", | |
| 145 | + summary: "Show a pull request", | |
| 146 | + args: "<number>", | |
| 147 | + options: { comments: { type: "boolean", short: "c", desc: "Include the conversation" } }, | |
| 148 | + repo: true, | |
| 149 | + json: true, | |
| 150 | + minArgs: 1, | |
| 151 | + maxArgs: 1, | |
| 152 | + async run(ctx, o, [num]) { | |
| 153 | + const n = positiveInt(num, "pull request number"); | |
| 154 | + const { pr, api, repo } = await getPull(ctx, n); | |
| 155 | + const comments = o.comments ? await api.get<CommentJson[]>(`${repoPath(repo)}/issues/${n}/comments`) : undefined; | |
| 156 | + if (ctx.json) return json(ctx.io, comments ? { ...pr, comment_list: comments } : pr); | |
| 157 | + const c = ctx.c; | |
| 158 | + ctx.out(`${c.bold(pr.title)} ${c.dim(`#${pr.number}`)}`); | |
| 159 | + ctx.out(`${prState(c, pr)} · ${pr.user.login} wants to merge ${c.cyan(pr.head?.ref ?? "?")} into ${c.cyan(pr.base?.ref ?? "?")} · ${ago(pr.created_at)}`); | |
| 160 | + if (pr.intent) ctx.out(`${c.dim("intent:")} ${pr.intent}`); | |
| 161 | + if (pr.state === "open") { | |
| 162 | + ctx.out(`${c.dim("mergeable:")} ${statusText(c, pr.mergeable_state)} · approvals ${pr.approvals}/${pr.required_approvals}`); | |
| 163 | + for (const b of pr.merge_blockers ?? []) ctx.out(` ${c.red("•")} ${b}`); | |
| 164 | + } | |
| 165 | + if (pr.checks?.length) ctx.out(`${c.dim("checks:")} ${pr.checks.map((k) => `${checkSymbol(ctx, k.state)} ${k.name}`).join(" ")}`); | |
| 166 | + if (pr.body?.trim()) ctx.out(`\n${pr.body.trim()}\n`); | |
| 167 | + for (const cm of comments ?? []) ctx.out(`\n${c.bold(cm.user.login)} ${c.dim(ago(cm.created_at))}\n${cm.body.trim()}`); | |
| 168 | + ctx.out(c.dim(pr.html_url)); | |
| 169 | + }, | |
| 170 | +}; | |
| 171 | + | |
| 172 | +function checkSymbol(ctx: Ctx, state: string): string { | |
| 173 | + const done = ["success", "failure", "neutral", "cancelled", "skipped", "timed_out", "action_required", "stale"]; | |
| 174 | + return done.includes(state) ? symbol(ctx.c, "completed", state) : symbol(ctx.c, state); | |
| 175 | +} | |
| 176 | + | |
| 177 | +const diff: Command = { | |
| 178 | + name: "pr diff", | |
| 179 | + summary: "Show a pull request's changes as a unified diff", | |
| 180 | + args: "<number>", | |
| 181 | + options: { "name-only": { type: "boolean", desc: "Only list changed file names" } }, | |
| 182 | + repo: true, | |
| 183 | + minArgs: 1, | |
| 184 | + maxArgs: 1, | |
| 185 | + async run(ctx, o, [num]) { | |
| 186 | + const repo = await ctx.repo(); | |
| 187 | + const files = await (await ctx.api()).get<Array<{ filename: string; status: string; patch?: string; binary?: boolean }>>(`${repoPath(repo)}/pulls/${positiveInt(num, "pull request number")}/files`); | |
| 188 | + if (o["name-only"]) return ctx.io.stdout.write(files.map((f) => `${f.filename}\n`).join("")); | |
| 189 | + const c = ctx.c; | |
| 190 | + for (const f of files) { | |
| 191 | + const patch = f.patch ?? `diff --git a/${f.filename} b/${f.filename}\n`; | |
| 192 | + if (!c.enabled) { | |
| 193 | + ctx.io.stdout.write(patch.endsWith("\n") ? patch : `${patch}\n`); | |
| 194 | + continue; | |
| 195 | + } | |
| 196 | + for (const line of patch.replace(/\n$/, "").split("\n")) { | |
| 197 | + if (line.startsWith("diff --git") || line.startsWith("--- ") || line.startsWith("+++ ")) ctx.out(c.bold(line)); | |
| 198 | + else if (line.startsWith("@@")) ctx.out(c.cyan(line)); | |
| 199 | + else if (line.startsWith("+")) ctx.out(c.green(line)); | |
| 200 | + else if (line.startsWith("-")) ctx.out(c.red(line)); | |
| 201 | + else ctx.out(line); | |
| 202 | + } | |
| 203 | + } | |
| 204 | + }, | |
| 205 | +}; | |
| 206 | + | |
| 207 | +const checks: Command = { | |
| 208 | + name: "pr checks", | |
| 209 | + summary: "Show check status for a pull request", | |
| 210 | + description: "Exit status: 0 when all checks passed, 1 when any failed, 8 when some are still pending.", | |
| 211 | + args: "<number>", | |
| 212 | + repo: true, | |
| 213 | + json: true, | |
| 214 | + minArgs: 1, | |
| 215 | + maxArgs: 1, | |
| 216 | + async run(ctx, _o, [num]) { | |
| 217 | + const { pr } = await getPull(ctx, positiveInt(num, "pull request number")); | |
| 218 | + const list = pr.checks ?? []; | |
| 219 | + if (ctx.json) json(ctx.io, list); | |
| 220 | + else if (!list.length) ctx.err("No checks reported for this pull request."); | |
| 221 | + else ctx.io.stdout.write(table(list.map((k) => [checkSymbol(ctx, k.state), k.name, statusText(ctx.c, ["success", "failure", "neutral", "cancelled", "skipped", "timed_out", "action_required"].includes(k.state) ? "completed" : k.state, k.state)]))); | |
| 222 | + const failed = list.some((k) => ["failure", "cancelled", "timed_out", "action_required"].includes(k.state)); | |
| 223 | + const pending = list.some((k) => ["queued", "in_progress", "waiting", "pending", "requested"].includes(k.state)); | |
| 224 | + throw new ExitCode(failed ? 1 : pending ? 8 : 0); | |
| 225 | + }, | |
| 226 | +}; | |
| 227 | + | |
| 228 | +const review: Command = { | |
| 229 | + name: "pr review", | |
| 230 | + summary: "Approve, request changes or comment on a pull request", | |
| 231 | + args: "<number>", | |
| 232 | + options: { | |
| 233 | + approve: { type: "boolean", short: "a", desc: "Approve" }, | |
| 234 | + "request-changes": { type: "boolean", short: "r", desc: "Request changes" }, | |
| 235 | + comment: { type: "boolean", short: "c", desc: "Comment without approving" }, | |
| 236 | + ...bodyOpts, | |
| 237 | + }, | |
| 238 | + repo: true, | |
| 239 | + json: true, | |
| 240 | + minArgs: 1, | |
| 241 | + maxArgs: 1, | |
| 242 | + async run(ctx, o, [num]) { | |
| 243 | + const n = positiveInt(num, "pull request number"); | |
| 244 | + const which = exactlyOne(o, ["approve", "request-changes", "comment"]); | |
| 245 | + const event = which === "approve" ? "APPROVE" : which === "request-changes" ? "REQUEST_CHANGES" : "COMMENT"; | |
| 246 | + const body = await bodyFrom(ctx, o); | |
| 247 | + if (event !== "APPROVE" && !body) throw new CliError(`--body is required with --${which}`, 2); | |
| 248 | + const repo = await ctx.repo(); | |
| 249 | + const r = await (await ctx.authedApi()).post<{ id: string; state: string }>(`${repoPath(repo)}/pulls/${n}/reviews`, { event, body }); | |
| 250 | + if (ctx.json) return json(ctx.io, r); | |
| 251 | + const verb = event === "APPROVE" ? "Approved" : event === "REQUEST_CHANGES" ? "Requested changes on" : "Reviewed"; | |
| 252 | + ctx.out(`${ctx.c.green("✓")} ${verb} #${n}`); | |
| 253 | + }, | |
| 254 | +}; | |
| 255 | + | |
| 256 | +const merge: Command = { | |
| 257 | + name: "pr merge", | |
| 258 | + summary: "Merge a pull request", | |
| 259 | + args: "<number>", | |
| 260 | + options: { | |
| 261 | + merge: { type: "boolean", short: "m", desc: "Create a merge commit (default)" }, | |
| 262 | + squash: { type: "boolean", short: "s", desc: "Squash into one commit" }, | |
| 263 | + rebase: { type: "boolean", short: "r", desc: "Rebase the commits onto the base branch" }, | |
| 264 | + "delete-branch": { type: "boolean", short: "d", desc: "Delete the head branch after merging" }, | |
| 265 | + subject: { type: "string", short: "t", desc: "Commit title", arg: "TEXT" }, | |
| 266 | + ...bodyOpts, | |
| 267 | + "match-head-commit": { type: "string", desc: "Only merge if the head is still at this SHA", arg: "SHA" }, | |
| 268 | + }, | |
| 269 | + repo: true, | |
| 270 | + json: true, | |
| 271 | + minArgs: 1, | |
| 272 | + maxArgs: 1, | |
| 273 | + async run(ctx, o, [num]) { | |
| 274 | + const n = positiveInt(num, "pull request number"); | |
| 275 | + const method = exactlyOne(o, ["merge", "squash", "rebase"], "merge"); | |
| 276 | + const repo = await ctx.repo(); | |
| 277 | + const payload = { | |
| 278 | + merge_method: method, | |
| 279 | + delete_branch: o["delete-branch"] === true ? true : undefined, | |
| 280 | + commit_title: str(o, "subject"), | |
| 281 | + commit_message: await bodyFrom(ctx, o), | |
| 282 | + sha: str(o, "match-head-commit"), | |
| 283 | + }; | |
| 284 | + const r = await (await ctx.authedApi()).put<{ merged: boolean; sha: string; method: string }>(`${repoPath(repo)}/pulls/${n}/merge`, payload); | |
| 285 | + if (ctx.json) return json(ctx.io, r); | |
| 286 | + ctx.out(`${ctx.c.green("✓")} Merged #${n} (${r.method ?? method}) ${ctx.c.dim(String(r.sha ?? "").slice(0, 12))}${payload.delete_branch ? " and deleted the branch" : ""}`); | |
| 287 | + }, | |
| 288 | +}; | |
| 289 | + | |
| 290 | +const comment: Command = { | |
| 291 | + name: "pr comment", | |
| 292 | + summary: "Comment on a pull request", | |
| 293 | + args: "<number>", | |
| 294 | + options: bodyOpts, | |
| 295 | + repo: true, | |
| 296 | + json: true, | |
| 297 | + minArgs: 1, | |
| 298 | + maxArgs: 1, | |
| 299 | + async run(ctx, o, [num]) { | |
| 300 | + const n = positiveInt(num, "pull request number"); | |
| 301 | + const body = await bodyFrom(ctx, o, true); | |
| 302 | + if (!body?.trim()) throw new CliError("--body is required", 2); | |
| 303 | + const repo = await ctx.repo(); | |
| 304 | + const r = await (await ctx.authedApi()).post<CommentJson>(`${repoPath(repo)}/issues/${n}/comments`, { body }); | |
| 305 | + if (ctx.json) return json(ctx.io, r); | |
| 306 | + ctx.out(`${ctx.c.green("✓")} Commented on #${n}`); | |
| 307 | + }, | |
| 308 | +}; | |
| 309 | + | |
| 310 | +export const prGroup: Group = { name: "pr", summary: "Work with pull requests", commands: [create, listCmd, view, diff, checks, review, merge, comment] }; |
addedapps/cli/src/commands/repo.ts+114−0View file
| @@ -1,0 +1,114 @@ | ||
| 1 | +import path from "node:path"; | |
| 2 | +import { enc } from "../api.js"; | |
| 3 | +import { exactlyOne, parseRepoArg, repoPath, str, type Command, type Group } from "../cli.js"; | |
| 4 | +import { basicAuthHeader, gitInteractive, withGitConfig } from "../git.js"; | |
| 5 | +import { CliError } from "../io.js"; | |
| 6 | +import { ago, json, table, truncate } from "../output.js"; | |
| 7 | + | |
| 8 | +export interface RepoJson { | |
| 9 | + id: string; | |
| 10 | + name: string; | |
| 11 | + full_name: string; | |
| 12 | + owner: { login: string; type: string }; | |
| 13 | + private: boolean; | |
| 14 | + visibility: "public" | "internal" | "private"; | |
| 15 | + description: string | null; | |
| 16 | + default_branch: string; | |
| 17 | + archived: boolean; | |
| 18 | + html_url: string; | |
| 19 | + clone_url: string; | |
| 20 | + pushed_at: string | null; | |
| 21 | + updated_at: string; | |
| 22 | + permissions?: { role: string }; | |
| 23 | + stats?: Record<string, unknown>; | |
| 24 | +} | |
| 25 | + | |
| 26 | +const create: Command = { | |
| 27 | + name: "repo create", | |
| 28 | + summary: "Create a repository", | |
| 29 | + args: "<name>", | |
| 30 | + options: { | |
| 31 | + org: { type: "string", desc: "Create it in this organization", arg: "ORG" }, | |
| 32 | + private: { type: "boolean", desc: "Only collaborators can see it (default)" }, | |
| 33 | + internal: { type: "boolean", desc: "Every signed-in member of this Diffinery can see it" }, | |
| 34 | + public: { type: "boolean", desc: "Anyone can see it" }, | |
| 35 | + description: { type: "string", short: "d", desc: "Description", arg: "TEXT" }, | |
| 36 | + "default-branch": { type: "string", desc: "Default branch name", arg: "BRANCH" }, | |
| 37 | + }, | |
| 38 | + json: true, | |
| 39 | + minArgs: 1, | |
| 40 | + maxArgs: 1, | |
| 41 | + async run(ctx, o, [name]) { | |
| 42 | + const visibility = exactlyOne(o, ["private", "internal", "public"], "private"); | |
| 43 | + const api = await ctx.authedApi(); | |
| 44 | + const org = str(o, "org"); | |
| 45 | + const r = await api.post<RepoJson>(org ? `/orgs/${enc(org)}/repos` : "/user/repos", { name, description: str(o, "description"), visibility, default_branch: str(o, "default-branch") }); | |
| 46 | + if (ctx.json) return json(ctx.io, r); | |
| 47 | + ctx.out(`${ctx.c.green("✓")} Created ${ctx.c.bold(r.full_name)} (${r.visibility})`); | |
| 48 | + ctx.out(r.html_url); | |
| 49 | + ctx.err(ctx.c.dim(`git remote add origin ${r.clone_url}`)); | |
| 50 | + }, | |
| 51 | +}; | |
| 52 | + | |
| 53 | +const listCmd: Command = { | |
| 54 | + name: "repo list", | |
| 55 | + summary: "List repositories you can see", | |
| 56 | + options: { owner: { type: "string", desc: "Only repositories owned by this user or organization", arg: "OWNER" } }, | |
| 57 | + json: true, | |
| 58 | + maxArgs: 0, | |
| 59 | + async run(ctx, o) { | |
| 60 | + const api = await ctx.api(); | |
| 61 | + const owner = str(o, "owner"); | |
| 62 | + // The server has no /users/:login/repos; /orgs/:owner/repos accepts any account login. | |
| 63 | + const repos = await api.get<RepoJson[]>(owner ? `/orgs/${enc(owner)}/repos` : "/user/repos"); | |
| 64 | + if (ctx.json) return json(ctx.io, repos); | |
| 65 | + if (!repos.length) return ctx.err("No repositories."); | |
| 66 | + ctx.io.stdout.write(table(repos.map((r) => [ctx.c.bold(r.full_name), r.archived ? ctx.c.dim("archived") : r.visibility, truncate(r.description, 50), ctx.c.dim(ago(r.pushed_at ?? r.updated_at))]))); | |
| 67 | + }, | |
| 68 | +}; | |
| 69 | + | |
| 70 | +const view: Command = { | |
| 71 | + name: "repo view", | |
| 72 | + summary: "Show a repository", | |
| 73 | + args: "[OWNER/NAME]", | |
| 74 | + repo: true, | |
| 75 | + json: true, | |
| 76 | + maxArgs: 1, | |
| 77 | + async run(ctx, _o, [arg]) { | |
| 78 | + const ref = arg ? parseRepoArg(arg) : await ctx.repo(); | |
| 79 | + const r = await (await ctx.api()).get<RepoJson>(repoPath(ref)); | |
| 80 | + if (ctx.json) return json(ctx.io, r); | |
| 81 | + const c = ctx.c; | |
| 82 | + ctx.out(`${c.bold(r.full_name)} ${c.dim(r.visibility)}${r.archived ? c.yellow(" archived") : ""}`); | |
| 83 | + if (r.description) ctx.out(r.description); | |
| 84 | + const rows: string[][] = [ | |
| 85 | + ["default branch", r.default_branch], | |
| 86 | + ["web", r.html_url], | |
| 87 | + ["clone", r.clone_url], | |
| 88 | + ]; | |
| 89 | + if (r.permissions?.role) rows.push(["your role", r.permissions.role]); | |
| 90 | + if (r.pushed_at) rows.push(["last push", ago(r.pushed_at)]); | |
| 91 | + for (const [k, v] of Object.entries(r.stats ?? {})) if (typeof v === "number" || typeof v === "string") rows.push([k.replace(/_/g, " "), String(v)]); | |
| 92 | + ctx.io.stdout.write(table(rows.map(([k, v]) => [c.dim(k!), v!]))); | |
| 93 | + }, | |
| 94 | +}; | |
| 95 | + | |
| 96 | +const clone: Command = { | |
| 97 | + name: "repo clone", | |
| 98 | + summary: "Clone a repository", | |
| 99 | + description: "The token is passed to this one git command as an HTTP header and is never written to .git/config.\nRun 'diffinery auth setup-git' so later fetches and pushes authenticate too.", | |
| 100 | + args: "<OWNER/NAME> [directory]", | |
| 101 | + minArgs: 1, | |
| 102 | + maxArgs: 2, | |
| 103 | + async run(ctx, _o, [arg, dir]) { | |
| 104 | + const ref = parseRepoArg(arg!); | |
| 105 | + const cfg = await ctx.config(); | |
| 106 | + const url = `${cfg.url}/${ref.owner}/${ref.name}.git`; | |
| 107 | + const env = cfg.token ? withGitConfig(ctx.io.env, [basicAuthHeader(cfg.url, "x-token", cfg.token), ["protocol.version", "2"]]) : withGitConfig(ctx.io.env, [["protocol.version", "2"]]); | |
| 108 | + const code = await gitInteractive(ctx.io, ["clone", url, ...(dir ? [dir] : [])], { env }); | |
| 109 | + if (code !== 0) throw new CliError(`git clone exited with ${code}`, code); | |
| 110 | + ctx.err(ctx.c.dim(`Cloned into ${dir ?? path.basename(ref.name)}. Tip: 'diffinery auth setup-git' lets plain git push/fetch authenticate.`)); | |
| 111 | + }, | |
| 112 | +}; | |
| 113 | + | |
| 114 | +export const repoGroup: Group = { name: "repo", summary: "Create, list, view and clone repositories", commands: [create, listCmd, view, clone] }; |
addedapps/cli/src/commands/run.ts+213−0View file
| @@ -1,0 +1,213 @@ | ||
| 1 | +import { enc } from "../api.js"; | |
| 2 | +import { positiveInt, repoPath, str, type Command, type Ctx, type Group } from "../cli.js"; | |
| 3 | +import { CliError, ExitCode } from "../io.js"; | |
| 4 | +import { ago, json, statusText, symbol, table, truncate } from "../output.js"; | |
| 5 | + | |
| 6 | +export interface RunJson { | |
| 7 | + id: string; | |
| 8 | + run_number: number; | |
| 9 | + run_attempt: number; | |
| 10 | + name: string; | |
| 11 | + display_title: string; | |
| 12 | + path: string; | |
| 13 | + event: string; | |
| 14 | + head_branch: string; | |
| 15 | + head_sha: string; | |
| 16 | + status: string; | |
| 17 | + conclusion: string | null; | |
| 18 | + actor: { login: string }; | |
| 19 | + created_at: string; | |
| 20 | + updated_at: string; | |
| 21 | + html_url: string; | |
| 22 | + pull_request_number: number | null; | |
| 23 | +} | |
| 24 | + | |
| 25 | +export interface JobJson { | |
| 26 | + id: string; | |
| 27 | + runId: string; | |
| 28 | + key: string; | |
| 29 | + name: string; | |
| 30 | + status: string; | |
| 31 | + conclusion: string | null; | |
| 32 | + startedAt: string | null; | |
| 33 | + completedAt: string | null; | |
| 34 | + steps: Array<{ index: number; name?: string; status?: string; conclusion?: string | null }>; | |
| 35 | + failure: { message?: string } | null; | |
| 36 | +} | |
| 37 | + | |
| 38 | +type RunDetail = RunJson & { jobs: JobJson[] }; | |
| 39 | + | |
| 40 | +function duration(a: string | null, b: string | null): string { | |
| 41 | + if (!a) return ""; | |
| 42 | + const ms = (b ? Date.parse(b) : Date.now()) - Date.parse(a); | |
| 43 | + if (!Number.isFinite(ms) || ms < 0) return ""; | |
| 44 | + const s = Math.round(ms / 1000); | |
| 45 | + return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`; | |
| 46 | +} | |
| 47 | + | |
| 48 | +const listCmd: Command = { | |
| 49 | + name: "run list", | |
| 50 | + summary: "List workflow runs", | |
| 51 | + options: { | |
| 52 | + branch: { type: "string", short: "b", desc: "Only runs for this branch", arg: "BRANCH" }, | |
| 53 | + workflow: { type: "string", short: "w", desc: "Only runs of this workflow (file name or path)", arg: "FILE" }, | |
| 54 | + status: { type: "string", short: "s", desc: "Only runs with this status (queued, in_progress, completed, ...)", arg: "STATUS" }, | |
| 55 | + event: { type: "string", short: "e", desc: "Only runs triggered by this event", arg: "EVENT" }, | |
| 56 | + commit: { type: "string", short: "c", desc: "Only runs for this commit SHA", arg: "SHA" }, | |
| 57 | + limit: { type: "string", short: "L", desc: "Maximum number to show (default 20)", arg: "N" }, | |
| 58 | + }, | |
| 59 | + repo: true, | |
| 60 | + json: true, | |
| 61 | + maxArgs: 0, | |
| 62 | + async run(ctx, o) { | |
| 63 | + const repo = await ctx.repo(); | |
| 64 | + const limit = str(o, "limit") ? positiveInt(str(o, "limit"), "--limit") : 20; | |
| 65 | + const res = await (await ctx.api()).get<{ total_count: number; workflow_runs: RunJson[] }>(`${repoPath(repo)}/actions/runs`, { | |
| 66 | + branch: str(o, "branch"), | |
| 67 | + workflow: str(o, "workflow"), | |
| 68 | + status: str(o, "status"), | |
| 69 | + event: str(o, "event"), | |
| 70 | + head_sha: str(o, "commit"), | |
| 71 | + per_page: Math.min(limit, 100), | |
| 72 | + }); | |
| 73 | + if (ctx.json) return json(ctx.io, res.workflow_runs); | |
| 74 | + if (!res.workflow_runs.length) return ctx.err("No runs found."); | |
| 75 | + const c = ctx.c; | |
| 76 | + ctx.io.stdout.write( | |
| 77 | + table([ | |
| 78 | + ["", "TITLE", "WORKFLOW", "BRANCH", "EVENT", "ID", "AGE"].map((h) => c.dim(h)), | |
| 79 | + ...res.workflow_runs.map((r) => [symbol(c, r.status, r.conclusion), truncate(r.display_title, 50), r.name, c.cyan(r.head_branch), r.event, r.id, c.dim(ago(r.created_at))]), | |
| 80 | + ]), | |
| 81 | + ); | |
| 82 | + }, | |
| 83 | +}; | |
| 84 | + | |
| 85 | +function printRun(ctx: Ctx, r: RunDetail): void { | |
| 86 | + const c = ctx.c; | |
| 87 | + ctx.out(`${symbol(c, r.status, r.conclusion)} ${c.bold(r.display_title)} ${c.dim(`${r.name} #${r.run_number}${r.run_attempt > 1 ? ` (attempt ${r.run_attempt})` : ""}`)}`); | |
| 88 | + ctx.out(`${statusText(c, r.status, r.conclusion)} · ${r.event} on ${c.cyan(r.head_branch)} ${c.dim(r.head_sha.slice(0, 7))} · ${r.actor.login} · ${ago(r.created_at)}`); | |
| 89 | + ctx.out(""); | |
| 90 | + ctx.io.stdout.write(table(r.jobs.map((j) => [symbol(c, j.status, j.conclusion), j.name, statusText(c, j.status, j.conclusion), c.dim(duration(j.startedAt, j.completedAt)), c.dim(`job ${j.id}`)]))); | |
| 91 | + for (const j of r.jobs) if (j.failure?.message) ctx.out(`${c.red("X")} ${j.name}: ${j.failure.message}`); | |
| 92 | + ctx.out(c.dim(r.html_url)); | |
| 93 | +} | |
| 94 | + | |
| 95 | +const view: Command = { | |
| 96 | + name: "run view", | |
| 97 | + summary: "Show a run and its jobs", | |
| 98 | + args: "<run-id>", | |
| 99 | + repo: true, | |
| 100 | + json: true, | |
| 101 | + minArgs: 1, | |
| 102 | + maxArgs: 1, | |
| 103 | + async run(ctx, _o, [id]) { | |
| 104 | + const repo = await ctx.repo(); | |
| 105 | + const r = await (await ctx.api()).get<RunDetail>(`${repoPath(repo)}/actions/runs/${enc(id!)}`); | |
| 106 | + if (ctx.json) return json(ctx.io, r); | |
| 107 | + printRun(ctx, r); | |
| 108 | + }, | |
| 109 | +}; | |
| 110 | + | |
| 111 | +const watch: Command = { | |
| 112 | + name: "run watch", | |
| 113 | + summary: "Follow a run until it completes", | |
| 114 | + description: "Polls every 3 seconds and prints job status changes. Exits non-zero unless the run succeeds.", | |
| 115 | + args: "<run-id>", | |
| 116 | + options: { interval: { type: "string", short: "i", desc: "Seconds between polls (default 3)", arg: "SECONDS" } }, | |
| 117 | + repo: true, | |
| 118 | + minArgs: 1, | |
| 119 | + maxArgs: 1, | |
| 120 | + async run(ctx, o, [id]) { | |
| 121 | + const repo = await ctx.repo(); | |
| 122 | + const api = await ctx.api(); | |
| 123 | + const interval = Math.max(0, Number(str(o, "interval") ?? 3)) * 1000; | |
| 124 | + if (!Number.isFinite(interval)) throw new CliError("--interval must be a number", 2); | |
| 125 | + const c = ctx.c; | |
| 126 | + const seen = new Map<string, string>(); | |
| 127 | + let first = true; | |
| 128 | + let failures = 0; | |
| 129 | + for (;;) { | |
| 130 | + let r: RunDetail; | |
| 131 | + try { | |
| 132 | + r = await api.get<RunDetail>(`${repoPath(repo)}/actions/runs/${enc(id!)}`); | |
| 133 | + failures = 0; | |
| 134 | + } catch (e) { | |
| 135 | + // Ride out transient network/server errors; give up on client errors. | |
| 136 | + const status = (e as { status?: number }).status; | |
| 137 | + if ((status && status < 500 && status !== 429) || ++failures > 5) throw e; | |
| 138 | + await ctx.io.sleep(interval); | |
| 139 | + continue; | |
| 140 | + } | |
| 141 | + if (first) { | |
| 142 | + ctx.out(`${c.bold(r.display_title)} ${c.dim(`${r.name} #${r.run_number} · ${r.html_url}`)}`); | |
| 143 | + first = false; | |
| 144 | + } | |
| 145 | + for (const j of r.jobs) { | |
| 146 | + const key = `${j.status}/${j.conclusion ?? ""}`; | |
| 147 | + if (seen.get(j.id) === key) continue; | |
| 148 | + seen.set(j.id, key); | |
| 149 | + const extra = j.status === "completed" ? ` ${c.dim(duration(j.startedAt, j.completedAt))}` : ""; | |
| 150 | + ctx.out(`${symbol(c, j.status, j.conclusion)} ${j.name}: ${statusText(c, j.status, j.conclusion)}${extra}${j.failure?.message && j.status === "completed" ? ` ${c.dim(`(${j.failure.message})`)}` : ""}`); | |
| 151 | + } | |
| 152 | + if (r.status === "completed") { | |
| 153 | + ctx.out(`${symbol(c, r.status, r.conclusion)} Run ${r.id} completed: ${statusText(c, r.status, r.conclusion)}`); | |
| 154 | + if (r.conclusion !== "success") { | |
| 155 | + const failed = r.jobs.find((j) => j.conclusion === "failure" || j.conclusion === "timed_out"); | |
| 156 | + if (failed) ctx.err(c.dim(`See the log: diffinery run logs ${failed.id} -R ${repo.owner}/${repo.name}`)); | |
| 157 | + } | |
| 158 | + throw new ExitCode(r.conclusion === "success" ? 0 : 1); | |
| 159 | + } | |
| 160 | + await ctx.io.sleep(interval); | |
| 161 | + } | |
| 162 | + }, | |
| 163 | +}; | |
| 164 | + | |
| 165 | +const logs: Command = { | |
| 166 | + name: "run logs", | |
| 167 | + summary: "Print a job's log", | |
| 168 | + args: "<job-id>", | |
| 169 | + repo: true, | |
| 170 | + json: true, | |
| 171 | + minArgs: 1, | |
| 172 | + maxArgs: 1, | |
| 173 | + async run(ctx, _o, [id]) { | |
| 174 | + const repo = await ctx.repo(); | |
| 175 | + const api = await ctx.api(); | |
| 176 | + if (ctx.json) return json(ctx.io, await api.get(`${repoPath(repo)}/actions/jobs/${enc(id!)}/logs`, { format: "json" })); | |
| 177 | + const res = await api.raw<string>("GET", `${repoPath(repo)}/actions/jobs/${enc(id!)}/logs`, { text: true }); | |
| 178 | + ctx.io.stdout.write(res.data.endsWith("\n") || !res.data ? res.data : `${res.data}\n`); | |
| 179 | + }, | |
| 180 | +}; | |
| 181 | + | |
| 182 | +const rerun: Command = { | |
| 183 | + name: "run rerun", | |
| 184 | + summary: "Re-run a workflow run", | |
| 185 | + args: "<run-id>", | |
| 186 | + options: { failed: { type: "boolean", desc: "Only re-run failed jobs (where supported)" } }, | |
| 187 | + repo: true, | |
| 188 | + json: true, | |
| 189 | + minArgs: 1, | |
| 190 | + maxArgs: 1, | |
| 191 | + async run(ctx, o, [id]) { | |
| 192 | + const repo = await ctx.repo(); | |
| 193 | + const r = await (await ctx.authedApi()).post<{ run_id: string }>(`${repoPath(repo)}/actions/runs/${enc(id!)}/${o.failed ? "rerun-failed-jobs" : "rerun"}`); | |
| 194 | + if (ctx.json) return json(ctx.io, r); | |
| 195 | + ctx.out(`${ctx.c.green("✓")} Re-running as run ${r.run_id}`); | |
| 196 | + }, | |
| 197 | +}; | |
| 198 | + | |
| 199 | +const cancel: Command = { | |
| 200 | + name: "run cancel", | |
| 201 | + summary: "Cancel a queued or running run", | |
| 202 | + args: "<run-id>", | |
| 203 | + repo: true, | |
| 204 | + minArgs: 1, | |
| 205 | + maxArgs: 1, | |
| 206 | + async run(ctx, _o, [id]) { | |
| 207 | + const repo = await ctx.repo(); | |
| 208 | + await (await ctx.authedApi()).post(`${repoPath(repo)}/actions/runs/${enc(id!)}/cancel`); | |
| 209 | + ctx.out(`${ctx.c.green("✓")} Cancellation requested for run ${id}`); | |
| 210 | + }, | |
| 211 | +}; | |
| 212 | + | |
| 213 | +export const runGroup: Group = { name: "run", summary: "Inspect and control workflow runs", commands: [listCmd, view, watch, logs, rerun, cancel] }; |
addedapps/cli/src/commands/workflow.ts+140−0View file
| @@ -1,0 +1,140 @@ | ||
| 1 | +import { readdir, readFile, stat } from "node:fs/promises"; | |
| 2 | +import path from "node:path"; | |
| 3 | +import { isWarning, parseWorkflow } from "@diffinery/workflow-yaml"; | |
| 4 | +import { enc } from "../api.js"; | |
| 5 | +import { list, repoPath, str, type Command, type Group } from "../cli.js"; | |
| 6 | +import { gitCapture } from "../git.js"; | |
| 7 | +import { CliError } from "../io.js"; | |
| 8 | +import { json, table } from "../output.js"; | |
| 9 | +import type { RepoJson } from "./repo.js"; | |
| 10 | + | |
| 11 | +export const WORKFLOW_DIRS = [".github/workflows", ".diffinery/workflows"]; | |
| 12 | +const isYaml = (f: string) => /\.ya?ml$/i.test(f); | |
| 13 | + | |
| 14 | +/** Parses -f key=value pairs into workflow_dispatch inputs. */ | |
| 15 | +export function parseInputs(pairs: string[]): Record<string, string> { | |
| 16 | + const out: Record<string, string> = {}; | |
| 17 | + for (const p of pairs) { | |
| 18 | + const i = p.indexOf("="); | |
| 19 | + if (i <= 0) throw new CliError(`-f expects key=value, got "${p}"`, 2); | |
| 20 | + out[p.slice(0, i)] = p.slice(i + 1); | |
| 21 | + } | |
| 22 | + return out; | |
| 23 | +} | |
| 24 | + | |
| 25 | +const run: Command = { | |
| 26 | + name: "workflow run", | |
| 27 | + summary: "Start a workflow_dispatch run", | |
| 28 | + args: "<workflow-file>", | |
| 29 | + description: "The workflow is matched by path (.github/workflows/deploy.yml) or file name (deploy.yml).", | |
| 30 | + options: { | |
| 31 | + ref: { type: "string", short: "r", desc: "Branch or tag to run on (default: the default branch)", arg: "REF" }, | |
| 32 | + field: { type: "string", short: "f", multiple: true, desc: "Input value", arg: "KEY=VALUE" }, | |
| 33 | + }, | |
| 34 | + repo: true, | |
| 35 | + json: true, | |
| 36 | + minArgs: 1, | |
| 37 | + maxArgs: 1, | |
| 38 | + async run(ctx, o, [file]) { | |
| 39 | + const inputs = parseInputs(list(o, "field")); | |
| 40 | + const repo = await ctx.repo(); | |
| 41 | + const api = await ctx.authedApi(); | |
| 42 | + const ref = str(o, "ref") ?? (await api.get<RepoJson>(repoPath(repo))).default_branch; | |
| 43 | + const wf = file!.replace(/^\.\//, ""); | |
| 44 | + const r = await api.post<{ run_id: string; html_url: string }>(`${repoPath(repo)}/actions/workflows/${enc(wf)}/dispatches`, { ref, inputs }); | |
| 45 | + if (ctx.json) return json(ctx.io, r); | |
| 46 | + ctx.out(`${ctx.c.green("✓")} Started run ${r.run_id} of ${wf} on ${ref}`); | |
| 47 | + ctx.out(r.html_url); | |
| 48 | + ctx.err(ctx.c.dim(`Follow it: diffinery run watch ${r.run_id} -R ${repo.owner}/${repo.name}`)); | |
| 49 | + }, | |
| 50 | +}; | |
| 51 | + | |
| 52 | +const listCmd: Command = { | |
| 53 | + name: "workflow list", | |
| 54 | + summary: "List the repository's workflows", | |
| 55 | + repo: true, | |
| 56 | + json: true, | |
| 57 | + maxArgs: 0, | |
| 58 | + async run(ctx) { | |
| 59 | + const repo = await ctx.repo(); | |
| 60 | + const res = await (await ctx.api()).get<{ workflows: Array<{ name: string; path: string; state: string; errors: string[]; triggers?: string[] }> }>(`${repoPath(repo)}/actions/workflows`); | |
| 61 | + if (ctx.json) return json(ctx.io, res.workflows); | |
| 62 | + if (!res.workflows.length) return ctx.err("No workflows."); | |
| 63 | + const c = ctx.c; | |
| 64 | + ctx.io.stdout.write(table(res.workflows.map((w) => [w.name, c.dim(w.path), w.state === "active" ? c.green(w.state) : c.red(w.state), c.dim((w.triggers ?? []).join(", "))]))); | |
| 65 | + }, | |
| 66 | +}; | |
| 67 | + | |
| 68 | +async function collectFiles(cwd: string, root: string, args: string[]): Promise<string[]> { | |
| 69 | + const out: string[] = []; | |
| 70 | + const addDir = async (dir: string, explicit: boolean) => { | |
| 71 | + let entries: string[]; | |
| 72 | + try { | |
| 73 | + entries = await readdir(dir); | |
| 74 | + } catch (e) { | |
| 75 | + if (!explicit && (e as NodeJS.ErrnoException).code === "ENOENT") return; | |
| 76 | + throw new CliError(`cannot read ${dir}: ${(e as Error).message}`); | |
| 77 | + } | |
| 78 | + for (const f of entries.sort()) if (isYaml(f)) out.push(path.join(dir, f)); | |
| 79 | + }; | |
| 80 | + if (!args.length) { | |
| 81 | + for (const d of WORKFLOW_DIRS) await addDir(path.join(root, d), false); | |
| 82 | + return out; | |
| 83 | + } | |
| 84 | + for (const a of args) { | |
| 85 | + const p = path.resolve(cwd, a); | |
| 86 | + let s; | |
| 87 | + try { | |
| 88 | + s = await stat(p); | |
| 89 | + } catch { | |
| 90 | + throw new CliError(`no such file: ${a}`); | |
| 91 | + } | |
| 92 | + if (s.isDirectory()) await addDir(p, true); | |
| 93 | + else out.push(p); | |
| 94 | + } | |
| 95 | + return out; | |
| 96 | +} | |
| 97 | + | |
| 98 | +const validate: Command = { | |
| 99 | + name: "workflow validate", | |
| 100 | + summary: "Check workflow files for errors", | |
| 101 | + args: "[files or directories...]", | |
| 102 | + description: `Default: every .yml/.yaml file in .github/workflows and .diffinery/workflows of the | |
| 103 | +current repository. Uses the same parser as the server. Exits 1 when any file has errors.`, | |
| 104 | + json: true, | |
| 105 | + async run(ctx, _o, args) { | |
| 106 | + const top = await gitCapture(ctx.io, ["rev-parse", "--show-toplevel"]); | |
| 107 | + const root = top.code === 0 && top.stdout.trim() ? top.stdout.trim() : ctx.io.cwd; | |
| 108 | + const files = await collectFiles(ctx.io.cwd, root, args); | |
| 109 | + if (!files.length) { | |
| 110 | + if (ctx.json) return json(ctx.io, []); | |
| 111 | + ctx.err(`No workflow files found in ${WORKFLOW_DIRS.join(" or ")}.`); | |
| 112 | + return 0; | |
| 113 | + } | |
| 114 | + const c = ctx.c; | |
| 115 | + const results = []; | |
| 116 | + let errors = 0; | |
| 117 | + let warnings = 0; | |
| 118 | + for (const f of files) { | |
| 119 | + const rel = path.relative(ctx.io.cwd, f) || f; | |
| 120 | + const relRoot = path.relative(root, f).split(path.sep).join("/"); | |
| 121 | + const source = await readFile(f, "utf8"); | |
| 122 | + const res = parseWorkflow(source, relRoot.startsWith("..") ? rel : relRoot); | |
| 123 | + const items = res.errors.map((e) => ({ ...e, severity: isWarning(e) ? "warning" : "error", message: isWarning(e) ? e.message.replace(/^warning:\s*/, "") : e.message })); | |
| 124 | + results.push({ file: rel, valid: !items.some((i) => i.severity === "error"), problems: items }); | |
| 125 | + for (const i of items) { | |
| 126 | + if (i.severity === "error") errors++; | |
| 127 | + else warnings++; | |
| 128 | + if (ctx.json) continue; | |
| 129 | + const loc = `${rel}${i.line ? `:${i.line}${i.col ? `:${i.col}` : ""}` : ""}`; | |
| 130 | + ctx.out(`${c.bold(loc)}: ${i.severity === "error" ? c.red("error") : c.yellow("warning")}: ${i.message}${i.path ? c.dim(` (${i.path})`) : ""}`); | |
| 131 | + } | |
| 132 | + if (!ctx.json && !items.length) ctx.out(`${c.green("✓")} ${rel}`); | |
| 133 | + } | |
| 134 | + if (ctx.json) json(ctx.io, results); | |
| 135 | + else ctx.err(`${files.length} file${files.length === 1 ? "" : "s"} checked: ${errors ? c.red(`${errors} error${errors === 1 ? "" : "s"}`) : "no errors"}, ${warnings} warning${warnings === 1 ? "" : "s"}`); | |
| 136 | + return errors ? 1 : 0; | |
| 137 | + }, | |
| 138 | +}; | |
| 139 | + | |
| 140 | +export const workflowGroup: Group = { name: "workflow", summary: "Run and validate workflows", commands: [run, listCmd, validate] }; |
addedapps/cli/src/config.ts+89−0View file
| @@ -1,0 +1,89 @@ | ||
| 1 | +import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; | |
| 2 | +import { homedir } from "node:os"; | |
| 3 | +import path from "node:path"; | |
| 4 | +import { CliError, type Io } from "./io.js"; | |
| 5 | + | |
| 6 | +export const DEFAULT_URL = "https://diffinery.com"; | |
| 7 | + | |
| 8 | +export interface StoredConfig { | |
| 9 | + url?: string; | |
| 10 | + token?: string; | |
| 11 | +} | |
| 12 | + | |
| 13 | +export interface ResolvedConfig { | |
| 14 | + url: string; | |
| 15 | + token: string | null; | |
| 16 | + /** Where the token came from, for messages. */ | |
| 17 | + tokenSource: "env" | "config" | null; | |
| 18 | + urlSource: "env" | "config" | "default"; | |
| 19 | + file: string; | |
| 20 | +} | |
| 21 | + | |
| 22 | +export function configDir(env: NodeJS.ProcessEnv): string { | |
| 23 | + const xdg = env.XDG_CONFIG_HOME; | |
| 24 | + const base = xdg && path.isAbsolute(xdg) ? xdg : path.join(env.HOME || homedir(), ".config"); | |
| 25 | + return path.join(base, "diffinery"); | |
| 26 | +} | |
| 27 | + | |
| 28 | +export const configPath = (env: NodeJS.ProcessEnv) => path.join(configDir(env), "config.json"); | |
| 29 | + | |
| 30 | +export async function readConfig(env: NodeJS.ProcessEnv): Promise<StoredConfig> { | |
| 31 | + const file = configPath(env); | |
| 32 | + let text: string; | |
| 33 | + try { | |
| 34 | + text = await readFile(file, "utf8"); | |
| 35 | + } catch (e) { | |
| 36 | + if ((e as NodeJS.ErrnoException).code === "ENOENT") return {}; | |
| 37 | + throw e; | |
| 38 | + } | |
| 39 | + try { | |
| 40 | + const v = JSON.parse(text) as unknown; | |
| 41 | + if (!v || typeof v !== "object") return {}; | |
| 42 | + const o = v as Record<string, unknown>; | |
| 43 | + return { url: typeof o.url === "string" ? o.url : undefined, token: typeof o.token === "string" ? o.token : undefined }; | |
| 44 | + } catch { | |
| 45 | + throw new CliError(`${file} is not valid JSON; fix or delete it`); | |
| 46 | + } | |
| 47 | +} | |
| 48 | + | |
| 49 | +export async function writeConfig(env: NodeJS.ProcessEnv, cfg: StoredConfig): Promise<string> { | |
| 50 | + const dir = configDir(env); | |
| 51 | + await mkdir(dir, { recursive: true, mode: 0o700 }); | |
| 52 | + const file = configPath(env); | |
| 53 | + const clean: StoredConfig = {}; | |
| 54 | + if (cfg.url) clean.url = cfg.url; | |
| 55 | + if (cfg.token) clean.token = cfg.token; | |
| 56 | + await writeFile(file, JSON.stringify(clean, null, 2) + "\n", { mode: 0o600 }); | |
| 57 | + await chmod(file, 0o600); // writeFile's mode only applies on create | |
| 58 | + return file; | |
| 59 | +} | |
| 60 | + | |
| 61 | +/** Normalizes a user-typed server URL: adds https://, drops trailing slashes and any path. */ | |
| 62 | +export function normalizeUrl(u: string): string { | |
| 63 | + let s = u.trim(); | |
| 64 | + if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) s = `https://${s}`; | |
| 65 | + let parsed: URL; | |
| 66 | + try { | |
| 67 | + parsed = new URL(s); | |
| 68 | + } catch { | |
| 69 | + throw new CliError(`invalid URL: ${u}`); | |
| 70 | + } | |
| 71 | + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw new CliError(`URL must be http(s): ${u}`); | |
| 72 | + return `${parsed.protocol}//${parsed.host}${parsed.pathname.replace(/\/+$/, "")}`; | |
| 73 | +} | |
| 74 | + | |
| 75 | +/** Env vars override the config file; the URL falls back to the public instance. */ | |
| 76 | +export async function resolveConfig(io: Io): Promise<ResolvedConfig> { | |
| 77 | + const stored = await readConfig(io.env); | |
| 78 | + const envUrl = io.env.DIFFINERY_URL?.trim(); | |
| 79 | + const envToken = io.env.DIFFINERY_TOKEN?.trim(); | |
| 80 | + const url = envUrl ? normalizeUrl(envUrl) : stored.url ? normalizeUrl(stored.url) : DEFAULT_URL; | |
| 81 | + const token = envToken || stored.token || null; | |
| 82 | + return { | |
| 83 | + url, | |
| 84 | + token, | |
| 85 | + tokenSource: envToken ? "env" : stored.token ? "config" : null, | |
| 86 | + urlSource: envUrl ? "env" : stored.url ? "config" : "default", | |
| 87 | + file: configPath(io.env), | |
| 88 | + }; | |
| 89 | +} |
addedapps/cli/src/git.ts+100−0View file
| @@ -1,0 +1,100 @@ | ||
| 1 | +import { spawn } from "node:child_process"; | |
| 2 | +import { CliError, type Io } from "./io.js"; | |
| 3 | + | |
| 4 | +export interface GitResult { | |
| 5 | + code: number; | |
| 6 | + stdout: string; | |
| 7 | + stderr: string; | |
| 8 | +} | |
| 9 | + | |
| 10 | +/** Runs git and captures its output. */ | |
| 11 | +export function gitCapture(io: Io, args: string[], o: { cwd?: string; env?: NodeJS.ProcessEnv; input?: string } = {}): Promise<GitResult> { | |
| 12 | + return new Promise((resolve) => { | |
| 13 | + let child; | |
| 14 | + try { | |
| 15 | + child = spawn("git", args, { cwd: o.cwd ?? io.cwd, env: o.env ?? io.env, stdio: ["pipe", "pipe", "pipe"] }); | |
| 16 | + } catch (e) { | |
| 17 | + return resolve({ code: 127, stdout: "", stderr: String((e as Error).message) }); | |
| 18 | + } | |
| 19 | + let stdout = ""; | |
| 20 | + let stderr = ""; | |
| 21 | + child.stdout.on("data", (d: Buffer) => (stdout += d.toString())); | |
| 22 | + child.stderr.on("data", (d: Buffer) => (stderr += d.toString())); | |
| 23 | + child.on("error", (e) => resolve({ code: 127, stdout, stderr: stderr + e.message })); | |
| 24 | + child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr })); | |
| 25 | + child.stdin.end(o.input ?? ""); | |
| 26 | + }); | |
| 27 | +} | |
| 28 | + | |
| 29 | +/** Runs git with output going to the user's terminal (clone/push progress). */ | |
| 30 | +export function gitInteractive(io: Io, args: string[], o: { cwd?: string; env?: NodeJS.ProcessEnv } = {}): Promise<number> { | |
| 31 | + return new Promise((resolve, reject) => { | |
| 32 | + const child = spawn("git", args, { cwd: o.cwd ?? io.cwd, env: o.env ?? io.env, stdio: ["inherit", "pipe", "pipe"] }); | |
| 33 | + child.stdout.on("data", (d: Buffer) => io.stdout.write(d.toString())); | |
| 34 | + child.stderr.on("data", (d: Buffer) => io.stderr.write(d.toString())); | |
| 35 | + child.on("error", (e) => reject(new CliError(`could not run git: ${e.message}`))); | |
| 36 | + child.on("close", (code) => resolve(code ?? 1)); | |
| 37 | + }); | |
| 38 | +} | |
| 39 | + | |
| 40 | +export async function git(io: Io, args: string[], o: { cwd?: string; env?: NodeJS.ProcessEnv } = {}): Promise<string> { | |
| 41 | + const r = await gitCapture(io, args, o); | |
| 42 | + if (r.code !== 0) throw new CliError(`git ${args[0]} failed: ${r.stderr.trim() || `exit ${r.code}`}`); | |
| 43 | + return r.stdout.trim(); | |
| 44 | +} | |
| 45 | + | |
| 46 | +/** | |
| 47 | + * Environment that adds one-off git config for a single git invocation | |
| 48 | + * (GIT_CONFIG_COUNT/KEY/VALUE, git 2.31+). Unlike `-c`, values do not show up | |
| 49 | + * in the process list, and like `-c` they are never written to .git/config. | |
| 50 | + */ | |
| 51 | +export function withGitConfig(env: NodeJS.ProcessEnv, entries: Array<[string, string]>): NodeJS.ProcessEnv { | |
| 52 | + const out = { ...env }; | |
| 53 | + let n = Number(out.GIT_CONFIG_COUNT ?? 0); | |
| 54 | + if (!Number.isInteger(n) || n < 0) n = 0; | |
| 55 | + for (const [k, v] of entries) { | |
| 56 | + out[`GIT_CONFIG_KEY_${n}`] = k; | |
| 57 | + out[`GIT_CONFIG_VALUE_${n}`] = v; | |
| 58 | + n++; | |
| 59 | + } | |
| 60 | + out.GIT_CONFIG_COUNT = String(n); | |
| 61 | + return out; | |
| 62 | +} | |
| 63 | + | |
| 64 | +/** `http.<url>.extraHeader` entry that authenticates git HTTP requests to `baseUrl` only. */ | |
| 65 | +export function basicAuthHeader(baseUrl: string, user: string, password: string): [string, string] { | |
| 66 | + return [`http.${baseUrl.replace(/\/$/, "")}/.extraHeader`, `Authorization: Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`]; | |
| 67 | +} | |
| 68 | + | |
| 69 | +/** Parses owner/name out of a remote URL that points at `host` (https, ssh or scp-like). */ | |
| 70 | +export function parseRemote(remote: string, host: string): { owner: string; name: string } | null { | |
| 71 | + const r = remote.trim(); | |
| 72 | + let h: string; | |
| 73 | + let p: string; | |
| 74 | + const scp = /^(?:[^@/]+@)?([^:/]+):(?!\/\/)(.+)$/.exec(r); | |
| 75 | + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(r)) { | |
| 76 | + try { | |
| 77 | + const u = new URL(r); | |
| 78 | + h = u.host; | |
| 79 | + p = decodeURIComponent(u.pathname); | |
| 80 | + if (u.protocol === "ssh:" && u.port) h = u.hostname; // ssh port differs from the web port | |
| 81 | + } catch { | |
| 82 | + return null; | |
| 83 | + } | |
| 84 | + } else if (scp) { | |
| 85 | + h = scp[1]!; | |
| 86 | + p = scp[2]!; | |
| 87 | + } else return null; | |
| 88 | + const want = host.toLowerCase(); | |
| 89 | + if (h.toLowerCase() !== want && h.toLowerCase() !== want.replace(/:\d+$/, "")) return null; | |
| 90 | + const parts = p.replace(/^\/+/, "").replace(/\/+$/, "").split("/"); | |
| 91 | + if (parts.length !== 2 || !parts[0] || !parts[1]) return null; | |
| 92 | + const name = parts[1].replace(/\.git$/, ""); | |
| 93 | + return name ? { owner: parts[0], name } : null; | |
| 94 | +} | |
| 95 | + | |
| 96 | +export async function currentBranch(io: Io): Promise<string> { | |
| 97 | + const r = await gitCapture(io, ["symbolic-ref", "--quiet", "--short", "HEAD"]); | |
| 98 | + if (r.code !== 0 || !r.stdout.trim()) throw new CliError("could not determine the current branch (detached HEAD or not a git repository); pass --head"); | |
| 99 | + return r.stdout.trim(); | |
| 100 | +} |
addedapps/cli/src/io.ts+89−0View file
| @@ -1,0 +1,89 @@ | ||
| 1 | +import type { Readable } from "node:stream"; | |
| 2 | + | |
| 3 | +/** Everything a command touches in the outside world, so tests can run the CLI in-process. */ | |
| 4 | +export interface Io { | |
| 5 | + env: NodeJS.ProcessEnv; | |
| 6 | + cwd: string; | |
| 7 | + stdin: Readable & { isTTY?: boolean }; | |
| 8 | + stdout: { write(s: string): unknown; isTTY?: boolean }; | |
| 9 | + stderr: { write(s: string): unknown; isTTY?: boolean }; | |
| 10 | + /** Overridable for tests (polling loops). */ | |
| 11 | + sleep(ms: number, signal?: AbortSignal): Promise<void>; | |
| 12 | +} | |
| 13 | + | |
| 14 | +export function sleep(ms: number, signal?: AbortSignal): Promise<void> { | |
| 15 | + return new Promise((resolve) => { | |
| 16 | + if (signal?.aborted) return resolve(); | |
| 17 | + const t = setTimeout(done, ms); | |
| 18 | + function done() { | |
| 19 | + clearTimeout(t); | |
| 20 | + signal?.removeEventListener("abort", done); | |
| 21 | + resolve(); | |
| 22 | + } | |
| 23 | + signal?.addEventListener("abort", done, { once: true }); | |
| 24 | + }); | |
| 25 | +} | |
| 26 | + | |
| 27 | +export function processIo(): Io { | |
| 28 | + return { env: process.env, cwd: process.cwd(), stdin: process.stdin, stdout: process.stdout, stderr: process.stderr, sleep }; | |
| 29 | +} | |
| 30 | + | |
| 31 | +/** A user-facing failure: printed as `error: message`, exits with `code`. */ | |
| 32 | +export class CliError extends Error { | |
| 33 | + constructor( | |
| 34 | + message: string, | |
| 35 | + readonly code = 1, | |
| 36 | + ) { | |
| 37 | + super(message); | |
| 38 | + } | |
| 39 | +} | |
| 40 | + | |
| 41 | +/** Exit silently with a code (the command already printed what it wanted). */ | |
| 42 | +export class ExitCode extends Error { | |
| 43 | + constructor(readonly code: number) { | |
| 44 | + super(`exit ${code}`); | |
| 45 | + } | |
| 46 | +} | |
| 47 | + | |
| 48 | +export async function readAll(stream: Readable): Promise<string> { | |
| 49 | + const chunks: Buffer[] = []; | |
| 50 | + for await (const c of stream) chunks.push(typeof c === "string" ? Buffer.from(c) : (c as Buffer)); | |
| 51 | + return Buffer.concat(chunks).toString("utf8"); | |
| 52 | +} | |
| 53 | + | |
| 54 | +/** Reads one line from a TTY without echoing it (for tokens and secret values). */ | |
| 55 | +export async function promptHidden(io: Io, question: string): Promise<string> { | |
| 56 | + const stdin = io.stdin as NodeJS.ReadStream; | |
| 57 | + io.stderr.write(question); | |
| 58 | + return new Promise((resolve, reject) => { | |
| 59 | + let value = ""; | |
| 60 | + const raw = typeof stdin.setRawMode === "function"; | |
| 61 | + if (raw) stdin.setRawMode(true); | |
| 62 | + stdin.resume(); | |
| 63 | + stdin.setEncoding("utf8"); | |
| 64 | + const finish = (err?: Error) => { | |
| 65 | + stdin.off("data", onData); | |
| 66 | + if (raw) stdin.setRawMode(false); | |
| 67 | + stdin.pause(); | |
| 68 | + io.stderr.write("\n"); | |
| 69 | + if (err) reject(err); | |
| 70 | + else resolve(value); | |
| 71 | + }; | |
| 72 | + const onData = (chunk: string) => { | |
| 73 | + for (const ch of chunk) { | |
| 74 | + if (ch === "\r" || ch === "\n" || ch === "\u0004") return finish(); | |
| 75 | + if (ch === "\u0003") return finish(new CliError("cancelled", 130)); | |
| 76 | + if (ch === "\u007f" || ch === "\b") value = value.slice(0, -1); | |
| 77 | + else value += ch; | |
| 78 | + } | |
| 79 | + }; | |
| 80 | + stdin.on("data", onData); | |
| 81 | + }); | |
| 82 | +} | |
| 83 | + | |
| 84 | +/** Value from a flag, else stdin when piped, else a hidden prompt. */ | |
| 85 | +export async function valueFromFlagOrStdin(io: Io, flag: string | undefined, prompt: string): Promise<string> { | |
| 86 | + if (flag !== undefined) return flag; | |
| 87 | + if (!io.stdin.isTTY) return (await readAll(io.stdin)).replace(/\r?\n$/, ""); | |
| 88 | + return promptHidden(io, prompt); | |
| 89 | +} |
addedapps/cli/src/output.ts+67−0View file
| @@ -1,0 +1,67 @@ | ||
| 1 | +import type { Io } from "./io.js"; | |
| 2 | + | |
| 3 | +export interface Colors { | |
| 4 | + enabled: boolean; | |
| 5 | + bold(s: string): string; | |
| 6 | + dim(s: string): string; | |
| 7 | + red(s: string): string; | |
| 8 | + green(s: string): string; | |
| 9 | + yellow(s: string): string; | |
| 10 | + cyan(s: string): string; | |
| 11 | + magenta(s: string): string; | |
| 12 | +} | |
| 13 | + | |
| 14 | +export function colors(io: Io): Colors { | |
| 15 | + const on = !!io.stdout.isTTY && io.env.NO_COLOR === undefined && io.env.TERM !== "dumb"; | |
| 16 | + const wrap = (open: number, close: number) => (s: string) => (on ? `\x1b[${open}m${s}\x1b[${close}m` : s); | |
| 17 | + return { enabled: on, bold: wrap(1, 22), dim: wrap(2, 22), red: wrap(31, 39), green: wrap(32, 39), yellow: wrap(33, 39), cyan: wrap(36, 39), magenta: wrap(35, 39) }; | |
| 18 | +} | |
| 19 | + | |
| 20 | +const ANSI = /\x1b\[[0-9;]*m/g; | |
| 21 | +const width = (s: string) => [...s.replace(ANSI, "")].length; | |
| 22 | + | |
| 23 | +/** Left-aligned columns separated by two spaces; the last column is not padded. */ | |
| 24 | +export function table(rows: string[][]): string { | |
| 25 | + if (!rows.length) return ""; | |
| 26 | + const cols = Math.max(...rows.map((r) => r.length)); | |
| 27 | + const widths = Array.from({ length: cols }, (_, i) => Math.max(...rows.map((r) => width(r[i] ?? "")))); | |
| 28 | + return rows.map((r) => r.map((cell, i) => (i === r.length - 1 ? cell : cell + " ".repeat(widths[i]! - width(cell)))).join(" ").trimEnd()).join("\n") + "\n"; | |
| 29 | +} | |
| 30 | + | |
| 31 | +export function json(io: Io, v: unknown): void { | |
| 32 | + io.stdout.write(JSON.stringify(v, null, 2) + "\n"); | |
| 33 | +} | |
| 34 | + | |
| 35 | +export function truncate(s: string | null | undefined, n: number): string { | |
| 36 | + const t = (s ?? "").replace(/\s+/g, " ").trim(); | |
| 37 | + return t.length > n ? `${t.slice(0, n - 1)}…` : t; | |
| 38 | +} | |
| 39 | + | |
| 40 | +export function ago(iso: string | null | undefined, now = Date.now()): string { | |
| 41 | + if (!iso) return ""; | |
| 42 | + const t = Date.parse(iso); | |
| 43 | + if (Number.isNaN(t)) return iso; | |
| 44 | + const s = Math.max(0, Math.round((now - t) / 1000)); | |
| 45 | + if (s < 60) return `${s}s ago`; | |
| 46 | + if (s < 3600) return `${Math.floor(s / 60)}m ago`; | |
| 47 | + if (s < 86400) return `${Math.floor(s / 3600)}h ago`; | |
| 48 | + if (s < 86400 * 30) return `${Math.floor(s / 86400)}d ago`; | |
| 49 | + return iso.slice(0, 10); | |
| 50 | +} | |
| 51 | + | |
| 52 | +/** Colors a status/conclusion word. */ | |
| 53 | +export function statusText(c: Colors, status: string | null | undefined, conclusion?: string | null): string { | |
| 54 | + if (status && status !== "completed") return c.yellow(status.replace(/_/g, " ")); | |
| 55 | + const v = conclusion ?? status ?? "unknown"; | |
| 56 | + if (v === "success" || v === "merged" || v === "open" || v === "clean") return c.green(v); | |
| 57 | + if (v === "failure" || v === "timed_out" || v === "cancelled" || v === "action_required") return c.red(v.replace(/_/g, " ")); | |
| 58 | + if (v === "closed" || v === "skipped" || v === "neutral") return c.dim(v); | |
| 59 | + return c.yellow(v.replace(/_/g, " ")); | |
| 60 | +} | |
| 61 | + | |
| 62 | +export function symbol(c: Colors, status: string | null | undefined, conclusion?: string | null): string { | |
| 63 | + if (status && status !== "completed") return c.yellow("*"); | |
| 64 | + if (conclusion === "success") return c.green("✓"); | |
| 65 | + if (conclusion === "skipped" || conclusion === "neutral") return c.dim("-"); | |
| 66 | + return c.red("X"); | |
| 67 | +} |
addedapps/cli/src/version.ts+1−0View file
| @@ -1,0 +1,1 @@ | ||
| 1 | +export const VERSION = "0.1.0"; |
addedapps/cli/tsconfig.json+1−0View file
| @@ -1,0 +1,1 @@ | ||
| 1 | +{ "extends": "../../tsconfig.base.json", "compilerOptions": { "noEmit": true, "types": ["node"] }, "include": ["src", "test"] } |
modifiedapps/diffinery/package.json+6−4View file
| @@ -4,11 +4,13 @@ | ||
| 4 | 4 | "private": true, |
| 5 | 5 | "type": "module", |
| 6 | 6 | "scripts": { |
| 7 | - "dev": "wrangler dev", | |
| 8 | - "deploy": "wrangler deploy", | |
| 7 | + "dev": "wrangler dev -e dev", | |
| 8 | + "deploy": "wrangler deploy -c wrangler.deploy.jsonc", | |
| 9 | 9 | "typecheck": "tsc --noEmit", |
| 10 | - "test": "vitest run", | |
| 11 | - "cf-typegen": "wrangler types" | |
| 10 | + "test": "vitest run --passWithNoTests", | |
| 11 | + "cf-typegen": "wrangler types", | |
| 12 | + "e2e": "node --test --test-timeout=600000 test/e2e/e2e.test.mjs", | |
| 13 | + "db:migrate:local": "wrangler d1 migrations apply diffinery-dev --local -e dev" | |
| 12 | 14 | }, |
| 13 | 15 | "dependencies": { |
| 14 | 16 | "@cloudflare/containers": "^0.3.7", |
addedapps/diffinery/src/app.ts+196−0View file
| @@ -1,0 +1,196 @@ | ||
| 1 | +import { Hono } from "hono"; | |
| 2 | +import type { DiffineryEvent } from "@diffinery/shared"; | |
| 3 | +import { cronMatches } from "@diffinery/workflow-yaml"; | |
| 4 | +import type { Env } from "./env.js"; | |
| 5 | +import { resolvePrincipal, type AppVars } from "./auth/principal.js"; | |
| 6 | +import { Directory } from "./lib/directory.js"; | |
| 7 | +import { hmacSha256Hex, newId } from "./lib/util.js"; | |
| 8 | +import { errorResponse, loadRepo, repoStub } from "./http/common.js"; | |
| 9 | +import { git } from "./http/git.js"; | |
| 10 | +import { runner } from "./http/runner.js"; | |
| 11 | +import { api, wellKnown } from "./http/api.js"; | |
| 12 | +import { mcp } from "./mcp/server.js"; | |
| 13 | + | |
| 14 | +export const app = new Hono<{ Bindings: Env; Variables: AppVars }>(); | |
| 15 | + | |
| 16 | +app.onError((e) => errorResponse(e)); | |
| 17 | + | |
| 18 | +// Security headers on every response, then identity: tokens, Access JWT, or anonymous. | |
| 19 | +app.use("*", async (c, next) => { | |
| 20 | + const dir = new Directory(c.env); | |
| 21 | + c.set("dir", dir); | |
| 22 | + c.set("principal", await resolvePrincipal(c.req.raw, c.env, dir)); | |
| 23 | + await next(); | |
| 24 | + if (c.res.status === 101) return; | |
| 25 | + const h = c.res.headers; | |
| 26 | + h.set("strict-transport-security", "max-age=63072000; includeSubDomains; preload"); | |
| 27 | + h.set("x-content-type-options", "nosniff"); | |
| 28 | + h.set("referrer-policy", "strict-origin-when-cross-origin"); | |
| 29 | + if (!h.has("x-frame-options")) h.set("x-frame-options", "DENY"); | |
| 30 | + h.set("permissions-policy", "camera=(), microphone=(), geolocation=()"); | |
| 31 | +}); | |
| 32 | + | |
| 33 | +app.get("/healthz", (c) => c.json({ ok: true })); | |
| 34 | + | |
| 35 | +app.route("/", wellKnown); | |
| 36 | +app.route("/", git); | |
| 37 | +app.route("/", runner); | |
| 38 | +app.route("/", api); | |
| 39 | +app.route("/", mcp); | |
| 40 | + | |
| 41 | +// Live updates: authorise in the Worker, then hand the WebSocket to the repository's Durable Object. | |
| 42 | +app.get("/:owner/:repo/live", async (c) => { | |
| 43 | + if (c.req.header("upgrade") !== "websocket") return c.text("expected websocket", 426); | |
| 44 | + const { stub } = await loadRepo(c, c.req.param("owner"), c.req.param("repo")); | |
| 45 | + return stub.fetch(c.req.raw); | |
| 46 | +}); | |
| 47 | + | |
| 48 | +app.get("/:owner/:repo/actions/runs/:runId/artifacts/:name", async (c) => { | |
| 49 | + const { stub } = await loadRepo(c, c.req.param("owner"), c.req.param("repo")); | |
| 50 | + const name = c.req.param("name"); | |
| 51 | + if (!(await stub.artifacts(c.req.param("runId"))).some((a) => a.name === name)) return c.json({ message: "artifact not found" }, 404); | |
| 52 | + const obj = await c.env.BUCKET.get(await stub.artifactKeyForRun(c.req.param("runId"), name)); | |
| 53 | + if (!obj) return c.json({ message: "artifact expired" }, 410); | |
| 54 | + return new Response(obj.body, { | |
| 55 | + headers: { | |
| 56 | + "content-type": "application/gzip", | |
| 57 | + "content-length": String(obj.size), | |
| 58 | + "content-disposition": `attachment; filename="${name.replace(/[^\w.-]+/g, "_")}.tar.gz"`, | |
| 59 | + }, | |
| 60 | + }); | |
| 61 | +}); | |
| 62 | + | |
| 63 | +// ---------------------------------------------------------------- events queue | |
| 64 | + | |
| 65 | +const GITHUB_EVENT: Record<DiffineryEvent["type"], string> = { | |
| 66 | + push: "push", | |
| 67 | + pull_request: "pull_request", | |
| 68 | + issues: "issues", | |
| 69 | + issue_comment: "issue_comment", | |
| 70 | + pull_request_review: "pull_request_review", | |
| 71 | + workflow_run: "workflow_run", | |
| 72 | + workflow_dispatch: "workflow_dispatch", | |
| 73 | + audit: "audit", | |
| 74 | +}; | |
| 75 | + | |
| 76 | +function webhookPayload(env: Env, ev: Exclude<DiffineryEvent, { type: "audit" }>) { | |
| 77 | + const repository = { id: ev.repoId, name: ev.repo, full_name: `${ev.owner}/${ev.repo}`, owner: { login: ev.owner }, html_url: `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}`, clone_url: `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}.git` }; | |
| 78 | + const sender = { id: ev.actor.id, login: ev.actor.login, type: ev.actor.kind === "agent" ? "Bot" : ev.actor.kind === "system" ? "Bot" : "User", on_behalf_of: ev.actor.onBehalfOf }; | |
| 79 | + const { type: _t, repoId: _r, owner: _o, repo: _n, actor: _a, ...rest } = ev; | |
| 80 | + const action = "action" in ev ? (ev.action === "merged" ? "closed" : ev.action) : undefined; | |
| 81 | + const extra: Record<string, unknown> = {}; | |
| 82 | + if (ev.type === "pull_request") { | |
| 83 | + extra.pull_request = { number: ev.number, html_url: `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}/pull/${ev.number}`, head: { ref: ev.headRef, sha: ev.headSha }, base: { ref: ev.baseRef }, merged: ev.action === "merged" }; | |
| 84 | + } | |
| 85 | + if (ev.type === "issues" || ev.type === "issue_comment") extra.issue = { number: ev.number, html_url: `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}/issues/${ev.number}` }; | |
| 86 | + return { ...rest, action, repository, sender, ...extra }; | |
| 87 | +} | |
| 88 | + | |
| 89 | +async function deliverWebhooks(env: Env, ev: Exclude<DiffineryEvent, { type: "audit" }>): Promise<void> { | |
| 90 | + const stub = repoStub(env, ev.repoId); | |
| 91 | + const hooks = (await stub.webhooks()).filter((h) => h.active && (h.events.includes("*") || h.events.includes(GITHUB_EVENT[ev.type]))); | |
| 92 | + if (!hooks.length) return; | |
| 93 | + const body = JSON.stringify(webhookPayload(env, ev)); | |
| 94 | + await Promise.all( | |
| 95 | + hooks.map(async (h) => { | |
| 96 | + const delivery = newId(); | |
| 97 | + let status = 0; | |
| 98 | + try { | |
| 99 | + const res = await fetch(h.url, { | |
| 100 | + method: "POST", | |
| 101 | + headers: { | |
| 102 | + "content-type": "application/json", | |
| 103 | + "user-agent": "Diffinery-Hookshot/1", | |
| 104 | + "x-github-event": GITHUB_EVENT[ev.type], | |
| 105 | + "x-github-delivery": delivery, | |
| 106 | + "x-diffinery-event": ev.type, | |
| 107 | + "x-hub-signature-256": `sha256=${await hmacSha256Hex(h.secret, body)}`, | |
| 108 | + }, | |
| 109 | + body, | |
| 110 | + redirect: "manual", | |
| 111 | + signal: AbortSignal.timeout(10_000), | |
| 112 | + }); | |
| 113 | + status = res.status; | |
| 114 | + } catch { | |
| 115 | + status = 0; | |
| 116 | + } | |
| 117 | + await stub.recordDelivery(h.id, status); | |
| 118 | + }), | |
| 119 | + ); | |
| 120 | +} | |
| 121 | + | |
| 122 | +/** People who should hear about an event: participants, assignees and requested reviewers, minus the actor. */ | |
| 123 | +async function notifyParticipants(env: Env, dir: Directory, ev: DiffineryEvent): Promise<void> { | |
| 124 | + if (ev.type === "workflow_run") { | |
| 125 | + if (ev.action !== "completed" || ev.conclusion !== "failure" || ev.actor.kind === "system") return; | |
| 126 | + await dir.notify([ev.actor.id], { repoId: ev.repoId, kind: "run_failed", title: `Workflow run failed in ${ev.owner}/${ev.repo}`, url: `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}/actions/runs/${ev.runId}` }); | |
| 127 | + return; | |
| 128 | + } | |
| 129 | + if (ev.type !== "pull_request" && ev.type !== "issues" && ev.type !== "issue_comment" && ev.type !== "pull_request_review") return; | |
| 130 | + if (ev.type === "pull_request" && ev.action === "synchronize") return; | |
| 131 | + const { issue, comments, reviews } = await repoStub(env, ev.repoId).issue(ev.number); | |
| 132 | + const ids = new Set<string>([issue.author.id, ...comments.map((c) => c.author.id), ...reviews.map((r) => r.author.id)]); | |
| 133 | + for (const login of [...issue.assignees, ...(issue.pull?.requestedReviewers ?? [])]) { | |
| 134 | + const a = await dir.accountByLogin(login); | |
| 135 | + if (a) ids.add(a.id); | |
| 136 | + } | |
| 137 | + ids.delete(ev.actor.id); | |
| 138 | + if (!ids.size) return; | |
| 139 | + const kind = issue.kind === "pull" ? "pull" : "issues"; | |
| 140 | + const verb = ev.type === "issue_comment" ? "commented on" : ev.type === "pull_request_review" ? `reviewed (${ev.state.toLowerCase()})` : ev.action; | |
| 141 | + await dir.notify([...ids].slice(0, 100), { | |
| 142 | + repoId: ev.repoId, | |
| 143 | + kind: ev.type, | |
| 144 | + title: `${ev.actor.login} ${verb} ${ev.owner}/${ev.repo}#${ev.number}: ${issue.title}`, | |
| 145 | + url: `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}/${kind}/${ev.number}`, | |
| 146 | + }); | |
| 147 | +} | |
| 148 | + | |
| 149 | +async function handleEvent(env: Env, dir: Directory, ev: DiffineryEvent): Promise<void> { | |
| 150 | + if (ev.type === "audit") { | |
| 151 | + await dir.audit({ actorId: ev.actor.id, actorLogin: ev.actor.login, onBehalfOf: ev.actor.onBehalfOf, action: ev.action, target: ev.target, detail: ev.detail, at: ev.at }); | |
| 152 | + return; | |
| 153 | + } | |
| 154 | + if (ev.type === "push") { | |
| 155 | + await dir.audit({ actorId: ev.actor.id, actorLogin: ev.actor.login, onBehalfOf: ev.actor.onBehalfOf, action: ev.forced ? "git.force_push" : ev.deleted ? "git.delete_ref" : "git.push", target: `${ev.owner}/${ev.repo}:${ev.ref}`, detail: { before: ev.before, after: ev.after }, at: ev.at }); | |
| 156 | + } | |
| 157 | + if (ev.type === "pull_request" && ev.action === "merged") { | |
| 158 | + await dir.audit({ actorId: ev.actor.id, actorLogin: ev.actor.login, onBehalfOf: ev.actor.onBehalfOf, action: "pull.merge", target: `${ev.owner}/${ev.repo}#${ev.number}`, at: ev.at }); | |
| 159 | + } | |
| 160 | + await Promise.all([deliverWebhooks(env, ev), notifyParticipants(env, dir, ev)]); | |
| 161 | +} | |
| 162 | + | |
| 163 | +export async function queue(batch: MessageBatch<DiffineryEvent>, env: Env): Promise<void> { | |
| 164 | + const dir = new Directory(env); | |
| 165 | + // Keep an append-only copy of every event in R2 for long-term audit retention. | |
| 166 | + const day = new Date().toISOString().slice(0, 10).replaceAll("-", "/"); | |
| 167 | + await env.BUCKET.put(`events/${day}/${Date.now()}-${newId()}.jsonl`, batch.messages.map((m) => JSON.stringify(m.body)).join("\n")).catch((e) => console.error("event archive failed", e)); | |
| 168 | + for (const msg of batch.messages) { | |
| 169 | + try { | |
| 170 | + await handleEvent(env, dir, msg.body); | |
| 171 | + msg.ack(); | |
| 172 | + } catch (e) { | |
| 173 | + console.error("event handling failed", msg.body.type, e); | |
| 174 | + msg.retry({ delaySeconds: Math.min(300, 10 * 2 ** msg.attempts) }); | |
| 175 | + } | |
| 176 | + } | |
| 177 | +} | |
| 178 | + | |
| 179 | +// ---------------------------------------------------------------- cron: workflow schedules | |
| 180 | + | |
| 181 | +export async function scheduled(controller: ScheduledController, env: Env): Promise<void> { | |
| 182 | + // The trigger runs every 5 minutes (GitHub's minimum schedule interval); fire anything due in the window. | |
| 183 | + const end = new Date(controller.scheduledTime); | |
| 184 | + end.setUTCSeconds(0, 0); | |
| 185 | + const minutes = Array.from({ length: 5 }, (_, i) => new Date(end.getTime() - i * 60_000)); | |
| 186 | + const { results } = await env.DB.prepare("SELECT s.repo_id, s.workflow_path, s.cron FROM schedules s JOIN repos r ON r.id = s.repo_id WHERE r.archived = 0").all<{ repo_id: string; workflow_path: string; cron: string }>(); | |
| 187 | + await Promise.all( | |
| 188 | + results.map(async (s) => { | |
| 189 | + try { | |
| 190 | + if (minutes.some((m) => cronMatches(s.cron, m))) await repoStub(env, s.repo_id).scheduleTick(s.workflow_path, s.cron); | |
| 191 | + } catch (e) { | |
| 192 | + console.error("schedule failed", s.repo_id, s.workflow_path, e); | |
| 193 | + } | |
| 194 | + }), | |
| 195 | + ); | |
| 196 | +} |
modifiedapps/diffinery/src/git/protocol.ts+20−6View file
| @@ -1,4 +1,4 @@ | ||
| 1 | -import { ByteWriter, FLUSH, DELIM, ZERO_OID, fromUtf8, objectsForFetch, parseTag, pkt, pktText, readPkts, sideband, writePack, utf8, type ObjectReader } from "@diffinery/git-core"; | |
| 1 | +import { ByteWriter, FLUSH, DELIM, ZERO_OID, fromUtf8, planFetch, parseTag, pkt, pktText, readPkts, sideband, writePack, utf8, type ObjectReader } from "@diffinery/git-core"; | |
| 2 | 2 | import type { RawObject } from "../repo/objects.js"; |
| 3 | 3 | |
| 4 | 4 | export const AGENT = "diffinery/0.1"; |
| @@ -22,7 +22,7 @@ | ||
| 22 | 22 | w.write(pkt("version 2\n")); |
| 23 | 23 | w.write(pkt(`agent=${AGENT}\n`)); |
| 24 | 24 | w.write(pkt("ls-refs=unborn\n")); |
| 25 | - w.write(pkt("fetch=wait-for-done\n")); | |
| 25 | + w.write(pkt("fetch=shallow wait-for-done\n")); | |
| 26 | 26 | w.write(pkt("server-option\n")); |
| 27 | 27 | w.write(pkt("object-format=sha1\n")); |
| 28 | 28 | w.write(FLUSH); |
| @@ -107,6 +107,9 @@ | ||
| 107 | 107 | const haves: string[] = []; |
| 108 | 108 | let done = false; |
| 109 | 109 | let noProgress = false; |
| 110 | + let depth: number | undefined; | |
| 111 | + let since: number | undefined; | |
| 112 | + const clientShallow = new Set<string>(); | |
| 110 | 113 | for (const a of req.args) { |
| 111 | 114 | if (a.startsWith("want ")) wants.push(a.slice(5)); |
| 112 | 115 | else if (a.startsWith("want-ref ")) { |
| @@ -115,9 +118,13 @@ | ||
| 115 | 118 | } else if (a.startsWith("have ")) haves.push(a.slice(5)); |
| 116 | 119 | else if (a === "done") done = true; |
| 117 | 120 | else if (a === "no-progress") noProgress = true; |
| 118 | - else if (a.startsWith("deepen") || a.startsWith("shallow ") || a.startsWith("filter ")) { | |
| 119 | - throw new Error("shallow and partial clones are not supported yet; clone without --depth/--filter"); | |
| 120 | - } | |
| 121 | + else if (a.startsWith("shallow ")) clientShallow.add(a.slice(8)); | |
| 122 | + else if (a.startsWith("deepen ")) depth = Math.max(1, Number.parseInt(a.slice(7), 10) || 1); | |
| 123 | + else if (a.startsWith("deepen-since ")) since = Number.parseInt(a.slice(13), 10); | |
| 124 | + else if (a === "deepen-relative") { | |
| 125 | + // Approximation: treat relative deepening as absolute depth from the wants. | |
| 126 | + } else if (a.startsWith("deepen-not ")) throw new Error("--shallow-exclude is not supported; use --depth or --shallow-since"); | |
| 127 | + else if (a.startsWith("filter ")) throw new Error("partial clones (--filter) are not supported yet"); | |
| 121 | 128 | } |
| 122 | 129 | for (const w of wants) if (!ctx.store.has(w)) throw new Error(`not our ref ${w}`); |
| 123 | 130 | const common = haves.filter((h) => ctx.store.has(h)); |
| @@ -130,8 +137,15 @@ | ||
| 130 | 137 | out.write(pkt("ready\n")); |
| 131 | 138 | out.write(DELIM); |
| 132 | 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 | + } | |
| 133 | 147 | out.write(pkt("packfile\n")); |
| 134 | - const oids = await objectsForFetch(ctx.store, wants, common); | |
| 148 | + const oids = plan.oids; | |
| 135 | 149 | if (!noProgress) sideband(2, utf8(`Enumerating objects: ${oids.length}, done.\n`), out); |
| 136 | 150 | const entries = []; |
| 137 | 151 | for (const oid of oids) { |
modifiedapps/diffinery/src/index.ts+2−194View file
| @@ -1,207 +1,15 @@ | ||
| 1 | -import { Hono } from "hono"; | |
| 2 | 1 | import type { DiffineryEvent } from "@diffinery/shared"; |
| 3 | -import { cronMatches } from "@diffinery/workflow-yaml"; | |
| 4 | 2 | import type { Env } from "./env.js"; |
| 5 | -import { resolvePrincipal, type AppVars } from "./auth/principal.js"; | |
| 6 | -import { Directory } from "./lib/directory.js"; | |
| 7 | -import { hmacSha256Hex, newId } from "./lib/util.js"; | |
| 8 | -import { errorResponse, loadRepo, repoStub } from "./http/common.js"; | |
| 9 | -import { git } from "./http/git.js"; | |
| 10 | -import { runner } from "./http/runner.js"; | |
| 11 | -import { api, wellKnown } from "./http/api.js"; | |
| 12 | -import { mcp } from "./mcp/server.js"; | |
| 3 | +import { app, queue, scheduled } from "./app.js"; | |
| 13 | 4 | import { web } from "./web/routes.js"; |
| 14 | 5 | |
| 15 | 6 | export { RepoDO } from "./repo/RepoDO.js"; |
| 16 | 7 | export { RunnerContainer, SandboxContainer } from "./runner/containers.js"; |
| 17 | 8 | export { RunWorkflow } from "./runner/workflow.js"; |
| 18 | 9 | |
| 19 | -const app = new Hono<{ Bindings: Env; Variables: AppVars }>(); | |
| 20 | - | |
| 21 | -app.onError((e) => errorResponse(e)); | |
| 22 | - | |
| 23 | -// Security headers on every response, then identity: tokens, Access JWT, or anonymous. | |
| 24 | -app.use("*", async (c, next) => { | |
| 25 | - const dir = new Directory(c.env); | |
| 26 | - c.set("dir", dir); | |
| 27 | - c.set("principal", await resolvePrincipal(c.req.raw, c.env, dir)); | |
| 28 | - await next(); | |
| 29 | - if (c.res.status === 101) return; | |
| 30 | - const h = c.res.headers; | |
| 31 | - h.set("strict-transport-security", "max-age=63072000; includeSubDomains; preload"); | |
| 32 | - h.set("x-content-type-options", "nosniff"); | |
| 33 | - h.set("referrer-policy", "strict-origin-when-cross-origin"); | |
| 34 | - if (!h.has("x-frame-options")) h.set("x-frame-options", "DENY"); | |
| 35 | - h.set("permissions-policy", "camera=(), microphone=(), geolocation=()"); | |
| 36 | -}); | |
| 37 | - | |
| 38 | -app.get("/healthz", (c) => c.json({ ok: true })); | |
| 39 | - | |
| 40 | -app.route("/", wellKnown); | |
| 41 | -app.route("/", git); | |
| 42 | -app.route("/", runner); | |
| 43 | -app.route("/", api); | |
| 44 | -app.route("/", mcp); | |
| 45 | - | |
| 46 | -// Live updates: authorise in the Worker, then hand the WebSocket to the repository's Durable Object. | |
| 47 | -app.get("/:owner/:repo/live", async (c) => { | |
| 48 | - if (c.req.header("upgrade") !== "websocket") return c.text("expected websocket", 426); | |
| 49 | - const { stub } = await loadRepo(c, c.req.param("owner"), c.req.param("repo")); | |
| 50 | - return stub.fetch(c.req.raw); | |
| 51 | -}); | |
| 52 | - | |
| 53 | -app.get("/:owner/:repo/actions/runs/:runId/artifacts/:name", async (c) => { | |
| 54 | - const { stub } = await loadRepo(c, c.req.param("owner"), c.req.param("repo")); | |
| 55 | - const name = c.req.param("name"); | |
| 56 | - if (!(await stub.artifacts(c.req.param("runId"))).some((a) => a.name === name)) return c.json({ message: "artifact not found" }, 404); | |
| 57 | - const obj = await c.env.BUCKET.get(await stub.artifactKeyForRun(c.req.param("runId"), name)); | |
| 58 | - if (!obj) return c.json({ message: "artifact expired" }, 410); | |
| 59 | - return new Response(obj.body, { | |
| 60 | - headers: { | |
| 61 | - "content-type": "application/gzip", | |
| 62 | - "content-length": String(obj.size), | |
| 63 | - "content-disposition": `attachment; filename="${name.replace(/[^\w.-]+/g, "_")}.tar.gz"`, | |
| 64 | - }, | |
| 65 | - }); | |
| 66 | -}); | |
| 67 | - | |
| 10 | +// The web UI is mounted last so API, git and runner routes take precedence. | |
| 68 | 11 | app.route("/", web); |
| 69 | 12 | |
| 70 | -// ---------------------------------------------------------------- events queue | |
| 71 | - | |
| 72 | -const GITHUB_EVENT: Record<DiffineryEvent["type"], string> = { | |
| 73 | - push: "push", | |
| 74 | - pull_request: "pull_request", | |
| 75 | - issues: "issues", | |
| 76 | - issue_comment: "issue_comment", | |
| 77 | - pull_request_review: "pull_request_review", | |
| 78 | - workflow_run: "workflow_run", | |
| 79 | - workflow_dispatch: "workflow_dispatch", | |
| 80 | - audit: "audit", | |
| 81 | -}; | |
| 82 | - | |
| 83 | -function webhookPayload(env: Env, ev: Exclude<DiffineryEvent, { type: "audit" }>) { | |
| 84 | - const repository = { id: ev.repoId, name: ev.repo, full_name: `${ev.owner}/${ev.repo}`, owner: { login: ev.owner }, html_url: `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}`, clone_url: `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}.git` }; | |
| 85 | - const sender = { id: ev.actor.id, login: ev.actor.login, type: ev.actor.kind === "agent" ? "Bot" : ev.actor.kind === "system" ? "Bot" : "User", on_behalf_of: ev.actor.onBehalfOf }; | |
| 86 | - const { type: _t, repoId: _r, owner: _o, repo: _n, actor: _a, ...rest } = ev; | |
| 87 | - const action = "action" in ev ? (ev.action === "merged" ? "closed" : ev.action) : undefined; | |
| 88 | - const extra: Record<string, unknown> = {}; | |
| 89 | - if (ev.type === "pull_request") { | |
| 90 | - extra.pull_request = { number: ev.number, html_url: `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}/pull/${ev.number}`, head: { ref: ev.headRef, sha: ev.headSha }, base: { ref: ev.baseRef }, merged: ev.action === "merged" }; | |
| 91 | - } | |
| 92 | - if (ev.type === "issues" || ev.type === "issue_comment") extra.issue = { number: ev.number, html_url: `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}/issues/${ev.number}` }; | |
| 93 | - return { ...rest, action, repository, sender, ...extra }; | |
| 94 | -} | |
| 95 | - | |
| 96 | -async function deliverWebhooks(env: Env, ev: Exclude<DiffineryEvent, { type: "audit" }>): Promise<void> { | |
| 97 | - const stub = repoStub(env, ev.repoId); | |
| 98 | - const hooks = (await stub.webhooks()).filter((h) => h.active && (h.events.includes("*") || h.events.includes(GITHUB_EVENT[ev.type]))); | |
| 99 | - if (!hooks.length) return; | |
| 100 | - const body = JSON.stringify(webhookPayload(env, ev)); | |
| 101 | - await Promise.all( | |
| 102 | - hooks.map(async (h) => { | |
| 103 | - const delivery = newId(); | |
| 104 | - let status = 0; | |
| 105 | - try { | |
| 106 | - const res = await fetch(h.url, { | |
| 107 | - method: "POST", | |
| 108 | - headers: { | |
| 109 | - "content-type": "application/json", | |
| 110 | - "user-agent": "Diffinery-Hookshot/1", | |
| 111 | - "x-github-event": GITHUB_EVENT[ev.type], | |
| 112 | - "x-github-delivery": delivery, | |
| 113 | - "x-diffinery-event": ev.type, | |
| 114 | - "x-hub-signature-256": `sha256=${await hmacSha256Hex(h.secret, body)}`, | |
| 115 | - }, | |
| 116 | - body, | |
| 117 | - redirect: "manual", | |
| 118 | - signal: AbortSignal.timeout(10_000), | |
| 119 | - }); | |
| 120 | - status = res.status; | |
| 121 | - } catch { | |
| 122 | - status = 0; | |
| 123 | - } | |
| 124 | - await stub.recordDelivery(h.id, status); | |
| 125 | - }), | |
| 126 | - ); | |
| 127 | -} | |
| 128 | - | |
| 129 | -/** People who should hear about an event: participants, assignees and requested reviewers, minus the actor. */ | |
| 130 | -async function notifyParticipants(env: Env, dir: Directory, ev: DiffineryEvent): Promise<void> { | |
| 131 | - if (ev.type === "workflow_run") { | |
| 132 | - if (ev.action !== "completed" || ev.conclusion !== "failure" || ev.actor.kind === "system") return; | |
| 133 | - await dir.notify([ev.actor.id], { repoId: ev.repoId, kind: "run_failed", title: `Workflow run failed in ${ev.owner}/${ev.repo}`, url: `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}/actions/runs/${ev.runId}` }); | |
| 134 | - return; | |
| 135 | - } | |
| 136 | - if (ev.type !== "pull_request" && ev.type !== "issues" && ev.type !== "issue_comment" && ev.type !== "pull_request_review") return; | |
| 137 | - if (ev.type === "pull_request" && ev.action === "synchronize") return; | |
| 138 | - const { issue, comments, reviews } = await repoStub(env, ev.repoId).issue(ev.number); | |
| 139 | - const ids = new Set<string>([issue.author.id, ...comments.map((c) => c.author.id), ...reviews.map((r) => r.author.id)]); | |
| 140 | - for (const login of [...issue.assignees, ...(issue.pull?.requestedReviewers ?? [])]) { | |
| 141 | - const a = await dir.accountByLogin(login); | |
| 142 | - if (a) ids.add(a.id); | |
| 143 | - } | |
| 144 | - ids.delete(ev.actor.id); | |
| 145 | - if (!ids.size) return; | |
| 146 | - const kind = issue.kind === "pull" ? "pull" : "issues"; | |
| 147 | - const verb = ev.type === "issue_comment" ? "commented on" : ev.type === "pull_request_review" ? `reviewed (${ev.state.toLowerCase()})` : ev.action; | |
| 148 | - await dir.notify([...ids].slice(0, 100), { | |
| 149 | - repoId: ev.repoId, | |
| 150 | - kind: ev.type, | |
| 151 | - title: `${ev.actor.login} ${verb} ${ev.owner}/${ev.repo}#${ev.number}: ${issue.title}`, | |
| 152 | - url: `${env.PUBLIC_URL}/${ev.owner}/${ev.repo}/${kind}/${ev.number}`, | |
| 153 | - }); | |
| 154 | -} | |
| 155 | - | |
| 156 | -async function handleEvent(env: Env, dir: Directory, ev: DiffineryEvent): Promise<void> { | |
| 157 | - if (ev.type === "audit") { | |
| 158 | - await dir.audit({ actorId: ev.actor.id, actorLogin: ev.actor.login, onBehalfOf: ev.actor.onBehalfOf, action: ev.action, target: ev.target, detail: ev.detail, at: ev.at }); | |
| 159 | - return; | |
| 160 | - } | |
| 161 | - if (ev.type === "push") { | |
| 162 | - await dir.audit({ actorId: ev.actor.id, actorLogin: ev.actor.login, onBehalfOf: ev.actor.onBehalfOf, action: ev.forced ? "git.force_push" : ev.deleted ? "git.delete_ref" : "git.push", target: `${ev.owner}/${ev.repo}:${ev.ref}`, detail: { before: ev.before, after: ev.after }, at: ev.at }); | |
| 163 | - } | |
| 164 | - if (ev.type === "pull_request" && ev.action === "merged") { | |
| 165 | - await dir.audit({ actorId: ev.actor.id, actorLogin: ev.actor.login, onBehalfOf: ev.actor.onBehalfOf, action: "pull.merge", target: `${ev.owner}/${ev.repo}#${ev.number}`, at: ev.at }); | |
| 166 | - } | |
| 167 | - await Promise.all([deliverWebhooks(env, ev), notifyParticipants(env, dir, ev)]); | |
| 168 | -} | |
| 169 | - | |
| 170 | -async function queue(batch: MessageBatch<DiffineryEvent>, env: Env): Promise<void> { | |
| 171 | - const dir = new Directory(env); | |
| 172 | - // Keep an append-only copy of every event in R2 for long-term audit retention. | |
| 173 | - const day = new Date().toISOString().slice(0, 10).replaceAll("-", "/"); | |
| 174 | - await env.BUCKET.put(`events/${day}/${Date.now()}-${newId()}.jsonl`, batch.messages.map((m) => JSON.stringify(m.body)).join("\n")).catch((e) => console.error("event archive failed", e)); | |
| 175 | - for (const msg of batch.messages) { | |
| 176 | - try { | |
| 177 | - await handleEvent(env, dir, msg.body); | |
| 178 | - msg.ack(); | |
| 179 | - } catch (e) { | |
| 180 | - console.error("event handling failed", msg.body.type, e); | |
| 181 | - msg.retry({ delaySeconds: Math.min(300, 10 * 2 ** msg.attempts) }); | |
| 182 | - } | |
| 183 | - } | |
| 184 | -} | |
| 185 | - | |
| 186 | -// ---------------------------------------------------------------- cron: workflow schedules | |
| 187 | - | |
| 188 | -async function scheduled(controller: ScheduledController, env: Env): Promise<void> { | |
| 189 | - // The trigger runs every 5 minutes (GitHub's minimum schedule interval); fire anything due in the window. | |
| 190 | - const end = new Date(controller.scheduledTime); | |
| 191 | - end.setUTCSeconds(0, 0); | |
| 192 | - const minutes = Array.from({ length: 5 }, (_, i) => new Date(end.getTime() - i * 60_000)); | |
| 193 | - const { results } = await env.DB.prepare("SELECT s.repo_id, s.workflow_path, s.cron FROM schedules s JOIN repos r ON r.id = s.repo_id WHERE r.archived = 0").all<{ repo_id: string; workflow_path: string; cron: string }>(); | |
| 194 | - await Promise.all( | |
| 195 | - results.map(async (s) => { | |
| 196 | - try { | |
| 197 | - if (minutes.some((m) => cronMatches(s.cron, m))) await repoStub(env, s.repo_id).scheduleTick(s.workflow_path, s.cron); | |
| 198 | - } catch (e) { | |
| 199 | - console.error("schedule failed", s.repo_id, s.workflow_path, e); | |
| 200 | - } | |
| 201 | - }), | |
| 202 | - ); | |
| 203 | -} | |
| 204 | - | |
| 205 | 13 | export default { |
| 206 | 14 | fetch: app.fetch, |
| 207 | 15 | queue, |
modifiedapps/diffinery/src/web/account.tsx+14−17View file
| @@ -5,10 +5,10 @@ | ||
| 5 | 5 | import type { Account, Repo, TokenScope } from "../lib/directory.js"; |
| 6 | 6 | import { loadRepo } from "../http/common.js"; |
| 7 | 7 | import { HttpError } from "../lib/util.js"; |
| 8 | -import { ROLE_LEVEL, type Role } from "../repo/types.js"; | |
| 8 | +import type { Role } from "../repo/types.js"; | |
| 9 | 9 | import { ICONS } from "./assets.js"; |
| 10 | 10 | import { csrfToken } from "./csrf.js"; |
| 11 | -import { Actor, Avatar, CopyButton, Form, ROLES, Time, act, ago, post, publicUrl, render, repoUrl, type Stub, type WebApp } from "./ui.js"; | |
| 11 | +import { Actor, Avatar, CopyButton, Form, ROLES, Time, act, ago, post, publicUrl, render, repoUrl, type Stub, type WebApp, prm } from "./ui.js"; | |
| 12 | 12 | |
| 13 | 13 | // ---------------------------------------------------------------- shared bits |
| 14 | 14 | |
| @@ -381,7 +381,7 @@ | ||
| 381 | 381 | web.get("/notifications/:id", async (c) => { |
| 382 | 382 | const me = requireAccount(c); |
| 383 | 383 | const dir = c.get("dir"); |
| 384 | - const id = c.req.param("id"); | |
| 384 | + const id = prm(c, "id"); | |
| 385 | 385 | const n = (await dir.notifications(me.id)).find((x) => x.id === id); |
| 386 | 386 | if (!n) throw new HttpError(404, "notification not found"); |
| 387 | 387 | await dir.markRead(me.id, id); |
| @@ -413,7 +413,7 @@ | ||
| 413 | 413 | <p class="muted"> |
| 414 | 414 | Accept this invite as <b>{p.account.login}</b> ({p.account.email ?? "no email"}). Invites only work for the email address they were sent to. |
| 415 | 415 | </p> |
| 416 | - <Form action={`/invite/${encodeURIComponent(c.req.param("token"))}`} t={csrfToken(c)}> | |
| 416 | + <Form action={`/invite/${encodeURIComponent(prm(c, "token"))}`} t={csrfToken(c)}> | |
| 417 | 417 | <button class="btn primary">Accept invite</button> |
| 418 | 418 | </Form> |
| 419 | 419 | </div> |
| @@ -426,7 +426,7 @@ | ||
| 426 | 426 | if (p.type !== "account") throw new HttpError(401, "sign in to accept this invite"); |
| 427 | 427 | if (p.account.status === "disabled") throw new HttpError(403, "your account is disabled"); |
| 428 | 428 | const dir = c.get("dir"); |
| 429 | - const res = await dir.acceptInvite(c.req.param("token"), p.account); | |
| 429 | + const res = await dir.acceptInvite(prm(c, "token"), p.account); | |
| 430 | 430 | let to = "/"; |
| 431 | 431 | if (res.repoId) { |
| 432 | 432 | const r = await dir.repoById(res.repoId); |
| @@ -546,7 +546,7 @@ | ||
| 546 | 546 | How to use it |
| 547 | 547 | </summary> |
| 548 | 548 | <pre class="cmd mt"> |
| 549 | - {`# Git over HTTPS (any username, the token as password)\ngit clone https://x:${token.slice(0, 8)}…@${new URL(publicUrl(c)).host}/OWNER/REPO.git\n\n# Or store it once in your credential helper\ngit config --global credential.helper store\n\n# REST API / MCP\ncurl -H "Authorization: Bearer <token>" ${publicUrl(c)}/api/...`} | |
| 549 | + {`# Git over HTTPS: use your username and this token as the password\ngit clone ${publicUrl(c)}/OWNER/REPO.git\n\n# Remember it (or use your OS keychain helper)\ngit config --global credential.helper store\n\n# HTTP APIs and MCP clients\nAuthorization: Bearer <token>`} | |
| 550 | 550 | </pre> |
| 551 | 551 | </details> |
| 552 | 552 | </div> |
| @@ -737,8 +737,8 @@ | ||
| 737 | 737 | c, |
| 738 | 738 | "/settings/tokens", |
| 739 | 739 | async () => { |
| 740 | - await c.get("dir").revokeToken(me.id, c.req.param("id")); | |
| 741 | - c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "token.revoke", target: c.req.param("id") })); | |
| 740 | + await c.get("dir").revokeToken(me.id, prm(c, "id")); | |
| 741 | + c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "token.revoke", target: prm(c, "id") })); | |
| 742 | 742 | }, |
| 743 | 743 | "Token revoked.", |
| 744 | 744 | ); |
| @@ -894,7 +894,7 @@ | ||
| 894 | 894 | }); |
| 895 | 895 | |
| 896 | 896 | const loadAgent = async (c: AppContext, me: Account): Promise<Account> => { |
| 897 | - const agent = await c.get("dir").account(c.req.param("id") ?? ""); | |
| 897 | + const agent = await c.get("dir").account(prm(c, "id")); | |
| 898 | 898 | if (!agent || !(await canManageAgent(c, me, agent))) throw new HttpError(404, "agent not found"); |
| 899 | 899 | return agent; |
| 900 | 900 | }; |
| @@ -912,7 +912,7 @@ | ||
| 912 | 912 | post(web, "/settings/agents/:id/tokens/:tid/revoke", async (c) => { |
| 913 | 913 | const me = requireAccount(c); |
| 914 | 914 | const agent = await loadAgent(c, me); |
| 915 | - return act(c, "/settings/agents", async () => c.get("dir").revokeToken(agent.id, c.req.param("tid")), "Token revoked."); | |
| 915 | + return act(c, "/settings/agents", async () => c.get("dir").revokeToken(agent.id, prm(c, "tid")), "Token revoked."); | |
| 916 | 916 | }); |
| 917 | 917 | |
| 918 | 918 | post(web, "/settings/agents/:id/status", async (c, f) => { |
| @@ -1120,7 +1120,7 @@ | ||
| 1120 | 1120 | "/settings/admin", |
| 1121 | 1121 | async () => { |
| 1122 | 1122 | if (!status) throw new HttpError(422, "invalid status"); |
| 1123 | - const target = await dir.account(c.req.param("id")); | |
| 1123 | + const target = await dir.account(prm(c, "id")); | |
| 1124 | 1124 | if (!target || target.kind !== "user") throw new HttpError(404, "user not found"); |
| 1125 | 1125 | if (target.id === me.id) throw new HttpError(422, "you cannot change your own status"); |
| 1126 | 1126 | await dir.setStatus(target.id, status); |
| @@ -1197,7 +1197,7 @@ | ||
| 1197 | 1197 | const orgContext = async (c: AppContext) => { |
| 1198 | 1198 | const me = requireAccount(c); |
| 1199 | 1199 | const dir = c.get("dir"); |
| 1200 | - const org = await dir.accountByLogin(c.req.param("owner") ?? ""); | |
| 1200 | + const org = await dir.accountByLogin(prm(c, "owner")); | |
| 1201 | 1201 | if (!org) throw new HttpError(404, "not found"); |
| 1202 | 1202 | if (org.kind !== "org") throw new HttpError(404, "not an organization"); |
| 1203 | 1203 | const role = await dir.orgRole(org.id, me.id); |
| @@ -1210,9 +1210,9 @@ | ||
| 1210 | 1210 | const me = requireAccount(c); |
| 1211 | 1211 | const dir = c.get("dir"); |
| 1212 | 1212 | const t = csrfToken(c); |
| 1213 | - const acct = await dir.accountByLogin(c.req.param("owner") ?? ""); | |
| 1213 | + const acct = await dir.accountByLogin(prm(c, "owner")); | |
| 1214 | 1214 | if (!acct) throw new HttpError(404, "not found"); |
| 1215 | - if (acct.login !== c.req.param("owner")) return c.redirect(`/${acct.login}${new URL(c.req.url).search}`, 301); | |
| 1215 | + if (acct.login !== prm(c, "owner")) return c.redirect(`/${acct.login}${new URL(c.req.url).search}`, 301); | |
| 1216 | 1216 | |
| 1217 | 1217 | if (acct.kind === "agent") { |
| 1218 | 1218 | const owner = acct.owner_id ? await dir.account(acct.owner_id) : null; |
| @@ -1615,6 +1615,3 @@ | ||
| 1615 | 1615 | }, "Organization updated."); |
| 1616 | 1616 | }); |
| 1617 | 1617 | } |
| 1618 | - | |
| 1619 | -/** Role helper for other modules. */ | |
| 1620 | -export const roleRank = (r: Role): number => ROLE_LEVEL[r]; |
addedapps/diffinery/src/web/actions.tsx+578−0View file
| @@ -1,0 +1,578 @@ | ||
| 1 | +/** @jsxImportSource hono/jsx */ | |
| 2 | +import { parseWorkflow, type DispatchInput } from "@diffinery/workflow-yaml"; | |
| 3 | +import { raw } from "hono/html"; | |
| 4 | +import type { FC } from "hono/jsx"; | |
| 5 | +import type { AppContext } from "../auth/principal.js"; | |
| 6 | +import { loadRepo } from "../http/common.js"; | |
| 7 | +import { HttpError } from "../lib/util.js"; | |
| 8 | +import type { JobInfo, RunInfo } from "../repo/actions.js"; | |
| 9 | +import { atLeast } from "../repo/types.js"; | |
| 10 | +import { ICONS } from "./assets.js"; | |
| 11 | +import { csrfToken } from "./csrf.js"; | |
| 12 | +import { Actor, Form, Markdown, Pager, Status, Time, act, duration, encPath, fmtBytes, pageNum, post, qs, refNames, render, repoUrl, shortSha, type WebApp, prm } from "./ui.js"; | |
| 13 | + | |
| 14 | +type Loaded = Awaited<ReturnType<typeof loadRepo>>; | |
| 15 | +type LogLine = { seq: number; t: string; step: number; text: string }; | |
| 16 | + | |
| 17 | +const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g; | |
| 18 | +const MAX_LOG_LINES = 20000; | |
| 19 | + | |
| 20 | +const refLabel = (ref: string): string => ref.replace(/^refs\/(heads|tags)\//, ""); | |
| 21 | + | |
| 22 | +function runDuration(r: Pick<RunInfo, "status" | "createdAt" | "updatedAt">): string { | |
| 23 | + return duration(r.createdAt, r.status === "completed" ? r.updatedAt : null); | |
| 24 | +} | |
| 25 | + | |
| 26 | +function matrixLabel(m: Record<string, unknown>): string { | |
| 27 | + const entries = Object.entries(m ?? {}); | |
| 28 | + if (!entries.length) return ""; | |
| 29 | + return entries.map(([k, v]) => `${k}: ${typeof v === "object" ? JSON.stringify(v) : String(v)}`).join(", "); | |
| 30 | +} | |
| 31 | + | |
| 32 | +const RunRow: FC<{ base: string; r: RunInfo }> = ({ base, r }) => ( | |
| 33 | + <li> | |
| 34 | + <Status status={r.status} conclusion={r.conclusion} /> | |
| 35 | + <div class="grow"> | |
| 36 | + <a class="title" href={`${base}/actions/runs/${r.id}`}> | |
| 37 | + {r.title || r.workflowName} | |
| 38 | + </a> | |
| 39 | + <div class="meta"> | |
| 40 | + <b>{r.workflowName}</b> #{r.number} | |
| 41 | + {r.attempt > 1 ? ` (attempt ${r.attempt})` : ""}: {r.event.replace(/_/g, " ")} | |
| 42 | + {r.prNumber ? ( | |
| 43 | + <> | |
| 44 | + {" "} | |
| 45 | + for <a href={`${base}/pull/${r.prNumber}`}>#{r.prNumber}</a> | |
| 46 | + </> | |
| 47 | + ) : null}{" "} | |
| 48 | + by {r.actor.login} | |
| 49 | + {r.actor.kind === "agent" ? <span class="badge agent" style="margin-left:4px">agent</span> : null} | |
| 50 | + </div> | |
| 51 | + </div> | |
| 52 | + <span class="badge mono nowrap" title={r.ref}> | |
| 53 | + {refLabel(r.ref)} | |
| 54 | + </span> | |
| 55 | + <div class="right small muted nowrap" style="min-width:110px"> | |
| 56 | + <div> | |
| 57 | + <Time at={r.createdAt} /> | |
| 58 | + </div> | |
| 59 | + <div>{runDuration(r)}</div> | |
| 60 | + </div> | |
| 61 | + </li> | |
| 62 | +); | |
| 63 | + | |
| 64 | +/** Parse workflow_dispatch input definitions from the workflow file on the default branch. */ | |
| 65 | +async function dispatchInputs(l: Loaded, path: string): Promise<Record<string, DispatchInput> | null> { | |
| 66 | + try { | |
| 67 | + const blob = await l.stub.blob(l.repo.default_branch, path); | |
| 68 | + if (!blob || blob.binary) return null; | |
| 69 | + const { workflow } = parseWorkflow(new TextDecoder().decode(blob.content), path); | |
| 70 | + const d = workflow?.on.workflow_dispatch; | |
| 71 | + return d ? (d.inputs ?? {}) : null; | |
| 72 | + } catch { | |
| 73 | + return null; | |
| 74 | + } | |
| 75 | +} | |
| 76 | + | |
| 77 | +function classify(text: string): { cls: string; text: string } { | |
| 78 | + const t = text.replace(ANSI, ""); | |
| 79 | + if (t.startsWith("::group::")) return { cls: "grp", text: `▾ ${t.slice(9)}` }; | |
| 80 | + if (t.startsWith("::endgroup::")) return { cls: "grp", text: "" }; | |
| 81 | + if (/^(##\[error\]|::error)/.test(t)) return { cls: "err", text: t }; | |
| 82 | + if (/^(##\[warning\]|::warning)/.test(t)) return { cls: "wrn", text: t }; | |
| 83 | + return { cls: "", text: t }; | |
| 84 | +} | |
| 85 | + | |
| 86 | +async function allLogs(l: Loaded, jobId: string): Promise<{ lines: LogLine[]; complete: boolean; truncated: boolean }> { | |
| 87 | + const out: LogLine[] = []; | |
| 88 | + let after = -1; | |
| 89 | + let complete = false; | |
| 90 | + for (let i = 0; i < 5; i++) { | |
| 91 | + const page = await l.stub.jobLogs(jobId, after); | |
| 92 | + complete = page.complete; | |
| 93 | + out.push(...page.lines); | |
| 94 | + if (page.lines.length < 5000 || out.length >= MAX_LOG_LINES) break; | |
| 95 | + after = page.lines[page.lines.length - 1]!.seq; | |
| 96 | + } | |
| 97 | + return { lines: out.slice(0, MAX_LOG_LINES), complete, truncated: out.length >= MAX_LOG_LINES }; | |
| 98 | +} | |
| 99 | + | |
| 100 | +export function registerActions(web: WebApp): void { | |
| 101 | + web.get("/:owner/:repo/actions", async (c) => { | |
| 102 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); | |
| 103 | + const { repo, caller, stub } = l; | |
| 104 | + const base = repoUrl(repo); | |
| 105 | + const workflow = c.req.query("workflow") ?? ""; | |
| 106 | + const branch = c.req.query("branch") ?? ""; | |
| 107 | + const status = c.req.query("status") ?? ""; | |
| 108 | + const page = pageNum(c); | |
| 109 | + const per = 25; | |
| 110 | + const [runs, workflows, names] = await Promise.all([ | |
| 111 | + stub.runs({ workflow: workflow || undefined, branch: branch || undefined, status: status || undefined, limit: per, offset: (page - 1) * per }), | |
| 112 | + stub.workflows().catch(() => [] as Array<{ path: string; name: string; errors: string[]; triggers: string[] }>), | |
| 113 | + refNames(stub), | |
| 114 | + ]); | |
| 115 | + const selected = workflow ? workflows.find((w) => w.path === workflow || w.path.endsWith(`/${workflow}`)) : undefined; | |
| 116 | + const canWrite = atLeast(caller.role, "write") && c.get("principal").type === "account" && !repo.archived; | |
| 117 | + const inputs = selected && canWrite && selected.triggers.includes("workflow_dispatch") ? await dispatchInputs(l, selected.path) : null; | |
| 118 | + const t = csrfToken(c); | |
| 119 | + const href = (p: Record<string, string | number | undefined>) => `${base}/actions${qs({ workflow: workflow || undefined, branch: branch || undefined, status: status || undefined, ...p })}`; | |
| 120 | + return render( | |
| 121 | + c, | |
| 122 | + { title: `Actions · ${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "actions" }, live: "runs" }, | |
| 123 | + <div class="grid left"> | |
| 124 | + <aside class="side"> | |
| 125 | + <section> | |
| 126 | + <h3>Workflows</h3> | |
| 127 | + <div class="nav"> | |
| 128 | + <a href={`${base}/actions`} class={!workflow ? "on" : ""}> | |
| 129 | + All workflows | |
| 130 | + </a> | |
| 131 | + {workflows.map((w) => ( | |
| 132 | + <a href={`${base}/actions${qs({ workflow: w.path })}`} class={selected?.path === w.path ? "on" : ""} title={w.path}> | |
| 133 | + {w.errors.length ? <span class="bad">{raw(ICONS.failure)}</span> : null} {w.name} | |
| 134 | + </a> | |
| 135 | + ))} | |
| 136 | + </div> | |
| 137 | + {workflows.length === 0 ? ( | |
| 138 | + <p class="muted small"> | |
| 139 | + No workflows on <span class="mono">{repo.default_branch}</span>. Add a YAML file under <code>.github/workflows/</code>. | |
| 140 | + </p> | |
| 141 | + ) : null} | |
| 142 | + </section> | |
| 143 | + <section> | |
| 144 | + <h3>Help</h3> | |
| 145 | + <p class="small muted m0">Workflows use GitHub Actions syntax. Jobs run on Linux containers; secrets and variables are configured in repository settings.</p> | |
| 146 | + </section> | |
| 147 | + </aside> | |
| 148 | + <div> | |
| 149 | + <div class="row between mb"> | |
| 150 | + <h1 class="m0">{selected ? selected.name : "All workflows"}</h1> | |
| 151 | + {selected ? <span class="mono muted small">{selected.path}</span> : null} | |
| 152 | + </div> | |
| 153 | + {selected?.errors.length ? ( | |
| 154 | + <div class="failure"> | |
| 155 | + <b>This workflow file has errors</b> | |
| 156 | + <pre>{selected.errors.join("\n")}</pre> | |
| 157 | + </div> | |
| 158 | + ) : null} | |
| 159 | + {!selected && workflows.some((w) => w.errors.length) ? ( | |
| 160 | + <div class="failure"> | |
| 161 | + <b>Some workflow files have errors</b> | |
| 162 | + <pre>{workflows.filter((w) => w.errors.length).map((w) => `${w.path}\n ${w.errors.join("\n ")}`).join("\n")}</pre> | |
| 163 | + </div> | |
| 164 | + ) : null} | |
| 165 | + {selected && inputs ? ( | |
| 166 | + <details class="box"> | |
| 167 | + <summary class="head" style="cursor:pointer"> | |
| 168 | + {raw(ICONS.play)} <b>Run workflow</b> | |
| 169 | + <span class="muted small">This workflow has a workflow_dispatch trigger.</span> | |
| 170 | + </summary> | |
| 171 | + <Form action={`${base}/actions/dispatch`} t={t} class="body"> | |
| 172 | + <input type="hidden" name="workflow" value={selected.path} /> | |
| 173 | + <div class="field" style="max-width:320px"> | |
| 174 | + <label>Use workflow from</label> | |
| 175 | + <select name="ref"> | |
| 176 | + {names.branches.map((b) => ( | |
| 177 | + <option value={b} selected={b === repo.default_branch}> | |
| 178 | + {b} | |
| 179 | + </option> | |
| 180 | + ))} | |
| 181 | + {names.tags.map((tg) => ( | |
| 182 | + <option value={tg}>tag: {tg}</option> | |
| 183 | + ))} | |
| 184 | + </select> | |
| 185 | + </div> | |
| 186 | + {Object.entries(inputs).map(([name, d]) => ( | |
| 187 | + <div class="field" style="max-width:520px"> | |
| 188 | + {d.type === "boolean" ? ( | |
| 189 | + <> | |
| 190 | + <input type="hidden" name={`in_${name}`} value="false" /> | |
| 191 | + <label class="inline"> | |
| 192 | + <input type="checkbox" name={`in_${name}`} value="true" checked={d.default === true || d.default === "true"} /> {d.description ?? name} | |
| 193 | + </label> | |
| 194 | + </> | |
| 195 | + ) : ( | |
| 196 | + <> | |
| 197 | + <label> | |
| 198 | + {d.description ?? name} {d.required ? <span class="bad">*</span> : null} | |
| 199 | + </label> | |
| 200 | + {d.type === "choice" && d.options?.length ? ( | |
| 201 | + <select name={`in_${name}`}> | |
| 202 | + {d.options.map((o) => ( | |
| 203 | + <option value={o} selected={String(d.default ?? "") === o}> | |
| 204 | + {o} | |
| 205 | + </option> | |
| 206 | + ))} | |
| 207 | + </select> | |
| 208 | + ) : ( | |
| 209 | + <input type={d.type === "number" ? "number" : "text"} name={`in_${name}`} value={d.default === undefined || d.default === null ? "" : String(d.default)} required={!!d.required} /> | |
| 210 | + )} | |
| 211 | + </> | |
| 212 | + )} | |
| 213 | + {d.description && d.type !== "boolean" ? <div class="hint mono">{name}</div> : null} | |
| 214 | + </div> | |
| 215 | + ))} | |
| 216 | + <button class="btn green">{raw(ICONS.play)} Run workflow</button> | |
| 217 | + </Form> | |
| 218 | + </details> | |
| 219 | + ) : null} | |
| 220 | + <div class="box" data-fragment="runs"> | |
| 221 | + <div class="head"> | |
| 222 | + <b> | |
| 223 | + {runs.total} workflow run{runs.total === 1 ? "" : "s"} | |
| 224 | + </b> | |
| 225 | + <span class="grow" /> | |
| 226 | + <form method="get" action={`${base}/actions`} class="row" style="gap:6px"> | |
| 227 | + {workflow ? <input type="hidden" name="workflow" value={workflow} /> : null} | |
| 228 | + <select name="branch" style="width:auto;padding:3px 6px"> | |
| 229 | + <option value="">All branches</option> | |
| 230 | + {names.branches.map((b) => ( | |
| 231 | + <option value={b} selected={b === branch}> | |
| 232 | + {b} | |
| 233 | + </option> | |
| 234 | + ))} | |
| 235 | + </select> | |
| 236 | + <select name="status" style="width:auto;padding:3px 6px"> | |
| 237 | + <option value="">Any status</option> | |
| 238 | + {["queued", "in_progress", "completed", "success", "failure", "cancelled"].map((s) => ( | |
| 239 | + <option value={s} selected={s === status}> | |
| 240 | + {s.replace("_", " ")} | |
| 241 | + </option> | |
| 242 | + ))} | |
| 243 | + </select> | |
| 244 | + <button class="btn sm">Filter</button> | |
| 245 | + </form> | |
| 246 | + </div> | |
| 247 | + {runs.runs.length ? ( | |
| 248 | + <ul class="list"> | |
| 249 | + {runs.runs.map((r) => ( | |
| 250 | + <RunRow base={base} r={r} /> | |
| 251 | + ))} | |
| 252 | + </ul> | |
| 253 | + ) : ( | |
| 254 | + <div class="empty">{workflow || branch || status ? "No runs match these filters." : "No workflow runs yet. Push a commit with a workflow file to start one."}</div> | |
| 255 | + )} | |
| 256 | + </div> | |
| 257 | + <Pager page={page} hasNext={page * per < runs.total} href={(p) => href({ page: p > 1 ? p : undefined })} /> | |
| 258 | + </div> | |
| 259 | + </div>, | |
| 260 | + ); | |
| 261 | + }); | |
| 262 | + | |
| 263 | + post(web, "/:owner/:repo/actions/dispatch", async (c, f) => { | |
| 264 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write"); | |
| 265 | + if (c.get("principal").type !== "account") throw new HttpError(401, "sign in required"); | |
| 266 | + const base = repoUrl(l.repo); | |
| 267 | + const workflow = f.s("workflow"); | |
| 268 | + const inputs: Record<string, unknown> = {}; | |
| 269 | + for (const key of f.keys()) if (key.startsWith("in_")) inputs[key.slice(3)] = f.all(key).pop(); | |
| 270 | + return act(c, `${base}/actions${qs({ workflow })}`, async () => { | |
| 271 | + const run = await l.stub.dispatchWorkflow(workflow, f.s("ref") || l.repo.default_branch, inputs, l.caller); | |
| 272 | + return `${base}/actions/runs/${run.id}`; | |
| 273 | + }); | |
| 274 | + }); | |
| 275 | + | |
| 276 | + web.get("/:owner/:repo/actions/runs/:runId", async (c) => { | |
| 277 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); | |
| 278 | + const { repo, caller, stub } = l; | |
| 279 | + const base = repoUrl(repo); | |
| 280 | + const runId = prm(c, "runId"); | |
| 281 | + const run = await stub.run(runId); | |
| 282 | + const artifacts = await stub.artifacts(runId).catch(() => [] as Array<{ name: string; size: number; createdAt: string }>); | |
| 283 | + const groups = new Map<string, JobInfo[]>(); | |
| 284 | + for (const j of run.jobs) groups.set(j.key, [...(groups.get(j.key) ?? []), j]); | |
| 285 | + const canWrite = atLeast(caller.role, "write") && c.get("principal").type === "account" && !repo.archived; | |
| 286 | + const t = csrfToken(c); | |
| 287 | + const behalf = run.actor.onBehalfOf; | |
| 288 | + return render( | |
| 289 | + c, | |
| 290 | + { title: `${run.title || run.workflowName} · Run #${run.number}`, repo: { repo, caller, tab: "actions" }, live: run.status !== "completed" ? `run:${run.id}` : undefined }, | |
| 291 | + <div data-fragment="run"> | |
| 292 | + <div class="row between mb" style="align-items:flex-start"> | |
| 293 | + <div class="grow"> | |
| 294 | + <div class="muted small"> | |
| 295 | + <a href={`${base}/actions${qs({ workflow: run.workflowPath })}`}>{run.workflowName}</a> · run #{run.number} | |
| 296 | + {run.attempt > 1 ? ` · attempt ${run.attempt}` : ""} | |
| 297 | + </div> | |
| 298 | + <h1 class="row" style="gap:8px;margin-top:4px"> | |
| 299 | + <Status status={run.status} conclusion={run.conclusion} /> {run.title || run.workflowName} | |
| 300 | + </h1> | |
| 301 | + </div> | |
| 302 | + <div class="row"> | |
| 303 | + {canWrite && run.status !== "completed" ? ( | |
| 304 | + <Form action={`${base}/actions/runs/${run.id}/cancel`} t={t} class="inline" confirm="Cancel this run?"> | |
| 305 | + <button class="btn danger">{raw(ICONS.cancelled)} Cancel run</button> | |
| 306 | + </Form> | |
| 307 | + ) : null} | |
| 308 | + {canWrite && run.status === "completed" ? ( | |
| 309 | + <Form action={`${base}/actions/runs/${run.id}/rerun`} t={t} class="inline"> | |
| 310 | + <button class="btn">{raw(ICONS.history)} Re-run all jobs</button> | |
| 311 | + </Form> | |
| 312 | + ) : null} | |
| 313 | + </div> | |
| 314 | + </div> | |
| 315 | + <div class="box"> | |
| 316 | + <div class="body"> | |
| 317 | + <dl class="kv"> | |
| 318 | + <dt>Status</dt> | |
| 319 | + <dd> | |
| 320 | + <b>{run.status === "completed" ? (run.conclusion ?? "completed") : run.status.replace("_", " ")}</b> | |
| 321 | + {run.status === "completed" ? <span class="muted"> in {runDuration(run)}</span> : <span class="muted"> for {runDuration(run)}</span>} | |
| 322 | + </dd> | |
| 323 | + <dt>Triggered</dt> | |
| 324 | + <dd> | |
| 325 | + {run.event.replace(/_/g, " ")} by <Actor login={run.actor.login} kind={run.actor.kind} onBehalfOf={behalf} avatar={false} /> <Time at={run.createdAt} /> | |
| 326 | + </dd> | |
| 327 | + <dt>Commit</dt> | |
| 328 | + <dd> | |
| 329 | + <a class="sha" href={`${base}/commit/${run.sha}`}> | |
| 330 | + {shortSha(run.sha)} | |
| 331 | + </a>{" "} | |
| 332 | + on{" "} | |
| 333 | + <a class="mono" href={`${base}/tree/${encPath(refLabel(run.ref))}`}> | |
| 334 | + {refLabel(run.ref)} | |
| 335 | + </a> | |
| 336 | + {run.prNumber ? ( | |
| 337 | + <> | |
| 338 | + {" "} | |
| 339 | + · pull request <a href={`${base}/pull/${run.prNumber}`}>#{run.prNumber}</a> | |
| 340 | + </> | |
| 341 | + ) : null} | |
| 342 | + </dd> | |
| 343 | + <dt>Workflow file</dt> | |
| 344 | + <dd> | |
| 345 | + <a class="mono" href={`${base}/blob/${run.sha}/${encPath(run.workflowPath)}`}> | |
| 346 | + {run.workflowPath} | |
| 347 | + </a> | |
| 348 | + </dd> | |
| 349 | + </dl> | |
| 350 | + </div> | |
| 351 | + </div> | |
| 352 | + {[...groups.entries()].map(([key, jobs]) => ( | |
| 353 | + <div class="box jobgroup"> | |
| 354 | + <div class="head"> | |
| 355 | + <b class="mono">{key}</b> | |
| 356 | + {jobs.length > 1 ? <span class="muted small">{jobs.length} jobs (matrix)</span> : null} | |
| 357 | + </div> | |
| 358 | + {jobs.map((j) => ( | |
| 359 | + <a class="job" href={`${base}/actions/runs/${run.id}/job/${j.id}`}> | |
| 360 | + <Status status={j.status} conclusion={j.conclusion} /> | |
| 361 | + <div class="grow"> | |
| 362 | + <b>{j.name}</b> | |
| 363 | + {matrixLabel(j.matrix) && !j.name.includes("(") ? <span class="muted small"> ({matrixLabel(j.matrix)})</span> : null} | |
| 364 | + {j.failure ? <div class="small bad">{j.failure.message.split("\n")[0]}</div> : null} | |
| 365 | + </div> | |
| 366 | + <span class="muted small nowrap">{j.startedAt ? duration(j.startedAt, j.completedAt) : j.status}</span> | |
| 367 | + </a> | |
| 368 | + ))} | |
| 369 | + </div> | |
| 370 | + ))} | |
| 371 | + {run.jobs.length === 0 ? <div class="blank mb">{run.status === "completed" ? "This run has no jobs." : "Waiting for jobs to be scheduled…"}</div> : null} | |
| 372 | + {artifacts.length ? ( | |
| 373 | + <div class="box"> | |
| 374 | + <div class="head"> | |
| 375 | + <h3>{raw(ICONS.download)} Artifacts</h3> | |
| 376 | + </div> | |
| 377 | + <ul class="list"> | |
| 378 | + {artifacts.map((a) => ( | |
| 379 | + <li> | |
| 380 | + <div class="grow"> | |
| 381 | + <a class="title" href={`${base}/actions/runs/${run.id}/artifacts/${encodeURIComponent(a.name)}`}> | |
| 382 | + {a.name} | |
| 383 | + </a> | |
| 384 | + <div class="meta"> | |
| 385 | + {fmtBytes(a.size)} · <Time at={a.createdAt} /> | |
| 386 | + </div> | |
| 387 | + </div> | |
| 388 | + </li> | |
| 389 | + ))} | |
| 390 | + </ul> | |
| 391 | + </div> | |
| 392 | + ) : null} | |
| 393 | + </div>, | |
| 394 | + ); | |
| 395 | + }); | |
| 396 | + | |
| 397 | + post(web, "/:owner/:repo/actions/runs/:runId/cancel", async (c) => { | |
| 398 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write"); | |
| 399 | + const back = `${repoUrl(l.repo)}/actions/runs/${prm(c, "runId")}`; | |
| 400 | + return act(c, back, async () => l.stub.cancelRun(prm(c, "runId"), l.caller), "Cancellation requested."); | |
| 401 | + }); | |
| 402 | + | |
| 403 | + post(web, "/:owner/:repo/actions/runs/:runId/rerun", async (c) => { | |
| 404 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write"); | |
| 405 | + const base = repoUrl(l.repo); | |
| 406 | + return act(c, `${base}/actions/runs/${prm(c, "runId")}`, async () => { | |
| 407 | + const run = await l.stub.rerun(prm(c, "runId"), l.caller); | |
| 408 | + return `${base}/actions/runs/${run.id}`; | |
| 409 | + }); | |
| 410 | + }); | |
| 411 | + | |
| 412 | + web.get("/:owner/:repo/actions/runs/:runId/job/:jobId", async (c) => { | |
| 413 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); | |
| 414 | + const { repo, caller, stub } = l; | |
| 415 | + const base = repoUrl(repo); | |
| 416 | + const runId = prm(c, "runId"); | |
| 417 | + const jobId = prm(c, "jobId"); | |
| 418 | + const run = await stub.run(runId); | |
| 419 | + const job = await stub.job(jobId); | |
| 420 | + if (job.runId !== run.id) throw new HttpError(404, "job not found"); | |
| 421 | + const logs = await allLogs(l, jobId); | |
| 422 | + const byStep = new Map<number, LogLine[]>(); | |
| 423 | + for (const line of logs.lines) byStep.set(line.step, [...(byStep.get(line.step) ?? []), line]); | |
| 424 | + const stepIdx = new Set<number>([...job.steps.map((s) => s.index), ...byStep.keys()]); | |
| 425 | + const sections = [...stepIdx].sort((a, b) => a - b); | |
| 426 | + const stepOf = new Map(job.steps.map((s) => [s.index, s])); | |
| 427 | + const lastSeq = logs.lines.length ? logs.lines[logs.lines.length - 1]!.seq : -1; | |
| 428 | + const live = job.status !== "completed"; | |
| 429 | + const failedStep = job.failure?.step; | |
| 430 | + return render( | |
| 431 | + c, | |
| 432 | + { title: `${job.name} · ${run.title || run.workflowName}`, repo: { repo, caller, tab: "actions" }, live: live ? `job:${job.id},run:${run.id}` : undefined, liveMode: "log", lastSeq, wide: true }, | |
| 433 | + <div class="grid left"> | |
| 434 | + <aside class="side"> | |
| 435 | + <section> | |
| 436 | + <a href={`${base}/actions/runs/${run.id}`} class="small"> | |
| 437 | + ← {run.workflowName} #{run.number} | |
| 438 | + </a> | |
| 439 | + <h3 class="mt">Jobs</h3> | |
| 440 | + <div class="nav"> | |
| 441 | + {run.jobs.map((j) => ( | |
| 442 | + <a href={`${base}/actions/runs/${run.id}/job/${j.id}`} class={j.id === job.id ? "on" : ""} style="display:flex;gap:6px;align-items:center"> | |
| 443 | + <Status status={j.status} conclusion={j.conclusion} /> <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{j.name}</span> | |
| 444 | + </a> | |
| 445 | + ))} | |
| 446 | + </div> | |
| 447 | + </section> | |
| 448 | + </aside> | |
| 449 | + <div> | |
| 450 | + <div class="row between mb"> | |
| 451 | + <h1 class="row m0" style="gap:8px"> | |
| 452 | + <Status status={job.status} conclusion={job.conclusion} /> {job.name} | |
| 453 | + </h1> | |
| 454 | + <div class="row"> | |
| 455 | + <span class="muted small"> | |
| 456 | + {job.startedAt ? ( | |
| 457 | + <> | |
| 458 | + {job.status === "completed" ? "took" : "running for"} {duration(job.startedAt, job.completedAt)} | |
| 459 | + </> | |
| 460 | + ) : ( | |
| 461 | + job.status | |
| 462 | + )} | |
| 463 | + </span> | |
| 464 | + <a class="btn sm" href={`${base}/actions/runs/${run.id}/job/${job.id}/logs`}> | |
| 465 | + {raw(ICONS.download)} Raw logs | |
| 466 | + </a> | |
| 467 | + </div> | |
| 468 | + </div> | |
| 469 | + {matrixLabel(job.matrix) ? <p class="muted small">Matrix: {matrixLabel(job.matrix)}</p> : null} | |
| 470 | + {job.failure ? ( | |
| 471 | + <div class="failure"> | |
| 472 | + <b> | |
| 473 | + {raw(ICONS.failure)} {job.conclusion === "cancelled" ? "Cancelled" : job.conclusion === "timed_out" ? "Timed out" : "Failed"} | |
| 474 | + {failedStep !== undefined && failedStep !== null ? ( | |
| 475 | + <> | |
| 476 | + {" "} | |
| 477 | + at{" "} | |
| 478 | + <a href={`#step-${failedStep}`} class="bad"> | |
| 479 | + {stepOf.get(failedStep)?.name ?? `step ${failedStep + 1}`} | |
| 480 | + </a> | |
| 481 | + </> | |
| 482 | + ) : null} | |
| 483 | + </b> | |
| 484 | + <pre>{job.failure.message}</pre> | |
| 485 | + </div> | |
| 486 | + ) : null} | |
| 487 | + {logs.truncated ? ( | |
| 488 | + <div class="flash info"> | |
| 489 | + Showing the first {MAX_LOG_LINES.toLocaleString("en-US")} lines. <a href={`${base}/actions/runs/${run.id}/job/${job.id}/logs`}>Download the full log</a>. | |
| 490 | + </div> | |
| 491 | + ) : null} | |
| 492 | + <div class="log"> | |
| 493 | + {sections.length === 0 ? <div style="padding:14px;color:#9aa3ad">{live ? "Waiting for the runner to start…" : "No log output."}</div> : null} | |
| 494 | + {sections.map((idx) => { | |
| 495 | + const s = stepOf.get(idx); | |
| 496 | + const lines = byStep.get(idx) ?? []; | |
| 497 | + const failed = s?.conclusion === "failure" || failedStep === idx; | |
| 498 | + const running = s?.status === "in_progress"; | |
| 499 | + return ( | |
| 500 | + <details data-step={String(idx)} id={`step-${idx}`} open={failed || running || (live && idx === sections[sections.length - 1])}> | |
| 501 | + <summary> | |
| 502 | + {s ? ( | |
| 503 | + <Status status={s.status} conclusion={s.conclusion} /> | |
| 504 | + ) : idx < 0 ? ( | |
| 505 | + <Status conclusion={job.startedAt ? "success" : "queued"} /> | |
| 506 | + ) : ( | |
| 507 | + <Status conclusion="neutral" /> | |
| 508 | + )} | |
| 509 | + {idx < 0 ? "Set up job" : (s?.name ?? `Step ${idx + 1}`)} | |
| 510 | + <span class="dur">{s?.startedAt ? duration(s.startedAt, s.completedAt) : ""}</span> | |
| 511 | + </summary> | |
| 512 | + <pre> | |
| 513 | + {lines.map((ln) => { | |
| 514 | + const k = classify(ln.text); | |
| 515 | + return <span class={k.cls || undefined}>{k.text}</span>; | |
| 516 | + })} | |
| 517 | + </pre> | |
| 518 | + </details> | |
| 519 | + ); | |
| 520 | + })} | |
| 521 | + </div> | |
| 522 | + {job.summary ? ( | |
| 523 | + <div class="box"> | |
| 524 | + <div class="head"> | |
| 525 | + <h3>Job summary</h3> | |
| 526 | + </div> | |
| 527 | + <div class="body"> | |
| 528 | + <Markdown text={job.summary} /> | |
| 529 | + </div> | |
| 530 | + </div> | |
| 531 | + ) : null} | |
| 532 | + {Object.keys(job.outputs ?? {}).length ? ( | |
| 533 | + <div class="box"> | |
| 534 | + <div class="head"> | |
| 535 | + <h3>Outputs</h3> | |
| 536 | + </div> | |
| 537 | + <div class="body"> | |
| 538 | + <dl class="kv"> | |
| 539 | + {Object.entries(job.outputs).map(([k, v]) => ( | |
| 540 | + <> | |
| 541 | + <dt class="mono">{k}</dt> | |
| 542 | + <dd class="mono" style="word-break:break-all"> | |
| 543 | + {v} | |
| 544 | + </dd> | |
| 545 | + </> | |
| 546 | + ))} | |
| 547 | + </dl> | |
| 548 | + </div> | |
| 549 | + </div> | |
| 550 | + ) : null} | |
| 551 | + </div> | |
| 552 | + </div>, | |
| 553 | + ); | |
| 554 | + }); | |
| 555 | + | |
| 556 | + web.get("/:owner/:repo/actions/runs/:runId/job/:jobId/logs", async (c) => { | |
| 557 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); | |
| 558 | + const job = await l.stub.job(prm(c, "jobId")); | |
| 559 | + if (job.runId !== prm(c, "runId")) throw new HttpError(404, "job not found"); | |
| 560 | + const out: string[] = []; | |
| 561 | + let after = -1; | |
| 562 | + for (let i = 0; i < 40; i++) { | |
| 563 | + const page = await l.stub.jobLogs(job.id, after); | |
| 564 | + for (const ln of page.lines) out.push(`${ln.t} ${ln.text.replace(ANSI, "")}`); | |
| 565 | + if (page.lines.length < 5000) break; | |
| 566 | + after = page.lines[page.lines.length - 1]!.seq; | |
| 567 | + } | |
| 568 | + return new Response(`${out.join("\n")}\n`, { | |
| 569 | + headers: { | |
| 570 | + "content-type": "text/plain; charset=utf-8", | |
| 571 | + "x-content-type-options": "nosniff", | |
| 572 | + "content-security-policy": "sandbox", | |
| 573 | + "content-disposition": `inline; filename*=UTF-8''${encodeURIComponent(`${job.name}.log`)}`, | |
| 574 | + "cache-control": "private, no-store", | |
| 575 | + }, | |
| 576 | + }); | |
| 577 | + }); | |
| 578 | +} |
addedapps/diffinery/src/web/code.tsx+1107−0View file
| @@ -1,0 +1,1107 @@ | ||
| 1 | +/** @jsxImportSource hono/jsx */ | |
| 2 | +import { raw } from "hono/html"; | |
| 3 | +import type { Child, FC } from "hono/jsx"; | |
| 4 | +import type { AppContext } from "../auth/principal.js"; | |
| 5 | +import { loadRepo } from "../http/common.js"; | |
| 6 | +import { HttpError } from "../lib/util.js"; | |
| 7 | +import { atLeast, globToRegExp } from "../repo/types.js"; | |
| 8 | +import { ICONS } from "./assets.js"; | |
| 9 | +import { csrfToken, type FormData } from "./csrf.js"; | |
| 10 | +import { | |
| 11 | + CommitRow, | |
| 12 | + CopyButton, | |
| 13 | + DiffStats, | |
| 14 | + DiffView, | |
| 15 | + FileSummary, | |
| 16 | + Form, | |
| 17 | + Markdown, | |
| 18 | + Pager, | |
| 19 | + RefSwitcher, | |
| 20 | + Time, | |
| 21 | + act, | |
| 22 | + cloneUrl, | |
| 23 | + encPath, | |
| 24 | + firstLine, | |
| 25 | + fmtBytes, | |
| 26 | + pageNum, | |
| 27 | + post, | |
| 28 | + qs, | |
| 29 | + refNames, | |
| 30 | + render, | |
| 31 | + repoUrl, | |
| 32 | + restOfPath, | |
| 33 | + shortSha, | |
| 34 | + splitRefPath, | |
| 35 | + statusOf, | |
| 36 | + Status, | |
| 37 | + type DiffFile, | |
| 38 | + type WebApp, | |
| 39 | + prm, | |
| 40 | +} from "./ui.js"; | |
| 41 | + | |
| 42 | +type Loaded = Awaited<ReturnType<typeof loadRepo>>; | |
| 43 | + | |
| 44 | +const IMAGE_TYPES: Record<string, string> = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", avif: "image/avif", ico: "image/x-icon", bmp: "image/bmp", svg: "image/svg+xml" }; | |
| 45 | +const MAX_RENDER = 1024 * 1024; | |
| 46 | + | |
| 47 | +const extOf = (path: string): string => (/\.([A-Za-z0-9]+)$/.exec(path)?.[1] ?? "").toLowerCase(); | |
| 48 | +const isMarkdown = (path: string): boolean => /\.(md|markdown|mdown|mkd)$/i.test(path); | |
| 49 | +const decoder = () => new TextDecoder(); | |
| 50 | + | |
| 51 | +/** Normalise a user-supplied repository path; null when invalid. */ | |
| 52 | +function cleanPath(p: string): string | null { | |
| 53 | + const segs = p.replace(/\\/g, "/").split("/").filter((s) => s && s !== "."); | |
| 54 | + if (!segs.length || segs.some((s) => s === ".." || s === ".git")) return null; | |
| 55 | + return segs.join("/"); | |
| 56 | +} | |
| 57 | + | |
| 58 | +const Crumbs: FC<{ base: string; ref: string; path: string; last?: boolean }> = ({ base, ref, path, last }) => { | |
| 59 | + const segs = path ? path.split("/") : []; | |
| 60 | + return ( | |
| 61 | + <div class="crumbs"> | |
| 62 | + <a href={`${base}/tree/${encPath(ref)}`}>{base.split("/")[2]}</a> | |
| 63 | + {segs.map((s, i) => ( | |
| 64 | + <> | |
| 65 | + <span class="faint">/</span> | |
| 66 | + {i === segs.length - 1 && last !== false ? <b>{s}</b> : <a href={`${base}/tree/${encPath(ref)}/${encPath(segs.slice(0, i + 1).join("/"))}`}>{s}</a>} | |
| 67 | + </> | |
| 68 | + ))} | |
| 69 | + </div> | |
| 70 | + ); | |
| 71 | +}; | |
| 72 | + | |
| 73 | +const CloneBox: FC<{ url: string }> = ({ url }) => ( | |
| 74 | + <div class="clone"> | |
| 75 | + <input type="text" readonly value={url} aria-label="Clone URL" /> | |
| 76 | + <CopyButton text={url} /> | |
| 77 | + </div> | |
| 78 | +); | |
| 79 | + | |
| 80 | +const QuickSetup: FC<{ c: AppContext; l: Loaded }> = ({ c, l }) => { | |
| 81 | + const url = cloneUrl(c, l.repo); | |
| 82 | + const b = l.repo.default_branch; | |
| 83 | + const base = repoUrl(l.repo); | |
| 84 | + const canWrite = atLeast(l.caller.role, "write"); | |
| 85 | + return ( | |
| 86 | + <div style="max-width:900px"> | |
| 87 | + <div class="box"> | |
| 88 | + <div class="head"> | |
| 89 | + <h2>Quick setup</h2> | |
| 90 | + <span class="muted small">This repository is empty.</span> | |
| 91 | + </div> | |
| 92 | + <div class="body"> | |
| 93 | + <CloneBox url={url} /> | |
| 94 | + {canWrite ? ( | |
| 95 | + <p class="mt muted"> | |
| 96 | + Get started by <a href={`${base}/new/${encPath(b)}?filename=README.md`}>creating a README on the web</a>, or push an existing repository from the command line. | |
| 97 | + </p> | |
| 98 | + ) : null} | |
| 99 | + <h3 class="mt">…create a new repository on the command line</h3> | |
| 100 | + <pre class="cmd">{`echo "# ${l.repo.name}" >> README.md\ngit init -b ${b}\ngit add README.md\ngit commit -m "first commit"\ngit remote add origin ${url}\ngit push -u origin ${b}`}</pre> | |
| 101 | + <h3>…or push an existing repository</h3> | |
| 102 | + <pre class="cmd">{`git remote add origin ${url}\ngit push -u origin ${b}`}</pre> | |
| 103 | + <h3>Authentication</h3> | |
| 104 | + <p class="muted"> | |
| 105 | + When Git asks for credentials, use your username and a <a href="/settings/tokens">personal access token</a> as the password. To avoid typing it every time, run <code>git config --global credential.helper store</code> (or use | |
| 106 | + your OS keychain helper). Git 2.26 or newer is required. | |
| 107 | + </p> | |
| 108 | + <p class="muted m0"> | |
| 109 | + Workflows in <code>.github/workflows/*.yml</code> run automatically on push. AI agents should use their own <a href="/settings/agents">agent account and token</a>. | |
| 110 | + </p> | |
| 111 | + </div> | |
| 112 | + </div> | |
| 113 | + </div> | |
| 114 | + ); | |
| 115 | +}; | |
| 116 | + | |
| 117 | +async function treePage(c: AppContext, l: Loaded, ref: string, path: string, root: boolean): Promise<Response> { | |
| 118 | + const { repo, caller, stub } = l; | |
| 119 | + const base = repoUrl(repo); | |
| 120 | + const [tree, names, stats, last] = await Promise.all([ | |
| 121 | + stub.tree(ref, path), | |
| 122 | + refNames(stub), | |
| 123 | + root && !path ? stub.stats() : Promise.resolve(null), | |
| 124 | + !path ? stub.log(ref, { limit: 1 }).catch(() => []) : Promise.resolve([]), | |
| 125 | + ]); | |
| 126 | + const counts = stats ? { issues: stats.openIssues, pulls: stats.openPulls } : undefined; | |
| 127 | + if (!tree) { | |
| 128 | + if (!names.branches.length && !names.tags.length) { | |
| 129 | + return render(c, { title: `${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "code", counts } }, <QuickSetup c={c} l={l} />); | |
| 130 | + } | |
| 131 | + const blob = path ? await stub.blob(ref, path).catch(() => null) : null; | |
| 132 | + if (blob) return c.redirect(`${base}/blob/${encPath(ref)}/${encPath(path)}`, 302); | |
| 133 | + throw new HttpError(404, "path not found"); | |
| 134 | + } | |
| 135 | + const readme = tree.items.find((i) => i.type === "blob" && /^readme(\.(md|markdown|txt))?$/i.test(i.name)); | |
| 136 | + const readmeBlob = readme && (readme.size ?? 0) < MAX_RENDER ? await stub.blob(ref, readme.path).catch(() => null) : null; | |
| 137 | + const head = last[0]; | |
| 138 | + const canWrite = atLeast(caller.role, "write") && names.branches.includes(ref); | |
| 139 | + const main = ( | |
| 140 | + <div> | |
| 141 | + <div class="row between mb"> | |
| 142 | + <div class="row"> | |
| 143 | + <RefSwitcher current={ref} branches={names.branches} tags={names.tags} to={(r) => `${base}/tree/${encPath(r)}${path ? `/${encPath(path)}` : ""}`} /> | |
| 144 | + {path ? <Crumbs base={base} ref={ref} path={path} /> : null} | |
| 145 | + {!path ? ( | |
| 146 | + <span class="muted small"> | |
| 147 | + <a href={`${base}/branches`}> | |
| 148 | + {raw(ICONS.branch)} {names.branches.length} branch{names.branches.length === 1 ? "" : "es"} | |
| 149 | + </a>{" "} | |
| 150 | + ·{" "} | |
| 151 | + <a href={`${base}/tags`}> | |
| 152 | + {raw(ICONS.tag)} {names.tags.length} tag{names.tags.length === 1 ? "" : "s"} | |
| 153 | + </a> | |
| 154 | + </span> | |
| 155 | + ) : null} | |
| 156 | + </div> | |
| 157 | + <div class="row"> | |
| 158 | + {canWrite ? ( | |
| 159 | + <a class="btn sm" href={`${base}/new/${encPath(ref)}${path ? `/${encPath(path)}` : ""}`}> | |
| 160 | + {raw(ICONS.plus)} Add file | |
| 161 | + </a> | |
| 162 | + ) : null} | |
| 163 | + <a class="btn sm" href={`${base}/commits/${encPath(ref)}${path ? qs({ path }) : ""}`}> | |
| 164 | + {raw(ICONS.history)} History | |
| 165 | + </a> | |
| 166 | + </div> | |
| 167 | + </div> | |
| 168 | + <div class="box"> | |
| 169 | + {head ? ( | |
| 170 | + <div class="head small"> | |
| 171 | + <b>{head.author.name}</b> | |
| 172 | + <a href={`${base}/commit/${head.sha}`} class="grow" style="color:var(--fg);overflow:hidden;text-overflow:ellipsis;white-space:nowrap"> | |
| 173 | + {firstLine(head.message)} | |
| 174 | + </a> | |
| 175 | + <a class="sha" href={`${base}/commit/${head.sha}`}> | |
| 176 | + {shortSha(head.sha)} | |
| 177 | + </a> | |
| 178 | + <Time at={new Date(head.committer.timestamp * 1000).toISOString()} /> | |
| 179 | + </div> | |
| 180 | + ) : null} | |
| 181 | + <table class="files"> | |
| 182 | + {path ? ( | |
| 183 | + <tr> | |
| 184 | + <td class="ic" /> | |
| 185 | + <td colspan={2}> | |
| 186 | + <a href={`${base}/tree/${encPath(ref)}${path.includes("/") ? `/${encPath(path.split("/").slice(0, -1).join("/"))}` : ""}`}>..</a> | |
| 187 | + </td> | |
| 188 | + </tr> | |
| 189 | + ) : null} | |
| 190 | + {tree.items.map((it) => ( | |
| 191 | + <tr> | |
| 192 | + <td class={`ic ${it.type === "tree" ? "ic-dir" : ""}`}>{raw(it.type === "tree" ? ICONS.dir : it.type === "commit" ? ICONS.submodule : ICONS.file)}</td> | |
| 193 | + <td> | |
| 194 | + {it.type === "commit" ? ( | |
| 195 | + <span> | |
| 196 | + {it.name} <span class="faint mono small">@ {shortSha(it.oid)}</span> | |
| 197 | + </span> | |
| 198 | + ) : ( | |
| 199 | + <a href={`${base}/${it.type === "tree" ? "tree" : "blob"}/${encPath(ref)}/${encPath(it.path)}`}>{it.name}</a> | |
| 200 | + )} | |
| 201 | + </td> | |
| 202 | + <td class="sz">{it.type === "blob" ? fmtBytes(it.size) : ""}</td> | |
| 203 | + </tr> | |
| 204 | + ))} | |
| 205 | + </table> | |
| 206 | + {tree.items.length === 0 ? <div class="empty">This directory is empty.</div> : null} | |
| 207 | + </div> | |
| 208 | + {readmeBlob && !readmeBlob.binary ? ( | |
| 209 | + <div class="box readme"> | |
| 210 | + <div class="head"> | |
| 211 | + {raw(ICONS.file)} <b>{readme!.name}</b> | |
| 212 | + </div> | |
| 213 | + <div class="body"> | |
| 214 | + {isMarkdown(readme!.name) ? ( | |
| 215 | + <Markdown text={decoder().decode(readmeBlob.content)} base={{ owner: repo.owner_login, repo: repo.name, ref, dir: path }} /> | |
| 216 | + ) : ( | |
| 217 | + <pre style="white-space:pre-wrap;margin:0">{decoder().decode(readmeBlob.content)}</pre> | |
| 218 | + )} | |
| 219 | + </div> | |
| 220 | + </div> | |
| 221 | + ) : null} | |
| 222 | + </div> | |
| 223 | + ); | |
| 224 | + if (path) return render(c, { title: `${path} at ${ref} · ${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "code", ref } }, main); | |
| 225 | + return render( | |
| 226 | + c, | |
| 227 | + { title: `${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "code", ref, counts } }, | |
| 228 | + <div class="grid"> | |
| 229 | + {main} | |
| 230 | + <aside class="side"> | |
| 231 | + <section> | |
| 232 | + <h3>Clone</h3> | |
| 233 | + <CloneBox url={cloneUrl(c, repo)} /> | |
| 234 | + <p class="small muted" style="margin-top:6px"> | |
| 235 | + Use a <a href="/settings/tokens">token</a> as the password. | |
| 236 | + </p> | |
| 237 | + </section> | |
| 238 | + <section> | |
| 239 | + <h3>About</h3> | |
| 240 | + <p class={repo.description ? "" : "muted"}>{repo.description || "No description."}</p> | |
| 241 | + {stats ? ( | |
| 242 | + <dl class="kv"> | |
| 243 | + <dt>Default branch</dt> | |
| 244 | + <dd class="mono">{repo.default_branch}</dd> | |
| 245 | + <dt>Open issues</dt> | |
| 246 | + <dd> | |
| 247 | + <a href={`${base}/issues`}>{stats.openIssues}</a> | |
| 248 | + </dd> | |
| 249 | + <dt>Open pull requests</dt> | |
| 250 | + <dd> | |
| 251 | + <a href={`${base}/pulls`}>{stats.openPulls}</a> | |
| 252 | + </dd> | |
| 253 | + <dt>Refs</dt> | |
| 254 | + <dd>{stats.refs}</dd> | |
| 255 | + <dt>Objects</dt> | |
| 256 | + <dd>{stats.objects.toLocaleString("en-US")}</dd> | |
| 257 | + <dt>Size</dt> | |
| 258 | + <dd>{fmtBytes(stats.bytes)}</dd> | |
| 259 | + <dt>Last push</dt> | |
| 260 | + <dd>{repo.pushed_at ? <Time at={repo.pushed_at} /> : "never"}</dd> | |
| 261 | + </dl> | |
| 262 | + ) : null} | |
| 263 | + </section> | |
| 264 | + <section> | |
| 265 | + <h3>Shortcuts</h3> | |
| 266 | + <p class="small muted m0"> | |
| 267 | + <kbd>Ctrl</kbd> <kbd>K</kbd> jump anywhere · <kbd>t</kbd> find a file · <kbd>g</kbd> <kbd>i</kbd> issues · <kbd>g</kbd> <kbd>p</kbd> pull requests · <kbd>g</kbd> <kbd>a</kbd> actions | |
| 268 | + </p> | |
| 269 | + </section> | |
| 270 | + </aside> | |
| 271 | + </div>, | |
| 272 | + ); | |
| 273 | +} | |
| 274 | + | |
| 275 | +// ---------------------------------------------------------------- editor | |
| 276 | + | |
| 277 | +interface EditorValues { | |
| 278 | + path: string; | |
| 279 | + content: string; | |
| 280 | + message: string; | |
| 281 | + description: string; | |
| 282 | + choice: "direct" | "branch"; | |
| 283 | + newBranch: string; | |
| 284 | + head: string; | |
| 285 | +} | |
| 286 | + | |
| 287 | +const Editor: FC<{ c: AppContext; l: Loaded; ref: string; mode: "edit" | "new"; orig: string; dir: string; v: EditorValues; error?: string; canDirect: boolean }> = ({ c, l, ref, mode, orig, dir, v, error, canDirect }) => { | |
| 288 | + const base = repoUrl(l.repo); | |
| 289 | + const t = csrfToken(c); | |
| 290 | + const action = mode === "edit" ? `${base}/edit/${encPath(ref)}/${encPath(orig)}` : `${base}/new/${encPath(ref)}${dir ? `/${encPath(dir)}` : ""}`; | |
| 291 | + return ( | |
| 292 | + <div> | |
| 293 | + {error ? <div class="flash error">{error}</div> : null} | |
| 294 | + <Form action={action} t={t}> | |
| 295 | + <input type="hidden" name="head" value={v.head} /> | |
| 296 | + <div class="row mb"> | |
| 297 | + <span class="badge">{raw(ICONS.branch)} {ref}</span> | |
| 298 | + <div class="grow"> | |
| 299 | + <input type="text" name="path" value={v.path} required placeholder="path/to/file.ext" aria-label="File path" class="mono" /> | |
| 300 | + </div> | |
| 301 | + {mode === "edit" ? ( | |
| 302 | + <a class="btn sm" href={`${base}/blob/${encPath(ref)}/${encPath(orig)}`}> | |
| 303 | + Cancel | |
| 304 | + </a> | |
| 305 | + ) : null} | |
| 306 | + </div> | |
| 307 | + <div class="box"> | |
| 308 | + <textarea name="content" class="code" spellcheck={false} style="border:0;border-radius:0;min-height:480px" aria-label="File contents"> | |
| 309 | + {`\n${v.content}`} | |
| 310 | + </textarea> | |
| 311 | + </div> | |
| 312 | + <div class="box"> | |
| 313 | + <div class="head"> | |
| 314 | + <h3>Commit changes</h3> | |
| 315 | + </div> | |
| 316 | + <div class="body"> | |
| 317 | + <div class="field"> | |
| 318 | + <input type="text" name="message" value={v.message} required aria-label="Commit message" /> | |
| 319 | + </div> | |
| 320 | + <div class="field"> | |
| 321 | + <textarea name="description" placeholder="Add an optional extended description…" style="min-height:70px"> | |
| 322 | + {v.description} | |
| 323 | + </textarea> | |
| 324 | + </div> | |
| 325 | + <div class="field"> | |
| 326 | + {canDirect ? ( | |
| 327 | + <label class="inline" style="margin-bottom:6px"> | |
| 328 | + <input type="radio" name="choice" value="direct" checked={v.choice === "direct"} /> Commit directly to the <code>{ref}</code> branch | |
| 329 | + </label> | |
| 330 | + ) : ( | |
| 331 | + <p class="muted small"> | |
| 332 | + {raw(ICONS.shield)} <code>{ref}</code> is protected; your change goes to a new branch and a pull request. | |
| 333 | + </p> | |
| 334 | + )} | |
| 335 | + <label class="inline"> | |
| 336 | + <input type="radio" name="choice" value="branch" checked={v.choice === "branch" || !canDirect} /> Create a new branch for this commit and start a pull request: | |
| 337 | + </label> | |
| 338 | + <input type="text" name="new_branch" value={v.newBranch} style="max-width:320px;margin:6px 0 0 24px" class="mono" aria-label="New branch name" /> | |
| 339 | + </div> | |
| 340 | + <button class="btn primary">Commit changes</button> | |
| 341 | + </div> | |
| 342 | + </div> | |
| 343 | + </Form> | |
| 344 | + {mode === "edit" ? ( | |
| 345 | + <Form action={action} t={t} class="box" confirm={`Delete ${orig}?`}> | |
| 346 | + <input type="hidden" name="_delete" value="1" /> | |
| 347 | + <input type="hidden" name="head" value={v.head} /> | |
| 348 | + <div class="body row between"> | |
| 349 | + <span class="muted">Delete this file in a new commit on {ref}.</span> | |
| 350 | + <button class="btn sm danger">Delete file</button> | |
| 351 | + </div> | |
| 352 | + </Form> | |
| 353 | + ) : null} | |
| 354 | + </div> | |
| 355 | + ); | |
| 356 | +}; | |
| 357 | + | |
| 358 | +// ---------------------------------------------------------------- compare and pull request creation | |
| 359 | + | |
| 360 | +export interface PullFormValues { | |
| 361 | + title: string; | |
| 362 | + body: string; | |
| 363 | + intent: string; | |
| 364 | + draft: boolean; | |
| 365 | +} | |
| 366 | + | |
| 367 | +async function comparePage(c: AppContext, l: Loaded, baseRef: string, headRef: string, form?: { values: PullFormValues; error: string }): Promise<Response> { | |
| 368 | + const { repo, caller, stub } = l; | |
| 369 | + const base = repoUrl(repo); | |
| 370 | + const t = csrfToken(c); | |
| 371 | + const names = await refNames(stub); | |
| 372 | + const all = [...names.branches, ...names.tags.filter((x) => !names.branches.includes(x))]; | |
| 373 | + let cmp: Awaited<ReturnType<typeof stub.compare>> | null = null; | |
| 374 | + let error: string | null = null; | |
| 375 | + try { | |
| 376 | + cmp = await stub.compare(baseRef, headRef); | |
| 377 | + } catch (e) { | |
| 378 | + const s = statusOf(e); | |
| 379 | + if (s.status >= 500) throw e; | |
| 380 | + error = s.message; | |
| 381 | + } | |
| 382 | + const bothBranches = names.branches.includes(baseRef) && names.branches.includes(headRef); | |
| 383 | + const canPr = c.get("principal").type === "account" && atLeast(caller.role, "write") && bothBranches && !!cmp && cmp.aheadBy > 0; | |
| 384 | + let existing: number | null = null; | |
| 385 | + if (canPr) { | |
| 386 | + const open = await stub.issues({ kind: "pull", state: "open", limit: 100 }); | |
| 387 | + existing = open.items.find((i) => i.pull?.head === headRef && i.pull?.base === baseRef)?.number ?? null; | |
| 388 | + } | |
| 389 | + const defaultTitle = cmp && cmp.commits.length === 1 ? firstLine(cmp.commits[0]!.message) : headRef.replace(/[-_/]+/g, " ").replace(/^\w/, (m) => m.toUpperCase()); | |
| 390 | + const v = form?.values ?? { title: defaultTitle, body: cmp && cmp.commits.length === 1 ? cmp.commits[0]!.message.split("\n").slice(1).join("\n").trim() : "", intent: "", draft: false }; | |
| 391 | + return render( | |
| 392 | + c, | |
| 393 | + { title: `Comparing ${baseRef}...${headRef}`, repo: { repo, caller, tab: "pulls", ref: headRef }, wide: !!cmp && cmp.files.length > 0 }, | |
| 394 | + <div> | |
| 395 | + <h1>Compare changes</h1> | |
| 396 | + <p class="muted">Choose two branches to see what changed or to start a new pull request.</p> | |
| 397 | + <form method="get" action={`${base}/compare`} class="box"> | |
| 398 | + <div class="body row"> | |
| 399 | + {raw(ICONS.branch)} | |
| 400 | + <label class="inline"> | |
| 401 | + base: | |
| 402 | + <select name="base" style="width:auto"> | |
| 403 | + {all.map((n) => ( | |
| 404 | + <option value={n} selected={n === baseRef}> | |
| 405 | + {n} | |
| 406 | + </option> | |
| 407 | + ))} | |
| 408 | + </select> | |
| 409 | + </label> | |
| 410 | + <span class="faint">←</span> | |
| 411 | + <label class="inline"> | |
| 412 | + compare: | |
| 413 | + <select name="head" style="width:auto"> | |
| 414 | + {all.map((n) => ( | |
| 415 | + <option value={n} selected={n === headRef}> | |
| 416 | + {n} | |
| 417 | + </option> | |
| 418 | + ))} | |
| 419 | + </select> | |
| 420 | + </label> | |
| 421 | + <button class="btn sm">Compare</button> | |
| 422 | + {cmp ? ( | |
| 423 | + <span class="muted small" style="margin-left:auto"> | |
| 424 | + {cmp.aheadBy} commit{cmp.aheadBy === 1 ? "" : "s"} ahead, {cmp.behindBy} behind {baseRef} | |
| 425 | + </span> | |
| 426 | + ) : null} | |
| 427 | + </div> | |
| 428 | + </form> | |
| 429 | + {error ? <div class="flash error">{error}</div> : null} | |
| 430 | + {cmp && cmp.aheadBy === 0 ? ( | |
| 431 | + <div class="blank mb"> | |
| 432 | + <h3>There isn't anything to compare.</h3> | |
| 433 | + <p class="m0"> | |
| 434 | + <b>{baseRef}</b> is up to date with all commits from <b>{headRef}</b>. | |
| 435 | + </p> | |
| 436 | + </div> | |
| 437 | + ) : null} | |
| 438 | + {existing ? ( | |
| 439 | + <div class="flash info"> | |
| 440 | + A pull request for these branches already exists:{" "} | |
| 441 | + <a href={`${base}/pull/${existing}`}> | |
| 442 | + #{existing} | |
| 443 | + </a> | |
| 444 | + </div> | |
| 445 | + ) : canPr ? ( | |
| 446 | + <Form action={`${base}/pulls`} t={t} class="box" style="max-width:980px"> | |
| 447 | + <input type="hidden" name="base" value={baseRef} /> | |
| 448 | + <input type="hidden" name="head" value={headRef} /> | |
| 449 | + <div class="head"> | |
| 450 | + <h2>Open a pull request</h2> | |
| 451 | + <span class="muted small"> | |
| 452 | + {headRef} → {baseRef} | |
| 453 | + </span> | |
| 454 | + </div> | |
| 455 | + <div class="body"> | |
| 456 | + {form?.error ? <div class="flash error">{form.error}</div> : null} | |
| 457 | + <div class="field"> | |
| 458 | + <label for="pt">Title</label> | |
| 459 | + <input id="pt" name="title" type="text" required value={v.title} /> | |
| 460 | + </div> | |
| 461 | + <div class="field"> | |
| 462 | + <label for="pi">Intent (what this change is meant to do — helps reviewers and AI reviewers)</label> | |
| 463 | + <textarea id="pi" name="intent" style="min-height:80px" placeholder="e.g. Fix the race in token refresh without changing the public API."> | |
| 464 | + {v.intent} | |
| 465 | + </textarea> | |
| 466 | + </div> | |
| 467 | + <div class="field"> | |
| 468 | + <label for="pb">Description</label> | |
| 469 | + <textarea id="pb" name="body" placeholder="Markdown is supported."> | |
| 470 | + {v.body} | |
| 471 | + </textarea> | |
| 472 | + </div> | |
| 473 | + <div class="row between"> | |
| 474 | + <label class="inline"> | |
| 475 | + <input type="checkbox" name="draft" value="1" checked={v.draft} /> Create as draft | |
| 476 | + </label> | |
| 477 | + <button class="btn green">Create pull request</button> | |
| 478 | + </div> | |
| 479 | + </div> | |
| 480 | + </Form> | |
| 481 | + ) : null} | |
| 482 | + {cmp && cmp.commits.length ? ( | |
| 483 | + <div class="box"> | |
| 484 | + <div class="head"> | |
| 485 | + <h3> | |
| 486 | + {raw(ICONS.commit)} {cmp.commits.length} commit{cmp.commits.length === 1 ? "" : "s"} | |
| 487 | + </h3> | |
| 488 | + </div> | |
| 489 | + <ul class="list"> | |
| 490 | + {cmp.commits.map((cm) => ( | |
| 491 | + <CommitRow base={base} sha={cm.sha} message={cm.message} author={cm.author} signed={cm.signed} /> | |
| 492 | + ))} | |
| 493 | + </ul> | |
| 494 | + </div> | |
| 495 | + ) : null} | |
| 496 | + {cmp && cmp.files.length ? ( | |
| 497 | + <> | |
| 498 | + <div class="row between mb"> | |
| 499 | + <DiffStats files={cmp.totalFiles} additions={cmp.additions} deletions={cmp.deletions} /> | |
| 500 | + {cmp.truncated ? <span class="badge amber">showing the first {cmp.files.length} files</span> : null} | |
| 501 | + </div> | |
| 502 | + <FileSummary files={cmp.files as DiffFile[]} /> | |
| 503 | + <DiffView files={cmp.files as DiffFile[]} opts={{ viewHref: (p) => `${base}/blob/${encPath(headRef)}/${encPath(p)}` }} /> | |
| 504 | + </> | |
| 505 | + ) : null} | |
| 506 | + </div>, | |
| 507 | + ); | |
| 508 | +} | |
| 509 | + | |
| 510 | +// ---------------------------------------------------------------- routes | |
| 511 | + | |
| 512 | +export function registerCode(web: WebApp): void { | |
| 513 | + web.get("/:owner/:repo", async (c) => { | |
| 514 | + const name = prm(c, "repo"); | |
| 515 | + if (name.endsWith(".git")) return c.redirect(`/${prm(c, "owner")}/${name.slice(0, -4)}`, 301); | |
| 516 | + const l = await loadRepo(c, prm(c, "owner"), name, "read"); | |
| 517 | + if (l.repo.owner_login !== prm(c, "owner") || l.repo.name !== name) return c.redirect(repoUrl(l.repo), 301); | |
| 518 | + return treePage(c, l, l.repo.default_branch, "", true); | |
| 519 | + }); | |
| 520 | + | |
| 521 | + web.get("/:owner/:repo/tree/*", async (c) => { | |
| 522 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); | |
| 523 | + const rest = restOfPath(c, 3); | |
| 524 | + if (!rest) return c.redirect(repoUrl(l.repo), 302); | |
| 525 | + const { ref, path } = await splitRefPath(l.stub, rest); | |
| 526 | + return treePage(c, l, ref, path, !path); | |
| 527 | + }); | |
| 528 | + | |
| 529 | + web.get("/:owner/:repo/blob/*", async (c) => { | |
| 530 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); | |
| 531 | + const { repo, caller, stub } = l; | |
| 532 | + const base = repoUrl(repo); | |
| 533 | + const { ref, path } = await splitRefPath(stub, restOfPath(c, 3)); | |
| 534 | + if (!path) return c.redirect(`${base}/tree/${encPath(ref)}`, 302); | |
| 535 | + const [blob, names] = await Promise.all([stub.blob(ref, path), refNames(stub)]); | |
| 536 | + if (!blob) { | |
| 537 | + const tree = await stub.tree(ref, path); | |
| 538 | + if (tree) return c.redirect(`${base}/tree/${encPath(ref)}/${encPath(path)}`, 302); | |
| 539 | + throw new HttpError(404, "file not found"); | |
| 540 | + } | |
| 541 | + const ext = extOf(path); | |
| 542 | + const rawUrl = `${base}/raw/${encPath(ref)}/${encPath(path)}`; | |
| 543 | + const plain = c.req.query("plain") === "1"; | |
| 544 | + const canEdit = atLeast(caller.role, "write") && names.branches.includes(ref) && !blob.binary && blob.size <= MAX_RENDER && !repo.archived; | |
| 545 | + let content: Child; | |
| 546 | + let lineCount = 0; | |
| 547 | + if (IMAGE_TYPES[ext] && ext !== "svg") { | |
| 548 | + content = ( | |
| 549 | + <div class="body" style="text-align:center;background:var(--bg2)"> | |
| 550 | + <img src={rawUrl} alt={path} style="max-width:100%" /> | |
| 551 | + </div> | |
| 552 | + ); | |
| 553 | + } else if (blob.binary) { | |
| 554 | + content = ( | |
| 555 | + <div class="empty"> | |
| 556 | + Binary file, {fmtBytes(blob.size)} ({blob.size.toLocaleString("en-US")} bytes).{" "} | |
| 557 | + <a class="btn sm" href={rawUrl}> | |
| 558 | + {raw(ICONS.download)} Download | |
| 559 | + </a> | |
| 560 | + </div> | |
| 561 | + ); | |
| 562 | + } else if (blob.size > MAX_RENDER) { | |
| 563 | + content = ( | |
| 564 | + <div class="empty"> | |
| 565 | + This file is too large to display ({fmtBytes(blob.size)}). <a href={rawUrl}>View raw</a> | |
| 566 | + </div> | |
| 567 | + ); | |
| 568 | + } else { | |
| 569 | + const text = decoder().decode(blob.content); | |
| 570 | + const lines = text.split("\n"); | |
| 571 | + if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop(); | |
| 572 | + lineCount = lines.length; | |
| 573 | + if (isMarkdown(path) && !plain) { | |
| 574 | + content = ( | |
| 575 | + <div class="body readme" style="margin:0;padding:24px 32px"> | |
| 576 | + <Markdown text={text} base={{ owner: repo.owner_login, repo: repo.name, ref, dir: path.split("/").slice(0, -1).join("/") }} empty="Empty file." /> | |
| 577 | + </div> | |
| 578 | + ); | |
| 579 | + } else { | |
| 580 | + content = ( | |
| 581 | + <div class="code-wrap"> | |
| 582 | + <table class="blob"> | |
| 583 | + {lines.map((line, i) => ( | |
| 584 | + <tr id={`L${i + 1}`}> | |
| 585 | + <td class="ln"> | |
| 586 | + <a href={`#L${i + 1}`}>{i + 1}</a> | |
| 587 | + </td> | |
| 588 | + <td>{line.replace(/\r$/, "")}</td> | |
| 589 | + </tr> | |
| 590 | + ))} | |
| 591 | + </table> | |
| 592 | + </div> | |
| 593 | + ); | |
| 594 | + } | |
| 595 | + } | |
| 596 | + return render( | |
| 597 | + c, | |
| 598 | + { title: `${path} at ${ref} · ${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "code", ref } }, | |
| 599 | + <div> | |
| 600 | + <div class="row between mb"> | |
| 601 | + <div class="row"> | |
| 602 | + <RefSwitcher current={ref} branches={names.branches} tags={names.tags} to={(r) => `${base}/blob/${encPath(r)}/${encPath(path)}`} /> | |
| 603 | + <Crumbs base={base} ref={ref} path={path} /> | |
| 604 | + <CopyButton text={path} label="Copy path" /> | |
| 605 | + </div> | |
| 606 | + </div> | |
| 607 | + <div class="box"> | |
| 608 | + <div class="head small"> | |
| 609 | + <span class="muted"> | |
| 610 | + {lineCount ? `${lineCount} line${lineCount === 1 ? "" : "s"} · ` : ""} | |
| 611 | + {fmtBytes(blob.size)} | |
| 612 | + {blob.mode === "100755" ? " · executable" : ""} | |
| 613 | + </span> | |
| 614 | + <span class="grow" /> | |
| 615 | + {isMarkdown(path) && !blob.binary ? ( | |
| 616 | + <div class="subnav"> | |
| 617 | + <a href={`?`} class={!plain ? "on" : ""}> | |
| 618 | + Preview | |
| 619 | + </a> | |
| 620 | + <a href={`?plain=1`} class={plain ? "on" : ""}> | |
| 621 | + Code | |
| 622 | + </a> | |
| 623 | + </div> | |
| 624 | + ) : null} | |
| 625 | + <a class="btn sm" href={rawUrl}> | |
| 626 | + Raw | |
| 627 | + </a> | |
| 628 | + <a class="btn sm" href={`${base}/commits/${encPath(ref)}${qs({ path })}`}> | |
| 629 | + {raw(ICONS.history)} History | |
| 630 | + </a> | |
| 631 | + {canEdit ? ( | |
| 632 | + <a class="btn sm" href={`${base}/edit/${encPath(ref)}/${encPath(path)}`}> | |
| 633 | + {raw(ICONS.edit)} Edit | |
| 634 | + </a> | |
| 635 | + ) : null} | |
| 636 | + </div> | |
| 637 | + {content} | |
| 638 | + </div> | |
| 639 | + </div>, | |
| 640 | + ); | |
| 641 | + }); | |
| 642 | + | |
| 643 | + web.get("/:owner/:repo/raw/*", async (c) => { | |
| 644 | + const { stub } = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); | |
| 645 | + const { ref, path } = await splitRefPath(stub, restOfPath(c, 3)); | |
| 646 | + const blob = path ? await stub.blob(ref, path) : null; | |
| 647 | + if (!blob) throw new HttpError(404, "file not found"); | |
| 648 | + const ext = extOf(path); | |
| 649 | + const type = IMAGE_TYPES[ext] ?? (blob.binary ? "application/octet-stream" : "text/plain; charset=utf-8"); | |
| 650 | + const name = path.split("/").pop() ?? "file"; | |
| 651 | + const headers: Record<string, string> = { | |
| 652 | + "content-type": type, | |
| 653 | + "x-content-type-options": "nosniff", | |
| 654 | + "content-security-policy": "sandbox; default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'", | |
| 655 | + "cache-control": /^[0-9a-f]{40}$/.test(ref) ? "private, max-age=86400, immutable" : "private, max-age=60", | |
| 656 | + "content-disposition": `${type === "application/octet-stream" ? "attachment" : "inline"}; filename*=UTF-8''${encodeURIComponent(name)}`, | |
| 657 | + "x-frame-options": "DENY", | |
| 658 | + }; | |
| 659 | + return new Response(blob.content, { headers }); | |
| 660 | + }); | |
| 661 | + | |
| 662 | + // Editor (edit an existing file / create a new one). | |
| 663 | + const editorGet = async (c: AppContext, mode: "edit" | "new") => { | |
| 664 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write"); | |
| 665 | + const { repo, caller, stub } = l; | |
| 666 | + const base = repoUrl(repo); | |
| 667 | + const names = await refNames(stub); | |
| 668 | + const empty = !names.branches.length && !names.tags.length; | |
| 669 | + const rest = restOfPath(c, 3); | |
| 670 | + const split = empty ? { ref: rest.split("/")[0] || repo.default_branch, path: rest.split("/").slice(1).join("/") } : await splitRefPath(stub, rest); | |
| 671 | + const ref = split.ref; | |
| 672 | + if (!empty && !names.branches.includes(ref)) throw new HttpError(422, "files can only be edited on a branch"); | |
| 673 | + const meName = caller.login; | |
| 674 | + const patchBranch = `${meName}-patch-${Math.floor(Date.now() / 1000) % 100000}`; | |
| 675 | + const canDirect = await canCommitDirect(l, ref); | |
| 676 | + if (mode === "edit") { | |
| 677 | + const blob = split.path ? await stub.blob(ref, split.path) : null; | |
| 678 | + if (!blob) throw new HttpError(404, "file not found"); | |
| 679 | + if (blob.binary) throw new HttpError(422, "binary files cannot be edited in the browser"); | |
| 680 | + return render( | |
| 681 | + c, | |
| 682 | + { title: `Editing ${split.path}`, repo: { repo, caller, tab: "code", ref } }, | |
| 683 | + <Editor | |
| 684 | + c={c} | |
| 685 | + l={l} | |
| 686 | + ref={ref} | |
| 687 | + mode="edit" | |
| 688 | + orig={split.path} | |
| 689 | + dir="" | |
| 690 | + canDirect={canDirect} | |
| 691 | + v={{ path: split.path, content: decoder().decode(blob.content), message: `Update ${split.path.split("/").pop()}`, description: "", choice: canDirect ? "direct" : "branch", newBranch: patchBranch, head: blob.sha }} | |
| 692 | + />, | |
| 693 | + ); | |
| 694 | + } | |
| 695 | + const headSha = empty ? "" : ((await stub.resolve(ref)) ?? ""); | |
| 696 | + const filename = c.req.query("filename") ?? ""; | |
| 697 | + const dir = split.path; | |
| 698 | + return render( | |
| 699 | + c, | |
| 700 | + { title: `New file · ${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "code", ref } }, | |
| 701 | + <> | |
| 702 | + <p class="muted"> | |
| 703 | + Create a new file in <a href={`${base}/tree/${encPath(ref)}${dir ? `/${encPath(dir)}` : ""}`}>{dir || repo.name}</a>. | |
| 704 | + </p> | |
| 705 | + <Editor | |
| 706 | + c={c} | |
| 707 | + l={l} | |
| 708 | + ref={ref} | |
| 709 | + mode="new" | |
| 710 | + orig="" | |
| 711 | + dir={dir} | |
| 712 | + canDirect={canDirect || empty} | |
| 713 | + v={{ path: `${dir ? `${dir}/` : ""}${cleanPath(filename) ?? ""}`, content: "", message: filename ? `Create ${filename}` : "Create new file", description: "", choice: canDirect || empty ? "direct" : "branch", newBranch: patchBranch, head: headSha }} | |
| 714 | + /> | |
| 715 | + </>, | |
| 716 | + ); | |
| 717 | + }; | |
| 718 | + | |
| 719 | + const editorPost = async (c: AppContext, mode: "edit" | "new", f: FormData) => { | |
| 720 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write"); | |
| 721 | + const { repo, caller, stub } = l; | |
| 722 | + const base = repoUrl(repo); | |
| 723 | + const names = await refNames(stub); | |
| 724 | + const empty = !names.branches.length && !names.tags.length; | |
| 725 | + const rest = restOfPath(c, 3); | |
| 726 | + const split = empty ? { ref: rest.split("/")[0] || repo.default_branch, path: rest.split("/").slice(1).join("/") } : await splitRefPath(stub, rest); | |
| 727 | + const ref = split.ref; | |
| 728 | + const orig = mode === "edit" ? split.path : ""; | |
| 729 | + const head = f.s("head"); | |
| 730 | + | |
| 731 | + if (mode === "edit" && f.b("_delete")) { | |
| 732 | + return act(c, `${base}/blob/${encPath(ref)}/${encPath(orig)}`, async () => { | |
| 733 | + await stub.commitFiles({ branch: ref, message: `Delete ${orig}`, changes: [{ path: orig, delete: true }], expectedHeadSha: head || undefined }, caller); | |
| 734 | + const parent = orig.split("/").slice(0, -1).join("/"); | |
| 735 | + return `${base}/tree/${encPath(ref)}${parent ? `/${encPath(parent)}` : ""}`; | |
| 736 | + }); | |
| 737 | + } | |
| 738 | + | |
| 739 | + let content = f.s("content"); | |
| 740 | + if (content.startsWith("\r\n")) content = content.slice(2); | |
| 741 | + else if (content.startsWith("\n")) content = content.slice(1); | |
| 742 | + let mode100: "100644" | "100755" | "120000" | undefined; | |
| 743 | + if (mode === "edit") { | |
| 744 | + const old = await stub.blob(ref, orig); | |
| 745 | + const crlf = !!old && decoder().decode(old.content.slice(0, 65536)).includes("\r\n"); | |
| 746 | + if (!crlf) content = content.replace(/\r\n/g, "\n"); | |
| 747 | + if (old && (old.mode === "100755" || old.mode === "120000")) mode100 = old.mode; | |
| 748 | + } else content = content.replace(/\r\n/g, "\n"); | |
| 749 | + const values: EditorValues = { | |
| 750 | + path: f.s("path"), | |
| 751 | + content, | |
| 752 | + message: f.s("message"), | |
| 753 | + description: f.s("description"), | |
| 754 | + choice: f.s("choice") === "direct" ? "direct" : "branch", | |
| 755 | + newBranch: f.s("new_branch").trim(), | |
| 756 | + head, | |
| 757 | + }; | |
| 758 | + const canDirect = empty || (await canCommitDirect(l, ref)); | |
| 759 | + try { | |
| 760 | + const path = cleanPath(values.path); | |
| 761 | + if (!path) throw new HttpError(422, "enter a valid file path"); | |
| 762 | + if (!values.message.trim()) throw new HttpError(422, "a commit message is required"); | |
| 763 | + const toBranch = values.choice === "direct" && canDirect ? ref : values.newBranch; | |
| 764 | + if (!toBranch) throw new HttpError(422, "enter a branch name"); | |
| 765 | + const changes: Array<{ path: string; content?: Uint8Array; delete?: boolean; mode?: "100644" | "100755" | "120000" }> = []; | |
| 766 | + if (mode === "edit" && path !== orig) changes.push({ path: orig, delete: true }); | |
| 767 | + if (mode === "new" && !empty && (await stub.blob(ref, path))) throw new HttpError(422, `${path} already exists`); | |
| 768 | + changes.push({ path, content: new TextEncoder().encode(content), mode: mode100 }); | |
| 769 | + const message = values.description.trim() ? `${values.message.trim()}\n\n${values.description.trim()}` : values.message.trim(); | |
| 770 | + const newBranch = toBranch !== ref; | |
| 771 | + if (newBranch && names.branches.includes(toBranch)) throw new HttpError(422, `branch ${toBranch} already exists`); | |
| 772 | + await stub.commitFiles({ branch: toBranch, message, changes, expectedHeadSha: !newBranch && head ? head : undefined, createBranchFrom: newBranch ? ref : undefined }, caller); | |
| 773 | + c.executionCtx.waitUntil(c.get("dir").touchPushed(repo.id)); | |
| 774 | + if (newBranch) return c.redirect(`${base}/compare/${encPath(ref)}...${encPath(toBranch)}`, 303); | |
| 775 | + return c.redirect(`${base}/blob/${encPath(ref)}/${encPath(path)}`, 303); | |
| 776 | + } catch (e) { | |
| 777 | + const s = statusOf(e); | |
| 778 | + if (s.status >= 500) throw e; | |
| 779 | + return render( | |
| 780 | + c, | |
| 781 | + { title: mode === "edit" ? `Editing ${orig}` : "New file", repo: { repo, caller, tab: "code", ref }, status: s.status }, | |
| 782 | + <Editor c={c} l={l} ref={ref} mode={mode} orig={orig} dir={mode === "new" ? split.path : ""} canDirect={canDirect} v={values} error={s.message} />, | |
| 783 | + ); | |
| 784 | + } | |
| 785 | + }; | |
| 786 | + | |
| 787 | + web.get("/:owner/:repo/edit/*", (c) => editorGet(c, "edit")); | |
| 788 | + post(web, "/:owner/:repo/edit/*", (c, f) => editorPost(c, "edit", f)); | |
| 789 | + web.get("/:owner/:repo/new/*", (c) => editorGet(c, "new")); | |
| 790 | + post(web, "/:owner/:repo/new/*", (c, f) => editorPost(c, "new", f)); | |
| 791 | + | |
| 792 | + // History | |
| 793 | + web.get("/:owner/:repo/commits/*", async (c) => { | |
| 794 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); | |
| 795 | + const { repo, caller, stub } = l; | |
| 796 | + const base = repoUrl(repo); | |
| 797 | + const ref = restOfPath(c, 3) || repo.default_branch; | |
| 798 | + const path = c.req.query("path") ?? ""; | |
| 799 | + const page = pageNum(c); | |
| 800 | + const per = 30; | |
| 801 | + const [commits, names] = await Promise.all([stub.log(ref, { path: path || undefined, limit: per + 1, skip: (page - 1) * per }), refNames(stub)]); | |
| 802 | + const hasNext = commits.length > per; | |
| 803 | + const shown = commits.slice(0, per); | |
| 804 | + const groups = new Map<string, typeof shown>(); | |
| 805 | + for (const cm of shown) { | |
| 806 | + const day = new Date(cm.committer.timestamp * 1000).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }); | |
| 807 | + groups.set(day, [...(groups.get(day) ?? []), cm]); | |
| 808 | + } | |
| 809 | + return render( | |
| 810 | + c, | |
| 811 | + { title: `Commits · ${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "code", ref } }, | |
| 812 | + <div> | |
| 813 | + <div class="row between mb"> | |
| 814 | + <div class="row"> | |
| 815 | + <h1 class="m0">Commits</h1> | |
| 816 | + <RefSwitcher current={ref} branches={names.branches} tags={names.tags} to={(r) => `${base}/commits/${encPath(r)}${qs({ path })}`} /> | |
| 817 | + {path ? ( | |
| 818 | + <span class="muted"> | |
| 819 | + for <span class="mono">{path}</span> · <a href={`${base}/commits/${encPath(ref)}`}>all files</a> | |
| 820 | + </span> | |
| 821 | + ) : null} | |
| 822 | + </div> | |
| 823 | + </div> | |
| 824 | + {shown.length === 0 ? <div class="blank">No commits found.</div> : null} | |
| 825 | + {[...groups.entries()].map(([day, list]) => ( | |
| 826 | + <div class="mb"> | |
| 827 | + <h3 class="muted small" style="font-weight:500"> | |
| 828 | + {raw(ICONS.commit)} Commits on {day} | |
| 829 | + </h3> | |
| 830 | + <div class="box"> | |
| 831 | + <ul class="list"> | |
| 832 | + {list.map((cm) => ( | |
| 833 | + <CommitRow base={base} sha={cm.sha} message={cm.message} author={cm.author} signed={cm.signed} /> | |
| 834 | + ))} | |
| 835 | + </ul> | |
| 836 | + </div> | |
| 837 | + </div> | |
| 838 | + ))} | |
| 839 | + <Pager page={page} hasNext={hasNext} href={(p) => `${base}/commits/${encPath(ref)}${qs({ path, page: p > 1 ? p : undefined })}`} /> | |
| 840 | + </div>, | |
| 841 | + ); | |
| 842 | + }); | |
| 843 | + web.get("/:owner/:repo/commits", (c) => c.redirect(`/${prm(c, "owner")}/${prm(c, "repo")}/commits/HEAD`, 302)); | |
| 844 | + | |
| 845 | + web.get("/:owner/:repo/commit/:sha", async (c) => { | |
| 846 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); | |
| 847 | + const { repo, caller, stub } = l; | |
| 848 | + const base = repoUrl(repo); | |
| 849 | + const cm = await stub.commitWithDiff(prm(c, "sha")); | |
| 850 | + const checks = await stub.checks(cm.sha).catch(() => []); | |
| 851 | + const [title, ...rest] = cm.message.split("\n"); | |
| 852 | + const body = rest.join("\n").trim(); | |
| 853 | + const files = cm.files as DiffFile[]; | |
| 854 | + return render( | |
| 855 | + c, | |
| 856 | + { title: `${firstLine(cm.message)} · ${shortSha(cm.sha)}`, repo: { repo, caller, tab: "code", ref: cm.sha }, wide: true }, | |
| 857 | + <div> | |
| 858 | + <div class="box"> | |
| 859 | + <div class="body"> | |
| 860 | + <h2 style="font-size:18px">{title}</h2> | |
| 861 | + {body ? <pre style="white-space:pre-wrap;margin:0 0 12px;font-size:13px">{body}</pre> : null} | |
| 862 | + <div class="row small muted"> | |
| 863 | + <b style="color:var(--fg)">{cm.author.name}</b> authored <Time at={new Date(cm.author.timestamp * 1000).toISOString()} /> | |
| 864 | + {cm.committer.name !== cm.author.name ? ( | |
| 865 | + <span> | |
| 866 | + · <b style="color:var(--fg)">{cm.committer.name}</b> committed <Time at={new Date(cm.committer.timestamp * 1000).toISOString()} /> | |
| 867 | + </span> | |
| 868 | + ) : null} | |
| 869 | + {cm.signed ? <span class="badge green">signed</span> : null} | |
| 870 | + <span class="grow" /> | |
| 871 | + <span> | |
| 872 | + {cm.parents.length} parent{cm.parents.length === 1 ? "" : "s"}{" "} | |
| 873 | + {cm.parents.map((p) => ( | |
| 874 | + <a class="sha" href={`${base}/commit/${p}`} style="margin-left:4px"> | |
| 875 | + {shortSha(p)} | |
| 876 | + </a> | |
| 877 | + ))} | |
| 878 | + </span> | |
| 879 | + <span> | |
| 880 | + commit <span class="sha">{cm.sha}</span> | |
| 881 | + </span> | |
| 882 | + <CopyButton text={cm.sha} /> | |
| 883 | + <a class="btn sm" href={`${base}/tree/${cm.sha}`}> | |
| 884 | + Browse files | |
| 885 | + </a> | |
| 886 | + </div> | |
| 887 | + {checks.length ? ( | |
| 888 | + <ul class="checks mt"> | |
| 889 | + {checks.map((ch) => ( | |
| 890 | + <li> | |
| 891 | + <Status status={ch.status} conclusion={ch.conclusion} /> | |
| 892 | + {ch.runId ? <a href={`${base}/actions/runs/${ch.runId}${ch.jobId ? `/job/${ch.jobId}` : ""}`}>{ch.name}</a> : <span>{ch.name}</span>} | |
| 893 | + <span class="muted small">{ch.output?.title ?? ""}</span> | |
| 894 | + </li> | |
| 895 | + ))} | |
| 896 | + </ul> | |
| 897 | + ) : null} | |
| 898 | + </div> | |
| 899 | + </div> | |
| 900 | + <div class="row between mb"> | |
| 901 | + <DiffStats files={cm.totalFiles} additions={cm.additions} deletions={cm.deletions} /> | |
| 902 | + {cm.truncated ? <span class="badge amber">showing the first {files.length} files</span> : null} | |
| 903 | + </div> | |
| 904 | + <FileSummary files={files} /> | |
| 905 | + <DiffView files={files} opts={{ viewHref: (p) => `${base}/blob/${cm.sha}/${encPath(p)}` }} /> | |
| 906 | + </div>, | |
| 907 | + ); | |
| 908 | + }); | |
| 909 | + | |
| 910 | + // Branches and tags | |
| 911 | + web.get("/:owner/:repo/branches", async (c) => { | |
| 912 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); | |
| 913 | + const { repo, caller, stub } = l; | |
| 914 | + const base = repoUrl(repo); | |
| 915 | + const t = csrfToken(c); | |
| 916 | + const branches = await stub.branches(); | |
| 917 | + branches.sort((a, b) => (a.isDefault ? -1 : b.isDefault ? 1 : a.name.localeCompare(b.name))); | |
| 918 | + const infos = await Promise.all(branches.slice(0, 40).map((b) => stub.commitInfo(b.sha).catch(() => null))); | |
| 919 | + const canWrite = atLeast(caller.role, "write") && !repo.archived; | |
| 920 | + return render( | |
| 921 | + c, | |
| 922 | + { title: `Branches · ${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "code" } }, | |
| 923 | + <div> | |
| 924 | + <div class="row between mb"> | |
| 925 | + <h1 class="m0">Branches</h1> | |
| 926 | + </div> | |
| 927 | + <div class="box"> | |
| 928 | + {branches.length ? ( | |
| 929 | + <ul class="list"> | |
| 930 | + {branches.map((b, i) => { | |
| 931 | + const info = infos[i]; | |
| 932 | + return ( | |
| 933 | + <li> | |
| 934 | + <span class="st faint">{raw(ICONS.branch)}</span> | |
| 935 | + <div class="grow"> | |
| 936 | + <a class="title mono" href={`${base}/tree/${encPath(b.name)}`}> | |
| 937 | + {b.name} | |
| 938 | + </a>{" "} | |
| 939 | + {b.isDefault ? <span class="badge blue">default</span> : null} {b.protected ? <span class="badge">{raw(ICONS.shield)} protected</span> : null} | |
| 940 | + {info ? ( | |
| 941 | + <div class="meta"> | |
| 942 | + {firstLine(info.message)} · {info.author.name} · <Time at={new Date(info.committer.timestamp * 1000).toISOString()} /> | |
| 943 | + </div> | |
| 944 | + ) : null} | |
| 945 | + </div> | |
| 946 | + <a class="sha" href={`${base}/commit/${b.sha}`}> | |
| 947 | + {shortSha(b.sha)} | |
| 948 | + </a> | |
| 949 | + {!b.isDefault ? ( | |
| 950 | + <a class="btn sm" href={`${base}/compare/${encPath(repo.default_branch)}...${encPath(b.name)}`}> | |
| 951 | + {raw(ICONS.pr)} Compare | |
| 952 | + </a> | |
| 953 | + ) : null} | |
| 954 | + {canWrite && !b.isDefault ? ( | |
| 955 | + <Form action={`${base}/branches/delete`} t={t} class="inline" confirm={`Delete branch ${b.name}?`}> | |
| 956 | + <input type="hidden" name="name" value={b.name} /> | |
| 957 | + <button class="btn sm danger" title="Delete branch"> | |
| 958 | + {raw(ICONS.x)} | |
| 959 | + </button> | |
| 960 | + </Form> | |
| 961 | + ) : null} | |
| 962 | + </li> | |
| 963 | + ); | |
| 964 | + })} | |
| 965 | + </ul> | |
| 966 | + ) : ( | |
| 967 | + <div class="empty">No branches yet.</div> | |
| 968 | + )} | |
| 969 | + </div> | |
| 970 | + {canWrite && branches.length ? ( | |
| 971 | + <Form action={`${base}/branches`} t={t} class="box"> | |
| 972 | + <div class="head"> | |
| 973 | + <h3>Create a branch</h3> | |
| 974 | + </div> | |
| 975 | + <div class="body row" style="align-items:flex-end"> | |
| 976 | + <div class="field grow m0"> | |
| 977 | + <label>Branch name</label> | |
| 978 | + <input name="name" type="text" required class="mono" /> | |
| 979 | + </div> | |
| 980 | + <div class="field m0"> | |
| 981 | + <label>From</label> | |
| 982 | + <select name="from"> | |
| 983 | + {branches.map((b) => ( | |
| 984 | + <option value={b.name} selected={b.isDefault}> | |
| 985 | + {b.name} | |
| 986 | + </option> | |
| 987 | + ))} | |
| 988 | + </select> | |
| 989 | + </div> | |
| 990 | + <button class="btn primary">Create branch</button> | |
| 991 | + </div> | |
| 992 | + </Form> | |
| 993 | + ) : null} | |
| 994 | + </div>, | |
| 995 | + ); | |
| 996 | + }); | |
| 997 | + | |
| 998 | + post(web, "/:owner/:repo/branches", async (c, f) => { | |
| 999 | + const { repo, caller, stub } = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write"); | |
| 1000 | + const base = repoUrl(repo); | |
| 1001 | + return act(c, `${base}/branches`, async () => { | |
| 1002 | + const name = f.s("name").trim(); | |
| 1003 | + const sha = await stub.resolve(f.s("from")); | |
| 1004 | + if (!sha) throw new HttpError(422, "unknown source branch"); | |
| 1005 | + await stub.createRef(`refs/heads/${name}`, sha, caller); | |
| 1006 | + return `${base}/tree/${encPath(name)}`; | |
| 1007 | + }); | |
| 1008 | + }); | |
| 1009 | + | |
| 1010 | + post(web, "/:owner/:repo/branches/delete", async (c, f) => { | |
| 1011 | + const { repo, caller, stub } = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write"); | |
| 1012 | + return act(c, `${repoUrl(repo)}/branches`, async () => stub.deleteRef(`refs/heads/${f.s("name")}`, caller), "Branch deleted."); | |
| 1013 | + }); | |
| 1014 | + | |
| 1015 | + web.get("/:owner/:repo/tags", async (c) => { | |
| 1016 | + const { repo, caller, stub } = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); | |
| 1017 | + const base = repoUrl(repo); | |
| 1018 | + const tags = (await stub.tags()).reverse(); | |
| 1019 | + const infos = await Promise.all(tags.slice(0, 40).map((tg) => stub.commitInfo(tg.target).catch(() => null))); | |
| 1020 | + return render( | |
| 1021 | + c, | |
| 1022 | + { title: `Tags · ${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "code" } }, | |
| 1023 | + <div> | |
| 1024 | + <h1>Tags</h1> | |
| 1025 | + <div class="box"> | |
| 1026 | + {tags.length ? ( | |
| 1027 | + <ul class="list"> | |
| 1028 | + {tags.map((tg, i) => ( | |
| 1029 | + <li> | |
| 1030 | + <span class="st faint">{raw(ICONS.tag)}</span> | |
| 1031 | + <div class="grow"> | |
| 1032 | + <a class="title mono" href={`${base}/tree/${encPath(tg.name)}`}> | |
| 1033 | + {tg.name} | |
| 1034 | + </a> | |
| 1035 | + {infos[i] ? ( | |
| 1036 | + <div class="meta"> | |
| 1037 | + {firstLine(infos[i]!.message)} · <Time at={new Date(infos[i]!.committer.timestamp * 1000).toISOString()} /> | |
| 1038 | + </div> | |
| 1039 | + ) : null} | |
| 1040 | + </div> | |
| 1041 | + <a class="sha" href={`${base}/commit/${tg.target}`}> | |
| 1042 | + {shortSha(tg.target)} | |
| 1043 | + </a> | |
| 1044 | + <a class="btn sm" href={`${base}/compare/${encPath(tg.name)}...${encPath(repo.default_branch)}`}> | |
| 1045 | + Compare | |
| 1046 | + </a> | |
| 1047 | + </li> | |
| 1048 | + ))} | |
| 1049 | + </ul> | |
| 1050 | + ) : ( | |
| 1051 | + <div class="empty"> | |
| 1052 | + No tags yet. Create one with <code>git tag v1.0.0 && git push --tags</code>. | |
| 1053 | + </div> | |
| 1054 | + )} | |
| 1055 | + </div> | |
| 1056 | + </div>, | |
| 1057 | + ); | |
| 1058 | + }); | |
| 1059 | + | |
| 1060 | + // Compare | |
| 1061 | + web.get("/:owner/:repo/compare", async (c) => { | |
| 1062 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); | |
| 1063 | + const base = repoUrl(l.repo); | |
| 1064 | + const b = c.req.query("base"); | |
| 1065 | + const h = c.req.query("head"); | |
| 1066 | + if (b && h) return c.redirect(`${base}/compare/${encPath(b)}...${encPath(h)}`, 302); | |
| 1067 | + const names = await refNames(l.stub); | |
| 1068 | + const other = names.branches.find((x) => x !== l.repo.default_branch); | |
| 1069 | + return comparePage(c, l, l.repo.default_branch, h ?? other ?? l.repo.default_branch); | |
| 1070 | + }); | |
| 1071 | + | |
| 1072 | + web.get("/:owner/:repo/compare/*", async (c) => { | |
| 1073 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); | |
| 1074 | + const spec = restOfPath(c, 3); | |
| 1075 | + const idx = spec.indexOf("..."); | |
| 1076 | + const baseRef = idx >= 0 ? spec.slice(0, idx) : l.repo.default_branch; | |
| 1077 | + const headRef = idx >= 0 ? spec.slice(idx + 3) : spec; | |
| 1078 | + if (!headRef) return c.redirect(`${repoUrl(l.repo)}/compare`, 302); | |
| 1079 | + return comparePage(c, l, baseRef || l.repo.default_branch, headRef); | |
| 1080 | + }); | |
| 1081 | + | |
| 1082 | + post(web, "/:owner/:repo/pulls", async (c, f) => { | |
| 1083 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write"); | |
| 1084 | + if (c.get("principal").type !== "account") throw new HttpError(401, "sign in required"); | |
| 1085 | + const values: PullFormValues = { title: f.s("title").trim(), body: f.s("body").replace(/\r\n/g, "\n"), intent: f.s("intent").replace(/\r\n/g, "\n").trim(), draft: f.b("draft") }; | |
| 1086 | + const baseRef = f.s("base") || l.repo.default_branch; | |
| 1087 | + const headRef = f.s("head"); | |
| 1088 | + try { | |
| 1089 | + const pr = await l.stub.createPull({ title: values.title, body: values.body, head: headRef, base: baseRef, draft: values.draft, intent: values.intent || undefined }, l.caller); | |
| 1090 | + return c.redirect(`${repoUrl(l.repo)}/pull/${pr.number}`, 303); | |
| 1091 | + } catch (e) { | |
| 1092 | + const s = statusOf(e); | |
| 1093 | + if (s.status >= 500) throw e; | |
| 1094 | + return comparePage(c, l, baseRef, headRef, { values, error: s.message }); | |
| 1095 | + } | |
| 1096 | + }); | |
| 1097 | +} | |
| 1098 | + | |
| 1099 | +/** Whether a web commit straight to `branch` would pass protection rules for this caller. */ | |
| 1100 | +async function canCommitDirect(l: Loaded, branch: string): Promise<boolean> { | |
| 1101 | + const rules = (await l.stub.listProtections()).find((p) => globToRegExp(p.pattern).test(branch))?.rules; | |
| 1102 | + if (l.caller.kind === "agent" && (branch === l.repo.default_branch || rules) && !rules?.agentsMayPush) return false; | |
| 1103 | + if (!rules) return true; | |
| 1104 | + if (l.caller.role === "admin" && !rules.enforceAdmins) return true; | |
| 1105 | + return !rules.requirePullRequest; | |
| 1106 | +} | |
| 1107 | + |
addedapps/diffinery/src/web/collab.tsx+1198−0View file
| @@ -1,0 +1,1198 @@ | ||
| 1 | +/** @jsxImportSource hono/jsx */ | |
| 2 | +import { raw } from "hono/html"; | |
| 3 | +import type { Child, FC } from "hono/jsx"; | |
| 4 | +import type { AppContext } from "../auth/principal.js"; | |
| 5 | +import { loadRepo } from "../http/common.js"; | |
| 6 | +import { HttpError } from "../lib/util.js"; | |
| 7 | +import type { Check, Comment, Issue, Mergeability, Review } from "../repo/collab.js"; | |
| 8 | +import { atLeast, type Role } from "../repo/types.js"; | |
| 9 | +import { ICONS } from "./assets.js"; | |
| 10 | +import { csrfToken } from "./csrf.js"; | |
| 11 | +import { | |
| 12 | + Actor, | |
| 13 | + CommitRow, | |
| 14 | + DiffStats, | |
| 15 | + DiffView, | |
| 16 | + FileSummary, | |
| 17 | + Form, | |
| 18 | + Label, | |
| 19 | + Markdown, | |
| 20 | + Pager, | |
| 21 | + Status, | |
| 22 | + Time, | |
| 23 | + act, | |
| 24 | + duration, | |
| 25 | + encPath, | |
| 26 | + onBehalfOf, | |
| 27 | + pageNum, | |
| 28 | + post, | |
| 29 | + qs, | |
| 30 | + render, | |
| 31 | + repoUrl, | |
| 32 | + shortSha, | |
| 33 | + statusOf, | |
| 34 | + type DiffFile, | |
| 35 | + type LineThread, | |
| 36 | + type WebApp, | |
| 37 | + prm, | |
| 38 | +} from "./ui.js"; | |
| 39 | + | |
| 40 | +type Loaded = Awaited<ReturnType<typeof loadRepo>>; | |
| 41 | +type LabelDef = { name: string; color: string; description: string }; | |
| 42 | + | |
| 43 | +/** Load a repo for a state-changing action by a signed-in account. */ | |
| 44 | +async function forAction(c: AppContext, min: Role = "read"): Promise<Loaded> { | |
| 45 | + if (c.get("principal").type !== "account") throw new HttpError(401, "sign in required"); | |
| 46 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), min); | |
| 47 | + if (l.repo.archived) throw new HttpError(403, "repository is archived (read-only)"); | |
| 48 | + return l; | |
| 49 | +} | |
| 50 | + | |
| 51 | +function issueNumber(c: AppContext): number { | |
| 52 | + const n = Number(prm(c, "n")); | |
| 53 | + if (!Number.isInteger(n) || n <= 0) throw new HttpError(404, "not found"); | |
| 54 | + return n; | |
| 55 | +} | |
| 56 | + | |
| 57 | +const kindPath = (i: Pick<Issue, "kind">) => (i.kind === "pull" ? "pull" : "issues"); | |
| 58 | + | |
| 59 | +const StateBadge: FC<{ i: Issue }> = ({ i }) => { | |
| 60 | + if (i.kind === "pull") { | |
| 61 | + if (i.pull?.merged) | |
| 62 | + return ( | |
| 63 | + <span class="state merged"> | |
| 64 | + {raw(ICONS.prMerged)} Merged | |
| 65 | + </span> | |
| 66 | + ); | |
| 67 | + if (i.state === "closed") | |
| 68 | + return ( | |
| 69 | + <span class="state closed"> | |
| 70 | + {raw(ICONS.prClosed)} Closed | |
| 71 | + </span> | |
| 72 | + ); | |
| 73 | + if (i.pull?.draft) | |
| 74 | + return ( | |
| 75 | + <span class="state draft"> | |
| 76 | + {raw(ICONS.pr)} Draft | |
| 77 | + </span> | |
| 78 | + ); | |
| 79 | + return ( | |
| 80 | + <span class="state open"> | |
| 81 | + {raw(ICONS.pr)} Open | |
| 82 | + </span> | |
| 83 | + ); | |
| 84 | + } | |
| 85 | + return i.state === "open" ? ( | |
| 86 | + <span class="state open"> | |
| 87 | + {raw(ICONS.issueOpen)} Open | |
| 88 | + </span> | |
| 89 | + ) : ( | |
| 90 | + <span class="state merged" style={i.stateReason === "not_planned" ? "background:var(--fg3)" : ""}> | |
| 91 | + {raw(ICONS.issueClosed)} Closed | |
| 92 | + </span> | |
| 93 | + ); | |
| 94 | +}; | |
| 95 | + | |
| 96 | +const StateIcon: FC<{ i: Issue }> = ({ i }) => { | |
| 97 | + if (i.kind === "pull") { | |
| 98 | + if (i.pull?.merged) return <span class="st merged">{raw(ICONS.prMerged)}</span>; | |
| 99 | + if (i.state === "closed") return <span class="st bad">{raw(ICONS.prClosed)}</span>; | |
| 100 | + return <span class={`st ${i.pull?.draft ? "faint" : "ok"}`}>{raw(ICONS.pr)}</span>; | |
| 101 | + } | |
| 102 | + return i.state === "open" ? <span class="st ok">{raw(ICONS.issueOpen)}</span> : <span class="st merged">{raw(ICONS.issueClosed)}</span>; | |
| 103 | +}; | |
| 104 | + | |
| 105 | +// ---------------------------------------------------------------- lists | |
| 106 | + | |
| 107 | +async function listPage(c: AppContext, kind: "issue" | "pull"): Promise<Response> { | |
| 108 | + const { repo, caller, stub } = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); | |
| 109 | + const base = repoUrl(repo); | |
| 110 | + const state = c.req.query("state") === "closed" ? "closed" : "open"; | |
| 111 | + const label = c.req.query("label") ?? ""; | |
| 112 | + const q = (c.req.query("q") ?? "").slice(0, 200); | |
| 113 | + const author = c.req.query("author") ?? ""; | |
| 114 | + const page = pageNum(c); | |
| 115 | + const per = 25; | |
| 116 | + const filter = { kind, label: label || undefined, q: q || undefined, author: author || undefined }; | |
| 117 | + const [list, other, labels] = await Promise.all([ | |
| 118 | + stub.issues({ ...filter, state, limit: per, offset: (page - 1) * per }), | |
| 119 | + stub.issues({ ...filter, state: state === "open" ? "closed" : "open", limit: 1 }), | |
| 120 | + stub.labels(), | |
| 121 | + ]); | |
| 122 | + const colors = new Map(labels.map((l) => [l.name, l.color])); | |
| 123 | + const path = kind === "pull" ? "pulls" : "issues"; | |
| 124 | + const href = (p: Record<string, string | number | undefined>) => `${base}/${path}${qs({ state: state === "closed" ? "closed" : undefined, label: label || undefined, q: q || undefined, author: author || undefined, ...p })}`; | |
| 125 | + const openCount = state === "open" ? list.total : other.total; | |
| 126 | + const closedCount = state === "closed" ? list.total : other.total; | |
| 127 | + const isAccount = c.get("principal").type === "account"; | |
| 128 | + const behalf = await onBehalfOf(c, list.items.map((i) => i.author)); | |
| 129 | + return render( | |
| 130 | + c, | |
| 131 | + { title: `${kind === "pull" ? "Pull requests" : "Issues"} · ${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: kind === "pull" ? "pulls" : "issues" } }, | |
| 132 | + <div> | |
| 133 | + <div class="row between mb"> | |
| 134 | + <form method="get" action={`${base}/${path}`} class="row grow" style="max-width:640px"> | |
| 135 | + <input type="hidden" name="state" value={state === "closed" ? "closed" : ""} /> | |
| 136 | + {label ? <input type="hidden" name="label" value={label} /> : null} | |
| 137 | + <input type="search" name="q" value={q} placeholder={`Search ${kind === "pull" ? "pull requests" : "issues"}… ( / )`} class="grow" style="flex:1" /> | |
| 138 | + </form> | |
| 139 | + <div class="row"> | |
| 140 | + <a class="btn sm" href={`${base}/labels`}> | |
| 141 | + Labels | |
| 142 | + </a> | |
| 143 | + {kind === "pull" ? ( | |
| 144 | + <a class="btn sm primary" href={`${base}/compare`}> | |
| 145 | + New pull request | |
| 146 | + </a> | |
| 147 | + ) : isAccount && !repo.archived ? ( | |
| 148 | + <a class="btn sm primary" href={`${base}/issues/new`}> | |
| 149 | + New issue | |
| 150 | + </a> | |
| 151 | + ) : null} | |
| 152 | + </div> | |
| 153 | + </div> | |
| 154 | + <div class="box"> | |
| 155 | + <div class="head"> | |
| 156 | + <a href={href({ state: undefined, page: undefined })} style={state === "open" ? "font-weight:600;color:var(--fg)" : "color:var(--fg2)"}> | |
| 157 | + {raw(kind === "pull" ? ICONS.pr : ICONS.issueOpen)} {openCount} Open | |
| 158 | + </a> | |
| 159 | + <a href={href({ state: "closed", page: undefined })} style={state === "closed" ? "font-weight:600;color:var(--fg)" : "color:var(--fg2)"}> | |
| 160 | + {raw(ICONS.check)} {closedCount} Closed | |
| 161 | + </a> | |
| 162 | + <span class="grow" /> | |
| 163 | + {label ? ( | |
| 164 | + <span class="small"> | |
| 165 | + Label: <Label name={label} color={colors.get(label)} /> <a href={`${base}/${path}${qs({ state: state === "closed" ? "closed" : undefined, q: q || undefined })}`}>clear</a> | |
| 166 | + </span> | |
| 167 | + ) : null} | |
| 168 | + {author ? ( | |
| 169 | + <span class="small"> | |
| 170 | + Author: <b>{author}</b> <a href={href({ author: undefined })}>clear</a> | |
| 171 | + </span> | |
| 172 | + ) : null} | |
| 173 | + {labels.length ? ( | |
| 174 | + <details class="menu"> | |
| 175 | + <summary class="btn sm">Label ▾</summary> | |
| 176 | + <div class="pop right"> | |
| 177 | + {labels.map((l) => ( | |
| 178 | + <a href={href({ label: l.name, page: undefined })}> | |
| 179 | + <Label name={l.name} color={l.color} /> | |
| 180 | + <span class="muted small">{l.description}</span> | |
| 181 | + </a> | |
| 182 | + ))} | |
| 183 | + </div> | |
| 184 | + </details> | |
| 185 | + ) : null} | |
| 186 | + </div> | |
| 187 | + {list.items.length ? ( | |
| 188 | + <ul class="list"> | |
| 189 | + {list.items.map((i) => ( | |
| 190 | + <li> | |
| 191 | + <StateIcon i={i} /> | |
| 192 | + <div class="grow"> | |
| 193 | + <a class="title" href={`${base}/${kindPath(i)}/${i.number}`}> | |
| 194 | + {i.title} | |
| 195 | + </a>{" "} | |
| 196 | + {i.labels.map((l) => ( | |
| 197 | + <Label name={l} color={colors.get(l)} href={href({ label: l, page: undefined })} /> | |
| 198 | + ))} | |
| 199 | + {i.pull?.draft && i.state === "open" ? <span class="badge">draft</span> : null} | |
| 200 | + <div class="meta"> | |
| 201 | + #{i.number} {i.state === "open" ? "opened" : i.pull?.merged ? "merged" : "closed"} <Time at={i.state === "open" ? i.createdAt : (i.pull?.mergedAt ?? i.closedAt ?? i.updatedAt)} /> by{" "} | |
| 202 | + <a href={href({ author: i.author.login, page: undefined })}>{i.author.login}</a> | |
| 203 | + {i.author.kind === "agent" ? ( | |
| 204 | + <> | |
| 205 | + {" "} | |
| 206 | + <span class="badge agent">agent</span> | |
| 207 | + {behalf.get(i.author.login) ? <span> on behalf of {behalf.get(i.author.login)}</span> : null} | |
| 208 | + </> | |
| 209 | + ) : null} | |
| 210 | + {i.pull ? ( | |
| 211 | + <span class="mono"> | |
| 212 | + {" "} | |
| 213 | + · {i.pull.head} → {i.pull.base} | |
| 214 | + </span> | |
| 215 | + ) : null} | |
| 216 | + </div> | |
| 217 | + </div> | |
| 218 | + {i.comments ? ( | |
| 219 | + <a class="muted small nowrap" href={`${base}/${kindPath(i)}/${i.number}`}> | |
| 220 | + {raw(ICONS.comment)} {i.comments} | |
| 221 | + </a> | |
| 222 | + ) : null} | |
| 223 | + </li> | |
| 224 | + ))} | |
| 225 | + </ul> | |
| 226 | + ) : ( | |
| 227 | + <div class="empty"> | |
| 228 | + {q || label || author ? "No results match your filters." : state === "open" ? `There are no open ${kind === "pull" ? "pull requests" : "issues"}.` : `No closed ${kind === "pull" ? "pull requests" : "issues"}.`} | |
| 229 | + </div> | |
| 230 | + )} | |
| 231 | + </div> | |
| 232 | + <Pager page={page} hasNext={page * per < list.total} href={(p) => href({ page: p > 1 ? p : undefined })} /> | |
| 233 | + </div>, | |
| 234 | + ); | |
| 235 | +} | |
| 236 | + | |
| 237 | +// ---------------------------------------------------------------- conversation pieces | |
| 238 | + | |
| 239 | +interface ConvCtx { | |
| 240 | + c: AppContext; | |
| 241 | + l: Loaded; | |
| 242 | + issue: Issue; | |
| 243 | + comments: Comment[]; | |
| 244 | + reviews: Review[]; | |
| 245 | + labels: LabelDef[]; | |
| 246 | + behalf: Map<string, string>; | |
| 247 | +} | |
| 248 | + | |
| 249 | +const CommentCard: FC<{ x: ConvCtx; author: { login: string; kind: string }; at: string; body: string; verb?: string; id?: string; extra?: Child; cls?: string }> = ({ x, author, at, body, verb, id, extra, cls }) => ( | |
| 250 | + <div class={`comment ${cls ?? ""}`} id={id}> | |
| 251 | + <div class="head"> | |
| 252 | + <Actor login={author.login} kind={author.kind} onBehalfOf={x.behalf.get(author.login)} /> | |
| 253 | + <span class="muted"> | |
| 254 | + {verb ?? "commented"} <Time at={at} /> | |
| 255 | + </span> | |
| 256 | + <span class="grow" /> | |
| 257 | + {author.login === x.issue.author.login ? <span class="badge">Author</span> : null} | |
| 258 | + {extra} | |
| 259 | + </div> | |
| 260 | + <div class="body"> | |
| 261 | + <Markdown text={body} base={{ owner: x.l.repo.owner_login, repo: x.l.repo.name, ref: x.l.repo.default_branch, dir: "" }} /> | |
| 262 | + </div> | |
| 263 | + </div> | |
| 264 | +); | |
| 265 | + | |
| 266 | +const LineComments: FC<{ x: ConvCtx; list: Comment[] }> = ({ x, list }) => { | |
| 267 | + const base = repoUrl(x.l.repo); | |
| 268 | + return ( | |
| 269 | + <div> | |
| 270 | + {list.map((cm) => ( | |
| 271 | + <div class="linec" id={`c-${cm.id}`}> | |
| 272 | + <div class="row small" style="gap:8px"> | |
| 273 | + <a class="where" href={`${base}/pull/${x.issue.number}/files#diff-${encodeURIComponent(cm.path ?? "")}`}> | |
| 274 | + {cm.path} | |
| 275 | + {cm.line ? `:${cm.line}` : ""} | |
| 276 | + </a> | |
| 277 | + {cm.commitId && x.issue.pull && cm.commitId !== x.issue.pull.headSha ? <span class="badge">outdated</span> : null} | |
| 278 | + {cm.resolved ? <span class="badge green">resolved</span> : null} | |
| 279 | + <span class="grow" /> | |
| 280 | + <Actor login={cm.author.login} kind={cm.author.kind} avatar={false} /> | |
| 281 | + <Time at={cm.createdAt} /> | |
| 282 | + </div> | |
| 283 | + <Markdown text={cm.body} /> | |
| 284 | + </div> | |
| 285 | + ))} | |
| 286 | + </div> | |
| 287 | + ); | |
| 288 | +}; | |
| 289 | + | |
| 290 | +const Timeline: FC<{ x: ConvCtx }> = ({ x }) => { | |
| 291 | + type Item = { at: string; node: Child }; | |
| 292 | + const items: Item[] = []; | |
| 293 | + for (const cm of x.comments.filter((c) => !c.reviewId)) { | |
| 294 | + items.push({ at: cm.createdAt, node: <CommentCard x={x} author={cm.author} at={cm.createdAt} body={cm.body} id={`c-${cm.id}`} /> }); | |
| 295 | + } | |
| 296 | + for (const rv of x.reviews) { | |
| 297 | + const lines = x.comments.filter((c) => c.reviewId === rv.id); | |
| 298 | + const cls = rv.state === "APPROVED" ? "approved" : rv.state === "CHANGES_REQUESTED" ? "changes" : ""; | |
| 299 | + const verb = rv.state === "APPROVED" ? "approved these changes" : rv.state === "CHANGES_REQUESTED" ? "requested changes" : rv.state === "DISMISSED" ? "reviewed (dismissed)" : "reviewed"; | |
| 300 | + items.push({ | |
| 301 | + at: rv.submittedAt, | |
| 302 | + node: ( | |
| 303 | + <div> | |
| 304 | + <div class={`event ${cls}`}> | |
| 305 | + <span class="ic">{raw(rv.state === "APPROVED" ? ICONS.check : rv.state === "CHANGES_REQUESTED" ? ICONS.x : ICONS.eye)}</span> | |
| 306 | + <Actor login={rv.author.login} kind={rv.author.kind} onBehalfOf={x.behalf.get(rv.author.login)} avatar={false} /> | |
| 307 | + <span> | |
| 308 | + {verb} <Time at={rv.submittedAt} /> | |
| 309 | + </span> | |
| 310 | + <span class="faint mono small">on {shortSha(rv.commitId)}</span> | |
| 311 | + </div> | |
| 312 | + {rv.body.trim() || lines.length ? ( | |
| 313 | + <div class="comment" style="margin-left:24px"> | |
| 314 | + <div class="body"> | |
| 315 | + {rv.body.trim() ? <Markdown text={rv.body} /> : null} | |
| 316 | + {lines.length ? <LineComments x={x} list={lines} /> : null} | |
| 317 | + </div> | |
| 318 | + </div> | |
| 319 | + ) : null} | |
| 320 | + </div> | |
| 321 | + ), | |
| 322 | + }); | |
| 323 | + } | |
| 324 | + if (x.issue.state === "closed" && x.issue.closedAt) { | |
| 325 | + items.push({ | |
| 326 | + at: x.issue.closedAt, | |
| 327 | + node: ( | |
| 328 | + <div class="event"> | |
| 329 | + <span class="ic" style={x.issue.pull?.merged ? "color:var(--purple)" : ""}> | |
| 330 | + {raw(x.issue.pull?.merged ? ICONS.prMerged : ICONS.issueClosed)} | |
| 331 | + </span> | |
| 332 | + {x.issue.pull?.merged ? ( | |
| 333 | + <span> | |
| 334 | + <b>{x.issue.pull.mergedBy}</b> merged commit{" "} | |
| 335 | + {x.issue.pull.mergeCommit ? ( | |
| 336 | + <a class="sha" href={`${repoUrl(x.l.repo)}/commit/${x.issue.pull.mergeCommit}`}> | |
| 337 | + {shortSha(x.issue.pull.mergeCommit)} | |
| 338 | + </a> | |
| 339 | + ) : null}{" "} | |
| 340 | + into <span class="mono">{x.issue.pull.base}</span> <Time at={x.issue.pull.mergedAt} /> | |
| 341 | + </span> | |
| 342 | + ) : ( | |
| 343 | + <span> | |
| 344 | + Closed{x.issue.stateReason && x.issue.stateReason !== "completed" ? ` as ${x.issue.stateReason.replace(/_/g, " ")}` : ""} <Time at={x.issue.closedAt} /> | |
| 345 | + </span> | |
| 346 | + )} | |
| 347 | + </div> | |
| 348 | + ), | |
| 349 | + }); | |
| 350 | + } | |
| 351 | + items.sort((a, b) => a.at.localeCompare(b.at)); | |
| 352 | + return <div class="timeline">{items.map((i) => i.node)}</div>; | |
| 353 | +}; | |
| 354 | + | |
| 355 | +const CommentForm: FC<{ x: ConvCtx }> = ({ x }) => { | |
| 356 | + const p = x.c.get("principal"); | |
| 357 | + if (p.type !== "account") return <div class="blank">Sign in to join the conversation.</div>; | |
| 358 | + if (x.l.repo.archived) return <div class="blank">This repository is archived; the conversation is read-only.</div>; | |
| 359 | + const base = repoUrl(x.l.repo); | |
| 360 | + const canState = x.issue.author.id === x.l.caller.id || atLeast(x.l.caller.role, "triage"); | |
| 361 | + const merged = !!x.issue.pull?.merged; | |
| 362 | + return ( | |
| 363 | + <Form action={`${base}/issues/${x.issue.number}/comment`} t={csrfToken(x.c)} class="comment"> | |
| 364 | + <div class="head"> | |
| 365 | + <b>Add a comment</b> | |
| 366 | + <span class="muted small">Markdown supported</span> | |
| 367 | + </div> | |
| 368 | + <div class="body"> | |
| 369 | + <textarea name="body" placeholder="Leave a comment" aria-label="Comment"></textarea> | |
| 370 | + <div class="row end mt"> | |
| 371 | + {canState && !merged ? ( | |
| 372 | + x.issue.state === "open" ? ( | |
| 373 | + <> | |
| 374 | + {x.issue.kind === "issue" ? ( | |
| 375 | + <button class="btn" name="action" value="not_planned"> | |
| 376 | + Close as not planned | |
| 377 | + </button> | |
| 378 | + ) : null} | |
| 379 | + <button class="btn" name="action" value="close"> | |
| 380 | + {raw(ICONS.issueClosed)} Close {x.issue.kind === "pull" ? "pull request" : "issue"} | |
| 381 | + </button> | |
| 382 | + </> | |
| 383 | + ) : ( | |
| 384 | + <button class="btn" name="action" value="reopen"> | |
| 385 | + Reopen | |
| 386 | + </button> | |
| 387 | + ) | |
| 388 | + ) : null} | |
| 389 | + <button class="btn primary" name="action" value="comment"> | |
| 390 | + Comment | |
| 391 | + </button> | |
| 392 | + </div> | |
| 393 | + </div> | |
| 394 | + </Form> | |
| 395 | + ); | |
| 396 | +}; | |
| 397 | + | |
| 398 | +const Sidebar: FC<{ x: ConvCtx }> = ({ x }) => { | |
| 399 | + const base = repoUrl(x.l.repo); | |
| 400 | + const t = csrfToken(x.c); | |
| 401 | + const canTriage = atLeast(x.l.caller.role, "triage") && !x.l.repo.archived && x.c.get("principal").type === "account"; | |
| 402 | + const colors = new Map(x.labels.map((l) => [l.name, l.color])); | |
| 403 | + const participants = [...new Map([x.issue.author, ...x.comments.map((c) => c.author), ...x.reviews.map((r) => r.author)].map((a) => [a.login, a])).values()]; | |
| 404 | + return ( | |
| 405 | + <aside class="side"> | |
| 406 | + {x.issue.pull ? ( | |
| 407 | + <section> | |
| 408 | + <h3>Reviewers</h3> | |
| 409 | + {x.reviews.length || x.issue.pull.requestedReviewers.length ? ( | |
| 410 | + <div> | |
| 411 | + {[...new Map(x.reviews.filter((r) => r.state !== "COMMENTED").map((r) => [r.author.login, r])).values()].map((r) => ( | |
| 412 | + <div class="row small" style="margin:4px 0"> | |
| 413 | + <Actor login={r.author.login} kind={r.author.kind} /> | |
| 414 | + <span class="grow" /> | |
| 415 | + <span class={r.state === "APPROVED" ? "ok" : r.state === "CHANGES_REQUESTED" ? "bad" : "faint"}>{r.state === "APPROVED" ? "approved" : r.state === "CHANGES_REQUESTED" ? "changes requested" : "dismissed"}</span> | |
| 416 | + </div> | |
| 417 | + ))} | |
| 418 | + {x.issue.pull.requestedReviewers.map((r) => ( | |
| 419 | + <div class="row small" style="margin:4px 0"> | |
| 420 | + <Actor login={r} /> | |
| 421 | + <span class="grow" /> | |
| 422 | + <span class="warn">pending</span> | |
| 423 | + </div> | |
| 424 | + ))} | |
| 425 | + </div> | |
| 426 | + ) : ( | |
| 427 | + <p class="muted small m0">No reviews yet.</p> | |
| 428 | + )} | |
| 429 | + </section> | |
| 430 | + ) : null} | |
| 431 | + <section> | |
| 432 | + <h3>Labels</h3> | |
| 433 | + {x.issue.labels.length ? ( | |
| 434 | + <div> | |
| 435 | + {x.issue.labels.map((l) => ( | |
| 436 | + <Label name={l} color={colors.get(l)} href={`${base}/${x.issue.kind === "pull" ? "pulls" : "issues"}${qs({ label: l })}`} /> | |
| 437 | + ))} | |
| 438 | + </div> | |
| 439 | + ) : ( | |
| 440 | + <p class="muted small m0">None yet</p> | |
| 441 | + )} | |
| 442 | + {canTriage ? ( | |
| 443 | + <details class="mt" style="margin-top:8px"> | |
| 444 | + <summary class="small" style="cursor:pointer;color:var(--accent)"> | |
| 445 | + Edit labels | |
| 446 | + </summary> | |
| 447 | + <Form action={`${base}/issues/${x.issue.number}/labels`} t={t} class="mt" style="margin-top:8px"> | |
| 448 | + {x.labels.map((l) => ( | |
| 449 | + <label class="inline" style="margin:3px 0"> | |
| 450 | + <input type="checkbox" name="labels" value={l.name} checked={x.issue.labels.includes(l.name)} /> <Label name={l.name} color={l.color} /> | |
| 451 | + </label> | |
| 452 | + ))} | |
| 453 | + {x.issue.labels | |
| 454 | + .filter((l) => !colors.has(l)) | |
| 455 | + .map((l) => ( | |
| 456 | + <label class="inline" style="margin:3px 0"> | |
| 457 | + <input type="checkbox" name="labels" value={l} checked /> <Label name={l} /> | |
| 458 | + </label> | |
| 459 | + ))} | |
| 460 | + <input type="text" name="new_label" placeholder="New label…" style="margin:6px 0" /> | |
| 461 | + <button class="btn sm">Apply</button> | |
| 462 | + </Form> | |
| 463 | + </details> | |
| 464 | + ) : null} | |
| 465 | + </section> | |
| 466 | + <section> | |
| 467 | + <h3>Assignees</h3> | |
| 468 | + {x.issue.assignees.length ? ( | |
| 469 | + x.issue.assignees.map((a) => ( | |
| 470 | + <div style="margin:4px 0"> | |
| 471 | + <Actor login={a} /> | |
| 472 | + </div> | |
| 473 | + )) | |
| 474 | + ) : ( | |
| 475 | + <p class="muted small m0">No one assigned</p> | |
| 476 | + )} | |
| 477 | + {canTriage ? ( | |
| 478 | + <details style="margin-top:8px"> | |
| 479 | + <summary class="small" style="cursor:pointer;color:var(--accent)"> | |
| 480 | + Edit assignees | |
| 481 | + </summary> | |
| 482 | + <Form action={`${base}/issues/${x.issue.number}/assignees`} t={t} style="margin-top:8px"> | |
| 483 | + <input type="text" name="assignees" value={x.issue.assignees.join(", ")} placeholder="login, login" /> | |
| 484 | + <button class="btn sm" style="margin-top:6px"> | |
| 485 | + Save | |
| 486 | + </button> | |
| 487 | + </Form> | |
| 488 | + </details> | |
| 489 | + ) : null} | |
| 490 | + </section> | |
| 491 | + <section> | |
| 492 | + <h3>Participants</h3> | |
| 493 | + <div class="row" style="gap:4px"> | |
| 494 | + {participants.map((a) => ( | |
| 495 | + <a href={`/${encodeURIComponent(a.login)}`} title={a.login}> | |
| 496 | + <span class={`av ${a.kind === "agent" ? "bot" : ""}`} style={`--h:${[...a.login].reduce((h, ch) => (h * 31 + ch.charCodeAt(0)) >>> 0, 0) % 360}`}> | |
| 497 | + {(a.login.replace(/[^A-Za-z0-9]/g, "")[0] ?? "?").toUpperCase()} | |
| 498 | + </span> | |
| 499 | + </a> | |
| 500 | + ))} | |
| 501 | + </div> | |
| 502 | + </section> | |
| 503 | + </aside> | |
| 504 | + ); | |
| 505 | +}; | |
| 506 | + | |
| 507 | +const TitleEditor: FC<{ x: ConvCtx }> = ({ x }) => { | |
| 508 | + const can = x.c.get("principal").type === "account" && !x.l.repo.archived && (x.issue.author.id === x.l.caller.id || atLeast(x.l.caller.role, "triage")); | |
| 509 | + if (!can) return null; | |
| 510 | + return ( | |
| 511 | + <details class="menu"> | |
| 512 | + <summary class="btn sm">Edit</summary> | |
| 513 | + <div class="pop right" style="width:min(560px,90vw)"> | |
| 514 | + <Form action={`${repoUrl(x.l.repo)}/issues/${x.issue.number}/edit`} t={csrfToken(x.c)} style="padding:8px"> | |
| 515 | + <div class="field"> | |
| 516 | + <label>Title</label> | |
| 517 | + <input type="text" name="title" value={x.issue.title} required /> | |
| 518 | + </div> | |
| 519 | + <div class="field"> | |
| 520 | + <label>Description</label> | |
| 521 | + <textarea name="body">{x.issue.body}</textarea> | |
| 522 | + </div> | |
| 523 | + <button class="btn primary sm">Save</button> | |
| 524 | + </Form> | |
| 525 | + </div> | |
| 526 | + </details> | |
| 527 | + ); | |
| 528 | +}; | |
| 529 | + | |
| 530 | +const IssueHeader: FC<{ x: ConvCtx }> = ({ x }) => { | |
| 531 | + const i = x.issue; | |
| 532 | + const base = repoUrl(x.l.repo); | |
| 533 | + return ( | |
| 534 | + <div class="mb" data-fragment="header"> | |
| 535 | + <div class="row between" style="align-items:flex-start"> | |
| 536 | + <h1 class="grow" style="font-size:24px;font-weight:500"> | |
| 537 | + {i.title} <span class="faint">#{i.number}</span> | |
| 538 | + </h1> | |
| 539 | + <div class="row"> | |
| 540 | + <TitleEditor x={x} /> | |
| 541 | + {i.kind === "issue" ? ( | |
| 542 | + <a class="btn sm primary" href={`${base}/issues/new`}> | |
| 543 | + New issue | |
| 544 | + </a> | |
| 545 | + ) : null} | |
| 546 | + </div> | |
| 547 | + </div> | |
| 548 | + <div class="row" style="gap:8px"> | |
| 549 | + <StateBadge i={i} /> | |
| 550 | + <Actor login={i.author.login} kind={i.author.kind} onBehalfOf={x.behalf.get(i.author.login)} avatar={false} /> | |
| 551 | + {i.pull ? ( | |
| 552 | + <span class="muted"> | |
| 553 | + {i.pull.merged ? "merged" : "wants to merge"} into <a class="sha" href={`${base}/tree/${encPath(i.pull.base)}`}>{i.pull.base}</a> from{" "} | |
| 554 | + <a class="sha" href={`${base}/tree/${encPath(i.pull.head)}`}> | |
| 555 | + {i.pull.head} | |
| 556 | + </a> | |
| 557 | + </span> | |
| 558 | + ) : ( | |
| 559 | + <span class="muted"> | |
| 560 | + opened this issue <Time at={i.createdAt} /> · {i.comments} comment{i.comments === 1 ? "" : "s"} | |
| 561 | + </span> | |
| 562 | + )} | |
| 563 | + </div> | |
| 564 | + </div> | |
| 565 | + ); | |
| 566 | +}; | |
| 567 | + | |
| 568 | +const PullTabs: FC<{ x: ConvCtx; tab: "conversation" | "commits" | "files" | "checks" }> = ({ x, tab }) => { | |
| 569 | + const b = `${repoUrl(x.l.repo)}/pull/${x.issue.number}`; | |
| 570 | + return ( | |
| 571 | + <nav class="tabs mb" style="border-bottom:1px solid var(--border);margin-top:0"> | |
| 572 | + <a href={b} class={tab === "conversation" ? "on" : ""}> | |
| 573 | + {raw(ICONS.comment)} Conversation <span class="n">{x.issue.comments}</span> | |
| 574 | + </a> | |
| 575 | + <a href={`${b}/commits`} class={tab === "commits" ? "on" : ""}> | |
| 576 | + {raw(ICONS.commit)} Commits | |
| 577 | + </a> | |
| 578 | + <a href={`${b}/checks`} class={tab === "checks" ? "on" : ""}> | |
| 579 | + {raw(ICONS.play)} Checks | |
| 580 | + </a> | |
| 581 | + <a href={`${b}/files`} class={tab === "files" ? "on" : ""}> | |
| 582 | + {raw(ICONS.file)} Files changed | |
| 583 | + </a> | |
| 584 | + </nav> | |
| 585 | + ); | |
| 586 | +}; | |
| 587 | + | |
| 588 | +// ---------------------------------------------------------------- merge box and review | |
| 589 | + | |
| 590 | +const MergeBox: FC<{ x: ConvCtx; m: Mergeability | null; checks: Check[] }> = ({ x, m, checks }) => { | |
| 591 | + const i = x.issue; | |
| 592 | + const p = i.pull!; | |
| 593 | + const base = repoUrl(x.l.repo); | |
| 594 | + const caller = x.l.caller; | |
| 595 | + const isAccount = x.c.get("principal").type === "account"; | |
| 596 | + if (p.merged) { | |
| 597 | + return ( | |
| 598 | + <div class="mergebox merged" data-fragment="merge"> | |
| 599 | + <div class="row"> | |
| 600 | + <span class="st merged">{raw(ICONS.prMerged)}</span> | |
| 601 | + <div> | |
| 602 | + <b>Pull request successfully merged and closed</b> | |
| 603 | + <div class="muted small"> | |
| 604 | + {p.mergedBy} merged {p.mergeCommit ? <a href={`${base}/commit/${p.mergeCommit}`}>{shortSha(p.mergeCommit)}</a> : null} into {p.base} <Time at={p.mergedAt} /> | |
| 605 | + </div> | |
| 606 | + </div> | |
| 607 | + </div> | |
| 608 | + </div> | |
| 609 | + ); | |
| 610 | + } | |
| 611 | + if (i.state === "closed") { | |
| 612 | + return ( | |
| 613 | + <div class="mergebox" data-fragment="merge"> | |
| 614 | + <div> | |
| 615 | + <b>This pull request is closed.</b> | |
| 616 | + <span class="muted"> Reopen it to continue.</span> | |
| 617 | + </div> | |
| 618 | + </div> | |
| 619 | + ); | |
| 620 | + } | |
| 621 | + if (!m) return <div class="mergebox" data-fragment="merge" />; | |
| 622 | + const headline = | |
| 623 | + m.state === "clean" | |
| 624 | + ? "This branch has no conflicts with the base branch" | |
| 625 | + : m.state === "conflicting" | |
| 626 | + ? "This branch has conflicts that must be resolved" | |
| 627 | + : m.state === "draft" | |
| 628 | + ? "This pull request is still a work in progress" | |
| 629 | + : "Merging is blocked"; | |
| 630 | + const bypass = !m.mergeable && caller.role === "admin" && caller.kind === "user" && m.conflicts.length === 0 && !p.draft; | |
| 631 | + const showChecks = checks.length ? checks : m.checks.map((ch) => ({ name: ch.name, status: ch.state === "queued" || ch.state === "in_progress" ? ch.state : "completed", conclusion: ch.state, runId: null, jobId: null, output: null }) as unknown as Check); | |
| 632 | + const failing = showChecks.filter((ch) => ch.status === "completed" && ch.conclusion !== "success" && ch.conclusion !== "skipped" && ch.conclusion !== "neutral").length; | |
| 633 | + const pending = showChecks.filter((ch) => ch.status !== "completed").length; | |
| 634 | + return ( | |
| 635 | + <div class={`mergebox ${m.state}`} data-fragment="merge"> | |
| 636 | + {showChecks.length ? ( | |
| 637 | + <div> | |
| 638 | + <div class="row mb" style="margin-bottom:6px"> | |
| 639 | + <Status status={pending ? "in_progress" : "completed"} conclusion={failing ? "failure" : "success"} /> | |
| 640 | + <b>{pending ? "Some checks haven't completed yet" : failing ? `${failing} failing check${failing === 1 ? "" : "s"}` : "All checks have passed"}</b> | |
| 641 | + <span class="muted small"> | |
| 642 | + {showChecks.length} check{showChecks.length === 1 ? "" : "s"} | |
| 643 | + </span> | |
| 644 | + </div> | |
| 645 | + <ul class="checks"> | |
| 646 | + {showChecks.map((ch) => ( | |
| 647 | + <li> | |
| 648 | + <Status status={ch.status} conclusion={ch.conclusion} /> | |
| 649 | + {ch.runId ? <a href={`${base}/actions/runs/${ch.runId}${ch.jobId ? `/job/${ch.jobId}` : ""}`}>{ch.name}</a> : <span>{ch.name}</span>} | |
| 650 | + <span class="muted small grow">{ch.output?.title && ch.output.title !== ch.conclusion ? ch.output.title : ""}</span> | |
| 651 | + <span class="muted small">{ch.startedAt ? duration(ch.startedAt, ch.completedAt) : ""}</span> | |
| 652 | + </li> | |
| 653 | + ))} | |
| 654 | + </ul> | |
| 655 | + </div> | |
| 656 | + ) : null} | |
| 657 | + <div> | |
| 658 | + <div class="row"> | |
| 659 | + <Status conclusion={m.approvals >= m.requiredApprovals && !m.changesRequested.length ? "success" : "pending"} /> | |
| 660 | + <span> | |
| 661 | + <b> | |
| 662 | + {m.approvals} approval{m.approvals === 1 ? "" : "s"} | |
| 663 | + </b> | |
| 664 | + {m.requiredApprovals ? <span class="muted"> of {m.requiredApprovals} required</span> : null} | |
| 665 | + {m.changesRequested.length ? <span class="bad"> · changes requested by {m.changesRequested.join(", ")}</span> : null} | |
| 666 | + </span> | |
| 667 | + </div> | |
| 668 | + </div> | |
| 669 | + <div> | |
| 670 | + <div class="row"> | |
| 671 | + <Status conclusion={m.mergeable ? "success" : m.state === "conflicting" ? "failure" : "pending"} /> | |
| 672 | + <b>{headline}</b> | |
| 673 | + </div> | |
| 674 | + {m.conflicts.length ? ( | |
| 675 | + <ul class="small mono" style="margin:6px 0 0 24px"> | |
| 676 | + {m.conflicts.map((f) => ( | |
| 677 | + <li>{f}</li> | |
| 678 | + ))} | |
| 679 | + </ul> | |
| 680 | + ) : null} | |
| 681 | + {m.blockers.length ? ( | |
| 682 | + <ul class="small" style="margin:6px 0 0 24px;color:var(--fg2)"> | |
| 683 | + {m.blockers.map((b) => ( | |
| 684 | + <li>{b}</li> | |
| 685 | + ))} | |
| 686 | + </ul> | |
| 687 | + ) : null} | |
| 688 | + </div> | |
| 689 | + {isAccount && atLeast(caller.role, "write") && !x.l.repo.archived ? ( | |
| 690 | + <div> | |
| 691 | + <Form action={`${base}/pull/${i.number}/merge`} t={csrfToken(x.c)} class="row" confirm={bypass ? "Merge without meeting the branch requirements?" : undefined}> | |
| 692 | + <input type="hidden" name="expected_head" value={p.headSha} /> | |
| 693 | + <select name="method" style="width:auto"> | |
| 694 | + <option value="merge">Create a merge commit</option> | |
| 695 | + <option value="squash">Squash and merge</option> | |
| 696 | + <option value="rebase">Rebase and merge (fast-forward)</option> | |
| 697 | + </select> | |
| 698 | + <label class="inline"> | |
| 699 | + <input type="checkbox" name="delete_branch" value="1" checked /> Delete <span class="mono">{p.head}</span> after merge | |
| 700 | + </label> | |
| 701 | + <span class="grow" /> | |
| 702 | + {m.mergeable ? ( | |
| 703 | + <button class="btn green">Merge pull request</button> | |
| 704 | + ) : bypass ? ( | |
| 705 | + <button class="btn danger">Merge without waiting for requirements (bypass)</button> | |
| 706 | + ) : ( | |
| 707 | + <button class="btn" disabled> | |
| 708 | + Merge pull request | |
| 709 | + </button> | |
| 710 | + )} | |
| 711 | + </Form> | |
| 712 | + </div> | |
| 713 | + ) : null} | |
| 714 | + </div> | |
| 715 | + ); | |
| 716 | +}; | |
| 717 | + | |
| 718 | +const ReviewForm: FC<{ x: ConvCtx }> = ({ x }) => { | |
| 719 | + if (x.c.get("principal").type !== "account" || x.issue.state !== "open" || x.l.repo.archived) return null; | |
| 720 | + const own = x.issue.author.id === x.l.caller.id; | |
| 721 | + const canApprove = !own && atLeast(x.l.caller.role, "write"); | |
| 722 | + return ( | |
| 723 | + <details class="box"> | |
| 724 | + <summary class="head" style="cursor:pointer"> | |
| 725 | + {raw(ICONS.eye)} <b>Review changes</b> | |
| 726 | + <span class="muted small">{own ? "You can comment on your own pull request." : "Approve, request changes or leave a review comment."}</span> | |
| 727 | + </summary> | |
| 728 | + <Form action={`${repoUrl(x.l.repo)}/pull/${x.issue.number}/review`} t={csrfToken(x.c)} class="body"> | |
| 729 | + <div class="field"> | |
| 730 | + <textarea name="body" placeholder="Review summary (Markdown)"></textarea> | |
| 731 | + </div> | |
| 732 | + <div class="field"> | |
| 733 | + <label class="inline"> | |
| 734 | + <input type="radio" name="event" value="COMMENT" checked /> Comment | |
| 735 | + </label> | |
| 736 | + <label class="inline"> | |
| 737 | + <input type="radio" name="event" value="APPROVE" disabled={!canApprove} /> <span class="ok">Approve</span> | |
| 738 | + </label> | |
| 739 | + <label class="inline"> | |
| 740 | + <input type="radio" name="event" value="REQUEST_CHANGES" disabled={!canApprove} /> <span class="bad">Request changes</span> | |
| 741 | + </label> | |
| 742 | + </div> | |
| 743 | + <button class="btn primary">Submit review</button> | |
| 744 | + </Form> | |
| 745 | + </details> | |
| 746 | + ); | |
| 747 | +}; | |
| 748 | + | |
| 749 | +// ---------------------------------------------------------------- pages | |
| 750 | + | |
| 751 | +async function loadConv(c: AppContext): Promise<ConvCtx> { | |
| 752 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); | |
| 753 | + const n = issueNumber(c); | |
| 754 | + const [data, labels] = await Promise.all([l.stub.issue(n), l.stub.labels()]); | |
| 755 | + const behalf = await onBehalfOf(c, [data.issue.author, ...data.comments.map((x) => x.author), ...data.reviews.map((x) => x.author)]); | |
| 756 | + return { c, l, issue: data.issue, comments: data.comments, reviews: data.reviews, labels, behalf }; | |
| 757 | +} | |
| 758 | + | |
| 759 | +async function issuePage(c: AppContext): Promise<Response> { | |
| 760 | + const x = await loadConv(c); | |
| 761 | + const base = repoUrl(x.l.repo); | |
| 762 | + if (x.issue.kind === "pull") return c.redirect(`${base}/pull/${x.issue.number}${new URL(c.req.url).search}`, 302); | |
| 763 | + return render( | |
| 764 | + c, | |
| 765 | + { title: `${x.issue.title} · Issue #${x.issue.number}`, repo: { repo: x.l.repo, caller: x.l.caller, tab: "issues" }, live: `pr:${x.issue.number}` }, | |
| 766 | + <div> | |
| 767 | + <IssueHeader x={x} /> | |
| 768 | + <div class="grid"> | |
| 769 | + <div> | |
| 770 | + <div data-fragment="timeline"> | |
| 771 | + <CommentCard x={x} author={x.issue.author} at={x.issue.createdAt} body={x.issue.body} verb="opened" /> | |
| 772 | + <Timeline x={x} /> | |
| 773 | + </div> | |
| 774 | + <CommentForm x={x} /> | |
| 775 | + </div> | |
| 776 | + <Sidebar x={x} /> | |
| 777 | + </div> | |
| 778 | + </div>, | |
| 779 | + ); | |
| 780 | +} | |
| 781 | + | |
| 782 | +async function pullPage(c: AppContext, tab: "conversation" | "commits" | "files" | "checks"): Promise<Response> { | |
| 783 | + const x = await loadConv(c); | |
| 784 | + const { repo, caller, stub } = x.l; | |
| 785 | + const base = repoUrl(repo); | |
| 786 | + const i = x.issue; | |
| 787 | + if (i.kind !== "pull" || !i.pull) return c.redirect(`${base}/issues/${i.number}`, 302); | |
| 788 | + const n = i.number; | |
| 789 | + const page = (body: Child, wide = false) => | |
| 790 | + render( | |
| 791 | + c, | |
| 792 | + { title: `${i.title} · Pull request #${n}`, repo: { repo, caller, tab: "pulls", ref: i.pull!.head }, live: `pr:${n}`, wide }, | |
| 793 | + <div> | |
| 794 | + <IssueHeader x={x} /> | |
| 795 | + <PullTabs x={x} tab={tab} /> | |
| 796 | + {body} | |
| 797 | + </div>, | |
| 798 | + ); | |
| 799 | + | |
| 800 | + if (tab === "conversation") { | |
| 801 | + const [m, checks] = await Promise.all([i.state === "open" ? stub.mergeability(n, caller).catch(() => null) : Promise.resolve(null), stub.checks(i.pull.headSha).catch(() => [] as Check[])]); | |
| 802 | + return page( | |
| 803 | + <div class="grid"> | |
| 804 | + <div> | |
| 805 | + <div data-fragment="timeline"> | |
| 806 | + <CommentCard x={x} author={i.author} at={i.createdAt} body={i.body} verb="opened" /> | |
| 807 | + {i.pull.intent ? ( | |
| 808 | + <div class="comment intent"> | |
| 809 | + <div class="head"> | |
| 810 | + {raw(ICONS.zap)} <b>Intent</b> | |
| 811 | + <span class="muted small">what this change is meant to do</span> | |
| 812 | + </div> | |
| 813 | + <div class="body"> | |
| 814 | + <Markdown text={i.pull.intent} /> | |
| 815 | + </div> | |
| 816 | + </div> | |
| 817 | + ) : null} | |
| 818 | + <Timeline x={x} /> | |
| 819 | + </div> | |
| 820 | + <MergeBox x={x} m={m} checks={checks} /> | |
| 821 | + <ReviewForm x={x} /> | |
| 822 | + <CommentForm x={x} /> | |
| 823 | + </div> | |
| 824 | + <Sidebar x={x} /> | |
| 825 | + </div>, | |
| 826 | + ); | |
| 827 | + } | |
| 828 | + | |
| 829 | + if (tab === "commits") { | |
| 830 | + const commits = await stub.pullCommits(n).catch(() => []); | |
| 831 | + return page( | |
| 832 | + <div class="box"> | |
| 833 | + {commits.length ? ( | |
| 834 | + <ul class="list"> | |
| 835 | + {commits.map((cm) => ( | |
| 836 | + <CommitRow base={base} sha={cm.sha} message={cm.message} author={cm.author} signed={cm.signed} /> | |
| 837 | + ))} | |
| 838 | + </ul> | |
| 839 | + ) : ( | |
| 840 | + <div class="empty">No commits.</div> | |
| 841 | + )} | |
| 842 | + </div>, | |
| 843 | + ); | |
| 844 | + } | |
| 845 | + | |
| 846 | + if (tab === "checks") { | |
| 847 | + const [checks, runs] = await Promise.all([stub.checks(i.pull.headSha).catch(() => [] as Check[]), stub.runs({ prNumber: n, limit: 30 }).catch(() => ({ runs: [], total: 0 }))]); | |
| 848 | + return page( | |
| 849 | + <div data-fragment="checks"> | |
| 850 | + <div class="box"> | |
| 851 | + <div class="head"> | |
| 852 | + <h3>Checks for {shortSha(i.pull.headSha)}</h3> | |
| 853 | + </div> | |
| 854 | + {checks.length ? ( | |
| 855 | + <ul class="list"> | |
| 856 | + {checks.map((ch) => ( | |
| 857 | + <li> | |
| 858 | + <Status status={ch.status} conclusion={ch.conclusion} /> | |
| 859 | + <div class="grow"> | |
| 860 | + {ch.runId ? ( | |
| 861 | + <a class="title" href={`${base}/actions/runs/${ch.runId}${ch.jobId ? `/job/${ch.jobId}` : ""}`}> | |
| 862 | + {ch.name} | |
| 863 | + </a> | |
| 864 | + ) : ( | |
| 865 | + <span class="title">{ch.name}</span> | |
| 866 | + )} | |
| 867 | + <div class="meta"> | |
| 868 | + {ch.app} · {ch.status === "completed" ? (ch.conclusion ?? "neutral") : ch.status.replace("_", " ")} | |
| 869 | + {ch.output?.summary ? ` · ${ch.output.summary.slice(0, 200)}` : ""} | |
| 870 | + </div> | |
| 871 | + </div> | |
| 872 | + <span class="muted small">{duration(ch.startedAt, ch.completedAt)}</span> | |
| 873 | + </li> | |
| 874 | + ))} | |
| 875 | + </ul> | |
| 876 | + ) : ( | |
| 877 | + <div class="empty">No checks have reported for the latest commit.</div> | |
| 878 | + )} | |
| 879 | + </div> | |
| 880 | + <div class="box"> | |
| 881 | + <div class="head"> | |
| 882 | + <h3>Workflow runs</h3> | |
| 883 | + </div> | |
| 884 | + {runs.runs.length ? ( | |
| 885 | + <ul class="list"> | |
| 886 | + {runs.runs.map((r) => ( | |
| 887 | + <li> | |
| 888 | + <Status status={r.status} conclusion={r.conclusion} /> | |
| 889 | + <div class="grow"> | |
| 890 | + <a class="title" href={`${base}/actions/runs/${r.id}`}> | |
| 891 | + {r.title} | |
| 892 | + </a> | |
| 893 | + <div class="meta"> | |
| 894 | + {r.workflowName} #{r.number} · {r.event} · {shortSha(r.sha)} · <Time at={r.createdAt} /> | |
| 895 | + </div> | |
| 896 | + </div> | |
| 897 | + </li> | |
| 898 | + ))} | |
| 899 | + </ul> | |
| 900 | + ) : ( | |
| 901 | + <div class="empty">No workflow runs for this pull request.</div> | |
| 902 | + )} | |
| 903 | + </div> | |
| 904 | + </div>, | |
| 905 | + ); | |
| 906 | + } | |
| 907 | + | |
| 908 | + // Files changed | |
| 909 | + const diff = await stub.pullFiles(n); | |
| 910 | + const threads = new Map<string, LineThread[]>(); | |
| 911 | + const byKey = new Map<string, Comment[]>(); | |
| 912 | + for (const cm of x.comments) { | |
| 913 | + if (!cm.path || !cm.line) continue; | |
| 914 | + const key = `${cm.path}\u0000${cm.side === "LEFT" ? "LEFT" : "RIGHT"}\u0000${cm.line}`; | |
| 915 | + byKey.set(key, [...(byKey.get(key) ?? []), cm]); | |
| 916 | + } | |
| 917 | + for (const [key, list] of byKey) { | |
| 918 | + const [path, side, line] = key.split("\u0000") as [string, "LEFT" | "RIGHT", string]; | |
| 919 | + threads.set(path, [...(threads.get(path) ?? []), { side, line: Number(line), node: <LineComments x={x} list={list} /> }]); | |
| 920 | + } | |
| 921 | + const canComment = c.get("principal").type === "account" && i.state === "open" && !repo.archived; | |
| 922 | + const files = diff.files as DiffFile[]; | |
| 923 | + return page( | |
| 924 | + <div> | |
| 925 | + <div class="row between mb"> | |
| 926 | + <DiffStats files={diff.totalFiles} additions={diff.additions} deletions={diff.deletions} /> | |
| 927 | + <span class="muted small"> | |
| 928 | + {diff.mergeBase ? ( | |
| 929 | + <> | |
| 930 | + base <span class="sha">{shortSha(diff.mergeBase)}</span> →{" "} | |
| 931 | + </> | |
| 932 | + ) : null} | |
| 933 | + head <span class="sha">{shortSha(diff.headSha)}</span> | |
| 934 | + </span> | |
| 935 | + </div> | |
| 936 | + {canComment ? ( | |
| 937 | + <template id="line-comment-tpl"> | |
| 938 | + <Form action={`${base}/pull/${n}/line-comment`} t={csrfToken(c)}> | |
| 939 | + <input type="hidden" name="path" /> | |
| 940 | + <input type="hidden" name="line" /> | |
| 941 | + <input type="hidden" name="side" /> | |
| 942 | + <div class="small muted mb" style="margin-bottom:6px"> | |
| 943 | + Comment on <span class="where mono" /> | |
| 944 | + </div> | |
| 945 | + <textarea name="body" required placeholder="Leave a comment on this line" style="min-height:80px"></textarea> | |
| 946 | + <div class="row end" style="margin-top:6px"> | |
| 947 | + <button type="button" class="btn sm" data-cancel-line="1"> | |
| 948 | + Cancel | |
| 949 | + </button> | |
| 950 | + <button class="btn sm primary">Add comment</button> | |
| 951 | + </div> | |
| 952 | + </Form> | |
| 953 | + </template> | |
| 954 | + ) : null} | |
| 955 | + <FileSummary files={files} /> | |
| 956 | + <DiffView files={files} opts={{ commentable: canComment, threads, viewHref: (p) => `${base}/blob/${diff.headSha}/${encPath(p)}` }} /> | |
| 957 | + {diff.truncated ? <div class="flash info">Only the first {files.length} files are shown.</div> : null} | |
| 958 | + </div>, | |
| 959 | + true, | |
| 960 | + ); | |
| 961 | +} | |
| 962 | + | |
| 963 | +// ---------------------------------------------------------------- routes | |
| 964 | + | |
| 965 | +export function registerCollab(web: WebApp): void { | |
| 966 | + web.get("/:owner/:repo/issues", (c) => listPage(c, "issue")); | |
| 967 | + web.get("/:owner/:repo/pulls", (c) => listPage(c, "pull")); | |
| 968 | + | |
| 969 | + const NewIssue: FC<{ c: AppContext; l: Loaded; labels: LabelDef[]; v: { title: string; body: string; labels: string[] }; error?: string }> = ({ c, l, labels, v, error }) => ( | |
| 970 | + <div style="max-width:900px"> | |
| 971 | + <h1>New issue</h1> | |
| 972 | + {error ? <div class="flash error">{error}</div> : null} | |
| 973 | + <Form action={`${repoUrl(l.repo)}/issues`} t={csrfToken(c)} class="box"> | |
| 974 | + <div class="body"> | |
| 975 | + <div class="field"> | |
| 976 | + <label for="it">Title</label> | |
| 977 | + <input id="it" name="title" type="text" required value={v.title} autofocus /> | |
| 978 | + </div> | |
| 979 | + <div class="field"> | |
| 980 | + <label for="ib">Description</label> | |
| 981 | + <textarea id="ib" name="body" style="min-height:220px" placeholder="Markdown is supported."> | |
| 982 | + {v.body} | |
| 983 | + </textarea> | |
| 984 | + </div> | |
| 985 | + {labels.length && atLeast(l.caller.role, "triage") ? ( | |
| 986 | + <div class="field"> | |
| 987 | + <label>Labels</label> | |
| 988 | + {labels.map((lb) => ( | |
| 989 | + <label class="inline" style="margin-right:12px"> | |
| 990 | + <input type="checkbox" name="labels" value={lb.name} checked={v.labels.includes(lb.name)} /> <Label name={lb.name} color={lb.color} /> | |
| 991 | + </label> | |
| 992 | + ))} | |
| 993 | + </div> | |
| 994 | + ) : null} | |
| 995 | + <button class="btn green">Submit new issue</button> | |
| 996 | + </div> | |
| 997 | + </Form> | |
| 998 | + </div> | |
| 999 | + ); | |
| 1000 | + | |
| 1001 | + web.get("/:owner/:repo/issues/new", async (c) => { | |
| 1002 | + const l = await forAction(c); | |
| 1003 | + const labels = await l.stub.labels(); | |
| 1004 | + return render(c, { title: `New issue · ${l.repo.owner_login}/${l.repo.name}`, repo: { repo: l.repo, caller: l.caller, tab: "issues" } }, <NewIssue c={c} l={l} labels={labels} v={{ title: c.req.query("title") ?? "", body: "", labels: [] }} />); | |
| 1005 | + }); | |
| 1006 | + | |
| 1007 | + post(web, "/:owner/:repo/issues", async (c, f) => { | |
| 1008 | + const l = await forAction(c); | |
| 1009 | + const v = { title: f.s("title").trim(), body: f.s("body").replace(/\r\n/g, "\n"), labels: atLeast(l.caller.role, "triage") ? f.all("labels") : [] }; | |
| 1010 | + try { | |
| 1011 | + const issue = await l.stub.createIssue({ title: v.title, body: v.body, labels: v.labels }, l.caller); | |
| 1012 | + return c.redirect(`${repoUrl(l.repo)}/issues/${issue.number}`, 303); | |
| 1013 | + } catch (e) { | |
| 1014 | + const s = statusOf(e); | |
| 1015 | + if (s.status >= 500) throw e; | |
| 1016 | + const labels = await l.stub.labels(); | |
| 1017 | + return render(c, { title: "New issue", repo: { repo: l.repo, caller: l.caller, tab: "issues" }, status: s.status }, <NewIssue c={c} l={l} labels={labels} v={v} error={s.message} />); | |
| 1018 | + } | |
| 1019 | + }); | |
| 1020 | + | |
| 1021 | + web.get("/:owner/:repo/issues/:n", (c) => issuePage(c)); | |
| 1022 | + web.get("/:owner/:repo/pull/:n", (c) => pullPage(c, "conversation")); | |
| 1023 | + web.get("/:owner/:repo/pull/:n/commits", (c) => pullPage(c, "commits")); | |
| 1024 | + web.get("/:owner/:repo/pull/:n/files", (c) => pullPage(c, "files")); | |
| 1025 | + web.get("/:owner/:repo/pull/:n/checks", (c) => pullPage(c, "checks")); | |
| 1026 | + web.get("/:owner/:repo/pulls/:n", (c) => c.redirect(`/${prm(c, "owner")}/${prm(c, "repo")}/pull/${prm(c, "n")}`, 301)); | |
| 1027 | + | |
| 1028 | + const backTo = async (l: Loaded, n: number): Promise<string> => { | |
| 1029 | + const { issue } = await l.stub.issue(n); | |
| 1030 | + return `${repoUrl(l.repo)}/${kindPath(issue)}/${n}`; | |
| 1031 | + }; | |
| 1032 | + | |
| 1033 | + post(web, "/:owner/:repo/issues/:n/comment", async (c, f) => { | |
| 1034 | + const l = await forAction(c); | |
| 1035 | + const n = issueNumber(c); | |
| 1036 | + const back = await backTo(l, n); | |
| 1037 | + const body = f.s("body").replace(/\r\n/g, "\n"); | |
| 1038 | + const action = f.s("action"); | |
| 1039 | + return act(c, back, async () => { | |
| 1040 | + if (body.trim()) await l.stub.addComment(n, body, l.caller); | |
| 1041 | + else if (action === "comment") throw new HttpError(422, "write a comment first"); | |
| 1042 | + if (action === "close" || action === "not_planned") await l.stub.updateIssue(n, { state: "closed", stateReason: action === "not_planned" ? "not_planned" : "completed" }, l.caller); | |
| 1043 | + if (action === "reopen") await l.stub.updateIssue(n, { state: "open" }, l.caller); | |
| 1044 | + }); | |
| 1045 | + }); | |
| 1046 | + | |
| 1047 | + post(web, "/:owner/:repo/issues/:n/edit", async (c, f) => { | |
| 1048 | + const l = await forAction(c); | |
| 1049 | + const n = issueNumber(c); | |
| 1050 | + const back = await backTo(l, n); | |
| 1051 | + return act(c, back, async () => { | |
| 1052 | + const title = f.s("title").trim(); | |
| 1053 | + if (!title) throw new HttpError(422, "title is required"); | |
| 1054 | + await l.stub.updateIssue(n, { title, body: f.s("body").replace(/\r\n/g, "\n") }, l.caller); | |
| 1055 | + }); | |
| 1056 | + }); | |
| 1057 | + | |
| 1058 | + post(web, "/:owner/:repo/issues/:n/labels", async (c, f) => { | |
| 1059 | + const l = await forAction(c, "triage"); | |
| 1060 | + const n = issueNumber(c); | |
| 1061 | + const back = await backTo(l, n); | |
| 1062 | + return act(c, back, async () => { | |
| 1063 | + const labels = new Set(f.all("labels").map((x) => x.trim()).filter(Boolean)); | |
| 1064 | + const fresh = f.s("new_label").trim().slice(0, 50); | |
| 1065 | + if (fresh) { | |
| 1066 | + const existing = await l.stub.labels(); | |
| 1067 | + if (!existing.some((x) => x.name === fresh)) await l.stub.setLabel(fresh, LABEL_COLORS[existing.length % LABEL_COLORS.length]!, ""); | |
| 1068 | + labels.add(fresh); | |
| 1069 | + } | |
| 1070 | + await l.stub.updateIssue(n, { labels: [...labels] }, l.caller); | |
| 1071 | + }); | |
| 1072 | + }); | |
| 1073 | + | |
| 1074 | + post(web, "/:owner/:repo/issues/:n/assignees", async (c, f) => { | |
| 1075 | + const l = await forAction(c, "triage"); | |
| 1076 | + const n = issueNumber(c); | |
| 1077 | + const back = await backTo(l, n); | |
| 1078 | + return act(c, back, async () => { | |
| 1079 | + const assignees = [...new Set(f.s("assignees").split(/[\s,]+/).map((s) => s.replace(/^@/, "")).filter(Boolean))].slice(0, 10); | |
| 1080 | + await l.stub.updateIssue(n, { assignees }, l.caller); | |
| 1081 | + }); | |
| 1082 | + }); | |
| 1083 | + | |
| 1084 | + post(web, "/:owner/:repo/pull/:n/review", async (c, f) => { | |
| 1085 | + const l = await forAction(c); | |
| 1086 | + const n = issueNumber(c); | |
| 1087 | + const event = (["APPROVE", "REQUEST_CHANGES", "COMMENT"] as const).find((e) => e === f.s("event")) ?? "COMMENT"; | |
| 1088 | + return act(c, `${repoUrl(l.repo)}/pull/${n}`, async () => { | |
| 1089 | + await l.stub.submitReview(n, { event, body: f.s("body").replace(/\r\n/g, "\n") }, l.caller); | |
| 1090 | + }); | |
| 1091 | + }); | |
| 1092 | + | |
| 1093 | + post(web, "/:owner/:repo/pull/:n/line-comment", async (c, f) => { | |
| 1094 | + const l = await forAction(c); | |
| 1095 | + const n = issueNumber(c); | |
| 1096 | + const line = parseInt(f.s("line"), 10); | |
| 1097 | + const side = f.s("side") === "LEFT" ? "LEFT" : "RIGHT"; | |
| 1098 | + return act(c, `${repoUrl(l.repo)}/pull/${n}/files`, async () => { | |
| 1099 | + const body = f.s("body").replace(/\r\n/g, "\n"); | |
| 1100 | + if (!body.trim()) throw new HttpError(422, "comment is empty"); | |
| 1101 | + if (!Number.isInteger(line) || line <= 0 || !f.s("path")) throw new HttpError(422, "invalid line"); | |
| 1102 | + await l.stub.submitReview(n, { event: "COMMENT", comments: [{ path: f.s("path"), line, side, body }] }, l.caller); | |
| 1103 | + }); | |
| 1104 | + }); | |
| 1105 | + | |
| 1106 | + post(web, "/:owner/:repo/pull/:n/merge", async (c, f) => { | |
| 1107 | + const l = await forAction(c, "write"); | |
| 1108 | + const n = issueNumber(c); | |
| 1109 | + const method = (["merge", "squash", "rebase"] as const).find((m) => m === f.s("method")) ?? "merge"; | |
| 1110 | + return act( | |
| 1111 | + c, | |
| 1112 | + `${repoUrl(l.repo)}/pull/${n}`, | |
| 1113 | + async () => { | |
| 1114 | + await l.stub.mergePull(n, { method, deleteBranch: f.b("delete_branch"), expectedHeadSha: f.s("expected_head") || undefined }, l.caller); | |
| 1115 | + c.executionCtx.waitUntil(c.get("dir").touchPushed(l.repo.id)); | |
| 1116 | + }, | |
| 1117 | + "Pull request merged.", | |
| 1118 | + ); | |
| 1119 | + }); | |
| 1120 | + | |
| 1121 | + // Labels | |
| 1122 | + web.get("/:owner/:repo/labels", async (c) => { | |
| 1123 | + const { repo, caller, stub } = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); | |
| 1124 | + const base = repoUrl(repo); | |
| 1125 | + const t = csrfToken(c); | |
| 1126 | + const labels = await stub.labels(); | |
| 1127 | + const canEdit = atLeast(caller.role, "triage") && !repo.archived && c.get("principal").type === "account"; | |
| 1128 | + return render( | |
| 1129 | + c, | |
| 1130 | + { title: `Labels · ${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "issues" } }, | |
| 1131 | + <div style="max-width:900px"> | |
| 1132 | + <h1>Labels</h1> | |
| 1133 | + <div class="box"> | |
| 1134 | + {labels.length ? ( | |
| 1135 | + <ul class="list"> | |
| 1136 | + {labels.map((lb) => ( | |
| 1137 | + <li style="align-items:center"> | |
| 1138 | + <div style="width:220px"> | |
| 1139 | + <Label name={lb.name} color={lb.color} href={`${base}/issues${qs({ label: lb.name })}`} /> | |
| 1140 | + </div> | |
| 1141 | + <span class="grow muted small">{lb.description}</span> | |
| 1142 | + {canEdit ? ( | |
| 1143 | + <Form action={`${base}/labels/delete`} t={t} class="inline" confirm={`Delete label ${lb.name}?`}> | |
| 1144 | + <input type="hidden" name="name" value={lb.name} /> | |
| 1145 | + <button class="btn sm danger">Delete</button> | |
| 1146 | + </Form> | |
| 1147 | + ) : null} | |
| 1148 | + </li> | |
| 1149 | + ))} | |
| 1150 | + </ul> | |
| 1151 | + ) : ( | |
| 1152 | + <div class="empty">No labels yet.</div> | |
| 1153 | + )} | |
| 1154 | + </div> | |
| 1155 | + {canEdit ? ( | |
| 1156 | + <Form action={`${base}/labels`} t={t} class="box"> | |
| 1157 | + <div class="head"> | |
| 1158 | + <h3>New label</h3> | |
| 1159 | + </div> | |
| 1160 | + <div class="body row" style="align-items:flex-end"> | |
| 1161 | + <div class="field m0"> | |
| 1162 | + <label>Name</label> | |
| 1163 | + <input name="name" type="text" required maxlength={50} /> | |
| 1164 | + </div> | |
| 1165 | + <div class="field grow m0"> | |
| 1166 | + <label>Description</label> | |
| 1167 | + <input name="description" type="text" maxlength={100} /> | |
| 1168 | + </div> | |
| 1169 | + <div class="field m0"> | |
| 1170 | + <label>Color</label> | |
| 1171 | + <input name="color" type="color" value={`#${LABEL_COLORS[labels.length % LABEL_COLORS.length]}`} style="width:56px;height:36px;padding:2px" /> | |
| 1172 | + </div> | |
| 1173 | + <button class="btn primary">Create label</button> | |
| 1174 | + </div> | |
| 1175 | + </Form> | |
| 1176 | + ) : null} | |
| 1177 | + </div>, | |
| 1178 | + ); | |
| 1179 | + }); | |
| 1180 | + | |
| 1181 | + post(web, "/:owner/:repo/labels", async (c, f) => { | |
| 1182 | + const l = await forAction(c, "triage"); | |
| 1183 | + return act(c, `${repoUrl(l.repo)}/labels`, async () => { | |
| 1184 | + const name = f.s("name").trim().slice(0, 50); | |
| 1185 | + const color = f.s("color").replace(/^#/, ""); | |
| 1186 | + if (!name) throw new HttpError(422, "name is required"); | |
| 1187 | + if (!/^[0-9a-fA-F]{6}$/.test(color)) throw new HttpError(422, "invalid color"); | |
| 1188 | + await l.stub.setLabel(name, color, f.s("description").trim().slice(0, 100)); | |
| 1189 | + }); | |
| 1190 | + }); | |
| 1191 | + | |
| 1192 | + post(web, "/:owner/:repo/labels/delete", async (c, f) => { | |
| 1193 | + const l = await forAction(c, "triage"); | |
| 1194 | + return act(c, `${repoUrl(l.repo)}/labels`, async () => l.stub.setLabel(f.s("name"), null)); | |
| 1195 | + }); | |
| 1196 | +} | |
| 1197 | + | |
| 1198 | +const LABEL_COLORS = ["d73a4a", "0075ca", "a2eeef", "7057ff", "008672", "e4e669", "d876e3", "fbca04", "0e8a16", "5319e7", "b60205", "1d76db"]; |
addedapps/diffinery/src/web/repo-settings.tsx+744−0View file
| @@ -1,0 +1,744 @@ | ||
| 1 | +/** @jsxImportSource hono/jsx */ | |
| 2 | +import { raw } from "hono/html"; | |
| 3 | +import type { Child, FC } from "hono/jsx"; | |
| 4 | +import { requireAccount, type AppContext } from "../auth/principal.js"; | |
| 5 | +import { loadRepo } from "../http/common.js"; | |
| 6 | +import { HttpError } from "../lib/util.js"; | |
| 7 | +import type { ProtectionRules, Role } from "../repo/types.js"; | |
| 8 | +import { ICONS } from "./assets.js"; | |
| 9 | +import { csrfToken, type FormData } from "./csrf.js"; | |
| 10 | +import { Actor, CopyButton, Form, ROLES, Time, act, ago, post, refNames, render, repoUrl, statusOf, type WebApp, prm } from "./ui.js"; | |
| 11 | + | |
| 12 | +type Loaded = Awaited<ReturnType<typeof loadRepo>>; | |
| 13 | +type Tab = "general" | "access" | "branches" | "secrets" | "webhooks"; | |
| 14 | + | |
| 15 | +const WEBHOOK_EVENTS = ["push", "pull_request", "pull_request_review", "issues", "issue_comment", "workflow_run", "workflow_dispatch"]; | |
| 16 | + | |
| 17 | +const load = (c: AppContext) => loadRepo(c, prm(c, "owner"), prm(c, "repo"), "admin"); | |
| 18 | + | |
| 19 | +function audit(c: AppContext, l: Loaded, action: string, detail?: Record<string, unknown>): void { | |
| 20 | + const p = c.get("principal"); | |
| 21 | + const actor = p.type === "account" ? p.account : null; | |
| 22 | + c.executionCtx.waitUntil( | |
| 23 | + c | |
| 24 | + .get("dir") | |
| 25 | + .audit({ actorId: actor?.id, actorLogin: actor?.login, onBehalfOf: l.caller.onBehalfOf, action, target: `${l.repo.owner_login}/${l.repo.name}`, detail }) | |
| 26 | + .catch((e) => console.error("audit failed", e)), | |
| 27 | + ); | |
| 28 | +} | |
| 29 | + | |
| 30 | +function page(c: AppContext, l: Loaded, tab: Tab, body: Child, status?: number) { | |
| 31 | + const base = `${repoUrl(l.repo)}/settings`; | |
| 32 | + const nav: Array<[Tab, string, string]> = [ | |
| 33 | + ["general", "General", base], | |
| 34 | + ["access", "Collaborators", `${base}/access`], | |
| 35 | + ["branches", "Branch protection", `${base}/branches`], | |
| 36 | + ["secrets", "Secrets and variables", `${base}/secrets`], | |
| 37 | + ["webhooks", "Webhooks", `${base}/webhooks`], | |
| 38 | + ]; | |
| 39 | + return render( | |
| 40 | + c, | |
| 41 | + { title: `Settings · ${l.repo.owner_login}/${l.repo.name}`, repo: { repo: l.repo, caller: l.caller, tab: "settings" }, status }, | |
| 42 | + <div class="grid left"> | |
| 43 | + <aside class="side"> | |
| 44 | + <section> | |
| 45 | + <div class="nav"> | |
| 46 | + {nav.map(([k, label, href]) => ( | |
| 47 | + <a href={href} class={tab === k ? "on" : ""}> | |
| 48 | + {label} | |
| 49 | + </a> | |
| 50 | + ))} | |
| 51 | + </div> | |
| 52 | + </section> | |
| 53 | + </aside> | |
| 54 | + <div>{body}</div> | |
| 55 | + </div>, | |
| 56 | + ); | |
| 57 | +} | |
| 58 | + | |
| 59 | +const Once: FC<{ title: string; value: string; note: string }> = ({ title, value, note }) => ( | |
| 60 | + <div class="box" style="border-color:var(--amber)"> | |
| 61 | + <div class="body"> | |
| 62 | + <h3>{title}</h3> | |
| 63 | + <p class="muted">{note}</p> | |
| 64 | + <div class="row"> | |
| 65 | + <div class="secret grow">{value}</div> | |
| 66 | + <CopyButton text={value} /> | |
| 67 | + </div> | |
| 68 | + </div> | |
| 69 | + </div> | |
| 70 | +); | |
| 71 | + | |
| 72 | +// ---------------------------------------------------------------- protection form | |
| 73 | + | |
| 74 | +const RULE_FLAGS: Array<[keyof ProtectionRules, string, string]> = [ | |
| 75 | + ["requirePullRequest", "Require a pull request before merging", "Direct pushes are rejected; changes land through reviewed pull requests."], | |
| 76 | + ["dismissStaleReviews", "Dismiss stale approvals when new commits are pushed", ""], | |
| 77 | + ["requireLinearHistory", "Require linear history", "Merge commits are rejected; use squash or rebase merges."], | |
| 78 | + ["allowForcePush", "Allow force pushes", "Off by default for protected branches."], | |
| 79 | + ["allowDeletion", "Allow deletion", ""], | |
| 80 | + ["enforceAdmins", "Do not allow administrators to bypass these rules", ""], | |
| 81 | + ["agentsMayPush", "Allow agents to push directly", "By default agents must open pull requests against protected and default branches."], | |
| 82 | + ["agentsMayMerge", "Allow agents to merge pull requests", "By default a human must merge into protected and default branches."], | |
| 83 | +]; | |
| 84 | + | |
| 85 | +const RuleForm: FC<{ c: AppContext; base: string; pattern?: string; rules?: ProtectionRules }> = ({ c, base, pattern, rules = {} }) => ( | |
| 86 | + <Form action={`${base}/settings/branches`} t={csrfToken(c)} class="body"> | |
| 87 | + <input type="hidden" name="original" value={pattern ?? ""} /> | |
| 88 | + <div class="row" style="align-items:flex-start"> | |
| 89 | + <div class="field grow"> | |
| 90 | + <label>Branch name pattern</label> | |
| 91 | + <input name="pattern" type="text" required value={pattern ?? ""} placeholder="main, release/*, **" class="mono" /> | |
| 92 | + <div class="hint"> | |
| 93 | + <code>*</code> matches within a path segment, <code>**</code> across segments. | |
| 94 | + </div> | |
| 95 | + </div> | |
| 96 | + <div class="field" style="width:180px"> | |
| 97 | + <label>Required approvals</label> | |
| 98 | + <input name="requiredApprovals" type="number" min={0} max={10} value={String(rules.requiredApprovals ?? 0)} /> | |
| 99 | + </div> | |
| 100 | + </div> | |
| 101 | + <div class="field"> | |
| 102 | + <label>Required status checks</label> | |
| 103 | + <input name="requiredChecks" type="text" value={(rules.requiredChecks ?? []).join(", ")} placeholder="ci / test, lint" /> | |
| 104 | + <div class="hint">Comma-separated check names, exactly as they appear on pull requests.</div> | |
| 105 | + </div> | |
| 106 | + <div class="field"> | |
| 107 | + {RULE_FLAGS.map(([key, label, hint]) => ( | |
| 108 | + <label class="inline" style="margin:4px 0;align-items:flex-start"> | |
| 109 | + <input type="checkbox" name={key} value="1" checked={!!rules[key]} style="margin-top:4px" /> | |
| 110 | + <span> | |
| 111 | + {label} | |
| 112 | + {hint ? <span class="muted small"> — {hint}</span> : null} | |
| 113 | + </span> | |
| 114 | + </label> | |
| 115 | + ))} | |
| 116 | + </div> | |
| 117 | + <button class="btn primary">{pattern ? "Save rule" : "Create rule"}</button> | |
| 118 | + </Form> | |
| 119 | +); | |
| 120 | + | |
| 121 | +function rulesFrom(f: FormData): ProtectionRules { | |
| 122 | + const rules: ProtectionRules = {}; | |
| 123 | + for (const [key] of RULE_FLAGS) if (f.b(key)) (rules as Record<string, unknown>)[key] = true; | |
| 124 | + const approvals = Math.max(0, Math.min(10, parseInt(f.s("requiredApprovals"), 10) || 0)); | |
| 125 | + if (approvals) rules.requiredApprovals = approvals; | |
| 126 | + const checks = f | |
| 127 | + .s("requiredChecks") | |
| 128 | + .split(/[,\n]/) | |
| 129 | + .map((s) => s.trim()) | |
| 130 | + .filter(Boolean); | |
| 131 | + if (checks.length) rules.requiredChecks = checks; | |
| 132 | + return rules; | |
| 133 | +} | |
| 134 | + | |
| 135 | +// ---------------------------------------------------------------- pages | |
| 136 | + | |
| 137 | +async function generalPage(c: AppContext, l: Loaded) { | |
| 138 | + const { repo } = l; | |
| 139 | + const base = repoUrl(repo); | |
| 140 | + const t = csrfToken(c); | |
| 141 | + const names = await refNames(l.stub); | |
| 142 | + const full = `${repo.owner_login}/${repo.name}`; | |
| 143 | + return page( | |
| 144 | + c, | |
| 145 | + l, | |
| 146 | + "general", | |
| 147 | + <> | |
| 148 | + <h1>General</h1> | |
| 149 | + <Form action={`${base}/settings/general`} t={t} class="box"> | |
| 150 | + <div class="body"> | |
| 151 | + <div class="field"> | |
| 152 | + <label>Repository name</label> | |
| 153 | + <input name="name" type="text" value={repo.name} required pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,99}" /> | |
| 154 | + <div class="hint">Renaming changes the clone URL; update your remotes afterwards.</div> | |
| 155 | + </div> | |
| 156 | + <div class="field"> | |
| 157 | + <label>Description</label> | |
| 158 | + <input name="description" type="text" value={repo.description} maxlength={350} /> | |
| 159 | + </div> | |
| 160 | + <div class="field" style="max-width:320px"> | |
| 161 | + <label>Default branch</label> | |
| 162 | + {names.branches.length ? ( | |
| 163 | + <select name="default_branch"> | |
| 164 | + {names.branches.map((b) => ( | |
| 165 | + <option value={b} selected={b === repo.default_branch}> | |
| 166 | + {b} | |
| 167 | + </option> | |
| 168 | + ))} | |
| 169 | + </select> | |
| 170 | + ) : ( | |
| 171 | + <input name="default_branch" type="text" value={repo.default_branch} /> | |
| 172 | + )} | |
| 173 | + </div> | |
| 174 | + <div class="field"> | |
| 175 | + <label>Visibility</label> | |
| 176 | + {(["private", "internal", "public"] as const).map((v) => ( | |
| 177 | + <label class="inline" style="margin-right:16px"> | |
| 178 | + <input type="radio" name="visibility" value={v} checked={repo.visibility === v} /> {v} | |
| 179 | + </label> | |
| 180 | + ))} | |
| 181 | + <div class="hint">Internal repositories are visible to everyone signed in to this instance; public ones to anyone.</div> | |
| 182 | + </div> | |
| 183 | + <button class="btn primary">Save changes</button> | |
| 184 | + </div> | |
| 185 | + </Form> | |
| 186 | + <div class="box" style="border-color:color-mix(in srgb,var(--red) 45%,transparent)"> | |
| 187 | + <div class="head" style="background:var(--red-soft)"> | |
| 188 | + <h2 class="bad">Danger zone</h2> | |
| 189 | + </div> | |
| 190 | + <div class="body"> | |
| 191 | + <Form action={`${base}/settings/archive`} t={t} class="row between mb" confirm={repo.archived ? "Unarchive this repository?" : "Archive this repository? It becomes read-only."}> | |
| 192 | + <input type="hidden" name="archived" value={repo.archived ? "0" : "1"} /> | |
| 193 | + <div> | |
| 194 | + <b>{repo.archived ? "Unarchive this repository" : "Archive this repository"}</b> | |
| 195 | + <div class="muted small">{repo.archived ? "Make the repository writable again." : "Mark it read-only: no pushes, issues, pull requests or runs."}</div> | |
| 196 | + </div> | |
| 197 | + <button class="btn danger">{repo.archived ? "Unarchive" : "Archive"}</button> | |
| 198 | + </Form> | |
| 199 | + <hr /> | |
| 200 | + <Form action={`${base}/settings/delete`} t={t} match={full}> | |
| 201 | + <b>Delete this repository</b> | |
| 202 | + <p class="muted small"> | |
| 203 | + This permanently deletes the repository, its history, issues, pull requests, runs and secrets. Type <b class="mono">{full}</b> to confirm. | |
| 204 | + </p> | |
| 205 | + <div class="row"> | |
| 206 | + <input name="confirm" type="text" required autocomplete="off" style="max-width:320px" aria-label="Confirm repository name" /> | |
| 207 | + <button class="btn danger">Delete this repository</button> | |
| 208 | + </div> | |
| 209 | + </Form> | |
| 210 | + </div> | |
| 211 | + </div> | |
| 212 | + </>, | |
| 213 | + ); | |
| 214 | +} | |
| 215 | + | |
| 216 | +async function accessPage(c: AppContext, l: Loaded, invite?: { url: string; email: string }) { | |
| 217 | + const { repo } = l; | |
| 218 | + const base = repoUrl(repo); | |
| 219 | + const t = csrfToken(c); | |
| 220 | + const collaborators = await c.get("dir").collaborators(repo.id); | |
| 221 | + return page( | |
| 222 | + c, | |
| 223 | + l, | |
| 224 | + "access", | |
| 225 | + <> | |
| 226 | + <h1>Collaborators</h1> | |
| 227 | + <p class="muted"> | |
| 228 | + {repo.owner_kind === "org" ? ( | |
| 229 | + <> | |
| 230 | + Members of <a href={`/${repo.owner_login}`}>{repo.owner_login}</a> have write access (owners and admins: admin). Add people here for extra or narrower access. | |
| 231 | + </> | |
| 232 | + ) : ( | |
| 233 | + <> | |
| 234 | + <b>{repo.owner_login}</b> owns this repository. Add collaborators to share it with friends or agents. | |
| 235 | + </> | |
| 236 | + )} | |
| 237 | + </p> | |
| 238 | + {invite ? <Once title={`Invite created for ${invite.email}`} value={invite.url} note="Send this link. It expires in 14 days and only works for that email address. The person must also be allowed by your Cloudflare Access policy." /> : null} | |
| 239 | + <div class="box"> | |
| 240 | + {collaborators.length ? ( | |
| 241 | + <table class="tbl"> | |
| 242 | + <thead> | |
| 243 | + <tr> | |
| 244 | + <th>Account</th> | |
| 245 | + <th>Role</th> | |
| 246 | + <th /> | |
| 247 | + </tr> | |
| 248 | + </thead> | |
| 249 | + <tbody> | |
| 250 | + {collaborators.map((x) => ( | |
| 251 | + <tr> | |
| 252 | + <td> | |
| 253 | + <Actor login={x.account.login} kind={x.account.kind} /> | |
| 254 | + </td> | |
| 255 | + <td> | |
| 256 | + <Form action={`${base}/settings/access`} t={t} class="row" style="gap:6px;flex-wrap:nowrap"> | |
| 257 | + <input type="hidden" name="login" value={x.account.login} /> | |
| 258 | + <select name="role" style="width:auto;padding:3px 6px"> | |
| 259 | + {ROLES.map((r) => ( | |
| 260 | + <option value={r} selected={x.role === r}> | |
| 261 | + {r} | |
| 262 | + </option> | |
| 263 | + ))} | |
| 264 | + </select> | |
| 265 | + <button class="btn sm">Save</button> | |
| 266 | + </Form> | |
| 267 | + </td> | |
| 268 | + <td class="right"> | |
| 269 | + <Form action={`${base}/settings/access`} t={t} class="inline" confirm={`Remove ${x.account.login}'s access?`}> | |
| 270 | + <input type="hidden" name="login" value={x.account.login} /> | |
| 271 | + <input type="hidden" name="role" value="remove" /> | |
| 272 | + <button class="btn sm danger">Remove</button> | |
| 273 | + </Form> | |
| 274 | + </td> | |
| 275 | + </tr> | |
| 276 | + ))} | |
| 277 | + </tbody> | |
| 278 | + </table> | |
| 279 | + ) : ( | |
| 280 | + <div class="empty">No direct collaborators.</div> | |
| 281 | + )} | |
| 282 | + </div> | |
| 283 | + <div class="row" style="align-items:stretch"> | |
| 284 | + <Form action={`${base}/settings/access`} t={t} class="box grow"> | |
| 285 | + <div class="head"> | |
| 286 | + <h3>Add by username</h3> | |
| 287 | + </div> | |
| 288 | + <div class="body"> | |
| 289 | + <div class="field"> | |
| 290 | + <label>Username (person or agent)</label> | |
| 291 | + <input name="login" type="text" required /> | |
| 292 | + </div> | |
| 293 | + <div class="field"> | |
| 294 | + <label>Role</label> | |
| 295 | + <select name="role"> | |
| 296 | + {ROLES.map((r) => ( | |
| 297 | + <option value={r} selected={r === "write"}> | |
| 298 | + {r} | |
| 299 | + </option> | |
| 300 | + ))} | |
| 301 | + </select> | |
| 302 | + </div> | |
| 303 | + <button class="btn primary">Add collaborator</button> | |
| 304 | + </div> | |
| 305 | + </Form> | |
| 306 | + <Form action={`${base}/settings/access/invite`} t={t} class="box grow"> | |
| 307 | + <div class="head"> | |
| 308 | + <h3>Invite by email</h3> | |
| 309 | + </div> | |
| 310 | + <div class="body"> | |
| 311 | + <div class="field"> | |
| 312 | + <label>Email address</label> | |
| 313 | + <input name="email" type="email" required placeholder="friend@example.com" /> | |
| 314 | + </div> | |
| 315 | + <div class="field"> | |
| 316 | + <label>Role</label> | |
| 317 | + <select name="role"> | |
| 318 | + {ROLES.map((r) => ( | |
| 319 | + <option value={r} selected={r === "write"}> | |
| 320 | + {r} | |
| 321 | + </option> | |
| 322 | + ))} | |
| 323 | + </select> | |
| 324 | + </div> | |
| 325 | + <button class="btn primary">Create invite link</button> | |
| 326 | + </div> | |
| 327 | + </Form> | |
| 328 | + </div> | |
| 329 | + <div class="box"> | |
| 330 | + <div class="body small muted"> | |
| 331 | + <b>Roles:</b> read — view and clone · triage — manage issues and labels · write — push, open and merge pull requests, run workflows · maintain — also move tags · admin — settings, secrets and access. | |
| 332 | + </div> | |
| 333 | + </div> | |
| 334 | + </>, | |
| 335 | + ); | |
| 336 | +} | |
| 337 | + | |
| 338 | +async function branchesPage(c: AppContext, l: Loaded) { | |
| 339 | + const base = repoUrl(l.repo); | |
| 340 | + const t = csrfToken(c); | |
| 341 | + const rules = await l.stub.listProtections(); | |
| 342 | + return page( | |
| 343 | + c, | |
| 344 | + l, | |
| 345 | + "branches", | |
| 346 | + <> | |
| 347 | + <h1>Branch protection</h1> | |
| 348 | + <p class="muted"> | |
| 349 | + Rules protect matching branches from force pushes, deletion and unreviewed changes. The default branch (<span class="mono">{l.repo.default_branch}</span>) is always protected from agents. | |
| 350 | + </p> | |
| 351 | + {rules.map((r) => ( | |
| 352 | + <details class="box"> | |
| 353 | + <summary class="head" style="cursor:pointer"> | |
| 354 | + {raw(ICONS.shield)} <b class="mono">{r.pattern}</b> | |
| 355 | + <span class="muted small grow"> | |
| 356 | + {[ | |
| 357 | + r.rules.requirePullRequest ? "PR required" : "", | |
| 358 | + r.rules.requiredApprovals ? `${r.rules.requiredApprovals} approval${r.rules.requiredApprovals === 1 ? "" : "s"}` : "", | |
| 359 | + r.rules.requiredChecks?.length ? `${r.rules.requiredChecks.length} required check${r.rules.requiredChecks.length === 1 ? "" : "s"}` : "", | |
| 360 | + r.rules.requireLinearHistory ? "linear history" : "", | |
| 361 | + r.rules.enforceAdmins ? "admins included" : "", | |
| 362 | + ] | |
| 363 | + .filter(Boolean) | |
| 364 | + .join(" · ") || "basic protection"} | |
| 365 | + </span> | |
| 366 | + <Form action={`${base}/settings/branches/delete`} t={t} class="inline" confirm={`Delete the rule for ${r.pattern}?`}> | |
| 367 | + <input type="hidden" name="pattern" value={r.pattern} /> | |
| 368 | + <button class="btn sm danger">Delete</button> | |
| 369 | + </Form> | |
| 370 | + </summary> | |
| 371 | + <RuleForm c={c} base={base} pattern={r.pattern} rules={r.rules} /> | |
| 372 | + </details> | |
| 373 | + ))} | |
| 374 | + {rules.length === 0 ? <div class="blank mb">No branch protection rules yet.</div> : null} | |
| 375 | + <div class="box"> | |
| 376 | + <div class="head"> | |
| 377 | + <h3>Add a rule</h3> | |
| 378 | + </div> | |
| 379 | + <RuleForm c={c} base={base} rules={{ requirePullRequest: true, requiredApprovals: 1 }} /> | |
| 380 | + </div> | |
| 381 | + </>, | |
| 382 | + ); | |
| 383 | +} | |
| 384 | + | |
| 385 | +async function secretsPage(c: AppContext, l: Loaded) { | |
| 386 | + const base = repoUrl(l.repo); | |
| 387 | + const t = csrfToken(c); | |
| 388 | + const [secrets, vars] = await Promise.all([l.stub.secretNames(), l.stub.variables()]); | |
| 389 | + return page( | |
| 390 | + c, | |
| 391 | + l, | |
| 392 | + "secrets", | |
| 393 | + <> | |
| 394 | + <h1>Secrets and variables</h1> | |
| 395 | + <p class="muted"> | |
| 396 | + Use them in workflows as <code>{"${{ secrets.NAME }}"}</code> and <code>{"${{ vars.NAME }}"}</code>. Secret values are encrypted and never shown again; they are masked in logs. | |
| 397 | + </p> | |
| 398 | + <div class="box"> | |
| 399 | + <div class="head"> | |
| 400 | + <h2>{raw(ICONS.key)} Repository secrets</h2> | |
| 401 | + </div> | |
| 402 | + {secrets.length ? ( | |
| 403 | + <ul class="list"> | |
| 404 | + {secrets.map((s) => ( | |
| 405 | + <li style="align-items:center"> | |
| 406 | + <span class="mono grow"> | |
| 407 | + <b>{s.name}</b> | |
| 408 | + </span> | |
| 409 | + <span class="muted small">updated {ago(s.updatedAt)}</span> | |
| 410 | + <Form action={`${base}/settings/secrets/delete`} t={t} class="inline" confirm={`Delete secret ${s.name}?`}> | |
| 411 | + <input type="hidden" name="name" value={s.name} /> | |
| 412 | + <button class="btn sm danger">Delete</button> | |
| 413 | + </Form> | |
| 414 | + </li> | |
| 415 | + ))} | |
| 416 | + </ul> | |
| 417 | + ) : ( | |
| 418 | + <div class="empty">No secrets yet.</div> | |
| 419 | + )} | |
| 420 | + <Form action={`${base}/settings/secrets`} t={t} class="body" style="border-top:1px solid var(--border2)"> | |
| 421 | + <div class="field" style="max-width:360px"> | |
| 422 | + <label>Name</label> | |
| 423 | + <input name="name" type="text" required pattern="[A-Za-z_][A-Za-z0-9_]*" class="mono" placeholder="API_TOKEN" autocomplete="off" /> | |
| 424 | + </div> | |
| 425 | + <div class="field"> | |
| 426 | + <label>Value</label> | |
| 427 | + <textarea name="value" required class="mono" style="min-height:80px" autocomplete="off" spellcheck={false}></textarea> | |
| 428 | + <div class="hint">Setting an existing name replaces its value.</div> | |
| 429 | + </div> | |
| 430 | + <button class="btn primary">Save secret</button> | |
| 431 | + </Form> | |
| 432 | + </div> | |
| 433 | + <div class="box"> | |
| 434 | + <div class="head"> | |
| 435 | + <h2>Variables</h2> | |
| 436 | + </div> | |
| 437 | + {Object.keys(vars).length ? ( | |
| 438 | + <table class="tbl"> | |
| 439 | + <tbody> | |
| 440 | + {Object.entries(vars) | |
| 441 | + .sort(([a], [b]) => a.localeCompare(b)) | |
| 442 | + .map(([k, v]) => ( | |
| 443 | + <tr> | |
| 444 | + <td class="mono"> | |
| 445 | + <b>{k}</b> | |
| 446 | + </td> | |
| 447 | + <td class="mono small" style="word-break:break-all"> | |
| 448 | + {v} | |
| 449 | + </td> | |
| 450 | + <td class="right"> | |
| 451 | + <Form action={`${base}/settings/variables/delete`} t={t} class="inline" confirm={`Delete variable ${k}?`}> | |
| 452 | + <input type="hidden" name="name" value={k} /> | |
| 453 | + <button class="btn sm danger">Delete</button> | |
| 454 | + </Form> | |
| 455 | + </td> | |
| 456 | + </tr> | |
| 457 | + ))} | |
| 458 | + </tbody> | |
| 459 | + </table> | |
| 460 | + ) : ( | |
| 461 | + <div class="empty">No variables yet.</div> | |
| 462 | + )} | |
| 463 | + <Form action={`${base}/settings/variables`} t={t} class="body row" style="align-items:flex-end;border-top:1px solid var(--border2)"> | |
| 464 | + <div class="field m0" style="min-width:200px"> | |
| 465 | + <label>Name</label> | |
| 466 | + <input name="name" type="text" required pattern="[A-Za-z_][A-Za-z0-9_]*" class="mono" /> | |
| 467 | + </div> | |
| 468 | + <div class="field grow m0"> | |
| 469 | + <label>Value</label> | |
| 470 | + <input name="value" type="text" class="mono" /> | |
| 471 | + </div> | |
| 472 | + <button class="btn primary">Save variable</button> | |
| 473 | + </Form> | |
| 474 | + </div> | |
| 475 | + </>, | |
| 476 | + ); | |
| 477 | +} | |
| 478 | + | |
| 479 | +async function webhooksPage(c: AppContext, l: Loaded, created?: { secret: string; url: string }) { | |
| 480 | + const base = repoUrl(l.repo); | |
| 481 | + const t = csrfToken(c); | |
| 482 | + const hooks = await l.stub.webhooks(); | |
| 483 | + return page( | |
| 484 | + c, | |
| 485 | + l, | |
| 486 | + "webhooks", | |
| 487 | + <> | |
| 488 | + <h1>Webhooks</h1> | |
| 489 | + <p class="muted"> | |
| 490 | + Diffinery POSTs JSON events to these URLs, signed with an HMAC-SHA256 of the body using the webhook's secret. | |
| 491 | + </p> | |
| 492 | + {created ? <Once title={`Webhook created for ${created.url}`} value={created.secret} note="This is the signing secret. Copy it now; it will not be shown again." /> : null} | |
| 493 | + <div class="box"> | |
| 494 | + {hooks.length ? ( | |
| 495 | + <ul class="list"> | |
| 496 | + {hooks.map((h) => ( | |
| 497 | + <li style="align-items:center"> | |
| 498 | + <span class={`st ${h.lastStatus === null ? "faint" : h.lastStatus >= 200 && h.lastStatus < 300 ? "ok" : "bad"}`}>{raw(h.lastStatus === null ? ICONS.queued : h.lastStatus >= 200 && h.lastStatus < 300 ? ICONS.success : ICONS.failure)}</span> | |
| 499 | + <div class="grow"> | |
| 500 | + <b class="mono" style="word-break:break-all"> | |
| 501 | + {h.url} | |
| 502 | + </b> | |
| 503 | + <div class="meta"> | |
| 504 | + {h.events.join(", ")} · {h.active ? "active" : "inactive"} | |
| 505 | + {h.lastDeliveryAt ? ( | |
| 506 | + <> | |
| 507 | + {" "} | |
| 508 | + · last delivery {h.lastStatus ?? "?"} <Time at={h.lastDeliveryAt} /> | |
| 509 | + </> | |
| 510 | + ) : ( | |
| 511 | + " · no deliveries yet" | |
| 512 | + )} | |
| 513 | + </div> | |
| 514 | + </div> | |
| 515 | + <Form action={`${base}/settings/webhooks/delete`} t={t} class="inline" confirm="Delete this webhook?"> | |
| 516 | + <input type="hidden" name="id" value={h.id} /> | |
| 517 | + <button class="btn sm danger">Delete</button> | |
| 518 | + </Form> | |
| 519 | + </li> | |
| 520 | + ))} | |
| 521 | + </ul> | |
| 522 | + ) : ( | |
| 523 | + <div class="empty">No webhooks yet.</div> | |
| 524 | + )} | |
| 525 | + </div> | |
| 526 | + <Form action={`${base}/settings/webhooks`} t={t} class="box"> | |
| 527 | + <div class="head"> | |
| 528 | + <h3>Add a webhook</h3> | |
| 529 | + </div> | |
| 530 | + <div class="body"> | |
| 531 | + <div class="field"> | |
| 532 | + <label>Payload URL</label> | |
| 533 | + <input name="url" type="url" required placeholder="https://example.com/hooks/diffinery" pattern="https://.*" /> | |
| 534 | + </div> | |
| 535 | + <div class="field"> | |
| 536 | + <label>Events</label> | |
| 537 | + <label class="inline" style="margin-right:14px"> | |
| 538 | + <input type="checkbox" name="events" value="*" checked /> Everything | |
| 539 | + </label> | |
| 540 | + {WEBHOOK_EVENTS.map((e) => ( | |
| 541 | + <label class="inline" style="margin-right:14px"> | |
| 542 | + <input type="checkbox" name="events" value={e} /> <span class="mono small">{e}</span> | |
| 543 | + </label> | |
| 544 | + ))} | |
| 545 | + </div> | |
| 546 | + <div class="field" style="max-width:420px"> | |
| 547 | + <label> | |
| 548 | + Secret <span class="faint">(optional — generated when empty)</span> | |
| 549 | + </label> | |
| 550 | + <input name="secret" type="text" autocomplete="off" class="mono" /> | |
| 551 | + </div> | |
| 552 | + <button class="btn primary">Add webhook</button> | |
| 553 | + </div> | |
| 554 | + </Form> | |
| 555 | + </>, | |
| 556 | + ); | |
| 557 | +} | |
| 558 | + | |
| 559 | +// ---------------------------------------------------------------- routes | |
| 560 | + | |
| 561 | +export function registerRepoSettings(web: WebApp): void { | |
| 562 | + web.get("/:owner/:repo/settings", async (c) => generalPage(c, await load(c))); | |
| 563 | + web.get("/:owner/:repo/settings/access", async (c) => accessPage(c, await load(c))); | |
| 564 | + web.get("/:owner/:repo/settings/branches", async (c) => branchesPage(c, await load(c))); | |
| 565 | + web.get("/:owner/:repo/settings/secrets", async (c) => secretsPage(c, await load(c))); | |
| 566 | + web.get("/:owner/:repo/settings/webhooks", async (c) => webhooksPage(c, await load(c))); | |
| 567 | + | |
| 568 | + post(web, "/:owner/:repo/settings/general", async (c, f) => { | |
| 569 | + const l = await load(c); | |
| 570 | + const dir = c.get("dir"); | |
| 571 | + return act( | |
| 572 | + c, | |
| 573 | + `${repoUrl(l.repo)}/settings`, | |
| 574 | + async () => { | |
| 575 | + const name = f.s("name").trim(); | |
| 576 | + const visibility = (["private", "internal", "public"] as const).find((v) => v === f.s("visibility")); | |
| 577 | + const defaultBranch = f.s("default_branch").trim(); | |
| 578 | + if (name && name !== l.repo.name && (await dir.repo(l.repo.owner_login, name))) throw new HttpError(422, "a repository with that name already exists"); | |
| 579 | + const updated = await dir.updateRepo(l.repo, { | |
| 580 | + name: name && name !== l.repo.name ? name : undefined, | |
| 581 | + description: f.s("description").trim(), | |
| 582 | + visibility: visibility && visibility !== l.repo.visibility ? visibility : undefined, | |
| 583 | + defaultBranch: defaultBranch && defaultBranch !== l.repo.default_branch ? defaultBranch : undefined, | |
| 584 | + }); | |
| 585 | + audit(c, l, "repo.update", { name: updated.name, visibility: updated.visibility, defaultBranch: updated.default_branch }); | |
| 586 | + return `${repoUrl(updated)}/settings`; | |
| 587 | + }, | |
| 588 | + "Settings saved.", | |
| 589 | + ); | |
| 590 | + }); | |
| 591 | + | |
| 592 | + post(web, "/:owner/:repo/settings/archive", async (c, f) => { | |
| 593 | + const l = await load(c); | |
| 594 | + const archived = f.s("archived") === "1"; | |
| 595 | + return act( | |
| 596 | + c, | |
| 597 | + `${repoUrl(l.repo)}/settings`, | |
| 598 | + async () => { | |
| 599 | + await c.get("dir").updateRepo(l.repo, { archived }); | |
| 600 | + audit(c, l, archived ? "repo.archive" : "repo.unarchive"); | |
| 601 | + }, | |
| 602 | + archived ? "Repository archived." : "Repository unarchived.", | |
| 603 | + ); | |
| 604 | + }); | |
| 605 | + | |
| 606 | + post(web, "/:owner/:repo/settings/delete", async (c, f) => { | |
| 607 | + const l = await load(c); | |
| 608 | + requireAccount(c); | |
| 609 | + const full = `${l.repo.owner_login}/${l.repo.name}`; | |
| 610 | + return act( | |
| 611 | + c, | |
| 612 | + `${repoUrl(l.repo)}/settings`, | |
| 613 | + async () => { | |
| 614 | + if (f.s("confirm").trim() !== full) throw new HttpError(422, `type ${full} to confirm deletion`); | |
| 615 | + await c.get("dir").deleteRepo(l.repo); | |
| 616 | + audit(c, l, "repo.delete"); | |
| 617 | + return "/"; | |
| 618 | + }, | |
| 619 | + `Deleted ${full}.`, | |
| 620 | + ); | |
| 621 | + }); | |
| 622 | + | |
| 623 | + post(web, "/:owner/:repo/settings/access", async (c, f) => { | |
| 624 | + const l = await load(c); | |
| 625 | + const dir = c.get("dir"); | |
| 626 | + return act(c, `${repoUrl(l.repo)}/settings/access`, async () => { | |
| 627 | + const who = await dir.accountByLogin(f.s("login").trim()); | |
| 628 | + if (!who || who.kind === "org") throw new HttpError(404, "no person or agent with that username"); | |
| 629 | + const roleIn = f.s("role"); | |
| 630 | + const role: Role | null = roleIn === "remove" ? null : (ROLES.find((r) => r === roleIn) ?? null); | |
| 631 | + if (roleIn !== "remove" && !role) throw new HttpError(422, "invalid role"); | |
| 632 | + await dir.setCollaborator(l.repo.id, who.id, role); | |
| 633 | + audit(c, l, role ? "repo.collaborator.set" : "repo.collaborator.remove", { login: who.login, role }); | |
| 634 | + }); | |
| 635 | + }); | |
| 636 | + | |
| 637 | + post(web, "/:owner/:repo/settings/access/invite", async (c, f) => { | |
| 638 | + const l = await load(c); | |
| 639 | + const me = requireAccount(c); | |
| 640 | + const email = f.s("email").trim(); | |
| 641 | + const role = ROLES.find((r) => r === f.s("role")) ?? "write"; | |
| 642 | + if (!/^[^\s@]+@[^\s@]+$/.test(email)) throw new HttpError(422, "enter a valid email address"); | |
| 643 | + const inv = await c.get("dir").createInvite(me, { email, repoId: l.repo.id, role }); | |
| 644 | + audit(c, l, "repo.invite", { email, role }); | |
| 645 | + return accessPage(c, l, { url: inv.url, email }); | |
| 646 | + }); | |
| 647 | + | |
| 648 | + post(web, "/:owner/:repo/settings/branches", async (c, f) => { | |
| 649 | + const l = await load(c); | |
| 650 | + return act( | |
| 651 | + c, | |
| 652 | + `${repoUrl(l.repo)}/settings/branches`, | |
| 653 | + async () => { | |
| 654 | + const pattern = f.s("pattern").trim(); | |
| 655 | + if (!pattern || /\s/.test(pattern)) throw new HttpError(422, "enter a branch name pattern"); | |
| 656 | + const original = f.s("original").trim(); | |
| 657 | + const rules = rulesFrom(f); | |
| 658 | + if (original && original !== pattern) await l.stub.setProtection(original, null); | |
| 659 | + await l.stub.setProtection(pattern, rules); | |
| 660 | + audit(c, l, "repo.protection.set", { pattern, rules }); | |
| 661 | + }, | |
| 662 | + "Branch protection saved.", | |
| 663 | + ); | |
| 664 | + }); | |
| 665 | + | |
| 666 | + post(web, "/:owner/:repo/settings/branches/delete", async (c, f) => { | |
| 667 | + const l = await load(c); | |
| 668 | + return act(c, `${repoUrl(l.repo)}/settings/branches`, async () => { | |
| 669 | + await l.stub.setProtection(f.s("pattern"), null); | |
| 670 | + audit(c, l, "repo.protection.delete", { pattern: f.s("pattern") }); | |
| 671 | + }); | |
| 672 | + }); | |
| 673 | + | |
| 674 | + post(web, "/:owner/:repo/settings/secrets", async (c, f) => { | |
| 675 | + const l = await load(c); | |
| 676 | + return act( | |
| 677 | + c, | |
| 678 | + `${repoUrl(l.repo)}/settings/secrets`, | |
| 679 | + async () => { | |
| 680 | + const name = f.s("name").trim(); | |
| 681 | + try { | |
| 682 | + await l.stub.setSecret(name, f.s("value")); | |
| 683 | + } catch (e) { | |
| 684 | + const s = statusOf(e); | |
| 685 | + if (s.status < 500) throw e; | |
| 686 | + throw new HttpError(422, `could not store the secret: ${(e as Error).message || "encryption unavailable"} (is MASTER_KEY configured?)`); | |
| 687 | + } | |
| 688 | + audit(c, l, "repo.secret.set", { name: name.toUpperCase() }); | |
| 689 | + }, | |
| 690 | + "Secret saved.", | |
| 691 | + ); | |
| 692 | + }); | |
| 693 | + | |
| 694 | + post(web, "/:owner/:repo/settings/secrets/delete", async (c, f) => { | |
| 695 | + const l = await load(c); | |
| 696 | + return act(c, `${repoUrl(l.repo)}/settings/secrets`, async () => { | |
| 697 | + await l.stub.deleteSecret(f.s("name")); | |
| 698 | + audit(c, l, "repo.secret.delete", { name: f.s("name") }); | |
| 699 | + }); | |
| 700 | + }); | |
| 701 | + | |
| 702 | + post(web, "/:owner/:repo/settings/variables", async (c, f) => { | |
| 703 | + const l = await load(c); | |
| 704 | + return act(c, `${repoUrl(l.repo)}/settings/secrets`, async () => { | |
| 705 | + const name = f.s("name").trim(); | |
| 706 | + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new HttpError(422, "invalid variable name"); | |
| 707 | + await l.stub.setVariable(name, f.s("value")); | |
| 708 | + audit(c, l, "repo.variable.set", { name }); | |
| 709 | + }); | |
| 710 | + }); | |
| 711 | + | |
| 712 | + post(web, "/:owner/:repo/settings/variables/delete", async (c, f) => { | |
| 713 | + const l = await load(c); | |
| 714 | + return act(c, `${repoUrl(l.repo)}/settings/secrets`, async () => { | |
| 715 | + await l.stub.setVariable(f.s("name"), null); | |
| 716 | + audit(c, l, "repo.variable.delete", { name: f.s("name") }); | |
| 717 | + }); | |
| 718 | + }); | |
| 719 | + | |
| 720 | + post(web, "/:owner/:repo/settings/webhooks", async (c, f) => { | |
| 721 | + const l = await load(c); | |
| 722 | + const url = f.s("url").trim(); | |
| 723 | + const events = f.all("events").filter((e) => e === "*" || WEBHOOK_EVENTS.includes(e)); | |
| 724 | + try { | |
| 725 | + const hook = await l.stub.createWebhook(url, events.includes("*") ? ["*"] : events, f.s("secret").trim() || undefined); | |
| 726 | + audit(c, l, "repo.webhook.create", { url, events }); | |
| 727 | + return webhooksPage(c, l, { secret: hook.secret, url }); | |
| 728 | + } catch (e) { | |
| 729 | + const s = statusOf(e); | |
| 730 | + if (s.status >= 500) throw e; | |
| 731 | + return act(c, `${repoUrl(l.repo)}/settings/webhooks`, async () => { | |
| 732 | + throw e; | |
| 733 | + }); | |
| 734 | + } | |
| 735 | + }); | |
| 736 | + | |
| 737 | + post(web, "/:owner/:repo/settings/webhooks/delete", async (c, f) => { | |
| 738 | + const l = await load(c); | |
| 739 | + return act(c, `${repoUrl(l.repo)}/settings/webhooks`, async () => { | |
| 740 | + await l.stub.deleteWebhook(f.s("id")); | |
| 741 | + audit(c, l, "repo.webhook.delete", { id: f.s("id") }); | |
| 742 | + }); | |
| 743 | + }); | |
| 744 | +} |
addedapps/diffinery/src/web/routes.tsx+30−0View file
| @@ -1,0 +1,30 @@ | ||
| 1 | +/** @jsxImportSource hono/jsx */ | |
| 2 | +import { Hono } from "hono"; | |
| 3 | +import type { Env } from "../env.js"; | |
| 4 | +import type { AppVars } from "../auth/principal.js"; | |
| 5 | +import { registerAccount } from "./account.js"; | |
| 6 | +import { registerActions } from "./actions.js"; | |
| 7 | +import { registerCode } from "./code.js"; | |
| 8 | +import { registerCollab } from "./collab.js"; | |
| 9 | +import { registerRepoSettings } from "./repo-settings.js"; | |
| 10 | +import { errorPage } from "./ui.js"; | |
| 11 | + | |
| 12 | +/** | |
| 13 | + * Server-rendered web UI. Mount at "/" after the API, Git and runner routes: | |
| 14 | + * it owns `/`, `/new`, `/settings/*`, `/notifications/*`, `/invite/*`, `/-/*`, | |
| 15 | + * `/:owner` and `/:owner/:repo/...` (including `/:owner/:repo/raw/*`). | |
| 16 | + * | |
| 17 | + * Expects the `principal` and `dir` variables to be set by an earlier middleware. | |
| 18 | + * Every POST is CSRF-checked (double-submit cookie, see ./csrf.ts). | |
| 19 | + */ | |
| 20 | +export const web = new Hono<{ Bindings: Env; Variables: AppVars }>(); | |
| 21 | + | |
| 22 | +web.onError((err, c) => errorPage(c, err)); | |
| 23 | + | |
| 24 | +// Order matters: fixed paths (/settings/tokens, /notifications/:id, …) must be | |
| 25 | +// registered before the /:owner/:repo patterns they would otherwise match. | |
| 26 | +registerAccount(web); | |
| 27 | +registerRepoSettings(web); | |
| 28 | +registerActions(web); | |
| 29 | +registerCollab(web); | |
| 30 | +registerCode(web); |
modifiedapps/diffinery/src/web/ui.tsx+7−0View file
| @@ -171,6 +171,13 @@ | ||
| 171 | 171 | return s ? `?${s}` : ""; |
| 172 | 172 | } |
| 173 | 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 | + | |
| 174 | 181 | export const pageNum = (c: AppContext): number => Math.max(1, Math.min(1000, parseInt(c.req.query("page") ?? "1", 10) || 1)); |
| 175 | 182 | |
| 176 | 183 | // ---------------------------------------------------------------- errors, flash and actions |
addedapps/diffinery/test/e2e/e2e.test.mjs+315−0View file
| @@ -1,0 +1,315 @@ | ||
| 1 | +// End-to-end: a real `wrangler dev` Worker, the real git CLI and the real runner agent. | |
| 2 | +// | |
| 3 | +// pnpm --filter @diffinery/runner build && pnpm --filter @diffinery/app e2e | |
| 4 | +// | |
| 5 | +// Covers: token auth, repo creation, git push/clone/fetch (protocol v2), push | |
| 6 | +// protection, workflows triggered by push and pull_request and executed by a | |
| 7 | +// self-hosted runner (the same agent that runs in Cloudflare Containers), | |
| 8 | +// check runs, branch protection with required checks and human review for | |
| 9 | +// agent-authored PRs, merging, the REST contents API and the MCP server. | |
| 10 | +import { spawn, spawnSync, execFileSync } from "node:child_process"; | |
| 11 | +import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, existsSync, rmSync } from "node:fs"; | |
| 12 | +import { tmpdir } from "node:os"; | |
| 13 | +import { join, dirname, resolve } from "node:path"; | |
| 14 | +import { fileURLToPath } from "node:url"; | |
| 15 | +import { createServer } from "node:net"; | |
| 16 | +import { test, before, after } from "node:test"; | |
| 17 | +import assert from "node:assert/strict"; | |
| 18 | + | |
| 19 | +const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); | |
| 20 | +const AGENT = resolve(APP, "../../containers/runner/dist/agent.mjs"); | |
| 21 | +const work = mkdtempSync(join(tmpdir(), "diffinery-e2e-")); | |
| 22 | +let base; | |
| 23 | +let dev; | |
| 24 | +let devLog = ""; | |
| 25 | +let token; | |
| 26 | +let agentToken; | |
| 27 | + | |
| 28 | +const freePort = () => | |
| 29 | + new Promise((res) => { | |
| 30 | + const s = createServer(); | |
| 31 | + s.listen(0, "127.0.0.1", () => { | |
| 32 | + const { port } = s.address(); | |
| 33 | + s.close(() => res(port)); | |
| 34 | + }); | |
| 35 | + }); | |
| 36 | + | |
| 37 | +async function api(method, path, body, tok = token) { | |
| 38 | + const res = await fetch(`${base}/api/v3${path}`, { | |
| 39 | + method, | |
| 40 | + headers: { authorization: `Bearer ${tok}`, "content-type": "application/json" }, | |
| 41 | + body: body === undefined ? undefined : JSON.stringify(body), | |
| 42 | + }); | |
| 43 | + const text = await res.text(); | |
| 44 | + let json = null; | |
| 45 | + try { | |
| 46 | + json = text ? JSON.parse(text) : null; | |
| 47 | + } catch { | |
| 48 | + json = text; | |
| 49 | + } | |
| 50 | + return { status: res.status, json }; | |
| 51 | +} | |
| 52 | +async function ok(method, path, body, tok) { | |
| 53 | + const r = await api(method, path, body, tok); | |
| 54 | + assert.ok(r.status >= 200 && r.status < 300, `${method} ${path} -> ${r.status} ${JSON.stringify(r.json)}`); | |
| 55 | + return r.json; | |
| 56 | +} | |
| 57 | + | |
| 58 | +function git(cwd, args, tok = token, opts = {}) { | |
| 59 | + const auth = Buffer.from(`x-token:${tok}`).toString("base64"); | |
| 60 | + return execFileSync("git", ["-c", `http.extraHeader=Authorization: Basic ${auth}`, "-c", "protocol.version=2", ...args], { | |
| 61 | + cwd, | |
| 62 | + encoding: "utf8", | |
| 63 | + stdio: ["ignore", "pipe", "pipe"], | |
| 64 | + env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_AUTHOR_NAME: "E2E", GIT_AUTHOR_EMAIL: "e2e@example.com", GIT_COMMITTER_NAME: "E2E", GIT_COMMITTER_EMAIL: "e2e@example.com" }, | |
| 65 | + ...opts, | |
| 66 | + }); | |
| 67 | +} | |
| 68 | +function gitFails(cwd, args, tok) { | |
| 69 | + try { | |
| 70 | + git(cwd, args, tok); | |
| 71 | + } catch (e) { | |
| 72 | + return `${e.stdout ?? ""}${e.stderr ?? ""}`; | |
| 73 | + } | |
| 74 | + assert.fail(`expected git ${args.join(" ")} to fail`); | |
| 75 | +} | |
| 76 | + | |
| 77 | +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); | |
| 78 | +async function until(what, fn, timeoutMs = 60_000) { | |
| 79 | + const end = Date.now() + timeoutMs; | |
| 80 | + let last; | |
| 81 | + while (Date.now() < end) { | |
| 82 | + last = await fn(); | |
| 83 | + if (last) return last; | |
| 84 | + await sleep(500); | |
| 85 | + } | |
| 86 | + assert.fail(`timed out waiting for ${what}\n--- wrangler log tail ---\n${devLog.slice(-4000)}`); | |
| 87 | +} | |
| 88 | + | |
| 89 | +/** Claim one waiting self-hosted job and run it with the real agent. */ | |
| 90 | +async function runOneJob(owner, repo) { | |
| 91 | + const claim = await until("a job to claim", async () => { | |
| 92 | + const r = await api("POST", `/repos/${owner}/${repo}/actions/runners/claim`, { name: "e2e", labels: ["linux"] }); | |
| 93 | + return r.status === 200 ? r.json : null; | |
| 94 | + }); | |
| 95 | + const r = spawnSync(process.execPath, [AGENT], { | |
| 96 | + encoding: "utf8", | |
| 97 | + env: { ...process.env, DIFFINERY_API_URL: claim.api_url, DIFFINERY_JOB_ID: claim.job_id, DIFFINERY_JOB_TOKEN: claim.token, DIFFINERY_WORK_ROOT: join(work, `job-${claim.job_id}`) }, | |
| 98 | + timeout: 120_000, | |
| 99 | + }); | |
| 100 | + const out = `${r.stdout}${r.stderr}`; | |
| 101 | + if (r.status !== 0) { | |
| 102 | + const log = await (await fetch(`${base}/api/v3/repos/${owner}/${repo}/actions/jobs/${claim.job_id}/logs`, { headers: { authorization: `Bearer ${token}` } })).text(); | |
| 103 | + const job = await api("GET", `/repos/${owner}/${repo}/actions/jobs/${claim.job_id}`); | |
| 104 | + assert.fail(`runner agent exited ${r.status}:\n${out}\n--- job ---\n${JSON.stringify(job.json, null, 2)}\n--- job log ---\n${log}`); | |
| 105 | + } | |
| 106 | + return { claim, out }; | |
| 107 | +} | |
| 108 | + | |
| 109 | +before(async () => { | |
| 110 | + assert.ok(existsSync(AGENT), `build the runner first: pnpm --filter @diffinery/runner build (missing ${AGENT})`); | |
| 111 | + const port = await freePort(); | |
| 112 | + base = `http://127.0.0.1:${port}`; | |
| 113 | + const persist = join(work, "state"); | |
| 114 | + execFileSync("pnpm", ["exec", "wrangler", "d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } }); | |
| 115 | + // Until the web UI exists, run the same app through the API-only entry. | |
| 116 | + const entry = existsSync(join(APP, "src/web/routes.tsx")) ? [] : ["test/e2e/entry.ts"]; | |
| 117 | + dev = spawn("pnpm", ["exec", "wrangler", "dev", ...entry, "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--show-interactive-dev-session=false"], { | |
| 118 | + cwd: APP, | |
| 119 | + env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, | |
| 120 | + stdio: ["ignore", "pipe", "pipe"], | |
| 121 | + detached: true, | |
| 122 | + }); | |
| 123 | + dev.stdout.on("data", (d) => (devLog += d)); | |
| 124 | + dev.stderr.on("data", (d) => (devLog += d)); | |
| 125 | + await until("wrangler dev", async () => { | |
| 126 | + try { | |
| 127 | + return (await fetch(`${base}/healthz`)).ok; | |
| 128 | + } catch { | |
| 129 | + return false; | |
| 130 | + } | |
| 131 | + }, 120_000); | |
| 132 | + // DEV_AUTH signs requests without credentials in as dev@diffinery.local; mint a real token for everything else. | |
| 133 | + const res = await fetch(`${base}/api/v3/diffinery/tokens`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "e2e", scopes: ["admin"] }) }); | |
| 134 | + assert.equal(res.status, 201, await res.clone().text()); | |
| 135 | + token = (await res.json()).token; | |
| 136 | +}); | |
| 137 | + | |
| 138 | +after(() => { | |
| 139 | + if (dev?.pid) { | |
| 140 | + try { | |
| 141 | + process.kill(-dev.pid, "SIGTERM"); | |
| 142 | + } catch {} | |
| 143 | + } | |
| 144 | + if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true }); | |
| 145 | +}); | |
| 146 | + | |
| 147 | +test("git hosting, Actions, protected PR flow and MCP", async () => { | |
| 148 | + const me = await ok("GET", "/user"); | |
| 149 | + const owner = me.login; | |
| 150 | + assert.ok(me.site_admin, "dev user is a bootstrap admin"); | |
| 151 | + | |
| 152 | + // ------------------------------------------------------------ repo + first push | |
| 153 | + const repo = await ok("POST", "/user/repos", { name: "demo", description: "e2e" }); | |
| 154 | + assert.equal(repo.full_name, `${owner}/demo`); | |
| 155 | + const src = join(work, "src"); | |
| 156 | + mkdirSync(join(src, ".github/workflows"), { recursive: true }); | |
| 157 | + writeFileSync(join(src, "README.md"), "# demo\n"); | |
| 158 | + writeFileSync(join(src, "app.txt"), "one\ntwo\nthree\n"); | |
| 159 | + writeFileSync( | |
| 160 | + join(src, ".github/workflows/ci.yml"), | |
| 161 | + `name: CI | |
| 162 | +on: | |
| 163 | + push: | |
| 164 | + branches: [main] | |
| 165 | + pull_request: | |
| 166 | +jobs: | |
| 167 | + build: | |
| 168 | + runs-on: [self-hosted, linux] | |
| 169 | + steps: | |
| 170 | + - uses: actions/checkout@v4 | |
| 171 | + - name: Check files | |
| 172 | + run: | | |
| 173 | + test -f README.md | |
| 174 | + echo "event=\${{ github.event_name }} sha=\${{ github.sha }}" | |
| 175 | + grep -q bad app.txt && { echo "found bad line"; exit 1; } || true | |
| 176 | + - id: out | |
| 177 | + run: echo "lines=$(wc -l < app.txt | tr -d ' ')" >> "$GITHUB_OUTPUT" | |
| 178 | + - run: echo "app.txt has \${{ steps.out.outputs.lines }} lines" | |
| 179 | +`, | |
| 180 | + ); | |
| 181 | + git(src, ["init", "-q", "-b", "main"]); | |
| 182 | + git(src, ["add", "-A"]); | |
| 183 | + git(src, ["commit", "-q", "-m", "initial"]); | |
| 184 | + git(src, ["remote", "add", "origin", `${base}/${owner}/demo.git`]); | |
| 185 | + git(src, ["push", "-q", "origin", "main"]); | |
| 186 | + const mainSha = git(src, ["rev-parse", "HEAD"]).trim(); | |
| 187 | + | |
| 188 | + const branch = await ok("GET", `/repos/${owner}/demo/branches/main`); | |
| 189 | + assert.equal(branch.commit.sha, mainSha); | |
| 190 | + | |
| 191 | + // ------------------------------------------------------------ the push ran CI on a self-hosted runner | |
| 192 | + const pushRun = await until("push run", async () => (await ok("GET", `/repos/${owner}/demo/actions/runs?event=push`)).workflow_runs[0]); | |
| 193 | + assert.equal(pushRun.head_sha, mainSha); | |
| 194 | + const job1 = await runOneJob(owner, "demo"); | |
| 195 | + const finished = await until("push run to complete", async () => { | |
| 196 | + const r = await ok("GET", `/repos/${owner}/demo/actions/runs/${pushRun.id}`); | |
| 197 | + return r.status === "completed" ? r : null; | |
| 198 | + }); | |
| 199 | + assert.equal(finished.conclusion, "success", job1.out); | |
| 200 | + const logs = await (await fetch(`${base}/api/v3/repos/${owner}/demo/actions/jobs/${job1.claim.job_id}/logs`, { headers: { authorization: `Bearer ${token}` } })).text(); | |
| 201 | + assert.match(logs, /event=push/); | |
| 202 | + assert.match(logs, /app\.txt has 3 lines/); | |
| 203 | + const checks = await ok("GET", `/repos/${owner}/demo/commits/${mainSha}/check-runs`); | |
| 204 | + assert.equal(checks.check_runs.find((c) => c.name === "CI / build")?.conclusion, "success"); | |
| 205 | + | |
| 206 | + // ------------------------------------------------------------ clone over protocol v2 | |
| 207 | + const clone = join(work, "clone"); | |
| 208 | + git(work, ["clone", "-q", `${base}/${owner}/demo.git`, clone]); | |
| 209 | + assert.equal(readFileSync(join(clone, "app.txt"), "utf8"), "one\ntwo\nthree\n"); | |
| 210 | + | |
| 211 | + // ------------------------------------------------------------ secrets are blocked on push | |
| 212 | + writeFileSync(join(clone, "config.env"), "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPL3\n"); | |
| 213 | + git(clone, ["add", "-A"]); | |
| 214 | + git(clone, ["commit", "-q", "-m", "oops"]); | |
| 215 | + assert.match(gitFails(clone, ["push", "origin", "main"]), /secret|AWS/i); | |
| 216 | + git(clone, ["reset", "-q", "--hard", "origin/main"]); | |
| 217 | + | |
| 218 | + // ------------------------------------------------------------ protect main: PR + green CI + review | |
| 219 | + await ok("PUT", `/repos/${owner}/demo/branches/main/protection`, { | |
| 220 | + requirePullRequest: true, | |
| 221 | + requiredApprovals: 0, | |
| 222 | + dismissStaleReviews: true, | |
| 223 | + requiredChecks: ["CI / build"], | |
| 224 | + allowForcePush: false, | |
| 225 | + allowDeletion: false, | |
| 226 | + requireLinearHistory: false, | |
| 227 | + enforceAdmins: true, | |
| 228 | + agentsMayPush: false, | |
| 229 | + agentsMayMerge: false, | |
| 230 | + }); | |
| 231 | + writeFileSync(join(clone, "app.txt"), "one\ntwo\nthree\nfour\n"); | |
| 232 | + git(clone, ["commit", "-q", "-am", "direct push"]); | |
| 233 | + assert.match(gitFails(clone, ["push", "origin", "main"]), /pull request|protected/i); | |
| 234 | + git(clone, ["reset", "-q", "--hard", "origin/main"]); | |
| 235 | + | |
| 236 | + // ------------------------------------------------------------ an AI agent opens a PR | |
| 237 | + const created = await ok("POST", "/diffinery/agents", { login: "e2e-bot", name: "E2E Bot" }); | |
| 238 | + agentToken = created.token.token; | |
| 239 | + const agentClone = join(work, "agent"); | |
| 240 | + git(work, ["clone", "-q", `${base}/${owner}/demo.git`, agentClone], agentToken); | |
| 241 | + git(agentClone, ["checkout", "-q", "-b", "feature"], agentToken); | |
| 242 | + writeFileSync(join(agentClone, "app.txt"), "one\ntwo\nthree\nfour\n"); | |
| 243 | + git(agentClone, ["commit", "-q", "-am", "Add a fourth line"], agentToken); | |
| 244 | + assert.match(gitFails(agentClone, ["push", "origin", "HEAD:main"], agentToken), /agent|protected|pull request/i); | |
| 245 | + git(agentClone, ["push", "-q", "origin", "feature"], agentToken); | |
| 246 | + const pr = await ok("POST", `/repos/${owner}/demo/pulls`, { title: "Add a fourth line", head: "feature", base: "main", intent: "Extend app.txt for the e2e test" }, agentToken); | |
| 247 | + assert.equal(pr.number, 1); | |
| 248 | + | |
| 249 | + // The pull_request run executes; until then the PR is blocked on the required check. | |
| 250 | + let m = await ok("GET", `/repos/${owner}/demo/pulls/1/merge`); | |
| 251 | + assert.equal(m.mergeable, false); | |
| 252 | + await runOneJob(owner, "demo"); | |
| 253 | + await until("PR check to pass", async () => { | |
| 254 | + const r = await ok("GET", `/repos/${owner}/demo/pulls/1/merge`); | |
| 255 | + return r.checks?.length > 0 && r.checks.every((c) => c.state === "success") ? r : null; | |
| 256 | + }); | |
| 257 | + m = await ok("GET", `/repos/${owner}/demo/pulls/1/merge`); | |
| 258 | + assert.equal(m.mergeable, false, "agent-authored PRs need a human approval"); | |
| 259 | + assert.ok(JSON.stringify(m.blockers).match(/approv|human/i), JSON.stringify(m.blockers)); | |
| 260 | + | |
| 261 | + // The agent cannot approve or merge its own work. | |
| 262 | + const agentMerge = await api("PUT", `/repos/${owner}/demo/pulls/1/merge`, { merge_method: "squash" }, agentToken); | |
| 263 | + assert.ok([403, 405, 409].includes(agentMerge.status), JSON.stringify(agentMerge)); | |
| 264 | + | |
| 265 | + // A human reviews (line comment + approval) and merges. | |
| 266 | + await ok("POST", `/repos/${owner}/demo/pulls/1/reviews`, { event: "APPROVE", body: "LGTM", comments: [{ path: "app.txt", line: 4, body: "nice line" }] }); | |
| 267 | + m = await ok("GET", `/repos/${owner}/demo/pulls/1/merge`); | |
| 268 | + assert.equal(m.mergeable, true, JSON.stringify(m)); | |
| 269 | + const merged = await ok("PUT", `/repos/${owner}/demo/pulls/1/merge`, { merge_method: "squash", delete_branch: true }); | |
| 270 | + assert.ok(merged.sha); | |
| 271 | + const prAfter = await ok("GET", `/repos/${owner}/demo/pulls/1`); | |
| 272 | + assert.equal(prAfter.merged, true); | |
| 273 | + | |
| 274 | + // ------------------------------------------------------------ fetch the merge, read through REST | |
| 275 | + git(clone, ["pull", "-q", "--ff-only", "origin", "main"]); | |
| 276 | + assert.equal(readFileSync(join(clone, "app.txt"), "utf8"), "one\ntwo\nthree\nfour\n"); | |
| 277 | + const content = await ok("GET", `/repos/${owner}/demo/contents/app.txt`); | |
| 278 | + assert.equal(Buffer.from(content.content, "base64").toString(), "one\ntwo\nthree\nfour\n"); | |
| 279 | + const branches = await ok("GET", `/repos/${owner}/demo/branches`); | |
| 280 | + assert.ok(!branches.some((b) => b.name === "feature"), "head branch deleted after merge"); | |
| 281 | + | |
| 282 | + // ------------------------------------------------------------ shallow clone, then deepen | |
| 283 | + const shallow = join(work, "shallow"); | |
| 284 | + git(work, ["clone", "-q", "--depth", "1", `${base}/${owner}/demo.git`, shallow]); | |
| 285 | + assert.equal(git(shallow, ["rev-list", "--count", "HEAD"]).trim(), "1"); | |
| 286 | + assert.equal(readFileSync(join(shallow, "app.txt"), "utf8"), "one\ntwo\nthree\nfour\n"); | |
| 287 | + git(shallow, ["fetch", "-q", "--unshallow"]); | |
| 288 | + assert.equal(git(shallow, ["rev-list", "--count", "HEAD"]).trim(), "2"); | |
| 289 | + git(shallow, ["fsck", "--no-progress"]); | |
| 290 | + | |
| 291 | + // ------------------------------------------------------------ MCP: the agent works without git | |
| 292 | + const rpc = async (method, params, id = 1) => { | |
| 293 | + const r = await fetch(`${base}/mcp`, { method: "POST", headers: { authorization: `Bearer ${agentToken}`, "content-type": "application/json", accept: "application/json, text/event-stream" }, body: JSON.stringify({ jsonrpc: "2.0", id, method, params }) }); | |
| 294 | + assert.equal(r.status, 200); | |
| 295 | + return (await r.json()).result; | |
| 296 | + }; | |
| 297 | + const init = await rpc("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "e2e", version: "1" } }); | |
| 298 | + assert.equal(init.serverInfo.name, "diffinery"); | |
| 299 | + const tools = await rpc("tools/list", {}); | |
| 300 | + assert.ok(tools.tools.some((t) => t.name === "commit_files")); | |
| 301 | + const file = await rpc("tools/call", { name: "get_file", arguments: { owner, repo: "demo", path: "README.md" } }); | |
| 302 | + assert.equal(file.isError, false); | |
| 303 | + assert.equal(file.structuredContent.content, "# demo\n"); | |
| 304 | + const commit = await rpc("tools/call", { name: "commit_files", arguments: { owner, repo: "demo", branch: "mcp-change", create_branch_from: "main", message: "Edit via MCP", files: [{ path: "docs/notes.md", content: "hello\n" }] } }); | |
| 305 | + assert.equal(commit.isError, false, JSON.stringify(commit)); | |
| 306 | + const blocked = await rpc("tools/call", { name: "commit_files", arguments: { owner, repo: "demo", branch: "main", message: "sneaky", files: [{ path: "x", content: "y" }] } }); | |
| 307 | + assert.equal(blocked.isError, true); | |
| 308 | + const opened = await rpc("tools/call", { name: "create_pull_request", arguments: { owner, repo: "demo", title: "Notes", head: "mcp-change", intent: "Add notes" } }); | |
| 309 | + assert.equal(opened.isError, false, JSON.stringify(opened)); | |
| 310 | + assert.equal(opened.structuredContent.number, 2); | |
| 311 | + | |
| 312 | + // The commit made through MCP carries the agent's identity. | |
| 313 | + const agentCommit = await ok("GET", `/repos/${owner}/demo/commits/mcp-change`); | |
| 314 | + assert.match(JSON.stringify(agentCommit), /e2e-bot/); | |
| 315 | +}); |
addedapps/diffinery/test/e2e/entry.ts+10−0View file
| @@ -1,0 +1,10 @@ | ||
| 1 | +// Worker entry for end-to-end tests while the web UI is optional: same app, no HTML routes. | |
| 2 | +import type { DiffineryEvent } from "@diffinery/shared"; | |
| 3 | +import type { Env } from "../../src/env.js"; | |
| 4 | +import { app, queue, scheduled } from "../../src/app.js"; | |
| 5 | + | |
| 6 | +export { RepoDO } from "../../src/repo/RepoDO.js"; | |
| 7 | +export { RunnerContainer, SandboxContainer } from "../../src/runner/containers.js"; | |
| 8 | +export { RunWorkflow } from "../../src/runner/workflow.js"; | |
| 9 | + | |
| 10 | +export default { fetch: app.fetch, queue, scheduled } satisfies ExportedHandler<Env, DiffineryEvent>; |
addedapps/diffinery/wrangler.jsonc+78−0View file
| @@ -1,0 +1,78 @@ | ||
| 1 | +// Diffinery: one Worker, everything on Cloudflare. | |
| 2 | +// `scripts/bootstrap.sh` creates the account resources and writes | |
| 3 | +// wrangler.deploy.jsonc (this file plus real IDs) for CI deploys. | |
| 4 | +{ | |
| 5 | + "$schema": "node_modules/wrangler/config-schema.json", | |
| 6 | + "name": "diffinery", | |
| 7 | + "main": "src/index.ts", | |
| 8 | + "compatibility_date": "2026-10-01", | |
| 9 | + "compatibility_flags": ["nodejs_compat"], | |
| 10 | + "workers_dev": false, | |
| 11 | + "routes": [{ "pattern": "diffinery.com", "custom_domain": true }], | |
| 12 | + "observability": { "enabled": true, "head_sampling_rate": 1 }, | |
| 13 | + "upload_source_maps": true, | |
| 14 | + "limits": { "cpu_ms": 300000 }, | |
| 15 | + | |
| 16 | + "vars": { | |
| 17 | + "PUBLIC_URL": "https://diffinery.com", | |
| 18 | + "BOOTSTRAP_ADMINS": "cnbrkdmrci,iletisim@canberkdemirci.com,canberkdemirci@icloud.com,iletisim@altugankarali.com", | |
| 19 | + "ACCESS_TEAM_DOMAIN": "", | |
| 20 | + "ACCESS_AUD": "" | |
| 21 | + }, | |
| 22 | + | |
| 23 | + "d1_databases": [ | |
| 24 | + { "binding": "DB", "database_name": "diffinery", "database_id": "00000000-0000-0000-0000-000000000000", "migrations_dir": "migrations" } | |
| 25 | + ], | |
| 26 | + "r2_buckets": [{ "binding": "BUCKET", "bucket_name": "diffinery-data" }], | |
| 27 | + "queues": { | |
| 28 | + "producers": [{ "binding": "EVENTS", "queue": "diffinery-events" }], | |
| 29 | + "consumers": [ | |
| 30 | + { "queue": "diffinery-events", "max_batch_size": 50, "max_batch_timeout": 2, "max_retries": 8, "dead_letter_queue": "diffinery-events-dlq" } | |
| 31 | + ] | |
| 32 | + }, | |
| 33 | + "durable_objects": { | |
| 34 | + "bindings": [ | |
| 35 | + { "name": "REPO", "class_name": "RepoDO" }, | |
| 36 | + { "name": "RUNNER", "class_name": "RunnerContainer" }, | |
| 37 | + { "name": "SANDBOX", "class_name": "SandboxContainer" } | |
| 38 | + ] | |
| 39 | + }, | |
| 40 | + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["RepoDO", "RunnerContainer", "SandboxContainer"] }], | |
| 41 | + "workflows": [{ "name": "diffinery-run", "binding": "RUN_WORKFLOW", "class_name": "RunWorkflow" }], | |
| 42 | + "containers": [ | |
| 43 | + { "class_name": "RunnerContainer", "image": "../../containers/runner/Dockerfile", "image_build_context": "../../containers/runner", "instance_type": "standard-2", "max_instances": 100 }, | |
| 44 | + { "class_name": "SandboxContainer", "image": "../../containers/runner/Dockerfile", "image_build_context": "../../containers/runner", "instance_type": "standard-1", "max_instances": 50 } | |
| 45 | + ], | |
| 46 | + "triggers": { "crons": ["*/5 * * * *"] }, | |
| 47 | + | |
| 48 | + "env": { | |
| 49 | + // Local development and end-to-end tests: no Access, a fixed dev identity, no containers. | |
| 50 | + "dev": { | |
| 51 | + "routes": [], | |
| 52 | + "workers_dev": true, | |
| 53 | + "vars": { | |
| 54 | + "PUBLIC_URL": "http://localhost:8787", | |
| 55 | + "BOOTSTRAP_ADMINS": "dev@diffinery.local", | |
| 56 | + "ACCESS_TEAM_DOMAIN": "", | |
| 57 | + "ACCESS_AUD": "", | |
| 58 | + "DEV_AUTH": "dev@diffinery.local" | |
| 59 | + }, | |
| 60 | + "d1_databases": [{ "binding": "DB", "database_name": "diffinery-dev", "database_id": "00000000-0000-0000-0000-000000000000", "migrations_dir": "migrations" }], | |
| 61 | + "r2_buckets": [{ "binding": "BUCKET", "bucket_name": "diffinery-data-dev" }], | |
| 62 | + "queues": { | |
| 63 | + "producers": [{ "binding": "EVENTS", "queue": "diffinery-events-dev" }], | |
| 64 | + "consumers": [{ "queue": "diffinery-events-dev", "max_batch_size": 50, "max_batch_timeout": 1 }] | |
| 65 | + }, | |
| 66 | + "durable_objects": { | |
| 67 | + "bindings": [ | |
| 68 | + { "name": "REPO", "class_name": "RepoDO" }, | |
| 69 | + { "name": "RUNNER", "class_name": "RunnerContainer" }, | |
| 70 | + { "name": "SANDBOX", "class_name": "SandboxContainer" } | |
| 71 | + ] | |
| 72 | + }, | |
| 73 | + "workflows": [{ "name": "diffinery-run-dev", "binding": "RUN_WORKFLOW", "class_name": "RunWorkflow" }], | |
| 74 | + "triggers": { "crons": [] }, | |
| 75 | + "containers": [] | |
| 76 | + } | |
| 77 | + } | |
| 78 | +} |
modifiedpackages/git-core/src/store.ts+83−7View file
| @@ -177,11 +177,46 @@ | ||
| 177 | 177 | * Objects a client needs given what it wants and what it already has, in an |
| 178 | 178 | * order suitable for a pack (commits, then trees and blobs, then tags). |
| 179 | 179 | */ |
| 180 | +export interface FetchOptions { | |
| 181 | + /** `deepen <n>`: keep at most n commits of history from each want. */ | |
| 182 | + depth?: number; | |
| 183 | + /** `deepen-since <unix time>`: keep commits committed at or after this time. */ | |
| 184 | + since?: number; | |
| 185 | + /** Commits the client reported as its shallow boundary (`shallow <oid>`). */ | |
| 186 | + clientShallow?: ReadonlySet<string>; | |
| 187 | +} | |
| 188 | + | |
| 189 | +export interface FetchPlan { | |
| 190 | + oids: string[]; | |
| 191 | + /** Commits that become the client's new shallow boundary. */ | |
| 192 | + shallow: string[]; | |
| 193 | + /** Client shallow commits whose parents are now being sent. */ | |
| 194 | + unshallow: string[]; | |
| 195 | +} | |
| 196 | + | |
| 197 | +/** Objects to send for a fetch: everything reachable from wants and not from haves. */ | |
| 180 | 198 | export async function objectsForFetch(store: ObjectReader, wants: readonly string[], haves: readonly string[]): Promise<string[]> { |
| 199 | + return (await planFetch(store, wants, haves)).oids; | |
| 200 | +} | |
| 201 | + | |
| 202 | +export async function planFetch(store: ObjectReader, wants: readonly string[], haves: readonly string[], opts: FetchOptions = {}): Promise<FetchPlan> { | |
| 203 | + const clientShallow = opts.clientShallow ?? new Set<string>(); | |
| 204 | + // History the client has, cut at its shallow boundary (it lacks those commits' parents). | |
| 181 | 205 | const haveCommits = new Set<string>(); |
| 206 | + const haveTips: string[] = []; | |
| 182 | 207 | for (const h of haves) { |
| 183 | 208 | const o = await store.read(h); |
| 184 | - if (o?.type === "commit") for (const a of await ancestors(store, h)) haveCommits.add(a); | |
| 209 | + if (o?.type !== "commit") continue; | |
| 210 | + haveTips.push(h); | |
| 211 | + const stack = [h]; | |
| 212 | + while (stack.length) { | |
| 213 | + const cur = stack.pop()!; | |
| 214 | + if (haveCommits.has(cur)) continue; | |
| 215 | + const c = await store.read(cur); | |
| 216 | + if (!c || c.type !== "commit") continue; | |
| 217 | + haveCommits.add(cur); | |
| 218 | + if (!clientShallow.has(cur)) stack.push(...parseCommit(c.content).parents); | |
| 219 | + } | |
| 185 | 220 | } |
| 186 | 221 | const tags: string[] = []; |
| 187 | 222 | const commitTips: string[] = []; |
| @@ -200,11 +235,52 @@ | ||
| 200 | 235 | break; |
| 201 | 236 | } |
| 202 | 237 | } |
| 203 | - const commits = await walkCommits(store, commitTips, { stop: haveCommits }); | |
| 204 | 238 | |
| 205 | - // Trees and blobs the client already has: everything under the boundary commits' trees. | |
| 239 | + const shallow: string[] = []; | |
| 240 | + const unshallow: string[] = []; | |
| 241 | + let commits: Array<{ oid: string; commit: Commit }>; | |
| 242 | + if (opts.depth !== undefined || opts.since !== undefined) { | |
| 243 | + // Breadth-first by depth so each commit gets its shortest distance from a want. | |
| 244 | + const depthLimit = opts.depth ?? Number.POSITIVE_INFINITY; | |
| 245 | + const since = opts.since ?? Number.NEGATIVE_INFINITY; | |
| 246 | + const best = new Map<string, number>(); | |
| 247 | + const parsed = new Map<string, Commit>(); | |
| 248 | + let frontier = commitTips.map((oid) => ({ oid, depth: 1 })); | |
| 249 | + const order: string[] = []; | |
| 250 | + while (frontier.length) { | |
| 251 | + const next: Array<{ oid: string; depth: number }> = []; | |
| 252 | + for (const { oid, depth } of frontier) { | |
| 253 | + if ((best.get(oid) ?? Number.POSITIVE_INFINITY) <= depth) continue; | |
| 254 | + const o = await store.read(oid); | |
| 255 | + if (!o || o.type !== "commit") continue; | |
| 256 | + const commit = parsed.get(oid) ?? parseCommit(o.content); | |
| 257 | + if (commit.committer.timestamp < since && depth > 1) continue; | |
| 258 | + if (!best.has(oid)) order.push(oid); | |
| 259 | + best.set(oid, depth); | |
| 260 | + parsed.set(oid, commit); | |
| 261 | + if (depth < depthLimit) for (const p of commit.parents) next.push({ oid: p, depth: depth + 1 }); | |
| 262 | + } | |
| 263 | + frontier = next; | |
| 264 | + } | |
| 265 | + commits = []; | |
| 266 | + for (const oid of order) { | |
| 267 | + const commit = parsed.get(oid)!; | |
| 268 | + const depth = best.get(oid)!; | |
| 269 | + let cut = false; | |
| 270 | + for (const p of commit.parents) { | |
| 271 | + if (!best.has(p)) cut = true; | |
| 272 | + } | |
| 273 | + if (cut && commit.parents.length) shallow.push(oid); | |
| 274 | + else if (clientShallow.has(oid) && commit.parents.length && depth < depthLimit) unshallow.push(oid); | |
| 275 | + if (!haveCommits.has(oid) || unshallow.includes(oid)) commits.push({ oid, commit }); | |
| 276 | + } | |
| 277 | + } else { | |
| 278 | + commits = await walkCommits(store, commitTips, { stop: haveCommits }); | |
| 279 | + } | |
| 280 | + | |
| 281 | + // Trees and blobs the client already has: everything under the trees of commits it has at the edge. | |
| 206 | 282 | const uninteresting = new Set<string>(); |
| 207 | - const boundary = new Set<string>(); | |
| 283 | + const boundary = new Set<string>(haveTips); | |
| 208 | 284 | for (const { commit } of commits) for (const p of commit.parents) if (haveCommits.has(p)) boundary.add(p); |
| 209 | 285 | for (const b of boundary) await markTree(store, (await readCommit(store, b)).tree, uninteresting); |
| 210 | 286 | |
| @@ -216,7 +292,7 @@ | ||
| 216 | 292 | out.push(oid); |
| 217 | 293 | } |
| 218 | 294 | }; |
| 219 | - for (const { oid } of commits) add(oid); | |
| 295 | + for (const { oid } of commits) if (!haveCommits.has(oid)) add(oid); | |
| 220 | 296 | const walkTree = async (treeOid: string) => { |
| 221 | 297 | if (added.has(treeOid) || uninteresting.has(treeOid)) return; |
| 222 | 298 | add(treeOid); |
| @@ -226,14 +302,14 @@ | ||
| 226 | 302 | else add(e.oid); |
| 227 | 303 | } |
| 228 | 304 | }; |
| 229 | - for (const { commit } of commits) await walkTree(commit.tree); | |
| 305 | + for (const { oid, commit } of commits) if (!haveCommits.has(oid)) await walkTree(commit.tree); | |
| 230 | 306 | for (const oid of extra) { |
| 231 | 307 | const o = await mustRead(store, oid); |
| 232 | 308 | if (o.type === "tree") await walkTree(oid); |
| 233 | 309 | else add(oid); |
| 234 | 310 | } |
| 235 | 311 | for (const t of tags) add(t); |
| 236 | - return out; | |
| 312 | + return { oids: out, shallow, unshallow }; | |
| 237 | 313 | } |
| 238 | 314 | |
| 239 | 315 | // ---------------------------------------------------------------- writing trees |
modifiedpnpm-lock.yaml+19−0View file
| @@ -15,6 +15,25 @@ | ||
| 15 | 15 | specifier: ^5.9.3 |
| 16 | 16 | version: 5.9.3 |
| 17 | 17 | |
| 18 | + apps/cli: | |
| 19 | + dependencies: | |
| 20 | + '@diffinery/workflow-yaml': | |
| 21 | + specifier: workspace:* | |
| 22 | + version: link:../../packages/workflow-yaml | |
| 23 | + devDependencies: | |
| 24 | + '@types/node': | |
| 25 | + specifier: ^22.0.0 | |
| 26 | + version: 22.20.5 | |
| 27 | + esbuild: | |
| 28 | + specifier: ^0.25.0 | |
| 29 | + version: 0.25.12 | |
| 30 | + typescript: | |
| 31 | + specifier: ^5.9.3 | |
| 32 | + version: 5.9.3 | |
| 33 | + vitest: | |
| 34 | + specifier: ^4.1.0 | |
| 35 | + version: 4.1.11(@types/node@22.20.5)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.25.12)(yaml@2.9.1)) | |
| 36 | + | |
| 18 | 37 | apps/diffinery: |
| 19 | 38 | dependencies: |
| 20 | 39 | '@cloudflare/containers': |
addedscripts/bootstrap.sh+133−0View file
| @@ -1,0 +1,133 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | +# Idempotent production setup for Diffinery on a Cloudflare account. | |
| 3 | +# | |
| 4 | +# Creates (only when missing): the D1 database, R2 bucket, event queues and the | |
| 5 | +# Cloudflare Access applications that put sign-in in front of the web UI while | |
| 6 | +# letting git, the API, MCP and runners authenticate with Diffinery tokens. | |
| 7 | +# Then renders apps/diffinery/wrangler.deploy.jsonc and applies D1 migrations. | |
| 8 | +# | |
| 9 | +# Requires: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, node, jq, curl. | |
| 10 | +# Optional: DIFFINERY_DOMAIN (default diffinery.com), ACCESS_TEAM_DOMAIN | |
| 11 | +# (e.g. codician.cloudflareaccess.com) when the token cannot read it. | |
| 12 | +set -euo pipefail | |
| 13 | + | |
| 14 | +: "${CLOUDFLARE_API_TOKEN:?set CLOUDFLARE_API_TOKEN}" | |
| 15 | +: "${CLOUDFLARE_ACCOUNT_ID:?set CLOUDFLARE_ACCOUNT_ID}" | |
| 16 | +DOMAIN="${DIFFINERY_DOMAIN:-diffinery.com}" | |
| 17 | +ROOT="$(cd "$(dirname "$0")/.." && pwd)" | |
| 18 | +APP="$ROOT/apps/diffinery" | |
| 19 | +WRANGLER=(pnpm --dir "$APP" exec wrangler) | |
| 20 | +API="https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID" | |
| 21 | + | |
| 22 | +cf() { # cf METHOD PATH [JSON] | |
| 23 | + local method="$1" path="$2" body="${3:-}" | |
| 24 | + if [[ -n "$body" ]]; then | |
| 25 | + curl -sS -X "$method" "$API$path" -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json" --data "$body" | |
| 26 | + else | |
| 27 | + curl -sS -X "$method" "$API$path" -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" | |
| 28 | + fi | |
| 29 | +} | |
| 30 | +log() { echo "::group::$*"; } | |
| 31 | +endlog() { echo "::endgroup::"; } | |
| 32 | +warn() { echo "::warning::$*" >&2; } | |
| 33 | + | |
| 34 | +# ------------------------------------------------------------------ D1 | |
| 35 | +log "D1 database" | |
| 36 | +D1_ID="$(cf GET "/d1/database?name=diffinery&per_page=100" | jq -r '.result[]? | select(.name=="diffinery") | .uuid' | head -n1)" | |
| 37 | +if [[ -z "$D1_ID" ]]; then | |
| 38 | + D1_ID="$(cf POST /d1/database '{"name":"diffinery"}' | jq -r '.result.uuid // empty')" | |
| 39 | + [[ -n "$D1_ID" ]] || { echo "could not create D1 database (does the token have D1: Edit?)" >&2; exit 1; } | |
| 40 | + echo "created D1 database $D1_ID" | |
| 41 | +else | |
| 42 | + echo "D1 database exists: $D1_ID" | |
| 43 | +fi | |
| 44 | +endlog | |
| 45 | + | |
| 46 | +# ------------------------------------------------------------------ R2 | |
| 47 | +log "R2 bucket" | |
| 48 | +if cf GET "/r2/buckets/diffinery-data" | jq -e '.success' >/dev/null; then | |
| 49 | + echo "R2 bucket exists" | |
| 50 | +else | |
| 51 | + cf POST /r2/buckets '{"name":"diffinery-data"}' | jq -e '.success' >/dev/null || { echo "could not create R2 bucket (R2 enabled? token has R2: Edit?)" >&2; exit 1; } | |
| 52 | + echo "created R2 bucket diffinery-data" | |
| 53 | +fi | |
| 54 | +# Expire transient data: run logs/caches are rewritten on use, artifacts and event archives age out. | |
| 55 | +cf PUT "/r2/buckets/diffinery-data/lifecycle" '{"rules":[ | |
| 56 | + {"id":"artifacts-90d","enabled":true,"conditions":{"prefix":"repos/"},"deleteObjectsTransition":{"condition":{"type":"Age","maxAge":7776000}},"abortMultipartUploadsTransition":{"condition":{"type":"Age","maxAge":86400}}}, | |
| 57 | + {"id":"events-400d","enabled":true,"conditions":{"prefix":"events/"},"deleteObjectsTransition":{"condition":{"type":"Age","maxAge":34560000}}} | |
| 58 | +]}' >/dev/null || true | |
| 59 | +endlog | |
| 60 | + | |
| 61 | +# ------------------------------------------------------------------ Queues | |
| 62 | +log "Queues" | |
| 63 | +EXISTING_QUEUES="$(cf GET "/queues?per_page=100" | jq -r '.result[]?.queue_name')" | |
| 64 | +for q in diffinery-events diffinery-events-dlq; do | |
| 65 | + if grep -qx "$q" <<<"$EXISTING_QUEUES"; then | |
| 66 | + echo "queue $q exists" | |
| 67 | + else | |
| 68 | + cf POST /queues "{\"queue_name\":\"$q\"}" | jq -e '.success' >/dev/null || { echo "could not create queue $q (token has Queues: Edit?)" >&2; exit 1; } | |
| 69 | + echo "created queue $q" | |
| 70 | + fi | |
| 71 | +done | |
| 72 | +endlog | |
| 73 | + | |
| 74 | +# ------------------------------------------------------------------ Access | |
| 75 | +log "Cloudflare Access" | |
| 76 | +TEAM="${ACCESS_TEAM_DOMAIN:-}" | |
| 77 | +if [[ -z "$TEAM" ]]; then | |
| 78 | + TEAM="$(cf GET /access/organizations | jq -r '.result.auth_domain // empty' || true)" | |
| 79 | +fi | |
| 80 | +APPS="$(cf GET "/access/apps?per_page=100" || echo '{}')" | |
| 81 | +AUD="" | |
| 82 | +if ! jq -e '.success' >/dev/null <<<"$APPS"; then | |
| 83 | + warn "Could not read Access applications (token needs Access: Apps and Policies Edit). Sign-in stays disabled until Access is configured." | |
| 84 | +else | |
| 85 | + WEB_APP="$(jq -c --arg n "Diffinery" '.result[] | select(.name==$n)' <<<"$APPS" | head -n1)" | |
| 86 | + if [[ -z "$WEB_APP" ]]; then | |
| 87 | + WEB_APP="$(cf POST /access/apps "$(jq -nc --arg d "$DOMAIN" '{ | |
| 88 | + name: "Diffinery", type: "self_hosted", domain: $d, | |
| 89 | + destinations: [{type: "public", uri: $d}], | |
| 90 | + session_duration: "168h", app_launcher_visible: true, http_only_cookie_attribute: true, same_site_cookie_attribute: "lax", | |
| 91 | + policies: [{name: "Anyone who signs in (Diffinery invites decide access)", decision: "allow", include: [{everyone: {}}], precedence: 1}] | |
| 92 | + }')" | jq -c '.result // empty')" | |
| 93 | + [[ -n "$WEB_APP" ]] && echo "created Access app for $DOMAIN" || warn "Could not create the Access application for $DOMAIN." | |
| 94 | + else | |
| 95 | + echo "Access app exists" | |
| 96 | + fi | |
| 97 | + AUD="$(jq -r '.aud // empty' <<<"${WEB_APP:-{}}")" | |
| 98 | + | |
| 99 | + # Machine endpoints authenticate with Diffinery tokens, so Access must not intercept them. | |
| 100 | + BYPASS_DESTS="$(jq -nc --arg d "$DOMAIN" '[ | |
| 101 | + "/api/*", "/mcp", "/mcp/*", "/runner/*", "/.well-known/*", "/healthz", | |
| 102 | + "/*/*/info/refs", "/*/*/git-upload-pack", "/*/*/git-receive-pack", "/*/*/info/lfs/*", | |
| 103 | + "/*/*/actions/runs/*/artifacts/*" | |
| 104 | + ] | map({type: "public", uri: ($d + .)})')" | |
| 105 | + BYPASS_BODY="$(jq -nc --arg d "$DOMAIN" --argjson dests "$BYPASS_DESTS" '{ | |
| 106 | + name: "Diffinery machine endpoints", type: "self_hosted", domain: ($d + "/api/*"), destinations: $dests, | |
| 107 | + app_launcher_visible: false, | |
| 108 | + policies: [{name: "Bypass: Diffinery verifies tokens itself", decision: "bypass", include: [{everyone: {}}], precedence: 1}] | |
| 109 | + }')" | |
| 110 | + BYPASS_ID="$(jq -r '.result[] | select(.name=="Diffinery machine endpoints") | .id' <<<"$APPS" | head -n1)" | |
| 111 | + if [[ -z "$BYPASS_ID" ]]; then | |
| 112 | + cf POST /access/apps "$BYPASS_BODY" | jq -e '.success' >/dev/null && echo "created Access bypass for git/API/MCP/runner paths" || warn "Could not create the Access bypass application; git and API calls will be redirected to sign-in." | |
| 113 | + else | |
| 114 | + cf PUT "/access/apps/$BYPASS_ID" "$(jq -c 'del(.policies)' <<<"$BYPASS_BODY")" | jq -e '.success' >/dev/null && echo "updated Access bypass paths" || warn "Could not update the Access bypass application." | |
| 115 | + fi | |
| 116 | +fi | |
| 117 | +if [[ -z "$TEAM" ]]; then | |
| 118 | + # Fall back to the sign-in redirect, which names the team domain. | |
| 119 | + TEAM="$(curl -sS -o /dev/null -w '%{redirect_url}' "https://$DOMAIN/" | sed -nE 's#^https://([^/]+\.cloudflareaccess\.com)/.*#\1#p' || true)" | |
| 120 | +fi | |
| 121 | +[[ -n "$TEAM" ]] || warn "Access team domain unknown; set the ACCESS_TEAM_DOMAIN repository variable (Zero Trust → Settings → Custom pages shows it)." | |
| 122 | +echo "team=${TEAM:-?} aud=${AUD:+set}" | |
| 123 | +endlog | |
| 124 | + | |
| 125 | +# ------------------------------------------------------------------ config + migrations | |
| 126 | +log "Render config and migrate D1" | |
| 127 | +node "$ROOT/scripts/render-config.mjs" --d1 "$D1_ID" ${TEAM:+--team "$TEAM"} ${AUD:+--aud "$AUD"} --public-url "https://$DOMAIN" | |
| 128 | +"${WRANGLER[@]}" d1 migrations apply diffinery --remote -c wrangler.deploy.jsonc | |
| 129 | +endlog | |
| 130 | + | |
| 131 | +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then | |
| 132 | + { echo "d1_id=$D1_ID"; echo "access_team=$TEAM"; echo "access_aud_set=${AUD:+true}"; } >>"$GITHUB_OUTPUT" | |
| 133 | +fi |
addedscripts/ensure-secrets.sh+29−0View file
| @@ -1,0 +1,29 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | +# Generate the Worker's own secrets once (never rotated automatically): | |
| 3 | +# MASTER_KEY root key for encrypting Actions secrets | |
| 4 | +# OIDC_PRIVATE_KEY ES256 key that signs workflow OIDC id tokens | |
| 5 | +# Values are created on the runner and sent straight to Cloudflare; they are never printed. | |
| 6 | +set -euo pipefail | |
| 7 | +APP="$(cd "$(dirname "$0")/../apps/diffinery" && pwd)" | |
| 8 | +WRANGLER=(pnpm --dir "$APP" exec wrangler) | |
| 9 | + | |
| 10 | +# Fail closed: if listing fails we must not mistake "unknown" for "missing" and | |
| 11 | +# overwrite MASTER_KEY, which would make every stored Actions secret unreadable. | |
| 12 | +LIST="$("${WRANGLER[@]}" secret list -c wrangler.deploy.jsonc --format json)" | |
| 13 | +EXISTING="$(jq -er '.[].name' <<<"$LIST" || true)" | |
| 14 | +jq -e 'type == "array"' >/dev/null <<<"$LIST" || { echo "unexpected output from wrangler secret list" >&2; exit 1; } | |
| 15 | + | |
| 16 | +if ! grep -qx MASTER_KEY <<<"$EXISTING"; then | |
| 17 | + openssl rand -base64 32 | tr -d '\n' | "${WRANGLER[@]}" secret put MASTER_KEY -c wrangler.deploy.jsonc >/dev/null | |
| 18 | + echo "set MASTER_KEY" | |
| 19 | +fi | |
| 20 | +if ! grep -qx OIDC_PRIVATE_KEY <<<"$EXISTING"; then | |
| 21 | + node --input-type=module -e ' | |
| 22 | + const { subtle } = globalThis.crypto; | |
| 23 | + const k = await subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]); | |
| 24 | + const jwk = await subtle.exportKey("jwk", k.privateKey); | |
| 25 | + jwk.kid = "diffinery-" + new Date().toISOString().slice(0, 10); | |
| 26 | + process.stdout.write(JSON.stringify(jwk)); | |
| 27 | + ' | "${WRANGLER[@]}" secret put OIDC_PRIVATE_KEY -c wrangler.deploy.jsonc >/dev/null | |
| 28 | + echo "set OIDC_PRIVATE_KEY" | |
| 29 | +fi |
addedscripts/render-config.mjs+43−0View file
| @@ -1,0 +1,43 @@ | ||
| 1 | +#!/usr/bin/env node | |
| 2 | +// Render apps/diffinery/wrangler.deploy.jsonc from wrangler.jsonc plus values | |
| 3 | +// discovered by scripts/bootstrap.sh (real D1 id, Access team domain and AUD). | |
| 4 | +// | |
| 5 | +// node scripts/render-config.mjs --d1 <uuid> [--team <x.cloudflareaccess.com>] [--aud <tag>] | |
| 6 | +import { readFileSync, writeFileSync } from "node:fs"; | |
| 7 | +import { dirname, join } from "node:path"; | |
| 8 | +import { fileURLToPath } from "node:url"; | |
| 9 | +import { parseArgs } from "node:util"; | |
| 10 | + | |
| 11 | +const { values } = parseArgs({ options: { d1: { type: "string" }, team: { type: "string" }, aud: { type: "string" }, "public-url": { type: "string" } } }); | |
| 12 | +if (!values.d1) throw new Error("--d1 <database id> is required"); | |
| 13 | + | |
| 14 | +/** Strip // and /* *\/ comments and trailing commas from JSONC, leaving strings intact. */ | |
| 15 | +export function parseJsonc(text) { | |
| 16 | + let out = ""; | |
| 17 | + for (let i = 0; i < text.length; i++) { | |
| 18 | + const ch = text[i]; | |
| 19 | + if (ch === '"') { | |
| 20 | + let j = i + 1; | |
| 21 | + while (j < text.length && text[j] !== '"') j += text[j] === "\\" ? 2 : 1; | |
| 22 | + out += text.slice(i, j + 1); | |
| 23 | + i = j; | |
| 24 | + } else if (ch === "/" && text[i + 1] === "/") { | |
| 25 | + while (i < text.length && text[i] !== "\n") i++; | |
| 26 | + out += "\n"; | |
| 27 | + } else if (ch === "/" && text[i + 1] === "*") { | |
| 28 | + i = text.indexOf("*/", i + 2) + 1; | |
| 29 | + } else out += ch; | |
| 30 | + } | |
| 31 | + return JSON.parse(out.replace(/,(\s*[}\]])/g, "$1")); | |
| 32 | +} | |
| 33 | + | |
| 34 | +const appDir = join(dirname(fileURLToPath(import.meta.url)), "..", "apps", "diffinery"); | |
| 35 | +const cfg = parseJsonc(readFileSync(join(appDir, "wrangler.jsonc"), "utf8")); | |
| 36 | +delete cfg.env; | |
| 37 | +delete cfg.$schema; | |
| 38 | +cfg.d1_databases[0].database_id = values.d1; | |
| 39 | +if (values.team) cfg.vars.ACCESS_TEAM_DOMAIN = values.team; | |
| 40 | +if (values.aud) cfg.vars.ACCESS_AUD = values.aud; | |
| 41 | +if (values["public-url"]) cfg.vars.PUBLIC_URL = values["public-url"]; | |
| 42 | +writeFileSync(join(appDir, "wrangler.deploy.jsonc"), `// Generated by scripts/render-config.mjs; do not edit.\n${JSON.stringify(cfg, null, 2)}\n`); | |
| 43 | +console.log(`wrote ${join(appDir, "wrangler.deploy.jsonc")}`); |