Public: anyone can read this; the crew can change it.
Nothing waits behind a label no one asked for: the Later gate holds silently #7797
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7791-nothing-waits-behind-a-label-no-one-aske → main 2 commits fork 1 thread · 1 unresolved
3 files
changed +44
−55
docs/CREW.md +7 −6
| @@ -141,12 +141,13 @@ matching. | ||
| 141 | 141 | **Live triage files issues.** On the same tick, an open issue an hour old |
| 142 | 142 | that serves no pillar is filed: under the pillar its title, else its body, |
| 143 | 143 | names by its short form (`P0`) or whole label (`P0 Trust`) — a bare word like |
| 144 | "trust" does not count — otherwise behind the Later gate. Each gets a comment | |
| 145 | marked automatic. Directions, umbrellas and the gate are skipped, as is a | |
| 146 | repository with neither pillars nor a gate. To undo it, name a pillar (link | |
| 147 | its umbrella to wait for the issue) or unlink the gate; the comment stays, so | |
| 148 | triage does not file that issue again. The tick line counts `labelled=N`, and | |
| 149 | shows `triageMiss=N` for old issues it could not file (no gate to defer to). | |
| 144 | "trust" does not count. An issue naming no pillar is left as it is: nothing is | |
| 145 | held for lacking one. Each filed issue gets a comment marked automatic. | |
| 146 | Directions, umbrellas and the gate are skipped, as is a repository with no | |
| 147 | pillars. To undo a filing, unlink the umbrella; the comment stays, so triage | |
| 148 | does not file that issue again. The tick line counts `labelled=N`, and | |
| 149 | shows `triageMiss=N` for issues naming a pillar that the per-tick cap left for | |
| 150 | the next tick. | |
| 150 | 151 | |
| 151 | 152 | Live triage also folds questions. An open issue opened by a person who can |
| 152 | 153 | write here, whose title starts `Question:`, `Wish:` or `Idea:` or ends in `?`, |
src/crew/livetriage.ts +18 −28
| @@ -2,11 +2,11 @@ | ||
| 2 | 2 | * Live triage (#3242). Two rules, no run, no spend. |
| 3 | 3 | * |
| 4 | 4 | * Filing (#3266): an open issue an hour old that serves no pillar gets one — |
| 5 | * the pillar its title, else its body, names — or else waits behind the | |
| 6 | * Later gate, with a one-line comment saying so. A person undoes it by | |
| 7 | * naming a pillar, or by unlinking the gate: the note stays, so it is not | |
| 8 | * done twice. Directions, umbrellas and the gate itself are left alone, and | |
| 9 | * a repository with no pillars and no gate has nothing to file under. | |
| 5 | * the pillar its title, else its body, names. One that names no pillar is | |
| 6 | * left as it is: nothing is held for lacking a pillar (#7791). The note | |
| 7 | * stays on a filed issue, so it is not done twice. Directions, umbrellas | |
| 8 | * and the gate itself are left alone, and a repository with no pillars has | |
| 9 | * nothing to file under. | |
| 10 | 10 | * |
| 11 | 11 | * Closing: an issue the crew found already done on |
| 12 | 12 | * the base branch stops waiting for a person to close it. Rules only, no |
| @@ -51,9 +51,9 @@ export const MOST_CHANGES = 25; | ||
| 51 | 51 | export interface Triaged { |
| 52 | 52 | closed: number; |
| 53 | 53 | folded: number; |
| 54 | /** Issues given a pillar or put behind the Later gate. */ | |
| 54 | /** Issues given the pillar they name. */ | |
| 55 | 55 | labelled: number; |
| 56 | /** Open issues over an hour old that serve no pillar and that triage has yet to file. */ | |
| 56 | /** Open issues over an hour old that name a pillar and that triage has yet to file. */ | |
| 57 | 57 | missed: number; |
| 58 | 58 | } |
| 59 | 59 | |
| @@ -122,35 +122,25 @@ function unfiled(app: App, repo: Repo, set: Pillars, now: Date): Issue[] { | ||
| 122 | 122 | } |
| 123 | 123 | |
| 124 | 124 | /** |
| 125 | * File old, unplaced issues; returns how many were, and how many were left | |
| 126 | * unplaced. The link comes first: if the note then fails the issue is still | |
| 125 | * File old, unplaced issues that name a pillar; returns how many were, and how | |
| 126 | * many that name one were left for the next tick by the cap. The link comes first: if the note then fails the issue is still | |
| 127 | 127 | * placed, which is the part that matters, and the failure is logged. |
| 128 | 128 | */ |
| 129 | 129 | function file(app: App, repo: Repo, by: User, now: Date): [number, number] { |
| 130 | 130 | const set = pillarsOf(app.db, repo); |
| 131 | if (set.pillars.length === 0 && set.gate === undefined) return [0, 0]; | |
| 132 | const todo = unfiled(app, repo, set, now); | |
| 133 | let filed = 0; | |
| 134 | for (const issue of todo) { | |
| 135 | if (filed >= MOST_CHANGES) break; | |
| 131 | if (set.pillars.length === 0) return [0, 0]; | |
| 132 | const todo = unfiled(app, repo, set, now).flatMap((issue) => { | |
| 136 | 133 | const pillar = namedPillar(set, issue); |
| 137 | if (pillar === undefined && set.gate === undefined) continue; | |
| 134 | return pillar === undefined ? [] : [{ issue, pillar }]; | |
| 135 | }); | |
| 136 | let filed = 0; | |
| 137 | for (const { issue, pillar } of todo) { | |
| 138 | if (filed >= MOST_CHANGES) break; | |
| 138 | 139 | try { |
| 139 | const linked = | |
| 140 | pillar !== undefined | |
| 141 | ? linkIssue(app, by, repo, pillar.umbrella, issue) | |
| 142 | : linkIssue(app, by, repo, issue, set.gate!); | |
| 140 | const linked = linkIssue(app, by, repo, pillar.umbrella, issue); | |
| 143 | 141 | if (!linked.ok) continue; |
| 144 | 142 | filed++; |
| 145 | addComment( | |
| 146 | app, | |
| 147 | by, | |
| 148 | repo, | |
| 149 | issue, | |
| 150 | pillar !== undefined | |
| 151 | ? `${FILED_NOTE} filed under ${pillar.name}, which it names.` | |
| 152 | : `${FILED_NOTE} no pillar named, so it waits behind the Later gate; name a pillar to move it.`, | |
| 153 | ); | |
| 143 | addComment(app, by, repo, issue, `${FILED_NOTE} filed under ${pillar.name}, which it names.`); | |
| 154 | 144 | } catch (error) { |
| 155 | 145 | log.error("filing failed", { |
| 156 | 146 | repo: `${repo.ownerName}/${repo.name}`, |
test/livetriage.test.ts +19 −21
| @@ -468,23 +468,21 @@ describe("live triage files unplaced issues", () => { | ||
| 468 | 468 | expect(waitsFor(umbrella, work)).toBe(false); |
| 469 | 469 | }); |
| 470 | 470 | |
| 471 | test("a bare pillar name in passing does not file it: it goes to Later", async () => { | |
| 471 | test("a bare pillar name in passing does not file it, and nothing holds it", async () => { | |
| 472 | 472 | const work = open("Tell the story of trust", "A story about trust, in passing."); |
| 473 | 473 | await liveTriage(app, fresh(), CLOCK); |
| 474 | 474 | expect(waitsFor(umbrella, work)).toBe(false); |
| 475 | expect(waitsFor(work, gate)).toBe(true); | |
| 475 | expect(waitsFor(work, gate)).toBe(false); | |
| 476 | 476 | }); |
| 477 | 477 | |
| 478 | test("an issue naming none goes behind the Later gate with a comment, once", async () => { | |
| 478 | test("an issue naming none is never held: no gate, no label, no comment", async () => { | |
| 479 | 479 | const work = open("Add a colour picker"); |
| 480 | await liveTriage(app, fresh(), CLOCK); | |
| 481 | expect(waitsFor(work, gate)).toBe(true); | |
| 482 | expect(labelsFor(app.db, work.id).map((l) => l.name)).toContain("Later"); | |
| 483 | expect(notes(work)[0]!.body).toContain("Later gate"); | |
| 484 | const again = await liveTriage(app, fresh(), CLOCK); | |
| 485 | expect(again.labelled).toBe(0); | |
| 486 | expect(again.missed).toBe(0); | |
| 487 | expect(notes(work)).toHaveLength(1); | |
| 480 | const did = await liveTriage(app, fresh(), CLOCK); | |
| 481 | expect(waitsFor(work, gate)).toBe(false); | |
| 482 | expect(labelsFor(app.db, work.id)).toHaveLength(0); | |
| 483 | expect(notes(work)).toHaveLength(0); | |
| 484 | expect(did.labelled).toBe(0); | |
| 485 | expect(did.missed).toBe(0); | |
| 488 | 486 | }); |
| 489 | 487 | |
| 490 | 488 | test("an issue under an hour old is left, and a placed one is not moved", async () => { |
| @@ -508,12 +506,12 @@ describe("live triage files unplaced issues", () => { | ||
| 508 | 506 | expect(waitsFor(gate, gate)).toBe(false); |
| 509 | 507 | }); |
| 510 | 508 | |
| 511 | test("a person who unlinks the gate is not overruled", async () => { | |
| 512 | const work = open("Needs thought"); | |
| 509 | test("a person who unfiles a filed issue is not overruled", async () => { | |
| 510 | const work = open("Harden P0 again"); | |
| 513 | 511 | await liveTriage(app, fresh(), CLOCK); |
| 514 | app.db.query(`DELETE FROM issue_dependency WHERE issue_id = ?1`).run(work.id); | |
| 512 | app.db.query(`DELETE FROM issue_dependency WHERE blocked_by_id = ?1`).run(work.id); | |
| 515 | 513 | const did = await liveTriage(app, fresh(), CLOCK); |
| 516 | expect(waitsFor(work, gate)).toBe(false); | |
| 514 | expect(waitsFor(umbrella, work)).toBe(false); | |
| 517 | 515 | expect(did.labelled).toBe(0); |
| 518 | 516 | expect(did.missed).toBe(0); |
| 519 | 517 | }); |
| @@ -543,7 +541,7 @@ describe("live triage where there is little to file under", () => { | ||
| 543 | 541 | }); |
| 544 | 542 | |
| 545 | 543 | /** A repository with the given furniture; `count` old issues naming no pillar. */ |
| 546 | const world = async (name: string, furniture: string[], count: number) => { | |
| 544 | const world = async (name: string, furniture: string[], count: number, stem = "Plain work") => { | |
| 547 | 545 | const made = await createRepo(app, alice, { name, visibility: "private" }); |
| 548 | 546 | if (!made.ok) throw new Error(String(made.error)); |
| 549 | 547 | const set = await updateRepoSettings(app, alice, made.value, { |
| @@ -562,16 +560,16 @@ describe("live triage where there is little to file under", () => { | ||
| 562 | 560 | return getIssueById(app.db, o.value.id)!; |
| 563 | 561 | }; |
| 564 | 562 | furniture.forEach(open); |
| 565 | const work = Array.from({ length: count }, (_, n) => open(`Plain work ${n}`)); | |
| 563 | const work = Array.from({ length: count }, (_, n) => open(`${stem} ${n}`)); | |
| 566 | 564 | return { repo, work }; |
| 567 | 565 | }; |
| 568 | 566 | const comments = (on: Issue) => listComments(app.db, on.id).length; |
| 569 | 567 | |
| 570 | test("pillars but no gate: an unnamed issue is left alone and counted as missed", async () => { | |
| 571 | const w = await world("nogate", ["P0 Trust: be safe"], 1); | |
| 568 | test("an unnamed issue is left alone, and is not counted as missed", async () => { | |
| 569 | const w = await world("unnamed", ["P0 Trust: be safe"], 1); | |
| 572 | 570 | const did = await liveTriage(app, w.repo(), CLOCK); |
| 573 | 571 | expect(did.labelled).toBe(0); |
| 574 | expect(did.missed).toBe(1); | |
| 572 | expect(did.missed).toBe(0); | |
| 575 | 573 | expect(comments(w.work[0]!)).toBe(0); |
| 576 | 574 | expect(labelsFor(app.db, w.work[0]!.id)).toHaveLength(0); |
| 577 | 575 | const links = app.db |
| @@ -589,7 +587,7 @@ describe("live triage where there is little to file under", () => { | ||
| 589 | 587 | }); |
| 590 | 588 | |
| 591 | 589 | test("at most 25 issues are filed per tick; the rest are missed, then filed", async () => { |
| 592 | const w = await world("many", ["Later gate: not yet"], MOST_CHANGES + 1); | |
| 590 | const w = await world("many", ["P0 Trust: be safe"], MOST_CHANGES + 1, "Plain P0 work"); | |
| 593 | 591 | const first = await liveTriage(app, w.repo(), CLOCK); |
| 594 | 592 | expect(first.labelled).toBe(MOST_CHANGES); |
| 595 | 593 | expect(first.missed).toBe(1); |
new above
offline
Non-blocking: this comment line is over-long after the edit; rewrap it to the file width.