agent-pmo 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.
- agent_pmo/__init__.py +7 -0
- agent_pmo/__main__.py +22 -0
- agent_pmo/adherence.py +455 -0
- agent_pmo/adopt.py +693 -0
- agent_pmo/automation.py +391 -0
- agent_pmo/backlog.py +251 -0
- agent_pmo/bootstrap.py +322 -0
- agent_pmo/changelog.py +340 -0
- agent_pmo/changes.py +156 -0
- agent_pmo/checks.py +115 -0
- agent_pmo/cli.py +1483 -0
- agent_pmo/coderabbit.py +258 -0
- agent_pmo/config.py +170 -0
- agent_pmo/container.py +95 -0
- agent_pmo/coord.py +277 -0
- agent_pmo/decisions.py +305 -0
- agent_pmo/deploy.py +925 -0
- agent_pmo/discuss.py +562 -0
- agent_pmo/gitx.py +48 -0
- agent_pmo/guide.py +184 -0
- agent_pmo/hosting.py +118 -0
- agent_pmo/issues.py +133 -0
- agent_pmo/kit/CHANGELOG.md +23 -0
- agent_pmo/kit/WORKFLOW.md +159 -0
- agent_pmo/kit/docs/README.md +14 -0
- agent_pmo/kit/docs/automation.md +60 -0
- agent_pmo/kit/docs/commands.md +400 -0
- agent_pmo/kit/docs/concepts.md +86 -0
- agent_pmo/kit/docs/configuration.md +88 -0
- agent_pmo/kit/docs/development.md +97 -0
- agent_pmo/kit/docs/getting-started.md +65 -0
- agent_pmo/kit/profiles/generic.md +10 -0
- agent_pmo/kit/profiles/generic.toml +51 -0
- agent_pmo/kit/profiles/go.md +8 -0
- agent_pmo/kit/profiles/go.toml +57 -0
- agent_pmo/kit/profiles/java.md +8 -0
- agent_pmo/kit/profiles/java.toml +56 -0
- agent_pmo/kit/profiles/python.md +46 -0
- agent_pmo/kit/profiles/python.toml +61 -0
- agent_pmo/kit/profiles/rust.md +8 -0
- agent_pmo/kit/profiles/rust.toml +57 -0
- agent_pmo/kit/profiles/typescript.md +10 -0
- agent_pmo/kit/profiles/typescript.toml +60 -0
- agent_pmo/kit/templates/agents/AGENTS.md +9 -0
- agent_pmo/kit/templates/agents/CLAUDE.md +9 -0
- agent_pmo/kit/templates/agents/GEMINI.md +9 -0
- agent_pmo/kit/templates/agents/copilot-instructions.md +5 -0
- agent_pmo/kit/templates/agents/cursor-workflow.mdc +8 -0
- agent_pmo/kit/templates/architecture.md +23 -0
- agent_pmo/kit/templates/architecture_changelog.md +8 -0
- agent_pmo/kit/templates/assumptions.md +10 -0
- agent_pmo/kit/templates/backlog_index.md +6 -0
- agent_pmo/kit/templates/change_request.md +46 -0
- agent_pmo/kit/templates/changelog.md +8 -0
- agent_pmo/kit/templates/changes_index.md +7 -0
- agent_pmo/kit/templates/decisions_index.md +9 -0
- agent_pmo/kit/templates/deploy_changelog.md +8 -0
- agent_pmo/kit/templates/deploy_deployments.md +6 -0
- agent_pmo/kit/templates/docs_readme.md +12 -0
- agent_pmo/kit/templates/guide_agent.md +54 -0
- agent_pmo/kit/templates/guide_changelog.md +10 -0
- agent_pmo/kit/templates/guide_coding_style.md +7 -0
- agent_pmo/kit/templates/guide_developer.md +114 -0
- agent_pmo/kit/templates/guide_project_manager.md +104 -0
- agent_pmo/kit/templates/module_readme.md +19 -0
- agent_pmo/kit/templates/security_index.md +6 -0
- agent_pmo/kit/templates/spec.md +77 -0
- agent_pmo/kit/templates/spec_index.md +28 -0
- agent_pmo/kit/workflows/adopt.md +15 -0
- agent_pmo/kit/workflows/change-request.md +17 -0
- agent_pmo/kit/workflows/deploy.md +28 -0
- agent_pmo/kit/workflows/discuss.md +21 -0
- agent_pmo/kit/workflows/fix.md +11 -0
- agent_pmo/kit/workflows/implement.md +21 -0
- agent_pmo/kit/workflows/merge.md +9 -0
- agent_pmo/kit/workflows/oneoff.md +16 -0
- agent_pmo/kit/workflows/parallel.md +12 -0
- agent_pmo/kit/workflows/planning.md +27 -0
- agent_pmo/kit/workflows/release.md +12 -0
- agent_pmo/kit/workflows/revert.md +12 -0
- agent_pmo/kit/workflows/review.md +31 -0
- agent_pmo/kit/workflows/scheduled.md +11 -0
- agent_pmo/kit/workflows/security.md +11 -0
- agent_pmo/kit/workflows/setup.md +70 -0
- agent_pmo/kit/workflows/test.md +15 -0
- agent_pmo/kit/workflows/triage.md +18 -0
- agent_pmo/kit.py +260 -0
- agent_pmo/landing.py +763 -0
- agent_pmo/md.py +96 -0
- agent_pmo/oneoff.py +329 -0
- agent_pmo/provider.py +562 -0
- agent_pmo/registry.py +689 -0
- agent_pmo/release.py +108 -0
- agent_pmo/reports.py +462 -0
- agent_pmo/review.py +404 -0
- agent_pmo/security.py +402 -0
- agent_pmo/specedit.py +147 -0
- agent_pmo/style.py +775 -0
- agent_pmo/templates.py +13 -0
- agent_pmo/trace.py +237 -0
- agent_pmo/traceability.py +222 -0
- agent_pmo/triage.py +336 -0
- agent_pmo-0.1.0.dist-info/METADATA +73 -0
- agent_pmo-0.1.0.dist-info/RECORD +107 -0
- agent_pmo-0.1.0.dist-info/WHEEL +4 -0
- agent_pmo-0.1.0.dist-info/entry_points.txt +2 -0
- agent_pmo-0.1.0.dist-info/licenses/LICENSE +21 -0
agent_pmo/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""agent-pmo: a project management office for AI coding agents (specs, plans, decisions, tests, reviews, releases).
|
|
2
|
+
|
|
3
|
+
Language-neutral and standard-library only (Python >= 3.11). Everything project-specific lives in the project's
|
|
4
|
+
pm.toml; the kit's documentation is in docs/ (commands.md, configuration.md).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
agent_pmo/__main__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""`agent-pmo` and `python -m agent_pmo`: the command-line entry point (Python >= 3.11, standard library only)."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def run() -> int:
|
|
7
|
+
if sys.version_info < (3, 11):
|
|
8
|
+
sys.exit("agent-pmo needs Python 3.11 or newer (uses tomllib)")
|
|
9
|
+
if not sys.flags.utf8_mode:
|
|
10
|
+
import locale
|
|
11
|
+
|
|
12
|
+
if locale.getencoding().lower().replace("-", "") != "utf8": # e.g. Windows code pages: docs are UTF-8 everywhere
|
|
13
|
+
import subprocess
|
|
14
|
+
|
|
15
|
+
return subprocess.call([sys.executable, "-X", "utf8", "-m", "agent_pmo", *sys.argv[1:]])
|
|
16
|
+
from .cli import main
|
|
17
|
+
|
|
18
|
+
return main()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
if __name__ == "__main__":
|
|
22
|
+
sys.exit(run())
|
agent_pmo/adherence.py
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
"""Workflow adherence: did an agent follow every step of a workflow? Checked from the evidence the steps leave behind.
|
|
2
|
+
|
|
3
|
+
Each check lists the workflow's steps (numbered as in .pm/workflows/<name>.md) with ok, missing or n/a and why. A
|
|
4
|
+
missing step means it was skipped: do it. `report` runs the checks for every unit, backlog fix and change request
|
|
5
|
+
and summarises them; the pre-push hook runs the check matching a pushed unit branch's status.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from . import gitx, md
|
|
17
|
+
from .config import Config
|
|
18
|
+
|
|
19
|
+
WORKFLOWS = ("setup", "implement", "test", "review", "change-request", "security", "fix", "oneoff", "adopt", "triage",
|
|
20
|
+
"discuss", "deploy") # fmt: skip
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Step:
|
|
25
|
+
n: str
|
|
26
|
+
name: str
|
|
27
|
+
status: str # ok | missing | n/a
|
|
28
|
+
detail: str = ""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Result:
|
|
33
|
+
workflow: str
|
|
34
|
+
subject: str
|
|
35
|
+
steps: list[Step] = field(default_factory=list)
|
|
36
|
+
|
|
37
|
+
def add(self, n: str, name: str, ok: bool | None, detail: str = "") -> None:
|
|
38
|
+
self.steps.append(Step(n, name, "n/a" if ok is None else ("ok" if ok else "missing"), detail))
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def missing(self) -> list[Step]:
|
|
42
|
+
return [s for s in self.steps if s.status == "missing"]
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def passed(self) -> bool:
|
|
46
|
+
return not self.missing
|
|
47
|
+
|
|
48
|
+
def text(self) -> str:
|
|
49
|
+
counted = [s for s in self.steps if s.status != "n/a"]
|
|
50
|
+
lines = [f"{self.workflow} {self.subject}: {len(counted) - len(self.missing)}/{len(counted)} steps"
|
|
51
|
+
+ ("" if self.passed else f" — {len(self.missing)} missing (workflows/{self.workflow}.md)")]
|
|
52
|
+
mark = {"ok": "ok ", "missing": "MISSING", "n/a": "n/a"}
|
|
53
|
+
lines += [f" {mark[s.status]:<7} step {s.n} {s.name}" + (f": {s.detail}" if s.detail else "") for s in self.steps]
|
|
54
|
+
return "\n".join(lines)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------- helpers
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _unit_commits(cfg: Config, unit: str, trailer: str = "Unit") -> list[dict[str, Any]]:
|
|
61
|
+
from . import trace
|
|
62
|
+
|
|
63
|
+
return [c for c in trace.commits(cfg) if c["trailers"].get(trailer) == unit]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _module(cfg: Config, name: str) -> Any:
|
|
67
|
+
from . import registry
|
|
68
|
+
|
|
69
|
+
mods = registry.load(cfg)
|
|
70
|
+
if name not in mods:
|
|
71
|
+
raise ValueError(f"unknown module {name!r}")
|
|
72
|
+
return mods[name]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _relevant(problems: list[str], *needles: str) -> list[str]:
|
|
76
|
+
return [p for p in problems if any(n and n in p for n in needles)]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------- unit workflows
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def implement(cfg: Config, module: str, k: int, *, prepush: bool = False) -> Result:
|
|
83
|
+
from . import checks, coord, registry
|
|
84
|
+
|
|
85
|
+
m = _module(cfg, module)
|
|
86
|
+
inc = m.inc(k)
|
|
87
|
+
if inc is None:
|
|
88
|
+
raise ValueError(f"{module} has no increment {k}")
|
|
89
|
+
r = Result("implement", f"{module} inc {k}")
|
|
90
|
+
unit = f"{module} inc {k}"
|
|
91
|
+
commits = _unit_commits(cfg, unit)
|
|
92
|
+
held = {c.module: c for c in coord.claims(cfg, include_expired=True)} if coord.mode(cfg) != "off" else {}
|
|
93
|
+
claimed = module in held or any("Agent" in c["trailers"] for c in commits)
|
|
94
|
+
r.add("1", "claim the module (next implement --claim)", None if coord.mode(cfg) == "off" else claimed,
|
|
95
|
+
held[module].agent if module in held else ("" if claimed else "no claim and no Agent trailer")) # fmt: skip
|
|
96
|
+
branch = gitx.current_branch(cfg.root)
|
|
97
|
+
pattern = re.compile(rf"^{re.escape(cfg.prefix.lower())}-\d{{4}}-{re.escape(module)}-inc{k}$")
|
|
98
|
+
on_branch = branch not in ("", "HEAD", cfg.default_branch, cfg.get("git.dev", "dev")) and f"inc{k}" in branch
|
|
99
|
+
r.add("3", "work on the unit branch <prefix>-NNNN-<module>-inc<k>", bool(pattern.match(branch)) if on_branch else None,
|
|
100
|
+
branch if on_branch else "") # fmt: skip
|
|
101
|
+
files = re.findall(r"^- `([^`]+)`", registry.increment_body(cfg, m, k).split("**Files**", 1)[-1].split("**Tests**", 1)[0],
|
|
102
|
+
re.M) if "**Files**" in registry.increment_body(cfg, m, k) else [] # fmt: skip
|
|
103
|
+
absent = [f for f in files if not (cfg.root / f).exists() and "(planned)" not in f]
|
|
104
|
+
r.add("4", "create the increment's files", not absent if inc.status != "planned" else None,
|
|
105
|
+
("missing: " + ", ".join(absent)) if absent else "") # fmt: skip
|
|
106
|
+
r.add("6", "status coded (or later) with every task ticked", inc.status != "planned" and not inc.open_tasks,
|
|
107
|
+
f"status {inc.status}, {inc.open_tasks} task(s) open") # fmt: skip
|
|
108
|
+
ids = {c["trailers"].get("Change-Id") for c in commits}
|
|
109
|
+
log = (m.folder / "changelog.md").read_text() if (m.folder / "changelog.md").exists() else ""
|
|
110
|
+
r.add("6", "a changelog row for the unit's change", any(i and f"| {i} |" in log for i in ids),
|
|
111
|
+
"" if ids else "no commit carries this unit's trailers yet") # fmt: skip
|
|
112
|
+
probs = _relevant(checks.run(cfg), m.folder.relative_to(cfg.root).as_posix(), m.dir.relative_to(cfg.root).as_posix(),
|
|
113
|
+
f" {module} inc {k}") # fmt: skip
|
|
114
|
+
r.add("7", "agent-pmo check clean for this module", not probs, "; ".join(probs[:3]))
|
|
115
|
+
feature = [c for c in commits if c["trailers"].get("Type") == "feature"]
|
|
116
|
+
r.add("8", "commit with trailers (Change-Id, Unit, Type feature, Implemented-By)",
|
|
117
|
+
any("Implemented-By" in c["trailers"] and "Change-Id" in c["trailers"] for c in feature),
|
|
118
|
+
"" if feature else "no commit with Unit + Type: feature") # fmt: skip
|
|
119
|
+
return r
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test(cfg: Config, module: str, k: int, *, prepush: bool = False) -> Result:
|
|
123
|
+
from . import reports
|
|
124
|
+
|
|
125
|
+
m = _module(cfg, module)
|
|
126
|
+
inc = m.inc(k)
|
|
127
|
+
r = Result("test", f"{module} inc {k}")
|
|
128
|
+
folder = m.folder / "test"
|
|
129
|
+
plans = reports.for_unit(reports.numbered(folder, "test_plan"), module, k)
|
|
130
|
+
reps = reports.for_unit(reports.numbered(folder, "test_report"), module, k)
|
|
131
|
+
r.add("2", "test plan, filled in", bool(plans) and md.PLACEHOLDER not in plans[-1].read_text(),
|
|
132
|
+
"" if plans else "no test plan") # fmt: skip
|
|
133
|
+
latest = reps[-1].read_text() if reps else ""
|
|
134
|
+
r.add("6", "PASS test report, filled in (coverage of the plan, bugs found)",
|
|
135
|
+
"**Result:** PASS" in latest and md.PLACEHOLDER not in latest, "" if reps else "no test report") # fmt: skip
|
|
136
|
+
r.add("7", "status tested (or reviewed)", inc.status in ("tested", "reviewed"), f"status {inc.status}")
|
|
137
|
+
log = (m.folder / "changelog.md").read_text() if (m.folder / "changelog.md").exists() else ""
|
|
138
|
+
r.add("7", "the changelog links the test report", bool(reps) and f"test/{reps[-1].name}" in log)
|
|
139
|
+
requests = reports.for_unit(reports.numbered(m.folder / "review", "request"), module, k)
|
|
140
|
+
r.add("8", "review requested (testing ends by triggering the review)", bool(requests))
|
|
141
|
+
commits = _unit_commits(cfg, f"{module} inc {k}")
|
|
142
|
+
r.add("9", "commit with Type: test", any(c["trailers"].get("Type") == "test" for c in commits))
|
|
143
|
+
return r
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def review(cfg: Config, module: str, k: int, *, prepush: bool = False) -> Result:
|
|
147
|
+
from . import review as rv
|
|
148
|
+
from . import security
|
|
149
|
+
|
|
150
|
+
m = _module(cfg, module)
|
|
151
|
+
inc = m.inc(k)
|
|
152
|
+
r = Result("review", f"{module} inc {k}")
|
|
153
|
+
sec = security.latest_module_report(m)
|
|
154
|
+
sec_text = sec.read_text() if sec else ""
|
|
155
|
+
r.add("2", "module security report: PASS and assessed", bool(sec) and "**Result:** PASS" in sec_text
|
|
156
|
+
and md.PLACEHOLDER not in sec_text, "" if sec else "no module security report") # fmt: skip
|
|
157
|
+
from . import reports
|
|
158
|
+
|
|
159
|
+
r.add("3", "review request filed", bool(reports.for_unit(reports.numbered(m.folder / "review", "request"), module, k)))
|
|
160
|
+
probs = rv.review_problems(cfg, m, inc) if rv._reviews(m, k) else ["no code review"]
|
|
161
|
+
independent = [p for p in probs if "authored" in p or "reviewer model" in p]
|
|
162
|
+
r.add("5", "independent review by a different model (findings with backlog items)",
|
|
163
|
+
bool(rv._reviews(m, k)) and not independent and not [p for p in probs if "BL-" in p or "finding" in p],
|
|
164
|
+
"; ".join(probs[:2])) # fmt: skip
|
|
165
|
+
from . import coderabbit
|
|
166
|
+
|
|
167
|
+
if coderabbit.enabled(cfg):
|
|
168
|
+
cr = coderabbit.unit_problems(cfg, m, k)
|
|
169
|
+
r.add("5", "CodeRabbit review fetched and every finding triaged", not cr, "; ".join(cr))
|
|
170
|
+
r.add("8", "approved as the rules require", bool(rv._reviews(m, k)) and not [p for p in probs if "approv" in p],
|
|
171
|
+
"; ".join(p for p in probs if "approv" in p)[:200]) # fmt: skip
|
|
172
|
+
r.add("9", "status reviewed and the changelog links the review", inc.status == "reviewed" and not
|
|
173
|
+
[p for p in probs if "changelog" in p], f"status {inc.status}") # fmt: skip
|
|
174
|
+
commits = _unit_commits(cfg, f"{module} inc {k}")
|
|
175
|
+
r.add("9", "commit with Type: review and Reviewed-By", any(c["trailers"].get("Type") == "review" and
|
|
176
|
+
"Reviewed-By" in c["trailers"] for c in commits)) # fmt: skip
|
|
177
|
+
if prepush or cfg.strategy == "local":
|
|
178
|
+
r.add("10", "landed (merged into the integration branch)", None if prepush else _landed(cfg, commits))
|
|
179
|
+
else:
|
|
180
|
+
r.add("10", "landed (agent PR merged into dev, review PR opened)", _landed(cfg, commits))
|
|
181
|
+
return r
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _landed(cfg: Config, commits: list[dict[str, Any]]) -> bool | None:
|
|
185
|
+
if not commits:
|
|
186
|
+
return False
|
|
187
|
+
target = cfg.default_branch if cfg.strategy == "local" else cfg.get("git.dev", "dev")
|
|
188
|
+
remote = cfg.get("provider.remote", "origin")
|
|
189
|
+
for ref in (f"{remote}/{target}", target):
|
|
190
|
+
if gitx.ok(cfg.root, "rev-parse", "--verify", ref):
|
|
191
|
+
return gitx.ok(cfg.root, "merge-base", "--is-ancestor", commits[0]["sha"], ref)
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ---------------------------------------------------------------- other workflows
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def security_wf(cfg: Config, module: str) -> Result:
|
|
199
|
+
from . import security
|
|
200
|
+
|
|
201
|
+
m = _module(cfg, module)
|
|
202
|
+
r = Result("security", module)
|
|
203
|
+
why = security.module_report_problem(cfg, m)
|
|
204
|
+
r.add("1", "module security report for the current files", not why or "assessment" in why, why)
|
|
205
|
+
r.add("2", "assessment written", bool(security.latest_module_report(m)) and "assessment" not in why, why if "assessment" in why else "")
|
|
206
|
+
p = security.latest_module_report(m)
|
|
207
|
+
r.add("3", "no blocking findings (or accepted risks)", bool(p) and "**Result:** PASS" in p.read_text())
|
|
208
|
+
return r
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def change_request(cfg: Config, cr: str) -> Result:
|
|
212
|
+
from . import changes, decisions
|
|
213
|
+
|
|
214
|
+
path = changes.find(cfg, cr)
|
|
215
|
+
text = path.read_text()
|
|
216
|
+
r = Result("change-request", cr)
|
|
217
|
+
status = (re.search(r"^\*\*Status:\*\* `(\w+)`", text, re.M) or [None, "?"])[1]
|
|
218
|
+
r.add("1", "recorded with the user's request", md.PLACEHOLDER not in text.split("## Impact", 1)[0])
|
|
219
|
+
r.add("2", "impact written", md.PLACEHOLDER not in text.split("## Plan", 1)[0])
|
|
220
|
+
r.add("2", "assumptions resolved", not decisions.open_for(cfg, f"cr:{cr}"))
|
|
221
|
+
|
|
222
|
+
def section(name: str) -> str:
|
|
223
|
+
return text.split(f"## {name}", 1)[-1].split("\n## ", 1)[0] if f"## {name}" in text else ""
|
|
224
|
+
|
|
225
|
+
later = status in ("planned", "implemented", "tested", "reviewed", "done")
|
|
226
|
+
r.add("3", "specs updated (requirements changed)", bool(re.search(r"^- ", section("Requirements changed"), re.M)) if later else None)
|
|
227
|
+
r.add("4", "increments planned for it", bool(re.search(r"^- ", section("Plan"), re.M)) if later else None)
|
|
228
|
+
r.add("5", "status follows the increments", not [p for p in changes.cr_problems(cfg) if path.name in p])
|
|
229
|
+
return r
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def fix(cfg: Config, item_id: str) -> Result:
|
|
233
|
+
from . import backlog, registry, reports
|
|
234
|
+
|
|
235
|
+
item = backlog.find(cfg, item_id)
|
|
236
|
+
r = Result("fix", item_id)
|
|
237
|
+
commits = _unit_commits(cfg, item_id, "Backlog")
|
|
238
|
+
r.add("1", "backlog item with priority and detail", md.PLACEHOLDER not in item.path.read_text())
|
|
239
|
+
done = item.status in backlog.NEEDS_FIX_ID
|
|
240
|
+
r.add("3", "fixed, with the fix change id", done and item.fix_change_id not in ("", "—") if item.status not in
|
|
241
|
+
("wontfix", "duplicate", "accepted-risk") else None, f"status {item.status}") # fmt: skip
|
|
242
|
+
mods = registry.load(cfg)
|
|
243
|
+
mod = mods.get(item.module)
|
|
244
|
+
tested = mod and any(i.status in ("tested", "reviewed") for i in mod.increments)
|
|
245
|
+
regression = [p for p in reports.numbered(mod.folder / "test", "test_report") if item_id in p.read_text()] if mod else []
|
|
246
|
+
r.add("4", "regression test report for the tested module", bool(regression) if tested and done else None)
|
|
247
|
+
r.add("5", "commit with the Backlog trailer", bool(commits) if done else None)
|
|
248
|
+
r.add("5", "issue mirrored", item.issue_sync != "pending", item.issue_sync)
|
|
249
|
+
return r
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def setup(cfg: Config) -> Result:
|
|
253
|
+
from . import decisions, guide, registry, security, style
|
|
254
|
+
|
|
255
|
+
r = Result("setup", cfg.project)
|
|
256
|
+
r.add("1", "project described and confirmed (architecture goals)",
|
|
257
|
+
cfg.architecture.exists() and md.PLACEHOLDER not in cfg.architecture.read_text().split("## 2.", 1)[0]) # fmt: skip
|
|
258
|
+
r.add("3", "setup answers recorded as decisions", any(d.scope == "project" for d in decisions.decisions(cfg)))
|
|
259
|
+
opened = decisions.open_for(cfg, "project")
|
|
260
|
+
r.add("6/7", "no open project assumptions", not opened, ", ".join(a.id for a in opened))
|
|
261
|
+
r.add("5", "guides exist", all(p.exists() for p in guide.guide_paths(cfg)))
|
|
262
|
+
r.add("5", "components specified (at least one spec)", bool(registry.load(cfg)))
|
|
263
|
+
r.add("6", "coding style agreed", style.state(cfg)["agreed"] if style.required(cfg) else None)
|
|
264
|
+
missing = [k for k in security.required(cfg) if k != "secrets" and not cfg.get(f"security.{k}") and not security.waiver(cfg, k)]
|
|
265
|
+
r.add("4", "required security checks configured or waived (security tools)", not missing, ", ".join(missing))
|
|
266
|
+
hooks = cfg.get("hooks.pre_commit") or cfg.get("hooks.pre_push")
|
|
267
|
+
r.add("4", "git hooks installed", gitx.out(cfg.root, "config", "core.hooksPath") != "" if hooks else None)
|
|
268
|
+
return r
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def oneoff_wf(cfg: Config, where: str) -> Result:
|
|
272
|
+
from . import oneoff
|
|
273
|
+
|
|
274
|
+
path = oneoff.find(oneoff.base_dir(cfg.root, cfg.data), where)
|
|
275
|
+
st = oneoff.load(path)
|
|
276
|
+
r = Result("oneoff", st["name"])
|
|
277
|
+
r.add("2", "assumptions recorded instead of asking", bool(st["assumptions"]))
|
|
278
|
+
r.add("3", "plan: approach, tasks, test and run commands", bool(st["approach"] and st["tasks"] and st["test_commands"]
|
|
279
|
+
and st["run"])) # fmt: skip
|
|
280
|
+
r.add("4", "every task done", all(t["status"] == "done" for t in st["tasks"]))
|
|
281
|
+
r.add("5", "tests run and passed", bool(st["tests"]) and all(t["rc"] == 0 for t in st["tests"]))
|
|
282
|
+
r.add("6", "REPORT.md written after the last change", (path / "REPORT.md").exists() and
|
|
283
|
+
(path / "REPORT.md").stat().st_mtime >= (path / oneoff.STATE).stat().st_mtime - 5) # fmt: skip
|
|
284
|
+
r.add("4", "limitations and future scope noted", bool(st["limitations"] or st["future"]))
|
|
285
|
+
return r
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def adopt_wf(cfg: Config) -> Result:
|
|
289
|
+
from . import adopt
|
|
290
|
+
|
|
291
|
+
r = Result("adopt", cfg.project)
|
|
292
|
+
w = adopt.work(cfg.root)
|
|
293
|
+
plan = json.loads((w / "plan.json").read_text()) if (w / "plan.json").exists() else {}
|
|
294
|
+
r.add("2", "repository scanned", (w / "analysis.json").exists() or bool(plan.get("applied")))
|
|
295
|
+
r.add("3", "plan agreed with the user", bool(plan.get("finalised")))
|
|
296
|
+
snap = plan.get("snapshot") or {}
|
|
297
|
+
r.add("4", "old code kept on a pushed branch", bool(snap.get("branch")) and bool(snap.get("pushed")),
|
|
298
|
+
snap.get("branch", "")) # fmt: skip
|
|
299
|
+
r.add("6", "applied: architecture, specs, baseline", bool(plan.get("applied")) and
|
|
300
|
+
(cfg.architecture_dir / "baseline.md").exists()) # fmt: skip
|
|
301
|
+
return r
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def triage_wf(cfg: Config) -> Result:
|
|
305
|
+
from . import triage
|
|
306
|
+
|
|
307
|
+
r = Result("triage", cfg.project)
|
|
308
|
+
p = triage.data_path(cfg)
|
|
309
|
+
rows = json.loads(p.read_text())["rows"] if p.exists() else []
|
|
310
|
+
r.add("1", "issues fetched and checked against the specs", p.exists())
|
|
311
|
+
vague = [f"#{x['number']}" for x in rows if x["status"] == "vague"]
|
|
312
|
+
r.add("3/4", "every vague issue decided by the user", not vague, ", ".join(vague))
|
|
313
|
+
return r
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def discuss_wf(cfg: Config, number: int) -> Result:
|
|
317
|
+
from . import discuss
|
|
318
|
+
|
|
319
|
+
st = discuss.load(cfg.root, number)
|
|
320
|
+
r = Result("discuss", f"issue #{number}")
|
|
321
|
+
r.add("2", "questions posted", any(p["type"] == "questions" for p in st["posts"]))
|
|
322
|
+
left = [q["id"] for q in st["questions"] if not q["answer"]]
|
|
323
|
+
r.add("3", "every question answered (verbatim)", not left, ", ".join(left))
|
|
324
|
+
r.add("4", "summary confirmed by the user", "summary" in st["approvals"])
|
|
325
|
+
r.add("5", "plan reviewed and approved by the user", "plan" in st["approvals"])
|
|
326
|
+
r.add("6", "applied to the specifications", st["phase"] == "applied", f"phase {st['phase']}")
|
|
327
|
+
return r
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def deploy_wf(cfg: Config) -> Result:
|
|
331
|
+
from . import deploy
|
|
332
|
+
|
|
333
|
+
r = Result("deploy", cfg.project)
|
|
334
|
+
st = deploy.state(cfg)
|
|
335
|
+
r.add("1-3", "deploy plan agreed and applied", st.get("agreed", False))
|
|
336
|
+
probs = deploy.problems(cfg) if st.get("agreed") else []
|
|
337
|
+
r.add("4", "generated files in step with the plan, every module placed", not probs if st.get("agreed") else None,
|
|
338
|
+
"; ".join(probs[:2])) # fmt: skip
|
|
339
|
+
warn = deploy.credential_warnings(cfg)
|
|
340
|
+
r.add("6", "no deploy credentials on the agents' machine", not warn, "; ".join(warn))
|
|
341
|
+
return r
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
# ---------------------------------------------------------------- entry points
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def run(cfg: Config, workflow: str, **kw: Any) -> Result:
|
|
348
|
+
if workflow in ("implement", "test", "review"):
|
|
349
|
+
if not kw.get("module") or not kw.get("increment"):
|
|
350
|
+
raise ValueError("--module and --increment")
|
|
351
|
+
fn = {"implement": implement, "test": test, "review": review}[workflow]
|
|
352
|
+
return fn(cfg, kw["module"], int(kw["increment"]), prepush=kw.get("prepush", False))
|
|
353
|
+
if workflow == "security":
|
|
354
|
+
return security_wf(cfg, kw["module"])
|
|
355
|
+
if workflow == "change-request":
|
|
356
|
+
return change_request(cfg, kw["cr"])
|
|
357
|
+
if workflow == "fix":
|
|
358
|
+
return fix(cfg, kw["backlog"])
|
|
359
|
+
if workflow == "setup":
|
|
360
|
+
return setup(cfg)
|
|
361
|
+
if workflow == "oneoff":
|
|
362
|
+
return oneoff_wf(cfg, kw.get("dir", ""))
|
|
363
|
+
if workflow == "adopt":
|
|
364
|
+
return adopt_wf(cfg)
|
|
365
|
+
if workflow == "triage":
|
|
366
|
+
return triage_wf(cfg)
|
|
367
|
+
if workflow == "discuss":
|
|
368
|
+
return discuss_wf(cfg, int(kw["issue"]))
|
|
369
|
+
if workflow == "deploy":
|
|
370
|
+
return deploy_wf(cfg)
|
|
371
|
+
raise ValueError(f"unknown workflow {workflow!r}; known: {', '.join(WORKFLOWS)}")
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def stage_checks(cfg: Config, module: str, k: int, *, prepush: bool = False) -> list[Result]:
|
|
375
|
+
"""The checks that apply to an increment's current status: coded → implement, tested → +test, reviewed → +review."""
|
|
376
|
+
from . import registry
|
|
377
|
+
|
|
378
|
+
inc = registry.load(cfg)[module].inc(k)
|
|
379
|
+
out = []
|
|
380
|
+
if inc.status in ("coded", "tested", "reviewed"):
|
|
381
|
+
out.append(implement(cfg, module, k, prepush=prepush))
|
|
382
|
+
if inc.status in ("tested", "reviewed"):
|
|
383
|
+
out.append(test(cfg, module, k, prepush=prepush))
|
|
384
|
+
if inc.status == "reviewed":
|
|
385
|
+
out.append(review(cfg, module, k, prepush=prepush))
|
|
386
|
+
return out
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def report(cfg: Config) -> dict[str, Any]:
|
|
390
|
+
from . import backlog, changes, registry
|
|
391
|
+
|
|
392
|
+
results: list[Result] = []
|
|
393
|
+
for m in registry.load(cfg).values():
|
|
394
|
+
for inc in m.increments:
|
|
395
|
+
results += stage_checks(cfg, m.name, inc.number)
|
|
396
|
+
for item in backlog.items(cfg):
|
|
397
|
+
if item.status in backlog.NEEDS_FIX_ID:
|
|
398
|
+
results.append(fix(cfg, item.id))
|
|
399
|
+
for p in changes.all_crs(cfg):
|
|
400
|
+
cr = "-".join(p.name.split("-")[:2])
|
|
401
|
+
if not re.search(r"^\*\*Status:\*\* `(proposed|rejected)`", p.read_text(), re.M):
|
|
402
|
+
results.append(change_request(cfg, cr))
|
|
403
|
+
by: dict[str, dict[str, int]] = {}
|
|
404
|
+
for r in results:
|
|
405
|
+
b = by.setdefault(r.workflow, {"runs": 0, "passed": 0, "steps": 0, "missing": 0})
|
|
406
|
+
b["runs"] += 1
|
|
407
|
+
b["passed"] += r.passed
|
|
408
|
+
b["steps"] += len([s for s in r.steps if s.status != "n/a"])
|
|
409
|
+
b["missing"] += len(r.missing)
|
|
410
|
+
skipped: dict[str, int] = {}
|
|
411
|
+
for r in results:
|
|
412
|
+
for s in r.missing:
|
|
413
|
+
key = f"{r.workflow} step {s.n}: {s.name}"
|
|
414
|
+
skipped[key] = skipped.get(key, 0) + 1
|
|
415
|
+
return {"results": results, "by_workflow": by, "most_skipped": sorted(skipped.items(), key=lambda kv: -kv[1])[:10]}
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def report_text(data: dict[str, Any]) -> str:
|
|
419
|
+
lines = ["| Workflow | Runs | Followed fully | Steps done |", "|---|---|---|---|"]
|
|
420
|
+
for wf, b in sorted(data["by_workflow"].items()):
|
|
421
|
+
pct = 100 * (b["steps"] - b["missing"]) / b["steps"] if b["steps"] else 100
|
|
422
|
+
lines.append(f"| {wf} | {b['runs']} | {b['passed']} | {pct:.0f}% |")
|
|
423
|
+
if data["most_skipped"]:
|
|
424
|
+
lines += ["", "Most skipped steps:", *[f"- {k} ({n}×)" for k, n in data["most_skipped"]]]
|
|
425
|
+
failing = [r for r in data["results"] if not r.passed]
|
|
426
|
+
if failing:
|
|
427
|
+
lines += ["", "Not followed fully:", *[f"- {r.workflow} {r.subject}: " + "; ".join(f"step {s.n} {s.name}" for s in r.missing)
|
|
428
|
+
for r in failing]] # fmt: skip
|
|
429
|
+
return "\n".join(lines)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def prepush(cfg: Config, branches: list[str]) -> list[str]:
|
|
433
|
+
"""Pre-push: for each pushed unit branch, the checks of its increment's status must pass."""
|
|
434
|
+
from . import registry
|
|
435
|
+
|
|
436
|
+
failures = []
|
|
437
|
+
mods = registry.load(cfg)
|
|
438
|
+
rx = re.compile(rf"^{re.escape(cfg.prefix.lower())}-\d{{4}}-(?P<module>[\w-]+?)-inc(?P<k>\d+)$")
|
|
439
|
+
for b in branches:
|
|
440
|
+
m = rx.match(b)
|
|
441
|
+
if not m or m.group("module") not in mods or mods[m.group("module")].inc(int(m.group("k"))) is None:
|
|
442
|
+
continue
|
|
443
|
+
for r in stage_checks(cfg, m.group("module"), int(m.group("k")), prepush=True):
|
|
444
|
+
if not r.passed:
|
|
445
|
+
failures.append("workflow not followed: " + r.text())
|
|
446
|
+
return failures
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def workflow_file(cfg: Config, name: str) -> Path:
|
|
450
|
+
from .config import RES
|
|
451
|
+
|
|
452
|
+
path = RES / "workflows" / f"{name}.md"
|
|
453
|
+
if not path.exists():
|
|
454
|
+
raise ValueError(f"no workflow {name!r}")
|
|
455
|
+
return path
|