diff --git a/README.md b/README.md index 134d782..12e900c 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,8 @@ Unknown fields are round-tripped untouched. Filenames are frozen at creation so A ticket id is the command: ```bash -docket CORE-14 # show it, dependency context and all +docket CORE-14 # show it, dependency context and all, with the body rendered as Markdown +docket CORE-14 show --plain # the same content as bare text, body left raw 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] @@ -101,6 +102,7 @@ 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 body # the raw Markdown body, no frontmatter docket CORE-14 meta # the whole map as JSON docket CORE-14 meta KEY # one value, bare, or JSON when it has structure ``` diff --git a/docs/tickets/todo/FEAT-16_ticketShowUsesOptionalFormatting.md b/docs/tickets/done/FEAT-16_ticketShowUsesOptionalFormatting.md similarity index 97% rename from docs/tickets/todo/FEAT-16_ticketShowUsesOptionalFormatting.md rename to docs/tickets/done/FEAT-16_ticketShowUsesOptionalFormatting.md index 47b2a35..4956c4a 100644 --- a/docs/tickets/todo/FEAT-16_ticketShowUsesOptionalFormatting.md +++ b/docs/tickets/done/FEAT-16_ticketShowUsesOptionalFormatting.md @@ -1,7 +1,7 @@ --- id: FEAT-16 title: Ticket Show Uses Optional Formatting -status: todo +status: done priority: 2 requires: [] metadata: {} diff --git a/roadmap.md b/roadmap.md index e920db5..18199e1 100644 --- a/roadmap.md +++ b/roadmap.md @@ -11,13 +11,12 @@ graph TD FEAT_6["FEAT-6
Templates for Tickets
per Key
p3 todo"] 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"] end FEAT_6 --> FEAT_12 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_12,FEAT_16 todoP2 + class FEAT_12 todoP2 class FEAT_6 todoP3 class BUG_6,FEAT_8 todoP4 ``` diff --git a/src/docket/__init__.py b/src/docket/__init__.py index c02698b..3a37944 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.7.0" +__version__ = "1.8.0" diff --git a/src/docket/cli/commands.py b/src/docket/cli/commands.py index ed1586a..bfac14c 100644 --- a/src/docket/cli/commands.py +++ b/src/docket/cli/commands.py @@ -16,7 +16,7 @@ from rich.text import Text 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.cli.output import STATUS_STYLES, Output, buildContextTable, buildTicketBody, buildTicketPanel, plainTicket, relativeToRoot from docket.core.config import Config, discoverConfig from docket.core.deploy import DeployReport, deploy, upgrade from docket.core.handoff import HANDOFF_FILENAME, renderHandoff @@ -30,7 +30,7 @@ # 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. +# 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, or for the lines of the body, which `commandField` prints one per line so an empty one prints nothing at all. FIELD_READERS: dict[str, Callable[[Store, Ticket], object]] = { "title": lambda store, ticket: ticket.title, "status": lambda store, ticket: ticket.status, @@ -39,6 +39,7 @@ "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, + "body": lambda store, ticket: ticket.trimmedBody.splitlines(), } # MARK: Functions @@ -111,7 +112,7 @@ def commandShow(args: argparse.Namespace, store: Store, output: Output) -> int: """ Show a ticket with its resolved dependency context. - The raw file carries bare ids in one direction only, so this resolves the titles and statuses the file deliberately does not duplicate. Use `cat` for the raw file. + The raw file carries bare ids in one direction only, so this resolves the titles and statuses the file deliberately does not duplicate. The body is rendered as Markdown unless `--plain` asks for the same content as bare text. Use `cat` for the raw file. Args: args: The parsed arguments. @@ -124,10 +125,16 @@ def commandShow(args: argparse.Namespace, store: Store, output: Output) -> int: loaded: TicketSet = store.loadAll() ticket: Ticket = loaded.get(args.id) - context = dependencyContext(loaded, args.id) + context: dict[str, list[dict[str, object]]] = dependencyContext(loaded, args.id) + root: Path = store.config.repoRoot - output.print(Text(f"{ticket.id} {ticket.title}", style="bold")) - output.print(f"status [{STATUS_STYLES.get(ticket.status, 'white')}]{ticket.status}[/] priority {ticket.priority} key {ticket.key}") + # Plain carries everything the styled form does, only without the styling or the rendering. + if args.plain: + output.raw(plainTicket(ticket, context, root)) + + return EXIT_OK + + output.print(buildTicketPanel(ticket, root)) # Show both directions, since the reverse one is the whole reason the file can afford to store only forward edges. output.print("") @@ -136,7 +143,7 @@ def commandShow(args: argparse.Namespace, store: Store, output: Output) -> int: output.print(buildContextTable("Required by", context["requiredBy"])) output.print("") - output.print(ticket.body.strip("\n")) + output.print(buildTicketBody(ticket)) return EXIT_OK diff --git a/src/docket/cli/grammar.py b/src/docket/cli/grammar.py index e6f00c7..431f62c 100644 --- a/src/docket/cli/grammar.py +++ b/src/docket/cli/grammar.py @@ -52,6 +52,7 @@ "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.", + "body": "Print the ticket's body as raw Markdown, with no frontmatter. Prints nothing when it is empty.", } # 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. @@ -409,7 +410,11 @@ def buildTicketParser(commands: argparse._SubParsersAction, priorityOptions: str # Not required, so a bare id parses and falls through to showing the ticket. 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) + # A bare id never reaches the show parser, so the ticket parser carries the default it would otherwise leave unset. + ticketParser.set_defaults(plain=False) + + showParser: argparse.ArgumentParser = ticketCommands.add_parser("show", help="Show the ticket with its resolved dependency context. This is what a bare id does.", formatter_class=RichHelpFormatter) + showParser.add_argument("--plain", action="store_true", help="Write the same content as bare text, with no styling and the body left as raw Markdown. Use `cat` for the raw file.") # 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(): diff --git a/src/docket/cli/output.py b/src/docket/cli/output.py index d69cab7..602a105 100644 --- a/src/docket/cli/output.py +++ b/src/docket/cli/output.py @@ -6,19 +6,27 @@ # MARK: Imports +import json import sys from pathlib import Path from typing import Any, Iterable, Optional from rich.console import Console +from rich.markdown import Markdown +from rich.panel import Panel from rich.table import Table from rich.text import Text +from docket.core.ticket import Ticket + # MARK: Constants # Styles for the status column, matching the intent of the mermaid classes without depending on them. STATUS_STYLES: dict[str, str] = {"todo": "dim", "wip": "yellow", "done": "green"} +# The columns of a dependency table, shared by its styled and plain forms so the two cannot drift. +CONTEXT_COLUMNS: tuple[str, str, str] = ("ID", "STATUS", "TITLE") + # MARK: Classes @@ -97,7 +105,7 @@ def value(self, value: Any) -> None: value: The value to write. """ - if value is None or isinstance(value, (dict, list, tuple, bool)): + if isStructured(value): self.json(value) return @@ -127,40 +135,201 @@ def error(self, message: str) -> None: # MARK: Functions -def buildContextTable(heading: str, entries: list[dict[str, object]]) -> Table: +def isStructured(value: Any) -> bool: """ - Build the table showing one direction of a ticket's resolved dependencies. + Report whether a value has structure that only JSON can spell for a reader. + + A mapping, a list, a boolean, or a null reads as Python's spelling when turned into text, which is neither what was written nor anything a shell could parse. Args: - heading: What to title the table. - entries: The resolved records. + value: The value to classify. Returns: - The table. + Whether the value should be written as JSON. """ - table: Table = Table(title=heading, title_justify="left", box=None, pad_edge=False, title_style="bold") - table.add_column("ID") - table.add_column("STATUS") - table.add_column("TITLE") + return value is None or isinstance(value, (dict, list, tuple, bool)) - if not entries: - table.add_row("[dim]none[/dim]", "", "") - return table +def describeValue(value: Any) -> str: + """ + Describe one value on a single line, as compact JSON when it has structure. + + Args: + value: The value to describe. + + Returns: + The value's text. + """ + + if isStructured(value): + return json.dumps(value, default=str) + + return str(value) + + +def ticketFieldRows(ticket: Ticket, root: Path) -> list[tuple[Text, Text]]: + """ + Build the labeled rows describing a ticket's frontmatter, for both the styled and plain forms of `show`. + + A map contributes one row per entry, labeled only on its first, so a long map reads as one group. An empty map contributes nothing. + + Args: + ticket: The ticket to describe. + root: The directory to describe the ticket's path against. + + Returns: + The label and value of each row. + """ + + # Style lives on the text itself, so the plain form only has to drop it. + rows: list[tuple[Text, Text]] = [ + (Text("Status"), Text(ticket.status, style=STATUS_STYLES.get(ticket.status, "white"))), + (Text("Priority"), Text(str(ticket.priority))), + (Text("Key"), Text(ticket.key)), + (Text("File"), Text(relativeToRoot(ticket.path, root))), + ] + + # Both maps are free-form, so every entry is shown rather than a chosen few. + for label, entries in (("Metadata", ticket.metadata), ("Extra", ticket.extra)): + for index, (name, value) in enumerate(entries.items()): + rows.append((Text(label if index == 0 else ""), Text(f"{name}: {describeValue(value)}"))) + + return rows + + +def buildTicketPanel(ticket: Ticket, root: Path) -> Panel: + """ + Build the panel heading a shown ticket, titled with its id and title and holding its frontmatter. + + Args: + ticket: The ticket to describe. + root: The directory to describe the ticket's path against. + + Returns: + The panel. + """ + + grid: Table = Table.grid(padding=(0, 2)) + grid.add_column(style="bold") + grid.add_column() + + for label, value in ticketFieldRows(ticket, root): + grid.add_row(label, value) + + return Panel(grid, title=Text(f"{ticket.id} {ticket.title}", style="bold"), title_align="left", expand=False) + + +def buildTicketBody(ticket: Ticket) -> Markdown: + """ + Build the rendered form of a ticket's body. + + Args: + ticket: The ticket whose body to render. + + Returns: + The body as rendered Markdown. + """ + + return Markdown(ticket.trimmedBody) + +def contextRows(entries: list[dict[str, object]]) -> list[tuple[Text, Text, Text]]: + """ + Build the rows of one direction of a ticket's resolved dependencies, for both the styled and plain forms of `show`. + + Args: + entries: The resolved records. + + Returns: + The id, status, and title of each row. + """ + + if not entries: + return [(Text("none", style="dim"), Text(""), Text(""))] + + rows: list[tuple[Text, Text, Text]] = [] for entry in entries: # A dependency naming a missing id is shown rather than hidden, since a broken link the reader cannot see is worse than one they can. if not entry["exists"]: - table.add_row(str(entry["id"]), Text("missing", style="bold red"), "[dim]no such ticket[/dim]") + rows.append((Text(str(entry["id"])), Text("missing", style="bold red"), Text("no such ticket", style="dim"))) continue status: str = str(entry["status"]) - table.add_row(str(entry["id"]), Text(status, style=STATUS_STYLES.get(status, "white")), str(entry["title"])) + rows.append((Text(str(entry["id"])), Text(status, style=STATUS_STYLES.get(status, "white")), Text(str(entry["title"])))) + + return rows + + +def buildContextTable(heading: str, entries: list[dict[str, object]]) -> Table: + """ + Build the table showing one direction of a ticket's resolved dependencies. + + Args: + heading: What to title the table. + entries: The resolved records. + + Returns: + The table. + """ + + table: Table = Table(title=heading, title_justify="left", box=None, pad_edge=False, title_style="bold", header_style="bold dim") + for column in CONTEXT_COLUMNS: + table.add_column(column) + + for row in contextRows(entries): + table.add_row(*row) return table +def plainTicket(ticket: Ticket, context: dict[str, list[dict[str, object]]], root: Path) -> str: + """ + Describe a shown ticket as bare text, carrying the same content as the styled form with no styling or rendering. + + The body goes out exactly as written, so the Markdown survives for whatever reads it next. + + Args: + ticket: The ticket to describe. + context: The ticket's resolved dependency context. + root: The directory to describe the ticket's path against. + + Returns: + The text, ending in a newline. + """ + + # Head it the way the panel does, with the rows aligned the way its grid aligns them. + fieldRows: list[tuple[str, ...]] = [(label.plain, value.plain) for label, value in ticketFieldRows(ticket, root)] + sections: list[str] = [f"{ticket.id} {ticket.title}\n{_alignRows(fieldRows)}"] + + # Both directions follow, each under its own heading with the same columns as the styled table. + for heading, entries in (("Requires", context["requires"]), ("Required by", context["requiredBy"])): + rows: list[tuple[str, ...]] = [CONTEXT_COLUMNS, *(tuple(cell.plain for cell in row) for row in contextRows(entries))] + sections.append(f"{heading}\n{_alignRows(rows)}") + + # The body goes last and untouched, and an empty one adds nothing rather than a stray blank section. + if ticket.trimmedBody: + sections.append(ticket.trimmedBody) + + return "\n\n".join(sections) + "\n" + + +def _alignRows(rows: list[tuple[str, ...]]) -> str: + """ + Align rows of cells into columns separated by two spaces, the way the styled grids space them. + + Args: + rows: The rows to align. + + Returns: + One line per row, joined by newlines, with no trailing whitespace. + """ + + widths: list[int] = [max(len(row[column]) for row in rows) for column in range(len(rows[0]))] + + return "\n".join(" ".join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip() for row in rows) + + def relativeToRoot(path: Optional[Path], root: Path) -> str: """ Describe a path relative to a repository root, so output stays readable in a narrow terminal. diff --git a/src/docket/core/ticket.py b/src/docket/core/ticket.py index e990320..0c54fdb 100644 --- a/src/docket/core/ticket.py +++ b/src/docket/core/ticket.py @@ -96,6 +96,14 @@ def isDone(self) -> bool: return self.status == STATUS_DONE + @property + def trimmedBody(self) -> str: + """ + The body without the blank lines framing it, which is how every reader presents it. + """ + + return self.body.strip("\n") + # MARK: Functions def toFrontmatter(self) -> dict[str, Any]: diff --git a/tests/test_cli.py b/tests/test_cli.py index 8405257..c67555c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -188,6 +188,130 @@ def testShowReportsAMissingDependency(inRepo: Path, capsys: pytest.CaptureFixtur assert "missing" in capsys.readouterr().out +def testShowRendersTheBodyAsMarkdown(inRepo: Path, capsys: pytest.CaptureFixture[str]) -> None: + """ + The body is rendered rather than printed, so its Markdown syntax does not reach the reader. + """ + + main(["new", "CORE", "App Shell", "--body", "Goal: a **window** that `opens`."]) + capsys.readouterr() + + main(["CORE-1", "show"]) + + out: str = capsys.readouterr().out + + assert "Goal: a window that opens." in out + assert "**" not in out + assert "`" not in out + + +def testShowListsTheFileAndFrontmatter(inRepo: Path, capsys: pytest.CaptureFixture[str]) -> None: + """ + The header carries every field the file does, along with where the file lives. + """ + + main(["new", "CORE", "App Shell", "--priority", "1"]) + capsys.readouterr() + + main(["CORE-1", "show"]) + + out: str = capsys.readouterr().out + + assert "CORE-1 App Shell" in out + assert "Priority" in out + assert "CORE-1_appShell.md" in out + + +def testShowListsMetadataAndExtraFields(inRepo: Path, capsys: pytest.CaptureFixture[str]) -> None: + """ + Both free-form maps are shown entry by entry, with structured values written as compact JSON. + """ + + main(["new", "CORE", "App Shell"]) + main(["CORE-1", "meta", "video", "2026-01-devlog"]) + + # Add a field the schema does not recognize, the way a consumer repo would extend it. + path: Path = inRepo / "docs" / "tickets" / "todo" / "CORE-1_appShell.md" + path.write_text(path.read_text(encoding="utf-8").replace("---\n", "---\nowners: [ana, ben]\n", 1), encoding="utf-8") + capsys.readouterr() + + main(["CORE-1", "show"]) + + out: str = capsys.readouterr().out + + assert "Metadata" in out + assert "video: 2026-01-devlog" in out + assert "Extra" in out + assert 'owners: ["ana", "ben"]' in out + + +def testShowHidesEmptyMaps(inRepo: Path, capsys: pytest.CaptureFixture[str]) -> None: + """ + An empty map adds nothing, rather than a label with no value beside it. + """ + + main(["new", "CORE", "App Shell"]) + capsys.readouterr() + + main(["CORE-1", "show"]) + + out: str = capsys.readouterr().out + + assert "Metadata" not in out + assert "Extra" not in out + + +def testShowPlainLeavesTheBodyRaw(inRepo: Path, capsys: pytest.CaptureFixture[str]) -> None: + """ + `--plain` keeps the resolved context but writes the body exactly as it was written, Markdown and all. + """ + + main(["new", "CORE", "App Shell"]) + main(["new", "CORE", "Skirmish Setup", "--requires", "CORE-1", "--body", "Goal: a **window** that `opens`."]) + capsys.readouterr() + + assert main(["CORE-2", "show", "--plain"]) == EXIT_OK + + out: str = capsys.readouterr().out + + assert out.startswith("CORE-2 Skirmish Setup\n") + assert "Requires\nID STATUS TITLE\nCORE-1 todo App Shell\n" in out + assert "Required by\nID STATUS TITLE\nnone\n" in out + assert out.endswith("Goal: a **window** that `opens`.\n") + + +def testShowPlainListsTheSameFields(inRepo: Path, capsys: pytest.CaptureFixture[str]) -> None: + """ + Plain drops the styling, never the content, so every header row appears aligned as it does in the panel. + """ + + main(["new", "CORE", "App Shell"]) + main(["CORE-1", "meta", "video", "2026-01-devlog"]) + main(["CORE-1", "meta", "clip", "intro"]) + capsys.readouterr() + + main(["CORE-1", "show", "--plain"]) + + out: str = capsys.readouterr().out + + assert "Status todo\n" in out + assert "Metadata video: 2026-01-devlog\n clip: intro\n" in out + + +def testShowPlainWritesNoEscapeSequences(inRepo: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None: + """ + Plain bypasses `rich` entirely, so a forced color terminal still receives bare text. + """ + + monkeypatch.setenv("FORCE_COLOR", "1") + main(["new", "CORE", "App Shell"]) + capsys.readouterr() + + main(["CORE-1", "show", "--plain"]) + + assert "\x1b[" not in capsys.readouterr().out + + def testShowOnAnUnknownIdFails(inRepo: Path, capsys: pytest.CaptureFixture[str]) -> None: """ Showing a ticket that does not exist is an error. @@ -832,6 +956,41 @@ def testAnEmptyIdListPrintsNothing(inRepo: Path, capsys: pytest.CaptureFixture[s assert capsys.readouterr().out == "" +def testBodyPrintsTheRawMarkdown(inRepo: Path, capsys: pytest.CaptureFixture[str]) -> None: + """ + The body goes out exactly as written, with none of the frontmatter above it and none of the rendering `show` applies. + """ + + main(["new", "CORE", "App Shell", "--body", "Goal: a **window**.\n\nThen a `menu`."]) + capsys.readouterr() + + assert main(["CORE-1", "body"]) == EXIT_OK + + out: str = capsys.readouterr().out + + assert out.startswith("# App Shell\n") + assert out.endswith("Goal: a **window**.\n\nThen a `menu`.\n") + assert "title:" not in out + assert "\x1b" not in out + + +def testAnEmptyBodyPrintsNothing(inRepo: Path, capsys: pytest.CaptureFixture[str]) -> None: + """ + No body is no lines, the same way no ids is, rather than one blank line. + """ + + main(["new", "CORE", "App Shell"]) + + # Cut everything after the closing delimiter, leaving a ticket that is only frontmatter. + path: Path = inRepo / "docs" / "tickets" / "todo" / "CORE-1_appShell.md" + text: str = path.read_text(encoding="utf-8") + path.write_text(text[: text.index("---\n", 4) + 4], encoding="utf-8") + capsys.readouterr() + + assert main(["CORE-1", "body"]) == EXIT_OK + assert capsys.readouterr().out == "" + + @pytest.mark.parametrize("accessor", sorted(ACCESSORS)) def testAnAccessorOnAnUnknownTicketFails(inRepo: Path, capsys: pytest.CaptureFixture[str], accessor: str) -> None: """ @@ -1570,11 +1729,11 @@ def testShorthandFlagsDriveNewAndSet(inRepo: Path, capsys: pytest.CaptureFixture capsys.readouterr() - main(["GEN-1"]) + main(["GEN-1", "show", "--plain"]) out: str = capsys.readouterr().out assert "Renamed" in out - assert "priority 3" in out + assert "Priority 3" in out def testShorthandFlagsDriveListAndGraph(inRepo: Path, capsys: pytest.CaptureFixture[str]) -> None: