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: 4 additions & 0 deletions .docket.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ doneDir = "done"
defaultPriority = 2
maxPriority = 4

# How many nodes `docket docs roadmap` aims to draw. Past this it drops the completed tickets furthest from the work still open.
# Set it to 0 for no ceiling, and remember that mermaid stops being readable well before a thousand nodes.
maxRoadmapNodes = 200

# How long, in seconds, one docket process waits for another to finish writing before giving up.
# Writes are serialized across processes, so a CLI command and a running MCP server never overwrite each other.
lockTimeout = 5.0
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,14 @@ docket list [-s todo] [-k CORE] [-m 2] [-r]
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
docket docs handoff [-o FILE]
docket docs handoff [-p] [-o FILE]
docket docs roadmap [CORE-14 | GEN | todo] [-m N] [-p] [-o FILE]
```

`docs` writes a file: `handoff.md` and `roadmap.md` at the repo root. `-p/--print` sends it to stdout instead, `-o` picks another path, and passing both writes the file and prints it. `graph` is the exception, printing unless you ask for a file, because its output is usually piped.

`docket docs roadmap` is the committable picture of the graph: a markdown file wrapping a mermaid diagram, plus the legend a renderer that ignores styling would otherwise leave you without. Past `maxRoadmapNodes` it drops the completed tickets furthest from the work still open, and never drops an open ticket, so the diagram stays readable without losing what is ahead.

`-r` replaces the dependency list. `-ra` and `-rr` edit the one already there. Both in one call is refused.

A ticket is ready when every id in its `requires` names a ticket that is `done`. A missing dependency blocks, and a `done` ticket is never ready, so `docket list -r` is the set you can pick up right now.
Expand Down Expand Up @@ -157,6 +162,7 @@ doneDir = "done"
defaultPriority = 2
maxPriority = 4
lockTimeout = 5.0
maxRoadmapNodes = 200

[keys]
# Primary arch
Expand All @@ -167,6 +173,8 @@ META = "campaign and progression"

A key's rationale becomes the comment above it, and removing the key takes the comment with it. The status vocabulary is deliberately not configurable.

`maxRoadmapNodes` is how many nodes `docket docs roadmap` aims to draw, and `0` turns the ceiling off.

`docket deploy` never rewrites an existing `.docket.toml`. Run `docket upgrade .` later to refresh the template and repair the server entry without touching your config or tickets.

## Concurrent Access
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
---
id: FEAT-14
title: Roadmap Graph Generation
status: todo
status: done
priority: 2
requires: [FEAT-6]
requires: []
metadata: {}
---

Expand Down
78 changes: 78 additions & 0 deletions roadmap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Roadmap

> Generated with `docket docs roadmap`.

```mermaid
graph TD
subgraph BUG
BUG_1("BUG-1<br/>Fix Character Encoding<br/>Issue in FEAT-5<br/>p1 done")
BUG_2("BUG-2<br/>Cannot Clear with Set<br/>Command<br/>p0 done")
BUG_3("BUG-3<br/>Migrate Server to MCP<br/>2.x MCPServer API<br/>p0 done")
BUG_4("BUG-4<br/>Add to Requires in CLI<br/>p1 done")
BUG_5("BUG-5<br/>Serialize Ticket Writes<br/>Across Processes<br/>p3 done")
BUG_6["BUG-6<br/>Key Removal Checks Usage<br/>Outside the Lock<br/>p4 todo"]
end
subgraph FEAT
FEAT_1("FEAT-1<br/>Record Demo GIF with VHS<br/>p2 done")
FEAT_2("FEAT-2<br/>Set Up and Publish to<br/>PyPI<br/>p3 done")
FEAT_3("FEAT-3<br/>All Tickets Graph<br/>p2 done")
FEAT_4("FEAT-4<br/>Shorthand for CLI<br/>p2 done")
FEAT_5("FEAT-5<br/>Selection Options in CLI<br/>p2 done")
FEAT_6["FEAT-6<br/>Templates for Tickets<br/>per Key<br/>p3 todo"]
FEAT_7("FEAT-7<br/>Unified Version Code<br/>p1 done")
FEAT_8["FEAT-8<br/>Host Flag<br/>p4 todo"]
FEAT_9("FEAT-9<br/>Track Arbitrary<br/>Additional Metadata on<br/>Tickets/Groups<br/>p1 done")
FEAT_10("FEAT-10<br/>Tree Style CLI Commands<br/>p2 done")
FEAT_11("FEAT-11<br/>Repository Automation<br/>and Contribution<br/>Scaffolding<br/>p3 done")
FEAT_12["FEAT-12<br/>Per Key Ticket Board<br/>View<br/>p2 todo"]
FEAT_13("FEAT-13<br/>Check if Ticket Is Ready<br/>for Work<br/>p1 done")
FEAT_14["FEAT-14<br/>Roadmap Graph Generation<br/>p2 todo"]
FEAT_15("FEAT-15<br/>Use Title Case for<br/>Tickets<br/>p1 done")
FEAT_16["FEAT-16<br/>Ticket Show Uses<br/>Optional Formatting<br/>p2 todo"]
FEAT_17("FEAT-17<br/>Add Status to Graph<br/>Scope<br/>p1 done")
FEAT_18("FEAT-18<br/>Offsite Ticket Authoring<br/>Brief<br/>p1 done")
FEAT_19["FEAT-19<br/>Add Google Style<br/>Documentation Comments<br/>p1 todo"]
end
BUG_1 --> FEAT_2
BUG_1 --> FEAT_5
BUG_2 --> FEAT_2
BUG_3 --> FEAT_2
BUG_4 --> FEAT_2
BUG_5 --> BUG_6
BUG_5 --> FEAT_2
FEAT_1 --> FEAT_2
FEAT_2 --> FEAT_11
FEAT_4 --> FEAT_2
FEAT_5 --> FEAT_2
FEAT_6 --> FEAT_12
FEAT_7 --> FEAT_2
FEAT_10 --> FEAT_2
classDef doneP0 fill:#2d6a4f,color:#fff,stroke:#ff6b6b,stroke-width:4px
classDef doneP1 fill:#2d6a4f,color:#fff,stroke:#ff922b,stroke-width:3px
classDef doneP2 fill:#2d6a4f,color:#fff,stroke:#ffd43b,stroke-width:2px
classDef doneP3 fill:#2d6a4f,color:#fff,stroke:#adb5bd,stroke-width:2px
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 BUG_2,BUG_3 doneP0
class BUG_1,BUG_4,FEAT_13,FEAT_15,FEAT_17,FEAT_18,FEAT_7,FEAT_9 doneP1
class FEAT_1,FEAT_10,FEAT_3,FEAT_4,FEAT_5 doneP2
class BUG_5,FEAT_11,FEAT_2 doneP3
class FEAT_19 todoP1
class FEAT_12,FEAT_14,FEAT_16 todoP2
class FEAT_6 todoP3
class BUG_6,FEAT_8 todoP4
```

## Legend

| Shape | Status |
|---|---|
| `[ ]` | todo, not started |
| `{ }` | wip, in flight |
| `( )` | done |

Arrows indicate required order of operations.

Each node's border represents its priority. Heaviest is higher priority.
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.4.0"
__version__ = "1.5.0"
8 changes: 8 additions & 0 deletions src/docket/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,16 @@
commandList,
commandMeta,
commandNew,
commandRoadmap,
commandSet,
commandShow,
commandStatus,
commandStatusRead,
commandTicket,
commandValidate,
documentPath,
emitDocument,
requireScopeKey,
)
from docket.cli.grammar import (
CLEAR_SENTINEL,
Expand All @@ -40,6 +43,7 @@
TOKEN_KEY,
TOKEN_PRIORITY,
TOKEN_STATUS,
addScopeArguments,
buildParser,
classifyToken,
describeKeys,
Expand Down Expand Up @@ -73,6 +77,7 @@
"TOKEN_PRIORITY",
"TOKEN_STATUS",
"Output",
"addScopeArguments",
"buildContextTable",
"buildParser",
"classifyToken",
Expand All @@ -83,6 +88,7 @@
"commandList",
"commandMeta",
"commandNew",
"commandRoadmap",
"commandSet",
"commandShow",
"commandStatus",
Expand All @@ -92,11 +98,13 @@
"describeKeys",
"describePriorities",
"dispatch",
"documentPath",
"emitDocument",
"main",
"parseEditIdList",
"parseIdList",
"relativeToRoot",
"requireScopeKey",
"resolveGraphScope",
"resolveListFilters",
"rewriteIdFirst",
Expand Down
126 changes: 94 additions & 32 deletions src/docket/cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,13 @@

from docket.cli.grammar import 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
from docket.core.config import Config, discoverConfig
from docket.core.deploy import DeployReport, deploy, upgrade
from docket.core.handoff import renderHandoff
from docket.core.graph import Readiness, ResolvedGraph, dependencyContext, readyTickets, resolveGraph, subgraphForId, subgraphForKey, subgraphForStatus, ticketReadiness
from docket.core.handoff import HANDOFF_FILENAME, renderHandoff
from docket.core.graph import Readiness, 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
from docket.core.store import Store, TicketResult, TicketSet
from docket.core.ticket import STATUSES, Ticket
from docket.core.validate import SEVERITY_ERROR, ValidationReport, validate
Expand Down Expand Up @@ -329,18 +330,9 @@ def commandGraph(args: argparse.Namespace, store: Store, output: Output) -> int:

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

graph: ResolvedGraph = resolveGraph(store.loadAll())
requireScopeKey(store, key)

# 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)
graph: ResolvedGraph = scopeGraph(resolveGraph(store.loadAll()), ticketId, key, status)

source: str = renderGraph(graph)

Expand Down Expand Up @@ -421,36 +413,75 @@ def commandValidate(args: argparse.Namespace, store: Store, output: Output) -> i
return EXIT_INVALID if report.errors else EXIT_OK


def emitDocument(text: str, destination: Optional[str], name: str, output: Output) -> int:
def requireScopeKey(store: Store, key: Optional[str]) -> None:
"""
Refuse a scope naming a key the repository has not registered.

Scoping to an unknown key would draw an empty graph, which reads as an answer rather than as the typo it is. `list` refuses one for the same reason. A status needs no equivalent check, since the vocabulary is fixed and both spellings are checked against it before they arrive here, so an empty result there is a true answer.

store: The store holding the registry.
key: The key the scope named, or `None` when it named something else.
"""
Write rendered text to a file when one was named, and to stdout when one was not.

Both the mermaid source and the shipped documents are text a machine reads next, so both leave through here rather than each growing their own copy of the rule.
if key is not None:
store.config.requireKnownKey(key)


def documentPath(config: Optional[Config], filename: str) -> Path:
"""
Place a shipped document's prescribed destination.

A document belongs to the repository it describes, so it lands beside the configuration that governs it. Run outside a repository there is no such place, and the working directory is the only honest fallback, which is the same reasoning that lets the brief render without a configuration at all.

config: The configuration governing the document, or `None` when none was found.
filename: What the document is called.

Returns the path to write to unless the caller names another.
"""

return (config.repoRoot if config is not None else Path.cwd()) / filename


def emitDocument(text: str, destination: Optional[str], name: str, output: Output, defaultPath: Optional[Path] = None, toPrint: bool = False, note: str = "") -> int:
"""
Send rendered text to the file it belongs in, to stdout, or to both.

Every command that renders text leaves through here rather than each growing its own copy of the rule. What differs between them is only whether they have a file to fall back on: a document does and so writes one unasked, while `graph` does not and so stays a pipe.

A named destination always wins. Without one, the prescribed path is written unless printing was asked for instead, and asking for both does both.

text: The rendered text to emit.
destination: The path to write to, or `None` to write to stdout.
destination: The path the caller named, or `None` when none was named.
name: What to name the destination in an error message, for example `--output path`.
output: Where to write.
defaultPath: The path to write when none was named, or `None` to leave stdout as the only destination.
toPrint: Whether to print the text to stdout.
note: Anything to append to the confirmation line, already spaced and parenthesized.

Returns the process exit code.
"""

if destination is not None:
# Check the destination before rendering work is spent on it, and translate whatever the filesystem still refuses, so no write failure reaches the user as a traceback.
outPath: Path = writeFile(requireWritableFile(destination, name), text, name)
output.print(f"Wrote {outPath}")
chosen: Optional[str] = destination

return EXIT_OK
# Fall back to the prescribed path, which printing replaces rather than adds to, so a bare print stays clean enough to pipe.
if chosen is None and defaultPath is not None and not toPrint:
chosen = str(defaultPath)

if chosen is not None:
# Check the destination before the filesystem is touched, and translate whatever it still refuses, so no write failure reaches the user as a traceback.
outPath: Path = writeFile(requireWritableFile(chosen, name), text, name)
output.print(f"Wrote {outPath}{note}")

# Straight to stdout with no styling, so a redirect captures exactly what was rendered and nothing else.
output.raw(text)
# Straight to stdout with no styling, so a redirect captures exactly what was rendered and nothing else. With nothing written this is the whole of the command's output.
if toPrint or chosen is None:
output.raw(text)

return EXIT_OK


def commandDocs(args: argparse.Namespace, config: Optional[Config], output: Output) -> int:
"""
Print a document docket ships, rendered for this repository.
Write a document docket ships, rendered for this repository.

args: The parsed arguments.
config: The configuration governing the current directory, or `None` when none was found.
Expand All @@ -459,14 +490,45 @@ def commandDocs(args: argparse.Namespace, config: Optional[Config], output: Outp
Returns the process exit code.
"""

if args.docsCommand != "handoff":
output.error("Expected one of: handoff.")
return EXIT_USAGE
if args.docsCommand == "handoff":
# A configuration is what lets the brief name real keys and real numbering, but its absence is a state the document handles rather than an error, since a person may be anywhere when they go to fetch it.
store: Optional[Store] = Store(config) if config is not None else None

return emitDocument(renderHandoff(store), args.output, OUTPUT_ARGUMENT, output, documentPath(config, HANDOFF_FILENAME), args.toPrint)

if args.docsCommand == "roadmap":
# The roadmap is nothing but this repository's own tickets, so unlike the brief it cannot be rendered without one. Discovery is repeated here so the reason a configuration could not be found is reported by the code that knows it.
return commandRoadmap(args, Store(config if config is not None else discoverConfig()), output)

output.error("Expected one of: handoff, roadmap.")

return EXIT_USAGE


def commandRoadmap(args: argparse.Namespace, store: Store, output: Output) -> int:
"""
Write the dependency graph as a markdown document with an embedded mermaid diagram.

args: The parsed arguments.
store: The store to read from.
output: Where to write.

Returns the process exit code.
"""

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

requireScopeKey(store, key)

# The flag overrides the configured ceiling for one run, which is what lets a repository render a bigger diagram once without editing its configuration.
maxNodes: int = store.config.maxRoadmapNodes if args.maxNodes is None else args.maxNodes

roadmap: Roadmap = buildRoadmap(store, ticketId=ticketId, key=key, status=status, maxNodes=maxNodes)

# A configuration is what lets the brief name real keys and real numbering, but its absence is a state the document handles rather than an error, since a person may be anywhere when they go to fetch it.
store: Optional[Store] = Store(config) if config is not None else None
# The document says nothing about what the ceiling dropped, so the person who ran the command is told here instead. Otherwise a diagram that quietly stopped showing its history gives no hint of why.
note: str = f" ({roadmap.dropped} completed ticket(s) omitted)" if roadmap.dropped else ""

return emitDocument(renderHandoff(store), args.output, OUTPUT_ARGUMENT, output)
return emitDocument(roadmap.document, args.output, OUTPUT_ARGUMENT, output, documentPath(store.config, ROADMAP_FILENAME), args.toPrint, note)


def commandDeploy(args: argparse.Namespace, output: Output) -> int:
Expand Down
Loading
Loading