diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3dcad032..8ad2aedd2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,7 @@ concurrency: jobs: check: runs-on: ubuntu-24.04 + timeout-minutes: 45 steps: # The runner image ships Postgres 16; starting it avoids Docker Hub pulls and their rate limits. - name: Start Postgres @@ -36,6 +37,8 @@ jobs: run: bun run check - name: Test run: bun run test + - name: Chain + run: bun run test:chain policy: if: github.event_name == 'pull_request' diff --git a/AGENTS.md b/AGENTS.md index 906b67f47..d80c521ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,6 +55,7 @@ Check these for a stock route or primitive before touching the hub, sidecars or - Only load-bearing tests, end to end where possible. - Tests beside the code are fast and offline; anything that runs real services lives in `e2e/`. +- The one exception is `tests/chain/`: it starts the real hub and local sidecars but reaches no outside service (a fake GitHub and a stub decision model), and CI runs it as its own step, `bun run test:chain`. - Never read `process.env` in tests. - A change to how a workflow judges or reviews states its package's eval result before and after. diff --git a/bun.lock b/bun.lock index 097f5a9d4..ae4bdbb1f 100644 --- a/bun.lock +++ b/bun.lock @@ -413,6 +413,18 @@ "@corbits/evals": "0.1.2", }, }, + "tests/chain": { + "name": "@corbits/chain-test", + "version": "0.1.0", + "dependencies": { + "@corbits/common": "workspace:*", + "@corbits/triage-contracts": "workspace:*", + "@intx/db": "workspace:*", + "@intx/hub-client": "workspace:*", + "drizzle-orm": "catalog:", + "isomorphic-git": "catalog:", + }, + }, "vendor/interchange/apps/hub": { "name": "@intx/hub-app", "version": "0.5.0", @@ -962,6 +974,8 @@ "@corbits/bitbucket-datacenter-tool": ["@corbits/bitbucket-datacenter-tool@workspace:packages/bitbucket-datacenter-tool"], + "@corbits/chain-test": ["@corbits/chain-test@workspace:tests/chain"], + "@corbits/common": ["@corbits/common@workspace:packages/common"], "@corbits/cron": ["@corbits/cron@0.2.1", "", { "dependencies": { "arktype": "^2.2.3" }, "peerDependencies": { "@intx/db": "^0.4.0", "@intx/hub-api": "^0.4.0", "drizzle-orm": "^0.45.3", "hono": "^4.13.8", "postgres": "^3.4.9" } }, "sha512-eJm1nD9YSf9ehgkpE+1+9oiVfhZFfgb+Ck3jeHw2koTIeh9cwQ5eoLXigHlmDN+hAqqGeE+v/GBaHOuDy6uYbQ=="], diff --git a/docs/DEV.md b/docs/DEV.md index 2296e5140..986b2eb3a 100644 --- a/docs/DEV.md +++ b/docs/DEV.md @@ -78,12 +78,15 @@ Code review against the emulator: the emulator ignores GitHub's diff media type ```sh bun run test # unit + integration; hub tests need Postgres +bun run test:chain # the live chain: hub, sidecars, listener, manager, judge; about three minutes bun run check # typecheck web, tooling, extension, hub; bundle the hub bun run lint ``` Hub tests connect to `postgres://postgres:postgres@localhost:5432/interchange` and ignore `DATABASE_URL`. +`bun run test:chain` (`tests/chain/`) starts the hub on a database of its own, publishes and upgrades the workspace as Settings does, and sends GitHub's webhooks to it. GitHub and the decision model are local stand-ins, so it needs no account or key; each sidecar is a local process, and it installs the workflows' native modules from the npm registry as a deploy does. Code review needs an isolated sidecar, which a local process is not, so it is not part of the chain test. + Scripts are listed in [IMPLEMENTATION.md](IMPLEMENTATION.md#scripts). ## Check a change locally diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index b1702bd5e..21eee3de0 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -135,8 +135,9 @@ A WXT browser extension that checks the open GitHub pull request locally; see | `bun run dev` | Database migrations, then hub and portal with prefixed logs (`tooling/dev.ts`). | | `bun run db:migrate` | Apply every database migration the hub needs to `DATABASE_URL`; run before the hub starts. | | `bun run hub` | Hub only; does not migrate. | -| `bun run check` | Typecheck tooling, portal, extension, hub, packages and `e2e/`, and bundle the hub. | +| `bun run check` | Typecheck tooling, portal, extension, hub, packages, `e2e/` and `tests/chain/`, and bundle the hub. | | `bun run test` | Unit and integration tests; hub tests need Postgres. | +| `bun run test:chain` | The live triage chain from webhook to pull request on one machine, against a fake GitHub and a stub decision model; needs Postgres and reaches the npm registry for native modules. | | `bun run lint` | oxlint. | | `bun e2e/provisioners/.ts` | Live checks against real services, by hand only; see [e2e/README.md](../e2e/README.md). | | `bun run eval` in `packages/triage-workflows` | Decision-model eval; see [DECISION_MODEL.md](DECISION_MODEL.md#measured-accuracy). | diff --git a/package.json b/package.json index 9a2ff7249..6da6f1bee 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "apps/*", "packages/*", "e2e", + "tests/chain", "vendor/interchange/packages/*", "vendor/interchange/apps/*", "vendor/interchange/tests/admin-ui-e2e" @@ -28,7 +29,8 @@ "dev": "bun run db:migrate && bun --conditions=intx-src tooling/dev.ts", "db:migrate": "bun --conditions=intx-src apps/hub/src/migrate.ts", "test": "bun --conditions=intx-src test --path-ignore-patterns='**/*.dom.test.tsx' ./apps/ ./packages/ ./tooling/ && bun --conditions=intx-src test --preload ./apps/web/src/lib/dom-preload.ts .dom.test", - "check": "bun tooling/exact-deps-check.ts && tsc --noEmit -p tooling && tsc -b apps/web && tsc --noEmit -p apps/extension && tsc --noEmit -p apps/extension/tsconfig.background.json && tsc --noEmit -p apps/extension/e2e && tsc --noEmit -p apps/hub && tsc --noEmit -p packages/triage-workflows && tsc --noEmit -p packages/triage-manager && tsc --noEmit -p packages/triage-judge && tsc --noEmit -p packages/pr-code-review && tsc --noEmit -p packages/pr-code-review-anthropic && tsc --noEmit -p packages/pr-workflow-anthropic && tsc --noEmit -p packages/provisioner-test && tsc --noEmit -p packages/provisioner-template && tsc --noEmit -p packages/provisioner-docker && tsc --noEmit -p packages/provisioner-e2b && tsc --noEmit -p packages/provisioner-railway && tsc --noEmit -p packages/policy-check-action && tsc --noEmit -p e2e && bun build --conditions=intx-src --external @ast-grep/napi apps/hub/src/server.ts --target bun --outfile /dev/null", + "check": "bun tooling/exact-deps-check.ts && tsc --noEmit -p tooling && tsc -b apps/web && tsc --noEmit -p apps/extension && tsc --noEmit -p apps/extension/tsconfig.background.json && tsc --noEmit -p apps/extension/e2e && tsc --noEmit -p apps/hub && tsc --noEmit -p packages/triage-workflows && tsc --noEmit -p packages/triage-manager && tsc --noEmit -p packages/triage-judge && tsc --noEmit -p packages/pr-code-review && tsc --noEmit -p packages/pr-code-review-anthropic && tsc --noEmit -p packages/pr-workflow-anthropic && tsc --noEmit -p packages/provisioner-test && tsc --noEmit -p packages/provisioner-template && tsc --noEmit -p packages/provisioner-docker && tsc --noEmit -p packages/provisioner-e2b && tsc --noEmit -p packages/provisioner-railway && tsc --noEmit -p packages/policy-check-action && tsc --noEmit -p e2e && tsc --noEmit -p tests/chain && bun build --conditions=intx-src --external @ast-grep/napi apps/hub/src/server.ts --target bun --outfile /dev/null", + "test:chain": "bun --conditions=intx-src test tests/chain", "lint": "oxlint", "eval:review": "bun --conditions=intx-src packages/pr-code-review-anthropic/evals/review.eval.ts", "holdback": "bun --conditions=intx-src tooling/holdback-replay.ts", diff --git a/packages/triage-manager/src/triage-manager.test.ts b/packages/triage-manager/src/triage-manager.test.ts index 0e00f1825..2ae628256 100644 --- a/packages/triage-manager/src/triage-manager.test.ts +++ b/packages/triage-manager/src/triage-manager.test.ts @@ -260,15 +260,6 @@ describe("triage-manager", () => { expect(welcomed).toBeGreaterThan(ready); }); - test("rules that need the judge post nothing yet", async () => { - const { writes, host } = githubHost(); - const hub = fakeHub({ takes: [{ ...PASS, request: hubRequest(JUDGED) }], dones: [ENDED] }); - const { ran } = await runMail(github, wake(), host, hub.hub); - expect(ran).toEqual(["approvals", "done", "facts", "held", "judgeScope", "judgeWait", "judging", "paused", "ran", "route", "rules", "take", "triaged", "unready", "waiting"]); - expect(writes).toEqual([]); - expect(doneBody(hub.calls)).not.toHaveProperty("report"); - }); - test("a head the hub holds for CI gets the Waiting label and is not triaged", async () => { const { writes, host } = githubHost(); const hub = fakeHub({ takes: [{ ...PASS, events: [mailEvent(HEAD, REQUEST), mailEvent(HEAD, { ...REQUEST, kind: WAITING_KIND }, "evt_2")] }], dones: [ENDED] }); diff --git a/tests/chain/chain.test.ts b/tests/chain/chain.test.ts new file mode 100644 index 000000000..d332d26eb --- /dev/null +++ b/tests/chain/chain.test.ts @@ -0,0 +1,319 @@ +// The live chain from a webhook to the pull request: the hub, the listener, one manager per pull request and the judge, +// each on a local sidecar, against a fake GitHub and a stub decision model. Each scenario runs once, on a +// repository of its own, and its tests read what it left behind. +import { afterAll, beforeAll, expect, setDefaultTimeout, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { sql } from "drizzle-orm"; +import { createDB } from "@intx/db"; +import { listWorkflowDeployments, listWorkflowRuns, readWorkflowRunEvents } from "@intx/hub-client"; +import { VERDICT_LABELS, WAITING_LABEL } from "@corbits/triage-contracts"; +import { approveHeldDo, isLiveDeployment } from "../../apps/web/src/lib/hub-api.ts"; +import { fakeGithub, OWNER, type Pull, type Write } from "./github.ts"; +import { stubModel, type Answer, type Question } from "./model.ts"; +import { buildRelease, createDatabase, deliver, dropDatabase, setUpWorkspace, signUp, startHub, upgrade, type Database, type Hub, type Workspace } from "./stack.ts"; + +setDefaultTimeout(20 * 60_000); + +const MANAGER = "triage-manager-github"; +const SETTLE_MS = 90_000; + +const DETERMINISTIC = `${OWNER}/deterministic`; +const JUDGED = `${OWNER}/judged`; +const WAITING = `${OWNER}/waiting`; +const PUSHED = `${OWNER}/pushed`; +const HELD = `${OWNER}/held`; + +const MAX_FILES = " - check: max-files-changed\n text: Flag pull requests over 30 files changed.\n max: 30\n"; +const FOCUSED = " - check: focused\n text: Check the change is focused on one thing.\n"; +const CI_GREEN = " - check: ci-green\n text: CI must pass before merge.\n"; +const ASSIGN_ON_PASS = "actions:\n pass:\n - do: assign\n target:\n to: users\n users:\n - octocat\n"; + +function policy(rules: string[], extra = ""): string { + return `rules:\n${rules.map((rule) => `${rule} reason: The maintainers set it.\n evidence:\n - maintainer-stated\n`).join("")}${extra}`; +} + +/** The judge finds every change focused, by the first of a choice's options, and no pull request steering it. */ +function decide(id: string, question: Question): Answer { + if (question.type !== "choice") return { type: "noul", noul: id === "instructed" ? 0.05 : 0.95 }; + const [first, ...rest] = Object.keys(question.criteria as Record); + return { type: "choice", choice: first!, probabilities: { [first!]: 0.96, ...Object.fromEntries(rest.map((option) => [option, 0.04 / rest.length])) }, confidence: 0.96 }; +} + +const github = fakeGithub(); +const model = stubModel(decide); +const releaseDir = mkdtempSync(join(tmpdir(), "corbits-chain-release-")); +let database: Database; +let hub: Hub; +let workspace: Workspace; + +beforeAll(async () => { + github.addRepo(DETERMINISTIC, policy([MAX_FILES])); + github.addRepo(JUDGED, policy([MAX_FILES, FOCUSED])); + github.addRepo(WAITING, policy([CI_GREEN])); + github.addRepo(PUSHED, policy([MAX_FILES])); + github.addRepo(HELD, policy([MAX_FILES], ASSIGN_ON_PASS)); + database = await createDatabase(); + const built = buildRelease(releaseDir); + hub = await startHub(database, github); + workspace = await signUp(hub, database); + await setUpWorkspace(workspace, github, model.origin); + await built; + await upgrade(workspace, hub, releaseDir); +}); + +afterAll(async () => { + await hub?.stop(); + if (database) await dropDatabase(database); + github.stop(); + model.stop(); + rmSync(releaseDir, { recursive: true, force: true }); +}); + +function sha(seed: string): string { + return new Bun.CryptoHasher("sha1").update(seed).digest("hex"); +} + +function pullOf(number: number, head: string): Pull { + return { + number, title: "fix: widget sizing", body: "Fixes the widget's size.", author: "octocat", head, + files: [{ filename: "src/widget.ts", status: "modified", additions: 1, deletions: 1, patch: "@@ -1 +1 @@\n-export const size = 1;\n+export const size = 2;" }], + commits: ["fix: widget sizing"], labels: [], comments: [], + }; +} + +/** GitHub's webhook for a pull request opened or pushed to, after the fake shows that head. */ +async function pushed(repo: string, pull: Pull, action: "opened" | "synchronize" = "opened"): Promise { + github.open(repo, pull); + await deliver(hub, workspace, "pull_request", { + action, installation: { id: 1 }, repository: { full_name: repo }, + pull_request: { number: pull.number, state: "open", draft: false, head: { sha: pull.head }, user: { login: pull.author }, title: pull.title, body: pull.body }, + }); +} + +/** Every run of every deployment, step by step, for a failure message. */ +async function runEvents(): Promise { + const { transport, tenantId } = workspace; + const lines: string[] = []; + async function dump(deploymentId: string, runId: string): Promise { + const { events } = await readWorkflowRunEvents(transport, tenantId, deploymentId, runId); + lines.push(`== ${runId}`); + for (const event of events) { + const body = event.body as Record; + if (event.type === "ChildSpawned") await dump(deploymentId, String(body["childRunId"])); + else if (event.type.startsWith("Step") && event.type !== "StepStarted") lines.push(`${event.type} ${String(body["stepId"])} ${JSON.stringify(body["output"] ?? body["error"] ?? body["message"]).slice(0, 400)}`); + else if (event.type.startsWith("Run")) lines.push(event.type); + } + } + for (const deployment of await listWorkflowDeployments(transport, tenantId)) { + for (const run of await listWorkflowRuns(transport, tenantId, deployment.id)) await dump(deployment.id, run); + } + return lines.join("\n"); +} + +async function until(what: string, read: () => Promise | T | undefined, ms = SETTLE_MS): Promise { + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + const value = await read(); + if (value !== undefined) return value; + await Bun.sleep(500); + } + throw new Error(`Timed out waiting for ${what}.\nRuns:\n${await runEvents()}\nGitHub misses: ${github.misses.join(", ")}`); +} + +async function rows(query: string): Promise[]> { + const { db, close } = createDB(database.config); + try { + return [...await db.execute(sql.raw(query))] as Record[]; + } finally { + await close(); + } +} + +type Claim = { status: "live" | "ended"; runId?: string; history: { head: string; runId: string; doneAt: string }[] }; + +async function claimOf(repo: string, number: number): Promise { + const [row] = await rows(`select state from corbits_triage.manager_state where kind = 'claim' and key = 'mgr:github:${repo}#${number}'`); + return row?.["state"] as Claim | undefined; +} + +/** Every manager deployment's anchor run, oldest first. */ +async function managers(): Promise<{ id: string; createdAt: Date; status: string }[]> { + const found = await rows(`select r.id, r.created_at, r.status from workflow_run r join workflow_definition d on d.id = r.definition_id left join workflow_run_launch_spec s on s.anchor_run_id = r.id where r.id = r.anchor_run_id and coalesce(s.frozen_approval_bundle -> 'projection' ->> 'id', d.name) = '${MANAGER}' order by r.created_at`); + return found.map((row) => ({ id: String(row["id"]), createdAt: new Date(String(row["created_at"])), status: String(row["status"]) })); +} + +/** The claim once its manager's run is gone, whether done ended the claim or the run stopped without ending it. */ +async function settled(repo: string, number: number, passes = 1): Promise { + return until(`${repo}#${number}'s manager to stop`, async function stopped() { + const claim = await claimOf(repo, number); + if (!claim || claim.history.length < passes) return undefined; + const runs = await managers(); + const runId = claim.runId ?? claim.history.at(-1)!.runId; + const live = runs.some((run) => run.id === runId && isLiveDeployment(run.status)); + return live ? undefined : claim; + }); +} + +async function verdictsOf(repo: string, number: number): Promise[]> { + return rows(`select run_id, head_sha, state, run_status, dos, detail from corbits_triage.verdict where repo = '${repo}' and number = ${number} order by verdict_at`); +} + +function comments(writes: readonly Write[]): string[] { + return writes.filter((write) => write.method === "POST" && write.path.endsWith("/comments")).map((write) => String((write.body as { body: string }).body)); +} + +function labelled(writes: readonly Write[]): string[] { + return writes.filter((write) => write.method === "POST" && write.path.endsWith("/labels")).flatMap((write) => (write.body as { labels: string[] }).labels); +} + +/** Runs `scenario` once, on the first test that asks for it. */ +function once(scenario: () => Promise): () => Promise { + let started: Promise | undefined; + return function run() { + started ??= scenario(); + return started; + }; +} + +/** Scenarios run one after another, so each counts the managers it deployed; one that failed is reported by its own tests. */ +async function after(previous: () => Promise): Promise { + try { + await previous(); + } catch { + // Its own tests report it. + } +} + +const deterministic = once(async function deterministic() { + const head = sha("deterministic-1"); + const [before, missed] = [(await managers()).length, github.misses.length]; + await pushed(DETERMINISTIC, pullOf(1, head)); + const claim = await settled(DETERMINISTIC, 1); + return { head, claim, deployed: (await managers()).slice(before), writes: github.writesTo(DETERMINISTIC, 1), misses: github.misses.slice(missed) }; +}); + +test("a policy of only deterministic rules posts its verdict at once and the manager stops", async () => { + const { head, claim, deployed, writes, misses } = await deterministic(); + expect(misses).toEqual([]); + expect(comments(writes)).toEqual([expect.stringContaining("**Triage: Pass**")]); + expect(labelled(writes)).toEqual([VERDICT_LABELS.pass]); + expect(claim).toMatchObject({ status: "ended", history: [{ head, runId: deployed[0]!.id }] }); + expect(deployed).toHaveLength(1); + expect(isLiveDeployment(deployed[0]!.status)).toBe(false); + expect(model.decisions).toEqual([]); +}); + +test("the hub records the verdict from the manager's done", async () => { + const { head, claim } = await deterministic(); + expect(await verdictsOf(DETERMINISTIC, 1)).toEqual([expect.objectContaining({ run_id: claim.history[0]!.runId, head_sha: head, run_status: "completed" })]); +}); + +const judged = once(async function judged() { + const head = sha("judged-1"); + await after(deterministic); + const [asked, missed] = [model.decisions.length, github.misses.length]; + await pushed(JUDGED, pullOf(1, head)); + await settled(JUDGED, 1); + return { head, asked: model.decisions.slice(asked).map((row) => row.id), writes: github.writesTo(JUDGED, 1), misses: github.misses.slice(missed) }; +}); + +test("a policy that needs the judge deploys it and posts the judge's verdict once", async () => { + const { head, asked, writes, misses } = await judged(); + expect(misses).toEqual([]); + expect(asked).toContain("focused"); + expect(comments(writes)).toEqual([expect.stringContaining("**Triage: Pass**")]); + expect(labelled(writes)).toEqual([VERDICT_LABELS.pass]); + // The pass that deployed the judge reported no verdict, so only the judge's is recorded. + expect(await verdictsOf(JUDGED, 1)).toEqual([expect.objectContaining({ head_sha: head, state: "ready-monitoring" })]); +}); + +const waiting = once(async function waiting() { + const head = sha("waiting-1"); + await after(judged); + const missed = github.misses.length; + github.setChecks(WAITING, head, [{ name: "build", status: "in_progress", conclusion: null }]); + await pushed(WAITING, pullOf(1, head)); + const held = await settled(WAITING, 1); + const whileWaiting = [...github.writesTo(WAITING, 1)]; + github.setChecks(WAITING, head, [{ name: "build", status: "completed", conclusion: "success" }]); + await deliver(hub, workspace, "check_run", { + action: "completed", installation: { id: 1 }, repository: { full_name: WAITING }, + check_run: { head_sha: head, status: "completed", conclusion: "success", pull_requests: [{ number: 1, head: { sha: head } }] }, + }); + const claim = await settled(WAITING, 1, held.history.length + 1); + return { head, whileWaiting, claim, pull: github.pull(WAITING, 1), misses: github.misses.slice(missed) }; +}); + +test("a head held for CI is marked waiting, and its check run's completion settles it", async () => { + const { head, whileWaiting, claim, pull, misses } = await waiting(); + expect(misses).toEqual([]); + expect(labelled(whileWaiting)).toEqual([WAITING_LABEL]); + expect(comments(whileWaiting)).toEqual([]); + expect(pull.labels).toEqual([VERDICT_LABELS.pass]); + expect(pull.comments.map((comment) => comment.body)).toEqual([expect.stringContaining("**Triage: Pass**")]); + expect(claim.status).toBe("ended"); + expect(claim.history.at(-1)!.head).toBe(head); +}); + +const pushedAgain = once(async function pushedAgain() { + const [first, second, third] = [sha("pushed-1"), sha("pushed-2"), sha("pushed-3")]; + await after(waiting); + const [before, missed] = [(await managers()).length, github.misses.length]; + // The manager's pass reads the files, so holding them keeps it up while the second push arrives. + const release = github.hold(PUSHED, 1); + await pushed(PUSHED, pullOf(1, first)); + await until("the manager to claim the pull request", async () => (await claimOf(PUSHED, 1))?.status === "live" || undefined); + await pushed(PUSHED, pullOf(1, second), "synchronize"); + await until("the second push to be recorded", async () => { + const [row] = await rows(`select count(*)::int as recorded from corbits_triage.manager_state where kind = 'event' and key like 'evt:github:${PUSHED}#1:%'`); + return Number(row!["recorded"]) >= 2 || undefined; + }); + release(); + const alive = await settled(PUSHED, 1, 2); + const afterTwo = (await managers()).slice(before); + await pushed(PUSHED, pullOf(1, third), "synchronize"); + const claim = await settled(PUSHED, 1, alive.history.length + 1); + return { second, third, alive, afterTwo, claim, deployed: (await managers()).slice(before), misses: github.misses.slice(missed) }; +}); + +test("a push while the manager is up is mailed to it, and one after it stopped deploys the next, never two at once", async () => { + const { second, third, alive, afterTwo, claim, deployed, misses } = await pushedAgain(); + expect(misses).toEqual([]); + expect(afterTwo).toHaveLength(1); + expect(new Set(alive.history.map((pass) => pass.runId))).toEqual(new Set([afterTwo[0]!.id])); + expect(alive.history.at(-1)!.head).toBe(second); + expect(deployed).toHaveLength(2); + expect(claim.history.at(-1)).toMatchObject({ head: third, runId: deployed[1]!.id }); + expect(deployed[1]!.createdAt.getTime()).toBeGreaterThanOrEqual(new Date(alive.history.at(-1)!.doneAt).getTime() - 1_000); +}); + +const approved = once(async function approved() { + const head = sha("held-1"); + await after(pushedAgain); + const missed = github.misses.length; + await pushed(HELD, pullOf(1, head)); + await settled(HELD, 1); + const writes = [...github.writesTo(HELD, 1)]; + const [verdict] = await verdictsOf(HELD, 1); + return { head, writes, verdict, misses: github.misses.slice(missed) }; +}); + +test("a Do the policy does not run on its own is held", async () => { + const { head, writes, verdict, misses } = await approved(); + expect(misses).toEqual([]); + expect(verdict).toMatchObject({ head_sha: head, dos: [{ list: "pass" }], detail: { actions: [expect.objectContaining({ list: "pass", kind: "assign", target: { users: ["octocat"] }, held: true })] } }); + expect(writes.some((write) => write.path.endsWith("/assignees"))).toBe(false); + expect(labelled(writes)).toEqual([VERDICT_LABELS.pass]); +}); + +test("a held Do approved through the hub runs once on the pull request", async () => { + const { verdict } = await approved(); + // The verdict's one Do is the held assign, whose effect id its recorded Dos keep. + const [held] = verdict!["dos"] as { effectId: string }[]; + await approveHeldDo(workspace.transport, workspace.tenantId, { runId: String(verdict!["run_id"]), repo: HELD, number: 1, effectId: held!.effectId, kind: "assign" }); + await until("the assignment", () => github.writesTo(HELD, 1).find((write) => write.path.endsWith("/assignees"))); + await settled(HELD, 1, 2); + expect(github.writesTo(HELD, 1).filter((write) => write.path.endsWith("/assignees"))).toEqual([expect.objectContaining({ method: "POST", body: { assignees: ["octocat"] } })]); +}); diff --git a/tests/chain/github.ts b/tests/chain/github.ts new file mode 100644 index 000000000..f60766f7c --- /dev/null +++ b/tests/chain/github.ts @@ -0,0 +1,177 @@ +// GitHub as the hub and the workflows' sidecars reach it: one App installed on one account, its repositories, their +// policy files and open pull requests. Writes change what later reads answer and are kept in order for the test. +import { POLICY_FILE_PATH } from "@corbits/triage-contracts"; + +export const OWNER = "acme"; +export const INSTALLATION = 1; +const BOT = { login: "corbits[bot]", type: "Bot" }; + +export type File = { filename: string; status: string; additions: number; deletions: number; patch: string }; +export type CheckRun = { name: string; status: "queued" | "in_progress" | "completed"; conclusion: "success" | "failure" | null }; +export type Pull = { + number: number; + title: string; + body: string; + author: string; + head: string; + files: File[]; + commits: string[]; + labels: string[]; + comments: { id: number; user: typeof BOT; body: string }[]; +}; +export type Write = { method: string; path: string; body: unknown }; + +type Repo = { name: string; policy: string; pulls: Map; checks: Map }; + +export function fakeGithub() { + const repos = new Map(); + const writes: Write[] = []; + const misses: string[] = []; + const requests: string[] = []; + const held = new Map>(); + let ids = 0; + + function pullJson(repo: Repo, pull: Pull) { + return { + number: pull.number, title: pull.title, body: pull.body, body_html: `

