Public: anyone can read this; the crew can change it.
A production deploy records which merged pulls it carries #1753
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1600-a-production-deploy-records-which-merged → main 6 commits fork 8 threads · 8 unresolved
README.md +4
| @@ -377,6 +377,10 @@ file), and if you named an agent, the sentence became issue #1, assigned. | ||
| 377 | 377 | queues on the assignment. |
| 378 | 378 | - **Every deploy is a run**, and the run is the check on the pull. Turn on |
| 379 | 379 | **require checks** and a pull cannot merge until its head is green. |
| 380 | - **A production deploy records the merged pulls it carries**: one `git | |
| 381 | rev-list` of the deployed commit when the run finishes. The run page says | |
| 382 | "Carries #3, #5" and `GET /api/v1/repos/:owner/:name/runs/:id` has `carries`. | |
| 383 | The terminal has no list of deploys yet, so it does not show this. | |
| 380 | 384 | - A **private** app answers only to someone the forge has vouched for |
| 381 | 385 | (`canRead` on the repository, then a signed grant); a public one to anyone. |
| 382 | 386 | - **Every app has a terminal.** `ssh -p 2222 <name>@<forge>` runs the app's |
scripts/gate.ts +2
| @@ -240,6 +240,8 @@ export const GATE: readonly string[] = [ | ||
| 240 | 240 | "test/make-it-yours.test.ts", |
| 241 | 241 | "test/map.test.ts", |
| 242 | 242 | "test/pillars.test.ts", |
| 243 | "test/carried.test.ts", | |
| 244 | "test/carried-deploy.test.ts", | |
| 243 | 245 | "test/reaches.test.ts", |
| 244 | 246 | "test/milestones.test.ts", |
| 245 | 247 | "test/mcp.test.ts", |
src/apps/runtime.ts +17
| @@ -13,6 +13,7 @@ | ||
| 13 | 13 | * commit, once. |
| 14 | 14 | */ |
| 15 | 15 | |
| 16 | import { recordCarried } from "../core/carried.ts"; | |
| 16 | 17 | import { pullDir, runDir } from "../core/pulls.ts"; |
| 17 | 18 | import { accountActive } from "../core/accounts.ts"; |
| 18 | 19 | import { sshCommand } from "../ssh/command.ts"; |
| @@ -1114,6 +1115,7 @@ export class AppsRuntime { | ||
| 1114 | 1115 | summary: `running at ${url}`, |
| 1115 | 1116 | }); |
| 1116 | 1117 | write(`done: ${url}`); |
| 1118 | if (done && state.env === "production") await this.noteCarried(repo, done); | |
| 1117 | 1119 | if (done) announceRun(this.app, done); |
| 1118 | 1120 | return; |
| 1119 | 1121 | } |
| @@ -1130,6 +1132,21 @@ export class AppsRuntime { | ||
| 1130 | 1132 | if (done) announceRun(this.app, done); |
| 1131 | 1133 | } |
| 1132 | 1134 | |
| 1135 | /** Note the merged pulls a finished production deploy carries; never fails the deploy. */ | |
| 1136 | private async noteCarried(repo: Repo, run: Run): Promise<void> { | |
| 1137 | try { | |
| 1138 | const dir = await runDir(this.app.db, this.app.reposRoot, repo, null); | |
| 1139 | await recordCarried(this.app.db, { | |
| 1140 | runId: run.id, | |
| 1141 | repoId: repo.id, | |
| 1142 | commitOid: run.commitOid, | |
| 1143 | dir, | |
| 1144 | }); | |
| 1145 | } catch (error) { | |
| 1146 | log.error("could not record what a deploy carries", { error: String(error) }); | |
| 1147 | } | |
| 1148 | } | |
| 1149 | ||
| 1133 | 1150 | private async bringUp( |
| 1134 | 1151 | state: Environment, |
| 1135 | 1152 | repo: Repo, |
src/core/carried.ts added +106
| @@ -0,0 +1,106 @@ | ||
| 1 | /** | |
| 2 | * Carried — which merged pulls a production deploy carries. | |
| 3 | * | |
| 4 | * When a production deploy finishes, one `git rev-list` of its commit says | |
| 5 | * which commits it contains; every merged pull of the repository whose | |
| 6 | * merge commit is among them is recorded against the run. A pull merged | |
| 7 | * after the deployed commit is not in the walk, so it is not recorded. The | |
| 8 | * table is an index over git (`deploy_pull`): rebuilding it is running this | |
| 9 | * again for the deploy. | |
| 10 | */ | |
| 11 | ||
| 12 | import type { Database } from "bun:sqlite"; | |
| 13 | import { runLocal } from "../git/local.ts"; | |
| 14 | ||
| 15 | const FULL_OID = /^[0-9a-f]{40,64}$/; | |
| 16 | /** Most commits one walk reads; past this a deploy records what it saw. */ | |
| 17 | const WALK_MAX = 200_000; | |
| 18 | ||
| 19 | /** The merged pulls whose merge commit is in this set, as issue numbers. */ | |
| 20 | function mergedAmong( | |
| 21 | db: Database, | |
| 22 | repoId: string, | |
| 23 | contained: ReadonlySet<string>, | |
| 24 | ): { issueId: string; number: number; mergedOid: string }[] { | |
| 25 | return db | |
| 26 | .query<{ issueId: string; number: number; mergedOid: string }, [string]>( | |
| 27 | `SELECT issue.id AS issueId, issue.number AS number, pull.merged_oid AS mergedOid | |
| 28 | FROM issue JOIN pull ON pull.issue_id = issue.id | |
| 29 | WHERE issue.repo_id = ?1 AND pull.merged_at IS NOT NULL | |
| 30 | AND pull.merged_oid IS NOT NULL AND pull.merged_oid != '' | |
| 31 | ORDER BY issue.number`, | |
| 32 | ) | |
| 33 | .all(repoId) | |
| 34 | .filter((row) => contained.has(row.mergedOid)); | |
| 35 | } | |
| 36 | ||
| 37 | /** | |
| 38 | * Record the merged pulls `commitOid` contains against a finished | |
| 39 | * production deploy. Returns their numbers; a commit git cannot walk | |
| 40 | * records nothing. Recording twice is the same as once. | |
| 41 | */ | |
| 42 | export async function recordCarried( | |
| 43 | db: Database, | |
| 44 | input: { | |
| 45 | runId: string; | |
| 46 | repoId: string; | |
| 47 | commitOid: string; | |
| 48 | /** The repository's directory. */ | |
| 49 | dir: string; | |
| 50 | }, | |
| 51 | ): Promise<number[]> { | |
| 52 | if (!FULL_OID.test(input.commitOid)) return []; | |
| 53 | const walked = await runLocal( | |
| 54 | ["rev-list", `--max-count=${WALK_MAX}`, input.commitOid], | |
| 55 | { cwd: input.dir }, | |
| 56 | ); | |
| 57 | if (!walked.ok) return []; | |
| 58 | const contained = new Set(walked.value.split("\n").filter((l) => l !== "")); | |
| 59 | const carried = mergedAmong(db, input.repoId, contained); | |
| 60 | const insert = db.query( | |
| 61 | `INSERT OR IGNORE INTO deploy_pull (run_id, issue_id, pull_number, merged_oid) | |
| 62 | VALUES (?1, ?2, ?3, ?4)`, | |
| 63 | ); | |
| 64 | db.transaction(() => { | |
| 65 | for (const p of carried) | |
| 66 | insert.run(input.runId, p.issueId, p.number, p.mergedOid); | |
| 67 | })(); | |
| 68 | return carried.map((p) => p.number); | |
| 69 | } | |
| 70 | ||
| 71 | /** The pull numbers a deploy run carries, ascending. */ | |
| 72 | export function carriedBy(db: Database, runId: string): number[] { | |
carriedBy is called only from the test, so this is dead code in production. Wire it to a page, tty and MCP/API read, or the table has no reader. |
||
| 73 | return db | |
| 74 | .query<{ pull_number: number }, [string]>( | |
| 75 | "SELECT pull_number FROM deploy_pull WHERE run_id = ?1 ORDER BY pull_number", | |
| 76 | ) | |
| 77 | .all(runId) | |
| 78 | .map((row) => row.pull_number); | |
| 79 | } | |
| 80 | ||
| 81 | /** The pull numbers each of these runs carries, in one read; runs that carry none are absent. */ | |
| 82 | export function carriedByRuns( | |
Dead since the |
||
| 83 | db: Database, | |
| 84 | runIds: readonly string[], | |
Nothing in this pull uses this or |
||
| 85 | ): Map<string, number[]> { | |
| 86 | const found = new Map<string, number[]>(); | |
| 87 | if (runIds.length === 0) return found; | |
| 88 | const rows = db | |
| 89 | .query<{ run_id: string; pull_number: number }, string[]>( | |
| 90 | `SELECT run_id, pull_number FROM deploy_pull | |
| 91 | WHERE run_id IN (${runIds.map((_, i) => `?${i + 1}`).join(",")}) | |
| 92 | ORDER BY run_id, pull_number`, | |
| 93 | ) | |
| 94 | .all(...runIds); | |
| 95 | for (const row of rows) { | |
| 96 | const list = found.get(row.run_id) ?? []; | |
| 97 | list.push(row.pull_number); | |
| 98 | found.set(row.run_id, list); | |
| 99 | } | |
| 100 | return found; | |
| 101 | } | |
| 102 | ||
| 103 | /** `carries #3 #5`, or nothing when a deploy carries no pull. */ | |
| 104 | export function carriesText(numbers: readonly number[]): string { | |
Dead as well: nothing calls |
||
| 105 | return numbers.length === 0 ? "" : `carries ${numbers.map((n) => `#${n}`).join(" ")}`; | |
| 106 | } |
src/core/schema.ts +13
| @@ -1741,6 +1741,19 @@ export const migrations: readonly string[] = [ | ||
| 1741 | 1741 | ALTER TABLE repo ADD COLUMN decisions_by TEXT NOT NULL DEFAULT 'me' |
| 1742 | 1742 | CHECK (decisions_by IN ('me', 'decider', 'decider_tell')); |
| 1743 | 1743 | `, |
| 1744 | // 86 — what a production deploy carries (pull #1753, issue #1600): one row per merged pull | |
| 1745 | // whose merge commit the deployed commit contains, written when the deploy | |
| 1746 | // finishes. An index over git, so no foreign key outlives a deleted run. | |
| 1747 | ` | |
| 1748 | CREATE TABLE deploy_pull ( | |
| 1749 | run_id TEXT NOT NULL, | |
| 1750 | issue_id TEXT NOT NULL, | |
| 1751 | pull_number INTEGER NOT NULL, | |
| 1752 | merged_oid TEXT NOT NULL, | |
| 1753 | PRIMARY KEY (run_id, issue_id) | |
| 1754 | ); | |
| 1755 | CREATE INDEX deploy_pull_issue ON deploy_pull(issue_id); | |
| 1756 | `, | |
| 1744 | 1757 | ]; |
| 1745 | 1758 | |
| 1746 | 1759 | /** |
src/http/api.ts +7 −1
| @@ -305,6 +305,7 @@ import { resolveOid } from "../git/merge.ts"; | ||
| 305 | 305 | import { readUsage, usageDays, USAGE_DAYS_MAX } from "../actions/usage.ts"; |
| 306 | 306 | import { readReturns, reportSpend, RETURNS_DAYS } from "../actions/returns.ts"; |
| 307 | 307 | import { fleetSelection, readFleetGraph } from "../actions/fleet-graph.ts"; |
| 308 | import { carriedBy } from "../core/carried.ts"; | |
| 308 | 309 | import { readRunLog, subscribeRunLog } from "../runs/log.ts"; |
| 309 | 310 | import { blobForRender, commitLog, listRefs, listTree } from "../git/read.ts"; |
| 310 | 311 | import { repoDir } from "../git/repos.ts"; |
| @@ -4750,7 +4751,12 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 4750 | 4751 | const repo = loadRepo(ctx); |
| 4751 | 4752 | if (!repo) return notFound(); |
| 4752 | 4753 | const run = getRepoRun(ctx.app, ctx.auth.user, repo, ctx.params.id ?? ""); |
| 4753 | return run.ok ? json(runForViewer(ctx, repo)(run.value)) : notFound(); | |
| 4754 | if (!run.ok) return notFound(); | |
| 4755 | // The merged pulls a finished production deploy carries (`core/carried.ts`). | |
| 4756 | return json({ | |
| 4757 | ...runForViewer(ctx, repo)(run.value), | |
| 4758 | carries: carriedBy(ctx.app.db, run.value.id), | |
| 4759 | }); | |
| 4754 | 4760 | }); |
| 4755 | 4761 | |
| 4756 | 4762 | // The log: text, or a live tail as server-sent events (`?follow=1`, or |
src/web/studio/run.ts +15
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | import { directionNote } from "../../crew/direct.ts"; |
| 12 | 12 | import { appsRuntime } from "../../apps/runtime.ts"; |
| 13 | 13 | import { getAppByRepo } from "../../core/apps.ts"; |
| 14 | import { carriedBy } from "../../core/carried.ts"; | |
| 14 | 15 | import { getIssueById } from "../../core/issues.ts"; |
| 15 | 16 | import { getPull, openPullsClosing } from "../../core/pulls.ts"; |
| 16 | 17 | import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; |
| @@ -100,6 +101,7 @@ export async function runView( | ||
| 100 | 101 | const duration = durationText(run); |
| 101 | 102 | const all = ctx.url.searchParams.get("all") === "1"; |
| 102 | 103 | const held = listHeldSecrets(db, run.id); |
| 104 | const carried = carriedBy(db, run.id); | |
| 103 | 105 | const stream = `/api/v1/repos/${repo.ownerName}/${repo.name}/runs/${run.id}/log?follow=1`; |
| 104 | 106 | |
| 105 | 107 | // The pull this run's tell has become, once one exists: the tell itself |
| @@ -160,6 +162,19 @@ export async function runView( | ||
| 160 | 162 | )} |
| 161 | 163 | </div> |
| 162 | 164 | <p class="run-summary" id="run-summary">${run.summary}</p> |
| 165 | ${when( | |
| 166 | carried.length > 0, | |
| 167 | () => | |
| 168 | html`<p class="muted small" id="run-carries"> | |
| 169 | Carries | |
| 170 | ${carried.map( | |
| 171 | (n, i) => | |
| 172 | html`${i === 0 ? "" : ", "}<a href="${changeHref(base, n)}" | |
| 173 | >#${n}</a | |
| 174 | >`, | |
| 175 | )}. | |
| 176 | </p>`, | |
| 177 | )} | |
| 163 | 178 | ${sandboxLine(run)} |
| 164 | 179 | ${when( |
| 165 | 180 | held.length > 0, |
test/carried-deploy.test.ts added +127
| @@ -0,0 +1,127 @@ | ||
| 1 | /** | |
| 2 | * A production deploy that finishes records the merged pulls it carries, | |
| 3 | * and says so: the run's page, the API's run, and the terminal's run list. | |
Stale: this test no longer covers a terminal run list. Name only the run page and the API. |
||
| 4 | */ | |
| 5 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { AppsRuntime } from "../src/apps/runtime.ts"; | |
| 11 | import { newApp } from "../src/actions/apps.ts"; | |
| 12 | import { mergePull, openPull } from "../src/actions/pulls.ts"; | |
| 13 | import { commitToBranch } from "../src/actions/repos.ts"; | |
| 14 | import { carriedBy } from "../src/core/carried.ts"; | |
| 15 | import type { Repo } from "../src/core/repos.ts"; | |
| 16 | import { listRuns, type Run } from "../src/core/runs.ts"; | |
| 17 | import { createToken } from "../src/core/tokens.ts"; | |
| 18 | import { createUser, type User } from "../src/core/users.ts"; | |
| 19 | import { getPull } from "../src/core/pulls.ts"; | |
| 20 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 21 | ||
| 22 | describe("a production deploy carries what it records", () => { | |
| 23 | let dataDir: string; | |
| 24 | let app: App; | |
| 25 | let servers: Servers; | |
| 26 | let runtime: AppsRuntime; | |
| 27 | let alice: User; | |
| 28 | let repo: Repo; | |
| 29 | ||
| 30 | async function until<T>(read: () => T | undefined, ms = 90_000): Promise<T> { | |
| 31 | const deadline = Date.now() + ms; | |
| 32 | while (Date.now() < deadline) { | |
| 33 | const value = read(); | |
| 34 | if (value !== undefined) return value; | |
| 35 | await Bun.sleep(100); | |
| 36 | } | |
| 37 | throw new Error("did not happen in time"); | |
| 38 | } | |
| 39 | const finished = (oid: string): Promise<Run> => | |
| 40 | until(() => | |
| 41 | listRuns(app.db, repo.id, { kind: "deploy", env: "production" }).find( | |
| 42 | (r) => r.commitOid === oid && r.state === "ok", | |
| 43 | ), | |
| 44 | ); | |
| 45 | ||
| 46 | async function land(name: string): Promise<{ number: number; oid: string }> { | |
| 47 | const branch = await commitToBranch(app, alice, repo, { | |
| 48 | branch: `feature/${name}`, | |
| 49 | base: "main", | |
| 50 | message: name, | |
| 51 | files: [{ path: `${name}.md`, content: `${name}\n` }], | |
| 52 | }); | |
| 53 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); | |
| 54 | const opened = await openPull(app, alice, repo, { | |
| 55 | title: name, | |
| 56 | baseBranch: "main", | |
| 57 | headBranch: `feature/${name}`, | |
| 58 | }); | |
| 59 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 60 | const done = await mergePull(app, alice, repo, opened.value, "squash"); | |
| 61 | if (!done.ok) throw new Error(JSON.stringify(done.error)); | |
| 62 | const pull = getPull(app.db, repo.id, opened.value.number)!; | |
| 63 | return { number: pull.number, oid: pull.mergedOid! }; | |
| 64 | } | |
| 65 | ||
| 66 | beforeAll(async () => { | |
| 67 | dataDir = await mkdtemp(join(tmpdir(), "forge-carried-deploy-")); | |
| 68 | app = createApp({ dataDir, port: 0 }); | |
| 69 | servers = await serve(app); | |
| 70 | runtime = new AppsRuntime(app, { | |
| 71 | domain: "apps.test", | |
| 72 | scheme: "http" as const, | |
| 73 | publicPort: servers.port, | |
| 74 | forgeUrl: servers.url, | |
| 75 | }); | |
| 76 | await runtime.start(); | |
| 77 | const created = await createUser(app.db, { | |
| 78 | trusted: true, | |
| 79 | username: "alice", | |
| 80 | email: "[email protected]", | |
| 81 | password: "a-good-passphrase", | |
| 82 | adminIfFirst: true, | |
| 83 | }); | |
| 84 | if (!created.ok) throw new Error(created.error); | |
| 85 | alice = created.value; | |
| 86 | const made = await newApp(app, alice, { | |
| 87 | name: "hello", | |
| 88 | description: "A place for notes", | |
| 89 | visibility: "public", | |
| 90 | }); | |
| 91 | if (!made.ok) throw new Error(String(made.error)); | |
| 92 | repo = made.value.repo; | |
| 93 | }, 120_000); | |
| 94 | ||
| 95 | afterAll(async () => { | |
| 96 | await runtime.stop(); | |
| 97 | servers.stop(); | |
| 98 | app.close(); | |
| 99 | await rm(dataDir, { recursive: true, force: true }); | |
| 100 | }); | |
| 101 | ||
| 102 | test("finishing a production deploy records its merges and the API and page say so", async () => { | |
| 103 | const one = await land("one"); | |
| 104 | const two = await land("two"); | |
| 105 | const run = await finished(two.oid); | |
| 106 | // `done` is announced after the row is finished; the record follows it. | |
| 107 | await until(() => (carriedBy(app.db, run.id).length > 0 ? true : undefined)); | |
| 108 | expect(carriedBy(app.db, run.id)).toEqual([one.number, two.number]); | |
| 109 | ||
| 110 | const rw = createToken(app.db, alice.id, "rw", "rw").token; | |
| 111 | const api = (await ( | |
| 112 | await fetch( | |
| 113 | `${servers.url}/api/v1/repos/alice/hello/runs/${run.id}`, | |
| 114 | { headers: { authorization: `Bearer ${rw}` } }, | |
| 115 | ) | |
| 116 | ).json()) as { carries: number[] }; | |
| 117 | expect(api.carries).toEqual([one.number, two.number]); | |
| 118 | ||
| 119 | const page = await ( | |
| 120 | await fetch(`${servers.url}/alice/hello/runs/${run.id}`, { | |
| 121 | headers: { authorization: `Bearer ${rw}` }, | |
| 122 | }) | |
| 123 | ).text(); | |
| 124 | expect(page).toContain('id="run-carries"'); | |
| 125 | expect(page).toContain(`/changes/${two.number}`); | |
| 126 | }, 180_000); | |
| 127 | }); |
test/carried.test.ts added +113
| @@ -0,0 +1,113 @@ | ||
| 1 | /** | |
| 2 | * A production deploy records the merged pulls its commit carries: two | |
| 3 | * merges before the deployed commit are both recorded, a merge after it is | |
| 4 | * not, and recording again changes nothing. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { mergePull, openPull } from "../src/actions/pulls.ts"; | |
| 12 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 13 | import { carriedBy, recordCarried } from "../src/core/carried.ts"; | |
| 14 | import { getPull } from "../src/core/pulls.ts"; | |
| 15 | import { repoDir } from "../src/git/repos.ts"; | |
| 16 | import type { Repo } from "../src/core/repos.ts"; | |
| 17 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 18 | ||
| 19 | describe("carried", () => { | |
| 20 | let dataDir: string; | |
| 21 | let app: App; | |
| 22 | let repo: Repo; | |
| 23 | let dir: string; | |
| 24 | const merged: { number: number; oid: string }[] = []; | |
| 25 | ||
| 26 | beforeAll(async () => { | |
| 27 | dataDir = await mkdtemp(join(tmpdir(), "forge-carried-")); | |
| 28 | app = createApp({ dataDir, port: 0 }); | |
| 29 | const made = await createUser(app.db, { | |
| 30 | trusted: true, | |
| 31 | username: "alice", | |
| 32 | email: "[email protected]", | |
| 33 | password: "a-good-passphrase", | |
| 34 | adminIfFirst: true, | |
| 35 | }); | |
| 36 | if (!made.ok) throw new Error(made.error); | |
| 37 | const alice = getUserByName(app.db, "alice")!; | |
| 38 | const created = await createRepo(app, alice, { name: "engine" }); | |
| 39 | if (!created.ok) throw new Error(String(created.error)); | |
| 40 | repo = created.value; | |
| 41 | dir = repoDir(app.reposRoot, "alice", "engine"); | |
| 42 | const seeded = await commitToBranch(app, alice, repo, { | |
| 43 | message: "Start", | |
| 44 | files: [{ path: "README.md", content: "# engine\n" }], | |
| 45 | }); | |
| 46 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 47 | for (const name of ["one", "two", "three"]) { | |
| 48 | const branch = await commitToBranch(app, alice, repo, { | |
| 49 | branch: `feature/${name}`, | |
| 50 | base: "main", | |
| 51 | message: name, | |
| 52 | files: [{ path: `${name}.md`, content: `${name}\n` }], | |
| 53 | }); | |
| 54 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); | |
| 55 | const opened = await openPull(app, alice, repo, { | |
| 56 | title: name, | |
| 57 | body: "", | |
| 58 | baseBranch: "main", | |
| 59 | headBranch: `feature/${name}`, | |
| 60 | }); | |
| 61 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 62 | const done = await mergePull(app, alice, repo, opened.value, "squash"); | |
| 63 | if (!done.ok) throw new Error(JSON.stringify(done.error)); | |
| 64 | const pull = getPull(app.db, repo.id, opened.value.number)!; | |
| 65 | merged.push({ number: pull.number, oid: pull.mergedOid! }); | |
| 66 | } | |
| 67 | }); | |
| 68 | ||
| 69 | afterAll(async () => { | |
| 70 | app.close?.(); | |
| 71 | await rm(dataDir, { recursive: true, force: true }); | |
| 72 | }); | |
| 73 | ||
| 74 | test("a deploy of a commit carrying two merges records both, and not the later one", async () => { | |
| 75 | const got = await recordCarried(app.db, { | |
| 76 | runId: "run-a", | |
| 77 | repoId: repo.id, | |
| 78 | commitOid: merged[1]!.oid, | |
| 79 | dir, | |
| 80 | }); | |
| 81 | expect(got).toEqual([merged[0]!.number, merged[1]!.number]); | |
| 82 | expect(carriedBy(app.db, "run-a")).toEqual(got); | |
| 83 | expect(carriedBy(app.db, "run-a")).not.toContain(merged[2]!.number); | |
| 84 | }); | |
| 85 | ||
| 86 | test("recording again is the same as once, and a later deploy carries all three", async () => { | |
| 87 | await recordCarried(app.db, { | |
| 88 | runId: "run-a", | |
| 89 | repoId: repo.id, | |
| 90 | commitOid: merged[1]!.oid, | |
| 91 | dir, | |
| 92 | }); | |
| 93 | expect(carriedBy(app.db, "run-a")).toHaveLength(2); | |
| 94 | await recordCarried(app.db, { | |
| 95 | runId: "run-b", | |
| 96 | repoId: repo.id, | |
| 97 | commitOid: merged[2]!.oid, | |
| 98 | dir, | |
| 99 | }); | |
| 100 | expect(carriedBy(app.db, "run-b")).toHaveLength(3); | |
| 101 | }); | |
| 102 | ||
| 103 | test("a commit that is not an object id records nothing", async () => { | |
| 104 | expect( | |
| 105 | await recordCarried(app.db, { | |
| 106 | runId: "run-c", | |
| 107 | repoId: repo.id, | |
| 108 | commitOid: "--all", | |
| 109 | dir, | |
| 110 | }), | |
| 111 | ).toEqual([]); | |
| 112 | }); | |
| 113 | }); |
No test covers this call: remove it and every test still passes. Exercise a production deploy finishing and assert deploy_pull rows.