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
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: gemba-worktree
|
|
3
|
+
description: Check out the repositories of a task as git worktrees inside its harness, list them and remove them. Use whenever, inside a harness, the user asks to check out, attach, add or open a repository of the task, to start a repository from another branch, to list or show the worktrees, their branches or what is not pushed, or to detach, drop or remove a worktree, whatever the workstream.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Work on repositories in worktrees
|
|
7
|
+
|
|
8
|
+
A worktree is a checkout of another repository under `worktrees/` of the harness, on
|
|
9
|
+
one local branch of the run that gemba names after the task. The `gemba worktree`
|
|
10
|
+
commands create, move, list and remove the worktrees and their branches; every git
|
|
11
|
+
command against the repository a worktree comes from is theirs. Your part is to choose
|
|
12
|
+
the repositories and the base branch, run the commands, relay what they print, and do
|
|
13
|
+
the work of the task inside the worktrees.
|
|
14
|
+
|
|
15
|
+
Every organization-specific value (the repositories folder, the workstreams and their
|
|
16
|
+
rules) lives in `gemba.yaml`, at the tasks repo root. Run
|
|
17
|
+
`gemba config show --workstream <id>`, with the `workstream` of `harness.yaml`, on every
|
|
18
|
+
run and rely on nothing else you know or remember about repositories, branches or
|
|
19
|
+
workstreams. It prints the config resolved, from anywhere inside the harness:
|
|
20
|
+
`repos_dir` at the top level, and in the entry of the workstream, under
|
|
21
|
+
`processes.worktree`, the block of every verb, such as `processes.worktree.add`, with
|
|
22
|
+
the block shared by every workstream already joined to the workstream's. Wherever this
|
|
23
|
+
skill says to read a block, read its `context` as background and follow every entry of
|
|
24
|
+
its `rules` in order; when two rules contradict, follow the later one.
|
|
25
|
+
|
|
26
|
+
## Prerequisite
|
|
27
|
+
|
|
28
|
+
The `gemba` command must be on the PATH. It is installed once from PyPI with
|
|
29
|
+
`uv tool install gemba-cli` and checked with `gemba --version`. If `gemba` is not
|
|
30
|
+
found, stop and tell the user to install it with that command. Every verb runs inside a
|
|
31
|
+
harness, from its root or any folder below it. Never run `git worktree` or
|
|
32
|
+
`git branch`, and never run any git command in the repository a worktree comes from.
|
|
33
|
+
|
|
34
|
+
## Add
|
|
35
|
+
|
|
36
|
+
1. **Find the repositories the task needs** in `task.md` and in the notes of the run.
|
|
37
|
+
Name each one by its folder name under the `repos_dir` that `gemba config show`
|
|
38
|
+
prints, such as `nakatomi-web`. When a name is not clear from them, ask the user; never
|
|
39
|
+
browse `repos_dir` or any folder above the harness to guess it.
|
|
40
|
+
2. **Read the `worktree.add` block**, `processes.worktree.add` of the document. Read
|
|
41
|
+
`context` as background and follow every entry of `rules`: they say which branch each
|
|
42
|
+
repository starts from.
|
|
43
|
+
3. **Choose the base.** Without `--from`, the worktree starts at the default branch of
|
|
44
|
+
`origin`. Pass `--from <branch>` when the rules, the task or the user name another
|
|
45
|
+
branch of `origin`. Only a branch is a base: never pass a tag or a commit.
|
|
46
|
+
4. **Run the command** once per repository:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
gemba worktree add <repo> [--from <branch>]
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
5. **Relay** what it printed: the worktree path, `branch:`, `from:` and `created`, or
|
|
53
|
+
`already attached, worktree unchanged` when the repository is already checked out in
|
|
54
|
+
this harness. When it refuses, relay every line, the repositories it names as found
|
|
55
|
+
at the root of `repos_dir` included, and ask the user how to go on; never work
|
|
56
|
+
around a refusal.
|
|
57
|
+
|
|
58
|
+
## Branches
|
|
59
|
+
|
|
60
|
+
- The branch of a worktree is local: gemba names it after the run, it exists only on
|
|
61
|
+
this machine and it is never pushed under its own name.
|
|
62
|
+
- It starts with no upstream. Inside the worktree, commit, fetch and pull freely; a
|
|
63
|
+
pull needs the upstream that the first push sets.
|
|
64
|
+
- A push reaches the remote, so it needs an `external-action` note whose frontmatter
|
|
65
|
+
reads `approved: true` and that names the branch of the remote. The push always names
|
|
66
|
+
its target, `git push -u origin HEAD:<the branch the note names>`; the first one sets
|
|
67
|
+
the upstream, which `gemba worktree list` then shows. Never run `git push` alone or
|
|
68
|
+
`git push origin HEAD`.
|
|
69
|
+
- To start again from another base, run `gemba worktree add <repo> --from <branch>` on
|
|
70
|
+
the repository already attached: it moves the branch of the run to that base and
|
|
71
|
+
keeps its name. It is not a rebase: it refuses while anything is uncommitted or on no
|
|
72
|
+
remote branch, and even after a push it discards the local tree. To carry your
|
|
73
|
+
commits onto another base instead, rebase inside the worktree with
|
|
74
|
+
`git rebase --onto origin/<new base> origin/<old base>`.
|
|
75
|
+
|
|
76
|
+
## List
|
|
77
|
+
|
|
78
|
+
Read the `worktree.list` block, `processes.worktree.list` of the document, and run:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
gemba worktree list
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
For each worktree it prints its path, `branch:`, `upstream:` (`none` until a push sets
|
|
85
|
+
it), `uncommitted:`, the number of paths not committed, and `unpushed:`, the number of
|
|
86
|
+
commits on no remote branch. Every value is read from git when it runs. Relay them; a
|
|
87
|
+
folder listed as `not a worktree` was not created by gemba, so tell the user. With no
|
|
88
|
+
worktree it prints `no worktrees`.
|
|
89
|
+
|
|
90
|
+
## Remove
|
|
91
|
+
|
|
92
|
+
1. **Read the `worktree.remove` block**, `processes.worktree.remove` of the document.
|
|
93
|
+
Read `context` as background and follow every entry of `rules` before showing the
|
|
94
|
+
state.
|
|
95
|
+
2. **Show the state** by running the command without `--yes`:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
gemba worktree remove <repo>
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
It prints the block `list` prints for that worktree, then
|
|
102
|
+
`not removed: pass --yes to delete the worktree`, and deletes nothing.
|
|
103
|
+
3. **Ask the user.** Show them the state, ask whether to remove the worktree and its
|
|
104
|
+
local branch, and remind them to close any editor window open on that folder first.
|
|
105
|
+
A yes in the conversation is enough: the worktree and its branch are local.
|
|
106
|
+
4. **Remove only after a yes**, by running the same command with `--yes`:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
gemba worktree remove <repo> --yes
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
When the user says no, stop. Relay `removed`: the worktree and its local branch are
|
|
113
|
+
gone, and the remote is what it was.
|
|
114
|
+
|
|
115
|
+
## When work is pending
|
|
116
|
+
|
|
117
|
+
`gemba worktree add --from` prints `worktree not moved:` and `gemba worktree remove`
|
|
118
|
+
prints `worktree not removed:` while the worktree holds uncommitted paths or commits on
|
|
119
|
+
no remote branch. Relay every line and say how each is saved: uncommitted paths by a
|
|
120
|
+
commit inside the worktree; commits on no remote branch by a push through an approved
|
|
121
|
+
`external-action` note. Never commit, push or delete anything to make the command pass.
|
|
122
|
+
|
|
123
|
+
**Never force first.** `--force` discards work. Never pass it on the first run of
|
|
124
|
+
`add --from` or `remove`, even when the user asks to force it: run the command without
|
|
125
|
+
it, relay what it reports as pending, and pass `--force` only after the user confirms,
|
|
126
|
+
in the conversation, that everything in that list is to be discarded. The same holds
|
|
127
|
+
for `add --force` without `--from`, which deletes and recreates a branch of the run
|
|
128
|
+
left without a worktree, discarding its commits.
|
|
129
|
+
|
|
130
|
+
## Rules
|
|
131
|
+
|
|
132
|
+
- The commands own the worktrees and the branches of the run. Never run `git worktree`
|
|
133
|
+
or `git branch`, and never run git in the repository a worktree comes from.
|
|
134
|
+
- Nothing reaches the remote without an `external-action` note whose frontmatter reads
|
|
135
|
+
`approved: true`.
|
|
136
|
+
- When `gemba.yaml` changes, follow the new content. This skill never needs an edit when
|
|
137
|
+
a workstream, a rule or the repositories folder is added, changed or removed.
|
gemba/cli.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Entry point of the `gemba` command: subcommand dispatch and `--version`."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from importlib.metadata import version
|
|
7
|
+
from typing import NoReturn
|
|
8
|
+
|
|
9
|
+
from gemba.commands import archive, config, create, harness, init, mark, record, report, skills, worktree
|
|
10
|
+
from gemba.commands import list as list_ # named after the process; the alias keeps list() usable
|
|
11
|
+
from gemba.commands import set as set_ # named after the process; the alias keeps set() usable
|
|
12
|
+
|
|
13
|
+
COMMANDS = (init, create, list_, skills, config, harness, record, mark, archive, report, set_, worktree)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ArgumentParser(argparse.ArgumentParser):
|
|
17
|
+
"""Argument parser whose usage errors exit with 1, like every other gemba error."""
|
|
18
|
+
|
|
19
|
+
def error(self, message: str) -> NoReturn:
|
|
20
|
+
self.print_usage(sys.stderr)
|
|
21
|
+
self.exit(1, f"{self.prog}: error: {message}\n")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
25
|
+
parser = ArgumentParser(prog="gemba", description="Task-based system for working with AI agents.")
|
|
26
|
+
parser.add_argument("--version", action="version", version=version("gemba-cli"))
|
|
27
|
+
subparsers = parser.add_subparsers(dest="command", metavar="<command>")
|
|
28
|
+
for command in COMMANDS:
|
|
29
|
+
command.register(subparsers)
|
|
30
|
+
|
|
31
|
+
args = parser.parse_args(argv)
|
|
32
|
+
if args.command is None:
|
|
33
|
+
parser.print_help(sys.stderr)
|
|
34
|
+
return 1
|
|
35
|
+
return args.handler(args)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""One module per process of Gemba, each exposing `register(subparsers)`."""
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""`gemba archive`: move tasks that went cold into the archive folder of their workstream."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path, PurePosixPath
|
|
8
|
+
|
|
9
|
+
from gemba import config, harness, tasks
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
13
|
+
parser = subparsers.add_parser(
|
|
14
|
+
"archive",
|
|
15
|
+
help="move tasks into the archive folder of their workstream",
|
|
16
|
+
description=(
|
|
17
|
+
f"Move the file of each task, unchanged, into {tasks.TASKS_DIR}/<workstream>/{tasks.ARCHIVE_DIR}/, "
|
|
18
|
+
"whatever its status. Each ref is handled on its own and gets one line, archived or not archived; "
|
|
19
|
+
"the exit code is 1 when any ref was refused. A task whose harness folder exists is refused. "
|
|
20
|
+
"Nothing is committed."
|
|
21
|
+
),
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"refs", nargs="+", metavar="ref", help="ref of a task, such as SUP-1; give several to archive them"
|
|
25
|
+
)
|
|
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
|
+
print(f"gemba archive: {exc}", file=sys.stderr)
|
|
35
|
+
return 1
|
|
36
|
+
|
|
37
|
+
root = gemba_config.root
|
|
38
|
+
refused = False
|
|
39
|
+
for ref in args.refs:
|
|
40
|
+
try:
|
|
41
|
+
task = tasks.find_by_ref(root, gemba_config.workstreams, ref)
|
|
42
|
+
except tasks.TaskError as exc:
|
|
43
|
+
problem = f"cannot be resolved: {exc}"
|
|
44
|
+
else:
|
|
45
|
+
if task is None:
|
|
46
|
+
problem = f"is the ref of no task under {tasks.TASKS_DIR}/, archives included"
|
|
47
|
+
else:
|
|
48
|
+
folder = harness.harness_path(root, task.workstream, PurePosixPath(task.path).stem)
|
|
49
|
+
destination = tasks.archive_destination(task)
|
|
50
|
+
problem = tasks.archive_problem(
|
|
51
|
+
task,
|
|
52
|
+
folder.relative_to(root).as_posix() if folder.exists() else None,
|
|
53
|
+
(root / destination).exists(),
|
|
54
|
+
)
|
|
55
|
+
if problem is None:
|
|
56
|
+
(root / destination).parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
(root / task.path).rename(root / destination)
|
|
58
|
+
print(f"archived: {destination}")
|
|
59
|
+
continue
|
|
60
|
+
refused = True
|
|
61
|
+
print(f"not archived: {ref} {problem}")
|
|
62
|
+
return 1 if refused else 0
|
gemba/commands/config.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""`gemba config check` and `gemba config show`: validate gemba.yaml, then summarize or resolve it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from gemba import config
|
|
10
|
+
from gemba.commands import selection
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
14
|
+
parser = subparsers.add_parser(
|
|
15
|
+
"config",
|
|
16
|
+
help="check the gemba config or show it resolved",
|
|
17
|
+
description=(
|
|
18
|
+
f"Read {config.CONFIG_FILENAME}, the config of the tasks repo, without ever writing it: the file is "
|
|
19
|
+
"edited by hand."
|
|
20
|
+
),
|
|
21
|
+
)
|
|
22
|
+
parser.set_defaults(handler=lambda _args: usage(parser))
|
|
23
|
+
verbs = parser.add_subparsers(dest="verb", metavar="<verb>")
|
|
24
|
+
|
|
25
|
+
check = verbs.add_parser(
|
|
26
|
+
"check",
|
|
27
|
+
help="validate the config and summarize it",
|
|
28
|
+
description=(
|
|
29
|
+
f"Validate {config.CONFIG_FILENAME} as every command does before acting. A valid config is summarized "
|
|
30
|
+
"with its workstreams and prefixes, its default workstream and its agent providers; an invalid one is "
|
|
31
|
+
"reported with the path of the file and the first problem, and the command exits 1."
|
|
32
|
+
),
|
|
33
|
+
)
|
|
34
|
+
check.add_argument("--root", type=Path, help="tasks repo root (default: walk up to the nearest gemba.yaml)")
|
|
35
|
+
check.set_defaults(handler=run_check)
|
|
36
|
+
|
|
37
|
+
show = verbs.add_parser(
|
|
38
|
+
"show",
|
|
39
|
+
help="print the effective config as one JSON document",
|
|
40
|
+
description=(
|
|
41
|
+
f"Validate {config.CONFIG_FILENAME} as check does and print the effective config as one JSON object: "
|
|
42
|
+
"the top-level keys, the defaults applied and null where nothing is declared, and `workstreams`, in "
|
|
43
|
+
"catalogue order, each with its prefix, its description, its effective metadata schema and, under "
|
|
44
|
+
"`processes`, the effective block of every process and verb, the base block followed by the "
|
|
45
|
+
"workstream's."
|
|
46
|
+
),
|
|
47
|
+
)
|
|
48
|
+
show.add_argument("--workstream", metavar="ID", help="workstream identifier from gemba.yaml (default: every one)")
|
|
49
|
+
show.add_argument("--root", type=Path, help="tasks repo root (default: walk up to the nearest gemba.yaml)")
|
|
50
|
+
show.set_defaults(handler=run_show)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def run_check(args: argparse.Namespace) -> int:
|
|
54
|
+
try:
|
|
55
|
+
gemba_config = config.load(root=args.root)
|
|
56
|
+
except config.ConfigError as exc:
|
|
57
|
+
return fail("check", str(exc))
|
|
58
|
+
|
|
59
|
+
workstreams = ", ".join(
|
|
60
|
+
f"{workstream.id} ({workstream.prefix})" for workstream in gemba_config.workstreams.values()
|
|
61
|
+
)
|
|
62
|
+
providers = ", ".join(gemba_config.agent_providers) if gemba_config.agent_providers else "none declared"
|
|
63
|
+
print(f"{config.CONFIG_FILENAME} is valid")
|
|
64
|
+
print(f"workstreams: {workstreams}")
|
|
65
|
+
print(f"default workstream: {gemba_config.default_workstream}")
|
|
66
|
+
print(f"agent providers: {providers}")
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def run_show(args: argparse.Namespace) -> int:
|
|
71
|
+
try:
|
|
72
|
+
gemba_config = config.load(root=args.root)
|
|
73
|
+
except config.ConfigError as exc:
|
|
74
|
+
return fail("show", str(exc))
|
|
75
|
+
|
|
76
|
+
if args.workstream is not None and args.workstream not in gemba_config.workstreams:
|
|
77
|
+
return fail(
|
|
78
|
+
"show",
|
|
79
|
+
f"unknown workstream '{args.workstream}' (valid workstreams: {', '.join(gemba_config.workstreams)})",
|
|
80
|
+
)
|
|
81
|
+
shown = [args.workstream] if args.workstream is not None else list(gemba_config.workstreams)
|
|
82
|
+
document = {
|
|
83
|
+
"organization": gemba_config.organization,
|
|
84
|
+
"branch_prefix": gemba_config.branch_prefix,
|
|
85
|
+
"repos_dir": gemba_config.repos_dir,
|
|
86
|
+
"ide": gemba_config.ide,
|
|
87
|
+
"agent_providers": None if gemba_config.agent_providers is None else list(gemba_config.agent_providers),
|
|
88
|
+
"default_workstream": gemba_config.default_workstream,
|
|
89
|
+
"workstreams": [workstream(gemba_config, workstream_id) for workstream_id in shown],
|
|
90
|
+
}
|
|
91
|
+
print(selection.dump(document))
|
|
92
|
+
return 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def workstream(gemba_config: config.Config, workstream_id: str) -> dict[str, object]:
|
|
96
|
+
"""The entry of one workstream in the document of show."""
|
|
97
|
+
declared = gemba_config.workstreams[workstream_id]
|
|
98
|
+
return {
|
|
99
|
+
"id": declared.id,
|
|
100
|
+
"prefix": declared.prefix,
|
|
101
|
+
"description": declared.description,
|
|
102
|
+
"metadata": [
|
|
103
|
+
{"name": field.name, "type": field.type, "required": field.required, "description": field.description}
|
|
104
|
+
for field in gemba_config.effective_schema(workstream_id)
|
|
105
|
+
],
|
|
106
|
+
"processes": gemba_config.effective_processes(workstream_id),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def usage(parser: argparse.ArgumentParser) -> int:
|
|
111
|
+
parser.print_help(sys.stderr)
|
|
112
|
+
return 1
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def fail(verb: str, message: str) -> int:
|
|
116
|
+
print(f"gemba config {verb}: {message}", file=sys.stderr)
|
|
117
|
+
return 1
|
gemba/commands/create.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""`gemba create`: validate a creation request, allocate the ref and write the task file."""
|
|
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, metadata, tasks, values
|
|
11
|
+
|
|
12
|
+
CREATION_STATUSES = ("open", "in-progress", "blocked")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
16
|
+
parser = subparsers.add_parser(
|
|
17
|
+
"create",
|
|
18
|
+
help="create a task file",
|
|
19
|
+
description="Validate the request against gemba.yaml, allocate the ref and write the task file.",
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument("--workstream", required=True, help="workstream identifier from gemba.yaml")
|
|
22
|
+
parser.add_argument("--slug", required=True, help="lowercase ASCII words joined by single hyphens")
|
|
23
|
+
parser.add_argument("--date", help="creation date as YYYY-MM-DD (default: today)")
|
|
24
|
+
parser.add_argument("--status", default="open", help="open (default), in-progress or blocked")
|
|
25
|
+
parser.add_argument("--description", help="short description written at the top of the body")
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"--set",
|
|
28
|
+
dest="assignments",
|
|
29
|
+
action="append",
|
|
30
|
+
default=[],
|
|
31
|
+
metavar="KEY=VALUE",
|
|
32
|
+
help="metadata value, converted by the declared type of its field (null for none); "
|
|
33
|
+
"repeat a key of a text field to build a list",
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument("--root", type=Path, help="tasks repo root (default: walk up to the nearest gemba.yaml)")
|
|
36
|
+
parser.set_defaults(handler=run)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def run(args: argparse.Namespace) -> int:
|
|
40
|
+
try:
|
|
41
|
+
gemba_config = config.load(root=args.root)
|
|
42
|
+
except config.ConfigError as exc:
|
|
43
|
+
return fail(str(exc))
|
|
44
|
+
|
|
45
|
+
errors = []
|
|
46
|
+
workstream = gemba_config.workstreams.get(args.workstream)
|
|
47
|
+
if workstream is None:
|
|
48
|
+
errors.append(
|
|
49
|
+
f"unknown workstream '{args.workstream}' (valid workstreams: {', '.join(gemba_config.workstreams)})"
|
|
50
|
+
)
|
|
51
|
+
if not tasks.is_valid_slug(args.slug):
|
|
52
|
+
errors.append(f"invalid slug '{args.slug}': use lowercase ASCII letters and digits joined by single hyphens")
|
|
53
|
+
if args.status == "done":
|
|
54
|
+
errors.append("status 'done' is not allowed at creation (allowed: open, in-progress, blocked)")
|
|
55
|
+
elif args.status not in CREATION_STATUSES:
|
|
56
|
+
errors.append(f"invalid status '{args.status}' (allowed: open, in-progress, blocked)")
|
|
57
|
+
created, date_error = parse_date(args.date)
|
|
58
|
+
if date_error:
|
|
59
|
+
errors.append(date_error)
|
|
60
|
+
assignments, assignment_errors = metadata.parse_assignments(args.assignments, allow_add=False)
|
|
61
|
+
errors.extend(assignment_errors)
|
|
62
|
+
|
|
63
|
+
task_metadata: dict[str, object] = {}
|
|
64
|
+
if workstream is not None:
|
|
65
|
+
schema = gemba_config.effective_schema(workstream.id)
|
|
66
|
+
assigned = metadata.assign(schema, {}, assignments, f"'{workstream.id}'")
|
|
67
|
+
errors.extend(assigned.problems)
|
|
68
|
+
task_metadata = assigned.values
|
|
69
|
+
|
|
70
|
+
if errors:
|
|
71
|
+
return fail("task not created:\n" + "\n".join(f"- {error}" for error in errors))
|
|
72
|
+
assert workstream is not None and created is not None, "every failed check above appended an error"
|
|
73
|
+
|
|
74
|
+
ref = tasks.next_ref(gemba_config.root, workstream.prefix)
|
|
75
|
+
folder = gemba_config.root / tasks.TASKS_DIR / workstream.id
|
|
76
|
+
target = folder / tasks.filename(created, ref, args.slug)
|
|
77
|
+
relative = target.relative_to(gemba_config.root).as_posix()
|
|
78
|
+
if target.exists():
|
|
79
|
+
return fail(f"task not created: {relative} already exists")
|
|
80
|
+
|
|
81
|
+
for path in (folder, folder / tasks.ARCHIVE_DIR):
|
|
82
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
(path / tasks.GITKEEP).touch(exist_ok=True)
|
|
84
|
+
content = tasks.render(args.status, ref, created, task_metadata, args.description)
|
|
85
|
+
with target.open("x", encoding="utf-8") as handle:
|
|
86
|
+
handle.write(content)
|
|
87
|
+
|
|
88
|
+
print(relative)
|
|
89
|
+
print(ref)
|
|
90
|
+
return 0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def parse_date(text: str | None) -> tuple[datetime.date | None, str | None]:
|
|
94
|
+
if text is None:
|
|
95
|
+
return datetime.date.today(), None
|
|
96
|
+
created = values.iso_date(text)
|
|
97
|
+
if created is None:
|
|
98
|
+
return None, f"invalid date '{text}': use YYYY-MM-DD"
|
|
99
|
+
return created, None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def fail(message: str) -> int:
|
|
103
|
+
print(f"gemba create: {message}", file=sys.stderr)
|
|
104
|
+
return 1
|