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

A note's status says "Shipped in <release>", or "Shipped on <date>" until a release includes it #5789

Merged opened this

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

Merged

scout/forge:scout/5412-a-note-s-status-says-shipped-in-release → main 1 commit fork

  • Merged. scout/5412-a-note-s-status-says-shipped-in-release was merged into main as a3b8d3a.

Review

Sign in to review this change.
4 files changed +68 −3
src/actions/app_notes.ts +9 −2
@@ -9,6 +9,8 @@ import type { App } from "../app.ts";
99 import { banVisitor, bannedNotes, unbanVisitor, markSender, getNote, hideNote, insertNote, isBanned, notesOf, notesOfApp, type AppNote } from "../core/app_notes.ts";
1010 import type { AppRecord } from "../core/apps.ts";
1111 import { createLabel, getIssue, getLabel } from "../core/issues.ts";
12 import { mergedPullClosing } from "../core/pulls.ts";
13 import { releaseName, releaseOfPull } from "../release/record.ts";
1214 import { RateLimiter } from "../core/ratelimit.ts";
1315 import { canAdmin, type Repo } from "../core/repos.ts";
1416 import { plainText } from "../core/reports.ts";
@@ -102,8 +104,13 @@ export function statusOf(app: App, repo: Repo, number: number): { status: NoteSt
102104 if (issue.state === "closed") {
103105 const when = (issue.closedAt ?? issue.updatedAt).slice(0, 10);
104106 // Only "done" is a promise kept; the other reasons say what they are.
105 if (issue.closeReason === "done" || issue.closeReason === null)
106 return { title: issue.title, status: { state: "shipped", label: `Shipped on ${when}` } };
107 if (issue.closeReason === "done" || issue.closeReason === null) {
108 const pull = mergedPullClosing(app.db, repo.id, number);
109 const release = pull === undefined ? undefined : releaseOfPull(app.db, repo.id, pull.number);
110 const label =
111 release !== undefined ? `Shipped in ${releaseName(release)}` : `Shipped on ${(pull?.mergedAt ?? issue.closedAt ?? issue.updatedAt).slice(0, 10)}`;
112 return { title: issue.title, status: { state: "shipped", label } };
113 }
107114 const label = issue.closeReason === "parked" ? "Set aside for now" : issue.closeReason === "split" ? "Split into smaller pieces" : "Closed without a change";
108115 return { title: issue.title, status: { state: "closed", label: `${label} (${when})` } };
109116 }
src/core/pulls.ts +19
@@ -1025,6 +1025,25 @@ export function closesFromBody(body: string): number[] {
10251025 return [...out];
10261026 }
10271027
1028 /**
1029 * The newest merged pull whose description closes issue `number`: the pull
1030 * that shipped it. Undefined when it was closed by hand.
1031 */
1032 export function mergedPullClosing(db: Database, repoId: string, number: number): Pull | undefined {
1033 const rows = db
1034 .query<{ number: number }, [string, string]>(
1035 `SELECT issue.number AS number FROM pull JOIN issue ON issue.id = pull.issue_id
1036 WHERE issue.repo_id = ?1 AND pull.merged_at IS NOT NULL AND issue.body LIKE ?2
1037 ORDER BY pull.merged_at DESC LIMIT 50`,
1038 )
1039 .all(repoId, `%#${number}%`);
1040 for (const row of rows) {
1041 const pull = getPull(db, repoId, row.number);
1042 if (pull !== undefined && closesFromBody(pull.body).includes(number)) return pull;
1043 }
1044 return undefined;
1045 }
1046
10281047 /**
10291048 * Open pulls of a repository by the issue number each would close: the
10301049 * work that is proposed and not yet landed, which is neither ready nor
src/release/record.ts +13
@@ -52,3 +52,16 @@ export function listReleases(db: Database, limit = 20): Release[] {
5252 .all(limit)
5353 .map(toRelease);
5454 }
55
56 /** The newest healthy release a pull landed in; undefined while it is in none. */
57 export function releaseOfPull(db: Database, repoId: string, pullNumber: number): Release | undefined {
58 const row = db
59 .query<Row, [string, number]>(
60 `SELECT * FROM release WHERE repo_id = ?1 AND pull_number = ?2 AND ok = 1 ORDER BY id DESC LIMIT 1`,
61 )
62 .get(repoId, pullNumber);
63 return row === null ? undefined : toRelease(row);
64 }
65
66 /** What a release is called where people read it: the landed commit, short. */
67 export const releaseName = (r: Release): string => r.sha.slice(0, 12);
test/apps-feedback.test.ts +27 −1
@@ -17,7 +17,8 @@ import { feedback, REQUEST_MAX } from "../src/apps/feedback.ts";
1717 import { getAppBySlug } from "../src/core/apps.ts";
1818 import { getNote, isBanned } from "../src/core/app_notes.ts";
1919 import { grantedCookie } from "./helpers/identity.ts";
20 import { getIssue, labelsFor, setIssueState } from "../src/core/issues.ts";
20 import { recordRelease } from "../src/release/record.ts";
21 import { createIssue, getIssue, labelsFor, setIssueState } from "../src/core/issues.ts";
2122 import { getRepo } from "../src/core/repos.ts";
2223 import { createUser, type User } from "../src/core/users.ts";
2324 import { serve, type Servers } from "../src/http/server.ts";
@@ -260,6 +261,31 @@ describe("the forge button takes notes from anyone", () => {
260261 expect((await post("one more", "", source)).status).toBe(429);
261262 });
262263
264 test("a note's fix says the release it is in, or the date it merged", async () => {
265 const made = await newApp(app, alice, { name: "shipping", description: "Words", visibility: "public" });
266 if (!made.ok) throw new Error(String(made.error));
267 const repo = getRepo(app.db, "alice", "shipping")!;
268 const land = (n: number, mergedAt: string) => {
269 const pr = createIssue(app.db, { repoId: repo.id, authorId: alice.id, title: `fix ${n}`, body: `Closes #${n}`, kind: "pull" });
270 if (!pr.ok) throw new Error("pull");
271 app.db.query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at) VALUES (?1, 'main', 'fix', 'a', 'b', ?2)`).run(pr.value.id, mergedAt);
272 return pr.value.number;
273 };
274 const label = async (text: string, ip: string, mergedAt: string, released: boolean) => {
275 const first = await post(text, "", ip, "shipping");
276 const cookie = cookieOf(first);
277 const n = Number(((await first.json()) as { note: { url: string } }).note.url.split("/").pop());
278 const pull = land(n, mergedAt);
279 if (released) recordRelease(app.db, { sha: "c0ffee".repeat(6), repoId: repo.id, pullNumber: pull, ok: true, line: "ok" });
280 setIssueState(app.db, getIssue(app.db, repo.id, n)!.id, "closed", "done");
281 const [note] = ((await (await at("shipping", "/__forge/notes", {}, cookie)).json()) as { notes: { status: { state: string; label: string } }[] }).notes;
282 expect(note!.status.state).toBe("shipped");
283 return note!.status.label;
284 };
285 expect(await label("released fix", "10.7.0.1", "2026-03-04T10:00:00Z", true)).toBe("Shipped in c0ffeec0ffee");
286 expect(await label("merged fix", "10.7.0.2", "2026-03-04T10:00:00Z", false)).toBe("Shipped on 2026-03-04");
287 });
288
263289 test("a body is read only up to the cap, whatever it declares", async () => {
264290 const record = getAppBySlug(app.db, "notes")!;
265291 let pulled = 0;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs