Web UI, reserved logins, image build fixes; e2e covers web pages and runs jobs in the runner image
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL
21 files changed+887−50
Files (21)
- apps/cli/package.json+1−1
- apps/cli/src/bin.ts+16−0
- apps/cli/src/cli.ts+1−1
- apps/cli/src/commands/pr.ts+4−1
- apps/cli/src/git.ts+7−4
- apps/cli/src/main.ts+0−22
- apps/cli/test/config.test.ts+109−0
- apps/cli/test/credential.test.ts+54−0
- apps/cli/test/helpers.ts+203−0
- apps/cli/test/misc.test.ts+138−0
- apps/cli/test/pr.test.ts+119−0
- apps/cli/test/runner.test.ts+94−0
- apps/cli/test/workflow.test.ts+98−0
- apps/diffinery/src/lib/directory.ts+5−5
- apps/diffinery/src/lib/util.ts+5−0
- apps/diffinery/src/web/actions.tsx+1−2
- apps/diffinery/src/web/assets.ts+4−5
- apps/diffinery/src/web/markdown.ts+4−3
- apps/diffinery/src/web/routes.tsx+5−0
- apps/diffinery/test/e2e/e2e.test.mjs+17−5
- containers/runner/Dockerfile+2−1
modifiedapps/cli/package.json+1−1View file
| @@ -8,7 +8,7 @@ | ||
| 8 | 8 | "files": ["dist"], |
| 9 | 9 | "engines": { "node": ">=22" }, |
| 10 | 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);\"", | |
| 11 | + "build": "esbuild src/bin.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/diffinery.mjs \"--banner:js=#!/usr/bin/env node\nimport { createRequire as __diffineryCreateRequire } from 'node:module'; const require = __diffineryCreateRequire(import.meta.url);\"", | |
| 12 | 12 | "typecheck": "tsc --noEmit", |
| 13 | 13 | "test": "vitest run" |
| 14 | 14 | }, |
addedapps/cli/src/bin.ts+16−0View file
| @@ -1,0 +1,16 @@ | ||
| 1 | +import { main } from "./main.js"; | |
| 2 | + | |
| 3 | +// EPIPE when piping into `head` and similar should end quietly. | |
| 4 | +process.stdout.on("error", (e: NodeJS.ErrnoException) => { | |
| 5 | + if (e.code === "EPIPE") process.exit(0); | |
| 6 | +}); | |
| 7 | + | |
| 8 | +main(process.argv.slice(2)).then( | |
| 9 | + (code) => { | |
| 10 | + process.exitCode = code; | |
| 11 | + }, | |
| 12 | + (e: unknown) => { | |
| 13 | + process.stderr.write(`error: ${String(e)}\n`); | |
| 14 | + process.exitCode = 1; | |
| 15 | + }, | |
| 16 | +); |
modifiedapps/cli/src/cli.ts+1−1View file
| @@ -206,7 +206,7 @@ | ||
| 206 | 206 | try { |
| 207 | 207 | const parsed = parseArgs({ |
| 208 | 208 | args: argv, |
| 209 | - options: Object.fromEntries(Object.entries(specs).map(([k, o]) => [k, { type: o.type, short: o.short, multiple: o.multiple }])), | |
| 209 | + options: Object.fromEntries(Object.entries(specs).map(([k, o]) => [k, { type: o.type, ...(o.short ? { short: o.short } : {}), ...(o.multiple ? { multiple: true } : {}) }])), | |
| 210 | 210 | allowPositionals: true, |
| 211 | 211 | strict: true, |
| 212 | 212 | }); |
modifiedapps/cli/src/commands/pr.ts+4−1View file
| @@ -185,7 +185,10 @@ | ||
| 185 | 185 | async run(ctx, o, [num]) { |
| 186 | 186 | const repo = await ctx.repo(); |
| 187 | 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("")); | |
| 188 | + if (o["name-only"]) { | |
| 189 | + ctx.io.stdout.write(files.map((f) => `${f.filename}\n`).join("")); | |
| 190 | + return; | |
| 191 | + } | |
| 189 | 192 | const c = ctx.c; |
| 190 | 193 | for (const f of files) { |
| 191 | 194 | const patch = f.patch ?? `diff --git a/${f.filename} b/${f.filename}\n`; |
modifiedapps/cli/src/git.ts+7−4View file
| @@ -12,17 +12,20 @@ | ||
| 12 | 12 | return new Promise((resolve) => { |
| 13 | 13 | let child; |
| 14 | 14 | try { |
| 15 | - child = spawn("git", args, { cwd: o.cwd ?? io.cwd, env: o.env ?? io.env, stdio: ["pipe", "pipe", "pipe"] }); | |
| 15 | + child = spawn("git", args, { cwd: o.cwd ?? io.cwd, env: o.env ?? io.env, stdio: [o.input === undefined ? "ignore" : "pipe", "pipe", "pipe"] }); | |
| 16 | 16 | } catch (e) { |
| 17 | 17 | return resolve({ code: 127, stdout: "", stderr: String((e as Error).message) }); |
| 18 | 18 | } |
| 19 | 19 | let stdout = ""; |
| 20 | 20 | let stderr = ""; |
| 21 | - child.stdout.on("data", (d: Buffer) => (stdout += d.toString())); | |
| 22 | - child.stderr.on("data", (d: Buffer) => (stderr += d.toString())); | |
| 21 | + child.stdout?.on("data", (d: Buffer) => (stdout += d.toString())); | |
| 22 | + child.stderr?.on("data", (d: Buffer) => (stderr += d.toString())); | |
| 23 | 23 | child.on("error", (e) => resolve({ code: 127, stdout, stderr: stderr + e.message })); |
| 24 | 24 | child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr })); |
| 25 | - child.stdin.end(o.input ?? ""); | |
| 25 | + if (child.stdin) { | |
| 26 | + child.stdin.on("error", () => {}); // git may exit before reading its input | |
| 27 | + child.stdin.end(o.input); | |
| 28 | + } | |
| 26 | 29 | }); |
| 27 | 30 | } |
| 28 | 31 |
modifiedapps/cli/src/main.ts+0−22View file
| @@ -28,25 +28,3 @@ | ||
| 28 | 28 | return 1; |
| 29 | 29 | } |
| 30 | 30 | } |
| 31 | - | |
| 32 | -const isEntry = (() => { | |
| 33 | - const entry = process.argv[1]; | |
| 34 | - if (!entry) return false; | |
| 35 | - return /(?:^|[\\/])(?:diffinery(?:\.mjs)?|main\.[cm]?[jt]s)$/.test(entry); | |
| 36 | -})(); | |
| 37 | - | |
| 38 | -if (isEntry) { | |
| 39 | - // EPIPE when piping into `head` and similar should end quietly. | |
| 40 | - process.stdout.on("error", (e: NodeJS.ErrnoException) => { | |
| 41 | - if (e.code === "EPIPE") process.exit(0); | |
| 42 | - }); | |
| 43 | - main(process.argv.slice(2)).then( | |
| 44 | - (code) => { | |
| 45 | - process.exitCode = code; | |
| 46 | - }, | |
| 47 | - (e: unknown) => { | |
| 48 | - process.stderr.write(`error: ${String(e)}\n`); | |
| 49 | - process.exitCode = 1; | |
| 50 | - }, | |
| 51 | - ); | |
| 52 | -} |
addedapps/cli/test/config.test.ts+109−0View file
| @@ -1,0 +1,109 @@ | ||
| 1 | +import { mkdirSync, statSync, writeFileSync, readFileSync } from "node:fs"; | |
| 2 | +import path from "node:path"; | |
| 3 | +import { afterEach, beforeEach, describe, expect, it } from "vitest"; | |
| 4 | +import { configPath, normalizeUrl, resolveConfig, writeConfig } from "../src/config.js"; | |
| 5 | +import type { Io } from "../src/io.js"; | |
| 6 | +import { fakeServer, isolatedEnv, run, type FakeServer } from "./helpers.js"; | |
| 7 | + | |
| 8 | +const ioWith = (env: NodeJS.ProcessEnv) => ({ env }) as unknown as Io; | |
| 9 | + | |
| 10 | +describe("config", () => { | |
| 11 | + it("defaults to https://diffinery.com with no token", async () => { | |
| 12 | + const cfg = await resolveConfig(ioWith(isolatedEnv())); | |
| 13 | + expect(cfg).toMatchObject({ url: "https://diffinery.com", token: null, urlSource: "default", tokenSource: null }); | |
| 14 | + }); | |
| 15 | + | |
| 16 | + it("reads the file under XDG_CONFIG_HOME and lets env vars override it", async () => { | |
| 17 | + const env = isolatedEnv(); | |
| 18 | + const file = path.join(env.XDG_CONFIG_HOME!, "diffinery", "config.json"); | |
| 19 | + expect(configPath(env)).toBe(file); | |
| 20 | + mkdirSync(path.dirname(file), { recursive: true }); | |
| 21 | + writeFileSync(file, JSON.stringify({ url: "https://forge.example.com/", token: "dfy_file" })); | |
| 22 | + | |
| 23 | + expect(await resolveConfig(ioWith(env))).toMatchObject({ url: "https://forge.example.com", token: "dfy_file", urlSource: "config", tokenSource: "config" }); | |
| 24 | + expect(await resolveConfig(ioWith({ ...env, DIFFINERY_TOKEN: "dfy_env" }))).toMatchObject({ url: "https://forge.example.com", token: "dfy_env", tokenSource: "env" }); | |
| 25 | + expect(await resolveConfig(ioWith({ ...env, DIFFINERY_URL: "other.example.com" }))).toMatchObject({ url: "https://other.example.com", token: "dfy_file", urlSource: "env" }); | |
| 26 | + }); | |
| 27 | + | |
| 28 | + it("falls back to ~/.config when XDG_CONFIG_HOME is unset", () => { | |
| 29 | + const env = isolatedEnv(); | |
| 30 | + delete env.XDG_CONFIG_HOME; | |
| 31 | + expect(configPath(env)).toBe(path.join(env.HOME!, ".config", "diffinery", "config.json")); | |
| 32 | + }); | |
| 33 | + | |
| 34 | + it("writes the config with mode 0600", async () => { | |
| 35 | + const env = isolatedEnv(); | |
| 36 | + const file = await writeConfig(env, { url: "https://a.example", token: "dfy_x" }); | |
| 37 | + expect(statSync(file).mode & 0o777).toBe(0o600); | |
| 38 | + expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({ url: "https://a.example", token: "dfy_x" }); | |
| 39 | + }); | |
| 40 | + | |
| 41 | + it("normalizes URLs", () => { | |
| 42 | + expect(normalizeUrl("forge.example.com/")).toBe("https://forge.example.com"); | |
| 43 | + expect(normalizeUrl("http://localhost:8787")).toBe("http://localhost:8787"); | |
| 44 | + expect(() => normalizeUrl("ftp://x")).toThrow(); | |
| 45 | + }); | |
| 46 | +}); | |
| 47 | + | |
| 48 | +describe("login / whoami / logout", () => { | |
| 49 | + let srv: FakeServer; | |
| 50 | + beforeEach(async () => { | |
| 51 | + srv = await fakeServer(); | |
| 52 | + srv.on("GET", "/api/v3/user", (r) => (r.headers.authorization === "Bearer dfy_good" ? { body: { login: "alice", name: "Alice", type: "User" } } : { status: 401, body: { message: "bad credentials" } })); | |
| 53 | + }); | |
| 54 | + afterEach(() => srv.close()); | |
| 55 | + | |
| 56 | + it("verifies the token, saves it 0600, and whoami uses it", async () => { | |
| 57 | + const env = isolatedEnv(); | |
| 58 | + const r = await run(["login", "--url", srv.url, "--token", "dfy_good"], { env }); | |
| 59 | + expect(r.code).toBe(0); | |
| 60 | + expect(r.stdout).toContain("Logged in to"); | |
| 61 | + expect(r.stdout).toContain("alice"); | |
| 62 | + expect(r.stdout).not.toContain("dfy_good"); | |
| 63 | + const file = configPath(env); | |
| 64 | + expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({ url: srv.url, token: "dfy_good" }); | |
| 65 | + expect(statSync(file).mode & 0o777).toBe(0o600); | |
| 66 | + | |
| 67 | + const who = await run(["whoami"], { env }); | |
| 68 | + expect(who.code).toBe(0); | |
| 69 | + expect(who.stdout).toContain("alice"); | |
| 70 | + expect(srv.find("GET", "/api/v3/user").at(-1)!.headers.authorization).toBe("Bearer dfy_good"); | |
| 71 | + | |
| 72 | + const out = await run(["logout"], { env }); | |
| 73 | + expect(out.code).toBe(0); | |
| 74 | + expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({ url: srv.url }); | |
| 75 | + expect((await run(["whoami"], { env })).code).toBe(1); | |
| 76 | + }); | |
| 77 | + | |
| 78 | + it("reads the token from stdin when it is not a TTY", async () => { | |
| 79 | + const env = isolatedEnv(); | |
| 80 | + const r = await run(["login", "--url", srv.url], { env, stdin: "dfy_good\n" }); | |
| 81 | + expect(r.code).toBe(0); | |
| 82 | + expect(JSON.parse(readFileSync(configPath(env), "utf8")).token).toBe("dfy_good"); | |
| 83 | + }); | |
| 84 | + | |
| 85 | + it("refuses a token the server rejects and saves nothing", async () => { | |
| 86 | + const env = isolatedEnv(); | |
| 87 | + const r = await run(["login", "--url", srv.url, "--token", "dfy_bad"], { env }); | |
| 88 | + expect(r.code).toBe(1); | |
| 89 | + expect(r.stderr).toContain("bad credentials"); | |
| 90 | + expect(() => readFileSync(configPath(env))).toThrow(); | |
| 91 | + }); | |
| 92 | + | |
| 93 | + it("DIFFINERY_TOKEN/DIFFINERY_URL override the saved config", async () => { | |
| 94 | + const env = isolatedEnv(); | |
| 95 | + await writeConfig(env, { url: "http://127.0.0.1:1", token: "dfy_saved" }); | |
| 96 | + const r = await run(["whoami", "--json"], { env: { ...env, DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "dfy_good" } }); | |
| 97 | + expect(r.code).toBe(0); | |
| 98 | + expect(JSON.parse(r.stdout).login).toBe("alice"); | |
| 99 | + }); | |
| 100 | + | |
| 101 | + it("mcp-config prints a snippet without the token unless asked", async () => { | |
| 102 | + const env = isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "dfy_good" }); | |
| 103 | + const r = await run(["mcp-config"], { env }); | |
| 104 | + const cfg = JSON.parse(r.stdout); | |
| 105 | + expect(cfg.mcpServers.diffinery).toEqual({ type: "http", url: `${srv.url}/mcp`, headers: { Authorization: "Bearer ${DIFFINERY_TOKEN}" } }); | |
| 106 | + const shown = JSON.parse((await run(["mcp-config", "--show-token"], { env })).stdout); | |
| 107 | + expect(shown.mcpServers.diffinery.headers.Authorization).toBe("Bearer dfy_good"); | |
| 108 | + }); | |
| 109 | +}); |
addedapps/cli/test/credential.test.ts+54−0View file
| @@ -1,0 +1,54 @@ | ||
| 1 | +import { execFileSync } from "node:child_process"; | |
| 2 | +import { describe, expect, it } from "vitest"; | |
| 3 | +import { parseCredentialInput } from "../src/commands/auth.js"; | |
| 4 | +import { isolatedEnv, run } from "./helpers.js"; | |
| 5 | + | |
| 6 | +describe("git credential helper", () => { | |
| 7 | + const env = isolatedEnv({ DIFFINERY_URL: "https://forge.example.com", DIFFINERY_TOKEN: "dfy_secret" }); | |
| 8 | + | |
| 9 | + it("parses key=value lines up to the blank line", () => { | |
| 10 | + expect(parseCredentialInput("protocol=https\nhost=a.b\npath=x/y.git\n\nignored=1\n")).toEqual({ protocol: "https", host: "a.b", path: "x/y.git" }); | |
| 11 | + }); | |
| 12 | + | |
| 13 | + it("answers get for the configured host", async () => { | |
| 14 | + const r = await run(["auth", "git-credential", "get"], { env, stdin: "protocol=https\nhost=forge.example.com\n\n" }); | |
| 15 | + expect(r.code).toBe(0); | |
| 16 | + expect(r.stdout).toBe("protocol=https\nhost=forge.example.com\nusername=x-token\npassword=dfy_secret\n"); | |
| 17 | + }); | |
| 18 | + | |
| 19 | + it("stays silent for other hosts, other protocols and other operations", async () => { | |
| 20 | + for (const stdin of ["protocol=https\nhost=github.com\n\n", "protocol=http\nhost=forge.example.com\n\n"]) { | |
| 21 | + const r = await run(["auth", "git-credential", "get"], { env, stdin }); | |
| 22 | + expect(r.code).toBe(0); | |
| 23 | + expect(r.stdout).toBe(""); | |
| 24 | + } | |
| 25 | + for (const op of ["store", "erase"]) { | |
| 26 | + const r = await run(["auth", "git-credential", op], { env, stdin: "protocol=https\nhost=forge.example.com\nusername=x\npassword=y\n\n" }); | |
| 27 | + expect(r.code).toBe(0); | |
| 28 | + expect(r.stdout).toBe(""); | |
| 29 | + } | |
| 30 | + }); | |
| 31 | + | |
| 32 | + it("matches hosts with ports", async () => { | |
| 33 | + const e = { ...env, DIFFINERY_URL: "http://127.0.0.1:8787" }; | |
| 34 | + const r = await run(["auth", "git-credential", "get"], { env: e, stdin: "protocol=http\nhost=127.0.0.1:8787\n" }); | |
| 35 | + expect(r.stdout).toContain("password=dfy_secret"); | |
| 36 | + }); | |
| 37 | + | |
| 38 | + it("prints nothing without a token", async () => { | |
| 39 | + const e = { ...env }; | |
| 40 | + delete e.DIFFINERY_TOKEN; | |
| 41 | + const r = await run(["auth", "git-credential", "get"], { env: e, stdin: "protocol=https\nhost=forge.example.com\n" }); | |
| 42 | + expect(r.stdout).toBe(""); | |
| 43 | + }); | |
| 44 | + | |
| 45 | + it("auth setup-git configures a URL-scoped helper in the global git config", async () => { | |
| 46 | + const r = await run(["auth", "setup-git"], { env }); | |
| 47 | + expect(r.code).toBe(0); | |
| 48 | + const helpers = execFileSync("git", ["config", "--global", "--get-all", "credential.https://forge.example.com.helper"], { env }).toString().split("\n"); | |
| 49 | + expect(helpers.slice(0, 2)).toEqual(["", "!diffinery auth git-credential"]); | |
| 50 | + // Idempotent. | |
| 51 | + await run(["auth", "setup-git"], { env }); | |
| 52 | + expect(execFileSync("git", ["config", "--global", "--get-all", "credential.https://forge.example.com.helper"], { env }).toString().split("\n").slice(0, -1)).toEqual(["", "!diffinery auth git-credential"]); | |
| 53 | + }); | |
| 54 | +}); |
addedapps/cli/test/helpers.ts+203−0View file
| @@ -1,0 +1,203 @@ | ||
| 1 | +import { execFileSync, spawn } from "node:child_process"; | |
| 2 | +import { mkdtempSync, writeFileSync } from "node:fs"; | |
| 3 | +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; | |
| 4 | +import type { AddressInfo } from "node:net"; | |
| 5 | +import { tmpdir } from "node:os"; | |
| 6 | +import path from "node:path"; | |
| 7 | +import { Readable } from "node:stream"; | |
| 8 | +import type { Io } from "../src/io.js"; | |
| 9 | +import { main } from "../src/main.js"; | |
| 10 | + | |
| 11 | +export interface Recorded { | |
| 12 | + method: string; | |
| 13 | + path: string; | |
| 14 | + query: Record<string, string>; | |
| 15 | + headers: IncomingMessage["headers"]; | |
| 16 | + body: unknown; | |
| 17 | +} | |
| 18 | + | |
| 19 | +export interface Reply { | |
| 20 | + status?: number; | |
| 21 | + body?: unknown; | |
| 22 | + headers?: Record<string, string>; | |
| 23 | +} | |
| 24 | + | |
| 25 | +type Handler = (req: Recorded) => Reply | Promise<Reply>; | |
| 26 | + | |
| 27 | +export interface FakeServer { | |
| 28 | + url: string; | |
| 29 | + host: string; | |
| 30 | + requests: Recorded[]; | |
| 31 | + on(method: string, path: string | RegExp, h: Handler | Reply): void; | |
| 32 | + find(method: string, path: string | RegExp): Recorded[]; | |
| 33 | + close(): Promise<void>; | |
| 34 | +} | |
| 35 | + | |
| 36 | +/** | |
| 37 | + * A tiny programmable HTTP server: unmatched routes return 404 { message }. | |
| 38 | + * With `gitRoot`, paths containing `.git/` are served by `git http-backend` from that directory. | |
| 39 | + */ | |
| 40 | +export async function fakeServer(o: { gitRoot?: string; gitAuth?: string } = {}): Promise<FakeServer> { | |
| 41 | + const routes: Array<{ method: string; path: string | RegExp; h: Handler }> = []; | |
| 42 | + const requests: Recorded[] = []; | |
| 43 | + const matches = (p: string | RegExp, path: string) => (typeof p === "string" ? p === path : p.test(path)); | |
| 44 | + const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { | |
| 45 | + if (o.gitRoot && /\.git\//.test(req.url ?? "")) return gitBackend(o.gitRoot, o.gitAuth, req, res, requests); | |
| 46 | + const chunks: Buffer[] = []; | |
| 47 | + for await (const c of req) chunks.push(c as Buffer); | |
| 48 | + const text = Buffer.concat(chunks).toString("utf8"); | |
| 49 | + let body: unknown = text || undefined; | |
| 50 | + try { | |
| 51 | + if (text) body = JSON.parse(text); | |
| 52 | + } catch { | |
| 53 | + /* keep text */ | |
| 54 | + } | |
| 55 | + const u = new URL(req.url ?? "/", "http://x"); | |
| 56 | + const rec: Recorded = { method: req.method ?? "GET", path: u.pathname, query: Object.fromEntries(u.searchParams), headers: req.headers, body }; | |
| 57 | + requests.push(rec); | |
| 58 | + const route = [...routes].reverse().find((r) => r.method === rec.method && matches(r.path, rec.path)); | |
| 59 | + const reply: Reply = route ? await route.h(rec) : { status: 404, body: { message: "not found" } }; | |
| 60 | + const status = reply.status ?? 200; | |
| 61 | + const headers: Record<string, string> = { ...reply.headers }; | |
| 62 | + let out = ""; | |
| 63 | + if (reply.body !== undefined && status !== 204) { | |
| 64 | + if (typeof reply.body === "string") { | |
| 65 | + out = reply.body; | |
| 66 | + headers["content-type"] ??= "text/plain"; | |
| 67 | + } else { | |
| 68 | + out = JSON.stringify(reply.body); | |
| 69 | + headers["content-type"] ??= "application/json"; | |
| 70 | + } | |
| 71 | + } | |
| 72 | + res.writeHead(status, headers); | |
| 73 | + res.end(out); | |
| 74 | + }); | |
| 75 | + await new Promise<void>((r) => server.listen(0, "127.0.0.1", r)); | |
| 76 | + const port = (server.address() as AddressInfo).port; | |
| 77 | + return { | |
| 78 | + url: `http://127.0.0.1:${port}`, | |
| 79 | + host: `127.0.0.1:${port}`, | |
| 80 | + requests, | |
| 81 | + on(method, path, h) { | |
| 82 | + routes.push({ method, path, h: typeof h === "function" ? h : () => h }); | |
| 83 | + }, | |
| 84 | + find(method, path) { | |
| 85 | + return requests.filter((r) => r.method === method && matches(path, r.path)); | |
| 86 | + }, | |
| 87 | + close: () => | |
| 88 | + new Promise<void>((r) => { | |
| 89 | + server.closeAllConnections(); | |
| 90 | + server.close(() => r()); | |
| 91 | + }), | |
| 92 | + }; | |
| 93 | +} | |
| 94 | + | |
| 95 | +export function tempDir(prefix = "diffinery-cli-"): string { | |
| 96 | + return mkdtempSync(path.join(tmpdir(), prefix)); | |
| 97 | +} | |
| 98 | + | |
| 99 | +export interface RunResult { | |
| 100 | + code: number; | |
| 101 | + stdout: string; | |
| 102 | + stderr: string; | |
| 103 | +} | |
| 104 | + | |
| 105 | +export interface RunOptions { | |
| 106 | + env?: NodeJS.ProcessEnv; | |
| 107 | + cwd?: string; | |
| 108 | + stdin?: string; | |
| 109 | + stdinTTY?: boolean; | |
| 110 | +} | |
| 111 | + | |
| 112 | +/** An isolated environment: HOME/XDG in a temp dir, git with no global config. */ | |
| 113 | +export function isolatedEnv(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { | |
| 114 | + const home = tempDir("diffinery-home-"); | |
| 115 | + const gitconfig = path.join(home, ".gitconfig"); | |
| 116 | + writeFileSync(gitconfig, ""); | |
| 117 | + return { | |
| 118 | + PATH: process.env.PATH, | |
| 119 | + HOME: home, | |
| 120 | + XDG_CONFIG_HOME: path.join(home, ".config"), | |
| 121 | + GIT_CONFIG_GLOBAL: gitconfig, | |
| 122 | + GIT_CONFIG_NOSYSTEM: "1", | |
| 123 | + GIT_AUTHOR_NAME: "Test", | |
| 124 | + GIT_AUTHOR_EMAIL: "test@example.com", | |
| 125 | + GIT_COMMITTER_NAME: "Test", | |
| 126 | + GIT_COMMITTER_EMAIL: "test@example.com", | |
| 127 | + ...extra, | |
| 128 | + }; | |
| 129 | +} | |
| 130 | + | |
| 131 | +export async function run(argv: string[], o: RunOptions = {}): Promise<RunResult> { | |
| 132 | + let stdout = ""; | |
| 133 | + let stderr = ""; | |
| 134 | + const stdin = Readable.from(o.stdin !== undefined ? [Buffer.from(o.stdin)] : []) as Io["stdin"]; | |
| 135 | + stdin.isTTY = o.stdinTTY ?? false; | |
| 136 | + const io: Io = { | |
| 137 | + env: o.env ?? isolatedEnv(), | |
| 138 | + cwd: o.cwd ?? tempDir(), | |
| 139 | + stdin, | |
| 140 | + stdout: { write: (s: string) => ((stdout += s), true) }, | |
| 141 | + stderr: { write: (s: string) => ((stderr += s), true) }, | |
| 142 | + sleep: () => new Promise((r) => setTimeout(r, 1)), | |
| 143 | + }; | |
| 144 | + const code = await main(argv, io); | |
| 145 | + return { code, stdout, stderr }; | |
| 146 | +} | |
| 147 | + | |
| 148 | +/** Creates a git repository whose origin points at `remote`, on branch `branch`. */ | |
| 149 | +export function gitRepo(env: NodeJS.ProcessEnv, remote: string, branch = "feature/x"): string { | |
| 150 | + const dir = tempDir("diffinery-repo-"); | |
| 151 | + const g = (...args: string[]) => execFileSync("git", args, { cwd: dir, env, stdio: "pipe" }); | |
| 152 | + g("init", "-q", "-b", "main"); | |
| 153 | + g("remote", "add", "origin", remote); | |
| 154 | + g("commit", "-q", "--allow-empty", "-m", "init"); | |
| 155 | + g("checkout", "-q", "-b", branch); | |
| 156 | + return dir; | |
| 157 | +} | |
| 158 | + | |
| 159 | +/** Serves git smart HTTP via `git http-backend` (CGI), requiring `auth` as the Authorization header when set. */ | |
| 160 | +async function gitBackend(root: string, auth: string | undefined, req: IncomingMessage, res: ServerResponse, requests: Recorded[]): Promise<void> { | |
| 161 | + const u = new URL(req.url ?? "/", "http://x"); | |
| 162 | + requests.push({ method: req.method ?? "GET", path: u.pathname, query: Object.fromEntries(u.searchParams), headers: req.headers, body: undefined }); | |
| 163 | + if (auth && req.headers.authorization !== auth) { | |
| 164 | + res.writeHead(401, { "www-authenticate": 'Basic realm="test"' }); | |
| 165 | + return void res.end(); | |
| 166 | + } | |
| 167 | + const child = spawn("git", ["http-backend"], { | |
| 168 | + env: { | |
| 169 | + PATH: process.env.PATH, | |
| 170 | + GIT_PROJECT_ROOT: root, | |
| 171 | + GIT_HTTP_EXPORT_ALL: "1", | |
| 172 | + GIT_CONFIG_NOSYSTEM: "1", | |
| 173 | + GIT_CONFIG_GLOBAL: "/dev/null", | |
| 174 | + REQUEST_METHOD: req.method ?? "GET", | |
| 175 | + PATH_INFO: decodeURIComponent(u.pathname), | |
| 176 | + QUERY_STRING: u.search.slice(1), | |
| 177 | + CONTENT_TYPE: req.headers["content-type"] ?? "", | |
| 178 | + HTTP_CONTENT_ENCODING: String(req.headers["content-encoding"] ?? ""), | |
| 179 | + GIT_PROTOCOL: String(req.headers["git-protocol"] ?? ""), | |
| 180 | + REMOTE_USER: "tester", | |
| 181 | + REMOTE_ADDR: "127.0.0.1", | |
| 182 | + }, | |
| 183 | + stdio: ["pipe", "pipe", "inherit"], | |
| 184 | + }); | |
| 185 | + req.pipe(child.stdin); | |
| 186 | + const chunks: Buffer[] = []; | |
| 187 | + for await (const c of child.stdout) chunks.push(c as Buffer); | |
| 188 | + const out = Buffer.concat(chunks); | |
| 189 | + const sep = out.indexOf("\r\n\r\n"); | |
| 190 | + const head = out.subarray(0, sep).toString(); | |
| 191 | + const headers: Record<string, string> = {}; | |
| 192 | + let status = 200; | |
| 193 | + for (const line of head.split("\r\n")) { | |
| 194 | + const i = line.indexOf(":"); | |
| 195 | + if (i < 0) continue; | |
| 196 | + const k = line.slice(0, i).trim(); | |
| 197 | + const v = line.slice(i + 1).trim(); | |
| 198 | + if (k.toLowerCase() === "status") status = Number(v.split(" ")[0]); | |
| 199 | + else headers[k] = v; | |
| 200 | + } | |
| 201 | + res.writeHead(status, headers); | |
| 202 | + res.end(out.subarray(sep + 4)); | |
| 203 | +} |
addedapps/cli/test/misc.test.ts+138−0View file
| @@ -1,0 +1,138 @@ | ||
| 1 | +import { afterEach, beforeEach, describe, expect, it } from "vitest"; | |
| 2 | +import { GitHub, nextLink } from "../src/github.js"; | |
| 3 | +import { withGitConfig } from "../src/git.js"; | |
| 4 | +import { fakeServer, isolatedEnv, run, type FakeServer, type Reply } from "./helpers.js"; | |
| 5 | + | |
| 6 | +describe("run watch", () => { | |
| 7 | + let srv: FakeServer; | |
| 8 | + beforeEach(async () => { | |
| 9 | + srv = await fakeServer(); | |
| 10 | + }); | |
| 11 | + afterEach(() => srv.close()); | |
| 12 | + | |
| 13 | + const runBody = (status: string, conclusion: string | null, jobs: Array<[string, string, string | null]>) => ({ | |
| 14 | + id: "run1", | |
| 15 | + run_number: 4, | |
| 16 | + run_attempt: 1, | |
| 17 | + name: "CI", | |
| 18 | + display_title: "Fix bug", | |
| 19 | + path: ".github/workflows/ci.yml", | |
| 20 | + event: "push", | |
| 21 | + head_branch: "main", | |
| 22 | + head_sha: "abcdef1234", | |
| 23 | + status, | |
| 24 | + conclusion, | |
| 25 | + actor: { login: "alice" }, | |
| 26 | + created_at: new Date().toISOString(), | |
| 27 | + updated_at: new Date().toISOString(), | |
| 28 | + html_url: "http://x/runs/run1", | |
| 29 | + jobs: jobs.map(([id, s, c]) => ({ id, name: `job-${id}`, status: s, conclusion: c, startedAt: null, completedAt: null, steps: [], failure: c === "failure" ? { message: "exit 1" } : null })), | |
| 30 | + }); | |
| 31 | + | |
| 32 | + it("prints job transitions and exits 1 when the run fails", async () => { | |
| 33 | + const states = [ | |
| 34 | + runBody("queued", null, [["a", "queued", null]]), | |
| 35 | + runBody("in_progress", null, [["a", "in_progress", null]]), | |
| 36 | + runBody("in_progress", null, [["a", "in_progress", null]]), | |
| 37 | + runBody("completed", "failure", [["a", "completed", "failure"]]), | |
| 38 | + ]; | |
| 39 | + let i = 0; | |
| 40 | + srv.on("GET", "/api/v3/repos/o/r/actions/runs/run1", () => ({ body: states[Math.min(i++, states.length - 1)] })); | |
| 41 | + const r = await run(["run", "watch", "run1", "-R", "o/r", "--interval", "0"], { env: isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "t" }) }); | |
| 42 | + expect(r.code).toBe(1); | |
| 43 | + const lines = r.stdout.split("\n").filter((l) => l.includes("job-a")); | |
| 44 | + expect(lines).toHaveLength(3); // queued, in progress, failure (no duplicate for the repeated state) | |
| 45 | + expect(r.stdout).toContain("completed: failure"); | |
| 46 | + }); | |
| 47 | + | |
| 48 | + it("exits 0 on success", async () => { | |
| 49 | + srv.on("GET", "/api/v3/repos/o/r/actions/runs/run1", { body: runBody("completed", "success", [["a", "completed", "success"]]) }); | |
| 50 | + const r = await run(["run", "watch", "run1", "-R", "o/r"], { env: isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "t" }) }); | |
| 51 | + expect(r.code).toBe(0); | |
| 52 | + }); | |
| 53 | +}); | |
| 54 | + | |
| 55 | +describe("GitHub client", () => { | |
| 56 | + let srv: FakeServer; | |
| 57 | + beforeEach(async () => { | |
| 58 | + srv = await fakeServer(); | |
| 59 | + }); | |
| 60 | + afterEach(() => srv.close()); | |
| 61 | + | |
| 62 | + it("follows Link pagination and waits out rate limits", async () => { | |
| 63 | + let hits = 0; | |
| 64 | + srv.on("GET", "/repos/o/r/issues", (req): Reply => { | |
| 65 | + hits++; | |
| 66 | + if (hits === 1) return { status: 403, headers: { "retry-after": "2" }, body: { message: "secondary rate limit" } }; | |
| 67 | + if (req.query.page === "2") return { body: [{ number: 3 }] }; | |
| 68 | + return { body: [{ number: 1 }, { number: 2 }], headers: { link: `<${srv.url}/repos/o/r/issues?page=2&per_page=100>; rel="next", <${srv.url}/repos/o/r/issues?page=2>; rel="last"` } }; | |
| 69 | + }); | |
| 70 | + srv.on("GET", "/repos/o/r/actions/variables", (req) => | |
| 71 | + req.query.page === "2" ? { body: { total_count: 2, variables: [{ name: "B", value: "2" }] } } : { body: { total_count: 2, variables: [{ name: "A", value: "1" }] }, headers: { link: `<${srv.url}/repos/o/r/actions/variables?page=2>; rel="next"` } }, | |
| 72 | + ); | |
| 73 | + srv.on("GET", "/rate", () => (hits++ < 10 ? { status: 403, headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": "1000" }, body: { message: "API rate limit exceeded" } } : { body: { ok: true } })); | |
| 74 | + const waits: number[] = []; | |
| 75 | + const gh = new GitHub({ baseUrl: srv.url, token: "ght", sleep: async (ms) => void waits.push(ms), now: () => 990_000 }); | |
| 76 | + expect((await gh.paginate<{ number: number }>("/repos/o/r/issues", { state: "all" })).map((i) => i.number)).toEqual([1, 2, 3]); | |
| 77 | + expect(waits).toEqual([2000]); | |
| 78 | + expect(srv.requests[0]!.headers.authorization).toBe("Bearer ght"); | |
| 79 | + expect(srv.requests[0]!.query).toMatchObject({ state: "all", per_page: "100" }); | |
| 80 | + expect(await gh.paginate("/repos/o/r/actions/variables", {}, "variables")).toEqual([ | |
| 81 | + { name: "A", value: "1" }, | |
| 82 | + { name: "B", value: "2" }, | |
| 83 | + ]); | |
| 84 | + hits = 5; | |
| 85 | + waits.length = 0; | |
| 86 | + expect(await gh.get("/rate")).toEqual({ ok: true }); | |
| 87 | + expect(waits.length).toBeGreaterThan(0); | |
| 88 | + expect(waits[0]).toBe(11_000); // reset (1000s) - now (990s) + 1s | |
| 89 | + }); | |
| 90 | + | |
| 91 | + it("parses Link headers", () => { | |
| 92 | + expect(nextLink('<https://a/x?page=2>; rel="next", <https://a/x?page=9>; rel="last"')).toBe("https://a/x?page=2"); | |
| 93 | + expect(nextLink('<https://a/x?page=1>; rel="prev"')).toBeNull(); | |
| 94 | + expect(nextLink(null)).toBeNull(); | |
| 95 | + }); | |
| 96 | +}); | |
| 97 | + | |
| 98 | +describe("misc", () => { | |
| 99 | + it("withGitConfig appends to an existing GIT_CONFIG_COUNT", () => { | |
| 100 | + const env = withGitConfig({ GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: "a.b", GIT_CONFIG_VALUE_0: "c" }, [["http.extraHeader", "X: y"]]); | |
| 101 | + expect(env).toMatchObject({ GIT_CONFIG_COUNT: "2", GIT_CONFIG_KEY_1: "http.extraHeader", GIT_CONFIG_VALUE_1: "X: y" }); | |
| 102 | + }); | |
| 103 | + | |
| 104 | + it("--help works for every command and --version prints the version", async () => { | |
| 105 | + const { registry } = await import("../src/main.js"); | |
| 106 | + const env = isolatedEnv(); | |
| 107 | + for (const g of registry.groups) { | |
| 108 | + expect((await run([g.name, "--help"], { env })).code).toBe(0); | |
| 109 | + for (const c of g.commands) { | |
| 110 | + const r = await run([...c.name.split(" "), "--help"], { env }); | |
| 111 | + expect(r.code, c.name).toBe(0); | |
| 112 | + expect(r.stdout).toContain(`Usage: diffinery ${c.name}`); | |
| 113 | + } | |
| 114 | + } | |
| 115 | + for (const c of registry.commands) expect((await run([c.name, "--help"], { env })).code).toBe(0); | |
| 116 | + expect((await run(["--version"], { env })).stdout).toMatch(/^diffinery \d+\.\d+\.\d+/); | |
| 117 | + expect((await run(["nope"], { env })).code).toBe(2); | |
| 118 | + }); | |
| 119 | + | |
| 120 | + it("api prints JSON and exits 1 on HTTP errors", async () => { | |
| 121 | + const srv = await fakeServer(); | |
| 122 | + try { | |
| 123 | + srv.on("GET", "/api/v3/user", { body: { login: "alice" } }); | |
| 124 | + srv.on("POST", "/api/v3/repos/o/r/labels", (req) => ({ status: 201, body: req.body })); | |
| 125 | + const env = isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "t" }); | |
| 126 | + const r = await run(["api", "get", "/user"], { env }); | |
| 127 | + expect(r.code).toBe(0); | |
| 128 | + expect(JSON.parse(r.stdout)).toEqual({ login: "alice" }); | |
| 129 | + const p = await run(["api", "POST", "repos/o/r/labels", "--input", "-"], { env, stdin: '{"name":"bug"}' }); | |
| 130 | + expect(JSON.parse(p.stdout)).toEqual({ name: "bug" }); | |
| 131 | + const missing = await run(["api", "GET", "/nope"], { env }); | |
| 132 | + expect(missing.code).toBe(1); | |
| 133 | + expect(JSON.parse(missing.stdout)).toEqual({ message: "not found" }); | |
| 134 | + } finally { | |
| 135 | + await srv.close(); | |
| 136 | + } | |
| 137 | + }); | |
| 138 | +}); |
addedapps/cli/test/pr.test.ts+119−0View file
| @@ -1,0 +1,119 @@ | ||
| 1 | +import { afterEach, beforeEach, describe, expect, it } from "vitest"; | |
| 2 | +import { parseRemote } from "../src/git.js"; | |
| 3 | +import { fakeServer, gitRepo, isolatedEnv, run, type FakeServer } from "./helpers.js"; | |
| 4 | + | |
| 5 | +const pull = (n: number, extra: Record<string, unknown> = {}) => ({ | |
| 6 | + number: n, | |
| 7 | + title: "Add thing", | |
| 8 | + body: "", | |
| 9 | + state: "open", | |
| 10 | + user: { login: "alice", type: "User" }, | |
| 11 | + labels: [], | |
| 12 | + assignees: [], | |
| 13 | + comments: 0, | |
| 14 | + created_at: new Date().toISOString(), | |
| 15 | + updated_at: new Date().toISOString(), | |
| 16 | + html_url: `http://x/acme/widgets/pull/${n}`, | |
| 17 | + head: { ref: "feature/x", sha: "abc" }, | |
| 18 | + base: { ref: "main" }, | |
| 19 | + ...extra, | |
| 20 | +}); | |
| 21 | + | |
| 22 | +describe("remote parsing", () => { | |
| 23 | + it("recognizes https, ssh and scp-style remotes for the configured host", () => { | |
| 24 | + expect(parseRemote("https://diffinery.com/acme/widgets.git", "diffinery.com")).toEqual({ owner: "acme", name: "widgets" }); | |
| 25 | + expect(parseRemote("http://127.0.0.1:8787/acme/widgets", "127.0.0.1:8787")).toEqual({ owner: "acme", name: "widgets" }); | |
| 26 | + expect(parseRemote("git@diffinery.com:acme/widgets.git", "diffinery.com")).toEqual({ owner: "acme", name: "widgets" }); | |
| 27 | + expect(parseRemote("https://github.com/acme/widgets.git", "diffinery.com")).toBeNull(); | |
| 28 | + }); | |
| 29 | +}); | |
| 30 | + | |
| 31 | +describe("pull requests", () => { | |
| 32 | + let srv: FakeServer; | |
| 33 | + let env: NodeJS.ProcessEnv; | |
| 34 | + beforeEach(async () => { | |
| 35 | + srv = await fakeServer(); | |
| 36 | + env = isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "dfy_t" }); | |
| 37 | + }); | |
| 38 | + afterEach(() => srv.close()); | |
| 39 | + | |
| 40 | + it("pr create infers the repo from origin and the head from the current branch", async () => { | |
| 41 | + srv.on("POST", "/api/v3/repos/acme/widgets/pulls", { status: 201, body: pull(7) }); | |
| 42 | + const cwd = gitRepo(env, `${srv.url}/acme/widgets.git`, "feature/x"); | |
| 43 | + const r = await run(["pr", "create", "--title", "Add thing", "-b", "Body text", "--base", "main", "--draft", "--intent", "Make widgets faster"], { env, cwd }); | |
| 44 | + expect(r.stderr).toBe(""); | |
| 45 | + expect(r.code).toBe(0); | |
| 46 | + expect(r.stdout).toContain("#7"); | |
| 47 | + const req = srv.find("POST", "/api/v3/repos/acme/widgets/pulls")[0]!; | |
| 48 | + expect(req.headers.authorization).toBe("Bearer dfy_t"); | |
| 49 | + expect(req.body).toEqual({ title: "Add thing", body: "Body text", head: "feature/x", base: "main", draft: true, intent: "Make widgets faster" }); | |
| 50 | + }); | |
| 51 | + | |
| 52 | + it("pr create with -R and --head omits unset fields", async () => { | |
| 53 | + srv.on("POST", "/api/v3/repos/o/r/pulls", { status: 201, body: pull(1) }); | |
| 54 | + const r = await run(["pr", "create", "-R", "o/r", "--title", "T", "--head", "topic"], { env }); | |
| 55 | + expect(r.code).toBe(0); | |
| 56 | + expect(srv.find("POST", "/api/v3/repos/o/r/pulls")[0]!.body).toEqual({ title: "T", head: "topic" }); | |
| 57 | + }); | |
| 58 | + | |
| 59 | + it("pr create requires a title", async () => { | |
| 60 | + const r = await run(["pr", "create", "-R", "o/r"], { env }); | |
| 61 | + expect(r.code).toBe(2); | |
| 62 | + expect(r.stderr).toContain("--title is required"); | |
| 63 | + }); | |
| 64 | + | |
| 65 | + it("pr merge sends the method and delete_branch", async () => { | |
| 66 | + srv.on("PUT", "/api/v3/repos/o/r/pulls/12/merge", { body: { merged: true, sha: "0123456789abcdef", method: "squash" } }); | |
| 67 | + const r = await run(["pr", "merge", "12", "-R", "o/r", "--squash", "--delete-branch"], { env }); | |
| 68 | + expect(r.code).toBe(0); | |
| 69 | + expect(r.stdout).toContain("Merged #12"); | |
| 70 | + expect(srv.find("PUT", "/api/v3/repos/o/r/pulls/12/merge")[0]!.body).toEqual({ merge_method: "squash", delete_branch: true }); | |
| 71 | + }); | |
| 72 | + | |
| 73 | + it("pr merge defaults to a merge commit and rejects conflicting flags", async () => { | |
| 74 | + srv.on("PUT", "/api/v3/repos/o/r/pulls/3/merge", { body: { merged: true, sha: "abc", method: "merge" } }); | |
| 75 | + expect((await run(["pr", "merge", "3", "-R", "o/r"], { env })).code).toBe(0); | |
| 76 | + expect(srv.find("PUT", "/api/v3/repos/o/r/pulls/3/merge")[0]!.body).toEqual({ merge_method: "merge" }); | |
| 77 | + const bad = await run(["pr", "merge", "3", "-R", "o/r", "--squash", "--rebase"], { env }); | |
| 78 | + expect(bad.code).toBe(2); | |
| 79 | + }); | |
| 80 | + | |
| 81 | + it("surfaces the server's { message } on errors", async () => { | |
| 82 | + srv.on("PUT", "/api/v3/repos/o/r/pulls/4/merge", { status: 405, body: { message: "pull request is not mergeable: 1 approval required" } }); | |
| 83 | + const r = await run(["pr", "merge", "4", "-R", "o/r"], { env }); | |
| 84 | + expect(r.code).toBe(1); | |
| 85 | + expect(r.stderr).toContain("1 approval required"); | |
| 86 | + }); | |
| 87 | + | |
| 88 | + it("pr review maps flags to review events", async () => { | |
| 89 | + srv.on("POST", "/api/v3/repos/o/r/pulls/5/reviews", { status: 201, body: { id: "rv1", state: "APPROVED" } }); | |
| 90 | + expect((await run(["pr", "review", "5", "-R", "o/r", "--approve"], { env })).code).toBe(0); | |
| 91 | + expect((await run(["pr", "review", "5", "-R", "o/r", "--request-changes", "-b", "fix it"], { env })).code).toBe(0); | |
| 92 | + expect((await run(["pr", "review", "5", "-R", "o/r", "--comment"], { env })).code).toBe(2); | |
| 93 | + expect(srv.find("POST", "/api/v3/repos/o/r/pulls/5/reviews").map((r) => r.body)).toEqual([{ event: "APPROVE" }, { event: "REQUEST_CHANGES", body: "fix it" }]); | |
| 94 | + }); | |
| 95 | + | |
| 96 | + it("pr list and --json", async () => { | |
| 97 | + srv.on("GET", "/api/v3/repos/o/r/pulls", { body: [pull(1), pull(2, { draft: true, title: "WIP" })] }); | |
| 98 | + const r = await run(["pr", "list", "-R", "o/r"], { env }); | |
| 99 | + expect(r.code).toBe(0); | |
| 100 | + expect(r.stdout).toMatch(/#1\s+Add thing/); | |
| 101 | + expect(r.stdout).toMatch(/#2\s+WIP.*draft/); | |
| 102 | + const j = await run(["pr", "list", "-R", "o/r", "--json"], { env }); | |
| 103 | + expect(JSON.parse(j.stdout)).toHaveLength(2); | |
| 104 | + expect(srv.find("GET", "/api/v3/repos/o/r/pulls")[0]!.query.state).toBe("open"); | |
| 105 | + }); | |
| 106 | + | |
| 107 | + it("pr checks exits 1 on failure", async () => { | |
| 108 | + srv.on("GET", "/api/v3/repos/o/r/pulls/9", { body: { ...pull(9), checks: [{ name: "ci / test", state: "failure" }, { name: "lint", state: "success" }] } }); | |
| 109 | + const r = await run(["pr", "checks", "9", "-R", "o/r"], { env }); | |
| 110 | + expect(r.code).toBe(1); | |
| 111 | + expect(r.stdout).toContain("ci / test"); | |
| 112 | + }); | |
| 113 | + | |
| 114 | + it("pr comment posts to the issue comments endpoint", async () => { | |
| 115 | + srv.on("POST", "/api/v3/repos/o/r/issues/9/comments", { status: 201, body: { id: "c1", body: "hi", user: { login: "alice" }, created_at: "" } }); | |
| 116 | + expect((await run(["pr", "comment", "9", "-R", "o/r", "-b", "hi"], { env })).code).toBe(0); | |
| 117 | + expect(srv.find("POST", "/api/v3/repos/o/r/issues/9/comments")[0]!.body).toEqual({ body: "hi" }); | |
| 118 | + }); | |
| 119 | +}); |
addedapps/cli/test/runner.test.ts+94−0View file
| @@ -1,0 +1,94 @@ | ||
| 1 | +import { existsSync, readFileSync, writeFileSync } from "node:fs"; | |
| 2 | +import path from "node:path"; | |
| 3 | +import { afterEach, beforeEach, describe, expect, it } from "vitest"; | |
| 4 | +import { fakeServer, isolatedEnv, run, tempDir, type FakeServer } from "./helpers.js"; | |
| 5 | + | |
| 6 | +/** A stand-in for containers/runner/dist/agent.mjs: records argv, env and cwd, then exits. */ | |
| 7 | +function fakeAgent(exitCode = 0): { agent: string; record: string } { | |
| 8 | + const dir = tempDir("diffinery-agent-"); | |
| 9 | + const record = path.join(dir, "record.json"); | |
| 10 | + const agent = path.join(dir, "agent.mjs"); | |
| 11 | + writeFileSync( | |
| 12 | + agent, | |
| 13 | + `import { writeFileSync } from "node:fs"; | |
| 14 | +writeFileSync(${JSON.stringify(record)}, JSON.stringify({ argv: process.argv.slice(2), cwd: process.cwd(), env: process.env })); | |
| 15 | +console.log("hello from the agent"); | |
| 16 | +console.error("agent stderr line"); | |
| 17 | +process.exit(${exitCode}); | |
| 18 | +`, | |
| 19 | + ); | |
| 20 | + return { agent, record }; | |
| 21 | +} | |
| 22 | + | |
| 23 | +describe("runner", () => { | |
| 24 | + let srv: FakeServer; | |
| 25 | + let env: NodeJS.ProcessEnv; | |
| 26 | + beforeEach(async () => { | |
| 27 | + srv = await fakeServer(); | |
| 28 | + env = isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "dfy_user_token" }); | |
| 29 | + }); | |
| 30 | + afterEach(() => srv.close()); | |
| 31 | + | |
| 32 | + it("--once claims a job, runs the agent with the job's env, and returns its exit code", async () => { | |
| 33 | + let claims = 0; | |
| 34 | + srv.on("POST", "/api/v3/repos/acme/app/actions/runners/claim", () => | |
| 35 | + ++claims === 1 ? { body: { job_id: "job42", run_id: "run7", name: "build (linux)", token: "djt_repo_job42_secret", api_url: srv.url } } : { status: 204 }, | |
| 36 | + ); | |
| 37 | + const { agent, record } = fakeAgent(3); | |
| 38 | + const work = tempDir("diffinery-work-"); | |
| 39 | + const r = await run(["runner", "-R", "acme/app", "--once", "--agent", agent, "--labels", "gpu, linux", "--name", "box-1", "--work-dir", work], { env }); | |
| 40 | + | |
| 41 | + expect(r.code).toBe(3); | |
| 42 | + const claimReq = srv.find("POST", "/api/v3/repos/acme/app/actions/runners/claim"); | |
| 43 | + expect(claimReq).toHaveLength(1); | |
| 44 | + expect(claimReq[0]!.body).toEqual({ name: "box-1", labels: ["gpu", "linux"] }); | |
| 45 | + expect(claimReq[0]!.headers.authorization).toBe("Bearer dfy_user_token"); | |
| 46 | + | |
| 47 | + const rec = JSON.parse(readFileSync(record, "utf8")) as { argv: string[]; cwd: string; env: Record<string, string> }; | |
| 48 | + expect(rec.argv).toEqual([]); | |
| 49 | + expect(rec.env.DIFFINERY_API_URL).toBe(srv.url); | |
| 50 | + expect(rec.env.DIFFINERY_JOB_ID).toBe("job42"); | |
| 51 | + expect(rec.env.DIFFINERY_JOB_TOKEN).toBe("djt_repo_job42_secret"); | |
| 52 | + expect(rec.env.DIFFINERY_WORK_ROOT).toBe(work); | |
| 53 | + expect(rec.env.DIFFINERY_TOKEN).toBeUndefined(); | |
| 54 | + expect(rec.env.DIFFINERY_URL).toBeUndefined(); | |
| 55 | + | |
| 56 | + expect(r.stdout).toContain("[build (linux)] hello from the agent"); | |
| 57 | + expect(r.stderr).toContain("[build (linux)] agent stderr line"); | |
| 58 | + expect(r.stdout).not.toContain("djt_repo_job42_secret"); | |
| 59 | + }); | |
| 60 | + | |
| 61 | + it("--once with no waiting job exits 0 without running the agent", async () => { | |
| 62 | + srv.on("POST", /\/runners\/claim$/, { status: 204 }); | |
| 63 | + const { agent, record } = fakeAgent(0); | |
| 64 | + const r = await run(["runner", "-R", "acme/app", "-R", "acme/other", "--once", "--agent", agent], { env }); | |
| 65 | + expect(r.code).toBe(0); | |
| 66 | + expect(r.stdout).toContain("No jobs waiting"); | |
| 67 | + expect(existsSync(record)).toBe(false); | |
| 68 | + expect(srv.find("POST", /runners\/claim$/).map((q) => q.path)).toEqual(["/api/v3/repos/acme/app/actions/runners/claim", "/api/v3/repos/acme/other/actions/runners/claim"]); | |
| 69 | + }); | |
| 70 | + | |
| 71 | + it("uses DIFFINERY_RUNNER_AGENT and tries the next repository", async () => { | |
| 72 | + srv.on("POST", "/api/v3/repos/acme/app/actions/runners/claim", { status: 204 }); | |
| 73 | + srv.on("POST", "/api/v3/repos/acme/other/actions/runners/claim", { body: { job_id: "j1", run_id: "r1", name: "test", token: "djt_x", api_url: srv.url } }); | |
| 74 | + const { agent, record } = fakeAgent(0); | |
| 75 | + const r = await run(["runner", "-R", "acme/app", "-R", "acme/other", "--once", "--work-dir", tempDir()], { env: { ...env, DIFFINERY_RUNNER_AGENT: agent } }); | |
| 76 | + expect(r.code).toBe(0); | |
| 77 | + expect(JSON.parse(readFileSync(record, "utf8")).env.DIFFINERY_JOB_ID).toBe("j1"); | |
| 78 | + }); | |
| 79 | + | |
| 80 | + it("explains how to build the agent when none is configured", async () => { | |
| 81 | + const r = await run(["runner", "-R", "acme/app", "--once"], { env }); | |
| 82 | + expect(r.code).toBe(1); | |
| 83 | + expect(r.stderr).toContain("pnpm --filter @diffinery/runner build"); | |
| 84 | + expect(r.stderr).toContain("containers/runner/dist/agent.mjs"); | |
| 85 | + }); | |
| 86 | + | |
| 87 | + it("fails fast on permission errors", async () => { | |
| 88 | + srv.on("POST", "/api/v3/repos/acme/app/actions/runners/claim", { status: 403, body: { message: "admin access required" } }); | |
| 89 | + const { agent } = fakeAgent(0); | |
| 90 | + const r = await run(["runner", "-R", "acme/app", "--agent", agent, "--interval", "0"], { env }); | |
| 91 | + expect(r.code).toBe(1); | |
| 92 | + expect(r.stderr).toContain("admin access required"); | |
| 93 | + }); | |
| 94 | +}); |
addedapps/cli/test/workflow.test.ts+98−0View file
| @@ -1,0 +1,98 @@ | ||
| 1 | +import { mkdirSync, writeFileSync } from "node:fs"; | |
| 2 | +import path from "node:path"; | |
| 3 | +import { afterEach, beforeEach, describe, expect, it } from "vitest"; | |
| 4 | +import { parseInputs } from "../src/commands/workflow.js"; | |
| 5 | +import { fakeServer, isolatedEnv, run, tempDir, type FakeServer } from "./helpers.js"; | |
| 6 | + | |
| 7 | +const GOOD = `name: CI | |
| 8 | +on: [push] | |
| 9 | +jobs: | |
| 10 | + test: | |
| 11 | + runs-on: ubuntu-latest | |
| 12 | + steps: | |
| 13 | + - run: echo hi | |
| 14 | +`; | |
| 15 | + | |
| 16 | +const BAD = `name: Broken | |
| 17 | +on: push | |
| 18 | +jobs: | |
| 19 | + build: | |
| 20 | + runs-on: ubuntu-latest | |
| 21 | + steps: | |
| 22 | + - run: echo hi | |
| 23 | + bogus: true | |
| 24 | + deploy: | |
| 25 | + needs: missing-job | |
| 26 | + runs-on: ubuntu-latest | |
| 27 | + steps: | |
| 28 | + - run: echo deploy | |
| 29 | +`; | |
| 30 | + | |
| 31 | +describe("workflow validate", () => { | |
| 32 | + function project(files: Record<string, string>): string { | |
| 33 | + const dir = tempDir("diffinery-wf-"); | |
| 34 | + for (const [p, content] of Object.entries(files)) { | |
| 35 | + mkdirSync(path.dirname(path.join(dir, p)), { recursive: true }); | |
| 36 | + writeFileSync(path.join(dir, p), content); | |
| 37 | + } | |
| 38 | + return dir; | |
| 39 | + } | |
| 40 | + | |
| 41 | + it("passes valid files in both workflow directories", async () => { | |
| 42 | + const cwd = project({ ".github/workflows/ci.yml": GOOD, ".diffinery/workflows/other.yaml": GOOD, ".github/workflows/README.md": "not yaml" }); | |
| 43 | + const r = await run(["workflow", "validate"], { cwd, env: isolatedEnv() }); | |
| 44 | + expect(r.code).toBe(0); | |
| 45 | + expect(r.stdout).toContain(".github/workflows/ci.yml"); | |
| 46 | + expect(r.stdout).toContain(".diffinery/workflows/other.yaml"); | |
| 47 | + expect(r.stderr).toContain("2 files checked: no errors"); | |
| 48 | + }); | |
| 49 | + | |
| 50 | + it("reports errors with file:line and exits 1", async () => { | |
| 51 | + const cwd = project({ ".github/workflows/ci.yml": GOOD, ".github/workflows/bad.yml": BAD }); | |
| 52 | + const r = await run(["workflow", "validate"], { cwd, env: isolatedEnv() }); | |
| 53 | + expect(r.code).toBe(1); | |
| 54 | + expect(r.stdout).toMatch(/\.github\/workflows\/bad\.yml:10(:\d+)?: error: .*missing-job/); | |
| 55 | + expect(r.stdout).toMatch(/\.github\/workflows\/bad\.yml:8(:\d+)?: (error|warning): .*bogus/); | |
| 56 | + expect(r.stderr).toMatch(/2 files checked: \d+ errors?/); | |
| 57 | + }); | |
| 58 | + | |
| 59 | + it("validates explicit files and reports YAML syntax errors", async () => { | |
| 60 | + const cwd = project({ "x.yml": "jobs: [unclosed\n" }); | |
| 61 | + const r = await run(["workflow", "validate", "x.yml", "--json"], { cwd, env: isolatedEnv() }); | |
| 62 | + expect(r.code).toBe(1); | |
| 63 | + const res = JSON.parse(r.stdout); | |
| 64 | + expect(res[0].file).toBe("x.yml"); | |
| 65 | + expect(res[0].valid).toBe(false); | |
| 66 | + expect(res[0].problems[0].message).toMatch(/YAML/); | |
| 67 | + }); | |
| 68 | + | |
| 69 | + it("is fine with no workflow files", async () => { | |
| 70 | + const r = await run(["workflow", "validate"], { cwd: project({}), env: isolatedEnv() }); | |
| 71 | + expect(r.code).toBe(0); | |
| 72 | + expect(r.stderr).toContain("No workflow files"); | |
| 73 | + }); | |
| 74 | +}); | |
| 75 | + | |
| 76 | +describe("workflow run", () => { | |
| 77 | + let srv: FakeServer; | |
| 78 | + beforeEach(async () => { | |
| 79 | + srv = await fakeServer(); | |
| 80 | + }); | |
| 81 | + afterEach(() => srv.close()); | |
| 82 | + | |
| 83 | + it("dispatches with inputs on the default branch", async () => { | |
| 84 | + srv.on("GET", "/api/v3/repos/o/r", { body: { default_branch: "trunk" } }); | |
| 85 | + srv.on("POST", /\/actions\/workflows\/.+\/dispatches$/, { status: 201, body: { run_id: "run1", html_url: "http://x" } }); | |
| 86 | + const env = isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "dfy" }); | |
| 87 | + const r = await run(["workflow", "run", "deploy.yml", "-R", "o/r", "-f", "env=prod", "-f", "dry=true"], { env }); | |
| 88 | + expect(r.code).toBe(0); | |
| 89 | + const req = srv.find("POST", /dispatches$/)[0]!; | |
| 90 | + expect(req.path).toBe("/api/v3/repos/o/r/actions/workflows/deploy.yml/dispatches"); | |
| 91 | + expect(req.body).toEqual({ ref: "trunk", inputs: { env: "prod", dry: "true" } }); | |
| 92 | + }); | |
| 93 | + | |
| 94 | + it("parses -f pairs", () => { | |
| 95 | + expect(parseInputs(["a=1", "b=x=y", "c="])).toEqual({ a: "1", b: "x=y", c: "" }); | |
| 96 | + expect(() => parseInputs(["nope"])).toThrow(); | |
| 97 | + }); | |
| 98 | +}); |
modifiedapps/diffinery/src/lib/directory.ts+5−5View file
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import type { Env } from "../env.js"; |
| 2 | 2 | import type { RepoMeta, Role } from "../repo/types.js"; |
| 3 | 3 | import { ROLE_LEVEL } from "../repo/types.js"; |
| 4 | -import { HttpError, isValidName, newId, now, randomToken, sha256 } from "./util.js"; | |
| 4 | +import { HttpError, RESERVED_LOGINS, isValidLogin, isValidName, newId, now, randomToken, sha256 } from "./util.js"; | |
| 5 | 5 | |
| 6 | 6 | export interface Account { |
| 7 | 7 | id: string; |
| @@ -64,7 +64,7 @@ | ||
| 64 | 64 | let candidate = base.replace(/[^A-Za-z0-9-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 39) || "user"; |
| 65 | 65 | for (let i = 0; i < 50; i++) { |
| 66 | 66 | const tryLogin = i === 0 ? candidate : `${candidate}-${i + 1}`; |
| 67 | - if (!(await this.accountByLogin(tryLogin))) return tryLogin; | |
| 67 | + if (!RESERVED_LOGINS.has(tryLogin.toLowerCase()) && !(await this.accountByLogin(tryLogin))) return tryLogin; | |
| 68 | 68 | } |
| 69 | 69 | candidate = `${candidate}-${randomToken(3)}`; |
| 70 | 70 | return candidate; |
| @@ -93,7 +93,7 @@ | ||
| 93 | 93 | } |
| 94 | 94 | |
| 95 | 95 | async updateAccount(id: string, patch: { login?: string; name?: string }): Promise<void> { |
| 96 | - if (patch.login && !isValidName(patch.login)) throw new HttpError(422, "invalid login"); | |
| 96 | + if (patch.login && !isValidLogin(patch.login)) throw new HttpError(422, "invalid or reserved login"); | |
| 97 | 97 | if (patch.login) { |
| 98 | 98 | const clash = await this.accountByLogin(patch.login); |
| 99 | 99 | if (clash && clash.id !== id) throw new HttpError(422, "login already taken"); |
| @@ -107,7 +107,7 @@ | ||
| 107 | 107 | } |
| 108 | 108 | |
| 109 | 109 | async createOrg(login: string, name: string, creator: Account): Promise<Account> { |
| 110 | - if (!isValidName(login)) throw new HttpError(422, "invalid organization name"); | |
| 110 | + if (!isValidLogin(login)) throw new HttpError(422, "invalid or reserved organization name"); | |
| 111 | 111 | if (await this.accountByLogin(login)) throw new HttpError(422, "name already taken"); |
| 112 | 112 | const id = newId(); |
| 113 | 113 | const t = now(); |
| @@ -156,7 +156,7 @@ | ||
| 156 | 156 | // ---------------------------------------------------------------- agents |
| 157 | 157 | |
| 158 | 158 | async createAgent(owner: Account, input: { login: string; name?: string; orgId?: string; policy?: Record<string, unknown> }): Promise<Account> { |
| 159 | - if (!isValidName(input.login)) throw new HttpError(422, "invalid agent name"); | |
| 159 | + if (!isValidLogin(input.login)) throw new HttpError(422, "invalid or reserved agent name"); | |
| 160 | 160 | const login = input.login.endsWith("[bot]") ? input.login : `${input.login}`; |
| 161 | 161 | if (await this.accountByLogin(login)) throw new HttpError(422, "name already taken"); |
| 162 | 162 | const id = newId(); |
modifiedapps/diffinery/src/lib/util.ts+5−0View file
| @@ -66,6 +66,11 @@ | ||
| 66 | 66 | } |
| 67 | 67 | } |
| 68 | 68 | |
| 69 | +/** Top-level paths the app serves; no account may use them as its login. */ | |
| 70 | +export const RESERVED_LOGINS = new Set(["api", "mcp", "runner", "settings", "new", "notifications", "invite", "request-access", "healthz", "login", "logout", "admin", "explore", "favicon.ico", "robots.txt", "about", "docs", "static", "assets", "www", "git", "diffinery"]); | |
| 71 | + | |
| 72 | +export const isValidLogin = (s: string): boolean => isValidName(s) && !s.includes(".") && !RESERVED_LOGINS.has(s.toLowerCase()); | |
| 73 | + | |
| 69 | 74 | export const isValidName = (s: string): boolean => /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(s) && !s.endsWith(".git") && s !== "." && s !== ".."; |
| 70 | 75 | |
| 71 | 76 | export function isValidRefName(ref: string): boolean { |
modifiedapps/diffinery/src/web/actions.tsx+1−2View file
| @@ -2,7 +2,6 @@ | ||
| 2 | 2 | import { parseWorkflow, type DispatchInput } from "@diffinery/workflow-yaml"; |
| 3 | 3 | import { raw } from "hono/html"; |
| 4 | 4 | import type { FC } from "hono/jsx"; |
| 5 | -import type { AppContext } from "../auth/principal.js"; | |
| 6 | 5 | import { loadRepo } from "../http/common.js"; |
| 7 | 6 | import { HttpError } from "../lib/util.js"; |
| 8 | 7 | import type { JobInfo, RunInfo } from "../repo/actions.js"; |
| @@ -431,7 +430,7 @@ | ||
| 431 | 430 | const failedStep = job.failure?.step; |
| 432 | 431 | return render( |
| 433 | 432 | c, |
| 434 | - { 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 | + { title: `${job.name} · ${run.title || run.workflowName}`, repo: { repo, caller, tab: "actions" }, live: live ? `job:${job.id}` : undefined, liveMode: "log", lastSeq, wide: true }, | |
| 435 | 434 | <div class="grid left"> |
| 436 | 435 | <aside class="side"> |
| 437 | 436 | <section> |
modifiedapps/diffinery/src/web/assets.ts+4−5View file
| @@ -276,15 +276,15 @@ | ||
| 276 | 276 | const live=b.dataset.live,mode=b.dataset.liveMode||'reload'; |
| 277 | 277 | if(live&&repo&&'WebSocket' in window){let ws,tries=0,timer=null,lastSeq=Number(b.dataset.lastSeq||'-1'),opened=false; |
| 278 | 278 | function connect(){ws=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/'+repo+'/live?topics='+encodeURIComponent(live)); |
| 279 | - ws.onopen=()=>{if(opened)refresh();opened=true;tries=0}; | |
| 279 | + ws.onopen=()=>{if(opened){if(mode==='log')location.reload();else refresh()}opened=true;tries=0}; | |
| 280 | 280 | ws.onmessage=e=>{if(e.data==='pong')return;let m;try{m=JSON.parse(e.data)}catch(_){return}handle(m)}; |
| 281 | 281 | ws.onclose=()=>{setTimeout(connect,Math.min(30000,1000*Math.pow(2,tries++)))}} |
| 282 | 282 | setInterval(()=>{if(ws&&ws.readyState===1)ws.send('ping')},25000); |
| 283 | 283 | function handle(m){const a=m.data&&m.data.action; |
| 284 | - if(mode==='log'&&String(m.topic).startsWith('job:')){ | |
| 284 | + if(mode==='log'){if(!String(m.topic).startsWith('job:'))return; | |
| 285 | 285 | if(a==='logs'){append(m.data.lines||[]);return} |
| 286 | 286 | if(a==='step'&&m.data.step){stepState(m.data.step);return} |
| 287 | - if(a==='completed'){setTimeout(()=>location.reload(),700);return}} | |
| 287 | + if(a==='completed'){setTimeout(()=>location.reload(),700)}return} | |
| 288 | 288 | clearTimeout(timer);timer=setTimeout(refresh,350)} |
| 289 | 289 | async function refresh(){const frags=$$('[data-fragment]');if(!frags.length){location.reload();return} |
| 290 | 290 | try{const r=await fetch(location.href,{credentials:'same-origin'});if(!r.ok)return;const doc=new DOMParser().parseFromString(await r.text(),'text/html'); |
| @@ -298,8 +298,7 @@ | ||
| 298 | 298 | if(t.startsWith('::group::')){sp.className='grp';sp.textContent='\\u25be '+t.slice(9)}else if(/^##\\[error\\]|^::error/.test(t)){sp.className='err';sp.textContent=t}else if(/^::warning/.test(t)){sp.className='wrn';sp.textContent=t}else sp.textContent=t; |
| 299 | 299 | pre.append(sp);const det=pre.parentElement;if(det&&!det.open)det.open=true} |
| 300 | 300 | if(follow)scrollTo(0,d.body.scrollHeight)} |
| 301 | - function stepState(s){const sec=$('.log [data-step="'+s.index+'"]');if(!sec){section(s.index);return stepState2(s)}stepState2(s)} | |
| 302 | - function stepState2(s){const sec=$('.log [data-step="'+s.index+'"]');if(!sec)return;const sm=$('summary',sec);if(!sm)return;const icon=$('.st',sm); | |
| 301 | + function stepState(s){if(!$('.log [data-step="'+s.index+'"]'))section(s.index);const sec=$('.log [data-step="'+s.index+'"]');if(!sec)return;const sm=$('summary',sec);if(!sm)return;const icon=$('.st',sm); | |
| 303 | 302 | if(s.status==='completed'&&icon){icon.className='st '+(s.conclusion==='success'?'ok':s.conclusion==='failure'?'bad':'faint');icon.textContent=s.conclusion==='success'?'\\u2713':s.conclusion==='failure'?'\\u2715':'\\u2212'} |
| 304 | 303 | if(s.name){const label=[...sm.childNodes].find(n=>n.nodeType===3);if(label)label.textContent=s.name}} |
| 305 | 304 | connect()} |
modifiedapps/diffinery/src/web/markdown.ts+4−3View file
| @@ -513,9 +513,9 @@ | ||
| 513 | 513 | // Paragraph. |
| 514 | 514 | const para: string[] = [line.trimStart()]; |
| 515 | 515 | i++; |
| 516 | - while (i < lines.length && lines[i]!.trim() && !startsBlock(lines[i]!, lines[i + 1])) { | |
| 517 | - // Setext headings. | |
| 518 | - if (/^ {0,3}=+\s*$/.test(lines[i]!) || (/^ {0,3}-+\s*$/.test(lines[i]!) && para.length === 1)) { | |
| 516 | + while (i < lines.length && lines[i]!.trim()) { | |
| 517 | + // Setext headings (checked before "---" is taken as a thematic break). | |
| 518 | + if (/^ {0,3}=+\s*$/.test(lines[i]!) || /^ {0,3}-+\s*$/.test(lines[i]!)) { | |
| 519 | 519 | const level = lines[i]!.includes("=") ? 1 : 2; |
| 520 | 520 | const text = para.join(" "); |
| 521 | 521 | const slug = slugify(text); |
| @@ -524,6 +524,7 @@ | ||
| 524 | 524 | i++; |
| 525 | 525 | break; |
| 526 | 526 | } |
| 527 | + if (startsBlock(lines[i]!, lines[i + 1])) break; | |
| 527 | 528 | para.push(lines[i]!.trimStart()); |
| 528 | 529 | i++; |
| 529 | 530 | } |
modifiedapps/diffinery/src/web/routes.tsx+5−0View file
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | import { registerCode } from "./code.js"; |
| 8 | 8 | import { registerCollab } from "./collab.js"; |
| 9 | 9 | import { registerRepoSettings } from "./repo-settings.js"; |
| 10 | +import { ICONS } from "./assets.js"; | |
| 10 | 11 | import { errorPage } from "./ui.js"; |
| 11 | 12 | |
| 12 | 13 | /** |
| @@ -21,6 +22,10 @@ | ||
| 21 | 22 | |
| 22 | 23 | web.onError((err, c) => errorPage(c, err)); |
| 23 | 24 | |
| 25 | +// Browsers and crawlers ask for these on every origin; keep them off the /:owner route. | |
| 26 | +web.get("/favicon.ico", (c) => c.body(ICONS.logo.replace(/currentColor/g, "#5e6ad2"), 200, { "content-type": "image/svg+xml", "cache-control": "public, max-age=86400" })); | |
| 27 | +web.get("/robots.txt", (c) => c.text("User-agent: *\nDisallow: /\n", 200, { "cache-control": "public, max-age=86400" })); | |
| 28 | + | |
| 24 | 29 | // Order matters: fixed paths (/settings/tokens, /notifications/:id, …) must be |
| 25 | 30 | // registered before the /:owner/:repo patterns they would otherwise match. |
| 26 | 31 | registerAccount(web); |
modifiedapps/diffinery/test/e2e/e2e.test.mjs+17−5View file
| @@ -92,11 +92,12 @@ | ||
| 92 | 92 | const r = await api("POST", `/repos/${owner}/${repo}/actions/runners/claim`, { name: "e2e", labels: ["linux"] }); |
| 93 | 93 | return r.status === 200 ? r.json : null; |
| 94 | 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 | - }); | |
| 95 | + const jobEnv = { DIFFINERY_API_URL: claim.api_url, DIFFINERY_JOB_ID: claim.job_id, DIFFINERY_JOB_TOKEN: claim.token }; | |
| 96 | + // E2E_RUNNER_IMAGE runs the job inside the real runner image instead of the local agent bundle. | |
| 97 | + const image = process.env.E2E_RUNNER_IMAGE; | |
| 98 | + const r = image | |
| 99 | + ? spawnSync("docker", ["run", "--rm", "--network", "host", ...Object.entries(jobEnv).flatMap(([k, v]) => ["-e", `${k}=${v}`]), image], { encoding: "utf8", timeout: 300_000 }) | |
| 100 | + : spawnSync(process.execPath, [AGENT], { encoding: "utf8", env: { ...process.env, ...jobEnv, DIFFINERY_WORK_ROOT: join(work, `job-${claim.job_id}`) }, timeout: 120_000 }); | |
| 100 | 101 | const out = `${r.stdout}${r.stderr}`; |
| 101 | 102 | if (r.status !== 0) { |
| 102 | 103 | const log = await (await fetch(`${base}/api/v3/repos/${owner}/${repo}/actions/jobs/${claim.job_id}/logs`, { headers: { authorization: `Bearer ${token}` } })).text(); |
| @@ -314,6 +315,17 @@ | ||
| 314 | 315 | assert.equal(opened.isError, false, JSON.stringify(opened)); |
| 315 | 316 | assert.equal(opened.structuredContent.number, 2); |
| 316 | 317 | |
| 318 | + // ------------------------------------------------------------ web UI renders the same data (signed in via DEV_AUTH) | |
| 319 | + for (const path of ["/", `/${owner}`, `/${owner}/demo`, `/${owner}/demo/blob/main/app.txt`, `/${owner}/demo/commits/main`, `/${owner}/demo/pulls`, `/${owner}/demo/pull/1`, `/${owner}/demo/pull/1/files`, `/${owner}/demo/actions`, `/${owner}/demo/actions/runs/${pushRun.id}`, `/${owner}/demo/actions/runs/${pushRun.id}/job/${job1.claim.job_id}`, `/${owner}/demo/settings/branches`, "/settings/tokens"]) { | |
| 320 | + const res = await fetch(`${base}${path}`); | |
| 321 | + const html = await res.text(); | |
| 322 | + assert.equal(res.status, 200, `${path} -> ${res.status}\n${html.slice(0, 500)}`); | |
| 323 | + assert.match(res.headers.get("content-type") ?? "", /text\/html/, path); | |
| 324 | + } | |
| 325 | + assert.match(await (await fetch(`${base}/${owner}/demo/pull/1`)).text(), /Add a fourth line/); | |
| 326 | + const forged = await fetch(`${base}/${owner}/demo/issues/1/comment`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", origin: "https://evil.example", "sec-fetch-site": "cross-site" }, body: "body=hi" }); | |
| 327 | + assert.equal(forged.status, 403); | |
| 328 | + | |
| 317 | 329 | // The commit made through MCP carries the agent's identity. |
| 318 | 330 | const agentCommit = await ok("GET", `/repos/${owner}/demo/commits/mcp-change`); |
| 319 | 331 | assert.match(JSON.stringify(agentCommit), /e2e-bot/); |
modifiedcontainers/runner/Dockerfile+2−1View file
| @@ -2,7 +2,8 @@ | ||
| 2 | 2 | # Build context: containers/runner. Run `pnpm build` first so dist/agent.mjs exists. |
| 3 | 3 | # |
| 4 | 4 | # docker build --platform linux/amd64 -t diffinery-runner containers/runner |
| 5 | -FROM --platform=linux/amd64 ubuntu:24.04 | |
| 5 | +ARG BASE_IMAGE=ubuntu:24.04 | |
| 6 | +FROM ${BASE_IMAGE} | |
| 6 | 7 | |
| 7 | 8 | ARG NODE_VERSION=22.22.0 |
| 8 | 9 |