Public: anyone can read this; the crew can change it.
Live triage folds a writer's open question or wish into the direction issue as one linked line #3441
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3268-live-triage-folds-a-writer-s-open-questi → main 3 commits fork 1 thread · 1 unresolved
3 files
changed +222
−8
README.md +10
| @@ -1084,6 +1084,16 @@ its umbrella to wait for the issue) or unlink the gate; the comment stays, so | ||
| 1084 | 1084 | triage does not file that issue again. The tick line counts `labelled=N`, and |
| 1085 | 1085 | shows `triageMiss=N` for old issues it could not file (no gate to defer to). |
| 1086 | 1086 | |
| 1087 | Live triage also folds questions. An open issue opened by a person who can | |
| 1088 | write here, whose title starts `Question:`, `Wish:` or `Idea:` or ends in `?`, | |
| 1089 | becomes one `Folded: #N <title>` comment on the direction issue and closes as | |
| 1090 | *dropped*, with a comment saying how to get it back: reopen it and retitle it | |
| 1091 | as the task it asks for; the reopen stands. Issues from outsiders, agents and | |
| 1092 | imported ones are never folded, and nothing is folded before the repository | |
| 1093 | has a direction issue. Note that a writer's real task titled `Can we add X?` | |
| 1094 | is folded too, since a title ending in `?` is the rule; phrase tasks as | |
| 1095 | statements. The tick line counts folds as `folded=N`. | |
| 1096 | ||
| 1087 | 1097 | **A red pull.** The decider stops a red check only when it is at least 70% |
| 1088 | 1098 | sure; below that the pull gets another feedback run. A stop parks the pull: it |
| 1089 | 1099 | closes as *parked*, not done, with a comment that says why. The pull page's |
src/crew/livetriage.ts +97 −3
| @@ -16,6 +16,13 @@ | ||
| 16 | 16 | * open. A second pass over the same state changes nothing, because a closed |
| 17 | 17 | * issue is no longer a candidate. |
| 18 | 18 | * |
| 19 | * Second rule (#3268): a question is not a task. An open issue a person who | |
| 20 | * can write here opened, whose title starts `Question:`, `Wish:` or `Idea:` | |
| 21 | * or ends in `?`, becomes one `Folded: #N <title>` line on the direction | |
| 22 | * issue and closes as dropped, with a comment that says how to reopen it as | |
| 23 | * a task. Anyone else's issue, and an imported one, is never folded. A | |
| 24 | * folded issue that a person reopens stays open: the comment is the mark. | |
| 25 | * | |
| 19 | 26 | * The close is `setIssueState`, whose state change is committed before the |
| 20 | 27 | * event is published; the counters here move only after it returns, so a |
| 21 | 28 | * change that rolled back is neither counted nor announced. |
| @@ -27,7 +34,7 @@ import { addComment, setIssueState } from "../actions/issues.ts"; | ||
| 27 | 34 | import { alreadyDone, CLOSED_NOTE, DONE_LIKE } from "../core/done_on_main.ts"; |
| 28 | 35 | import { getIssueById, getIssuesByIds, type Issue } from "../core/issues.ts"; |
| 29 | 36 | import { isStructural, pillarsOf, servedBy, type Pillar, type Pillars } from "../core/pillars.ts"; |
| 30 | import type { Repo } from "../core/repos.ts"; | |
| 37 | import { canWrite, type Repo } from "../core/repos.ts"; | |
| 31 | 38 | import { getUserById, type User } from "../core/users.ts"; |
| 32 | 39 | import { repoDir } from "../git/repos.ts"; |
| 33 | 40 | import { isMirror } from "../origins/write-through.ts"; |
| @@ -42,13 +49,14 @@ export const MOST_CHANGES = 25; | ||
| 42 | 49 | /** What a pass did. */ |
| 43 | 50 | export interface Triaged { |
| 44 | 51 | closed: number; |
| 52 | folded: number; | |
| 45 | 53 | /** Issues given a pillar or put behind the Later gate. */ |
| 46 | 54 | labelled: number; |
| 47 | 55 | /** Open issues over an hour old that serve no pillar and that triage has yet to file. */ |
| 48 | 56 | missed: number; |
| 49 | 57 | } |
| 50 | 58 | |
| 51 | export const none = (): Triaged => ({ closed: 0, labelled: 0, missed: 0 }); | |
| 59 | export const none = (): Triaged => ({ closed: 0, folded: 0, labelled: 0, missed: 0 }); | |
| 52 | 60 | |
| 53 | 61 | /** How old an issue is before triage files it. */ |
| 54 | 62 | export const FILE_AFTER_MS = 60 * 60 * 1000; |
| @@ -157,11 +165,93 @@ function file(app: App, repo: Repo, by: User, now: Date): [number, number] { | ||
| 157 | 165 | export function tickFields(did: Triaged): Record<string, number> { |
| 158 | 166 | return { |
| 159 | 167 | triaged: did.closed, |
| 168 | folded: did.folded, | |
| 160 | 169 | labelled: did.labelled, |
| 161 | 170 | ...(did.missed === 0 ? {} : { triageMiss: did.missed }), |
| 162 | 171 | }; |
| 163 | 172 | } |
| 164 | 173 | |
| 174 | /** What the comment on a folded issue starts with. */ | |
| 175 | export const FOLDED_NOTE = "Folded into the direction issue"; | |
| 176 | ||
| 177 | /** A title that asks or wishes rather than names work. */ | |
| 178 | export const isQuestion = (title: string): boolean => | |
| 179 | /^\s*(question|wish|idea):/i.test(title) || /\?\s*$/.test(title); | |
| 180 | ||
| 181 | /** | |
| 182 | * Open questions by people who can write here, oldest first. One carrying | |
| 183 | * the triage user's own folded note was folded once and reopened by a | |
| 184 | * person: that reopen stands. Matched on author and exact prefix, so no | |
| 185 | * one else's comment can block a fold and `_` or `%` are not wildcards. | |
| 186 | */ | |
| 187 | function questions(app: App, repo: Repo, by: User, directionId: string): Issue[] { | |
| 188 | const rows = app.db | |
| 189 | .query<{ id: string }, [string, string, string, string]>( | |
| 190 | `SELECT i.id AS id FROM issue i | |
| 191 | WHERE i.repo_id = ?1 AND i.kind = 'issue' AND i.state = 'open' AND i.id != ?2 | |
| 192 | AND i.origin_author IS NULL | |
| 193 | AND NOT EXISTS (SELECT 1 FROM comment c WHERE c.issue_id = i.id | |
| 194 | AND c.author_id = ?3 | |
| 195 | AND substr(c.body, 1, length(?4)) = ?4) | |
| 196 | ORDER BY i.number`, | |
| 197 | ) | |
| 198 | .all(repo.id, directionId, by.id, FOLDED_NOTE); | |
| 199 | const out: Issue[] = []; | |
| 200 | for (const { id } of rows) { | |
| 201 | const issue = getIssueById(app.db, id); | |
| 202 | if (!issue || !isQuestion(issue.title)) continue; | |
| 203 | const author = getUserById(app.db, issue.authorId); | |
| 204 | if (author?.kind === "user" && canWrite(app.db, repo, author)) out.push(issue); | |
| 205 | } | |
| 206 | return out; | |
| 207 | } | |
| 208 | ||
| 209 | /** | |
| 210 | * Fold the questions into the direction issue. Each write commits and | |
| 211 | * announces on its own, in an order a partial failure leaves true: the line | |
| 212 | * (written once, found again by its exact text), then the close, then the | |
| 213 | * note that marks the fold done. A close that fails leaves a line that is | |
| 214 | * true and an issue the next tick folds without a second line. | |
| 215 | */ | |
| 216 | function fold(app: App, repo: Repo, by: User, did: Triaged): void { | |
| 217 | if (repo.directionIssueId === null) return; | |
| 218 | const direction = getIssueById(app.db, repo.directionIssueId); | |
| 219 | if (!direction || direction.state !== "open") return; | |
| 220 | for (const issue of questions(app, repo, by, direction.id)) { | |
| 221 | if (did.closed + did.folded >= MOST_CHANGES) break; | |
| 222 | try { | |
| 223 | const line = `Folded: #${issue.number} ${issue.title}`; | |
| 224 | const there = app.db | |
| 225 | .query<{ n: number }, [string, string, string]>( | |
| 226 | `SELECT COUNT(*) AS n FROM comment WHERE issue_id = ?1 AND author_id = ?2 AND body = ?3`, | |
| 227 | ) | |
| 228 | .get(direction.id, by.id, line)!.n; | |
| 229 | if (there === 0) { | |
| 230 | const added = addComment(app, by, repo, direction, line); | |
| 231 | if (!added.ok) throw new Error(String(added.error)); | |
| 232 | } | |
| 233 | const closed = setIssueState(app, by, repo, issue, "closed", { reason: "dropped", triage: true }); | |
| 234 | if (!closed.ok || closed.value.state !== "closed") throw new Error("not closed"); | |
| 235 | did.folded++; | |
| 236 | const note = addComment( | |
| 237 | app, | |
| 238 | by, | |
| 239 | repo, | |
| 240 | issue, | |
| 241 | `${FOLDED_NOTE} (#${direction.number}) as a question, not a task. ` + | |
| 242 | "To work it, reopen this issue and retitle it as the task it asks for.", | |
| 243 | ); | |
| 244 | if (!note.ok) throw new Error(String(note.error)); | |
| 245 | } catch (error) { | |
| 246 | log.error("fold failed", { | |
| 247 | repo: `${repo.ownerName}/${repo.name}`, | |
| 248 | issue: issue.number, | |
| 249 | error: String(error), | |
| 250 | }); | |
| 251 | } | |
| 252 | } | |
| 253 | } | |
| 254 | ||
| 165 | 255 | /** |
| 166 | 256 | * Open issues that carry an already-done note, oldest first. One that |
| 167 | 257 | * already has a close note was closed once and reopened by a person: that |
| @@ -200,7 +290,10 @@ export async function liveTriage( | ||
| 200 | 290 | } |
| 201 | 291 | const ids = candidates(app, repo); |
| 202 | 292 | const noted = alreadyDone(app.db, ids); |
| 203 | if (noted.size === 0) return did; | |
| 293 | if (noted.size === 0) { | |
| 294 | fold(app, repo, by, did); | |
| 295 | return did; | |
| 296 | } | |
| 204 | 297 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); |
| 205 | 298 | for (const id of ids) { |
| 206 | 299 | const oids = noted.get(id); |
| @@ -225,5 +318,6 @@ export async function liveTriage( | ||
| 225 | 318 | }); |
| 226 | 319 | } |
| 227 | 320 | } |
| 321 | fold(app, repo, by, did); | |
| 228 | 322 | return did; |
| 229 | 323 | } |
test/livetriage.test.ts +115 −5
| @@ -11,9 +11,10 @@ import { addComment, openIssue, setIssueState } from "../src/actions/issues.ts"; | ||
| 11 | 11 | import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts"; |
| 12 | 12 | import { getIssueById, labelsFor, listComments, type Issue } from "../src/core/issues.ts"; |
| 13 | 13 | import { getRepoById, type Repo } from "../src/core/repos.ts"; |
| 14 | import { createUser, type User } from "../src/core/users.ts"; | |
| 14 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 15 | 15 | import { ALREADY_NOTE } from "../src/crew/already.ts"; |
| 16 | import { liveTriage, MOST_CHANGES, FILE_AFTER_MS, tickFields } from "../src/crew/livetriage.ts"; | |
| 16 | import { ensureDirectionIssue } from "../src/crew/autonomy.ts"; | |
| 17 | import { FILE_AFTER_MS, FOLDED_NOTE, liveTriage, MOST_CHANGES, tickFields } from "../src/crew/livetriage.ts"; | |
| 17 | 18 | |
| 18 | 19 | describe("live triage", () => { |
| 19 | 20 | let dir: string; |
| @@ -78,6 +79,7 @@ describe("live triage", () => { | ||
| 78 | 79 | |
| 79 | 80 | const first = await liveTriage(app, fresh()); |
| 80 | 81 | expect(first.closed).toBe(1); |
| 82 | expect(first.folded).toBe(0); | |
| 81 | 83 | expect(state(done).state).toBe("closed"); |
| 82 | 84 | expect(state(done).closeReason).toBe("done"); |
| 83 | 85 | expect(listComments(app.db, done.id).some((c) => c.body.includes(oid.slice(0, 7)))).toBe(true); |
| @@ -147,6 +149,113 @@ describe("live triage", () => { | ||
| 147 | 149 | expect((await liveTriage(app, fresh())).closed).toBe(MOST_CHANGES); |
| 148 | 150 | expect((await liveTriage(app, fresh())).closed).toBe(3); |
| 149 | 151 | }); |
| 152 | ||
| 153 | describe("questions", () => { | |
| 154 | beforeAll(() => { | |
| 155 | ensureDirectionIssue(app, alice, fresh()); | |
| 156 | }); | |
| 157 | const direction = () => getIssueById(app.db, fresh().directionIssueId!)!; | |
| 158 | const lines = () => listComments(app.db, direction().id).map((c) => c.body); | |
| 159 | const outsider = async () => { | |
| 160 | const made = await createUser(app.db, { | |
| 161 | origin: "seed", | |
| 162 | trusted: true, | |
| 163 | username: "carol", | |
| 164 | email: "[email protected]", | |
| 165 | password: "a-good-passphrase", | |
| 166 | }); | |
| 167 | if (!made.ok) throw new Error(made.error); | |
| 168 | return made.value; | |
| 169 | }; | |
| 170 | ||
| 171 | test("a writer's question folds into the direction issue as one line and closes as dropped", async () => { | |
| 172 | for (const title of ["Question: why sqlite", "Wish: dark mode", "idea: a tui", "Should it sync?"]) { | |
| 173 | const q = issue(title); | |
| 174 | const before = lines().length; | |
| 175 | const did = await liveTriage(app, fresh()); | |
| 176 | expect(did.folded).toBe(1); | |
| 177 | expect(lines().slice(before)).toEqual([`Folded: #${q.number} ${title}`]); | |
| 178 | expect(state(q).state).toBe("closed"); | |
| 179 | expect(state(q).closeReason).toBe("dropped"); | |
| 180 | expect(listComments(app.db, q.id).some((c) => c.body.includes("reopen"))).toBe(true); | |
| 181 | } | |
| 182 | }); | |
| 183 | ||
| 184 | test("an outsider's question is never folded", async () => { | |
| 185 | const carol = await outsider(); | |
| 186 | const theirs = issue("Question: placeholder"); | |
| 187 | app.db.query(`UPDATE issue SET author_id = ?1 WHERE id = ?2`).run(carol.id, theirs.id); | |
| 188 | const before = lines().length; | |
| 189 | expect((await liveTriage(app, fresh())).folded).toBe(0); | |
| 190 | expect(state(theirs).state).toBe("open"); | |
| 191 | expect(lines().length).toBe(before); | |
| 192 | }); | |
| 193 | ||
| 194 | test("a task title stays open, and a reopened fold stays open", async () => { | |
| 195 | const task = issue("Add a dark mode"); | |
| 196 | const asked = issue("Wish: export"); | |
| 197 | expect((await liveTriage(app, fresh())).folded).toBe(1); | |
| 198 | expect(state(task).state).toBe("open"); | |
| 199 | expect(setIssueState(app, alice, fresh(), state(asked), "open").ok).toBe(true); | |
| 200 | const before = lines().length; | |
| 201 | expect((await liveTriage(app, fresh())).folded).toBe(0); | |
| 202 | expect(state(asked).state).toBe("open"); | |
| 203 | expect(lines().length).toBe(before); | |
| 204 | }); | |
| 205 | ||
| 206 | test("a close that fails announces no second line and the next tick finishes it", async () => { | |
| 207 | const stuck = issue("Wish: rollback"); | |
| 208 | app.db.run( | |
| 209 | `CREATE TRIGGER no_fold_close BEFORE UPDATE ON issue | |
| 210 | WHEN NEW.state = 'closed' AND NEW.title = 'Wish: rollback' | |
| 211 | BEGIN SELECT RAISE(ABORT, 'rolled back'); END`, | |
| 212 | ); | |
| 213 | const line = `Folded: #${stuck.number} Wish: rollback`; | |
| 214 | const before = closedEvents(); | |
| 215 | try { | |
| 216 | expect((await liveTriage(app, fresh())).folded).toBe(0); | |
| 217 | expect(state(stuck).state).toBe("open"); | |
| 218 | expect(closedEvents()).toBe(before); | |
| 219 | } finally { | |
| 220 | app.db.run(`DROP TRIGGER no_fold_close`); | |
| 221 | } | |
| 222 | expect((await liveTriage(app, fresh())).folded).toBe(1); | |
| 223 | expect(state(stuck).state).toBe("closed"); | |
| 224 | expect(lines().filter((l) => l === line).length).toBe(1); | |
| 225 | }); | |
| 226 | ||
| 227 | test("a stranger's comment cannot block a fold", async () => { | |
| 228 | const carol = await outsider().catch(() => getUserByName(app.db, "carol")!); | |
| 229 | const q = issue("Idea: wildcard"); | |
| 230 | app.db | |
| 231 | .query(`INSERT INTO comment (id, issue_id, author_id, body, created_at) VALUES (?1, ?2, ?3, ?4, ?5)`) | |
| 232 | .run("c-spoof", q.id, carol.id, `${FOLDED_NOTE} nope`, new Date().toISOString()); | |
| 233 | expect((await liveTriage(app, fresh())).folded).toBe(1); | |
| 234 | expect(state(q).state).toBe("closed"); | |
| 235 | }); | |
| 236 | ||
| 237 | test("an agent's or an imported question is never folded", async () => { | |
| 238 | const agent = issue("Question: by an agent"); | |
| 239 | const imported = issue("Question: imported"); | |
| 240 | app.db.query(`UPDATE issue SET origin_author = 'stranger' WHERE id = ?1`).run(imported.id); | |
| 241 | app.db.query(`UPDATE user SET is_agent = 1 WHERE id = ?1`).run(alice.id); | |
| 242 | try { | |
| 243 | expect((await liveTriage(app, fresh())).folded).toBe(0); | |
| 244 | } finally { | |
| 245 | app.db.query(`UPDATE user SET is_agent = 0 WHERE id = ?1`).run(alice.id); | |
| 246 | } | |
| 247 | expect(state(agent).state).toBe("open"); | |
| 248 | expect(state(imported).state).toBe("open"); | |
| 249 | app.db.query(`UPDATE issue SET state = 'closed' WHERE id IN (?1, ?2)`).run(agent.id, imported.id); | |
| 250 | }); | |
| 251 | ||
| 252 | test("folds count toward the cap", async () => { | |
| 253 | const many = Array.from({ length: MOST_CHANGES + 2 }, (_, n) => issue(`Idea: cap ${n}`)); | |
| 254 | expect((await liveTriage(app, fresh())).folded).toBe(MOST_CHANGES); | |
| 255 | expect((await liveTriage(app, fresh())).folded).toBe(2); | |
| 256 | expect(many.every((m) => state(m).state === "closed")).toBe(true); | |
| 257 | }); | |
| 258 | }); | |
| 150 | 259 | }); |
| 151 | 260 | |
| 152 | 261 | describe("live triage files unplaced issues", () => { |
| @@ -342,7 +451,7 @@ describe("live triage where there is little to file under", () => { | ||
| 342 | 451 | |
| 343 | 452 | test("neither pillars nor gate: nothing is filed, counted or written", async () => { |
| 344 | 453 | const w = await world("plain", [], 2); |
| 345 | expect(await liveTriage(app, w.repo())).toEqual({ closed: 0, labelled: 0, missed: 0 }); | |
| 454 | expect(await liveTriage(app, w.repo())).toEqual({ closed: 0, folded: 0, labelled: 0, missed: 0 }); | |
| 346 | 455 | for (const issue of w.work) expect(comments(issue)).toBe(0); |
| 347 | 456 | }); |
| 348 | 457 | |
| @@ -357,9 +466,10 @@ describe("live triage where there is little to file under", () => { | ||
| 357 | 466 | }); |
| 358 | 467 | |
| 359 | 468 | test("the tick line carries labelled always and triageMiss only when something was missed", () => { |
| 360 | expect(tickFields({ closed: 2, labelled: 3, missed: 0 })).toEqual({ triaged: 2, labelled: 3 }); | |
| 361 | expect(tickFields({ closed: 0, labelled: 0, missed: 4 })).toEqual({ | |
| 469 | expect(tickFields({ closed: 2, folded: 1, labelled: 3, missed: 0 })).toEqual({ triaged: 2, folded: 1, labelled: 3 }); | |
| 470 | expect(tickFields({ closed: 0, folded: 0, labelled: 0, missed: 4 })).toEqual({ | |
| 362 | 471 | triaged: 0, |
| 472 | folded: 0, | |
| 363 | 473 | labelled: 0, |
| 364 | 474 | triageMiss: 4, |
| 365 | 475 | }); |
new above
offline
The
\?\s*$branch is the risky one: it closes real tasks silently. Prefer the three prefixes plus interrogative-leading titles.