diff --git a/CLAUDE.md b/CLAUDE.md index 708aa53..9fa6cb5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,6 +58,16 @@ TOML config keys stay camelCase, matching the repo style. Keep the snake_case-to-camelCase mapping explicit in `server.py`. Neither convention leaks into the other. +## Frontmatter accessors + +Every ticket frontmatter field has a CLI accessor that prints its bare value for a pipe (`docket title`, `docket requires`, and so on). Any new frontmatter field gets one in the same change, never as follow-up work. + +- Register the command and its help in `ACCESSORS`, and map the field to it in `FIELD_ACCESSORS`, both in `src/docket/cli/grammar.py`. +- Give it a reader in `FIELD_READERS` in `src/docket/cli/commands.py`. A list of ids prints one per line, and structured values print as JSON through `Output.json`. +- Add it to the accessor block in `README.md`. + +`testEveryFrontmatterFieldHasAnAccessor` fails when `CANONICAL_FIELDS` gains a field that `FIELD_ACCESSORS` does not name. + ## Commits - Plain messages only. Do NOT add a `Co-Authored-By` or `Generated with` trailer unless explicitly asked. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 06492be..50f6cc6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,6 +14,7 @@ Docket runs on Docket. * [Code Style](#code-style) * [API Docs](#api-docs) * [Naming Across Interfaces](#naming-across-interfaces) + * [Frontmatter Fields](#frontmatter-fields) * [Versioning](#versioning) * [Tests](#tests) * [Opening a Pull Request](#opening-a-pull-request) @@ -103,6 +104,14 @@ Two external interfaces deliberately break camelCase, and neither convention lea snake_case is the MCP ecosystem convention and it is what the model reads, so a camelCase tool name or parameter is a bug. The mapping lives explicitly in `server.py`. +## Frontmatter Fields + +Every ticket frontmatter field is readable from the CLI as a bare value, for a pipe (`docket CORE-14 title`, `docket CORE-14 requires`, and so on). +A new field is not finished until it has one. + +Add it to `ACCESSORS` and `FIELD_ACCESSORS` in `src/docket/cli/grammar.py`, and give it a reader in `FIELD_READERS` in `src/docket/cli/commands.py`. +`testEveryFrontmatterFieldHasAnAccessor` fails until all three agree with `CANONICAL_FIELDS`, and the README's accessor block lists the new command. + ## Versioning The version lives in exactly one place, `__version__` in `src/docket/__init__.py`. diff --git a/README.md b/README.md index fe55ae7..134d782 100644 --- a/README.md +++ b/README.md @@ -86,13 +86,27 @@ A ticket id is the command: ```bash docket CORE-14 # show it, dependency context and all -docket CORE-14 status # bare word, for a pipe -docket CORE-14 ready # true or false. Every dependency done? docket CORE-14 done # todo, wip, or done. The file follows docket CORE-14 set [-t TEXT] [-p N] [-r A,B|none] [-ra A,B] [-rr A,B] docket CORE-14 meta [KEY [VALUE]] [-c] ``` +Every frontmatter field has an accessor that prints the value and nothing else, for a pipe: + +```bash +docket CORE-14 title # the title +docket CORE-14 status # todo, wip, or done +docket CORE-14 priority # the number +docket CORE-14 requires # one id per line, nothing when empty +docket CORE-14 required-by # the reverse direction, same shape +docket CORE-14 key # CORE +docket CORE-14 ready # true or false. Every dependency done? +docket CORE-14 meta # the whole map as JSON +docket CORE-14 meta KEY # one value, bare, or JSON when it has structure +``` + +JSON is highlighted in a terminal and plain when piped, so `docket CORE-14 meta | jq` works as written. + Everything else works on the set: ```bash diff --git a/docs/tickets/todo/FEAT-20_cliAccessorsForAllHeadmatter.md b/docs/tickets/done/FEAT-20_cliAccessorsForAllHeadmatter.md similarity index 98% rename from docs/tickets/todo/FEAT-20_cliAccessorsForAllHeadmatter.md rename to docs/tickets/done/FEAT-20_cliAccessorsForAllHeadmatter.md index b402f90..57f2257 100644 --- a/docs/tickets/todo/FEAT-20_cliAccessorsForAllHeadmatter.md +++ b/docs/tickets/done/FEAT-20_cliAccessorsForAllHeadmatter.md @@ -1,7 +1,7 @@ --- id: FEAT-20 title: CLI Accessors for All Headmatter -status: todo +status: done priority: 0 requires: [] metadata: {} diff --git a/roadmap.md b/roadmap.md index ffc554b..e920db5 100644 --- a/roadmap.md +++ b/roadmap.md @@ -12,17 +12,11 @@ graph TD FEAT_8["FEAT-8
Host Flag
p4 todo"] FEAT_12["FEAT-12
Per Key Ticket Board
View
p2 todo"] FEAT_16["FEAT-16
Ticket Show Uses
Optional Formatting
p2 todo"] - FEAT_19["FEAT-19
Add Google Style
Documentation Comments
p1 todo"] - FEAT_20["FEAT-20
CLI Accessors for All
Headmatter
p0 todo"] end FEAT_6 --> FEAT_12 - classDef todoP0 fill:#495057,color:#fff,stroke:#ff6b6b,stroke-width:4px - classDef todoP1 fill:#495057,color:#fff,stroke:#ff922b,stroke-width:3px classDef todoP2 fill:#495057,color:#fff,stroke:#ffd43b,stroke-width:2px classDef todoP3 fill:#495057,color:#fff,stroke:#adb5bd,stroke-width:2px classDef todoP4 fill:#495057,color:#fff,stroke:#6c757d,stroke-width:1px - class FEAT_20 todoP0 - class FEAT_19 todoP1 class FEAT_12,FEAT_16 todoP2 class FEAT_6 todoP3 class BUG_6,FEAT_8 todoP4 diff --git a/src/docket/__init__.py b/src/docket/__init__.py index fffbf71..c02698b 100644 --- a/src/docket/__init__.py +++ b/src/docket/__init__.py @@ -8,4 +8,4 @@ # No type check to comply with hatch's requirements. # Do not re-add. -__version__ = "1.6.0" +__version__ = "1.7.0" diff --git a/src/docket/cli/__init__.py b/src/docket/cli/__init__.py index 7c1db9b..b597c9e 100644 --- a/src/docket/cli/__init__.py +++ b/src/docket/cli/__init__.py @@ -13,8 +13,10 @@ from typing import Optional from docket.cli.commands import ( + FIELD_READERS, commandDeploy, commandDocs, + commandField, commandGraph, commandKey, commandList, @@ -24,7 +26,6 @@ commandSet, commandShow, commandStatus, - commandStatusRead, commandTicket, commandValidate, documentPath, @@ -32,10 +33,12 @@ requireScopeKey, ) from docket.cli.grammar import ( + ACCESSORS, CLEAR_SENTINEL, EXIT_INVALID, EXIT_OK, EXIT_USAGE, + FIELD_ACCESSORS, OUTPUT_ARGUMENT, PROGRAM_NAME, TICKET_COMMAND, @@ -64,10 +67,13 @@ # `docket.cli` was one module before it was a package, and it is what `pyproject.toml` names as the console script. Everything the outside world reached for then is still reachable by the same path. __all__: list[str] = [ + "ACCESSORS", "CLEAR_SENTINEL", "EXIT_INVALID", "EXIT_OK", "EXIT_USAGE", + "FIELD_ACCESSORS", + "FIELD_READERS", "OUTPUT_ARGUMENT", "PROGRAM_NAME", "STATUS_STYLES", @@ -83,6 +89,7 @@ "classifyToken", "commandDeploy", "commandDocs", + "commandField", "commandGraph", "commandKey", "commandList", @@ -92,7 +99,6 @@ "commandSet", "commandShow", "commandStatus", - "commandStatusRead", "commandTicket", "commandValidate", "describeKeys", diff --git a/src/docket/cli/commands.py b/src/docket/cli/commands.py index 20b0ab6..ed1586a 100644 --- a/src/docket/cli/commands.py +++ b/src/docket/cli/commands.py @@ -10,17 +10,17 @@ import argparse from pathlib import Path -from typing import Optional +from typing import Callable, Optional from rich.table import Table from rich.text import Text -from docket.cli.grammar import EXIT_INVALID, EXIT_OK, EXIT_USAGE, OUTPUT_ARGUMENT, parseEditIdList, parseIdList, resolveGraphScope, resolveListFilters +from docket.cli.grammar import ACCESSORS, EXIT_INVALID, EXIT_OK, EXIT_USAGE, OUTPUT_ARGUMENT, parseEditIdList, parseIdList, resolveGraphScope, resolveListFilters from docket.cli.output import STATUS_STYLES, Output, buildContextTable, relativeToRoot from docket.core.config import Config, discoverConfig from docket.core.deploy import DeployReport, deploy, upgrade from docket.core.handoff import HANDOFF_FILENAME, renderHandoff -from docket.core.graph import Readiness, ResolvedGraph, dependencyContext, readyTickets, resolveGraph, scopeGraph, ticketReadiness +from docket.core.graph import ResolvedGraph, dependencyContext, readyTickets, resolveGraph, scopeGraph, ticketReadiness from docket.core.inputs import requireWritableFile, writeFile from docket.core.mermaid import renderGraph from docket.core.roadmap import ROADMAP_FILENAME, Roadmap, buildRoadmap @@ -28,6 +28,19 @@ from docket.core.ticket import STATUSES, Ticket from docket.core.validate import SEVERITY_ERROR, ValidationReport, validate +# MARK: Constants + +# How each accessor in the grammar reads its answer off a ticket. The store is passed alongside the ticket because a derived answer, such as the reverse dependencies or readiness, cannot be read from one ticket alone. A list comes back for a list of ids, which `commandField` prints one per line. +FIELD_READERS: dict[str, Callable[[Store, Ticket], object]] = { + "title": lambda store, ticket: ticket.title, + "status": lambda store, ticket: ticket.status, + "priority": lambda store, ticket: ticket.priority, + "requires": lambda store, ticket: list(ticket.requires), + "required-by": lambda store, ticket: [entry["id"] for entry in dependencyContext(store.loadAll(), ticket.id)["requiredBy"]], + "key": lambda store, ticket: ticket.key, + "ready": lambda store, ticket: ticketReadiness(store.loadAll(), ticket.id).isReady, +} + # MARK: Functions @@ -50,11 +63,13 @@ def commandTicket(args: argparse.Namespace, store: Store, output: Output) -> int if args.ticketCommand in STATUSES: return commandStatus(args, store, output) + # Every read shares one handler, since what differs between them is only which value is read. + if args.ticketCommand in ACCESSORS: + return commandField(args, store, output) + handlers = { None: commandShow, "show": commandShow, - "status": commandStatusRead, - "ready": commandReady, "set": commandSet, "meta": commandMeta, } @@ -226,33 +241,11 @@ def commandStatus(args: argparse.Namespace, store: Store, output: Output) -> int return EXIT_OK -def commandStatusRead(args: argparse.Namespace, store: Store, output: Output) -> int: - """ - Print a ticket's status and nothing else. - - This goes out raw, with no styling and no surrounding words, so a shell can read the answer as easily as a person can. - - Args: - args: The parsed arguments. - store: The store to read from. - output: Where to write. - - Returns: - The process exit code. - """ - - ticket: Ticket = store.load(args.id) - - output.raw(f"{ticket.status}\n") - - return EXIT_OK - - -def commandReady(args: argparse.Namespace, store: Store, output: Output) -> int: +def commandField(args: argparse.Namespace, store: Store, output: Output) -> int: """ - Print whether a ticket's dependencies are all done, and nothing else. + Print one thing about a ticket and nothing else. - This goes out raw for the same reason `status` does. What is blocking is deliberately left to `show`, which already tables both dependency directions with their statuses. + This goes out raw, with no styling and no surrounding words, so a shell can read the answer as easily as a person can. A list of ids is written one per line and anything else through `Output.value`. What is blocking a ticket that is not ready is deliberately left to `show`, which already tables both dependency directions with their statuses. Args: args: The parsed arguments. @@ -263,9 +256,12 @@ def commandReady(args: argparse.Namespace, store: Store, output: Output) -> int: The process exit code, which reports whether the question could be answered rather than what the answer was. """ - readiness: Readiness = ticketReadiness(store.loadAll(), args.id) + value: object = FIELD_READERS[args.ticketCommand](store, store.load(args.id)) - output.raw(f"{'true' if readiness.isReady else 'false'}\n") + if isinstance(value, list): + output.lines(value) + else: + output.value(value) return EXIT_OK @@ -291,20 +287,8 @@ def commandMeta(args: argparse.Namespace, store: Store, output: Output) -> int: output.error("Nothing to clear. Name the metadata key to remove.") return EXIT_USAGE - ticket: Ticket = store.load(args.id) - - if not ticket.metadata: - output.print("[dim]No metadata.[/dim]") - return EXIT_OK - - table: Table = Table(box=None, pad_edge=False) - table.add_column("KEY", style="bold") - table.add_column("VALUE") - - for key, value in ticket.metadata.items(): - table.add_row(key, str(value)) - - output.print(table) + # The whole map goes out as JSON, including an empty one, so a pipe into `jq` never meets a sentence where the object should be. + output.json(store.load(args.id).metadata) return EXIT_OK @@ -312,15 +296,15 @@ def commandMeta(args: argparse.Namespace, store: Store, output: Output) -> int: output.error("Cannot pass a value together with -c/--clear.") return EXIT_USAGE - # A key with no value reads that entry, raw, for the same reason `status` does. + # A key with no value reads that entry, raw, for the same reason `status` does. A structured value goes out as JSON rather than in Python's own spelling. if not args.clear and args.value is None: - ticket = store.load(args.id) + ticket: Ticket = store.load(args.id) if args.key not in ticket.metadata: output.error(f"'{args.key}' is not set on {ticket.id}.") return EXIT_USAGE - output.raw(f"{ticket.metadata[args.key]}\n") + output.value(ticket.metadata[args.key]) return EXIT_OK diff --git a/src/docket/cli/grammar.py b/src/docket/cli/grammar.py index dd9ee51..e6f00c7 100644 --- a/src/docket/cli/grammar.py +++ b/src/docket/cli/grammar.py @@ -43,6 +43,27 @@ # The word that clears a comma-separated list argument. An id can never collide with it, since every id is an uppercase key followed by a hyphen and a number. CLEAR_SENTINEL: str = "none" +# The commands that print one thing about a ticket and nothing else, for a pipe to read, each with its help text. The name is the subcommand that reaches it and the key `commands` looks its reader up by, so this one table is the whole vocabulary of reads. +ACCESSORS: dict[str, str] = { + "title": "Print the ticket's title.", + "status": "Print the ticket's status.", + "priority": "Print the ticket's priority.", + "requires": "Print the ids this ticket depends on, one per line. Prints nothing when there are none.", + "required-by": "Print the ids of the tickets depending on this one, one per line. Prints nothing when there are none.", + "key": "Print the key portion of the ticket's id and nothing else.", + "ready": "Print whether every dependency is done, as a bare true or false.", +} + +# The command that reads each frontmatter field. `id` has none, since it is what was typed to reach the ticket, and `metadata` is read through `meta` because that command also writes it. A field missing from here fails the suite, which is what keeps a new field from arriving without a way to read it. +FIELD_ACCESSORS: dict[str, Optional[str]] = { + "id": None, + "title": "title", + "status": "status", + "priority": "priority", + "requires": "requires", + "metadata": "meta", +} + # How a destination is named when a message has to talk about it. The graph and the shipped documents share the flag, so they share its name too. OUTPUT_ARGUMENT: str = "--output path" @@ -389,8 +410,10 @@ def buildTicketParser(commands: argparse._SubParsersAction, priorityOptions: str ticketCommands = ticketParser.add_subparsers(dest="ticketCommand", metavar="COMMAND") ticketCommands.add_parser("show", help="Show the ticket with its resolved dependency context. This is what a bare id does.", formatter_class=RichHelpFormatter) - ticketCommands.add_parser("status", help="Print the ticket's status and nothing else, for a pipe to read.", formatter_class=RichHelpFormatter) - ticketCommands.add_parser("ready", help="Print whether every dependency is done, as a bare true or false, for a pipe to read.", formatter_class=RichHelpFormatter) + + # Every read is registered from the one table, so a new one needs no parser code of its own. + for accessor, accessorHelp in ACCESSORS.items(): + ticketCommands.add_parser(accessor, help=accessorHelp, formatter_class=RichHelpFormatter) # One parser per status is what makes 'docket CORE-14 done' work. It also puts the whole vocabulary into the error when a command is misspelled, which a single `choices` list on a value argument could not do. for status in STATUSES: @@ -405,7 +428,7 @@ def buildTicketParser(commands: argparse._SubParsersAction, priorityOptions: str # How much of the call was typed is what it means, the same way a status reads with no value and writes with one. metaParser: argparse.ArgumentParser = ticketCommands.add_parser("meta", help="Inspect and manage the ticket's metadata map.", formatter_class=RichHelpFormatter) - metaParser.add_argument("key", nargs="?", metavar="KEY", help="The metadata key. Namespace it, for example 'video', so it cannot collide with another tool's key. Omit it to show the whole map.") + metaParser.add_argument("key", nargs="?", metavar="KEY", help="The metadata key. Namespace it, for example 'video', so it cannot collide with another tool's key. Omit it to print the whole map as JSON.") metaParser.add_argument("value", nargs="?", metavar="VALUE", help="The value to store. Omit it to print the key's value and nothing else.") metaParser.add_argument("-c", "--clear", action="store_true", help="Remove the key instead of setting it.") diff --git a/src/docket/cli/output.py b/src/docket/cli/output.py index 797598c..d69cab7 100644 --- a/src/docket/cli/output.py +++ b/src/docket/cli/output.py @@ -8,7 +8,7 @@ import sys from pathlib import Path -from typing import Optional +from typing import Any, Iterable, Optional from rich.console import Console from rich.table import Table @@ -63,6 +63,46 @@ def raw(self, text: str) -> None: sys.stdout.write(text) + def lines(self, entries: Iterable[object]) -> None: + """ + Write each entry raw on its own line. + + A list read by a shell is most useful one entry per line, since that is what `while read`, `xargs`, and `wc -l` all expect. An empty list writes nothing at all. + + Args: + entries: The entries to write. + """ + + self.raw("".join(f"{entry}\n" for entry in entries)) + + def json(self, data: Any) -> None: + """ + Write structured data as JSON. + + `rich` highlights it for a terminal and falls back to plain text when stdout is a pipe or a file, so the same call serves a person and `jq` alike. It also never wraps, so a long value is not split across lines. A value JSON has no form for, such as a date `pyyaml` parsed, is written as its text rather than refused. + + Args: + data: The data to write. + """ + + self.console.print_json(data=data, default=str) + + def value(self, value: Any) -> None: + """ + Write one value for a pipe, bare when it is plain and as JSON when it has structure. + + A string or a number is written exactly as it reads, with no quoting. A mapping, a list, a boolean, or a null goes out as JSON, since its own text would be Python's spelling rather than anything a shell could parse. + + Args: + value: The value to write. + """ + + if value is None or isinstance(value, (dict, list, tuple, bool)): + self.json(value) + return + + self.raw(f"{value}\n") + def warn(self, message: str) -> None: """ Report a non-fatal warning. diff --git a/tests/test_cli.py b/tests/test_cli.py index 570b49c..8405257 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6,6 +6,7 @@ # MARK: Imports +import json import os from pathlib import Path from typing import Iterator, Optional @@ -13,9 +14,12 @@ import pytest from docket.cli import ( + ACCESSORS, EXIT_INVALID, EXIT_OK, EXIT_USAGE, + FIELD_ACCESSORS, + FIELD_READERS, TICKET_COMMAND, TOKEN_ID, TOKEN_KEY, @@ -35,6 +39,8 @@ from docket.core.errors import ConflictingArgumentsError, InvalidArgumentError, InvalidIdError from docket.core.handoff import HANDOFF_FILENAME from docket.core.roadmap import ROADMAP_FILENAME +from docket.core.store import Store +from docket.core.ticket import CANONICAL_FIELDS # MARK: Fixtures @@ -546,16 +552,50 @@ def testMetaReadingAnUnsetKeyFails(inRepo: Path, capsys: pytest.CaptureFixture[s assert "video" in captured.err -def testMetaWithNoMetadataSaysSo(inRepo: Path, capsys: pytest.CaptureFixture[str]) -> None: +def testMetaPrintsTheWholeMapAsJson(inRepo: Path, capsys: pytest.CaptureFixture[str]) -> None: """ - A ticket with an empty metadata map is stated rather than printed as a bare table. + The whole map goes out as JSON with no styling once stdout is not a terminal, so it parses exactly as written. + """ + + main(["new", "CORE", "Skirmish Setup"]) + main(["CORE-1", "meta", "video", "2026-01-devlog"]) + main(["CORE-1", "meta", "clip", "intro"]) + capsys.readouterr() + + assert main(["CORE-1", "meta"]) == EXIT_OK + + out: str = capsys.readouterr().out + + assert json.loads(out) == {"video": "2026-01-devlog", "clip": "intro"} + assert "\x1b" not in out + + +def testMetaWithNoMetadataPrintsAnEmptyObject(inRepo: Path, capsys: pytest.CaptureFixture[str]) -> None: + """ + An empty map is still valid JSON rather than a sentence, so a pipe into a parser never breaks on it. """ main(["new", "CORE", "Skirmish Setup"]) capsys.readouterr() assert main(["CORE-1", "meta"]) == EXIT_OK - assert "No metadata." in capsys.readouterr().out + assert json.loads(capsys.readouterr().out) == {} + + +def testMetaWithAKeyPrintsAStructuredValueAsJson(inRepo: Path, capsys: pytest.CaptureFixture[str]) -> None: + """ + A value with structure goes out as JSON rather than in Python's own spelling, which no shell tool could parse. + """ + + main(["new", "CORE", "Skirmish Setup"]) + main(["CORE-1", "meta", "video", "x"]) + capsys.readouterr() + + # The CLI only writes strings, so a structured value is one written through the store, the way an MCP caller would. + Store(loadConfig(inRepo / ".docket.toml")).setMetadata(ticketId="CORE-1", key="clip", value={"start": 12, "end": 40}) + + assert main(["CORE-1", "meta", "clip"]) == EXIT_OK + assert json.loads(capsys.readouterr().out) == {"start": 12, "end": 40} def testMetaClearsAKeyWithTheFlag(inRepo: Path, capsys: pytest.CaptureFixture[str]) -> None: @@ -571,7 +611,7 @@ def testMetaClearsAKeyWithTheFlag(inRepo: Path, capsys: pytest.CaptureFixture[st capsys.readouterr() main(["CORE-1", "meta"]) - assert "No metadata." in capsys.readouterr().out + assert json.loads(capsys.readouterr().out) == {} def testMetaRejectsAValueTogetherWithClear(inRepo: Path, capsys: pytest.CaptureFixture[str]) -> None: @@ -715,6 +755,97 @@ def testReadyOnAnUnknownTicketFails(inRepo: Path, capsys: pytest.CaptureFixture[ assert "CORE-99" in capsys.readouterr().err +def testEveryFrontmatterFieldHasAnAccessor() -> None: + """ + A frontmatter field the CLI cannot read is a field a script cannot reach, so adding one without an accessor fails here rather than going unnoticed. + """ + + assert set(FIELD_ACCESSORS) == set(CANONICAL_FIELDS) + + # Every accessor named must be a command the ticket branch actually registers. + ticketCommands: set[str] = {*ACCESSORS, "meta"} + for accessor in FIELD_ACCESSORS.values(): + assert accessor is None or accessor in ticketCommands + + +def testEveryAccessorHasAReader() -> None: + """ + The grammar and the handler each hold one side of an accessor, so they must name exactly the same set. + """ + + assert set(FIELD_READERS) == set(ACCESSORS) + + +@pytest.mark.parametrize( + ("accessor", "expected"), + [ + ("title", "Deployment\n"), + ("status", "todo\n"), + ("priority", "3\n"), + ("key", "CORE\n"), + ("requires", "CORE-1\nGEN-1\n"), + ("ready", "false\n"), + ], +) +def testAnAccessorPrintsOnlyItsValue(inRepo: Path, capsys: pytest.CaptureFixture[str], accessor: str, expected: str) -> None: + """ + Every accessor yields the bare value and nothing else, so a shell can read the answer as easily as a person can. + """ + + main(["new", "CORE", "Skirmish Setup"]) + main(["new", "GEN", "Map Generator"]) + main(["new", "CORE", "Deployment", "--requires", "CORE-1,GEN-1", "--priority", "3"]) + capsys.readouterr() + + assert main(["CORE-2", accessor]) == EXIT_OK + + out: str = capsys.readouterr().out + + assert out == expected + assert "\x1b" not in out + + +def testRequiredByPrintsTheReverseDirection(inRepo: Path, capsys: pytest.CaptureFixture[str]) -> None: + """ + The file never stores what depends on it, so the reverse side is derived and printed one id per line like its forward counterpart. + """ + + main(["new", "CORE", "Skirmish Setup"]) + main(["new", "CORE", "Deployment", "--requires", "CORE-1"]) + main(["new", "CORE", "Victory", "--requires", "CORE-1"]) + capsys.readouterr() + + assert main(["CORE-1", "required-by"]) == EXIT_OK + assert capsys.readouterr().out == "CORE-2\nCORE-3\n" + + +@pytest.mark.parametrize("accessor", ["requires", "required-by"]) +def testAnEmptyIdListPrintsNothing(inRepo: Path, capsys: pytest.CaptureFixture[str], accessor: str) -> None: + """ + No ids is no lines, so a loop over the output runs zero times rather than once over a placeholder. + """ + + main(["new", "CORE", "Skirmish Setup"]) + capsys.readouterr() + + assert main(["CORE-1", accessor]) == EXIT_OK + assert capsys.readouterr().out == "" + + +@pytest.mark.parametrize("accessor", sorted(ACCESSORS)) +def testAnAccessorOnAnUnknownTicketFails(inRepo: Path, capsys: pytest.CaptureFixture[str], accessor: str) -> None: + """ + A ticket that does not exist has nothing to read, so every accessor fails the way naming an unknown ticket always does, with nothing on stdout. + """ + + assert main(["CORE-99", accessor]) == EXIT_USAGE + + captured = capsys.readouterr() + + assert captured.out == "" + assert "CORE-99" in captured.err + + def testListReadyKeepsOnlyUnblockedTickets(inRepo: Path, capsys: pytest.CaptureFixture[str]) -> None: """ The filter answers "what can I pick up right now", so a blocked ticket and a finished one both fall out of it.