gemba-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gemba/__init__.py +1 -0
- gemba/assets/harness/guide.md +88 -0
- gemba/assets/init/gemba.yaml +12 -0
- gemba/assets/skills/gemba-archive/SKILL.md +78 -0
- gemba/assets/skills/gemba-config/SKILL.md +176 -0
- gemba/assets/skills/gemba-create/SKILL.md +99 -0
- gemba/assets/skills/gemba-harness/SKILL.md +194 -0
- gemba/assets/skills/gemba-init/SKILL.md +81 -0
- gemba/assets/skills/gemba-list/SKILL.md +137 -0
- gemba/assets/skills/gemba-mark/SKILL.md +88 -0
- gemba/assets/skills/gemba-record/SKILL.md +89 -0
- gemba/assets/skills/gemba-report/SKILL.md +149 -0
- gemba/assets/skills/gemba-set/SKILL.md +104 -0
- gemba/assets/skills/gemba-worktree/SKILL.md +137 -0
- gemba/cli.py +35 -0
- gemba/commands/__init__.py +1 -0
- gemba/commands/archive.py +62 -0
- gemba/commands/config.py +117 -0
- gemba/commands/create.py +104 -0
- gemba/commands/harness.py +534 -0
- gemba/commands/init.py +112 -0
- gemba/commands/list.py +51 -0
- gemba/commands/mark.py +91 -0
- gemba/commands/record.py +113 -0
- gemba/commands/report.py +80 -0
- gemba/commands/selection.py +170 -0
- gemba/commands/set.py +81 -0
- gemba/commands/skills.py +102 -0
- gemba/commands/worktree.py +292 -0
- gemba/config.py +371 -0
- gemba/harness.py +401 -0
- gemba/metadata.py +442 -0
- gemba/repo.py +76 -0
- gemba/report.py +153 -0
- gemba/skills.py +199 -0
- gemba/tasks.py +621 -0
- gemba/values.py +16 -0
- gemba/worktree.py +191 -0
- gemba_cli-0.1.0.dist-info/METADATA +321 -0
- gemba_cli-0.1.0.dist-info/RECORD +43 -0
- gemba_cli-0.1.0.dist-info/WHEEL +4 -0
- gemba_cli-0.1.0.dist-info/entry_points.txt +2 -0
- gemba_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
gemba/tasks.py
ADDED
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
"""Task file rules: slug, filename, ref allocation, frontmatter and body, written and read."""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Collection, Iterable, Mapping, Sequence
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path, PurePosixPath
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from gemba import metadata, values
|
|
13
|
+
from gemba.metadata import Conditions
|
|
14
|
+
|
|
15
|
+
TASKS_DIR = "tasks"
|
|
16
|
+
ARCHIVE_DIR = "archive"
|
|
17
|
+
GITKEEP = ".gitkeep"
|
|
18
|
+
|
|
19
|
+
SLUG_PATTERN = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
|
|
20
|
+
FILENAME_PATTERN = re.compile(
|
|
21
|
+
r"^(?P<date>\d{4}-\d{2}-\d{2})-(?P<ref>[A-Z]+-[1-9][0-9]*)-(?P<slug>[a-z0-9]+(?:-[a-z0-9]+)*)\.md$"
|
|
22
|
+
)
|
|
23
|
+
STATUSES = ("open", "in-progress", "blocked", "done")
|
|
24
|
+
DONE = "done"
|
|
25
|
+
# The keys of the frontmatter in the order gemba writes them; done_at only while the task is done.
|
|
26
|
+
FRONTMATTER_KEYS = ("status", "ref", "updated_at", "done_at", "metadata")
|
|
27
|
+
UPDATES_HEADING = "## Updates"
|
|
28
|
+
SECTION_PREFIX = "## "
|
|
29
|
+
# A heading under `## Updates`; its text must be the ISO date of the subsection.
|
|
30
|
+
SUBSECTION_HEADING = re.compile(r"^### (?P<text>.*?)\s*$")
|
|
31
|
+
BULLET_PREFIX = "- "
|
|
32
|
+
CONTINUATION_PREFIX = " "
|
|
33
|
+
FRONTMATTER_DELIMITER = "---"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TaskError(Exception):
|
|
37
|
+
"""A file is not a valid task; the message names the failing rule."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Task:
|
|
42
|
+
"""A task read from disk, with the fields of its listing record in record order."""
|
|
43
|
+
|
|
44
|
+
path: str
|
|
45
|
+
workstream: str
|
|
46
|
+
archived: bool
|
|
47
|
+
ref: str
|
|
48
|
+
date: datetime.date
|
|
49
|
+
title: str
|
|
50
|
+
status: str
|
|
51
|
+
updated_at: datetime.date
|
|
52
|
+
done_at: datetime.date | None
|
|
53
|
+
metadata: dict[str, object]
|
|
54
|
+
description: str | None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class Filters:
|
|
59
|
+
"""Listing filters: values of one filter are alternatives, different filters are conditions.
|
|
60
|
+
|
|
61
|
+
An empty collection or None leaves that filter out. Date ranges are inclusive.
|
|
62
|
+
`metadata` holds the metadata conditions, and `schemas` the keys of the effective
|
|
63
|
+
schema of each workstream, which tell a key the task lacks from one it may not hold.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
statuses: Collection[str] = ()
|
|
67
|
+
ref: str | None = None
|
|
68
|
+
created_from: datetime.date | None = None
|
|
69
|
+
created_to: datetime.date | None = None
|
|
70
|
+
updated_from: datetime.date | None = None
|
|
71
|
+
updated_to: datetime.date | None = None
|
|
72
|
+
done_from: datetime.date | None = None
|
|
73
|
+
done_to: datetime.date | None = None
|
|
74
|
+
metadata: Conditions = field(default_factory=Conditions)
|
|
75
|
+
schemas: Mapping[str, Collection[str]] = field(default_factory=dict)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class Transition:
|
|
80
|
+
"""What a status transition did to a task file, so that a command reports it without reading the file again."""
|
|
81
|
+
|
|
82
|
+
old_status: str
|
|
83
|
+
new_status: str
|
|
84
|
+
old_updated_at: datetime.date
|
|
85
|
+
new_updated_at: datetime.date
|
|
86
|
+
done_at: datetime.date | None = None # the date written when the task reached done
|
|
87
|
+
done_at_removed: bool = False # whether the task left done and its done_at was dropped
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def changed(self) -> bool:
|
|
91
|
+
return self.new_status != self.old_status
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class Problem:
|
|
96
|
+
"""Something found while scanning that is not a readable task, and why."""
|
|
97
|
+
|
|
98
|
+
path: str
|
|
99
|
+
problem: str
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def is_valid_slug(slug: str) -> bool:
|
|
103
|
+
return bool(SLUG_PATTERN.match(slug))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def filename(created: datetime.date, ref: str, slug: str) -> str:
|
|
107
|
+
return f"{created.isoformat()}-{ref}-{slug}.md"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def next_ref(root: Path, prefix: str) -> str:
|
|
111
|
+
"""Allocate `<prefix>-<N>` from the highest N used anywhere under tasks/, archives included."""
|
|
112
|
+
pattern = re.compile(rf"^\d{{4}}-\d{{2}}-\d{{2}}-{re.escape(prefix)}-(\d+)-")
|
|
113
|
+
highest = 0
|
|
114
|
+
tasks_dir = root / TASKS_DIR
|
|
115
|
+
if tasks_dir.is_dir():
|
|
116
|
+
for path in tasks_dir.rglob("*"):
|
|
117
|
+
match = pattern.match(path.name)
|
|
118
|
+
if match and path.is_file():
|
|
119
|
+
highest = max(highest, int(match.group(1)))
|
|
120
|
+
return f"{prefix}-{highest + 1}"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class FrontmatterDumper(yaml.SafeDumper):
|
|
124
|
+
"""Block style for maps, flow style for lists of scalars: `project: [checkout, landing]`.
|
|
125
|
+
|
|
126
|
+
A value written under two keys, such as one date for `updated_at` and `done_at`, is
|
|
127
|
+
written twice and never as a YAML anchor and alias.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def ignore_aliases(self, data: object) -> bool:
|
|
131
|
+
return True
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
FrontmatterDumper.add_representer(
|
|
135
|
+
list, lambda dumper, data: dumper.represent_sequence("tag:yaml.org,2002:seq", data, flow_style=True)
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def dump_frontmatter(data: Mapping[str, object], dumper: type[FrontmatterDumper] = FrontmatterDumper) -> str:
|
|
140
|
+
"""The YAML lines of a frontmatter map, keys in the order given."""
|
|
141
|
+
return yaml.dump(
|
|
142
|
+
data,
|
|
143
|
+
Dumper=dumper,
|
|
144
|
+
sort_keys=False,
|
|
145
|
+
default_flow_style=False,
|
|
146
|
+
allow_unicode=True,
|
|
147
|
+
width=float("inf"),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def render(
|
|
152
|
+
status: str,
|
|
153
|
+
ref: str,
|
|
154
|
+
updated_at: datetime.date,
|
|
155
|
+
metadata: dict[str, object],
|
|
156
|
+
description: str | None = None,
|
|
157
|
+
) -> str:
|
|
158
|
+
"""Full task file content: frontmatter in fixed key order, then the body."""
|
|
159
|
+
frontmatter = dump_frontmatter({"status": status, "ref": ref, "updated_at": updated_at, "metadata": metadata})
|
|
160
|
+
body = f"{UPDATES_HEADING}\n"
|
|
161
|
+
if description and description.strip():
|
|
162
|
+
body = f"{description.strip()}\n\n{body}"
|
|
163
|
+
return f"---\n{frontmatter}---\n\n{body}"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def scan(
|
|
167
|
+
root: Path,
|
|
168
|
+
workstream_ids: Iterable[str],
|
|
169
|
+
include_archived: bool,
|
|
170
|
+
configured: Collection[str] | None = None,
|
|
171
|
+
) -> tuple[list[Task], list[Problem]]:
|
|
172
|
+
"""Read the `.md` files of the given workstream folders, archives only on request.
|
|
173
|
+
|
|
174
|
+
Tasks come ordered by date, workstream and filename; problems by path. When
|
|
175
|
+
`configured` holds the workstream ids of the config, every other non-hidden entry
|
|
176
|
+
under tasks/ is a problem, so that tasks never disappear silently.
|
|
177
|
+
"""
|
|
178
|
+
found: list[Task] = []
|
|
179
|
+
problems: list[Problem] = []
|
|
180
|
+
tasks_dir = root / TASKS_DIR
|
|
181
|
+
for workstream in workstream_ids:
|
|
182
|
+
folders = [(tasks_dir / workstream, False)]
|
|
183
|
+
if include_archived:
|
|
184
|
+
folders.append((tasks_dir / workstream / ARCHIVE_DIR, True))
|
|
185
|
+
for folder, archived in folders:
|
|
186
|
+
for path in _task_files(folder):
|
|
187
|
+
try:
|
|
188
|
+
found.append(read(path, root, workstream, archived))
|
|
189
|
+
except TaskError as exc:
|
|
190
|
+
problems.append(Problem(path.relative_to(root).as_posix(), str(exc)))
|
|
191
|
+
if configured is not None and tasks_dir.is_dir():
|
|
192
|
+
for entry in tasks_dir.iterdir():
|
|
193
|
+
if entry.name.startswith(".") or entry.name in configured:
|
|
194
|
+
continue
|
|
195
|
+
problem = (
|
|
196
|
+
"folder is not a workstream of the config; its files are not listed"
|
|
197
|
+
if entry.is_dir()
|
|
198
|
+
else "entry is not a workstream folder of the config"
|
|
199
|
+
)
|
|
200
|
+
problems.append(Problem(entry.relative_to(root).as_posix(), problem))
|
|
201
|
+
found.sort(key=lambda task: (task.date, task.workstream, PurePosixPath(task.path).name, task.path))
|
|
202
|
+
problems.sort(key=lambda problem: problem.path)
|
|
203
|
+
return found, problems
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def find_by_ref(root: Path, workstream_ids: Iterable[str], ref: str) -> Task | None:
|
|
207
|
+
"""The readable task whose ref is `ref`, archives included, or None when no task has it."""
|
|
208
|
+
found, _problems = scan(root, workstream_ids, include_archived=True)
|
|
209
|
+
holders = [task for task in found if task.ref == ref]
|
|
210
|
+
if len(holders) > 1:
|
|
211
|
+
raise TaskError(f"ref '{ref}' is held by several tasks: {', '.join(task.path for task in holders)}")
|
|
212
|
+
return holders[0] if holders else None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def status_problem(status: object) -> str | None:
|
|
216
|
+
"""Why `status` is not one of the four statuses, or None when it is."""
|
|
217
|
+
if status in STATUSES:
|
|
218
|
+
return None
|
|
219
|
+
return f"invalid status {_show(status)} (allowed: {', '.join(STATUSES)})"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def transition(text: str, status: str, day: datetime.date) -> tuple[str, Transition]:
|
|
223
|
+
"""The task file content moved to `status` on `day`, and what moved.
|
|
224
|
+
|
|
225
|
+
One rule table for every status change after creation: reaching `done` sets
|
|
226
|
+
`done_at` to `day`, any other status holds no `done_at`, and `updated_at` moves to
|
|
227
|
+
`day` only when it is later. The same status returns the text as it was. Otherwise
|
|
228
|
+
the frontmatter is dumped again in the fixed key order and the body is kept byte for
|
|
229
|
+
byte.
|
|
230
|
+
"""
|
|
231
|
+
if problem := status_problem(status):
|
|
232
|
+
raise TaskError(problem)
|
|
233
|
+
lines = text.split("\n")
|
|
234
|
+
closing = _closing_line(lines)
|
|
235
|
+
data = _frontmatter_map(lines, closing)
|
|
236
|
+
current = data.get("status")
|
|
237
|
+
if problem := status_problem(current):
|
|
238
|
+
raise TaskError(problem)
|
|
239
|
+
assert isinstance(current, str), "status_problem accepts the four statuses only"
|
|
240
|
+
previous = _date_field(data, "updated_at")
|
|
241
|
+
if previous is None:
|
|
242
|
+
raise TaskError("updated_at is missing")
|
|
243
|
+
if current == status:
|
|
244
|
+
return text, Transition(current, status, previous, previous)
|
|
245
|
+
|
|
246
|
+
updated_at = max(previous, day)
|
|
247
|
+
data["status"] = status
|
|
248
|
+
data["updated_at"] = updated_at
|
|
249
|
+
done_at = None
|
|
250
|
+
removed = False
|
|
251
|
+
if status == DONE:
|
|
252
|
+
data["done_at"] = done_at = day
|
|
253
|
+
else:
|
|
254
|
+
removed = data.pop("done_at", None) is not None
|
|
255
|
+
return _rewritten(data, lines, closing), Transition(current, status, previous, updated_at, done_at, removed)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def set_metadata(text: str, values: Mapping[str, object]) -> str:
|
|
259
|
+
"""The task file content with `values` as its metadata map.
|
|
260
|
+
|
|
261
|
+
Every other frontmatter key keeps its value, `updated_at` included, the frontmatter
|
|
262
|
+
is dumped again in the fixed key order and the body is kept byte for byte.
|
|
263
|
+
"""
|
|
264
|
+
lines = text.split("\n")
|
|
265
|
+
closing = _closing_line(lines)
|
|
266
|
+
data = _frontmatter_map(lines, closing)
|
|
267
|
+
data["metadata"] = dict(values)
|
|
268
|
+
return _rewritten(data, lines, closing)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _rewritten(data: dict[Any, object], lines: list[str], closing: int) -> str:
|
|
272
|
+
"""The frontmatter `data` dumped in the fixed key order, then the body after line `closing` as it was."""
|
|
273
|
+
ordered = {key: data[key] for key in FRONTMATTER_KEYS if key in data}
|
|
274
|
+
ordered.update((key, value) for key, value in data.items() if key not in ordered)
|
|
275
|
+
body = "\n".join(lines[closing + 1 :])
|
|
276
|
+
return f"{FRONTMATTER_DELIMITER}\n{dump_frontmatter(ordered)}{FRONTMATTER_DELIMITER}\n{body}"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def start(text: str, today: datetime.date) -> tuple[str, Transition]:
|
|
280
|
+
"""The task file content with work started, and what moved.
|
|
281
|
+
|
|
282
|
+
An `open` task moves to `in-progress` through `transition`; a task with any other
|
|
283
|
+
status is returned as it was, with a transition that changed nothing.
|
|
284
|
+
"""
|
|
285
|
+
lines = text.split("\n")
|
|
286
|
+
current = _frontmatter_map(lines, _closing_line(lines)).get("status")
|
|
287
|
+
return transition(text, "in-progress" if current == "open" else str(current), today)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def archive_destination(task: Task) -> PurePosixPath:
|
|
291
|
+
"""Where `gemba archive` moves the task file: the same name under `archive/` of its workstream folder."""
|
|
292
|
+
return PurePosixPath(TASKS_DIR, task.workstream, ARCHIVE_DIR, PurePosixPath(task.path).name)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def archive_problem(task: Task, harness_folder: str | None, destination_exists: bool) -> str | None:
|
|
296
|
+
"""Why the task cannot be archived, phrased after its ref, or None when it can.
|
|
297
|
+
|
|
298
|
+
`harness_folder` is the harness folder of the task, relative to the tasks repo root,
|
|
299
|
+
when a folder exists there: the `task.md` link inside it would break with the move.
|
|
300
|
+
The status is not checked.
|
|
301
|
+
"""
|
|
302
|
+
if task.archived:
|
|
303
|
+
return f"is already archived ({task.path})"
|
|
304
|
+
if harness_folder is not None:
|
|
305
|
+
return f"has a harness at {harness_folder}: remove it first with gemba harness remove {task.ref}"
|
|
306
|
+
if destination_exists:
|
|
307
|
+
return f"cannot move to {archive_destination(task)}: a file is already there"
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def updates_problem(body: str) -> str | None:
|
|
312
|
+
"""The first rule of the updates section that `body` breaks, or None when the section is valid.
|
|
313
|
+
|
|
314
|
+
The body holds one `## Updates` line and no `## ` heading after it, and every `### `
|
|
315
|
+
heading under it is an ISO date, in strictly increasing order. The other lines under
|
|
316
|
+
a date heading are not checked.
|
|
317
|
+
"""
|
|
318
|
+
lines = body.split("\n")
|
|
319
|
+
found = [index for index, line in enumerate(lines) if line.rstrip() == UPDATES_HEADING]
|
|
320
|
+
if not found:
|
|
321
|
+
return f"has no '{UPDATES_HEADING}' section"
|
|
322
|
+
if len(found) > 1:
|
|
323
|
+
return f"holds '{UPDATES_HEADING}' more than once; the section must appear once, at the end of the body"
|
|
324
|
+
previous: datetime.date | None = None
|
|
325
|
+
for line in lines[found[0] + 1 :]:
|
|
326
|
+
heading = line.rstrip()
|
|
327
|
+
if heading.startswith(SECTION_PREFIX):
|
|
328
|
+
return f"has '{heading}' after '{UPDATES_HEADING}', which must be the last section of the body"
|
|
329
|
+
match = SUBSECTION_HEADING.match(line)
|
|
330
|
+
if match is None:
|
|
331
|
+
continue
|
|
332
|
+
day = values.iso_date(match["text"].strip())
|
|
333
|
+
if day is None:
|
|
334
|
+
return f"has '{heading}' under '{UPDATES_HEADING}', which is not a date heading (### YYYY-MM-DD)"
|
|
335
|
+
if previous is not None and day <= previous:
|
|
336
|
+
order = "repeats a date" if day == previous else f"follows '### {previous.isoformat()}'"
|
|
337
|
+
return f"has '{heading}' under '{UPDATES_HEADING}', which {order}; the dates must be strictly increasing"
|
|
338
|
+
previous = day
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def parse_bullets(text: str) -> tuple[list[str], list[str]]:
|
|
343
|
+
"""The lines of a drafted update, without the blank lines around them, and every rule the draft breaks.
|
|
344
|
+
|
|
345
|
+
Every non-blank line is a `- ` bullet or, continuing the previous bullet, starts with
|
|
346
|
+
two spaces, and at least one bullet is present. The lines come as written, line
|
|
347
|
+
endings aside, and only when the draft breaks no rule.
|
|
348
|
+
"""
|
|
349
|
+
lines = [line.removesuffix("\r") for line in text.split("\n")]
|
|
350
|
+
filled = [index for index, line in enumerate(lines) if line.strip()]
|
|
351
|
+
stray = []
|
|
352
|
+
bullets = 0
|
|
353
|
+
for index in filled:
|
|
354
|
+
if lines[index].startswith(BULLET_PREFIX):
|
|
355
|
+
bullets += 1
|
|
356
|
+
elif not (bullets and lines[index].startswith(CONTINUATION_PREFIX)):
|
|
357
|
+
stray.append(str(index + 1))
|
|
358
|
+
problems = []
|
|
359
|
+
if not bullets:
|
|
360
|
+
problems.append(f"holds no bullet: write each one on a line starting with '{BULLET_PREFIX}'")
|
|
361
|
+
if stray:
|
|
362
|
+
which = f"line {stray[0]} is" if len(stray) == 1 else f"lines {', '.join(stray)} are"
|
|
363
|
+
problems.append(f"{which} neither a '{BULLET_PREFIX}' bullet nor a continuation indented by two spaces")
|
|
364
|
+
if problems:
|
|
365
|
+
return [], problems
|
|
366
|
+
return lines[filled[0] : filled[-1] + 1], []
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def date_problem(day: datetime.date, created: datetime.date, today: datetime.date) -> str | None:
|
|
370
|
+
"""Why `day` cannot date a change to a task created on `created`, or None when it can."""
|
|
371
|
+
if day > today:
|
|
372
|
+
return f"date {day.isoformat()} is after today, {today.isoformat()}"
|
|
373
|
+
if day < created:
|
|
374
|
+
return f"date {day.isoformat()} is before {created.isoformat()}, the creation date of the task"
|
|
375
|
+
return None
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def record(
|
|
379
|
+
text: str, created: datetime.date, today: datetime.date, day: datetime.date, bullets: Sequence[str]
|
|
380
|
+
) -> tuple[str, datetime.date, datetime.date]:
|
|
381
|
+
"""The task file content with `bullets` recorded under `### <day>`, and `updated_at` before and after.
|
|
382
|
+
|
|
383
|
+
The subsection is inserted in date order, or the bullets are appended to the
|
|
384
|
+
subsection of that day when there is one. `updated_at` moves to `day` only when it
|
|
385
|
+
is later, and the frontmatter is dumped again in its own key order; every line of
|
|
386
|
+
the body outside the written subsection is kept as it was.
|
|
387
|
+
"""
|
|
388
|
+
if problem := date_problem(day, created, today):
|
|
389
|
+
raise TaskError(problem)
|
|
390
|
+
if not bullets:
|
|
391
|
+
raise TaskError("there is no bullet to record")
|
|
392
|
+
lines = text.split("\n")
|
|
393
|
+
closing = _closing_line(lines)
|
|
394
|
+
data = _load_frontmatter("\n".join(lines[1:closing]))
|
|
395
|
+
if not isinstance(data, dict):
|
|
396
|
+
raise TaskError("frontmatter must be a map")
|
|
397
|
+
body = lines[closing + 1 :]
|
|
398
|
+
if problem := updates_problem("\n".join(body)):
|
|
399
|
+
raise TaskError(problem)
|
|
400
|
+
previous = _date_field(data, "updated_at")
|
|
401
|
+
if previous is None:
|
|
402
|
+
raise TaskError("updated_at is missing")
|
|
403
|
+
updated_at = max(previous, day)
|
|
404
|
+
data["updated_at"] = updated_at
|
|
405
|
+
body = _with_subsection(body, day, list(bullets))
|
|
406
|
+
frontmatter = f"{FRONTMATTER_DELIMITER}\n{dump_frontmatter(data)}{FRONTMATTER_DELIMITER}"
|
|
407
|
+
return "\n".join([frontmatter, *body]), previous, updated_at
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _with_subsection(body: list[str], day: datetime.date, bullets: list[str]) -> list[str]:
|
|
411
|
+
"""The lines of a body whose updates section is valid, with `bullets` written under `### <day>`.
|
|
412
|
+
|
|
413
|
+
A subsection is separated from its neighbours by one blank line, and the body ends
|
|
414
|
+
with one newline when the written subsection is the last one.
|
|
415
|
+
"""
|
|
416
|
+
start = next(index for index, line in enumerate(body) if line.rstrip() == UPDATES_HEADING)
|
|
417
|
+
headings = []
|
|
418
|
+
for index in range(start + 1, len(body)):
|
|
419
|
+
if match := SUBSECTION_HEADING.match(body[index]):
|
|
420
|
+
heading_date = values.iso_date(match["text"].strip())
|
|
421
|
+
assert heading_date is not None, "updates_problem checked every heading"
|
|
422
|
+
headings.append((index, heading_date))
|
|
423
|
+
|
|
424
|
+
same = next((index for index, heading_date in headings if heading_date == day), None)
|
|
425
|
+
if same is not None:
|
|
426
|
+
end = next((index for index, _date in headings if index > same), len(body))
|
|
427
|
+
last = max(index for index in range(same, end) if body[index].strip())
|
|
428
|
+
added = bullets if last > same else ["", *bullets]
|
|
429
|
+
rest = body[last + 1 :] if end < len(body) else [""]
|
|
430
|
+
return [*body[: last + 1], *added, *rest]
|
|
431
|
+
|
|
432
|
+
subsection = ["", f"### {day.isoformat()}", "", *bullets]
|
|
433
|
+
following = next((index for index, heading_date in headings if heading_date > day), None)
|
|
434
|
+
if following is None:
|
|
435
|
+
return [*_trimmed(body), *subsection, ""]
|
|
436
|
+
return [*_trimmed(body[:following]), *subsection, "", *body[following:]]
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _trimmed(lines: list[str]) -> list[str]:
|
|
440
|
+
"""`lines` without the blank lines at their end."""
|
|
441
|
+
end = len(lines)
|
|
442
|
+
while end and not lines[end - 1].strip():
|
|
443
|
+
end -= 1
|
|
444
|
+
return lines[:end]
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def matches(task: Task, filters: Filters) -> bool:
|
|
448
|
+
"""Whether the task passes every filter; the workstream filter is the scan scope, not a filter."""
|
|
449
|
+
if filters.statuses and task.status not in filters.statuses:
|
|
450
|
+
return False
|
|
451
|
+
if filters.ref is not None and task.ref != filters.ref:
|
|
452
|
+
return False
|
|
453
|
+
if not _within(task.date, filters.created_from, filters.created_to):
|
|
454
|
+
return False
|
|
455
|
+
if not _within(task.updated_at, filters.updated_from, filters.updated_to):
|
|
456
|
+
return False
|
|
457
|
+
if filters.done_from is not None or filters.done_to is not None:
|
|
458
|
+
if task.done_at is None or not _within(task.done_at, filters.done_from, filters.done_to):
|
|
459
|
+
return False
|
|
460
|
+
return filters.metadata.matches(task.metadata, filters.schemas.get(task.workstream, ()))
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _within(day: datetime.date, start: datetime.date | None, end: datetime.date | None) -> bool:
|
|
464
|
+
return (start is None or start <= day) and (end is None or day <= end)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _task_files(folder: Path) -> list[Path]:
|
|
468
|
+
"""The non-hidden `.md` files directly under `folder`; none when it does not exist."""
|
|
469
|
+
if not folder.is_dir():
|
|
470
|
+
return []
|
|
471
|
+
return sorted(
|
|
472
|
+
path for path in folder.iterdir() if path.suffix == ".md" and not path.name.startswith(".") and path.is_file()
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def read(path: Path, root: Path, workstream: str, archived: bool) -> Task:
|
|
477
|
+
"""Read the task file at `path`, applying every reading rule of the task file format.
|
|
478
|
+
|
|
479
|
+
Metadata keys are not checked against the schema: a task written under an earlier
|
|
480
|
+
schema stays readable after the config changes.
|
|
481
|
+
"""
|
|
482
|
+
match = FILENAME_PATTERN.match(path.name)
|
|
483
|
+
if match is None:
|
|
484
|
+
raise TaskError("filename does not match YYYY-MM-DD-<ref>-<slug>.md")
|
|
485
|
+
created = values.iso_date(match["date"])
|
|
486
|
+
if created is None:
|
|
487
|
+
raise TaskError(f"filename date '{match['date']}' is not a valid date")
|
|
488
|
+
try:
|
|
489
|
+
text = path.read_text(encoding="utf-8")
|
|
490
|
+
except UnicodeDecodeError:
|
|
491
|
+
raise TaskError("the file is not UTF-8 text") from None
|
|
492
|
+
except OSError as exc:
|
|
493
|
+
raise TaskError(f"the file cannot be read: {exc.strerror}") from None
|
|
494
|
+
|
|
495
|
+
frontmatter, body = split(text)
|
|
496
|
+
data = _load_frontmatter(frontmatter)
|
|
497
|
+
if not isinstance(data, dict):
|
|
498
|
+
raise TaskError("frontmatter must be a map")
|
|
499
|
+
|
|
500
|
+
status = data.get("status")
|
|
501
|
+
if status is None:
|
|
502
|
+
raise TaskError(f"status is missing (allowed: {', '.join(STATUSES)})")
|
|
503
|
+
if status not in STATUSES:
|
|
504
|
+
raise TaskError(f"invalid status {_show(status)} (allowed: {', '.join(STATUSES)})")
|
|
505
|
+
ref = data.get("ref")
|
|
506
|
+
if ref is None:
|
|
507
|
+
raise TaskError(f"ref is missing (the filename says '{match['ref']}')")
|
|
508
|
+
if ref != match["ref"]:
|
|
509
|
+
raise TaskError(f"ref {_show(ref)} in the frontmatter does not match '{match['ref']}' in the filename")
|
|
510
|
+
updated_at = _date_field(data, "updated_at")
|
|
511
|
+
if updated_at is None:
|
|
512
|
+
raise TaskError("updated_at is missing")
|
|
513
|
+
done_at = _date_field(data, "done_at")
|
|
514
|
+
metadata = _metadata(data)
|
|
515
|
+
|
|
516
|
+
return Task(
|
|
517
|
+
path=path.relative_to(root).as_posix(),
|
|
518
|
+
workstream=workstream,
|
|
519
|
+
archived=archived,
|
|
520
|
+
ref=ref,
|
|
521
|
+
date=created,
|
|
522
|
+
title=display_title(path.name, ref),
|
|
523
|
+
status=status,
|
|
524
|
+
updated_at=updated_at,
|
|
525
|
+
done_at=done_at,
|
|
526
|
+
metadata=metadata,
|
|
527
|
+
description=description(body),
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def display_title(filename: str, ref: str) -> str:
|
|
532
|
+
"""`2026-09-30-SUP-1-login-fails-on-safari.md` with ref `SUP-1` reads `Login fails on safari`."""
|
|
533
|
+
slug = filename.removesuffix(".md")[len("YYYY-MM-DD-") :].removeprefix(f"{ref}-")
|
|
534
|
+
words = slug.replace("-", " ")
|
|
535
|
+
return words[:1].upper() + words[1:]
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def description(body: str) -> str | None:
|
|
539
|
+
"""The first paragraph of the body, or None when the body starts with the updates heading."""
|
|
540
|
+
paragraph: list[str] = []
|
|
541
|
+
for line in body.split("\n"):
|
|
542
|
+
if line.strip():
|
|
543
|
+
paragraph.append(line.rstrip())
|
|
544
|
+
elif paragraph:
|
|
545
|
+
break
|
|
546
|
+
if not paragraph or paragraph[0].strip() == UPDATES_HEADING:
|
|
547
|
+
return None
|
|
548
|
+
return "\n".join(paragraph).strip()
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def split(text: str) -> tuple[str, str]:
|
|
552
|
+
"""The frontmatter block and the body that follows its closing line."""
|
|
553
|
+
lines = text.split("\n")
|
|
554
|
+
closing = _closing_line(lines)
|
|
555
|
+
return "\n".join(lines[1:closing]), "\n".join(lines[closing + 1 :])
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _closing_line(lines: list[str]) -> int:
|
|
559
|
+
"""Index of the line that closes the frontmatter opened by the first line."""
|
|
560
|
+
if lines[0].rstrip() != FRONTMATTER_DELIMITER:
|
|
561
|
+
raise TaskError(f"no frontmatter: the file does not start with a {FRONTMATTER_DELIMITER} line")
|
|
562
|
+
for index in range(1, len(lines)):
|
|
563
|
+
if lines[index].rstrip() == FRONTMATTER_DELIMITER:
|
|
564
|
+
return index
|
|
565
|
+
raise TaskError(f"frontmatter is not closed by a {FRONTMATTER_DELIMITER} line")
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _frontmatter_map(lines: list[str], closing: int) -> dict[Any, object]:
|
|
569
|
+
"""The frontmatter between the first line and `closing`, which must be a map; YAML keys may be of any type."""
|
|
570
|
+
data = _load_frontmatter("\n".join(lines[1:closing]))
|
|
571
|
+
if not isinstance(data, dict):
|
|
572
|
+
raise TaskError("frontmatter must be a map")
|
|
573
|
+
return data
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _load_frontmatter(text: str) -> object:
|
|
577
|
+
try:
|
|
578
|
+
return yaml.safe_load(text)
|
|
579
|
+
except yaml.MarkedYAMLError as exc:
|
|
580
|
+
# The block starts on the second line of the file, after the opening delimiter.
|
|
581
|
+
where = f" (line {exc.problem_mark.line + 2})" if exc.problem_mark else ""
|
|
582
|
+
raise TaskError(f"frontmatter is not valid YAML{where}: {exc.problem or exc.context}") from None
|
|
583
|
+
except (yaml.YAMLError, ValueError) as exc: # ValueError: an impossible date such as 2026-13-01
|
|
584
|
+
raise TaskError(f"frontmatter is not valid YAML: {exc}") from None
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _date_field(data: dict[object, object], key: str) -> datetime.date | None:
|
|
588
|
+
value = data.get(key)
|
|
589
|
+
if value is None:
|
|
590
|
+
return None
|
|
591
|
+
if isinstance(value, datetime.date) and not isinstance(value, datetime.datetime):
|
|
592
|
+
return value
|
|
593
|
+
if isinstance(value, str) and (parsed := values.iso_date(value)) is not None:
|
|
594
|
+
return parsed
|
|
595
|
+
raise TaskError(f"{key} must be an ISO date (YYYY-MM-DD), not {_show(value)}")
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _metadata(data: dict[object, object]) -> dict[str, object]:
|
|
599
|
+
if "metadata" not in data:
|
|
600
|
+
raise TaskError("metadata is missing (it must be a map, {} when empty)")
|
|
601
|
+
found = data["metadata"]
|
|
602
|
+
if not isinstance(found, dict):
|
|
603
|
+
kind = "a list" if isinstance(found, list) else "null" if found is None else "a scalar"
|
|
604
|
+
raise TaskError(f"metadata must be a map, not {kind}")
|
|
605
|
+
for key, value in found.items():
|
|
606
|
+
if not isinstance(key, str):
|
|
607
|
+
raise TaskError(f"metadata key {_show(key)} must be a string")
|
|
608
|
+
if problem := metadata.shape_problem(value):
|
|
609
|
+
raise TaskError(f"metadata value of '{key}' {problem}")
|
|
610
|
+
return found
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _show(value: object) -> str:
|
|
614
|
+
"""A frontmatter value as it reads in YAML, quoted when it is a string."""
|
|
615
|
+
if isinstance(value, str):
|
|
616
|
+
return f"'{value}'"
|
|
617
|
+
if isinstance(value, bool):
|
|
618
|
+
return "true" if value else "false"
|
|
619
|
+
if value is None:
|
|
620
|
+
return "null"
|
|
621
|
+
return str(value)
|
gemba/values.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Values typed on the command line: ISO dates."""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def iso_date(text: str) -> datetime.date | None:
|
|
10
|
+
"""The date written as exactly `YYYY-MM-DD`, or None when the text is anything else."""
|
|
11
|
+
if DATE_PATTERN.match(text):
|
|
12
|
+
try:
|
|
13
|
+
return datetime.date.fromisoformat(text)
|
|
14
|
+
except ValueError:
|
|
15
|
+
return None
|
|
16
|
+
return None
|