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/commands/list.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""`gemba list`: validate the filters, scan the workstream folders and print one JSON document."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import dataclasses
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from gemba import config
|
|
11
|
+
from gemba.commands import selection
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
15
|
+
parser = subparsers.add_parser(
|
|
16
|
+
"list",
|
|
17
|
+
help="list tasks as one JSON document",
|
|
18
|
+
description=(
|
|
19
|
+
"Scan the workstream folders of gemba.yaml, validate every task file and print one JSON object: "
|
|
20
|
+
"`tasks`, the matching task records, and `problems`, the files that are not valid tasks and the "
|
|
21
|
+
"metadata values that are not of the type their field declares. Values of one filter are "
|
|
22
|
+
"alternatives; different filters must all match. Date ranges and bounds are inclusive."
|
|
23
|
+
),
|
|
24
|
+
)
|
|
25
|
+
selection.add_options(parser)
|
|
26
|
+
parser.add_argument("--root", type=Path, help="tasks repo root (default: walk up to the nearest gemba.yaml)")
|
|
27
|
+
parser.set_defaults(handler=run)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run(args: argparse.Namespace) -> int:
|
|
31
|
+
try:
|
|
32
|
+
gemba_config = config.load(root=args.root)
|
|
33
|
+
except config.ConfigError as exc:
|
|
34
|
+
return fail(str(exc))
|
|
35
|
+
|
|
36
|
+
scope, filters, errors = selection.build(args, gemba_config)
|
|
37
|
+
if errors:
|
|
38
|
+
return fail("nothing listed:\n" + "\n".join(f"- {error}" for error in errors))
|
|
39
|
+
|
|
40
|
+
found, problems = selection.select(gemba_config, scope, filters)
|
|
41
|
+
document = {
|
|
42
|
+
"tasks": [dataclasses.asdict(task) for task in found],
|
|
43
|
+
"problems": [dataclasses.asdict(problem) for problem in problems],
|
|
44
|
+
}
|
|
45
|
+
print(selection.dump(document))
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def fail(message: str) -> int:
|
|
50
|
+
print(f"gemba list: {message}", file=sys.stderr)
|
|
51
|
+
return 1
|
gemba/commands/mark.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""`gemba mark`: move the status of a task after its creation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import datetime
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from gemba import config, tasks, values
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
14
|
+
parser = subparsers.add_parser(
|
|
15
|
+
"mark",
|
|
16
|
+
help="move the status of a task",
|
|
17
|
+
description=(
|
|
18
|
+
f"Set the status of the task with this ref to one of {', '.join(tasks.STATUSES)}. Reaching done "
|
|
19
|
+
"writes done_at, leaving done removes it, and updated_at moves to the date when it is later. Only "
|
|
20
|
+
"the frontmatter is rewritten: the body, the harness of the task and git are not touched."
|
|
21
|
+
),
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument("ref", help="ref of the task, such as SUP-1")
|
|
24
|
+
parser.add_argument("status", help=f"the status to move to: {', '.join(tasks.STATUSES)}")
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--date",
|
|
27
|
+
metavar="YYYY-MM-DD",
|
|
28
|
+
help="the day of the transition (default: today); not after today, not before the task was created",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument("--root", type=Path, help="tasks repo root (default: walk up to the nearest gemba.yaml)")
|
|
31
|
+
parser.set_defaults(handler=run)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def run(args: argparse.Namespace) -> int:
|
|
35
|
+
try:
|
|
36
|
+
gemba_config = config.load(root=args.root)
|
|
37
|
+
except config.ConfigError as exc:
|
|
38
|
+
return fail(str(exc))
|
|
39
|
+
|
|
40
|
+
errors = []
|
|
41
|
+
if problem := tasks.status_problem(args.status):
|
|
42
|
+
errors.append(problem)
|
|
43
|
+
try:
|
|
44
|
+
task = tasks.find_by_ref(gemba_config.root, gemba_config.workstreams, args.ref)
|
|
45
|
+
except tasks.TaskError as exc:
|
|
46
|
+
task = None
|
|
47
|
+
errors.append(str(exc))
|
|
48
|
+
else:
|
|
49
|
+
if task is None:
|
|
50
|
+
errors.append(f"no task has ref '{args.ref}' under {tasks.TASKS_DIR}/, archives included")
|
|
51
|
+
elif task.archived:
|
|
52
|
+
errors.append(
|
|
53
|
+
f"{task.ref} is archived ({task.path}): move the file out of {tasks.ARCHIVE_DIR}/ before marking it"
|
|
54
|
+
)
|
|
55
|
+
today = datetime.date.today()
|
|
56
|
+
day = today if args.date is None else values.iso_date(args.date)
|
|
57
|
+
if day is None:
|
|
58
|
+
errors.append(f"invalid date '{args.date}': use YYYY-MM-DD")
|
|
59
|
+
# Without a task there is no creation date to compare with, only today.
|
|
60
|
+
elif problem := tasks.date_problem(day, task.date if task is not None else datetime.date.min, today):
|
|
61
|
+
errors.append(problem)
|
|
62
|
+
if errors:
|
|
63
|
+
return fail("task not marked:\n" + "\n".join(f"- {error}" for error in errors))
|
|
64
|
+
assert task is not None and day is not None, "every failed check appended an error"
|
|
65
|
+
|
|
66
|
+
task_file = gemba_config.root / task.path
|
|
67
|
+
try:
|
|
68
|
+
text, moved = tasks.transition(task_file.read_bytes().decode("utf-8"), args.status, day)
|
|
69
|
+
except tasks.TaskError as exc:
|
|
70
|
+
return fail(f"task not marked: {task.path}: {exc}")
|
|
71
|
+
if moved.changed:
|
|
72
|
+
task_file.write_bytes(text.encode("utf-8"))
|
|
73
|
+
print(task.path)
|
|
74
|
+
if not moved.changed:
|
|
75
|
+
print(f"status: {moved.old_status}, left as it was")
|
|
76
|
+
return 0
|
|
77
|
+
print(f"status: {moved.old_status} -> {moved.new_status}")
|
|
78
|
+
if moved.done_at is not None:
|
|
79
|
+
print(f"done_at: {moved.done_at.isoformat()}")
|
|
80
|
+
elif moved.done_at_removed:
|
|
81
|
+
print("done_at: removed")
|
|
82
|
+
if moved.new_updated_at == moved.old_updated_at:
|
|
83
|
+
print(f"updated_at: {moved.old_updated_at.isoformat()} (unchanged)")
|
|
84
|
+
else:
|
|
85
|
+
print(f"updated_at: {moved.old_updated_at.isoformat()} -> {moved.new_updated_at.isoformat()}")
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def fail(message: str) -> int:
|
|
90
|
+
print(f"gemba mark: {message}", file=sys.stderr)
|
|
91
|
+
return 1
|
gemba/commands/record.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""`gemba record`: write the drafted update of a day into the task of a harness."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import datetime
|
|
7
|
+
import sys
|
|
8
|
+
import textwrap
|
|
9
|
+
from pathlib import Path, PurePosixPath
|
|
10
|
+
|
|
11
|
+
from gemba import config, harness, skills, tasks, values
|
|
12
|
+
from gemba.commands import harness as harness_command
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
16
|
+
parser = subparsers.add_parser(
|
|
17
|
+
"record",
|
|
18
|
+
help="write the drafted update of a day into the task of a harness",
|
|
19
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
20
|
+
description=textwrap.fill(
|
|
21
|
+
f"Write the bullets of a draft under ### YYYY-MM-DD in the {tasks.UPDATES_HEADING} section of the task "
|
|
22
|
+
"of this harness, appending them when that day already has a subsection, move updated_at to the day "
|
|
23
|
+
"when it is later, and delete the draft. The path of the draft is relative to the harness root. "
|
|
24
|
+
"Nothing is committed.",
|
|
25
|
+
width=79,
|
|
26
|
+
),
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"file",
|
|
30
|
+
help=f"the draft, such as update.md: lines starting with '{tasks.BULLET_PREFIX}', continued by lines "
|
|
31
|
+
f"indented by two spaces",
|
|
32
|
+
)
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"--date",
|
|
35
|
+
metavar="YYYY-MM-DD",
|
|
36
|
+
help="the day the update is about (default: today); not after today, not before the task was created",
|
|
37
|
+
)
|
|
38
|
+
harness_command.add_location(parser)
|
|
39
|
+
parser.set_defaults(handler=run)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def run(args: argparse.Namespace) -> int:
|
|
43
|
+
try:
|
|
44
|
+
folder = harness_command.locate(args.harness)
|
|
45
|
+
gemba_config = config.load(root=args.root, cwd=folder)
|
|
46
|
+
identity = harness.read_config(folder, list(skills.PROVIDERS))
|
|
47
|
+
except (harness.HarnessError, config.ConfigError) as exc:
|
|
48
|
+
return fail(str(exc))
|
|
49
|
+
|
|
50
|
+
root = gemba_config.root
|
|
51
|
+
task_file = root / identity.task
|
|
52
|
+
if not task_file.is_file():
|
|
53
|
+
return fail(
|
|
54
|
+
f"record not done: the task file {identity.task} named by {harness.CONFIG_FILENAME} "
|
|
55
|
+
f"was not found there, under {root}"
|
|
56
|
+
)
|
|
57
|
+
archived = PurePosixPath(identity.task).parent.name == tasks.ARCHIVE_DIR
|
|
58
|
+
try:
|
|
59
|
+
task = tasks.read(task_file, root, identity.workstream, archived)
|
|
60
|
+
except tasks.TaskError as exc:
|
|
61
|
+
return fail(f"record not done: {identity.task} is not a valid task: {exc}")
|
|
62
|
+
text = task_file.read_bytes().decode("utf-8")
|
|
63
|
+
|
|
64
|
+
today = datetime.date.today()
|
|
65
|
+
errors = []
|
|
66
|
+
day = today if args.date is None else values.iso_date(args.date)
|
|
67
|
+
if day is None:
|
|
68
|
+
errors.append(f"invalid date '{args.date}': use YYYY-MM-DD")
|
|
69
|
+
elif problem := tasks.date_problem(day, task.date, today):
|
|
70
|
+
errors.append(problem)
|
|
71
|
+
bullets, draft_errors = read_draft(folder, args.file)
|
|
72
|
+
errors.extend(f"{args.file} {problem}" for problem in draft_errors)
|
|
73
|
+
_frontmatter, body = tasks.split(text)
|
|
74
|
+
if problem := tasks.updates_problem(body):
|
|
75
|
+
errors.append(f"{task.path} {problem}")
|
|
76
|
+
if errors:
|
|
77
|
+
return fail("record not done:\n" + "\n".join(f"- {error}" for error in errors))
|
|
78
|
+
assert day is not None, "an invalid date appended an error"
|
|
79
|
+
|
|
80
|
+
recorded, previous, updated_at = tasks.record(text, task.date, today, day, bullets)
|
|
81
|
+
task_file.write_bytes(recorded.encode("utf-8"))
|
|
82
|
+
try:
|
|
83
|
+
(folder / args.file).unlink()
|
|
84
|
+
except OSError as exc:
|
|
85
|
+
return fail(
|
|
86
|
+
f"{task.path} is recorded but the draft {args.file} could not be deleted: {exc.strerror}; "
|
|
87
|
+
"delete it by hand and do not record it again, which would write its bullets twice"
|
|
88
|
+
)
|
|
89
|
+
print(task.path)
|
|
90
|
+
if updated_at == previous:
|
|
91
|
+
print(f"updated_at: {previous.isoformat()} (unchanged)")
|
|
92
|
+
else:
|
|
93
|
+
print(f"updated_at: {previous.isoformat()} -> {updated_at.isoformat()}")
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def read_draft(folder: Path, given: str) -> tuple[list[str], list[str]]:
|
|
98
|
+
"""The bullet lines of the draft at `given`, relative to the harness root, and every problem found with it."""
|
|
99
|
+
path = (folder / given).resolve()
|
|
100
|
+
if not path.is_relative_to(folder):
|
|
101
|
+
return [], ["is not inside the harness (paths are relative to the harness root)"]
|
|
102
|
+
if not path.is_file():
|
|
103
|
+
return [], ["is not a file" if path.exists() else "does not exist"]
|
|
104
|
+
try:
|
|
105
|
+
text = path.read_bytes().decode("utf-8")
|
|
106
|
+
except UnicodeDecodeError:
|
|
107
|
+
return [], ["is not UTF-8 text"]
|
|
108
|
+
return tasks.parse_bullets(text)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def fail(message: str) -> int:
|
|
112
|
+
print(f"gemba record: {message}", file=sys.stderr)
|
|
113
|
+
return 1
|
gemba/commands/report.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""`gemba report`: select tasks as `gemba list` does, then count, group and sum them in one JSON document."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import dataclasses
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from gemba import config, report
|
|
11
|
+
from gemba.commands import selection
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
15
|
+
parser = subparsers.add_parser(
|
|
16
|
+
"report",
|
|
17
|
+
help="count, group and sum the selected tasks as one JSON document",
|
|
18
|
+
description=(
|
|
19
|
+
"Select tasks with the filters of gemba list, then print one JSON object: `by` and `sum` as given, "
|
|
20
|
+
"`groups`, one entry per combination of grouping values with its task count, refs and sums, "
|
|
21
|
+
"`total`, over every selected task counted once, and `problems`, as gemba list reports them. "
|
|
22
|
+
"Sums are exact; a task without a number to add is counted as missing."
|
|
23
|
+
),
|
|
24
|
+
)
|
|
25
|
+
selection.add_options(parser)
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"--by",
|
|
28
|
+
dest="groupings",
|
|
29
|
+
action="append",
|
|
30
|
+
default=[],
|
|
31
|
+
metavar="KEY",
|
|
32
|
+
help="group by workstream, status, or a declared text or boolean field; repeat to group within groups",
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--sum",
|
|
36
|
+
dest="sums",
|
|
37
|
+
action="append",
|
|
38
|
+
default=[],
|
|
39
|
+
metavar="KEY",
|
|
40
|
+
help="add up a declared number field per group and in the total; repeat for several",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument("--root", type=Path, help="tasks repo root (default: walk up to the nearest gemba.yaml)")
|
|
43
|
+
parser.set_defaults(handler=run)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def run(args: argparse.Namespace) -> int:
|
|
47
|
+
try:
|
|
48
|
+
gemba_config = config.load(root=args.root)
|
|
49
|
+
except config.ConfigError as exc:
|
|
50
|
+
return fail(str(exc))
|
|
51
|
+
|
|
52
|
+
scope, filters, errors = selection.build(args, gemba_config)
|
|
53
|
+
types = gemba_config.types()
|
|
54
|
+
errors.extend(report.problems(args.groupings, args.sums, types))
|
|
55
|
+
if errors:
|
|
56
|
+
return fail("nothing reported:\n" + "\n".join(f"- {error}" for error in errors))
|
|
57
|
+
|
|
58
|
+
found, problems = selection.select(gemba_config, scope, filters)
|
|
59
|
+
result = report.aggregate(found, args.groupings, args.sums, types, list(gemba_config.workstreams))
|
|
60
|
+
document = {
|
|
61
|
+
"by": args.groupings,
|
|
62
|
+
"sum": args.sums,
|
|
63
|
+
"groups": [
|
|
64
|
+
{"key": group.key, "tasks": group.tasks, "refs": group.refs, "sums": sums(group.sums)}
|
|
65
|
+
for group in result.groups
|
|
66
|
+
],
|
|
67
|
+
"total": {"tasks": result.tasks, "sums": sums(result.sums)},
|
|
68
|
+
"problems": [dataclasses.asdict(problem) for problem in problems],
|
|
69
|
+
}
|
|
70
|
+
print(selection.dump(document))
|
|
71
|
+
return 0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def sums(found: dict[str, report.Sum]) -> dict[str, dict[str, object]]:
|
|
75
|
+
return {key: {"total": total.total, "missing": total.missing} for key, total in found.items()}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def fail(message: str) -> int:
|
|
79
|
+
print(f"gemba report: {message}", file=sys.stderr)
|
|
80
|
+
return 1
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""The selection shared by `gemba list` and `gemba report`: its options, their validation and the scan."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import datetime
|
|
7
|
+
import json
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from gemba import config, metadata, tasks, values
|
|
11
|
+
|
|
12
|
+
# Destination of each date option, named after the Filters field it fills.
|
|
13
|
+
DATE_OPTIONS = (
|
|
14
|
+
("created_from", "--from"),
|
|
15
|
+
("created_to", "--to"),
|
|
16
|
+
("updated_from", "--updated-from"),
|
|
17
|
+
("updated_to", "--updated-to"),
|
|
18
|
+
("done_from", "--done-from"),
|
|
19
|
+
("done_to", "--done-to"),
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Scope:
|
|
25
|
+
"""The workstream folders a selection scans; unless they were filtered, every stray entry under tasks/ too."""
|
|
26
|
+
|
|
27
|
+
workstreams: tuple[str, ...]
|
|
28
|
+
include_archived: bool
|
|
29
|
+
filtered: bool
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def add_options(parser: argparse.ArgumentParser) -> None:
|
|
33
|
+
"""Register the selection filters on the parser of a command."""
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--workstream",
|
|
36
|
+
dest="workstreams",
|
|
37
|
+
action="append",
|
|
38
|
+
default=[],
|
|
39
|
+
metavar="ID",
|
|
40
|
+
help="workstream identifier from gemba.yaml; repeat for several (default: every workstream)",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"--status",
|
|
44
|
+
dest="statuses",
|
|
45
|
+
action="append",
|
|
46
|
+
default=[],
|
|
47
|
+
metavar="STATUS",
|
|
48
|
+
help="open, in-progress, blocked or done; repeat for several (default: every status)",
|
|
49
|
+
)
|
|
50
|
+
parser.add_argument("--ref", help="exact task ref, such as SUP-1")
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"--from", dest="created_from", metavar="YYYY-MM-DD", help="created on or after (the filename date)"
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"--to", dest="created_to", metavar="YYYY-MM-DD", help="created on or before (the filename date)"
|
|
56
|
+
)
|
|
57
|
+
parser.add_argument("--updated-from", dest="updated_from", metavar="YYYY-MM-DD", help="updated_at on or after")
|
|
58
|
+
parser.add_argument("--updated-to", dest="updated_to", metavar="YYYY-MM-DD", help="updated_at on or before")
|
|
59
|
+
parser.add_argument(
|
|
60
|
+
"--done-from", dest="done_from", metavar="YYYY-MM-DD", help="done_at on or after; tasks without it never match"
|
|
61
|
+
)
|
|
62
|
+
parser.add_argument(
|
|
63
|
+
"--done-to", dest="done_to", metavar="YYYY-MM-DD", help="done_at on or before; tasks without it never match"
|
|
64
|
+
)
|
|
65
|
+
parser.add_argument("--include-archived", action="store_true", help="also read the archive/ folders")
|
|
66
|
+
parser.add_argument(
|
|
67
|
+
"--where",
|
|
68
|
+
dest="conditions",
|
|
69
|
+
action="append",
|
|
70
|
+
default=[],
|
|
71
|
+
metavar="KEY=VALUE",
|
|
72
|
+
help=(
|
|
73
|
+
"metadata condition on a declared key, the value written by the type of the key (null finds unset "
|
|
74
|
+
"values); matches an equal value or a list containing it; repeat a key for alternatives"
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
parser.add_argument(
|
|
78
|
+
"--where-from",
|
|
79
|
+
dest="lower",
|
|
80
|
+
action="append",
|
|
81
|
+
default=[],
|
|
82
|
+
metavar="KEY=VALUE",
|
|
83
|
+
help="lower bound, included, on a number or date key; one per key; null values never match",
|
|
84
|
+
)
|
|
85
|
+
parser.add_argument(
|
|
86
|
+
"--where-to",
|
|
87
|
+
dest="upper",
|
|
88
|
+
action="append",
|
|
89
|
+
default=[],
|
|
90
|
+
metavar="KEY=VALUE",
|
|
91
|
+
help="upper bound, included, on a number or date key; one per key; null values never match",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def build(args: argparse.Namespace, gemba_config: config.Config) -> tuple[Scope, tasks.Filters, list[str]]:
|
|
96
|
+
"""The scan scope and the filters of the options, and every invalid filter."""
|
|
97
|
+
errors = []
|
|
98
|
+
workstreams = list(dict.fromkeys(args.workstreams))
|
|
99
|
+
for workstream in workstreams:
|
|
100
|
+
if workstream not in gemba_config.workstreams:
|
|
101
|
+
errors.append(
|
|
102
|
+
f"unknown workstream '{workstream}' (valid workstreams: {', '.join(gemba_config.workstreams)})"
|
|
103
|
+
)
|
|
104
|
+
for status in dict.fromkeys(args.statuses):
|
|
105
|
+
if problem := tasks.status_problem(status):
|
|
106
|
+
errors.append(problem)
|
|
107
|
+
dates: dict[str, datetime.date | None] = {}
|
|
108
|
+
for dest, option in DATE_OPTIONS:
|
|
109
|
+
text = getattr(args, dest)
|
|
110
|
+
dates[dest] = None if text is None else values.iso_date(text)
|
|
111
|
+
if text is not None and dates[dest] is None:
|
|
112
|
+
errors.append(f"invalid date '{text}' for {option}: use YYYY-MM-DD")
|
|
113
|
+
conditions, condition_errors = metadata.parse_conditions(
|
|
114
|
+
args.conditions, args.lower, args.upper, gemba_config.types()
|
|
115
|
+
)
|
|
116
|
+
errors.extend(condition_errors)
|
|
117
|
+
|
|
118
|
+
# A workstream filter is the scan scope: no other folder is read or reported.
|
|
119
|
+
scope = Scope(tuple(workstreams or gemba_config.workstreams), args.include_archived, bool(workstreams))
|
|
120
|
+
filters = tasks.Filters(
|
|
121
|
+
statuses=args.statuses,
|
|
122
|
+
ref=args.ref,
|
|
123
|
+
created_from=dates["created_from"],
|
|
124
|
+
created_to=dates["created_to"],
|
|
125
|
+
updated_from=dates["updated_from"],
|
|
126
|
+
updated_to=dates["updated_to"],
|
|
127
|
+
done_from=dates["done_from"],
|
|
128
|
+
done_to=dates["done_to"],
|
|
129
|
+
metadata=conditions,
|
|
130
|
+
schemas={
|
|
131
|
+
workstream: [field.name for field in gemba_config.effective_schema(workstream)]
|
|
132
|
+
for workstream in gemba_config.workstreams
|
|
133
|
+
},
|
|
134
|
+
)
|
|
135
|
+
return scope, filters, errors
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def select(
|
|
139
|
+
gemba_config: config.Config, scope: Scope, filters: tasks.Filters
|
|
140
|
+
) -> tuple[list[tasks.Task], list[tasks.Problem]]:
|
|
141
|
+
"""The tasks of the scope that pass the filters, and the problems of the scan.
|
|
142
|
+
|
|
143
|
+
Every value of a scanned task whose key has a declared type and that is not of that
|
|
144
|
+
type is a problem too, whatever the filters, and its task is still selected when it
|
|
145
|
+
matches.
|
|
146
|
+
"""
|
|
147
|
+
found, problems = tasks.scan(
|
|
148
|
+
gemba_config.root,
|
|
149
|
+
scope.workstreams,
|
|
150
|
+
scope.include_archived,
|
|
151
|
+
configured=None if scope.filtered else list(gemba_config.workstreams),
|
|
152
|
+
)
|
|
153
|
+
types = gemba_config.types()
|
|
154
|
+
for task in found:
|
|
155
|
+
for key, value in task.metadata.items():
|
|
156
|
+
if key in types and (problem := metadata.value_problem(types[key], value)):
|
|
157
|
+
problems.append(tasks.Problem(task.path, f"metadata value of '{key}' {problem}"))
|
|
158
|
+
problems.sort(key=lambda problem: problem.path)
|
|
159
|
+
return [task for task in found if tasks.matches(task, filters)], problems
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def dump(document: object) -> str:
|
|
163
|
+
"""A document as `list` and `report` print it: indented JSON, dates as ISO strings."""
|
|
164
|
+
return json.dumps(document, indent=2, ensure_ascii=False, default=_iso)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _iso(value: object) -> str:
|
|
168
|
+
if isinstance(value, datetime.date):
|
|
169
|
+
return value.isoformat()
|
|
170
|
+
raise TypeError(f"{type(value).__name__} is not JSON serializable")
|
gemba/commands/set.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""`gemba set`: write metadata values of a task after its creation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from gemba import config, metadata, tasks
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
13
|
+
parser = subparsers.add_parser(
|
|
14
|
+
"set",
|
|
15
|
+
help="set metadata values of a task",
|
|
16
|
+
description=(
|
|
17
|
+
"Write metadata values into the task with this ref, archives included, each converted by the "
|
|
18
|
+
"declared type of its field. The metadata map is completed with the keys of the current schema, "
|
|
19
|
+
"as null, and keeps the keys the schema no longer declares. Only the metadata is rewritten: "
|
|
20
|
+
"updated_at, the body, the harness of the task and git are not touched."
|
|
21
|
+
),
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument("ref", help="ref of the task, such as SUP-1")
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"assignments",
|
|
26
|
+
nargs="+",
|
|
27
|
+
metavar="KEY=VALUE",
|
|
28
|
+
help=(
|
|
29
|
+
"key=value sets a value written by the type of its field, key=null clears it, and key+=number adds "
|
|
30
|
+
"to a number field, null counting as zero; repeat a key of a text field to set a list"
|
|
31
|
+
),
|
|
32
|
+
)
|
|
33
|
+
parser.add_argument("--root", type=Path, help="tasks repo root (default: walk up to the nearest gemba.yaml)")
|
|
34
|
+
parser.set_defaults(handler=run)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def run(args: argparse.Namespace) -> int:
|
|
38
|
+
try:
|
|
39
|
+
gemba_config = config.load(root=args.root)
|
|
40
|
+
except config.ConfigError as exc:
|
|
41
|
+
return fail(str(exc))
|
|
42
|
+
|
|
43
|
+
assignments, errors = metadata.parse_assignments(args.assignments, allow_add=True)
|
|
44
|
+
try:
|
|
45
|
+
task = tasks.find_by_ref(gemba_config.root, gemba_config.workstreams, args.ref)
|
|
46
|
+
except tasks.TaskError as exc:
|
|
47
|
+
task = None
|
|
48
|
+
errors.append(str(exc))
|
|
49
|
+
else:
|
|
50
|
+
if task is None:
|
|
51
|
+
errors.append(f"no task has ref '{args.ref}' under {tasks.TASKS_DIR}/, archives included")
|
|
52
|
+
assigned = None
|
|
53
|
+
if task is not None:
|
|
54
|
+
schema = gemba_config.effective_schema(task.workstream)
|
|
55
|
+
assigned = metadata.assign(schema, task.metadata, assignments, f"'{task.workstream}'")
|
|
56
|
+
errors.extend(assigned.problems)
|
|
57
|
+
if errors:
|
|
58
|
+
return fail("metadata not set:\n" + "\n".join(f"- {error}" for error in errors))
|
|
59
|
+
assert task is not None and assigned is not None, "every failed check appended an error"
|
|
60
|
+
|
|
61
|
+
if assigned.changed:
|
|
62
|
+
task_file = gemba_config.root / task.path
|
|
63
|
+
try:
|
|
64
|
+
text = tasks.set_metadata(task_file.read_bytes().decode("utf-8"), assigned.values)
|
|
65
|
+
except tasks.TaskError as exc:
|
|
66
|
+
return fail(f"metadata not set: {task.path}: {exc}")
|
|
67
|
+
task_file.write_bytes(text.encode("utf-8"))
|
|
68
|
+
print(task.path)
|
|
69
|
+
for change in assigned.changes:
|
|
70
|
+
if change.added:
|
|
71
|
+
print(f"{change.key}: added as null")
|
|
72
|
+
elif change.changed:
|
|
73
|
+
print(f"{change.key}: {metadata.show(change.old)} -> {metadata.show(change.new)}")
|
|
74
|
+
else:
|
|
75
|
+
print(f"{change.key}: {metadata.show(change.new)} (unchanged)")
|
|
76
|
+
return 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def fail(message: str) -> int:
|
|
80
|
+
print(f"gemba set: {message}", file=sys.stderr)
|
|
81
|
+
return 1
|