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: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ Everything else works on the set:
```bash
docket new CORE "Skirmish setup" [-r CORE-9,GEN-3] [-p 1] [-b TEXT]
docket list [-s todo] [-k CORE] [-m 2] [-r]
docket graph [-i CORE-14 | -k GEN] [-o FILE]
docket graph [-i CORE-14 | -k GEN | -s todo] [-o FILE]
docket key list | add KEY "desc" [-r TEXT] | remove KEY
docket validate | deploy PATH | upgrade PATH
```
Expand All @@ -121,7 +121,7 @@ Every short flag has a long form (`-k/--key`, `-p/--priority`, `-m/--priority-ma
| `create_ticket(key, title, body?, requires?, priority?)` | Allocates the id, writes the file. |
| `update_ticket(id, title?, priority?, requires?, requires_add?, requires_remove?)` | Those three fields only. |
| `set_status(id, status)` | Writes frontmatter and moves the file together. |
| `graph(id?, key?)` | Mermaid source. |
| `graph(id?, key?, status?)` | Mermaid source. |
| `list_keys()` | The registered keys. |
| `add_key(key, description, rationale)` | After the agent has asked the user. |
| `validate()` | Structured findings. |
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
id: FEAT-17
title: Add Status to Graph Scope
status: todo
status: done
priority: 1
requires: []
metadata: {}
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.2.0"
__version__ = "1.3.0"
9 changes: 6 additions & 3 deletions src/docket/cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from docket.cli.output import STATUS_STYLES, Output, buildContextTable, relativeToRoot
from docket.core.config import Config
from docket.core.deploy import DeployReport, deploy, upgrade
from docket.core.graph import Readiness, ResolvedGraph, dependencyContext, readyTickets, resolveGraph, subgraphForId, subgraphForKey, ticketReadiness
from docket.core.graph import Readiness, ResolvedGraph, dependencyContext, readyTickets, resolveGraph, subgraphForId, subgraphForKey, subgraphForStatus, ticketReadiness
from docket.core.inputs import requireWritableFile, writeFile
from docket.core.mermaid import renderGraph
from docket.core.store import Store, TicketResult, TicketSet
Expand Down Expand Up @@ -325,17 +325,20 @@ def commandGraph(args: argparse.Namespace, store: Store, output: Output) -> int:
Returns the process exit code.
"""

ticketId, key = resolveGraphScope(args.scope, args.id, args.key)
ticketId, key, status = resolveGraphScope(args.scope, args.id, args.key, args.status)

graph: ResolvedGraph = resolveGraph(store.loadAll())

# Scope the graph when asked. At most one of the two survives resolution.
# Scope the graph when asked. At most one of the three survives resolution.
if ticketId is not None:
graph = subgraphForId(graph, ticketId)
elif key is not None:
# For the same reason as `list`, an unregistered key here would render an empty graph rather than admitting the key does not exist.
store.config.requireKnownKey(key)
graph = subgraphForKey(graph, key)
elif status is not None:
# No equivalent check, because the vocabulary is fixed and both spellings are checked against it before they arrive. An empty result here is a true answer rather than a typo.
graph = subgraphForStatus(graph, status)

source: str = renderGraph(graph)

Expand Down
27 changes: 17 additions & 10 deletions src/docket/cli/grammar.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,34 +184,40 @@ def resolveListFilters(tokens: list[str], status: Optional[str], key: Optional[s
return resolved[TOKEN_STATUS], resolved[TOKEN_KEY], None if ceiling is None else int(ceiling)


def resolveGraphScope(scope: Optional[str], ticketId: Optional[str], key: Optional[str]) -> tuple[Optional[str], Optional[str]]:
def resolveGraphScope(scope: Optional[str], ticketId: Optional[str], key: Optional[str], status: Optional[str]) -> tuple[Optional[str], Optional[str], Optional[str]]:
"""
Resolve the graph's scope from its bare token and its flags together.

An id and a key are told apart by shape, which is what makes `docket graph CORE-14` and `docket graph GEN` both unambiguous. The flags stay for the explicit form, and combining the two spellings is refused, since `argparse` can enforce that `--id` and `--key` exclude each other but cannot reach across to a positional.
An id, a key, and a status are told apart by shape, which is what makes `docket graph CORE-14`, `docket graph GEN`, and `docket graph todo` all unambiguous. The flags stay for the explicit form, and combining the two spellings is refused, since `argparse` can enforce that the flags exclude each other but cannot reach across to a positional.

The three scopes remain exclusive rather than composing. A graph is scoped to one thing, and narrowing an already narrowed graph is what `list`'s filters are for.

scope: The bare scope token, or `None`.
ticketId: The id named by `--id`, or `None`.
key: The key named by `--key`, or `None`.
status: The status named by `--status`, or `None`.

Returns the resolved `(id, key)` pair, at most one of which is set.
Returns the resolved `(id, key, status)` triple, at most one of which is set.
"""

if scope is None:
return ticketId, key
return ticketId, key, status

if ticketId is not None or key is not None:
raise ConflictingArgumentsError(f"'{scope}' already scopes the graph, so it cannot be combined with -i/--id or -k/--key.")
if ticketId is not None or key is not None or status is not None:
raise ConflictingArgumentsError(f"'{scope}' already scopes the graph, so it cannot be combined with -i/--id, -k/--key, or -s/--status.")

kind: Optional[str] = classifyToken(scope)

if kind == TOKEN_ID:
return scope, None
return scope, None, None

if kind == TOKEN_KEY:
return None, scope
return None, scope, None

if kind == TOKEN_STATUS:
return None, None, scope

raise InvalidArgumentError(f"Cannot read '{scope}' as a scope. Expected a ticket id, for example 'CORE-14', or a key.")
raise InvalidArgumentError(f"Cannot read '{scope}' as a scope. Expected a ticket id, for example 'CORE-14', a key, or a status ({', '.join(STATUSES)}).")


def buildParser(config: Optional[Config] = None) -> argparse.ArgumentParser:
Expand Down Expand Up @@ -254,10 +260,11 @@ def buildParser(config: Optional[Config] = None) -> argparse.ArgumentParser:
listParser.add_argument("-r", "--ready", action="store_true", help="Keep only tickets whose dependencies are all done. A done ticket is never ready, so this never shows one.")

graphParser: argparse.ArgumentParser = commands.add_parser("graph", help="Render the dependency graph as mermaid source.", formatter_class=RichHelpFormatter)
graphParser.add_argument("scope", nargs="?", metavar="SCOPE", help="What to scope to, read from its own shape: a ticket id, or a key. The flags below are the same two, named explicitly.")
graphParser.add_argument("scope", nargs="?", metavar="SCOPE", help=f"What to scope to, read from its own shape: a ticket id, a key, or a status ({', '.join(STATUSES)}). The flags below are the same three, named explicitly.")
graphScope = graphParser.add_mutually_exclusive_group()
graphScope.add_argument("-i", "--id", help="Scope to one ticket's ancestors and descendants.")
graphScope.add_argument("-k", "--key", help=f"Scope to one key, plus its immediate cross-key neighbors. {keyOptions}")
graphScope.add_argument("-s", "--status", choices=STATUSES, help="Scope to the tickets with this status alone. Nothing outside it is borrowed, so an edge survives only when both of its ends carry the status.")
graphParser.add_argument("-o", "--out", help="Write to a file rather than to stdout.")

keyParser: argparse.ArgumentParser = commands.add_parser("key", help="Inspect and manage the key registry.", formatter_class=RichHelpFormatter)
Expand Down
17 changes: 17 additions & 0 deletions src/docket/core/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,23 @@ def subgraphForKey(graph: ResolvedGraph, key: str) -> ResolvedGraph:
return scoped


def subgraphForStatus(graph: ResolvedGraph, status: str) -> ResolvedGraph:
"""
Scope a graph to the tickets carrying one status, and nothing else.

Nothing is borrowed from outside, unlike the key scope. A key has a boundary worth drawing, since the work either side of it is still related, but the tickets around a status are only the same work at a different moment, so pulling them in would put every other status back on the page. An edge therefore survives only when both of its ends carry the status, which is what lets the result read as the ordering within that status alone.

graph: The graph to scope.
status: The status to scope to.

Returns the scoped graph.
"""

members: set[str] = {node.id for node in graph.nodes.values() if node.status == status}

return _restrict(graph, members, scope=status)


def dependencyContext(ticketSet: TicketSet, ticketId: str) -> dict[str, list[dict[str, object]]]:
"""
Resolve the context a raw ticket file deliberately does not carry.
Expand Down
129 changes: 117 additions & 12 deletions src/docket/core/mermaid.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@

# MARK: Imports

import textwrap

from docket.core.graph import Edge, GraphNode, ResolvedGraph
from docket.core.ticket import STATUSES

# MARK: Constants

Expand All @@ -26,6 +27,32 @@
"done": "fill:#2d6a4f,color:#fff",
}

# The delimiters that shape a node, so status survives in a renderer that ignores `classDef` entirely, the same way priority does by sitting in the label. Work in flight reads as a hexagon and finished work as a rounded rectangle, leaving the plain rectangle for what has not been started.
STATUS_SHAPES: dict[str, tuple[str, str]] = {
"todo": ("[", "]"),
"wip": ("{{", "}}"),
"done": ("(", ")"),
}

# The shape a status nobody recognizes takes. It is the plain one, for the same reason such a node takes no class: inventing a shape for it would claim to know what it means.
DEFAULT_SHAPE: tuple[str, str] = ("[", "]")

# The border a node takes from its priority, most urgent first, so urgency reads as weight and color and not only as the text in the label. A priority past the end of this takes the last entry, because the band's ceiling is configurable and this module cannot see it.
#
# The ramp drains toward neutral rather than cooling toward green, for two reasons. Green already means done here, so a low-priority todo would otherwise wear the color of finished work. And a red to green ramp is the one pairing a red-green colorblind reader cannot order at all, where draining to grey stays legible to everyone. Grey is also the honest end point, since the least urgent thing on the page wants no attention rather than a different kind of it.
#
# Width descends alongside the color, so the ordering survives being read in greyscale.
PRIORITY_STROKES: tuple[str, ...] = (
"stroke:#ff6b6b,stroke-width:4px",
"stroke:#ff922b,stroke-width:3px",
"stroke:#ffd43b,stroke-width:2px",
"stroke:#adb5bd,stroke-width:2px",
"stroke:#6c757d,stroke-width:1px",
)

# Where a long title wraps, in characters. Mermaid lays a label out on one line unless told otherwise, so one long title would stretch every node beside it.
LABEL_WRAP_WIDTH: int = 24

# The class marking a node borrowed from another key, dashed so the boundary reads at a glance.
EXTERNAL_CLASS: str = "external"
EXTERNAL_STYLE: str = "fill:#212529,color:#adb5bd,stroke-dasharray: 3 3"
Expand Down Expand Up @@ -73,14 +100,49 @@ def renderNode(node: GraphNode) -> str:
"""
Render one node declaration.

Priority is written into the label rather than carried by the class, so it survives in a renderer that ignores `classDef` entirely.
Status and priority are each said twice, once in a way a bare renderer keeps and once in a way it drops. The shape and the label survive anywhere, while the fill and the border are the richer reading for a renderer that honors `classDef`. So nothing a reader needs is only ever a color.

node: The node to render.

Returns the declaration line.
"""

return f'{sanitizeId(node.id)}["{escapeLabel(node.id)} {escapeLabel(node.title)}<br/>p{node.priority} {escapeLabel(node.status)}"]'
opening, closing = STATUS_SHAPES.get(node.status, DEFAULT_SHAPE)

return f'{sanitizeId(node.id)}{opening}"{renderLabel(node)}"{closing}'


def renderLabel(node: GraphNode) -> str:
"""
Build the label text for one node.

The id, the title, and the priority and status sit on their own lines rather than running together, since the id is what a reader scans for and a title beside it buries the id.

node: The node to label.

Returns the label, with its lines joined by mermaid's line break.
"""

lines: list[str] = [escapeLabel(node.id), *wrapLabel(node.title), f"p{node.priority} {escapeLabel(node.status)}"]

return "<br/>".join(lines)


def wrapLabel(text: str) -> list[str]:
"""
Split a title into label lines at word boundaries.

Wrapping happens before escaping, so an entity the escape introduces can neither be counted toward the width nor be broken across two lines. A single word longer than the width is left whole, because breaking an id or a path mid-word costs the reader more than the width does.

text: The title to wrap.

Returns the escaped lines, empty when there is no title to show.
"""

if not text.strip():
return []

return [escapeLabel(line) for line in textwrap.wrap(text, width=LABEL_WRAP_WIDTH, break_long_words=False, break_on_hyphens=False)]


def renderEdge(edge: Edge) -> str:
Expand All @@ -99,6 +161,8 @@ def renderStyles(graph: ResolvedGraph) -> list[str]:
"""
Render the class definitions and the class assignments for a graph.

A class carries the fill of a status and the border of a priority together, rather than a node taking one class for each. Combining them is what keeps every node to a single class, and it means only the combinations actually present are ever declared.

graph: The graph to style.

Returns the style lines, empty when there is nothing to style.
Expand All @@ -107,29 +171,70 @@ def renderStyles(graph: ResolvedGraph) -> list[str]:
if not graph.nodes:
return []

lines: list[str] = [f"{INDENT}classDef {status} {STATUS_STYLES[status]}" for status in STATUSES]

# Group nodes by the class they take, with external winning over status so the boundary stays visible.
styles: dict[str, str] = {}
grouped: dict[str, list[str]] = {}
for node in graph.nodes.values():
className: str = EXTERNAL_CLASS if node.isExternal else node.status

# A status that arrived by hand-editing has no class, and is left unstyled rather than inventing one.
if className != EXTERNAL_CLASS and className not in STATUS_STYLES:
if node.isExternal:
className, style = EXTERNAL_CLASS, EXTERNAL_STYLE
elif node.status in STATUS_STYLES:
className, style = statusClassName(node.status, node.priority), f"{STATUS_STYLES[node.status]},{priorityStroke(node.priority)}"
else:
# A status that arrived by hand-editing has no class, and is left unstyled rather than inventing one.
continue

styles[className] = style
grouped.setdefault(className, []).append(sanitizeId(node.id))

# Only declare the external class when something actually uses it.
if EXTERNAL_CLASS in grouped:
lines.append(f"{INDENT}classDef {EXTERNAL_CLASS} {EXTERNAL_STYLE}")
lines: list[str] = [f"{INDENT}classDef {className} {styles[className]}" for className in sorted(styles)]

for className in sorted(grouped):
lines.append(f"{INDENT}class {','.join(sorted(grouped[className]))} {className}")

return lines


def statusClassName(status: str, priority: int) -> str:
"""
Name the class carrying one status and priority pairing.

The priority is named by its band rather than by its number, so the class count stays bounded however high the configured ceiling goes.

status: The status the class fills for.
priority: The priority the class borders for.

Returns the class name.
"""

return f"{status}P{priorityBand(priority)}"


def priorityStroke(priority: int) -> str:
"""
Select the border for one priority.

priority: The priority to style.

Returns the stroke declaration.
"""

return PRIORITY_STROKES[priorityBand(priority)]


def priorityBand(priority: int) -> int:
"""
Clamp a priority to an index into `PRIORITY_STROKES`.

Everything past the end shares the lightest border, since the configured ceiling can sit anywhere above it and a band nobody can distinguish is not worth a class of its own.

priority: The priority to place.

Returns the index.
"""

return min(max(priority, 0), len(PRIORITY_STROKES) - 1)


def sanitizeId(ticketId: str) -> str:
"""
Convert a ticket id into a mermaid node identifier.
Expand Down
7 changes: 3 additions & 4 deletions src/docket/core/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@

from docket.core.atomic import writeTextAtomic
from docket.core.config import Config
from docket.core.errors import ConflictingArgumentsError, InvalidPriorityError, InvalidStatusError, TicketNotFoundError, TicketParseError
from docket.core.errors import ConflictingArgumentsError, InvalidPriorityError, TicketNotFoundError, TicketParseError
from docket.core.ids import buildFilename, nextId, parseId, requireValidKey
from docket.core.inputs import requireText
from docket.core.ticket import STATUS_DONE, STATUSES, Ticket, buildBody, parseTicket, serializeTicket
from docket.core.ticket import STATUS_DONE, STATUSES, Ticket, buildBody, parseTicket, requireKnownStatus, serializeTicket
from docket.core.titles import toTitleCase

# MARK: Constants
Expand Down Expand Up @@ -430,8 +430,7 @@ def setStatus(self, ticketId: str, status: str) -> Ticket:
"""

# Reject an unrecognized status at the boundary rather than writing it and leaving `validate` to find it later.
if status not in STATUSES:
raise InvalidStatusError(f"Status '{status}' is not one of {', '.join(STATUSES)}.")
requireKnownStatus(status)

with self.config.exclusiveLock():
ticket: Ticket = self.__loadAllUnlocked().get(ticketId)
Expand Down
19 changes: 18 additions & 1 deletion src/docket/core/ticket.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import yaml

from docket.core.errors import TicketParseError
from docket.core.errors import InvalidStatusError, TicketParseError
from docket.core.fields import readDict, readInt, readString, readStringList
from docket.core.ids import keyOf

Expand Down Expand Up @@ -158,6 +158,23 @@ def _representFlowList(dumper: yaml.SafeDumper, data: FlowList) -> yaml.Node:
TicketDumper.add_representer(FlowList, _representFlowList)


def requireKnownStatus(status: str) -> str:
"""
Return the status unchanged, raising when it is not one of the fixed vocabulary.

The vocabulary is closed, so every surface that accepts a status has the same check to make. It lives here beside the vocabulary itself rather than at each surface, the same way `requireKnownKey` sits beside the registry.

status: The status to check.

Returns the same status.
"""

if status not in STATUSES:
raise InvalidStatusError(f"Status '{status}' is not one of {', '.join(STATUSES)}.")

return status


def splitFrontmatter(text: str) -> tuple[str, str]:
"""
Split raw file text into its frontmatter block and its body.
Expand Down
Loading