Public: anyone can read this; the crew can change it.

When a fix for a note lands, the crew says in plain words what was wrong and what changed #10828

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/10564-when-a-fix-for-a-note-lands-the-crew-say → main 3 commits fork 4 threads · 4 unresolved

  • Merged. scout/10564-when-a-fix-for-a-note-lands-the-crew-say was merged into main as 9c82f56.

Review

Sign in to review this change.
9 files changed +318 −1
docs/features/notes.md +2
@@ -30,3 +30,5 @@ The terminal has the same door: the palette's "Notes" (`src/tui/screens/thoughtn
3030 A note sent from an app's forge button (or the terminal's "Tell the builders") files a from-app issue, and the sender is told its number at once: "Filed as #N". Anyone the repository would show the issue to (`canRead`, so a signed-in reader of a private repository too, not only a public app on a public repository) also gets the issue's link and the repository's; everyone else gets the number and no link. The list shows `#N` beside each note. The browser button links only to the forge's own origin. In the terminal (`src/tui/screens/notes.ts`) the sender is the SSH user: the confirmation reads "Sent as #N, owner/repo/issues/N" and the list and moderation screens carry `#N`; the terminal has no clickable link.
3131
3232 The crew's answers come back to the sender: under each note the button's list (and the terminal's "Notes from the app", where picking a note shows them) carries the comments on its issue by the repository's crew and writers, with author and time, oldest first. Anyone else's comments stay on the forge. The button shows the latest five and says how many are earlier.
33
34 When a pull that closes a note's issue merges, the forge leaves one plain-words comment on it (`crew/note_closed.ts`): what was wrong and what changed, from the pull's description, with no hashes or links, saying "the next release" until one is cut, then rewritten to name it ("release 57 (10 Nov 2026)"). Both ways a merge closes an issue (the merge itself, and a reviewer's box pass on an already-landed pull) go through `noteShipped`. The forge marks its comment in `note_shipped`, so a person's comment with the same opening never stands in for it. The button shows it as a reply and the note reads shipped.
src/actions/box_pass.ts +4 −1
@@ -14,6 +14,7 @@
1414 import type { App } from "../app.ts";
1515 import { PASS_LEAD, tickBoxes, unticked } from "../core/done_on_main.ts";
1616 import { addComment, getIssue, setIssueState as setIssueStateCore, updateIssueText } from "../core/issues.ts";
17 import { noteShipped } from "../crew/note_closed.ts";
1718 import { isStructural, pillarsOf } from "../core/pillars.ts";
1819 import { closesFromBody, type Pull } from "../core/pulls.ts";
1920 import type { Repo } from "../core/repos.ts";
@@ -141,12 +142,14 @@ export function applyBoxPass(app: App, repo: Repo, pull: Pull, reviewer: User, p
141142 });
142143 }
143144 // A pull that landed before its boxes were read closes the issue now, as the landing would have.
144 if (pull.mergedAt !== null && unticked(body).length === 0 && setIssueStateCore(app.db, issue.id, "closed"))
145 if (pull.mergedAt !== null && unticked(body).length === 0 && setIssueStateCore(app.db, issue.id, "closed")) {
145146 app.bus.append({
146147 kind: "issue.closed",
147148 repoId: repo.id,
148149 actorId: reviewer.id,
149150 payload: { number: n, by: "merge", pull: pull.number },
150151 });
152 noteShipped(app, reviewer.id, repo, issue, pull);
153 }
151154 }
152155 }
src/actions/pulls.ts +3
@@ -10,6 +10,7 @@ import { takeGuestPull } from "./guests.ts";
1010 import { requireAbility } from "./roles.ts";
1111 import { announceTeamWaits } from "./team_waits.ts";
1212 import { addComment as addIssueComment } from "../core/issues.ts";
13 import { noteShipped } from "../crew/note_closed.ts";
1314 import { landedNote, leftNote, unticked } from "../core/done_on_main.ts";
1415 import { resolveOpen } from "../core/decisions.ts";
1516 import type { App } from "../app.ts";
@@ -649,6 +650,8 @@ export async function mergePull(
649650 actorId: writer.value.id,
650651 payload: { number: issue.number, by: "merge", pull: pull.number },
651652 });
653 // A note from an app's button hears, in plain words, that it shipped (#10564).

