From eaa0614bec4301bd87b4da66dbbad3cef8497172 Mon Sep 17 00:00:00 2001 From: Marcus Gibson Date: Sun, 11 Oct 2026 14:37:29 -0500 Subject: [PATCH 01/19] tests: expect the Playbooks section to stop, a missing file from check.py, and the drift review line --- tests/test_check.py | 24 ++++++++++ tests/test_pstack_t3.py | 87 ++++++++++++++++++++++++++++++++++--- tests/test_sync_upstream.py | 4 +- 3 files changed, 108 insertions(+), 7 deletions(-) diff --git a/tests/test_check.py b/tests/test_check.py index ac63dab2..2e1019d4 100644 --- a/tests/test_check.py +++ b/tests/test_check.py @@ -218,6 +218,30 @@ def test_other_targets_still_must_resolve(self): ) +class PlaybookBulletCheckTest(unittest.TestCase): + def test_a_demo_skill_has_no_playbook_finding(self): + with tempfile.TemporaryDirectory() as directory: + skill = Path(directory) / "demo" + skill.mkdir() + (skill / "SKILL.md").write_text("---\nname: demo\ndescription: d\n---\n\nbody\n") + self.assertEqual(check.check_tree(directory), []) + + def test_the_command_reports_a_missing_playbook_file(self): + with tempfile.TemporaryDirectory() as directory: + skills = Path(directory) / "skills" + skill = skills / "poteto-mode" + skill.mkdir(parents=True) + (skill / "SKILL.md").write_text("---\nname: poteto-mode\ndescription: d\n---\n\n## Playbooks\n- **No such playbook.** Nothing.\n") + tests = tests_tree(directory, {}) + output = io.StringIO() + with mock.patch.object(check, "TESTS_DIR", tests), contextlib.redirect_stdout(output): + status = check.main([str(skills)]) + self.assertEqual( + (status, output.getvalue()), + (1, 'poteto-mode/SKILL.md: playbook "No such playbook" resolves to playbooks/no-such-playbook.md, which does not exist\n'), + ) + + class FrontmatterWithoutPyYamlTest(unittest.TestCase): def test_without_pyyaml_a_malformed_flow_sequence_is_not_reported(self): with tempfile.TemporaryDirectory() as directory: diff --git a/tests/test_pstack_t3.py b/tests/test_pstack_t3.py index f9cd9685..90c50113 100644 --- a/tests/test_pstack_t3.py +++ b/tests/test_pstack_t3.py @@ -1220,6 +1220,87 @@ def test_every_built_playbook_bullet_resolves_to_an_existing_file(self): self.assertTrue((ROOT / "skills/poteto-mode" / file).is_file(), title) self.assertEqual(catalog.missing_playbooks(ROOT / "skills"), []) + def test_vendor_playbook_entries_match_playbook_files(self): + import catalog + resolved = {title: file for title, file, _ in catalog.playbook_entries(ROOT / "vendor/pstack/skills")} + self.assertEqual(resolved, PLAYBOOK_FILES) + self.assertEqual(catalog.missing_playbooks(ROOT / "vendor/pstack/skills"), []) + + def test_a_skill_with_no_playbooks_heading_returns_nothing(self): + import catalog + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + skill = root / "poteto-mode" + skill.mkdir() + (skill / "SKILL.md").write_text("---\nname: poteto-mode\ndescription: d\n---\n\n# Poteto mode\n") + self.assertEqual(catalog.playbook_entries(root), []) + self.assertEqual(catalog.missing_playbooks(root), []) + + def test_playbooks_extra_does_not_open_the_section(self): + import catalog + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + skill = root / "poteto-mode" + skill.mkdir() + (skill / "SKILL.md").write_text("---\nname: poteto-mode\ndescription: d\n---\n\n## Playbooks extra\n- **No such playbook** Nothing.\n") + self.assertEqual(catalog.playbook_entries(root), []) + self.assertEqual(catalog.missing_playbooks(root), []) + + def test_the_playbooks_section_keeps_catalog_bullets_and_reports_the_other_list_markers(self): + import catalog + front = "---\nname: poteto-mode\ndescription: d\n---\n" + body = [ + "## Before", + "- **Ignored.** Because this is before Playbooks.", + "## Playbooks", + "This is prose.", + "- **No such playbook** Nothing.", + "* **Star.** Nothing.", + " - **Indented.** Nothing.", + "- **Empty.**", + "+ **Plus.** Nothing.", + "- **Bug fix.** File: `playbooks/bug-fix.md`.", + "- **No such playbook.** Nothing.", + "-not a bullet", + "1. **Ordered.** Nothing.", + "### Nested", + "- **Nested title.** File: `playbooks/bug-fix.md`.", + "## Later", + "- **No such playbook.** Nothing.", + "## Playbooks", + "- **Second.** Nothing.", + ] + text = front + "\n".join(body) + "\n" + lines = text.splitlines() + bad = [ + "- **No such playbook** Nothing.", + "* **Star.** Nothing.", + " - **Indented.** Nothing.", + "- **Empty.**", + "+ **Plus.** Nothing.", + ] + expected = [ + f'poteto-mode/SKILL.md:{lines.index(bullet) + 1}: playbook bullet does not match "- **Title.** summary": {bullet.strip()[:120]}' + for bullet in bad + ] + expected.append('poteto-mode/SKILL.md: playbook "No such playbook" resolves to playbooks/no-such-playbook.md, which does not exist') + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + skill = root / "poteto-mode" + (skill / "playbooks").mkdir(parents=True) + (skill / "SKILL.md").write_text(text) + (skill / "playbooks/bug-fix.md").write_text("ok\n") + self.assertEqual( + catalog.playbook_entries(root), + [ + ("Bug fix", "playbooks/bug-fix.md", ""), + ("No such playbook", "playbooks/no-such-playbook.md", "Nothing."), + ("Nested title", "playbooks/bug-fix.md", ""), + ], + ) + self.assertEqual(catalog.missing_playbooks(root), expected) + self.assertEqual(check.check_tree(root), expected) + def test_build_fails_before_the_swap_on_a_playbook_bullet_whose_file_does_not_exist(self): import build from unittest import mock @@ -1233,12 +1314,8 @@ def render_with_a_stray_bullet(destination): with tempfile.TemporaryDirectory() as directory: out = Path(directory) / "skills" with mock.patch.object(build, "render", render_with_a_stray_bullet): - with self.assertRaises(SystemExit) as failure: + with self.assertRaises(SystemExit): build.build(out, skip_lock=True) - self.assertIn( - 'poteto-mode/SKILL.md: playbook "No such playbook" resolves to playbooks/no-such-playbook.md, which does not exist', - str(failure.exception), - ) self.assertFalse(out.exists()) def test_check_flags_cursor_leftovers(self): diff --git a/tests/test_sync_upstream.py b/tests/test_sync_upstream.py index f1b7ec48..fb5b2127 100644 --- a/tests/test_sync_upstream.py +++ b/tests/test_sync_upstream.py @@ -528,7 +528,7 @@ def test_a_clean_merge_writes_the_merged_override_and_leaves_the_lock(self): [ "clean t3/overrides/alpha/SKILL.md merged", "override drift:", - " alpha/SKILL.md: upstream changed since this override was written; re-port it, then --update-lock", + " alpha/SKILL.md: upstream changed since this override was written; review or re-port it, then --update-lock", "", *FOOTER, ], @@ -922,7 +922,7 @@ def test_conflicts_with_no_conflict_prints_what_a_run_without_it_prints_and_crea [ "clean t3/overrides/alpha/SKILL.md merged", "override drift:", - " alpha/SKILL.md: upstream changed since this override was written; re-port it, then --update-lock", + " alpha/SKILL.md: upstream changed since this override was written; review or re-port it, then --update-lock", "", *FOOTER, ], From 7ed7c955ab80d8211fbe2f50b6cc1fe19681e8cb Mon Sep 17 00:00:00 2001 From: Marcus Gibson Date: Sun, 11 Oct 2026 14:38:16 -0500 Subject: [PATCH 02/19] check: bound Playbooks, report a bad bullet, and name drift as review or re-port --- AGENTS.md | 4 ++-- changes/pstack-t3%2Fd169.md | 1 + scripts/build.py | 6 ++--- scripts/catalog.py | 45 ++++++++++++++++++++++++++++--------- scripts/check.py | 15 +++++++++++++ 5 files changed, 55 insertions(+), 16 deletions(-) create mode 100644 changes/pstack-t3%2Fd169.md diff --git a/AGENTS.md b/AGENTS.md index af46bcb0..0b487065 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,10 +35,10 @@ Start from the upstream file and change only what T3 changes. Upstream's rules, - After opening or driving a PR, add the `link_pull_request` step. 3. Link the runtime by relative path (`../pstack-runtime/SKILL.md` from a SKILL.md, `../../pstack-runtime/SKILL.md` from a playbook) when the step needs it. Do not paste runtime content. 4. Keep tool names unprefixed (`delegate_task`, not `mcp__t3-code__delegate_task`). -5. Run `python3 scripts/build.py`. The build fails on Cursor leftovers (see `scripts/check.py`), broken links, bad frontmatter, or override drift. +5. Run `python3 scripts/build.py`. The build fails on Cursor leftovers (see `scripts/check.py`), broken links, bad frontmatter, a poteto-mode playbook bullet whose file does not exist, or override drift. 6. Run `python3 scripts/build.py --update-lock` only after reviewing that the override matches the current upstream file. -`python3 scripts/sync_upstream.py --merge` syncs and, for each override whose upstream file changed, either writes a clean three-way merge of it or leaves the override as it is and names it. A clean merge can carry a new Cursor mechanism in upstream's lines, so a merged override still needs the review in step 6 and the build's Cursor-leftover check, which `python3 scripts/build.py --skip-lock` reaches while the lock is stale. +`python3 scripts/sync_upstream.py --merge` syncs and, for each override whose upstream file changed, either writes a clean three-way merge of it or leaves the override as it is and names it. Add `--conflicts ` to also write git's conflict-marked merge of each override whose merge conflicts under ``, a path outside the checkout that does not exist yet. A clean merge can carry a new Cursor mechanism in upstream's lines, so a merged override still needs the review in step 6 and the build's Cursor-leftover check, which `python3 scripts/build.py --skip-lock` reaches while the lock is stale. ## Writing style diff --git a/changes/pstack-t3%2Fd169.md b/changes/pstack-t3%2Fd169.md new file mode 100644 index 00000000..3d4e5abd --- /dev/null +++ b/changes/pstack-t3%2Fd169.md @@ -0,0 +1 @@ +- `scripts/build.py` reports override drift as `: upstream changed since this override was written; review or re-port it, then --update-lock`. `scripts/check.py` reports a missing poteto-mode playbook file when `poteto-mode/SKILL.md` is a file, using `poteto-mode/SKILL.md: playbook "" resolves to <file>, which does not exist`. `scripts/build.py` still fails on that finding before it swaps the tree, and it no longer calls `missing_playbooks` itself. `scripts/catalog.py` reads only the Playbooks section, from the line after the level-two heading `Playbooks` until the next level-two heading. A line in that section that starts with `- `, `* `, or `+ ` after leading whitespace, and does not match `- **Title.** summary`, is a finding `poteto-mode/SKILL.md:<line>: playbook bullet does not match "- **Title.** summary": <line text>`. A line that matches `- **Title.** summary` still resolves to the backticked `playbooks/<name>.md` at the end of the bullet, with or without a `File: ` lead-in, or to `playbooks/<kebab title>.md`. `AGENTS.md` step 5 names a poteto-mode playbook bullet whose file does not exist. The `--merge` paragraph names `--conflicts <dir>`. diff --git a/scripts/build.py b/scripts/build.py index ec3cf84c..3530012d 100644 --- a/scripts/build.py +++ b/scripts/build.py @@ -81,7 +81,7 @@ def check_lock(update): if rel not in locked: problems.append(f"{rel}: override not in overrides.lock.json (run build.py --update-lock after reviewing)") elif locked[rel] != digest: - problems.append(f"{rel}: upstream changed since this override was written; re-port it, then --update-lock") + problems.append(f"{rel}: upstream changed since this override was written; review or re-port it, then --update-lock") for rel in locked: if rel not in current: problems.append(f"{rel}: locked but no override exists (run --update-lock)") @@ -125,9 +125,9 @@ def build(destination, update_lock=False, skip_lock=False): render(staged) sys.path.insert(0, str(ROOT / "scripts")) from check import check_tree - from catalog import missing_playbooks, write + from catalog import write - findings = check_tree(staged) + missing_playbooks(staged) + findings = check_tree(staged) if findings: raise SystemExit("check failed:\n " + "\n ".join(findings)) replace_tree(staged, destination) diff --git a/scripts/catalog.py b/scripts/catalog.py index 0e39bd49..fe36d1dd 100644 --- a/scripts/catalog.py +++ b/scripts/catalog.py @@ -31,20 +31,41 @@ def kebab(title): return re.sub(r"[^a-z0-9 -]", "", title.lower()).replace(" ", "-") +def _scan_playbooks(text): + entries = [] + unparsed = [] + in_section = False + for number, line in enumerate(text.splitlines(), 1): + if line.startswith("## ") and not line.startswith("###"): + if in_section: + break + if line[3:].strip() == "Playbooks": + in_section = True + continue + if not in_section: + continue + match = BULLET.fullmatch(line) + if match: + title, summary = match.group(1), match.group(2) + given = GIVEN_FILE.search(summary) + file = given.group(1) if given else f"playbooks/{kebab(title)}.md" + entries.append((title, file, GIVEN_FILE.sub("", summary).strip())) + continue + if line.lstrip().startswith(("- ", "* ", "+ ")): + unparsed.append( + f'poteto-mode/SKILL.md:{number}: playbook bullet does not match "- **Title.** summary": {line.strip()[:120]}' + ) + return entries, unparsed + + def playbook_entries(skills): - """Return one (title, file, summary) tuple per "- **Title.** summary" line after poteto-mode's "## Playbooks" heading, in list order. + """Return one (title, file, summary) tuple per "- **Title.** summary" line in poteto-mode's Playbooks section, in list order. + The section starts on the line after the first level-two heading whose text is Playbooks and ends before the next level-two heading. A level-three heading does not end it. file is the skill-relative path GIVEN_FILE finds at the end of the bullet, otherwise playbooks/<kebab(title)>.md. summary is the bullet's text after the title with the GIVEN_FILE match removed, stripped of surrounding whitespace. """ - text = (skills / "poteto-mode/SKILL.md").read_text() - section = text.split("## Playbooks", 1)[1] - entries = [] - for title, summary in BULLET.findall(section): - given = GIVEN_FILE.search(summary) - file = given.group(1) if given else f"playbooks/{kebab(title)}.md" - entries.append((title, file, GIVEN_FILE.sub("", summary).strip())) - return entries + return _scan_playbooks((skills / "poteto-mode/SKILL.md").read_text())[0] def playbooks(skills): @@ -52,11 +73,13 @@ def playbooks(skills): def missing_playbooks(skills): - return [ + entries, unparsed = _scan_playbooks((skills / "poteto-mode/SKILL.md").read_text()) + missing = [ f'poteto-mode/SKILL.md: playbook "{title}" resolves to {file}, which does not exist' - for title, file, _ in playbook_entries(skills) + for title, file, _ in entries if not (skills / "poteto-mode" / file).is_file() ] + return unparsed + missing def render(skills): diff --git a/scripts/check.py b/scripts/check.py index ab349b51..6ce9b999 100644 --- a/scripts/check.py +++ b/scripts/check.py @@ -60,6 +60,19 @@ TABLE_SEPARATOR = re.compile(r"\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?") +_catalog_module = None + + +def _catalog(): + global _catalog_module + if _catalog_module is None: + spec = importlib.util.spec_from_file_location("pstack_catalog", Path(__file__).resolve().parent / "catalog.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + _catalog_module = module + return _catalog_module + + def light_waiver_cells(): spec = importlib.util.spec_from_file_location("pstack_roles", ROLES_PY) roles = importlib.util.module_from_spec(spec) @@ -163,6 +176,8 @@ def check_tree(root): continue if path and not (file.parent / path).resolve().exists(): findings.append(f"{rel}: broken link {target}") + if (root / "poteto-mode/SKILL.md").is_file(): + findings.extend(_catalog().missing_playbooks(root)) return findings From 44ef6f71356545fc23529d343d78ea7dfb401cbb Mon Sep 17 00:00:00 2001 From: Marcus Gibson <marcusroygibson@gmail.com> Date: Sun, 11 Oct 2026 15:24:52 -0500 Subject: [PATCH 03/19] tests: an indented Playbooks note is not a finding --- tests/test_check.py | 18 ++++++++++++++++++ tests/test_pstack_t3.py | 18 +++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/tests/test_check.py b/tests/test_check.py index 2e1019d4..d39cdc0e 100644 --- a/tests/test_check.py +++ b/tests/test_check.py @@ -241,6 +241,24 @@ def test_the_command_reports_a_missing_playbook_file(self): (1, 'poteto-mode/SKILL.md: playbook "No such playbook" resolves to playbooks/no-such-playbook.md, which does not exist\n'), ) + def test_the_command_accepts_an_indented_note_under_a_playbook_bullet(self): + with tempfile.TemporaryDirectory() as directory: + skills = Path(directory) / "skills" + skill = skills / "poteto-mode" + (skill / "playbooks").mkdir(parents=True) + (skill / "playbooks/bug-fix.md").write_text("ok\n") + (skill / "SKILL.md").write_text( + "---\nname: poteto-mode\ndescription: d\n---\n\n" + "## Playbooks\n" + "- **Bug fix.** File: `playbooks/bug-fix.md`.\n" + " - Choose this when the defect is already reproduced.\n" + ) + tests = tests_tree(directory, {}) + output = io.StringIO() + with mock.patch.object(check, "TESTS_DIR", tests), contextlib.redirect_stdout(output): + status = check.main([str(skills)]) + self.assertEqual((status, output.getvalue()), (0, "ok\n")) + class FrontmatterWithoutPyYamlTest(unittest.TestCase): def test_without_pyyaml_a_malformed_flow_sequence_is_not_reported(self): diff --git a/tests/test_pstack_t3.py b/tests/test_pstack_t3.py index 90c50113..2a9a9c44 100644 --- a/tests/test_pstack_t3.py +++ b/tests/test_pstack_t3.py @@ -1275,7 +1275,6 @@ def test_the_playbooks_section_keeps_catalog_bullets_and_reports_the_other_list_ bad = [ "- **No such playbook** Nothing.", "* **Star.** Nothing.", - " - **Indented.** Nothing.", "- **Empty.**", "+ **Plus.** Nothing.", ] @@ -1318,6 +1317,23 @@ def render_with_a_stray_bullet(destination): build.build(out, skip_lock=True) self.assertFalse(out.exists()) + def test_build_accepts_an_indented_note_under_a_playbook_bullet(self): + import build + from unittest import mock + real = build.render + note = " - Choose this when the defect is already reproduced.\n" + + def render_with_an_indented_note(destination): + real(destination) + with (destination / "poteto-mode/SKILL.md").open("a") as skill: + skill.write(note) + + with tempfile.TemporaryDirectory() as directory: + out = Path(directory) / "skills" + with mock.patch.object(build, "render", render_with_an_indented_note): + self.assertEqual(build.build(out, skip_lock=True), out) + self.assertIn(note, (out / "poteto-mode/SKILL.md").read_text()) + def test_check_flags_cursor_leftovers(self): with tempfile.TemporaryDirectory() as directory: skill = Path(directory) / "demo" From 9d0a0957629a1fd1705330079364a975c1a66fbc Mon Sep 17 00:00:00 2001 From: Marcus Gibson <marcusroygibson@gmail.com> Date: Sun, 11 Oct 2026 15:25:42 -0500 Subject: [PATCH 04/19] check: skip an indented list line inside Playbooks --- changes/pstack-t3%2Fd169.md | 3 ++- scripts/build.py | 2 +- scripts/catalog.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/changes/pstack-t3%2Fd169.md b/changes/pstack-t3%2Fd169.md index 3d4e5abd..a998ca6f 100644 --- a/changes/pstack-t3%2Fd169.md +++ b/changes/pstack-t3%2Fd169.md @@ -1 +1,2 @@ -- `scripts/build.py` reports override drift as `<rel>: upstream changed since this override was written; review or re-port it, then --update-lock`. `scripts/check.py` reports a missing poteto-mode playbook file when `poteto-mode/SKILL.md` is a file, using `poteto-mode/SKILL.md: playbook "<title>" resolves to <file>, which does not exist`. `scripts/build.py` still fails on that finding before it swaps the tree, and it no longer calls `missing_playbooks` itself. `scripts/catalog.py` reads only the Playbooks section, from the line after the level-two heading `Playbooks` until the next level-two heading. A line in that section that starts with `- `, `* `, or `+ ` after leading whitespace, and does not match `- **Title.** summary`, is a finding `poteto-mode/SKILL.md:<line>: playbook bullet does not match "- **Title.** summary": <line text>`. A line that matches `- **Title.** summary` still resolves to the backticked `playbooks/<name>.md` at the end of the bullet, with or without a `File: ` lead-in, or to `playbooks/<kebab title>.md`. `AGENTS.md` step 5 names a poteto-mode playbook bullet whose file does not exist. The `--merge` paragraph names `--conflicts <dir>`. +- `scripts/build.py` reports override drift as `<rel>: upstream changed since this override was written; review or re-port it, then --update-lock`. `scripts/check.py` reports a missing poteto-mode playbook file when `poteto-mode/SKILL.md` is a file, using `poteto-mode/SKILL.md: playbook "<title>" resolves to <file>, which does not exist`. `scripts/build.py` still fails on that finding before it swaps the tree, and it no longer calls `missing_playbooks` itself. `scripts/catalog.py` reads only the Playbooks section, from the line after the level-two heading `Playbooks` until the next level-two heading. A line in that section that starts with `- `, `* `, or `+ ` in column one, with no leading whitespace, and does not match `- **Title.** summary`, is a finding `poteto-mode/SKILL.md:<line>: playbook bullet does not match "- **Title.** summary": <line text>`. A line that matches `- **Title.** summary` still resolves to the backticked `playbooks/<name>.md` at the end of the bullet, with or without a `File: ` lead-in, or to `playbooks/<kebab title>.md`. `AGENTS.md` step 5 names a poteto-mode playbook bullet whose file does not exist. The `--merge` paragraph names `--conflicts <dir>`. +- An indented list line inside the Playbooks section is skipped. It is not a playbook entry and it is not a playbook finding. diff --git a/scripts/build.py b/scripts/build.py index 3530012d..59e50d7c 100644 --- a/scripts/build.py +++ b/scripts/build.py @@ -8,7 +8,7 @@ t3/overrides.lock.json sha256 of each upstream file an override replaces An override whose upstream file changed since it was written fails the build -until it is re-ported and the lock refreshed with --update-lock. +until it is reviewed or re-ported and the lock refreshed with --update-lock. """ import argparse diff --git a/scripts/catalog.py b/scripts/catalog.py index fe36d1dd..9e1ab13f 100644 --- a/scripts/catalog.py +++ b/scripts/catalog.py @@ -51,7 +51,7 @@ def _scan_playbooks(text): file = given.group(1) if given else f"playbooks/{kebab(title)}.md" entries.append((title, file, GIVEN_FILE.sub("", summary).strip())) continue - if line.lstrip().startswith(("- ", "* ", "+ ")): + if line.startswith(("- ", "* ", "+ ")): unparsed.append( f'poteto-mode/SKILL.md:{number}: playbook bullet does not match "- **Title.** summary": {line.strip()[:120]}' ) From 3ec01476adea45b57bd7dcedb12b00665f0700e8 Mon Sep 17 00:00:00 2001 From: Marcus Gibson <marcusroygibson@gmail.com> Date: Sun, 11 Oct 2026 07:47:48 -0500 Subject: [PATCH 05/19] brigade: add a kind cell and a goal cell to each ticket and work item row, and a goals table `rail.tsv` and `dishes.tsv` each gain two trailing columns, `kind` and `goal`. A row written before this change lacks both cells and reads with each one empty. A read leaves the file as it is. The next rewrite of the table writes each of its rows at the full width. `open` writes the new `goals.tsv` with its header into a new store. A store with no `goals.tsv` reads as a store with no goals, and `status`, `watch`, `startable`, `ticket list`, and `close` do not create the file. `read_labels` reads the two cells and `goals.tsv`. A kind that is not one of the five kinds reads as no kind, and a goal with no row in `goals.tsv` reads as unassigned. No command calls it yet. `activity.py` reads a work item row of 15, 16, or 17 cells and prints what it printed for the first 15. --- skills/brigade/scripts/activity.py | 10 ++- skills/brigade/scripts/brigade.py | 114 +++++++++++++++++++++++++-- t3/added/brigade/scripts/activity.py | 10 ++- t3/added/brigade/scripts/brigade.py | 114 +++++++++++++++++++++++++-- tests/test_activity.py | 59 ++++++++++++++ tests/test_brigade.py | 91 ++++++++++++++++++--- 6 files changed, 371 insertions(+), 27 deletions(-) diff --git a/skills/brigade/scripts/activity.py b/skills/brigade/scripts/activity.py index d68b0c78..d5f1cd35 100644 --- a/skills/brigade/scripts/activity.py +++ b/skills/brigade/scripts/activity.py @@ -118,9 +118,11 @@ UNITS_TABLE = "dishes.tsv" LOG_TABLE = "log.tsv" STORE_COLUMNS = { - UNITS_TABLE: ("id", "at", "state", "station", "tickets", "task", "thread", "branch", "pr", "sha", "summary", "timebox", "lease", "paths", "reported"), + UNITS_TABLE: ("id", "at", "state", "station", "tickets", "task", "thread", "branch", "pr", "sha", "summary", "timebox", "lease", "paths", "reported", "kind", "goal"), LOG_TABLE: ("at", "kind", "id", "state", "note"), } +# How many trailing cells a row of each table may lack. A missing cell reads as empty. brigade.py allows the same. +ADDED_COLUMNS = {UNITS_TABLE: 2} TABLE_WORDS = {UNITS_TABLE: "work item table", LOG_TABLE: "log"} # The forms read_store accepts at these keys of the store's record. # Each is absent, null, or a string that encodes as UTF-8 and holds no NUL character. @@ -419,6 +421,7 @@ def read_table(directory, table): """The rows of a table's finished lines after its header line. A table that does not exist has none. A finished line ends in a newline. The bytes after the last newline are never decoded. A finished line that is not UTF-8 is refused as malformed, with its own line number. So is a finished line after the header with a wrong field count. + A work item row may lack its last cell or its last two, and each missing cell reads as empty. The header line is decoded and never parsed. """ columns = STORE_COLUMNS[table] @@ -445,9 +448,10 @@ def read_table(directory, table): if number == 1: continue fields = text.split("\t") - if len(fields) != len(columns): + missing = len(columns) - len(fields) + if not 0 <= missing <= ADDED_COLUMNS.get(table, 0): raise malformed(table, number) - rows.append(dict(zip(columns, fields))) + rows.append(dict(zip(columns, fields + [""] * missing))) return rows diff --git a/skills/brigade/scripts/brigade.py b/skills/brigade/scripts/brigade.py index d872b3de..983ad557 100644 --- a/skills/brigade/scripts/brigade.py +++ b/skills/brigade/scripts/brigade.py @@ -37,6 +37,8 @@ COUNTED_STATES = ("in-progress", "in-review") # A dish in one of these holds an active lease that watch keeps renewing. A queued dish's lease is the queue's. LEASED_STATES = ("in-progress", "in-review", "passed", "sent-back", "blocked") +# A dish in one of these has started and is neither merged nor dropped. +IN_FLIGHT_STATES = (*LEASED_STATES, "queued") OPEN_LINES = {"in-review": "in review", "passed": "passed, not submitted", "sent-back": "sent back", "blocked": "parked"} VERDICTS = {"pass": "passed", "send-back": "sent-back", "blocked": "blocked", "pass-with-edits": "sent-back"} EDITS_VERDICT = "pass-with-edits" @@ -50,6 +52,14 @@ PRIORITIES_BY_RANK = ("urgent", "normal", "low") AUTOFIRE = ("off", *PRIORITIES_BY_RANK) SOURCE_PRIORITY = {"upstream": "urgent", "report": "low"} +KINDS = ("feature", "fix", "polish", "upkeep", "study") +# The kind fire gives an item when neither its lead ticket nor --kind gives one. A station not listed here gives none. +STATION_KIND = {"bug-fix": "fix", "perf-issue": "fix", "investigation": "study"} +GOAL_STATES = ("open", "paused", "done", "dropped") +CLOSED_GOAL_STATES = ("done", "dropped") +# The states a goal may move to from each state. +GOAL_MOVES = {"open": ("paused", "done", "dropped"), "paused": ("open", "done", "dropped"), + "done": ("open",), "dropped": ("open",)} LOW_RESERVE = 1 LOW_FLAG_DAYS = 7 GENERATED_ROOT = "skills/" @@ -76,17 +86,20 @@ class StandingText: STARTS_AFTER = "starts after {tickets}" TABLES = { - "rail.tsv": ("id", "at", "state", "source", "ref", "dish", "summary", "priority", "paths", "decision"), - "dishes.tsv": ("id", "at", "state", "station", "tickets", "task", "thread", "branch", "pr", "sha", "summary", "timebox", "lease", "paths", "reported"), + "rail.tsv": ("id", "at", "state", "source", "ref", "dish", "summary", "priority", "paths", "decision", "kind", "goal"), + "dishes.tsv": ("id", "at", "state", "station", "tickets", "task", "thread", "branch", "pr", "sha", "summary", "timebox", "lease", "paths", "reported", + "kind", "goal"), "pass.tsv": ("at", "dish", "pr", "sha", "verdict", "author", "verifier", "note", "report", "member"), "86.tsv": ("id", "at", "state", "dish", "question", "options", "default", "answer", "kind", "answered"), "log.tsv": ("at", "kind", "id", "state", "note"), "edits.tsv": ("at", "dish", "from", "to", "report"), + "goals.tsv": ("id", "at", "state", "name", "purpose", "done", "share", "closed"), # Only the executive admin's store has this table. "rulings.tsv": ("id", "at", "kind", "parties", "question", "rule", "decision", "supersedes", "state"), } -ADDED_COLUMNS = {"pass.tsv": 2, "86.tsv": 2, "rail.tsv": 3} -PREFIX = {"rail.tsv": "T", "dishes.tsv": "D", "86.tsv": "Q", "rulings.tsv": "R"} +# How many trailing cells a row of each table may lack. A missing cell reads as empty. +ADDED_COLUMNS = {"pass.tsv": 2, "86.tsv": 2, "rail.tsv": 5, "dishes.tsv": 2} +PREFIX = {"rail.tsv": "T", "dishes.tsv": "D", "86.tsv": "Q", "rulings.tsv": "R", "goals.tsv": "G"} ADMIN_DIR = ".admin" ADMIN_NAME = "executive admin" WORK_COMMANDS = ("fire", "brief", "contract", "dish", "pass", "edits", "watch", "startable") @@ -456,14 +469,14 @@ def find(self, table, ident): return rows, row raise BrigadeError(f"no {ident} in {table}") - def update(self, table, ident, kind, note=None, **fields): + def update(self, table, ident, log_kind, note=None, **fields): rows, row = self.find(table, ident) changed = {key: value for key, value in fields.items() if value is not None} moved = "state" in changed and changed["state"] != row.get("state") row.update(changed) self.save_rows(table, rows) if moved: - self.log(kind, ident, changed["state"], note or row.get("summary") or row.get("question", "")) + self.log(log_kind, ident, changed["state"], note or row.get("summary") or row.get("question", "")) return row @@ -750,6 +763,95 @@ def refuse_live_ref(restaurant, ref, rails): raise BrigadeError(f"{ref} is already {row['id']}{where} ({row['state']}); nothing added") +def one_of(words): + """`a, b, or c` from three or more words.""" + return ", ".join(words[:-1]) + f", or {words[-1]}" + + +@dataclass(frozen=True) +class Goal: + """One row of goals.tsv as read_labels reads it. state is one of GOAL_STATES. share is None or a whole number of 1 or more.""" + id: str + state: str + name: str + purpose: str + done: str + share: object + closed: str + + +@dataclass(frozen=True) +class Label: + """A ticket's or an item's kind and goal as read_labels reads them. + + kind is one of KINDS, or "" for no kind. goal is the id of a row of goals.tsv, or "" for unassigned. + """ + kind: str = "" + goal: str = "" + + +@dataclass(frozen=True) +class Labels: + """The kinds and goals of one store, read under one hold of the store lock. + + goals is {goal id: Goal} in file order. tickets is {ticket id: Label} for the rows of rail.tsv, and items is + {item id: Label} for the rows of dishes.tsv. faults is one (row id, text) pair per cell whose value is not legal. + """ + goals: dict + tickets: dict + items: dict + faults: tuple + + +def read_goal(row, faults): + state, share = row["state"], None + if state not in GOAL_STATES: + faults.append((row["id"], f"{row['id']} has state {state!r}, which is not {one_of(GOAL_STATES)}; it reads as open; " + f"run goal set {row['id']} --state <state>")) + state = "open" + if row["share"].isascii() and row["share"].isdigit() and int(row["share"]) >= 1: + share = int(row["share"]) + elif row["share"]: + faults.append((row["id"], f"{row['id']} has share {row['share']!r}, which is not a whole number of 1 or more; " + f"it reads as no limit; run goal set {row['id']} --share <n>")) + return Goal(row["id"], state, row["name"], row["purpose"], row["done"], share, row["closed"]) + + +def read_label(table, row, goals, faults): + ident = row["id"] + kind = row["kind"] if row["kind"] in KINDS else "" + goal = row["goal"] if row["goal"] in goals else "" + # ticket set changes these two cells of a waiting ticket and of no other row. + waiting = table == "rail.tsv" and row["state"] == "waiting" + by_hand = f"no command changes this cell; correct it in {table} by hand" + if row["kind"] and not kind: + faults.append((ident, f"{ident} has kind {row['kind']!r}, which is not {one_of(KINDS)}; it reads as no kind; " + + (f"run ticket set {ident} --kind <kind>" if waiting else by_hand))) + if row["goal"] and not goal: + faults.append((ident, f"{ident} names goal {row['goal']!r}, which has no row in goals.tsv; it reads as unassigned; " + + (f"run ticket set {ident} --goal G<n> or --no-goal" if waiting else by_hand))) + return Label(kind, goal) + + +def read_labels(restaurant): + """The one reader of the kind and goal cells of rail.tsv and dishes.tsv, and of goals.tsv. It writes nothing. + + Call it under the store lock. A goals.tsv that does not exist reads as no goals. + A cell whose value is not legal reads as follows and adds one pair to faults. + + - A kind that is not one of KINDS reads as no kind. + - A goal that is not the id of a row of goals.tsv reads as unassigned. + - A goal's state that is not one of GOAL_STATES reads as open. + - A goal's share that is not empty and not a whole number of 1 or more reads as no limit. + """ + goals, faults = {}, [] + for row in restaurant.rows("goals.tsv"): + goals.setdefault(row["id"], read_goal(row, faults)) + tickets = {row["id"]: read_label("rail.tsv", row, goals, faults) for row in restaurant.rows("rail.tsv")} + items = {row["id"]: read_label("dishes.tsv", row, goals, faults) for row in restaurant.rows("dishes.tsv")} + return Labels(goals, tickets, items, tuple(faults)) + + def add_ticket(restaurant, summary, source, ref, request="", rails=None, again=False, priority="", paths=(), decision=False): source, ref, request = clean(source), clean(ref), clean(request) meta = restaurant.meta diff --git a/t3/added/brigade/scripts/activity.py b/t3/added/brigade/scripts/activity.py index d68b0c78..d5f1cd35 100644 --- a/t3/added/brigade/scripts/activity.py +++ b/t3/added/brigade/scripts/activity.py @@ -118,9 +118,11 @@ UNITS_TABLE = "dishes.tsv" LOG_TABLE = "log.tsv" STORE_COLUMNS = { - UNITS_TABLE: ("id", "at", "state", "station", "tickets", "task", "thread", "branch", "pr", "sha", "summary", "timebox", "lease", "paths", "reported"), + UNITS_TABLE: ("id", "at", "state", "station", "tickets", "task", "thread", "branch", "pr", "sha", "summary", "timebox", "lease", "paths", "reported", "kind", "goal"), LOG_TABLE: ("at", "kind", "id", "state", "note"), } +# How many trailing cells a row of each table may lack. A missing cell reads as empty. brigade.py allows the same. +ADDED_COLUMNS = {UNITS_TABLE: 2} TABLE_WORDS = {UNITS_TABLE: "work item table", LOG_TABLE: "log"} # The forms read_store accepts at these keys of the store's record. # Each is absent, null, or a string that encodes as UTF-8 and holds no NUL character. @@ -419,6 +421,7 @@ def read_table(directory, table): """The rows of a table's finished lines after its header line. A table that does not exist has none. A finished line ends in a newline. The bytes after the last newline are never decoded. A finished line that is not UTF-8 is refused as malformed, with its own line number. So is a finished line after the header with a wrong field count. + A work item row may lack its last cell or its last two, and each missing cell reads as empty. The header line is decoded and never parsed. """ columns = STORE_COLUMNS[table] @@ -445,9 +448,10 @@ def read_table(directory, table): if number == 1: continue fields = text.split("\t") - if len(fields) != len(columns): + missing = len(columns) - len(fields) + if not 0 <= missing <= ADDED_COLUMNS.get(table, 0): raise malformed(table, number) - rows.append(dict(zip(columns, fields))) + rows.append(dict(zip(columns, fields + [""] * missing))) return rows diff --git a/t3/added/brigade/scripts/brigade.py b/t3/added/brigade/scripts/brigade.py index d872b3de..983ad557 100644 --- a/t3/added/brigade/scripts/brigade.py +++ b/t3/added/brigade/scripts/brigade.py @@ -37,6 +37,8 @@ COUNTED_STATES = ("in-progress", "in-review") # A dish in one of these holds an active lease that watch keeps renewing. A queued dish's lease is the queue's. LEASED_STATES = ("in-progress", "in-review", "passed", "sent-back", "blocked") +# A dish in one of these has started and is neither merged nor dropped. +IN_FLIGHT_STATES = (*LEASED_STATES, "queued") OPEN_LINES = {"in-review": "in review", "passed": "passed, not submitted", "sent-back": "sent back", "blocked": "parked"} VERDICTS = {"pass": "passed", "send-back": "sent-back", "blocked": "blocked", "pass-with-edits": "sent-back"} EDITS_VERDICT = "pass-with-edits" @@ -50,6 +52,14 @@ PRIORITIES_BY_RANK = ("urgent", "normal", "low") AUTOFIRE = ("off", *PRIORITIES_BY_RANK) SOURCE_PRIORITY = {"upstream": "urgent", "report": "low"} +KINDS = ("feature", "fix", "polish", "upkeep", "study") +# The kind fire gives an item when neither its lead ticket nor --kind gives one. A station not listed here gives none. +STATION_KIND = {"bug-fix": "fix", "perf-issue": "fix", "investigation": "study"} +GOAL_STATES = ("open", "paused", "done", "dropped") +CLOSED_GOAL_STATES = ("done", "dropped") +# The states a goal may move to from each state. +GOAL_MOVES = {"open": ("paused", "done", "dropped"), "paused": ("open", "done", "dropped"), + "done": ("open",), "dropped": ("open",)} LOW_RESERVE = 1 LOW_FLAG_DAYS = 7 GENERATED_ROOT = "skills/" @@ -76,17 +86,20 @@ class StandingText: STARTS_AFTER = "starts after {tickets}" TABLES = { - "rail.tsv": ("id", "at", "state", "source", "ref", "dish", "summary", "priority", "paths", "decision"), - "dishes.tsv": ("id", "at", "state", "station", "tickets", "task", "thread", "branch", "pr", "sha", "summary", "timebox", "lease", "paths", "reported"), + "rail.tsv": ("id", "at", "state", "source", "ref", "dish", "summary", "priority", "paths", "decision", "kind", "goal"), + "dishes.tsv": ("id", "at", "state", "station", "tickets", "task", "thread", "branch", "pr", "sha", "summary", "timebox", "lease", "paths", "reported", + "kind", "goal"), "pass.tsv": ("at", "dish", "pr", "sha", "verdict", "author", "verifier", "note", "report", "member"), "86.tsv": ("id", "at", "state", "dish", "question", "options", "default", "answer", "kind", "answered"), "log.tsv": ("at", "kind", "id", "state", "note"), "edits.tsv": ("at", "dish", "from", "to", "report"), + "goals.tsv": ("id", "at", "state", "name", "purpose", "done", "share", "closed"), # Only the executive admin's store has this table. "rulings.tsv": ("id", "at", "kind", "parties", "question", "rule", "decision", "supersedes", "state"), } -ADDED_COLUMNS = {"pass.tsv": 2, "86.tsv": 2, "rail.tsv": 3} -PREFIX = {"rail.tsv": "T", "dishes.tsv": "D", "86.tsv": "Q", "rulings.tsv": "R"} +# How many trailing cells a row of each table may lack. A missing cell reads as empty. +ADDED_COLUMNS = {"pass.tsv": 2, "86.tsv": 2, "rail.tsv": 5, "dishes.tsv": 2} +PREFIX = {"rail.tsv": "T", "dishes.tsv": "D", "86.tsv": "Q", "rulings.tsv": "R", "goals.tsv": "G"} ADMIN_DIR = ".admin" ADMIN_NAME = "executive admin" WORK_COMMANDS = ("fire", "brief", "contract", "dish", "pass", "edits", "watch", "startable") @@ -456,14 +469,14 @@ def find(self, table, ident): return rows, row raise BrigadeError(f"no {ident} in {table}") - def update(self, table, ident, kind, note=None, **fields): + def update(self, table, ident, log_kind, note=None, **fields): rows, row = self.find(table, ident) changed = {key: value for key, value in fields.items() if value is not None} moved = "state" in changed and changed["state"] != row.get("state") row.update(changed) self.save_rows(table, rows) if moved: - self.log(kind, ident, changed["state"], note or row.get("summary") or row.get("question", "")) + self.log(log_kind, ident, changed["state"], note or row.get("summary") or row.get("question", "")) return row @@ -750,6 +763,95 @@ def refuse_live_ref(restaurant, ref, rails): raise BrigadeError(f"{ref} is already {row['id']}{where} ({row['state']}); nothing added") +def one_of(words): + """`a, b, or c` from three or more words.""" + return ", ".join(words[:-1]) + f", or {words[-1]}" + + +@dataclass(frozen=True) +class Goal: + """One row of goals.tsv as read_labels reads it. state is one of GOAL_STATES. share is None or a whole number of 1 or more.""" + id: str + state: str + name: str + purpose: str + done: str + share: object + closed: str + + +@dataclass(frozen=True) +class Label: + """A ticket's or an item's kind and goal as read_labels reads them. + + kind is one of KINDS, or "" for no kind. goal is the id of a row of goals.tsv, or "" for unassigned. + """ + kind: str = "" + goal: str = "" + + +@dataclass(frozen=True) +class Labels: + """The kinds and goals of one store, read under one hold of the store lock. + + goals is {goal id: Goal} in file order. tickets is {ticket id: Label} for the rows of rail.tsv, and items is + {item id: Label} for the rows of dishes.tsv. faults is one (row id, text) pair per cell whose value is not legal. + """ + goals: dict + tickets: dict + items: dict + faults: tuple + + +def read_goal(row, faults): + state, share = row["state"], None + if state not in GOAL_STATES: + faults.append((row["id"], f"{row['id']} has state {state!r}, which is not {one_of(GOAL_STATES)}; it reads as open; " + f"run goal set {row['id']} --state <state>")) + state = "open" + if row["share"].isascii() and row["share"].isdigit() and int(row["share"]) >= 1: + share = int(row["share"]) + elif row["share"]: + faults.append((row["id"], f"{row['id']} has share {row['share']!r}, which is not a whole number of 1 or more; " + f"it reads as no limit; run goal set {row['id']} --share <n>")) + return Goal(row["id"], state, row["name"], row["purpose"], row["done"], share, row["closed"]) + + +def read_label(table, row, goals, faults): + ident = row["id"] + kind = row["kind"] if row["kind"] in KINDS else "" + goal = row["goal"] if row["goal"] in goals else "" + # ticket set changes these two cells of a waiting ticket and of no other row. + waiting = table == "rail.tsv" and row["state"] == "waiting" + by_hand = f"no command changes this cell; correct it in {table} by hand" + if row["kind"] and not kind: + faults.append((ident, f"{ident} has kind {row['kind']!r}, which is not {one_of(KINDS)}; it reads as no kind; " + + (f"run ticket set {ident} --kind <kind>" if waiting else by_hand))) + if row["goal"] and not goal: + faults.append((ident, f"{ident} names goal {row['goal']!r}, which has no row in goals.tsv; it reads as unassigned; " + + (f"run ticket set {ident} --goal G<n> or --no-goal" if waiting else by_hand))) + return Label(kind, goal) + + +def read_labels(restaurant): + """The one reader of the kind and goal cells of rail.tsv and dishes.tsv, and of goals.tsv. It writes nothing. + + Call it under the store lock. A goals.tsv that does not exist reads as no goals. + A cell whose value is not legal reads as follows and adds one pair to faults. + + - A kind that is not one of KINDS reads as no kind. + - A goal that is not the id of a row of goals.tsv reads as unassigned. + - A goal's state that is not one of GOAL_STATES reads as open. + - A goal's share that is not empty and not a whole number of 1 or more reads as no limit. + """ + goals, faults = {}, [] + for row in restaurant.rows("goals.tsv"): + goals.setdefault(row["id"], read_goal(row, faults)) + tickets = {row["id"]: read_label("rail.tsv", row, goals, faults) for row in restaurant.rows("rail.tsv")} + items = {row["id"]: read_label("dishes.tsv", row, goals, faults) for row in restaurant.rows("dishes.tsv")} + return Labels(goals, tickets, items, tuple(faults)) + + def add_ticket(restaurant, summary, source, ref, request="", rails=None, again=False, priority="", paths=(), decision=False): source, ref, request = clean(source), clean(ref), clean(request) meta = restaurant.meta diff --git a/tests/test_activity.py b/tests/test_activity.py index 833e935e..3d256c72 100644 --- a/tests/test_activity.py +++ b/tests/test_activity.py @@ -299,6 +299,65 @@ def test_row_with_a_wrong_field_count_exits_1_with_its_line_number(self): table.write("D8\ttoo few fields\n") self.assertEqual(self.fails(1), "activity: line 3 of the store's work item table is malformed; fix or remove it") + def widened(self, cells): + """Rewrite the work item table with each row's extra cells from `cells` by item id appended to its 15 cells.""" + path = self.fixture.store / "dishes.tsv" + header, *rows = path.read_text().splitlines() + path.write_text("\n".join([header, *("\t".join([row, *cells.get(row.split("\t")[0], ())]) for row in rows)]) + "\n") + + def brigade_rows(self, table): + return runpy.run_path(str(SCRIPT.with_name("brigade.py")))["Restaurant"](self.fixture.store).rows(table) + + def kinds_and_goals(self): + return [(row["id"], row["kind"], row["goal"]) for row in MOD["read_table"](self.fixture.store, "dishes.tsv")] + + def test_work_item_table_of_15_cell_rows_is_read_with_an_empty_kind_and_goal_on_each_row(self): + one_running(self.fixture).write() + self.assertEqual([len(line.split("\t")) for line in (self.fixture.store / "dishes.tsv").read_text().splitlines()], [15, 15, 15]) + self.assertEqual(self.kinds_and_goals(), [("D7", "", ""), ("D6", "", "")]) + self.assertEqual(MOD["read_table"](self.fixture.store, "dishes.tsv"), self.brigade_rows("dishes.tsv")) + + def test_work_item_table_of_17_cell_rows_is_read_with_the_kind_and_goal_each_row_holds(self): + one_running(self.fixture).write() + self.widened({"D7": ("feature", "G1"), "D6": ("fix", "")}) + self.assertEqual(self.kinds_and_goals(), [("D7", "feature", "G1"), ("D6", "fix", "")]) + self.assertEqual(MOD["read_table"](self.fixture.store, "dishes.tsv"), self.brigade_rows("dishes.tsv")) + + def test_work_item_table_that_mixes_15_cell_and_17_cell_rows_is_read_with_the_rows_brigade_reads(self): + one_running(self.fixture).write() + self.widened({"D6": ("polish", "G2")}) + self.assertEqual([len(line.split("\t")) for line in (self.fixture.store / "dishes.tsv").read_text().splitlines()], [15, 15, 17]) + self.assertEqual(self.kinds_and_goals(), [("D7", "", ""), ("D6", "polish", "G2")]) + self.assertEqual(MOD["read_table"](self.fixture.store, "dishes.tsv"), self.brigade_rows("dishes.tsv")) + + def test_work_item_row_of_16_cells_lacks_one_trailing_cell_and_is_read_with_an_empty_goal_as_brigade_reads_it(self): + one_running(self.fixture).write() + self.widened({"D7": ("upkeep",)}) + self.assertEqual(self.kinds_and_goals(), [("D7", "upkeep", ""), ("D6", "", "")]) + self.assertEqual(MOD["read_table"](self.fixture.store, "dishes.tsv"), self.brigade_rows("dishes.tsv")) + + def test_page_and_text_for_17_cell_work_item_rows_equal_those_for_the_same_rows_with_the_kind_and_goal_cells_removed(self): + fixture = one_running(self.fixture).write() + narrow = (fixture.stdout_bytes(), fixture.stdout_bytes("--text")) + self.widened({"D7": ("feature", "G1"), "D6": ("fix", "G2")}) + self.assertEqual(self.kinds_and_goals(), [("D7", "feature", "G1"), ("D6", "fix", "G2")]) + self.assertEqual((fixture.stdout_bytes(), fixture.stdout_bytes("--text")), narrow) + self.assertIn(b"Agent activity page", narrow[0]) + self.assertEqual(narrow[1].split(b"\n")[0], b"Agent activity for kit, last 3h") + + def test_work_item_row_of_14_cells_or_18_cells_exits_1_with_its_line_number(self): + for extra in (None, ("feature", "G1", "one more")): + with self.subTest(extra): + fixture = self.fixture = one_running(Fixture(self.root / str(len(extra or ())))).write() + if extra is None: + path = fixture.store / "dishes.tsv" + header, first, second = path.read_text().splitlines() + path.write_text("\n".join([header, first, second.rsplit("\t", 1)[0]]) + "\n") + else: + self.widened({"D6": extra}) + self.assertEqual(len((fixture.store / "dishes.tsv").read_text().splitlines()[2].split("\t")), 14 if extra is None else 18) + self.assertEqual(self.fails(1), "activity: line 3 of the store's work item table is malformed; fix or remove it") + def test_log_row_that_is_not_utf8_exits_1_with_its_line_number(self): self.fixture.write() with open(self.fixture.store / "log.tsv", "ab") as table: diff --git a/tests/test_brigade.py b/tests/test_brigade.py index 6a37b679..add9cecc 100644 --- a/tests/test_brigade.py +++ b/tests/test_brigade.py @@ -4599,16 +4599,89 @@ def test_ticket_list_reads_rail_rows_written_before_the_priority_paths_and_decis self.assertEqual(self.brigade("ticket", "add", "--summary", "New", "--priority", "urgent", "--paths", "a.txt"), "T4") self.assertEqual((self.at / "rail.tsv").read_bytes()[:len(before)], before) self.assertEqual(self.brigade("ticket", "list").splitlines()[-1], "T4 waiting urgent [user] New") - self.assertEqual((self.at / "rail.tsv").read_text().splitlines()[-1].split("\t")[5:], ["", "New", "urgent", "a.txt", ""]) + self.assertEqual((self.at / "rail.tsv").read_text().splitlines()[-1].split("\t")[5:], + ["", "New", "urgent", "a.txt", "", "", ""]) - def test_a_rail_row_with_six_fields_or_eleven_is_malformed(self): + def test_a_rail_row_with_six_fields_or_thirteen_is_malformed(self): self.open() stamp = "2026-10-01T00:00:00+00:00" - for fields in (["T1", stamp, "waiting", "user", "", ""], ["T1", stamp, "waiting", "user", "", "", "s", "", "", "", ""]): + for fields in (["T1", stamp, "waiting", "user", "", ""], ["T1", stamp, "waiting", "user", "", "", "s", *[""] * 6]): (self.at / "rail.tsv").write_text("id\n" + "\t".join(fields) + "\n") self.assertEqual(self.brigade("ticket", "list", ok=False), "brigade: bridge-kit/perf/rail.tsv line 2 is malformed; fix or remove it") + def test_a_rail_row_with_seven_ten_eleven_or_twelve_fields_is_read(self): + self.open() + stamp = "2026-10-01T00:00:00+00:00" + for extra in ([], ["low", "a.txt", ""], ["low", "a.txt", "", ""], ["low", "a.txt", "", "", ""]): + (self.at / "rail.tsv").write_text("id\n" + "\t".join(["T1", stamp, "waiting", "user", "", "", "s", *extra]) + "\n") + self.assertEqual(self.brigade("ticket", "list"), f"T1 waiting {'low' if extra else 'normal'} [user] s", extra) + + OLD_DISHES = "id\tat\tstate\tstation\ttickets\ttask\tthread\tbranch\tpr\tsha\tsummary\ttimebox\tlease\tpaths\treported\n" + + def old_item(self, stamp="2026-10-01T00:00:00+00:00"): + """One assigned ticket and its item in review, each in a row of the width the tool wrote before the kind and goal cells.""" + (self.at / "rail.tsv").write_text( + "id\tat\tstate\tsource\tref\tdish\tsummary\tpriority\tpaths\tdecision\n" + f"T1\t{stamp}\tassigned\tuser\t\tD1\tFix login\t\t\t\n") + (self.at / "dishes.tsv").write_text( + self.OLD_DISHES + f"D1\t{stamp}\tin-review\tbug-fix\tT1\t\tthread-1\t\t\tabc\tFix login\t60\t\t\t\n") + + def test_a_15_cell_dishes_row_and_a_10_cell_rail_row_are_read_and_a_read_leaves_both_tables_byte_identical(self): + self.open() + self.old_item() + before = self.tables() + self.assertEqual(self.brigade("ticket", "list"), "T1 assigned normal [user] Fix login") + self.assertEqual(self.brigade("watch"), "workers: 1 of 2 running, 1 idle, auto-start: normal\nD1: in review") + self.assertEqual(self.brigade("status").splitlines()[1], "reporting: milestones, no landing contract, in review: 1") + self.assertEqual(self.brigade("pass", "check", "D1", "--sha", "abc", ok=False), "brigade: D1 has no review verdict for abc") + self.assertEqual(self.tables(), before) + + def test_a_rewrite_of_an_old_table_keeps_each_cell_and_adds_an_empty_kind_and_goal_cell_to_each_row(self): + self.open() + self.old_item() + rail, dishes = ((self.at / name).read_text().splitlines()[1].split("\t") for name in ("rail.tsv", "dishes.tsv")) + self.assertEqual(self.brigade("dish", "D1", "--timebox", "90"), "D1 in-review") + self.assertEqual(self.brigade("ticket", "set", "T1", "--priority", "low"), "T1 priority low") + dishes[11], rail[7] = "90", "low" + self.assertEqual((self.at / "dishes.tsv").read_text().splitlines()[1].split("\t"), [*dishes, "", ""]) + self.assertEqual((self.at / "rail.tsv").read_text().splitlines()[1].split("\t"), [*rail, "", ""]) + self.assertEqual((self.at / "dishes.tsv").read_text().splitlines()[0].split("\t")[-3:], ["reported", "kind", "goal"]) + self.assertEqual((self.at / "rail.tsv").read_text().splitlines()[0].split("\t")[-3:], ["decision", "kind", "goal"]) + + def test_a_dishes_row_with_fourteen_fields_or_eighteen_is_malformed_and_one_with_sixteen_is_read(self): + self.open() + self.old_item() + row = (self.at / "dishes.tsv").read_text().splitlines()[1].split("\t") + for fields in (row[:14], [*row, "", "", ""]): + (self.at / "dishes.tsv").write_text(self.OLD_DISHES + "\t".join(fields) + "\n") + self.assertEqual(self.brigade("watch", ok=False), + "brigade: bridge-kit/perf/dishes.tsv line 2 is malformed; fix or remove it") + (self.at / "dishes.tsv").write_text(self.OLD_DISHES + "\t".join([*row, ""]) + "\n") + self.assertEqual(self.brigade("watch"), "workers: 1 of 2 running, 1 idle, auto-start: normal\nD1: in review") + + def test_open_writes_goals_tsv_with_its_header_and_no_row_into_a_new_store_and_into_the_admins_store(self): + self.open() + self.open_admin() + for directory in (self.at, self.store / "bridge-kit" / ".admin"): + self.assertEqual((directory / "goals.tsv").read_text(), "id\tat\tstate\tname\tpurpose\tdone\tshare\tclosed\n") + + def test_a_store_with_no_goals_table_runs_status_watch_startable_ticket_list_and_close_and_gains_no_table(self): + self.fired_bug_fix() + self.brigade("ticket", "add", "--summary", "next") + (self.at / "goals.tsv").unlink() + files = sorted(self.store_files()) + self.assertEqual(self.brigade("status").splitlines()[0], "thread not recorded") + self.assertEqual(self.brigade("watch"), "workers: 1 of 2 running, 1 idle, auto-start: normal\nD1: running 0m of 60m (no worker recorded)") + self.assertEqual(self.brigade("startable").splitlines()[:2], + ["workers: 1 of 2 running, 1 idle, auto-start: normal", "waiting tickets: 1, unknown: 1"]) + self.assertEqual(self.brigade("ticket", "list", "--state", "waiting"), "T2 waiting normal [user] next") + code, out, err = self.close_run("--dry-run") + self.assertEqual((code, err), (0, "")) + self.assertEqual(out.splitlines()[0], "# Perf report") + self.assertEqual(sorted(self.store_files()), files) + self.assertFalse((self.at / "goals.tsv").exists()) + def test_a_priority_paths_or_decision_change_adds_no_log_row_and_keeps_a_blocked_ticket_blocked(self): self.brigade("open", "--project-root", str(self.project), "--name", "Perf", "--workers", "1") self.brigade("ticket", "add", "--summary", "one") @@ -6281,7 +6354,7 @@ def test_from_report_files_the_same_report_name_in_two_siblings(self): (self.dir(name) / "reports" / "D1.md").write_text(body) self.assertEqual(self.brigade(name, "ticket", "add", "--from-report", "reports/D1.md"), f"T2 added from {name}/reports/D1.md#1: Shared text.") - self.assertEqual(self.rows("engine", "rail.tsv")[1][2:], ["waiting", "report", "engine/reports/D1.md#1", "", "Shared text.", "", "", ""]) + self.assertEqual(self.rows("engine", "rail.tsv")[1][2:], ["waiting", "report", "engine/reports/D1.md#1", "", "Shared text.", "", "", "", "", ""]) def test_a_sibling_cannot_claim_a_source_another_owns(self): self.open("docs", "--intake", "github") @@ -6479,7 +6552,7 @@ def test_a_moved_ticket_keeps_its_priority_paths_and_decision_after_ticket_take( "priority": "urgent", "paths": "docs,src/cache.py", "decision": "yes"}) self.assertEqual(self.brigade("engine", "ticket", "take"), "T1 from app/core/T1: Fix the cache") self.assertEqual(self.rows("engine", "rail.tsv")[0][2:], - ["waiting", "user (from app/core/T1)", "", "", "Fix the cache", "urgent", "docs,src/cache.py", "yes"]) + ["waiting", "user (from app/core/T1)", "", "", "Fix the cache", "urgent", "docs,src/cache.py", "yes", "", ""]) self.assertEqual(self.brigade("engine", "ticket", "list"), "T1 waiting urgent [user (from app/core/T1)] Fix the cache") def test_a_moved_ticket_with_an_empty_priority_cell_reads_the_default_of_its_source_after_ticket_take(self): @@ -6488,7 +6561,7 @@ def test_a_moved_ticket_with_an_empty_priority_cell_reads_the_default_of_its_sou self.brigade("core", "ticket", "add", "--summary", "Sync", "--source", "upstream") self.brigade("core", "ticket", "move", "T1", "--to", "engine") self.brigade("engine", "ticket", "take") - self.assertEqual(self.rows("engine", "rail.tsv")[0][7:], ["", "", ""]) + self.assertEqual(self.rows("engine", "rail.tsv")[0][7:], ["", "", "", "", ""]) self.assertEqual(self.brigade("engine", "ticket", "list"), "T1 waiting urgent [upstream (from app/core/T1)] Sync") def test_ticket_take_files_a_handoff_written_without_priority_paths_and_decision(self): @@ -6500,7 +6573,7 @@ def test_ticket_take_files_a_handoff_written_without_priority_paths_and_decision {"handoff": "app/core/T1", "summary": "Tidy the guide", "source": "report", "ref": "core/reports/D1.md#1"})) self.assertEqual(self.brigade("engine", "ticket", "take"), "T1 from app/core/T1: Tidy the guide") self.assertEqual(self.rows("engine", "rail.tsv")[0][2:], - ["waiting", "report (from app/core/T1)", "core/reports/D1.md#1", "", "Tidy the guide", "", "", ""]) + ["waiting", "report (from app/core/T1)", "core/reports/D1.md#1", "", "Tidy the guide", "", "", "", "", ""]) self.assertEqual(self.brigade("engine", "ticket", "list"), "T1 waiting low [report (from app/core/T1)] Tidy the guide core/reports/D1.md#1") @@ -6511,7 +6584,7 @@ def test_ticket_set_with_no_flag_on_a_moved_ticket_prints_the_moved_refusal(self def assert_one_handed_ticket(self, name="engine"): self.assertEqual([row[2:] for row in self.rows(name, "rail.tsv")], - [["waiting", "github (from app/core/T1)", "https://github.com/o/r/issues/7", "", "Fix the cache", "", "", ""]]) + [["waiting", "github (from app/core/T1)", "https://github.com/o/r/issues/7", "", "Fix the cache", "", "", "", "", ""]]) log = (self.dir(name) / "log.tsv").read_text() self.assertTrue(log.endswith("\n")) self.assertTrue(all(len(line.split("\t")) == 5 for line in log.splitlines())) @@ -6652,7 +6725,7 @@ def test_a_tail_cut_inside_a_multi_byte_character_is_skipped_by_a_read_and_dropp added = after[len(complete):].decode() self.assertEqual((added.count("\n"), added[-1]), (1, "\n")) fields = added[:-1].split("\t") - self.assertEqual(fields[:1] + fields[2:], ["T2", "waiting", "user", "", "", "b", "", "", ""]) + self.assertEqual(fields[:1] + fields[2:], ["T2", "waiting", "user", "", "", "b", "", "", "", "", ""]) def test_a_short_append_restores_the_last_complete_row(self): from unittest import mock From 0101ffe586543561ce662a480909503ab4241f99 Mon Sep 17 00:00:00 2001 From: Marcus Gibson <marcusroygibson@gmail.com> Date: Sun, 11 Oct 2026 07:55:16 -0500 Subject: [PATCH 06/19] brigade: add the goal add, goal set, and goal list commands `goal add` appends one open goal to `goals.tsv`, logs one `goal` row, and prints the new id. It refuses the name of an open or paused goal, so the same command run twice adds one goal. `goal set` changes a goal's state, name, purpose, done test, or share. An open or paused goal moves to any other state, and a done or dropped goal moves only to open. A move to done or to dropped needs `--evidence`, which is stored with the time in the goal's `closed` cell, and is refused while the goal has a waiting ticket, an item in flight, or a ticket riding along. No command gives a ticket a goal yet, so the tests of that refusal come with the commands that do. `goal list` prints each open or paused goal with its counts, then the counts of unassigned work. `--all` adds the done and dropped goals. `count_labels` derives each count from `rail.tsv`, `dishes.tsv`, and `pass.tsv` on every call. No count is stored. A goal state that is not one of the four reads as open, and a share that is not a whole number of 1 or more reads as no limit. `goal list` prints one warning on standard error for each, and `goal set` rewrites the cell. Paused and share change no start. --- docs/cli/brigade.md | 57 +++- skills/brigade/scripts/brigade.py | 255 ++++++++++++++++- t3/added/brigade/scripts/brigade.py | 255 ++++++++++++++++- tests/test_brigade.py | 426 ++++++++++++++++++++++++++++ 4 files changed, 980 insertions(+), 13 deletions(-) diff --git a/docs/cli/brigade.md b/docs/cli/brigade.md index 0f3697c4..3c87b0be 100644 --- a/docs/cli/brigade.md +++ b/docs/cli/brigade.md @@ -18,7 +18,7 @@ Bookkeeping for brigade restaurants. | `--at AT` | | | | restaurant directory (default $BRIGADE_DIR) | | `--owner OWNER` | | | | \<thread>@\<generation> from status; every write in a store with a generation needs it | -`<command>` is one of `open`, `set`, `ticket`, `fire`, `brief`, `contract`, `watch`, `startable`, `hang`, `dish`, `pass`, `edits`, `86`, `inbox`, `request`, `rule`, `sync`, `status`, `close`, `walk`. +`<command>` is one of `open`, `set`, `ticket`, `goal`, `fire`, `brief`, `contract`, `watch`, `startable`, `hang`, `dish`, `pass`, `edits`, `86`, `inbox`, `request`, `rule`, `sync`, `status`, `close`, `walk`. ## brigade.py open @@ -150,6 +150,61 @@ record the tracked paths each waiting ticket's summary quotes, on tickets that r | --- | --- | --- | --- | --- | | `--dry-run` | | | | print what it would record; it changes no table | +## brigade.py goal + +```text +brigade.py [flags] goal <action> +``` + +add, change, or list goals; a goal is a named line of work + +`<action>` is one of `add`, `set`, `list`. + +## brigade.py goal add + +```text +brigade.py [flags] goal add --name NAME --purpose PURPOSE --done DONE [--share SHARE] +``` + +record a new open goal and print its id; refused when an open or paused goal has the same name, where case, leading and trailing whitespace, and the length of a whitespace run do not count + +| Argument | Required | Choices | Default | Help | +| --- | --- | --- | --- | --- | +| `--name NAME` | yes | | | what the goal is called, such as "activity view" | +| `--purpose PURPOSE` | yes | | | one sentence that says why this line of work exists | +| `--done DONE` | yes | | | one sentence that states the test that shows the goal is reached | +| `--share SHARE` | | | | a whole number of 1 or more, recorded as the goal's share of the worker slots; goal list prints it, and it does not affect which tickets start | + +## brigade.py goal set + +```text +brigade.py [flags] goal set [--state STATE] [--evidence EVIDENCE] [--name NAME] [--purpose PURPOSE] [--done DONE] [--share SHARE] id +``` + +change a goal's state, name, purpose, done test, or share + +| Argument | Required | Choices | Default | Help | +| --- | --- | --- | --- | --- | +| `id` | yes | | | | +| `--state STATE` | | `open`, `paused`, `done`, `dropped` | | paused is recorded and printed. It does not affect which tickets start. A move to done or to dropped needs --evidence, and is refused while a ticket of the goal is waiting, an item of the goal is in progress, in review, passed, sent back, blocked, or waiting to land, or an assigned ticket of the goal is not in an item of the goal. A done or dropped goal moves only to open. | +| `--evidence EVIDENCE` | | | | one line, stored with the time in the goal's closed cell when the goal moves to done or dropped; with --state done, what shows the done test is met; with --state dropped, why the goal is dropped; refused without --state done or --state dropped | +| `--name NAME` | | | | replaces the goal's name; refused when it is empty or only whitespace | +| `--purpose PURPOSE` | | | | replaces the goal's purpose; refused when it is empty or only whitespace | +| `--done DONE` | | | | replaces the goal's done test; refused when it is empty or only whitespace | +| `--share SHARE` | | | | a whole number of 1 or more, recorded as the goal's share of the worker slots; "" clears it; goal list prints it, and it does not affect which tickets start | + +## brigade.py goal list + +```text +brigade.py [flags] goal list [--all] +``` + +the state, name, counts, purpose, and done test of each open or paused goal and of each done or dropped goal that has a waiting ticket, an item in flight, or a ticket riding along, then the counts of unassigned work; waiting counts waiting tickets; in flight counts items in progress, in review, passed, sent back, blocked, or waiting to land; merged counts merged items; send-backs counts the send-back verdicts on the goal's items, in any state, that are not a panel member's row; riding along counts assigned tickets of the goal that are not in an item of the goal + +| Argument | Required | Choices | Default | Help | +| --- | --- | --- | --- | --- | +| `--all` | | | | also print each other done or dropped goal | + ## brigade.py fire ```text diff --git a/skills/brigade/scripts/brigade.py b/skills/brigade/scripts/brigade.py index 983ad557..90b86d03 100644 --- a/skills/brigade/scripts/brigade.py +++ b/skills/brigade/scripts/brigade.py @@ -770,13 +770,16 @@ def one_of(words): @dataclass(frozen=True) class Goal: - """One row of goals.tsv as read_labels reads it. state is one of GOAL_STATES. share is None or a whole number of 1 or more.""" + """One row of goals.tsv as read_labels reads it. + + state is one of GOAL_STATES. share is the digits of a whole number of 1 or more with no leading zero, or "" for no limit. + """ id: str state: str name: str purpose: str done: str - share: object + share: str closed: str @@ -803,15 +806,18 @@ class Labels: faults: tuple +def whole_share(text): + """The digits of a whole number of 1 or more with no leading zero, or "" when the text is not such a number.""" + return text.lstrip("0") if text.isascii() and text.isdigit() else "" + + def read_goal(row, faults): - state, share = row["state"], None + state, share = row["state"], whole_share(row["share"]) if state not in GOAL_STATES: faults.append((row["id"], f"{row['id']} has state {state!r}, which is not {one_of(GOAL_STATES)}; it reads as open; " f"run goal set {row['id']} --state <state>")) state = "open" - if row["share"].isascii() and row["share"].isdigit() and int(row["share"]) >= 1: - share = int(row["share"]) - elif row["share"]: + if row["share"] and not share: faults.append((row["id"], f"{row['id']} has share {row['share']!r}, which is not a whole number of 1 or more; " f"it reads as no limit; run goal set {row['id']} --share <n>")) return Goal(row["id"], state, row["name"], row["purpose"], row["done"], share, row["closed"]) @@ -852,6 +858,196 @@ def read_labels(restaurant): return Labels(goals, tickets, items, tuple(faults)) +def shown_labels(restaurant): + """read_labels, and one warning line on standard error for each of its faults.""" + labels = read_labels(restaurant) + for _, text in labels.faults: + print(f"brigade: warning: {text}", file=sys.stderr) + return labels + + +@dataclass(frozen=True) +class Tally: + """The work under one kind or one goal. waiting holds ticket ids. in_flight and merged hold item ids.""" + waiting: tuple = () + in_flight: tuple = () + merged: tuple = () + send_backs: int = 0 + + +@dataclass(frozen=True) +class LabelCounts: + """kinds is {kind: Tally} and goals is {goal id: Tally}. The key "" holds the work with no kind, and the unassigned work. + + riding is {goal id: ticket ids}. A key with no work is absent. + """ + kinds: dict + goals: dict + riding: dict + + +def count_labels(restaurant, labels): + """The counts by kind and by goal. Call it under the same hold of the store lock as the read_labels that made `labels`. + + Each row counts under its own kind and its own goal as `labels` gives them. + + - waiting. The rows of rail.tsv whose state is waiting. + - in flight. The rows of dishes.tsv whose state is in IN_FLIGHT_STATES. + - merged. The rows of dishes.tsv whose state is merged. + - send-backs. The rows of pass.tsv whose verdict is send-back, whose member cell is empty, and whose dish cell is the id of + a row of dishes.tsv. They count under that item's kind and goal, whatever the item's state. + - riding, for a goal. The rows of rail.tsv whose state is assigned and whose goal is that goal, where the dish cell is not + the id of an item under that goal. + """ + kinds, goals, riding = {}, {}, {} + + def add(label, field, ident): + for found, key in ((kinds, label.kind), (goals, label.goal)): + found.setdefault(key, {"waiting": [], "in_flight": [], "merged": [], "send_backs": []})[field].append(ident) + + for row in restaurant.rows("rail.tsv"): + label = labels.tickets[row["id"]] + if row["state"] == "waiting": + add(label, "waiting", row["id"]) + elif row["state"] == "assigned" and label.goal and labels.items.get(row["dish"], Label()).goal != label.goal: + riding.setdefault(label.goal, []).append(row["id"]) + for row in restaurant.rows("dishes.tsv"): + if row["state"] in IN_FLIGHT_STATES: + add(labels.items[row["id"]], "in_flight", row["id"]) + elif row["state"] == "merged": + add(labels.items[row["id"]], "merged", row["id"]) + for row in restaurant.rows("pass.tsv"): + if row["verdict"] == "send-back" and not row["member"] and row["dish"] in labels.items: + add(labels.items[row["dish"]], "send_backs", row["dish"]) + + def tallies(found): + return {key: Tally(tuple(lists["waiting"]), tuple(lists["in_flight"]), tuple(lists["merged"]), len(lists["send_backs"])) + for key, lists in found.items()} + + return LabelCounts(tallies(kinds), tallies(goals), {goal: tuple(ids) for goal, ids in riding.items()}) + + +def goal_share(text): + """--share as the cell to store. Empty text stays empty. Refuses text that is not a whole number of 1 or more.""" + text = clean(text) + if text and not whole_share(text): + raise BrigadeError(f'--share takes a whole number of 1 or more, or "" for no limit, got {text!r}') + return whole_share(text) + + +def add_goal(restaurant, name, purpose, done, share=""): + """Append one open goal and log it. Returns the new id. + + Refuses when an open or paused goal has the same name by same_text, so a command run a second time adds no second goal. + """ + name, purpose, done, share = clean(name), clean(purpose), clean(done), goal_share(share) + if not (name and purpose and done): + raise BrigadeError("goal add needs a nonempty --name, --purpose, and --done") + for goal in read_labels(restaurant).goals.values(): + if goal.state not in CLOSED_GOAL_STATES and same_text(goal.name) == same_text(name): + raise BrigadeError(f"same name as {goal.id} ({goal.state}); nothing added") + ident = restaurant.next_id("goals.tsv") + restaurant.append("goals.tsv", {"id": ident, "at": now(), "state": "open", "name": name, "purpose": purpose, + "done": done, "share": share}) + restaurant.log("goal", ident, "open", name) + return ident + + +def goal_work(counted, ident): + """What keeps a goal from closing, as (words, advice) pairs. Empty when no ticket of the goal waits or rides along and no item is in flight.""" + tally = counted.goals.get(ident, Tally()) + held = ((tally.waiting, "waiting", "give each waiting ticket another goal with ticket set --goal or --no-goal"), + (tally.in_flight, "in flight", "finish or drop each item in flight"), + (counted.riding.get(ident, ()), "riding along", "finish or drop the item each of those tickets rides along in")) + return [(f"{', '.join(ids)} {words}", advice) for ids, words, advice in held if ids] + + +def set_goal(restaurant, ident, name=None, purpose=None, done=None, share=None, state=None, evidence=None): + """Change one goal. Returns one line such as `G1 paused; share 2`. + + GOAL_MOVES is the state machine. A state the goal's cell already holds is accepted and changes neither the state nor + the closed cell. A state cell that is not one of GOAL_STATES reads as open, and --state open rewrites it. + A move to done or dropped needs --evidence, and is refused while goal_work finds work under the goal. + One update call writes every change. It logs a goal row when the state changed. + """ + labels = read_labels(restaurant) + goal = labels.goals.get(ident) + if goal is None: + raise BrigadeError(f"no goal {ident}; run goal list --all to see every goal") + if all(value is None for value in (name, purpose, done, share, state)): + raise BrigadeError("goal set needs --state, --name, --purpose, --done, or --share") + for flag, value in (("--name", name), ("--purpose", purpose), ("--done", done)): + if value is not None and not clean(value): + raise BrigadeError(f"goal set {flag} takes text; a goal keeps a name, a purpose, and a done test") + if evidence is not None and state not in CLOSED_GOAL_STATES: + raise BrigadeError("--evidence goes only with --state done or --state dropped") + if share is not None: + share = goal_share(share) + _, row = restaurant.find("goals.tsv", ident) + moving = state is not None and state != row["state"] + closing = moving and state in CLOSED_GOAL_STATES + if moving and state != goal.state and state not in GOAL_MOVES[goal.state]: + raise BrigadeError(f"{ident} is {goal.state}; a {goal.state} goal moves only to open; run goal set {ident} --state open first") + if closing: + if not clean(evidence): + raise BrigadeError(f"--state {state} needs --evidence <one line>") + work = goal_work(count_labels(restaurant, labels), ident) + if work: + raise BrigadeError(f"{ident} still has work: {'; '.join(words for words, _ in work)}; " + f"{'; '.join(advice for _, advice in work)}; then run this again") + closed = None + if closing: + closed = f"{now()} {clean(evidence)}" + elif moving and goal.state in CLOSED_GOAL_STATES: + closed = "" + said = [] + if state is not None: + said.append(state if moving else f"already {state}") + if name is not None: + said.append(f"name {clean(name)}") + if purpose is not None: + said.append("purpose changed") + if done is not None: + said.append("done test changed") + if share is not None: + said.append(f"share {share}" if share else "no share") + kept = clean(name) if name is not None else goal.name + restaurant.update("goals.tsv", ident, "goal", note=f"{kept}: {clean(evidence)}" if closing else kept, + state=state, name=name, purpose=purpose, done=done, share=share, closed=closed) + return f"{ident} {'; '.join(said)}" + + +def plural(count, word): + """`1 send-back`, `0 send-backs`, `2 send-backs`.""" + return f"{count} {word}" if count == 1 else f"{count} {word}s" + + +def tally_words(tally): + return (f"{len(tally.waiting)} waiting, {len(tally.in_flight)} in flight, {len(tally.merged)} merged, " + f"{plural(tally.send_backs, 'send-back')}") + + +def goal_lines(labels, counted, every): + """The text of goal list. + + A goal is listed when it is open or paused, or when it has a ticket waiting, an item in flight, or a ticket riding along. + `every` lists each other goal too. The unassigned line is last. + """ + lines = [] + for goal in labels.goals.values(): + tally, riding = counted.goals.get(goal.id, Tally()), counted.riding.get(goal.id, ()) + closed = goal.state in CLOSED_GOAL_STATES + if closed and not every and not (tally.waiting or tally.in_flight or riding): + continue + words = tally_words(tally) + (f", {len(riding)} riding along" if riding else "") + (f", share {goal.share}" if goal.share else "") + lines += [f"{goal.id} {goal.state} {goal.name}: {words}", f" purpose: {goal.purpose}", f" done when: {goal.done}"] + if closed: + lines.append(f" closed: {goal.closed}") + if not lines: + lines.append("no goals" if every else "no open or paused goals") + return "\n".join([*lines, f"unassigned: {tally_words(counted.goals.get('', Tally()))}"]) + + def add_ticket(restaurant, summary, source, ref, request="", rails=None, again=False, priority="", paths=(), decision=False): source, ref, request = clean(source), clean(ref), clean(request) meta = restaurant.meta @@ -3276,6 +3472,45 @@ def parser(): "is not counted when a file of the same name is tracked under t3/") a.add_argument("--dry-run", action="store_true", help="print what it would record; it changes no table") + p = sub.add_parser("goal", help="add, change, or list goals; a goal is a named line of work") + t = p.add_subparsers(dest="action", required=True) + a = t.add_parser("add", help="record a new open goal and print its id; refused when an open or paused goal has the same " + "name, where case, leading and trailing whitespace, and the length of a whitespace run " + "do not count") + a.add_argument("--name", required=True, help='what the goal is called, such as "activity view"') + a.add_argument("--purpose", required=True, help="one sentence that says why this line of work exists") + a.add_argument("--done", required=True, help="one sentence that states the test that shows the goal is reached") + a.add_argument("--share", default="", + help="a whole number of 1 or more, recorded as the goal's share of the worker slots; goal list prints it, " + "and it does not affect which tickets start") + a = t.add_parser("set", help="change a goal's state, name, purpose, done test, or share") + a.add_argument("id") + a.add_argument("--state", choices=GOAL_STATES, + help="paused is recorded and printed. It does not affect which tickets start. " + "A move to done or to dropped needs --evidence, and is refused while a ticket of the goal is " + "waiting, an item of the goal is in progress, in review, passed, sent back, blocked, or waiting " + "to land, or an assigned ticket of the goal is not in an item of the goal. " + "A done or dropped goal moves only to open.") + a.add_argument("--evidence", + help="one line, stored with the time in the goal's closed cell when the goal moves to done or dropped; " + "with --state done, what shows the done test is met; with --state dropped, why the goal is " + "dropped; refused without --state done or --state dropped") + a.add_argument("--name", help="replaces the goal's name; refused when it is empty or only whitespace") + a.add_argument("--purpose", help="replaces the goal's purpose; refused when it is empty or only whitespace") + a.add_argument("--done", help="replaces the goal's done test; refused when it is empty or only whitespace") + a.add_argument("--share", + help='a whole number of 1 or more, recorded as the goal\'s share of the worker slots; "" clears it; ' + "goal list prints it, and it does not affect which tickets start") + a = t.add_parser("list", + help="the state, name, counts, purpose, and done test of each open or paused goal and of each done or " + "dropped goal that has a waiting ticket, an item in flight, or a ticket riding along, then the " + "counts of unassigned work; waiting counts waiting tickets; in flight counts items in progress, " + "in review, passed, sent back, blocked, or waiting to land; merged counts merged items; " + "send-backs counts the send-back verdicts on the goal's items, in any state, that are not a " + "panel member's row; riding along counts assigned tickets of the goal that are not in an item " + "of the goal") + a.add_argument("--all", action="store_true", help="also print each other done or dropped goal") + p = sub.add_parser("fire", help="group tickets into one dish and assign it to a station") p.add_argument("--tickets", required=True, help="comma-separated ticket ids") p.add_argument("--station", required=True, help="the pstack playbook the worker runs") @@ -3704,6 +3939,14 @@ def command(restaurant, args, contract=None, rails=None): rows = [row for row in restaurant.rows("rulings.tsv") if not args.state or row["state"] == args.state] return "\n".join(ruling_line(row) for row in rows) or "no rulings" + if args.command == "goal": + if args.action == "add": + return add_goal(restaurant, args.name, args.purpose, args.done, args.share) + if args.action == "set": + return set_goal(restaurant, args.id, args.name, args.purpose, args.done, args.share, args.state, args.evidence) + labels = shown_labels(restaurant) + return goal_lines(labels, count_labels(restaurant, labels), args.all) + if args.command == "ticket": if args.action == "add": return add_ticket(restaurant, args.summary, args.source, args.ref, args.request, rails or {}, again=args.again, diff --git a/t3/added/brigade/scripts/brigade.py b/t3/added/brigade/scripts/brigade.py index 983ad557..90b86d03 100644 --- a/t3/added/brigade/scripts/brigade.py +++ b/t3/added/brigade/scripts/brigade.py @@ -770,13 +770,16 @@ def one_of(words): @dataclass(frozen=True) class Goal: - """One row of goals.tsv as read_labels reads it. state is one of GOAL_STATES. share is None or a whole number of 1 or more.""" + """One row of goals.tsv as read_labels reads it. + + state is one of GOAL_STATES. share is the digits of a whole number of 1 or more with no leading zero, or "" for no limit. + """ id: str state: str name: str purpose: str done: str - share: object + share: str closed: str @@ -803,15 +806,18 @@ class Labels: faults: tuple +def whole_share(text): + """The digits of a whole number of 1 or more with no leading zero, or "" when the text is not such a number.""" + return text.lstrip("0") if text.isascii() and text.isdigit() else "" + + def read_goal(row, faults): - state, share = row["state"], None + state, share = row["state"], whole_share(row["share"]) if state not in GOAL_STATES: faults.append((row["id"], f"{row['id']} has state {state!r}, which is not {one_of(GOAL_STATES)}; it reads as open; " f"run goal set {row['id']} --state <state>")) state = "open" - if row["share"].isascii() and row["share"].isdigit() and int(row["share"]) >= 1: - share = int(row["share"]) - elif row["share"]: + if row["share"] and not share: faults.append((row["id"], f"{row['id']} has share {row['share']!r}, which is not a whole number of 1 or more; " f"it reads as no limit; run goal set {row['id']} --share <n>")) return Goal(row["id"], state, row["name"], row["purpose"], row["done"], share, row["closed"]) @@ -852,6 +858,196 @@ def read_labels(restaurant): return Labels(goals, tickets, items, tuple(faults)) +def shown_labels(restaurant): + """read_labels, and one warning line on standard error for each of its faults.""" + labels = read_labels(restaurant) + for _, text in labels.faults: + print(f"brigade: warning: {text}", file=sys.stderr) + return labels + + +@dataclass(frozen=True) +class Tally: + """The work under one kind or one goal. waiting holds ticket ids. in_flight and merged hold item ids.""" + waiting: tuple = () + in_flight: tuple = () + merged: tuple = () + send_backs: int = 0 + + +@dataclass(frozen=True) +class LabelCounts: + """kinds is {kind: Tally} and goals is {goal id: Tally}. The key "" holds the work with no kind, and the unassigned work. + + riding is {goal id: ticket ids}. A key with no work is absent. + """ + kinds: dict + goals: dict + riding: dict + + +def count_labels(restaurant, labels): + """The counts by kind and by goal. Call it under the same hold of the store lock as the read_labels that made `labels`. + + Each row counts under its own kind and its own goal as `labels` gives them. + + - waiting. The rows of rail.tsv whose state is waiting. + - in flight. The rows of dishes.tsv whose state is in IN_FLIGHT_STATES. + - merged. The rows of dishes.tsv whose state is merged. + - send-backs. The rows of pass.tsv whose verdict is send-back, whose member cell is empty, and whose dish cell is the id of + a row of dishes.tsv. They count under that item's kind and goal, whatever the item's state. + - riding, for a goal. The rows of rail.tsv whose state is assigned and whose goal is that goal, where the dish cell is not + the id of an item under that goal. + """ + kinds, goals, riding = {}, {}, {} + + def add(label, field, ident): + for found, key in ((kinds, label.kind), (goals, label.goal)): + found.setdefault(key, {"waiting": [], "in_flight": [], "merged": [], "send_backs": []})[field].append(ident) + + for row in restaurant.rows("rail.tsv"): + label = labels.tickets[row["id"]] + if row["state"] == "waiting": + add(label, "waiting", row["id"]) + elif row["state"] == "assigned" and label.goal and labels.items.get(row["dish"], Label()).goal != label.goal: + riding.setdefault(label.goal, []).append(row["id"]) + for row in restaurant.rows("dishes.tsv"): + if row["state"] in IN_FLIGHT_STATES: + add(labels.items[row["id"]], "in_flight", row["id"]) + elif row["state"] == "merged": + add(labels.items[row["id"]], "merged", row["id"]) + for row in restaurant.rows("pass.tsv"): + if row["verdict"] == "send-back" and not row["member"] and row["dish"] in labels.items: + add(labels.items[row["dish"]], "send_backs", row["dish"]) + + def tallies(found): + return {key: Tally(tuple(lists["waiting"]), tuple(lists["in_flight"]), tuple(lists["merged"]), len(lists["send_backs"])) + for key, lists in found.items()} + + return LabelCounts(tallies(kinds), tallies(goals), {goal: tuple(ids) for goal, ids in riding.items()}) + + +def goal_share(text): + """--share as the cell to store. Empty text stays empty. Refuses text that is not a whole number of 1 or more.""" + text = clean(text) + if text and not whole_share(text): + raise BrigadeError(f'--share takes a whole number of 1 or more, or "" for no limit, got {text!r}') + return whole_share(text) + + +def add_goal(restaurant, name, purpose, done, share=""): + """Append one open goal and log it. Returns the new id. + + Refuses when an open or paused goal has the same name by same_text, so a command run a second time adds no second goal. + """ + name, purpose, done, share = clean(name), clean(purpose), clean(done), goal_share(share) + if not (name and purpose and done): + raise BrigadeError("goal add needs a nonempty --name, --purpose, and --done") + for goal in read_labels(restaurant).goals.values(): + if goal.state not in CLOSED_GOAL_STATES and same_text(goal.name) == same_text(name): + raise BrigadeError(f"same name as {goal.id} ({goal.state}); nothing added") + ident = restaurant.next_id("goals.tsv") + restaurant.append("goals.tsv", {"id": ident, "at": now(), "state": "open", "name": name, "purpose": purpose, + "done": done, "share": share}) + restaurant.log("goal", ident, "open", name) + return ident + + +def goal_work(counted, ident): + """What keeps a goal from closing, as (words, advice) pairs. Empty when no ticket of the goal waits or rides along and no item is in flight.""" + tally = counted.goals.get(ident, Tally()) + held = ((tally.waiting, "waiting", "give each waiting ticket another goal with ticket set --goal or --no-goal"), + (tally.in_flight, "in flight", "finish or drop each item in flight"), + (counted.riding.get(ident, ()), "riding along", "finish or drop the item each of those tickets rides along in")) + return [(f"{', '.join(ids)} {words}", advice) for ids, words, advice in held if ids] + + +def set_goal(restaurant, ident, name=None, purpose=None, done=None, share=None, state=None, evidence=None): + """Change one goal. Returns one line such as `G1 paused; share 2`. + + GOAL_MOVES is the state machine. A state the goal's cell already holds is accepted and changes neither the state nor + the closed cell. A state cell that is not one of GOAL_STATES reads as open, and --state open rewrites it. + A move to done or dropped needs --evidence, and is refused while goal_work finds work under the goal. + One update call writes every change. It logs a goal row when the state changed. + """ + labels = read_labels(restaurant) + goal = labels.goals.get(ident) + if goal is None: + raise BrigadeError(f"no goal {ident}; run goal list --all to see every goal") + if all(value is None for value in (name, purpose, done, share, state)): + raise BrigadeError("goal set needs --state, --name, --purpose, --done, or --share") + for flag, value in (("--name", name), ("--purpose", purpose), ("--done", done)): + if value is not None and not clean(value): + raise BrigadeError(f"goal set {flag} takes text; a goal keeps a name, a purpose, and a done test") + if evidence is not None and state not in CLOSED_GOAL_STATES: + raise BrigadeError("--evidence goes only with --state done or --state dropped") + if share is not None: + share = goal_share(share) + _, row = restaurant.find("goals.tsv", ident) + moving = state is not None and state != row["state"] + closing = moving and state in CLOSED_GOAL_STATES + if moving and state != goal.state and state not in GOAL_MOVES[goal.state]: + raise BrigadeError(f"{ident} is {goal.state}; a {goal.state} goal moves only to open; run goal set {ident} --state open first") + if closing: + if not clean(evidence): + raise BrigadeError(f"--state {state} needs --evidence <one line>") + work = goal_work(count_labels(restaurant, labels), ident) + if work: + raise BrigadeError(f"{ident} still has work: {'; '.join(words for words, _ in work)}; " + f"{'; '.join(advice for _, advice in work)}; then run this again") + closed = None + if closing: + closed = f"{now()} {clean(evidence)}" + elif moving and goal.state in CLOSED_GOAL_STATES: + closed = "" + said = [] + if state is not None: + said.append(state if moving else f"already {state}") + if name is not None: + said.append(f"name {clean(name)}") + if purpose is not None: + said.append("purpose changed") + if done is not None: + said.append("done test changed") + if share is not None: + said.append(f"share {share}" if share else "no share") + kept = clean(name) if name is not None else goal.name + restaurant.update("goals.tsv", ident, "goal", note=f"{kept}: {clean(evidence)}" if closing else kept, + state=state, name=name, purpose=purpose, done=done, share=share, closed=closed) + return f"{ident} {'; '.join(said)}" + + +def plural(count, word): + """`1 send-back`, `0 send-backs`, `2 send-backs`.""" + return f"{count} {word}" if count == 1 else f"{count} {word}s" + + +def tally_words(tally): + return (f"{len(tally.waiting)} waiting, {len(tally.in_flight)} in flight, {len(tally.merged)} merged, " + f"{plural(tally.send_backs, 'send-back')}") + + +def goal_lines(labels, counted, every): + """The text of goal list. + + A goal is listed when it is open or paused, or when it has a ticket waiting, an item in flight, or a ticket riding along. + `every` lists each other goal too. The unassigned line is last. + """ + lines = [] + for goal in labels.goals.values(): + tally, riding = counted.goals.get(goal.id, Tally()), counted.riding.get(goal.id, ()) + closed = goal.state in CLOSED_GOAL_STATES + if closed and not every and not (tally.waiting or tally.in_flight or riding): + continue + words = tally_words(tally) + (f", {len(riding)} riding along" if riding else "") + (f", share {goal.share}" if goal.share else "") + lines += [f"{goal.id} {goal.state} {goal.name}: {words}", f" purpose: {goal.purpose}", f" done when: {goal.done}"] + if closed: + lines.append(f" closed: {goal.closed}") + if not lines: + lines.append("no goals" if every else "no open or paused goals") + return "\n".join([*lines, f"unassigned: {tally_words(counted.goals.get('', Tally()))}"]) + + def add_ticket(restaurant, summary, source, ref, request="", rails=None, again=False, priority="", paths=(), decision=False): source, ref, request = clean(source), clean(ref), clean(request) meta = restaurant.meta @@ -3276,6 +3472,45 @@ def parser(): "is not counted when a file of the same name is tracked under t3/") a.add_argument("--dry-run", action="store_true", help="print what it would record; it changes no table") + p = sub.add_parser("goal", help="add, change, or list goals; a goal is a named line of work") + t = p.add_subparsers(dest="action", required=True) + a = t.add_parser("add", help="record a new open goal and print its id; refused when an open or paused goal has the same " + "name, where case, leading and trailing whitespace, and the length of a whitespace run " + "do not count") + a.add_argument("--name", required=True, help='what the goal is called, such as "activity view"') + a.add_argument("--purpose", required=True, help="one sentence that says why this line of work exists") + a.add_argument("--done", required=True, help="one sentence that states the test that shows the goal is reached") + a.add_argument("--share", default="", + help="a whole number of 1 or more, recorded as the goal's share of the worker slots; goal list prints it, " + "and it does not affect which tickets start") + a = t.add_parser("set", help="change a goal's state, name, purpose, done test, or share") + a.add_argument("id") + a.add_argument("--state", choices=GOAL_STATES, + help="paused is recorded and printed. It does not affect which tickets start. " + "A move to done or to dropped needs --evidence, and is refused while a ticket of the goal is " + "waiting, an item of the goal is in progress, in review, passed, sent back, blocked, or waiting " + "to land, or an assigned ticket of the goal is not in an item of the goal. " + "A done or dropped goal moves only to open.") + a.add_argument("--evidence", + help="one line, stored with the time in the goal's closed cell when the goal moves to done or dropped; " + "with --state done, what shows the done test is met; with --state dropped, why the goal is " + "dropped; refused without --state done or --state dropped") + a.add_argument("--name", help="replaces the goal's name; refused when it is empty or only whitespace") + a.add_argument("--purpose", help="replaces the goal's purpose; refused when it is empty or only whitespace") + a.add_argument("--done", help="replaces the goal's done test; refused when it is empty or only whitespace") + a.add_argument("--share", + help='a whole number of 1 or more, recorded as the goal\'s share of the worker slots; "" clears it; ' + "goal list prints it, and it does not affect which tickets start") + a = t.add_parser("list", + help="the state, name, counts, purpose, and done test of each open or paused goal and of each done or " + "dropped goal that has a waiting ticket, an item in flight, or a ticket riding along, then the " + "counts of unassigned work; waiting counts waiting tickets; in flight counts items in progress, " + "in review, passed, sent back, blocked, or waiting to land; merged counts merged items; " + "send-backs counts the send-back verdicts on the goal's items, in any state, that are not a " + "panel member's row; riding along counts assigned tickets of the goal that are not in an item " + "of the goal") + a.add_argument("--all", action="store_true", help="also print each other done or dropped goal") + p = sub.add_parser("fire", help="group tickets into one dish and assign it to a station") p.add_argument("--tickets", required=True, help="comma-separated ticket ids") p.add_argument("--station", required=True, help="the pstack playbook the worker runs") @@ -3704,6 +3939,14 @@ def command(restaurant, args, contract=None, rails=None): rows = [row for row in restaurant.rows("rulings.tsv") if not args.state or row["state"] == args.state] return "\n".join(ruling_line(row) for row in rows) or "no rulings" + if args.command == "goal": + if args.action == "add": + return add_goal(restaurant, args.name, args.purpose, args.done, args.share) + if args.action == "set": + return set_goal(restaurant, args.id, args.name, args.purpose, args.done, args.share, args.state, args.evidence) + labels = shown_labels(restaurant) + return goal_lines(labels, count_labels(restaurant, labels), args.all) + if args.command == "ticket": if args.action == "add": return add_ticket(restaurant, args.summary, args.source, args.ref, args.request, rails or {}, again=args.again, diff --git a/tests/test_brigade.py b/tests/test_brigade.py index add9cecc..8ce2a2d2 100644 --- a/tests/test_brigade.py +++ b/tests/test_brigade.py @@ -236,6 +236,16 @@ def partial(fd, data): os._exit(9) return real_write(fd, data) + os.write = partial + elif mode == "die-inside-goal-append": + real_write = os.write + + def partial(fd, data): + if data.startswith(b"G1\t"): + real_write(fd, data[:data.index(b"\topen\t") + len(b"\tope")]) + os._exit(9) + return real_write(fd, data) + os.write = partial else: raise SystemExit(f"unknown mode {mode}") @@ -6263,6 +6273,332 @@ def test_a_store_with_no_edits_table_runs_status_watch_startable_a_pass_and_clos self.assertEqual(out.splitlines()[0], "# Perf report") self.assertFalse((self.at / "edits.tsv").exists()) + def goal(self, name="activity view", *extra, ok=True, owner=True): + return self.brigade("goal", "add", "--name", name, "--purpose", "One page of activity", "--done", "The page ships", *extra, + ok=ok, owner=owner) + + def rows_of(self, table): + return [line.split("\t") for line in (self.at / table).read_text().splitlines()[1:]] + + def goal_log(self): + return [row[2:] for row in self.rows_of("log.tsv") if row[1] == "goal"] + + def hand_edit(self, table, ident, **cells): + """Change cells of one row the way an editor would, with no command.""" + path = self.at / table + header, *lines = path.read_text().splitlines() + keys = header.split("\t") + rows = [dict(zip(keys, line.split("\t"))) for line in lines] + for row in rows: + if row["id"] == ident: + row.update(cells) + path.write_text("\n".join([header] + ["\t".join(row.get(key, "") for key in keys) for row in rows]) + "\n") + + NO_UNASSIGNED_WORK = "unassigned: 0 waiting, 0 in flight, 0 merged, 0 send-backs" + + def test_goal_add_prints_the_new_id_appends_one_open_row_and_logs_one_goal_row(self): + self.open() + self.assertEqual(self.goal("activity view", "--share", "2"), "G1") + self.assertEqual(self.goal("current with upstream"), "G2") + rows = self.rows_of("goals.tsv") + self.assertEqual([row[:1] + row[2:] for row in rows], + [["G1", "open", "activity view", "One page of activity", "The page ships", "2", ""], + ["G2", "open", "current with upstream", "One page of activity", "The page ships", "", ""]]) + self.assertEqual(self.goal_log(), [["G1", "open", "activity view"], ["G2", "open", "current with upstream"]]) + + def test_goal_add_refuses_a_name_a_purpose_or_a_done_test_that_is_only_whitespace_and_changes_no_file(self): + self.open() + before = self.store_files() + for flag in ("--name", "--purpose", "--done"): + words = {"--name": "n", "--purpose": "p", "--done": "d", flag: " \t\n"} + with self.subTest(flag=flag): + self.assertEqual(self.brigade("goal", "add", *[word for pair in words.items() for word in pair], ok=False), + "brigade: goal add needs a nonempty --name, --purpose, and --done") + self.assertEqual(self.store_files(), before) + + def test_goal_add_refuses_the_name_of_an_open_goal_and_of_a_paused_goal_and_adds_no_row(self): + self.open() + self.goal("Activity view") + before = self.store_files() + self.assertEqual(self.goal("activity view", ok=False), "brigade: same name as G1 (open); nothing added") + self.assertEqual(self.goal(" activity\tVIEW ", ok=False), "brigade: same name as G1 (open); nothing added") + self.assertEqual(self.store_files(), before) + self.brigade("goal", "set", "G1", "--state", "paused") + self.assertEqual(self.goal("activity view", ok=False), "brigade: same name as G1 (paused); nothing added") + self.assertEqual(len(self.rows_of("goals.tsv")), 1) + + def test_goal_add_takes_the_name_of_a_done_goal_and_of_a_dropped_goal(self): + self.open() + self.goal("activity view") + self.brigade("goal", "set", "G1", "--state", "done", "--evidence", "shipped") + self.assertEqual(self.goal("activity view"), "G2") + self.brigade("goal", "set", "G2", "--state", "dropped", "--evidence", "not wanted") + self.assertEqual(self.goal("activity view"), "G3") + + def test_goal_add_and_goal_set_refuse_a_share_that_is_not_a_whole_number_of_1_or_more_and_change_no_file(self): + self.open() + self.goal("kept") + before = self.store_files() + for share in ("0", "00", "-1", "two", "1.5", "+2", "\u0663"): + refusal = f'brigade: --share takes a whole number of 1 or more, or "" for no limit, got {share!r}' + with self.subTest(share=share): + self.assertEqual(self.goal("new", f"--share={share}", ok=False), refusal) + self.assertEqual(self.brigade("goal", "set", "G1", f"--share={share}", ok=False), refusal) + self.assertEqual(self.store_files(), before) + + def test_goal_add_and_goal_set_store_a_share_without_its_leading_zeros_and_an_empty_share_as_an_empty_cell(self): + self.open() + self.goal("a", "--share", "02") + self.assertEqual(self.table_row(self.at, "goals.tsv", "G1")["share"], "2") + self.assertEqual(self.brigade("goal", "set", "G1", "--share", "010"), "G1 share 10") + self.assertEqual(self.table_row(self.at, "goals.tsv", "G1")["share"], "10") + self.assertEqual(self.brigade("goal", "set", "G1", "--share", ""), "G1 no share") + self.assertEqual(self.table_row(self.at, "goals.tsv", "G1")["share"], "") + + def test_goal_set_refuses_an_id_with_no_row_and_a_call_with_no_change_flag_and_changes_no_file(self): + self.open() + self.goal() + before = self.store_files() + self.assertEqual(self.brigade("goal", "set", "G7", "--state", "paused", ok=False), + "brigade: no goal G7; run goal list --all to see every goal") + self.assertEqual(self.brigade("goal", "set", "G1", ok=False), + "brigade: goal set needs --state, --name, --purpose, --done, or --share") + self.assertEqual(self.brigade("goal", "set", "G1", "--evidence", "x", ok=False), + "brigade: goal set needs --state, --name, --purpose, --done, or --share") + self.assertEqual(self.store_files(), before) + + def test_goal_set_refuses_a_name_a_purpose_or_a_done_test_that_is_only_whitespace_and_changes_no_file(self): + self.open() + self.goal() + before = self.store_files() + for flag in ("--name", "--purpose", "--done"): + with self.subTest(flag=flag): + self.assertEqual(self.brigade("goal", "set", "G1", flag, " ", "--state", "paused", ok=False), + f"brigade: goal set {flag} takes text; a goal keeps a name, a purpose, and a done test") + self.assertEqual(self.store_files(), before) + + def test_goal_set_refuses_evidence_beside_state_open_state_paused_or_no_state_and_changes_no_file(self): + self.open() + self.goal() + before = self.store_files() + for flags in (("--state", "paused"), ("--state", "open"), ("--name", "other")): + with self.subTest(flags=flags): + self.assertEqual(self.brigade("goal", "set", "G1", *flags, "--evidence", "x", ok=False), + "brigade: --evidence goes only with --state done or --state dropped") + self.assertEqual(self.store_files(), before) + + def test_goal_set_refuses_a_move_to_done_and_a_move_to_dropped_with_no_evidence_or_with_evidence_that_is_only_whitespace(self): + self.open() + self.goal() + before = self.store_files() + for state in ("done", "dropped"): + with self.subTest(state=state): + self.assertEqual(self.brigade("goal", "set", "G1", "--state", state, ok=False), + f"brigade: --state {state} needs --evidence <one line>") + self.assertEqual(self.brigade("goal", "set", "G1", "--state", state, "--evidence", " ", ok=False), + f"brigade: --state {state} needs --evidence <one line>") + self.assertEqual(self.store_files(), before) + + def test_goal_set_state_done_and_state_dropped_store_the_time_and_the_evidence_in_the_closed_cell_and_log_the_name_and_the_evidence(self): + self.open() + self.goal("activity view") + self.goal("current with upstream") + self.assertEqual(self.brigade("goal", "set", "G1", "--state", "done", "--evidence", "The page\tshipped in #170"), "G1 done") + self.assertEqual(self.brigade("goal", "set", "G2", "--state", "dropped", "--evidence", "Upstream is frozen"), "G2 dropped") + first, second = (self.table_row(self.at, "goals.tsv", ident) for ident in ("G1", "G2")) + self.assertEqual((first["state"], second["state"]), ("done", "dropped")) + stamp, evidence = first["closed"].split(" ", 1) + self.assertEqual(evidence, "The page shipped in #170") + self.assertEqual(datetime.fromisoformat(stamp).tzinfo, timezone.utc) + self.assertEqual(second["closed"].split(" ", 1)[1], "Upstream is frozen") + self.assertEqual(self.goal_log()[2:], [["G1", "done", "activity view: The page shipped in #170"], + ["G2", "dropped", "current with upstream: Upstream is frozen"]]) + + def test_goal_set_moves_an_open_goal_to_paused_and_a_paused_goal_to_open_and_logs_each_move(self): + self.open() + self.goal() + self.assertEqual(self.brigade("goal", "set", "G1", "--state", "paused"), "G1 paused") + self.assertEqual(self.table_row(self.at, "goals.tsv", "G1")["state"], "paused") + self.assertEqual(self.brigade("goal", "set", "G1", "--state", "open"), "G1 open") + self.assertEqual(self.table_row(self.at, "goals.tsv", "G1")["state"], "open") + self.assertEqual(self.goal_log(), [["G1", "open", "activity view"], ["G1", "paused", "activity view"], + ["G1", "open", "activity view"]]) + + def test_goal_set_closes_a_paused_goal_with_evidence(self): + self.open() + self.goal() + self.brigade("goal", "set", "G1", "--state", "paused") + self.assertEqual(self.brigade("goal", "set", "G1", "--state", "dropped", "--evidence", "not wanted"), "G1 dropped") + self.assertEqual(self.table_row(self.at, "goals.tsv", "G1")["state"], "dropped") + + def test_goal_set_refuses_a_move_from_done_or_dropped_to_a_state_other_than_open_and_changes_no_file(self): + self.open() + self.goal("a") + self.goal("b") + self.brigade("goal", "set", "G1", "--state", "done", "--evidence", "shipped") + self.brigade("goal", "set", "G2", "--state", "dropped", "--evidence", "not wanted") + before = self.store_files() + for ident, state, flags in (("G1", "done", ("--state", "dropped", "--evidence", "x")), ("G1", "done", ("--state", "paused")), + ("G2", "dropped", ("--state", "done", "--evidence", "x")), ("G2", "dropped", ("--state", "paused"))): + with self.subTest(ident=ident, flags=flags): + self.assertEqual(self.brigade("goal", "set", ident, *flags, ok=False), + f"brigade: {ident} is {state}; a {state} goal moves only to open; " + f"run goal set {ident} --state open first") + self.assertEqual(self.store_files(), before) + + def test_goal_set_state_open_on_a_done_goal_and_on_a_dropped_goal_empties_the_closed_cell_and_logs_the_move(self): + self.open() + self.goal("a") + self.goal("b") + self.brigade("goal", "set", "G1", "--state", "done", "--evidence", "shipped") + self.brigade("goal", "set", "G2", "--state", "dropped", "--evidence", "not wanted") + self.assertEqual(self.brigade("goal", "set", "G1", "--state", "open"), "G1 open") + self.assertEqual(self.brigade("goal", "set", "G2", "--state", "open"), "G2 open") + for ident in ("G1", "G2"): + row = self.table_row(self.at, "goals.tsv", ident) + self.assertEqual((row["state"], row["closed"]), ("open", "")) + self.assertEqual(self.goal_log()[2:], [["G1", "done", "a: shipped"], ["G2", "dropped", "b: not wanted"], + ["G1", "open", "a"], ["G2", "open", "b"]]) + + def test_goal_set_with_the_state_the_goal_has_prints_already_and_changes_no_file(self): + self.open() + self.goal("a") + self.goal("b") + self.goal("c") + self.brigade("goal", "set", "G2", "--state", "paused") + self.brigade("goal", "set", "G3", "--state", "done", "--evidence", "shipped") + before = self.store_files() + self.assertEqual(self.brigade("goal", "set", "G1", "--state", "open"), "G1 already open") + self.assertEqual(self.brigade("goal", "set", "G2", "--state", "paused"), "G2 already paused") + self.assertEqual(self.brigade("goal", "set", "G3", "--state", "done"), "G3 already done") + self.assertEqual(self.brigade("goal", "set", "G3", "--state", "done", "--evidence", "other words"), "G3 already done") + self.assertEqual(self.store_files(), before) + + def test_goal_set_prints_its_changes_in_the_order_state_name_purpose_done_test_share_and_writes_each(self): + self.open() + self.goal() + self.assertEqual(self.brigade("goal", "set", "G1", "--share", "3", "--done", "Two pages ship", "--purpose", "Why", + "--name", "Activity\tpage", "--state", "paused"), + "G1 paused; name Activity page; purpose changed; done test changed; share 3") + row = self.table_row(self.at, "goals.tsv", "G1") + self.assertEqual([row[key] for key in ("state", "name", "purpose", "done", "share", "closed")], + ["paused", "Activity page", "Why", "Two pages ship", "3", ""]) + self.assertEqual(self.goal_log()[-1], ["G1", "paused", "Activity page"]) + + def test_goal_set_that_changes_no_state_adds_no_log_row(self): + self.open() + self.goal() + before = ((self.at / "log.tsv").read_bytes(), (self.at / "restaurant.json").read_bytes()) + self.assertEqual(self.brigade("goal", "set", "G1", "--name", "other", "--share", "4"), "G1 name other; share 4") + self.assertEqual(((self.at / "log.tsv").read_bytes(), (self.at / "restaurant.json").read_bytes()), before) + + def test_goal_list_in_a_store_with_no_goal_prints_that_and_the_unassigned_line(self): + self.open() + before = self.store_files() + self.assertEqual(self.run_raw("goal", "list"), (0, f"no open or paused goals\n{self.NO_UNASSIGNED_WORK}\n", "")) + self.assertEqual(self.run_raw("goal", "list", "--all"), (0, f"no goals\n{self.NO_UNASSIGNED_WORK}\n", "")) + self.assertEqual(self.store_files(), before) + + def test_goal_list_prints_each_open_and_paused_goal_in_file_order_with_its_share_and_all_adds_each_done_and_dropped_goal_with_its_closed_cell(self): + self.open() + self.goal("activity view", "--share", "2") + self.goal("old lint") + self.goal("current with upstream") + self.goal("windows port") + self.brigade("goal", "set", "G2", "--state", "done", "--evidence", "shipped in #12") + self.brigade("goal", "set", "G3", "--state", "paused") + self.brigade("goal", "set", "G4", "--state", "dropped", "--evidence", "no users") + closed = {ident: self.table_row(self.at, "goals.tsv", ident)["closed"] for ident in ("G2", "G4")} + self.assertEqual(closed["G2"].split(" ", 1)[1], "shipped in #12") + counts = "0 waiting, 0 in flight, 0 merged, 0 send-backs" + detail = [" purpose: One page of activity", " done when: The page ships"] + self.assertEqual(self.brigade("goal", "list").splitlines(), + [f"G1 open activity view: {counts}, share 2", *detail, + f"G3 paused current with upstream: {counts}", *detail, + self.NO_UNASSIGNED_WORK]) + self.assertEqual(self.brigade("goal", "list", "--all").splitlines(), + [f"G1 open activity view: {counts}, share 2", *detail, + f"G2 done old lint: {counts}", *detail, f" closed: {closed['G2']}", + f"G3 paused current with upstream: {counts}", *detail, + f"G4 dropped windows port: {counts}", *detail, f" closed: {closed['G4']}", + self.NO_UNASSIGNED_WORK]) + + def test_goal_list_with_only_done_and_dropped_goals_prints_no_open_or_paused_goals(self): + self.open() + self.goal() + self.brigade("goal", "set", "G1", "--state", "done", "--evidence", "shipped") + self.assertEqual(self.brigade("goal", "list"), f"no open or paused goals\n{self.NO_UNASSIGNED_WORK}") + + def test_a_goal_state_that_is_none_of_the_four_reads_as_open_with_a_warning_and_goal_set_state_rewrites_the_cell(self): + self.open() + self.goal() + self.hand_edit("goals.tsv", "G1", state="finished") + warning = ("brigade: warning: G1 has state 'finished', which is not open, paused, done, or dropped; it reads as open; " + "run goal set G1 --state <state>\n") + code, out, err = self.run_raw("goal", "list") + self.assertEqual((code, err), (0, warning)) + self.assertEqual(out.splitlines()[0], "G1 open activity view: 0 waiting, 0 in flight, 0 merged, 0 send-backs") + self.assertEqual(self.goal("activity view", ok=False), "brigade: same name as G1 (open); nothing added") + self.assertEqual(self.brigade("goal", "set", "G1", "--state", "open"), "G1 open") + self.assertEqual(self.table_row(self.at, "goals.tsv", "G1")["state"], "open") + self.assertEqual(self.run_raw("goal", "list")[2], "") + self.hand_edit("goals.tsv", "G1", state="finished") + self.assertEqual(self.brigade("goal", "set", "G1", "--state", "paused"), "G1 paused") + self.assertEqual(self.table_row(self.at, "goals.tsv", "G1")["state"], "paused") + + def test_a_goal_share_that_is_not_a_whole_number_of_1_or_more_reads_as_no_limit_with_a_warning_and_goal_set_share_rewrites_the_cell(self): + self.open() + self.goal() + for share in ("lots", "0", "-3"): + with self.subTest(share=share): + self.hand_edit("goals.tsv", "G1", share=share) + code, out, err = self.run_raw("goal", "list") + self.assertEqual((code, err), (0, f"brigade: warning: G1 has share {share!r}, which is not a whole number of 1 or more; " + "it reads as no limit; run goal set G1 --share <n>\n")) + self.assertEqual(out.splitlines()[0], "G1 open activity view: 0 waiting, 0 in flight, 0 merged, 0 send-backs") + self.assertEqual(self.brigade("goal", "set", "G1", "--share", "2"), "G1 share 2") + code, out, err = self.run_raw("goal", "list") + self.assertEqual((code, err), (0, "")) + self.assertEqual(out.splitlines()[0], "G1 open activity view: 0 waiting, 0 in flight, 0 merged, 0 send-backs, share 2") + + def test_a_goal_share_of_five_thousand_digits_is_read_and_printed(self): + self.open() + self.goal() + self.hand_edit("goals.tsv", "G1", share="7" * 5000) + code, out, err = self.run_raw("goal", "list") + self.assertEqual((code, err), (0, "")) + self.assertTrue(out.splitlines()[0].endswith(", share " + "7" * 5000)) + + def test_goal_list_in_a_store_with_no_goals_table_creates_none_and_the_first_goal_add_writes_the_table_with_its_header(self): + self.open() + (self.at / "goals.tsv").unlink() + self.assertEqual(self.brigade("goal", "list"), f"no open or paused goals\n{self.NO_UNASSIGNED_WORK}") + self.assertEqual(self.brigade("goal", "set", "G1", "--state", "paused", ok=False), + "brigade: no goal G1; run goal list --all to see every goal") + self.assertFalse((self.at / "goals.tsv").exists()) + self.assertEqual(self.goal(), "G1") + header, row = (self.at / "goals.tsv").read_text().splitlines() + self.assertEqual(header, "id\tat\tstate\tname\tpurpose\tdone\tshare\tclosed") + self.assertEqual(row.split("\t")[2:], ["open", "activity view", "One page of activity", "The page ships", "", ""]) + + def test_goal_add_and_goal_set_in_a_store_with_a_generation_need_the_owner_and_goal_list_does_not(self): + self.open() + self.brigade("set", "--thread", "t1") + refusal = "brigade: this store is owned by t1@1; pass --owner <thread>@<generation> from status" + self.assertEqual(self.goal(owner=False, ok=False), refusal) + self.assertEqual(self.goal(), "G1") + before = self.store_files() + self.assertEqual(self.brigade("goal", "set", "G1", "--state", "paused", ok=False, owner=False), refusal) + self.assertEqual(self.store_files(), before) + self.assertEqual(self.brigade("goal", "list", owner=False).splitlines()[0], + "G1 open activity view: 0 waiting, 0 in flight, 0 merged, 0 send-backs") + + def test_goal_set_state_refuses_a_state_that_is_none_of_the_four(self): + self.open() + self.goal() + self.assert_refused_choice(self.brigade("goal", "set", "G1", "--state", "finished", ok=False), "--state", "finished", + ("open", "paused", "done", "dropped")) + class StoresTest(unittest.TestCase): """Two or three coordinators on one project root, as `app/<name>` under the store.""" @@ -6777,6 +7113,83 @@ def _land_has_rulings(): return True +class GoalWriteTest(StoresTest): + """A goal command that is killed or that fails partway through its writes.""" + + ADD = ("goal", "add", "--name", "activity view", "--purpose", "One page", "--done", "It ships") + NO_UNASSIGNED_WORK = "unassigned: 0 waiting, 0 in flight, 0 merged, 0 send-backs" + + def goals(self): + return [row[:1] + row[2:] for row in self.rows("core", "goals.tsv")] + + def goal_log(self): + return [row[2:] for row in self.rows("core", "log.tsv") if row[1] == "goal"] + + def test_a_goal_add_killed_inside_its_append_leaves_a_tail_that_goal_list_skips_and_the_next_goal_add_drops(self): + self.open("core") + died = self.child("die-inside-goal-append", "core", *self.ADD) + self.assertEqual(died.returncode, 9, died.stderr) + self.assertRegex((self.dir("core") / "goals.tsv").read_text(), r"\tclosed\nG1\t[^\t\n]+\tope$") + self.assertEqual(self.brigade("core", "goal", "list"), f"no open or paused goals\n{self.NO_UNASSIGNED_WORK}") + self.assertEqual(self.brigade("core", *self.ADD), "G1") + self.assertEqual(self.goals(), [["G1", "open", "activity view", "One page", "It ships", "", ""]]) + self.assertEqual(self.goal_log(), [["G1", "open", "activity view"]]) + + def test_a_goal_add_killed_before_its_log_row_leaves_the_goal_and_the_same_command_again_is_refused_and_adds_no_row(self): + self.open("core") + died = self.child("die-before-log", "core", *self.ADD) + self.assertEqual(died.returncode, 9, died.stderr) + self.assertEqual(self.goals(), [["G1", "open", "activity view", "One page", "It ships", "", ""]]) + self.assertEqual(self.goal_log(), []) + self.assertEqual(self.brigade("core", *self.ADD, ok=False), "brigade: same name as G1 (open); nothing added") + self.assertEqual(len(self.goals()), 1) + self.assertEqual(self.goal_log(), []) + self.assertEqual(self.brigade("core", "goal", "list").splitlines()[0], + "G1 open activity view: 0 waiting, 0 in flight, 0 merged, 0 send-backs") + + def test_a_goal_set_killed_before_its_log_row_leaves_the_new_state_and_the_same_command_again_prints_already(self): + self.open("core") + self.brigade("core", *self.ADD) + died = self.child("die-before-log", "core", "goal", "set", "G1", "--state", "done", "--evidence", "shipped") + self.assertEqual(died.returncode, 9, died.stderr) + state, closed = self.goals()[0][1], self.goals()[0][6] + self.assertEqual((state, closed.split(" ", 1)[1]), ("done", "shipped")) + self.assertEqual(self.goal_log(), [["G1", "open", "activity view"]]) + self.assertEqual(self.brigade("core", "goal", "set", "G1", "--state", "done", "--evidence", "shipped"), "G1 already done") + self.assertEqual(self.goals()[0][6], closed) + self.assertEqual(self.goal_log(), [["G1", "open", "activity view"]]) + + def test_a_goal_set_whose_temp_file_write_fails_leaves_goals_tsv_and_the_log_as_they_were_and_no_temp_file(self): + self.open("core") + self.brigade("core", *self.ADD) + before = self.files("core") + module = runpy.run_path(str(SCRIPT)) + glob = module["run"].__globals__ + + class FailingTempfile: + @staticmethod + def NamedTemporaryFile(*args, **kwargs): + handle = tempfile.NamedTemporaryFile(*args, **kwargs) + write = handle.write + + def fail(text): + write(text[:10]) + handle.flush() + raise OSError(28, "No space left on device") + + handle.write = fail + return handle + + glob["tempfile"] = FailingTempfile + with self.assertRaises(OSError): + glob["run"](["--at", str(self.dir("core")), "goal", "set", "G1", "--state", "paused", "--share", "2"]) + glob["tempfile"] = tempfile + self.assertEqual(self.files("core"), before) + self.assertEqual(self.brigade("core", "goal", "set", "G1", "--state", "paused", "--share", "2"), "G1 paused; share 2") + self.assertEqual(self.goals(), [["G1", "paused", "activity view", "One page", "It ships", "2", ""]]) + self.assertEqual(self.goal_log(), [["G1", "open", "activity view"], ["G1", "paused", "activity view"]]) + + class AdminTest(StoresTest): """The executive admin at `app/.admin`, beside the coordinators on the same root.""" @@ -6791,6 +7204,19 @@ def open_admin(self, *extra, ok=True): def meta(self, name): return json.loads((self.dir(name) / "restaurant.json").read_text()) + def test_goal_add_goal_set_and_goal_list_work_in_the_executive_admins_store(self): + self.open_admin() + self.assertEqual(self.admin("goal", "add", "--name", "routing", "--purpose", "Requests reach an owner", "--done", "Standing", + "--share", "1"), "G1") + self.assertEqual(self.admin("goal", "set", "G1", "--state", "paused"), "G1 paused") + self.assertEqual(self.admin("goal", "list"), "\n".join([ + "G1 paused routing: 0 waiting, 0 in flight, 0 merged, 0 send-backs, share 1", + " purpose: Requests reach an owner", + " done when: Standing", + "unassigned: 0 waiting, 0 in flight, 0 merged, 0 send-backs"])) + self.assertEqual([row[1:] for row in self.rows(".admin", "log.tsv") if row[1] == "goal"], + [["goal", "G1", "open", "routing"], ["goal", "G1", "paused", "routing"]]) + def relays(self): """The admin's relay rows as (store@offset, the copied row).""" return [(row[2], json.loads(row[4])) for row in self.rows(".admin", "log.tsv") if row[1] == "relay"] From 83029155a399053cc929f1a9e212d90146d8ee19 Mon Sep 17 00:00:00 2001 From: Marcus Gibson <marcusroygibson@gmail.com> Date: Sun, 11 Oct 2026 08:17:43 -0500 Subject: [PATCH 07/19] brigade: record a kind and an optional goal on a ticket `ticket add --summary` needs `--kind`, one of feature, fix, polish, upkeep, and study, and takes `--goal` with the id of an open or paused goal. The two new checks run after the owner check and after every refusal the command gave before, so a command line that was refused before is refused with the same words. `ticket set --kind`, `--goal`, and `--no-goal` change a waiting ticket. On a ticket in another state they are refused before any write. `ticket add --from-report` gives each ticket the goal of the report's item and, when that item has a kind, the kind polish. It refuses `--kind` and `--goal`. No command gives an item a kind or a goal yet. `ticket move` writes the ticket's kind into the handoff file and leaves the goal behind, because a goal id names a row of one store. Its line says which goal the ticket leaves. `ticket take` files a handoff with no `kind` key, or with a kind that is none of the five, as a ticket with no kind. A script gave each of the 189 `ticket add --summary` calls in `tests/test_brigade.py` an explicit `--kind fix`. Seven assertions on stored rows and handoff files gain that word, and one gains the three new flags in the `ticket set` refusal. --- docs/cli/brigade.md | 13 +- skills/brigade/scripts/brigade.py | 94 +++- t3/added/brigade/scripts/brigade.py | 94 +++- tests/test_brigade.py | 701 ++++++++++++++++++++-------- 4 files changed, 680 insertions(+), 222 deletions(-) diff --git a/docs/cli/brigade.md b/docs/cli/brigade.md index 3c87b0be..4aa88d04 100644 --- a/docs/cli/brigade.md +++ b/docs/cli/brigade.md @@ -75,13 +75,13 @@ add, list, update, move, or take tickets on the rail ## brigade.py ticket add ```text -brigade.py [flags] ticket add (--summary SUMMARY | --from-report FILE) [--dry-run] [--source SOURCE] [--ref REF] [--request REQUEST] [--again] [--priority PRIORITY] [--paths PATHS] [--decision] +brigade.py [flags] ticket add (--summary SUMMARY | --from-report FILE) [--dry-run] [--source SOURCE] [--ref REF] [--request REQUEST] [--again] [--priority PRIORITY] [--paths PATHS] [--decision] [--kind KIND] [--goal G<n>] ``` | Argument | Required | Choices | Default | Help | | --- | --- | --- | --- | --- | | `--summary SUMMARY` | | | | | -| `--from-report FILE` | | | | an item report under reports/, such as reports/D2.md; files one waiting ticket per follow-up in it and changes only the ticket table, the log, and lastActivityAt | +| `--from-report FILE` | | | | an item report under reports/, such as reports/D2.md; files one waiting ticket per follow-up in it and changes only the ticket table, the log, and lastActivityAt; each ticket takes the goal of the report's item, and when that item has a kind the ticket takes fix at priority urgent or normal and polish at low; a follow-up is filed with no --priority, so it is low | | `--dry-run` | | | | with --from-report: print what it would add; it changes no table and no log | | `--source SOURCE` | | | `user` | | | `--ref REF` | | | | | @@ -90,6 +90,8 @@ brigade.py [flags] ticket add (--summary SUMMARY | --from-report FILE) [--dry-ru | `--priority PRIORITY` | | `urgent`, `normal`, `low` | | missing reads as urgent from upstream, low from report, and normal from every other source | | `--paths PATHS` | | | | comma-separated files and directories the work will touch | | `--decision` | | | | the ticket asks the owner to decide; the startable command never lists it as startable | +| `--kind KIND` | | `feature`, `fix`, `polish`, `upkeep`, `study` | | the kind of work; a ticket added with --summary is refused without it | +| `--goal G<n>` | | | | with --summary: the goal this ticket belongs to, which must be open or paused; without it the ticket is unassigned | Give exactly one of `--summary`, `--from-report`. @@ -106,7 +108,7 @@ brigade.py [flags] ticket list [--state STATE] ## brigade.py ticket set ```text -brigade.py [flags] ticket set [--state STATE] [--priority PRIORITY] [--paths PATHS] [--decision] id +brigade.py [flags] ticket set [--state STATE] [--priority PRIORITY] [--paths PATHS] [--decision] [--kind KIND] [--goal G<n> | --no-goal] id ``` | Argument | Required | Choices | Default | Help | @@ -116,6 +118,11 @@ brigade.py [flags] ticket set [--state STATE] [--priority PRIORITY] [--paths PAT | `--priority PRIORITY` | | `urgent`, `normal`, `low` | | | | `--paths PATHS` | | | | replaces the recorded paths; "" clears them | | `--decision, --no-decision` | | | | mark or unmark the ticket as one that asks the owner to decide | +| `--kind KIND` | | `feature`, `fix`, `polish`, `upkeep`, `study` | | replaces the kind of a waiting ticket; refused on a ticket in another state | +| `--goal G<n>` | | | | replaces the goal of a waiting ticket with one that is open or paused; refused on a ticket in another state | +| `--no-goal` | | | | clears the goal of a waiting ticket, so it is unassigned; refused on a ticket in another state | + +Give at most one of `--goal`, `--no-goal`. ## brigade.py ticket move diff --git a/skills/brigade/scripts/brigade.py b/skills/brigade/scripts/brigade.py index 90b86d03..079f7c9d 100644 --- a/skills/brigade/scripts/brigade.py +++ b/skills/brigade/scripts/brigade.py @@ -53,6 +53,7 @@ AUTOFIRE = ("off", *PRIORITIES_BY_RANK) SOURCE_PRIORITY = {"upstream": "urgent", "report": "low"} KINDS = ("feature", "fix", "polish", "upkeep", "study") +KIND_CHOICE = f"<{'|'.join(KINDS)}>" # The kind fire gives an item when neither its lead ticket nor --kind gives one. A station not listed here gives none. STATION_KIND = {"bug-fix": "fix", "perf-issue": "fix", "investigation": "study"} GOAL_STATES = ("open", "paused", "done", "dropped") @@ -927,6 +928,19 @@ def tallies(found): return LabelCounts(tallies(kinds), tallies(goals), {goal: tuple(ids) for goal, ids in riding.items()}) +def open_goal(labels, ident): + """The goal a ticket is being given. Refuses an id with no row, and a goal that is done or dropped.""" + if not ident: + raise BrigadeError('--goal "" names no goal; run goal list --all to see every goal') + goal = labels.goals.get(ident) + if goal is None: + raise BrigadeError(f"no goal {ident}; run goal list --all to see every goal") + if goal.state in CLOSED_GOAL_STATES: + raise BrigadeError(f"{ident} is {goal.state}; a ticket takes an open or paused goal; " + f"run goal set {ident} --state open first, or pick another goal") + return goal + + def goal_share(text): """--share as the cell to store. Empty text stays empty. Refuses text that is not a whole number of 1 or more.""" text = clean(text) @@ -1048,7 +1062,8 @@ def goal_lines(labels, counted, every): return "\n".join([*lines, f"unassigned: {tally_words(counted.goals.get('', Tally()))}"]) -def add_ticket(restaurant, summary, source, ref, request="", rails=None, again=False, priority="", paths=(), decision=False): +def add_ticket(restaurant, summary, source, ref, request="", rails=None, again=False, priority="", paths=(), decision=False, + kind="", goal=None): source, ref, request = clean(source), clean(ref), clean(request) meta = restaurant.meta if request: @@ -1070,14 +1085,22 @@ def add_ticket(restaurant, summary, source, ref, request="", rails=None, again=F "pass --again to file a second ticket") if request: source = f"{source} (request {request})" - return append_ticket(restaurant, summary, source, ref, priority, paths, decision) + with restaurant.guarded(): + # The kind and goal checks run after the owner fence and after each refusal above, so a ticket add that was + # refused before kinds and goals existed is refused with the same words. + if not kind: + raise BrigadeError(f"ticket add needs --kind {KIND_CHOICE}") + if goal is not None: + open_goal(read_labels(restaurant), goal) + return append_ticket(restaurant, summary, source, ref, priority, paths, decision, kind, goal or "") -def append_ticket(restaurant, summary, source, ref, priority="", paths=(), decision=False): +def append_ticket(restaurant, summary, source, ref, priority="", paths=(), decision=False, kind="", goal=""): ident = restaurant.next_id("rail.tsv") restaurant.append("rail.tsv", {"id": ident, "at": now(), "state": "waiting", "source": source, "ref": ref, "summary": summary, "priority": priority, - "paths": ",".join(paths), "decision": "yes" if decision else ""}) + "paths": ",".join(paths), "decision": "yes" if decision else "", + "kind": kind, "goal": goal}) restaurant.log("ticket", ident, "waiting", summary) return ident @@ -1556,13 +1579,23 @@ def same_text(value): return " ".join(value.split()).casefold() +def follow_up_kind(item_kind, priority): + """The kind of a follow-up ticket. No kind when its item has none. Else fix at priority urgent or normal, and polish at low.""" + if not item_kind: + return "" + return "polish" if priority == "low" else "fix" + + def file_follow_ups(restaurant, name, found, write, tracked): """One waiting ticket per follow-up whose text no ticket of this store holds, in any state. A done or dropped ticket counts, so a rerun on the same report after the coordinator dropped one files nothing. The ref ends in a position, which is not an identity, so the text is the only key and refuse_live_ref is not asked. + Each ticket takes the goal of the report's item, whatever that goal's state, and follow_up_kind of that item's kind. """ restaurant.find("dishes.tsv", name[:-3]) + label = read_labels(restaurant).items[name[:-3]] + kind = follow_up_kind(label.kind, priority_of({"priority": "", "source": "report"})) if found is None: return f"reports/{name} has no follow-ups section; nothing added" aside = [f"not filed, {reason}: {text}" if text else f"not filed, {reason}" for reason, text in found.asides] @@ -1578,7 +1611,7 @@ def file_follow_ups(restaurant, name, found, write, tracked): if key in holders: lines.append(f"skipped {ref}, same text as {holders[key]}: {text}") elif write: - ident = append_ticket(restaurant, text, "report", ref, paths=quoted_paths(text, tracked)) + ident = append_ticket(restaurant, text, "report", ref, paths=quoted_paths(text, tracked), kind=kind, goal=label.goal) holders[key] = f"{ident} (waiting)" lines.append(f"{ident} added from {ref}: {text}") else: @@ -1622,12 +1655,16 @@ def move_ticket(restaurant, ident, to, rails): target_rows = rails.get(target) if target_rows is None: target_rows = Restaurant(target).rows("rail.tsv") + # The kind goes with the ticket. The goal stays, because a goal id names a row of this store's goals.tsv only. + label = read_labels(restaurant).tickets[ident] if taken_row(target_rows, handoff) is None: restaurant.publish(inbox_file(target, handoff), json.dumps({ "handoff": handoff, "summary": row["summary"], "source": base_source(row["source"]), "ref": row["ref"], "priority": row["priority"], "paths": row["paths"], "decision": row["decision"], + **({"kind": label.kind} if label.kind else {}), }, indent=2) + "\n") - return f"{ident} moved to {name}; {tell(name, sibling)}" + left = f"; it leaves {label.goal} here and arrives unassigned" if label.goal else "" + return f"{ident} moved to {name}; {tell(name, sibling)}{left}" def take_tickets(restaurant): @@ -1639,16 +1676,21 @@ def take_tickets(restaurant): raise BrigadeError(f"{path} is not valid JSON: {error}") from error source = handoff["handoff"] row = taken_row(restaurant.rows("rail.tsv"), source) + unknown = "" if row is None: rows = restaurant.rows("rail.tsv") + kind = handoff.get("kind") or "" + if kind not in KINDS: + unknown = f"; kind {clean(kind)} is not one this version knows, so it has no kind" if kind else "" + kind = "" row = {"id": restaurant.next_id("rail.tsv"), "at": now(), "state": "waiting", "source": f"{handoff['source']} (from {source})", "ref": handoff["ref"], "summary": handoff["summary"], - **{key: handoff.get(key, "") for key in ("priority", "paths", "decision")}} + **{key: handoff.get(key, "") for key in ("priority", "paths", "decision")}, "kind": kind} restaurant.save_rows("rail.tsv", rows + [row]) if not any(event["kind"] == "ticket" and event["note"] == f"from {source}" for event in restaurant.rows("log.tsv")): restaurant.log("ticket", row["id"], "waiting", f"from {source}") restaurant.remove(path) - lines.append(f"{row['id']} from {source}: {row['summary']}") + lines.append(f"{row['id']} from {source}: {row['summary']}{unknown}") return lines @@ -3438,7 +3480,9 @@ def parser(): how.add_argument("--summary") how.add_argument("--from-report", metavar="FILE", help="an item report under reports/, such as reports/D2.md; files one waiting ticket per follow-up in it " - "and changes only the ticket table, the log, and lastActivityAt") + "and changes only the ticket table, the log, and lastActivityAt; each ticket takes the goal of the " + "report's item, and when that item has a kind the ticket takes fix at priority urgent or normal " + "and polish at low; a follow-up is filed with no --priority, so it is low") a.add_argument("--dry-run", action="store_true", help="with --from-report: print what it would add; it changes no table and no log") a.add_argument("--source", default="user") a.add_argument("--ref", default="") @@ -3452,6 +3496,10 @@ def parser(): a.add_argument("--paths", default="", help="comma-separated files and directories the work will touch") a.add_argument("--decision", action="store_true", help="the ticket asks the owner to decide; the startable command never lists it as startable") + a.add_argument("--kind", choices=KINDS, help="the kind of work; a ticket added with --summary is refused without it") + a.add_argument("--goal", metavar="G<n>", + help="with --summary: the goal this ticket belongs to, which must be open or paused; without it the " + "ticket is unassigned") a = t.add_parser("list") a.add_argument("--state", choices=TICKET_STATES) a = t.add_parser("set") @@ -3461,6 +3509,13 @@ def parser(): a.add_argument("--paths", help='replaces the recorded paths; "" clears them') a.add_argument("--decision", action=argparse.BooleanOptionalAction, default=None, help="mark or unmark the ticket as one that asks the owner to decide") + a.add_argument("--kind", choices=KINDS, help="replaces the kind of a waiting ticket; refused on a ticket in another state") + goal = a.add_mutually_exclusive_group() + goal.add_argument("--goal", metavar="G<n>", + help="replaces the goal of a waiting ticket with one that is open or paused; refused on a ticket in " + "another state") + goal.add_argument("--no-goal", action="store_true", + help="clears the goal of a waiting ticket, so it is unassigned; refused on a ticket in another state") a = t.add_parser("move", help="hand a waiting ticket to a sibling coordinator through its inbox") a.add_argument("id") a.add_argument("--to", required=True, help="the sibling's directory name") @@ -3826,6 +3881,8 @@ def run(argv): raise BrigadeError("--from-report takes no --source, --ref, --request, or --again") if args.priority or args.paths or args.decision: raise BrigadeError("--from-report takes no --priority, nonempty --paths, or --decision") + if args.kind or args.goal is not None: + raise BrigadeError("--from-report takes no --kind or --goal; each ticket's kind and goal follow from the report's item") name = item_report(restaurant, args.from_report) try: found = follow_ups((restaurant.dir / "reports" / name).read_text(encoding="utf-8")) @@ -3950,7 +4007,8 @@ def command(restaurant, args, contract=None, rails=None): if args.command == "ticket": if args.action == "add": return add_ticket(restaurant, args.summary, args.source, args.ref, args.request, rails or {}, again=args.again, - priority=args.priority or "", paths=args.paths, decision=args.decision) + priority=args.priority or "", paths=args.paths, decision=args.decision, + kind=args.kind or "", goal=args.goal) if args.action == "move": return move_ticket(restaurant, args.id, args.to, rails or {}) if args.action == "take": @@ -3958,6 +4016,11 @@ def command(restaurant, args, contract=None, rails=None): _, ticket = restaurant.find("rail.tsv", args.id) if ticket["state"] == "moved": raise BrigadeError(f"{args.id} moved to {ticket['dish'].removeprefix('to:')}; it is that coordinator's ticket now") + if (args.kind or args.goal is not None or args.no_goal) and ticket["state"] != "waiting": + # fire copied the lead ticket's kind and goal onto the item, and no command writes the item's two cells after that. + kept = (f"; {ticket['dish']} keeps the kind and goal it took when it started" + if ticket["state"] == "assigned" and ticket["dish"] else "") + raise BrigadeError(f"{args.id} is {ticket['state']}; --kind, --goal, and --no-goal change a waiting ticket only{kept}") changes, said = {}, [] if args.state: changes["state"] = args.state @@ -3971,8 +4034,17 @@ def command(restaurant, args, contract=None, rails=None): if args.decision is not None: changes["decision"] = "yes" if args.decision else "" said.append("decision" if args.decision else "no decision") + if args.kind: + changes["kind"] = args.kind + said.append(f"kind {args.kind}") + if args.goal is not None: + changes["goal"] = open_goal(read_labels(restaurant), args.goal).id + said.append(f"goal {args.goal}") + if args.no_goal: + changes["goal"] = "" + said.append("unassigned") if not said: - raise BrigadeError("ticket set needs --state, --priority, --paths, --decision, or --no-decision") + raise BrigadeError("ticket set needs --state, --priority, --paths, --decision, --no-decision, --kind, --goal, or --no-goal") restaurant.update("rail.tsv", args.id, "ticket", **changes) return f"{args.id} {'; '.join(said)}" diff --git a/t3/added/brigade/scripts/brigade.py b/t3/added/brigade/scripts/brigade.py index 90b86d03..079f7c9d 100644 --- a/t3/added/brigade/scripts/brigade.py +++ b/t3/added/brigade/scripts/brigade.py @@ -53,6 +53,7 @@ AUTOFIRE = ("off", *PRIORITIES_BY_RANK) SOURCE_PRIORITY = {"upstream": "urgent", "report": "low"} KINDS = ("feature", "fix", "polish", "upkeep", "study") +KIND_CHOICE = f"<{'|'.join(KINDS)}>" # The kind fire gives an item when neither its lead ticket nor --kind gives one. A station not listed here gives none. STATION_KIND = {"bug-fix": "fix", "perf-issue": "fix", "investigation": "study"} GOAL_STATES = ("open", "paused", "done", "dropped") @@ -927,6 +928,19 @@ def tallies(found): return LabelCounts(tallies(kinds), tallies(goals), {goal: tuple(ids) for goal, ids in riding.items()}) +def open_goal(labels, ident): + """The goal a ticket is being given. Refuses an id with no row, and a goal that is done or dropped.""" + if not ident: + raise BrigadeError('--goal "" names no goal; run goal list --all to see every goal') + goal = labels.goals.get(ident) + if goal is None: + raise BrigadeError(f"no goal {ident}; run goal list --all to see every goal") + if goal.state in CLOSED_GOAL_STATES: + raise BrigadeError(f"{ident} is {goal.state}; a ticket takes an open or paused goal; " + f"run goal set {ident} --state open first, or pick another goal") + return goal + + def goal_share(text): """--share as the cell to store. Empty text stays empty. Refuses text that is not a whole number of 1 or more.""" text = clean(text) @@ -1048,7 +1062,8 @@ def goal_lines(labels, counted, every): return "\n".join([*lines, f"unassigned: {tally_words(counted.goals.get('', Tally()))}"]) -def add_ticket(restaurant, summary, source, ref, request="", rails=None, again=False, priority="", paths=(), decision=False): +def add_ticket(restaurant, summary, source, ref, request="", rails=None, again=False, priority="", paths=(), decision=False, + kind="", goal=None): source, ref, request = clean(source), clean(ref), clean(request) meta = restaurant.meta if request: @@ -1070,14 +1085,22 @@ def add_ticket(restaurant, summary, source, ref, request="", rails=None, again=F "pass --again to file a second ticket") if request: source = f"{source} (request {request})" - return append_ticket(restaurant, summary, source, ref, priority, paths, decision) + with restaurant.guarded(): + # The kind and goal checks run after the owner fence and after each refusal above, so a ticket add that was + # refused before kinds and goals existed is refused with the same words. + if not kind: + raise BrigadeError(f"ticket add needs --kind {KIND_CHOICE}") + if goal is not None: + open_goal(read_labels(restaurant), goal) + return append_ticket(restaurant, summary, source, ref, priority, paths, decision, kind, goal or "") -def append_ticket(restaurant, summary, source, ref, priority="", paths=(), decision=False): +def append_ticket(restaurant, summary, source, ref, priority="", paths=(), decision=False, kind="", goal=""): ident = restaurant.next_id("rail.tsv") restaurant.append("rail.tsv", {"id": ident, "at": now(), "state": "waiting", "source": source, "ref": ref, "summary": summary, "priority": priority, - "paths": ",".join(paths), "decision": "yes" if decision else ""}) + "paths": ",".join(paths), "decision": "yes" if decision else "", + "kind": kind, "goal": goal}) restaurant.log("ticket", ident, "waiting", summary) return ident @@ -1556,13 +1579,23 @@ def same_text(value): return " ".join(value.split()).casefold() +def follow_up_kind(item_kind, priority): + """The kind of a follow-up ticket. No kind when its item has none. Else fix at priority urgent or normal, and polish at low.""" + if not item_kind: + return "" + return "polish" if priority == "low" else "fix" + + def file_follow_ups(restaurant, name, found, write, tracked): """One waiting ticket per follow-up whose text no ticket of this store holds, in any state. A done or dropped ticket counts, so a rerun on the same report after the coordinator dropped one files nothing. The ref ends in a position, which is not an identity, so the text is the only key and refuse_live_ref is not asked. + Each ticket takes the goal of the report's item, whatever that goal's state, and follow_up_kind of that item's kind. """ restaurant.find("dishes.tsv", name[:-3]) + label = read_labels(restaurant).items[name[:-3]] + kind = follow_up_kind(label.kind, priority_of({"priority": "", "source": "report"})) if found is None: return f"reports/{name} has no follow-ups section; nothing added" aside = [f"not filed, {reason}: {text}" if text else f"not filed, {reason}" for reason, text in found.asides] @@ -1578,7 +1611,7 @@ def file_follow_ups(restaurant, name, found, write, tracked): if key in holders: lines.append(f"skipped {ref}, same text as {holders[key]}: {text}") elif write: - ident = append_ticket(restaurant, text, "report", ref, paths=quoted_paths(text, tracked)) + ident = append_ticket(restaurant, text, "report", ref, paths=quoted_paths(text, tracked), kind=kind, goal=label.goal) holders[key] = f"{ident} (waiting)" lines.append(f"{ident} added from {ref}: {text}") else: @@ -1622,12 +1655,16 @@ def move_ticket(restaurant, ident, to, rails): target_rows = rails.get(target) if target_rows is None: target_rows = Restaurant(target).rows("rail.tsv") + # The kind goes with the ticket. The goal stays, because a goal id names a row of this store's goals.tsv only. + label = read_labels(restaurant).tickets[ident] if taken_row(target_rows, handoff) is None: restaurant.publish(inbox_file(target, handoff), json.dumps({ "handoff": handoff, "summary": row["summary"], "source": base_source(row["source"]), "ref": row["ref"], "priority": row["priority"], "paths": row["paths"], "decision": row["decision"], + **({"kind": label.kind} if label.kind else {}), }, indent=2) + "\n") - return f"{ident} moved to {name}; {tell(name, sibling)}" + left = f"; it leaves {label.goal} here and arrives unassigned" if label.goal else "" + return f"{ident} moved to {name}; {tell(name, sibling)}{left}" def take_tickets(restaurant): @@ -1639,16 +1676,21 @@ def take_tickets(restaurant): raise BrigadeError(f"{path} is not valid JSON: {error}") from error source = handoff["handoff"] row = taken_row(restaurant.rows("rail.tsv"), source) + unknown = "" if row is None: rows = restaurant.rows("rail.tsv") + kind = handoff.get("kind") or "" + if kind not in KINDS: + unknown = f"; kind {clean(kind)} is not one this version knows, so it has no kind" if kind else "" + kind = "" row = {"id": restaurant.next_id("rail.tsv"), "at": now(), "state": "waiting", "source": f"{handoff['source']} (from {source})", "ref": handoff["ref"], "summary": handoff["summary"], - **{key: handoff.get(key, "") for key in ("priority", "paths", "decision")}} + **{key: handoff.get(key, "") for key in ("priority", "paths", "decision")}, "kind": kind} restaurant.save_rows("rail.tsv", rows + [row]) if not any(event["kind"] == "ticket" and event["note"] == f"from {source}" for event in restaurant.rows("log.tsv")): restaurant.log("ticket", row["id"], "waiting", f"from {source}") restaurant.remove(path) - lines.append(f"{row['id']} from {source}: {row['summary']}") + lines.append(f"{row['id']} from {source}: {row['summary']}{unknown}") return lines @@ -3438,7 +3480,9 @@ def parser(): how.add_argument("--summary") how.add_argument("--from-report", metavar="FILE", help="an item report under reports/, such as reports/D2.md; files one waiting ticket per follow-up in it " - "and changes only the ticket table, the log, and lastActivityAt") + "and changes only the ticket table, the log, and lastActivityAt; each ticket takes the goal of the " + "report's item, and when that item has a kind the ticket takes fix at priority urgent or normal " + "and polish at low; a follow-up is filed with no --priority, so it is low") a.add_argument("--dry-run", action="store_true", help="with --from-report: print what it would add; it changes no table and no log") a.add_argument("--source", default="user") a.add_argument("--ref", default="") @@ -3452,6 +3496,10 @@ def parser(): a.add_argument("--paths", default="", help="comma-separated files and directories the work will touch") a.add_argument("--decision", action="store_true", help="the ticket asks the owner to decide; the startable command never lists it as startable") + a.add_argument("--kind", choices=KINDS, help="the kind of work; a ticket added with --summary is refused without it") + a.add_argument("--goal", metavar="G<n>", + help="with --summary: the goal this ticket belongs to, which must be open or paused; without it the " + "ticket is unassigned") a = t.add_parser("list") a.add_argument("--state", choices=TICKET_STATES) a = t.add_parser("set") @@ -3461,6 +3509,13 @@ def parser(): a.add_argument("--paths", help='replaces the recorded paths; "" clears them') a.add_argument("--decision", action=argparse.BooleanOptionalAction, default=None, help="mark or unmark the ticket as one that asks the owner to decide") + a.add_argument("--kind", choices=KINDS, help="replaces the kind of a waiting ticket; refused on a ticket in another state") + goal = a.add_mutually_exclusive_group() + goal.add_argument("--goal", metavar="G<n>", + help="replaces the goal of a waiting ticket with one that is open or paused; refused on a ticket in " + "another state") + goal.add_argument("--no-goal", action="store_true", + help="clears the goal of a waiting ticket, so it is unassigned; refused on a ticket in another state") a = t.add_parser("move", help="hand a waiting ticket to a sibling coordinator through its inbox") a.add_argument("id") a.add_argument("--to", required=True, help="the sibling's directory name") @@ -3826,6 +3881,8 @@ def run(argv): raise BrigadeError("--from-report takes no --source, --ref, --request, or --again") if args.priority or args.paths or args.decision: raise BrigadeError("--from-report takes no --priority, nonempty --paths, or --decision") + if args.kind or args.goal is not None: + raise BrigadeError("--from-report takes no --kind or --goal; each ticket's kind and goal follow from the report's item") name = item_report(restaurant, args.from_report) try: found = follow_ups((restaurant.dir / "reports" / name).read_text(encoding="utf-8")) @@ -3950,7 +4007,8 @@ def command(restaurant, args, contract=None, rails=None): if args.command == "ticket": if args.action == "add": return add_ticket(restaurant, args.summary, args.source, args.ref, args.request, rails or {}, again=args.again, - priority=args.priority or "", paths=args.paths, decision=args.decision) + priority=args.priority or "", paths=args.paths, decision=args.decision, + kind=args.kind or "", goal=args.goal) if args.action == "move": return move_ticket(restaurant, args.id, args.to, rails or {}) if args.action == "take": @@ -3958,6 +4016,11 @@ def command(restaurant, args, contract=None, rails=None): _, ticket = restaurant.find("rail.tsv", args.id) if ticket["state"] == "moved": raise BrigadeError(f"{args.id} moved to {ticket['dish'].removeprefix('to:')}; it is that coordinator's ticket now") + if (args.kind or args.goal is not None or args.no_goal) and ticket["state"] != "waiting": + # fire copied the lead ticket's kind and goal onto the item, and no command writes the item's two cells after that. + kept = (f"; {ticket['dish']} keeps the kind and goal it took when it started" + if ticket["state"] == "assigned" and ticket["dish"] else "") + raise BrigadeError(f"{args.id} is {ticket['state']}; --kind, --goal, and --no-goal change a waiting ticket only{kept}") changes, said = {}, [] if args.state: changes["state"] = args.state @@ -3971,8 +4034,17 @@ def command(restaurant, args, contract=None, rails=None): if args.decision is not None: changes["decision"] = "yes" if args.decision else "" said.append("decision" if args.decision else "no decision") + if args.kind: + changes["kind"] = args.kind + said.append(f"kind {args.kind}") + if args.goal is not None: + changes["goal"] = open_goal(read_labels(restaurant), args.goal).id + said.append(f"goal {args.goal}") + if args.no_goal: + changes["goal"] = "" + said.append("unassigned") if not said: - raise BrigadeError("ticket set needs --state, --priority, --paths, --decision, or --no-decision") + raise BrigadeError("ticket set needs --state, --priority, --paths, --decision, --no-decision, --kind, --goal, or --no-goal") restaurant.update("rail.tsv", args.id, "ticket", **changes) return f"{args.id} {'; '.join(said)}" diff --git a/tests/test_brigade.py b/tests/test_brigade.py index 8ce2a2d2..7c2565e6 100644 --- a/tests/test_brigade.py +++ b/tests/test_brigade.py @@ -562,8 +562,8 @@ def test_open_creates_the_store_once_and_keeps_edits(self): def test_fire_groups_waiting_tickets_into_one_dish(self): self.open() self.brigade("set", "--intake", "github") - self.assertEqual(self.brigade("ticket", "add", "--summary", "Startup is slow on cold boot", "--source", "github", "--ref", "#12"), "T1") - self.assertEqual(self.brigade("ticket", "add", "--summary", "Splash screen hangs"), "T2") + self.assertEqual(self.brigade("ticket", "add", "--kind", "fix", "--summary", "Startup is slow on cold boot", "--source", "github", "--ref", "#12"), "T1") + self.assertEqual(self.brigade("ticket", "add", "--kind", "fix", "--summary", "Splash screen hangs"), "T2") self.assertEqual(self.brigade("fire", "--tickets", "T1,T2", "--station", "perf-issue", "--summary", "Cut cold start time"), "D1") self.assertEqual(self.brigade("ticket", "list"), "T1 assigned normal [github] Startup is slow on cold boot #12\nT2 assigned normal [user] Splash screen hangs") @@ -575,65 +575,65 @@ def same_text_refusal(self, ticket, state): def test_ticket_add_refuses_a_user_request_with_the_summary_of_a_waiting_ticket(self): self.open() - self.assertEqual(self.brigade("ticket", "add", "--summary", "Fix the login page"), "T1") - self.assertEqual(self.brigade("ticket", "add", "--summary", "fix the LOGIN page", ok=False), + self.assertEqual(self.brigade("ticket", "add", "--kind", "fix", "--summary", "Fix the login page"), "T1") + self.assertEqual(self.brigade("ticket", "add", "--kind", "fix", "--summary", "fix the LOGIN page", ok=False), self.same_text_refusal("T1", "waiting")) - self.assertEqual(self.brigade("ticket", "add", "--summary", " Fix the\tlogin\npage ", ok=False), + self.assertEqual(self.brigade("ticket", "add", "--kind", "fix", "--summary", " Fix the\tlogin\npage ", ok=False), self.same_text_refusal("T1", "waiting")) self.assertEqual(self.brigade("ticket", "list"), "T1 waiting normal [user] Fix the login page") - self.assertEqual(self.brigade("ticket", "add", "--summary", "Fix the loginpage"), "T2") + self.assertEqual(self.brigade("ticket", "add", "--kind", "fix", "--summary", "Fix the loginpage"), "T2") self.assertEqual(self.brigade("ticket", "list"), "T1 waiting normal [user] Fix the login page\nT2 waiting normal [user] Fix the loginpage") - self.assertEqual(self.brigade("ticket", "add", "--summary", "fix the LOGIN page", "--again"), "T3") + self.assertEqual(self.brigade("ticket", "add", "--kind", "fix", "--summary", "fix the LOGIN page", "--again"), "T3") def test_ticket_add_refuses_a_user_request_with_the_summary_of_an_assigned_ticket(self): self.open() - self.brigade("ticket", "add", "--summary", "Fix the login page") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "Fix the login page") self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "Fix login") - self.assertEqual(self.brigade("ticket", "add", "--summary", "Fix the login page", ok=False), + self.assertEqual(self.brigade("ticket", "add", "--kind", "fix", "--summary", "Fix the login page", ok=False), self.same_text_refusal("T1", "assigned")) def test_ticket_add_takes_a_user_request_with_the_summary_of_a_done_or_dropped_ticket(self): self.open() - self.brigade("ticket", "add", "--summary", "Fix the login page") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "Fix the login page") self.brigade("ticket", "set", "T1", "--state", "done") - self.assertEqual(self.brigade("ticket", "add", "--summary", "Fix the login page"), "T2") + self.assertEqual(self.brigade("ticket", "add", "--kind", "fix", "--summary", "Fix the login page"), "T2") self.brigade("ticket", "set", "T2", "--state", "dropped") - self.assertEqual(self.brigade("ticket", "add", "--summary", "Fix the login page"), "T3") + self.assertEqual(self.brigade("ticket", "add", "--kind", "fix", "--summary", "Fix the login page"), "T3") def test_ticket_add_names_a_waiting_ticket_of_another_source_with_the_same_summary(self): self.open() self.brigade("set", "--intake", "github") - self.brigade("ticket", "add", "--summary", "Fix the login page", "--source", "github", "--ref", "#12") - self.assertEqual(self.brigade("ticket", "add", "--summary", "Fix the login page", ok=False), + self.brigade("ticket", "add", "--kind", "fix", "--summary", "Fix the login page", "--source", "github", "--ref", "#12") + self.assertEqual(self.brigade("ticket", "add", "--kind", "fix", "--summary", "Fix the login page", ok=False), self.same_text_refusal("T1", "waiting")) def test_ticket_add_with_a_request_id_still_refuses_the_summary_of_a_waiting_ticket(self): self.open() - self.brigade("ticket", "add", "--summary", "Fix the login page") - self.assertEqual(self.brigade("ticket", "add", "--summary", "Fix the login page", "--request", "A2", ok=False), + self.brigade("ticket", "add", "--kind", "fix", "--summary", "Fix the login page") + self.assertEqual(self.brigade("ticket", "add", "--kind", "fix", "--summary", "Fix the login page", "--request", "A2", ok=False), self.same_text_refusal("T1", "waiting")) def test_ticket_add_with_a_ref_takes_the_summary_of_a_waiting_ticket(self): self.open() - self.brigade("ticket", "add", "--summary", "Fix the login page") - self.assertEqual(self.brigade("ticket", "add", "--summary", "Fix the login page", "--ref", "#12"), "T2") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "Fix the login page") + self.assertEqual(self.brigade("ticket", "add", "--kind", "fix", "--summary", "Fix the login page", "--ref", "#12"), "T2") def test_again_lifts_neither_the_ref_refusal_nor_the_request_refusal_nor_the_source_refusal(self): self.open() - self.brigade("ticket", "add", "--summary", "Fix the login page", "--ref", "#12", "--request", "A2") - self.assertEqual(self.brigade("ticket", "add", "--summary", "Fix the login page", "--ref", "#12", "--again", ok=False), + self.brigade("ticket", "add", "--kind", "fix", "--summary", "Fix the login page", "--ref", "#12", "--request", "A2") + self.assertEqual(self.brigade("ticket", "add", "--kind", "fix", "--summary", "Fix the login page", "--ref", "#12", "--again", ok=False), "brigade: #12 is already T1 (waiting); nothing added") - self.assertEqual(self.brigade("ticket", "add", "--summary", "Fix the login page", "--request", "A2", "--again", ok=False), + self.assertEqual(self.brigade("ticket", "add", "--kind", "fix", "--summary", "Fix the login page", "--request", "A2", "--again", ok=False), "brigade: request A2 is already T1; nothing added") - self.assertEqual(self.brigade("ticket", "add", "--summary", "Fix the login page", "--source", "github", "--again", ok=False), + self.assertEqual(self.brigade("ticket", "add", "--kind", "fix", "--summary", "Fix the login page", "--source", "github", "--again", ok=False), "brigade: no coordinator owns intake from github; the one that reads it runs set --intake github") self.assertEqual(self.brigade("ticket", "list"), "T1 waiting normal [user (request A2)] Fix the login page #12") def test_the_skill_quotes_the_same_summary_refusal_ticket_add_prints(self): self.open() - self.brigade("ticket", "add", "--summary", "Fix the login page") - line = self.brigade("ticket", "add", "--summary", "Fix the login page", ok=False) + self.brigade("ticket", "add", "--kind", "fix", "--summary", "Fix the login page") + line = self.brigade("ticket", "add", "--kind", "fix", "--summary", "Fix the login page", ok=False) text = (ROOT / "t3/added/brigade/SKILL.md").read_text() section = text.split("## Filing tracker work", 1)[1].split("\n## ", 1)[0] step = next(row for row in section.splitlines() if row.startswith("4. **Refusals.**")) @@ -643,7 +643,7 @@ def test_the_skill_quotes_the_same_summary_refusal_ticket_add_prints(self): def test_pass_refuses_a_verifier_from_the_author_family(self): self.open() - self.brigade("ticket", "add", "--summary", "s") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "s") self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "Fix s") error = self.brigade("pass", "record", "D1", "--pr", "u/1", "--sha", "abc", "--verdict", "pass", "--author", CLAUDE, "--verifier", "cursor/claude-sonnet-5-5", ok=False) @@ -654,7 +654,7 @@ def test_pass_refuses_a_verifier_from_the_author_family(self): def test_queueing_or_merging_needs_a_pass_at_the_current_sha(self): self.open() - self.brigade("ticket", "add", "--summary", "s") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "s") self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "Fix s") self.brigade("pass", "record", "D1", "--pr", "u/1", "--sha", "abc", "--verdict", "pass", "--author", CLAUDE, "--verifier", CODEX) self.assertEqual(self.brigade("dish", "D1", "--state", "queued", "--sha", "def", ok=False), @@ -666,7 +666,7 @@ def test_queueing_or_merging_needs_a_pass_at_the_current_sha(self): def test_send_back_blocks_landing(self): self.open() - self.brigade("ticket", "add", "--summary", "s") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "s") self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "Fix s") self.brigade("pass", "record", "D1", "--pr", "u/1", "--sha", "abc", "--verdict", "send-back", "--author", CLAUDE, "--verifier", CODEX, "--note", "test asserts the bug") @@ -685,7 +685,7 @@ def record(self, sha, verdict, *extra, author=CLAUDE, verifier=CODEX): def fired_bug_fix(self): self.open() - self.brigade("ticket", "add", "--summary", "s") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "s") self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "Fix s") def test_pass_check_json_prints_a_cross_family_pass(self): @@ -1156,7 +1156,7 @@ def changed_by(*command): after = self.store_files() return before, sorted(name for name in after if after[name] != before.get(name)) - _, by_hand = changed_by("ticket", "add", "--summary", "by hand") + _, by_hand = changed_by("ticket", "add", "--kind", "fix", "--summary", "by hand") (self.at / "restaurant.lock").unlink() before, from_report = changed_by("ticket", "add", "--from-report", "reports/D1.md") self.assertEqual(from_report, ["log.tsv", "rail.tsv", "restaurant.json"]) @@ -1166,7 +1166,7 @@ def changed_by(*command): def test_from_report_skips_text_a_ticket_already_holds_whatever_its_spacing_and_case(self): self.fired_bug_fix() - self.brigade("ticket", "add", "--summary", "pr #87 was NOT commented on, approved, or closed.") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "pr #87 was NOT commented on, approved, or closed.") self.assertEqual(self.follow_ups_in(BULLETS).splitlines()[2], "skipped perf/reports/D1.md#3, same text as T2 (waiting): " "PR #87 was not commented on, approved, or closed.") @@ -1278,9 +1278,9 @@ def test_from_report_refuses_the_flags_of_a_hand_written_ticket(self): for extra in (("--source", "github"), ("--ref", "x"), ("--request", "A1"), ("--again",)): self.assertEqual(self.brigade(*add, *extra, ok=False), "brigade: --from-report takes no --source, --ref, --request, or --again") - self.assertEqual(self.brigade("ticket", "add", "--summary", "x", "--dry-run", ok=False), + self.assertEqual(self.brigade("ticket", "add", "--kind", "fix", "--summary", "x", "--dry-run", ok=False), "brigade: --dry-run needs --from-report") - self.assertEqual(self.brigade("ticket", "add", "--summary", "x", "--source", "report", ok=False), + self.assertEqual(self.brigade("ticket", "add", "--kind", "fix", "--summary", "x", "--source", "report", ok=False), "brigade: no coordinator owns intake from report; the one that reads it runs set --intake report") for flags, message in ((("--summary", "x", "--from-report", "reports/D1.md"), "argument --from-report: not allowed with argument --summary"), @@ -1561,7 +1561,7 @@ def test_close_warns_about_a_live_link_and_a_directory_named_like_review_reports def test_close_warns_about_a_dangling_link_that_only_a_row_of_another_item_names(self): self.fired_bug_fix() - self.brigade("ticket", "add", "--summary", "t") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "t") self.brigade("fire", "--tickets", "T2", "--station", "bug-fix", "--summary", "Fix t") self.review_file("D2-review-1.md") self.brigade("pass", "record", "D2", "--sha", "abc", "--verdict", "send-back", "--author", CLAUDE, "--verifier", CODEX, @@ -1617,8 +1617,8 @@ def test_close_ignores_a_file_that_is_not_a_review_report(self): def test_report_lists_only_what_changed_since_the_last_report(self): self.open() - self.brigade("ticket", "add", "--summary", "Startup is slow") - self.brigade("ticket", "add", "--summary", "Splash hangs") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "Startup is slow") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "Splash hangs") self.brigade("fire", "--tickets", "T1", "--station", "perf-issue", "--summary", "Cut cold start time") self.brigade("86", "add", "--question", "Drop Windows 7 support?", "--options", "yes, no", "--default", "no") first = self.brigade("close") @@ -1639,8 +1639,8 @@ def test_report_lists_only_what_changed_since_the_last_report(self): def test_a_dish_that_moves_through_every_state_in_one_report_appears_once(self): self.open() - self.brigade("ticket", "add", "--summary", "s") - self.brigade("ticket", "add", "--summary", "UI", "--source", "user") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "s") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "UI", "--source", "user") self.brigade("ticket", "set", "T2", "--state", "dropped") self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "Fix s") self.brigade("dish", "D1", "--state", "in-review", "--sha", "abc") @@ -1655,8 +1655,8 @@ def test_a_dish_that_moves_through_every_state_in_one_report_appears_once(self): def test_close_to_file_prints_the_written_path_and_advances_the_report(self): self.open() - self.brigade("ticket", "add", "--summary", "s") - self.brigade("ticket", "add", "--summary", "UI", "--source", "user") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "s") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "UI", "--source", "user") self.brigade("ticket", "set", "T2", "--state", "dropped") self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "Fix s") self.brigade("dish", "D1", "--state", "in-review", "--sha", "abc") @@ -1684,7 +1684,7 @@ def test_status_help_names_the_thread_line_then_counts(self): def test_status_and_walk_speak_plain_engineering_prose(self): self.open() self.brigade("set", "--thread", "thread-1", "--schedule", "report=s-1") - self.brigade("ticket", "add", "--summary", "s") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "s") self.brigade("86", "add", "--question", "Ship it?", "--options", "yes, no", "--default", "no") self.assertEqual(self.brigade("status"), "thread thread-1\nreporting: milestones, no landing contract, waiting tickets: 1, decisions for you: 1\nworkers: 0 of 2 running, 2 idle, auto-start: normal\nowner thread-1@1") @@ -1768,11 +1768,11 @@ def test_walk_groups_repositories_and_repo_selects_one(self): self.run_at(docs, "set", "--thread", "thread-docs") self.run_at(engine, "set", "--thread", "thread-engine") self.run_at(core, "set", "--thread", "thread-core") - self.run_at(docs, "ticket", "add", "--summary", "Write the guide") + self.run_at(docs, "ticket", "add", "--kind", "fix", "--summary", "Write the guide") self.assertEqual(self.run_at(docs, "fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "Write the guide", "--paths", "a.txt"), "D1 (lease L1 held by docs/D1)") - self.run_at(engine, "ticket", "add", "--summary", "Blocked edit") + self.run_at(engine, "ticket", "add", "--kind", "fix", "--summary", "Blocked edit") refused = self.run_at(engine, "fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "Blocked edit", "--paths", "a.txt", ok=False) self.assertIn("nothing fired: paths overlap L1 held by docs/D1", refused) @@ -1802,7 +1802,7 @@ def test_walk_groups_repositories_and_repo_selects_one(self): def fire_one(self, station="perf-issue", timebox="60"): (self.at / "menu.md").write_text("# Menu: Perf\n\n## Purpose\n\nMake startup fast.\n\n## Budget\n\nsmall\n") - self.brigade("ticket", "add", "--summary", "Startup is slow") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "Startup is slow") return self.brigade("fire", "--tickets", "T1", "--station", station, "--summary", "Cut startup", "--branch", "perf/d1", "--thread", "thread-9", "--timebox", timebox) @@ -1860,7 +1860,7 @@ def test_brief_refuses_when_no_coordinator_thread_is_recorded(self): def test_brief_refuses_a_menu_without_a_purpose_or_a_missing_acceptance(self): self.open() - self.brigade("ticket", "add", "--summary", "s") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "s") self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "Fix s") self.brigade("set", "--thread", "thread-coord") args = ["brief", "D1", "--contract", "none", "--goal", "g", "--verify", "v", "--paths", "a", "--lease", "L1", "--base", "origin/main"] @@ -1975,8 +1975,8 @@ def test_fire_claims_the_lease_and_a_refused_claim_fires_nothing(self): check=True, capture_output=True) self.open() (self.at / "menu.md").write_text("## Purpose\n\nFast.\n") - self.brigade("ticket", "add", "--summary", "one") - self.brigade("ticket", "add", "--summary", "two") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "one") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "two") self.assertEqual(self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "s", "--paths", "src"), "D1 (lease L1 held by perf/D1)") self.assertIn("nothing fired: paths overlap L1 held by perf/D1", @@ -2002,7 +2002,7 @@ def test_fire_claims_outside_the_store_lock_and_rechecks_before_it_writes(self): "else:\n print('L7 released')\n") self.open() (self.at / "menu.md").write_text("## Purpose\n\nFast.\n") - self.brigade("ticket", "add", "--summary", "one") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "one") firing = subprocess.Popen([sys.executable, str(Path(__file__).resolve()), "--race-child", "fake-land", str(case), "--store", str(self.store), "--at", str(self.at), "fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "s", "--paths", "src"], @@ -2026,7 +2026,7 @@ def test_fire_claims_outside_the_store_lock_and_rechecks_before_it_writes(self): def test_tabs_and_newlines_in_input_cannot_break_a_table(self): self.open() - self.brigade("ticket", "add", "--summary", "line one\nline\ttwo") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "line one\nline\ttwo") self.assertEqual(self.brigade("ticket", "list"), "T1 waiting normal [user] line one line two") def test_commands_outside_a_restaurant_fail_with_the_fix(self): @@ -2147,7 +2147,7 @@ def test_a_brief_waiting_on_stdin_leaves_the_store_to_other_commands(self): others = [subprocess.Popen([sys.executable, str(SCRIPT), "--store", str(self.store), "--at", str(self.at), *_owner_words(self.at), *args], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - for args in (("status",), ("watch",), ("ticket", "add", "--summary", "second"))] + for args in (("status",), ("watch",), ("ticket", "add", "--kind", "fix", "--summary", "second"))] outputs = [other.communicate(timeout=10) for other in others] self.assertEqual([other.returncode for other in others], [0, 0, 0], outputs) self.assertEqual(outputs[2][0].strip(), "T2") @@ -2423,7 +2423,7 @@ def test_dropping_an_item_returns_its_tickets_to_waiting(self): with mock.patch.dict(os.environ, {"XDG_STATE_HOME": self.land_env()["XDG_STATE_HOME"]}): self.open() self.brigade("set", "--intake", "github") - self.assertEqual(self.brigade("ticket", "add", "--summary", "bug", "--source", "github", "--ref", ref), "T1") + self.assertEqual(self.brigade("ticket", "add", "--kind", "fix", "--summary", "bug", "--source", "github", "--ref", ref), "T1") self.assertEqual(self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "fix", "--paths", "a.py"), "D1 (lease L1 held by perf/D1)") self.assertEqual(self.brigade("dish", "D1", "--state", "dropped"), "D1 dropped; T1 waiting again") @@ -2433,7 +2433,7 @@ def test_dropping_an_item_returns_its_tickets_to_waiting(self): [("dish", "D1", "dropped", "fix (released L1)"), ("ticket", "T1", "waiting", "bug (back from dropped D1)")]) self.assertIn("waiting tickets: 1", self.brigade("status")) - self.assertEqual(self.brigade("ticket", "add", "--summary", "bug again", "--source", "github", "--ref", ref, + self.assertEqual(self.brigade("ticket", "add", "--kind", "fix", "--summary", "bug again", "--source", "github", "--ref", ref, ok=False), f"brigade: {ref} is already T1 (waiting); nothing added") self.assertEqual(self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "fix", "--paths", "a.py"), "D2 (lease L2 held by perf/D2)") @@ -2445,7 +2445,7 @@ def test_dropping_an_item_returns_its_tickets_to_waiting(self): def test_dropping_an_item_leaves_its_other_tickets_alone(self): self.open() for summary in ("one", "two"): - self.brigade("ticket", "add", "--summary", summary) + self.brigade("ticket", "add", "--kind", "fix", "--summary", summary) self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "a") self.brigade("fire", "--tickets", "T2", "--station", "bug-fix", "--summary", "b") self.brigade("ticket", "set", "T2", "--state", "done") @@ -2457,7 +2457,7 @@ def test_blocked_counts_follow_the_block_that_holds_now(self): self.init_landing() with mock.patch.dict(os.environ, {"XDG_STATE_HOME": self.land_env()["XDG_STATE_HOME"]}): self.open() - self.brigade("ticket", "add", "--summary", "Fix the gate") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "Fix the gate") self.land("lease", "claim", "--holder", "engine/D1", "--paths", "src") self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "Fix the gate", "--paths", "src", ok=False) @@ -2475,8 +2475,8 @@ def test_fire_and_watch_name_the_worker_cap_first_when_both_caps_hold(self): self.init_landing("--cap", "1") with mock.patch.dict(os.environ, {"XDG_STATE_HOME": self.land_env()["XDG_STATE_HOME"]}): self.brigade("open", "--project-root", str(self.project), "--name", "Perf", "--workers", "1") - self.brigade("ticket", "add", "--summary", "one") - self.brigade("ticket", "add", "--summary", "two") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "one") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "two") self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "one", "--paths", "src") refused = self.brigade("fire", "--tickets", "T2", "--station", "bug-fix", "--summary", "two", "--paths", "docs", ok=False) @@ -2493,7 +2493,7 @@ def renamed(self, hook=None): """Fire D1 on perf/d1, then run dish D1 --branch perf/d1-r2 in this process, calling hook at its lease list.""" self.open() self.brigade("set", "--thread", "coordinator") - self.brigade("ticket", "add", "--summary", "one") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "one") self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "s", "--branch", "perf/d1", "--paths", "src") glob = runpy.run_path(str(SCRIPT))["run"].__globals__ @@ -2572,7 +2572,7 @@ def test_a_branch_rename_after_submit_says_the_lease_cannot_change(self): from unittest import mock with mock.patch.dict(os.environ, self.land_env()): self.open() - self.brigade("ticket", "add", "--summary", "one") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "one") self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "one", "--branch", "perf/d1", "--paths", "a.txt") sha = self.worker_commit("perf/d1", {"a.txt": "changed\n"}) @@ -2710,7 +2710,7 @@ def test_a_losing_open_keeps_the_owners_ticket_and_menu(self): else: owner_code = owner_out = owner_err = None added = subprocess.run( - [sys.executable, str(SCRIPT), "--at", str(directory), "ticket", "add", "--summary", "Keep this ticket"], + [sys.executable, str(SCRIPT), "--at", str(directory), "ticket", "add", "--kind", "fix", "--summary", "Keep this ticket"], capture_output=True, text=True, env=dict(os.environ, PYTHONDONTWRITEBYTECODE="1")) self.assertEqual((added.returncode, added.stdout), (0, "T1\n"), added.stderr) menu = directory / "menu.md" @@ -2809,7 +2809,7 @@ def test_a_refused_fire_stays_blocked_until_the_lease_is_free(self): self.init_landing() with mock.patch.dict(os.environ, {"XDG_STATE_HOME": self.land_env()["XDG_STATE_HOME"]}): self.open() - self.brigade("ticket", "add", "--summary", "Fix the gate") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "Fix the gate") self.land("lease", "claim", "--holder", "engine/D1", "--paths", "src") refused = self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "Fix the gate", "--paths", "src", "--timebox", "60", ok=False) @@ -2834,7 +2834,7 @@ def test_an_unblocked_command_round_trips_through_bash(self): self.init_landing() with mock.patch.dict(os.environ, {"XDG_STATE_HOME": self.land_env()["XDG_STATE_HOME"]}): self.open() - self.brigade("ticket", "add", "--summary", "quoted") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "quoted") self.land("lease", "claim", "--holder", "engine/D1", "--paths", "src/app.py") refused = self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", summary, "--branch", "docs/gate", "--paths", "src/app.py", "--timebox", "45", ok=False) @@ -2853,12 +2853,12 @@ def test_an_unblocked_command_round_trips_through_bash(self): def test_an_option_leading_summary_round_trips_through_bash(self): self.brigade("open", "--project-root", str(self.project), "--name", "Perf", "--workers", "1") - self.brigade("ticket", "add", "--summary", "seed") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "seed") self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "seed") blocker = "D1" cases = (("-Werror", "T2", "D2"), ("--help", "T3", "D3")) for summary, ticket, dish in cases: - self.brigade("ticket", "add", f"--summary={summary}") + self.brigade("ticket", "add", "--kind", "fix", f"--summary={summary}") refused = self.brigade("fire", "--tickets", ticket, "--station", "bug-fix", f"--summary={summary}", "--timebox", "45", ok=False) self.assertIn("nothing fired: 1 of 1 workers running", refused) @@ -2879,8 +2879,8 @@ def test_a_later_fire_does_not_keep_the_refused_attempts_fragment(self): directory = self.store / "bridge-kit" / "review-more" opened = self.brigade("open", "--project-root", str(self.project), "--name", "Review more") self.assertEqual(opened, f"opened {directory}") - self.run_at(directory, "ticket", "add", "--summary", "Blocked on src") - self.run_at(directory, "ticket", "add", "--summary", "Takes the first id") + self.run_at(directory, "ticket", "add", "--kind", "fix", "--summary", "Blocked on src") + self.run_at(directory, "ticket", "add", "--kind", "fix", "--summary", "Takes the first id") self.land("lease", "claim", "--holder", "engine/D9", "--paths", "src") refused = self.run_at(directory, "fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "Blocked on src", "--paths", "src", "--timebox", "30", ok=False) @@ -2912,7 +2912,7 @@ def test_a_refused_fire_keeps_an_explicit_branch(self): directory = self.store / "bridge-kit" / "review" opened = self.brigade("open", "--project-root", str(self.project), "--name", "Review") self.assertEqual(opened, f"opened {directory}") - self.run_at(directory, "ticket", "add", "--summary", "Explicit branch") + self.run_at(directory, "ticket", "add", "--kind", "fix", "--summary", "Explicit branch") self.land("lease", "claim", "--holder", "engine/D1", "--paths", "src") refused = self.run_at(directory, "fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "Explicit branch", "--branch", "docs/a%b/c", @@ -2936,9 +2936,9 @@ def test_a_refused_fire_keeps_an_explicit_branch(self): def test_a_worker_refusal_prints_the_running_count(self): self.brigade("open", "--project-root", str(self.project), "--name", "Perf", "--workers", "3") for number, summary in enumerate(("one", "two", "three"), start=1): - self.brigade("ticket", "add", "--summary", summary) + self.brigade("ticket", "add", "--kind", "fix", "--summary", summary) self.brigade("fire", "--tickets", f"T{number}", "--station", "bug-fix", "--summary", summary) - self.brigade("ticket", "add", "--summary", "four") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "four") self.brigade("set", "--workers", "1") refused = self.brigade("fire", "--tickets", "T4", "--station", "bug-fix", "--summary", "four", ok=False) self.assertIn("nothing fired: 3 of 1 workers running", refused) @@ -2957,7 +2957,7 @@ def test_a_failed_lease_check_keeps_the_landing_diagnostic(self): " sys.exit(1)\n" "sys.exit(0)\n") self.open() - self.brigade("ticket", "add", "--summary", "one") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "one") refused = subprocess.run([sys.executable, str(Path(__file__).resolve()), "--race-child", "fake-land", str(case), "--store", str(self.store), "--at", str(self.at), "fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "s", "--paths", "src"], @@ -2975,7 +2975,7 @@ def test_a_repository_cap_refusal_prints_the_repository_line(self): self.init_landing("--cap", "1") with mock.patch.dict(os.environ, {"XDG_STATE_HOME": self.land_env()["XDG_STATE_HOME"]}): self.open() - self.brigade("ticket", "add", "--summary", "Room") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "Room") self.land("lease", "claim", "--holder", "engine/D1", "--paths", "other.txt") refused = self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "Room", "--paths", "src", ok=False) @@ -2987,7 +2987,7 @@ def test_the_worker_cap_defaults_to_two_and_a_later_fire_clears_the_block(self): self.open() self.assertNotIn("workers", json.loads((self.at / "restaurant.json").read_text())) for summary in ("one", "two", "three"): - self.brigade("ticket", "add", "--summary", summary) + self.brigade("ticket", "add", "--kind", "fix", "--summary", summary) self.assertEqual(self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "a"), "D1") self.assertEqual(self.brigade("fire", "--tickets", "T2", "--station", "bug-fix", "--summary", "b"), "D2") refused = self.brigade("fire", "--tickets", "T3", "--station", "bug-fix", "--summary", "c", ok=False) @@ -3005,7 +3005,7 @@ def test_workers_one_refuses_fire_and_a_dish_that_is_not_already_counted(self): self.assertEqual(opened, f"opened {self.at}") self.assertEqual(json.loads((self.at / "restaurant.json").read_text())["workers"], 1) for summary in ("one", "two", "three"): - self.brigade("ticket", "add", "--summary", summary) + self.brigade("ticket", "add", "--kind", "fix", "--summary", summary) self.assertEqual(self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "a"), "D1") refused = self.brigade("fire", "--tickets", "T2", "--station", "bug-fix", "--summary", "b", ok=False) self.assertIn("nothing fired: 1 of 1 workers running", refused) @@ -3049,8 +3049,8 @@ def test_fire_leases_the_branch_changelog_fragment_and_dish_records_a_new_lease( self.open() (self.at / "menu.md").write_text("## Purpose\n\nFast.\n") self.brigade("set", "--thread", "thread-coord") - self.brigade("ticket", "add", "--summary", "one") - self.brigade("ticket", "add", "--summary", "two") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "one") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "two") self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "s", "--branch", "docs/a", "--paths", "src/a.py") text = self.brigade("brief", "D1", "--contract", "none", "--goal", "g", "--acceptance", "a", "--verify", "v", "--base", "origin/main") @@ -3079,7 +3079,7 @@ def test_fire_warns_when_releasing_a_conflicting_lease_fails(self): " print('lease L7 is held elsewhere', file=sys.stderr)\n" " sys.exit(1)\n") self.open() - self.brigade("ticket", "add", "--summary", "one") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "one") firing = subprocess.Popen([sys.executable, str(Path(__file__).resolve()), "--race-child", "fake-land", str(case), "--store", str(self.store), "--at", str(self.at), "fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "s", "--paths", "src"], @@ -3117,7 +3117,7 @@ def test_watch_rechecks_a_lease_outside_the_store_lock(self): " sys.exit(1)\n" "print('ok')\n") self.open() - self.brigade("ticket", "add", "--summary", "one") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "one") refused = subprocess.run([sys.executable, str(Path(__file__).resolve()), "--race-child", "fake-land", str(case), "--store", str(self.store), "--at", str(self.at), "fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "s", "--paths", "src"], @@ -3131,7 +3131,7 @@ def test_watch_rechecks_a_lease_outside_the_store_lock(self): try: _wait_for_path(case / "checking") added = subprocess.run([sys.executable, str(SCRIPT), "--store", str(self.store), "--at", str(self.at), - "ticket", "add", "--summary", "two"], + "ticket", "add", "--kind", "fix", "--summary", "two"], capture_output=True, text=True, timeout=10) self.assertEqual(added.stdout.strip(), "T2", added.stderr) (case / "check-proceed").touch() @@ -3153,7 +3153,7 @@ def started(self, *init, paths="README.md"): self.landing_env() self.open() (self.at / "menu.md").write_text("## Purpose\n\nFast.\n") - self.brigade("ticket", "add", "--summary", "one") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "one") self.assertEqual(self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "s", "--paths", paths), "D1 (lease L1 held by perf/D1)") @@ -4047,7 +4047,7 @@ def test_a_late_worker_submit_after_the_drop_is_refused(self): engine = self.store / "bridge-kit" / "engine" self.run_at(engine, "open", "--project-root", str(self.project), "--name", "Engine") (engine / "menu.md").write_text("## Purpose\n\nEngine.\n") - self.run_at(engine, "ticket", "add", "--summary", "engine work") + self.run_at(engine, "ticket", "add", "--kind", "fix", "--summary", "engine work") self.assertEqual(self.run_at(engine, "fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "e", "--paths", "README.md"), "D1 (lease L2 held by engine/D1)") (case / "go").touch() @@ -4070,7 +4070,7 @@ def test_a_replaced_coordinators_paused_commands_change_nothing(self): self.run_at(docs, "set", "--thread", "t1") old = ("--owner", "t1@1") self.assertIn("owner t1@1", self.run_at(docs, *old, "status")) - self.run_at(docs, *old, "ticket", "add", "--summary", "one") + self.run_at(docs, *old, "ticket", "add", "--kind", "fix", "--summary", "one") self.assertEqual(self.run_at(docs, *old, "fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "s", "--paths", "README.md"), "D1 (lease L1 held by docs/D1)") self.run_at(docs, *old, "dish", "D1", "--thread", "w1") @@ -4079,7 +4079,7 @@ def test_a_replaced_coordinators_paused_commands_change_nothing(self): stale_land = "land: owner docs/@1 is stale; docs/ is at generation 2" land_script = str(ROOT / "t3/added/landing/scripts/land.py") commands = [ - ("pause", ["--store", str(self.store), "--at", str(docs), *old, "ticket", "add", "--summary", "late"], stale_brigade), + ("pause", ["--store", str(self.store), "--at", str(docs), *old, "ticket", "add", "--kind", "fix", "--summary", "late"], stale_brigade), ("pause", ["--store", str(self.store), "--at", str(docs), *old, "dish", "D1", "--state", "dropped", "--stopped", "r1"], stale_brigade), ("land-pause", ["--repo", str(self.project), "lease", "release", "L1", "--owner", "docs/@1"], stale_land), @@ -4106,7 +4106,7 @@ def test_a_replaced_coordinators_paused_commands_change_nothing(self): self.assertEqual({name: (docs / name).read_bytes() for name in tables}, after_replace) self.assertEqual(self.lease_row(1), row) new = ("--owner", "t2@2") - self.assertEqual(self.run_at(docs, *new, "ticket", "add", "--summary", "late"), "T2") + self.assertEqual(self.run_at(docs, *new, "ticket", "add", "--kind", "fix", "--summary", "late"), "T2") self.assertEqual(self.land("lease", "renew", "L1", "--owner", "docs/@2"), "L1 renewed") self.assertEqual(self.land("lease", "release", "L1", "--owner", "docs/@2"), "L1 released") self.assertEqual(self.run_at(docs, *new, "dish", "D1", "--state", "dropped", "--stopped", "r1"), "D1 dropped; T1 waiting again") @@ -4119,11 +4119,11 @@ def test_a_store_without_a_generation_works_until_set_thread_records_one(self): meta = json.loads((self.at / "restaurant.json").read_text()) meta["thread"] = "t1" (self.at / "restaurant.json").write_text(json.dumps(meta)) - self.assertEqual(self.brigade("ticket", "add", "--summary", "legacy"), "T1") + self.assertEqual(self.brigade("ticket", "add", "--kind", "fix", "--summary", "legacy"), "T1") self.brigade("set", "--thread", "t1") - self.assertEqual(self.brigade("ticket", "add", "--summary", "unowned", ok=False, owner=False), + self.assertEqual(self.brigade("ticket", "add", "--kind", "fix", "--summary", "unowned", ok=False, owner=False), "brigade: this store is owned by t1@1; pass --owner <thread>@<generation> from status") - self.assertEqual(self.brigade("--owner", "t1@1", "ticket", "add", "--summary", "owned"), "T2") + self.assertEqual(self.brigade("--owner", "t1@1", "ticket", "add", "--kind", "fix", "--summary", "owned"), "T2") def mode_rows(self): @@ -4152,7 +4152,7 @@ def test_open_mode_records_light_and_set_mode_clears_it(self): def test_item_mode_flags_refuse_light_and_a_missing_reason_with_exit_1(self): self.fired_bug_fix() - self.brigade("ticket", "add", "--summary", "t") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "t") before = (self.at / "log.tsv").read_bytes() refusals = [ (("dish", "D1", "--mode", "light", "--reason", "r"), @@ -4211,7 +4211,7 @@ def test_dish_mode_full_with_a_stale_owner_appends_nothing(self): def test_fire_mode_full_records_the_escalation_after_the_dish_row(self): self.open() - self.brigade("ticket", "add", "--summary", "lock order") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "lock order") self.assertEqual(self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "Fix the lock order", "--mode", "full", "--reason", "tickets name a lock"), "D1") rows = [line.split("\t")[1:4] for line in (self.at / "log.tsv").read_text().splitlines()[1:]] @@ -4221,8 +4221,8 @@ def test_fire_mode_full_records_the_escalation_after_the_dish_row(self): def test_a_refused_fire_mode_full_keeps_the_reason_in_the_unblocked_command(self): self.open() self.brigade("set", "--workers", "1") - self.brigade("ticket", "add", "--summary", "one") - self.brigade("ticket", "add", "--summary", "two") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "one") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "two") self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "a") self.assertIn("nothing fired: 1 of 1 workers running", self.brigade("fire", "--tickets", "T2", "--station", "bug-fix", "--summary", "Fix the lock order", @@ -4280,7 +4280,7 @@ def coordinator(self, station="feature", mode="light", *fire): self.brigade("set", "--thread", "c1") if mode: self.brigade("set", "--mode", mode) - self.brigade("ticket", "add", "--summary", "one") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "one") self.brigade("fire", "--tickets", "T1", "--station", station, "--summary", "s", "--branch", "perf/d1", "--thread", "w1", *fire) @@ -4552,9 +4552,9 @@ def old_rail(self, stamp="2026-10-01T00:00:00+00:00"): def test_ticket_list_prints_the_priority_that_ticket_add_or_ticket_set_recorded(self): self.open() - self.assertEqual(self.brigade("ticket", "add", "--summary", "a", "--priority", "urgent"), "T1") - self.assertEqual(self.brigade("ticket", "add", "--summary", "b", "--priority", "low"), "T2") - self.assertEqual(self.brigade("ticket", "add", "--summary", "c", "--priority", "normal"), "T3") + self.assertEqual(self.brigade("ticket", "add", "--kind", "fix", "--summary", "a", "--priority", "urgent"), "T1") + self.assertEqual(self.brigade("ticket", "add", "--kind", "fix", "--summary", "b", "--priority", "low"), "T2") + self.assertEqual(self.brigade("ticket", "add", "--kind", "fix", "--summary", "c", "--priority", "normal"), "T3") self.assertEqual(self.brigade("ticket", "list"), "T1 waiting urgent [user] a\nT2 waiting low [user] b\nT3 waiting normal [user] c") self.assertEqual(self.brigade("ticket", "set", "T1", "--priority", "low"), "T1 priority low") @@ -4565,10 +4565,10 @@ def test_ticket_list_prints_the_priority_that_ticket_add_or_ticket_set_recorded( def test_ticket_add_and_ticket_set_refuse_a_priority_outside_urgent_normal_and_low(self): self.open() - self.brigade("ticket", "add", "--summary", "a") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "a") choices = ("urgent", "normal", "low") self.assert_refused_choice( - self.brigade("ticket", "add", "--summary", "b", "--priority", "high", ok=False), "--priority", "high", choices) + self.brigade("ticket", "add", "--kind", "fix", "--summary", "b", "--priority", "high", ok=False), "--priority", "high", choices) self.assert_refused_choice( self.brigade("ticket", "set", "T1", "--priority", "high", ok=False), "--priority", "high", choices) self.assertEqual(self.brigade("ticket", "list"), "T1 waiting normal [user] a") @@ -4576,11 +4576,11 @@ def test_ticket_add_and_ticket_set_refuse_a_priority_outside_urgent_normal_and_l def test_a_ticket_with_an_empty_priority_cell_reads_urgent_from_upstream_low_from_report_and_normal_from_other_sources(self): self.open() self.brigade("set", "--intake", "upstream,report,github") - self.brigade("ticket", "add", "--summary", "a", "--source", "upstream") - self.brigade("ticket", "add", "--summary", "b", "--source", "report") - self.brigade("ticket", "add", "--summary", "c", "--source", "github") - self.brigade("ticket", "add", "--summary", "d") - self.brigade("ticket", "add", "--summary", "e", "--source", "upstream", "--request", "A1") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "a", "--source", "upstream") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "b", "--source", "report") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "c", "--source", "github") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "d") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "e", "--source", "upstream", "--request", "A1") self.assertEqual(self.brigade("ticket", "list").splitlines(), [ "T1 waiting urgent [upstream] a", "T2 waiting low [report] b", @@ -4593,7 +4593,7 @@ def test_a_ticket_with_an_empty_priority_cell_reads_urgent_from_upstream_low_fro def test_a_recorded_priority_wins_over_the_default_of_the_source(self): self.open() self.brigade("set", "--intake", "upstream") - self.brigade("ticket", "add", "--summary", "a", "--source", "upstream", "--priority", "low") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "a", "--source", "upstream", "--priority", "low") self.assertEqual(self.brigade("ticket", "list"), "T1 waiting low [upstream] a") def test_ticket_list_reads_rail_rows_written_before_the_priority_paths_and_decision_cells(self): @@ -4606,11 +4606,11 @@ def test_ticket_list_reads_rail_rows_written_before_the_priority_paths_and_decis "T3 waiting urgent [upstream] Sync upstream abc123", ]) self.assertEqual((self.at / "rail.tsv").read_bytes(), before) - self.assertEqual(self.brigade("ticket", "add", "--summary", "New", "--priority", "urgent", "--paths", "a.txt"), "T4") + self.assertEqual(self.brigade("ticket", "add", "--kind", "fix", "--summary", "New", "--priority", "urgent", "--paths", "a.txt"), "T4") self.assertEqual((self.at / "rail.tsv").read_bytes()[:len(before)], before) self.assertEqual(self.brigade("ticket", "list").splitlines()[-1], "T4 waiting urgent [user] New") self.assertEqual((self.at / "rail.tsv").read_text().splitlines()[-1].split("\t")[5:], - ["", "New", "urgent", "a.txt", "", "", ""]) + ["", "New", "urgent", "a.txt", "", "fix", ""]) def test_a_rail_row_with_six_fields_or_thirteen_is_malformed(self): self.open() @@ -4678,7 +4678,7 @@ def test_open_writes_goals_tsv_with_its_header_and_no_row_into_a_new_store_and_i def test_a_store_with_no_goals_table_runs_status_watch_startable_ticket_list_and_close_and_gains_no_table(self): self.fired_bug_fix() - self.brigade("ticket", "add", "--summary", "next") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "next") (self.at / "goals.tsv").unlink() files = sorted(self.store_files()) self.assertEqual(self.brigade("status").splitlines()[0], "thread not recorded") @@ -4694,8 +4694,8 @@ def test_a_store_with_no_goals_table_runs_status_watch_startable_ticket_list_and def test_a_priority_paths_or_decision_change_adds_no_log_row_and_keeps_a_blocked_ticket_blocked(self): self.brigade("open", "--project-root", str(self.project), "--name", "Perf", "--workers", "1") - self.brigade("ticket", "add", "--summary", "one") - self.brigade("ticket", "add", "--summary", "two") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "one") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "two") self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "one") self.assertEqual(self.brigade("fire", "--tickets", "T2", "--station", "bug-fix", "--summary", "two", ok=False), "brigade: nothing fired: 1 of 1 workers running") @@ -4711,7 +4711,7 @@ def test_a_priority_paths_or_decision_change_adds_no_log_row_and_keeps_a_blocked def test_ticket_set_prints_each_given_change_in_one_line_and_refuses_a_call_with_none(self): self.open() for summary in ("a", "b", "c"): - self.brigade("ticket", "add", "--summary", summary) + self.brigade("ticket", "add", "--kind", "fix", "--summary", summary) self.assertEqual(self.brigade("ticket", "set", "T1", "--state", "dropped"), "T1 dropped") self.assertEqual(self.brigade("ticket", "set", "T2", "--state", "dropped", "--priority", "normal"), "T2 dropped; priority normal") @@ -4722,13 +4722,13 @@ def test_ticket_set_prints_each_given_change_in_one_line_and_refuses_a_call_with self.assertEqual(self.cells("T3"), ("low", "", "")) before = self.store_files() self.assertEqual(self.brigade("ticket", "set", "T3", ok=False), - "brigade: ticket set needs --state, --priority, --paths, --decision, or --no-decision") + "brigade: ticket set needs --state, --priority, --paths, --decision, --no-decision, --kind, --goal, or --no-goal") self.assertEqual(self.brigade("ticket", "set", "T9", ok=False), "brigade: no T9 in rail.tsv") self.assertEqual(self.store_files(), before) def test_ticket_add_and_ticket_set_record_paths_sorted_normalized_and_without_repeats(self): self.open() - self.brigade("ticket", "add", "--summary", "a", "--paths", "src/b.py, docs/,src/b.py,./a.txt") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "a", "--paths", "src/b.py, docs/,src/b.py,./a.txt") self.assertEqual(self.cells("T1"), ("", "a.txt,docs,src/b.py", "")) self.assertEqual(self.brigade("ticket", "set", "T1", "--paths", "x\\y,/abs/z,new/file.py"), "T1 paths abs/z,new/file.py,x/y") @@ -4736,25 +4736,25 @@ def test_ticket_add_and_ticket_set_record_paths_sorted_normalized_and_without_re def test_ticket_set_with_empty_paths_clears_the_recorded_paths(self): self.open() - self.brigade("ticket", "add", "--summary", "a", "--paths", "a.txt") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "a", "--paths", "a.txt") self.assertEqual(self.brigade("ticket", "set", "T1", "--paths", ""), "T1 no paths") self.assertEqual(self.cells("T1"), ("", "", "")) def test_ticket_add_and_ticket_set_refuse_a_path_that_is_empty_or_the_repository_or_outside_it(self): self.open() - self.brigade("ticket", "add", "--summary", "a", "--paths", "a.txt") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "a", "--paths", "a.txt") before = self.store_files() for value, part in (("..", ".."), ("src,../x", "../x"), ("a,,b", ""), (".", "."), ("/", "/"), ("a/../..", "a/../.."), (" ", " "), ("a, ./", " ./")): refusal = f"brigade: --paths takes files or directories inside the repository, got {part!r}" - self.assertEqual(self.brigade("ticket", "add", "--summary", "b", "--paths", value, ok=False), refusal, value) + self.assertEqual(self.brigade("ticket", "add", "--kind", "fix", "--summary", "b", "--paths", value, ok=False), refusal, value) self.assertEqual(self.brigade("ticket", "set", "T1", "--paths", value, ok=False), refusal, value) self.assertEqual(self.store_files(), before) def test_ticket_add_and_ticket_set_record_and_clear_the_decision_cell(self): self.open() - self.brigade("ticket", "add", "--summary", "a", "--decision") - self.brigade("ticket", "add", "--summary", "b") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "a", "--decision") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "b") self.assertEqual([self.cells("T1"), self.cells("T2")], [("", "", "yes"), ("", "", "")]) self.assertEqual(self.brigade("ticket", "set", "T1", "--no-decision"), "T1 no decision") self.assertEqual(self.brigade("ticket", "set", "T2", "--decision"), "T2 decision") @@ -4766,7 +4766,7 @@ def test_status_prints_the_workers_line_third_before_reports_to_mode_and_owner(s "thread not recorded\nreporting: milestones, no landing contract\n" "workers: 0 of 2 running, 2 idle, auto-start: normal") self.brigade("set", "--thread", "t1", "--reports-to", "admin-thread", "--mode", "light") - self.brigade("ticket", "add", "--summary", "a") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "a") self.assertEqual(self.brigade("status").splitlines(), [ "thread t1", "reporting: milestones, no landing contract, waiting tickets: 1", @@ -4807,7 +4807,7 @@ def test_the_executive_admin_status_has_no_workers_line(self): def test_status_and_watch_count_items_in_progress_and_in_review_as_running_workers(self): self.brigade("open", "--project-root", str(self.project), "--name", "Perf", "--workers", "3") for summary in ("a", "b", "c"): - self.brigade("ticket", "add", "--summary", summary) + self.brigade("ticket", "add", "--kind", "fix", "--summary", summary) self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "a") self.brigade("fire", "--tickets", "T2", "--station", "bug-fix", "--summary", "b") self.brigade("dish", "D2", "--state", "in-review", "--sha", "abc1234") @@ -4825,7 +4825,7 @@ def test_status_and_watch_count_items_in_progress_and_in_review_as_running_worke def test_the_workers_line_prints_no_idle_worker_when_more_run_than_the_cap(self): self.open() for ticket in ("T1", "T2"): - self.brigade("ticket", "add", "--summary", ticket) + self.brigade("ticket", "add", "--kind", "fix", "--summary", ticket) self.brigade("fire", "--tickets", ticket, "--station", "bug-fix", "--summary", ticket) self.brigade("set", "--workers", "1") workers = "workers: 2 of 1 running, 0 idle, auto-start: normal" @@ -4909,10 +4909,10 @@ def test_from_report_dry_run_in_a_git_project_changes_no_store_file(self): def unrecorded_tickets(self): self.tracked_project(*TRACKED) self.open() - self.brigade("ticket", "add", "--summary", "Fix `docs/guide.md:246` and `tests/test_install.py`") - self.brigade("ticket", "add", "--summary", "Cover the `runId` wait") - self.brigade("ticket", "add", "--summary", "Reword `README.md`", "--paths", "t3/runtime.md") - self.brigade("ticket", "add", "--summary", "Drop `t3/scripts/roles.py`") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "Fix `docs/guide.md:246` and `tests/test_install.py`") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "Cover the `runId` wait") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "Reword `README.md`", "--paths", "t3/runtime.md") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "Drop `t3/scripts/roles.py`") self.brigade("ticket", "set", "T4", "--state", "dropped") def test_ticket_paths_records_quoted_paths_on_waiting_tickets_that_record_none_and_logs_nothing(self): @@ -4942,12 +4942,12 @@ def test_ticket_paths_says_so_when_every_waiting_ticket_records_paths(self): self.tracked_project(*TRACKED) self.open() self.assertEqual(self.brigade("ticket", "paths"), "every waiting ticket records paths") - self.brigade("ticket", "add", "--summary", "Reword `README.md`", "--paths", "t3/runtime.md") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "Reword `README.md`", "--paths", "t3/runtime.md") self.assertEqual(self.brigade("ticket", "paths"), "every waiting ticket records paths") def test_ticket_paths_refuses_a_project_that_is_not_a_git_repository_and_changes_no_store_file(self): self.open() - self.brigade("ticket", "add", "--summary", "Fix `docs/guide.md`") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "Fix `docs/guide.md`") before = self.store_files() for flags in ((), ("--dry-run",)): self.assertEqual(self.brigade("ticket", "paths", *flags, ok=False), @@ -4957,70 +4957,70 @@ def test_ticket_paths_refuses_a_project_that_is_not_a_git_repository_and_changes def test_ticket_paths_records_the_one_tracked_file_that_has_a_quoted_file_name_with_no_directory(self): self.tracked_project("src/app/tool.py", "docs/guide.md") self.open() - self.brigade("ticket", "add", "--summary", "Fix `tool.py`") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "Fix `tool.py`") self.assertEqual(self.brigade("ticket", "paths"), "T1 paths src/app/tool.py") self.assertEqual(self.paths_cells(), {"T1": "src/app/tool.py"}) def test_ticket_paths_records_nothing_for_a_quoted_file_name_that_no_tracked_file_has(self): self.tracked_project("src/app/tool.py") self.open() - self.brigade("ticket", "add", "--summary", "Fix `nope.py`") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "Fix `nope.py`") self.assertEqual(self.brigade("ticket", "paths"), "T1 quotes no tracked path") self.assertEqual(self.paths_cells(), {"T1": ""}) def test_ticket_paths_records_nothing_for_a_quoted_file_name_that_two_tracked_files_outside_skills_have(self): self.tracked_project("src/app/tool.py", "lib/tool.py") self.open() - self.brigade("ticket", "add", "--summary", "Fix `tool.py`") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "Fix `tool.py`") self.assertEqual(self.brigade("ticket", "paths"), "T1 quotes no tracked path") self.assertEqual(self.paths_cells(), {"T1": ""}) def test_ticket_paths_records_the_t3_file_for_a_quoted_file_name_that_a_file_at_the_same_path_under_skills_also_has(self): self.tracked_project("t3/added/x/scripts/tool.py", "skills/x/scripts/tool.py") self.open() - self.brigade("ticket", "add", "--summary", "Fix `tool.py`") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "Fix `tool.py`") self.assertEqual(self.brigade("ticket", "paths"), "T1 paths t3/added/x/scripts/tool.py") self.assertEqual(self.paths_cells(), {"T1": "t3/added/x/scripts/tool.py"}) def test_ticket_paths_records_the_t3_file_for_a_quoted_file_name_that_a_file_at_another_path_under_skills_also_has(self): self.tracked_project("t3/scripts/roles.py", "skills/pstack-runtime/scripts/roles.py") self.open() - self.brigade("ticket", "add", "--summary", "Fix `roles.py`") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "Fix `roles.py`") self.assertEqual(self.brigade("ticket", "paths"), "T1 paths t3/scripts/roles.py") self.assertEqual(self.paths_cells(), {"T1": "t3/scripts/roles.py"}) def test_ticket_paths_records_nothing_for_a_quoted_file_name_that_files_under_skills_under_t3_and_elsewhere_have(self): self.tracked_project("skills/x/tool.py", "t3/added/x/tool.py", "vendor/x/tool.py") self.open() - self.brigade("ticket", "add", "--summary", "Fix `tool.py`") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "Fix `tool.py`") self.assertEqual(self.brigade("ticket", "paths"), "T1 quotes no tracked path") self.assertEqual(self.paths_cells(), {"T1": ""}) def test_ticket_paths_records_a_file_under_skills_for_a_quoted_file_name_that_no_other_tracked_file_has(self): self.tracked_project("skills/x/scripts/tool.py", "t3/added/x/scripts/other.py") self.open() - self.brigade("ticket", "add", "--summary", "Fix `tool.py`") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "Fix `tool.py`") self.assertEqual(self.brigade("ticket", "paths"), "T1 paths skills/x/scripts/tool.py") self.assertEqual(self.paths_cells(), {"T1": "skills/x/scripts/tool.py"}) def test_ticket_paths_records_the_one_tracked_file_that_has_a_quoted_file_name_with_a_line_suffix(self): self.tracked_project("src/app/tool.py") self.open() - self.brigade("ticket", "add", "--summary", "Fix `tool.py:12` and `tool.py:30-34`") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "Fix `tool.py:12` and `tool.py:30-34`") self.assertEqual(self.brigade("ticket", "paths"), "T1 paths src/app/tool.py") self.assertEqual(self.paths_cells(), {"T1": "src/app/tool.py"}) def test_ticket_paths_records_a_quoted_file_name_that_is_a_tracked_file_at_the_root_when_another_directory_tracks_that_name(self): self.tracked_project("tool.py", "src/app/tool.py") self.open() - self.brigade("ticket", "add", "--summary", "Fix `tool.py`") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "Fix `tool.py`") self.assertEqual(self.brigade("ticket", "paths"), "T1 paths tool.py") self.assertEqual(self.paths_cells(), {"T1": "tool.py"}) def test_ticket_paths_dry_run_prints_the_one_tracked_file_that_has_a_quoted_file_name_and_changes_no_store_file(self): self.tracked_project("src/app/tool.py") self.open() - self.brigade("ticket", "add", "--summary", "Fix `tool.py`") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "Fix `tool.py`") before = self.store_files() self.assertEqual(self.brigade("ticket", "paths", "--dry-run", owner=False), "T1 would record src/app/tool.py") self.assertEqual(self.store_files(), before) @@ -5038,7 +5038,7 @@ def test_ticket_paths_records_nothing_for_a_file_name_only_one_nested_file_has_q self.tracked_project("src/app/tool.py", "tool.py:12") self.open() for quote in ("./tool.py", "tool.py/", "./tool.py:12", " tool.py "): - self.brigade("ticket", "add", "--summary", f"Fix `{quote}`") + self.brigade("ticket", "add", "--kind", "fix", "--summary", f"Fix `{quote}`") self.assertEqual(self.brigade("ticket", "paths"), "\n".join(f"T{number} quotes no tracked path" for number in (1, 2, 3, 4))) self.assertEqual(self.paths_cells(), {"T1": "", "T2": "", "T3": "", "T4": ""}) @@ -5056,7 +5056,7 @@ def test_from_report_records_nothing_for_a_file_name_only_one_nested_file_has_qu def test_ticket_paths_records_nothing_for_a_quote_that_is_a_tracked_file_named_as_one_nested_file_with_a_line_number_added(self): self.tracked_project("tool.py:12", "src/app/tool.py") self.open() - self.brigade("ticket", "add", "--summary", "Fix `tool.py:12`") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "Fix `tool.py:12`") self.assertEqual(self.brigade("ticket", "paths"), "T1 quotes no tracked path") self.assertEqual(self.paths_cells(), {"T1": ""}) @@ -5069,7 +5069,7 @@ def test_from_report_records_nothing_for_a_quote_that_is_a_tracked_file_named_as def test_ticket_paths_records_nothing_for_a_quote_that_is_a_tracked_directory_named_as_one_nested_file_with_a_line_number_added(self): self.tracked_project("tool.py:12/notes.md", "src/app/tool.py") self.open() - self.brigade("ticket", "add", "--summary", "Fix `tool.py:12`") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "Fix `tool.py:12`") self.assertEqual(self.brigade("ticket", "paths"), "T1 quotes no tracked path") self.assertEqual(self.paths_cells(), {"T1": ""}) @@ -5087,7 +5087,7 @@ def queue(self, *open_flags): self.brigade("open", "--project-root", str(self.project), "--name", "Perf", *open_flags) def filed(self, summary, *flags): - return self.brigade("ticket", "add", "--summary", summary, *flags) + return self.brigade("ticket", "add", "--kind", "fix", "--summary", summary, *flags) def refile(self, ident, **ago): stamp = (datetime.now(timezone.utc) - timedelta(**ago)).isoformat() @@ -6296,6 +6296,201 @@ def hand_edit(self, table, ident, **cells): NO_UNASSIGNED_WORK = "unassigned: 0 waiting, 0 in flight, 0 merged, 0 send-backs" + NEEDS_KIND = "brigade: ticket add needs --kind <feature|fix|polish|upkeep|study>" + + def label(self, table, ident): + row = self.table_row(self.at, table, ident) + return row["kind"], row["goal"] + + def test_ticket_add_with_a_summary_and_no_kind_is_refused_and_changes_no_file(self): + self.open() + before = self.store_files() + self.assertEqual(self.brigade("ticket", "add", "--summary", "Tidy the lease docs", ok=False), self.NEEDS_KIND) + self.assertEqual(self.brigade("ticket", "add", "--summary", "Tidy the lease docs", "--priority", "urgent", "--paths", "docs", + "--decision", ok=False), self.NEEDS_KIND) + self.assertEqual(self.store_files(), before) + + def test_ticket_add_with_no_kind_and_a_stale_owner_or_no_owner_prints_the_owner_refusal_first(self): + self.open() + self.brigade("set", "--thread", "t1") + before = self.store_files() + self.assertEqual(self.brigade("ticket", "add", "--summary", "a", ok=False, owner=False), + "brigade: this store is owned by t1@1; pass --owner <thread>@<generation> from status") + self.assertEqual(self.brigade("--owner", "t0@1", "ticket", "add", "--summary", "a", ok=False), + "brigade: owner t0@1 is stale; this store is owned by t1@1") + self.assertEqual(self.brigade("--owner", "t1@1", "ticket", "add", "--summary", "a", ok=False), self.NEEDS_KIND) + self.assertEqual(self.store_files(), before) + + def test_ticket_add_with_no_kind_prints_the_request_source_ref_and_same_text_refusals_first(self): + self.open() + self.brigade("set", "--intake", "github") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "Fix the login page", "--request", "A2") + self.brigade("ticket", "add", "--kind", "fix", "--summary", "Fix the cache", "--source", "github", "--ref", "#12") + before = self.store_files() + self.assertEqual(self.brigade("ticket", "add", "--summary", "other", "--request", "A2", ok=False), + "brigade: request A2 is already T1; nothing added") + self.assertEqual(self.brigade("ticket", "add", "--summary", "other", "--source", "linear", ok=False), + "brigade: no coordinator owns intake from linear; the one that reads it runs set --intake linear") + self.assertEqual(self.brigade("ticket", "add", "--summary", "other", "--source", "github", "--ref", "#12", ok=False), + "brigade: #12 is already T2 (waiting); nothing added") + self.assertEqual(self.brigade("ticket", "add", "--summary", "fix the login page", ok=False), + self.same_text_refusal("T1", "waiting")) + self.assertEqual(self.store_files(), before) + + def test_ticket_add_records_each_of_the_five_kinds_and_refuses_a_word_that_is_none_of_them(self): + self.open() + for number, kind in enumerate(("feature", "fix", "polish", "upkeep", "study"), 1): + self.assertEqual(self.brigade("ticket", "add", "--summary", f"work {number}", "--kind", kind), f"T{number}") + self.assertEqual(self.label("rail.tsv", f"T{number}"), (kind, "")) + before = self.store_files() + self.assert_refused_choice(self.brigade("ticket", "add", "--summary", "other", "--kind", "chore", ok=False), "--kind", "chore", + ("feature", "fix", "polish", "upkeep", "study")) + self.assert_refused_choice(self.brigade("ticket", "set", "T1", "--kind", "chore", ok=False), "--kind", "chore", + ("feature", "fix", "polish", "upkeep", "study")) + self.assertEqual(self.store_files(), before) + + def test_ticket_add_goal_records_an_open_goal_and_a_paused_goal(self): + self.open() + self.goal("activity view") + self.goal("current with upstream") + self.brigade("goal", "set", "G2", "--state", "paused") + self.assertEqual(self.brigade("ticket", "add", "--summary", "a", "--kind", "fix", "--goal", "G1"), "T1") + self.assertEqual(self.brigade("ticket", "add", "--summary", "b", "--kind", "upkeep", "--goal", "G2"), "T2") + self.assertEqual((self.label("rail.tsv", "T1"), self.label("rail.tsv", "T2")), (("fix", "G1"), ("upkeep", "G2"))) + + def test_ticket_add_and_ticket_set_refuse_a_goal_id_with_no_row_and_an_empty_goal_and_change_no_file(self): + self.open() + self.goal() + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix") + before = self.store_files() + self.assertEqual(self.brigade("ticket", "add", "--summary", "b", "--kind", "polish", "--goal", "G7", ok=False), + "brigade: no goal G7; run goal list --all to see every goal") + self.assertEqual(self.brigade("ticket", "set", "T1", "--goal", "G7", "--priority", "low", ok=False), + "brigade: no goal G7; run goal list --all to see every goal") + self.assertEqual(self.brigade("ticket", "add", "--summary", "b", "--kind", "polish", "--goal", "", ok=False), + 'brigade: --goal "" names no goal; run goal list --all to see every goal') + self.assertEqual(self.brigade("ticket", "set", "T1", "--goal", "", "--priority", "low", ok=False), + 'brigade: --goal "" names no goal; run goal list --all to see every goal') + self.assertEqual(self.store_files(), before) + + def assert_closed_goal_takes_no_ticket(self, state): + self.open() + self.goal() + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix") + self.brigade("goal", "set", "G1", "--state", state, "--evidence", "why") + before = self.store_files() + refusal = (f"brigade: G1 is {state}; a ticket takes an open or paused goal; " + "run goal set G1 --state open first, or pick another goal") + self.assertEqual(self.brigade("ticket", "add", "--summary", "b", "--kind", "polish", "--goal", "G1", ok=False), refusal) + self.assertEqual(self.brigade("ticket", "set", "T1", "--goal", "G1", "--priority", "low", ok=False), refusal) + self.assertEqual(self.store_files(), before) + + def test_ticket_add_and_ticket_set_refuse_a_done_goal_and_change_no_file(self): + self.assert_closed_goal_takes_no_ticket("done") + + def test_ticket_add_and_ticket_set_refuse_a_dropped_goal_and_change_no_file(self): + self.assert_closed_goal_takes_no_ticket("dropped") + + def test_ticket_add_from_report_refuses_kind_and_goal_and_files_no_ticket(self): + self.open() + self.goal() + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix") + self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "Fix a") + (self.at / "reports").mkdir(exist_ok=True) + (self.at / "reports" / "D1.md").write_text(BULLETS) + before = self.store_files() + refusal = "brigade: --from-report takes no --kind or --goal; each ticket's kind and goal follow from the report's item" + for flags in (("--kind", "fix"), ("--goal", "G1"), ("--goal", ""), ("--kind", "polish", "--goal", "G1")): + with self.subTest(flags=flags): + self.assertEqual(self.brigade("ticket", "add", "--from-report", "reports/D1.md", *flags, ok=False), refusal) + self.assertEqual(self.brigade("ticket", "add", "--from-report", "reports/D1.md", "--dry-run", *flags, ok=False), refusal) + self.assertEqual(self.store_files(), before) + + def test_ticket_set_kind_goal_and_no_goal_change_the_two_cells_of_a_waiting_ticket_and_add_no_log_row(self): + self.open() + self.goal("activity view") + self.goal("current with upstream") + self.brigade("goal", "set", "G2", "--state", "paused") + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix") + before = ((self.at / "log.tsv").read_bytes(), (self.at / "restaurant.json").read_bytes()) + self.assertEqual(self.brigade("ticket", "set", "T1", "--goal", "G1", "--kind", "polish"), "T1 kind polish; goal G1") + self.assertEqual(self.label("rail.tsv", "T1"), ("polish", "G1")) + self.assertEqual(self.brigade("ticket", "set", "T1", "--goal", "G2"), "T1 goal G2") + self.assertEqual(self.label("rail.tsv", "T1"), ("polish", "G2")) + self.assertEqual(self.brigade("ticket", "set", "T1", "--no-goal"), "T1 unassigned") + self.assertEqual(self.label("rail.tsv", "T1"), ("polish", "")) + self.assertEqual(self.brigade("ticket", "set", "T1", "--kind", "study", "--priority", "low", "--no-goal", "--state", "dropped"), + "T1 dropped; priority low; kind study; unassigned") + self.assertEqual(self.label("rail.tsv", "T1"), ("study", "")) + self.assertEqual(self.table_row(self.at, "rail.tsv", "T1")["state"], "dropped") + self.assertEqual((self.at / "log.tsv").read_bytes().count(b"\n"), before[0].count(b"\n") + 1) + + def test_ticket_set_with_goal_and_no_goal_together_exits_2_and_changes_no_file(self): + self.open() + self.goal() + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix", "--goal", "G1") + before = self.store_files() + code, out, _ = self.run_raw("ticket", "set", "T1", "--goal", "G1", "--no-goal") + self.assertEqual((code, out), (2, "")) + self.assertEqual(self.store_files(), before) + + def test_ticket_set_kind_goal_and_no_goal_are_refused_on_a_done_ticket_and_on_a_dropped_ticket_and_change_no_file(self): + self.open() + self.goal() + for number, state in enumerate(("done", "dropped"), 1): + self.brigade("ticket", "add", "--summary", f"work {number}", "--kind", "fix", "--goal", "G1") + self.brigade("ticket", "set", f"T{number}", "--state", state) + before = self.store_files() + for number, state in enumerate(("done", "dropped"), 1): + for flags in (("--kind", "polish"), ("--goal", "G1"), ("--no-goal",), ("--priority", "low", "--kind", "polish"), + ("--state", "waiting", "--no-goal")): + with self.subTest(state=state, flags=flags): + self.assertEqual(self.brigade("ticket", "set", f"T{number}", *flags, ok=False), + f"brigade: T{number} is {state}; --kind, --goal, and --no-goal change a waiting ticket only") + self.assertEqual(self.store_files(), before) + + def test_ticket_set_kind_goal_and_no_goal_are_refused_on_an_assigned_ticket_with_its_items_id_and_change_no_file(self): + self.open() + self.goal() + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix", "--goal", "G1") + self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "Fix a") + before = self.store_files() + for flags in (("--kind", "polish"), ("--goal", "G1"), ("--no-goal",), ("--priority", "low", "--kind", "polish")): + with self.subTest(flags=flags): + self.assertEqual(self.brigade("ticket", "set", "T1", *flags, ok=False), + "brigade: T1 is assigned; --kind, --goal, and --no-goal change a waiting ticket only; " + "D1 keeps the kind and goal it took when it started") + self.assertEqual(self.store_files(), before) + self.assertEqual(self.brigade("ticket", "set", "T1", "--priority", "low"), "T1 priority low") + + def test_goal_set_refuses_done_and_dropped_while_a_ticket_of_the_goal_waits_and_names_each_such_ticket(self): + self.open() + self.goal("activity view") + self.goal("current with upstream") + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix", "--goal", "G1") + self.brigade("ticket", "add", "--summary", "b", "--kind", "fix", "--goal", "G2") + self.brigade("ticket", "add", "--summary", "c", "--kind", "feature", "--goal", "G1") + self.brigade("ticket", "add", "--summary", "d", "--kind", "polish") + before = self.store_files() + for state in ("done", "dropped"): + with self.subTest(state=state): + self.assertEqual(self.brigade("goal", "set", "G1", "--state", state, "--evidence", "why", ok=False), + "brigade: G1 still has work: T1, T3 waiting; give each waiting ticket another goal with " + "ticket set --goal or --no-goal; then run this again") + self.assertEqual(self.store_files(), before) + self.brigade("ticket", "set", "T1", "--no-goal") + self.brigade("ticket", "set", "T3", "--goal", "G2") + self.assertEqual(self.brigade("goal", "set", "G1", "--state", "done", "--evidence", "why"), "G1 done") + + def test_a_dropped_ticket_and_a_done_ticket_of_a_goal_do_not_keep_the_goal_open(self): + self.open() + self.goal() + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix", "--goal", "G1") + self.brigade("ticket", "add", "--summary", "b", "--kind", "fix", "--goal", "G1") + self.brigade("ticket", "set", "T1", "--state", "dropped") + self.brigade("ticket", "set", "T2", "--state", "done") + self.assertEqual(self.brigade("goal", "set", "G1", "--state", "dropped", "--evidence", "why"), "G1 dropped") + def test_goal_add_prints_the_new_id_appends_one_open_row_and_logs_one_goal_row(self): self.open() self.assertEqual(self.goal("activity view", "--share", "2"), "G1") @@ -6667,7 +6862,7 @@ def handoff(self, source="core", target="engine", ref="https://github.com/o/r/is """core owns github, files T1, and moves it to engine. Returns engine's inbox file.""" self.open(source, "--intake", "github") self.open(target) - self.brigade(source, "ticket", "add", "--summary", "Fix the cache", "--source", "github", "--ref", ref) + self.brigade(source, "ticket", "add", "--kind", "fix", "--summary", "Fix the cache", "--source", "github", "--ref", ref) self.brigade(source, "ticket", "move", "T1", "--to", target) return self.dir(target) / "inbox" / f"app~{source}~T1.json" @@ -6676,15 +6871,15 @@ class HandoffTest(StoresTest): def test_ticket_add_takes_a_user_request_with_the_summary_of_a_moved_ticket(self): self.open("core") self.open("engine") - self.brigade("core", "ticket", "add", "--summary", "Fix the cache") + self.brigade("core", "ticket", "add", "--kind", "fix", "--summary", "Fix the cache") self.brigade("core", "ticket", "move", "T1", "--to", "engine") - self.assertEqual(self.brigade("core", "ticket", "add", "--summary", "Fix the cache"), "T2") + self.assertEqual(self.brigade("core", "ticket", "add", "--kind", "fix", "--summary", "Fix the cache"), "T2") def test_from_report_files_the_same_report_name_in_two_siblings(self): body = "## Follow-ups\n\n- Shared text.\n" for name in ("core", "engine"): self.open(name) - self.brigade(name, "ticket", "add", "--summary", "s") + self.brigade(name, "ticket", "add", "--kind", "fix", "--summary", "s") self.brigade(name, "fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "Fix s") (self.dir(name) / "reports").mkdir(exist_ok=True) (self.dir(name) / "reports" / "D1.md").write_text(body) @@ -6719,28 +6914,28 @@ def test_two_owners_of_one_source_both_refuse_to_file(self): meta = json.loads(path.read_text()) meta["intake"] = ["github"] path.write_text(json.dumps(meta)) - self.assertEqual(self.brigade("core", "ticket", "add", "--summary", "x", "--source", "github", ok=False), + self.assertEqual(self.brigade("core", "ticket", "add", "--kind", "fix", "--summary", "x", "--source", "github", ok=False), "brigade: docs owns intake from github; ask it to file this and move it here") - self.assertEqual(self.brigade("docs", "ticket", "add", "--summary", "x", "--source", "github", ok=False), + self.assertEqual(self.brigade("docs", "ticket", "add", "--kind", "fix", "--summary", "x", "--source", "github", ok=False), "brigade: core owns intake from github; ask it to file this and move it here") self.assertEqual(self.rows("core", "rail.tsv") + self.rows("docs", "rail.tsv"), []) - self.assertEqual(self.brigade("core", "ticket", "add", "--summary", "from the user"), "T1") + self.assertEqual(self.brigade("core", "ticket", "add", "--kind", "fix", "--summary", "from the user"), "T1") def test_one_ref_is_filed_once_while_live(self): ref = "https://github.com/o/r/issues/7" self.open("core", "--intake", "github") - self.assertEqual(self.brigade("core", "ticket", "add", "--summary", "a", "--source", "github", "--ref", ref), "T1") - self.assertEqual(self.brigade("core", "ticket", "add", "--summary", "b", "--source", "github", "--ref", f" {ref} ", ok=False), + self.assertEqual(self.brigade("core", "ticket", "add", "--kind", "fix", "--summary", "a", "--source", "github", "--ref", ref), "T1") + self.assertEqual(self.brigade("core", "ticket", "add", "--kind", "fix", "--summary", "b", "--source", "github", "--ref", f" {ref} ", ok=False), f"brigade: {ref} is already T1 (waiting); nothing added") self.brigade("core", "fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "fix") - self.assertEqual(self.brigade("core", "ticket", "add", "--summary", "b", "--ref", ref, ok=False), + self.assertEqual(self.brigade("core", "ticket", "add", "--kind", "fix", "--summary", "b", "--ref", ref, ok=False), f"brigade: {ref} is already T1 (assigned); nothing added") self.brigade("core", "ticket", "set", "T1", "--state", "done") - self.assertEqual(self.brigade("core", "ticket", "add", "--summary", "reopened", "--source", "github", "--ref", ref), "T2") + self.assertEqual(self.brigade("core", "ticket", "add", "--kind", "fix", "--summary", "reopened", "--source", "github", "--ref", ref), "T2") def test_dropping_a_source_with_a_waiting_ticket_is_refused(self): self.open("core", "--intake", "github,feed") - self.brigade("core", "ticket", "add", "--summary", "a", "--source", "github") + self.brigade("core", "ticket", "add", "--kind", "fix", "--summary", "a", "--source", "github") self.assertEqual(self.brigade("core", "set", "--intake", "feed", ok=False), "brigade: T1 from github is waiting; finish, drop, or move it before dropping github from intake") self.brigade("core", "ticket", "set", "T1", "--state", "dropped") @@ -6751,7 +6946,7 @@ def test_move_hands_a_ticket_to_a_sibling_and_take_files_it(self): self.open("core", "--intake", "github") self.open("engine") self.brigade("engine", "set", "--thread", "thread-engine") - self.brigade("core", "ticket", "add", "--summary", "Fix the cache", "--source", "github", "--ref", "R7") + self.brigade("core", "ticket", "add", "--kind", "fix", "--summary", "Fix the cache", "--source", "github", "--ref", "R7") self.assertEqual(self.brigade("core", "ticket", "move", "T1", "--to", "engine"), "T1 moved to engine; tell thread thread-engine") self.assertEqual(self.brigade("core", "ticket", "list"), "T1 moved normal [github] Fix the cache R7") @@ -6761,7 +6956,7 @@ def test_move_hands_a_ticket_to_a_sibling_and_take_files_it(self): "brigade: T1 moved to engine; it is that coordinator's ticket now") self.assertEqual(json.loads((self.dir("engine") / "inbox" / "app~core~T1.json").read_text()), {"handoff": "app/core/T1", "summary": "Fix the cache", "source": "github", "ref": "R7", - "priority": "", "paths": "", "decision": ""}) + "priority": "", "paths": "", "decision": "", "kind": "fix"}) self.assertEqual(self.brigade("core", "watch"), "workers: 0 of 2 running, 2 idle, auto-start: normal\nT1: moved to engine, waiting for ticket take") self.assertEqual(self.brigade("engine", "watch"), "workers: 0 of 2 running, 2 idle, auto-start: normal\nhanded to you: 1; run ticket take") self.assertEqual(self.brigade("engine", "status"), @@ -6782,33 +6977,33 @@ def test_a_moved_ref_stays_live_through_each_move_until_the_end_of_the_chain_fin self.open("core", "--intake", "github") self.open("docs") self.open("engine") - self.brigade("core", "ticket", "add", "--summary", "s", "--source", "github", "--ref", ref) + self.brigade("core", "ticket", "add", "--kind", "fix", "--summary", "s", "--source", "github", "--ref", ref) self.brigade("core", "ticket", "move", "T1", "--to", "docs") refused = f"brigade: {ref} is already T1 (moved); nothing added" - self.assertEqual(self.brigade("core", "ticket", "add", "--summary", "s", "--source", "github", "--ref", ref, ok=False), refused) + self.assertEqual(self.brigade("core", "ticket", "add", "--kind", "fix", "--summary", "s", "--source", "github", "--ref", ref, ok=False), refused) self.brigade("docs", "ticket", "take") self.brigade("docs", "ticket", "move", "T1", "--to", "engine") - self.assertEqual(self.brigade("core", "ticket", "add", "--summary", "s", "--source", "github", "--ref", ref, ok=False), refused) + self.assertEqual(self.brigade("core", "ticket", "add", "--kind", "fix", "--summary", "s", "--source", "github", "--ref", ref, ok=False), refused) self.brigade("engine", "ticket", "take") self.assertEqual(self.brigade("engine", "ticket", "list"), f"T1 waiting normal [github (from app/docs/T1)] s {ref}") - self.assertEqual(self.brigade("core", "ticket", "add", "--summary", "s", "--source", "github", "--ref", ref, ok=False), refused) + self.assertEqual(self.brigade("core", "ticket", "add", "--kind", "fix", "--summary", "s", "--source", "github", "--ref", ref, ok=False), refused) self.brigade("engine", "ticket", "set", "T1", "--state", "done") - self.assertEqual(self.brigade("core", "ticket", "add", "--summary", "s", "--source", "github", "--ref", ref), "T2") + self.assertEqual(self.brigade("core", "ticket", "add", "--kind", "fix", "--summary", "s", "--source", "github", "--ref", ref), "T2") def test_a_new_owner_refuses_a_ref_the_old_owner_still_holds_live(self): self.handoff(source="core", target="docs", ref="R7") self.open("engine") self.brigade("core", "set", "--intake", "") self.brigade("engine", "set", "--intake", "github") - self.assertEqual(self.brigade("engine", "ticket", "add", "--summary", "s", "--source", "github", "--ref", "R7", ok=False), + self.assertEqual(self.brigade("engine", "ticket", "add", "--kind", "fix", "--summary", "s", "--source", "github", "--ref", "R7", ok=False), "brigade: R7 is already T1 in core (moved); nothing added") - self.assertEqual(self.brigade("engine", "ticket", "add", "--summary", "s", "--source", "github", "--ref", "R8"), "T1") + self.assertEqual(self.brigade("engine", "ticket", "add", "--kind", "fix", "--summary", "s", "--source", "github", "--ref", "R8"), "T1") def test_a_replaced_owners_paused_move_does_not_republish_the_handoff(self): self.open("core", "--intake", "github") self.open("engine") self.brigade("core", "set", "--thread", "t1") - self.brigade("core", "ticket", "add", "--summary", "Fix the cache", "--source", "github", "--ref", "R7") + self.brigade("core", "ticket", "add", "--kind", "fix", "--summary", "Fix the cache", "--source", "github", "--ref", "R7") self.brigade("core", "ticket", "move", "T1", "--to", "engine") # The handoff was never delivered, so a rerun of the move would publish it again. (self.dir("engine") / "inbox" / "app~core~T1.json").unlink() @@ -6846,7 +7041,7 @@ def test_a_replaced_owners_paused_take_does_not_delete_the_inbox_file(self): def test_a_move_interrupted_after_the_rewrite_publishes_once_on_rerun(self): self.open("core", "--intake", "github") self.open("engine") - self.brigade("core", "ticket", "add", "--summary", "Fix the cache", "--source", "github", "--ref", "R7") + self.brigade("core", "ticket", "add", "--kind", "fix", "--summary", "Fix the cache", "--source", "github", "--ref", "R7") died = self.child("die-before-publish", "core", "ticket", "move", "T1", "--to", "engine") self.assertEqual(died.returncode, 9, died.stderr) self.assertEqual(self.brigade("core", "ticket", "list"), "T1 moved normal [github] Fix the cache R7") @@ -6880,24 +7075,24 @@ def test_take_twice_with_the_file_copied_back_files_one_ticket(self): def test_a_moved_ticket_keeps_its_priority_paths_and_decision_after_ticket_take(self): self.open("core") self.open("engine") - self.brigade("core", "ticket", "add", "--summary", "Fix the cache", "--priority", "urgent", + self.brigade("core", "ticket", "add", "--kind", "fix", "--summary", "Fix the cache", "--priority", "urgent", "--paths", "src/cache.py,docs", "--decision") self.brigade("core", "ticket", "move", "T1", "--to", "engine") self.assertEqual(json.loads((self.dir("engine") / "inbox" / "app~core~T1.json").read_text()), {"handoff": "app/core/T1", "summary": "Fix the cache", "source": "user", "ref": "", - "priority": "urgent", "paths": "docs,src/cache.py", "decision": "yes"}) + "priority": "urgent", "paths": "docs,src/cache.py", "decision": "yes", "kind": "fix"}) self.assertEqual(self.brigade("engine", "ticket", "take"), "T1 from app/core/T1: Fix the cache") self.assertEqual(self.rows("engine", "rail.tsv")[0][2:], - ["waiting", "user (from app/core/T1)", "", "", "Fix the cache", "urgent", "docs,src/cache.py", "yes", "", ""]) + ["waiting", "user (from app/core/T1)", "", "", "Fix the cache", "urgent", "docs,src/cache.py", "yes", "fix", ""]) self.assertEqual(self.brigade("engine", "ticket", "list"), "T1 waiting urgent [user (from app/core/T1)] Fix the cache") def test_a_moved_ticket_with_an_empty_priority_cell_reads_the_default_of_its_source_after_ticket_take(self): self.open("core", "--intake", "upstream") self.open("engine") - self.brigade("core", "ticket", "add", "--summary", "Sync", "--source", "upstream") + self.brigade("core", "ticket", "add", "--kind", "fix", "--summary", "Sync", "--source", "upstream") self.brigade("core", "ticket", "move", "T1", "--to", "engine") self.brigade("engine", "ticket", "take") - self.assertEqual(self.rows("engine", "rail.tsv")[0][7:], ["", "", "", "", ""]) + self.assertEqual(self.rows("engine", "rail.tsv")[0][7:], ["", "", "", "fix", ""]) self.assertEqual(self.brigade("engine", "ticket", "list"), "T1 waiting urgent [upstream (from app/core/T1)] Sync") def test_ticket_take_files_a_handoff_written_without_priority_paths_and_decision(self): @@ -6913,14 +7108,111 @@ def test_ticket_take_files_a_handoff_written_without_priority_paths_and_decision self.assertEqual(self.brigade("engine", "ticket", "list"), "T1 waiting low [report (from app/core/T1)] Tidy the guide core/reports/D1.md#1") + def labelled_handoff(self): + """core files T1 as a polish ticket under its goal G1 and moves it to engine. Returns the line ticket move printed.""" + self.open("core") + self.open("engine") + self.brigade("core", "goal", "add", "--name", "activity view", "--purpose", "p", "--done", "d") + self.brigade("core", "ticket", "add", "--summary", "Tidy the guide", "--kind", "polish", "--goal", "G1") + return self.brigade("core", "ticket", "move", "T1", "--to", "engine") + + def test_ticket_move_writes_the_kind_and_no_goal_into_the_handoff_and_says_which_goal_the_ticket_leaves(self): + said = "T1 moved to engine; no thread recorded for engine; it leaves G1 here and arrives unassigned" + self.assertEqual(self.labelled_handoff(), said) + self.assertEqual(json.loads((self.dir("engine") / "inbox" / "app~core~T1.json").read_text()), + {"handoff": "app/core/T1", "summary": "Tidy the guide", "source": "user", "ref": "", + "priority": "", "paths": "", "decision": "", "kind": "polish"}) + self.assertEqual(self.rows("core", "rail.tsv")[0][2:], + ["moved", "user", "", "to:engine", "Tidy the guide", "", "", "", "polish", "G1"]) + self.assertEqual(self.brigade("core", "ticket", "move", "T1", "--to", "engine"), said) + + def test_ticket_take_files_a_moved_ticket_with_its_kind_and_no_goal(self): + self.labelled_handoff() + self.brigade("engine", "goal", "add", "--name", "docs", "--purpose", "p", "--done", "d") + self.assertEqual(self.brigade("engine", "ticket", "take"), "T1 from app/core/T1: Tidy the guide") + self.assertEqual(self.rows("engine", "rail.tsv")[0][2:], + ["waiting", "user (from app/core/T1)", "", "", "Tidy the guide", "", "", "", "polish", ""]) + + def test_ticket_move_of_a_ticket_with_no_kind_and_no_goal_writes_the_seven_keys_the_handoff_had_before_kinds(self): + self.open("core") + self.open("engine") + (self.dir("core") / "rail.tsv").write_text( + "id\tat\tstate\tsource\tref\tdish\tsummary\tpriority\tpaths\tdecision\n" + "T1\t2026-10-01T00:00:00+00:00\twaiting\tuser\t\t\tTidy the guide\tlow\tdocs\t\n") + self.assertEqual(self.brigade("core", "ticket", "move", "T1", "--to", "engine"), + "T1 moved to engine; no thread recorded for engine") + self.assertEqual((self.dir("engine") / "inbox" / "app~core~T1.json").read_text(), json.dumps( + {"handoff": "app/core/T1", "summary": "Tidy the guide", "source": "user", "ref": "", + "priority": "low", "paths": "docs", "decision": ""}, indent=2) + "\n") + + def test_ticket_move_of_a_ticket_whose_kind_cell_holds_no_kind_writes_no_kind_into_the_handoff(self): + self.open("core") + self.open("engine") + self.brigade("core", "ticket", "add", "--summary", "Tidy the guide", "--kind", "polish") + rail = self.dir("core") / "rail.tsv" + rail.write_text(rail.read_text().replace("\tpolish\t", "\tbugfix\t")) + self.brigade("core", "ticket", "move", "T1", "--to", "engine") + self.assertNotIn("kind", json.loads((self.dir("engine") / "inbox" / "app~core~T1.json").read_text())) + self.assertEqual(self.brigade("engine", "ticket", "take"), "T1 from app/core/T1: Tidy the guide") + self.assertEqual(self.rows("engine", "rail.tsv")[0][10:], ["", ""]) + + def take_handoff(self, **extra): + """engine takes a handoff file that holds the seven keys from before kinds, and `extra`.""" + self.open("core") + self.open("engine") + inbox = self.dir("engine") / "inbox" + inbox.mkdir() + (inbox / "app~core~T1.json").write_text(json.dumps( + {"handoff": "app/core/T1", "summary": "Tidy the guide", "source": "user", "ref": "", + "priority": "low", "paths": "docs", "decision": "", **extra})) + return self.brigade("engine", "ticket", "take") + + def test_ticket_take_files_a_handoff_with_no_kind_key_as_a_ticket_with_no_kind_and_no_goal(self): + self.assertEqual(self.take_handoff(), "T1 from app/core/T1: Tidy the guide") + self.assertEqual(self.rows("engine", "rail.tsv")[0][2:], + ["waiting", "user (from app/core/T1)", "", "", "Tidy the guide", "low", "docs", "", "", ""]) + + def test_ticket_take_files_a_handoff_with_a_kind_as_a_ticket_of_that_kind(self): + self.assertEqual(self.take_handoff(kind="upkeep"), "T1 from app/core/T1: Tidy the guide") + self.assertEqual(self.rows("engine", "rail.tsv")[0][10:], ["upkeep", ""]) + + def test_ticket_take_files_a_handoff_with_a_kind_that_is_none_of_the_five_as_a_ticket_with_no_kind_and_says_so(self): + self.assertEqual(self.take_handoff(kind="chore"), + "T1 from app/core/T1: Tidy the guide; kind chore is not one this version knows, so it has no kind") + self.assertEqual(self.rows("engine", "rail.tsv")[0][10:], ["", ""]) + self.assertEqual(self.brigade("engine", "ticket", "take"), "nothing handed to you") + + def test_ticket_take_files_a_handoff_whose_kind_is_empty_and_one_whose_kind_is_null_as_tickets_with_no_kind(self): + self.open("core") + self.open("engine") + inbox = self.dir("engine") / "inbox" + inbox.mkdir() + for number, kind in ((1, ""), (2, None)): + (inbox / f"app~core~T{number}.json").write_text(json.dumps( + {"handoff": f"app/core/T{number}", "summary": f"Tidy guide {number}", "source": "user", "ref": "", + "priority": "", "paths": "", "decision": "", "kind": kind})) + self.assertEqual(self.brigade("engine", "ticket", "take"), + "T1 from app/core/T1: Tidy guide 1\nT2 from app/core/T2: Tidy guide 2") + self.assertEqual([row[10:] for row in self.rows("engine", "rail.tsv")], [["", ""], ["", ""]]) + + def test_ticket_take_ignores_a_goal_key_in_the_handoff(self): + self.assertEqual(self.take_handoff(kind="fix", goal="G1"), "T1 from app/core/T1: Tidy the guide") + self.assertEqual(self.rows("engine", "rail.tsv")[0][10:], ["fix", ""]) + def test_ticket_set_with_no_flag_on_a_moved_ticket_prints_the_moved_refusal(self): self.handoff() self.assertEqual(self.brigade("core", "ticket", "set", "T1", ok=False), "brigade: T1 moved to engine; it is that coordinator's ticket now") + def test_ticket_set_kind_or_no_goal_on_a_moved_ticket_prints_the_moved_refusal(self): + self.handoff() + for flags in (("--kind", "polish"), ("--no-goal",)): + self.assertEqual(self.brigade("core", "ticket", "set", "T1", *flags, ok=False), + "brigade: T1 moved to engine; it is that coordinator's ticket now") + def assert_one_handed_ticket(self, name="engine"): self.assertEqual([row[2:] for row in self.rows(name, "rail.tsv")], - [["waiting", "github (from app/core/T1)", "https://github.com/o/r/issues/7", "", "Fix the cache", "", "", "", "", ""]]) + [["waiting", "github (from app/core/T1)", "https://github.com/o/r/issues/7", "", "Fix the cache", "", "", "", "fix", ""]]) log = (self.dir(name) / "log.tsv").read_text() self.assertTrue(log.endswith("\n")) self.assertTrue(all(len(line.split("\t")) == 5 for line in log.splitlines())) @@ -6976,7 +7268,7 @@ def fail(text): def test_a_joined_line_stops_the_read_with_the_malformed_message(self): self.open("core") - self.brigade("core", "ticket", "add", "--summary", "a") + self.brigade("core", "ticket", "add", "--kind", "fix", "--summary", "a") log = self.dir("core") / "log.tsv" stamp = "2026-10-06T00:00:00.000000+00:00" log.write_text(log.read_text() + f"{stamp}\tticket\tT6\twaiting\tfro{stamp}\tticket\tT7\twaiting\tnote\n") @@ -6992,7 +7284,7 @@ def test_a_joined_line_stops_the_read_with_the_malformed_message(self): def test_a_table_that_is_not_utf8_names_the_line(self): self.open("core") - self.brigade("core", "ticket", "add", "--summary", "a") + self.brigade("core", "ticket", "add", "--kind", "fix", "--summary", "a") rail = self.dir("core") / "rail.tsv" rail.write_bytes(rail.read_bytes() + b"\xff\n") self.assertEqual(self.brigade("core", "ticket", "list", ok=False), @@ -7001,11 +7293,11 @@ def test_a_table_that_is_not_utf8_names_the_line(self): def test_a_malformed_line_is_named_with_its_store_from_a_sibling_from_walk_and_from_the_store_itself(self): self.open("core") self.open("docs") - self.brigade("docs", "ticket", "add", "--summary", "a") + self.brigade("docs", "ticket", "add", "--kind", "fix", "--summary", "a") rail = self.dir("docs") / "rail.tsv" rail.write_text(rail.read_text() + "not a row\n") line = "brigade: app/docs/rail.tsv line 3 is malformed; fix or remove it" - self.assertEqual(self.brigade("core", "ticket", "add", "--summary", "x", "--source", "user", "--ref", "gh#1", ok=False), + self.assertEqual(self.brigade("core", "ticket", "add", "--kind", "fix", "--summary", "x", "--source", "user", "--ref", "gh#1", ok=False), line) self.assertEqual(self.brigade("core", "watch", ok=False), line) self.assertEqual(self.brigade("docs", "ticket", "list", ok=False), line) @@ -7017,8 +7309,8 @@ def test_a_malformed_line_is_named_with_its_store_from_a_sibling_from_walk_and_f def test_walk_prints_every_other_store_and_exits_1_when_one_store_has_a_malformed_table(self): for name in ("core", "docs", "engine"): self.open(name) - self.brigade("core", "ticket", "add", "--summary", "a") - self.brigade("docs", "ticket", "add", "--summary", "b") + self.brigade("core", "ticket", "add", "--kind", "fix", "--summary", "a") + self.brigade("docs", "ticket", "add", "--kind", "fix", "--summary", "b") rail = self.dir("docs") / "rail.tsv" rail.write_text(rail.read_text() + "not a row\n") walked = subprocess.run([sys.executable, str(SCRIPT), "--store", str(self.store), "walk"], capture_output=True, text=True) @@ -7050,23 +7342,23 @@ def test_walk_names_each_malformed_table_on_stderr_when_two_stores_have_one(self def test_a_tail_cut_inside_a_multi_byte_character_is_skipped_by_a_read_and_dropped_by_the_next_append(self): self.open("core") - self.brigade("core", "ticket", "add", "--summary", "a") + self.brigade("core", "ticket", "add", "--kind", "fix", "--summary", "a") rail = self.dir("core") / "rail.tsv" complete = rail.read_bytes() rail.write_bytes(complete + "T9\t2026-10-10T00:00:00+00:00\twaiting\tuser\t\tcafé".encode()[:-1]) self.assertEqual(self.brigade("core", "ticket", "list"), "T1 waiting normal [user] a") - self.assertEqual(self.brigade("core", "ticket", "add", "--summary", "b"), "T2") + self.assertEqual(self.brigade("core", "ticket", "add", "--kind", "fix", "--summary", "b"), "T2") after = rail.read_bytes() self.assertEqual(after[:len(complete)], complete) added = after[len(complete):].decode() self.assertEqual((added.count("\n"), added[-1]), (1, "\n")) fields = added[:-1].split("\t") - self.assertEqual(fields[:1] + fields[2:], ["T2", "waiting", "user", "", "", "b", "", "", "", "", ""]) + self.assertEqual(fields[:1] + fields[2:], ["T2", "waiting", "user", "", "", "b", "", "", "", "fix", ""]) def test_a_short_append_restores_the_last_complete_row(self): from unittest import mock self.open("core") - self.brigade("core", "ticket", "add", "--summary", "a") + self.brigade("core", "ticket", "add", "--kind", "fix", "--summary", "a") log = self.dir("core") / "log.tsv" complete = log.read_text() log.write_text(complete + "2026-10-06T00:00:00.000000+00:00\tticket\tT9\twai") @@ -7217,6 +7509,21 @@ def test_goal_add_goal_set_and_goal_list_work_in_the_executive_admins_store(self self.assertEqual([row[1:] for row in self.rows(".admin", "log.tsv") if row[1] == "goal"], [["goal", "G1", "open", "routing"], ["goal", "G1", "paused", "routing"]]) + def test_the_executive_admins_ticket_add_needs_a_kind_and_takes_a_goal_and_its_moved_ticket_carries_the_kind(self): + self.open_admin() + self.open("core") + self.admin("goal", "add", "--name", "routing", "--purpose", "p", "--done", "d") + self.assertEqual(self.admin("ticket", "add", "--summary", "Fix the cache", ok=False), + "brigade: ticket add needs --kind <feature|fix|polish|upkeep|study>") + self.assertEqual(self.admin("ticket", "add", "--summary", "Fix the cache", "--kind", "fix", "--goal", "G1"), "T1") + self.assertEqual(self.rows(".admin", "rail.tsv")[0][10:], ["fix", "G1"]) + self.assertEqual(self.admin("ticket", "set", "T1", "--no-goal"), "T1 unassigned") + self.assertEqual(self.admin("ticket", "set", "T1", "--goal", "G1"), "T1 goal G1") + self.assertEqual(self.admin("ticket", "move", "T1", "--to", "core"), + "T1 moved to core; no thread recorded for core; it leaves G1 here and arrives unassigned") + self.brigade("core", "ticket", "take") + self.assertEqual(self.rows("core", "rail.tsv")[0][10:], ["fix", ""]) + def relays(self): """The admin's relay rows as (store@offset, the copied row).""" return [(row[2], json.loads(row[4])) for row in self.rows(".admin", "log.tsv") if row[1] == "relay"] @@ -7322,7 +7629,7 @@ def test_the_admin_store_refuses_work_commands(self): ("pass", "check", "D1", "--sha", "abc"), ("watch",)): self.assertEqual(self.admin(*args, ok=False), refusal, args) - self.assertEqual(self.admin("ticket", "add", "--summary", "Add a FAQ"), "T1") + self.assertEqual(self.admin("ticket", "add", "--kind", "fix", "--summary", "Add a FAQ"), "T1") self.assertEqual(self.admin("86", "add", "--question", "Which purpose?", "--options", "docs, engine", "--default", "docs"), "Q1") self.assertEqual(self.admin("status"), @@ -7336,10 +7643,10 @@ def test_a_ref_moved_through_the_admin_frees_at_every_step_once_done(self): self.open_admin() def refused(ident): - self.assertEqual(self.brigade("core", "ticket", "add", "--summary", "again", "--source", "github", "--ref", ref, + self.assertEqual(self.brigade("core", "ticket", "add", "--kind", "fix", "--summary", "again", "--source", "github", "--ref", ref, ok=False), f"brigade: {ref} is already {ident} (moved); nothing added") - self.assertEqual(self.brigade("core", "ticket", "add", "--summary", "Fix", "--source", "github", "--ref", ref), "T1") + self.assertEqual(self.brigade("core", "ticket", "add", "--kind", "fix", "--summary", "Fix", "--source", "github", "--ref", ref), "T1") self.brigade("core", "ticket", "move", "T1", "--to", ".admin") refused("T1") self.assertEqual(self.admin("inbox", "take"), "T1 from app/core/T1: Fix") @@ -7349,7 +7656,7 @@ def refused(ident): self.assertEqual(self.brigade("docs", "inbox", "take"), "T1 from app/.admin/T1: Fix") refused("T1") self.brigade("docs", "ticket", "set", "T1", "--state", "done") - self.assertEqual(self.brigade("core", "ticket", "add", "--summary", "Fix", "--source", "github", "--ref", ref), "T2") + self.assertEqual(self.brigade("core", "ticket", "add", "--kind", "fix", "--summary", "Fix", "--source", "github", "--ref", ref), "T2") self.brigade("core", "ticket", "move", "T2", "--to", ".admin") self.assertEqual(self.admin("inbox", "take"), "T2 from app/core/T2: Fix") self.admin("ticket", "move", "T2", "--to", "docs") @@ -7363,7 +7670,7 @@ def refused(ident): self.assertEqual(self.brigade("engine", "ticket", "take"), "T1 from app/.admin/T3: Fix") refused("T2") self.brigade("engine", "ticket", "set", "T1", "--state", "done") - self.assertEqual(self.brigade("core", "ticket", "add", "--summary", "Fix", "--source", "github", "--ref", ref), "T3") + self.assertEqual(self.brigade("core", "ticket", "add", "--kind", "fix", "--summary", "Fix", "--source", "github", "--ref", ref), "T3") def test_a_tracker_ref_the_admin_files_is_refused_again_and_reaches_the_coordinator_unchanged(self): ref = "app-7f3" @@ -7371,11 +7678,11 @@ def test_a_tracker_ref_the_admin_files_is_refused_again_and_reaches_the_coordina self.open_admin("--intake", "beads") def refused(store, ident, where=""): - self.assertEqual(self.brigade(store, "ticket", "add", "--summary", "again", "--source", "user", "--ref", ref, + self.assertEqual(self.brigade(store, "ticket", "add", "--kind", "fix", "--summary", "again", "--source", "user", "--ref", ref, ok=False), f"brigade: {ref} is already {ident}{where}; nothing added") - self.assertEqual(self.admin("ticket", "add", "--summary", "Fix login", "--source", "beads", "--ref", ref), "T1") - self.assertEqual(self.brigade("engine", "ticket", "add", "--summary", "Fix login", "--source", "beads", "--ref", ref, + self.assertEqual(self.admin("ticket", "add", "--kind", "fix", "--summary", "Fix login", "--source", "beads", "--ref", ref), "T1") + self.assertEqual(self.brigade("engine", "ticket", "add", "--kind", "fix", "--summary", "Fix login", "--source", "beads", "--ref", ref, ok=False), "brigade: .admin owns intake from beads; ask it to file this and move it here") refused(".admin", "T1", " (waiting)") refused("engine", "T1", " in .admin (waiting)") @@ -7582,13 +7889,13 @@ def test_a_request_finished_while_republish_runs_stays_finished(self): def test_a_replayed_from_user_request_files_no_second_ticket(self): self.open("docs") - self.assertEqual(self.brigade("docs", "ticket", "add", "--summary", "Add a FAQ", "--request", "A1"), "T1") + self.assertEqual(self.brigade("docs", "ticket", "add", "--kind", "fix", "--summary", "Add a FAQ", "--request", "A1"), "T1") refusal = "brigade: request A1 is already T1; nothing added" - self.assertEqual(self.brigade("docs", "ticket", "add", "--summary", "Add a FAQ", "--request", "A1", ok=False), refusal) + self.assertEqual(self.brigade("docs", "ticket", "add", "--kind", "fix", "--summary", "Add a FAQ", "--request", "A1", ok=False), refusal) self.brigade("docs", "ticket", "set", "T1", "--state", "done") - self.assertEqual(self.brigade("docs", "ticket", "add", "--summary", "Add a FAQ", "--request", "A1", ok=False), refusal) + self.assertEqual(self.brigade("docs", "ticket", "add", "--kind", "fix", "--summary", "Add a FAQ", "--request", "A1", ok=False), refusal) self.assertEqual(self.brigade("docs", "ticket", "list"), "T1 done normal [user (request A1)] Add a FAQ") - self.assertEqual(self.brigade("docs", "ticket", "add", "--summary", "Other", "--request", "A2"), "T2") + self.assertEqual(self.brigade("docs", "ticket", "add", "--kind", "fix", "--summary", "Other", "--request", "A2"), "T2") # Sync. @@ -7596,14 +7903,14 @@ def test_sync_relays_each_row_once_and_a_kill_before_the_cursor_copies_nothing_t self.open("docs") self.open_admin() start = self.size("docs") - self.brigade("docs", "ticket", "add", "--summary", "one") + self.brigade("docs", "ticket", "add", "--kind", "fix", "--summary", "one") sync = self.admin("sync") self.assertRegex(sync, r"^docs \S+ ticket T1 waiting: one$") self.assertEqual([(ident, row["kind"], row["id"], row["state"], row["note"]) for ident, row in self.relays()], [(f"app/docs@{start}", "ticket", "T1", "waiting", "one")]) self.assertEqual(self.cursor("docs"), self.size("docs")) self.assertEqual(self.admin("sync"), "nothing new") - self.brigade("docs", "ticket", "add", "--summary", "two") + self.brigade("docs", "ticket", "add", "--kind", "fix", "--summary", "two") before = self.cursor("docs") died = self.child("die-before-cursor", ".admin", "sync") self.assertEqual(died.returncode, 9, died.stderr) @@ -7616,7 +7923,7 @@ def test_sync_relays_each_row_once_and_a_kill_before_the_cursor_copies_nothing_t def test_a_row_appended_while_sync_runs_is_relayed_once_even_with_an_older_timestamp(self): self.open("docs") self.open_admin() - self.brigade("docs", "ticket", "add", "--summary", "one") + self.brigade("docs", "ticket", "add", "--kind", "fix", "--summary", "one") case, proc = self.admin_child("pause", "sync", "sync") _wait_for_path(case / "paused", timeout=20) older = "2020-01-01T00:00:00Z" @@ -7633,10 +7940,10 @@ def test_a_row_appended_while_sync_runs_is_relayed_once_even_with_an_older_times def test_an_older_sync_finishing_late_never_moves_the_cursor_back(self): self.open("docs") self.open_admin() - self.brigade("docs", "ticket", "add", "--summary", "one") + self.brigade("docs", "ticket", "add", "--kind", "fix", "--summary", "one") case, outer = self.admin_child("pause", "outer", "sync") _wait_for_path(case / "paused", timeout=20) - self.brigade("docs", "ticket", "add", "--summary", "two") + self.brigade("docs", "ticket", "add", "--kind", "fix", "--summary", "two") self.admin("sync") consumed = self.size("docs") self.assertEqual(self.cursor("docs"), consumed) @@ -7651,9 +7958,9 @@ def test_sync_leaves_an_unfinished_tail_and_relays_the_repaired_row_once(self): self.open("core") self.open_admin() for number in range(1, 7): - self.brigade("engine", "ticket", "add", "--summary", f"engine {number}") + self.brigade("engine", "ticket", "add", "--kind", "fix", "--summary", f"engine {number}") for number in range(1, 6): - self.brigade("core", "ticket", "add", "--summary", f"core {number}") + self.brigade("core", "ticket", "add", "--kind", "fix", "--summary", f"core {number}") self.brigade("engine", "ticket", "move", "T6", "--to", "core") self.admin("sync") died = self.child("die-inside-log-append", "core", "ticket", "take") @@ -7792,7 +8099,7 @@ def test_recovery_needs_a_stopped_run_and_retirement_raises_the_generation(self) self.assertEqual([row[2:] for row in self.rows(".admin", "log.tsv") if row[1] == "thread" and row[2] == "@4"], [["@4", "recorded", "replaced t2"]]) self.assertEqual(self.admin("--owner", "t2@3", "status").splitlines()[0], "thread not recorded") - self.assertEqual(self.admin("--owner", "t2@3", "ticket", "add", "--summary", "late", ok=False), + self.assertEqual(self.admin("--owner", "t2@3", "ticket", "add", "--kind", "fix", "--summary", "late", ok=False), "brigade: owner t2@3 is stale; this store is owned by @4") self.admin("set", "--thread", "recovering:t5", "--replace", "--expect", "") self.admin("set", "--thread", "t6", "--replace", "--expect", "recovering:t5", "--stopped", "gone") @@ -7902,8 +8209,8 @@ def test_claim_first_then_a_paused_rule_add_is_refused(self): def test_after_a_terminal_wait_the_old_runs_paused_commands_change_nothing(self): self.with_thread() - self.brigade("docs", "ticket", "add", "--summary", "one") - self.admin("ticket", "add", "--summary", "route me") + self.brigade("docs", "ticket", "add", "--kind", "fix", "--summary", "one") + self.admin("ticket", "add", "--kind", "fix", "--summary", "route me") old = ("--owner", "t1@1") paused = [self.admin_child("pause", f"stale{number}", *old, *args) for number, args in enumerate(( ("rule", "add", "--kind", "ownership", "--parties", "docs,engine", "--question", "q", "--rule", "priority", "--decision", "d"), From 0d69a60d55bad83351d5c771c4ff371b88b5f272 Mon Sep 17 00:00:00 2001 From: Marcus Gibson <marcusroygibson@gmail.com> Date: Sun, 11 Oct 2026 08:45:00 -0500 Subject: [PATCH 08/19] brigade: give each new work item its lead ticket's kind and goal at fire fire now writes a kind and a goal on the item it starts. The goal is the lead ticket's, the first id in --tickets. The kind is the lead ticket's when it has one, then --kind, then the station's. Station bug-fix and perf-issue give fix, and investigation gives study. A fire that finds no kind is refused. The lead ticket's kind cell takes the item's kind in the same rewrite that assigns the tickets, so the two rows agree. A ticket after the lead must have the lead ticket's goal or no goal. --ride-along names the ones that may be under another goal. Each must have priority low and must not be a feature, and each keeps its own kind and goal. The check runs under both locks. Its first run comes after the owner check, the not-waiting check, and the worker cap, so a fire one of those refused or blocked before gets the same words. It comes before the lease claim, so a fire it refuses claims no lease and writes nothing. Its second run catches a ticket set made while the claim ran, and releases the lease. A block note records --kind and --ride-along when the blocked fire had them, and watch reprints them. A note with neither holds the keys it held before and reprints the same line. One existing assertion changes. A follow-up ticket of an item started on station bug-fix now has the kind polish in its kind cell. --- docs/cli/brigade.md | 6 +- skills/brigade/scripts/brigade.py | 113 ++++++- t3/added/brigade/scripts/brigade.py | 113 ++++++- tests/test_brigade.py | 479 +++++++++++++++++++++++++++- 4 files changed, 686 insertions(+), 25 deletions(-) diff --git a/docs/cli/brigade.md b/docs/cli/brigade.md index 4aa88d04..8db21f75 100644 --- a/docs/cli/brigade.md +++ b/docs/cli/brigade.md @@ -215,14 +215,14 @@ the state, name, counts, purpose, and done test of each open or paused goal and ## brigade.py fire ```text -brigade.py [flags] fire --tickets TICKETS --station STATION --summary SUMMARY [--task TASK] [--thread THREAD] [--branch BRANCH] [--timebox TIMEBOX] [--paths PATHS] [--mode MODE] [--reason REASON] +brigade.py [flags] fire --tickets TICKETS --station STATION --summary SUMMARY [--task TASK] [--thread THREAD] [--branch BRANCH] [--timebox TIMEBOX] [--paths PATHS] [--mode MODE] [--reason REASON] [--kind KIND] [--ride-along RIDE_ALONG] ``` group tickets into one dish and assign it to a station | Argument | Required | Choices | Default | Help | | --- | --- | --- | --- | --- | -| `--tickets TICKETS` | yes | | | comma-separated ticket ids | +| `--tickets TICKETS` | yes | | | comma-separated ticket ids; the first is the lead ticket; the item takes the lead ticket's goal, and its kind when it has one | | `--station STATION` | yes | | | the pstack playbook the worker runs | | `--summary SUMMARY` | yes | | | | | `--task TASK` | | | | | @@ -232,6 +232,8 @@ group tickets into one dish and assign it to a station | `--paths PATHS` | | | | paths the dish will change; fire claims a landing lease on them first | | `--mode MODE` | | | | full, to start the dish in full mode; needs --reason | | `--reason REASON` | | | | one line: why the dish runs in full mode | +| `--kind KIND` | | `feature`, `fix`, `polish`, `upkeep`, `study` | | the item's kind when the lead ticket has none or --tickets names no ticket; a lead ticket with no kind records it too; refused when the lead ticket has another kind; with no --kind and no kind from the lead ticket, station bug-fix or perf-issue gives fix, station investigation gives study, and fire is refused on any other station | +| `--ride-along RIDE_ALONG` | | | | comma-separated ids from --tickets, not the first, that may be under another goal than the first ticket's; each must have priority low and must not be a feature; every other ticket after the first must have the first ticket's goal or no goal | ## brigade.py brief diff --git a/skills/brigade/scripts/brigade.py b/skills/brigade/scripts/brigade.py index 079f7c9d..47939c4c 100644 --- a/skills/brigade/scripts/brigade.py +++ b/skills/brigade/scripts/brigade.py @@ -2288,19 +2288,24 @@ def refusal_kind(message): return "lease" -def block_note(kind, ids, station, summary, paths, timebox, branch="", reason=""): +def block_note(kind, ids, station, summary, paths, timebox, branch="", reason="", work="", ride_along=()): + """`kind` is the kind of block. `work` is the --kind fire was given, and `ride_along` the ids --ride-along named.""" note = {"kind": kind, "tickets": list(ids), "station": station, "summary": summary, "paths": paths, "timebox": timebox} if branch: note["branch"] = branch if reason: note["reason"] = reason + if work: + note["work"] = work + if ride_along: + note["ride_along"] = list(ride_along) return json.dumps(note, separators=(",", ":")) -def record_blocked(restaurant, ids, kind, station, summary, paths, timebox, branch="", reason=""): +def record_blocked(restaurant, ids, kind, station, summary, paths, timebox, branch="", reason="", work="", ride_along=()): waiting = {row["id"] for row in restaurant.rows("rail.tsv") if row["state"] == "waiting"} - note = block_note(kind, ids, station, summary, paths, timebox, branch, reason) + note = block_note(kind, ids, station, summary, paths, timebox, branch, reason, work, ride_along) for ident in ids: if ident in waiting: restaurant.log("ticket", ident, "blocked", note) @@ -2381,6 +2386,10 @@ def fire_command(ident, note): if note.get("paths"): pairs.append(("paths", note["paths"])) pairs.append(("timebox", note.get("timebox", 60))) + if note.get("work"): + pairs.append(("kind", note["work"])) + if note.get("ride_along"): + pairs.append(("ride-along", ",".join(note["ride_along"]))) if note.get("reason"): pairs += [("mode", "full"), ("reason", note["reason"])] words = ["fire", *(f"--{name}={value}" for name, value in pairs)] @@ -2577,7 +2586,68 @@ def unfireable(restaurant, ids): return None -def fire(restaurant, ids, station, task, thread, branch, summary, timebox, paths, reason=None): +def fire_label(labels, rail, ids, station, kind, ride_along): + """The kind and goal the new item takes, as (Label, None), or (None, refusal). `rail` is {ticket id: row}. + + Call it after unfireable has found each id in `ids` and found it waiting. The lead ticket is ids[0]. + The item takes the lead ticket's goal, and no goal when `ids` is empty. + The kind is the first of these that gives one. The lead ticket's kind, then `kind`, then STATION_KIND[station]. + `kind` beside a lead ticket of another kind is refused. + Each id in `ride_along` must be in `ids`, must not be the lead ticket, must have priority low, and must not be a feature. + Each other ticket after the lead must have the lead ticket's goal or no goal. + """ + lead = ids[0] if ids else None + mine = labels.tickets[lead] if lead else Label() + if kind and mine.kind and kind != mine.kind: + return None, (f"{lead} has kind {mine.kind}, and --kind {kind} does not match it; " + f"drop --kind, or run ticket set {lead} --kind {kind} first") + found = mine.kind or kind or STATION_KIND.get(station, "") + if not found: + return None, ((f"{lead} has no kind" if lead else "no ticket gives this item a kind") + + f" and station {station} gives none; pass --kind {KIND_CHOICE}") + for ident in ride_along: + if ident not in ids: + return None, f"--ride-along names {ident}, which is not in --tickets; add it there or drop it" + if ident == lead: + return None, (f"{ident} is the lead ticket, and the lead ticket cannot ride along; " + "put the ticket whose goal the item takes first in --tickets") + if priority_of(rail[ident]) != "low": + return None, (f"{ident} is {priority_of(rail[ident])}; only a low ticket rides along; " + f"start it on its own, or run ticket set {ident} --priority low") + if labels.tickets[ident].kind == "feature": + return None, f"{ident} is a feature, and a feature does not ride along; start it as its own item" + for ident in ids[1:]: + theirs = labels.tickets[ident].goal + if ident not in ride_along and theirs and theirs != mine.goal: + return None, (f"{ident} is under {theirs} and the lead ticket {lead} is {'under ' + mine.goal if mine.goal else 'unassigned'}; " + f"name {ident} in --ride-along, or start it on its own") + return Label(found, mine.goal), None + + +def checked_fire_label(restaurant, ids, station, kind, ride_along, warned): + """fire_label on the store as it is now, read under the caller's lock. + + It prints the warning for each bad kind or goal cell of a ticket in `ids`, once per text in `warned`. + """ + labels = read_labels(restaurant) + for ident, text in labels.faults: + if ident in ids and text not in warned: + warned.add(text) + print(f"brigade: warning: {text}", file=sys.stderr) + rail = {row["id"]: row for row in restaurant.rows("rail.tsv")} + return fire_label(labels, rail, ids, station, kind, ride_along) + + +def fire(restaurant, ids, station, task, thread, branch, summary, timebox, paths, reason=None, kind="", ride_along=()): + """Start one item for the tickets in `ids`. The item takes the kind and goal fire_label gives. + + The kind and goal check runs under both locks. The first run comes after the owner check, unfireable, and the worker + cap, so a fire that one of those refuses or blocks gets the words it got before kinds and goals. It comes before the + lease claim, so a fire it refuses claims no lease. The second run sees a change made while the claim ran. + The lead ticket's kind cell takes the item's kind in the rewrite that assigns the tickets. No other kind or goal cell + of a ticket changes, so a ticket that rides along keeps its own. + """ + warned = set() while True: with restaurant.checked(): restaurant.fence() @@ -2589,25 +2659,32 @@ def fire(restaurant, ids, station, task, thread, branch, summary, timebox, paths leased = with_fragment(paths, known) if paths else "" full = workers_full(restaurant) if full: - record_blocked(restaurant, ids, "workers", station, summary, paths, timebox, branch, reason) + record_blocked(restaurant, ids, "workers", station, summary, paths, timebox, branch, reason, kind, ride_along) raise BrigadeError(f"nothing fired: {full}") + _, refusal = checked_fire_label(restaurant, ids, station, kind, ride_along, warned) + if refusal: + raise BrigadeError(refusal) # land.py can wait on the landing database, so the claim runs outside the store lock. # Another command may fire or take the tickets meanwhile, so the checks run again before the write. try: lease = claim_lease(restaurant, dish, leased) if leased else "" except ClaimError as error: with restaurant.checked(): - record_blocked(restaurant, ids, refusal_kind(str(error)), station, summary, paths, timebox, branch, reason) + record_blocked(restaurant, ids, refusal_kind(str(error)), station, summary, paths, timebox, branch, reason, + kind, ride_along) raise BrigadeError(f"nothing fired: {error}") from error with restaurant.checked(): refusal = unfireable(restaurant, ids) current = restaurant.next_id("dishes.tsv") full = None if refusal else workers_full(restaurant) - if not refusal and not full and current == dish: + label = None + if not refusal and not full: + label, refusal = checked_fire_label(restaurant, ids, station, kind, ride_along, warned) + if label and current == dish: restaurant.append("dishes.tsv", {"id": dish, "at": now(), "state": "in-progress", "station": station, "tickets": ",".join(ids), "task": task, "thread": thread, "branch": branch, "summary": summary, "timebox": timebox, - "lease": lease, "paths": leased}) + "lease": lease, "paths": leased, "kind": label.kind, "goal": label.goal}) restaurant.log("dish", dish, "in-progress", summary) if reason: record_escalation(restaurant, dish, reason) @@ -2615,13 +2692,15 @@ def fire(restaurant, ids, station, task, thread, branch, summary, timebox, paths for row in rows: if row["id"] in ids: row["state"], row["dish"] = "assigned", dish + if row["id"] in ids[:1]: + row["kind"] = label.kind restaurant.save_rows("rail.tsv", rows) for row in rows: if row["id"] in ids: restaurant.log("ticket", row["id"], "assigned", row["summary"]) return f"{dish} (lease {lease} held by {holder(restaurant, dish)})" if lease else dish if full and not refusal: - record_blocked(restaurant, ids, "workers", station, summary, paths, timebox, branch, reason) + record_blocked(restaurant, ids, "workers", station, summary, paths, timebox, branch, reason, kind, ride_along) refusal = full if lease: release_lease(restaurant, lease) @@ -3567,7 +3646,9 @@ def parser(): a.add_argument("--all", action="store_true", help="also print each other done or dropped goal") p = sub.add_parser("fire", help="group tickets into one dish and assign it to a station") - p.add_argument("--tickets", required=True, help="comma-separated ticket ids") + p.add_argument("--tickets", required=True, + help="comma-separated ticket ids; the first is the lead ticket; the item takes the lead ticket's goal, " + "and its kind when it has one") p.add_argument("--station", required=True, help="the pstack playbook the worker runs") p.add_argument("--summary", required=True) for field in ("task", "thread", "branch"): @@ -3576,6 +3657,15 @@ def parser(): p.add_argument("--paths", default="", help="paths the dish will change; fire claims a landing lease on them first") p.add_argument("--mode", help="full, to start the dish in full mode; needs --reason") p.add_argument("--reason", help="one line: why the dish runs in full mode") + p.add_argument("--kind", choices=KINDS, + help="the item's kind when the lead ticket has none or --tickets names no ticket; a lead ticket with no " + "kind records it too; refused when the lead ticket has another kind; with no --kind and no kind " + "from the lead ticket, station bug-fix or perf-issue gives fix, station investigation gives study, " + "and fire is refused on any other station") + p.add_argument("--ride-along", default="", + help="comma-separated ids from --tickets, not the first, that may be under another goal than the first " + "ticket's; each must have priority low and must not be a feature; every other ticket after the " + "first must have the first ticket's goal or no goal") p = sub.add_parser("brief", help="render the worker brief for a dish; refuses when a field or the coordinator thread is missing") p.add_argument("id") @@ -3861,8 +3951,9 @@ def run(argv): return verify_edits(restaurant, args.dish, args.from_sha, args.to) if args.command == "fire": ids = [ident.strip() for ident in args.tickets.split(",") if ident.strip()] + ride_along = [ident.strip() for ident in args.ride_along.split(",") if ident.strip()] return fire(restaurant, ids, args.station, args.task, args.thread, args.branch, args.summary, args.timebox, - args.paths, args.reason) + args.paths, args.reason, args.kind or "", ride_along) if args.command == "watch": return watch(restaurant) if args.command == "startable": diff --git a/t3/added/brigade/scripts/brigade.py b/t3/added/brigade/scripts/brigade.py index 079f7c9d..47939c4c 100644 --- a/t3/added/brigade/scripts/brigade.py +++ b/t3/added/brigade/scripts/brigade.py @@ -2288,19 +2288,24 @@ def refusal_kind(message): return "lease" -def block_note(kind, ids, station, summary, paths, timebox, branch="", reason=""): +def block_note(kind, ids, station, summary, paths, timebox, branch="", reason="", work="", ride_along=()): + """`kind` is the kind of block. `work` is the --kind fire was given, and `ride_along` the ids --ride-along named.""" note = {"kind": kind, "tickets": list(ids), "station": station, "summary": summary, "paths": paths, "timebox": timebox} if branch: note["branch"] = branch if reason: note["reason"] = reason + if work: + note["work"] = work + if ride_along: + note["ride_along"] = list(ride_along) return json.dumps(note, separators=(",", ":")) -def record_blocked(restaurant, ids, kind, station, summary, paths, timebox, branch="", reason=""): +def record_blocked(restaurant, ids, kind, station, summary, paths, timebox, branch="", reason="", work="", ride_along=()): waiting = {row["id"] for row in restaurant.rows("rail.tsv") if row["state"] == "waiting"} - note = block_note(kind, ids, station, summary, paths, timebox, branch, reason) + note = block_note(kind, ids, station, summary, paths, timebox, branch, reason, work, ride_along) for ident in ids: if ident in waiting: restaurant.log("ticket", ident, "blocked", note) @@ -2381,6 +2386,10 @@ def fire_command(ident, note): if note.get("paths"): pairs.append(("paths", note["paths"])) pairs.append(("timebox", note.get("timebox", 60))) + if note.get("work"): + pairs.append(("kind", note["work"])) + if note.get("ride_along"): + pairs.append(("ride-along", ",".join(note["ride_along"]))) if note.get("reason"): pairs += [("mode", "full"), ("reason", note["reason"])] words = ["fire", *(f"--{name}={value}" for name, value in pairs)] @@ -2577,7 +2586,68 @@ def unfireable(restaurant, ids): return None -def fire(restaurant, ids, station, task, thread, branch, summary, timebox, paths, reason=None): +def fire_label(labels, rail, ids, station, kind, ride_along): + """The kind and goal the new item takes, as (Label, None), or (None, refusal). `rail` is {ticket id: row}. + + Call it after unfireable has found each id in `ids` and found it waiting. The lead ticket is ids[0]. + The item takes the lead ticket's goal, and no goal when `ids` is empty. + The kind is the first of these that gives one. The lead ticket's kind, then `kind`, then STATION_KIND[station]. + `kind` beside a lead ticket of another kind is refused. + Each id in `ride_along` must be in `ids`, must not be the lead ticket, must have priority low, and must not be a feature. + Each other ticket after the lead must have the lead ticket's goal or no goal. + """ + lead = ids[0] if ids else None + mine = labels.tickets[lead] if lead else Label() + if kind and mine.kind and kind != mine.kind: + return None, (f"{lead} has kind {mine.kind}, and --kind {kind} does not match it; " + f"drop --kind, or run ticket set {lead} --kind {kind} first") + found = mine.kind or kind or STATION_KIND.get(station, "") + if not found: + return None, ((f"{lead} has no kind" if lead else "no ticket gives this item a kind") + + f" and station {station} gives none; pass --kind {KIND_CHOICE}") + for ident in ride_along: + if ident not in ids: + return None, f"--ride-along names {ident}, which is not in --tickets; add it there or drop it" + if ident == lead: + return None, (f"{ident} is the lead ticket, and the lead ticket cannot ride along; " + "put the ticket whose goal the item takes first in --tickets") + if priority_of(rail[ident]) != "low": + return None, (f"{ident} is {priority_of(rail[ident])}; only a low ticket rides along; " + f"start it on its own, or run ticket set {ident} --priority low") + if labels.tickets[ident].kind == "feature": + return None, f"{ident} is a feature, and a feature does not ride along; start it as its own item" + for ident in ids[1:]: + theirs = labels.tickets[ident].goal + if ident not in ride_along and theirs and theirs != mine.goal: + return None, (f"{ident} is under {theirs} and the lead ticket {lead} is {'under ' + mine.goal if mine.goal else 'unassigned'}; " + f"name {ident} in --ride-along, or start it on its own") + return Label(found, mine.goal), None + + +def checked_fire_label(restaurant, ids, station, kind, ride_along, warned): + """fire_label on the store as it is now, read under the caller's lock. + + It prints the warning for each bad kind or goal cell of a ticket in `ids`, once per text in `warned`. + """ + labels = read_labels(restaurant) + for ident, text in labels.faults: + if ident in ids and text not in warned: + warned.add(text) + print(f"brigade: warning: {text}", file=sys.stderr) + rail = {row["id"]: row for row in restaurant.rows("rail.tsv")} + return fire_label(labels, rail, ids, station, kind, ride_along) + + +def fire(restaurant, ids, station, task, thread, branch, summary, timebox, paths, reason=None, kind="", ride_along=()): + """Start one item for the tickets in `ids`. The item takes the kind and goal fire_label gives. + + The kind and goal check runs under both locks. The first run comes after the owner check, unfireable, and the worker + cap, so a fire that one of those refuses or blocks gets the words it got before kinds and goals. It comes before the + lease claim, so a fire it refuses claims no lease. The second run sees a change made while the claim ran. + The lead ticket's kind cell takes the item's kind in the rewrite that assigns the tickets. No other kind or goal cell + of a ticket changes, so a ticket that rides along keeps its own. + """ + warned = set() while True: with restaurant.checked(): restaurant.fence() @@ -2589,25 +2659,32 @@ def fire(restaurant, ids, station, task, thread, branch, summary, timebox, paths leased = with_fragment(paths, known) if paths else "" full = workers_full(restaurant) if full: - record_blocked(restaurant, ids, "workers", station, summary, paths, timebox, branch, reason) + record_blocked(restaurant, ids, "workers", station, summary, paths, timebox, branch, reason, kind, ride_along) raise BrigadeError(f"nothing fired: {full}") + _, refusal = checked_fire_label(restaurant, ids, station, kind, ride_along, warned) + if refusal: + raise BrigadeError(refusal) # land.py can wait on the landing database, so the claim runs outside the store lock. # Another command may fire or take the tickets meanwhile, so the checks run again before the write. try: lease = claim_lease(restaurant, dish, leased) if leased else "" except ClaimError as error: with restaurant.checked(): - record_blocked(restaurant, ids, refusal_kind(str(error)), station, summary, paths, timebox, branch, reason) + record_blocked(restaurant, ids, refusal_kind(str(error)), station, summary, paths, timebox, branch, reason, + kind, ride_along) raise BrigadeError(f"nothing fired: {error}") from error with restaurant.checked(): refusal = unfireable(restaurant, ids) current = restaurant.next_id("dishes.tsv") full = None if refusal else workers_full(restaurant) - if not refusal and not full and current == dish: + label = None + if not refusal and not full: + label, refusal = checked_fire_label(restaurant, ids, station, kind, ride_along, warned) + if label and current == dish: restaurant.append("dishes.tsv", {"id": dish, "at": now(), "state": "in-progress", "station": station, "tickets": ",".join(ids), "task": task, "thread": thread, "branch": branch, "summary": summary, "timebox": timebox, - "lease": lease, "paths": leased}) + "lease": lease, "paths": leased, "kind": label.kind, "goal": label.goal}) restaurant.log("dish", dish, "in-progress", summary) if reason: record_escalation(restaurant, dish, reason) @@ -2615,13 +2692,15 @@ def fire(restaurant, ids, station, task, thread, branch, summary, timebox, paths for row in rows: if row["id"] in ids: row["state"], row["dish"] = "assigned", dish + if row["id"] in ids[:1]: + row["kind"] = label.kind restaurant.save_rows("rail.tsv", rows) for row in rows: if row["id"] in ids: restaurant.log("ticket", row["id"], "assigned", row["summary"]) return f"{dish} (lease {lease} held by {holder(restaurant, dish)})" if lease else dish if full and not refusal: - record_blocked(restaurant, ids, "workers", station, summary, paths, timebox, branch, reason) + record_blocked(restaurant, ids, "workers", station, summary, paths, timebox, branch, reason, kind, ride_along) refusal = full if lease: release_lease(restaurant, lease) @@ -3567,7 +3646,9 @@ def parser(): a.add_argument("--all", action="store_true", help="also print each other done or dropped goal") p = sub.add_parser("fire", help="group tickets into one dish and assign it to a station") - p.add_argument("--tickets", required=True, help="comma-separated ticket ids") + p.add_argument("--tickets", required=True, + help="comma-separated ticket ids; the first is the lead ticket; the item takes the lead ticket's goal, " + "and its kind when it has one") p.add_argument("--station", required=True, help="the pstack playbook the worker runs") p.add_argument("--summary", required=True) for field in ("task", "thread", "branch"): @@ -3576,6 +3657,15 @@ def parser(): p.add_argument("--paths", default="", help="paths the dish will change; fire claims a landing lease on them first") p.add_argument("--mode", help="full, to start the dish in full mode; needs --reason") p.add_argument("--reason", help="one line: why the dish runs in full mode") + p.add_argument("--kind", choices=KINDS, + help="the item's kind when the lead ticket has none or --tickets names no ticket; a lead ticket with no " + "kind records it too; refused when the lead ticket has another kind; with no --kind and no kind " + "from the lead ticket, station bug-fix or perf-issue gives fix, station investigation gives study, " + "and fire is refused on any other station") + p.add_argument("--ride-along", default="", + help="comma-separated ids from --tickets, not the first, that may be under another goal than the first " + "ticket's; each must have priority low and must not be a feature; every other ticket after the " + "first must have the first ticket's goal or no goal") p = sub.add_parser("brief", help="render the worker brief for a dish; refuses when a field or the coordinator thread is missing") p.add_argument("id") @@ -3861,8 +3951,9 @@ def run(argv): return verify_edits(restaurant, args.dish, args.from_sha, args.to) if args.command == "fire": ids = [ident.strip() for ident in args.tickets.split(",") if ident.strip()] + ride_along = [ident.strip() for ident in args.ride_along.split(",") if ident.strip()] return fire(restaurant, ids, args.station, args.task, args.thread, args.branch, args.summary, args.timebox, - args.paths, args.reason) + args.paths, args.reason, args.kind or "", ride_along) if args.command == "watch": return watch(restaurant) if args.command == "startable": diff --git a/tests/test_brigade.py b/tests/test_brigade.py index 7c2565e6..a2db2f6d 100644 --- a/tests/test_brigade.py +++ b/tests/test_brigade.py @@ -6491,6 +6491,483 @@ def test_a_dropped_ticket_and_a_done_ticket_of_a_goal_do_not_keep_the_goal_open( self.brigade("ticket", "set", "T2", "--state", "done") self.assertEqual(self.brigade("goal", "set", "G1", "--state", "dropped", "--evidence", "why"), "G1 dropped") + KIND_CHOICE = "<feature|fix|polish|upkeep|study>" + + def rail_before_kinds(self, *summaries): + """rail.tsv as the tool wrote it before kinds and goals. Each row is a waiting user ticket of ten cells.""" + (self.at / "rail.tsv").write_text( + "id\tat\tstate\tsource\tref\tdish\tsummary\tpriority\tpaths\tdecision\n" + + "".join(f"T{number}\t2026-10-01T00:00:00+00:00\twaiting\tuser\t\t\t{summary}\t\t\t\n" + for number, summary in enumerate(summaries, 1))) + + def two_goals(self): + self.brigade("open", "--project-root", str(self.project), "--name", "Perf", "--workers", "9") + self.goal("activity view") + self.goal("current with upstream") + + def fake_land(self): + """A land.py that records its words, waits at a claim until claim-proceed exists, and prints lease L7.""" + case = Path(self.temporary.name) + (case / "land.py").write_text( + "import sys, time\nfrom pathlib import Path\n" + f"case = Path({str(case)!r})\n" + "with open(case / 'land-calls', 'a') as f:\n f.write(' '.join(sys.argv[3:]) + '\\n')\n" + "if sys.argv[4] == 'claim':\n" + " (case / 'claiming').touch()\n" + " end = time.monotonic() + 30\n" + " while not (case / 'claim-proceed').exists() and time.monotonic() < end:\n time.sleep(0.01)\n" + " print('L7')\n" + "else:\n print('L7 released')\n") + return case + + def fire_with_fake_land(self, case, *words): + proc = subprocess.Popen([sys.executable, str(Path(__file__).resolve()), "--race-child", "fake-land", str(case), + "--store", str(self.store), "--at", str(self.at), "fire", *words], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + env=dict(os.environ, PYTHONDONTWRITEBYTECODE="1")) + self.addCleanup(lambda: proc.poll() is None and proc.kill()) + return proc + + def test_fire_gives_the_item_the_kind_and_goal_of_its_lead_ticket_and_leaves_that_tickets_two_cells_as_they_were(self): + self.two_goals() + self.brigade("ticket", "add", "--summary", "a", "--kind", "polish", "--goal", "G2") + self.assertEqual(self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "Do a"), "D1") + self.assertEqual(self.label("dishes.tsv", "D1"), ("polish", "G2")) + self.assertEqual(self.label("rail.tsv", "T1"), ("polish", "G2")) + + def test_fire_kind_gives_the_kind_to_the_item_and_to_a_lead_ticket_that_has_none(self): + self.open() + self.rail_before_kinds("a") + self.assertEqual(self.brigade("fire", "--tickets", "T1", "--station", "feature", "--summary", "Do a", "--kind", "upkeep"), "D1") + self.assertEqual(self.label("dishes.tsv", "D1"), ("upkeep", "")) + self.assertEqual(self.label("rail.tsv", "T1"), ("upkeep", "")) + + def test_fire_with_no_kind_from_the_ticket_or_the_flag_gives_the_item_and_the_lead_ticket_the_kind_of_its_station(self): + self.brigade("open", "--project-root", str(self.project), "--name", "Perf", "--workers", "9") + self.rail_before_kinds("a", "b", "c") + for number, (station, kind) in enumerate((("bug-fix", "fix"), ("perf-issue", "fix"), ("investigation", "study")), 1): + with self.subTest(station=station): + self.assertEqual(self.brigade("fire", "--tickets", f"T{number}", "--station", station, "--summary", f"Do {number}"), + f"D{number}") + self.assertEqual(self.label("dishes.tsv", f"D{number}"), (kind, "")) + self.assertEqual(self.label("rail.tsv", f"T{number}"), (kind, "")) + + def test_fire_takes_the_lead_tickets_kind_over_the_kind_of_its_station(self): + self.open() + self.brigade("ticket", "add", "--summary", "a", "--kind", "upkeep") + self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "Do a") + self.assertEqual((self.label("dishes.tsv", "D1"), self.label("rail.tsv", "T1")), (("upkeep", ""), ("upkeep", ""))) + + def test_fire_kind_is_taken_over_the_kind_of_the_station(self): + self.open() + self.rail_before_kinds("a") + self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "Do a", "--kind", "polish") + self.assertEqual((self.label("dishes.tsv", "D1"), self.label("rail.tsv", "T1")), (("polish", ""), ("polish", ""))) + + def test_fire_of_a_lead_ticket_with_no_kind_on_a_station_that_gives_none_is_refused_and_changes_no_file(self): + self.open() + self.rail_before_kinds("a") + before = self.store_files() + for station in ("feature", "refactoring", "docs"): + with self.subTest(station=station): + self.assertEqual(self.brigade("fire", "--tickets", "T1", "--station", station, "--summary", "Do a", ok=False), + f"brigade: T1 has no kind and station {station} gives none; pass --kind {self.KIND_CHOICE}") + self.assertEqual(self.store_files(), before) + + def test_fire_with_paths_of_a_lead_ticket_with_no_kind_is_refused_before_it_claims_a_lease_and_changes_no_file(self): + case = self.fake_land() + (case / "claim-proceed").touch() + self.open() + self.rail_before_kinds("a") + before = self.store_files() + proc = self.fire_with_fake_land(case, "--tickets", "T1", "--station", "feature", "--summary", "Do a", "--paths", "src") + _, err = proc.communicate(timeout=20) + self.assertEqual((proc.returncode, err.strip()), + (1, f"brigade: T1 has no kind and station feature gives none; pass --kind {self.KIND_CHOICE}")) + self.assertFalse((case / "land-calls").exists()) + self.assertEqual(self.store_files(), before) + + def test_fire_with_no_ticket_needs_a_kind_from_the_flag_or_the_station(self): + self.open() + before = self.store_files() + self.assertEqual(self.brigade("fire", "--tickets", ",", "--station", "feature", "--summary", "No ticket", ok=False), + "brigade: no ticket gives this item a kind and station feature gives none; " + f"pass --kind {self.KIND_CHOICE}") + self.assertEqual(self.store_files(), before) + self.assertEqual(self.brigade("fire", "--tickets", ",", "--station", "feature", "--summary", "No ticket", "--kind", "study"), "D1") + self.assertEqual(self.brigade("fire", "--tickets", ",", "--station", "bug-fix", "--summary", "No ticket"), "D2") + self.assertEqual((self.label("dishes.tsv", "D1"), self.label("dishes.tsv", "D2")), (("study", ""), ("fix", ""))) + + def test_fire_kind_that_differs_from_the_lead_tickets_kind_is_refused_and_an_equal_one_is_accepted(self): + self.open() + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix") + before = self.store_files() + self.assertEqual(self.brigade("fire", "--tickets", "T1", "--station", "feature", "--summary", "Do a", "--kind", "feature", ok=False), + "brigade: T1 has kind fix, and --kind feature does not match it; " + "drop --kind, or run ticket set T1 --kind feature first") + self.assertEqual(self.store_files(), before) + self.assertEqual(self.brigade("fire", "--tickets", "T1", "--station", "feature", "--summary", "Do a", "--kind", "fix"), "D1") + self.assertEqual(self.label("dishes.tsv", "D1"), ("fix", "")) + + def test_fire_kind_refuses_a_word_that_is_none_of_the_five(self): + self.open() + self.rail_before_kinds("a") + before = self.store_files() + self.assert_refused_choice(self.brigade("fire", "--tickets", "T1", "--station", "feature", "--summary", "Do a", "--kind", "chore", + ok=False), "--kind", "chore", ("feature", "fix", "polish", "upkeep", "study")) + self.assertEqual(self.store_files(), before) + + def test_fire_refuses_a_ticket_after_the_lead_that_is_under_another_goal_and_takes_it_as_a_ride_along(self): + self.two_goals() + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix", "--goal", "G1") + self.brigade("ticket", "add", "--summary", "b", "--kind", "polish", "--goal", "G2", "--priority", "low") + before = self.store_files() + self.assertEqual(self.brigade("fire", "--tickets", "T1,T2", "--station", "bug-fix", "--summary", "Do both", ok=False), + "brigade: T2 is under G2 and the lead ticket T1 is under G1; " + "name T2 in --ride-along, or start it on its own") + self.assertEqual(self.store_files(), before) + rail = self.table_row(self.at, "rail.tsv", "T2") + self.assertEqual(self.brigade("fire", "--tickets", "T1,T2", "--ride-along", "T2", "--station", "bug-fix", "--summary", "Do both"), + "D1") + self.assertEqual(self.label("dishes.tsv", "D1"), ("fix", "G1")) + self.assertEqual(self.table_row(self.at, "rail.tsv", "T2"), {**rail, "state": "assigned", "dish": "D1"}) + self.assertEqual(self.label("rail.tsv", "T2"), ("polish", "G2")) + + def test_fire_refuses_a_ticket_under_a_goal_after_an_unassigned_lead_ticket_and_takes_it_as_a_ride_along(self): + self.two_goals() + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix") + self.brigade("ticket", "add", "--summary", "b", "--kind", "polish", "--goal", "G1", "--priority", "low") + before = self.store_files() + self.assertEqual(self.brigade("fire", "--tickets", "T1,T2", "--station", "bug-fix", "--summary", "Do both", ok=False), + "brigade: T2 is under G1 and the lead ticket T1 is unassigned; " + "name T2 in --ride-along, or start it on its own") + self.assertEqual(self.store_files(), before) + self.brigade("fire", "--tickets", "T1,T2", "--ride-along", "T2", "--station", "bug-fix", "--summary", "Do both") + self.assertEqual((self.label("dishes.tsv", "D1"), self.label("rail.tsv", "T2")), (("fix", ""), ("polish", "G1"))) + + def test_fire_takes_a_ticket_of_the_lead_tickets_goal_and_an_unassigned_ticket_with_no_ride_along_and_each_keeps_its_two_cells(self): + self.two_goals() + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix", "--goal", "G1") + self.brigade("ticket", "add", "--summary", "b", "--kind", "feature", "--goal", "G1") + self.brigade("ticket", "add", "--summary", "c", "--kind", "polish") + self.assertEqual(self.brigade("fire", "--tickets", "T1,T2,T3", "--station", "bug-fix", "--summary", "Do all"), "D1") + self.assertEqual(self.label("dishes.tsv", "D1"), ("fix", "G1")) + self.assertEqual([self.label("rail.tsv", ident) for ident in ("T1", "T2", "T3")], + [("fix", "G1"), ("feature", "G1"), ("polish", "")]) + + def test_fire_refuses_a_ride_along_id_that_is_not_in_tickets_and_one_that_is_the_lead_ticket(self): + self.two_goals() + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix", "--goal", "G1", "--priority", "low") + self.brigade("ticket", "add", "--summary", "b", "--kind", "polish", "--goal", "G2", "--priority", "low") + before = self.store_files() + self.assertEqual(self.brigade("fire", "--tickets", "T1", "--ride-along", "T2", "--station", "bug-fix", "--summary", "x", ok=False), + "brigade: --ride-along names T2, which is not in --tickets; add it there or drop it") + self.assertEqual(self.brigade("fire", "--tickets", "T1,T2", "--ride-along", "T1,T2", "--station", "bug-fix", "--summary", "x", + ok=False), + "brigade: T1 is the lead ticket, and the lead ticket cannot ride along; " + "put the ticket whose goal the item takes first in --tickets") + self.assertEqual(self.store_files(), before) + + def test_fire_refuses_a_ride_along_ticket_of_priority_normal_or_urgent(self): + self.two_goals() + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix", "--goal", "G1") + self.brigade("ticket", "add", "--summary", "b", "--kind", "polish", "--goal", "G2") + self.brigade("ticket", "add", "--summary", "c", "--kind", "polish", "--goal", "G2", "--priority", "urgent") + before = self.store_files() + for ident, priority in (("T2", "normal"), ("T3", "urgent")): + with self.subTest(ident=ident): + self.assertEqual(self.brigade("fire", "--tickets", f"T1,{ident}", "--ride-along", ident, "--station", "bug-fix", + "--summary", "x", ok=False), + f"brigade: {ident} is {priority}; only a low ticket rides along; " + f"start it on its own, or run ticket set {ident} --priority low") + self.assertEqual(self.store_files(), before) + self.brigade("ticket", "set", "T2", "--priority", "low") + self.assertEqual(self.brigade("fire", "--tickets", "T1,T2", "--ride-along", "T2", "--station", "bug-fix", "--summary", "x"), "D1") + + def test_fire_takes_a_report_ticket_with_an_empty_priority_cell_as_a_ride_along(self): + self.two_goals() + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix", "--goal", "G1") + self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "Do a") + self.follow_ups_in("## Follow-ups\n\n- Tidy the guide.\n") + self.assertEqual(self.table_row(self.at, "rail.tsv", "T2")["priority"], "") + self.brigade("ticket", "add", "--summary", "c", "--kind", "fix", "--goal", "G2") + self.assertEqual(self.brigade("fire", "--tickets", "T3,T2", "--ride-along", "T2", "--station", "bug-fix", "--summary", "x"), "D2") + self.assertEqual((self.label("dishes.tsv", "D2"), self.label("rail.tsv", "T2")), (("fix", "G2"), ("polish", "G1"))) + + def test_fire_refuses_a_ride_along_ticket_that_is_a_feature(self): + self.two_goals() + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix", "--goal", "G1") + self.brigade("ticket", "add", "--summary", "b", "--kind", "feature", "--goal", "G2", "--priority", "low") + before = self.store_files() + self.assertEqual(self.brigade("fire", "--tickets", "T1,T2", "--ride-along", "T2", "--station", "bug-fix", "--summary", "x", + ok=False), + "brigade: T2 is a feature, and a feature does not ride along; start it as its own item") + self.assertEqual(self.store_files(), before) + + def test_fire_refuses_a_low_feature_named_in_ride_along_that_is_under_the_lead_tickets_goal(self): + self.two_goals() + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix", "--goal", "G1") + self.brigade("ticket", "add", "--summary", "b", "--kind", "feature", "--goal", "G1", "--priority", "low") + self.assertEqual(self.brigade("fire", "--tickets", "T1,T2", "--ride-along", "T2", "--station", "bug-fix", "--summary", "x", + ok=False), + "brigade: T2 is a feature, and a feature does not ride along; start it as its own item") + self.assertEqual(self.brigade("fire", "--tickets", "T1,T2", "--station", "bug-fix", "--summary", "x"), "D1") + + def test_a_goal_change_while_fire_claims_its_lease_is_caught_before_the_write_and_the_lease_is_released(self): + case = self.fake_land() + self.two_goals() + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix", "--goal", "G1") + self.brigade("ticket", "add", "--summary", "b", "--kind", "fix", "--goal", "G1") + proc = self.fire_with_fake_land(case, "--tickets", "T1,T2", "--station", "bug-fix", "--summary", "Do both", "--paths", "src") + _wait_for_path(case / "claiming") + self.assertEqual(self.brigade("ticket", "set", "T2", "--goal", "G2"), "T2 goal G2") + before = self.store_files() + (case / "claim-proceed").touch() + _, err = proc.communicate(timeout=20) + self.assertEqual((proc.returncode, err.strip()), + (1, "brigade: nothing fired: T2 is under G2 and the lead ticket T1 is under G1; " + "name T2 in --ride-along, or start it on its own")) + self.assertEqual((case / "land-calls").read_text().splitlines(), + ["lease claim --holder perf/D1 --paths src,changes/perf%2Fd1.md", "lease release L7"]) + self.assertEqual(self.store_files(), before) + + def test_fire_of_a_lead_ticket_whose_kind_cell_holds_no_kind_warns_needs_a_kind_and_writes_it_over_the_cell(self): + self.open() + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix") + self.hand_edit("rail.tsv", "T1", kind="bugfix") + warning = ("brigade: warning: T1 has kind 'bugfix', which is not feature, fix, polish, upkeep, or study; it reads as no kind; " + "run ticket set T1 --kind <kind>\n") + before = self.store_files() + self.assertEqual(self.run_raw("fire", "--tickets", "T1", "--station", "feature", "--summary", "Do a"), + (1, "", warning + f"brigade: T1 has no kind and station feature gives none; " + f"pass --kind {self.KIND_CHOICE}\n")) + self.assertEqual(self.store_files(), before) + self.assertEqual(self.run_raw("fire", "--tickets", "T1", "--station", "feature", "--summary", "Do a", "--kind", "polish"), + (0, "D1\n", warning)) + self.assertEqual((self.label("dishes.tsv", "D1"), self.label("rail.tsv", "T1")), (("polish", ""), ("polish", ""))) + + def test_fire_of_a_lead_ticket_whose_kind_cell_holds_no_kind_writes_the_kind_of_its_station_over_the_cell(self): + self.open() + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix") + self.hand_edit("rail.tsv", "T1", kind="bugfix") + code, out, err = self.run_raw("fire", "--tickets", "T1", "--station", "investigation", "--summary", "Do a") + self.assertEqual((code, out), (0, "D1\n")) + self.assertIn("T1 has kind 'bugfix'", err) + self.assertEqual((self.label("dishes.tsv", "D1"), self.label("rail.tsv", "T1")), (("study", ""), ("study", ""))) + + def test_fire_of_a_lead_ticket_whose_goal_has_no_row_warns_starts_an_unassigned_item_and_leaves_the_tickets_cell(self): + self.two_goals() + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix", "--goal", "G1") + self.brigade("ticket", "add", "--summary", "b", "--kind", "fix", "--goal", "G2") + self.hand_edit("rail.tsv", "T1", goal="G9") + self.hand_edit("rail.tsv", "T2", goal="G8") + self.assertEqual(self.run_raw("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "Do a"), + (0, "D1\n", "brigade: warning: T1 names goal 'G9', which has no row in goals.tsv; it reads as unassigned; " + "run ticket set T1 --goal G<n> or --no-goal\n")) + self.assertEqual((self.label("dishes.tsv", "D1"), self.label("rail.tsv", "T1")), (("fix", ""), ("fix", "G9"))) + + def test_fire_warns_of_a_bad_cell_on_a_ticket_after_the_lead_and_reads_that_ticket_as_unassigned(self): + self.two_goals() + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix", "--goal", "G1") + self.brigade("ticket", "add", "--summary", "b", "--kind", "fix", "--goal", "G2") + self.hand_edit("rail.tsv", "T2", goal="G9") + self.assertEqual(self.run_raw("fire", "--tickets", "T1,T2", "--station", "bug-fix", "--summary", "Do both"), + (0, "D1\n", "brigade: warning: T2 names goal 'G9', which has no row in goals.tsv; it reads as unassigned; " + "run ticket set T2 --goal G<n> or --no-goal\n")) + self.assertEqual(self.label("rail.tsv", "T2"), ("fix", "G9")) + + def test_fire_of_a_ticket_with_no_kind_prints_the_owner_refusal_and_the_not_waiting_refusal_first(self): + self.open() + self.rail_before_kinds("a", "b") + self.brigade("ticket", "set", "T2", "--state", "done") + self.brigade("set", "--thread", "t1") + before = self.store_files() + self.assertEqual(self.brigade("fire", "--tickets", "T1", "--station", "feature", "--summary", "x", ok=False, owner=False), + "brigade: this store is owned by t1@1; pass --owner <thread>@<generation> from status") + self.assertEqual(self.brigade("--owner", "t0@1", "fire", "--tickets", "T1", "--station", "feature", "--summary", "x", ok=False), + "brigade: owner t0@1 is stale; this store is owned by t1@1") + self.assertEqual(self.brigade("fire", "--tickets", "T2", "--station", "feature", "--summary", "x", ok=False), + "brigade: T2 is done, not waiting") + self.assertEqual(self.store_files(), before) + + def test_fire_of_a_ticket_with_no_kind_is_blocked_by_a_full_worker_cap_with_the_words_it_had_before_kinds(self): + self.brigade("open", "--project-root", str(self.project), "--name", "Perf", "--workers", "1") + self.rail_before_kinds("a", "b") + self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "Do a") + self.assertEqual(self.brigade("fire", "--tickets", "T2", "--station", "feature", "--summary", "Do b", ok=False), + "brigade: nothing fired: 1 of 1 workers running") + self.assertEqual(self.blocked_note(self.at, "T2"), + {"kind": "workers", "tickets": ["T2"], "station": "feature", "summary": "Do b", "paths": "", "timebox": 60}) + self.brigade("dish", "D1", "--state", "sent-back") + line = "T2: unblocked; run fire --tickets=T2 --station=feature '--summary=Do b' --timebox=60" + self.assertIn(line, self.brigade("watch").splitlines()) + ran = self.bash_unblocked(self.at, line) + self.assertEqual((ran.returncode, ran.stderr.strip()), + (1, f"brigade: T2 has no kind and station feature gives none; pass --kind {self.KIND_CHOICE}")) + + def test_fire_of_a_ticket_with_no_kind_on_paths_another_lease_holds_is_refused_for_the_kind_and_logs_no_block(self): + self.init_landing() + with mock.patch.dict(os.environ, {"XDG_STATE_HOME": self.land_env()["XDG_STATE_HOME"]}): + self.open() + self.rail_before_kinds("a") + self.land("lease", "claim", "--holder", "engine/D1", "--paths", "src") + leases, before = self.land("lease", "list"), self.store_files() + self.assertEqual(self.brigade("fire", "--tickets", "T1", "--station", "feature", "--summary", "Do a", "--paths", "src", + ok=False), + f"brigade: T1 has no kind and station feature gives none; pass --kind {self.KIND_CHOICE}") + self.assertEqual((self.land("lease", "list"), self.store_files()), (leases, before)) + refused = self.brigade("fire", "--tickets", "T1", "--station", "feature", "--summary", "Do a", "--paths", "src", + "--kind", "upkeep", ok=False) + self.assertIn("nothing fired: paths overlap L1 held by engine/D1", refused) + self.assertEqual(self.blocked_note(self.at, "T1"), + {"kind": "lease", "tickets": ["T1"], "station": "feature", "summary": "Do a", "paths": "src", + "timebox": 60, "work": "upkeep"}) + + def test_a_block_note_written_before_kinds_reprints_the_fire_line_it_printed_then(self): + self.brigade("open", "--project-root", str(self.project), "--name", "Perf", "--workers", "1") + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix") + self.brigade("ticket", "add", "--summary", "Fix the gate", "--kind", "fix") + self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "Do a") + note = ('{"kind":"workers","tickets":["T2"],"station":"bug-fix","summary":"Fix the gate","paths":"","timebox":45,' + '"branch":"docs/gate","reason":"wide change"}') + with open(self.at / "log.tsv", "a") as log: + log.write(f"2026-10-01T00:00:00+00:00\tticket\tT2\tblocked\t{note}\n") + self.brigade("dish", "D1", "--state", "sent-back") + self.assertIn("T2: unblocked; run fire --tickets=T2 --station=bug-fix '--summary=Fix the gate' --branch=docs/gate " + "--timebox=45 --mode=full '--reason=wide change'", self.brigade("watch").splitlines()) + + def test_a_blocked_fire_with_no_kind_flag_and_no_ride_along_writes_the_note_keys_it_wrote_before_kinds(self): + self.brigade("open", "--project-root", str(self.project), "--name", "Perf", "--workers", "1") + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix") + self.brigade("ticket", "add", "--summary", "b", "--kind", "fix") + self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "Do a") + self.brigade("fire", "--tickets", "T2", "--station", "bug-fix", "--summary", "Do b", "--branch", "docs/gate", ok=False) + self.assertEqual(self.blocked_note(self.at, "T2"), + {"kind": "workers", "tickets": ["T2"], "station": "bug-fix", "summary": "Do b", "paths": "", "timebox": 60, + "branch": "docs/gate"}) + + def test_a_blocked_fire_with_kind_and_ride_along_records_both_and_the_fire_line_watch_reprints_carries_them_and_runs(self): + self.brigade("open", "--project-root", str(self.project), "--name", "Perf", "--workers", "1") + self.goal("activity view") + self.goal("current with upstream") + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix") + self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "Do a") + self.brigade("ticket", "add", "--summary", "b", "--kind", "fix", "--goal", "G1") + self.hand_edit("rail.tsv", "T2", kind="") + self.brigade("ticket", "add", "--summary", "c", "--kind", "polish", "--goal", "G2", "--priority", "low") + self.assertEqual(self.brigade("fire", "--tickets", "T2,T3", "--ride-along", "T3", "--kind", "upkeep", "--station", "feature", + "--summary", "Do b and c", ok=False), "brigade: nothing fired: 1 of 1 workers running") + note = {"kind": "workers", "tickets": ["T2", "T3"], "station": "feature", "summary": "Do b and c", "paths": "", "timebox": 60, + "work": "upkeep", "ride_along": ["T3"]} + self.assertEqual((self.blocked_note(self.at, "T2"), self.blocked_note(self.at, "T3")), (note, note)) + self.brigade("dish", "D1", "--state", "sent-back") + line = ("T2: unblocked; run fire --tickets=T2,T3 --station=feature '--summary=Do b and c' --timebox=60 --kind=upkeep " + "--ride-along=T3") + self.assertIn(line, self.brigade("watch").splitlines()) + ran = self.bash_unblocked(self.at, line) + self.assertEqual((ran.returncode, ran.stdout.strip()), (0, "D2"), ran.stderr) + self.assertEqual((self.label("dishes.tsv", "D2"), self.label("rail.tsv", "T2"), self.label("rail.tsv", "T3")), + (("upkeep", "G1"), ("upkeep", "G1"), ("polish", "G2"))) + + def test_a_dish_update_and_a_review_pass_leave_the_items_kind_and_goal_cells_as_fire_wrote_them(self): + self.two_goals() + self.brigade("ticket", "add", "--summary", "a", "--kind", "feature", "--goal", "G1") + self.brigade("fire", "--tickets", "T1", "--station", "feature", "--summary", "Do a") + self.brigade("dish", "D1", "--state", "in-review", "--sha", "abc", "--thread", "w1") + self.brigade("pass", "record", "D1", "--sha", "abc", "--verdict", "pass", "--author", CLAUDE, "--verifier", CODEX) + self.brigade("dish", "D1", "--state", "merged") + self.assertEqual((self.label("dishes.tsv", "D1"), self.label("rail.tsv", "T1")), (("feature", "G1"), ("feature", "G1"))) + self.assertEqual(self.table_row(self.at, "dishes.tsv", "D1")["state"], "merged") + + def test_a_dropped_items_lead_ticket_waits_again_with_the_kind_fire_wrote_on_it(self): + self.open() + self.rail_before_kinds("a") + self.brigade("fire", "--tickets", "T1", "--station", "investigation", "--summary", "Look at a") + self.assertEqual(self.brigade("dish", "D1", "--state", "dropped"), "D1 dropped; T1 waiting again") + row = self.table_row(self.at, "rail.tsv", "T1") + self.assertEqual((row["state"], row["kind"], row["goal"]), ("waiting", "study", "")) + self.assertEqual(self.brigade("fire", "--tickets", "T1", "--station", "feature", "--summary", "Do a"), "D2") + self.assertEqual(self.label("dishes.tsv", "D2"), ("study", "")) + + def assert_goal_keeps_open(self, ident, held, advice): + before = self.store_files() + for state in ("done", "dropped"): + with self.subTest(goal=ident, state=state): + self.assertEqual(self.brigade("goal", "set", ident, "--state", state, "--evidence", "why", ok=False), + f"brigade: {ident} still has work: {held}; {advice}; then run this again") + self.assertEqual(self.store_files(), before) + + def test_goal_set_refuses_done_and_dropped_while_an_item_of_the_goal_is_in_flight_and_names_the_item(self): + self.two_goals() + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix", "--goal", "G1") + self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "Do a") + for state in ("in-progress", "in-review", "sent-back", "blocked"): + with self.subTest(item=state): + self.brigade("dish", "D1", "--state", state, "--sha", "abc") + self.assert_goal_keeps_open("G1", "D1 in flight", "finish or drop each item in flight") + self.brigade("dish", "D1", "--state", "in-review", "--sha", "abc") + self.brigade("pass", "record", "D1", "--sha", "abc", "--verdict", "pass", "--author", CLAUDE, "--verifier", CODEX) + self.assert_goal_keeps_open("G1", "D1 in flight", "finish or drop each item in flight") + self.brigade("dish", "D1", "--state", "queued") + self.assert_goal_keeps_open("G1", "D1 in flight", "finish or drop each item in flight") + self.brigade("dish", "D1", "--state", "merged") + self.assertEqual(self.brigade("goal", "set", "G1", "--state", "done", "--evidence", "merged"), "G1 done") + + def test_goal_set_refuses_done_and_dropped_while_a_ticket_of_the_goal_rides_along_in_another_goals_item(self): + self.two_goals() + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix", "--goal", "G1") + self.brigade("ticket", "add", "--summary", "b", "--kind", "polish", "--goal", "G2", "--priority", "low") + self.brigade("fire", "--tickets", "T1,T2", "--ride-along", "T2", "--station", "bug-fix", "--summary", "Do both") + self.assert_goal_keeps_open("G2", "T2 riding along", "finish or drop the item each of those tickets rides along in") + self.assertEqual(self.brigade("dish", "D1", "--state", "dropped"), "D1 dropped; T1, T2 waiting again") + self.assert_goal_keeps_open("G2", "T2 waiting", "give each waiting ticket another goal with ticket set --goal or --no-goal") + + def test_goal_set_names_the_waiting_tickets_the_items_in_flight_and_the_tickets_riding_along_in_one_refusal(self): + self.two_goals() + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix", "--goal", "G1") + self.brigade("ticket", "add", "--summary", "b", "--kind", "polish", "--goal", "G2", "--priority", "low") + self.brigade("ticket", "add", "--summary", "c", "--kind", "fix", "--goal", "G2") + self.brigade("ticket", "add", "--summary", "d", "--kind", "fix", "--goal", "G2") + self.brigade("fire", "--tickets", "T1,T2", "--ride-along", "T2", "--station", "bug-fix", "--summary", "Do a and b") + self.brigade("fire", "--tickets", "T3", "--station", "bug-fix", "--summary", "Do c") + self.assert_goal_keeps_open( + "G2", "T4 waiting; D2 in flight; T2 riding along", + "give each waiting ticket another goal with ticket set --goal or --no-goal; finish or drop each item in flight; " + "finish or drop the item each of those tickets rides along in") + + def test_from_report_gives_each_ticket_the_kind_polish_and_the_goal_of_the_reports_item(self): + self.two_goals() + self.brigade("ticket", "add", "--summary", "a", "--kind", "feature", "--goal", "G2") + self.brigade("fire", "--tickets", "T1", "--station", "feature", "--summary", "Do a") + self.assertEqual(len(self.follow_ups_in(BULLETS).splitlines()), 3) + self.assertEqual([self.label("rail.tsv", ident) for ident in ("T2", "T3", "T4")], [("polish", "G2")] * 3) + + def test_from_report_on_an_item_with_no_kind_and_no_goal_files_tickets_with_no_kind_and_no_goal(self): + self.open() + self.rail_before_kinds("a") + self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "Do a") + self.hand_edit("dishes.tsv", "D1", kind="") + self.follow_ups_in(BULLETS) + self.assertEqual([self.label("rail.tsv", ident) for ident in ("T2", "T3", "T4")], [("", "")] * 3) + + def test_from_report_files_a_ticket_under_the_goal_of_its_item_when_that_goal_is_done_and_goal_list_prints_that_goal(self): + self.two_goals() + self.brigade("ticket", "add", "--summary", "a", "--kind", "feature", "--goal", "G1") + self.brigade("fire", "--tickets", "T1", "--station", "feature", "--summary", "Do a") + self.brigade("dish", "D1", "--state", "in-review", "--sha", "abc") + self.brigade("pass", "record", "D1", "--sha", "abc", "--verdict", "pass", "--author", CLAUDE, "--verifier", CODEX) + self.brigade("dish", "D1", "--state", "merged") + self.brigade("goal", "set", "G1", "--state", "done", "--evidence", "merged") + self.follow_ups_in("## Follow-ups\n\n- Tidy the guide.\n") + self.assertEqual(self.label("rail.tsv", "T2"), ("polish", "G1")) + self.assertEqual(self.brigade("goal", "list").splitlines()[0], + "G1 done activity view: 1 waiting, 0 in flight, 1 merged, 0 send-backs") + + def test_follow_up_kind_is_empty_for_an_item_with_no_kind_and_else_fix_at_urgent_or_normal_and_polish_at_low(self): + follow_up_kind = runpy.run_path(str(SCRIPT))["follow_up_kind"] + self.assertEqual([follow_up_kind("", priority) for priority in ("urgent", "normal", "low")], ["", "", ""]) + self.assertEqual([follow_up_kind("feature", priority) for priority in ("urgent", "normal", "low")], ["fix", "fix", "polish"]) + def test_goal_add_prints_the_new_id_appends_one_open_row_and_logs_one_goal_row(self): self.open() self.assertEqual(self.goal("activity view", "--share", "2"), "G1") @@ -6885,7 +7362,7 @@ def test_from_report_files_the_same_report_name_in_two_siblings(self): (self.dir(name) / "reports" / "D1.md").write_text(body) self.assertEqual(self.brigade(name, "ticket", "add", "--from-report", "reports/D1.md"), f"T2 added from {name}/reports/D1.md#1: Shared text.") - self.assertEqual(self.rows("engine", "rail.tsv")[1][2:], ["waiting", "report", "engine/reports/D1.md#1", "", "Shared text.", "", "", "", "", ""]) + self.assertEqual(self.rows("engine", "rail.tsv")[1][2:], ["waiting", "report", "engine/reports/D1.md#1", "", "Shared text.", "", "", "", "polish", ""]) def test_a_sibling_cannot_claim_a_source_another_owns(self): self.open("docs", "--intake", "github") From 6b1a107c7a1e993fc63310370cc830bbbedf3d2e Mon Sep 17 00:00:00 2001 From: Marcus Gibson <marcusroygibson@gmail.com> Date: Sun, 11 Oct 2026 09:18:30 -0500 Subject: [PATCH 09/19] brigade: print kinds and goals in ticket list, startable, status, and close `ticket list` and `startable` print a ticket's kind and then its goal after the priority. A ticket with neither prints the line it printed before. `status` prints up to three lines after its workers line, or after its counts line in the executive admin's store. `kinds:` and `goals:` hold the waiting tickets and the items in flight under each kind and each goal, and `untagged:` counts the waiting tickets with no kind. A line with nothing to count is absent, so a store with no kind and no goal gains `untagged: <n>` when a ticket waits and nothing else. `close` prints the kind before the id on the lines of Merged, In progress, and New tickets, not started. When a line of one of those sections has a goal, the section prints its lines under one heading per goal and an Unassigned heading. The three titles come from `SECTIONS` by key, so a renamed title cannot leave the grouping empty. Each of the four commands prints one warning on standard error for each kind cell that holds no kind and each goal cell that names no row of `goals.tsv`, reads the cell as empty, and exits as before. `plan()`, `counts()`, and `status_line()` are unchanged, so the order and the standings `startable` gives do not depend on a kind or a goal. A script added the kind word to 92 expected `ticket list` and `startable` lines in `tests/test_brigade.py`. Six `status` expectations gain a `kinds:` line, five `close` expectations gain the word `fix`, and the `status` help assertion takes the new sentence at 300 columns, where it fits on one line. --- docs/cli/brigade.md | 2 +- skills/brigade/scripts/brigade.py | 100 ++++- t3/added/brigade/scripts/brigade.py | 100 ++++- tests/test_brigade.py | 662 +++++++++++++++++++++++----- 4 files changed, 725 insertions(+), 139 deletions(-) diff --git a/docs/cli/brigade.md b/docs/cli/brigade.md index 8db21f75..b973f7b3 100644 --- a/docs/cli/brigade.md +++ b/docs/cli/brigade.md @@ -553,7 +553,7 @@ executive admin: copy each coordinator's new log rows into this log brigade.py [flags] status ``` -the thread line first, then counts, then workers outside the executive admin's store, then reports to, mode, and owner when present +the thread line first, then counts, then workers outside the executive admin's store, then a kinds line, a goals line, and an untagged line when each has something to count, then reports to, mode, and owner when present ## brigade.py close diff --git a/skills/brigade/scripts/brigade.py b/skills/brigade/scripts/brigade.py index 47939c4c..d58eeab3 100644 --- a/skills/brigade/scripts/brigade.py +++ b/skills/brigade/scripts/brigade.py @@ -131,6 +131,8 @@ class StandingText: ("dish", "dropped"): "Dropped", ("ticket", "dropped"): "Dropped", } +# The report sections whose lines print a kind and can be grouped by goal. +GROUPED_SECTIONS = tuple(SECTIONS[key] for key in (("dish", "merged"), ("dish", "in-progress"), ("ticket", "waiting"))) MENU = """# Menu: {restaurant} @@ -1062,6 +1064,44 @@ def goal_lines(labels, counted, every): return "\n".join([*lines, f"unassigned: {tally_words(counted.goals.get('', Tally()))}"]) +def label_words(label): + """What a ticket line prints for a label. ` fix G1`, ` fix`, ` G1`, or nothing for no kind and no goal.""" + return "".join(f" {word}" for word in (label.kind, label.goal) if word) + + +def status_label_lines(labels, counted): + """Up to three lines of status. The counts by kind, the counts by goal, and the waiting tickets with no kind. + + kinds: feature 1 waiting; fix 1 waiting, 1 in flight; upkeep 1 in flight + goals: G1 2 waiting, 1 in flight; G2 paused, 1 in flight; unassigned 2 waiting, 1 in flight + untagged: 1 + + An entry holds the waiting count and the in flight count that are not 0, and is absent when both are 0. + kinds has an entry per kind in KINDS order. An item in flight with no kind is on no line. + goals has an entry per goal in file order and then one for unassigned work. A goal that is not open has its state + after its id. The line is absent when goals.tsv has no row. + A line with no entry is absent, and so is untagged at 0. + """ + def entry(name, tally): + found = [f"{len(ids)} {word}" for ids, word in ((tally.waiting, "waiting"), (tally.in_flight, "in flight")) if ids] + return [f"{name} {', '.join(found)}"] if found else [] + + lines = [] + kinds = [text for kind in KINDS for text in entry(kind, counted.kinds.get(kind, Tally()))] + if kinds: + lines.append(f"kinds: {'; '.join(kinds)}") + goals = [text for goal in labels.goals.values() + for text in entry(goal.id if goal.state == "open" else f"{goal.id} {goal.state},", counted.goals.get(goal.id, Tally()))] + if labels.goals: + goals += entry("unassigned", counted.goals.get("", Tally())) + if goals: + lines.append(f"goals: {'; '.join(goals)}") + untagged = len(counted.kinds.get("", Tally()).waiting) + if untagged: + lines.append(f"untagged: {untagged}") + return lines + + def add_ticket(restaurant, summary, source, ref, request="", rails=None, again=False, priority="", paths=(), decision=False, kind="", goal=None): source, ref, request = clean(source), clean(ref), clean(request) @@ -2151,6 +2191,33 @@ def unclaimed_reports(restaurant): return unclaimed +def section_lines(title, items, dishes, labels): + """The lines of one report section under its title. + + A line of a section in GROUPED_SECTIONS has the kind of its item or ticket before the id, when it has one. + When a line of such a section has a goal, the section prints its lines under one `### <goal id> <goal name>` heading + per goal in goals.tsv order, then `### Unassigned`. A heading with no line is absent. + """ + grouped, bullets = title in GROUPED_SECTIONS, [] + for event in items: + of_item = event["kind"] in ("dish", "mode") + dish = dishes.get(event["id"], {}) if of_item else {} + tickets = f" ({dish['tickets'].replace(',', ', ')})" if dish.get("tickets") else "" + pr = f" {dish['pr']}" if dish.get("pr") else "" + label = (labels.items if of_item else labels.tickets).get(event["id"], Label()) + kind = f"{label.kind} " if grouped and label.kind else "" + bullets.append((label.goal, f"- {kind}{event['id']}{tickets}: {event['note']}{pr}")) + if not grouped or not any(goal for goal, _ in bullets): + return [*(text for _, text in bullets), ""] + lines = [] + headings = [*((goal.id, f"### {goal.id} {goal.name}") for goal in labels.goals.values()), ("", "### Unassigned")] + for goal, heading in headings: + under = [text for found, text in bullets if found == goal] + if under: + lines += [heading, "", *under, ""] + return lines + + def report(restaurant, write=True): meta = restaurant.meta since = meta.get("lastReportAt") or "" @@ -2164,18 +2231,12 @@ def report(restaurant, write=True): if title: sections[title].append(event) dishes = {row["id"]: row for row in restaurant.rows("dishes.tsv")} + labels = shown_labels(restaurant) stamp = now() lines = [f"# {meta['restaurant']} report", "", f"Since {since or 'opening'}. {status_line(restaurant)}.", ""] for title, items in sections.items(): - if not items: - continue - lines += [f"## {title}", ""] - for event in items: - dish = dishes.get(event["id"], {}) if event["kind"] in ("dish", "mode") else {} - tickets = f" ({dish['tickets'].replace(',', ', ')})" if dish.get("tickets") else "" - pr = f" {dish['pr']}" if dish.get("pr") else "" - lines.append(f"- {event['id']}{tickets}: {event['note']}{pr}") - lines.append("") + if items: + lines += [f"## {title}", "", *section_lines(title, items, dishes, labels)] if is_admin(meta): lines += admin_sections(restaurant, since) questions = [row for row in restaurant.rows("86.tsv") if row["state"] == "open"] @@ -2455,6 +2516,7 @@ class Waiting: paths: tuple decision: bool summary: str + label: Label = Label() @dataclass(frozen=True) @@ -2465,11 +2527,11 @@ class Standing: overdue: bool -def waiting_ticket(row): +def waiting_ticket(row, label=Label()): filed = datetime.fromisoformat(row["at"].replace("Z", "+00:00")) if filed.tzinfo is None: filed = filed.replace(tzinfo=timezone.utc) - return Waiting(row["id"], priority_of(row), filed, paths_of(row), row["decision"] == "yes", row["summary"]) + return Waiting(row["id"], priority_of(row), filed, paths_of(row), row["decision"] == "yes", row["summary"], label) def plan(tickets, running, cap, level, refusals, now): @@ -2542,7 +2604,7 @@ def startable_lines(standings, meta, running, now): flag = " (over a week)" if standing.overdue else "" paths = f"; {','.join(ticket.paths)}" if ticket.paths else "" words = STANDINGS[standing.kind].line_words.format(why=standing.why) - lines.append(f"{ticket.id} {ticket.priority}, {age(now - ticket.filed)}{flag}: {words}{paths}") + lines.append(f"{ticket.id} {ticket.priority}{label_words(ticket.label)}, {age(now - ticket.filed)}{flag}: {words}{paths}") if standing.kind in ("start", "rides"): lines.append(f" {ticket.summary}") return "\n".join(lines) @@ -2550,8 +2612,8 @@ def startable_lines(standings, meta, running, now): def startable(restaurant): with restaurant.checked(): - meta = restaurant.meta - tickets = [waiting_ticket(row) for row in restaurant.rows("rail.tsv") if row["state"] == "waiting"] + meta, labels = restaurant.meta, shown_labels(restaurant) + tickets = [waiting_ticket(row, labels.tickets[row["id"]]) for row in restaurant.rows("rail.tsv") if row["state"] == "waiting"] running, next_dish = running_workers(restaurant), restaurant.next_id("dishes.tsv") refusals = lease_refusals(meta, tickets, next_dish) moment = datetime.now(timezone.utc) @@ -2559,11 +2621,11 @@ def startable(restaurant): def ticket_lines(restaurant, state, held): - lines = [] + labels, lines = shown_labels(restaurant), [] for row in restaurant.rows("rail.tsv"): if state and row["state"] != state: continue - line = f"{row['id']} {row['state']} {priority_of(row)} [{row['source']}] {row['summary']}" + line = f"{row['id']} {row['state']} {priority_of(row)}{label_words(labels.tickets[row['id']])} [{row['source']}] {row['summary']}" if row["ref"]: line += f" {row['ref']}" if row["state"] == "waiting" and row["id"] in held: @@ -3793,7 +3855,9 @@ def parser(): sub.add_parser("sync", help="executive admin: copy each coordinator's new log rows into this log") - sub.add_parser("status", help="the thread line first, then counts, then workers outside the executive admin's store, then reports to, mode, and owner when present") + sub.add_parser("status", help="the thread line first, then counts, then workers outside the executive admin's store, " + "then a kinds line, a goals line, and an untagged line when each has something to count, " + "then reports to, mode, and owner when present") p = sub.add_parser("close", help="write the report of what changed since the last one") output = p.add_mutually_exclusive_group() output.add_argument("--dry-run", action="store_true") @@ -4268,6 +4332,8 @@ def command(restaurant, args, contract=None, rails=None): level if counts_text == "nothing on record" else f"{level}, {counts_text}"] if not is_admin(meta): lines.append(workers_line(meta, running_workers(restaurant))) + labels = shown_labels(restaurant) + lines += status_label_lines(labels, count_labels(restaurant, labels)) if meta.get("reportsTo"): lines.append(f"reports to {meta['reportsTo']}") if meta.get("mode"): diff --git a/t3/added/brigade/scripts/brigade.py b/t3/added/brigade/scripts/brigade.py index 47939c4c..d58eeab3 100644 --- a/t3/added/brigade/scripts/brigade.py +++ b/t3/added/brigade/scripts/brigade.py @@ -131,6 +131,8 @@ class StandingText: ("dish", "dropped"): "Dropped", ("ticket", "dropped"): "Dropped", } +# The report sections whose lines print a kind and can be grouped by goal. +GROUPED_SECTIONS = tuple(SECTIONS[key] for key in (("dish", "merged"), ("dish", "in-progress"), ("ticket", "waiting"))) MENU = """# Menu: {restaurant} @@ -1062,6 +1064,44 @@ def goal_lines(labels, counted, every): return "\n".join([*lines, f"unassigned: {tally_words(counted.goals.get('', Tally()))}"]) +def label_words(label): + """What a ticket line prints for a label. ` fix G1`, ` fix`, ` G1`, or nothing for no kind and no goal.""" + return "".join(f" {word}" for word in (label.kind, label.goal) if word) + + +def status_label_lines(labels, counted): + """Up to three lines of status. The counts by kind, the counts by goal, and the waiting tickets with no kind. + + kinds: feature 1 waiting; fix 1 waiting, 1 in flight; upkeep 1 in flight + goals: G1 2 waiting, 1 in flight; G2 paused, 1 in flight; unassigned 2 waiting, 1 in flight + untagged: 1 + + An entry holds the waiting count and the in flight count that are not 0, and is absent when both are 0. + kinds has an entry per kind in KINDS order. An item in flight with no kind is on no line. + goals has an entry per goal in file order and then one for unassigned work. A goal that is not open has its state + after its id. The line is absent when goals.tsv has no row. + A line with no entry is absent, and so is untagged at 0. + """ + def entry(name, tally): + found = [f"{len(ids)} {word}" for ids, word in ((tally.waiting, "waiting"), (tally.in_flight, "in flight")) if ids] + return [f"{name} {', '.join(found)}"] if found else [] + + lines = [] + kinds = [text for kind in KINDS for text in entry(kind, counted.kinds.get(kind, Tally()))] + if kinds: + lines.append(f"kinds: {'; '.join(kinds)}") + goals = [text for goal in labels.goals.values() + for text in entry(goal.id if goal.state == "open" else f"{goal.id} {goal.state},", counted.goals.get(goal.id, Tally()))] + if labels.goals: + goals += entry("unassigned", counted.goals.get("", Tally())) + if goals: + lines.append(f"goals: {'; '.join(goals)}") + untagged = len(counted.kinds.get("", Tally()).waiting) + if untagged: + lines.append(f"untagged: {untagged}") + return lines + + def add_ticket(restaurant, summary, source, ref, request="", rails=None, again=False, priority="", paths=(), decision=False, kind="", goal=None): source, ref, request = clean(source), clean(ref), clean(request) @@ -2151,6 +2191,33 @@ def unclaimed_reports(restaurant): return unclaimed +def section_lines(title, items, dishes, labels): + """The lines of one report section under its title. + + A line of a section in GROUPED_SECTIONS has the kind of its item or ticket before the id, when it has one. + When a line of such a section has a goal, the section prints its lines under one `### <goal id> <goal name>` heading + per goal in goals.tsv order, then `### Unassigned`. A heading with no line is absent. + """ + grouped, bullets = title in GROUPED_SECTIONS, [] + for event in items: + of_item = event["kind"] in ("dish", "mode") + dish = dishes.get(event["id"], {}) if of_item else {} + tickets = f" ({dish['tickets'].replace(',', ', ')})" if dish.get("tickets") else "" + pr = f" {dish['pr']}" if dish.get("pr") else "" + label = (labels.items if of_item else labels.tickets).get(event["id"], Label()) + kind = f"{label.kind} " if grouped and label.kind else "" + bullets.append((label.goal, f"- {kind}{event['id']}{tickets}: {event['note']}{pr}")) + if not grouped or not any(goal for goal, _ in bullets): + return [*(text for _, text in bullets), ""] + lines = [] + headings = [*((goal.id, f"### {goal.id} {goal.name}") for goal in labels.goals.values()), ("", "### Unassigned")] + for goal, heading in headings: + under = [text for found, text in bullets if found == goal] + if under: + lines += [heading, "", *under, ""] + return lines + + def report(restaurant, write=True): meta = restaurant.meta since = meta.get("lastReportAt") or "" @@ -2164,18 +2231,12 @@ def report(restaurant, write=True): if title: sections[title].append(event) dishes = {row["id"]: row for row in restaurant.rows("dishes.tsv")} + labels = shown_labels(restaurant) stamp = now() lines = [f"# {meta['restaurant']} report", "", f"Since {since or 'opening'}. {status_line(restaurant)}.", ""] for title, items in sections.items(): - if not items: - continue - lines += [f"## {title}", ""] - for event in items: - dish = dishes.get(event["id"], {}) if event["kind"] in ("dish", "mode") else {} - tickets = f" ({dish['tickets'].replace(',', ', ')})" if dish.get("tickets") else "" - pr = f" {dish['pr']}" if dish.get("pr") else "" - lines.append(f"- {event['id']}{tickets}: {event['note']}{pr}") - lines.append("") + if items: + lines += [f"## {title}", "", *section_lines(title, items, dishes, labels)] if is_admin(meta): lines += admin_sections(restaurant, since) questions = [row for row in restaurant.rows("86.tsv") if row["state"] == "open"] @@ -2455,6 +2516,7 @@ class Waiting: paths: tuple decision: bool summary: str + label: Label = Label() @dataclass(frozen=True) @@ -2465,11 +2527,11 @@ class Standing: overdue: bool -def waiting_ticket(row): +def waiting_ticket(row, label=Label()): filed = datetime.fromisoformat(row["at"].replace("Z", "+00:00")) if filed.tzinfo is None: filed = filed.replace(tzinfo=timezone.utc) - return Waiting(row["id"], priority_of(row), filed, paths_of(row), row["decision"] == "yes", row["summary"]) + return Waiting(row["id"], priority_of(row), filed, paths_of(row), row["decision"] == "yes", row["summary"], label) def plan(tickets, running, cap, level, refusals, now): @@ -2542,7 +2604,7 @@ def startable_lines(standings, meta, running, now): flag = " (over a week)" if standing.overdue else "" paths = f"; {','.join(ticket.paths)}" if ticket.paths else "" words = STANDINGS[standing.kind].line_words.format(why=standing.why) - lines.append(f"{ticket.id} {ticket.priority}, {age(now - ticket.filed)}{flag}: {words}{paths}") + lines.append(f"{ticket.id} {ticket.priority}{label_words(ticket.label)}, {age(now - ticket.filed)}{flag}: {words}{paths}") if standing.kind in ("start", "rides"): lines.append(f" {ticket.summary}") return "\n".join(lines) @@ -2550,8 +2612,8 @@ def startable_lines(standings, meta, running, now): def startable(restaurant): with restaurant.checked(): - meta = restaurant.meta - tickets = [waiting_ticket(row) for row in restaurant.rows("rail.tsv") if row["state"] == "waiting"] + meta, labels = restaurant.meta, shown_labels(restaurant) + tickets = [waiting_ticket(row, labels.tickets[row["id"]]) for row in restaurant.rows("rail.tsv") if row["state"] == "waiting"] running, next_dish = running_workers(restaurant), restaurant.next_id("dishes.tsv") refusals = lease_refusals(meta, tickets, next_dish) moment = datetime.now(timezone.utc) @@ -2559,11 +2621,11 @@ def startable(restaurant): def ticket_lines(restaurant, state, held): - lines = [] + labels, lines = shown_labels(restaurant), [] for row in restaurant.rows("rail.tsv"): if state and row["state"] != state: continue - line = f"{row['id']} {row['state']} {priority_of(row)} [{row['source']}] {row['summary']}" + line = f"{row['id']} {row['state']} {priority_of(row)}{label_words(labels.tickets[row['id']])} [{row['source']}] {row['summary']}" if row["ref"]: line += f" {row['ref']}" if row["state"] == "waiting" and row["id"] in held: @@ -3793,7 +3855,9 @@ def parser(): sub.add_parser("sync", help="executive admin: copy each coordinator's new log rows into this log") - sub.add_parser("status", help="the thread line first, then counts, then workers outside the executive admin's store, then reports to, mode, and owner when present") + sub.add_parser("status", help="the thread line first, then counts, then workers outside the executive admin's store, " + "then a kinds line, a goals line, and an untagged line when each has something to count, " + "then reports to, mode, and owner when present") p = sub.add_parser("close", help="write the report of what changed since the last one") output = p.add_mutually_exclusive_group() output.add_argument("--dry-run", action="store_true") @@ -4268,6 +4332,8 @@ def command(restaurant, args, contract=None, rails=None): level if counts_text == "nothing on record" else f"{level}, {counts_text}"] if not is_admin(meta): lines.append(workers_line(meta, running_workers(restaurant))) + labels = shown_labels(restaurant) + lines += status_label_lines(labels, count_labels(restaurant, labels)) if meta.get("reportsTo"): lines.append(f"reports to {meta['reportsTo']}") if meta.get("mode"): diff --git a/tests/test_brigade.py b/tests/test_brigade.py index a2db2f6d..a30c10a1 100644 --- a/tests/test_brigade.py +++ b/tests/test_brigade.py @@ -566,7 +566,7 @@ def test_fire_groups_waiting_tickets_into_one_dish(self): self.assertEqual(self.brigade("ticket", "add", "--kind", "fix", "--summary", "Splash screen hangs"), "T2") self.assertEqual(self.brigade("fire", "--tickets", "T1,T2", "--station", "perf-issue", "--summary", "Cut cold start time"), "D1") self.assertEqual(self.brigade("ticket", "list"), - "T1 assigned normal [github] Startup is slow on cold boot #12\nT2 assigned normal [user] Splash screen hangs") + "T1 assigned normal fix [github] Startup is slow on cold boot #12\nT2 assigned normal fix [user] Splash screen hangs") self.assertEqual(self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "again", ok=False), "brigade: T1 is assigned, not waiting") @@ -580,10 +580,10 @@ def test_ticket_add_refuses_a_user_request_with_the_summary_of_a_waiting_ticket( self.same_text_refusal("T1", "waiting")) self.assertEqual(self.brigade("ticket", "add", "--kind", "fix", "--summary", " Fix the\tlogin\npage ", ok=False), self.same_text_refusal("T1", "waiting")) - self.assertEqual(self.brigade("ticket", "list"), "T1 waiting normal [user] Fix the login page") + self.assertEqual(self.brigade("ticket", "list"), "T1 waiting normal fix [user] Fix the login page") self.assertEqual(self.brigade("ticket", "add", "--kind", "fix", "--summary", "Fix the loginpage"), "T2") self.assertEqual(self.brigade("ticket", "list"), - "T1 waiting normal [user] Fix the login page\nT2 waiting normal [user] Fix the loginpage") + "T1 waiting normal fix [user] Fix the login page\nT2 waiting normal fix [user] Fix the loginpage") self.assertEqual(self.brigade("ticket", "add", "--kind", "fix", "--summary", "fix the LOGIN page", "--again"), "T3") def test_ticket_add_refuses_a_user_request_with_the_summary_of_an_assigned_ticket(self): @@ -628,7 +628,7 @@ def test_again_lifts_neither_the_ref_refusal_nor_the_request_refusal_nor_the_sou "brigade: request A2 is already T1; nothing added") self.assertEqual(self.brigade("ticket", "add", "--kind", "fix", "--summary", "Fix the login page", "--source", "github", "--again", ok=False), "brigade: no coordinator owns intake from github; the one that reads it runs set --intake github") - self.assertEqual(self.brigade("ticket", "list"), "T1 waiting normal [user (request A2)] Fix the login page #12") + self.assertEqual(self.brigade("ticket", "list"), "T1 waiting normal fix [user (request A2)] Fix the login page #12") def test_the_skill_quotes_the_same_summary_refusal_ticket_add_prints(self): self.open() @@ -660,9 +660,9 @@ def test_queueing_or_merging_needs_a_pass_at_the_current_sha(self): self.assertEqual(self.brigade("dish", "D1", "--state", "queued", "--sha", "def", ok=False), "brigade: only reviewed work lands: D1 has no review verdict for def") self.assertEqual(self.brigade("dish", "D1", "--state", "queued"), "D1 queued") - self.assertEqual(self.brigade("status"), "thread not recorded\nreporting: milestones, no landing contract, waiting to land: 1\nworkers: 0 of 2 running, 2 idle, auto-start: normal") + self.assertEqual(self.brigade("status"), "thread not recorded\nreporting: milestones, no landing contract, waiting to land: 1\nworkers: 0 of 2 running, 2 idle, auto-start: normal\nkinds: fix 1 in flight") self.assertEqual(self.brigade("dish", "D1", "--state", "merged"), "D1 merged") - self.assertEqual(self.brigade("ticket", "list", "--state", "done"), "T1 done normal [user] s") + self.assertEqual(self.brigade("ticket", "list", "--state", "done"), "T1 done normal fix [user] s") def test_send_back_blocks_landing(self): self.open() @@ -907,7 +907,7 @@ def test_from_report_files_one_waiting_ticket_per_bullet_and_a_rerun_adds_nothin f"T4 added from perf/reports/D1.md#3: {third}", ]) self.assertEqual(self.brigade("ticket", "list").splitlines()[-1], - f"T4 waiting low [report] {third} perf/reports/D1.md#3") + f"T4 waiting low polish [report] {third} perf/reports/D1.md#3") self.assertEqual(self.table_row(self.at, "rail.tsv", "T2")["summary"], first) self.assertIn(f"\tticket\tT4\twaiting\t{third}\n", (self.at / "log.tsv").read_text()) before = (self.at / "rail.tsv").read_bytes() @@ -1622,15 +1622,15 @@ def test_report_lists_only_what_changed_since_the_last_report(self): self.brigade("fire", "--tickets", "T1", "--station", "perf-issue", "--summary", "Cut cold start time") self.brigade("86", "add", "--question", "Drop Windows 7 support?", "--options", "yes, no", "--default", "no") first = self.brigade("close") - self.assertIn("## In progress\n\n- D1 (T1): Cut cold start time\n", first) - self.assertIn("## New tickets, not started\n\n- T2: Splash hangs\n", first) + self.assertIn("## In progress\n\n- fix D1 (T1): Cut cold start time\n", first) + self.assertIn("## New tickets, not started\n\n- fix T2: Splash hangs\n", first) self.assertIn("- Q1: Drop Windows 7 support? Options: yes, no. Default if no answer: no.", first) self.brigade("pass", "record", "D1", "--pr", "https://github.com/o/r/pull/7", "--sha", "abc", "--verdict", "pass", "--author", CLAUDE, "--verifier", CODEX) self.brigade("dish", "D1", "--state", "merged") self.brigade("86", "answer", "Q1", "--answer", "no") second = self.brigade("close") - self.assertIn("## Merged\n\n- D1 (T1): Cut cold start time https://github.com/o/r/pull/7", second) + self.assertIn("## Merged\n\n- fix D1 (T1): Cut cold start time https://github.com/o/r/pull/7", second) self.assertNotIn("In progress", second) self.assertNotIn("Passed review", second) self.assertNotIn("Decisions for you", second) @@ -1649,7 +1649,7 @@ def test_a_dish_that_moves_through_every_state_in_one_report_appears_once(self): self.assertEqual(self.brigade("close"), "\n".join([ "# Perf report", "", f"Since opening. merged: 1.", "", - "## Merged", "", "- D1 (T1): Fix s", "", + "## Merged", "", "- fix D1 (T1): Fix s", "", "## Dropped", "", "- T2: UI", ])) @@ -1666,7 +1666,7 @@ def test_close_to_file_prints_the_written_path_and_advances_the_report(self): files = list((self.at / "closeouts").glob("*.md")) self.assertEqual(len(files), 1) self.assertEqual(written, str(files[0].resolve())) - self.assertIn("## Merged\n\n- D1 (T1): Fix s", files[0].read_text()) + self.assertIn("## Merged\n\n- fix D1 (T1): Fix s", files[0].read_text()) self.assertIn("Nothing new.", self.brigade("close")) def test_close_to_file_with_dry_run_writes_no_closeout(self): @@ -1675,10 +1675,10 @@ def test_close_to_file_with_dry_run_writes_no_closeout(self): self.assertEqual(list((self.at / "closeouts").glob("*.md")), []) def test_status_help_names_the_thread_line_then_counts(self): - env = os.environ | {"COLUMNS": "200"} + env = os.environ | {"COLUMNS": "300"} result = subprocess.run([sys.executable, str(SCRIPT), "--help"], capture_output=True, text=True, env=env) self.assertEqual(result.returncode, 0, result.stderr) - self.assertIn("status the thread line first, then counts, then workers outside the executive admin's store, then reports to, mode, and owner when present", result.stdout) + self.assertIn("status the thread line first, then counts, then workers outside the executive admin's store, then a kinds line, a goals line, and an untagged line when each has something to count, then reports to, mode, and owner when present", result.stdout) self.assertNotIn("one line of counts", result.stdout) def test_status_and_walk_speak_plain_engineering_prose(self): @@ -1687,7 +1687,7 @@ def test_status_and_walk_speak_plain_engineering_prose(self): self.brigade("ticket", "add", "--kind", "fix", "--summary", "s") self.brigade("86", "add", "--question", "Ship it?", "--options", "yes, no", "--default", "no") self.assertEqual(self.brigade("status"), - "thread thread-1\nreporting: milestones, no landing contract, waiting tickets: 1, decisions for you: 1\nworkers: 0 of 2 running, 2 idle, auto-start: normal\nowner thread-1@1") + "thread thread-1\nreporting: milestones, no landing contract, waiting tickets: 1, decisions for you: 1\nworkers: 0 of 2 running, 2 idle, auto-start: normal\nkinds: fix 1 waiting\nowner thread-1@1") self.assertEqual(self.brigade("walk"), "\n".join([ f"{_shown_root(self.project)}: no landing contract", " Perf (reports milestones): waiting tickets: 1, decisions for you: 1", @@ -1981,7 +1981,7 @@ def test_fire_claims_the_lease_and_a_refused_claim_fires_nothing(self): "D1 (lease L1 held by perf/D1)") self.assertIn("nothing fired: paths overlap L1 held by perf/D1", self.brigade("fire", "--tickets", "T2", "--station", "bug-fix", "--summary", "s", "--paths", "src/x.py", ok=False)) - self.assertEqual(self.brigade("ticket", "list", "--state", "waiting"), "T2 waiting normal [user] two blocked: lease") + self.assertEqual(self.brigade("ticket", "list", "--state", "waiting"), "T2 waiting normal fix [user] two blocked: lease") self.brigade("set", "--thread", "thread-coord") text = self.brigade("brief", "D1", "--contract", "none", "--goal", "g", "--acceptance", "a", "--verify", "v", "--base", "refs/landing/lane") self.assertIn("leased to you as L1: src,changes/perf%2Fd1.md.", text) @@ -2022,12 +2022,12 @@ def test_fire_claims_outside_the_store_lock_and_rechecks_before_it_writes(self): self.assertEqual(err.strip(), "brigade: nothing fired: T1 is assigned, not waiting") self.assertEqual(calls.read_text().splitlines(), ["lease claim --holder perf/D1 --paths src,changes/perf%2Fd1.md", "lease release L7"]) - self.assertEqual(self.brigade("status"), "thread not recorded\nreporting: milestones, no landing contract, in progress: 1\nworkers: 1 of 2 running, 1 idle, auto-start: normal") + self.assertEqual(self.brigade("status"), "thread not recorded\nreporting: milestones, no landing contract, in progress: 1\nworkers: 1 of 2 running, 1 idle, auto-start: normal\nkinds: fix 1 in flight") def test_tabs_and_newlines_in_input_cannot_break_a_table(self): self.open() self.brigade("ticket", "add", "--kind", "fix", "--summary", "line one\nline\ttwo") - self.assertEqual(self.brigade("ticket", "list"), "T1 waiting normal [user] line one line two") + self.assertEqual(self.brigade("ticket", "list"), "T1 waiting normal fix [user] line one line two") def test_commands_outside_a_restaurant_fail_with_the_fix(self): self.assertIn("run brigade.py open", self.brigade("status", ok=False)) @@ -2427,7 +2427,7 @@ def test_dropping_an_item_returns_its_tickets_to_waiting(self): self.assertEqual(self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "fix", "--paths", "a.py"), "D1 (lease L1 held by perf/D1)") self.assertEqual(self.brigade("dish", "D1", "--state", "dropped"), "D1 dropped; T1 waiting again") - self.assertEqual(self.brigade("ticket", "list"), f"T1 waiting normal [github] bug {ref}") + self.assertEqual(self.brigade("ticket", "list"), f"T1 waiting normal fix [github] bug {ref}") self.assertEqual(self.table_row(self.at, "rail.tsv", "T1")["dish"], "") self.assertEqual([(row["kind"], row["id"], row["state"], row["note"]) for row in self.log_rows()][-2:], [("dish", "D1", "dropped", "fix (released L1)"), @@ -2450,7 +2450,7 @@ def test_dropping_an_item_leaves_its_other_tickets_alone(self): self.brigade("fire", "--tickets", "T2", "--station", "bug-fix", "--summary", "b") self.brigade("ticket", "set", "T2", "--state", "done") self.assertEqual(self.brigade("dish", "D2", "--state", "dropped"), "D2 dropped") - self.assertEqual(self.brigade("ticket", "list"), "T1 assigned normal [user] one\nT2 done normal [user] two") + self.assertEqual(self.brigade("ticket", "list"), "T1 assigned normal fix [user] one\nT2 done normal fix [user] two") def test_blocked_counts_follow_the_block_that_holds_now(self): from unittest import mock @@ -2461,14 +2461,14 @@ def test_blocked_counts_follow_the_block_that_holds_now(self): self.land("lease", "claim", "--holder", "engine/D1", "--paths", "src") self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "Fix the gate", "--paths", "src", ok=False) - self.assertEqual(self.brigade("ticket", "list"), "T1 waiting normal [user] Fix the gate blocked: lease") + self.assertEqual(self.brigade("ticket", "list"), "T1 waiting normal fix [user] Fix the gate blocked: lease") self.assertIn("Perf (reports milestones): waiting tickets: 1 (1 blocked)", self.brigade("walk")) self.land("lease", "release", "L1") self.assertTrue(self.brigade("watch").splitlines()[1].startswith("T1: unblocked; run fire")) - self.assertEqual(self.brigade("ticket", "list"), "T1 waiting normal [user] Fix the gate") + self.assertEqual(self.brigade("ticket", "list"), "T1 waiting normal fix [user] Fix the gate") self.assertIn("Perf (reports milestones): waiting tickets: 1\n", self.brigade("walk") + "\n") self.land("lease", "claim", "--holder", "engine/D2", "--paths", "src") - self.assertEqual(self.brigade("ticket", "list"), "T1 waiting normal [user] Fix the gate blocked: lease") + self.assertEqual(self.brigade("ticket", "list"), "T1 waiting normal fix [user] Fix the gate blocked: lease") def test_fire_and_watch_name_the_worker_cap_first_when_both_caps_hold(self): from unittest import mock @@ -2482,12 +2482,12 @@ def test_fire_and_watch_name_the_worker_cap_first_when_both_caps_hold(self): "--paths", "docs", ok=False) self.assertEqual(refused, "brigade: nothing fired: 1 of 1 workers running") self.assertEqual(self.brigade("watch").splitlines()[-1], "T2: waiting for a worker (1 of 1 running)") - self.assertEqual(self.brigade("ticket", "list", "--state", "waiting"), "T2 waiting normal [user] two blocked: workers") + self.assertEqual(self.brigade("ticket", "list", "--state", "waiting"), "T2 waiting normal fix [user] two blocked: workers") self.brigade("set", "--workers", "2") self.assertEqual(self.brigade("watch").splitlines()[-1], "T2: waiting for room in the repository (1 of 1 changes in flight)") self.assertEqual(self.brigade("ticket", "list", "--state", "waiting"), - "T2 waiting normal [user] two blocked: repository") + "T2 waiting normal fix [user] two blocked: repository") def renamed(self, hook=None): """Fire D1 on perf/d1, then run dish D1 --branch perf/d1-r2 in this process, calling hook at its lease list.""" @@ -2814,7 +2814,7 @@ def test_a_refused_fire_stays_blocked_until_the_lease_is_free(self): refused = self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "Fix the gate", "--paths", "src", "--timebox", "60", ok=False) self.assertIn("nothing fired: paths overlap L1 held by engine/D1", refused) - self.assertEqual(self.brigade("ticket", "list"), "T1 waiting normal [user] Fix the gate blocked: lease") + self.assertEqual(self.brigade("ticket", "list"), "T1 waiting normal fix [user] Fix the gate blocked: lease") self.assertEqual(self.brigade("watch"), "workers: 0 of 2 running, 2 idle, auto-start: normal\nT1: waiting on L1 (engine/D1)") self.land("lease", "release", "L1") self.land("lease", "claim", "--holder", "engine/D1", "--paths", "src") @@ -2825,7 +2825,7 @@ def test_a_refused_fire_stays_blocked_until_the_lease_is_free(self): started = self.brigade(*shlex.split(line.split("run ", 1)[1])) self.assertEqual(started, "D1 (lease L3 held by perf/D1)") self.assertEqual(self.table_row(self.at, "dishes.tsv", "D1")["paths"], "src,changes/perf%2Fd1.md") - self.assertEqual(self.brigade("ticket", "list"), "T1 assigned normal [user] Fix the gate") + self.assertEqual(self.brigade("ticket", "list"), "T1 assigned normal fix [user] Fix the gate") def test_an_unblocked_command_round_trips_through_bash(self): from unittest import mock @@ -2993,7 +2993,7 @@ def test_the_worker_cap_defaults_to_two_and_a_later_fire_clears_the_block(self): refused = self.brigade("fire", "--tickets", "T3", "--station", "bug-fix", "--summary", "c", ok=False) self.assertIn("nothing fired: 2 of 2 workers running", refused) self.assertEqual(self.brigade("ticket", "list", "--state", "waiting"), - "T3 waiting normal [user] three blocked: workers") + "T3 waiting normal fix [user] three blocked: workers") self.assertIn("T3: waiting for a worker (2 of 2 running)", self.brigade("watch")) self.brigade("set", "--workers", "3") self.assertEqual(self.brigade("fire", "--tickets", "T3", "--station", "bug-fix", "--summary", "c"), "D3") @@ -3568,7 +3568,7 @@ def test_fire_refuses_a_ticket_of_an_item_that_owes_its_decision(self): before = self.unchanged() self.assertEqual(self.brigade(*self.FIRE_AGAIN, ok=False), self.NO_FIRE) self.unchanged(before) - self.assertEqual(self.brigade("ticket", "list"), "T1 waiting normal [user] one") + self.assertEqual(self.brigade("ticket", "list"), "T1 waiting normal fix [user] one") self.assertEqual(self.brigade(*self.ROUND_BUDGET), "Q1") self.assertEqual(self.brigade(*self.FIRE_AGAIN, ok=False), self.NO_FIRE) self.assertEqual(self.brigade("86", "answer", "Q1", "--answer", "redesign"), "Q1 answered") @@ -4173,7 +4173,7 @@ def test_item_mode_flags_refuse_light_and_a_missing_reason_with_exit_1(self): capture_output=True, text=True) self.assertEqual((result.returncode, result.stderr.strip()), (1, message), args) self.assertEqual((self.at / "log.tsv").read_bytes(), before) - self.assertEqual(self.brigade("ticket", "list", "--state", "waiting"), "T2 waiting normal [user] t") + self.assertEqual(self.brigade("ticket", "list", "--state", "waiting"), "T2 waiting normal fix [user] t") def test_dish_mode_full_twice_keeps_the_first_reason(self): self.fired_bug_fix() @@ -4556,11 +4556,11 @@ def test_ticket_list_prints_the_priority_that_ticket_add_or_ticket_set_recorded( self.assertEqual(self.brigade("ticket", "add", "--kind", "fix", "--summary", "b", "--priority", "low"), "T2") self.assertEqual(self.brigade("ticket", "add", "--kind", "fix", "--summary", "c", "--priority", "normal"), "T3") self.assertEqual(self.brigade("ticket", "list"), - "T1 waiting urgent [user] a\nT2 waiting low [user] b\nT3 waiting normal [user] c") + "T1 waiting urgent fix [user] a\nT2 waiting low fix [user] b\nT3 waiting normal fix [user] c") self.assertEqual(self.brigade("ticket", "set", "T1", "--priority", "low"), "T1 priority low") self.assertEqual(self.brigade("ticket", "set", "T2", "--priority", "urgent"), "T2 priority urgent") self.assertEqual(self.brigade("ticket", "list"), - "T1 waiting low [user] a\nT2 waiting urgent [user] b\nT3 waiting normal [user] c") + "T1 waiting low fix [user] a\nT2 waiting urgent fix [user] b\nT3 waiting normal fix [user] c") self.assertEqual([self.cells(ident)[0] for ident in ("T1", "T2", "T3")], ["low", "urgent", "normal"]) def test_ticket_add_and_ticket_set_refuse_a_priority_outside_urgent_normal_and_low(self): @@ -4571,7 +4571,7 @@ def test_ticket_add_and_ticket_set_refuse_a_priority_outside_urgent_normal_and_l self.brigade("ticket", "add", "--kind", "fix", "--summary", "b", "--priority", "high", ok=False), "--priority", "high", choices) self.assert_refused_choice( self.brigade("ticket", "set", "T1", "--priority", "high", ok=False), "--priority", "high", choices) - self.assertEqual(self.brigade("ticket", "list"), "T1 waiting normal [user] a") + self.assertEqual(self.brigade("ticket", "list"), "T1 waiting normal fix [user] a") def test_a_ticket_with_an_empty_priority_cell_reads_urgent_from_upstream_low_from_report_and_normal_from_other_sources(self): self.open() @@ -4582,11 +4582,11 @@ def test_a_ticket_with_an_empty_priority_cell_reads_urgent_from_upstream_low_fro self.brigade("ticket", "add", "--kind", "fix", "--summary", "d") self.brigade("ticket", "add", "--kind", "fix", "--summary", "e", "--source", "upstream", "--request", "A1") self.assertEqual(self.brigade("ticket", "list").splitlines(), [ - "T1 waiting urgent [upstream] a", - "T2 waiting low [report] b", - "T3 waiting normal [github] c", - "T4 waiting normal [user] d", - "T5 waiting urgent [upstream (request A1)] e", + "T1 waiting urgent fix [upstream] a", + "T2 waiting low fix [report] b", + "T3 waiting normal fix [github] c", + "T4 waiting normal fix [user] d", + "T5 waiting urgent fix [upstream (request A1)] e", ]) self.assertEqual([self.cells(f"T{number}") for number in range(1, 6)], [("", "", "")] * 5) @@ -4594,7 +4594,7 @@ def test_a_recorded_priority_wins_over_the_default_of_the_source(self): self.open() self.brigade("set", "--intake", "upstream") self.brigade("ticket", "add", "--kind", "fix", "--summary", "a", "--source", "upstream", "--priority", "low") - self.assertEqual(self.brigade("ticket", "list"), "T1 waiting low [upstream] a") + self.assertEqual(self.brigade("ticket", "list"), "T1 waiting low fix [upstream] a") def test_ticket_list_reads_rail_rows_written_before_the_priority_paths_and_decision_cells(self): self.open() @@ -4608,7 +4608,7 @@ def test_ticket_list_reads_rail_rows_written_before_the_priority_paths_and_decis self.assertEqual((self.at / "rail.tsv").read_bytes(), before) self.assertEqual(self.brigade("ticket", "add", "--kind", "fix", "--summary", "New", "--priority", "urgent", "--paths", "a.txt"), "T4") self.assertEqual((self.at / "rail.tsv").read_bytes()[:len(before)], before) - self.assertEqual(self.brigade("ticket", "list").splitlines()[-1], "T4 waiting urgent [user] New") + self.assertEqual(self.brigade("ticket", "list").splitlines()[-1], "T4 waiting urgent fix [user] New") self.assertEqual((self.at / "rail.tsv").read_text().splitlines()[-1].split("\t")[5:], ["", "New", "urgent", "a.txt", "", "fix", ""]) @@ -4685,7 +4685,7 @@ def test_a_store_with_no_goals_table_runs_status_watch_startable_ticket_list_and self.assertEqual(self.brigade("watch"), "workers: 1 of 2 running, 1 idle, auto-start: normal\nD1: running 0m of 60m (no worker recorded)") self.assertEqual(self.brigade("startable").splitlines()[:2], ["workers: 1 of 2 running, 1 idle, auto-start: normal", "waiting tickets: 1, unknown: 1"]) - self.assertEqual(self.brigade("ticket", "list", "--state", "waiting"), "T2 waiting normal [user] next") + self.assertEqual(self.brigade("ticket", "list", "--state", "waiting"), "T2 waiting normal fix [user] next") code, out, err = self.close_run("--dry-run") self.assertEqual((code, err), (0, "")) self.assertEqual(out.splitlines()[0], "# Perf report") @@ -4699,14 +4699,14 @@ def test_a_priority_paths_or_decision_change_adds_no_log_row_and_keeps_a_blocked self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "one") self.assertEqual(self.brigade("fire", "--tickets", "T2", "--station", "bug-fix", "--summary", "two", ok=False), "brigade: nothing fired: 1 of 1 workers running") - self.assertEqual(self.brigade("ticket", "list", "--state", "waiting"), "T2 waiting normal [user] two blocked: workers") + self.assertEqual(self.brigade("ticket", "list", "--state", "waiting"), "T2 waiting normal fix [user] two blocked: workers") before = ((self.at / "log.tsv").read_bytes(), (self.at / "restaurant.json").read_bytes()) self.assertEqual(self.brigade("ticket", "set", "T2", "--priority", "urgent"), "T2 priority urgent") self.assertEqual(self.brigade("ticket", "set", "T2", "--paths", "a.txt"), "T2 paths a.txt") self.assertEqual(self.brigade("ticket", "set", "T2", "--decision"), "T2 decision") self.assertEqual(((self.at / "log.tsv").read_bytes(), (self.at / "restaurant.json").read_bytes()), before) self.assertEqual(self.cells("T2"), ("urgent", "a.txt", "yes")) - self.assertEqual(self.brigade("ticket", "list", "--state", "waiting"), "T2 waiting urgent [user] two blocked: workers") + self.assertEqual(self.brigade("ticket", "list", "--state", "waiting"), "T2 waiting urgent fix [user] two blocked: workers") def test_ticket_set_prints_each_given_change_in_one_line_and_refuses_a_call_with_none(self): self.open() @@ -4771,6 +4771,7 @@ def test_status_prints_the_workers_line_third_before_reports_to_mode_and_owner(s "thread t1", "reporting: milestones, no landing contract, waiting tickets: 1", "workers: 0 of 2 running, 2 idle, auto-start: normal", + "kinds: fix 1 waiting", "reports to admin-thread", "mode light", "owner t1@1", @@ -4815,7 +4816,7 @@ def test_status_and_watch_count_items_in_progress_and_in_review_as_running_worke self.assertEqual(self.brigade("status"), "thread not recorded\n" "reporting: milestones, no landing contract, waiting tickets: 1, in progress: 1, in review: 1\n" - + workers) + + workers + "\nkinds: fix 1 waiting, 2 in flight") self.assertEqual(self.brigade("watch"), workers + "\nD1: running 0m of 60m (no worker recorded)\nD2: in review") self.brigade("pass", "record", "D2", "--sha", "abc1234", "--author", CODEX, "--verifier", CLAUDE, "--verdict", "send-back") workers = "workers: 1 of 3 running, 2 idle, auto-start: normal" @@ -5110,7 +5111,7 @@ def test_startable_lists_a_ticket_whose_paths_are_free_as_startable_with_its_sum self.assertEqual(self.brigade("startable"), "\n".join([ self.NONE_RUNNING, "waiting tickets: 1, startable: 1", - "T1 normal, 0m: startable; a.txt", + "T1 normal fix, 0m: startable; a.txt", " Fix a", ])) @@ -5121,7 +5122,7 @@ def test_startable_names_the_lease_and_holder_that_block_a_ticket(self): self.assertEqual(self.brigade("startable"), "\n".join([ self.NONE_RUNNING, "waiting tickets: 1, blocked: 1", - "T1 normal, 0m: blocked, waiting on L1 (engine/D1); src/app.py", + "T1 normal fix, 0m: blocked, waiting on L1 (engine/D1); src/app.py", ])) def test_startable_lists_a_ticket_that_records_no_paths_as_unknown(self): @@ -5130,7 +5131,7 @@ def test_startable_lists_a_ticket_that_records_no_paths_as_unknown(self): self.assertEqual(self.brigade("startable"), "\n".join([ self.NONE_RUNNING, "waiting tickets: 1, unknown: 1", - "T1 normal, 0m: unknown, no paths recorded", + "T1 normal fix, 0m: unknown, no paths recorded", ])) def test_startable_lists_a_decision_ticket_as_a_decision_with_or_without_paths(self): @@ -5140,8 +5141,8 @@ def test_startable_lists_a_decision_ticket_as_a_decision_with_or_without_paths(s self.assertEqual(self.brigade("startable"), "\n".join([ self.NONE_RUNNING, "waiting tickets: 2, decisions: 2", - "T1 urgent, 0m: decision for the owner; a.txt", - "T2 normal, 0m: decision for the owner", + "T1 urgent fix, 0m: decision for the owner; a.txt", + "T2 normal fix, 0m: decision for the owner", ])) def test_startable_lists_free_tickets_below_the_auto_start_level_as_below_it(self): @@ -5153,10 +5154,10 @@ def test_startable_lists_free_tickets_below_the_auto_start_level_as_below_it(sel self.assertEqual(self.brigade("startable"), "\n".join([ "workers: 0 of 2 running, 2 idle, auto-start: urgent", "waiting tickets: 3, startable: 1, below auto-start: 2", - "T1 urgent, 0m: startable; a.txt", + "T1 urgent fix, 0m: startable; a.txt", " Sync", - "T2 normal, 0m: below auto-start; b.txt", - "T3 low, 0m: below auto-start; c.txt", + "T2 normal fix, 0m: below auto-start; b.txt", + "T3 low fix, 0m: below auto-start; c.txt", ])) def test_startable_lists_no_ticket_as_startable_at_level_off(self): @@ -5167,8 +5168,8 @@ def test_startable_lists_no_ticket_as_startable_at_level_off(self): self.assertEqual(self.brigade("startable"), "\n".join([ "workers: 0 of 2 running, 2 idle, auto-start: off", "waiting tickets: 2, below auto-start: 2", - "T1 urgent, 0m: below auto-start; a.txt", - "T2 low, 0m: below auto-start; c.txt", + "T1 urgent fix, 0m: below auto-start; a.txt", + "T2 low fix, 0m: below auto-start; c.txt", ])) def test_startable_lists_a_low_ticket_on_a_startable_ticket_file_or_under_its_directory_as_riding_with_it(self): @@ -5179,11 +5180,11 @@ def test_startable_lists_a_low_ticket_on_a_startable_ticket_file_or_under_its_di self.assertEqual(self.brigade("startable"), "\n".join([ self.NONE_RUNNING, "waiting tickets: 3, startable: 1, riding: 2", - "T1 normal, 0m: startable; docs/guide.md,scripts", + "T1 normal fix, 0m: startable; docs/guide.md,scripts", " Fix the guide and the scripts", - "T2 low, 0m: rides with T1; docs/guide.md", + "T2 low fix, 0m: rides with T1; docs/guide.md", " Tidy the guide", - "T3 low, 0m: rides with T1; scripts/install.py", + "T3 low fix, 0m: rides with T1; scripts/install.py", " Tidy the installer", ])) @@ -5194,9 +5195,9 @@ def test_startable_blocks_a_second_normal_ticket_that_shares_a_path_with_a_start self.assertEqual(self.brigade("startable"), "\n".join([ self.NONE_RUNNING, "waiting tickets: 2, startable: 1, blocked: 1", - "T1 normal, 0m: startable; a.txt", + "T1 normal fix, 0m: startable; a.txt", " Fix a", - "T2 normal, 0m: blocked, starts after T1; a.txt,b.txt", + "T2 normal fix, 0m: blocked, starts after T1; a.txt,b.txt", ])) def test_startable_blocks_a_low_ticket_that_shares_paths_with_two_startable_tickets_and_names_both(self): @@ -5207,11 +5208,11 @@ def test_startable_blocks_a_low_ticket_that_shares_paths_with_two_startable_tick self.assertEqual(self.brigade("startable"), "\n".join([ "workers: 0 of 3 running, 3 idle, auto-start: normal", "waiting tickets: 3, startable: 2, blocked: 1", - "T1 normal, 0m: startable; a.txt", + "T1 normal fix, 0m: startable; a.txt", " Fix a", - "T2 normal, 0m: startable; b.txt", + "T2 normal fix, 0m: startable; b.txt", " Fix b", - "T3 low, 0m: blocked, starts after T1, T2; a.txt,b.txt", + "T3 low fix, 0m: blocked, starts after T1, T2; a.txt,b.txt", ])) def test_startable_blocks_a_low_ticket_with_one_path_on_a_startable_ticket_file_and_one_path_outside_its_paths(self): @@ -5221,9 +5222,9 @@ def test_startable_blocks_a_low_ticket_with_one_path_on_a_startable_ticket_file_ self.assertEqual(self.brigade("startable"), "\n".join([ self.NONE_RUNNING, "waiting tickets: 2, startable: 1, blocked: 1", - "T1 normal, 0m: startable; docs/guide.md", + "T1 normal fix, 0m: startable; docs/guide.md", " Fix the guide", - "T2 low, 0m: blocked, starts after T1; docs/cli.md,docs/guide.md", + "T2 low fix, 0m: blocked, starts after T1; docs/cli.md,docs/guide.md", ])) def test_startable_blocks_a_low_ticket_on_a_directory_above_a_startable_ticket_file_at_a_cap_of_one(self): @@ -5234,10 +5235,10 @@ def test_startable_blocks_a_low_ticket_on_a_directory_above_a_startable_ticket_f self.assertEqual(self.brigade("startable"), "\n".join([ "workers: 0 of 1 running, 1 idle, auto-start: normal", "waiting tickets: 3, startable: 1, blocked: 2", - "T1 urgent, 0m: startable; src/a/one.py", + "T1 urgent fix, 0m: startable; src/a/one.py", " Fix one", - "T2 normal, 0m: blocked, waiting for an idle worker; src/b/two.py", - "T3 low, 0m: blocked, starts after T1; src", + "T2 normal fix, 0m: blocked, waiting for an idle worker; src/b/two.py", + "T3 low fix, 0m: blocked, starts after T1; src", ])) def test_startable_blocks_a_free_ticket_when_the_worker_cap_is_reached(self): @@ -5248,7 +5249,7 @@ def test_startable_blocks_a_free_ticket_when_the_worker_cap_is_reached(self): self.assertEqual(self.brigade("startable"), "\n".join([ "workers: 1 of 1 running, 0 idle, auto-start: normal", "waiting tickets: 1, blocked: 1", - "T2 normal, 0m: blocked, waiting for an idle worker; b.txt", + "T2 normal fix, 0m: blocked, waiting for an idle worker; b.txt", ])) HELD_BACK = "held back, low tickets leave 1 worker idle while an urgent or normal ticket waits" @@ -5261,9 +5262,9 @@ def test_startable_holds_back_a_free_low_ticket_from_the_last_idle_worker_while_ self.assertEqual(self.brigade("startable"), "\n".join([ "workers: 0 of 2 running, 2 idle, auto-start: low", "waiting tickets: 2, startable: 1, held back: 1", - "T1 normal, 0m: startable; a.txt", + "T1 normal fix, 0m: startable; a.txt", " Fix a", - f"T2 low, 0m: {self.HELD_BACK}; b.txt", + f"T2 low fix, 0m: {self.HELD_BACK}; b.txt", ])) def test_startable_lists_a_free_low_ticket_as_startable_at_a_cap_of_one_when_no_urgent_or_normal_ticket_waits(self): @@ -5273,7 +5274,7 @@ def test_startable_lists_a_free_low_ticket_as_startable_at_a_cap_of_one_when_no_ self.assertEqual(self.brigade("startable"), "\n".join([ "workers: 0 of 1 running, 1 idle, auto-start: low", "waiting tickets: 1, startable: 1", - "T1 low, 0m: startable; a.txt", + "T1 low fix, 0m: startable; a.txt", " Tidy a", ])) @@ -5285,9 +5286,9 @@ def test_startable_lists_two_free_low_tickets_as_startable_at_a_cap_of_two_when_ self.assertEqual(self.brigade("startable"), "\n".join([ "workers: 0 of 2 running, 2 idle, auto-start: low", "waiting tickets: 2, startable: 2", - "T1 low, 0m: startable; a.txt", + "T1 low fix, 0m: startable; a.txt", " Tidy a", - "T2 low, 0m: startable; b.txt", + "T2 low fix, 0m: startable; b.txt", " Tidy b", ])) @@ -5299,9 +5300,9 @@ def test_startable_blocks_a_free_low_ticket_for_no_idle_worker_when_a_normal_tic self.assertEqual(self.brigade("startable"), "\n".join([ "workers: 0 of 1 running, 1 idle, auto-start: low", "waiting tickets: 2, startable: 1, blocked: 1", - "T1 normal, 0m: startable; a.txt", + "T1 normal fix, 0m: startable; a.txt", " Fix a", - "T2 low, 0m: blocked, waiting for an idle worker; b.txt", + "T2 low fix, 0m: blocked, waiting for an idle worker; b.txt", ])) def test_startable_holds_back_a_free_low_ticket_while_a_normal_ticket_blocked_by_a_lease_waits(self): @@ -5313,8 +5314,8 @@ def test_startable_holds_back_a_free_low_ticket_while_a_normal_ticket_blocked_by self.assertEqual(self.brigade("startable"), "\n".join([ "workers: 0 of 1 running, 1 idle, auto-start: low", "waiting tickets: 2, blocked: 1, held back: 1", - "T1 normal, 0m: blocked, waiting on L1 (engine/D1); src/app.py", - f"T2 low, 0m: {self.HELD_BACK}; a.txt", + "T1 normal fix, 0m: blocked, waiting on L1 (engine/D1); src/app.py", + f"T2 low fix, 0m: {self.HELD_BACK}; a.txt", ])) def test_startable_holds_back_a_free_low_ticket_while_an_urgent_ticket_with_no_paths_waits(self): @@ -5325,8 +5326,8 @@ def test_startable_holds_back_a_free_low_ticket_while_an_urgent_ticket_with_no_p self.assertEqual(self.brigade("startable"), "\n".join([ "workers: 0 of 1 running, 1 idle, auto-start: low", "waiting tickets: 2, unknown: 1, held back: 1", - "T1 urgent, 0m: unknown, no paths recorded", - f"T2 low, 0m: {self.HELD_BACK}; a.txt", + "T1 urgent fix, 0m: unknown, no paths recorded", + f"T2 low fix, 0m: {self.HELD_BACK}; a.txt", ])) def test_startable_holds_back_a_free_low_ticket_while_a_normal_decision_ticket_waits(self): @@ -5337,8 +5338,8 @@ def test_startable_holds_back_a_free_low_ticket_while_a_normal_decision_ticket_w self.assertEqual(self.brigade("startable"), "\n".join([ "workers: 0 of 1 running, 1 idle, auto-start: low", "waiting tickets: 2, decisions: 1, held back: 1", - "T1 normal, 0m: decision for the owner", - f"T2 low, 0m: {self.HELD_BACK}; a.txt", + "T1 normal fix, 0m: decision for the owner", + f"T2 low fix, 0m: {self.HELD_BACK}; a.txt", ])) def test_startable_flags_a_low_ticket_filed_more_than_seven_days_ago_and_no_other(self): @@ -5352,10 +5353,10 @@ def test_startable_flags_a_low_ticket_filed_more_than_seven_days_ago_and_no_othe self.assertEqual(self.brigade("startable"), "\n".join([ self.NONE_RUNNING, "waiting tickets: 3, startable: 1, below auto-start: 2", - "T3 normal, 9d: startable; c.txt", + "T3 normal fix, 9d: startable; c.txt", " Old", - "T1 low, 7d (over a week): below auto-start; a.txt", - "T2 low, 6d: below auto-start; b.txt", + "T1 low fix, 7d (over a week): below auto-start; a.txt", + "T2 low fix, 6d: below auto-start; b.txt", ])) def test_startable_with_no_waiting_ticket_prints_the_workers_line_and_says_so(self): @@ -5406,10 +5407,10 @@ def test_startable_without_an_owner_changes_no_store_file_and_no_landing_row(sel self.assertEqual(self.brigade("startable", owner=False), "\n".join([ self.NONE_RUNNING, "waiting tickets: 3, startable: 1, blocked: 1, unknown: 1", - "T1 normal, 0m: blocked, waiting on L1 (engine/D1); src/app.py", - "T2 normal, 0m: startable; a.txt", + "T1 normal fix, 0m: blocked, waiting on L1 (engine/D1); src/app.py", + "T2 normal fix, 0m: startable; a.txt", " Fix a", - "T3 normal, 0m: unknown, no paths recorded", + "T3 normal fix, 0m: unknown, no paths recorded", ])) self.assertEqual((self.store_files(), self.landing_rows()), (store, landing)) self.assertEqual(len(landing["lease"]), 1) @@ -5428,8 +5429,8 @@ def test_startable_agrees_with_lease_check_on_a_directory_lease_and_on_a_name_th self.assertEqual(self.brigade("startable"), "\n".join([ self.NONE_RUNNING, "waiting tickets: 2, startable: 1, blocked: 1", - "T1 normal, 0m: blocked, waiting on L1 (engine/D1); src/app/x.py", - "T2 normal, 0m: startable; srcx", + "T1 normal fix, 0m: blocked, waiting on L1 (engine/D1); src/app/x.py", + "T2 normal fix, 0m: startable; srcx", " Fix srcx", ])) @@ -5438,7 +5439,7 @@ def test_startable_prints_one_line_for_a_ticket_the_landing_queue_refuses_in_a_p self.filed("Fix a", "--paths", "src/a.py") lines = self.brigade("startable").splitlines() self.assertEqual(len(lines), 3) - self.assertTrue(lines[2].startswith("T1 normal, 0m: blocked, waiting on the landing queue ("), lines[2]) + self.assertTrue(lines[2].startswith("T1 normal fix, 0m: blocked, waiting on the landing queue ("), lines[2]) def test_startable_is_refused_in_the_executive_admin_store(self): self.open_admin() @@ -5454,8 +5455,8 @@ def test_from_report_prints_a_list_item_with_no_text_as_not_filed_and_gives_it_n "not filed, list item with no text", ]) self.assertEqual(self.brigade("ticket", "list", "--state", "waiting").splitlines(), [ - "T2 waiting low [report] Fix a. perf/reports/D1.md#1", - "T3 waiting low [report] Fix b. perf/reports/D1.md#2", + "T2 waiting low polish [report] Fix a. perf/reports/D1.md#1", + "T3 waiting low polish [report] Fix b. perf/reports/D1.md#2", ]) def test_from_report_on_a_report_whose_only_follow_up_is_a_list_item_with_no_text_adds_nothing(self): @@ -7272,6 +7273,439 @@ def test_goal_set_state_refuses_a_state_that_is_none_of_the_four(self): ("open", "paused", "done", "dropped")) + def worked_store(self): + """Two goals, ten tickets, five items, and nine review rows. A test that reads it names the rows behind each number.""" + self.brigade("open", "--project-root", str(self.project), "--name", "Perf", "--workers", "9") + self.goal("activity view", "--share", "2") + self.goal("current with upstream") + for summary, kind, *flags in ( + ("Activity view", "feature", "--goal", "G1"), + ("Lease check arms a reservation", "fix", "--goal", "G1"), + ("Tidy the lease help", "polish", "--goal", "G2", "--priority", "low"), + ("Activity page drops a row", "fix", "--goal", "G1"), + ("Tidy the lease docs", "polish", "--priority", "low"), + ("Runtime follows the nightly", "upkeep", "--goal", "G2", "--priority", "urgent"), + ("Capacity after the parallel runner", "study", "--priority", "low"), + ("Installer keeps a relative config home", "fix"), + ("Activity page filters by coordinator", "feature", "--goal", "G1"), + ("Docs batch", "polish", "--priority", "low")): + self.brigade("ticket", "add", "--summary", summary, "--kind", kind, *flags) + self.hand_edit("rail.tsv", "T8", kind="") + for tickets, station, *flags in (("T1", "feature"), ("T2,T3", "bug-fix", "--ride-along", "T3"), ("T6", "refactoring"), + ("T7", "investigation"), ("T10", "docs")): + self.brigade("fire", "--tickets", tickets, "--station", station, "--summary", f"Do {tickets}", *flags) + self.brigade("goal", "set", "G2", "--state", "paused") + for item, state in (("D1", "merged"), ("D2", "in-review"), ("D3", "sent-back"), ("D5", "merged")): + self.hand_edit("dishes.tsv", item, state=state) + for ticket in ("T1", "T10"): + self.hand_edit("rail.tsv", ticket, state="done") + with open(self.at / "pass.tsv", "a") as passes: + for item, verdict, member in (("D1", "send-back", ""), ("D1", "send-back", ""), ("D1", "pass", ""), + ("D2", "send-back", ""), ("D2", "send-back", "yes"), + ("D3", "send-back", ""), ("D3", "pass-with-edits", ""), + ("D5", "send-back", ""), ("D5", "pass", "")): + passes.write(f"2026-10-01T00:00:00+00:00\t{item}\t\tabc\t{verdict}\t{CLAUDE}\t{CODEX}\t\t\t{member}\n") + + def test_the_worked_store_holds_the_kind_and_goal_cells_its_tests_count(self): + self.worked_store() + self.assertEqual([(row[0], row[2], row[5], *row[10:]) for row in self.rows_of("rail.tsv")], [ + ("T1", "done", "D1", "feature", "G1"), ("T2", "assigned", "D2", "fix", "G1"), ("T3", "assigned", "D2", "polish", "G2"), + ("T4", "waiting", "", "fix", "G1"), ("T5", "waiting", "", "polish", ""), ("T6", "assigned", "D3", "upkeep", "G2"), + ("T7", "assigned", "D4", "study", ""), ("T8", "waiting", "", "", ""), ("T9", "waiting", "", "feature", "G1"), + ("T10", "done", "D5", "polish", "")]) + self.assertEqual([(row[0], row[2], row[4], *row[15:]) for row in self.rows_of("dishes.tsv")], [ + ("D1", "merged", "T1", "feature", "G1"), ("D2", "in-review", "T2,T3", "fix", "G1"), ("D3", "sent-back", "T6", "upkeep", "G2"), + ("D4", "in-progress", "T7", "study", ""), ("D5", "merged", "T10", "polish", "")]) + + def test_goal_list_prints_for_each_goal_the_counts_a_hand_join_of_the_three_tables_gives(self): + self.worked_store() + self.assertEqual(self.brigade("goal", "list").splitlines(), [ + # 2 waiting. T4 and T9 wait under G1. 1 in flight. D2 is in review under G1. 1 merged. D1. + # 3 send-backs. Two on D1 and one on D2. The D2 row with member yes does not count. + "G1 open activity view: 2 waiting, 1 in flight, 1 merged, 3 send-backs, share 2", + " purpose: One page of activity", + " done when: The page ships", + # 0 waiting. T3 and T6 are under G2 and assigned. 1 in flight. D3 is sent back under G2. 0 merged. + # 1 send-back. One on D3. Its pass-with-edits row does not count. 1 riding along. T3, assigned to D2, a G1 item. + "G2 paused current with upstream: 0 waiting, 1 in flight, 0 merged, 1 send-back, 1 riding along", + " purpose: One page of activity", + " done when: The page ships", + # 2 waiting. T5 and T8. 1 in flight. D4. 1 merged. D5. 1 send-back. One on D5. + "unassigned: 2 waiting, 1 in flight, 1 merged, 1 send-back", + ]) + + def test_status_prints_the_waiting_and_in_flight_counts_by_kind_and_by_goal_a_hand_join_gives_after_its_workers_line(self): + self.worked_store() + self.assertEqual(self.brigade("status").splitlines(), [ + "thread not recorded", + "reporting: milestones, no landing contract, waiting tickets: 4, in progress: 1, in review: 1, sent back: 1, merged: 2", + "workers: 2 of 9 running, 7 idle, auto-start: normal", + # feature. T9 waits. fix. T4 waits and D2 is in flight. polish. T5 waits, and T3 is assigned so it is in no entry. + # upkeep. D3 is in flight. study. D4 is in flight. + "kinds: feature 1 waiting; fix 1 waiting, 1 in flight; polish 1 waiting; upkeep 1 in flight; study 1 in flight", + # G1. T4 and T9 wait and D2 is in flight. G2. D3 is in flight. unassigned. T5 and T8 wait and D4 is in flight. + "goals: G1 2 waiting, 1 in flight; G2 paused, 1 in flight; unassigned 2 waiting, 1 in flight", + # T8 waits with no kind. The waiting counts by kind and this one sum to the 4 waiting tickets. + "untagged: 1", + ]) + + def test_ticket_list_prints_the_kind_then_the_goal_after_the_priority_and_neither_for_a_ticket_with_no_kind_and_no_goal(self): + self.worked_store() + self.hand_edit("rail.tsv", "T9", kind="") + self.assertEqual(self.brigade("ticket", "list", "--state", "waiting").splitlines(), [ + "T4 waiting normal fix G1 [user] Activity page drops a row", + "T5 waiting low polish [user] Tidy the lease docs", + "T8 waiting normal [user] Installer keeps a relative config home", + "T9 waiting normal G1 [user] Activity page filters by coordinator", + ]) + self.assertEqual(self.brigade("ticket", "list", "--state", "assigned").splitlines()[:2], [ + "T2 assigned normal fix G1 [user] Lease check arms a reservation", + "T3 assigned low polish G2 [user] Tidy the lease help", + ]) + + def test_status_prints_no_kinds_line_no_goals_line_and_no_untagged_line_when_no_ticket_waits_and_no_item_is_in_flight(self): + self.two_goals() + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix", "--goal", "G1") + self.brigade("ticket", "set", "T1", "--state", "dropped") + self.assertEqual(self.brigade("status").splitlines(), [ + "thread not recorded", "reporting: milestones, no landing contract", + "workers: 0 of 9 running, 9 idle, auto-start: normal"]) + + def test_status_prints_the_state_of_a_done_goal_that_has_a_waiting_ticket(self): + self.two_goals() + self.brigade("ticket", "add", "--summary", "a", "--kind", "feature", "--goal", "G1") + self.brigade("fire", "--tickets", "T1", "--station", "feature", "--summary", "Do a") + self.hand_edit("dishes.tsv", "D1", state="merged") + self.hand_edit("rail.tsv", "T1", state="done") + self.brigade("goal", "set", "G1", "--state", "done", "--evidence", "merged") + self.follow_ups_in("## Follow-ups\n\n- Tidy the guide.\n") + self.assertEqual(self.brigade("status").splitlines()[3:], ["kinds: polish 1 waiting", "goals: G1 done, 1 waiting"]) + + def test_status_prints_the_unassigned_entry_alone_when_the_store_has_a_goal_and_no_work_under_it(self): + self.two_goals() + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix") + self.assertEqual(self.brigade("status").splitlines()[3:], ["kinds: fix 1 waiting", "goals: unassigned 1 waiting"]) + + def test_startable_gives_the_same_standings_in_the_same_order_with_kinds_and_goals_as_without_them(self): + self.queue("--workers", "3") + self.goal("activity view", "--share", "1") + self.goal("current with upstream") + self.filed("Fix a", "--paths", "a.txt", "--goal", "G1") + self.brigade("ticket", "add", "--summary", "Add b", "--kind", "feature", "--paths", "b.txt", "--goal", "G1") + self.brigade("ticket", "add", "--summary", "Sync c", "--kind", "upkeep", "--paths", "c.txt", "--goal", "G2", + "--priority", "urgent") + self.brigade("ticket", "add", "--summary", "Tidy d", "--kind", "polish", "--paths", "d.txt", "--priority", "low") + self.brigade("ticket", "add", "--summary", "Look at e", "--kind", "study") + self.brigade("ticket", "add", "--summary", "Add f", "--kind", "feature", "--paths", "a.txt", "--goal", "G2") + self.brigade("goal", "set", "G2", "--state", "paused") + lines = ["workers: 0 of 3 running, 3 idle, auto-start: normal", + "waiting tickets: 6, startable: 3, blocked: 1, unknown: 1, below auto-start: 1", + "T3 urgent{}, 0m: startable; c.txt", " Sync c", + "T1 normal{}, 0m: startable; a.txt", " Fix a", + "T2 normal{}, 0m: startable; b.txt", " Add b", + "T5 normal{}, 0m: unknown, no paths recorded", + "T6 normal{}, 0m: blocked, starts after T1; a.txt", + "T4 low{}, 0m: below auto-start; d.txt"] + words = iter((" upkeep G2", " fix G1", " feature G1", " study", " feature G2", " polish")) + self.assertEqual(self.brigade("startable").splitlines(), + [line.format(next(words)) if "{}" in line else line for line in lines]) + for number in range(1, 7): + self.hand_edit("rail.tsv", f"T{number}", kind="", goal="") + self.assertEqual(self.brigade("startable").splitlines(), [line.format("") for line in lines]) + + def close_setup(self): + self.two_goals() + for summary, kind, *flags in (("Capacity", "study"), ("Lease check", "fix", "--goal", "G1"), + ("Tidy the help", "polish", "--goal", "G2", "--priority", "low"), ("Runtime", "upkeep"), + ("Splash hangs", "fix", "--goal", "G2"), ("Drops a row", "fix", "--goal", "G1"), + ("Later", "polish", "--goal", "G2"), ("Tidy the docs", "polish", "--priority", "low"), + ("Installer", "fix")): + self.brigade("ticket", "add", "--summary", summary, "--kind", kind, *flags) + self.hand_edit("rail.tsv", "T9", kind="") + self.brigade("fire", "--tickets", "T1", "--station", "investigation", "--summary", "Measure capacity") + self.brigade("fire", "--tickets", "T2,T3", "--ride-along", "T3", "--station", "bug-fix", "--summary", "Fix the lease check") + self.brigade("fire", "--tickets", "T4", "--station", "refactoring", "--summary", "Follow the nightly") + self.brigade("fire", "--tickets", "T5", "--station", "bug-fix", "--summary", "Fix the splash") + + def test_close_groups_merged_in_progress_and_new_tickets_by_goal_and_prints_the_kind_on_their_lines(self): + self.close_setup() + self.brigade("dish", "D1", "--state", "in-review", "--sha", "abc") + self.record("abc", "pass") + self.brigade("dish", "D1", "--state", "merged") + self.brigade("dish", "D4", "--state", "in-review", "--sha", "def") + self.brigade("pass", "record", "D4", "--sha", "def", "--verdict", "send-back", "--author", CLAUDE, "--verifier", CODEX, + "--note", "still hangs") + code, out, err = self.close_run("--dry-run") + self.assertEqual((code, err), (0, "")) + self.assertEqual(out.splitlines()[4:], [ + "## Merged", "", + "- study D1 (T1): Measure capacity", "", + "## Sent back after review", "", + "- D4 (T5): Fix the splash", "", + "## In progress", "", + "### G1 activity view", "", + "- fix D2 (T2, T3): Fix the lease check", "", + "### Unassigned", "", + "- upkeep D3 (T4): Follow the nightly", "", + "## New tickets, not started", "", + "### G1 activity view", "", + "- fix T6: Drops a row", "", + "### G2 current with upstream", "", + "- polish T7: Later", "", + "### Unassigned", "", + "- polish T8: Tidy the docs", + "- T9: Installer", + ]) + + def test_close_prints_no_goal_heading_in_a_store_whose_tickets_and_items_have_kinds_and_no_goal(self): + self.open() + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix") + self.brigade("ticket", "add", "--summary", "b", "--kind", "polish") + self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "Fix a") + self.assertEqual(self.close_run("--dry-run")[1].splitlines()[4:], + ["## In progress", "", "- fix D1 (T1): Fix a", "", "## New tickets, not started", "", "- polish T2: b"]) + + def printed(self): + """What each of the five commands that print kinds or goals gives, as {command: (exit code, stdout, stderr)}.""" + return {" ".join(words): self.run_raw(*words) + for words in (("status",), ("goal", "list"), ("ticket", "list"), ("startable",), ("close", "--dry-run"))} + + def assert_each_printing_command_warns(self, *warnings): + """Each of the five commands exits 0 with exactly these warnings on standard error, and changes no file. + + Returns {command: its standard output with the last newline cut}. + """ + before = self.store_files() + printed = self.printed() + for command, (code, _, err) in printed.items(): + with self.subTest(command=command): + self.assertEqual((code, err), (0, "".join(f"brigade: warning: {text}\n" for text in warnings))) + self.assertEqual(self.store_files(), before) + return {command: out.strip() for command, (_, out, _) in printed.items()} + + NOT_A_KIND = "which is not feature, fix, polish, upkeep, or study; it reads as no kind" + NO_GOAL_ROW = "which has no row in goals.tsv; it reads as unassigned" + + def test_a_waiting_ticket_whose_kind_cell_holds_no_kind_is_warned_of_by_each_printing_command_and_counts_as_untagged(self): + self.open() + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix") + self.hand_edit("rail.tsv", "T1", kind="bugfix") + out = self.assert_each_printing_command_warns(f"T1 has kind 'bugfix', {self.NOT_A_KIND}; run ticket set T1 --kind <kind>") + self.assertEqual(out["status"].splitlines()[3:], ["untagged: 1"]) + self.assertEqual(out["ticket list"], "T1 waiting normal [user] a") + self.assertEqual(out["startable"].splitlines()[2], "T1 normal, 0m: unknown, no paths recorded") + self.assertEqual(out["close --dry-run"].splitlines()[4:], ["## New tickets, not started", "", "- T1: a"]) + self.assertNotIn("bugfix", "".join(out.values())) + + def test_an_item_and_an_assigned_ticket_whose_kind_cells_hold_no_kind_are_warned_of_and_are_in_no_count_by_kind(self): + self.fired_bug_fix() + self.hand_edit("rail.tsv", "T1", kind="chore") + self.hand_edit("dishes.tsv", "D1", kind="chore") + out = self.assert_each_printing_command_warns( + f"T1 has kind 'chore', {self.NOT_A_KIND}; no command changes this cell; correct it in rail.tsv by hand", + f"D1 has kind 'chore', {self.NOT_A_KIND}; no command changes this cell; correct it in dishes.tsv by hand") + self.assertEqual(len(out["status"].splitlines()), 3) + self.assertEqual(out["ticket list"], "T1 assigned normal [user] s") + self.assertEqual(out["close --dry-run"].splitlines()[4:], ["## In progress", "", "- D1 (T1): Fix s"]) + self.assertNotIn("chore", "".join(out.values())) + + def test_a_ticket_and_an_item_that_name_a_goal_with_no_row_are_warned_of_and_count_as_unassigned(self): + self.two_goals() + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix", "--goal", "G1") + self.brigade("ticket", "add", "--summary", "b", "--kind", "fix", "--goal", "G1") + self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "Fix a") + self.hand_edit("rail.tsv", "T1", goal="") + self.hand_edit("rail.tsv", "T2", goal="G9") + self.hand_edit("dishes.tsv", "D1", goal="G7") + out = self.assert_each_printing_command_warns( + f"T2 names goal 'G9', {self.NO_GOAL_ROW}; run ticket set T2 --goal G<n> or --no-goal", + f"D1 names goal 'G7', {self.NO_GOAL_ROW}; no command changes this cell; correct it in dishes.tsv by hand") + self.assertEqual(out["status"].splitlines()[3:], + ["kinds: fix 1 waiting, 1 in flight", "goals: unassigned 1 waiting, 1 in flight"]) + self.assertEqual(out["goal list"].splitlines()[0], "G1 open activity view: 0 waiting, 0 in flight, 0 merged, 0 send-backs") + self.assertEqual(out["goal list"].splitlines()[-1], "unassigned: 1 waiting, 1 in flight, 0 merged, 0 send-backs") + self.assertEqual(out["ticket list"], "T1 assigned normal fix [user] a\nT2 waiting normal fix [user] b") + self.assertEqual(out["startable"].splitlines()[2], "T2 normal fix, 0m: unknown, no paths recorded") + self.assertEqual(out["close --dry-run"].splitlines()[4:], + ["## In progress", "", "- fix D1 (T1): Fix a", "", "## New tickets, not started", "", "- fix T2: b"]) + for value in ("G9", "G7"): + self.assertNotIn(value, "".join(out.values())) + + def test_with_goals_tsv_deleted_each_goal_cell_is_warned_of_its_row_counts_as_unassigned_and_no_command_writes_the_table(self): + self.two_goals() + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix", "--goal", "G1") + self.brigade("ticket", "add", "--summary", "b", "--kind", "fix", "--goal", "G1") + self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "Fix a") + (self.at / "goals.tsv").unlink() + out = self.assert_each_printing_command_warns( + f"T1 names goal 'G1', {self.NO_GOAL_ROW}; no command changes this cell; correct it in rail.tsv by hand", + f"T2 names goal 'G1', {self.NO_GOAL_ROW}; run ticket set T2 --goal G<n> or --no-goal", + f"D1 names goal 'G1', {self.NO_GOAL_ROW}; no command changes this cell; correct it in dishes.tsv by hand") + self.assertEqual(out["status"].splitlines()[3:], ["kinds: fix 1 waiting, 1 in flight"]) + self.assertEqual(out["goal list"], "no open or paused goals\nunassigned: 1 waiting, 1 in flight, 0 merged, 0 send-backs") + self.assertEqual(out["ticket list"], "T1 assigned normal fix [user] a\nT2 waiting normal fix [user] b") + self.assertEqual(out["close --dry-run"].splitlines()[4:], + ["## In progress", "", "- fix D1 (T1): Fix a", "", "## New tickets, not started", "", "- fix T2: b"]) + self.assertFalse((self.at / "goals.tsv").exists()) + + + def store_from_before_kinds(self, rows=True): + """A store as the tool wrote it before kinds and goals. Ticket rows of 10 cells, item rows of 15, and no goals.tsv. + + With `rows` it holds a ticket in each state, an item in each state, review rows, and an open and an answered decision. + """ + self.queue() + (self.at / "goals.tsv").unlink(missing_ok=True) + stamp = datetime.now(timezone.utc).isoformat() + tickets = [("T1", "assigned", "user", "", "D1", "Cold start is slow", "", "", ""), + ("T2", "assigned", "github", "https://github.com/o/r/issues/2", "D2", "Splash hangs", "urgent", "src/splash.py", ""), + ("T3", "assigned", "user", "", "D3", "Cache misses", "", "", ""), + ("T4", "assigned", "user", "", "D4", "Login loops", "", "", ""), + ("T5", "assigned", "user", "", "D5", "Export stalls", "", "", ""), + ("T6", "assigned", "user", "", "D6", "Footer overlaps", "low", "", ""), + ("T7", "done", "user", "", "D7", "Dates are wrong", "", "", ""), + ("T8", "waiting", "user", "", "", "Importer crashes", "", "", ""), + ("T9", "waiting", "user", "", "", "Tune the pool", "normal", "a.txt", ""), + ("T10", "waiting", "report", "perf/reports/D7.md#1", "", "Tidy the guide", "", "docs/guide.md", ""), + ("T11", "waiting", "user", "", "", "Pick a database", "urgent", "", "yes"), + ("T12", "waiting", "user", "", "", "Trim the pool", "low", "a.txt", ""), + ("T13", "dropped", "user", "", "", "Old idea", "", "", "")] + items = [("D1", "in-progress", "bug-fix", "T1", "", "w1", "", "", "", "Fix the cold start", "60", "", "", ""), + ("D2", "in-review", "perf-issue", "T2", "", "w2", "perf/d2", "https://github.com/o/r/pull/2", "s2", "Fix the splash", "90", "", "", ""), + ("D3", "passed", "bug-fix", "T3", "", "w3", "", "", "s3", "Fix the cache", "60", "", "", ""), + ("D4", "sent-back", "bug-fix", "T4", "", "w4", "", "", "s4", "Fix the login", "60", "", "", ""), + ("D5", "blocked", "feature", "T5", "", "w5", "", "", "", "Rework the export", "60", "", "", ""), + ("D6", "queued", "bug-fix", "T6", "", "w6", "", "", "s6", "Fix the footer", "60", "", "", ""), + ("D7", "merged", "feature", "T7", "", "w7", "", "https://github.com/o/r/pull/7", "s7", "Fix the dates", "60", "", "", "yes"), + ("D8", "dropped", "refactoring", "T8", "", "w8", "", "", "", "Split the importer", "60", "", "", "")] + passes = [("D3", "s3", "pass"), ("D4", "s4", "send-back"), ("D6", "s6", "pass"), ("D7", "s7", "pass")] + decisions = [("Q1", "open", "", "Drop Windows 7 support?", "yes, no", "no", "", "", ""), + ("Q2", "answered", "D5", "Which export format?", "csv, json", "csv", "json", "", stamp)] + log = [*(("ticket", ticket[0], "waiting", ticket[5]) for ticket in tickets), + *(("dish", item[0], "in-progress", item[9]) for item in items), + *(("ticket", ticket[0], "assigned", ticket[5]) for ticket in tickets[:7]), + *(("dish", item[0], item[1], item[9]) for item in items[1:]), + ("ticket", "T7", "done", "Dates are wrong"), ("ticket", "T13", "dropped", "Old idea"), + ("86", "Q1", "open", "Drop Windows 7 support?"), ("86", "Q2", "answered", "json")] + if not rows: + tickets = items = passes = decisions = log = [] + tables = { + "rail.tsv": ["id\tat\tstate\tsource\tref\tdish\tsummary\tpriority\tpaths\tdecision", + *("\t".join((ident, stamp, *rest)) for ident, *rest in tickets)], + "dishes.tsv": [self.OLD_DISHES.rstrip("\n"), *("\t".join((ident, stamp, *rest)) for ident, *rest in items)], + "pass.tsv": ["at\tdish\tpr\tsha\tverdict\tauthor\tverifier\tnote\treport\tmember", + *(f"{stamp}\t{item}\t\t{sha}\t{verdict}\t{CLAUDE}\t{CODEX}\t\t\t" for item, sha, verdict in passes)], + "86.tsv": ["id\tat\tstate\tdish\tquestion\toptions\tdefault\tanswer\tkind\tanswered", + *("\t".join((ident, stamp, *rest)) for ident, *rest in decisions)], + "log.tsv": ["at\tkind\tid\tstate\tnote", *("\t".join((stamp, *row)) for row in log)], + } + for name, lines in tables.items(): + (self.at / name).write_text("".join(f"{line}\n" for line in lines)) + + + OLD_COUNTS = ("waiting tickets: 5, in progress: 1, in review: 1, passed review: 1, waiting to land: 1, sent back: 1, " + "blocked: 1, merged: 1, decisions for you: 1") + OLD_WORKERS = "workers: 2 of 2 running, 0 idle, auto-start: normal" + + def test_each_reading_command_prints_for_a_store_from_before_kinds_what_the_tool_printed_before_kinds_and_changes_no_table(self): + self.store_from_before_kinds() + before = self.tables() + waiting = ["T8 waiting normal [user] Importer crashes", + "T9 waiting normal [user] Tune the pool", + "T10 waiting low [report] Tidy the guide perf/reports/D7.md#1", + "T11 waiting urgent [user] Pick a database", + "T12 waiting low [user] Trim the pool"] + self.assertEqual(self.brigade("ticket", "list").splitlines(), [ + "T1 assigned normal [user] Cold start is slow", + "T2 assigned urgent [github] Splash hangs https://github.com/o/r/issues/2", + "T3 assigned normal [user] Cache misses", + "T4 assigned normal [user] Login loops", + "T5 assigned normal [user] Export stalls", + "T6 assigned low [user] Footer overlaps", + "T7 done normal [user] Dates are wrong", + *waiting, + "T13 dropped normal [user] Old idea"]) + self.assertEqual(self.brigade("ticket", "list", "--state", "waiting").splitlines(), waiting) + self.assertEqual(self.brigade("startable").splitlines(), [ + self.OLD_WORKERS, + "waiting tickets: 5, blocked: 1, unknown: 1, decisions: 1, below auto-start: 2", + "T11 urgent, 0m: decision for the owner", + "T8 normal, 0m: unknown, no paths recorded", + "T9 normal, 0m: blocked, waiting for an idle worker; a.txt", + "T10 low, 0m: below auto-start; docs/guide.md", + "T12 low, 0m: below auto-start; a.txt"]) + self.assertEqual(self.brigade("watch").splitlines(), [ + self.OLD_WORKERS, "D1: running 0m of 60m (thread w1)", "D2: in review", "D3: passed, not submitted", "D4: sent back", + "D5: parked", "D6: queued, but no entry at s6"]) + self.assertEqual(self.brigade("86", "list"), "Q1: Drop Windows 7 support? Options: yes, no. Default: no.") + code, report, err = self.close_run("--dry-run") + self.assertEqual((code, err), (0, "")) + self.assertEqual(report.splitlines(), [ + "# Perf report", "", + f"Since opening. {self.OLD_COUNTS}.", "", + "## Merged", "", "- D7 (T7): Fix the dates https://github.com/o/r/pull/7", "", + "## Passed review, not submitted", "", "- D3 (T3): Fix the cache", "", + "## Waiting to land", "", "- D6 (T6): Fix the footer", "", + "## Sent back after review", "", "- D4 (T4): Fix the login", "", + "## Blocked", "", "- D5 (T5): Rework the export", "", + "## In progress", "", "- D1 (T1): Fix the cold start", "- D2 (T2): Fix the splash https://github.com/o/r/pull/2", "", + "## New tickets, not started", "", + "- T8: Importer crashes", "- T9: Tune the pool", "- T10: Tidy the guide", "- T11: Pick a database", "- T12: Trim the pool", "", + "## Dropped", "", "- T13: Old idea", "- D8 (T8): Split the importer", "", + "## Decisions for you", "", "- Q1: Drop Windows 7 support? Options: yes, no. Default if no answer: no."]) + self.assertEqual(self.brigade("walk").splitlines(), [ + f"{_shown_root(self.project)}: local mode onto refs/landing/lane. empty.", + f" Perf (reports milestones): {self.OLD_COUNTS}", + " thread not recorded", + " Q1: Drop Windows 7 support?"]) + self.assertEqual(self.brigade("pass", "check", "D3", "--sha", "s3"), f"D3 at s3 passed review by {CODEX}") + self.assertEqual(self.brigade("pass", "check", "D2", "--sha", "s2", ok=False), "brigade: D2 has no review verdict for s2") + self.assertEqual(self.brigade("contract", "D1", ok=False), "brigade: D1 has no contract on record; run brief with --contract") + self.assertEqual(self.tables(), before) + self.assertFalse((self.at / "goals.tsv").exists()) + + def test_status_on_a_store_from_before_kinds_prints_its_three_lines_and_then_the_count_of_waiting_tickets_as_untagged(self): + self.store_from_before_kinds() + before = self.store_files() + self.assertEqual(self.run_raw("status"), (0, "\n".join([ + "thread not recorded", f"reporting: milestones, lands by local, {self.OLD_COUNTS}", self.OLD_WORKERS, + "untagged: 5"]) + "\n", "")) + self.assertEqual(self.store_files(), before) + + def test_goal_list_on_a_store_from_before_kinds_counts_each_ticket_and_item_as_unassigned_and_writes_nothing(self): + self.store_from_before_kinds() + before = self.store_files() + # Waiting. T8 to T12. In flight. D1 to D6. Merged. D7. Send-backs. The one row on D4. + self.assertEqual(self.brigade("goal", "list"), + "no open or paused goals\nunassigned: 5 waiting, 6 in flight, 1 merged, 1 send-back") + self.assertEqual(self.store_files(), before) + + def test_each_reading_command_prints_for_an_empty_store_from_before_kinds_what_the_tool_printed_before_kinds_and_changes_no_table(self): + self.store_from_before_kinds(rows=False) + before = self.tables() + idle = "workers: 0 of 2 running, 2 idle, auto-start: normal" + self.assertEqual(self.run_raw("status"), (0, f"thread not recorded\nreporting: milestones, lands by local\n{idle}\n", "")) + self.assertEqual(self.brigade("ticket", "list"), "") + self.assertEqual(self.brigade("startable"), f"{idle}\nno waiting tickets") + self.assertEqual(self.brigade("watch"), f"{idle}\nno work in progress") + self.assertEqual(self.brigade("86", "list"), "no open decisions") + self.assertEqual(self.close_run("--dry-run"), (0, "# Perf report\n\nSince opening. nothing on record.\n\nNothing new.", "")) + self.assertEqual(self.brigade("walk").splitlines(), [ + f"{_shown_root(self.project)}: local mode onto refs/landing/lane. empty.", + " Perf (reports milestones): nothing on record", + " thread not recorded"]) + self.assertEqual(self.tables(), before) + + def test_a_ticket_added_to_a_store_from_before_kinds_leaves_each_older_row_of_the_ticket_table_byte_identical(self): + self.store_from_before_kinds() + before = (self.at / "rail.tsv").read_bytes() + self.assertEqual(self.brigade("ticket", "add", "--summary", "New", "--kind", "fix"), "T14") + after = (self.at / "rail.tsv").read_bytes() + self.assertEqual(after[:len(before)], before) + self.assertEqual(after[len(before):].decode().rstrip("\n").split("\t")[5:], ["", "New", "", "", "", "fix", ""]) + self.assertEqual(self.brigade("ticket", "list").splitlines()[-1], "T14 waiting normal fix [user] New") + + class StoresTest(unittest.TestCase): """Two or three coordinators on one project root, as `app/<name>` under the store.""" @@ -7426,7 +7860,7 @@ def test_move_hands_a_ticket_to_a_sibling_and_take_files_it(self): self.brigade("core", "ticket", "add", "--kind", "fix", "--summary", "Fix the cache", "--source", "github", "--ref", "R7") self.assertEqual(self.brigade("core", "ticket", "move", "T1", "--to", "engine"), "T1 moved to engine; tell thread thread-engine") - self.assertEqual(self.brigade("core", "ticket", "list"), "T1 moved normal [github] Fix the cache R7") + self.assertEqual(self.brigade("core", "ticket", "list"), "T1 moved normal fix [github] Fix the cache R7") self.assertEqual(self.brigade("core", "fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "x", ok=False), "brigade: T1 is moved, not waiting") self.assertEqual(self.brigade("core", "ticket", "set", "T1", "--state", "waiting", ok=False), @@ -7439,7 +7873,7 @@ def test_move_hands_a_ticket_to_a_sibling_and_take_files_it(self): self.assertEqual(self.brigade("engine", "status"), "thread thread-engine\nreporting: milestones, no landing contract, handed to you: 1\nworkers: 0 of 2 running, 2 idle, auto-start: normal\nowner thread-engine@1") self.assertEqual(self.brigade("engine", "ticket", "take"), "T1 from app/core/T1: Fix the cache") - self.assertEqual(self.brigade("engine", "ticket", "list"), "T1 waiting normal [github (from app/core/T1)] Fix the cache R7") + self.assertEqual(self.brigade("engine", "ticket", "list"), "T1 waiting normal fix [github (from app/core/T1)] Fix the cache R7") self.assertEqual(self.inbox("engine"), []) self.assertEqual(self.brigade("core", "watch"), "workers: 0 of 2 running, 2 idle, auto-start: normal\nno work in progress") self.assertEqual(self.brigade("engine", "watch"), "workers: 0 of 2 running, 2 idle, auto-start: normal\nno work in progress") @@ -7462,7 +7896,7 @@ def test_a_moved_ref_stays_live_through_each_move_until_the_end_of_the_chain_fin self.brigade("docs", "ticket", "move", "T1", "--to", "engine") self.assertEqual(self.brigade("core", "ticket", "add", "--kind", "fix", "--summary", "s", "--source", "github", "--ref", ref, ok=False), refused) self.brigade("engine", "ticket", "take") - self.assertEqual(self.brigade("engine", "ticket", "list"), f"T1 waiting normal [github (from app/docs/T1)] s {ref}") + self.assertEqual(self.brigade("engine", "ticket", "list"), f"T1 waiting normal fix [github (from app/docs/T1)] s {ref}") self.assertEqual(self.brigade("core", "ticket", "add", "--kind", "fix", "--summary", "s", "--source", "github", "--ref", ref, ok=False), refused) self.brigade("engine", "ticket", "set", "T1", "--state", "done") self.assertEqual(self.brigade("core", "ticket", "add", "--kind", "fix", "--summary", "s", "--source", "github", "--ref", ref), "T2") @@ -7521,7 +7955,7 @@ def test_a_move_interrupted_after_the_rewrite_publishes_once_on_rerun(self): self.brigade("core", "ticket", "add", "--kind", "fix", "--summary", "Fix the cache", "--source", "github", "--ref", "R7") died = self.child("die-before-publish", "core", "ticket", "move", "T1", "--to", "engine") self.assertEqual(died.returncode, 9, died.stderr) - self.assertEqual(self.brigade("core", "ticket", "list"), "T1 moved normal [github] Fix the cache R7") + self.assertEqual(self.brigade("core", "ticket", "list"), "T1 moved normal fix [github] Fix the cache R7") self.assertEqual(self.inbox("engine"), []) self.assertEqual(self.brigade("core", "watch"), "workers: 0 of 2 running, 2 idle, auto-start: normal\nT1: moved to engine, not delivered; run ticket move T1 --to engine again") @@ -7561,7 +7995,7 @@ def test_a_moved_ticket_keeps_its_priority_paths_and_decision_after_ticket_take( self.assertEqual(self.brigade("engine", "ticket", "take"), "T1 from app/core/T1: Fix the cache") self.assertEqual(self.rows("engine", "rail.tsv")[0][2:], ["waiting", "user (from app/core/T1)", "", "", "Fix the cache", "urgent", "docs,src/cache.py", "yes", "fix", ""]) - self.assertEqual(self.brigade("engine", "ticket", "list"), "T1 waiting urgent [user (from app/core/T1)] Fix the cache") + self.assertEqual(self.brigade("engine", "ticket", "list"), "T1 waiting urgent fix [user (from app/core/T1)] Fix the cache") def test_a_moved_ticket_with_an_empty_priority_cell_reads_the_default_of_its_source_after_ticket_take(self): self.open("core", "--intake", "upstream") @@ -7570,7 +8004,7 @@ def test_a_moved_ticket_with_an_empty_priority_cell_reads_the_default_of_its_sou self.brigade("core", "ticket", "move", "T1", "--to", "engine") self.brigade("engine", "ticket", "take") self.assertEqual(self.rows("engine", "rail.tsv")[0][7:], ["", "", "", "fix", ""]) - self.assertEqual(self.brigade("engine", "ticket", "list"), "T1 waiting urgent [upstream (from app/core/T1)] Sync") + self.assertEqual(self.brigade("engine", "ticket", "list"), "T1 waiting urgent fix [upstream (from app/core/T1)] Sync") def test_ticket_take_files_a_handoff_written_without_priority_paths_and_decision(self): self.open("core") @@ -7751,9 +8185,9 @@ def test_a_joined_line_stops_the_read_with_the_malformed_message(self): log.write_text(log.read_text() + f"{stamp}\tticket\tT6\twaiting\tfro{stamp}\tticket\tT7\twaiting\tnote\n") self.assertEqual(self.brigade("core", "ticket", "list", ok=False), "brigade: app/core/log.tsv line 3 is malformed; fix or remove it") log.write_text(log.read_text().replace(f"fro{stamp}\tticket\tT7\twaiting\tnote", "fro")) - self.assertEqual(self.brigade("core", "ticket", "list"), "T1 waiting normal [user] a") + self.assertEqual(self.brigade("core", "ticket", "list"), "T1 waiting normal fix [user] a") log.write_text(log.read_text() + f"{stamp[:19]}\tticket") - self.assertEqual(self.brigade("core", "ticket", "list"), "T1 waiting normal [user] a") + self.assertEqual(self.brigade("core", "ticket", "list"), "T1 waiting normal fix [user] a") log.write_text(log.read_text() + f"{stamp}\tticket\tT8\twaiting\tnote\n") self.assertEqual(self.brigade("core", "ticket", "list", ok=False), "brigade: app/core/log.tsv line 4 is malformed; fix or remove it") log.write_text(log.read_text().replace(f"{stamp[:19]}\tticket{stamp}", "2026-10-06")) @@ -7823,7 +8257,7 @@ def test_a_tail_cut_inside_a_multi_byte_character_is_skipped_by_a_read_and_dropp rail = self.dir("core") / "rail.tsv" complete = rail.read_bytes() rail.write_bytes(complete + "T9\t2026-10-10T00:00:00+00:00\twaiting\tuser\t\tcafé".encode()[:-1]) - self.assertEqual(self.brigade("core", "ticket", "list"), "T1 waiting normal [user] a") + self.assertEqual(self.brigade("core", "ticket", "list"), "T1 waiting normal fix [user] a") self.assertEqual(self.brigade("core", "ticket", "add", "--kind", "fix", "--summary", "b"), "T2") after = rail.read_bytes() self.assertEqual(after[:len(complete)], complete) @@ -7973,6 +8407,25 @@ def open_admin(self, *extra, ok=True): def meta(self, name): return json.loads((self.dir(name) / "restaurant.json").read_text()) + def test_status_in_the_executive_admins_store_prints_the_kinds_line_and_the_goals_line_after_its_counts_line(self): + self.open_admin() + self.admin("goal", "add", "--name", "one inbox", "--purpose", "p", "--done", "d") + self.admin("ticket", "add", "--kind", "upkeep", "--summary", "Add a FAQ", "--goal", "G1") + self.assertEqual(self.admin("status").splitlines(), + ["thread not recorded", "reporting: milestones, no landing contract, waiting tickets: 1", + "kinds: upkeep 1 waiting", "goals: G1 1 waiting"]) + + def test_sync_relays_a_coordinators_goal_log_row_and_the_admins_close_prints_it_in_that_coordinators_block(self): + self.open("docs") + self.open_admin() + self.brigade("docs", "goal", "add", "--name", "activity view", "--purpose", "p", "--done", "d") + self.assertRegex(self.admin("sync"), r"^docs \S+ goal G1 open: activity view$") + self.assertEqual([(row["kind"], row["id"], row["state"], row["note"]) for _, row in self.relays()], + [("goal", "G1", "open", "activity view")]) + report = self.admin("close", "--dry-run").splitlines() + block = report[report.index("### docs") + 2] + self.assertRegex(block, r"^- \S+ goal G1 open: activity view$") + def test_goal_add_goal_set_and_goal_list_work_in_the_executive_admins_store(self): self.open_admin() self.assertEqual(self.admin("goal", "add", "--name", "routing", "--purpose", "Requests reach an owner", "--done", "Standing", @@ -8110,7 +8563,8 @@ def test_the_admin_store_refuses_work_commands(self): self.assertEqual(self.admin("86", "add", "--question", "Which purpose?", "--options", "docs, engine", "--default", "docs"), "Q1") self.assertEqual(self.admin("status"), - "thread not recorded\nreporting: milestones, no landing contract, waiting tickets: 1, decisions for you: 1") + "thread not recorded\nreporting: milestones, no landing contract, waiting tickets: 1, decisions for you: 1\n" + "kinds: fix 1 waiting") def test_a_ref_moved_through_the_admin_frees_at_every_step_once_done(self): ref = "https://github.com/o/r/issues/7" @@ -8166,7 +8620,7 @@ def refused(store, ident, where=""): self.assertEqual(self.admin("ticket", "move", "T1", "--to", "engine"), "T1 moved to engine; no thread recorded for engine") refused(".admin", "T1", " (moved)") self.assertEqual(self.brigade("engine", "inbox", "take"), "T1 from app/.admin/T1: Fix login") - self.assertEqual(self.brigade("engine", "ticket", "list"), "T1 waiting normal [beads (from app/.admin/T1)] Fix login app-7f3") + self.assertEqual(self.brigade("engine", "ticket", "list"), "T1 waiting normal fix [beads (from app/.admin/T1)] Fix login app-7f3") refused("engine", "T1", " (waiting)") refused(".admin", "T1", " (moved)") @@ -8371,7 +8825,7 @@ def test_a_replayed_from_user_request_files_no_second_ticket(self): self.assertEqual(self.brigade("docs", "ticket", "add", "--kind", "fix", "--summary", "Add a FAQ", "--request", "A1", ok=False), refusal) self.brigade("docs", "ticket", "set", "T1", "--state", "done") self.assertEqual(self.brigade("docs", "ticket", "add", "--kind", "fix", "--summary", "Add a FAQ", "--request", "A1", ok=False), refusal) - self.assertEqual(self.brigade("docs", "ticket", "list"), "T1 done normal [user (request A1)] Add a FAQ") + self.assertEqual(self.brigade("docs", "ticket", "list"), "T1 done normal fix [user (request A1)] Add a FAQ") self.assertEqual(self.brigade("docs", "ticket", "add", "--kind", "fix", "--summary", "Other", "--request", "A2"), "T2") # Sync. From cb862bb6d86efa49b6ae71e596a90083f010f61c Mon Sep 17 00:00:00 2001 From: Marcus Gibson <marcusroygibson@gmail.com> Date: Sun, 11 Oct 2026 09:19:14 -0500 Subject: [PATCH 10/19] changes: add the note for kinds and goals in the coordinator's store One bullet for the changelog. It names each new command and flag, what fire, status, close, ticket list, and startable now print or store, and what a store written before this change reads as. --- changes/pstack-t3%2Fd160.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/pstack-t3%2Fd160.md diff --git a/changes/pstack-t3%2Fd160.md b/changes/pstack-t3%2Fd160.md new file mode 100644 index 00000000..f36032a1 --- /dev/null +++ b/changes/pstack-t3%2Fd160.md @@ -0,0 +1 @@ +- `brigade.py` records a kind and an optional goal on each ticket and work item, and keeps the goals in a new table, `goals.tsv`. `ticket add --summary` needs `--kind`, one of `feature`, `fix`, `polish`, `upkeep`, and `study`, and takes `--goal G<n>` with the id of an open or paused goal. `ticket set --kind`, `--goal`, and `--no-goal` change a waiting ticket and are refused on a ticket in another state. `goal add` records an open goal with a name, a purpose, and a done test. `goal set` changes a goal, and `--state done` and `--state dropped` need `--evidence` and are refused while the goal has a waiting ticket, an item in flight, or an assigned ticket in an item that is not under that goal. `goal list` prints each open or paused goal with its counts of waiting tickets, items in flight, merged items, and send-backs, then the counts of unassigned work. `fire` gives the new work item the goal of the first ticket in `--tickets` and that ticket's kind. When that ticket has no kind, the kind is `--kind`, or without it `fix` on station `bug-fix` or `perf-issue` and `study` on station `investigation`, and `fire` is refused on any other station. The first ticket records the kind the item took. A ticket after the first must have the first ticket's goal or no goal unless `--ride-along` names it, and a ticket named there must have priority low and must not be a feature. `ticket add --from-report` gives each ticket the goal of the report's item and, when that item has a kind, the kind `polish`, and it refuses `--kind` and `--goal`. `ticket move` hands the kind to the other coordinator and leaves the goal behind. `ticket list` and `startable` print a ticket's kind and goal after its priority. `status` prints a `kinds:` line and a `goals:` line with the waiting tickets and the items in flight under each, and an `untagged:` line with the number of waiting tickets that have no kind. A line with nothing to count is absent. `close` prints the kind on the lines of Merged, In progress, and New tickets, not started, and prints the lines of such a section under one heading per goal when one of them has a goal. A kind cell that holds none of the five kinds and a goal cell that names no row of `goals.tsv` read as empty, and `status`, `goal list`, `ticket list`, `startable`, and `close` print a warning for the cell on standard error. `fire` prints it for a cell of a ticket it names. `open` writes `goals.tsv` into a new store. A store written before this change reads with every ticket and item unassigned and with no kind. A command that rewrites `rail.tsv` or `dishes.tsv` there adds two empty cells to each row, and a `brigade.py` from before this change then calls that table malformed. `activity.py` reads a work item row with or without the two cells and prints the same page for both. From ae43765f2b6e438a203fc28e78d6d69fb92dc62b Mon Sep 17 00:00:00 2001 From: Marcus Gibson <marcusroygibson@gmail.com> Date: Sun, 11 Oct 2026 09:53:09 -0500 Subject: [PATCH 11/19] brigade: say how to give a ticket no goal and how to give a taken ticket a kind The goal set refusal for a goal with waiting tickets named --no-goal as a way to give a ticket another goal. It now names it as the way to give none. ticket take said a kind was unknown and stopped there. It now names the ticket set command that gives the ticket a kind. The change note said every cell that holds none of the five kinds is warned about, which took in an empty cell. It said fire warns for every ticket it names, which left out a fire refused before its kind and goal check. It said a rewrite adds two cells to each row, which is false for a row older than the last column change. --- changes/pstack-t3%2Fd160.md | 2 +- skills/brigade/scripts/brigade.py | 8 +++++--- t3/added/brigade/scripts/brigade.py | 8 +++++--- tests/test_brigade.py | 9 +++++---- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/changes/pstack-t3%2Fd160.md b/changes/pstack-t3%2Fd160.md index f36032a1..0764ed64 100644 --- a/changes/pstack-t3%2Fd160.md +++ b/changes/pstack-t3%2Fd160.md @@ -1 +1 @@ -- `brigade.py` records a kind and an optional goal on each ticket and work item, and keeps the goals in a new table, `goals.tsv`. `ticket add --summary` needs `--kind`, one of `feature`, `fix`, `polish`, `upkeep`, and `study`, and takes `--goal G<n>` with the id of an open or paused goal. `ticket set --kind`, `--goal`, and `--no-goal` change a waiting ticket and are refused on a ticket in another state. `goal add` records an open goal with a name, a purpose, and a done test. `goal set` changes a goal, and `--state done` and `--state dropped` need `--evidence` and are refused while the goal has a waiting ticket, an item in flight, or an assigned ticket in an item that is not under that goal. `goal list` prints each open or paused goal with its counts of waiting tickets, items in flight, merged items, and send-backs, then the counts of unassigned work. `fire` gives the new work item the goal of the first ticket in `--tickets` and that ticket's kind. When that ticket has no kind, the kind is `--kind`, or without it `fix` on station `bug-fix` or `perf-issue` and `study` on station `investigation`, and `fire` is refused on any other station. The first ticket records the kind the item took. A ticket after the first must have the first ticket's goal or no goal unless `--ride-along` names it, and a ticket named there must have priority low and must not be a feature. `ticket add --from-report` gives each ticket the goal of the report's item and, when that item has a kind, the kind `polish`, and it refuses `--kind` and `--goal`. `ticket move` hands the kind to the other coordinator and leaves the goal behind. `ticket list` and `startable` print a ticket's kind and goal after its priority. `status` prints a `kinds:` line and a `goals:` line with the waiting tickets and the items in flight under each, and an `untagged:` line with the number of waiting tickets that have no kind. A line with nothing to count is absent. `close` prints the kind on the lines of Merged, In progress, and New tickets, not started, and prints the lines of such a section under one heading per goal when one of them has a goal. A kind cell that holds none of the five kinds and a goal cell that names no row of `goals.tsv` read as empty, and `status`, `goal list`, `ticket list`, `startable`, and `close` print a warning for the cell on standard error. `fire` prints it for a cell of a ticket it names. `open` writes `goals.tsv` into a new store. A store written before this change reads with every ticket and item unassigned and with no kind. A command that rewrites `rail.tsv` or `dishes.tsv` there adds two empty cells to each row, and a `brigade.py` from before this change then calls that table malformed. `activity.py` reads a work item row with or without the two cells and prints the same page for both. +- `brigade.py` records a kind and an optional goal on each ticket and work item, and keeps the goals in a new table, `goals.tsv`. `ticket add --summary` needs `--kind`, one of `feature`, `fix`, `polish`, `upkeep`, and `study`, and takes `--goal G<n>` with the id of an open or paused goal. `ticket set --kind`, `--goal`, and `--no-goal` change a waiting ticket and are refused on a ticket in another state. `goal add` records an open goal with a name, a purpose, and a done test. `goal set` changes a goal, and `--state done` and `--state dropped` need `--evidence` and are refused while the goal has a waiting ticket, an item in flight, or an assigned ticket in an item that is not under that goal. `goal list` prints each open or paused goal with its counts of waiting tickets, items in flight, merged items, and send-backs, then the counts of unassigned work. `fire` gives the new work item the goal of the first ticket in `--tickets` and that ticket's kind. When that ticket has no kind, the kind is `--kind`, or without it `fix` on station `bug-fix` or `perf-issue` and `study` on station `investigation`, and `fire` is refused on any other station. The first ticket records the kind the item took. A ticket after the first must have the first ticket's goal or no goal unless `--ride-along` names it, and a ticket named there must have priority low and must not be a feature. `ticket add --from-report` gives each ticket the goal of the report's item and, when that item has a kind, the kind `polish`, and it refuses `--kind` and `--goal`. `ticket move` hands the kind to the other coordinator and leaves the goal behind. `ticket list` and `startable` print a ticket's kind and goal after its priority. `status` prints a `kinds:` line and a `goals:` line with the counts of waiting tickets and items in flight under each, and an `untagged:` line with the number of waiting tickets that have no kind. A line with nothing to count is absent. `close` prints the kind on the lines of Merged, In progress, and New tickets, not started, and prints the lines of such a section under one heading per goal when one of them has a goal. A kind cell that holds a value other than one of the five kinds and a goal cell that holds an id with no row in `goals.tsv` read as empty, and `status`, `goal list`, `ticket list`, `startable`, and `close` print a warning for the cell on standard error. `fire` prints it for a ticket it names when it reaches its kind and goal check. `open` writes `goals.tsv` into a new store. A store written before this change reads with every ticket and item unassigned and with no kind. A command that appends a row to `rail.tsv` or `dishes.tsv` there writes the row with the two new cells, and a command that rewrites the table writes every row with them. A `brigade.py` from before this change then calls that table malformed. `activity.py` reads a work item row with or without the two cells and prints the same page for both. diff --git a/skills/brigade/scripts/brigade.py b/skills/brigade/scripts/brigade.py index d58eeab3..c24ca38a 100644 --- a/skills/brigade/scripts/brigade.py +++ b/skills/brigade/scripts/brigade.py @@ -972,7 +972,7 @@ def add_goal(restaurant, name, purpose, done, share=""): def goal_work(counted, ident): """What keeps a goal from closing, as (words, advice) pairs. Empty when no ticket of the goal waits or rides along and no item is in flight.""" tally = counted.goals.get(ident, Tally()) - held = ((tally.waiting, "waiting", "give each waiting ticket another goal with ticket set --goal or --no-goal"), + held = ((tally.waiting, "waiting", "give each waiting ticket another goal with ticket set --goal, or none with --no-goal"), (tally.in_flight, "in flight", "finish or drop each item in flight"), (counted.riding.get(ident, ()), "riding along", "finish or drop the item each of those tickets rides along in")) return [(f"{', '.join(ids)} {words}", advice) for ids, words, advice in held if ids] @@ -1721,12 +1721,14 @@ def take_tickets(restaurant): rows = restaurant.rows("rail.tsv") kind = handoff.get("kind") or "" if kind not in KINDS: - unknown = f"; kind {clean(kind)} is not one this version knows, so it has no kind" if kind else "" - kind = "" + unknown, kind = clean(kind), "" row = {"id": restaurant.next_id("rail.tsv"), "at": now(), "state": "waiting", "source": f"{handoff['source']} (from {source})", "ref": handoff["ref"], "summary": handoff["summary"], **{key: handoff.get(key, "") for key in ("priority", "paths", "decision")}, "kind": kind} restaurant.save_rows("rail.tsv", rows + [row]) + if unknown: + unknown = (f"; kind {unknown} is not one this version knows, so it has no kind; " + f"run ticket set {row['id']} --kind {KIND_CHOICE}") if not any(event["kind"] == "ticket" and event["note"] == f"from {source}" for event in restaurant.rows("log.tsv")): restaurant.log("ticket", row["id"], "waiting", f"from {source}") restaurant.remove(path) diff --git a/t3/added/brigade/scripts/brigade.py b/t3/added/brigade/scripts/brigade.py index d58eeab3..c24ca38a 100644 --- a/t3/added/brigade/scripts/brigade.py +++ b/t3/added/brigade/scripts/brigade.py @@ -972,7 +972,7 @@ def add_goal(restaurant, name, purpose, done, share=""): def goal_work(counted, ident): """What keeps a goal from closing, as (words, advice) pairs. Empty when no ticket of the goal waits or rides along and no item is in flight.""" tally = counted.goals.get(ident, Tally()) - held = ((tally.waiting, "waiting", "give each waiting ticket another goal with ticket set --goal or --no-goal"), + held = ((tally.waiting, "waiting", "give each waiting ticket another goal with ticket set --goal, or none with --no-goal"), (tally.in_flight, "in flight", "finish or drop each item in flight"), (counted.riding.get(ident, ()), "riding along", "finish or drop the item each of those tickets rides along in")) return [(f"{', '.join(ids)} {words}", advice) for ids, words, advice in held if ids] @@ -1721,12 +1721,14 @@ def take_tickets(restaurant): rows = restaurant.rows("rail.tsv") kind = handoff.get("kind") or "" if kind not in KINDS: - unknown = f"; kind {clean(kind)} is not one this version knows, so it has no kind" if kind else "" - kind = "" + unknown, kind = clean(kind), "" row = {"id": restaurant.next_id("rail.tsv"), "at": now(), "state": "waiting", "source": f"{handoff['source']} (from {source})", "ref": handoff["ref"], "summary": handoff["summary"], **{key: handoff.get(key, "") for key in ("priority", "paths", "decision")}, "kind": kind} restaurant.save_rows("rail.tsv", rows + [row]) + if unknown: + unknown = (f"; kind {unknown} is not one this version knows, so it has no kind; " + f"run ticket set {row['id']} --kind {KIND_CHOICE}") if not any(event["kind"] == "ticket" and event["note"] == f"from {source}" for event in restaurant.rows("log.tsv")): restaurant.log("ticket", row["id"], "waiting", f"from {source}") restaurant.remove(path) diff --git a/tests/test_brigade.py b/tests/test_brigade.py index a30c10a1..e1f9c387 100644 --- a/tests/test_brigade.py +++ b/tests/test_brigade.py @@ -6477,7 +6477,7 @@ def test_goal_set_refuses_done_and_dropped_while_a_ticket_of_the_goal_waits_and_ with self.subTest(state=state): self.assertEqual(self.brigade("goal", "set", "G1", "--state", state, "--evidence", "why", ok=False), "brigade: G1 still has work: T1, T3 waiting; give each waiting ticket another goal with " - "ticket set --goal or --no-goal; then run this again") + "ticket set --goal, or none with --no-goal; then run this again") self.assertEqual(self.store_files(), before) self.brigade("ticket", "set", "T1", "--no-goal") self.brigade("ticket", "set", "T3", "--goal", "G2") @@ -6921,7 +6921,7 @@ def test_goal_set_refuses_done_and_dropped_while_a_ticket_of_the_goal_rides_alon self.brigade("fire", "--tickets", "T1,T2", "--ride-along", "T2", "--station", "bug-fix", "--summary", "Do both") self.assert_goal_keeps_open("G2", "T2 riding along", "finish or drop the item each of those tickets rides along in") self.assertEqual(self.brigade("dish", "D1", "--state", "dropped"), "D1 dropped; T1, T2 waiting again") - self.assert_goal_keeps_open("G2", "T2 waiting", "give each waiting ticket another goal with ticket set --goal or --no-goal") + self.assert_goal_keeps_open("G2", "T2 waiting", "give each waiting ticket another goal with ticket set --goal, or none with --no-goal") def test_goal_set_names_the_waiting_tickets_the_items_in_flight_and_the_tickets_riding_along_in_one_refusal(self): self.two_goals() @@ -6933,7 +6933,7 @@ def test_goal_set_names_the_waiting_tickets_the_items_in_flight_and_the_tickets_ self.brigade("fire", "--tickets", "T3", "--station", "bug-fix", "--summary", "Do c") self.assert_goal_keeps_open( "G2", "T4 waiting; D2 in flight; T2 riding along", - "give each waiting ticket another goal with ticket set --goal or --no-goal; finish or drop each item in flight; " + "give each waiting ticket another goal with ticket set --goal, or none with --no-goal; finish or drop each item in flight; " "finish or drop the item each of those tickets rides along in") def test_from_report_gives_each_ticket_the_kind_polish_and_the_goal_of_the_reports_item(self): @@ -8089,7 +8089,8 @@ def test_ticket_take_files_a_handoff_with_a_kind_as_a_ticket_of_that_kind(self): def test_ticket_take_files_a_handoff_with_a_kind_that_is_none_of_the_five_as_a_ticket_with_no_kind_and_says_so(self): self.assertEqual(self.take_handoff(kind="chore"), - "T1 from app/core/T1: Tidy the guide; kind chore is not one this version knows, so it has no kind") + "T1 from app/core/T1: Tidy the guide; kind chore is not one this version knows, so it has no kind; " + "run ticket set T1 --kind <feature|fix|polish|upkeep|study>") self.assertEqual(self.rows("engine", "rail.tsv")[0][10:], ["", ""]) self.assertEqual(self.brigade("engine", "ticket", "take"), "nothing handed to you") From adab50dbef3609d4eb0245ccaca2782811cd8964 Mon Sep 17 00:00:00 2001 From: Marcus Gibson <marcusroygibson@gmail.com> Date: Sun, 11 Oct 2026 10:01:58 -0500 Subject: [PATCH 12/19] brigade: correct eight statements about kinds and goals that said more than the code does The fire refusal for a ticket under another goal told the reader to name it in --ride-along. That fails for a ticket that is not low or is a feature, so the message now says what --ride-along takes. The fire docstring said its check runs under both locks. It runs twice under the store lock. A comment in ticket set said fire copies the lead ticket's kind, which is false when the kind came from --kind or the station. The change note said --state done needs --evidence. A goal that is already done takes the flag with no evidence, so the note now says a move to done. Two test names said each reading command, and the tests run eight commands and seven. One test name said close groups Merged by goal, and its merged item has no goal. Two test docstrings are corrected the same way. --- changes/pstack-t3%2Fd160.md | 2 +- skills/brigade/scripts/brigade.py | 7 ++++--- t3/added/brigade/scripts/brigade.py | 7 ++++--- tests/test_brigade.py | 16 ++++++++-------- 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/changes/pstack-t3%2Fd160.md b/changes/pstack-t3%2Fd160.md index 0764ed64..9253b734 100644 --- a/changes/pstack-t3%2Fd160.md +++ b/changes/pstack-t3%2Fd160.md @@ -1 +1 @@ -- `brigade.py` records a kind and an optional goal on each ticket and work item, and keeps the goals in a new table, `goals.tsv`. `ticket add --summary` needs `--kind`, one of `feature`, `fix`, `polish`, `upkeep`, and `study`, and takes `--goal G<n>` with the id of an open or paused goal. `ticket set --kind`, `--goal`, and `--no-goal` change a waiting ticket and are refused on a ticket in another state. `goal add` records an open goal with a name, a purpose, and a done test. `goal set` changes a goal, and `--state done` and `--state dropped` need `--evidence` and are refused while the goal has a waiting ticket, an item in flight, or an assigned ticket in an item that is not under that goal. `goal list` prints each open or paused goal with its counts of waiting tickets, items in flight, merged items, and send-backs, then the counts of unassigned work. `fire` gives the new work item the goal of the first ticket in `--tickets` and that ticket's kind. When that ticket has no kind, the kind is `--kind`, or without it `fix` on station `bug-fix` or `perf-issue` and `study` on station `investigation`, and `fire` is refused on any other station. The first ticket records the kind the item took. A ticket after the first must have the first ticket's goal or no goal unless `--ride-along` names it, and a ticket named there must have priority low and must not be a feature. `ticket add --from-report` gives each ticket the goal of the report's item and, when that item has a kind, the kind `polish`, and it refuses `--kind` and `--goal`. `ticket move` hands the kind to the other coordinator and leaves the goal behind. `ticket list` and `startable` print a ticket's kind and goal after its priority. `status` prints a `kinds:` line and a `goals:` line with the counts of waiting tickets and items in flight under each, and an `untagged:` line with the number of waiting tickets that have no kind. A line with nothing to count is absent. `close` prints the kind on the lines of Merged, In progress, and New tickets, not started, and prints the lines of such a section under one heading per goal when one of them has a goal. A kind cell that holds a value other than one of the five kinds and a goal cell that holds an id with no row in `goals.tsv` read as empty, and `status`, `goal list`, `ticket list`, `startable`, and `close` print a warning for the cell on standard error. `fire` prints it for a ticket it names when it reaches its kind and goal check. `open` writes `goals.tsv` into a new store. A store written before this change reads with every ticket and item unassigned and with no kind. A command that appends a row to `rail.tsv` or `dishes.tsv` there writes the row with the two new cells, and a command that rewrites the table writes every row with them. A `brigade.py` from before this change then calls that table malformed. `activity.py` reads a work item row with or without the two cells and prints the same page for both. +- `brigade.py` records a kind and an optional goal on each ticket and work item, and keeps the goals in a new table, `goals.tsv`. `ticket add --summary` needs `--kind`, one of `feature`, `fix`, `polish`, `upkeep`, and `study`, and takes `--goal G<n>` with the id of an open or paused goal. `ticket set --kind`, `--goal`, and `--no-goal` change a waiting ticket and are refused on a ticket in another state. `goal add` records an open goal with a name, a purpose, and a done test. `goal set` changes a goal, and a move to `done` or to `dropped` needs `--evidence` and is refused while the goal has a waiting ticket, an item in flight, or an assigned ticket in an item that is not under that goal. `goal list` prints each open or paused goal with its counts of waiting tickets, items in flight, merged items, and send-backs, then the counts of unassigned work. `fire` gives the new work item the goal of the first ticket in `--tickets` and that ticket's kind. When that ticket has no kind, the kind is `--kind`, or without it `fix` on station `bug-fix` or `perf-issue` and `study` on station `investigation`, and `fire` is refused on any other station. The first ticket records the kind the item took. A ticket after the first must have the first ticket's goal or no goal unless `--ride-along` names it, and a ticket named there must have priority low and must not be a feature. `ticket add --from-report` gives each ticket the goal of the report's item and, when that item has a kind, the kind `polish`, and it refuses `--kind` and `--goal`. `ticket move` hands the kind to the other coordinator and leaves the goal behind. `ticket list` and `startable` print a ticket's kind and goal after its priority. `status` prints a `kinds:` line and a `goals:` line with the counts of waiting tickets and items in flight under each, and an `untagged:` line with the number of waiting tickets that have no kind. A line with nothing to count is absent. `close` prints the kind on the lines of Merged, In progress, and New tickets, not started, and prints the lines of such a section under one heading per goal when one of them has a goal. A kind cell that holds a value other than one of the five kinds and a goal cell that holds an id with no row in `goals.tsv` read as empty, and `status`, `goal list`, `ticket list`, `startable`, and `close` print a warning for the cell on standard error. `fire` prints it for a ticket it names when it reaches its kind and goal check. `open` writes `goals.tsv` into a new store. A store written before this change reads with every ticket and item unassigned and with no kind. A command that appends a row to `rail.tsv` or `dishes.tsv` there writes the row with the two new cells, and a command that rewrites the table writes every row with them. A `brigade.py` from before this change then calls that table malformed. `activity.py` reads a work item row with or without the two cells and prints the same page for both. diff --git a/skills/brigade/scripts/brigade.py b/skills/brigade/scripts/brigade.py index c24ca38a..4c95fa82 100644 --- a/skills/brigade/scripts/brigade.py +++ b/skills/brigade/scripts/brigade.py @@ -2684,7 +2684,7 @@ def fire_label(labels, rail, ids, station, kind, ride_along): theirs = labels.tickets[ident].goal if ident not in ride_along and theirs and theirs != mine.goal: return None, (f"{ident} is under {theirs} and the lead ticket {lead} is {'under ' + mine.goal if mine.goal else 'unassigned'}; " - f"name {ident} in --ride-along, or start it on its own") + f"name {ident} in --ride-along, which takes a low ticket that is not a feature, or start it on its own") return Label(found, mine.goal), None @@ -2705,7 +2705,7 @@ def checked_fire_label(restaurant, ids, station, kind, ride_along, warned): def fire(restaurant, ids, station, task, thread, branch, summary, timebox, paths, reason=None, kind="", ride_along=()): """Start one item for the tickets in `ids`. The item takes the kind and goal fire_label gives. - The kind and goal check runs under both locks. The first run comes after the owner check, unfireable, and the worker + The kind and goal check runs twice, each time under the store lock. The first run comes after the owner check, unfireable, and the worker cap, so a fire that one of those refuses or blocks gets the words it got before kinds and goals. It comes before the lease claim, so a fire it refuses claims no lease. The second run sees a change made while the claim ran. The lead ticket's kind cell takes the item's kind in the rewrite that assigns the tickets. No other kind or goal cell @@ -4174,7 +4174,8 @@ def command(restaurant, args, contract=None, rails=None): if ticket["state"] == "moved": raise BrigadeError(f"{args.id} moved to {ticket['dish'].removeprefix('to:')}; it is that coordinator's ticket now") if (args.kind or args.goal is not None or args.no_goal) and ticket["state"] != "waiting": - # fire copied the lead ticket's kind and goal onto the item, and no command writes the item's two cells after that. + # fire wrote the item's kind and goal when it started, from the lead ticket, --kind, or the station, and no command + # writes the item's two cells after that. kept = (f"; {ticket['dish']} keeps the kind and goal it took when it started" if ticket["state"] == "assigned" and ticket["dish"] else "") raise BrigadeError(f"{args.id} is {ticket['state']}; --kind, --goal, and --no-goal change a waiting ticket only{kept}") diff --git a/t3/added/brigade/scripts/brigade.py b/t3/added/brigade/scripts/brigade.py index c24ca38a..4c95fa82 100644 --- a/t3/added/brigade/scripts/brigade.py +++ b/t3/added/brigade/scripts/brigade.py @@ -2684,7 +2684,7 @@ def fire_label(labels, rail, ids, station, kind, ride_along): theirs = labels.tickets[ident].goal if ident not in ride_along and theirs and theirs != mine.goal: return None, (f"{ident} is under {theirs} and the lead ticket {lead} is {'under ' + mine.goal if mine.goal else 'unassigned'}; " - f"name {ident} in --ride-along, or start it on its own") + f"name {ident} in --ride-along, which takes a low ticket that is not a feature, or start it on its own") return Label(found, mine.goal), None @@ -2705,7 +2705,7 @@ def checked_fire_label(restaurant, ids, station, kind, ride_along, warned): def fire(restaurant, ids, station, task, thread, branch, summary, timebox, paths, reason=None, kind="", ride_along=()): """Start one item for the tickets in `ids`. The item takes the kind and goal fire_label gives. - The kind and goal check runs under both locks. The first run comes after the owner check, unfireable, and the worker + The kind and goal check runs twice, each time under the store lock. The first run comes after the owner check, unfireable, and the worker cap, so a fire that one of those refuses or blocks gets the words it got before kinds and goals. It comes before the lease claim, so a fire it refuses claims no lease. The second run sees a change made while the claim ran. The lead ticket's kind cell takes the item's kind in the rewrite that assigns the tickets. No other kind or goal cell @@ -4174,7 +4174,8 @@ def command(restaurant, args, contract=None, rails=None): if ticket["state"] == "moved": raise BrigadeError(f"{args.id} moved to {ticket['dish'].removeprefix('to:')}; it is that coordinator's ticket now") if (args.kind or args.goal is not None or args.no_goal) and ticket["state"] != "waiting": - # fire copied the lead ticket's kind and goal onto the item, and no command writes the item's two cells after that. + # fire wrote the item's kind and goal when it started, from the lead ticket, --kind, or the station, and no command + # writes the item's two cells after that. kept = (f"; {ticket['dish']} keeps the kind and goal it took when it started" if ticket["state"] == "assigned" and ticket["dish"] else "") raise BrigadeError(f"{args.id} is {ticket['state']}; --kind, --goal, and --no-goal change a waiting ticket only{kept}") diff --git a/tests/test_brigade.py b/tests/test_brigade.py index e1f9c387..496b6db9 100644 --- a/tests/test_brigade.py +++ b/tests/test_brigade.py @@ -6625,7 +6625,7 @@ def test_fire_refuses_a_ticket_after_the_lead_that_is_under_another_goal_and_tak before = self.store_files() self.assertEqual(self.brigade("fire", "--tickets", "T1,T2", "--station", "bug-fix", "--summary", "Do both", ok=False), "brigade: T2 is under G2 and the lead ticket T1 is under G1; " - "name T2 in --ride-along, or start it on its own") + "name T2 in --ride-along, which takes a low ticket that is not a feature, or start it on its own") self.assertEqual(self.store_files(), before) rail = self.table_row(self.at, "rail.tsv", "T2") self.assertEqual(self.brigade("fire", "--tickets", "T1,T2", "--ride-along", "T2", "--station", "bug-fix", "--summary", "Do both"), @@ -6641,7 +6641,7 @@ def test_fire_refuses_a_ticket_under_a_goal_after_an_unassigned_lead_ticket_and_ before = self.store_files() self.assertEqual(self.brigade("fire", "--tickets", "T1,T2", "--station", "bug-fix", "--summary", "Do both", ok=False), "brigade: T2 is under G1 and the lead ticket T1 is unassigned; " - "name T2 in --ride-along, or start it on its own") + "name T2 in --ride-along, which takes a low ticket that is not a feature, or start it on its own") self.assertEqual(self.store_files(), before) self.brigade("fire", "--tickets", "T1,T2", "--ride-along", "T2", "--station", "bug-fix", "--summary", "Do both") self.assertEqual((self.label("dishes.tsv", "D1"), self.label("rail.tsv", "T2")), (("fix", ""), ("polish", "G1"))) @@ -6727,7 +6727,7 @@ def test_a_goal_change_while_fire_claims_its_lease_is_caught_before_the_write_an _, err = proc.communicate(timeout=20) self.assertEqual((proc.returncode, err.strip()), (1, "brigade: nothing fired: T2 is under G2 and the lead ticket T1 is under G1; " - "name T2 in --ride-along, or start it on its own")) + "name T2 in --ride-along, which takes a low ticket that is not a feature, or start it on its own")) self.assertEqual((case / "land-calls").read_text().splitlines(), ["lease claim --holder perf/D1 --paths src,changes/perf%2Fd1.md", "lease release L7"]) self.assertEqual(self.store_files(), before) @@ -7427,7 +7427,7 @@ def close_setup(self): self.brigade("fire", "--tickets", "T4", "--station", "refactoring", "--summary", "Follow the nightly") self.brigade("fire", "--tickets", "T5", "--station", "bug-fix", "--summary", "Fix the splash") - def test_close_groups_merged_in_progress_and_new_tickets_by_goal_and_prints_the_kind_on_their_lines(self): + def test_close_groups_in_progress_and_new_tickets_by_goal_and_prints_the_kind_on_the_lines_of_merged_in_progress_and_new_tickets(self): self.close_setup() self.brigade("dish", "D1", "--state", "in-review", "--sha", "abc") self.record("abc", "pass") @@ -7473,7 +7473,7 @@ def printed(self): def assert_each_printing_command_warns(self, *warnings): """Each of the five commands exits 0 with exactly these warnings on standard error, and changes no file. - Returns {command: its standard output with the last newline cut}. + Returns {command: its standard output with leading and trailing whitespace cut}. """ before = self.store_files() printed = self.printed() @@ -7552,7 +7552,7 @@ def test_with_goals_tsv_deleted_each_goal_cell_is_warned_of_its_row_counts_as_un def store_from_before_kinds(self, rows=True): """A store as the tool wrote it before kinds and goals. Ticket rows of 10 cells, item rows of 15, and no goals.tsv. - With `rows` it holds a ticket in each state, an item in each state, review rows, and an open and an answered decision. + With `rows` it holds a ticket in each state but moved, an item in each state, review rows, and an open and an answered decision. """ self.queue() (self.at / "goals.tsv").unlink(missing_ok=True) @@ -7607,7 +7607,7 @@ def store_from_before_kinds(self, rows=True): "blocked: 1, merged: 1, decisions for you: 1") OLD_WORKERS = "workers: 2 of 2 running, 0 idle, auto-start: normal" - def test_each_reading_command_prints_for_a_store_from_before_kinds_what_the_tool_printed_before_kinds_and_changes_no_table(self): + def test_ticket_list_startable_watch_86_list_close_walk_pass_check_and_contract_print_for_a_store_from_before_kinds_what_the_tool_printed_before_kinds_and_change_no_table(self): self.store_from_before_kinds() before = self.tables() waiting = ["T8 waiting normal [user] Importer crashes", @@ -7680,7 +7680,7 @@ def test_goal_list_on_a_store_from_before_kinds_counts_each_ticket_and_item_as_u "no open or paused goals\nunassigned: 5 waiting, 6 in flight, 1 merged, 1 send-back") self.assertEqual(self.store_files(), before) - def test_each_reading_command_prints_for_an_empty_store_from_before_kinds_what_the_tool_printed_before_kinds_and_changes_no_table(self): + def test_status_ticket_list_startable_watch_86_list_close_and_walk_print_for_an_empty_store_from_before_kinds_what_the_tool_printed_before_kinds_and_change_no_table(self): self.store_from_before_kinds(rows=False) before = self.tables() idle = "workers: 0 of 2 running, 2 idle, auto-start: normal" From ef5ec3ec097ef78abd29e42ac0c7cc328c6e8ad7 Mon Sep 17 00:00:00 2001 From: Marcus Gibson <marcusroygibson@gmail.com> Date: Sun, 11 Oct 2026 11:15:34 -0500 Subject: [PATCH 13/19] brigade: refuse a kind or goal change on a waiting ticket that an item in flight names, and warn of a goals.tsv row with no id or a repeated id --- changes/pstack-t3%2Fd160.md | 2 +- docs/cli/brigade.md | 6 +- skills/brigade/scripts/brigade.py | 83 +++++++++++++++++++------- t3/added/brigade/scripts/brigade.py | 83 +++++++++++++++++++------- tests/test_brigade.py | 90 ++++++++++++++++++++++++++++- 5 files changed, 214 insertions(+), 50 deletions(-) diff --git a/changes/pstack-t3%2Fd160.md b/changes/pstack-t3%2Fd160.md index 9253b734..99eaf6da 100644 --- a/changes/pstack-t3%2Fd160.md +++ b/changes/pstack-t3%2Fd160.md @@ -1 +1 @@ -- `brigade.py` records a kind and an optional goal on each ticket and work item, and keeps the goals in a new table, `goals.tsv`. `ticket add --summary` needs `--kind`, one of `feature`, `fix`, `polish`, `upkeep`, and `study`, and takes `--goal G<n>` with the id of an open or paused goal. `ticket set --kind`, `--goal`, and `--no-goal` change a waiting ticket and are refused on a ticket in another state. `goal add` records an open goal with a name, a purpose, and a done test. `goal set` changes a goal, and a move to `done` or to `dropped` needs `--evidence` and is refused while the goal has a waiting ticket, an item in flight, or an assigned ticket in an item that is not under that goal. `goal list` prints each open or paused goal with its counts of waiting tickets, items in flight, merged items, and send-backs, then the counts of unassigned work. `fire` gives the new work item the goal of the first ticket in `--tickets` and that ticket's kind. When that ticket has no kind, the kind is `--kind`, or without it `fix` on station `bug-fix` or `perf-issue` and `study` on station `investigation`, and `fire` is refused on any other station. The first ticket records the kind the item took. A ticket after the first must have the first ticket's goal or no goal unless `--ride-along` names it, and a ticket named there must have priority low and must not be a feature. `ticket add --from-report` gives each ticket the goal of the report's item and, when that item has a kind, the kind `polish`, and it refuses `--kind` and `--goal`. `ticket move` hands the kind to the other coordinator and leaves the goal behind. `ticket list` and `startable` print a ticket's kind and goal after its priority. `status` prints a `kinds:` line and a `goals:` line with the counts of waiting tickets and items in flight under each, and an `untagged:` line with the number of waiting tickets that have no kind. A line with nothing to count is absent. `close` prints the kind on the lines of Merged, In progress, and New tickets, not started, and prints the lines of such a section under one heading per goal when one of them has a goal. A kind cell that holds a value other than one of the five kinds and a goal cell that holds an id with no row in `goals.tsv` read as empty, and `status`, `goal list`, `ticket list`, `startable`, and `close` print a warning for the cell on standard error. `fire` prints it for a ticket it names when it reaches its kind and goal check. `open` writes `goals.tsv` into a new store. A store written before this change reads with every ticket and item unassigned and with no kind. A command that appends a row to `rail.tsv` or `dishes.tsv` there writes the row with the two new cells, and a command that rewrites the table writes every row with them. A `brigade.py` from before this change then calls that table malformed. `activity.py` reads a work item row with or without the two cells and prints the same page for both. +- `brigade.py` records a kind and an optional goal on each ticket and work item, and keeps the goals in a new table, `goals.tsv`. `ticket add --summary` needs `--kind`, one of `feature`, `fix`, `polish`, `upkeep`, and `study`, and takes `--goal G<n>` with the id of an open or paused goal. `ticket set --kind`, `--goal`, and `--no-goal` change a waiting ticket and are refused on a ticket in another state and on a waiting ticket that an item in flight names. `goal add` records an open goal with a name, a purpose, and a done test. `goal set` changes a goal, and a move to `done` or to `dropped` needs `--evidence` and is refused while the goal has a waiting ticket, an item in flight, or an assigned ticket in an item that is not under that goal. `goal list` prints each open or paused goal with its counts of waiting tickets, items in flight, merged items, and send-backs, then the counts of unassigned work. `fire` gives the new work item the goal of the first ticket in `--tickets` and that ticket's kind. When that ticket has no kind, the kind is `--kind`, or without it `fix` on station `bug-fix` or `perf-issue` and `study` on station `investigation`, and `fire` is refused on any other station. The first ticket records the kind the item took. A ticket after the first must have the first ticket's goal or no goal unless `--ride-along` names it, and a ticket named there must have priority low and must not be a feature. `ticket add --from-report` gives each ticket the goal of the report's item and, when that item has a kind, the kind `polish`, and it refuses `--kind` and `--goal`. `ticket move` hands the kind to the other coordinator and leaves the goal behind. `ticket list` and `startable` print a ticket's kind and goal after its priority. `status` prints a `kinds:` line and a `goals:` line with the counts of waiting tickets and items in flight under each, and an `untagged:` line with the number of waiting tickets that have no kind. A line with nothing to count is absent. `close` prints the kind on the lines of Merged, In progress, and New tickets, not started, and prints the lines of such a section under one heading per goal when one of them has a goal. A kind cell that holds a value other than one of the five kinds and a goal cell that holds an id with no row in `goals.tsv` read as empty, and `status`, `goal list`, `ticket list`, `startable`, and `close` print a warning for the cell on standard error. `fire` prints it for a ticket it names when it reaches its kind and goal check. `open` writes `goals.tsv` into a new store. A store written before this change reads with every ticket and item unassigned and with no kind. A command that appends a row to `rail.tsv` or `dishes.tsv` there writes the row with the two new cells, and a command that rewrites the table writes every row with them. A `brigade.py` from before this change then calls that table malformed. `activity.py` reads a work item row with or without the two cells and prints the same page for both. diff --git a/docs/cli/brigade.md b/docs/cli/brigade.md index b973f7b3..abd5d5ec 100644 --- a/docs/cli/brigade.md +++ b/docs/cli/brigade.md @@ -118,9 +118,9 @@ brigade.py [flags] ticket set [--state STATE] [--priority PRIORITY] [--paths PAT | `--priority PRIORITY` | | `urgent`, `normal`, `low` | | | | `--paths PATHS` | | | | replaces the recorded paths; "" clears them | | `--decision, --no-decision` | | | | mark or unmark the ticket as one that asks the owner to decide | -| `--kind KIND` | | `feature`, `fix`, `polish`, `upkeep`, `study` | | replaces the kind of a waiting ticket; refused on a ticket in another state | -| `--goal G<n>` | | | | replaces the goal of a waiting ticket with one that is open or paused; refused on a ticket in another state | -| `--no-goal` | | | | clears the goal of a waiting ticket, so it is unassigned; refused on a ticket in another state | +| `--kind KIND` | | `feature`, `fix`, `polish`, `upkeep`, `study` | | replaces the kind of a waiting ticket; refused on a ticket in another state, and on a waiting ticket that an item in flight names | +| `--goal G<n>` | | | | replaces the goal of a waiting ticket with one that is open or paused; refused on a ticket in another state, and on a waiting ticket that an item in flight names | +| `--no-goal` | | | | clears the goal of a waiting ticket, so it is unassigned; refused on a ticket in another state, and on a waiting ticket that an item in flight names | Give at most one of `--goal`, `--no-goal`. diff --git a/skills/brigade/scripts/brigade.py b/skills/brigade/scripts/brigade.py index 4c95fa82..f9ab344a 100644 --- a/skills/brigade/scripts/brigade.py +++ b/skills/brigade/scripts/brigade.py @@ -790,7 +790,7 @@ class Goal: class Label: """A ticket's or an item's kind and goal as read_labels reads them. - kind is one of KINDS, or "" for no kind. goal is the id of a row of goals.tsv, or "" for unassigned. + kind is one of KINDS, or "" for no kind. goal is a key of Labels.goals, or "" for unassigned. """ kind: str = "" goal: str = "" @@ -801,7 +801,8 @@ class Labels: """The kinds and goals of one store, read under one hold of the store lock. goals is {goal id: Goal} in file order. tickets is {ticket id: Label} for the rows of rail.tsv, and items is - {item id: Label} for the rows of dishes.tsv. faults is one (row id, text) pair per cell whose value is not legal. + {item id: Label} for the rows of dishes.tsv. faults is one (row id, text) pair per cell whose value is not legal, + and one per id of goals.tsv with a row that is not a goal. """ goals: dict tickets: dict @@ -826,19 +827,41 @@ def read_goal(row, faults): return Goal(row["id"], state, row["name"], row["purpose"], row["done"], share, row["closed"]) -def read_label(table, row, goals, faults): +def in_flight_tickets(restaurant): + """{ticket id: the first row of dishes.tsv whose state is in IN_FLIGHT_STATES and whose tickets cell names the id}.""" + found = {} + for item in restaurant.rows("dishes.tsv"): + if item["state"] in IN_FLIGHT_STATES: + for ident in item["tickets"].split(","): + found.setdefault(ident, item) + return found + + +def frozen_label(ticket, held): + """Why ticket set does not change this ticket's kind and goal now, or "" when it does. `held` is in_flight_tickets(). + + It changes them when the ticket is waiting and no item in flight names it. fire wrote an item's kind and goal when the + item started, and no command writes the item's two cells after that. + """ + if ticket["state"] != "waiting": + return f"{ticket['id']} is {ticket['state']}" + item = held.get(ticket["id"]) + return f"{ticket['id']} is a ticket of {item['id']}, which is {item['state']}" if item else "" + + +def read_label(table, row, goals, faults, frozen): + """One row's Label. `frozen` is frozen_label for a row of rail.tsv, and None for a row of dishes.tsv.""" ident = row["id"] kind = row["kind"] if row["kind"] in KINDS else "" goal = row["goal"] if row["goal"] in goals else "" - # ticket set changes these two cells of a waiting ticket and of no other row. - waiting = table == "rail.tsv" and row["state"] == "waiting" - by_hand = f"no command changes this cell; correct it in {table} by hand" + by_hand = (f"no command changes this cell; correct it in {table} by hand" if frozen is None + else f"ticket set does not change this cell while {frozen}; correct it in {table} by hand") if row["kind"] and not kind: faults.append((ident, f"{ident} has kind {row['kind']!r}, which is not {one_of(KINDS)}; it reads as no kind; " - + (f"run ticket set {ident} --kind <kind>" if waiting else by_hand))) + + (by_hand if frozen != "" else f"run ticket set {ident} --kind <kind>"))) if row["goal"] and not goal: faults.append((ident, f"{ident} names goal {row['goal']!r}, which has no row in goals.tsv; it reads as unassigned; " - + (f"run ticket set {ident} --goal G<n> or --no-goal" if waiting else by_hand))) + + (by_hand if frozen != "" else f"run ticket set {ident} --goal G<n> or --no-goal"))) return Label(kind, goal) @@ -846,18 +869,28 @@ def read_labels(restaurant): """The one reader of the kind and goal cells of rail.tsv and dishes.tsv, and of goals.tsv. It writes nothing. Call it under the store lock. A goals.tsv that does not exist reads as no goals. + A row of goals.tsv with an empty id is not a goal. Of the rows of goals.tsv with one id, the first is the goal. + The rows with an empty id add one pair to faults between them. So do the rows after the first with one id. A cell whose value is not legal reads as follows and adds one pair to faults. - A kind that is not one of KINDS reads as no kind. - - A goal that is not the id of a row of goals.tsv reads as unassigned. + - A goal that is not the id of a goal reads as unassigned. - A goal's state that is not one of GOAL_STATES reads as open. - A goal's share that is not empty and not a whole number of 1 or more reads as no limit. """ - goals, faults = {}, [] + goals, faults, extra = {}, [], [] for row in restaurant.rows("goals.tsv"): - goals.setdefault(row["id"], read_goal(row, faults)) - tickets = {row["id"]: read_label("rail.tsv", row, goals, faults) for row in restaurant.rows("rail.tsv")} - items = {row["id"]: read_label("dishes.tsv", row, goals, faults) for row in restaurant.rows("dishes.tsv")} + if row["id"] and row["id"] not in goals: + goals[row["id"]] = read_goal(row, faults) + elif row["id"] not in extra: + extra.append(row["id"]) + for ident in extra: + repeated = (f"goals.tsv has more than one row with id {ident}; the first is the goal and the others are not read; " + "remove the others from goals.tsv by hand") + faults.append((ident, repeated if ident else "goals.tsv has a row with no id; it is not a goal; remove it from goals.tsv by hand")) + held = in_flight_tickets(restaurant) + tickets = {row["id"]: read_label("rail.tsv", row, goals, faults, frozen_label(row, held)) for row in restaurant.rows("rail.tsv")} + items = {row["id"]: read_label("dishes.tsv", row, goals, faults, None) for row in restaurant.rows("dishes.tsv")} return Labels(goals, tickets, items, tuple(faults)) @@ -3652,13 +3685,15 @@ def parser(): a.add_argument("--paths", help='replaces the recorded paths; "" clears them') a.add_argument("--decision", action=argparse.BooleanOptionalAction, default=None, help="mark or unmark the ticket as one that asks the owner to decide") - a.add_argument("--kind", choices=KINDS, help="replaces the kind of a waiting ticket; refused on a ticket in another state") + a.add_argument("--kind", choices=KINDS, help="replaces the kind of a waiting ticket; refused on a ticket in another state, " + "and on a waiting ticket that an item in flight names") goal = a.add_mutually_exclusive_group() goal.add_argument("--goal", metavar="G<n>", help="replaces the goal of a waiting ticket with one that is open or paused; refused on a ticket in " - "another state") + "another state, and on a waiting ticket that an item in flight names") goal.add_argument("--no-goal", action="store_true", - help="clears the goal of a waiting ticket, so it is unassigned; refused on a ticket in another state") + help="clears the goal of a waiting ticket, so it is unassigned; refused on a ticket in another state, " + "and on a waiting ticket that an item in flight names") a = t.add_parser("move", help="hand a waiting ticket to a sibling coordinator through its inbox") a.add_argument("id") a.add_argument("--to", required=True, help="the sibling's directory name") @@ -4173,12 +4208,16 @@ def command(restaurant, args, contract=None, rails=None): _, ticket = restaurant.find("rail.tsv", args.id) if ticket["state"] == "moved": raise BrigadeError(f"{args.id} moved to {ticket['dish'].removeprefix('to:')}; it is that coordinator's ticket now") - if (args.kind or args.goal is not None or args.no_goal) and ticket["state"] != "waiting": - # fire wrote the item's kind and goal when it started, from the lead ticket, --kind, or the station, and no command - # writes the item's two cells after that. - kept = (f"; {ticket['dish']} keeps the kind and goal it took when it started" - if ticket["state"] == "assigned" and ticket["dish"] else "") - raise BrigadeError(f"{args.id} is {ticket['state']}; --kind, --goal, and --no-goal change a waiting ticket only{kept}") + held = in_flight_tickets(restaurant) + frozen = frozen_label(ticket, held) + if (args.kind or args.goal is not None or args.no_goal) and frozen: + if ticket["state"] != "waiting": + kept = (f"; {ticket['dish']} keeps the kind and goal it took when it started" + if ticket["state"] == "assigned" and ticket["dish"] else "") + raise BrigadeError(f"{frozen}; --kind, --goal, and --no-goal change a waiting ticket only{kept}") + item = held[args.id]["id"] + raise BrigadeError(f"{frozen}; --kind, --goal, and --no-goal change a waiting ticket that no item in flight names; " + f"{item} keeps the kind and goal it took when it started; drop {item} first, or leave {args.id} as it is") changes, said = {}, [] if args.state: changes["state"] = args.state diff --git a/t3/added/brigade/scripts/brigade.py b/t3/added/brigade/scripts/brigade.py index 4c95fa82..f9ab344a 100644 --- a/t3/added/brigade/scripts/brigade.py +++ b/t3/added/brigade/scripts/brigade.py @@ -790,7 +790,7 @@ class Goal: class Label: """A ticket's or an item's kind and goal as read_labels reads them. - kind is one of KINDS, or "" for no kind. goal is the id of a row of goals.tsv, or "" for unassigned. + kind is one of KINDS, or "" for no kind. goal is a key of Labels.goals, or "" for unassigned. """ kind: str = "" goal: str = "" @@ -801,7 +801,8 @@ class Labels: """The kinds and goals of one store, read under one hold of the store lock. goals is {goal id: Goal} in file order. tickets is {ticket id: Label} for the rows of rail.tsv, and items is - {item id: Label} for the rows of dishes.tsv. faults is one (row id, text) pair per cell whose value is not legal. + {item id: Label} for the rows of dishes.tsv. faults is one (row id, text) pair per cell whose value is not legal, + and one per id of goals.tsv with a row that is not a goal. """ goals: dict tickets: dict @@ -826,19 +827,41 @@ def read_goal(row, faults): return Goal(row["id"], state, row["name"], row["purpose"], row["done"], share, row["closed"]) -def read_label(table, row, goals, faults): +def in_flight_tickets(restaurant): + """{ticket id: the first row of dishes.tsv whose state is in IN_FLIGHT_STATES and whose tickets cell names the id}.""" + found = {} + for item in restaurant.rows("dishes.tsv"): + if item["state"] in IN_FLIGHT_STATES: + for ident in item["tickets"].split(","): + found.setdefault(ident, item) + return found + + +def frozen_label(ticket, held): + """Why ticket set does not change this ticket's kind and goal now, or "" when it does. `held` is in_flight_tickets(). + + It changes them when the ticket is waiting and no item in flight names it. fire wrote an item's kind and goal when the + item started, and no command writes the item's two cells after that. + """ + if ticket["state"] != "waiting": + return f"{ticket['id']} is {ticket['state']}" + item = held.get(ticket["id"]) + return f"{ticket['id']} is a ticket of {item['id']}, which is {item['state']}" if item else "" + + +def read_label(table, row, goals, faults, frozen): + """One row's Label. `frozen` is frozen_label for a row of rail.tsv, and None for a row of dishes.tsv.""" ident = row["id"] kind = row["kind"] if row["kind"] in KINDS else "" goal = row["goal"] if row["goal"] in goals else "" - # ticket set changes these two cells of a waiting ticket and of no other row. - waiting = table == "rail.tsv" and row["state"] == "waiting" - by_hand = f"no command changes this cell; correct it in {table} by hand" + by_hand = (f"no command changes this cell; correct it in {table} by hand" if frozen is None + else f"ticket set does not change this cell while {frozen}; correct it in {table} by hand") if row["kind"] and not kind: faults.append((ident, f"{ident} has kind {row['kind']!r}, which is not {one_of(KINDS)}; it reads as no kind; " - + (f"run ticket set {ident} --kind <kind>" if waiting else by_hand))) + + (by_hand if frozen != "" else f"run ticket set {ident} --kind <kind>"))) if row["goal"] and not goal: faults.append((ident, f"{ident} names goal {row['goal']!r}, which has no row in goals.tsv; it reads as unassigned; " - + (f"run ticket set {ident} --goal G<n> or --no-goal" if waiting else by_hand))) + + (by_hand if frozen != "" else f"run ticket set {ident} --goal G<n> or --no-goal"))) return Label(kind, goal) @@ -846,18 +869,28 @@ def read_labels(restaurant): """The one reader of the kind and goal cells of rail.tsv and dishes.tsv, and of goals.tsv. It writes nothing. Call it under the store lock. A goals.tsv that does not exist reads as no goals. + A row of goals.tsv with an empty id is not a goal. Of the rows of goals.tsv with one id, the first is the goal. + The rows with an empty id add one pair to faults between them. So do the rows after the first with one id. A cell whose value is not legal reads as follows and adds one pair to faults. - A kind that is not one of KINDS reads as no kind. - - A goal that is not the id of a row of goals.tsv reads as unassigned. + - A goal that is not the id of a goal reads as unassigned. - A goal's state that is not one of GOAL_STATES reads as open. - A goal's share that is not empty and not a whole number of 1 or more reads as no limit. """ - goals, faults = {}, [] + goals, faults, extra = {}, [], [] for row in restaurant.rows("goals.tsv"): - goals.setdefault(row["id"], read_goal(row, faults)) - tickets = {row["id"]: read_label("rail.tsv", row, goals, faults) for row in restaurant.rows("rail.tsv")} - items = {row["id"]: read_label("dishes.tsv", row, goals, faults) for row in restaurant.rows("dishes.tsv")} + if row["id"] and row["id"] not in goals: + goals[row["id"]] = read_goal(row, faults) + elif row["id"] not in extra: + extra.append(row["id"]) + for ident in extra: + repeated = (f"goals.tsv has more than one row with id {ident}; the first is the goal and the others are not read; " + "remove the others from goals.tsv by hand") + faults.append((ident, repeated if ident else "goals.tsv has a row with no id; it is not a goal; remove it from goals.tsv by hand")) + held = in_flight_tickets(restaurant) + tickets = {row["id"]: read_label("rail.tsv", row, goals, faults, frozen_label(row, held)) for row in restaurant.rows("rail.tsv")} + items = {row["id"]: read_label("dishes.tsv", row, goals, faults, None) for row in restaurant.rows("dishes.tsv")} return Labels(goals, tickets, items, tuple(faults)) @@ -3652,13 +3685,15 @@ def parser(): a.add_argument("--paths", help='replaces the recorded paths; "" clears them') a.add_argument("--decision", action=argparse.BooleanOptionalAction, default=None, help="mark or unmark the ticket as one that asks the owner to decide") - a.add_argument("--kind", choices=KINDS, help="replaces the kind of a waiting ticket; refused on a ticket in another state") + a.add_argument("--kind", choices=KINDS, help="replaces the kind of a waiting ticket; refused on a ticket in another state, " + "and on a waiting ticket that an item in flight names") goal = a.add_mutually_exclusive_group() goal.add_argument("--goal", metavar="G<n>", help="replaces the goal of a waiting ticket with one that is open or paused; refused on a ticket in " - "another state") + "another state, and on a waiting ticket that an item in flight names") goal.add_argument("--no-goal", action="store_true", - help="clears the goal of a waiting ticket, so it is unassigned; refused on a ticket in another state") + help="clears the goal of a waiting ticket, so it is unassigned; refused on a ticket in another state, " + "and on a waiting ticket that an item in flight names") a = t.add_parser("move", help="hand a waiting ticket to a sibling coordinator through its inbox") a.add_argument("id") a.add_argument("--to", required=True, help="the sibling's directory name") @@ -4173,12 +4208,16 @@ def command(restaurant, args, contract=None, rails=None): _, ticket = restaurant.find("rail.tsv", args.id) if ticket["state"] == "moved": raise BrigadeError(f"{args.id} moved to {ticket['dish'].removeprefix('to:')}; it is that coordinator's ticket now") - if (args.kind or args.goal is not None or args.no_goal) and ticket["state"] != "waiting": - # fire wrote the item's kind and goal when it started, from the lead ticket, --kind, or the station, and no command - # writes the item's two cells after that. - kept = (f"; {ticket['dish']} keeps the kind and goal it took when it started" - if ticket["state"] == "assigned" and ticket["dish"] else "") - raise BrigadeError(f"{args.id} is {ticket['state']}; --kind, --goal, and --no-goal change a waiting ticket only{kept}") + held = in_flight_tickets(restaurant) + frozen = frozen_label(ticket, held) + if (args.kind or args.goal is not None or args.no_goal) and frozen: + if ticket["state"] != "waiting": + kept = (f"; {ticket['dish']} keeps the kind and goal it took when it started" + if ticket["state"] == "assigned" and ticket["dish"] else "") + raise BrigadeError(f"{frozen}; --kind, --goal, and --no-goal change a waiting ticket only{kept}") + item = held[args.id]["id"] + raise BrigadeError(f"{frozen}; --kind, --goal, and --no-goal change a waiting ticket that no item in flight names; " + f"{item} keeps the kind and goal it took when it started; drop {item} first, or leave {args.id} as it is") changes, said = {}, [] if args.state: changes["state"] = args.state diff --git a/tests/test_brigade.py b/tests/test_brigade.py index 496b6db9..98c3d540 100644 --- a/tests/test_brigade.py +++ b/tests/test_brigade.py @@ -6464,6 +6464,38 @@ def test_ticket_set_kind_goal_and_no_goal_are_refused_on_an_assigned_ticket_with self.assertEqual(self.store_files(), before) self.assertEqual(self.brigade("ticket", "set", "T1", "--priority", "low"), "T1 priority low") + def test_ticket_set_kind_goal_and_no_goal_are_refused_on_a_waiting_ticket_that_an_item_in_flight_names_and_change_no_file_and_priority_alone_is_accepted(self): + self.two_goals() + self.brigade("ticket", "add", "--summary", "a", "--kind", "feature", "--goal", "G1") + self.brigade("ticket", "add", "--summary", "b", "--kind", "fix", "--goal", "G2", "--priority", "low") + self.brigade("fire", "--tickets", "T1,T2", "--ride-along", "T2", "--station", "feature", "--summary", "Build a") + for ticket in ("T1", "T2"): + self.assertEqual(self.brigade("ticket", "set", ticket, "--state", "waiting"), f"{ticket} waiting") + for state in ("in-progress", "in-review", "passed", "sent-back", "blocked", "queued"): + self.hand_edit("dishes.tsv", "D1", state=state) + before = self.store_files() + for ticket, flags in (("T1", ("--kind", "polish")), ("T1", ("--goal", "G2")), ("T1", ("--no-goal",)), + ("T1", ("--priority", "urgent", "--kind", "polish")), ("T2", ("--goal", "G1"))): + with self.subTest(state=state, ticket=ticket, flags=flags): + self.assertEqual(self.brigade("ticket", "set", ticket, *flags, ok=False), + f"brigade: {ticket} is a ticket of D1, which is {state}; --kind, --goal, and --no-goal change " + "a waiting ticket that no item in flight names; D1 keeps the kind and goal it took when it " + f"started; drop D1 first, or leave {ticket} as it is") + self.assertEqual(self.store_files(), before) + self.assertEqual(self.brigade("ticket", "set", "T1", "--priority", "low"), "T1 priority low") + + def test_ticket_set_kind_and_goal_change_a_ticket_set_back_to_waiting_whose_item_is_merged_or_dropped_and_leave_the_items_cells(self): + self.two_goals() + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix", "--goal", "G1") + self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "Fix a") + self.brigade("ticket", "set", "T1", "--state", "waiting") + self.hand_edit("dishes.tsv", "D1", state="merged") + self.assertEqual(self.brigade("ticket", "set", "T1", "--kind", "polish", "--goal", "G2"), "T1 kind polish; goal G2") + self.assertEqual((self.label("rail.tsv", "T1"), self.label("dishes.tsv", "D1")), (("polish", "G2"), ("fix", "G1"))) + self.hand_edit("dishes.tsv", "D1", state="dropped") + self.assertEqual(self.brigade("ticket", "set", "T1", "--kind", "upkeep", "--no-goal"), "T1 kind upkeep; unassigned") + self.assertEqual((self.label("rail.tsv", "T1"), self.label("dishes.tsv", "D1")), (("upkeep", ""), ("fix", "G1"))) + def test_goal_set_refuses_done_and_dropped_while_a_ticket_of_the_goal_waits_and_names_each_such_ticket(self): self.open() self.goal("activity view") @@ -7485,6 +7517,7 @@ def assert_each_printing_command_warns(self, *warnings): NOT_A_KIND = "which is not feature, fix, polish, upkeep, or study; it reads as no kind" NO_GOAL_ROW = "which has no row in goals.tsv; it reads as unassigned" + NOT_WHILE = "ticket set does not change this cell while" def test_a_waiting_ticket_whose_kind_cell_holds_no_kind_is_warned_of_by_each_printing_command_and_counts_as_untagged(self): self.open() @@ -7502,13 +7535,66 @@ def test_an_item_and_an_assigned_ticket_whose_kind_cells_hold_no_kind_are_warned self.hand_edit("rail.tsv", "T1", kind="chore") self.hand_edit("dishes.tsv", "D1", kind="chore") out = self.assert_each_printing_command_warns( - f"T1 has kind 'chore', {self.NOT_A_KIND}; no command changes this cell; correct it in rail.tsv by hand", + f"T1 has kind 'chore', {self.NOT_A_KIND}; {self.NOT_WHILE} T1 is assigned; correct it in rail.tsv by hand", f"D1 has kind 'chore', {self.NOT_A_KIND}; no command changes this cell; correct it in dishes.tsv by hand") self.assertEqual(len(out["status"].splitlines()), 3) self.assertEqual(out["ticket list"], "T1 assigned normal [user] s") self.assertEqual(out["close --dry-run"].splitlines()[4:], ["## In progress", "", "- D1 (T1): Fix s"]) self.assertNotIn("chore", "".join(out.values())) + def test_a_waiting_ticket_that_an_item_in_flight_names_is_warned_of_for_a_bad_kind_and_a_bad_goal_with_that_items_id_and_state(self): + self.fired_bug_fix() + self.brigade("ticket", "set", "T1", "--state", "waiting") + self.hand_edit("rail.tsv", "T1", kind="chore", goal="G9") + held = f"{self.NOT_WHILE} T1 is a ticket of D1, which is in-progress; correct it in rail.tsv by hand" + self.assert_each_printing_command_warns(f"T1 has kind 'chore', {self.NOT_A_KIND}; {held}", + f"T1 names goal 'G9', {self.NO_GOAL_ROW}; {held}") + + def copy_goal_row(self, ident, name): + """Append to goals.tsv a copy of its first row with this id and this name, the way an editor would.""" + path = self.at / "goals.tsv" + cells = path.read_text().splitlines()[1].split("\t") + cells[0], cells[3] = ident, name + path.write_text(path.read_text() + "\t".join(cells) + "\n") + + def test_a_goals_tsv_row_with_no_id_is_warned_of_is_not_listed_as_a_goal_and_adds_nothing_to_the_unassigned_counts(self): + self.two_goals() + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix") + self.brigade("ticket", "add", "--summary", "b", "--kind", "fix", "--goal", "G1") + self.copy_goal_row("", "no id") + out = self.assert_each_printing_command_warns("goals.tsv has a row with no id; it is not a goal; remove it from goals.tsv by hand") + self.assertEqual(out["status"].splitlines()[3:], ["kinds: fix 2 waiting", "goals: G1 1 waiting; unassigned 1 waiting"]) + self.assertEqual(out["goal list"].splitlines()[::3], + ["G1 open activity view: 1 waiting, 0 in flight, 0 merged, 0 send-backs", + "G2 open current with upstream: 0 waiting, 0 in flight, 0 merged, 0 send-backs", + "unassigned: 1 waiting, 0 in flight, 0 merged, 0 send-backs"]) + self.assertEqual(out["close --dry-run"].count("T1: a"), 1) + self.assertNotIn("no id", "".join(out.values())) + + def test_goal_set_refuses_an_empty_id_and_goal_add_gives_the_next_id_when_goals_tsv_has_a_row_with_no_id(self): + self.two_goals() + self.copy_goal_row("", "no id") + before = self.store_files() + self.assertEqual(self.brigade("goal", "set", "", "--name", "x", ok=False), "brigade: no goal ; run goal list --all to see every goal") + self.assertEqual(self.store_files(), before) + self.assertEqual(self.brigade("goal", "add", "--name", "third", "--purpose", "p", "--done", "d"), "G3") + + def test_a_goals_tsv_row_whose_id_an_earlier_row_has_is_warned_of_once_for_the_id_and_goal_list_prints_and_goal_set_changes_the_first_row(self): + self.two_goals() + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix", "--goal", "G1") + self.copy_goal_row("G1", "second") + self.copy_goal_row("G1", "third") + out = self.assert_each_printing_command_warns("goals.tsv has more than one row with id G1; the first is the goal and the " + "others are not read; remove the others from goals.tsv by hand") + self.assertEqual(out["goal list"].splitlines()[::3], + ["G1 open activity view: 1 waiting, 0 in flight, 0 merged, 0 send-backs", + "G2 open current with upstream: 0 waiting, 0 in flight, 0 merged, 0 send-backs", + self.NO_UNASSIGNED_WORK]) + for name in ("second", "third"): + self.assertNotIn(name, "".join(out.values())) + self.assertEqual(self.brigade("goal", "set", "G1", "--name", "renamed"), "G1 name renamed") + self.assertEqual([row[3] for row in self.rows_of("goals.tsv")], ["renamed", "current with upstream", "second", "third"]) + def test_a_ticket_and_an_item_that_name_a_goal_with_no_row_are_warned_of_and_count_as_unassigned(self): self.two_goals() self.brigade("ticket", "add", "--summary", "a", "--kind", "fix", "--goal", "G1") @@ -7538,7 +7624,7 @@ def test_with_goals_tsv_deleted_each_goal_cell_is_warned_of_its_row_counts_as_un self.brigade("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "Fix a") (self.at / "goals.tsv").unlink() out = self.assert_each_printing_command_warns( - f"T1 names goal 'G1', {self.NO_GOAL_ROW}; no command changes this cell; correct it in rail.tsv by hand", + f"T1 names goal 'G1', {self.NO_GOAL_ROW}; {self.NOT_WHILE} T1 is assigned; correct it in rail.tsv by hand", f"T2 names goal 'G1', {self.NO_GOAL_ROW}; run ticket set T2 --goal G<n> or --no-goal", f"D1 names goal 'G1', {self.NO_GOAL_ROW}; no command changes this cell; correct it in dishes.tsv by hand") self.assertEqual(out["status"].splitlines()[3:], ["kinds: fix 1 waiting, 1 in flight"]) From 13bb3b53d761aa866aac0489f20f0122642100c8 Mon Sep 17 00:00:00 2001 From: Marcus Gibson <marcusroygibson@gmail.com> Date: Sun, 11 Oct 2026 11:54:20 -0500 Subject: [PATCH 14/19] brigade: correct four statements that the refusal on a ticket an item in flight names made too broad --- skills/brigade/scripts/brigade.py | 10 ++++++---- t3/added/brigade/scripts/brigade.py | 10 ++++++---- tests/test_brigade.py | 13 ++++++++----- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/skills/brigade/scripts/brigade.py b/skills/brigade/scripts/brigade.py index f9ab344a..07baec7d 100644 --- a/skills/brigade/scripts/brigade.py +++ b/skills/brigade/scripts/brigade.py @@ -1005,7 +1005,8 @@ def add_goal(restaurant, name, purpose, done, share=""): def goal_work(counted, ident): """What keeps a goal from closing, as (words, advice) pairs. Empty when no ticket of the goal waits or rides along and no item is in flight.""" tally = counted.goals.get(ident, Tally()) - held = ((tally.waiting, "waiting", "give each waiting ticket another goal with ticket set --goal, or none with --no-goal"), + held = ((tally.waiting, "waiting", "give each waiting ticket another goal with ticket set --goal, or none with --no-goal; " + "a waiting ticket that an item in flight names keeps its goal until that item is merged or dropped"), (tally.in_flight, "in flight", "finish or drop each item in flight"), (counted.riding.get(ident, ()), "riding along", "finish or drop the item each of those tickets rides along in")) return [(f"{', '.join(ids)} {words}", advice) for ids, words, advice in held if ids] @@ -2697,7 +2698,7 @@ def fire_label(labels, rail, ids, station, kind, ride_along): mine = labels.tickets[lead] if lead else Label() if kind and mine.kind and kind != mine.kind: return None, (f"{lead} has kind {mine.kind}, and --kind {kind} does not match it; " - f"drop --kind, or run ticket set {lead} --kind {kind} first") + f"drop --kind, or run ticket set {lead} --kind {kind} first when no item in flight names {lead}") found = mine.kind or kind or STATION_KIND.get(station, "") if not found: return None, ((f"{lead} has no kind" if lead else "no ticket gives this item a kind") @@ -2724,7 +2725,7 @@ def fire_label(labels, rail, ids, station, kind, ride_along): def checked_fire_label(restaurant, ids, station, kind, ride_along, warned): """fire_label on the store as it is now, read under the caller's lock. - It prints the warning for each bad kind or goal cell of a ticket in `ids`, once per text in `warned`. + It prints the warning for each fault whose row id is in `ids`, once per text in `warned`. """ labels = read_labels(restaurant) for ident, text in labels.faults: @@ -4217,7 +4218,8 @@ def command(restaurant, args, contract=None, rails=None): raise BrigadeError(f"{frozen}; --kind, --goal, and --no-goal change a waiting ticket only{kept}") item = held[args.id]["id"] raise BrigadeError(f"{frozen}; --kind, --goal, and --no-goal change a waiting ticket that no item in flight names; " - f"{item} keeps the kind and goal it took when it started; drop {item} first, or leave {args.id} as it is") + f"{item} keeps the kind and goal it took when it started; drop {item}, and every other item in flight that names {args.id}, first, " + f"or leave {args.id} as it is") changes, said = {}, [] if args.state: changes["state"] = args.state diff --git a/t3/added/brigade/scripts/brigade.py b/t3/added/brigade/scripts/brigade.py index f9ab344a..07baec7d 100644 --- a/t3/added/brigade/scripts/brigade.py +++ b/t3/added/brigade/scripts/brigade.py @@ -1005,7 +1005,8 @@ def add_goal(restaurant, name, purpose, done, share=""): def goal_work(counted, ident): """What keeps a goal from closing, as (words, advice) pairs. Empty when no ticket of the goal waits or rides along and no item is in flight.""" tally = counted.goals.get(ident, Tally()) - held = ((tally.waiting, "waiting", "give each waiting ticket another goal with ticket set --goal, or none with --no-goal"), + held = ((tally.waiting, "waiting", "give each waiting ticket another goal with ticket set --goal, or none with --no-goal; " + "a waiting ticket that an item in flight names keeps its goal until that item is merged or dropped"), (tally.in_flight, "in flight", "finish or drop each item in flight"), (counted.riding.get(ident, ()), "riding along", "finish or drop the item each of those tickets rides along in")) return [(f"{', '.join(ids)} {words}", advice) for ids, words, advice in held if ids] @@ -2697,7 +2698,7 @@ def fire_label(labels, rail, ids, station, kind, ride_along): mine = labels.tickets[lead] if lead else Label() if kind and mine.kind and kind != mine.kind: return None, (f"{lead} has kind {mine.kind}, and --kind {kind} does not match it; " - f"drop --kind, or run ticket set {lead} --kind {kind} first") + f"drop --kind, or run ticket set {lead} --kind {kind} first when no item in flight names {lead}") found = mine.kind or kind or STATION_KIND.get(station, "") if not found: return None, ((f"{lead} has no kind" if lead else "no ticket gives this item a kind") @@ -2724,7 +2725,7 @@ def fire_label(labels, rail, ids, station, kind, ride_along): def checked_fire_label(restaurant, ids, station, kind, ride_along, warned): """fire_label on the store as it is now, read under the caller's lock. - It prints the warning for each bad kind or goal cell of a ticket in `ids`, once per text in `warned`. + It prints the warning for each fault whose row id is in `ids`, once per text in `warned`. """ labels = read_labels(restaurant) for ident, text in labels.faults: @@ -4217,7 +4218,8 @@ def command(restaurant, args, contract=None, rails=None): raise BrigadeError(f"{frozen}; --kind, --goal, and --no-goal change a waiting ticket only{kept}") item = held[args.id]["id"] raise BrigadeError(f"{frozen}; --kind, --goal, and --no-goal change a waiting ticket that no item in flight names; " - f"{item} keeps the kind and goal it took when it started; drop {item} first, or leave {args.id} as it is") + f"{item} keeps the kind and goal it took when it started; drop {item}, and every other item in flight that names {args.id}, first, " + f"or leave {args.id} as it is") changes, said = {}, [] if args.state: changes["state"] = args.state diff --git a/tests/test_brigade.py b/tests/test_brigade.py index 98c3d540..c31e168d 100644 --- a/tests/test_brigade.py +++ b/tests/test_brigade.py @@ -6480,7 +6480,7 @@ def test_ticket_set_kind_goal_and_no_goal_are_refused_on_a_waiting_ticket_that_a self.assertEqual(self.brigade("ticket", "set", ticket, *flags, ok=False), f"brigade: {ticket} is a ticket of D1, which is {state}; --kind, --goal, and --no-goal change " "a waiting ticket that no item in flight names; D1 keeps the kind and goal it took when it " - f"started; drop D1 first, or leave {ticket} as it is") + f"started; drop D1, and every other item in flight that names {ticket}, first, or leave {ticket} as it is") self.assertEqual(self.store_files(), before) self.assertEqual(self.brigade("ticket", "set", "T1", "--priority", "low"), "T1 priority low") @@ -6509,7 +6509,8 @@ def test_goal_set_refuses_done_and_dropped_while_a_ticket_of_the_goal_waits_and_ with self.subTest(state=state): self.assertEqual(self.brigade("goal", "set", "G1", "--state", state, "--evidence", "why", ok=False), "brigade: G1 still has work: T1, T3 waiting; give each waiting ticket another goal with " - "ticket set --goal, or none with --no-goal; then run this again") + "ticket set --goal, or none with --no-goal; a waiting ticket that an item in flight names keeps its goal until " + "that item is merged or dropped; then run this again") self.assertEqual(self.store_files(), before) self.brigade("ticket", "set", "T1", "--no-goal") self.brigade("ticket", "set", "T3", "--goal", "G2") @@ -6637,7 +6638,7 @@ def test_fire_kind_that_differs_from_the_lead_tickets_kind_is_refused_and_an_equ before = self.store_files() self.assertEqual(self.brigade("fire", "--tickets", "T1", "--station", "feature", "--summary", "Do a", "--kind", "feature", ok=False), "brigade: T1 has kind fix, and --kind feature does not match it; " - "drop --kind, or run ticket set T1 --kind feature first") + "drop --kind, or run ticket set T1 --kind feature first when no item in flight names T1") self.assertEqual(self.store_files(), before) self.assertEqual(self.brigade("fire", "--tickets", "T1", "--station", "feature", "--summary", "Do a", "--kind", "fix"), "D1") self.assertEqual(self.label("dishes.tsv", "D1"), ("fix", "")) @@ -6953,7 +6954,8 @@ def test_goal_set_refuses_done_and_dropped_while_a_ticket_of_the_goal_rides_alon self.brigade("fire", "--tickets", "T1,T2", "--ride-along", "T2", "--station", "bug-fix", "--summary", "Do both") self.assert_goal_keeps_open("G2", "T2 riding along", "finish or drop the item each of those tickets rides along in") self.assertEqual(self.brigade("dish", "D1", "--state", "dropped"), "D1 dropped; T1, T2 waiting again") - self.assert_goal_keeps_open("G2", "T2 waiting", "give each waiting ticket another goal with ticket set --goal, or none with --no-goal") + self.assert_goal_keeps_open("G2", "T2 waiting", "give each waiting ticket another goal with ticket set --goal, or none with --no-goal; a waiting " + "ticket that an item in flight names keeps its goal until that item is merged or dropped") def test_goal_set_names_the_waiting_tickets_the_items_in_flight_and_the_tickets_riding_along_in_one_refusal(self): self.two_goals() @@ -6965,7 +6967,8 @@ def test_goal_set_names_the_waiting_tickets_the_items_in_flight_and_the_tickets_ self.brigade("fire", "--tickets", "T3", "--station", "bug-fix", "--summary", "Do c") self.assert_goal_keeps_open( "G2", "T4 waiting; D2 in flight; T2 riding along", - "give each waiting ticket another goal with ticket set --goal, or none with --no-goal; finish or drop each item in flight; " + "give each waiting ticket another goal with ticket set --goal, or none with --no-goal; a waiting ticket that an item in " + "flight names keeps its goal until that item is merged or dropped; finish or drop each item in flight; " "finish or drop the item each of those tickets rides along in") def test_from_report_gives_each_ticket_the_kind_polish_and_the_goal_of_the_reports_item(self): From fd198dcdaa6112538e64cdd904a01eec5cf8f92f Mon Sep 17 00:00:00 2001 From: Marcus Gibson <marcusroygibson@gmail.com> Date: Sun, 11 Oct 2026 11:55:22 -0500 Subject: [PATCH 15/19] brigade: pass the kind and goal reader the end of its warning, not a three-way reason --- skills/brigade/scripts/brigade.py | 23 ++++++++++++++--------- t3/added/brigade/scripts/brigade.py | 23 ++++++++++++++--------- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/skills/brigade/scripts/brigade.py b/skills/brigade/scripts/brigade.py index 07baec7d..4af4b6f4 100644 --- a/skills/brigade/scripts/brigade.py +++ b/skills/brigade/scripts/brigade.py @@ -849,19 +849,20 @@ def frozen_label(ticket, held): return f"{ticket['id']} is a ticket of {item['id']}, which is {item['state']}" if item else "" -def read_label(table, row, goals, faults, frozen): - """One row's Label. `frozen` is frozen_label for a row of rail.tsv, and None for a row of dishes.tsv.""" +def read_label(row, goals, faults, by_hand): + """One row's Label. `by_hand` is the end of the warning for a bad cell of a row whose cells ticket set does not change now. + + It is "" for any other row, and the warning then ends with the ticket set line to run. + """ ident = row["id"] kind = row["kind"] if row["kind"] in KINDS else "" goal = row["goal"] if row["goal"] in goals else "" - by_hand = (f"no command changes this cell; correct it in {table} by hand" if frozen is None - else f"ticket set does not change this cell while {frozen}; correct it in {table} by hand") if row["kind"] and not kind: faults.append((ident, f"{ident} has kind {row['kind']!r}, which is not {one_of(KINDS)}; it reads as no kind; " - + (by_hand if frozen != "" else f"run ticket set {ident} --kind <kind>"))) + + (by_hand or f"run ticket set {ident} --kind <kind>"))) if row["goal"] and not goal: faults.append((ident, f"{ident} names goal {row['goal']!r}, which has no row in goals.tsv; it reads as unassigned; " - + (by_hand if frozen != "" else f"run ticket set {ident} --goal G<n> or --no-goal"))) + + (by_hand or f"run ticket set {ident} --goal G<n> or --no-goal"))) return Label(kind, goal) @@ -888,9 +889,13 @@ def read_labels(restaurant): repeated = (f"goals.tsv has more than one row with id {ident}; the first is the goal and the others are not read; " "remove the others from goals.tsv by hand") faults.append((ident, repeated if ident else "goals.tsv has a row with no id; it is not a goal; remove it from goals.tsv by hand")) - held = in_flight_tickets(restaurant) - tickets = {row["id"]: read_label("rail.tsv", row, goals, faults, frozen_label(row, held)) for row in restaurant.rows("rail.tsv")} - items = {row["id"]: read_label("dishes.tsv", row, goals, faults, None) for row in restaurant.rows("dishes.tsv")} + held, tickets = in_flight_tickets(restaurant), {} + for row in restaurant.rows("rail.tsv"): + frozen = frozen_label(row, held) + tickets[row["id"]] = read_label(row, goals, faults, frozen and f"ticket set does not change this cell while {frozen}; " + "correct it in rail.tsv by hand") + items = {row["id"]: read_label(row, goals, faults, "no command changes this cell; correct it in dishes.tsv by hand") + for row in restaurant.rows("dishes.tsv")} return Labels(goals, tickets, items, tuple(faults)) diff --git a/t3/added/brigade/scripts/brigade.py b/t3/added/brigade/scripts/brigade.py index 07baec7d..4af4b6f4 100644 --- a/t3/added/brigade/scripts/brigade.py +++ b/t3/added/brigade/scripts/brigade.py @@ -849,19 +849,20 @@ def frozen_label(ticket, held): return f"{ticket['id']} is a ticket of {item['id']}, which is {item['state']}" if item else "" -def read_label(table, row, goals, faults, frozen): - """One row's Label. `frozen` is frozen_label for a row of rail.tsv, and None for a row of dishes.tsv.""" +def read_label(row, goals, faults, by_hand): + """One row's Label. `by_hand` is the end of the warning for a bad cell of a row whose cells ticket set does not change now. + + It is "" for any other row, and the warning then ends with the ticket set line to run. + """ ident = row["id"] kind = row["kind"] if row["kind"] in KINDS else "" goal = row["goal"] if row["goal"] in goals else "" - by_hand = (f"no command changes this cell; correct it in {table} by hand" if frozen is None - else f"ticket set does not change this cell while {frozen}; correct it in {table} by hand") if row["kind"] and not kind: faults.append((ident, f"{ident} has kind {row['kind']!r}, which is not {one_of(KINDS)}; it reads as no kind; " - + (by_hand if frozen != "" else f"run ticket set {ident} --kind <kind>"))) + + (by_hand or f"run ticket set {ident} --kind <kind>"))) if row["goal"] and not goal: faults.append((ident, f"{ident} names goal {row['goal']!r}, which has no row in goals.tsv; it reads as unassigned; " - + (by_hand if frozen != "" else f"run ticket set {ident} --goal G<n> or --no-goal"))) + + (by_hand or f"run ticket set {ident} --goal G<n> or --no-goal"))) return Label(kind, goal) @@ -888,9 +889,13 @@ def read_labels(restaurant): repeated = (f"goals.tsv has more than one row with id {ident}; the first is the goal and the others are not read; " "remove the others from goals.tsv by hand") faults.append((ident, repeated if ident else "goals.tsv has a row with no id; it is not a goal; remove it from goals.tsv by hand")) - held = in_flight_tickets(restaurant) - tickets = {row["id"]: read_label("rail.tsv", row, goals, faults, frozen_label(row, held)) for row in restaurant.rows("rail.tsv")} - items = {row["id"]: read_label("dishes.tsv", row, goals, faults, None) for row in restaurant.rows("dishes.tsv")} + held, tickets = in_flight_tickets(restaurant), {} + for row in restaurant.rows("rail.tsv"): + frozen = frozen_label(row, held) + tickets[row["id"]] = read_label(row, goals, faults, frozen and f"ticket set does not change this cell while {frozen}; " + "correct it in rail.tsv by hand") + items = {row["id"]: read_label(row, goals, faults, "no command changes this cell; correct it in dishes.tsv by hand") + for row in restaurant.rows("dishes.tsv")} return Labels(goals, tickets, items, tuple(faults)) From 61e6a28c9efc9b8e3f704c3d61e121ec227eeb3f Mon Sep 17 00:00:00 2001 From: Marcus Gibson <marcusroygibson@gmail.com> Date: Sun, 11 Oct 2026 14:26:59 -0500 Subject: [PATCH 16/19] brigade: keep a hand-edited kind, goal, and goal id across a rewrite --- skills/brigade/scripts/brigade.py | 39 +++++++++-- t3/added/brigade/scripts/brigade.py | 39 +++++++++-- tests/test_brigade.py | 101 ++++++++++++++++++++++++++++ 3 files changed, 165 insertions(+), 14 deletions(-) diff --git a/skills/brigade/scripts/brigade.py b/skills/brigade/scripts/brigade.py index 4af4b6f4..a4db55c3 100644 --- a/skills/brigade/scripts/brigade.py +++ b/skills/brigade/scripts/brigade.py @@ -209,6 +209,22 @@ def clean(value): return re.sub(r"[\t\r\n]+", " ", str(value if value is not None else "")).strip() +# The cells save_rows and append write as they are given. read_labels decides whether each holds a legal value, and +# clean changes no legal value, so cleaning one could only make a cell that is not legal read as legal after a rewrite. +KEPT_CELLS = {"rail.tsv": ("kind", "goal"), "dishes.tsv": ("kind", "goal"), "goals.tsv": ("id",)} + + +def stored(table, key, value): + """The text save_rows and append write into one cell. + + A cell in KEPT_CELLS is written as it is given when it is text with no tab and no newline. That is every value a + table read gives. Every other value is written as clean gives it. + """ + if key in KEPT_CELLS.get(table, ()) and isinstance(value, str) and "\t" not in value and "\n" not in value: + return value + return clean(value) + + def write_atomic(path, text): path.parent.mkdir(parents=True, exist_ok=True) with tempfile.NamedTemporaryFile("w", dir=path.parent, delete=False, suffix=".tmp") as handle: @@ -433,11 +449,11 @@ def rows(self, table): def save_rows(self, table, rows): header = TABLES[table] - body = ["\t".join(header)] + ["\t".join(clean(row.get(key, "")) for key in header) for row in rows] + body = ["\t".join(header)] + ["\t".join(stored(table, key, row.get(key, "")) for key in header) for row in rows] self.write(table, "\n".join(body) + "\n") def append(self, table, row): - data = ("\t".join(clean(row.get(key, "")) for key in TABLES[table]) + "\n").encode() + data = ("\t".join(stored(table, key, row.get(key, "")) for key in TABLES[table]) + "\n").encode() path = self.dir / table with self.guarded(): if not path.exists(): @@ -870,8 +886,11 @@ def read_labels(restaurant): """The one reader of the kind and goal cells of rail.tsv and dishes.tsv, and of goals.tsv. It writes nothing. Call it under the store lock. A goals.tsv that does not exist reads as no goals. - A row of goals.tsv with an empty id is not a goal. Of the rows of goals.tsv with one id, the first is the goal. - The rows with an empty id add one pair to faults between them. So do the rows after the first with one id. + A row of goals.tsv with an empty id is not a goal. Neither is a row whose id clean changes, which is an id with white + space at an end or a carriage return in it. Of the other rows of goals.tsv with one id, the first is the goal. + The rows with an empty id add one pair to faults between them. So do the rows with one id that are not a goal. + A kind cell, a goal cell, and an id of goals.tsv are read as they are in the file, and stored writes each back as it + is, so a rewrite changes the reading of none that the command did not set. A cell whose value is not legal reads as follows and adds one pair to faults. - A kind that is not one of KINDS reads as no kind. @@ -881,14 +900,20 @@ def read_labels(restaurant): """ goals, faults, extra = {}, [], [] for row in restaurant.rows("goals.tsv"): - if row["id"] and row["id"] not in goals: + if row["id"] and clean(row["id"]) == row["id"] and row["id"] not in goals: goals[row["id"]] = read_goal(row, faults) elif row["id"] not in extra: extra.append(row["id"]) for ident in extra: - repeated = (f"goals.tsv has more than one row with id {ident}; the first is the goal and the others are not read; " + if not ident: + text = "goals.tsv has a row with no id; it is not a goal; remove it from goals.tsv by hand" + elif clean(ident) != ident: + text = (f"goals.tsv has a row with id {ident!r}, which has white space at an end or a carriage return in it; " + "it is not a goal; correct the id in goals.tsv by hand to an id no other row has, or remove the row") + else: + text = (f"goals.tsv has more than one row with id {ident}; the first is the goal and the others are not read; " "remove the others from goals.tsv by hand") - faults.append((ident, repeated if ident else "goals.tsv has a row with no id; it is not a goal; remove it from goals.tsv by hand")) + faults.append((ident, text)) held, tickets = in_flight_tickets(restaurant), {} for row in restaurant.rows("rail.tsv"): frozen = frozen_label(row, held) diff --git a/t3/added/brigade/scripts/brigade.py b/t3/added/brigade/scripts/brigade.py index 4af4b6f4..a4db55c3 100644 --- a/t3/added/brigade/scripts/brigade.py +++ b/t3/added/brigade/scripts/brigade.py @@ -209,6 +209,22 @@ def clean(value): return re.sub(r"[\t\r\n]+", " ", str(value if value is not None else "")).strip() +# The cells save_rows and append write as they are given. read_labels decides whether each holds a legal value, and +# clean changes no legal value, so cleaning one could only make a cell that is not legal read as legal after a rewrite. +KEPT_CELLS = {"rail.tsv": ("kind", "goal"), "dishes.tsv": ("kind", "goal"), "goals.tsv": ("id",)} + + +def stored(table, key, value): + """The text save_rows and append write into one cell. + + A cell in KEPT_CELLS is written as it is given when it is text with no tab and no newline. That is every value a + table read gives. Every other value is written as clean gives it. + """ + if key in KEPT_CELLS.get(table, ()) and isinstance(value, str) and "\t" not in value and "\n" not in value: + return value + return clean(value) + + def write_atomic(path, text): path.parent.mkdir(parents=True, exist_ok=True) with tempfile.NamedTemporaryFile("w", dir=path.parent, delete=False, suffix=".tmp") as handle: @@ -433,11 +449,11 @@ def rows(self, table): def save_rows(self, table, rows): header = TABLES[table] - body = ["\t".join(header)] + ["\t".join(clean(row.get(key, "")) for key in header) for row in rows] + body = ["\t".join(header)] + ["\t".join(stored(table, key, row.get(key, "")) for key in header) for row in rows] self.write(table, "\n".join(body) + "\n") def append(self, table, row): - data = ("\t".join(clean(row.get(key, "")) for key in TABLES[table]) + "\n").encode() + data = ("\t".join(stored(table, key, row.get(key, "")) for key in TABLES[table]) + "\n").encode() path = self.dir / table with self.guarded(): if not path.exists(): @@ -870,8 +886,11 @@ def read_labels(restaurant): """The one reader of the kind and goal cells of rail.tsv and dishes.tsv, and of goals.tsv. It writes nothing. Call it under the store lock. A goals.tsv that does not exist reads as no goals. - A row of goals.tsv with an empty id is not a goal. Of the rows of goals.tsv with one id, the first is the goal. - The rows with an empty id add one pair to faults between them. So do the rows after the first with one id. + A row of goals.tsv with an empty id is not a goal. Neither is a row whose id clean changes, which is an id with white + space at an end or a carriage return in it. Of the other rows of goals.tsv with one id, the first is the goal. + The rows with an empty id add one pair to faults between them. So do the rows with one id that are not a goal. + A kind cell, a goal cell, and an id of goals.tsv are read as they are in the file, and stored writes each back as it + is, so a rewrite changes the reading of none that the command did not set. A cell whose value is not legal reads as follows and adds one pair to faults. - A kind that is not one of KINDS reads as no kind. @@ -881,14 +900,20 @@ def read_labels(restaurant): """ goals, faults, extra = {}, [], [] for row in restaurant.rows("goals.tsv"): - if row["id"] and row["id"] not in goals: + if row["id"] and clean(row["id"]) == row["id"] and row["id"] not in goals: goals[row["id"]] = read_goal(row, faults) elif row["id"] not in extra: extra.append(row["id"]) for ident in extra: - repeated = (f"goals.tsv has more than one row with id {ident}; the first is the goal and the others are not read; " + if not ident: + text = "goals.tsv has a row with no id; it is not a goal; remove it from goals.tsv by hand" + elif clean(ident) != ident: + text = (f"goals.tsv has a row with id {ident!r}, which has white space at an end or a carriage return in it; " + "it is not a goal; correct the id in goals.tsv by hand to an id no other row has, or remove the row") + else: + text = (f"goals.tsv has more than one row with id {ident}; the first is the goal and the others are not read; " "remove the others from goals.tsv by hand") - faults.append((ident, repeated if ident else "goals.tsv has a row with no id; it is not a goal; remove it from goals.tsv by hand")) + faults.append((ident, text)) held, tickets = in_flight_tickets(restaurant), {} for row in restaurant.rows("rail.tsv"): frozen = frozen_label(row, held) diff --git a/tests/test_brigade.py b/tests/test_brigade.py index c31e168d..929884bb 100644 --- a/tests/test_brigade.py +++ b/tests/test_brigade.py @@ -6303,6 +6303,14 @@ def label(self, table, ident): row = self.table_row(self.at, table, ident) return row["kind"], row["goal"] + def raw_cells(self, table, ident, *keys): + """These cells of the row with this id, as the bytes in the file. A carriage return stays, which splitlines would cut.""" + header, *lines = (self.at / table).read_bytes().split(b"\n")[:-1] + names = header.decode().split("\t") + found = [row for row in (dict(zip(names, line.split(b"\t"))) for line in lines) if row["id"] == ident.encode()] + self.assertEqual(len(found), 1, f"{ident!r} in {table}") + return tuple(found[0][key] for key in keys) + def test_ticket_add_with_a_summary_and_no_kind_is_refused_and_changes_no_file(self): self.open() before = self.store_files() @@ -7598,6 +7606,99 @@ def test_a_goals_tsv_row_whose_id_an_earlier_row_has_is_warned_of_once_for_the_i self.assertEqual(self.brigade("goal", "set", "G1", "--name", "renamed"), "G1 name renamed") self.assertEqual([row[3] for row in self.rows_of("goals.tsv")], ["renamed", "current with upstream", "second", "third"]) + PADDED_GOAL_ROW = ("goals.tsv has a row with id ' G1 ', which has white space at an end or a carriage return in it; " + "it is not a goal; correct the id in goals.tsv by hand to an id no other row has, or remove the row") + + def test_a_goals_tsv_row_whose_id_has_white_space_at_an_end_is_warned_of_by_each_printing_command_and_is_not_listed_as_a_goal(self): + self.two_goals() + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix", "--goal", "G1") + self.hand_edit("goals.tsv", "G1", id=" G1 ") + out = self.assert_each_printing_command_warns( + self.PADDED_GOAL_ROW, f"T1 names goal 'G1', {self.NO_GOAL_ROW}; run ticket set T1 --goal G<n> or --no-goal") + self.assertEqual(out["status"].splitlines()[3:], ["kinds: fix 1 waiting", "goals: unassigned 1 waiting"]) + self.assertEqual(out["goal list"].splitlines()[::3], + ["G2 open current with upstream: 0 waiting, 0 in flight, 0 merged, 0 send-backs", + "unassigned: 1 waiting, 0 in flight, 0 merged, 0 send-backs"]) + self.assertEqual(out["ticket list"], "T1 waiting normal fix [user] a") + self.assertNotIn("activity view", "".join(out.values())) + self.assertEqual(self.raw_cells("goals.tsv", " G1 ", "id"), (b" G1 ",)) + + def test_fire_of_a_ticket_whose_goal_cell_is_a_padded_id_warns_starts_an_unassigned_item_and_leaves_the_cell_as_it_was(self): + self.two_goals() + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix", "--goal", "G1") + self.hand_edit("rail.tsv", "T1", goal=" G1 ") + names = f"brigade: warning: T1 names goal ' G1 ', {self.NO_GOAL_ROW}; " + self.assertEqual(self.run_raw("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "Do a"), + (0, "D1\n", names + "run ticket set T1 --goal G<n> or --no-goal\n")) + self.assertEqual(self.raw_cells("dishes.tsv", "D1", "kind", "goal"), (b"fix", b"")) + self.assertEqual(self.raw_cells("rail.tsv", "T1", "state", "kind", "goal"), (b"assigned", b"fix", b" G1 ")) + code, out, err = self.run_raw("status") + self.assertEqual((code, err), (0, names + f"{self.NOT_WHILE} T1 is assigned; correct it in rail.tsv by hand\n")) + self.assertEqual(out.splitlines()[3:], ["kinds: fix 1 in flight", "goals: unassigned 1 in flight"]) + + def test_fire_ride_along_of_a_low_ticket_whose_kind_cell_is_a_padded_feature_warns_and_leaves_the_cell_as_it_was(self): + self.open() + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix") + self.brigade("ticket", "add", "--summary", "b", "--kind", "feature", "--priority", "low") + self.hand_edit("rail.tsv", "T2", kind=" feature ") + has = f"brigade: warning: T2 has kind ' feature ', {self.NOT_A_KIND}; " + self.assertEqual(self.run_raw("fire", "--tickets", "T1,T2", "--station", "bug-fix", "--summary", "Do both", "--ride-along", "T2"), + (0, "D1\n", has + "run ticket set T2 --kind <kind>\n")) + self.assertEqual(self.raw_cells("rail.tsv", "T2", "state", "kind", "goal"), (b"assigned", b" feature ", b"")) + code, out, err = self.run_raw("ticket", "list") + self.assertEqual((code, err), (0, has + f"{self.NOT_WHILE} T2 is assigned; correct it in rail.tsv by hand\n")) + self.assertEqual(out, "T1 assigned normal fix [user] a\nT2 assigned low [user] b\n") + + def test_a_rewrite_of_its_table_leaves_each_kept_cell_byte_for_byte_and_the_warning_for_it_still_prints(self): + self.two_goals() + for summary in ("a", "b", "c", "d"): + self.brigade("ticket", "add", "--summary", summary, "--kind", "fix", "--goal", "G1") + self.brigade("fire", "--tickets", "T3", "--station", "bug-fix", "--summary", "Do c") + self.brigade("fire", "--tickets", "T4", "--station", "bug-fix", "--summary", "Do d") + # Per table, the row whose cell is padded, and a command that rewrites the table to change another row. + rewrites = {"rail.tsv": ("T1", lambda cell: ("ticket", "set", "T2", "--paths", f"docs/{cell}")), + "dishes.tsv": ("D1", lambda cell: ("dish", "D2", "--thread", f"thread-{cell}")), + "goals.tsv": ("G1", lambda cell: ("goal", "set", "G2", "--name", f"renamed {cell}"))} + kept = runpy.run_path(str(SCRIPT))["KEPT_CELLS"] + self.assertEqual(sorted(kept), sorted(rewrites)) + for table, cells in kept.items(): + ident, rewrite = rewrites[table] + for cell in cells: + with self.subTest(table=table, cell=cell): + legal, = self.raw_cells(table, ident, cell) + self.assertTrue(legal) + padded = b" " + legal + b" " + self.hand_edit(table, ident, **{cell: padded.decode()}) + held = padded.decode() if cell == "id" else ident + warned = self.run_raw("status") + self.assertEqual(warned[0], 0) + self.assertIn(repr(padded.decode()), warned[2]) + before = (self.at / table).read_bytes() + self.assertEqual(self.run_raw(*rewrite(cell))[0], 0) + self.assertNotEqual((self.at / table).read_bytes(), before) + self.assertEqual(self.raw_cells(table, held, cell), (padded,)) + self.assertEqual(self.run_raw("status"), warned) + self.hand_edit(table, held, **{cell: legal.decode()}) + self.assertEqual(self.run_raw("status")[2], "") + + def test_a_goal_cell_that_ends_in_a_carriage_return_still_reads_as_unassigned_after_another_ticket_is_rewritten(self): + self.two_goals() + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix", "--goal", "G1") + self.brigade("ticket", "add", "--summary", "b", "--kind", "fix") + rail = self.at / "rail.tsv" + self.assertEqual(rail.read_bytes().count(b"\tG1\n"), 1) + rail.write_bytes(rail.read_bytes().replace(b"\tG1\n", b"\tG1\r\n")) + warning = (f"brigade: warning: T1 names goal 'G1\\r', {self.NO_GOAL_ROW}; " + "run ticket set T1 --goal G<n> or --no-goal\n") + unassigned = ["kinds: fix 2 waiting", "goals: unassigned 2 waiting"] + code, out, err = self.run_raw("status") + self.assertEqual((code, err, out.splitlines()[3:]), (0, warning, unassigned)) + self.assertEqual(self.run_raw("ticket", "set", "T2", "--priority", "low"), (0, "T2 priority low\n", "")) + self.assertEqual(self.raw_cells("rail.tsv", "T2", "priority"), (b"low",)) + self.assertEqual(self.raw_cells("rail.tsv", "T1", "kind", "goal"), (b"fix", b"G1\r")) + code, out, err = self.run_raw("status") + self.assertEqual((code, err, out.splitlines()[3:]), (0, warning, unassigned)) + def test_a_ticket_and_an_item_that_name_a_goal_with_no_row_are_warned_of_and_count_as_unassigned(self): self.two_goals() self.brigade("ticket", "add", "--summary", "a", "--kind", "fix", "--goal", "G1") From 991342cc44a0bc22b8555b10cbd3f7ecdfd7fe80 Mon Sep 17 00:00:00 2001 From: Marcus Gibson <marcusroygibson@gmail.com> Date: Sun, 11 Oct 2026 14:33:24 -0500 Subject: [PATCH 17/19] brigade: print kind and goal warnings on every command that acts on them --- skills/brigade/scripts/brigade.py | 138 ++++++++++++++---------- t3/added/brigade/scripts/brigade.py | 138 ++++++++++++++---------- tests/test_brigade.py | 161 +++++++++++++++++++++++++--- 3 files changed, 315 insertions(+), 122 deletions(-) diff --git a/skills/brigade/scripts/brigade.py b/skills/brigade/scripts/brigade.py index a4db55c3..38b35d54 100644 --- a/skills/brigade/scripts/brigade.py +++ b/skills/brigade/scripts/brigade.py @@ -290,6 +290,14 @@ def __init__(self, directory, owner=None): self.unfenced = False self._lock_fd = None self._lock_depth = 0 + # The warning texts this command has printed. One command makes one Restaurant for its own store. + self.warned = set() + + def warn(self, text): + """Print one warning line on standard error. A text this command has printed is not printed again.""" + if text not in self.warned: + self.warned.add(text) + print(f"brigade: warning: {text}", file=sys.stderr) def fence(self): """Refuse a write from any thread but the recorded owner. Call it under the lock and keep the lock through the write.""" @@ -812,12 +820,41 @@ class Label: goal: str = "" +@dataclass(frozen=True) +class Fault: + """One warning of read_labels. table is the table the row is in, id is the row's id cell, and text is the warning.""" + table: str + id: str + text: str + + +@dataclass(frozen=True) +class ActsOn: + """The rows of rail.tsv and dishes.tsv whose kind and goal a command acts on. + + read_labels prints the warnings of these rows. tickets holds ticket ids and items holds item ids. + every is true for a command that lists or counts every row. + """ + tickets: tuple = () + items: tuple = () + every: bool = False + + def covers(self, fault): + """True when read_labels prints this fault. Every fault of goals.tsv is printed.""" + return (fault.table == "goals.tsv" or self.every + or fault.id in {"rail.tsv": self.tickets, "dishes.tsv": self.items}[fault.table]) + + +EVERY_ROW = ActsOn(every=True) +GOALS_ONLY = ActsOn() + + @dataclass(frozen=True) class Labels: """The kinds and goals of one store, read under one hold of the store lock. goals is {goal id: Goal} in file order. tickets is {ticket id: Label} for the rows of rail.tsv, and items is - {item id: Label} for the rows of dishes.tsv. faults is one (row id, text) pair per cell whose value is not legal, + {item id: Label} for the rows of dishes.tsv. faults is one Fault per cell whose value is not legal, and one per id of goals.tsv with a row that is not a goal. """ goals: dict @@ -834,12 +871,12 @@ def whole_share(text): def read_goal(row, faults): state, share = row["state"], whole_share(row["share"]) if state not in GOAL_STATES: - faults.append((row["id"], f"{row['id']} has state {state!r}, which is not {one_of(GOAL_STATES)}; it reads as open; " - f"run goal set {row['id']} --state <state>")) + faults.append(Fault("goals.tsv", row["id"], f"{row['id']} has state {state!r}, which is not {one_of(GOAL_STATES)}; " + f"it reads as open; run goal set {row['id']} --state <state>")) state = "open" if row["share"] and not share: - faults.append((row["id"], f"{row['id']} has share {row['share']!r}, which is not a whole number of 1 or more; " - f"it reads as no limit; run goal set {row['id']} --share <n>")) + faults.append(Fault("goals.tsv", row["id"], f"{row['id']} has share {row['share']!r}, which is not a whole number of " + f"1 or more; it reads as no limit; run goal set {row['id']} --share <n>")) return Goal(row["id"], state, row["name"], row["purpose"], row["done"], share, row["closed"]) @@ -865,7 +902,7 @@ def frozen_label(ticket, held): return f"{ticket['id']} is a ticket of {item['id']}, which is {item['state']}" if item else "" -def read_label(row, goals, faults, by_hand): +def read_label(table, row, goals, faults, by_hand): """One row's Label. `by_hand` is the end of the warning for a bad cell of a row whose cells ticket set does not change now. It is "" for any other row, and the warning then ends with the ticket set line to run. @@ -874,24 +911,26 @@ def read_label(row, goals, faults, by_hand): kind = row["kind"] if row["kind"] in KINDS else "" goal = row["goal"] if row["goal"] in goals else "" if row["kind"] and not kind: - faults.append((ident, f"{ident} has kind {row['kind']!r}, which is not {one_of(KINDS)}; it reads as no kind; " - + (by_hand or f"run ticket set {ident} --kind <kind>"))) + faults.append(Fault(table, ident, f"{ident} has kind {row['kind']!r}, which is not {one_of(KINDS)}; it reads as no kind; " + + (by_hand or f"run ticket set {ident} --kind <kind>"))) if row["goal"] and not goal: - faults.append((ident, f"{ident} names goal {row['goal']!r}, which has no row in goals.tsv; it reads as unassigned; " - + (by_hand or f"run ticket set {ident} --goal G<n> or --no-goal"))) + faults.append(Fault(table, ident, f"{ident} names goal {row['goal']!r}, which has no row in goals.tsv; it reads as unassigned; " + + (by_hand or f"run ticket set {ident} --goal G<n> or --no-goal"))) return Label(kind, goal) -def read_labels(restaurant): - """The one reader of the kind and goal cells of rail.tsv and dishes.tsv, and of goals.tsv. It writes nothing. +def read_labels(restaurant, acts_on): + """The one reader of the kind and goal cells of rail.tsv and dishes.tsv, and of goals.tsv. It writes nothing to the store. + It prints one warning line on standard error for each fault of goals.tsv and for each fault of a row in `acts_on`. + A command prints a warning once, however many times it reads. Call it under the store lock. A goals.tsv that does not exist reads as no goals. A row of goals.tsv with an empty id is not a goal. Neither is a row whose id clean changes, which is an id with white space at an end or a carriage return in it. Of the other rows of goals.tsv with one id, the first is the goal. - The rows with an empty id add one pair to faults between them. So do the rows with one id that are not a goal. + The rows with an empty id add one Fault between them. So do the rows with one id that are not a goal. A kind cell, a goal cell, and an id of goals.tsv are read as they are in the file, and stored writes each back as it is, so a rewrite changes the reading of none that the command did not set. - A cell whose value is not legal reads as follows and adds one pair to faults. + A cell whose value is not legal reads as follows and adds one Fault. - A kind that is not one of KINDS reads as no kind. - A goal that is not the id of a goal reads as unassigned. @@ -913,25 +952,20 @@ def read_labels(restaurant): else: text = (f"goals.tsv has more than one row with id {ident}; the first is the goal and the others are not read; " "remove the others from goals.tsv by hand") - faults.append((ident, text)) + faults.append(Fault("goals.tsv", ident, text)) held, tickets = in_flight_tickets(restaurant), {} for row in restaurant.rows("rail.tsv"): frozen = frozen_label(row, held) - tickets[row["id"]] = read_label(row, goals, faults, frozen and f"ticket set does not change this cell while {frozen}; " - "correct it in rail.tsv by hand") - items = {row["id"]: read_label(row, goals, faults, "no command changes this cell; correct it in dishes.tsv by hand") + tickets[row["id"]] = read_label("rail.tsv", row, goals, faults, + frozen and f"ticket set does not change this cell while {frozen}; correct it in rail.tsv by hand") + items = {row["id"]: read_label("dishes.tsv", row, goals, faults, "no command changes this cell; correct it in dishes.tsv by hand") for row in restaurant.rows("dishes.tsv")} + for fault in faults: + if acts_on.covers(fault): + restaurant.warn(fault.text) return Labels(goals, tickets, items, tuple(faults)) -def shown_labels(restaurant): - """read_labels, and one warning line on standard error for each of its faults.""" - labels = read_labels(restaurant) - for _, text in labels.faults: - print(f"brigade: warning: {text}", file=sys.stderr) - return labels - - @dataclass(frozen=True) class Tally: """The work under one kind or one goal. waiting holds ticket ids. in_flight and merged hold item ids.""" @@ -1022,7 +1056,7 @@ def add_goal(restaurant, name, purpose, done, share=""): name, purpose, done, share = clean(name), clean(purpose), clean(done), goal_share(share) if not (name and purpose and done): raise BrigadeError("goal add needs a nonempty --name, --purpose, and --done") - for goal in read_labels(restaurant).goals.values(): + for goal in read_labels(restaurant, GOALS_ONLY).goals.values(): if goal.state not in CLOSED_GOAL_STATES and same_text(goal.name) == same_text(name): raise BrigadeError(f"same name as {goal.id} ({goal.state}); nothing added") ident = restaurant.next_id("goals.tsv") @@ -1050,7 +1084,7 @@ def set_goal(restaurant, ident, name=None, purpose=None, done=None, share=None, A move to done or dropped needs --evidence, and is refused while goal_work finds work under the goal. One update call writes every change. It logs a goal row when the state changed. """ - labels = read_labels(restaurant) + labels = read_labels(restaurant, EVERY_ROW if state in CLOSED_GOAL_STATES else GOALS_ONLY) goal = labels.goals.get(ident) if goal is None: raise BrigadeError(f"no goal {ident}; run goal list --all to see every goal") @@ -1195,7 +1229,7 @@ def add_ticket(restaurant, summary, source, ref, request="", rails=None, again=F if not kind: raise BrigadeError(f"ticket add needs --kind {KIND_CHOICE}") if goal is not None: - open_goal(read_labels(restaurant), goal) + open_goal(read_labels(restaurant, GOALS_ONLY), goal) return append_ticket(restaurant, summary, source, ref, priority, paths, decision, kind, goal or "") @@ -1698,7 +1732,7 @@ def file_follow_ups(restaurant, name, found, write, tracked): Each ticket takes the goal of the report's item, whatever that goal's state, and follow_up_kind of that item's kind. """ restaurant.find("dishes.tsv", name[:-3]) - label = read_labels(restaurant).items[name[:-3]] + label = read_labels(restaurant, ActsOn(items=(name[:-3],))).items[name[:-3]] kind = follow_up_kind(label.kind, priority_of({"priority": "", "source": "report"})) if found is None: return f"reports/{name} has no follow-ups section; nothing added" @@ -1760,7 +1794,7 @@ def move_ticket(restaurant, ident, to, rails): if target_rows is None: target_rows = Restaurant(target).rows("rail.tsv") # The kind goes with the ticket. The goal stays, because a goal id names a row of this store's goals.tsv only. - label = read_labels(restaurant).tickets[ident] + label = read_labels(restaurant, ActsOn(tickets=(ident,))).tickets[ident] if taken_row(target_rows, handoff) is None: restaurant.publish(inbox_file(target, handoff), json.dumps({ "handoff": handoff, "summary": row["summary"], "source": base_source(row["source"]), "ref": row["ref"], @@ -1780,23 +1814,22 @@ def take_tickets(restaurant): raise BrigadeError(f"{path} is not valid JSON: {error}") from error source = handoff["handoff"] row = taken_row(restaurant.rows("rail.tsv"), source) - unknown = "" if row is None: rows = restaurant.rows("rail.tsv") - kind = handoff.get("kind") or "" - if kind not in KINDS: - unknown, kind = clean(kind), "" + given = handoff.get("kind") or "" + kind = given if given in KINDS else "" row = {"id": restaurant.next_id("rail.tsv"), "at": now(), "state": "waiting", "source": f"{handoff['source']} (from {source})", "ref": handoff["ref"], "summary": handoff["summary"], **{key: handoff.get(key, "") for key in ("priority", "paths", "decision")}, "kind": kind} + if given and not kind: + # The warning comes before the write, so a take killed after the write has printed it. + restaurant.warn(f"{row['id']} came from {source} with kind {given!r}, which is not one this version knows, " + f"so it has no kind; run ticket set {row['id']} --kind {KIND_CHOICE}") restaurant.save_rows("rail.tsv", rows + [row]) - if unknown: - unknown = (f"; kind {unknown} is not one this version knows, so it has no kind; " - f"run ticket set {row['id']} --kind {KIND_CHOICE}") if not any(event["kind"] == "ticket" and event["note"] == f"from {source}" for event in restaurant.rows("log.tsv")): restaurant.log("ticket", row["id"], "waiting", f"from {source}") restaurant.remove(path) - lines.append(f"{row['id']} from {source}: {row['summary']}{unknown}") + lines.append(f"{row['id']} from {source}: {row['summary']}") return lines @@ -2297,7 +2330,7 @@ def report(restaurant, write=True): if title: sections[title].append(event) dishes = {row["id"]: row for row in restaurant.rows("dishes.tsv")} - labels = shown_labels(restaurant) + labels = read_labels(restaurant, EVERY_ROW) stamp = now() lines = [f"# {meta['restaurant']} report", "", f"Since {since or 'opening'}. {status_line(restaurant)}.", ""] for title, items in sections.items(): @@ -2678,7 +2711,7 @@ def startable_lines(standings, meta, running, now): def startable(restaurant): with restaurant.checked(): - meta, labels = restaurant.meta, shown_labels(restaurant) + meta, labels = restaurant.meta, read_labels(restaurant, EVERY_ROW) tickets = [waiting_ticket(row, labels.tickets[row["id"]]) for row in restaurant.rows("rail.tsv") if row["state"] == "waiting"] running, next_dish = running_workers(restaurant), restaurant.next_id("dishes.tsv") refusals = lease_refusals(meta, tickets, next_dish) @@ -2687,7 +2720,7 @@ def startable(restaurant): def ticket_lines(restaurant, state, held): - labels, lines = shown_labels(restaurant), [] + labels, lines = read_labels(restaurant, EVERY_ROW), [] for row in restaurant.rows("rail.tsv"): if state and row["state"] != state: continue @@ -2752,16 +2785,12 @@ def fire_label(labels, rail, ids, station, kind, ride_along): return Label(found, mine.goal), None -def checked_fire_label(restaurant, ids, station, kind, ride_along, warned): +def checked_fire_label(restaurant, ids, station, kind, ride_along): """fire_label on the store as it is now, read under the caller's lock. - It prints the warning for each fault whose row id is in `ids`, once per text in `warned`. + read_labels prints the warnings of goals.tsv and of the tickets in `ids`. """ - labels = read_labels(restaurant) - for ident, text in labels.faults: - if ident in ids and text not in warned: - warned.add(text) - print(f"brigade: warning: {text}", file=sys.stderr) + labels = read_labels(restaurant, ActsOn(tickets=tuple(ids))) rail = {row["id"]: row for row in restaurant.rows("rail.tsv")} return fire_label(labels, rail, ids, station, kind, ride_along) @@ -2775,7 +2804,6 @@ def fire(restaurant, ids, station, task, thread, branch, summary, timebox, paths The lead ticket's kind cell takes the item's kind in the rewrite that assigns the tickets. No other kind or goal cell of a ticket changes, so a ticket that rides along keeps its own. """ - warned = set() while True: with restaurant.checked(): restaurant.fence() @@ -2789,7 +2817,7 @@ def fire(restaurant, ids, station, task, thread, branch, summary, timebox, paths if full: record_blocked(restaurant, ids, "workers", station, summary, paths, timebox, branch, reason, kind, ride_along) raise BrigadeError(f"nothing fired: {full}") - _, refusal = checked_fire_label(restaurant, ids, station, kind, ride_along, warned) + _, refusal = checked_fire_label(restaurant, ids, station, kind, ride_along) if refusal: raise BrigadeError(refusal) # land.py can wait on the landing database, so the claim runs outside the store lock. @@ -2807,7 +2835,7 @@ def fire(restaurant, ids, station, task, thread, branch, summary, timebox, paths full = None if refusal else workers_full(restaurant) label = None if not refusal and not full: - label, refusal = checked_fire_label(restaurant, ids, station, kind, ride_along, warned) + label, refusal = checked_fire_label(restaurant, ids, station, kind, ride_along) if label and current == dish: restaurant.append("dishes.tsv", {"id": dish, "at": now(), "state": "in-progress", "station": station, "tickets": ",".join(ids), "task": task, "thread": thread, @@ -4224,7 +4252,7 @@ def command(restaurant, args, contract=None, rails=None): return add_goal(restaurant, args.name, args.purpose, args.done, args.share) if args.action == "set": return set_goal(restaurant, args.id, args.name, args.purpose, args.done, args.share, args.state, args.evidence) - labels = shown_labels(restaurant) + labels = read_labels(restaurant, EVERY_ROW) return goal_lines(labels, count_labels(restaurant, labels), args.all) if args.command == "ticket": @@ -4267,7 +4295,7 @@ def command(restaurant, args, contract=None, rails=None): changes["kind"] = args.kind said.append(f"kind {args.kind}") if args.goal is not None: - changes["goal"] = open_goal(read_labels(restaurant), args.goal).id + changes["goal"] = open_goal(read_labels(restaurant, GOALS_ONLY), args.goal).id said.append(f"goal {args.goal}") if args.no_goal: changes["goal"] = "" @@ -4406,7 +4434,7 @@ def command(restaurant, args, contract=None, rails=None): level if counts_text == "nothing on record" else f"{level}, {counts_text}"] if not is_admin(meta): lines.append(workers_line(meta, running_workers(restaurant))) - labels = shown_labels(restaurant) + labels = read_labels(restaurant, EVERY_ROW) lines += status_label_lines(labels, count_labels(restaurant, labels)) if meta.get("reportsTo"): lines.append(f"reports to {meta['reportsTo']}") diff --git a/t3/added/brigade/scripts/brigade.py b/t3/added/brigade/scripts/brigade.py index a4db55c3..38b35d54 100644 --- a/t3/added/brigade/scripts/brigade.py +++ b/t3/added/brigade/scripts/brigade.py @@ -290,6 +290,14 @@ def __init__(self, directory, owner=None): self.unfenced = False self._lock_fd = None self._lock_depth = 0 + # The warning texts this command has printed. One command makes one Restaurant for its own store. + self.warned = set() + + def warn(self, text): + """Print one warning line on standard error. A text this command has printed is not printed again.""" + if text not in self.warned: + self.warned.add(text) + print(f"brigade: warning: {text}", file=sys.stderr) def fence(self): """Refuse a write from any thread but the recorded owner. Call it under the lock and keep the lock through the write.""" @@ -812,12 +820,41 @@ class Label: goal: str = "" +@dataclass(frozen=True) +class Fault: + """One warning of read_labels. table is the table the row is in, id is the row's id cell, and text is the warning.""" + table: str + id: str + text: str + + +@dataclass(frozen=True) +class ActsOn: + """The rows of rail.tsv and dishes.tsv whose kind and goal a command acts on. + + read_labels prints the warnings of these rows. tickets holds ticket ids and items holds item ids. + every is true for a command that lists or counts every row. + """ + tickets: tuple = () + items: tuple = () + every: bool = False + + def covers(self, fault): + """True when read_labels prints this fault. Every fault of goals.tsv is printed.""" + return (fault.table == "goals.tsv" or self.every + or fault.id in {"rail.tsv": self.tickets, "dishes.tsv": self.items}[fault.table]) + + +EVERY_ROW = ActsOn(every=True) +GOALS_ONLY = ActsOn() + + @dataclass(frozen=True) class Labels: """The kinds and goals of one store, read under one hold of the store lock. goals is {goal id: Goal} in file order. tickets is {ticket id: Label} for the rows of rail.tsv, and items is - {item id: Label} for the rows of dishes.tsv. faults is one (row id, text) pair per cell whose value is not legal, + {item id: Label} for the rows of dishes.tsv. faults is one Fault per cell whose value is not legal, and one per id of goals.tsv with a row that is not a goal. """ goals: dict @@ -834,12 +871,12 @@ def whole_share(text): def read_goal(row, faults): state, share = row["state"], whole_share(row["share"]) if state not in GOAL_STATES: - faults.append((row["id"], f"{row['id']} has state {state!r}, which is not {one_of(GOAL_STATES)}; it reads as open; " - f"run goal set {row['id']} --state <state>")) + faults.append(Fault("goals.tsv", row["id"], f"{row['id']} has state {state!r}, which is not {one_of(GOAL_STATES)}; " + f"it reads as open; run goal set {row['id']} --state <state>")) state = "open" if row["share"] and not share: - faults.append((row["id"], f"{row['id']} has share {row['share']!r}, which is not a whole number of 1 or more; " - f"it reads as no limit; run goal set {row['id']} --share <n>")) + faults.append(Fault("goals.tsv", row["id"], f"{row['id']} has share {row['share']!r}, which is not a whole number of " + f"1 or more; it reads as no limit; run goal set {row['id']} --share <n>")) return Goal(row["id"], state, row["name"], row["purpose"], row["done"], share, row["closed"]) @@ -865,7 +902,7 @@ def frozen_label(ticket, held): return f"{ticket['id']} is a ticket of {item['id']}, which is {item['state']}" if item else "" -def read_label(row, goals, faults, by_hand): +def read_label(table, row, goals, faults, by_hand): """One row's Label. `by_hand` is the end of the warning for a bad cell of a row whose cells ticket set does not change now. It is "" for any other row, and the warning then ends with the ticket set line to run. @@ -874,24 +911,26 @@ def read_label(row, goals, faults, by_hand): kind = row["kind"] if row["kind"] in KINDS else "" goal = row["goal"] if row["goal"] in goals else "" if row["kind"] and not kind: - faults.append((ident, f"{ident} has kind {row['kind']!r}, which is not {one_of(KINDS)}; it reads as no kind; " - + (by_hand or f"run ticket set {ident} --kind <kind>"))) + faults.append(Fault(table, ident, f"{ident} has kind {row['kind']!r}, which is not {one_of(KINDS)}; it reads as no kind; " + + (by_hand or f"run ticket set {ident} --kind <kind>"))) if row["goal"] and not goal: - faults.append((ident, f"{ident} names goal {row['goal']!r}, which has no row in goals.tsv; it reads as unassigned; " - + (by_hand or f"run ticket set {ident} --goal G<n> or --no-goal"))) + faults.append(Fault(table, ident, f"{ident} names goal {row['goal']!r}, which has no row in goals.tsv; it reads as unassigned; " + + (by_hand or f"run ticket set {ident} --goal G<n> or --no-goal"))) return Label(kind, goal) -def read_labels(restaurant): - """The one reader of the kind and goal cells of rail.tsv and dishes.tsv, and of goals.tsv. It writes nothing. +def read_labels(restaurant, acts_on): + """The one reader of the kind and goal cells of rail.tsv and dishes.tsv, and of goals.tsv. It writes nothing to the store. + It prints one warning line on standard error for each fault of goals.tsv and for each fault of a row in `acts_on`. + A command prints a warning once, however many times it reads. Call it under the store lock. A goals.tsv that does not exist reads as no goals. A row of goals.tsv with an empty id is not a goal. Neither is a row whose id clean changes, which is an id with white space at an end or a carriage return in it. Of the other rows of goals.tsv with one id, the first is the goal. - The rows with an empty id add one pair to faults between them. So do the rows with one id that are not a goal. + The rows with an empty id add one Fault between them. So do the rows with one id that are not a goal. A kind cell, a goal cell, and an id of goals.tsv are read as they are in the file, and stored writes each back as it is, so a rewrite changes the reading of none that the command did not set. - A cell whose value is not legal reads as follows and adds one pair to faults. + A cell whose value is not legal reads as follows and adds one Fault. - A kind that is not one of KINDS reads as no kind. - A goal that is not the id of a goal reads as unassigned. @@ -913,25 +952,20 @@ def read_labels(restaurant): else: text = (f"goals.tsv has more than one row with id {ident}; the first is the goal and the others are not read; " "remove the others from goals.tsv by hand") - faults.append((ident, text)) + faults.append(Fault("goals.tsv", ident, text)) held, tickets = in_flight_tickets(restaurant), {} for row in restaurant.rows("rail.tsv"): frozen = frozen_label(row, held) - tickets[row["id"]] = read_label(row, goals, faults, frozen and f"ticket set does not change this cell while {frozen}; " - "correct it in rail.tsv by hand") - items = {row["id"]: read_label(row, goals, faults, "no command changes this cell; correct it in dishes.tsv by hand") + tickets[row["id"]] = read_label("rail.tsv", row, goals, faults, + frozen and f"ticket set does not change this cell while {frozen}; correct it in rail.tsv by hand") + items = {row["id"]: read_label("dishes.tsv", row, goals, faults, "no command changes this cell; correct it in dishes.tsv by hand") for row in restaurant.rows("dishes.tsv")} + for fault in faults: + if acts_on.covers(fault): + restaurant.warn(fault.text) return Labels(goals, tickets, items, tuple(faults)) -def shown_labels(restaurant): - """read_labels, and one warning line on standard error for each of its faults.""" - labels = read_labels(restaurant) - for _, text in labels.faults: - print(f"brigade: warning: {text}", file=sys.stderr) - return labels - - @dataclass(frozen=True) class Tally: """The work under one kind or one goal. waiting holds ticket ids. in_flight and merged hold item ids.""" @@ -1022,7 +1056,7 @@ def add_goal(restaurant, name, purpose, done, share=""): name, purpose, done, share = clean(name), clean(purpose), clean(done), goal_share(share) if not (name and purpose and done): raise BrigadeError("goal add needs a nonempty --name, --purpose, and --done") - for goal in read_labels(restaurant).goals.values(): + for goal in read_labels(restaurant, GOALS_ONLY).goals.values(): if goal.state not in CLOSED_GOAL_STATES and same_text(goal.name) == same_text(name): raise BrigadeError(f"same name as {goal.id} ({goal.state}); nothing added") ident = restaurant.next_id("goals.tsv") @@ -1050,7 +1084,7 @@ def set_goal(restaurant, ident, name=None, purpose=None, done=None, share=None, A move to done or dropped needs --evidence, and is refused while goal_work finds work under the goal. One update call writes every change. It logs a goal row when the state changed. """ - labels = read_labels(restaurant) + labels = read_labels(restaurant, EVERY_ROW if state in CLOSED_GOAL_STATES else GOALS_ONLY) goal = labels.goals.get(ident) if goal is None: raise BrigadeError(f"no goal {ident}; run goal list --all to see every goal") @@ -1195,7 +1229,7 @@ def add_ticket(restaurant, summary, source, ref, request="", rails=None, again=F if not kind: raise BrigadeError(f"ticket add needs --kind {KIND_CHOICE}") if goal is not None: - open_goal(read_labels(restaurant), goal) + open_goal(read_labels(restaurant, GOALS_ONLY), goal) return append_ticket(restaurant, summary, source, ref, priority, paths, decision, kind, goal or "") @@ -1698,7 +1732,7 @@ def file_follow_ups(restaurant, name, found, write, tracked): Each ticket takes the goal of the report's item, whatever that goal's state, and follow_up_kind of that item's kind. """ restaurant.find("dishes.tsv", name[:-3]) - label = read_labels(restaurant).items[name[:-3]] + label = read_labels(restaurant, ActsOn(items=(name[:-3],))).items[name[:-3]] kind = follow_up_kind(label.kind, priority_of({"priority": "", "source": "report"})) if found is None: return f"reports/{name} has no follow-ups section; nothing added" @@ -1760,7 +1794,7 @@ def move_ticket(restaurant, ident, to, rails): if target_rows is None: target_rows = Restaurant(target).rows("rail.tsv") # The kind goes with the ticket. The goal stays, because a goal id names a row of this store's goals.tsv only. - label = read_labels(restaurant).tickets[ident] + label = read_labels(restaurant, ActsOn(tickets=(ident,))).tickets[ident] if taken_row(target_rows, handoff) is None: restaurant.publish(inbox_file(target, handoff), json.dumps({ "handoff": handoff, "summary": row["summary"], "source": base_source(row["source"]), "ref": row["ref"], @@ -1780,23 +1814,22 @@ def take_tickets(restaurant): raise BrigadeError(f"{path} is not valid JSON: {error}") from error source = handoff["handoff"] row = taken_row(restaurant.rows("rail.tsv"), source) - unknown = "" if row is None: rows = restaurant.rows("rail.tsv") - kind = handoff.get("kind") or "" - if kind not in KINDS: - unknown, kind = clean(kind), "" + given = handoff.get("kind") or "" + kind = given if given in KINDS else "" row = {"id": restaurant.next_id("rail.tsv"), "at": now(), "state": "waiting", "source": f"{handoff['source']} (from {source})", "ref": handoff["ref"], "summary": handoff["summary"], **{key: handoff.get(key, "") for key in ("priority", "paths", "decision")}, "kind": kind} + if given and not kind: + # The warning comes before the write, so a take killed after the write has printed it. + restaurant.warn(f"{row['id']} came from {source} with kind {given!r}, which is not one this version knows, " + f"so it has no kind; run ticket set {row['id']} --kind {KIND_CHOICE}") restaurant.save_rows("rail.tsv", rows + [row]) - if unknown: - unknown = (f"; kind {unknown} is not one this version knows, so it has no kind; " - f"run ticket set {row['id']} --kind {KIND_CHOICE}") if not any(event["kind"] == "ticket" and event["note"] == f"from {source}" for event in restaurant.rows("log.tsv")): restaurant.log("ticket", row["id"], "waiting", f"from {source}") restaurant.remove(path) - lines.append(f"{row['id']} from {source}: {row['summary']}{unknown}") + lines.append(f"{row['id']} from {source}: {row['summary']}") return lines @@ -2297,7 +2330,7 @@ def report(restaurant, write=True): if title: sections[title].append(event) dishes = {row["id"]: row for row in restaurant.rows("dishes.tsv")} - labels = shown_labels(restaurant) + labels = read_labels(restaurant, EVERY_ROW) stamp = now() lines = [f"# {meta['restaurant']} report", "", f"Since {since or 'opening'}. {status_line(restaurant)}.", ""] for title, items in sections.items(): @@ -2678,7 +2711,7 @@ def startable_lines(standings, meta, running, now): def startable(restaurant): with restaurant.checked(): - meta, labels = restaurant.meta, shown_labels(restaurant) + meta, labels = restaurant.meta, read_labels(restaurant, EVERY_ROW) tickets = [waiting_ticket(row, labels.tickets[row["id"]]) for row in restaurant.rows("rail.tsv") if row["state"] == "waiting"] running, next_dish = running_workers(restaurant), restaurant.next_id("dishes.tsv") refusals = lease_refusals(meta, tickets, next_dish) @@ -2687,7 +2720,7 @@ def startable(restaurant): def ticket_lines(restaurant, state, held): - labels, lines = shown_labels(restaurant), [] + labels, lines = read_labels(restaurant, EVERY_ROW), [] for row in restaurant.rows("rail.tsv"): if state and row["state"] != state: continue @@ -2752,16 +2785,12 @@ def fire_label(labels, rail, ids, station, kind, ride_along): return Label(found, mine.goal), None -def checked_fire_label(restaurant, ids, station, kind, ride_along, warned): +def checked_fire_label(restaurant, ids, station, kind, ride_along): """fire_label on the store as it is now, read under the caller's lock. - It prints the warning for each fault whose row id is in `ids`, once per text in `warned`. + read_labels prints the warnings of goals.tsv and of the tickets in `ids`. """ - labels = read_labels(restaurant) - for ident, text in labels.faults: - if ident in ids and text not in warned: - warned.add(text) - print(f"brigade: warning: {text}", file=sys.stderr) + labels = read_labels(restaurant, ActsOn(tickets=tuple(ids))) rail = {row["id"]: row for row in restaurant.rows("rail.tsv")} return fire_label(labels, rail, ids, station, kind, ride_along) @@ -2775,7 +2804,6 @@ def fire(restaurant, ids, station, task, thread, branch, summary, timebox, paths The lead ticket's kind cell takes the item's kind in the rewrite that assigns the tickets. No other kind or goal cell of a ticket changes, so a ticket that rides along keeps its own. """ - warned = set() while True: with restaurant.checked(): restaurant.fence() @@ -2789,7 +2817,7 @@ def fire(restaurant, ids, station, task, thread, branch, summary, timebox, paths if full: record_blocked(restaurant, ids, "workers", station, summary, paths, timebox, branch, reason, kind, ride_along) raise BrigadeError(f"nothing fired: {full}") - _, refusal = checked_fire_label(restaurant, ids, station, kind, ride_along, warned) + _, refusal = checked_fire_label(restaurant, ids, station, kind, ride_along) if refusal: raise BrigadeError(refusal) # land.py can wait on the landing database, so the claim runs outside the store lock. @@ -2807,7 +2835,7 @@ def fire(restaurant, ids, station, task, thread, branch, summary, timebox, paths full = None if refusal else workers_full(restaurant) label = None if not refusal and not full: - label, refusal = checked_fire_label(restaurant, ids, station, kind, ride_along, warned) + label, refusal = checked_fire_label(restaurant, ids, station, kind, ride_along) if label and current == dish: restaurant.append("dishes.tsv", {"id": dish, "at": now(), "state": "in-progress", "station": station, "tickets": ",".join(ids), "task": task, "thread": thread, @@ -4224,7 +4252,7 @@ def command(restaurant, args, contract=None, rails=None): return add_goal(restaurant, args.name, args.purpose, args.done, args.share) if args.action == "set": return set_goal(restaurant, args.id, args.name, args.purpose, args.done, args.share, args.state, args.evidence) - labels = shown_labels(restaurant) + labels = read_labels(restaurant, EVERY_ROW) return goal_lines(labels, count_labels(restaurant, labels), args.all) if args.command == "ticket": @@ -4267,7 +4295,7 @@ def command(restaurant, args, contract=None, rails=None): changes["kind"] = args.kind said.append(f"kind {args.kind}") if args.goal is not None: - changes["goal"] = open_goal(read_labels(restaurant), args.goal).id + changes["goal"] = open_goal(read_labels(restaurant, GOALS_ONLY), args.goal).id said.append(f"goal {args.goal}") if args.no_goal: changes["goal"] = "" @@ -4406,7 +4434,7 @@ def command(restaurant, args, contract=None, rails=None): level if counts_text == "nothing on record" else f"{level}, {counts_text}"] if not is_admin(meta): lines.append(workers_line(meta, running_workers(restaurant))) - labels = shown_labels(restaurant) + labels = read_labels(restaurant, EVERY_ROW) lines += status_label_lines(labels, count_labels(restaurant, labels)) if meta.get("reportsTo"): lines.append(f"reports to {meta['reportsTo']}") diff --git a/tests/test_brigade.py b/tests/test_brigade.py index 929884bb..b2f07239 100644 --- a/tests/test_brigade.py +++ b/tests/test_brigade.py @@ -7254,7 +7254,7 @@ def test_a_goal_state_that_is_none_of_the_four_reads_as_open_with_a_warning_and_ code, out, err = self.run_raw("goal", "list") self.assertEqual((code, err), (0, warning)) self.assertEqual(out.splitlines()[0], "G1 open activity view: 0 waiting, 0 in flight, 0 merged, 0 send-backs") - self.assertEqual(self.goal("activity view", ok=False), "brigade: same name as G1 (open); nothing added") + self.assertEqual(self.goal("activity view", ok=False), warning + "brigade: same name as G1 (open); nothing added") self.assertEqual(self.brigade("goal", "set", "G1", "--state", "open"), "G1 open") self.assertEqual(self.table_row(self.at, "goals.tsv", "G1")["state"], "open") self.assertEqual(self.run_raw("goal", "list")[2], "") @@ -7586,7 +7586,9 @@ def test_goal_set_refuses_an_empty_id_and_goal_add_gives_the_next_id_when_goals_ self.two_goals() self.copy_goal_row("", "no id") before = self.store_files() - self.assertEqual(self.brigade("goal", "set", "", "--name", "x", ok=False), "brigade: no goal ; run goal list --all to see every goal") + self.assertEqual(self.brigade("goal", "set", "", "--name", "x", ok=False), + "brigade: warning: goals.tsv has a row with no id; it is not a goal; remove it from goals.tsv by hand\n" + "brigade: no goal ; run goal list --all to see every goal") self.assertEqual(self.store_files(), before) self.assertEqual(self.brigade("goal", "add", "--name", "third", "--purpose", "p", "--done", "d"), "G3") @@ -7699,6 +7701,94 @@ def test_a_goal_cell_that_ends_in_a_carriage_return_still_reads_as_unassigned_af code, out, err = self.run_raw("status") self.assertEqual((code, err, out.splitlines()[3:]), (0, warning, unassigned)) + def test_ticket_add_goal_and_ticket_set_goal_refuse_an_id_whose_goals_tsv_row_has_a_space_at_each_end_warn_of_the_row_and_change_no_file(self): + self.open() + self.goal() + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix") + self.hand_edit("goals.tsv", "G1", id=" G1 ") + before = self.store_files() + warning = f"brigade: warning: {self.PADDED_GOAL_ROW}\n" + for goal in (" G1 ", "G1"): + with self.subTest(goal=goal): + refusal = warning + f"brigade: no goal {goal}; run goal list --all to see every goal\n" + self.assertEqual(self.run_raw("ticket", "add", "--summary", "Fix bug", "--kind", "fix", "--goal", goal), (1, "", refusal)) + self.assertEqual(self.run_raw("ticket", "set", "T1", "--goal", goal), (1, "", refusal)) + self.assertEqual(self.store_files(), before) + + def test_fire_of_a_ticket_whose_goal_cell_and_whose_goals_tsv_row_id_are_the_same_padded_text_warns_of_both_and_starts_an_unassigned_item(self): + self.open() + self.goal() + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix", "--goal", "G1") + self.hand_edit("goals.tsv", "G1", id=" G1 ") + self.hand_edit("rail.tsv", "T1", goal=" G1 ") + self.assertEqual(self.run_raw("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "Do a"), + (0, "D1\n", f"brigade: warning: {self.PADDED_GOAL_ROW}\n" + f"brigade: warning: T1 names goal ' G1 ', {self.NO_GOAL_ROW}; " + "run ticket set T1 --goal G<n> or --no-goal\n")) + self.assertEqual(self.raw_cells("dishes.tsv", "D1", "kind", "goal"), (b"fix", b"")) + self.assertEqual(self.raw_cells("rail.tsv", "T1", "state", "kind", "goal"), (b"assigned", b"fix", b" G1 ")) + self.assertEqual(self.raw_cells("goals.tsv", " G1 ", "id"), (b" G1 ",)) + + def test_ticket_add_goal_ticket_set_goal_and_fire_warn_of_a_goals_tsv_row_whose_id_an_earlier_row_has(self): + self.two_goals() + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix") + self.copy_goal_row("G1", "second") + warning = ("brigade: warning: goals.tsv has more than one row with id G1; the first is the goal and the others are not read; " + "remove the others from goals.tsv by hand\n") + self.assertEqual(self.run_raw("ticket", "add", "--summary", "b", "--kind", "fix", "--goal", "G1"), (0, "T2\n", warning)) + self.assertEqual(self.run_raw("ticket", "set", "T1", "--goal", "G1"), (0, "T1 goal G1\n", warning)) + self.assertEqual(self.run_raw("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "Do a"), (0, "D1\n", warning)) + self.assertEqual((self.label("dishes.tsv", "D1"), self.label("rail.tsv", "T2")), (("fix", "G1"), ("fix", "G1"))) + + def item_with_two_bad_cells(self): + """D1 in flight with a kind cell and a goal cell that are not legal, and its ticket T1 with a bad kind cell. Returns D1's warnings.""" + self.fired_bug_fix() + self.hand_edit("dishes.tsv", "D1", kind="chore", goal="G9") + self.hand_edit("rail.tsv", "T1", kind="chore") + by_hand = "no command changes this cell; correct it in dishes.tsv by hand" + return (f"brigade: warning: D1 has kind 'chore', {self.NOT_A_KIND}; {by_hand}\n" + f"brigade: warning: D1 names goal 'G9', {self.NO_GOAL_ROW}; {by_hand}\n") + + def test_ticket_add_from_report_of_an_item_with_a_bad_kind_cell_and_a_bad_goal_cell_warns_of_both_and_files_a_ticket_with_no_kind_and_no_goal(self): + warnings = self.item_with_two_bad_cells() + self.review_file("D1.md", "## Follow-ups\n\n- Tidy guide.\n") + self.assertEqual(self.run_raw("ticket", "add", "--from-report", "reports/D1.md"), + (0, "T2 added from perf/reports/D1.md#1: Tidy guide.\n", warnings)) + self.assertEqual(self.raw_cells("rail.tsv", "T2", "state", "kind", "goal"), (b"waiting", b"", b"")) + self.assertEqual(self.raw_cells("dishes.tsv", "D1", "kind", "goal"), (b"chore", b"G9")) + + def test_ticket_add_from_report_dry_run_prints_the_same_warnings_and_changes_no_file(self): + warnings = self.item_with_two_bad_cells() + self.review_file("D1.md", "## Follow-ups\n\n- Tidy guide.\n") + before = self.store_files() + self.assertEqual(self.run_raw("ticket", "add", "--from-report", "reports/D1.md", "--dry-run"), + (0, "would add from perf/reports/D1.md#1: Tidy guide.\n", warnings)) + self.assertEqual(self.store_files(), before) + + def test_goal_add_and_goal_set_warn_of_each_bad_goals_tsv_row_and_of_no_ticket(self): + self.two_goals() + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix") + self.hand_edit("rail.tsv", "T1", kind="chore", goal="G9") + self.hand_edit("goals.tsv", "G2", state="finished") + self.copy_goal_row("", "no id") + warnings = ("brigade: warning: G2 has state 'finished', which is not open, paused, done, or dropped; it reads as open; " + "run goal set G2 --state <state>\n" + "brigade: warning: goals.tsv has a row with no id; it is not a goal; remove it from goals.tsv by hand\n") + self.assertEqual(self.run_raw("goal", "add", "--name", "third", "--purpose", "p", "--done", "d"), (0, "G3\n", warnings)) + self.assertEqual(self.run_raw("goal", "set", "G1", "--name", "renamed"), (0, "G1 name renamed\n", warnings)) + self.assertEqual(self.run_raw("goal", "set", "G1", "--state", "paused"), (0, "G1 paused\n", warnings)) + self.assertEqual(self.raw_cells("rail.tsv", "T1", "kind", "goal"), (b"chore", b"G9")) + + def test_goal_set_state_done_warns_of_a_waiting_ticket_whose_goal_cell_is_that_goals_id_padded(self): + self.two_goals() + self.brigade("ticket", "add", "--summary", "a", "--kind", "fix", "--goal", "G1") + self.hand_edit("rail.tsv", "T1", goal=" G1 ") + self.assertEqual(self.run_raw("goal", "set", "G2", "--state", "paused"), (0, "G2 paused\n", "")) + self.assertEqual(self.run_raw("goal", "set", "G1", "--state", "done", "--evidence", "shipped"), + (0, "G1 done\n", f"brigade: warning: T1 names goal ' G1 ', {self.NO_GOAL_ROW}; " + "run ticket set T1 --goal G<n> or --no-goal\n")) + self.assertEqual(self.raw_cells("rail.tsv", "T1", "state", "goal"), (b"waiting", b" G1 ")) + def test_a_ticket_and_an_item_that_name_a_goal_with_no_row_are_warned_of_and_count_as_unassigned(self): self.two_goals() self.brigade("ticket", "add", "--summary", "a", "--kind", "fix", "--goal", "G1") @@ -8257,17 +8347,27 @@ def test_ticket_move_of_a_ticket_whose_kind_cell_holds_no_kind_writes_no_kind_in self.assertEqual(self.brigade("engine", "ticket", "take"), "T1 from app/core/T1: Tidy the guide") self.assertEqual(self.rows("engine", "rail.tsv")[0][10:], ["", ""]) - def take_handoff(self, **extra): - """engine takes a handoff file that holds the seven keys from before kinds, and `extra`.""" + def handed(self, *extras): + """engine's inbox holds one handoff file per dict, from core's T1 on. Each holds the seven keys from before kinds, and that dict.""" self.open("core") self.open("engine") inbox = self.dir("engine") / "inbox" inbox.mkdir() - (inbox / "app~core~T1.json").write_text(json.dumps( - {"handoff": "app/core/T1", "summary": "Tidy the guide", "source": "user", "ref": "", - "priority": "low", "paths": "docs", "decision": "", **extra})) + for number, extra in enumerate(extras, 1): + (inbox / f"app~core~T{number}.json").write_text(json.dumps( + {"handoff": f"app/core/T{number}", "summary": "Tidy the guide", "source": "user", "ref": "", + "priority": "low", "paths": "docs", "decision": "", **extra})) + + def take_handoff(self, **extra): + """engine takes a handoff file that holds the seven keys from before kinds, and `extra`.""" + self.handed(extra) return self.brigade("engine", "ticket", "take") + def unknown_kind(self, ident, quoted): + """The warning of a take for the ticket `ident` whose handoff kind, in quotes, is `quoted`.""" + return (f"brigade: warning: {ident} came from app/core/{ident} with kind {quoted}, which is not one this version knows, " + f"so it has no kind; run ticket set {ident} --kind <feature|fix|polish|upkeep|study>\n") + def test_ticket_take_files_a_handoff_with_no_kind_key_as_a_ticket_with_no_kind_and_no_goal(self): self.assertEqual(self.take_handoff(), "T1 from app/core/T1: Tidy the guide") self.assertEqual(self.rows("engine", "rail.tsv")[0][2:], @@ -8277,12 +8377,49 @@ def test_ticket_take_files_a_handoff_with_a_kind_as_a_ticket_of_that_kind(self): self.assertEqual(self.take_handoff(kind="upkeep"), "T1 from app/core/T1: Tidy the guide") self.assertEqual(self.rows("engine", "rail.tsv")[0][10:], ["upkeep", ""]) - def test_ticket_take_files_a_handoff_with_a_kind_that_is_none_of_the_five_as_a_ticket_with_no_kind_and_says_so(self): - self.assertEqual(self.take_handoff(kind="chore"), - "T1 from app/core/T1: Tidy the guide; kind chore is not one this version knows, so it has no kind; " - "run ticket set T1 --kind <feature|fix|polish|upkeep|study>") + def test_ticket_take_files_a_handoff_with_a_kind_that_is_none_of_the_five_as_a_ticket_with_no_kind_and_warns_on_standard_error(self): + self.handed({"kind": "chore"}) + took = self.run_brigade("engine", "ticket", "take") + self.assertEqual((took.returncode, took.stdout, took.stderr), + (0, "T1 from app/core/T1: Tidy the guide\n", + "brigade: warning: T1 came from app/core/T1 with kind 'chore', which is not one this version knows, " + "so it has no kind; run ticket set T1 --kind <feature|fix|polish|upkeep|study>\n")) self.assertEqual(self.rows("engine", "rail.tsv")[0][10:], ["", ""]) - self.assertEqual(self.brigade("engine", "ticket", "take"), "nothing handed to you") + again = self.run_brigade("engine", "ticket", "take") + self.assertEqual((again.returncode, again.stdout, again.stderr), (0, "nothing handed to you\n", "")) + + def test_inbox_take_warns_on_standard_error_of_a_handoff_kind_that_is_a_padded_fix(self): + self.handed({"kind": " fix "}) + took = self.run_brigade("engine", "inbox", "take") + self.assertEqual((took.returncode, took.stdout, took.stderr), + (0, "T1 from app/core/T1: Tidy the guide\n", self.unknown_kind("T1", "' fix '"))) + self.assertEqual(self.rows("engine", "rail.tsv")[0][10:], ["", ""]) + + def test_a_take_of_two_handoffs_with_unknown_kinds_prints_one_warning_for_each(self): + self.handed({"kind": "chore"}, {"kind": "chore"}) + took = self.run_brigade("engine", "ticket", "take") + self.assertEqual((took.returncode, took.stdout, took.stderr), + (0, "T1 from app/core/T1: Tidy the guide\nT2 from app/core/T2: Tidy the guide\n", + self.unknown_kind("T1", "'chore'") + self.unknown_kind("T2", "'chore'"))) + self.assertEqual([row[10:] for row in self.rows("engine", "rail.tsv")], [["", ""], ["", ""]]) + + def test_ticket_move_of_a_ticket_with_a_bad_kind_cell_and_a_bad_goal_cell_warns_of_both_on_standard_error_and_again_on_a_second_move(self): + self.open("core") + self.open("engine") + self.brigade("core", "ticket", "add", "--summary", "Tidy the guide", "--kind", "polish") + rail = self.dir("core") / "rail.tsv" + rail.write_text(rail.read_text().replace("\tpolish\t\n", "\tchore\tG9\n")) + by_hand = "ticket set does not change this cell while T1 is moved; correct it in rail.tsv by hand\n" + said = (0, "T1 moved to engine; no thread recorded for engine\n", + "brigade: warning: T1 has kind 'chore', which is not feature, fix, polish, upkeep, or study; it reads as no kind; " + by_hand + + "brigade: warning: T1 names goal 'G9', which has no row in goals.tsv; it reads as unassigned; " + by_hand) + for _ in range(2): + moved = self.run_brigade("core", "ticket", "move", "T1", "--to", "engine") + self.assertEqual((moved.returncode, moved.stdout, moved.stderr), said) + self.assertEqual(self.rows("core", "rail.tsv")[0][10:], ["chore", "G9"]) + self.assertEqual(json.loads((self.dir("engine") / "inbox" / "app~core~T1.json").read_text()), + {"handoff": "app/core/T1", "summary": "Tidy the guide", "source": "user", "ref": "", + "priority": "", "paths": "", "decision": ""}) def test_ticket_take_files_a_handoff_whose_kind_is_empty_and_one_whose_kind_is_null_as_tickets_with_no_kind(self): self.open("core") From cf91994af5fd13bd1dbf82c94836e8fc735143e0 Mon Sep 17 00:00:00 2001 From: Marcus Gibson <marcusroygibson@gmail.com> Date: Sun, 11 Oct 2026 14:35:49 -0500 Subject: [PATCH 18/19] brigade: write the lead ticket kind before the item row --- skills/brigade/scripts/brigade.py | 19 ++++++-- t3/added/brigade/scripts/brigade.py | 19 ++++++-- tests/test_brigade.py | 69 +++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 6 deletions(-) diff --git a/skills/brigade/scripts/brigade.py b/skills/brigade/scripts/brigade.py index 38b35d54..ed2b3c03 100644 --- a/skills/brigade/scripts/brigade.py +++ b/skills/brigade/scripts/brigade.py @@ -2795,13 +2795,27 @@ def checked_fire_label(restaurant, ids, station, kind, ride_along): return fire_label(labels, rail, ids, station, kind, ride_along) +def write_lead_kind(restaurant, ids, kind): + """Write `kind` into the kind cell of the lead ticket, ids[0], when the cell holds another value. It logs nothing. + + fire calls it before it appends the item row. A fire killed after it leaves no item whose kind its lead ticket lacks. + """ + rows = restaurant.rows("rail.tsv") + changed = [row for row in rows if row["id"] in ids[:1] and row["kind"] != kind] + for row in changed: + row["kind"] = kind + if changed: + restaurant.save_rows("rail.tsv", rows) + + def fire(restaurant, ids, station, task, thread, branch, summary, timebox, paths, reason=None, kind="", ride_along=()): """Start one item for the tickets in `ids`. The item takes the kind and goal fire_label gives. The kind and goal check runs twice, each time under the store lock. The first run comes after the owner check, unfireable, and the worker cap, so a fire that one of those refuses or blocks gets the words it got before kinds and goals. It comes before the lease claim, so a fire it refuses claims no lease. The second run sees a change made while the claim ran. - The lead ticket's kind cell takes the item's kind in the rewrite that assigns the tickets. No other kind or goal cell + The lead ticket's kind cell takes the item's kind in a rewrite of its own, before the item row is appended. A fire + killed between its writes leaves no item whose kind its lead ticket lacks. No other kind or goal cell of a ticket changes, so a ticket that rides along keeps its own. """ while True: @@ -2837,6 +2851,7 @@ def fire(restaurant, ids, station, task, thread, branch, summary, timebox, paths if not refusal and not full: label, refusal = checked_fire_label(restaurant, ids, station, kind, ride_along) if label and current == dish: + write_lead_kind(restaurant, ids, label.kind) restaurant.append("dishes.tsv", {"id": dish, "at": now(), "state": "in-progress", "station": station, "tickets": ",".join(ids), "task": task, "thread": thread, "branch": branch, "summary": summary, "timebox": timebox, @@ -2848,8 +2863,6 @@ def fire(restaurant, ids, station, task, thread, branch, summary, timebox, paths for row in rows: if row["id"] in ids: row["state"], row["dish"] = "assigned", dish - if row["id"] in ids[:1]: - row["kind"] = label.kind restaurant.save_rows("rail.tsv", rows) for row in rows: if row["id"] in ids: diff --git a/t3/added/brigade/scripts/brigade.py b/t3/added/brigade/scripts/brigade.py index 38b35d54..ed2b3c03 100644 --- a/t3/added/brigade/scripts/brigade.py +++ b/t3/added/brigade/scripts/brigade.py @@ -2795,13 +2795,27 @@ def checked_fire_label(restaurant, ids, station, kind, ride_along): return fire_label(labels, rail, ids, station, kind, ride_along) +def write_lead_kind(restaurant, ids, kind): + """Write `kind` into the kind cell of the lead ticket, ids[0], when the cell holds another value. It logs nothing. + + fire calls it before it appends the item row. A fire killed after it leaves no item whose kind its lead ticket lacks. + """ + rows = restaurant.rows("rail.tsv") + changed = [row for row in rows if row["id"] in ids[:1] and row["kind"] != kind] + for row in changed: + row["kind"] = kind + if changed: + restaurant.save_rows("rail.tsv", rows) + + def fire(restaurant, ids, station, task, thread, branch, summary, timebox, paths, reason=None, kind="", ride_along=()): """Start one item for the tickets in `ids`. The item takes the kind and goal fire_label gives. The kind and goal check runs twice, each time under the store lock. The first run comes after the owner check, unfireable, and the worker cap, so a fire that one of those refuses or blocks gets the words it got before kinds and goals. It comes before the lease claim, so a fire it refuses claims no lease. The second run sees a change made while the claim ran. - The lead ticket's kind cell takes the item's kind in the rewrite that assigns the tickets. No other kind or goal cell + The lead ticket's kind cell takes the item's kind in a rewrite of its own, before the item row is appended. A fire + killed between its writes leaves no item whose kind its lead ticket lacks. No other kind or goal cell of a ticket changes, so a ticket that rides along keeps its own. """ while True: @@ -2837,6 +2851,7 @@ def fire(restaurant, ids, station, task, thread, branch, summary, timebox, paths if not refusal and not full: label, refusal = checked_fire_label(restaurant, ids, station, kind, ride_along) if label and current == dish: + write_lead_kind(restaurant, ids, label.kind) restaurant.append("dishes.tsv", {"id": dish, "at": now(), "state": "in-progress", "station": station, "tickets": ",".join(ids), "task": task, "thread": thread, "branch": branch, "summary": summary, "timebox": timebox, @@ -2848,8 +2863,6 @@ def fire(restaurant, ids, station, task, thread, branch, summary, timebox, paths for row in rows: if row["id"] in ids: row["state"], row["dish"] = "assigned", dish - if row["id"] in ids[:1]: - row["kind"] = label.kind restaurant.save_rows("rail.tsv", rows) for row in rows: if row["id"] in ids: diff --git a/tests/test_brigade.py b/tests/test_brigade.py index b2f07239..211cd668 100644 --- a/tests/test_brigade.py +++ b/tests/test_brigade.py @@ -158,6 +158,15 @@ def paused_finished(admin): glob["finished_by_coordinator"] = paused_finished elif mode == "die-before-log": glob["Restaurant"].log = lambda self, *rest: os._exit(9) + elif mode == "die-before-item": + original_append = glob["Restaurant"].append + + def dying_append(self, table, row): + if table == "dishes.tsv": + os._exit(9) + return original_append(self, table, row) + + glob["Restaurant"].append = dying_append elif mode == "die-before-cursor": original_change = glob["Restaurant"].change_meta @@ -8721,6 +8730,66 @@ def fail(text): self.assertEqual(self.goal_log(), [["G1", "open", "activity view"], ["G1", "paused", "activity view"]]) +class FireWriteTest(StoresTest): + """A fire that is killed partway through its writes.""" + + FIRE = ("fire", "--tickets", "T1", "--station", "bug-fix", "--summary", "Fix bug") + + def before_kinds(self): + """core holds T1, a waiting ticket as the tool wrote it before kinds and goals.""" + self.open("core") + (self.dir("core") / "rail.tsv").write_text( + "id\tat\tstate\tsource\tref\tdish\tsummary\tpriority\tpaths\tdecision\n" + "T1\t2026-10-01T00:00:00+00:00\twaiting\tuser\t\t\tFix bug\t\t\t\n") + + def labelled(self, table): + """The id, state, kind, and goal of each row of core's table. A row from before kinds has no kind and no goal.""" + header, *lines = (self.dir("core") / table).read_text().splitlines() + rows = [dict(zip(header.split("\t"), line.split("\t"))) for line in lines] + return [tuple(row.get(key, "") for key in ("id", "state", "kind", "goal")) for row in rows] + + def killed(self, mode, *fire): + died = self.child(mode, "core", *fire) + self.assertEqual((died.returncode, died.stdout, died.stderr), (9, "", "")) + + def test_a_fire_killed_before_its_log_row_leaves_the_item_and_its_lead_ticket_with_the_same_kind_and_status_warns_of_nothing(self): + self.before_kinds() + self.killed("die-before-log", *self.FIRE) + self.assertEqual(self.labelled("dishes.tsv"), [("D1", "in-progress", "fix", "")]) + self.assertEqual(self.labelled("rail.tsv"), [("T1", "waiting", "fix", "")]) + self.assertEqual(self.rows("core", "log.tsv"), []) + status = self.run_brigade("core", "status") + self.assertEqual((status.returncode, status.stderr), (0, "")) + self.assertEqual(status.stdout.splitlines()[3:], ["kinds: fix 1 waiting, 1 in flight"]) + + def test_a_fire_killed_before_its_log_row_is_dropped_and_the_same_fire_again_starts_the_ticket_with_the_same_kind(self): + self.before_kinds() + self.killed("die-before-log", *self.FIRE) + self.assertEqual(self.brigade("core", "dish", "D1", "--state", "dropped"), "D1 dropped") + self.assertEqual(self.labelled("rail.tsv"), [("T1", "waiting", "fix", "")]) + self.assertEqual(self.brigade("core", *self.FIRE), "D2") + self.assertEqual(self.labelled("dishes.tsv"), [("D1", "dropped", "fix", ""), ("D2", "in-progress", "fix", "")]) + self.assertEqual(self.labelled("rail.tsv"), [("T1", "assigned", "fix", "")]) + + def test_a_fire_killed_before_its_item_row_leaves_the_lead_ticket_waiting_with_the_kind_and_no_item_and_the_same_fire_again_starts_it(self): + self.before_kinds() + self.killed("die-before-item", *self.FIRE) + self.assertEqual(self.labelled("dishes.tsv"), []) + self.assertEqual(self.labelled("rail.tsv"), [("T1", "waiting", "fix", "")]) + self.assertEqual(self.rows("core", "log.tsv"), []) + self.assertEqual(self.brigade("core", *self.FIRE), "D1") + self.assertEqual(self.labelled("dishes.tsv"), [("D1", "in-progress", "fix", "")]) + self.assertEqual(self.labelled("rail.tsv"), [("T1", "assigned", "fix", "")]) + + def test_a_fire_with_kind_killed_before_its_item_row_leaves_a_lead_ticket_whose_kind_ticket_set_changes(self): + self.before_kinds() + self.killed("die-before-item", *self.FIRE, "--kind", "polish") + self.assertEqual((self.labelled("dishes.tsv"), self.labelled("rail.tsv")), ([], [("T1", "waiting", "polish", "")])) + changed = self.run_brigade("core", "ticket", "set", "T1", "--kind", "fix") + self.assertEqual((changed.returncode, changed.stdout, changed.stderr), (0, "T1 kind fix\n", "")) + self.assertEqual(self.labelled("rail.tsv"), [("T1", "waiting", "fix", "")]) + + class AdminTest(StoresTest): """The executive admin at `app/.admin`, beside the coordinators on the same root.""" From ba2de937edcb6b1b401e5b2e4360c496681d88e3 Mon Sep 17 00:00:00 2001 From: Marcus Gibson <marcusroygibson@gmail.com> Date: Sun, 11 Oct 2026 14:36:00 -0500 Subject: [PATCH 19/19] brigade: say an item in flight with no kind is absent from kinds --- changes/pstack-t3%2Fd160.md | 1 + skills/brigade/scripts/brigade.py | 2 +- t3/added/brigade/scripts/brigade.py | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/changes/pstack-t3%2Fd160.md b/changes/pstack-t3%2Fd160.md index 99eaf6da..4ac9ecab 100644 --- a/changes/pstack-t3%2Fd160.md +++ b/changes/pstack-t3%2Fd160.md @@ -1 +1,2 @@ - `brigade.py` records a kind and an optional goal on each ticket and work item, and keeps the goals in a new table, `goals.tsv`. `ticket add --summary` needs `--kind`, one of `feature`, `fix`, `polish`, `upkeep`, and `study`, and takes `--goal G<n>` with the id of an open or paused goal. `ticket set --kind`, `--goal`, and `--no-goal` change a waiting ticket and are refused on a ticket in another state and on a waiting ticket that an item in flight names. `goal add` records an open goal with a name, a purpose, and a done test. `goal set` changes a goal, and a move to `done` or to `dropped` needs `--evidence` and is refused while the goal has a waiting ticket, an item in flight, or an assigned ticket in an item that is not under that goal. `goal list` prints each open or paused goal with its counts of waiting tickets, items in flight, merged items, and send-backs, then the counts of unassigned work. `fire` gives the new work item the goal of the first ticket in `--tickets` and that ticket's kind. When that ticket has no kind, the kind is `--kind`, or without it `fix` on station `bug-fix` or `perf-issue` and `study` on station `investigation`, and `fire` is refused on any other station. The first ticket records the kind the item took. A ticket after the first must have the first ticket's goal or no goal unless `--ride-along` names it, and a ticket named there must have priority low and must not be a feature. `ticket add --from-report` gives each ticket the goal of the report's item and, when that item has a kind, the kind `polish`, and it refuses `--kind` and `--goal`. `ticket move` hands the kind to the other coordinator and leaves the goal behind. `ticket list` and `startable` print a ticket's kind and goal after its priority. `status` prints a `kinds:` line and a `goals:` line with the counts of waiting tickets and items in flight under each, and an `untagged:` line with the number of waiting tickets that have no kind. A line with nothing to count is absent. `close` prints the kind on the lines of Merged, In progress, and New tickets, not started, and prints the lines of such a section under one heading per goal when one of them has a goal. A kind cell that holds a value other than one of the five kinds and a goal cell that holds an id with no row in `goals.tsv` read as empty, and `status`, `goal list`, `ticket list`, `startable`, and `close` print a warning for the cell on standard error. `fire` prints it for a ticket it names when it reaches its kind and goal check. `open` writes `goals.tsv` into a new store. A store written before this change reads with every ticket and item unassigned and with no kind. A command that appends a row to `rail.tsv` or `dishes.tsv` there writes the row with the two new cells, and a command that rewrites the table writes every row with them. A `brigade.py` from before this change then calls that table malformed. `activity.py` reads a work item row with or without the two cells and prints the same page for both. +- The prefixes `--no` and `--r` stop working, because `--no-goal` and `--ride-along` made them ambiguous, and the full flag names `--no-decision` and `--reason` work as before. diff --git a/skills/brigade/scripts/brigade.py b/skills/brigade/scripts/brigade.py index ed2b3c03..64748708 100644 --- a/skills/brigade/scripts/brigade.py +++ b/skills/brigade/scripts/brigade.py @@ -1175,7 +1175,7 @@ def status_label_lines(labels, counted): untagged: 1 An entry holds the waiting count and the in flight count that are not 0, and is absent when both are 0. - kinds has an entry per kind in KINDS order. An item in flight with no kind is on no line. + kinds has an entry per kind in KINDS order. An item in flight with no kind is absent from kinds. goals has an entry per goal in file order and then one for unassigned work. A goal that is not open has its state after its id. The line is absent when goals.tsv has no row. A line with no entry is absent, and so is untagged at 0. diff --git a/t3/added/brigade/scripts/brigade.py b/t3/added/brigade/scripts/brigade.py index ed2b3c03..64748708 100644 --- a/t3/added/brigade/scripts/brigade.py +++ b/t3/added/brigade/scripts/brigade.py @@ -1175,7 +1175,7 @@ def status_label_lines(labels, counted): untagged: 1 An entry holds the waiting count and the in flight count that are not 0, and is absent when both are 0. - kinds has an entry per kind in KINDS order. An item in flight with no kind is on no line. + kinds has an entry per kind in KINDS order. An item in flight with no kind is absent from kinds. goals has an entry per goal in file order and then one for unassigned work. A goal that is not open has its state after its id. The line is absent when goals.tsv has no row. A line with no entry is absent, and so is untagged at 0.