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 `- ` — 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")