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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5412-a-note-s-status-says-shipped-in-release → main 1 commit fork
4 files
changed +68
−3
src/actions/app_notes.ts +9 −2
| @@ -9,6 +9,8 @@ import type { App } from "../app.ts"; | ||
| 9 | 9 | import { banVisitor, bannedNotes, unbanVisitor, markSender, getNote, hideNote, insertNote, isBanned, notesOf, notesOfApp, type AppNote } from "../core/app_notes.ts"; |
| 10 | 10 | import type { AppRecord } from "../core/apps.ts"; |
| 11 | 11 | import { createLabel, getIssue, getLabel } from "../core/issues.ts"; |
| 12 | import { mergedPullClosing } from "../core/pulls.ts"; | |
| 13 | import { releaseName, releaseOfPull } from "../release/record.ts"; | |
| 12 | 14 | import { RateLimiter } from "../core/ratelimit.ts"; |
| 13 | 15 | import { canAdmin, type Repo } from "../core/repos.ts"; |
| 14 | 16 | import { plainText } from "../core/reports.ts"; |
| @@ -102,8 +104,13 @@ export function statusOf(app: App, repo: Repo, number: number): { status: NoteSt | ||
| 102 | 104 | if (issue.state === "closed") { |
| 103 | 105 | const when = (issue.closedAt ?? issue.updatedAt).slice(0, 10); |
| 104 | 106 | // 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 | } | |
| 107 | 114 | const label = issue.closeReason === "parked" ? "Set aside for now" : issue.closeReason === "split" ? "Split into smaller pieces" : "Closed without a change"; |
| 108 | 115 | return { title: issue.title, status: { state: "closed", label: `${label} (${when})` } }; |
| 109 | 116 | } |
src/core/pulls.ts +19
| @@ -1025,6 +1025,25 @@ export function closesFromBody(body: string): number[] { | ||
| 1025 | 1025 | return [...out]; |
| 1026 | 1026 | } |
| 1027 | 1027 | |
| 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 | ||
| 1028 | 1047 | /** |
| 1029 | 1048 | * Open pulls of a repository by the issue number each would close: the |
| 1030 | 1049 | * 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[] { | ||
| 52 | 52 | .all(limit) |
| 53 | 53 | .map(toRelease); |
| 54 | 54 | } |
| 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"; | ||
| 17 | 17 | import { getAppBySlug } from "../src/core/apps.ts"; |
| 18 | 18 | import { getNote, isBanned } from "../src/core/app_notes.ts"; |
| 19 | 19 | 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"; | |
| 21 | 22 | import { getRepo } from "../src/core/repos.ts"; |
| 22 | 23 | import { createUser, type User } from "../src/core/users.ts"; |
| 23 | 24 | import { serve, type Servers } from "../src/http/server.ts"; |
| @@ -260,6 +261,31 @@ describe("the forge button takes notes from anyone", () => { | ||
| 260 | 261 | expect((await post("one more", "", source)).status).toBe(429); |
| 261 | 262 | }); |
| 262 | 263 | |
| 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 | ||
| 263 | 289 | test("a body is read only up to the cap, whatever it declares", async () => { |
| 264 | 290 | const record = getAppBySlug(app.db, "notes")!; |
| 265 | 291 | let pulled = 0; |
new above
offline