Public: anyone can read this; the crew can change it.
The planner can close an issue as split when every issue it was split into has already closed as done #2518
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2515-the-planner-can-close-an-issue-as-split → main 2 commits fork
3 files
changed +74
−14
README.md +2 −1
| @@ -733,7 +733,8 @@ reads and writes that repository and administers nothing. | ||
| 733 | 733 | A **planner** on the crew may also close an issue it is re-planning, in |
| 734 | 734 | `actions/issues.ts` so the web, the API and the MCP agree, and only these |
| 735 | 735 | ways: `split` into open issues opened since it was handed the issue (since the |
| 736 | issue itself when the crew never parked it twice), `dropped` for an issue the | |
| 736 | issue itself when the crew never parked it twice, or into issues of the same | |
| 737 | repository that all closed as done since that hand-over), `dropped` for an issue the | |
| 737 | 738 | crew wrote, and `done` when commits on main already name the issue. A person's |
| 738 | 739 | ask is never dropped by the planner; any other close, any reopen and any edit |
| 739 | 740 | to a person's issue stay refused. |
src/actions/issues.ts +26 −12
| @@ -488,7 +488,9 @@ export function setIssueState( | ||
| 488 | 488 | * |
| 489 | 489 | * - split, only into what replaces it — every issue named must be an open |
| 490 | 490 | * issue made since the hand-over, not the issue itself and not the Later |
| 491 | * gate, so a closed or an old issue cannot stand in for a drop; | |
| 491 | * gate, so a closed or an old issue cannot stand in for a drop. Or, once | |
| 492 | * every piece has landed, into issues of this repository that all closed as | |
| 493 | * done since the hand-over, so old unrelated work cannot stand in either; | |
| 492 | 494 | * - dropped, only when an agent wrote the issue. A person's ask is dropped |
| 493 | 495 | * by a person. |
| 494 | 496 | * |
| @@ -521,17 +523,29 @@ function handedToPlanner( | ||
| 521 | 523 | const into = closing.into ?? []; |
| 522 | 524 | if (into.length === 0) return false; |
| 523 | 525 | const gate = laterGate(app.db, repo.id); |
| 524 | return into.every((number) => { | |
| 525 | const part = getIssue(app.db, repo.id, number); | |
| 526 | return ( | |
| 527 | part !== undefined && | |
| 528 | part.kind === "issue" && | |
| 529 | part.state === "open" && | |
| 530 | part.id !== issue.id && | |
| 531 | part.number !== gate && | |
| 532 | part.createdAt > since | |
| 533 | ); | |
| 534 | }); | |
| 526 | const found = into.map((number) => getIssue(app.db, repo.id, number)); | |
| 527 | const parts: Issue[] = []; | |
| 528 | for (const part of found) { | |
| 529 | if ( | |
| 530 | part === undefined || | |
| 531 | part.kind !== "issue" || | |
| 532 | part.id === issue.id || | |
| 533 | part.number === gate | |
| 534 | ) | |
| 535 | return false; | |
| 536 | parts.push(part); | |
| 537 | } | |
| 538 | // Every piece landed since: closed as done at or after the hand-over. | |
| 539 | if ( | |
| 540 | parts.every( | |
| 541 | (part) => | |
| 542 | part.state === "closed" && | |
| 543 | part.closeReason === "done" && | |
| 544 | (part.closedAt ?? "") >= since, | |
| 545 | ) | |
| 546 | ) | |
| 547 | return true; | |
| 548 | return parts.every((part) => part.state === "open" && part.createdAt > since); | |
| 535 | 549 | } |
| 536 | 550 | |
| 537 | 551 | /** How an issue closes: done unless said otherwise; split names its children. */ |
test/crew-parks.test.ts +46 −1
| @@ -769,7 +769,7 @@ describe("an issue parked twice is the planner's", () => { | ||
| 769 | 769 | "Later gate: nothing behind this starts until the owner opens it", |
| 770 | 770 | ); |
| 771 | 771 | const gone = e.issue("Particles, the old plan"); |
| 772 | expect(setIssueState(app, alice, e.fresh(), gone, "closed").ok).toBe(true); | |
| 772 | expect(setIssueState(app, alice, e.fresh(), gone, "closed", { reason: "dropped" }).ok).toBe(true); | |
| 773 | 773 | // An issue the crew wrote itself, parked twice like the person's. |
| 774 | 774 | const made = openIssue(app, mason, e.fresh(), { |
| 775 | 775 | title: "Tidy the particle pool", |
| @@ -1478,6 +1478,51 @@ describe("an issue found already done is the asker's to close, not a park", () = | ||
| 1478 | 1478 | expect(own.ok ? "closed" : own.error).toBe("forbidden"); |
| 1479 | 1479 | }); |
| 1480 | 1480 | |
| 1481 | test("the planner closes an umbrella as split into issues that all closed as done since, and no other", async () => { | |
| 1482 | const { atlas } = e; | |
| 1483 | const state = (on: Issue) => getIssueById(e.app.db, on.id)!; | |
| 1484 | const close = (on: Issue, into: number[]) => { | |
| 1485 | const closed = setIssueState(e.app, atlas, e.fresh(), state(on), "closed", { | |
| 1486 | reason: "split", | |
| 1487 | into, | |
| 1488 | }); | |
| 1489 | return closed.ok ? "closed" : closed.error; | |
| 1490 | }; | |
| 1491 | const finish = (on: Issue, reason: "done" | "dropped") => { | |
| 1492 | const r = setIssueState(e.app, e.alice, e.fresh(), state(on), "closed", { reason }); | |
| 1493 | if (!r.ok) throw new Error(String(r.error)); | |
| 1494 | }; | |
| 1495 | // Old, unrelated work that closed as done before the umbrella existed. | |
| 1496 | const old = e.issue("Old unrelated work"); | |
| 1497 | finish(old, "done"); | |
| 1498 | await Bun.sleep(5); | |
| 1499 | const umbrella = e.issue("Umbrella"); | |
| 1500 | await Bun.sleep(5); | |
| 1501 | const a = e.issue("Piece A"); | |
| 1502 | const b = e.issue("Piece B"); | |
| 1503 | const dropped = e.issue("Piece dropped"); | |
| 1504 | const open = e.issue("Piece still open, older than nothing"); | |
| 1505 | finish(a, "done"); | |
| 1506 | finish(dropped, "dropped"); | |
| 1507 | // Refused: an old done issue is no piece of it, alone or beside a real one. | |
| 1508 | expect(close(umbrella, [old.number])).toBe("forbidden"); | |
| 1509 | finish(b, "done"); | |
| 1510 | expect(close(umbrella, [a.number, old.number])).toBe("forbidden"); | |
| 1511 | // Refused: itself, a dropped piece, an open one, a done beside a fresh open. | |
| 1512 | expect(close(umbrella, [a.number, umbrella.number])).toBe("forbidden"); | |
| 1513 | expect(close(umbrella, [a.number, dropped.number])).toBe("forbidden"); | |
| 1514 | expect(close(umbrella, [a.number, open.number])).toBe("forbidden"); | |
| 1515 | await Bun.sleep(5); | |
| 1516 | const fresh = e.issue("Fresh piece, open"); | |
| 1517 | expect(close(umbrella, [a.number, fresh.number])).toBe("forbidden"); | |
| 1518 | // Refused: a number that is no issue here (another repository's is none of this one's). | |
| 1519 | expect(close(umbrella, [a.number, 99999])).toBe("forbidden"); | |
| 1520 | expect(state(umbrella).state).toBe("open"); | |
| 1521 | // Allowed: every piece closed as done since. | |
| 1522 | expect(close(umbrella, [a.number, b.number])).toBe("closed"); | |
| 1523 | expect(state(umbrella).closeReason).toBe("split"); | |
| 1524 | }); | |
| 1525 | ||
| 1481 | 1526 | test("another person's comment that reads like the note is that person's word", async () => { |
| 1482 | 1527 | const carol = await createUser(e.app.db, { |
| 1483 | 1528 | origin: "seed", |
new above
offline