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

Merged opened this

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

Merged

scout/forge:scout/3268-live-triage-folds-a-writer-s-open-questi → main 3 commits fork 1 thread · 1 unresolved

  • Merged. scout/3268-live-triage-folds-a-writer-s-open-questi was merged into main as 0fb81fe.

Review

Sign in to review this change.
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
10841084 triage does not file that issue again. The tick line counts `labelled=N`, and
10851085 shows `triageMiss=N` for old issues it could not file (no gate to defer to).
10861086
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
10871097 **A red pull.** The decider stops a red check only when it is at least 70%
10881098 sure; below that the pull gets another feedback run. A stop parks the pull: it
10891099 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 @@
1616 * open. A second pass over the same state changes nothing, because a closed
1717 * issue is no longer a candidate.
1818 *
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 *
1926 * The close is `setIssueState`, whose state change is committed before the
2027 * event is published; the counters here move only after it returns, so a
2128 * change that rolled back is neither counted nor announced.
@@ -27,7 +34,7 @@ import { addComment, setIssueState } from "../actions/issues.ts";
2734 import { alreadyDone, CLOSED_NOTE, DONE_LIKE } from "../core/done_on_main.ts";
2835 import { getIssueById, getIssuesByIds, type Issue } from "../core/issues.ts";
2936 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";
3138 import { getUserById, type User } from "../core/users.ts";
3239 import { repoDir } from "../git/repos.ts";
3340 import { isMirror } from "../origins/write-through.ts";
@@ -42,13 +49,14 @@ export const MOST_CHANGES = 25;
4249 /** What a pass did. */
4350 export interface Triaged {
4451 closed: number;
52 folded: number;
4553 /** Issues given a pillar or put behind the Later gate. */
4654 labelled: number;
4755 /** Open issues over an hour old that serve no pillar and that triage has yet to file. */
4856 missed: number;
4957 }
5058
51 export const none = (): Triaged => ({ closed: 0, labelled: 0, missed: 0 });
59 export const none = (): Triaged => ({ closed: 0, folded: 0, labelled: 0, missed: 0 });
5260
5361 /** How old an issue is before triage files it. */
5462 export const FILE_AFTER_MS = 60 * 60 * 1000;
@@ -157,11 +165,93 @@ function file(app: App, repo: Repo, by: User, now: Date): [number, number] {
157165 export function tickFields(did: Triaged): Record<string, number> {
158166 return {
159167 triaged: did.closed,
168 folded: did.folded,
160169 labelled: did.labelled,
161170 ...(did.missed === 0 ? {} : { triageMiss: did.missed }),
162171 };
163172 }
164173
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 /**

The \?\s*$ branch is the risky one: it closes real tasks silently. Prefer the three prefixes plus interrogative-leading titles.

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
165255 /**
166256 * Open issues that carry an already-done note, oldest first. One that
167257 * already has a close note was closed once and reopened by a person: that
@@ -200,7 +290,10 @@ export async function liveTriage(
200290 }
201291 const ids = candidates(app, repo);
202292 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 }
204297 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
205298 for (const id of ids) {
206299 const oids = noted.get(id);
@@ -225,5 +318,6 @@ export async function liveTriage(
225318 });
226319 }
227320 }
321 fold(app, repo, by, did);
228322 return did;
229323 }
test/livetriage.test.ts +115 −5
@@ -11,9 +11,10 @@ import { addComment, openIssue, setIssueState } from "../src/actions/issues.ts";
1111 import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts";
1212 import { getIssueById, labelsFor, listComments, type Issue } from "../src/core/issues.ts";
1313 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";
1515 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";
1718
1819 describe("live triage", () => {
1920 let dir: string;
@@ -78,6 +79,7 @@ describe("live triage", () => {
7879
7980 const first = await liveTriage(app, fresh());
8081 expect(first.closed).toBe(1);
82 expect(first.folded).toBe(0);
8183 expect(state(done).state).toBe("closed");
8284 expect(state(done).closeReason).toBe("done");
8385 expect(listComments(app.db, done.id).some((c) => c.body.includes(oid.slice(0, 7)))).toBe(true);
@@ -147,6 +149,113 @@ describe("live triage", () => {
147149 expect((await liveTriage(app, fresh())).closed).toBe(MOST_CHANGES);
148150 expect((await liveTriage(app, fresh())).closed).toBe(3);
149151 });
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 });
150259 });
151260
152261 describe("live triage files unplaced issues", () => {
@@ -342,7 +451,7 @@ describe("live triage where there is little to file under", () => {
342451
343452 test("neither pillars nor gate: nothing is filed, counted or written", async () => {
344453 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 });
346455 for (const issue of w.work) expect(comments(issue)).toBe(0);
347456 });
348457
@@ -357,9 +466,10 @@ describe("live triage where there is little to file under", () => {
357466 });
358467
359468 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({
362471 triaged: 0,
472 folded: 0,
363473 labelled: 0,
364474 triageMiss: 4,
365475 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs