feat(RMGR-WP-0008): add workplan and register receiving surfaces
This commit is contained in:
parent
5502afc1fd
commit
859df9aae7
15 changed files with 1501 additions and 11 deletions
|
|
@ -26,6 +26,7 @@ Architecture:
|
||||||
- [ADR-001 Implementation foundation](docs/adr-001-implementation-foundation.md)
|
- [ADR-001 Implementation foundation](docs/adr-001-implementation-foundation.md)
|
||||||
- [Railiance app deployment guide](docs/RailianceAppDeploymentGuide.md) (`RMGR-WP-0006`)
|
- [Railiance app deployment guide](docs/RailianceAppDeploymentGuide.md) (`RMGR-WP-0006`)
|
||||||
- [Repository standards v0.1](docs/repository-standards_v0.1.md) (`RMGR-WP-0004`)
|
- [Repository standards v0.1](docs/repository-standards_v0.1.md) (`RMGR-WP-0004`)
|
||||||
|
- [Repository registers v0.1](docs/repository-registers_v0.1.md) (`RMGR-WP-0008`)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make install # or: uv pip install -e ".[dev]"
|
make install # or: uv pip install -e ".[dev]"
|
||||||
|
|
@ -34,6 +35,12 @@ rmgr --version
|
||||||
rmgr observe --path .
|
rmgr observe --path .
|
||||||
rmgr reconcile --path .
|
rmgr reconcile --path .
|
||||||
rmgr update-task-status --path . --task-id <ID> --status progress
|
rmgr update-task-status --path . --task-id <ID> --status progress
|
||||||
|
rmgr workplan create --path . --workplan-id EX-WP-0001 --title "Example" --goal "Deliver X."
|
||||||
|
rmgr workplan update --path . --workplan-id EX-WP-0001 --status active
|
||||||
|
rmgr workplan delete --path . --workplan-id EX-WP-0001 --confirm # archives; never erases
|
||||||
|
rmgr register put --path . --kind technical-debt --entry-id TD-001 \
|
||||||
|
--title "Debt item" --data-json '{"severity":"high"}'
|
||||||
|
rmgr register list --path . --kind technical-debt
|
||||||
rmgr rapp wrap --path ../rapp-some-app --app some-app \
|
rmgr rapp wrap --path ../rapp-some-app --app some-app \
|
||||||
--ownership-repo some-app --from-app ../some-app
|
--ownership-repo some-app --from-app ../some-app
|
||||||
rmgr rapp validate --path ../rapp-user-engine --family-root ..
|
rmgr rapp validate --path ../rapp-user-engine --family-root ..
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@
|
||||||
| workplan | RMGR-WP-0005 | active | — | workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md |
|
| workplan | RMGR-WP-0005 | active | — | workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md |
|
||||||
| workplan | RMGR-WP-0006 | finished | — | workplans/RMGR-WP-0006-railiance-app-wrapper-setup.md |
|
| workplan | RMGR-WP-0006 | finished | — | workplans/RMGR-WP-0006-railiance-app-wrapper-setup.md |
|
||||||
| workplan | RMGR-WP-0007 | finished | — | workplans/RMGR-WP-0007-greenfield-rapp-wrap-efficiency.md |
|
| workplan | RMGR-WP-0007 | finished | — | workplans/RMGR-WP-0007-greenfield-rapp-wrap-efficiency.md |
|
||||||
| workplan | RMGR-WP-0008 | proposed | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md |
|
| workplan | RMGR-WP-0008 | active | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md |
|
||||||
| workplan | RMGR-WP-0009 | proposed | — | workplans/RMGR-WP-0009-coding-assistant-commit-provenance.md |
|
| workplan | RMGR-WP-0009 | ready | — | workplans/RMGR-WP-0009-coding-assistant-commit-provenance.md |
|
||||||
| task | RMGR-WP-0001-T01 | done | — | workplans/RMGR-WP-0001-foundation.md |
|
| task | RMGR-WP-0001-T01 | done | — | workplans/RMGR-WP-0001-foundation.md |
|
||||||
| task | RMGR-WP-0001-T02 | done | — | workplans/RMGR-WP-0001-foundation.md |
|
| task | RMGR-WP-0001-T02 | done | — | workplans/RMGR-WP-0001-foundation.md |
|
||||||
| task | RMGR-WP-0001-T03 | done | — | workplans/RMGR-WP-0001-foundation.md |
|
| task | RMGR-WP-0001-T03 | done | — | workplans/RMGR-WP-0001-foundation.md |
|
||||||
|
|
@ -65,11 +65,11 @@
|
||||||
| task | RMGR-WP-0007-T05 | done | — | workplans/RMGR-WP-0007-greenfield-rapp-wrap-efficiency.md |
|
| task | RMGR-WP-0007-T05 | done | — | workplans/RMGR-WP-0007-greenfield-rapp-wrap-efficiency.md |
|
||||||
| task | RMGR-WP-0007-T06 | done | — | workplans/RMGR-WP-0007-greenfield-rapp-wrap-efficiency.md |
|
| task | RMGR-WP-0007-T06 | done | — | workplans/RMGR-WP-0007-greenfield-rapp-wrap-efficiency.md |
|
||||||
| task | RMGR-WP-0007-T07 | done | — | workplans/RMGR-WP-0007-greenfield-rapp-wrap-efficiency.md |
|
| task | RMGR-WP-0007-T07 | done | — | workplans/RMGR-WP-0007-greenfield-rapp-wrap-efficiency.md |
|
||||||
| task | RMGR-WP-0008-T01 | todo | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md |
|
| task | RMGR-WP-0008-T01 | done | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md |
|
||||||
| task | RMGR-WP-0008-T02 | todo | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md |
|
| task | RMGR-WP-0008-T02 | todo | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md |
|
||||||
| task | RMGR-WP-0008-T03 | todo | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md |
|
| task | RMGR-WP-0008-T03 | todo | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md |
|
||||||
| task | RMGR-WP-0008-T04 | todo | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md |
|
| task | RMGR-WP-0008-T04 | todo | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md |
|
||||||
| task | RMGR-WP-0008-T05 | todo | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md |
|
| task | RMGR-WP-0008-T05 | progress | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md |
|
||||||
| task | RMGR-WP-0008-T06 | todo | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md |
|
| task | RMGR-WP-0008-T06 | todo | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md |
|
||||||
| task | RMGR-WP-0008-T07 | wait | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md |
|
| task | RMGR-WP-0008-T07 | wait | — | workplans/RMGR-WP-0008-work-record-and-register-receiving-surface.md |
|
||||||
| task | RMGR-WP-0009-T01 | todo | — | workplans/RMGR-WP-0009-coding-assistant-commit-provenance.md |
|
| task | RMGR-WP-0009-T01 | todo | — | workplans/RMGR-WP-0009-coding-assistant-commit-provenance.md |
|
||||||
|
|
|
||||||
|
|
@ -262,7 +262,17 @@ command:
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `repo.work.update_task_status` | task_id, status, blocking_reason? | **yes** | `repo:command:work_status` |
|
| `repo.work.update_task_status` | task_id, status, blocking_reason? | **yes** | `repo:command:work_status` |
|
||||||
| `repo.work.update_workplan_status` | workplan_id, status | **yes** | `repo:command:work_status` |
|
| `repo.work.update_workplan_status` | workplan_id, status | **yes** | `repo:command:work_status` |
|
||||||
|
| `repo.work.create_workplan` | workplan_id, title, goal, status?, owner?, domain?, topic_slug? | **yes** | `repo:command:workplan` |
|
||||||
|
| `repo.work.update_workplan` | workplan_id, title?, status?, owner?, domain?, topic_slug? | **yes** | `repo:command:workplan` |
|
||||||
|
| `repo.work.archive_workplan` | workplan_id, confirm_archive | **yes** | `repo:command:workplan` |
|
||||||
| `repo.work.writeback_ids` | record_ref, uuid fields | **yes** | `repo:command:writeback_ids` |
|
| `repo.work.writeback_ids` | record_ref, uuid fields | **yes** | `repo:command:writeback_ids` |
|
||||||
|
| `repo.register.upsert_entry` | kind, entry_id, title?, status?, data? | **yes** | `repo:command:register` |
|
||||||
|
| `repo.register.defer_entry` | kind, entry_id, status? | **yes** | `repo:command:register` |
|
||||||
|
| `repo.register.add_note` | kind, entry_id, note, author? | **yes** | `repo:command:register` |
|
||||||
|
|
||||||
|
`repo.work.archive_workplan` is the recoverable delete operation: it sets the
|
||||||
|
canonical status to `archived` and moves the file under the dated
|
||||||
|
`workplans/archived/` convention. It does not erase repository history.
|
||||||
|
|
||||||
#### Agents
|
#### Agents
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -115,9 +115,33 @@ commands:
|
||||||
scope: repo:command:work_status
|
scope: repo:command:work_status
|
||||||
mutates_git: true
|
mutates_git: true
|
||||||
params: [workplan_id, status]
|
params: [workplan_id, status]
|
||||||
|
- type: repo.work.create_workplan
|
||||||
|
scope: repo:command:workplan
|
||||||
|
mutates_git: true
|
||||||
|
params: [workplan_id, title, goal, status, owner, domain, topic_slug]
|
||||||
|
- type: repo.work.update_workplan
|
||||||
|
scope: repo:command:workplan
|
||||||
|
mutates_git: true
|
||||||
|
params: [workplan_id, title, status, owner, domain, topic_slug]
|
||||||
|
- type: repo.work.archive_workplan
|
||||||
|
scope: repo:command:workplan
|
||||||
|
mutates_git: true
|
||||||
|
params: [workplan_id, confirm_archive]
|
||||||
- type: repo.work.writeback_ids
|
- type: repo.work.writeback_ids
|
||||||
scope: repo:command:writeback_ids
|
scope: repo:command:writeback_ids
|
||||||
mutates_git: true
|
mutates_git: true
|
||||||
|
- type: repo.register.upsert_entry
|
||||||
|
scope: repo:command:register
|
||||||
|
mutates_git: true
|
||||||
|
params: [kind, entry_id, title, status, data]
|
||||||
|
- type: repo.register.defer_entry
|
||||||
|
scope: repo:command:register
|
||||||
|
mutates_git: true
|
||||||
|
params: [kind, entry_id, status]
|
||||||
|
- type: repo.register.add_note
|
||||||
|
scope: repo:command:register
|
||||||
|
mutates_git: true
|
||||||
|
params: [kind, entry_id, note, author]
|
||||||
- type: repo.agents.assign
|
- type: repo.agents.assign
|
||||||
scope: repo:command:agent_assign
|
scope: repo:command:agent_assign
|
||||||
mutates_git: preferred
|
mutates_git: preferred
|
||||||
|
|
|
||||||
56
docs/repository-registers_v0.1.md
Normal file
56
docs/repository-registers_v0.1.md
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
# Repository registers v0.1
|
||||||
|
|
||||||
|
Repository registers are ordinary, versioned YAML files under `registers/`.
|
||||||
|
They are authoritative in the repository; Repo Manager's index is a replaceable
|
||||||
|
projection.
|
||||||
|
|
||||||
|
## Shared envelope
|
||||||
|
|
||||||
|
Each register uses one file named `registers/<kind>.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
schema: repo-manager.register.v0
|
||||||
|
kind: technical-debt
|
||||||
|
entries:
|
||||||
|
- id: TD-001
|
||||||
|
title: Coupled persistence
|
||||||
|
status: open
|
||||||
|
severity: high
|
||||||
|
created: "2026-08-21T10:00:00+00:00"
|
||||||
|
updated: "2026-08-21T10:00:00+00:00"
|
||||||
|
notes: []
|
||||||
|
```
|
||||||
|
|
||||||
|
Entry ids are stable within their register. `created` is immutable; mutations
|
||||||
|
advance `updated`. Deletion-compatible routes map to `defer`, preserving the
|
||||||
|
entry and Git history. Notes are append-only objects with `content`, `author`,
|
||||||
|
and `created`.
|
||||||
|
|
||||||
|
## Kinds and compatibility fields
|
||||||
|
|
||||||
|
| Kind | Required create data | State Hub compatibility |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `sbom-inventory` | `package_name`, `ecosystem` | package/version/licence entries; snapshots and reports are derived |
|
||||||
|
| `repo-goals` | `description` | repository goal records |
|
||||||
|
| `upstream-contributions` | `type` | contribution type and target metadata |
|
||||||
|
| `technical-debt` | none beyond id/title | severity, debt type, location, notes |
|
||||||
|
| `extension-points` | none beyond id/title | priority, extension type, location |
|
||||||
|
| `register-entries` | `register_kind` | generic extension register entries |
|
||||||
|
|
||||||
|
Additional kind-specific fields are retained as YAML scalars, lists, or
|
||||||
|
mappings. The envelope fields `id`, `title`, `status`, `notes`, `created`, and
|
||||||
|
`updated` may only be changed through their governed command parameters.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rmgr register put --kind technical-debt --entry-id TD-001 \
|
||||||
|
--title "Coupled persistence" --data-json '{"severity":"high"}'
|
||||||
|
rmgr register list --kind technical-debt
|
||||||
|
rmgr register note --kind technical-debt --entry-id TD-001 --note "Owner assigned."
|
||||||
|
rmgr register defer --kind technical-debt --entry-id TD-001
|
||||||
|
```
|
||||||
|
|
||||||
|
Mutations use the same expected-HEAD, idempotency, Git commit, optional
|
||||||
|
push-seal, normalized-event, reindex, and dual-run-meter mechanism as workplan
|
||||||
|
and task mutations.
|
||||||
|
|
@ -15,6 +15,16 @@ from repo_manager.commands.rapp import validate as rapp_validate
|
||||||
from repo_manager.commands.rapp import wrap as rapp_wrap
|
from repo_manager.commands.rapp import wrap as rapp_wrap
|
||||||
|
|
||||||
|
|
||||||
|
def _json_object(raw: str) -> dict:
|
||||||
|
try:
|
||||||
|
value = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise argparse.ArgumentTypeError(str(exc)) from exc
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise argparse.ArgumentTypeError("value must be a JSON object")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="rmgr",
|
prog="rmgr",
|
||||||
|
|
@ -62,6 +72,98 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
help="Patch file only (invalid as full applied evidence; for tests)",
|
help="Patch file only (invalid as full applied evidence; for tests)",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
p_wp = sub.add_parser("workplan", help="Governed file-backed workplan mutations")
|
||||||
|
wp_sub = p_wp.add_subparsers(dest="workplan_command")
|
||||||
|
p_wp_create = wp_sub.add_parser("create", help="Create a workplan file")
|
||||||
|
p_wp_create.add_argument("--path", default=".")
|
||||||
|
p_wp_create.add_argument("--workplan-id", required=True)
|
||||||
|
p_wp_create.add_argument("--title", required=True)
|
||||||
|
p_wp_create.add_argument("--goal", required=True)
|
||||||
|
p_wp_create.add_argument(
|
||||||
|
"--status",
|
||||||
|
choices=["proposed", "ready", "active", "blocked", "backlog", "finished", "archived"],
|
||||||
|
default="proposed",
|
||||||
|
)
|
||||||
|
p_wp_create.add_argument("--owner", default="codex")
|
||||||
|
p_wp_create.add_argument("--domain", default=None)
|
||||||
|
p_wp_create.add_argument("--topic-slug", default=None)
|
||||||
|
p_wp_create.add_argument("--filename", default=None)
|
||||||
|
|
||||||
|
p_wp_update = wp_sub.add_parser("update", help="Update workplan metadata or status")
|
||||||
|
p_wp_update.add_argument("--path", default=".")
|
||||||
|
p_wp_update.add_argument("--workplan-id", required=True)
|
||||||
|
p_wp_update.add_argument("--title", default=None)
|
||||||
|
p_wp_update.add_argument(
|
||||||
|
"--status",
|
||||||
|
choices=["proposed", "ready", "active", "blocked", "backlog", "finished", "archived"],
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
p_wp_update.add_argument("--owner", default=None)
|
||||||
|
p_wp_update.add_argument("--domain", default=None)
|
||||||
|
p_wp_update.add_argument("--topic-slug", default=None)
|
||||||
|
|
||||||
|
p_wp_archive = wp_sub.add_parser(
|
||||||
|
"delete",
|
||||||
|
help="Recoverable delete: set archived and move to workplans/archived",
|
||||||
|
)
|
||||||
|
p_wp_archive.add_argument("--path", default=".")
|
||||||
|
p_wp_archive.add_argument("--workplan-id", required=True)
|
||||||
|
p_wp_archive.add_argument(
|
||||||
|
"--confirm",
|
||||||
|
action="store_true",
|
||||||
|
help="Confirm moving the workplan to the dated archive",
|
||||||
|
)
|
||||||
|
|
||||||
|
for wp_parser in (p_wp_create, p_wp_update, p_wp_archive):
|
||||||
|
wp_parser.add_argument("--reason", default="rmgr CLI")
|
||||||
|
wp_parser.add_argument("--correlation-id", default=None)
|
||||||
|
wp_parser.add_argument("--idempotency-key", default=None)
|
||||||
|
wp_parser.add_argument("--expected-head-sha", default=None)
|
||||||
|
wp_parser.add_argument("--slug", default=None)
|
||||||
|
wp_parser.add_argument("--push", action="store_true")
|
||||||
|
wp_parser.add_argument("--no-commit", action="store_true")
|
||||||
|
|
||||||
|
register_kinds = [
|
||||||
|
"sbom-inventory",
|
||||||
|
"repo-goals",
|
||||||
|
"upstream-contributions",
|
||||||
|
"technical-debt",
|
||||||
|
"extension-points",
|
||||||
|
"register-entries",
|
||||||
|
]
|
||||||
|
p_register = sub.add_parser("register", help="Repository-owned register spine")
|
||||||
|
register_sub = p_register.add_subparsers(dest="register_command")
|
||||||
|
p_reg_list = register_sub.add_parser("list", help="List indexed register entries")
|
||||||
|
p_reg_list.add_argument("--path", default=".")
|
||||||
|
p_reg_list.add_argument("--kind", choices=register_kinds, default=None)
|
||||||
|
p_reg_list.add_argument("--slug", default=None)
|
||||||
|
p_reg_put = register_sub.add_parser("put", help="Create or update a register entry")
|
||||||
|
p_reg_put.add_argument("--path", default=".")
|
||||||
|
p_reg_put.add_argument("--kind", required=True, choices=register_kinds)
|
||||||
|
p_reg_put.add_argument("--entry-id", required=True)
|
||||||
|
p_reg_put.add_argument("--title", default=None)
|
||||||
|
p_reg_put.add_argument("--status", default=None)
|
||||||
|
p_reg_put.add_argument("--data-json", type=_json_object, default=None)
|
||||||
|
p_reg_defer = register_sub.add_parser("defer", help="Defer a register entry")
|
||||||
|
p_reg_defer.add_argument("--path", default=".")
|
||||||
|
p_reg_defer.add_argument("--kind", required=True, choices=register_kinds)
|
||||||
|
p_reg_defer.add_argument("--entry-id", required=True)
|
||||||
|
p_reg_defer.add_argument("--status", default="deferred")
|
||||||
|
p_reg_note = register_sub.add_parser("note", help="Append a note to a register entry")
|
||||||
|
p_reg_note.add_argument("--path", default=".")
|
||||||
|
p_reg_note.add_argument("--kind", required=True, choices=register_kinds)
|
||||||
|
p_reg_note.add_argument("--entry-id", required=True)
|
||||||
|
p_reg_note.add_argument("--note", required=True)
|
||||||
|
p_reg_note.add_argument("--author", default="codex")
|
||||||
|
for register_parser in (p_reg_put, p_reg_defer, p_reg_note):
|
||||||
|
register_parser.add_argument("--reason", default="rmgr CLI")
|
||||||
|
register_parser.add_argument("--correlation-id", default=None)
|
||||||
|
register_parser.add_argument("--idempotency-key", default=None)
|
||||||
|
register_parser.add_argument("--expected-head-sha", default=None)
|
||||||
|
register_parser.add_argument("--slug", default=None)
|
||||||
|
register_parser.add_argument("--push", action="store_true")
|
||||||
|
register_parser.add_argument("--no-commit", action="store_true")
|
||||||
|
|
||||||
add_rapp_parser(sub)
|
add_rapp_parser(sub)
|
||||||
|
|
||||||
p_conf = sub.add_parser("conform", help="Check a repository against flavor standards")
|
p_conf = sub.add_parser("conform", help="Check a repository against flavor standards")
|
||||||
|
|
@ -152,6 +254,72 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
print(json.dumps(result.to_dict(), indent=2))
|
print(json.dumps(result.to_dict(), indent=2))
|
||||||
return 0 if result.status == "applied" else 1
|
return 0 if result.status == "applied" else 1
|
||||||
|
|
||||||
|
if args.command == "workplan":
|
||||||
|
from repo_manager.commands.workplan import mutate_workplan
|
||||||
|
|
||||||
|
if not args.workplan_command:
|
||||||
|
p_wp.print_help()
|
||||||
|
return 2
|
||||||
|
operation = "archive" if args.workplan_command == "delete" else args.workplan_command
|
||||||
|
result = mutate_workplan(
|
||||||
|
Path(args.path),
|
||||||
|
args.workplan_id,
|
||||||
|
operation=operation,
|
||||||
|
title=getattr(args, "title", None),
|
||||||
|
goal=getattr(args, "goal", None),
|
||||||
|
status=getattr(args, "status", None),
|
||||||
|
owner=getattr(args, "owner", None),
|
||||||
|
topic_slug=getattr(args, "topic_slug", None),
|
||||||
|
domain=getattr(args, "domain", None),
|
||||||
|
filename=getattr(args, "filename", None),
|
||||||
|
confirm_archive=getattr(args, "confirm", False),
|
||||||
|
correlation_id=args.correlation_id,
|
||||||
|
reason=args.reason,
|
||||||
|
commit=not args.no_commit,
|
||||||
|
push=args.push,
|
||||||
|
expected_head_sha=args.expected_head_sha,
|
||||||
|
idempotency_key=args.idempotency_key,
|
||||||
|
repo_slug=args.slug,
|
||||||
|
)
|
||||||
|
print(json.dumps(result.to_dict(), indent=2))
|
||||||
|
return 0 if result.status == "applied" else 1
|
||||||
|
|
||||||
|
if args.command == "register":
|
||||||
|
if not args.register_command:
|
||||||
|
p_register.print_help()
|
||||||
|
return 2
|
||||||
|
if args.register_command == "list":
|
||||||
|
from repo_manager.observe import observe_repository
|
||||||
|
|
||||||
|
_snapshot, index = observe_repository(Path(args.path), slug=args.slug)
|
||||||
|
prefix = f"register:{args.kind}" if args.kind else "register:"
|
||||||
|
entries = [r.__dict__ for r in index.work_records if r.kind.startswith(prefix)]
|
||||||
|
print(json.dumps({"ok": True, "entries": entries}, indent=2))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
from repo_manager.commands.register import mutate_register_entry
|
||||||
|
|
||||||
|
result = mutate_register_entry(
|
||||||
|
Path(args.path),
|
||||||
|
args.kind,
|
||||||
|
args.entry_id,
|
||||||
|
operation="upsert" if args.register_command == "put" else args.register_command,
|
||||||
|
title=getattr(args, "title", None),
|
||||||
|
status=getattr(args, "status", None),
|
||||||
|
data=getattr(args, "data_json", None),
|
||||||
|
note=getattr(args, "note", None),
|
||||||
|
note_author=getattr(args, "author", None),
|
||||||
|
correlation_id=args.correlation_id,
|
||||||
|
reason=args.reason,
|
||||||
|
commit=not args.no_commit,
|
||||||
|
push=args.push,
|
||||||
|
expected_head_sha=args.expected_head_sha,
|
||||||
|
idempotency_key=args.idempotency_key,
|
||||||
|
repo_slug=args.slug,
|
||||||
|
)
|
||||||
|
print(json.dumps(result.to_dict(), indent=2))
|
||||||
|
return 0 if result.status == "applied" else 1
|
||||||
|
|
||||||
if args.command == "rapp":
|
if args.command == "rapp":
|
||||||
if args.rapp_command == "init":
|
if args.rapp_command == "init":
|
||||||
result = rapp_init(
|
result = rapp_init(
|
||||||
|
|
|
||||||
311
src/repo_manager/commands/register.py
Normal file
311
src/repo_manager/commands/register.py
Normal file
|
|
@ -0,0 +1,311 @@
|
||||||
|
"""Governed mutation spine for repository-owned registers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from repo_manager import dual_run, idempotency
|
||||||
|
from repo_manager.commands.workplan import CommandResult
|
||||||
|
from repo_manager.gitops import GitError, commit_paths, head_sha, push_ff
|
||||||
|
from repo_manager.index_store import append_event, default_index_path, save_index
|
||||||
|
from repo_manager.observe import observe_repository
|
||||||
|
from repo_manager.parse.register import REGISTER_SCHEMA, SUPPORTED_REGISTER_KINDS, register_path
|
||||||
|
|
||||||
|
_ENTRY_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$")
|
||||||
|
_PROTECTED_FIELDS = frozenset({"id", "title", "status", "notes", "created", "updated"})
|
||||||
|
_REQUIRED_DATA = {
|
||||||
|
"sbom-inventory": frozenset({"package_name", "ecosystem"}),
|
||||||
|
"repo-goals": frozenset({"description"}),
|
||||||
|
"upstream-contributions": frozenset({"type"}),
|
||||||
|
"technical-debt": frozenset(),
|
||||||
|
"extension-points": frozenset(),
|
||||||
|
"register-entries": frozenset({"register_kind"}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _reject(command: str, correlation_id: str, code: str, message: str, **evidence: Any) -> CommandResult:
|
||||||
|
return CommandResult(
|
||||||
|
command=command,
|
||||||
|
status="rejected",
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
evidence={"status": "rejected", **evidence},
|
||||||
|
error={"code": code, "message": message, "correlation_id": correlation_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load(path: Path, kind: str) -> dict[str, Any]:
|
||||||
|
if not path.is_file():
|
||||||
|
return {"schema": REGISTER_SCHEMA, "kind": kind, "entries": []}
|
||||||
|
try:
|
||||||
|
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||||
|
except yaml.YAMLError as exc:
|
||||||
|
raise ValueError(f"invalid register YAML: {exc}") from exc
|
||||||
|
if not isinstance(data, dict) or not isinstance(data.get("entries", []), list):
|
||||||
|
raise TypeError("register must be a mapping with an entries list")
|
||||||
|
if data.get("kind", kind) != kind:
|
||||||
|
raise ValueError(f"register kind does not match filename {kind!r}")
|
||||||
|
data.setdefault("schema", REGISTER_SCHEMA)
|
||||||
|
data.setdefault("kind", kind)
|
||||||
|
data.setdefault("entries", [])
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _save(path: Path, document: dict[str, Any]) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(
|
||||||
|
yaml.safe_dump(document, sort_keys=False, allow_unicode=True),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def mutate_register_entry(
|
||||||
|
repo_root: Path,
|
||||||
|
kind: str,
|
||||||
|
entry_id: str,
|
||||||
|
*,
|
||||||
|
operation: str,
|
||||||
|
title: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
data: dict[str, Any] | None = None,
|
||||||
|
note: str | None = None,
|
||||||
|
note_author: str | None = None,
|
||||||
|
correlation_id: str | None = None,
|
||||||
|
reason: str = "rmgr command",
|
||||||
|
commit: bool = True,
|
||||||
|
push: bool = False,
|
||||||
|
expected_head_sha: str | None = None,
|
||||||
|
idempotency_key: str | None = None,
|
||||||
|
repo_slug: str | None = None,
|
||||||
|
) -> CommandResult:
|
||||||
|
"""Upsert, defer, or annotate an entry in one of the shared register kinds."""
|
||||||
|
command_map = {
|
||||||
|
"upsert": "repo.register.upsert_entry",
|
||||||
|
"defer": "repo.register.defer_entry",
|
||||||
|
"note": "repo.register.add_note",
|
||||||
|
}
|
||||||
|
correlation_id = correlation_id or str(uuid.uuid4())
|
||||||
|
command = command_map.get(operation, "repo.register.upsert_entry")
|
||||||
|
repo_root = repo_root.resolve()
|
||||||
|
payload = {
|
||||||
|
"repo_root": str(repo_root),
|
||||||
|
"kind": kind,
|
||||||
|
"entry_id": entry_id,
|
||||||
|
"operation": operation,
|
||||||
|
"title": title,
|
||||||
|
"status": status,
|
||||||
|
"data": data,
|
||||||
|
"note": note,
|
||||||
|
"note_author": note_author,
|
||||||
|
"expected_head_sha": expected_head_sha,
|
||||||
|
}
|
||||||
|
digest = idempotency.payload_hash(payload)
|
||||||
|
if idempotency_key:
|
||||||
|
prior = idempotency.get(idempotency_key)
|
||||||
|
if prior:
|
||||||
|
if prior.get("payload_hash") != digest:
|
||||||
|
return _reject(command, correlation_id, "conflict", "idempotency key reused")
|
||||||
|
previous = prior.get("result") or {}
|
||||||
|
return CommandResult(
|
||||||
|
command=previous.get("command", command),
|
||||||
|
status=previous.get("status", "applied"),
|
||||||
|
correlation_id=previous.get("correlation_id", correlation_id),
|
||||||
|
evidence=previous.get("evidence") or {"status": "applied", "replay": True},
|
||||||
|
error=previous.get("error"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if operation not in command_map:
|
||||||
|
return _reject(command, correlation_id, "validation_error", f"invalid operation {operation!r}")
|
||||||
|
if kind not in SUPPORTED_REGISTER_KINDS:
|
||||||
|
return _reject(command, correlation_id, "validation_error", f"unsupported register {kind!r}")
|
||||||
|
if not _ENTRY_ID_RE.fullmatch(entry_id):
|
||||||
|
return _reject(command, correlation_id, "validation_error", f"invalid entry id {entry_id!r}")
|
||||||
|
if status is not None and not status.strip():
|
||||||
|
return _reject(command, correlation_id, "validation_error", "status cannot be empty")
|
||||||
|
if data is not None and not isinstance(data, dict):
|
||||||
|
return _reject(command, correlation_id, "validation_error", "data must be an object")
|
||||||
|
protected = _PROTECTED_FIELDS.intersection(data or {})
|
||||||
|
if protected:
|
||||||
|
return _reject(
|
||||||
|
command,
|
||||||
|
correlation_id,
|
||||||
|
"validation_error",
|
||||||
|
f"data contains protected fields: {', '.join(sorted(protected))}",
|
||||||
|
)
|
||||||
|
|
||||||
|
current_head = head_sha(repo_root)
|
||||||
|
if expected_head_sha and current_head and expected_head_sha != current_head:
|
||||||
|
result = _reject(
|
||||||
|
command,
|
||||||
|
correlation_id,
|
||||||
|
"precondition_failed",
|
||||||
|
f"expected_head_sha {expected_head_sha} != {current_head}",
|
||||||
|
head_sha=current_head,
|
||||||
|
)
|
||||||
|
if result.error:
|
||||||
|
result.error["retryable"] = True
|
||||||
|
return result
|
||||||
|
|
||||||
|
snapshot, _index = observe_repository(repo_root, slug=repo_slug)
|
||||||
|
slug = repo_slug or snapshot.get("slug")
|
||||||
|
path = register_path(repo_root, kind)
|
||||||
|
try:
|
||||||
|
document = _load(path, kind)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
return _reject(command, correlation_id, "conflict", str(exc))
|
||||||
|
entries = document["entries"]
|
||||||
|
matches = [item for item in entries if isinstance(item, dict) and str(item.get("id")) == entry_id]
|
||||||
|
if len(matches) > 1:
|
||||||
|
return _reject(command, correlation_id, "conflict", f"duplicate entry id {entry_id!r}")
|
||||||
|
entry = matches[0] if matches else None
|
||||||
|
before = yaml.safe_dump(entry, sort_keys=True) if entry is not None else None
|
||||||
|
now = _now()
|
||||||
|
|
||||||
|
if operation == "upsert":
|
||||||
|
if entry is None:
|
||||||
|
if not title or not title.strip():
|
||||||
|
return _reject(command, correlation_id, "validation_error", "new entry requires title")
|
||||||
|
missing = sorted(key for key in _REQUIRED_DATA[kind] if key not in (data or {}))
|
||||||
|
if missing:
|
||||||
|
return _reject(
|
||||||
|
command,
|
||||||
|
correlation_id,
|
||||||
|
"validation_error",
|
||||||
|
f"{kind} entry missing data fields: {', '.join(missing)}",
|
||||||
|
)
|
||||||
|
entry = {
|
||||||
|
"id": entry_id,
|
||||||
|
"title": title.strip(),
|
||||||
|
"status": (status or "open").strip(),
|
||||||
|
**(data or {}),
|
||||||
|
"created": now,
|
||||||
|
"updated": now,
|
||||||
|
}
|
||||||
|
entries.append(entry)
|
||||||
|
else:
|
||||||
|
if title is not None:
|
||||||
|
if not title.strip():
|
||||||
|
return _reject(command, correlation_id, "validation_error", "title cannot be empty")
|
||||||
|
entry["title"] = title.strip()
|
||||||
|
if status is not None:
|
||||||
|
entry["status"] = status.strip()
|
||||||
|
entry.update(data or {})
|
||||||
|
entry["updated"] = now
|
||||||
|
elif operation == "defer":
|
||||||
|
if entry is None:
|
||||||
|
return _reject(command, correlation_id, "not_found", f"entry {entry_id!r} not found")
|
||||||
|
entry["status"] = (status or "deferred").strip()
|
||||||
|
entry["updated"] = now
|
||||||
|
else:
|
||||||
|
if entry is None:
|
||||||
|
return _reject(command, correlation_id, "not_found", f"entry {entry_id!r} not found")
|
||||||
|
if not note or not note.strip():
|
||||||
|
return _reject(command, correlation_id, "validation_error", "note requires content")
|
||||||
|
entry.setdefault("notes", []).append(
|
||||||
|
{"content": note.strip(), "author": note_author or "codex", "created": now}
|
||||||
|
)
|
||||||
|
entry["updated"] = now
|
||||||
|
|
||||||
|
after = yaml.safe_dump(entry, sort_keys=True)
|
||||||
|
if before == after:
|
||||||
|
result = CommandResult(
|
||||||
|
command=command,
|
||||||
|
status="applied",
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
evidence={"status": "applied", "git_sha": current_head, "files_touched": [], "noop": True},
|
||||||
|
)
|
||||||
|
if idempotency_key:
|
||||||
|
idempotency.put(idempotency_key, digest, result.to_dict())
|
||||||
|
return result
|
||||||
|
|
||||||
|
_save(path, document)
|
||||||
|
relative_path = str(path.relative_to(repo_root))
|
||||||
|
git_sha: str | None = None
|
||||||
|
push_ok: bool | None = None
|
||||||
|
push_message: str | None = None
|
||||||
|
if commit:
|
||||||
|
try:
|
||||||
|
git_sha = commit_paths(
|
||||||
|
repo_root,
|
||||||
|
[relative_path],
|
||||||
|
message=(
|
||||||
|
f"{command} {kind}/{entry_id}\n\n"
|
||||||
|
f"correlation_id: {correlation_id}\nreason: {reason}\nsource: repo-manager\n"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except GitError as exc:
|
||||||
|
return CommandResult(
|
||||||
|
command=command,
|
||||||
|
status="failed",
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
evidence={"status": "failed", "files_touched": [relative_path], "git_sha": None},
|
||||||
|
error={"code": "internal", "message": str(exc), "correlation_id": correlation_id},
|
||||||
|
)
|
||||||
|
if push:
|
||||||
|
push_ok, push_message = push_ff(repo_root)
|
||||||
|
|
||||||
|
_snapshot_after, index_after = observe_repository(repo_root, slug=slug)
|
||||||
|
append_event(
|
||||||
|
index_after,
|
||||||
|
{
|
||||||
|
"type": "repo.command.applied",
|
||||||
|
"command": command,
|
||||||
|
"operation": operation,
|
||||||
|
"correlation_id": correlation_id,
|
||||||
|
"register_kind": kind,
|
||||||
|
"entry_id": entry_id,
|
||||||
|
"git_sha": git_sha,
|
||||||
|
"files_touched": [relative_path],
|
||||||
|
"source": "repo-manager",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
append_event(
|
||||||
|
index_after,
|
||||||
|
{
|
||||||
|
"type": "repo.work.indexed",
|
||||||
|
"correlation_id": correlation_id,
|
||||||
|
"kind": f"register:{kind}",
|
||||||
|
"id": entry_id,
|
||||||
|
"source_path": relative_path,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
save_index(index_after)
|
||||||
|
dual_run.record_mutation(
|
||||||
|
source="repo-manager",
|
||||||
|
kind=f"register_{operation}",
|
||||||
|
repo_slug=slug,
|
||||||
|
detail={
|
||||||
|
"register_kind": kind,
|
||||||
|
"entry_id": entry_id,
|
||||||
|
"git_sha": git_sha,
|
||||||
|
"correlation_id": correlation_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
evidence: dict[str, Any] = {
|
||||||
|
"status": "applied",
|
||||||
|
"git_sha": git_sha,
|
||||||
|
"files_touched": [relative_path],
|
||||||
|
"index_path": str(default_index_path(repo_root)),
|
||||||
|
"source": "repo-manager",
|
||||||
|
}
|
||||||
|
if push:
|
||||||
|
evidence["push_ok"] = push_ok
|
||||||
|
evidence["push_message"] = push_message
|
||||||
|
result = CommandResult(
|
||||||
|
command=command,
|
||||||
|
status="applied",
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
evidence=evidence,
|
||||||
|
)
|
||||||
|
if idempotency_key:
|
||||||
|
idempotency.put(idempotency_key, digest, result.to_dict())
|
||||||
|
return result
|
||||||
498
src/repo_manager/commands/workplan.py
Normal file
498
src/repo_manager/commands/workplan.py
Normal file
|
|
@ -0,0 +1,498 @@
|
||||||
|
"""Governed workplan create, update, and archive commands."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, date, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from repo_manager import dual_run, idempotency
|
||||||
|
from repo_manager.gitops import GitError, commit_paths, head_sha, push_ff
|
||||||
|
from repo_manager.index_store import append_event, default_index_path, save_index
|
||||||
|
from repo_manager.observe import observe_repository
|
||||||
|
|
||||||
|
VALID_WORKPLAN_STATUSES = frozenset(
|
||||||
|
{"proposed", "ready", "active", "blocked", "backlog", "finished", "archived"}
|
||||||
|
)
|
||||||
|
_WORKPLAN_ID_RE = re.compile(r"^[A-Z][A-Z0-9]*-WP-[0-9]{4,}$")
|
||||||
|
_FRONTMATTER_RE = re.compile(r"\A---\r?\n(?P<meta>.*?)\r?\n---(?P<body>\r?\n.*)?\Z", re.DOTALL)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CommandResult:
|
||||||
|
command: str
|
||||||
|
status: str # applied | rejected | failed
|
||||||
|
evidence: dict[str, Any]
|
||||||
|
error: dict[str, Any] | None = None
|
||||||
|
correlation_id: str = ""
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
out: dict[str, Any] = {
|
||||||
|
"command": self.command,
|
||||||
|
"status": self.status,
|
||||||
|
"correlation_id": self.correlation_id,
|
||||||
|
"evidence": self.evidence,
|
||||||
|
}
|
||||||
|
if self.error:
|
||||||
|
out["error"] = self.error
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _result_from_prior(command: str, prior: dict[str, Any], correlation_id: str) -> CommandResult:
|
||||||
|
previous = prior.get("result") or {}
|
||||||
|
return CommandResult(
|
||||||
|
command=previous.get("command", command),
|
||||||
|
status=previous.get("status", "applied"),
|
||||||
|
correlation_id=previous.get("correlation_id", correlation_id),
|
||||||
|
evidence=previous.get("evidence") or {"status": "applied", "replay": True},
|
||||||
|
error=previous.get("error"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rejected(command: str, correlation_id: str, code: str, message: str, **evidence: Any) -> CommandResult:
|
||||||
|
return CommandResult(
|
||||||
|
command=command,
|
||||||
|
status="rejected",
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
evidence={"status": "rejected", **evidence},
|
||||||
|
error={"code": code, "message": message, "correlation_id": correlation_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_uuid(value: str) -> bool:
|
||||||
|
try:
|
||||||
|
uuid.UUID(value)
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _slugify(value: str) -> str:
|
||||||
|
return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") or "workplan"
|
||||||
|
|
||||||
|
|
||||||
|
def _quoted(value: str) -> str:
|
||||||
|
return json.dumps(value, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _today() -> date:
|
||||||
|
return datetime.now(UTC).date()
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_frontmatter(text: str, updates: dict[str, str]) -> str | None:
|
||||||
|
match = _FRONTMATTER_RE.match(text)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
meta = match.group("meta")
|
||||||
|
for key, value in updates.items():
|
||||||
|
rendered = value if key == "status" else _quoted(value)
|
||||||
|
pattern = re.compile(rf"^({re.escape(key)}:\s*).*$", re.MULTILINE)
|
||||||
|
if pattern.search(meta):
|
||||||
|
meta = pattern.sub(rf"\g<1>{rendered}", meta, count=1)
|
||||||
|
else:
|
||||||
|
meta = f"{meta.rstrip()}\n{key}: {rendered}"
|
||||||
|
return f"---\n{meta}\n---{match.group('body') or ''}"
|
||||||
|
|
||||||
|
|
||||||
|
def _find_workplan(index: Any, workplan_id: str) -> Any | None:
|
||||||
|
by_uuid = _is_uuid(workplan_id)
|
||||||
|
for record in index.work_records:
|
||||||
|
if record.kind != "workplan":
|
||||||
|
continue
|
||||||
|
if not by_uuid and record.id == workplan_id:
|
||||||
|
return record
|
||||||
|
if by_uuid and record.uuid and record.uuid.lower() == workplan_id.lower():
|
||||||
|
return record
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _create_text(
|
||||||
|
*,
|
||||||
|
workplan_id: str,
|
||||||
|
title: str,
|
||||||
|
goal: str,
|
||||||
|
status: str,
|
||||||
|
domain: str,
|
||||||
|
repo_slug: str,
|
||||||
|
owner: str,
|
||||||
|
topic_slug: str,
|
||||||
|
) -> str:
|
||||||
|
today = _today().isoformat()
|
||||||
|
return (
|
||||||
|
"---\n"
|
||||||
|
f"id: {workplan_id}\n"
|
||||||
|
"type: workplan\n"
|
||||||
|
f"title: {_quoted(title)}\n"
|
||||||
|
f"domain: {domain}\n"
|
||||||
|
f"repo: {repo_slug}\n"
|
||||||
|
f"status: {status}\n"
|
||||||
|
f"owner: {owner}\n"
|
||||||
|
f"topic_slug: {topic_slug}\n"
|
||||||
|
f"created: {_quoted(today)}\n"
|
||||||
|
f"updated: {_quoted(today)}\n"
|
||||||
|
"---\n\n"
|
||||||
|
f"# {title}\n\n"
|
||||||
|
"## Goal\n\n"
|
||||||
|
f"{goal.strip()}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def mutate_workplan(
|
||||||
|
repo_root: Path,
|
||||||
|
workplan_id: str,
|
||||||
|
*,
|
||||||
|
operation: str,
|
||||||
|
title: str | None = None,
|
||||||
|
goal: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
owner: str | None = None,
|
||||||
|
topic_slug: str | None = None,
|
||||||
|
domain: str | None = None,
|
||||||
|
filename: str | None = None,
|
||||||
|
confirm_archive: bool = False,
|
||||||
|
correlation_id: str | None = None,
|
||||||
|
reason: str = "rmgr command",
|
||||||
|
commit: bool = True,
|
||||||
|
push: bool = False,
|
||||||
|
expected_head_sha: str | None = None,
|
||||||
|
idempotency_key: str | None = None,
|
||||||
|
repo_slug: str | None = None,
|
||||||
|
) -> CommandResult:
|
||||||
|
"""Create, update, or archive a file-backed workplan.
|
||||||
|
|
||||||
|
Update/archive accepts a canonical workplan id or State Hub UUID. ``archive``
|
||||||
|
is the recoverable implementation of the legacy delete operation.
|
||||||
|
"""
|
||||||
|
commands = {
|
||||||
|
"create": "repo.work.create_workplan",
|
||||||
|
"update": "repo.work.update_workplan",
|
||||||
|
"archive": "repo.work.archive_workplan",
|
||||||
|
}
|
||||||
|
correlation_id = correlation_id or str(uuid.uuid4())
|
||||||
|
command = commands.get(operation, "repo.work.update_workplan")
|
||||||
|
repo_root = repo_root.resolve()
|
||||||
|
payload = {
|
||||||
|
"repo_root": str(repo_root),
|
||||||
|
"workplan_id": workplan_id,
|
||||||
|
"operation": operation,
|
||||||
|
"title": title,
|
||||||
|
"goal": goal,
|
||||||
|
"status": status,
|
||||||
|
"owner": owner,
|
||||||
|
"topic_slug": topic_slug,
|
||||||
|
"domain": domain,
|
||||||
|
"filename": filename,
|
||||||
|
"confirm_archive": confirm_archive,
|
||||||
|
"expected_head_sha": expected_head_sha,
|
||||||
|
}
|
||||||
|
payload_digest = idempotency.payload_hash(payload)
|
||||||
|
|
||||||
|
if idempotency_key:
|
||||||
|
prior = idempotency.get(idempotency_key)
|
||||||
|
if prior:
|
||||||
|
if prior.get("payload_hash") != payload_digest:
|
||||||
|
return _rejected(
|
||||||
|
command,
|
||||||
|
correlation_id,
|
||||||
|
"conflict",
|
||||||
|
"idempotency key reused with different payload",
|
||||||
|
)
|
||||||
|
return _result_from_prior(command, prior, correlation_id)
|
||||||
|
|
||||||
|
if operation not in commands:
|
||||||
|
return _rejected(command, correlation_id, "validation_error", f"invalid operation {operation!r}")
|
||||||
|
if status is not None and status not in VALID_WORKPLAN_STATUSES:
|
||||||
|
return _rejected(
|
||||||
|
command,
|
||||||
|
correlation_id,
|
||||||
|
"validation_error",
|
||||||
|
f"invalid workplan status {status!r}",
|
||||||
|
)
|
||||||
|
if operation == "archive" and not confirm_archive:
|
||||||
|
return _rejected(
|
||||||
|
command,
|
||||||
|
correlation_id,
|
||||||
|
"confirmation_required",
|
||||||
|
"archive requires confirm_archive=True",
|
||||||
|
)
|
||||||
|
|
||||||
|
current_head = head_sha(repo_root)
|
||||||
|
if expected_head_sha and current_head and expected_head_sha != current_head:
|
||||||
|
result = _rejected(
|
||||||
|
command,
|
||||||
|
correlation_id,
|
||||||
|
"precondition_failed",
|
||||||
|
f"expected_head_sha {expected_head_sha} != {current_head}",
|
||||||
|
head_sha=current_head,
|
||||||
|
)
|
||||||
|
if result.error:
|
||||||
|
result.error["retryable"] = True
|
||||||
|
return result
|
||||||
|
|
||||||
|
snapshot, index = observe_repository(repo_root, slug=repo_slug)
|
||||||
|
slug = repo_slug or snapshot.get("slug")
|
||||||
|
record = _find_workplan(index, workplan_id)
|
||||||
|
touched: list[str]
|
||||||
|
changes: dict[str, Any]
|
||||||
|
|
||||||
|
if operation == "create":
|
||||||
|
if not _WORKPLAN_ID_RE.fullmatch(workplan_id):
|
||||||
|
return _rejected(
|
||||||
|
command,
|
||||||
|
correlation_id,
|
||||||
|
"validation_error",
|
||||||
|
f"invalid canonical workplan id {workplan_id!r}",
|
||||||
|
)
|
||||||
|
if record is not None:
|
||||||
|
return _rejected(command, correlation_id, "conflict", f"workplan {workplan_id!r} exists")
|
||||||
|
if not title or not title.strip() or not goal or not goal.strip():
|
||||||
|
return _rejected(
|
||||||
|
command,
|
||||||
|
correlation_id,
|
||||||
|
"validation_error",
|
||||||
|
"create requires non-empty title and goal",
|
||||||
|
)
|
||||||
|
create_status = status or "proposed"
|
||||||
|
if create_status not in VALID_WORKPLAN_STATUSES:
|
||||||
|
return _rejected(
|
||||||
|
command,
|
||||||
|
correlation_id,
|
||||||
|
"validation_error",
|
||||||
|
f"invalid workplan status {create_status!r}",
|
||||||
|
)
|
||||||
|
name = filename or f"{workplan_id}-{_slugify(title)}.md"
|
||||||
|
if Path(name).name != name or not name.endswith(".md") or not name.startswith(f"{workplan_id}-"):
|
||||||
|
return _rejected(command, correlation_id, "validation_error", "unsafe workplan filename")
|
||||||
|
path = repo_root / "workplans" / name
|
||||||
|
if path.exists():
|
||||||
|
return _rejected(command, correlation_id, "conflict", f"source file exists: workplans/{name}")
|
||||||
|
classification = snapshot.get("classification") or {}
|
||||||
|
create_domain = domain or classification.get("domain") or "infotech"
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(
|
||||||
|
_create_text(
|
||||||
|
workplan_id=workplan_id,
|
||||||
|
title=title.strip(),
|
||||||
|
goal=goal,
|
||||||
|
status=create_status,
|
||||||
|
domain=create_domain,
|
||||||
|
repo_slug=slug,
|
||||||
|
owner=owner or "codex",
|
||||||
|
topic_slug=topic_slug or create_domain,
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
touched = [str(path.relative_to(repo_root))]
|
||||||
|
changes = {"title": title.strip(), "status": create_status}
|
||||||
|
else:
|
||||||
|
if record is None:
|
||||||
|
return _rejected(
|
||||||
|
command,
|
||||||
|
correlation_id,
|
||||||
|
"not_found",
|
||||||
|
f"workplan {workplan_id!r} not found in workplans",
|
||||||
|
)
|
||||||
|
path = repo_root / record.source_path
|
||||||
|
if not path.is_file():
|
||||||
|
return CommandResult(
|
||||||
|
command=command,
|
||||||
|
status="failed",
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
evidence={"status": "failed"},
|
||||||
|
error={
|
||||||
|
"code": "not_found",
|
||||||
|
"message": f"source file missing: {record.source_path}",
|
||||||
|
"correlation_id": correlation_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if operation == "update":
|
||||||
|
updates = {
|
||||||
|
key: value.strip()
|
||||||
|
for key, value in {
|
||||||
|
"title": title,
|
||||||
|
"status": status,
|
||||||
|
"owner": owner,
|
||||||
|
"topic_slug": topic_slug,
|
||||||
|
"domain": domain,
|
||||||
|
}.items()
|
||||||
|
if value is not None and value.strip()
|
||||||
|
}
|
||||||
|
if not updates:
|
||||||
|
return _rejected(
|
||||||
|
command,
|
||||||
|
correlation_id,
|
||||||
|
"validation_error",
|
||||||
|
"update requires at least one field",
|
||||||
|
)
|
||||||
|
changes = {
|
||||||
|
key: value
|
||||||
|
for key, value in updates.items()
|
||||||
|
if key not in {"status", "title"}
|
||||||
|
or (key == "status" and value != record.status)
|
||||||
|
or (key == "title" and value != record.title)
|
||||||
|
}
|
||||||
|
if not changes:
|
||||||
|
result = CommandResult(
|
||||||
|
command=command,
|
||||||
|
status="applied",
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
evidence={
|
||||||
|
"status": "applied",
|
||||||
|
"git_sha": current_head,
|
||||||
|
"files_touched": [],
|
||||||
|
"noop": True,
|
||||||
|
"observed_at": index.observed_at,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if idempotency_key:
|
||||||
|
idempotency.put(idempotency_key, payload_digest, result.to_dict())
|
||||||
|
return result
|
||||||
|
changes["updated"] = _today().isoformat()
|
||||||
|
new_text = _patch_frontmatter(path.read_text(encoding="utf-8"), changes)
|
||||||
|
if new_text is None:
|
||||||
|
return _rejected(command, correlation_id, "conflict", "workplan frontmatter is invalid")
|
||||||
|
path.write_text(new_text, encoding="utf-8")
|
||||||
|
touched = [record.source_path]
|
||||||
|
else:
|
||||||
|
if record.status == "archived" and record.source_path.startswith("workplans/archived/"):
|
||||||
|
result = CommandResult(
|
||||||
|
command=command,
|
||||||
|
status="applied",
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
evidence={
|
||||||
|
"status": "applied",
|
||||||
|
"git_sha": current_head,
|
||||||
|
"files_touched": [],
|
||||||
|
"noop": True,
|
||||||
|
"observed_at": index.observed_at,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if idempotency_key:
|
||||||
|
idempotency.put(idempotency_key, payload_digest, result.to_dict())
|
||||||
|
return result
|
||||||
|
archived_dir = repo_root / "workplans" / "archived"
|
||||||
|
archived_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
archived_path = archived_dir / f"{_today():%y%m%d}-{path.name}"
|
||||||
|
if archived_path.exists():
|
||||||
|
return _rejected(
|
||||||
|
command,
|
||||||
|
correlation_id,
|
||||||
|
"conflict",
|
||||||
|
f"archive target exists: {archived_path.relative_to(repo_root)}",
|
||||||
|
)
|
||||||
|
new_text = _patch_frontmatter(
|
||||||
|
path.read_text(encoding="utf-8"),
|
||||||
|
{"status": "archived", "updated": _today().isoformat()},
|
||||||
|
)
|
||||||
|
if new_text is None:
|
||||||
|
return _rejected(command, correlation_id, "conflict", "workplan frontmatter is invalid")
|
||||||
|
archived_path.write_text(new_text, encoding="utf-8")
|
||||||
|
path.unlink()
|
||||||
|
touched = [record.source_path, str(archived_path.relative_to(repo_root))]
|
||||||
|
changes = {"status": "archived", "source_path": touched[-1]}
|
||||||
|
|
||||||
|
git_sha: str | None = None
|
||||||
|
push_ok: bool | None = None
|
||||||
|
push_message: str | None = None
|
||||||
|
if commit:
|
||||||
|
try:
|
||||||
|
git_sha = commit_paths(
|
||||||
|
repo_root,
|
||||||
|
touched,
|
||||||
|
message=(
|
||||||
|
f"{command} {workplan_id} ({operation})\n\n"
|
||||||
|
f"correlation_id: {correlation_id}\nreason: {reason}\nsource: repo-manager\n"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except GitError as exc:
|
||||||
|
return CommandResult(
|
||||||
|
command=command,
|
||||||
|
status="failed",
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
evidence={"status": "failed", "files_touched": touched, "git_sha": None},
|
||||||
|
error={"code": "internal", "message": str(exc), "correlation_id": correlation_id},
|
||||||
|
)
|
||||||
|
if push:
|
||||||
|
push_ok, push_message = push_ff(repo_root)
|
||||||
|
|
||||||
|
_snapshot_after, index_after = observe_repository(repo_root, slug=slug)
|
||||||
|
event = {
|
||||||
|
"type": "repo.command.applied",
|
||||||
|
"command": command,
|
||||||
|
"operation": operation,
|
||||||
|
"correlation_id": correlation_id,
|
||||||
|
"workplan_id": record.id if record else workplan_id,
|
||||||
|
"workplan_uuid": record.uuid if record else None,
|
||||||
|
"changes": changes,
|
||||||
|
"git_sha": git_sha,
|
||||||
|
"files_touched": touched,
|
||||||
|
"expected_head_sha_before": current_head,
|
||||||
|
"source": "repo-manager",
|
||||||
|
}
|
||||||
|
append_event(index_after, event)
|
||||||
|
append_event(
|
||||||
|
index_after,
|
||||||
|
{
|
||||||
|
"type": "repo.work.indexed",
|
||||||
|
"correlation_id": correlation_id,
|
||||||
|
"kind": "workplan",
|
||||||
|
"id": record.id if record else workplan_id,
|
||||||
|
"source_path": touched[-1],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
save_index(index_after)
|
||||||
|
dual_run.record_mutation(
|
||||||
|
source="repo-manager",
|
||||||
|
kind=f"workplan_{operation}",
|
||||||
|
repo_slug=slug,
|
||||||
|
detail={
|
||||||
|
"workplan_id": record.id if record else workplan_id,
|
||||||
|
"changes": changes,
|
||||||
|
"git_sha": git_sha,
|
||||||
|
"correlation_id": correlation_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
evidence: dict[str, Any] = {
|
||||||
|
"status": "applied",
|
||||||
|
"git_sha": git_sha,
|
||||||
|
"files_touched": touched,
|
||||||
|
"observed_at": index_after.observed_at,
|
||||||
|
"index_path": str(default_index_path(repo_root)),
|
||||||
|
"source": "repo-manager",
|
||||||
|
}
|
||||||
|
if push:
|
||||||
|
evidence["push_ok"] = push_ok
|
||||||
|
evidence["push_message"] = push_message
|
||||||
|
result = CommandResult(
|
||||||
|
command=command,
|
||||||
|
status="applied",
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
evidence=evidence,
|
||||||
|
)
|
||||||
|
if idempotency_key:
|
||||||
|
idempotency.put(idempotency_key, payload_digest, result.to_dict())
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def create_workplan(repo_root: Path, workplan_id: str, title: str, goal: str, **kwargs: Any) -> CommandResult:
|
||||||
|
return mutate_workplan(
|
||||||
|
repo_root,
|
||||||
|
workplan_id,
|
||||||
|
operation="create",
|
||||||
|
title=title,
|
||||||
|
goal=goal,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def update_workplan(repo_root: Path, workplan_id: str, **kwargs: Any) -> CommandResult:
|
||||||
|
return mutate_workplan(repo_root, workplan_id, operation="update", **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def archive_workplan(repo_root: Path, workplan_id: str, **kwargs: Any) -> CommandResult:
|
||||||
|
return mutate_workplan(repo_root, workplan_id, operation="archive", **kwargs)
|
||||||
|
|
@ -9,6 +9,7 @@ import yaml
|
||||||
|
|
||||||
from repo_manager.gitops import head_sha, is_git_repo
|
from repo_manager.gitops import head_sha, is_git_repo
|
||||||
from repo_manager.index_store import RepoIndex, WorkRecordEntry, _now
|
from repo_manager.index_store import RepoIndex, WorkRecordEntry, _now
|
||||||
|
from repo_manager.parse.register import iter_register_files, parse_register_file
|
||||||
from repo_manager.parse.workplan import iter_workplan_files, parse_workplan_file
|
from repo_manager.parse.workplan import iter_workplan_files, parse_workplan_file
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -72,6 +73,19 @@ def observe_repository(repo_root: Path, *, slug: str | None = None) -> tuple[dic
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
for path in iter_register_files(repo_root):
|
||||||
|
for entry in parse_register_file(path, repo_root=repo_root):
|
||||||
|
records.append(
|
||||||
|
WorkRecordEntry(
|
||||||
|
kind=f"register:{entry.kind}",
|
||||||
|
id=entry.id,
|
||||||
|
status=entry.status,
|
||||||
|
title=entry.title,
|
||||||
|
source_path=entry.source_path,
|
||||||
|
extra={"register_kind": entry.kind, "entry": entry.raw},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
sha = head_sha(repo_root) if is_git_repo(repo_root) else None
|
sha = head_sha(repo_root) if is_git_repo(repo_root) else None
|
||||||
index = RepoIndex(
|
index = RepoIndex(
|
||||||
slug=slug,
|
slug=slug,
|
||||||
|
|
@ -101,6 +115,7 @@ def observe_repository(repo_root: Path, *, slug: str | None = None) -> tuple[dic
|
||||||
"index": {
|
"index": {
|
||||||
"workplan_count": sum(1 for r in records if r.kind == "workplan"),
|
"workplan_count": sum(1 for r in records if r.kind == "workplan"),
|
||||||
"task_count": sum(1 for r in records if r.kind == "task"),
|
"task_count": sum(1 for r in records if r.kind == "task"),
|
||||||
|
"register_entry_count": sum(1 for r in records if r.kind.startswith("register:")),
|
||||||
"record_count": len(records),
|
"record_count": len(records),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
73
src/repo_manager/parse/register.py
Normal file
73
src/repo_manager/parse/register.py
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
"""Parser for repository-owned register files."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
REGISTER_SCHEMA = "repo-manager.register.v0"
|
||||||
|
SUPPORTED_REGISTER_KINDS = frozenset(
|
||||||
|
{
|
||||||
|
"sbom-inventory",
|
||||||
|
"repo-goals",
|
||||||
|
"upstream-contributions",
|
||||||
|
"technical-debt",
|
||||||
|
"extension-points",
|
||||||
|
"register-entries",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ParsedRegisterEntry:
|
||||||
|
kind: str
|
||||||
|
id: str
|
||||||
|
title: str | None
|
||||||
|
status: str | None
|
||||||
|
source_path: str
|
||||||
|
raw: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
def register_path(repo_root: Path, kind: str) -> Path:
|
||||||
|
return repo_root / "registers" / f"{kind}.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_register_file(path: Path, *, repo_root: Path) -> list[ParsedRegisterEntry]:
|
||||||
|
try:
|
||||||
|
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||||
|
except (OSError, yaml.YAMLError):
|
||||||
|
return []
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return []
|
||||||
|
kind = str(data.get("kind") or path.stem)
|
||||||
|
if kind not in SUPPORTED_REGISTER_KINDS:
|
||||||
|
return []
|
||||||
|
entries = data.get("entries") or []
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
return []
|
||||||
|
source_path = str(path.relative_to(repo_root))
|
||||||
|
parsed: list[ParsedRegisterEntry] = []
|
||||||
|
for item in entries:
|
||||||
|
if not isinstance(item, dict) or not item.get("id"):
|
||||||
|
continue
|
||||||
|
parsed.append(
|
||||||
|
ParsedRegisterEntry(
|
||||||
|
kind=kind,
|
||||||
|
id=str(item["id"]),
|
||||||
|
title=str(item["title"]) if item.get("title") is not None else None,
|
||||||
|
status=str(item["status"]) if item.get("status") is not None else None,
|
||||||
|
source_path=source_path,
|
||||||
|
raw=item,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def iter_register_files(repo_root: Path) -> list[Path]:
|
||||||
|
directory = repo_root / "registers"
|
||||||
|
if not directory.is_dir():
|
||||||
|
return []
|
||||||
|
return sorted(path for path in directory.glob("*.yaml") if not path.name.startswith("."))
|
||||||
|
|
@ -55,11 +55,11 @@ def _clear_dual_run_cache():
|
||||||
dual_run.reload_config()
|
dual_run.reload_config()
|
||||||
|
|
||||||
|
|
||||||
def test_flags_and_pilot(monkeypatch: pytest.MonkeyPatch):
|
def test_flags_and_pilot(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||||
monkeypatch.delenv("RM_WRITEBACK", raising=False)
|
monkeypatch.delenv("RM_WRITEBACK", raising=False)
|
||||||
monkeypatch.delenv("RM_RECONCILE", raising=False)
|
monkeypatch.delenv("RM_RECONCILE", raising=False)
|
||||||
monkeypatch.delenv("RM_PILOT_REPOS", raising=False)
|
monkeypatch.delenv("RM_PILOT_REPOS", raising=False)
|
||||||
monkeypatch.delenv("RM_DUAL_RUN_CONFIG", raising=False)
|
monkeypatch.setenv("RM_DUAL_RUN_CONFIG", str(tmp_path / "missing-dual-run.yaml"))
|
||||||
dual_run.reload_config()
|
dual_run.reload_config()
|
||||||
assert dual_run.writeback_enabled() is False
|
assert dual_run.writeback_enabled() is False
|
||||||
|
|
||||||
|
|
|
||||||
144
tests/test_register_command.py
Normal file
144
tests/test_register_command.py
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
"""Shared repository register spine coverage for RMGR-WP-0008-T05."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from repo_manager.commands.register import mutate_register_entry
|
||||||
|
from repo_manager.observe import observe_repository
|
||||||
|
from repo_manager.parse.register import SUPPORTED_REGISTER_KINDS
|
||||||
|
|
||||||
|
|
||||||
|
def _git(repo: Path, *args: str) -> None:
|
||||||
|
subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _fixture(tmp_path: Path) -> Path:
|
||||||
|
repo = tmp_path / "pilot"
|
||||||
|
repo.mkdir()
|
||||||
|
_git(repo, "init")
|
||||||
|
_git(repo, "config", "user.email", "test@example.com")
|
||||||
|
_git(repo, "config", "user.name", "Test")
|
||||||
|
(repo / ".repo-classification.yaml").write_text(
|
||||||
|
"repo_classification:\n category: tooling\n domain: infotech\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
_git(repo, "add", ".")
|
||||||
|
_git(repo, "commit", "-m", "seed")
|
||||||
|
return repo
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_register_kinds_are_file_backed_and_indexed(tmp_path: Path) -> None:
|
||||||
|
repo = _fixture(tmp_path)
|
||||||
|
payloads = {
|
||||||
|
"sbom-inventory": {"package_name": "pyyaml", "ecosystem": "pypi"},
|
||||||
|
"repo-goals": {"description": "Keep repository records authoritative."},
|
||||||
|
"upstream-contributions": {"type": "patch"},
|
||||||
|
"technical-debt": {"severity": "medium"},
|
||||||
|
"extension-points": {"ep_type": "api"},
|
||||||
|
"register-entries": {"register_kind": "custom"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for sequence, kind in enumerate(sorted(SUPPORTED_REGISTER_KINDS), start=1):
|
||||||
|
result = mutate_register_entry(
|
||||||
|
repo,
|
||||||
|
kind,
|
||||||
|
f"ENTRY-{sequence:02d}",
|
||||||
|
operation="upsert",
|
||||||
|
title=f"{kind} entry",
|
||||||
|
data=payloads[kind],
|
||||||
|
)
|
||||||
|
assert result.status == "applied", result
|
||||||
|
assert result.evidence["git_sha"]
|
||||||
|
document = yaml.safe_load((repo / "registers" / f"{kind}.yaml").read_text())
|
||||||
|
assert document["schema"] == "repo-manager.register.v0"
|
||||||
|
assert document["kind"] == kind
|
||||||
|
|
||||||
|
snapshot, index = observe_repository(repo, slug="pilot")
|
||||||
|
records = [record for record in index.work_records if record.kind.startswith("register:")]
|
||||||
|
assert snapshot["index"]["register_entry_count"] == 6
|
||||||
|
assert len(records) == 6
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_note_defer_and_idempotency(tmp_path: Path, monkeypatch) -> None:
|
||||||
|
repo = _fixture(tmp_path)
|
||||||
|
monkeypatch.setenv("RM_IDEMPOTENCY_PATH", str(tmp_path / "idempotency.json"))
|
||||||
|
created = mutate_register_entry(
|
||||||
|
repo,
|
||||||
|
"technical-debt",
|
||||||
|
"TD-001",
|
||||||
|
operation="upsert",
|
||||||
|
title="Coupled persistence",
|
||||||
|
data={"severity": "high"},
|
||||||
|
idempotency_key="td-create",
|
||||||
|
)
|
||||||
|
replay = mutate_register_entry(
|
||||||
|
repo,
|
||||||
|
"technical-debt",
|
||||||
|
"TD-001",
|
||||||
|
operation="upsert",
|
||||||
|
title="Coupled persistence",
|
||||||
|
data={"severity": "high"},
|
||||||
|
idempotency_key="td-create",
|
||||||
|
)
|
||||||
|
assert replay.evidence["git_sha"] == created.evidence["git_sha"]
|
||||||
|
|
||||||
|
updated = mutate_register_entry(
|
||||||
|
repo,
|
||||||
|
"technical-debt",
|
||||||
|
"TD-001",
|
||||||
|
operation="upsert",
|
||||||
|
status="in_progress",
|
||||||
|
data={"severity": "critical"},
|
||||||
|
)
|
||||||
|
assert updated.status == "applied"
|
||||||
|
noted = mutate_register_entry(
|
||||||
|
repo,
|
||||||
|
"technical-debt",
|
||||||
|
"TD-001",
|
||||||
|
operation="note",
|
||||||
|
note="Migration owner assigned.",
|
||||||
|
note_author="operator",
|
||||||
|
)
|
||||||
|
assert noted.status == "applied"
|
||||||
|
deferred = mutate_register_entry(
|
||||||
|
repo,
|
||||||
|
"technical-debt",
|
||||||
|
"TD-001",
|
||||||
|
operation="defer",
|
||||||
|
)
|
||||||
|
assert deferred.status == "applied"
|
||||||
|
|
||||||
|
document = yaml.safe_load((repo / "registers" / "technical-debt.yaml").read_text())
|
||||||
|
entry = document["entries"][0]
|
||||||
|
assert entry["status"] == "deferred"
|
||||||
|
assert entry["severity"] == "critical"
|
||||||
|
assert entry["notes"][0]["author"] == "operator"
|
||||||
|
|
||||||
|
|
||||||
|
def test_schema_and_safety_validation(tmp_path: Path) -> None:
|
||||||
|
repo = _fixture(tmp_path)
|
||||||
|
missing = mutate_register_entry(
|
||||||
|
repo,
|
||||||
|
"sbom-inventory",
|
||||||
|
"PKG-01",
|
||||||
|
operation="upsert",
|
||||||
|
title="package",
|
||||||
|
data={"package_name": "demo"},
|
||||||
|
)
|
||||||
|
assert missing.status == "rejected"
|
||||||
|
assert missing.error and "ecosystem" in missing.error["message"]
|
||||||
|
|
||||||
|
protected = mutate_register_entry(
|
||||||
|
repo,
|
||||||
|
"technical-debt",
|
||||||
|
"TD-001",
|
||||||
|
operation="upsert",
|
||||||
|
title="Debt",
|
||||||
|
data={"id": "replacement"},
|
||||||
|
)
|
||||||
|
assert protected.status == "rejected"
|
||||||
|
assert protected.error and protected.error["code"] == "validation_error"
|
||||||
168
tests/test_workplan_command.py
Normal file
168
tests/test_workplan_command.py
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
"""Governed workplan mutation coverage for RMGR-WP-0008-T01."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from repo_manager.commands.workplan import archive_workplan, create_workplan, update_workplan
|
||||||
|
from repo_manager.gitops import head_sha
|
||||||
|
from repo_manager.index_store import default_index_path, load_index
|
||||||
|
from repo_manager.observe import observe_repository
|
||||||
|
|
||||||
|
|
||||||
|
def _git(repo: Path, *args: str) -> None:
|
||||||
|
subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _fixture(tmp_path: Path) -> Path:
|
||||||
|
repo = tmp_path / "pilot"
|
||||||
|
repo.mkdir()
|
||||||
|
_git(repo, "init")
|
||||||
|
_git(repo, "config", "user.email", "test@example.com")
|
||||||
|
_git(repo, "config", "user.name", "Test")
|
||||||
|
(repo / "workplans").mkdir()
|
||||||
|
(repo / ".repo-classification.yaml").write_text(
|
||||||
|
"repo_classification:\n category: tooling\n domain: infotech\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(repo / "workplans" / "P-WP-0001-first.md").write_text(
|
||||||
|
"""---
|
||||||
|
id: P-WP-0001
|
||||||
|
type: workplan
|
||||||
|
title: "First"
|
||||||
|
domain: infotech
|
||||||
|
repo: pilot
|
||||||
|
status: proposed
|
||||||
|
owner: codex
|
||||||
|
topic_slug: infotech
|
||||||
|
created: "2026-08-21"
|
||||||
|
updated: "2026-08-21"
|
||||||
|
state_hub_workstream_id: "11111111-1111-4111-8111-111111111111"
|
||||||
|
---
|
||||||
|
|
||||||
|
# First
|
||||||
|
|
||||||
|
## Task
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: P-WP-0001-T01
|
||||||
|
status: todo
|
||||||
|
priority: high
|
||||||
|
```
|
||||||
|
|
||||||
|
Do it.
|
||||||
|
""",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
_git(repo, "add", ".")
|
||||||
|
_git(repo, "commit", "-m", "seed")
|
||||||
|
return repo
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_workplan_commits_indexes_and_meters(tmp_path: Path, monkeypatch) -> None:
|
||||||
|
repo = _fixture(tmp_path)
|
||||||
|
meter = tmp_path / "meter.jsonl"
|
||||||
|
monkeypatch.setenv("RM_METER_PATH", str(meter))
|
||||||
|
|
||||||
|
result = create_workplan(
|
||||||
|
repo,
|
||||||
|
"P-WP-0002",
|
||||||
|
"Second plan",
|
||||||
|
"Deliver the second governed slice.",
|
||||||
|
status="ready",
|
||||||
|
correlation_id="00000000-0000-4000-8000-000000000002",
|
||||||
|
repo_slug="pilot",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.status == "applied"
|
||||||
|
assert result.evidence["git_sha"] == head_sha(repo)
|
||||||
|
path = repo / "workplans" / "P-WP-0002-second-plan.md"
|
||||||
|
assert path.is_file()
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
assert "status: ready" in text
|
||||||
|
assert "Deliver the second governed slice." in text
|
||||||
|
_snapshot, index = observe_repository(repo, slug="pilot")
|
||||||
|
assert next(r for r in index.work_records if r.id == "P-WP-0002").status == "ready"
|
||||||
|
stored = load_index(default_index_path(repo))
|
||||||
|
event = next(e for e in stored.events if e.get("type") == "repo.command.applied")
|
||||||
|
assert event["command"] == "repo.work.create_workplan"
|
||||||
|
assert '"kind": "workplan_create"' in meter.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_by_uuid_is_idempotent_and_honors_expected_head(tmp_path: Path, monkeypatch) -> None:
|
||||||
|
repo = _fixture(tmp_path)
|
||||||
|
monkeypatch.setenv("RM_IDEMPOTENCY_PATH", str(tmp_path / "idempotency.json"))
|
||||||
|
before = head_sha(repo)
|
||||||
|
|
||||||
|
rejected = update_workplan(
|
||||||
|
repo,
|
||||||
|
"11111111-1111-4111-8111-111111111111",
|
||||||
|
status="active",
|
||||||
|
expected_head_sha="0" * 40,
|
||||||
|
)
|
||||||
|
assert rejected.status == "rejected"
|
||||||
|
assert rejected.error and rejected.error["code"] == "precondition_failed"
|
||||||
|
|
||||||
|
applied = update_workplan(
|
||||||
|
repo,
|
||||||
|
"11111111-1111-4111-8111-111111111111",
|
||||||
|
title="Renamed",
|
||||||
|
status="active",
|
||||||
|
expected_head_sha=before,
|
||||||
|
idempotency_key="workplan-update-1",
|
||||||
|
)
|
||||||
|
assert applied.status == "applied"
|
||||||
|
assert applied.evidence["git_sha"] != before
|
||||||
|
text = (repo / "workplans" / "P-WP-0001-first.md").read_text(encoding="utf-8")
|
||||||
|
assert 'title: "Renamed"' in text
|
||||||
|
assert "status: active" in text
|
||||||
|
|
||||||
|
replay = update_workplan(
|
||||||
|
repo,
|
||||||
|
"11111111-1111-4111-8111-111111111111",
|
||||||
|
title="Renamed",
|
||||||
|
status="active",
|
||||||
|
expected_head_sha=before,
|
||||||
|
idempotency_key="workplan-update-1",
|
||||||
|
)
|
||||||
|
assert replay.status == "applied"
|
||||||
|
assert replay.evidence["git_sha"] == applied.evidence["git_sha"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_is_guarded_recoverable_archive(tmp_path: Path) -> None:
|
||||||
|
repo = _fixture(tmp_path)
|
||||||
|
|
||||||
|
rejected = archive_workplan(repo, "P-WP-0001")
|
||||||
|
assert rejected.status == "rejected"
|
||||||
|
assert rejected.error and rejected.error["code"] == "confirmation_required"
|
||||||
|
assert (repo / "workplans" / "P-WP-0001-first.md").is_file()
|
||||||
|
|
||||||
|
applied = archive_workplan(repo, "P-WP-0001", confirm_archive=True)
|
||||||
|
assert applied.status == "applied"
|
||||||
|
assert not (repo / "workplans" / "P-WP-0001-first.md").exists()
|
||||||
|
archived = list((repo / "workplans" / "archived").glob("*-P-WP-0001-first.md"))
|
||||||
|
assert len(archived) == 1
|
||||||
|
assert "status: archived" in archived[0].read_text(encoding="utf-8")
|
||||||
|
_snapshot, index = observe_repository(repo)
|
||||||
|
record = next(r for r in index.work_records if r.id == "P-WP-0001")
|
||||||
|
assert record.status == "archived"
|
||||||
|
assert record.source_path.startswith("workplans/archived/")
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_rejects_unsafe_filename_and_duplicate_id(tmp_path: Path) -> None:
|
||||||
|
repo = _fixture(tmp_path)
|
||||||
|
|
||||||
|
unsafe = create_workplan(
|
||||||
|
repo,
|
||||||
|
"P-WP-0002",
|
||||||
|
"Second",
|
||||||
|
"Goal",
|
||||||
|
filename="../outside.md",
|
||||||
|
)
|
||||||
|
assert unsafe.status == "rejected"
|
||||||
|
assert unsafe.error and unsafe.error["code"] == "validation_error"
|
||||||
|
|
||||||
|
duplicate = create_workplan(repo, "P-WP-0001", "Duplicate", "Goal")
|
||||||
|
assert duplicate.status == "rejected"
|
||||||
|
assert duplicate.error and duplicate.error["code"] == "conflict"
|
||||||
|
|
@ -4,11 +4,11 @@ type: workplan
|
||||||
title: "Receiving surface for work records and repository registers"
|
title: "Receiving surface for work records and repository registers"
|
||||||
domain: infotech
|
domain: infotech
|
||||||
repo: repo-manager
|
repo: repo-manager
|
||||||
status: proposed
|
status: active
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: infotech
|
topic_slug: infotech
|
||||||
created: "2026-08-20"
|
created: "2026-08-20"
|
||||||
updated: "2026-08-20"
|
updated: "2026-08-21"
|
||||||
parent_project: prj-state-hub-retirement
|
parent_project: prj-state-hub-retirement
|
||||||
parent_workplan: SHR-WP-0001
|
parent_workplan: SHR-WP-0001
|
||||||
related:
|
related:
|
||||||
|
|
@ -69,7 +69,7 @@ surfaces index and govern them, they do not become a second source of truth.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RMGR-WP-0008-T01
|
id: RMGR-WP-0008-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -83,6 +83,14 @@ Unblocks slice **A2b** (~9 routes plus `table:workplans`).
|
||||||
Follow the RMGR-WP-0002 dual-run shape — writeback, reconcile, mutation meter —
|
Follow the RMGR-WP-0002 dual-run shape — writeback, reconcile, mutation meter —
|
||||||
rather than inventing a second mechanism.
|
rather than inventing a second mechanism.
|
||||||
|
|
||||||
|
Result (2026-08-21): `rmgr workplan create|update|delete` now owns the
|
||||||
|
file-backed mutation path. Update accepts canonical ids or State Hub UUIDs;
|
||||||
|
delete is a confirmed, recoverable archive operation. All three operations use
|
||||||
|
optimistic HEAD checks, idempotency, Git evidence, reindexing, normalized
|
||||||
|
events, mutation metering, and optional push-seal. State Hub has the matching
|
||||||
|
`rm_update_workplan` compatibility adapter. Repo Manager's full suite and the
|
||||||
|
adapter's focused compatibility tests pass.
|
||||||
|
|
||||||
## Intake records
|
## Intake records
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
@ -131,7 +139,7 @@ Unblocks slice **A2e** (~6 items) and the dependency routes inside A2b.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RMGR-WP-0008-T05
|
id: RMGR-WP-0008-T05
|
||||||
status: todo
|
status: progress
|
||||||
priority: high
|
priority: high
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -152,6 +160,14 @@ Treat as one register spine with per-kind schemas rather than six unrelated
|
||||||
features — they share repo scoping, append-mostly write patterns, and the same
|
features — they share repo scoping, append-mostly write patterns, and the same
|
||||||
file-backed-authority rule.
|
file-backed-authority rule.
|
||||||
|
|
||||||
|
Progress (2026-08-21): the shared spine is live for all six kinds as versioned
|
||||||
|
`registers/<kind>.yaml` files, with parser/index support and governed
|
||||||
|
`put`/`defer`/`note` commands. Kind-specific required fields cover the existing
|
||||||
|
State Hub create contracts, and the generic `rm_update_register_entry` adapter
|
||||||
|
can route compatibility writes. Remaining before `done`: lockfile ingestion,
|
||||||
|
licence/copyleft derived reports, route-level cutover fixtures, and migration of
|
||||||
|
the live register rows.
|
||||||
|
|
||||||
## Topic and classification contract
|
## Topic and classification contract
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ type: workplan
|
||||||
title: "Coding-assistant commit provenance trailers"
|
title: "Coding-assistant commit provenance trailers"
|
||||||
domain: infotech
|
domain: infotech
|
||||||
repo: repo-manager
|
repo: repo-manager
|
||||||
status: proposed
|
status: ready
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: infotech
|
topic_slug: infotech
|
||||||
created: "2026-08-21"
|
created: "2026-08-21"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue