docassert 0.12.1__tar.gz → 0.14.0__tar.gz
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.
- {docassert-0.12.1/docassert.egg-info → docassert-0.14.0}/PKG-INFO +1 -1
- {docassert-0.12.1 → docassert-0.14.0}/docassert/__init__.py +1 -1
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/project.schema.json +5 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/bridge/plan.py +33 -1
- {docassert-0.12.1 → docassert-0.14.0}/docassert/cli.py +53 -15
- {docassert-0.12.1 → docassert-0.14.0}/docassert/status/derive.py +15 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/status/render.py +23 -5
- {docassert-0.12.1 → docassert-0.14.0/docassert.egg-info}/PKG-INFO +1 -1
- {docassert-0.12.1 → docassert-0.14.0}/tests/test_bridge.py +92 -0
- {docassert-0.12.1 → docassert-0.14.0}/tests/test_status.py +33 -0
- {docassert-0.12.1 → docassert-0.14.0}/LICENSE +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/NOTICE +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/README.md +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/__main__.py +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/consistency.yaml +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/adr.criteria.yaml +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/benefits-realization.criteria.yaml +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/brd.criteria.yaml +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/business-case.criteria.yaml +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/charter.criteria.yaml +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/data-migration-plan.criteria.yaml +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/frnfr.criteria.yaml +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/hypercare-plan.criteria.yaml +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/post-implementation-review.criteria.yaml +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/prd.criteria.yaml +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/project.criteria.yaml +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/qa-test-plan.criteria.yaml +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/raci-stakeholder.criteria.yaml +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/release-cutover-plan.criteria.yaml +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/risk-register.criteria.yaml +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/rollback-plan.criteria.yaml +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/runbook.criteria.yaml +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/status-report.criteria.yaml +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/test-cases.criteria.yaml +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/user-story.criteria.yaml +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/profiles/agile-delivery.yaml +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/profiles/lean-startup.yaml +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/profiles/regulated-industry.yaml +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/adr.schema.json +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/benefits-realization.schema.json +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/brd.schema.json +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/business-case.schema.json +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/charter.schema.json +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/data-migration-plan.schema.json +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/frnfr.schema.json +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/hypercare-plan.schema.json +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/post-implementation-review.schema.json +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/prd.schema.json +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/qa-test-plan.schema.json +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/raci-stakeholder.schema.json +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/release-cutover-plan.schema.json +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/risk-register.schema.json +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/rollback-plan.schema.json +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/runbook.schema.json +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/status-report.schema.json +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/test-cases.schema.json +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/user-story.schema.json +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/skills/doc-to-pmo/SKILL.md +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/adr.template.md +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/benefits-realization.template.md +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/brd.template.md +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/business-case.template.md +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/charter.template.md +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/data-migration-plan.template.md +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/frnfr.template.md +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/hypercare-plan.template.md +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/post-implementation-review.template.md +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/prd.template.md +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/project.template.md +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/qa-test-plan.template.md +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/raci-stakeholder.template.md +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/release-cutover-plan.template.md +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/risk-register.template.md +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/rollback-plan.template.md +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/runbook.template.md +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/status-report.template.md +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/test-cases.template.md +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/user-story.template.md +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/bridge/__init__.py +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/bridge/board.py +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/bridge/gh.py +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/bridge/ops.py +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/config.py +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/consistency.py +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/extract.py +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/graph.py +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/loader.py +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/models.py +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/profiles.py +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/projects.py +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/report.py +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/rtm.py +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/scaffold.py +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/semantic.py +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/status/__init__.py +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert/structural.py +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert.egg-info/SOURCES.txt +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert.egg-info/dependency_links.txt +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert.egg-info/entry_points.txt +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert.egg-info/requires.txt +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/docassert.egg-info/top_level.txt +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/pyproject.toml +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/setup.cfg +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/tests/test_badge.py +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/tests/test_config.py +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/tests/test_consistency.py +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/tests/test_defects.py +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/tests/test_draft_relaxation.py +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/tests/test_extract.py +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/tests/test_graph.py +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/tests/test_json_report.py +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/tests/test_kinds_delivery.py +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/tests/test_kinds_governance.py +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/tests/test_kinds_operate.py +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/tests/test_kinds_reporting.py +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/tests/test_profiles.py +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/tests/test_projects.py +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/tests/test_scaffold.py +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/tests/test_semantic.py +0 -0
- {docassert-0.12.1 → docassert-0.14.0}/tests/test_structural.py +0 -0
|
@@ -7,6 +7,11 @@
|
|
|
7
7
|
"additionalProperties": true,
|
|
8
8
|
"properties": {
|
|
9
9
|
"kind": { "const": "project" },
|
|
10
|
+
"repo": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$",
|
|
13
|
+
"description": "Optional OWNER/NAME of the code repository this project's execution bridge targets."
|
|
14
|
+
},
|
|
10
15
|
"id": {
|
|
11
16
|
"type": "string",
|
|
12
17
|
"pattern": "^PRJ-\\d{3,}-[A-Z]{2,6}$",
|
|
@@ -83,7 +83,8 @@ def _anchors(documents_dir: Path) -> dict[str, dict]:
|
|
|
83
83
|
continue
|
|
84
84
|
if doc.kind == "project" and doc.id:
|
|
85
85
|
out[doc.id] = {"code": str(doc.frontmatter.get("code", "")),
|
|
86
|
-
"name": str(doc.frontmatter.get("name", ""))
|
|
86
|
+
"name": str(doc.frontmatter.get("name", "")),
|
|
87
|
+
"repo": str(doc.frontmatter.get("repo", "") or "")}
|
|
87
88
|
return out
|
|
88
89
|
|
|
89
90
|
|
|
@@ -155,10 +156,41 @@ def build_bridge_plan(documents_dir: str | Path = "documents") -> BridgePlan:
|
|
|
155
156
|
"reason": "; ".join(problems)})
|
|
156
157
|
continue
|
|
157
158
|
plan.projects.append({"id": proj_id, "code": code, "name": meta["name"],
|
|
159
|
+
"repo": meta.get("repo", ""),
|
|
158
160
|
"features": list(features.values())})
|
|
159
161
|
return plan
|
|
160
162
|
|
|
161
163
|
|
|
164
|
+
def filter_plan(plan: BridgePlan, project: str) -> BridgePlan:
|
|
165
|
+
"""A plan reduced to one project (matched by id or code). Unknown ids
|
|
166
|
+
yield an empty plan whose skipped list explains why."""
|
|
167
|
+
key = project.strip()
|
|
168
|
+
keep = [p for p in plan.projects
|
|
169
|
+
if p["id"] == key or p["code"] == key]
|
|
170
|
+
out = BridgePlan(projects=keep,
|
|
171
|
+
skipped=[s for s in plan.skipped if s["id"] == key])
|
|
172
|
+
if not keep and not out.skipped:
|
|
173
|
+
out.skipped.append({"id": key, "reason": "no such project in the plan"})
|
|
174
|
+
return out
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def plans_by_repo(plan: BridgePlan) -> dict[str, BridgePlan]:
|
|
178
|
+
"""Group a plan by each project's mapped repository (the `repo` field on
|
|
179
|
+
the project anchor). Projects sharing a repo share one sub-plan, so
|
|
180
|
+
reconciliation in that repo sees the union of their managed ids.
|
|
181
|
+
Raises ValueError naming any project without a mapping."""
|
|
182
|
+
unmapped = [p["id"] for p in plan.projects if not p.get("repo")]
|
|
183
|
+
if unmapped:
|
|
184
|
+
raise ValueError(
|
|
185
|
+
"no repo mapping on project anchor(s): " + ", ".join(unmapped)
|
|
186
|
+
+ " (add `repo: OWNER/NAME` to project.md or pass --repo)")
|
|
187
|
+
groups: dict[str, BridgePlan] = {}
|
|
188
|
+
for proj in plan.projects:
|
|
189
|
+
groups.setdefault(proj["repo"],
|
|
190
|
+
BridgePlan(skipped=plan.skipped)).projects.append(proj)
|
|
191
|
+
return groups
|
|
192
|
+
|
|
193
|
+
|
|
162
194
|
def feature_title(f: Feature) -> str:
|
|
163
195
|
return f"[{f.id}] {_title(f.text)}"
|
|
164
196
|
|
|
@@ -238,7 +238,7 @@ def cmd_pages(args: argparse.Namespace) -> int:
|
|
|
238
238
|
data = _json.loads(Path(args.execution).read_text())
|
|
239
239
|
for proj in data.get("projects", []):
|
|
240
240
|
execution[proj["id"]] = {**proj, "scope": data.get("scope"),
|
|
241
|
-
"repo": data.get("repo")}
|
|
241
|
+
"repo": proj.get("repo") or data.get("repo")}
|
|
242
242
|
|
|
243
243
|
plist = projects_mod.load_projects(docs_dir)
|
|
244
244
|
for p in plist:
|
|
@@ -306,10 +306,30 @@ def cmd_bridge(args: argparse.Namespace) -> int:
|
|
|
306
306
|
Scope flows documents -> GitHub only; the bridge never edits documents."""
|
|
307
307
|
from .bridge import build_bridge_plan, ops
|
|
308
308
|
from .bridge import gh as ghmod
|
|
309
|
+
from .bridge.plan import filter_plan, plans_by_repo
|
|
309
310
|
|
|
310
311
|
plan = build_bridge_plan(args.documents_dir)
|
|
312
|
+
if getattr(args, "project", None):
|
|
313
|
+
plan = filter_plan(plan, args.project)
|
|
311
314
|
gh = ghmod.DryRunner() if getattr(args, "dry_run", False) else ghmod.GhRunner()
|
|
312
315
|
|
|
316
|
+
# Repo resolution: an explicit --repo applies the whole plan to one
|
|
317
|
+
# repository (single-repo portfolios, back-compatible); without it, each
|
|
318
|
+
# project routes to the repo mapped on its anchor.
|
|
319
|
+
if args.action != "create-board":
|
|
320
|
+
if args.repo:
|
|
321
|
+
repo_plans = {args.repo: plan}
|
|
322
|
+
else:
|
|
323
|
+
try:
|
|
324
|
+
repo_plans = plans_by_repo(plan)
|
|
325
|
+
except ValueError as exc:
|
|
326
|
+
print(f"docassert bridge: {exc}", file=sys.stderr)
|
|
327
|
+
return 2
|
|
328
|
+
if not repo_plans and plan.skipped:
|
|
329
|
+
for s in plan.skipped:
|
|
330
|
+
print(f"docassert bridge: skipped {s['id']}: {s['reason']}")
|
|
331
|
+
return 0
|
|
332
|
+
|
|
313
333
|
if args.action == "create-board":
|
|
314
334
|
from .bridge import board as board_mod
|
|
315
335
|
token = os.environ.get(args.board_token_env, "")
|
|
@@ -329,30 +349,46 @@ def cmd_bridge(args: argparse.Namespace) -> int:
|
|
|
329
349
|
"(needed for --project-number)", file=sys.stderr)
|
|
330
350
|
return 2
|
|
331
351
|
bgh = gh if isinstance(gh, ghmod.DryRunner) else ghmod.GhRunner(token=token)
|
|
332
|
-
|
|
352
|
+
owner = args.project_owner or next(iter(repo_plans)).split("/")[0]
|
|
353
|
+
board_cfg = {"gh": bgh, "owner": owner,
|
|
333
354
|
"number": args.project_number, "init": args.init_board}
|
|
334
355
|
|
|
335
356
|
if args.action == "scaffold":
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
357
|
+
for repo, sub in repo_plans.items():
|
|
358
|
+
actions = ops.scaffold(sub, gh, repo, docs_url=args.docs_url,
|
|
359
|
+
board_cfg=board_cfg)
|
|
360
|
+
for a in actions:
|
|
361
|
+
print(f"docassert bridge: [{repo}] {a}")
|
|
340
362
|
if isinstance(gh, ghmod.DryRunner):
|
|
341
363
|
print(f"docassert bridge: dry run — {len(gh.planned)} mutation(s) planned")
|
|
342
364
|
for m in gh.planned:
|
|
343
365
|
print(f" {m}")
|
|
344
366
|
return 0
|
|
345
367
|
if args.action == "reconcile":
|
|
346
|
-
|
|
347
|
-
for
|
|
348
|
-
|
|
349
|
-
|
|
368
|
+
worst = 0
|
|
369
|
+
for repo, sub in repo_plans.items():
|
|
370
|
+
lines, code = ops.reconcile(sub, gh, repo)
|
|
371
|
+
for line in lines:
|
|
372
|
+
print(f"docassert bridge: [{repo}] {line}")
|
|
373
|
+
worst = max(worst, code)
|
|
374
|
+
return worst
|
|
350
375
|
if args.action == "status":
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
376
|
+
merged: dict = {"repo": None, "projects": [], "scope": {"unverified": [], "orphaned": []}}
|
|
377
|
+
for repo, sub in repo_plans.items():
|
|
378
|
+
data = ops.status(sub, gh, repo)
|
|
379
|
+
for proj in data.get("projects", []):
|
|
380
|
+
proj["repo"] = repo
|
|
381
|
+
merged["projects"].append(proj)
|
|
382
|
+
for key in ("unverified", "orphaned"):
|
|
383
|
+
for item in data.get("scope", {}).get(key, []):
|
|
384
|
+
item["repo"] = repo
|
|
385
|
+
merged["scope"][key].append(item)
|
|
386
|
+
if len(repo_plans) == 1:
|
|
387
|
+
merged["repo"] = next(iter(repo_plans))
|
|
388
|
+
sys.stdout.write(ops.to_json(merged) if args.json else
|
|
389
|
+
ops.render_status(merged) + "\n")
|
|
354
390
|
if args.json and args.out:
|
|
355
|
-
Path(args.out).write_text(ops.to_json(
|
|
391
|
+
Path(args.out).write_text(ops.to_json(merged))
|
|
356
392
|
return 0
|
|
357
393
|
print(f"docassert bridge: unknown action {args.action!r}", file=sys.stderr)
|
|
358
394
|
return 2
|
|
@@ -441,7 +477,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
441
477
|
b = sub.add_parser("bridge", help="Execution bridge: scaffold and police the GitHub board from approved stories.")
|
|
442
478
|
b.add_argument("action", choices=["scaffold", "reconcile", "status", "create-board"],
|
|
443
479
|
help="scaffold: docs -> issues/board · reconcile: police the board · status: delivery figures.")
|
|
444
|
-
b.add_argument("--repo",
|
|
480
|
+
b.add_argument("--repo", help="Target GitHub repo (OWNER/NAME) for the whole plan; "
|
|
481
|
+
"omit to route each project to the `repo:` on its anchor.")
|
|
482
|
+
b.add_argument("--project", help="Scope to one project (PRJ-NNN-CODE id or CODE).")
|
|
445
483
|
b.add_argument("--docs-url", help="Base URL of the documents repo, for source links in issue bodies.")
|
|
446
484
|
b.add_argument("--dry-run", action="store_true", help="Print planned mutations without executing them.")
|
|
447
485
|
b.add_argument("--json", action="store_true", help="status: emit JSON.")
|
|
@@ -5,6 +5,7 @@ lives in docassert.status.render.
|
|
|
5
5
|
"""
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
|
+
import re
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
|
|
10
11
|
from .. import config as config_mod
|
|
@@ -63,6 +64,10 @@ def _coverage(graph, config, code=None):
|
|
|
63
64
|
return out
|
|
64
65
|
|
|
65
66
|
|
|
67
|
+
_RISK_FIELD_RE = re.compile(
|
|
68
|
+
r"\s*(?:Probability|Impact|Owner|Response)\s*:", re.IGNORECASE)
|
|
69
|
+
|
|
70
|
+
|
|
66
71
|
def _risks(graph, code=None):
|
|
67
72
|
risks = []
|
|
68
73
|
for item in graph.by_type.get("RISK", []):
|
|
@@ -70,11 +75,21 @@ def _risks(graph, code=None):
|
|
|
70
75
|
continue
|
|
71
76
|
prob = (_field_value(item.text, "probability") or "").lower()
|
|
72
77
|
impact = (_field_value(item.text, "impact") or "").lower()
|
|
78
|
+
# The description is the item text up to the first metadata field;
|
|
79
|
+
# the response runs from its label to the end (it may hold clauses
|
|
80
|
+
# that _field_value would truncate at a sentence break).
|
|
81
|
+
m = _RISK_FIELD_RE.search(item.text)
|
|
82
|
+
description = (item.text[:m.start()] if m else item.text).strip()
|
|
83
|
+
rm = re.search(r"Response\s*:\s*(.+)$", item.text,
|
|
84
|
+
re.IGNORECASE | re.DOTALL)
|
|
73
85
|
risks.append({
|
|
74
86
|
"id": item.id,
|
|
87
|
+
"description": description,
|
|
88
|
+
"threatens": item.links.get("threatens", []),
|
|
75
89
|
"probability": prob or "?",
|
|
76
90
|
"impact": impact or "?",
|
|
77
91
|
"owner": _field_value(item.text, "owner") or "?",
|
|
92
|
+
"response": rm.group(1).strip() if rm else "",
|
|
78
93
|
"score": _SEVERITY.get(prob, 0) * _SEVERITY.get(impact, 0),
|
|
79
94
|
})
|
|
80
95
|
return sorted(risks, key=lambda r: -r["score"])
|
|
@@ -143,9 +143,27 @@ def render_html(model) -> str:
|
|
|
143
143
|
+ (";background:var(--amber)" if cov["gaps"] else "") + '"></div></div></div>'
|
|
144
144
|
for cov in model["coverage"])
|
|
145
145
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
146
|
+
def sev(value):
|
|
147
|
+
color = {"critical": "var(--bad)", "high": "var(--bad)",
|
|
148
|
+
"medium": "var(--amber)", "low": "var(--ok)"}.get(
|
|
149
|
+
value, "var(--muted)")
|
|
150
|
+
return f'<td style="color:{color};font-weight:600;">{esc(value)}</td>'
|
|
151
|
+
|
|
152
|
+
if model["risks"]:
|
|
153
|
+
risk_rows = "".join(
|
|
154
|
+
f'<tr><td><code>{esc(r["id"])}</code></td>'
|
|
155
|
+
f'<td>{esc(r["description"])}</td>'
|
|
156
|
+
f'<td>{", ".join(f"<code>{esc(b)}</code>" for b in r["threatens"]) or "—"}</td>'
|
|
157
|
+
+ sev(r["probability"]) + sev(r["impact"])
|
|
158
|
+
+ f'<td>{esc(r["owner"])}</td>'
|
|
159
|
+
f'<td>{esc(r["response"]) or "—"}</td></tr>'
|
|
160
|
+
for r in model["risks"])
|
|
161
|
+
risks = ('<table><thead><tr><th>ID</th><th>Risk</th><th>Threatens</th>'
|
|
162
|
+
'<th>Probability</th><th>Impact</th><th>Owner</th>'
|
|
163
|
+
'<th>Response</th></tr></thead>'
|
|
164
|
+
f'<tbody>{risk_rows}</tbody></table>')
|
|
165
|
+
else:
|
|
166
|
+
risks = "<p>None open.</p>"
|
|
149
167
|
|
|
150
168
|
problems = ""
|
|
151
169
|
if model["broken_references"]:
|
|
@@ -230,7 +248,7 @@ def render_html(model) -> str:
|
|
|
230
248
|
bad = scope.get("unverified", []) + scope.get("orphaned", [])
|
|
231
249
|
if bad:
|
|
232
250
|
items = "".join(
|
|
233
|
-
f'<li><a href="https://github.com/{esc(ex.get("repo"
|
|
251
|
+
f'<li><a href="https://github.com/{esc(i.get("repo") or ex.get("repo") or "")}/issues/{i["number"]}">#{i["number"]}</a> {esc(i["title"])}</li>'
|
|
234
252
|
for i in bad)
|
|
235
253
|
execution_html += (
|
|
236
254
|
f'<section><h2 style="color:var(--bad)">Scope · {len(bad)} unmatched item(s)</h2>'
|
|
@@ -262,7 +280,7 @@ def render_html(model) -> str:
|
|
|
262
280
|
{document_set}
|
|
263
281
|
{execution_html}
|
|
264
282
|
{problems}
|
|
265
|
-
<section><h2>Open risks ({len(model['risks'])})</h2
|
|
283
|
+
<section><h2>Open risks ({len(model['risks'])})</h2>{risks}</section>
|
|
266
284
|
<section><h2>Latest status report</h2><p>{report}</p></section>
|
|
267
285
|
<section><h2>Documents ({c['total']})</h2>
|
|
268
286
|
<table><thead><tr><th>Kind</th><th>ID</th><th>Status</th><th>Audit</th></tr></thead>
|
|
@@ -349,3 +349,95 @@ def test_status_includes_scope_classification(tmp_path):
|
|
|
349
349
|
assert data["repo"] == "o/r"
|
|
350
350
|
assert [i["number"] for i in data["scope"]["unverified"]] == [2]
|
|
351
351
|
assert [i["doc"] for i in data["scope"]["orphaned"]] == ["TST-US-999"]
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# ── project filter and per-repo mapping (ENG-US-001 / ENG-US-002) ────────────
|
|
355
|
+
from docassert.bridge.plan import filter_plan, plans_by_repo # noqa: E402
|
|
356
|
+
|
|
357
|
+
ANCHOR2 = """---
|
|
358
|
+
kind: project
|
|
359
|
+
id: PRJ-002-TWO
|
|
360
|
+
code: TWO
|
|
361
|
+
name: Second Project
|
|
362
|
+
sponsor: S
|
|
363
|
+
status: active
|
|
364
|
+
repo: owner/two-repo
|
|
365
|
+
---
|
|
366
|
+
|
|
367
|
+
## Overview
|
|
368
|
+
|
|
369
|
+
x
|
|
370
|
+
|
|
371
|
+
## Scope
|
|
372
|
+
|
|
373
|
+
x
|
|
374
|
+
"""
|
|
375
|
+
|
|
376
|
+
PRD2 = PRD.replace("PRJ-001-TST", "PRJ-002-TWO").replace("TST", "TWO")
|
|
377
|
+
STORIES2 = STORIES.replace("PRJ-001-TST", "PRJ-002-TWO").replace("TST", "TWO")
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _two_project_tree(tmp_path, repo_one="owner/one-repo"):
|
|
381
|
+
docs = _tree(tmp_path)
|
|
382
|
+
if repo_one:
|
|
383
|
+
anchor = docs / "PRJ-001-TST" / "project.md"
|
|
384
|
+
anchor.write_text(anchor.read_text().replace(
|
|
385
|
+
"status: active", f"status: active\nrepo: {repo_one}"), encoding="utf-8")
|
|
386
|
+
d = docs / "PRJ-002-TWO"
|
|
387
|
+
d.mkdir()
|
|
388
|
+
(d / "project.md").write_text(ANCHOR2, encoding="utf-8")
|
|
389
|
+
(d / "prd.md").write_text(PRD2, encoding="utf-8")
|
|
390
|
+
(d / "user-story.md").write_text(STORIES2.format(status="approved"), encoding="utf-8")
|
|
391
|
+
return docs
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def test_filter_plan_by_id_and_code(tmp_path):
|
|
395
|
+
plan = build_bridge_plan(_two_project_tree(tmp_path))
|
|
396
|
+
assert len(plan.projects) == 2
|
|
397
|
+
only = filter_plan(plan, "PRJ-002-TWO")
|
|
398
|
+
assert [p["id"] for p in only.projects] == ["PRJ-002-TWO"]
|
|
399
|
+
by_code = filter_plan(plan, "TST")
|
|
400
|
+
assert [p["code"] for p in by_code.projects] == ["TST"]
|
|
401
|
+
missing = filter_plan(plan, "PRJ-999-NOPE")
|
|
402
|
+
assert not missing.projects and missing.skipped
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def test_plans_by_repo_routes_each_project(tmp_path):
|
|
406
|
+
plan = build_bridge_plan(_two_project_tree(tmp_path))
|
|
407
|
+
groups = plans_by_repo(plan)
|
|
408
|
+
assert set(groups) == {"owner/one-repo", "owner/two-repo"}
|
|
409
|
+
assert [p["id"] for p in groups["owner/one-repo"].projects] == ["PRJ-001-TST"]
|
|
410
|
+
assert [p["id"] for p in groups["owner/two-repo"].projects] == ["PRJ-002-TWO"]
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def test_plans_by_repo_shares_a_repo_as_a_union(tmp_path):
|
|
414
|
+
docs = _two_project_tree(tmp_path)
|
|
415
|
+
anchor = docs / "PRJ-002-TWO" / "project.md"
|
|
416
|
+
anchor.write_text(anchor.read_text().replace("owner/two-repo", "owner/one-repo"),
|
|
417
|
+
encoding="utf-8")
|
|
418
|
+
plan = build_bridge_plan(docs)
|
|
419
|
+
groups = plans_by_repo(plan)
|
|
420
|
+
assert set(groups) == {"owner/one-repo"}
|
|
421
|
+
union = groups["owner/one-repo"]
|
|
422
|
+
assert len(union.projects) == 2
|
|
423
|
+
# both projects' ids are managed, so neither would be orphaned in reconcile
|
|
424
|
+
assert any(i.startswith("TST-") for i in union.managed_ids)
|
|
425
|
+
assert any(i.startswith("TWO-") for i in union.managed_ids)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def test_plans_by_repo_names_unmapped_projects(tmp_path):
|
|
429
|
+
plan = build_bridge_plan(_two_project_tree(tmp_path, repo_one=None))
|
|
430
|
+
try:
|
|
431
|
+
plans_by_repo(plan)
|
|
432
|
+
raise AssertionError("expected ValueError")
|
|
433
|
+
except ValueError as exc:
|
|
434
|
+
assert "PRJ-001-TST" in str(exc) and "--repo" in str(exc)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def test_anchor_without_repo_keeps_single_repo_mode_working(tmp_path):
|
|
438
|
+
# the original single-repo path: no repo fields, explicit --repo, unchanged
|
|
439
|
+
plan = build_bridge_plan(_tree(tmp_path))
|
|
440
|
+
assert plan.projects[0].get("repo", "") == ""
|
|
441
|
+
gh = FakeGh()
|
|
442
|
+
ops.scaffold(plan, gh, "o/r", docs_url=None)
|
|
443
|
+
assert any("issues" in " ".join(c) for c in gh.calls)
|
|
@@ -129,3 +129,36 @@ def test_render_project_page_has_back_link_and_scoped_title():
|
|
|
129
129
|
assert 'href="index.html"' in out # back to the portfolio index
|
|
130
130
|
assert "Atlas" in out and "AMBER" in out
|
|
131
131
|
assert "http://" not in out and "https://" not in out
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ── the risk model carries the full story ────────────────────────────────────
|
|
135
|
+
def test_risks_carry_description_threatens_and_response():
|
|
136
|
+
_cd_root()
|
|
137
|
+
m = S.build_status(ROOT / "documents")
|
|
138
|
+
r = next(r for r in m["risks"] if r["id"] == "AUR-RISK-001")
|
|
139
|
+
assert r["description"] and "Probability" not in r["description"]
|
|
140
|
+
assert r["threatens"] # links to the BRs at stake
|
|
141
|
+
assert r["response"] # the full mitigation text
|
|
142
|
+
assert r["probability"] in {"low", "medium", "high", "critical", "?"}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_risks_tolerate_missing_fields():
|
|
146
|
+
from docassert.graph import Graph
|
|
147
|
+
from docassert.models import Item
|
|
148
|
+
from docassert.status.derive import _risks
|
|
149
|
+
g = Graph()
|
|
150
|
+
g.add(Item("XX-RISK-001", "XX", "RISK", "Bare risk with no fields yet.",
|
|
151
|
+
{}, "d.md", "risk-register", "draft", "S"))
|
|
152
|
+
(r,) = _risks(g)
|
|
153
|
+
assert r["description"] == "Bare risk with no fields yet."
|
|
154
|
+
assert r["response"] == "" and r["probability"] == "?"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_render_html_risk_table():
|
|
158
|
+
_cd_root()
|
|
159
|
+
out = S.render_html(S.build_status(ROOT / "documents"))
|
|
160
|
+
assert "<th>Risk</th>" in out and "<th>Response</th>" in out
|
|
161
|
+
assert "<th>Threatens</th>" in out and "<th>Owner</th>" in out
|
|
162
|
+
# severity cells are colored
|
|
163
|
+
assert 'style="color:var(--bad);font-weight:600;">high</td>' in out \
|
|
164
|
+
or 'style="color:var(--amber);font-weight:600;">medium</td>' in out
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/benefits-realization.criteria.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/data-migration-plan.criteria.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/raci-stakeholder.criteria.yaml
RENAMED
|
File without changes
|
{docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/release-cutover-plan.criteria.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/benefits-realization.schema.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/data-migration-plan.schema.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/post-implementation-review.schema.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/release-cutover-plan.schema.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/benefits-realization.template.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/data-migration-plan.template.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/raci-stakeholder.template.md
RENAMED
|
File without changes
|
{docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/release-cutover-plan.template.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|