box_pass.ts:149 closes issues too and is not covered. Share one helper for both paths.

654 noteShipped(app, writer.value.id, repo, issue, pull);
652655 }
653656 return merged;
654657 }
src/checks/landing.ts +3
@@ -21,6 +21,7 @@ import {
2121 } from "../release/checkout.ts";
2222 import { beginSwap, endSwap, handover, startRelease } from "../release/swap.ts";
2323 import { running } from "../kernel/version.ts";
24 import { noteReleased } from "../crew/note_closed.ts";
2425 import { recordRelease, type Release } from "../release/record.ts";
2526 import { scanHold } from "../release/scan_hold.ts";
2627 import type { Pull } from "../core/pulls.ts";
@@ -192,6 +193,8 @@ async function cut(
192193 app.log.warn("release held by code scanning", { sha: sha.slice(0, 12), pull: pull.number, findings: hold.count });
193194 addComment(app, by, repo, getIssueById(app.db, pull.id) ?? pull, `The landed tree ${sha.slice(0, 12)} is not released: ${hold.line}.`);
194195 }
196 // The notes this pull closed learn which release carries their fix (#10564).
197 if (verdict.ok && !hold.held) noteReleased(app.db, release);
195198 if (verdict.ok && out.ok && !hold.held) await swapIn(app, by, repo, pull, sha, out.value);
196199 return release;
197200 }
src/core/migration-pins/20261110150000-note-shipped.pin added +1
@@ -0,0 +1,1 @@
1 a1c7edf33c001c572a64ae81284a9832e7d9cb74853ea3b7a37c549d48cc5596
src/core/migrations/20261110150000-note-shipped.ts added +11
@@ -0,0 +1,11 @@
1 // The forge's own "this note shipped" comment is marked, so a second landing
2 // finds it by what the forge stored, never by what the comment says (#10564).
3 // One row per issue; the pull is kept so the release can be named later. Additive.
4 export default `
5 CREATE TABLE note_shipped (
6 issue_id TEXT PRIMARY KEY REFERENCES issue(id) ON DELETE CASCADE,
7 comment_id TEXT NOT NULL REFERENCES comment(id) ON DELETE CASCADE,
8 pull_number INTEGER NOT NULL
9 );
10 CREATE INDEX note_shipped_pull ON note_shipped (pull_number);
11 `;
src/crew/note_closed.ts added +113
@@ -0,0 +1,113 @@
1 /**
2 * A note's fix has landed (#10564): the pull that closes a `from-app` issue
3 * leaves one comment on it, in plain words, written from the pull's own
4 * description. The forge button shows it with the note, which reads shipped
5 * once the issue is closed (`actions/app_notes.ts`). Pure text plus one
6 * idempotent write; both ways a merge closes an issue (`mergePull`, the box
7 * pass) call `noteShipped`. The comment is found again by the `note_shipped`
8 * row the forge stores with it, never by its words, and when the release is
9 * cut `noteReleased` rewrites it to name that release.
10 */
11 import type { Database } from "bun:sqlite";
12 import { addComment, type Comment, type Issue } from "../core/issues.ts";
13 import { fromAppIssues } from "../core/from_app.ts";
14 import type { Pull } from "../core/pulls.ts";
15 import { getPull } from "../core/pulls.ts";
16 import { releaseLabel, releaseOfPull, type Release } from "../release/record.ts";
17 import type { App } from "../app.ts";
18 import type { Repo } from "../core/repos.ts";
19
20 const MAX = 600;
21 /** Starts every such comment; only for reading, never for finding one again. */
22 const LEAD = "Good news: this is fixed";
23
24 /** Lines of a description that say nothing to a sender: closings, code, headings' marks, links, attribution. */
25 function plain(text: string): string {
26 return text
outdated

This hex strip removes real words and long numbers. Anchor it to a context such as 'commit <sha>' or require at least one digit.

27 .replace(/```[\s\S]*?```/g, " ")
28 .replace(/^\s*(close[sd]?|fix(e[sd])?|resolve[sd]?)\b[^\n]*#\d[^\n]*$/gim, " ")
29 .replace(/^[^\n\w]*generated (with|by)\b[^\n]*$/gim, " ")
30 .replace(/^\s*co-authored-by:[^\n]*$/gim, " ")
31 .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
32 .replace(/https?:\/\/\S+/g, "")
33 .replace(/`([^`]*)`/g, "$1")
34 .replace(/^\s{0,3}(#{1,6}|[-*>]|\d+\.)\s+/gm, "")
35 .replace(/[*_~]{1,3}/g, "")
36 .replace(/#\d+/g, "")
37 // A hash is named as one ("commit abc1234") or has digits and letters both; a word or a number is not.
38 .replace(/\b(commit|sha|revision)\s+[0-9a-f]{7,40}\b/gi, "")
39 .replace(/\b(?=[0-9a-f]*\d)(?=[0-9a-f]*[a-f])[0-9a-f]{7,40}\b/gi, "")
40 .replace(/\(\s*[,;]?\s*\)/g, "")
41 .replace(/\s+([.,;)])/g, "$1")
42 .replace(/[ \t]+/g, " ");
43 }
44
45 /** What was wrong and what changed, from a pull's title and description: no hashes, no markup, short. */
46 export function plainSummary(title: string, body: string): string {
47 const paras = plain(body)
48 .split(/\n\s*\n/)
49 .map((p) => p.replace(/\s*\n\s*/g, " ").trim())
50 .filter((p) => p !== "");
51 // The description's opening carries what changed; the rest is process.
52 let text = paras.slice(0, 2).join("\n\n");
53 if (text === "") text = plain(title).replace(/\s+/g, " ").trim();
54 if (text.length > MAX) text = `${text.slice(0, MAX - 3).replace(/\s+\S*$/, "")}...`;
55 return text;
outdated

Dedup on body text is fragile. A person's comment with this opening suppresses the real one. Use a marker the forge writes, such as the comment kind.

56 }
57
58 /** The comment's words. */
59 export function shippedWords(pull: Pull, release: string | undefined): string {
60 const where = release === undefined ? "It ships in the next release" : `It shipped in ${release}`;
61 return `${LEAD}. ${where}.\n\n${plainSummary(pull.title, pull.body)}`;
62 }
63
64 /** The comment the forge wrote for this issue, if it has. */
outdated

releaseName gives a 12-char commit hash, and at merge time release is nearly always undefined. The issue wants the release named in plain words. Update the comment when the release is cut, with a readable label.

65 function told(db: Database, issueId: string): { commentId: string; pullNumber: number } | undefined {
66 const row = db
67 .query<{ comment_id: string; pull_number: number }, [string]>("SELECT comment_id, pull_number FROM note_shipped WHERE issue_id = ?1")
68 .get(issueId);
69 return row === null ? undefined : { commentId: row.comment_id, pullNumber: row.pull_number };
70 }
71
72 const wordsFor = (db: Database, pull: Pull, release: Release | undefined): string =>
73 shippedWords(pull, release === undefined ? undefined : releaseLabel(release));
74
75 /**
76 * Say a landed fix on `issue` once. Nothing when the issue is not a note from
77 * an app, or the forge already wrote the comment. Returns the new comment.
78 */
79 export function tellNoteShipped(db: Database, by: string, issue: Issue, pull: Pull): Comment | undefined {
80 if (!fromAppIssues(db, [issue.id]).has(issue.id)) return undefined;
81 if (told(db, issue.id) !== undefined) return undefined;
82 const made = addComment(db, { issueId: issue.id, authorId: by, body: wordsFor(db, pull, releaseOfPull(db, pull.repoId, pull.number)) });
83 if (!made.ok) return undefined;
84 db.query("INSERT OR IGNORE INTO note_shipped (issue_id, comment_id, pull_number) VALUES (?1, ?2, ?3)").run(issue.id, made.value.id, pull.number);
85 return made.value;
86 }
87
88 /** The one way a merge tells a note's sender: write the comment and announce it. */
89 export function noteShipped(app: App, by: string, repo: Repo, issue: Issue, pull: Pull): void {
90 const said = tellNoteShipped(app.db, by, issue, getPull(app.db, repo.id, pull.number) ?? pull);
91 if (said === undefined) return;
92 app.bus.append({
93 kind: "issue.commented",
94 repoId: repo.id,
95 actorId: by,
96 payload: { number: issue.number, body: said.body, commentId: said.id },
97 });
98 }
99
100 /** A release carrying `pull` was cut: its notes' comments now name it. Returns how many were rewritten. */
101 export function noteReleased(db: Database, release: Release): number {
102 if (!release.ok || release.pullNumber === null) return 0;
103 const pull = getPull(db, release.repoId, release.pullNumber);
104 if (pull === undefined) return 0;
105 const rows = db
106 .query<{ comment_id: string }, [number, string]>(
107 `SELECT s.comment_id FROM note_shipped s JOIN issue i ON i.id = s.issue_id WHERE s.pull_number = ?1 AND i.repo_id = ?2`,
108 )
109 .all(release.pullNumber, release.repoId);
110 const body = wordsFor(db, pull, release);
111 for (const r of rows) db.query("UPDATE comment SET body = ?1 WHERE id = ?2").run(body, r.comment_id);
112 return rows.length;
113 }
src/release/record.ts +7
@@ -82,3 +82,10 @@ export function releaseOfPull(db: Database, repoId: string, pullNumber: number):
8282
8383 /** What a release is called where people read it: the landed commit, short. */
8484 export const releaseName = (r: Release): string => r.sha.slice(0, 12);
85
86 /** What a release is called in plain words: its number and the day it was cut. */
87 export const releaseLabel = (r: Release): string => {
88 const day = new Date(r.createdAt);
89 const when = Number.isNaN(day.getTime()) ? r.createdAt.slice(0, 10) : day.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric", timeZone: "UTC" });
90 return `release ${r.id} (${when})`;
91 };
test/note_closed.test.ts added +174
@@ -0,0 +1,174 @@
1 /** A merged pull that closes a note tells its sender, once, in plain words (#10564). */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { newApp } from "../src/actions/apps.ts";
7 import { notesFor, sendNote, type Sender } from "../src/actions/app_notes.ts";
8 import { mergePull, openPull } from "../src/actions/pulls.ts";
9 import { commitToBranch } from "../src/actions/repos.ts";
10 import { createApp, type App } from "../src/app.ts";
11 import { getAppBySlug } from "../src/core/apps.ts";
12 import { getIssue, listComments, addComment, setIssueState } from "../src/core/issues.ts";
13 import { getPull } from "../src/core/pulls.ts";
14 import { getRepo } from "../src/core/repos.ts";
15 import { createUser, type User } from "../src/core/users.ts";
16 import { applyBoxPass } from "../src/actions/box_pass.ts";
17 import { recordRelease } from "../src/release/record.ts";
18 import { noteReleased, plainSummary, shippedWords, tellNoteShipped } from "../src/crew/note_closed.ts";
19 import { quietLogs } from "./helpers/globals.ts";
20
21 quietLogs();
22
23 describe("plainSummary", () => {
24 test("drops hashes, links, code, closings and markup", () => {
25 const out = plainSummary(
26 "t",
27 "## What\n\nThe **button** was hidden on phones (see https://x.test/a, `src/a.ts`, 0123abcd45ef).\n\nCloses #12\n\n```\ncode\n```\n\n- tests",
28 );
29 expect(out).not.toMatch(/https?:|`|\*|0123abcd|#12|code\n/);
30 expect(out).toContain("The button was hidden on phones");
31 });
32 test("keeps the attribution line out of a short description", () => {
33 const out = plainSummary("t", "Fixed the tiny button.\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <[email protected]>");
34 expect(out).toBe("Fixed the tiny button.");
35 });
36 test("strips hashes but not words or numbers", () => {
37 const out = plainSummary("t", "The defaced banner showed 1234567 visitors, was in commit 0123abcd45ef (also 9f8e7d6c5b4a).");
38 expect(out).toContain("defaced");
39 expect(out).toContain("1234567");
40 expect(out).not.toMatch(/0123abcd|9f8e7d6c|\(\)|commit/);
41 });
42 test("falls back to the title and stays short", () => {
43 expect(plainSummary("Make it blue", "")).toBe("Make it blue");
44 expect(plainSummary("t", "word ".repeat(400)).length).toBeLessThanOrEqual(600);
45 });
46 });
47
48 describe("a closing pull tells the note's sender", () => {
49 let dataDir: string;
50 let app: App;
51 let alice: User;
52
53 beforeAll(async () => {
54 dataDir = await mkdtemp(join(tmpdir(), "forge-note-closed-"));
55 app = createApp({ dataDir, port: 0, host: "forge.test" });
56 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", adminIfFirst: true });
57 if (!made.ok) throw new Error(String(made.error));
58 alice = made.value;
59 const a = await newApp(app, alice, { name: "sandbox-game", description: "Words", visibility: "private" });
60 if (!a.ok) throw new Error(String(a.error));
61 });
62 afterAll(async () => {
63 app.close();
64 await rm(dataDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 });
65 });
66
67 test("merging posts one comment, a second delivery posts none, and the status reads shipped", async () => {
68 const record = getAppBySlug(app.db, "sandbox-game")!;
69 const repo = getRepo(app.db, "alice", "sandbox-game")!;
70 const who: Sender = { key: "v:closer1", name: "visitor", person: false };
71 const sent = await sendNote(app, record, repo, "production", undefined, who, { text: "the button is tiny", page: "", viewport: "390x844" }, "10.2.0.1");
72 if (!sent.ok) throw new Error(sent.error.message);
73 const n = sent.value.number;
74 expect(sent.value.status.state).not.toBe("shipped");
75
76 const body = `The button was too small to tap on phones. It is now 48px tall (commit 0123abcd45ef).\n\nCloses #${n}`;
77 const seeded = await commitToBranch(app, alice, repo, { message: "seed", files: [{ path: "README.md", content: "hi\n" }] });
78 expect(seeded.ok).toBe(true);
79 const branched = await commitToBranch(app, alice, repo, { branch: "fix", base: "main", message: "fix", files: [{ path: "a.txt", content: "x\n" }] });
80 expect(branched.ok).toBe(true);
81 const opened = await openPull(app, alice, repo, { title: "Bigger button", body, baseBranch: "main", headBranch: "fix" });
82 if (!opened.ok) throw new Error(JSON.stringify(opened.error));
83 const merged = await mergePull(app, alice, repo, opened.value, "squash");
84 expect(merged.ok).toBe(true);
85
86 const issue = getIssue(app.db, repo.id, n)!;
87 expect(issue.state).toBe("closed");
88 const said = () => listComments(app.db, issue.id, { kind: "comment" }).filter((c) => c.body.startsWith("Good news"));
89 expect(said()).toHaveLength(1);
90 expect(said()[0]!.body).toContain("too small to tap on phones");
91 expect(said()[0]!.body).not.toMatch(/0123abcd|#\d/);
92 expect(said()[0]!.body).toContain("next release");
93
94 // Re-delivery: the same landing again says nothing more.
95 expect(tellNoteShipped(app.db, alice.id, issue, opened.value)).toBeUndefined();
96 expect(said()).toHaveLength(1);
97
98 const note = notesFor(app, record, repo, undefined, who)[0]!;
99 expect(note.status.state).toBe("shipped");
100 expect(note.replies.map((r) => r.text)).toEqual([said()[0]!.body]);
101 });
102
103 test("an issue that is not a note hears nothing", async () => {
104 const repo = getRepo(app.db, "alice", "sandbox-game")!;
105 const { openIssueThrough } = await import("../src/actions/issues.ts");
106 const plainIssue = await openIssueThrough(app, alice, repo, { title: "Plain", body: "x" });
107 if (!plainIssue.ok) throw new Error(String(plainIssue.error));
108 const pull = { number: 1, repoId: repo.id, title: "t", body: "" } as never;
109 expect(tellNoteShipped(app.db, alice.id, plainIssue.value, pull)).toBeUndefined();
110 expect(shippedWords(pull, "abc")).toContain("shipped in abc");
111 });
112
113 async function land(text: string, title: string, bodyOf: (n: number) => string, branch: string, merge = true) {
114 const record = getAppBySlug(app.db, "sandbox-game")!;
115 const repo = getRepo(app.db, "alice", "sandbox-game")!;
116 const who: Sender = { key: `v:${branch}`, name: "visitor", person: false };
117 const sent = await sendNote(app, record, repo, "production", undefined, who, { text, page: "", viewport: "390x844" }, `10.2.0.${branch.length}`);
118 if (!sent.ok) throw new Error(sent.error.message);
119 const n = sent.value.number;
120 const b = await commitToBranch(app, alice, repo, { branch, base: "main", message: branch, files: [{ path: `${branch}.txt`, content: "x\n" }] });
121 expect(b.ok).toBe(true);
122 const opened = await openPull(app, alice, repo, { title, body: bodyOf(n), baseBranch: "main", headBranch: branch });
123 if (!opened.ok) throw new Error(JSON.stringify(opened.error));
124 if (merge) expect((await mergePull(app, alice, repo, opened.value, "squash")).ok).toBe(true);
125 return { n, repo, pull: opened.value, record, who };
126 }
127 const marked = (issueId: string) => app.db.query("SELECT comment_id FROM note_shipped WHERE issue_id = ?1").all(issueId);
128
129 test("a person's comment that opens the same way does not stand in for the forge's", async () => {
130 const { n, repo } = await land("the logo is blurry", "Sharper logo", (k) => `The logo was blurry. It is sharp now.\n\nCloses #${k}`, "logo", false);
131 const issue = getIssue(app.db, repo.id, n)!;
132 addComment(app.db, { issueId: issue.id, authorId: alice.id, body: "Good news: this is fixed, says a person" });
133 const pull = getPullFor(repo.id, "logo");
134 expect(tellNoteShipped(app.db, alice.id, issue, pull)).toBeDefined();
135 expect(marked(issue.id)).toHaveLength(1);
136 });
137
138 test("the comment names the release once one is cut, in words, not a hash", async () => {
139 const { n, repo, pull } = await land("the footer overlaps", "Fix footer", (k) => `The footer overlapped the text. It no longer does.\n\nCloses #${k}`, "footer");
140 const issue = getIssue(app.db, repo.id, n)!;
141 const text = () => listComments(app.db, issue.id, { kind: "comment" }).filter((c) => c.body.startsWith("Good news"));
142 expect(text()[0]!.body).toContain("next release");
143 expect(noteReleased(app.db, recordRelease(app.db, { sha: "f00dfeedcafe1234", repoId: repo.id, pullNumber: pull.number, ok: false, line: "bad" }))).toBe(0);
144 const rel = recordRelease(app.db, { sha: "f00dfeedcafe1234", repoId: repo.id, pullNumber: pull.number, ok: true, line: "ok" });
145 expect(noteReleased(app.db, rel)).toBe(1);
146 expect(text()).toHaveLength(1);
147 expect(text()[0]!.body).toContain(`release ${rel.id} (`);
148 expect(text()[0]!.body).not.toMatch(/next release|f00dfeed/);
149 expect(text()[0]!.body).toContain("footer overlapped");
150 });
151
152 test("the box pass's closing path tells the sender too, once", async () => {
153 const { n, repo, pull } = await land("the menu is cut off", "Menu fits", (k) => `The menu was cut off. It fits now.\n\nCloses #${k}`, "menu");
154 const issue = getIssue(app.db, repo.id, n)!;
155 // As if the pull had landed before its boxes were read: the issue is open and unannounced.
156 app.db.query("DELETE FROM comment WHERE id = (SELECT comment_id FROM note_shipped WHERE issue_id = ?1)").run(issue.id);
157 expect(marked(issue.id)).toHaveLength(0);
158 setIssueState(app.db, issue.id, "open");
159 const merged = getPullFor(repo.id, "menu");
160 const pass = { ticks: [{ issue: n, box: 1, text: "x" }] };
161 applyBoxPass(app, repo, merged, alice, pass);
162 expect(getIssue(app.db, repo.id, n)!.state).toBe("closed");
163 const said = () => listComments(app.db, issue.id, { kind: "comment" }).filter((c) => c.body.startsWith("Good news"));
164 expect(said()).toHaveLength(1);
165 expect(said()[0]!.body).toContain("menu was cut off");
166 expect(pull.number).toBe(merged.number);
167 });
168
169 function getPullFor(repoId: string, head: string) {
170 const row = app.db.query<{ number: number }, [string, string]>("SELECT issue.number FROM pull JOIN issue ON issue.id = pull.issue_id WHERE issue.repo_id = ?1 AND pull.head_branch = ?2").get(...([repoId, head] as [string, string]));
171 if (row === null) throw new Error("no pull");
172 return getPull(app.db, repoId, row.number)!;
173 }
174 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs