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.
Files changed (43) hide show
  1. gemba/__init__.py +1 -0
  2. gemba/assets/harness/guide.md +88 -0
  3. gemba/assets/init/gemba.yaml +12 -0
  4. gemba/assets/skills/gemba-archive/SKILL.md +78 -0
  5. gemba/assets/skills/gemba-config/SKILL.md +176 -0
  6. gemba/assets/skills/gemba-create/SKILL.md +99 -0
  7. gemba/assets/skills/gemba-harness/SKILL.md +194 -0
  8. gemba/assets/skills/gemba-init/SKILL.md +81 -0
  9. gemba/assets/skills/gemba-list/SKILL.md +137 -0
  10. gemba/assets/skills/gemba-mark/SKILL.md +88 -0
  11. gemba/assets/skills/gemba-record/SKILL.md +89 -0
  12. gemba/assets/skills/gemba-report/SKILL.md +149 -0
  13. gemba/assets/skills/gemba-set/SKILL.md +104 -0
  14. gemba/assets/skills/gemba-worktree/SKILL.md +137 -0
  15. gemba/cli.py +35 -0
  16. gemba/commands/__init__.py +1 -0
  17. gemba/commands/archive.py +62 -0
  18. gemba/commands/config.py +117 -0
  19. gemba/commands/create.py +104 -0
  20. gemba/commands/harness.py +534 -0
  21. gemba/commands/init.py +112 -0
  22. gemba/commands/list.py +51 -0
  23. gemba/commands/mark.py +91 -0
  24. gemba/commands/record.py +113 -0
  25. gemba/commands/report.py +80 -0
  26. gemba/commands/selection.py +170 -0
  27. gemba/commands/set.py +81 -0
  28. gemba/commands/skills.py +102 -0
  29. gemba/commands/worktree.py +292 -0
  30. gemba/config.py +371 -0
  31. gemba/harness.py +401 -0
  32. gemba/metadata.py +442 -0
  33. gemba/repo.py +76 -0
  34. gemba/report.py +153 -0
  35. gemba/skills.py +199 -0
  36. gemba/tasks.py +621 -0
  37. gemba/values.py +16 -0
  38. gemba/worktree.py +191 -0
  39. gemba_cli-0.1.0.dist-info/METADATA +321 -0
  40. gemba_cli-0.1.0.dist-info/RECORD +43 -0
  41. gemba_cli-0.1.0.dist-info/WHEEL +4 -0
  42. gemba_cli-0.1.0.dist-info/entry_points.txt +2 -0
  43. 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