${pull.body}

`, state: "open", draft: false, merged: false, mergeable: true, mergeable_state: "clean", + user: { login: pull.author, type: "User" }, author_association: "CONTRIBUTOR", head: { sha: pull.head, ref: `pr-${pull.number}`, repo: { id: 1, full_name: repo.name } }, base: { ref: "main", sha: "0".repeat(40), repo: { id: 1, full_name: repo.name } }, + additions: pull.files.reduce((sum, file) => sum + file.additions, 0), deletions: pull.files.reduce((sum, file) => sum + file.deletions, 0), + changed_files: pull.files.length, labels: pull.labels.map((name) => ({ name })), requested_reviewers: [], requested_teams: [], assignees: [], + }; + } + + async function readRoute(repo: Repo, rest: string, url: URL): Promise { + if (rest === "") return Response.json({ full_name: repo.name, default_branch: "main", private: false, owner: { login: OWNER, type: "Organization" } }); + if (rest === "/installation") return Response.json({ id: INSTALLATION }); + if (rest === `/contents/${POLICY_FILE_PATH}`) return Response.json({ type: "file", sha: "policy", encoding: "base64", content: Buffer.from(repo.policy).toString("base64"), html_url: `https://github.com/${repo.name}/blob/main/${POLICY_FILE_PATH}` }); + if (rest.startsWith("/contents")) return new Response(null, { status: 404 }); + if (rest === "/pulls") return Response.json([...repo.pulls.values()].map((pull) => pullJson(repo, pull))); + const pulled = /^\/(pulls|issues)\/(\d+)(\/.*)?$/.exec(rest); + if (pulled) { + const pull = repo.pulls.get(Number(pulled[2])); + if (!pull) return new Response(null, { status: 404 }); + const tail = pulled[3] ?? ""; + if (tail === "") return Response.json(pullJson(repo, pull)); + if (tail === "/files") { + await held.get(`${repo.name}#${pull.number}`); + const perPage = Number(url.searchParams.get("per_page") ?? 30); + const page = Number(url.searchParams.get("page") ?? 1); + return Response.json(pull.files.slice((page - 1) * perPage, page * perPage)); + } + if (tail === "/commits") return Response.json(pull.commits.map((message, index) => ({ sha: index === pull.commits.length - 1 ? pull.head : `c${index}`.padEnd(40, "0"), commit: { message } }))); + if (tail === "/reviews") return Response.json([]); + if (tail === "/comments") return Response.json(pulled[1] === "issues" ? pull.comments : []); + if (tail === "/labels") return Response.json(pull.labels.map((name) => ({ name }))); + if (tail === "/requested_reviewers") return Response.json({ users: [], teams: [] }); + if (tail === "/timeline" || tail === "/events") return Response.json([]); + return undefined; + } + const commit = /^\/commits\/([0-9a-f]+)\/(check-runs|status|check-suites)$/.exec(rest); + const runs = commit && repo.checks.get(commit[1]!); + if (commit && runs) { + if (commit[2] === "check-runs") return Response.json({ total_count: runs.length, check_runs: runs.map((run, index) => ({ id: index + 1, head_sha: commit[1], ...run })) }); + if (commit[2] === "status") return Response.json({ state: "success", statuses: [], total_count: 0 }); + return Response.json({ total_count: 0, check_suites: [] }); + } + if (rest.startsWith("/git/trees/")) return Response.json({ sha: "tree", tree: [], truncated: false }); + if (rest.startsWith("/collaborators/")) return Response.json({ permission: "read", role_name: "read" }); + if (rest === "/labels" || rest.startsWith("/labels?")) return Response.json([]); + return undefined; + } + + /** The writes triage makes; any other is a miss, so a new write shows up as a failed scenario. */ + function writeRoute(repo: Repo, rest: string, method: string, body: Record): Response | undefined { + const issue = /^\/(issues|pulls)\/(\d+)(\/.*)?$/.exec(rest); + const pull = issue ? repo.pulls.get(Number(issue[2])) : undefined; + const tail = issue?.[3]; + if (pull && tail === "/labels" && method === "POST") { + for (const name of body["labels"] as string[]) if (!pull.labels.includes(name)) pull.labels.push(name); + return Response.json(pull.labels.map((name) => ({ name }))); + } + if (pull && tail?.startsWith("/labels/") && method === "DELETE") { + const name = decodeURIComponent(tail.slice("/labels/".length)); + pull.labels = pull.labels.filter((label) => label !== name); + return Response.json(pull.labels.map((name) => ({ name }))); + } + if (pull && tail === "/comments" && method === "POST") { + ids += 1; + pull.comments.push({ id: ids, user: BOT, body: String(body["body"]) }); + return Response.json(pull.comments.at(-1), { status: 201 }); + } + if (pull && tail === "/assignees" && method === "POST") return Response.json(pullJson(repo, pull), { status: 201 }); + const edited = /^\/issues\/comments\/(\d+)$/.exec(rest); + const comment = edited && method === "PATCH" ? [...repo.pulls.values()].flatMap((each) => each.comments).find((row) => row.id === Number(edited[1])) : undefined; + if (comment) { + comment.body = String(body["body"]); + return Response.json(comment); + } + return undefined; + } + + async function serve(req: Request): Promise { + const url = new URL(req.url); + const { pathname } = url; + requests.push(`${req.method} ${pathname}${url.search}`); + if (pathname === "/app") return Response.json({ id: 1, slug: "corbits", name: "Corbits" }); + if (pathname === "/app/installations") return Response.json([{ id: INSTALLATION, account: { login: OWNER }, html_url: "https://github.com/settings/installations/1", repository_selection: "selected", suspended_at: null }]); + if (pathname === `/app/installations/${INSTALLATION}/access_tokens`) return Response.json({ token: "installation-token", expires_at: new Date(Date.now() + 3_600_000).toISOString() }, { status: 201 }); + if (pathname === "/installation/repositories") return Response.json({ total_count: repos.size, repositories: [...repos.keys()].map((name) => ({ full_name: name })) }); + const named = /^\/repos\/([^/]+\/[^/]+)(.*)$/.exec(pathname); + const repo = named ? repos.get(named[1]!) : undefined; + if (repo && req.method === "GET") { + const answer = await readRoute(repo, named![2]!, url); + if (answer) return answer; + } + if (repo && req.method !== "GET") { + const text = await req.text(); + const body = text ? JSON.parse(text) as Record : {}; + const answer = writeRoute(repo, named![2]!, req.method, body); + if (answer) { + writes.push({ method: req.method, path: pathname, body }); + return answer; + } + } + misses.push(`${req.method} ${pathname}`); + return new Response(null, { status: 404 }); + } + + const server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: serve }); + return { + origin: `http://127.0.0.1:${server.port}`, + writes, + misses, + requests, + addRepo(name: string, policy: string): void { + repos.set(name, { name, policy, pulls: new Map(), checks: new Map() }); + }, + /** Shows `pull` at its head, which has no check runs unless the test set some. */ + open(repo: string, pull: Pull): void { + const shown = repos.get(repo)!; + shown.pulls.set(pull.number, pull); + if (!shown.checks.has(pull.head)) shown.checks.set(pull.head, []); + }, + pull(repo: string, number: number): Pull { + return repos.get(repo)!.pulls.get(number)!; + }, + /** Holds reads of the pull request's files, and so its manager's pass, until the returned release is called. */ + hold(repo: string, number: number): () => void { + let release = () => {}; + held.set(`${repo}#${number}`, new Promise(function wait(resolve) { release = resolve; })); + return function released() { + held.delete(`${repo}#${number}`); + release(); + }; + }, + setChecks(repo: string, head: string, runs: CheckRun[]): void { + repos.get(repo)!.checks.set(head, runs); + }, + writesTo(repo: string, number: number): Write[] { + return writes.filter((write) => write.path.startsWith(`/repos/${repo}/issues/${number}/`) || write.path.startsWith(`/repos/${repo}/pulls/${number}/`)); + }, + stop(): void { + server.stop(true); + }, + }; +} + +export type FakeGithub = ReturnType; diff --git a/tests/chain/model.ts b/tests/chain/model.ts new file mode 100644 index 000000000..780621bc0 --- /dev/null +++ b/tests/chain/model.ts @@ -0,0 +1,31 @@ +// The workspace's decision model, served locally on System One's wire: it answers each question from the test's script +// and keeps what it was asked. +export type Question = { type: "noul" | "choice" | "score"; instructions: string; criteria?: unknown }; +export type Answer = { type: "noul"; noul: number } | { type: "choice"; choice: string; probabilities: Record; confidence: number }; + +export function stubModel(decide: (id: string, question: Question) => Answer) { + const decisions: { id: string; question: Question }[] = []; + + async function systemOne(req: Request): Promise { + const { questions } = await req.json() as { questions: Record }; + const answers = Object.fromEntries(Object.entries(questions).map(function answer([id, question]) { + decisions.push({ id, question }); + return [id, decide(id, question)]; + })); + return Response.json({ model: "stub", usage: { input_tokens: 1, output_tokens: 1 }, answers }); + } + + async function serve(req: Request): Promise { + const { pathname } = new URL(req.url); + return req.method === "POST" && pathname.endsWith("/systemone") ? systemOne(req) : new Response(null, { status: 404 }); + } + + const server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: serve }); + return { + origin: `http://127.0.0.1:${server.port}`, + decisions, + stop(): void { + server.stop(true); + }, + }; +} diff --git a/tests/chain/package.json b/tests/chain/package.json new file mode 100644 index 000000000..8d8fc9989 --- /dev/null +++ b/tests/chain/package.json @@ -0,0 +1,16 @@ +{ + "name": "@corbits/chain-test", + "version": "0.1.0", + "private": true, + "license": "SEE LICENSE IN LICENSE.md", + "type": "module", + "dependencies": { + "@corbits/common": "workspace:*", + "@corbits/triage-contracts": "workspace:*", + "@intx/db": "workspace:*", + "@intx/hub-client": "workspace:*", + "drizzle-orm": "catalog:", + "isomorphic-git": "catalog:" + }, + "description": "The live triage chain from webhook to verdict, run locally against a fake GitHub and a stub decision model" +} diff --git a/tests/chain/stack.ts b/tests/chain/stack.ts new file mode 100644 index 000000000..d5f31bfa2 --- /dev/null +++ b/tests/chain/stack.ts @@ -0,0 +1,243 @@ +// The live chain on one machine: a migrated database of its own, the hub process with its local sidecars, and a +// workspace set up and upgraded through the same routes and portal code a person's browser uses. +import { createHmac, generateKeyPairSync, randomBytes } from "node:crypto"; +import fs from "node:fs"; +import { mkdtempSync, rmSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { eq, sql } from "drizzle-orm"; +import http from "isomorphic-git/http/web"; +import { createDB, schema } from "@intx/db"; +import { ApiError, type Transport } from "@intx/hub-client"; +import { GITHUB_HOOK_HEADERS, xHubHookMetadata } from "@corbits/common/hook-metadata"; +import { packWorkflows } from "../../apps/web/scripts/build-workflows.ts"; +import { DECISION_MODEL_ALIAS, INFERENCE_CREDENTIAL_NAME, INFERENCE_PROVIDER, patchAppConfig } from "../../apps/web/src/lib/hub-api.ts"; +import { connectGithubApp } from "../../apps/web/src/lib/github-manifest.ts"; +import { upgradeTriage } from "../../apps/web/src/lib/triage-upgrade.ts"; +import { publishSources, type Publisher } from "../../apps/web/src/lib/workflow-sources.ts"; +import type { FakeGithub } from "./github.ts"; + +const ROOT = resolve(import.meta.dir, "../.."); +const ADMIN_DB = { host: "localhost", port: 5432, user: "postgres", password: "postgres", database: "interchange" }; +// The hub and its sidecars shell out to bun, which needs it on PATH and a HOME for its cache. +const CHILD_PATH = `${dirname(process.execPath)}:/usr/bin:/bin`; +const SECRETS = { + BETTER_AUTH_SECRET: "a".repeat(64), + CREDENTIAL_ENCRYPTION_KEY: "b".repeat(64), + PRINCIPAL_KEY_ENCRYPTION_KEY: "c".repeat(64), + SIDECAR_CREDENTIAL_ENCRYPTION_KEY: "d".repeat(64), +}; +export const HOOK_SECRET = "chain-hook-secret"; +const READY_MS = 60_000; + +export type Database = { name: string; url: string; config: typeof ADMIN_DB }; + +export async function createDatabase(): Promise { + const name = `corbits_chain_${randomBytes(6).toString("hex")}`; + const admin = createDB(ADMIN_DB); + try { + await admin.db.execute(sql.raw(`create database ${name}`)); + } finally { + await admin.close(); + } + const url = `postgres://postgres:postgres@localhost:5432/${name}`; + await Bun.$`${process.execPath} --conditions=intx-src apps/hub/src/migrate.ts`.cwd(ROOT).env({ PATH: CHILD_PATH, HOME: homedir(), DATABASE_URL: url }).quiet(); + return { name, url, config: { ...ADMIN_DB, database: name } }; +} + +export async function dropDatabase(database: Database): Promise { + const admin = createDB(ADMIN_DB); + try { + await admin.db.execute(sql.raw(`drop database if exists ${database.name} with (force)`)); + } finally { + await admin.close(); + } +} + +function freePort(): number { + const probe = Bun.serve({ port: 0, fetch: () => new Response(null) }); + const port = probe.port!; + probe.stop(true); + return port; +} + +async function collect(stream: ReadableStream, lines: string[]): Promise { + const decoder = new TextDecoder(); + let partial = ""; + for await (const chunk of stream) { + const parts = (partial + decoder.decode(chunk, { stream: true })).split("\n"); + partial = parts.pop()!; + lines.push(...parts); + } +} + +export type Hub = { origin: string; dataDir: string; logs: string[]; stop: () => Promise }; + +/** The hub as `bun run hub` starts it, with every sidecar a local process under its data directory. */ +export async function startHub(database: Database, github: FakeGithub): Promise { + const port = freePort(); + const origin = `http://127.0.0.1:${port}`; + const dataDir = mkdtempSync(join(tmpdir(), "corbits-chain-")); + const logs: string[] = []; + const child = Bun.spawn([process.execPath, "--conditions=intx-src", "apps/hub/src/server.ts"], { + cwd: ROOT, + env: { + PATH: CHILD_PATH, + HOME: homedir(), + ...SECRETS, + DATABASE_URL: database.url, + HUB_DATA_DIR: dataDir, + PORT: String(port), + BETTER_AUTH_BASE_URL: origin, + GITHUB_API_ORIGIN: github.origin, + TRIAGE_COALESCE_QUIET_MS: "500", + // Every sidecar a local process, as on a hub without Docker, so each reaches the fake GitHub and model on loopback. + DOCKER_HOST: "unix:///nonexistent/docker.sock", + }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + void collect(child.stdout, logs); + void collect(child.stderr, logs); + async function stop(): Promise { + child.kill("SIGTERM"); + await Promise.race([child.exited, Bun.sleep(30_000)]); + if (child.exitCode === null) child.kill("SIGKILL"); + rmSync(dataDir, { recursive: true, force: true }); + } + const deadline = Date.now() + READY_MS; + while (Date.now() < deadline && child.exitCode === null) { + try { + if ((await fetch(`${origin}/api/me`)).status === 401) return { origin, dataDir, logs, stop }; + } catch { + // Still binding its socket. + } + await Bun.sleep(200); + } + await stop(); + throw new Error(`The hub did not start:\n${logs.join("\n")}`); +} + +/** The portal's transport, signed in as one person by their session cookie. */ +function cookieTransport(origin: string, cookie: string): Transport { + return { + async fetch(method: string, path: string, body?: unknown): Promise { + const res = await fetch(`${origin}${path}`, { + method, + headers: { cookie, origin, ...(body !== undefined && { "content-type": "application/json" }) }, + ...(body !== undefined && { body: JSON.stringify(body) }), + }); + const text = await res.text(); + const parsed = text ? JSON.parse(text) : undefined; + if (!res.ok) throw new ApiError(res.status, parsed?.error?.code ?? "unknown", parsed?.error?.message ?? `${method} ${path}: HTTP ${res.status} ${text}`); + return parsed as T; + }, + subscribe() { + throw new Error("The chain test subscribes to nothing."); + }, + }; +} + +export type Workspace = { tenantId: string; principalId: string; transport: Transport }; + +/** The hub's first account, verified as its emailed link would, and the workspace it creates. */ +export async function signUp(hub: Hub, database: Database): Promise { + const email = "owner@example.com"; + const signUp = await fetch(`${hub.origin}/api/auth/sign-up/email`, { + method: "POST", + headers: { "content-type": "application/json", origin: hub.origin }, + body: JSON.stringify({ email, password: "password-123456", name: "Owner" }), + }); + if (!signUp.ok) throw new Error(`Sign-up failed: ${signUp.status} ${await signUp.text()}`); + const { db, close } = createDB(database.config); + try { + await db.update(schema.user).set({ emailVerified: true }).where(eq(schema.user.email, email)); + } finally { + await close(); + } + const cookie = signUp.headers.getSetCookie().map((value) => value.split(";")[0]).join("; "); + const transport = cookieTransport(hub.origin, cookie); + const tenant = await transport.fetch<{ id: string }>("POST", "/api/tenants", { name: "Acme", slug: "acme" }); + const principals = await transport.fetch<{ data?: { id: string; email?: string }[] } | { id: string; email?: string }[]>("GET", `/api/tenants/${tenant.id}/principals`); + const rows = Array.isArray(principals) ? principals : principals.data!; + const owner = rows.find((row) => row.email === email); + if (!owner) throw new Error(`The workspace lists no principal for ${email}: ${JSON.stringify(rows)}`); + return { tenantId: tenant.id, principalId: owner.id, transport }; +} + +async function vaultKey(workspace: Workspace, provider: { name: string; plugin: string; apiBaseUrl: string }, credential: string): Promise { + const { transport, tenantId } = workspace; + const created = await transport.fetch<{ id: string }>("POST", `/api/tenants/${tenantId}/providers`, provider); + const key = await transport.fetch<{ id: string }>("POST", `/api/tenants/${tenantId}/credentials`, { providerId: created.id, name: credential, type: "api_key", secret: `${credential}-key` }); + return key.id; +} + +async function offer(workspace: Workspace, provider: { name: string; plugin: string; baseURL: string; credentialId: string }, models: { name: string; quirks?: Record }[]): Promise { + const { transport, tenantId } = workspace; + const catalog = `/api/tenants/${tenantId}/catalog`; + const row = await transport.fetch<{ id: string }>("POST", `${catalog}/providers`, provider); + for (const model of models) { + const created = await transport.fetch<{ id: string }>("POST", `${catalog}/models`, { canonicalName: model.name }); + await transport.fetch("POST", `${catalog}/offerings`, { modelId: created.id, providerId: row.id, priority: 0, ...(model.quirks && { quirks: model.quirks }) }); + } +} + +/** + * Connects the App and its webhook secret, lists its repositories, turns triage on for them and saves both models, each + * served by `modelOrigin`. The decision model and Anthropic's are written as Settings writes them, at a local endpoint + * Settings would refuse for not being HTTPS. + */ +export async function setUpWorkspace(workspace: Workspace, github: FakeGithub, modelOrigin: string): Promise { + const { transport, tenantId } = workspace; + const provider = await transport.fetch<{ id: string }>("POST", `/api/tenants/${tenantId}/providers`, { name: "github", plugin: "http", apiBaseUrl: github.origin }); + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048, privateKeyEncoding: { type: "pkcs8", format: "pem" }, publicKeyEncoding: { type: "spki", format: "pem" } }); + await connectGithubApp(transport, tenantId, { appId: "1", privateKey }); + await transport.fetch("POST", `/api/tenants/${tenantId}/credentials`, { providerId: provider.id, name: "github-hook", type: "api_key", secret: HOOK_SECRET, metadata: xHubHookMetadata("pr-triage", GITHUB_HOOK_HEADERS) }); + await transport.fetch("POST", `/api/integrations/github-installations/${tenantId}/sync`, {}); + await patchAppConfig(transport, tenantId, function enableAll(current) { + if (!current.repos) throw new Error("Syncing the App's installations recorded no repositories."); + return { ...current, repos: current.repos.map((row) => ({ ...(row as object), enabled: true })) }; + }); + const decisionKey = await vaultKey(workspace, { name: INFERENCE_PROVIDER, plugin: INFERENCE_PROVIDER, apiBaseUrl: modelOrigin }, INFERENCE_CREDENTIAL_NAME); + await offer(workspace, { name: INFERENCE_PROVIDER, plugin: INFERENCE_PROVIDER, baseURL: modelOrigin, credentialId: decisionKey }, [{ name: DECISION_MODEL_ALIAS, quirks: { model: "stub" } }]); +} + +/** Builds the portal's workflow packages once into `dir`, as `bun run dev` does on start. */ +export async function buildRelease(dir: string): Promise { + await packWorkflows(dir); +} + +/** Settings' Upgrade: publishes the build from `releaseDir` as the owner and starts GitHub's listener from it. */ +export async function upgrade(workspace: Workspace, hub: Hub, releaseDir: string): Promise { + const publisher: Publisher = { tenantId: workspace.tenantId, principalId: workspace.principalId }; + const gitDir = mkdtempSync(join(tmpdir(), "corbits-chain-sources-")); + async function packaged(input: string | URL | Request): Promise { + const path = String(input).replace(/^\/packages\//, ""); + const file = Bun.file(join(releaseDir, path)); + return (await file.exists()) ? new Response(file) : new Response(null, { status: 404 }); + } + try { + await upgradeTriage(workspace.transport, publisher, ["github"], function onStep() {}, function publish(transport, who) { + return publishSources(transport, who, { fs, http, origin: hub.origin, dir: gitDir }, packaged as typeof fetch); + }); + } finally { + rmSync(gitDir, { recursive: true, force: true }); + } +} + +/** A webhook delivery GitHub would send, signed with the App's secret. */ +export async function deliver(hub: Hub, workspace: Workspace, event: string, payload: unknown): Promise { + const body = JSON.stringify(payload); + const res = await fetch(`${hub.origin}/api/hooks/${workspace.tenantId}/github-hook`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-github-event": event, + "x-github-delivery": crypto.randomUUID(), + "x-hub-signature-256": `sha256=${createHmac("sha256", HOOK_SECRET).update(body).digest("hex")}`, + }, + body, + }); + if (res.status !== 202) throw new Error(`The hub refused a ${event} delivery: ${res.status} ${await res.text()}`); +} diff --git a/tests/chain/tsconfig.json b/tests/chain/tsconfig.json new file mode 100644 index 000000000..896762696 --- /dev/null +++ b/tests/chain/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "jsx": "react-jsx", + "lib": ["ESNext", "DOM"] + }, + "include": ["."] +}