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

Merged opened this

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

Merged

scout/forge:scout/2515-the-planner-can-close-an-issue-as-split → main 2 commits fork

  • Merged. scout/2515-the-planner-can-close-an-issue-as-split was merged into main as 69559f0.

Review

Sign in to review this change.
3 files changed +74 −14
README.md +2 −1
@@ -733,7 +733,8 @@ reads and writes that repository and administers nothing.
733733 A **planner** on the crew may also close an issue it is re-planning, in
734734 `actions/issues.ts` so the web, the API and the MCP agree, and only these
735735 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
737738 crew wrote, and `done` when commits on main already name the issue. A person's
738739 ask is never dropped by the planner; any other close, any reopen and any edit
739740 to a person's issue stay refused.
src/actions/issues.ts +26 −12
@@ -488,7 +488,9 @@ export function setIssueState(
488488 *
489489 * - split, only into what replaces it — every issue named must be an open
490490 * 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;
492494 * - dropped, only when an agent wrote the issue. A person's ask is dropped
493495 * by a person.
494496 *
@@ -521,17 +523,29 @@ function handedToPlanner(
521523 const into = closing.into ?? [];
522524 if (into.length === 0) return false;
523525 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);
535549 }
536550
537551 /** 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", () => {
769769 "Later gate: nothing behind this starts until the owner opens it",
770770 );
771771 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);
773773 // An issue the crew wrote itself, parked twice like the person's.
774774 const made = openIssue(app, mason, e.fresh(), {
775775 title: "Tidy the particle pool",
@@ -1478,6 +1478,51 @@ describe("an issue found already done is the asker's to close, not a park", () =
14781478 expect(own.ok ? "closed" : own.error).toBe("forbidden");
14791479 });
14801480
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
14811526 test("another person's comment that reads like the note is that person's word", async () => {
14821527 const carol = await createUser(e.app.db, {
14831528 origin: "seed",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs