From 463b35bdaf90103be1d7cf3fba86383321c84743 Mon Sep 17 00:00:00 2001 From: istos Date: Sat, 1 Aug 2026 11:21:27 +0200 Subject: [PATCH] A card joins a phase without the file being opened MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phases are meant to arrive whole — members listed, dependencies filled in, readable in a diff before any of it runs. This is the other case: the card you decide belongs after all. ⟶ phase sits on backlog/ and to-do/ cards that are not already in a phase and are not phase cards themselves, and opens a sheet naming the phase cards waiting in to-do/ with what each already holds. Picking one appends `- ` to the end of that phase's ## Cards — the way a person writes it, because the section is authored and read by hand and a machine-shaped line is how a format stops being pleasant. Only to-do/. A phase in in-progress/ is running: its branch exists and its members are being worked in the order the list had when it started, so appending mid-flight is a different feature with different questions. Offer it and someone finds that out the hard way. No phase waiting there and the action is absent rather than present and empty. Nothing else moves. One line into the phase card, nothing at all into the card added — membership runs one direction and joining a phase is not a commitment to start it. The append goes out through append_to_section, the same door the phase log uses, so it commits itself under BOARD_COMMIT_MOVES, reaches the other boards, and reads the phase card off the disk rather than off a render: two boards adding to one phase produce two lines, not a lost one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- AGENTS.md | 23 +- manager/core/board.html | 67 ++++++ manager/core/httpd.py | 11 + manager/core/taskfiles.py | 94 ++++++++ tests/test_add_to_phase.py | 467 +++++++++++++++++++++++++++++++++++++ 5 files changed, 659 insertions(+), 3 deletions(-) create mode 100644 tests/test_add_to_phase.py diff --git a/AGENTS.md b/AGENTS.md index 13bf28d..db30428 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -333,9 +333,10 @@ stacking on top of it) — at most two per state, only things you'd actually do without opening the card: **▸ start work** on in-progress cards (**▸ take over** when someone else holds them), **‖ hold** while an agent runs, **↩ back** on cards waiting on you, **↑ open PR** on review cards whose -branch has none, **↺ reopen** on done cards, and **◔ still true?** -everywhere. Actions that cost tokens or stop work arm on first click and -fire on the second. +branch has none, **↺ reopen** on done cards, **⟶ phase** on unstarted +cards a phase in `to-do/` could take, and **◔ still true?** everywhere. +Actions that cost tokens or stop work arm on first click and fire on the +second. Each launched agent wears a short name for its lifetime (Wren, Juno, Basil, …) — picked per launch, never shared by two running agents, shown as @@ -806,6 +807,22 @@ list says what runs next, a member's dependencies say whether it *may*. The board parses the numbers out of the line and shows them; acting on them belongs to the runner below. +A phase is normally written whole — members listed and each member's +dependencies filled in — before anything reaches the board, which is what +makes it a thing you can read in a diff. For the card you decide belongs +after all there is **⟶ phase**, on `backlog/` and `to-do/` cards that are +not already in a phase and are not phase cards themselves. It opens a +sheet naming the phase cards in `to-do/` and what each already holds, and +picking one appends `- <n> — <title>` — the way a person writes it — to +the end of that phase's `## Cards`. Nothing else moves: the card stays in +its stage, because joining a phase is not a commitment to start it. The +append goes through the board's own write path, so it commits itself under +`BOARD_COMMIT_MOVES` and reaches the other boards; an addition that never +left one working tree is not an addition the phase would run. Only phases +in `to-do/` are offered — one in `in-progress/` is running, its members +being worked in the order the list had when it started — and with no phase +waiting there the action is absent rather than present and empty. + ### A phase runs itself, on a branch of its own Running a phase works its list into a single integration branch. Starting diff --git a/manager/core/board.html b/manager/core/board.html index 51c12a4..a09bda9 100644 --- a/manager/core/board.html +++ b/manager/core/board.html @@ -1096,6 +1096,19 @@ function phaseLabel(phase) { return name.length > 22 ? name.slice(0, 21) + '…' : name; } +/* Which phases this card could join, which is also whether the action is + there at all: the phase cards waiting in to-do/, and none whatsoever for + a card already in a phase, a phase card itself (they do not nest), or a + card with no number for a list to name it by. A phase in in-progress/ is + running — its members are being worked in the order the list had when it + started — so it is not on offer. The board offers what it can do. */ +function joinablePhases(task) { + if (task.phase || task.isPhase || !task.number) return []; + if (!['backlog', 'to-do'].includes(task.stage)) return []; + const stage = (S.state?.board.stages || []).find(s => s.slug === 'to-do'); + return stage ? stage.tasks.filter(t => t.isPhase) : []; +} + function cardFor(task) { const el = document.createElement('article'); const agent = agentOnTask(task.file); @@ -1229,6 +1242,14 @@ function cardFor(task) { } else if (task.stage === 'done') { actions.push({ glyph: '↺', label: 'reopen', busy: 'reopening…', title: 'Put it back in the queue', run: () => move(task.file, 'done', 'to-do') }); + } else if (joinablePhases(task).length) { + // the small path, on the two stages that have room for it: the card + // you decide belongs in a phase after all. It writes one line into + // the phase card and moves nothing, so it neither costs tokens nor + // stops work — the sheet's named choice is the confirmation. + actions.push({ glyph: '⟶', label: 'phase', busy: 'choosing…', + title: "Add this card to the end of a phase's list — the phases waiting in to-do/", + run: () => { phaseSheet(task); return true; } }); } // work in review with no PR: no board opens one behind your back, so // the card offers it instead of the relevance check @@ -1680,6 +1701,52 @@ function completeSheet(task, from) { }); } +/* Joining a phase is a choice between a few named options, so it is put in + front of you the way finishing a card with work on it is — a short list + of phases, each saying what it already holds, rather than a guess. What + it writes is one line at the end of that phase's `## Cards`; the card + itself does not move, and says nothing about the phase it joined. */ +function phaseSheet(task) { + const phases = joinablePhases(task); + if (!phases.length) { toast('no phase is waiting in to-do/', true); return; } + const wrap = $('#sheetwrap'); + wrap.innerHTML = + `<div class="sheet">` + + `<div class="stitle"><span>${esc(task.title)}</span>` + + `<span class="mono">${task.number ? '#' + esc(task.number) + ' · ' : ''}⟶ phase</span></div>` + + `<p>Which phase runs this card? It goes at the end of that phase's list. ` + + `The card stays in ${esc(task.stage)}/ — joining a phase is not a commitment to start it.</p>` + + `<div class="sbtns">` + + phases.map((p, index) => { + const held = (p.cards || []).length; + return `<button data-pick="${index}">${esc(p.title)}` + + `<small>${held ? `holds ${held} card${held === 1 ? '' : 's'} — this one runs after them` + : 'empty so far — this one would be its first card'}</small></button>`; + }).join('') + + `<button id="sh-nophase">Not now<small>Nothing is written.</small></button>` + + `</div></div>`; + wrap.classList.add('open'); + wrap.addEventListener('click', (e) => { if (e.target === wrap) closeSheet(); }); + $('#sh-nophase').addEventListener('click', closeSheet); + wrap.querySelectorAll('[data-pick]').forEach(btn => + btn.addEventListener('click', () => { + closeSheet(); + addToPhase(task, phases[+btn.dataset.pick]); + })); +} + +async function addToPhase(task, phase) { + const res = await fetch('/api/phase/add', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ file: task.file, stage: task.stage, phase: phase.file }), + }); + const data = await res.json(); + toast(res.ok ? `${task.file} added to ${data.phaseName}` + : (data.error || 'that card did not join'), !res.ok); + await loadState(); + return res.ok; +} + function showDetail(task) { const changed = !S.selected || S.selected.file !== task.file; S.selected = task; diff --git a/manager/core/httpd.py b/manager/core/httpd.py index 734e163..0e32c7a 100644 --- a/manager/core/httpd.py +++ b/manager/core/httpd.py @@ -208,6 +208,17 @@ class Handler(BaseHTTPRequestHandler): # ‖ hold on a phase card: the run stops, nothing is unwound self._json(200, {"phase": phases.stop_phase(payload["file"], payload["stage"])}) + elif path == "/api/phase/add": + payload = self._read_body() + # one line into the phase card, nothing into the card added: + # membership lives in one place and joining one is not a move + result = taskfiles.add_to_phase(payload["file"], payload["stage"], + payload["phase"]) + state.record_board_event({ + "kind": "phase", "actor": "you", "file": result["phase"], + "summary": f"{result['phase']} gained {result['entry']}"}) + state.broadcast({"type": "board"}) + self._json(200, result) elif path == "/api/pr/open": payload = self._read_body() self._json(200, {"url": github.open_pr_now(payload["file"])}) diff --git a/manager/core/taskfiles.py b/manager/core/taskfiles.py index cd29c71..35cea24 100644 --- a/manager/core/taskfiles.py +++ b/manager/core/taskfiles.py @@ -39,6 +39,10 @@ CARDS_SECTION_RE = re.compile(r"^##\s+Cards\s*$(.*?)(?=^##\s|\Z)", re.MULTILINE | re.DOTALL) CARD_ITEM_RE = re.compile(r"^(?:[-*+]\s+)?#?0*(\d+)\b") DEPENDS_ITEM_RE = re.compile(r"#?\s*0*(\d+)") +# titles are written `51 — Add a card to a phase`, and a member line names +# the number itself, so the title's own copy of it is dropped — the same +# cut the chip's label makes +LEADING_NUMBER_RE = re.compile(r"^\s*\d+\s*[—–-]\s*") STAGE_ORDER = {slug: index for index, (slug, _) in enumerate(config.STAGES)} CLAIM_FROM = {"backlog", "to-do"} # the unstarted stages: leaving one claims @@ -484,6 +488,96 @@ def append_to_section(filename: str, stage: str, heading: str, line: str, return True +PHASE_JOIN_FROM = {"backlog", "to-do"} # a card joins a phase before it starts +PHASE_HOST = "to-do" # and only a phase still waiting accepts it + + +def _member_entry(number: str, title: str) -> str: + """`33 — The landing page` — the way a person writes it. + + The section is authored by hand and read by hand, so a line the board + adds has to be one you would have typed. Nothing parses what follows + the number, which is exactly why it must stay readable. The bullet is + left off here so the ticker can say the same thing in prose. + """ + rest = LEADING_NUMBER_RE.sub("", title).strip() + return f"{number} — {rest}" if rest else number + + +def _phase_holding(number: str) -> dict | None: + """The phase card that already lists this card, read off the disk. + + Membership runs one direction only, so the answer is only ever found by + looking at every phase card — and it is looked for here rather than + taken from what a tab rendered, because the file may have gained the + card since. + """ + for slug in config.STAGE_DIRS: + directory = config.TASKS / slug + if not directory.is_dir(): + continue + for path in sorted(directory.glob("*.md")): + task = read_task(path, slug) + if task["isPhase"] and number in task["cards"]: + return task + return None + + +def add_to_phase(filename: str, stage: str, phase_file: str) -> dict: + """Append a card to the end of a phase's `## Cards` section. + + The convenience path for the card you decide belongs after all: one + line into the phase card, and nothing at all into the card being added + — membership lives in one place and this does not move it. It goes out + through `append_to_section` like the phase log does, so it commits + under the same gate every other board-made write to a task file does; + an addition that never left one working tree is not an addition the + phase would run. + + Only a phase in `to-do/` takes cards. One in `in-progress/` is + running: its branch exists and its members are being worked in the + order the list had when it started, so appending mid-flight is a + different feature with different questions. + """ + if stage not in PHASE_JOIN_FROM: + raise ValueError("a card joins a phase from backlog/ or to-do/ only") + for name in (filename, phase_file): + if Path(name).name != name or not name.endswith(".md"): + raise ValueError("bad filename") + + card_path = config.TASKS / stage / filename + phase_path = config.TASKS / PHASE_HOST / phase_file + if not card_path.is_file(): + raise ValueError(f"{filename} is no longer in {stage}/ — refresh the board") + if not phase_path.is_file(): + raise ValueError(f"{phase_file} is no longer in {PHASE_HOST}/ — a phase takes " + "cards while it waits, not while it runs") + + card = read_task(card_path, stage) + phase = read_task(phase_path, PHASE_HOST) + if not phase["isPhase"]: + raise ValueError(f"{phase_file} is not a phase card") + if card["isPhase"]: + raise ValueError(f"{filename} is a phase — phases do not nest") + if not card["number"]: + raise ValueError(f"{filename} has no number, and a phase lists its cards by number") + + number = canonical_number(card["number"]) + holder = _phase_holding(number) + if holder is not None: + where = ("this phase already" if holder["file"] == phase_file + else f"phase {_phase_name(holder)} already") + raise ValueError(f"{where} lists {card['number']}") + + entry = _member_entry(card["number"], card["title"]) + if not append_to_section(phase_file, PHASE_HOST, "Cards", f"- {entry}", + f"gained {number}"): + raise ValueError(f"{phase_file} could not be written") + return {"file": filename, "number": card["number"], "title": card["title"], + "phase": phase_file, "phaseName": _phase_name(phase), + "entry": entry, "line": f"- {entry}"} + + def move_task(filename: str, source: str, target: str, actor: str = "you") -> dict: """Move a task file between stage directories and fix its Status line. diff --git a/tests/test_add_to_phase.py b/tests/test_add_to_phase.py new file mode 100644 index 0000000..ec7b24c --- /dev/null +++ b/tests/test_add_to_phase.py @@ -0,0 +1,467 @@ +"""Adding a card to a phase without opening the file (task 51). + +The action writes one line into the phase card and nothing at all into the +card being added — membership lives in one place (48) and joining a phase +moves nothing. So these cases hold three things: what the line looks like +and where it lands, what the board refuses (a running phase, a card +already in one, a phase card), and that the write reaches git the way +every other board-made write to a task file does. + + python3 -m unittest discover -s tests -v +""" + +from __future__ import annotations + +import re +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO / "manager" / "core")) + +import config # noqa: E402 +import state # noqa: E402 +import taskfiles # noqa: E402 + +BOARD = REPO / "manager" / "core" / "board.html" +NODE = shutil.which("node") + + +def card(title: str, *, kind: str | None = None, cards: str | None = None, + status: str = "Backlog") -> str: + """A task file as a person would write it.""" + text = f"# {title}\n\n**Status:** {status}\n**Priority:** Medium\n" + if kind: + text += f"**Type:** {kind}\n" + text += "\nWhat this card is for.\n" + if cards is not None: + text += f"\n## Cards\n\n{cards}" + return text + + +class AddingCase(unittest.TestCase): + """One throwaway tasks/ directory per test, read the way the board reads + it. Nothing here is a git repo — the commit gate has its own case.""" + + def setUp(self): + tmp = Path(tempfile.mkdtemp(prefix="bench-join-")).resolve() + self.addCleanup(shutil.rmtree, tmp, True) + self.tasks = tmp / "tasks" + for slug in config.STAGE_DIRS: + (self.tasks / slug).mkdir(parents=True) + self.patch(TASKS=self.tasks, TM_ROOT=tmp, COMMIT_MOVES=False) + + def patch(self, **values) -> None: + for attr, value in values.items(): + self.addCleanup(setattr, config, attr, getattr(config, attr)) + setattr(config, attr, value) + + def write(self, filename: str, text: str, stage: str = "backlog") -> None: + (self.tasks / stage / filename).write_text(text, encoding="utf-8") + + def read(self, filename: str, stage: str = "to-do") -> str: + return (self.tasks / stage / filename).read_text(encoding="utf-8") + + def board(self) -> dict[str, dict]: + return {task["file"]: task + for stage in taskfiles.collect()["stages"] + for task in stage["tasks"]} + + def phase(self, cards: str | None = None, stage: str = "to-do", + filename: str = "40-the-site.md") -> str: + self.write(filename, card("40 — Ship the site", kind="Phase", cards=cards), + stage) + return filename + + +class TheLineItWrites(AddingCase): + def test_the_card_lands_at_the_end_of_the_list(self): + self.write("51-the-afterthought.md", card("51 — The afterthought")) + self.write("31-stand-up-site.md", card("31 — Stand up site/")) + self.phase(cards="- 31 — Stand up site/\n") + + taskfiles.add_to_phase("51-the-afterthought.md", "backlog", "40-the-site.md") + + self.assertIn("- 31 — Stand up site/\n- 51 — The afterthought\n", + self.read("40-the-site.md")) + + def test_the_line_reads_the_way_a_person_writes_it(self): + """`- 51 — The afterthought`: the number the list is parsed by, and + the title without the copy of the number it opens with.""" + self.write("51-the-afterthought.md", card("51 — The afterthought")) + self.phase(cards="") + + result = taskfiles.add_to_phase("51-the-afterthought.md", "backlog", + "40-the-site.md") + + self.assertEqual(result["line"], "- 51 — The afterthought") + self.assertEqual(result["entry"], "51 — The afterthought", + "the same thing without the bullet, for the ticker") + + def test_a_title_that_does_not_repeat_its_number_is_used_whole(self): + self.write("51-the-afterthought.md", card("The afterthought")) + self.phase(cards="") + + taskfiles.add_to_phase("51-the-afterthought.md", "backlog", "40-the-site.md") + + self.assertIn("- 51 — The afterthought", self.read("40-the-site.md")) + + def test_the_phase_reads_the_new_card_as_its_last_member(self): + """The point of the whole action: the phase's list resolves and the + card wears the member chip 48 gives it.""" + self.write("31-stand-up-site.md", card("31 — Stand up site/")) + self.write("51-the-afterthought.md", card("51 — The afterthought")) + self.phase(cards="- 31 — Stand up site/\n") + + taskfiles.add_to_phase("51-the-afterthought.md", "backlog", "40-the-site.md") + + cards = self.board() + self.assertEqual([m["file"] for m in cards["40-the-site.md"]["members"]], + ["31-stand-up-site.md", "51-the-afterthought.md"]) + self.assertEqual(cards["51-the-afterthought.md"]["phase"]["index"], 2) + self.assertEqual(cards["51-the-afterthought.md"]["phase"]["total"], 2) + self.assertEqual(cards["40-the-site.md"]["phaseDrift"], []) + + def test_a_card_in_to_do_joins_from_where_it_stands(self): + self.write("51-the-afterthought.md", card("51 — The afterthought", + status="To Do"), "to-do") + self.phase(cards="") + + taskfiles.add_to_phase("51-the-afterthought.md", "to-do", "40-the-site.md") + + self.assertEqual(self.board()["51-the-afterthought.md"]["phase"]["index"], 1) + + def test_nothing_else_moves(self): + """Joining a phase is not a commitment to start it: the card stays in + its stage and says nothing at all about the phase that now holds it.""" + original = card("51 — The afterthought") + self.write("51-the-afterthought.md", original) + self.phase(cards="") + + taskfiles.add_to_phase("51-the-afterthought.md", "backlog", "40-the-site.md") + + self.assertEqual(self.read("51-the-afterthought.md", "backlog"), original) + self.assertTrue((self.tasks / "backlog" / "51-the-afterthought.md").is_file()) + + def test_the_rest_of_the_phase_card_is_untouched(self): + self.write("51-the-afterthought.md", card("51 — The afterthought")) + text = card("40 — Ship the site", kind="Phase", cards="- 31 — Stand up site/\n") \ + + "\n## Notes\n\nWhy this phase exists.\n" + self.write("40-the-site.md", text, "to-do") + self.write("31-stand-up-site.md", card("31 — Stand up site/")) + + taskfiles.add_to_phase("51-the-afterthought.md", "backlog", "40-the-site.md") + + after = self.read("40-the-site.md") + self.assertIn("# 40 — Ship the site", after) + self.assertIn("**Type:** Phase", after) + self.assertIn("## Notes\n\nWhy this phase exists.\n", after) + self.assertIn("- 31 — Stand up site/\n- 51 — The afterthought\n", after) + self.assertEqual(self.board()["40-the-site.md"]["phaseDrift"], []) + + def test_a_phase_with_no_cards_section_gains_one(self): + """Not appended to the end of the file as loose prose: the section + is what the list is, so it is created.""" + self.write("51-the-afterthought.md", card("51 — The afterthought")) + self.phase() # written with no ## Cards section at all + + taskfiles.add_to_phase("51-the-afterthought.md", "backlog", "40-the-site.md") + + after = self.read("40-the-site.md") + self.assertIn("## Cards\n\n- 51 — The afterthought", after) + self.assertEqual([m["number"] for m in self.board()["40-the-site.md"]["members"]], + ["51"]) + + def test_the_second_add_reads_the_file_as_it_is_on_disk(self): + """Two boards adding to one phase produce two lines, not a lost one: + the append re-reads the card rather than trusting a render.""" + self.write("51-the-afterthought.md", card("51 — The afterthought")) + self.write("52-the-other-one.md", card("52 — The other one")) + self.phase(cards="") + stale = self.read("40-the-site.md") + + taskfiles.add_to_phase("51-the-afterthought.md", "backlog", "40-the-site.md") + # the other board wrote in between; this one never saw it + self.assertNotIn("51", stale) + taskfiles.add_to_phase("52-the-other-one.md", "backlog", "40-the-site.md") + + self.assertEqual([m["number"] for m in self.board()["40-the-site.md"]["members"]], + ["51", "52"]) + + +class WhatItRefuses(AddingCase): + """Each refusal is a state the action is not offered in either — the + server is the backstop behind a card face that went stale.""" + + def setUp(self): + super().setUp() + self.write("51-the-afterthought.md", card("51 — The afterthought")) + + def add(self, phase_file: str = "40-the-site.md", stage: str = "backlog") -> str: + with self.assertRaises(ValueError) as caught: + taskfiles.add_to_phase("51-the-afterthought.md", stage, phase_file) + return str(caught.exception) + + def test_a_phase_that_is_running_takes_nothing(self): + """in-progress/ means the branch exists and the members are being + worked in the order the list had when it started.""" + self.phase(cards="", stage="in-progress") + + self.assertIn("to-do", self.add()) + + def test_a_phase_in_review_or_done_takes_nothing(self): + for stage in ("review", "done"): + with self.subTest(stage=stage): + self.phase(cards="", stage=stage, filename=f"4{stage[0]}-late.md") + self.assertIn("to-do", self.add(f"4{stage[0]}-late.md")) + + def test_a_card_that_is_not_a_phase_takes_nothing(self): + self.write("40-ordinary.md", card("40 — An ordinary card", kind="Feature", + cards=""), "to-do") + + self.assertIn("not a phase", self.add("40-ordinary.md")) + + def test_a_card_already_in_this_phase_is_not_listed_twice(self): + self.phase(cards="- 51 — The afterthought\n") + + self.assertIn("already", self.add()) + + def test_a_card_another_phase_holds_stays_where_it_is(self): + self.write("41-the-docs.md", card("41 — Ship the docs", kind="Phase", + cards="- 51 — The afterthought\n"), "to-do") + self.phase(cards="") + + message = self.add() + + self.assertIn("41", message) + self.assertNotIn("51", self.read("40-the-site.md")) + + def test_a_phase_does_not_join_a_phase(self): + self.write("41-the-docs.md", card("41 — Ship the docs", kind="Phase", + cards=""), "backlog") + self.phase(cards="") + + with self.assertRaises(ValueError) as caught: + taskfiles.add_to_phase("41-the-docs.md", "backlog", "40-the-site.md") + + self.assertIn("nest", str(caught.exception)) + + def test_a_card_at_work_does_not_join(self): + """The action lives on the two unstarted stages; the stages past + them are refused rather than quietly accepted.""" + self.phase(cards="") + for stage in ("in-progress", "review", "done"): + with self.subTest(stage=stage): + self.write("51-the-afterthought.md", card("51 — The afterthought"), + stage) + self.assertIn("backlog", self.add(stage=stage)) + + def test_a_card_with_no_number_says_so(self): + self.write("a-nameless-card.md", card("A nameless card")) + self.phase(cards="") + + with self.assertRaises(ValueError) as caught: + taskfiles.add_to_phase("a-nameless-card.md", "backlog", "40-the-site.md") + + self.assertIn("number", str(caught.exception)) + + def test_a_card_that_has_moved_since_the_page_rendered(self): + self.phase(cards="") + + with self.assertRaises(ValueError) as caught: + taskfiles.add_to_phase("51-the-afterthought.md", "to-do", "40-the-site.md") + + self.assertIn("refresh the board", str(caught.exception)) + + def test_a_path_is_not_a_filename(self): + self.phase(cards="") + + for bad in ("../../etc/passwd.md", "to-do/40-the-site.md"): + with self.subTest(name=bad): + self.assertIn("bad filename", self.add(bad)) + + +def git(cwd: Path, *args: str) -> subprocess.CompletedProcess: + return subprocess.run(["git", "-C", str(cwd), *args], + capture_output=True, text=True) + + +class TheCommitGate(unittest.TestCase): + """An addition that never leaves one working tree is not an addition the + phase will run — so it commits like every other board-made write, and + with the gate off it does not commit at all.""" + + NAME = "Mover One" + + def setUp(self): + # resolve(): macOS tempdirs sit behind /var → /private/var and git + # reports the resolved path, so absolute pathspecs must resolve too. + tmp = Path(tempfile.mkdtemp(prefix="bench-join-git-")).resolve() + self.addCleanup(shutil.rmtree, tmp, True) + self.repo = tmp / "repo" + for slug in config.STAGE_DIRS: + (self.repo / "tasks" / slug).mkdir(parents=True) + subprocess.run(["git", "init", "-q", "-b", "main", str(self.repo)], + check=True, capture_output=True) + git(self.repo, "config", "user.name", self.NAME) + git(self.repo, "config", "user.email", "mover@example.com") + + for attr, value in {"TASKS": self.repo / "tasks", "TM_ROOT": self.repo, + "REPO": self.repo, "SESSIONS_DIR": tmp / "sessions", + "COMMIT_MOVES": True}.items(): + self.addCleanup(setattr, config, attr, getattr(config, attr)) + setattr(config, attr, value) + state.BOARD_EVENTS.clear() + + (self.repo / "tasks" / "backlog" / "51-the-afterthought.md").write_text( + card("51 — The afterthought"), encoding="utf-8") + (self.repo / "tasks" / "to-do" / "40-the-site.md").write_text( + card("40 — Ship the site", kind="Phase", status="To Do", + cards="- 31 — Stand up site/\n"), encoding="utf-8") + git(self.repo, "add", "-A") + git(self.repo, "commit", "-q", "-m", "root") + self.baseline = self.commits() + + def commits(self) -> int: + return int(git(self.repo, "rev-list", "--count", "HEAD").stdout.strip()) + + def head_message(self) -> str: + return git(self.repo, "log", "-1", "--pretty=%s").stdout.strip() + + def head_files(self) -> list[str]: + out = git(self.repo, "show", "--name-only", "--pretty=format:", "HEAD").stdout + return sorted(line for line in out.splitlines() if line.strip()) + + def add(self) -> dict: + return taskfiles.add_to_phase("51-the-afterthought.md", "backlog", + "40-the-site.md") + + def test_the_append_commits_itself_messaged_like_the_bookkeeping_it_is(self): + self.add() + + self.assertEqual(self.commits(), self.baseline + 1) + self.assertEqual(self.head_message(), f"board: 40 gained 51 ({self.NAME})") + self.assertEqual(self.head_files(), ["tasks/to-do/40-the-site.md"], + "the phase card and nothing else") + self.assertEqual(git(self.repo, "status", "--porcelain").stdout, "", + "nothing is left behind for a human to find later") + + def test_the_commit_is_published_like_every_other_one(self): + published: list[str] = [] + state.COMMIT_HOOKS.append(published.append) + self.addCleanup(state.COMMIT_HOOKS.remove, published.append) + + self.add() + + self.assertEqual(published, ["40-the-site.md"], + "sync publishes the card that changed") + + def test_with_the_gate_off_the_file_is_simply_edited(self): + config.COMMIT_MOVES = False + + self.add() + + self.assertEqual(self.commits(), self.baseline) + self.assertIn("- 51 — The afterthought", + (self.repo / "tasks" / "to-do" / "40-the-site.md") + .read_text(encoding="utf-8")) + self.assertIn("40-the-site.md", + git(self.repo, "status", "--porcelain").stdout) + + +class TheActionOnTheCard(unittest.TestCase): + """board.html has no test runner, so these are source-level invariants: + the action is offered exactly where it can do something, and picking a + phase goes through the sheet the board already uses for a choice.""" + + @classmethod + def setUpClass(cls): + cls.html = BOARD.read_text(encoding="utf-8") + + def offer(self) -> str: + match = re.search(r"function joinablePhases\(task\) \{.*?\n\}", self.html, re.S) + self.assertIsNotNone(match, "board.html lost joinablePhases()") + return match.group(0) + + def sheet(self) -> str: + match = re.search(r"function phaseSheet\(task\) \{.*?\n\}", self.html, re.S) + self.assertIsNotNone(match, "board.html lost phaseSheet()") + return match.group(0) + + def test_only_phases_waiting_in_to_do_are_offered(self): + offer = self.offer() + self.assertIn("'to-do'", offer) + self.assertIn("t.isPhase", offer) + self.assertNotIn("in-progress", offer, + "a running phase is not on the list") + + def test_a_card_already_in_a_phase_offers_nothing(self): + self.assertIn("task.phase ||", self.offer()) + + def test_a_phase_card_does_not_offer_to_join_one(self): + self.assertIn("task.isPhase", self.offer()) + + def test_the_action_is_absent_when_there_is_no_phase_to_join(self): + """Present and empty is the thing this must not be.""" + self.assertIn("} else if (joinablePhases(task).length) {", self.html) + + def test_the_action_wears_the_phase_glyph_and_opens_the_sheet(self): + block = re.search(r"joinablePhases\(task\)\.length\) \{.*?\n \}", + self.html, re.S) + self.assertIsNotNone(block, "the action's own block is gone") + self.assertIn("⟶", block.group(0)) + self.assertIn("phaseSheet(task)", block.group(0)) + + def test_the_sheet_names_each_phase_and_what_it_holds(self): + sheet = self.sheet() + self.assertIn("p.cards", sheet, "each option says how many cards it holds") + self.assertIn("esc(p.title)", sheet) + self.assertIn("sheet", sheet) + + def test_the_sheet_can_be_left_without_writing_anything(self): + self.assertIn("sh-nophase", self.sheet()) + self.assertIn("closeSheet", self.sheet()) + + def test_picking_a_phase_posts_it_and_reloads_the_board(self): + match = re.search(r"async function addToPhase\(task, phase\) \{.*?\n\}", + self.html, re.S) + self.assertIsNotNone(match, "board.html lost addToPhase()") + post = match.group(0) + self.assertIn("'/api/phase/add'", post) + self.assertIn("phase: phase.file", post) + self.assertIn("stage: task.stage", post) + self.assertIn("loadState()", post) + self.assertIn("data.error", post, "a refusal has to reach the toast") + + @unittest.skipUnless(NODE, "node is needed to parse the page") + def test_the_page_still_parses(self): + with tempfile.TemporaryDirectory() as tmp: + for index, script in enumerate( + re.findall(r"<script[^>]*>(.*?)</script>", self.html, re.S)): + source = Path(tmp) / f"page-{index}.js" + source.write_text(script, encoding="utf-8") + out = subprocess.run([NODE, "--check", str(source)], + capture_output=True, text=True) + self.assertEqual(out.returncode, 0, out.stderr) + + +class TheRouteBehindIt(unittest.TestCase): + """The one place the action reaches the write path.""" + + def test_the_route_narrates_what_it_wrote(self): + source = (REPO / "manager" / "core" / "httpd.py").read_text(encoding="utf-8") + match = re.search(r'elif path == "/api/phase/add":.*?self\._json\(200, result\)', + source, re.S) + self.assertIsNotNone(match, "httpd.py has no /api/phase/add route") + route = match.group(0) + self.assertIn("taskfiles.add_to_phase", route) + self.assertIn("record_board_event", route) + self.assertIn('"kind": "phase"', route) + self.assertIn("result['entry']", route, + "the ticker says what landed, in the words the file got") + self.assertIn('state.broadcast({"type": "board"})', route, + "every open tab has to see the phase's new member")