Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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
```
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
id: FEAT-16
title: Ticket Show Uses Optional Formatting
status: todo
status: done
priority: 2
requires: []
metadata: {}
Expand Down
3 changes: 1 addition & 2 deletions roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,12 @@ graph TD
FEAT_6["FEAT-6<br/>Templates for Tickets<br/>per Key<br/>p3 todo"]
FEAT_8["FEAT-8<br/>Host Flag<br/>p4 todo"]
FEAT_12["FEAT-12<br/>Per Key Ticket Board<br/>View<br/>p2 todo"]
FEAT_16["FEAT-16<br/>Ticket Show Uses<br/>Optional Formatting<br/>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
```
Expand Down
2 changes: 1 addition & 1 deletion src/docket/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
21 changes: 14 additions & 7 deletions src/docket/cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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("")
Expand All @@ -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

Expand Down
7 changes: 6 additions & 1 deletion src/docket/cli/grammar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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():
Expand Down
199 changes: 184 additions & 15 deletions src/docket/cli/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading