docassert 0.7.1__tar.gz → 0.8.1__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.7.1/docassert.egg-info → docassert-0.8.1}/PKG-INFO +3 -2
- {docassert-0.7.1 → docassert-0.8.1}/README.md +2 -1
- {docassert-0.7.1 → docassert-0.8.1}/docassert/__init__.py +1 -1
- docassert-0.8.1/docassert/bridge/__init__.py +9 -0
- docassert-0.8.1/docassert/bridge/gh.py +118 -0
- docassert-0.8.1/docassert/bridge/ops.py +179 -0
- docassert-0.8.1/docassert/bridge/plan.py +178 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/cli.py +48 -0
- {docassert-0.7.1 → docassert-0.8.1/docassert.egg-info}/PKG-INFO +3 -2
- {docassert-0.7.1 → docassert-0.8.1}/docassert.egg-info/SOURCES.txt +5 -0
- docassert-0.8.1/tests/test_bridge.py +249 -0
- {docassert-0.7.1 → docassert-0.8.1}/LICENSE +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/NOTICE +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/__main__.py +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/consistency.yaml +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/criteria/adr.criteria.yaml +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/criteria/benefits-realization.criteria.yaml +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/criteria/brd.criteria.yaml +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/criteria/business-case.criteria.yaml +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/criteria/charter.criteria.yaml +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/criteria/data-migration-plan.criteria.yaml +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/criteria/frnfr.criteria.yaml +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/criteria/hypercare-plan.criteria.yaml +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/criteria/post-implementation-review.criteria.yaml +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/criteria/prd.criteria.yaml +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/criteria/project.criteria.yaml +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/criteria/qa-test-plan.criteria.yaml +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/criteria/raci-stakeholder.criteria.yaml +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/criteria/release-cutover-plan.criteria.yaml +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/criteria/risk-register.criteria.yaml +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/criteria/rollback-plan.criteria.yaml +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/criteria/runbook.criteria.yaml +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/criteria/status-report.criteria.yaml +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/criteria/test-cases.criteria.yaml +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/criteria/user-story.criteria.yaml +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/profiles/agile-delivery.yaml +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/profiles/lean-startup.yaml +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/profiles/regulated-industry.yaml +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/schema/adr.schema.json +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/schema/benefits-realization.schema.json +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/schema/brd.schema.json +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/schema/business-case.schema.json +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/schema/charter.schema.json +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/schema/data-migration-plan.schema.json +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/schema/frnfr.schema.json +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/schema/hypercare-plan.schema.json +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/schema/post-implementation-review.schema.json +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/schema/prd.schema.json +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/schema/project.schema.json +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/schema/qa-test-plan.schema.json +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/schema/raci-stakeholder.schema.json +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/schema/release-cutover-plan.schema.json +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/schema/risk-register.schema.json +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/schema/rollback-plan.schema.json +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/schema/runbook.schema.json +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/schema/status-report.schema.json +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/schema/test-cases.schema.json +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/schema/user-story.schema.json +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/skills/doc-to-pmo/SKILL.md +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/templates/adr.template.md +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/templates/benefits-realization.template.md +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/templates/brd.template.md +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/templates/business-case.template.md +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/templates/charter.template.md +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/templates/data-migration-plan.template.md +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/templates/frnfr.template.md +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/templates/hypercare-plan.template.md +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/templates/post-implementation-review.template.md +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/templates/prd.template.md +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/templates/project.template.md +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/templates/qa-test-plan.template.md +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/templates/raci-stakeholder.template.md +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/templates/release-cutover-plan.template.md +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/templates/risk-register.template.md +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/templates/rollback-plan.template.md +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/templates/runbook.template.md +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/templates/status-report.template.md +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/templates/test-cases.template.md +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/templates/user-story.template.md +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/config.py +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/consistency.py +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/extract.py +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/graph.py +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/loader.py +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/models.py +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/profiles.py +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/projects.py +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/report.py +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/rtm.py +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/scaffold.py +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/semantic.py +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/status/__init__.py +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/status/derive.py +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/status/render.py +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert/structural.py +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert.egg-info/dependency_links.txt +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert.egg-info/entry_points.txt +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert.egg-info/requires.txt +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/docassert.egg-info/top_level.txt +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/pyproject.toml +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/setup.cfg +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/tests/test_badge.py +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/tests/test_config.py +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/tests/test_consistency.py +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/tests/test_defects.py +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/tests/test_draft_relaxation.py +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/tests/test_extract.py +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/tests/test_graph.py +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/tests/test_json_report.py +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/tests/test_kinds_delivery.py +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/tests/test_kinds_governance.py +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/tests/test_kinds_operate.py +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/tests/test_kinds_reporting.py +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/tests/test_profiles.py +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/tests/test_projects.py +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/tests/test_scaffold.py +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/tests/test_status.py +0 -0
- {docassert-0.7.1 → docassert-0.8.1}/tests/test_structural.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: docassert
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.1
|
|
4
4
|
Summary: Unit testing for business documents — validate structured Markdown docs against a configurable audit standard.
|
|
5
5
|
Author: C4G Enterprises Inc.
|
|
6
6
|
License: Apache-2.0
|
|
@@ -54,7 +54,7 @@ derived from the documents rather than self-reported.
|
|
|
54
54
|
|
|
55
55
|
docassert is the reference implementation of **[PMO as Code](https://c4g-john.github.io/pmo-as-code/)** —
|
|
56
56
|
a vendor-neutral standard for running a PMO from version-controlled, declarative
|
|
57
|
-
files. It implements the [PMO as Code specification](https://github.com/c4g-john/pmo-as-code-spec) **v0.
|
|
57
|
+
files. It implements the [PMO as Code specification](https://github.com/c4g-john/pmo-as-code-spec) **v0.3** and passes its [conformance suite](https://github.com/c4g-john/pmo-as-code-spec/tree/main/conformance) in CI.
|
|
58
58
|
|
|
59
59
|
## Install
|
|
60
60
|
|
|
@@ -98,6 +98,7 @@ flagged as TODOs, never invented). The skill's source is
|
|
|
98
98
|
| `docassert projects [--out] [--check]` | Generate / verify the project registry. |
|
|
99
99
|
| `docassert new <kind> --project ID` | Scaffold a document from its template with identity filled in (`new project --code XYZ` auto-numbers the id); suggests the next free item ids. |
|
|
100
100
|
| `docassert init [DIR]` | Scaffold the default config into a repo. |
|
|
101
|
+
| `docassert bridge <action> --repo O/N` | Execution bridge: `scaffold` Features/Stories from approved user stories, `reconcile` (police the board against the docs), `status` (delivery figures). Needs the GitHub CLI. |
|
|
101
102
|
| `docassert extract <file>` | Extract plain text from a source `.docx` / `.pdf` / `.md` / `.txt` (the first step of doc-to-pmo conversion). Needs the `convert` extra: `pip install "docassert[convert]"`. |
|
|
102
103
|
|
|
103
104
|
Every document-reading command accepts `--documents-dir` (default `documents/`).
|
|
@@ -14,7 +14,7 @@ derived from the documents rather than self-reported.
|
|
|
14
14
|
|
|
15
15
|
docassert is the reference implementation of **[PMO as Code](https://c4g-john.github.io/pmo-as-code/)** —
|
|
16
16
|
a vendor-neutral standard for running a PMO from version-controlled, declarative
|
|
17
|
-
files. It implements the [PMO as Code specification](https://github.com/c4g-john/pmo-as-code-spec) **v0.
|
|
17
|
+
files. It implements the [PMO as Code specification](https://github.com/c4g-john/pmo-as-code-spec) **v0.3** and passes its [conformance suite](https://github.com/c4g-john/pmo-as-code-spec/tree/main/conformance) in CI.
|
|
18
18
|
|
|
19
19
|
## Install
|
|
20
20
|
|
|
@@ -58,6 +58,7 @@ flagged as TODOs, never invented). The skill's source is
|
|
|
58
58
|
| `docassert projects [--out] [--check]` | Generate / verify the project registry. |
|
|
59
59
|
| `docassert new <kind> --project ID` | Scaffold a document from its template with identity filled in (`new project --code XYZ` auto-numbers the id); suggests the next free item ids. |
|
|
60
60
|
| `docassert init [DIR]` | Scaffold the default config into a repo. |
|
|
61
|
+
| `docassert bridge <action> --repo O/N` | Execution bridge: `scaffold` Features/Stories from approved user stories, `reconcile` (police the board against the docs), `status` (delivery figures). Needs the GitHub CLI. |
|
|
61
62
|
| `docassert extract <file>` | Extract plain text from a source `.docx` / `.pdf` / `.md` / `.txt` (the first step of doc-to-pmo conversion). Needs the `convert` extra: `pip install "docassert[convert]"`. |
|
|
62
63
|
|
|
63
64
|
Every document-reading command accepts `--documents-dir` (default `documents/`).
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""The execution bridge: documents -> GitHub issues/boards -> dashboards.
|
|
2
|
+
|
|
3
|
+
Scope authority is one-directional. The bridge scaffolds Features and Stories
|
|
4
|
+
from approved user stories, polices the board against the documents, and reads
|
|
5
|
+
delivery progress back for rendering. It never modifies documents.
|
|
6
|
+
"""
|
|
7
|
+
from .plan import BridgePlan, build_bridge_plan
|
|
8
|
+
|
|
9
|
+
__all__ = ["BridgePlan", "build_bridge_plan"]
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Thin wrapper around the GitHub CLI for the bridge (injectable for tests).
|
|
2
|
+
|
|
3
|
+
All GitHub access goes through `gh` so token handling stays with gh/Actions
|
|
4
|
+
and docassert gains no HTTP dependencies. Every mutation the bridge performs
|
|
5
|
+
funnels through GhRunner, which dry-run mode replaces with a recorder.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import subprocess
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GhError(RuntimeError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GhRunner:
|
|
18
|
+
"""Executes gh commands. Tests substitute a fake with the same interface."""
|
|
19
|
+
|
|
20
|
+
def run(self, args: list[str], input_: str | None = None) -> str:
|
|
21
|
+
proc = subprocess.run(["gh", *args], capture_output=True, text=True,
|
|
22
|
+
input=input_)
|
|
23
|
+
if proc.returncode != 0:
|
|
24
|
+
raise GhError(proc.stderr.strip() or f"gh {' '.join(args)} failed")
|
|
25
|
+
return proc.stdout
|
|
26
|
+
|
|
27
|
+
def api_json(self, path: str, *flags: str):
|
|
28
|
+
out = self.run(["api", path, *flags])
|
|
29
|
+
return json.loads(out) if out.strip() else None
|
|
30
|
+
|
|
31
|
+
def graphql(self, query: str, **variables) -> dict:
|
|
32
|
+
args = ["api", "graphql", "-f", f"query={query}"]
|
|
33
|
+
for k, v in variables.items():
|
|
34
|
+
args += (["-F", f"{k}={v}"] if isinstance(v, int)
|
|
35
|
+
else ["-f", f"{k}={v}"])
|
|
36
|
+
data = json.loads(self.run(args))
|
|
37
|
+
if data.get("errors"):
|
|
38
|
+
raise GhError(json.dumps(data["errors"])[:400])
|
|
39
|
+
return data["data"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class DryRunner(GhRunner):
|
|
43
|
+
"""Reads pass through; mutations are recorded, not executed."""
|
|
44
|
+
|
|
45
|
+
MUTATING = ("-X POST", "-X PATCH", "-X PUT", "-X DELETE", "mutation")
|
|
46
|
+
|
|
47
|
+
def __init__(self) -> None:
|
|
48
|
+
self.planned: list[str] = []
|
|
49
|
+
|
|
50
|
+
def run(self, args: list[str], input_: str | None = None) -> str:
|
|
51
|
+
joined = " ".join(args)
|
|
52
|
+
if any(m in joined for m in ("-X", "--method")) or "mutation" in joined:
|
|
53
|
+
self.planned.append(f"gh {joined}")
|
|
54
|
+
return "{}"
|
|
55
|
+
return super().run(args, input_)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def list_issues(gh: GhRunner, repo: str, state: str = "all") -> list[dict]:
|
|
59
|
+
"""Every issue (not PR) in the repo, paginated."""
|
|
60
|
+
raw = gh.run(["api", f"repos/{repo}/issues?state={state}&per_page=100",
|
|
61
|
+
"--paginate"])
|
|
62
|
+
# --paginate concatenates JSON arrays
|
|
63
|
+
items: list[dict] = []
|
|
64
|
+
dec = json.JSONDecoder()
|
|
65
|
+
idx = 0
|
|
66
|
+
raw = raw.strip()
|
|
67
|
+
while idx < len(raw):
|
|
68
|
+
chunk, end = dec.raw_decode(raw, idx)
|
|
69
|
+
items.extend(chunk)
|
|
70
|
+
idx = end
|
|
71
|
+
while idx < len(raw) and raw[idx] in " \n\r\t":
|
|
72
|
+
idx += 1
|
|
73
|
+
return [i for i in items if "pull_request" not in i]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def ensure_label(gh: GhRunner, repo: str, name: str, color: str, desc: str) -> None:
|
|
77
|
+
try:
|
|
78
|
+
gh.run(["api", f"repos/{repo}/labels", "-X", "POST",
|
|
79
|
+
"-f", f"name={name}", "-f", f"color={color}",
|
|
80
|
+
"-f", f"description={desc}"])
|
|
81
|
+
except GhError as exc:
|
|
82
|
+
if "already_exists" not in str(exc) and "422" not in str(exc):
|
|
83
|
+
raise
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def create_issue(gh: GhRunner, repo: str, title: str, body: str,
|
|
87
|
+
labels: list[str]) -> dict:
|
|
88
|
+
args = ["api", f"repos/{repo}/issues", "-X", "POST",
|
|
89
|
+
"-f", f"title={title}", "-f", f"body={body}"]
|
|
90
|
+
for lb in labels:
|
|
91
|
+
args += ["-f", f"labels[]={lb}"]
|
|
92
|
+
return json.loads(gh.run(args) or "{}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def update_issue(gh: GhRunner, repo: str, number: int, **fields) -> None:
|
|
96
|
+
args = ["api", f"repos/{repo}/issues/{number}", "-X", "PATCH"]
|
|
97
|
+
for k, v in fields.items():
|
|
98
|
+
args += ["-f", f"{k}={v}"]
|
|
99
|
+
gh.run(args)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def comment(gh: GhRunner, repo: str, number: int, body: str) -> None:
|
|
103
|
+
gh.run(["api", f"repos/{repo}/issues/{number}/comments", "-X", "POST",
|
|
104
|
+
"-f", f"body={body}"])
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def add_sub_issue(gh: GhRunner, parent_node: str, child_node: str) -> None:
|
|
108
|
+
q = ("mutation($p: ID!, $c: ID!) { addSubIssue(input: {issueId: $p, "
|
|
109
|
+
"subIssueId: $c}) { issue { id } } }")
|
|
110
|
+
try:
|
|
111
|
+
gh.graphql(q, p=parent_node, c=child_node)
|
|
112
|
+
except GhError as exc:
|
|
113
|
+
# An existing identical link is success, not failure. GitHub words it
|
|
114
|
+
# several ways ("duplicate sub-issues", "may only have one parent").
|
|
115
|
+
msg = str(exc).lower()
|
|
116
|
+
if not any(s in msg for s in ("already", "duplicate sub-issue",
|
|
117
|
+
"only have one parent")):
|
|
118
|
+
raise
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Bridge operations: scaffold, reconcile, status.
|
|
2
|
+
|
|
3
|
+
scaffold documents -> issues/board (idempotent; never deletes)
|
|
4
|
+
reconcile board -> alerts (labels + one pinned scope report; never deletes)
|
|
5
|
+
status board -> read-only delivery figures
|
|
6
|
+
|
|
7
|
+
The bridge never writes to documents/ — scope changes go through document PRs.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
from . import gh as G
|
|
15
|
+
from .plan import (
|
|
16
|
+
MARKER,
|
|
17
|
+
BridgePlan,
|
|
18
|
+
feature_body,
|
|
19
|
+
feature_title,
|
|
20
|
+
story_body,
|
|
21
|
+
story_title,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
MARKER_RE = re.compile(r"<!-- docassert-bridge: ([A-Za-z0-9-]+) -->")
|
|
25
|
+
SCOPE_REPORT_KEY = "scope-report"
|
|
26
|
+
LABELS = {
|
|
27
|
+
"bridge:feature": ("1d76db", "Feature scaffolded from a product requirement"),
|
|
28
|
+
"bridge:story": ("0e8a16", "Story scaffolded from an approved user story"),
|
|
29
|
+
"scope:unverified": ("d93f0b", "No matching item in the governing documents"),
|
|
30
|
+
"scope:orphaned": ("b60205", "Its document item was removed or demoted"),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _marker_of(issue: dict) -> str | None:
|
|
35
|
+
m = MARKER_RE.search(issue.get("body") or "")
|
|
36
|
+
return m.group(1) if m else None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _index(issues: list[dict]) -> dict[str, dict]:
|
|
40
|
+
out: dict[str, dict] = {}
|
|
41
|
+
for i in issues:
|
|
42
|
+
key = _marker_of(i)
|
|
43
|
+
if key and key not in out: # first (oldest) wins
|
|
44
|
+
out[key] = i
|
|
45
|
+
return out
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def scaffold(plan: BridgePlan, gh: G.GhRunner, repo: str,
|
|
49
|
+
docs_url: str | None = None) -> list[str]:
|
|
50
|
+
"""Make the repo's issues match the plan. Returns human-readable actions."""
|
|
51
|
+
actions: list[str] = []
|
|
52
|
+
for name, (color, desc) in LABELS.items():
|
|
53
|
+
G.ensure_label(gh, repo, name, color, desc)
|
|
54
|
+
|
|
55
|
+
issues = G.list_issues(gh, repo)
|
|
56
|
+
by_key = _index(issues)
|
|
57
|
+
|
|
58
|
+
def ensure(key: str, title: str, body: str, label: str) -> dict:
|
|
59
|
+
existing = by_key.get(key)
|
|
60
|
+
if existing is None:
|
|
61
|
+
created = G.create_issue(gh, repo, title, body, [label])
|
|
62
|
+
created["body"] = body
|
|
63
|
+
by_key[key] = created
|
|
64
|
+
actions.append(f"created #{created.get('number', '?')} {title}")
|
|
65
|
+
return created
|
|
66
|
+
if existing.get("title") != title or (existing.get("body") or "").strip() != body.strip():
|
|
67
|
+
G.update_issue(gh, repo, existing["number"], title=title, body=body)
|
|
68
|
+
actions.append(f"updated #{existing['number']} {title}")
|
|
69
|
+
if existing.get("state") == "closed" and key in plan.managed_ids:
|
|
70
|
+
pass # closed means delivered; scaffold never reopens
|
|
71
|
+
return existing
|
|
72
|
+
|
|
73
|
+
for f in plan.features:
|
|
74
|
+
feat_issue = ensure(f.id, feature_title(f), feature_body(f, docs_url),
|
|
75
|
+
"bridge:feature")
|
|
76
|
+
for s in f.stories:
|
|
77
|
+
story_issue = ensure(s.id, story_title(s), story_body(s, docs_url),
|
|
78
|
+
"bridge:story")
|
|
79
|
+
pnode, cnode = feat_issue.get("node_id"), story_issue.get("node_id")
|
|
80
|
+
if pnode and cnode:
|
|
81
|
+
G.add_sub_issue(gh, pnode, cnode)
|
|
82
|
+
|
|
83
|
+
for sk in plan.skipped:
|
|
84
|
+
actions.append(f"skipped {sk['id']}: {sk['reason']}")
|
|
85
|
+
return actions
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def reconcile(plan: BridgePlan, gh: G.GhRunner, repo: str) -> tuple[list[str], int]:
|
|
89
|
+
"""Police the board against the documents. Returns (report lines, exit code)."""
|
|
90
|
+
for name, (color, desc) in LABELS.items():
|
|
91
|
+
G.ensure_label(gh, repo, name, color, desc)
|
|
92
|
+
|
|
93
|
+
managed = plan.managed_ids
|
|
94
|
+
issues = G.list_issues(gh, repo, state="open")
|
|
95
|
+
unverified, orphaned = [], []
|
|
96
|
+
|
|
97
|
+
for issue in issues:
|
|
98
|
+
key = _marker_of(issue)
|
|
99
|
+
if key == SCOPE_REPORT_KEY:
|
|
100
|
+
continue
|
|
101
|
+
labels = {lb["name"] for lb in issue.get("labels", [])}
|
|
102
|
+
number = issue["number"]
|
|
103
|
+
|
|
104
|
+
if key is None:
|
|
105
|
+
unverified.append(issue)
|
|
106
|
+
if "scope:unverified" not in labels:
|
|
107
|
+
gh.run(["api", f"repos/{repo}/issues/{number}/labels", "-X", "POST",
|
|
108
|
+
"-f", "labels[]=scope:unverified"])
|
|
109
|
+
G.comment(gh, repo, number,
|
|
110
|
+
"**Scope check:** this issue has no matching item in the "
|
|
111
|
+
"governing documents. Scope is defined in the PMO repo; "
|
|
112
|
+
"either link it to an approved item or move the scope "
|
|
113
|
+
"change through a documents PR.")
|
|
114
|
+
elif key not in managed:
|
|
115
|
+
orphaned.append(issue)
|
|
116
|
+
if "scope:orphaned" not in labels:
|
|
117
|
+
gh.run(["api", f"repos/{repo}/issues/{number}/labels", "-X", "POST",
|
|
118
|
+
"-f", "labels[]=scope:orphaned"])
|
|
119
|
+
G.comment(gh, repo, number,
|
|
120
|
+
f"**Scope check:** `{key}` no longer resolves to an "
|
|
121
|
+
"approved item in the documents (removed, renamed, or "
|
|
122
|
+
"demoted). A human should decide whether to close this "
|
|
123
|
+
"issue or restore the item.")
|
|
124
|
+
|
|
125
|
+
lines = [f"managed items: {len(managed)}",
|
|
126
|
+
f"open issues checked: {len(issues)}",
|
|
127
|
+
f"unverified: {len(unverified)}", f"orphaned: {len(orphaned)}"]
|
|
128
|
+
for i in unverified:
|
|
129
|
+
lines.append(f" unverified: #{i['number']} {i['title']}")
|
|
130
|
+
for i in orphaned:
|
|
131
|
+
lines.append(f" orphaned: #{i['number']} {i['title']} [{_marker_of(i)}]")
|
|
132
|
+
|
|
133
|
+
body = (MARKER.format(SCOPE_REPORT_KEY)
|
|
134
|
+
+ "\n_Maintained by `docassert bridge reconcile`. The documents are "
|
|
135
|
+
"the authoritative scope source._\n\n```\n" + "\n".join(lines) + "\n```\n")
|
|
136
|
+
report = _index(G.list_issues(gh, repo)).get(SCOPE_REPORT_KEY)
|
|
137
|
+
if report is None:
|
|
138
|
+
G.create_issue(gh, repo, "Scope report — docassert bridge", body, [])
|
|
139
|
+
else:
|
|
140
|
+
G.update_issue(gh, repo, report["number"], body=body)
|
|
141
|
+
|
|
142
|
+
return lines, (1 if (unverified or orphaned) else 0)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def status(plan: BridgePlan, gh: G.GhRunner, repo: str) -> dict:
|
|
146
|
+
"""Read-only: delivery progress per feature from issue states."""
|
|
147
|
+
by_key = _index(G.list_issues(gh, repo))
|
|
148
|
+
out: dict = {"projects": []}
|
|
149
|
+
for p in plan.projects:
|
|
150
|
+
feats = []
|
|
151
|
+
for f in p["features"]:
|
|
152
|
+
states = [(by_key.get(s.id) or {}).get("state") for s in f.stories]
|
|
153
|
+
feats.append({
|
|
154
|
+
"id": f.id,
|
|
155
|
+
"issue": (by_key.get(f.id) or {}).get("number"),
|
|
156
|
+
"stories_total": len(f.stories),
|
|
157
|
+
"stories_closed": sum(1 for s in states if s == "closed"),
|
|
158
|
+
"closed": (by_key.get(f.id) or {}).get("state") == "closed",
|
|
159
|
+
})
|
|
160
|
+
done = sum(x["stories_closed"] for x in feats)
|
|
161
|
+
total = sum(x["stories_total"] for x in feats)
|
|
162
|
+
out["projects"].append({"id": p["id"], "features": feats,
|
|
163
|
+
"stories_closed": done, "stories_total": total})
|
|
164
|
+
return out
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def render_status(data: dict) -> str:
|
|
168
|
+
lines = []
|
|
169
|
+
for p in data["projects"]:
|
|
170
|
+
lines.append(f"{p['id']}: {p['stories_closed']}/{p['stories_total']} stories closed")
|
|
171
|
+
for f in p["features"]:
|
|
172
|
+
mark = "✓" if f["closed"] else " "
|
|
173
|
+
lines.append(f" [{mark}] {f['id']}: {f['stories_closed']}/{f['stories_total']}"
|
|
174
|
+
+ (f" (#{f['issue']})" if f["issue"] else " (no issue)"))
|
|
175
|
+
return "\n".join(lines) or "no qualifying projects"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def to_json(data: dict) -> str:
|
|
179
|
+
return json.dumps(data, indent=2) + "\n"
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Build the execution-bridge plan from the document graph (pure, no I/O).
|
|
2
|
+
|
|
3
|
+
Scope authority flows one way: documents -> GitHub. This module decides WHAT
|
|
4
|
+
should exist on the board (Features from product requirements, Stories from
|
|
5
|
+
approved user stories); the executor makes GitHub match it. Nothing here, or
|
|
6
|
+
anywhere in the bridge, writes to documents/.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from ..graph import build_graph
|
|
14
|
+
from ..loader import load
|
|
15
|
+
|
|
16
|
+
APPROVED = {"approved", "baselined"}
|
|
17
|
+
MARKER = "<!-- docassert-bridge: {} -->"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Story:
|
|
22
|
+
id: str
|
|
23
|
+
text: str
|
|
24
|
+
feature_id: str
|
|
25
|
+
doc_path: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Feature:
|
|
30
|
+
id: str
|
|
31
|
+
text: str
|
|
32
|
+
doc_path: str
|
|
33
|
+
project: str
|
|
34
|
+
stories: list[Story] = field(default_factory=list)
|
|
35
|
+
acceptance: list[tuple[str, str]] = field(default_factory=list) # (id, text)
|
|
36
|
+
tests: list[str] = field(default_factory=list) # tc ids
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class BridgePlan:
|
|
41
|
+
projects: list[dict] = field(default_factory=list) # {id, code, features}
|
|
42
|
+
skipped: list[dict] = field(default_factory=list) # {id, reason}
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def features(self) -> list[Feature]:
|
|
46
|
+
return [f for p in self.projects for f in p["features"]]
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def stories(self) -> list[Story]:
|
|
50
|
+
return [s for f in self.features for s in f.stories]
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def managed_ids(self) -> set[str]:
|
|
54
|
+
return {f.id for f in self.features} | {s.id for s in self.stories}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _title(item_text: str, limit: int = 64) -> str:
|
|
58
|
+
"""First sentence of the item text, trimmed for an issue title."""
|
|
59
|
+
first = item_text.split(". ")[0].rstrip(".")
|
|
60
|
+
return first if len(first) <= limit else first[: limit - 1].rstrip() + "…"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _anchors(documents_dir: Path) -> dict[str, dict]:
|
|
64
|
+
"""project id -> {code, name} for every project anchor."""
|
|
65
|
+
out: dict[str, dict] = {}
|
|
66
|
+
for path in sorted(documents_dir.rglob("project.md")):
|
|
67
|
+
try:
|
|
68
|
+
doc = load(path)
|
|
69
|
+
except ValueError:
|
|
70
|
+
continue
|
|
71
|
+
if doc.kind == "project" and doc.id:
|
|
72
|
+
out[doc.id] = {"code": str(doc.frontmatter.get("code", "")),
|
|
73
|
+
"name": str(doc.frontmatter.get("name", ""))}
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _story_docs(documents_dir: Path) -> dict[str, list]:
|
|
78
|
+
"""project id -> its user-story documents."""
|
|
79
|
+
out: dict[str, list] = {}
|
|
80
|
+
for path in sorted(documents_dir.rglob("*.md")):
|
|
81
|
+
try:
|
|
82
|
+
doc = load(path)
|
|
83
|
+
except ValueError:
|
|
84
|
+
continue
|
|
85
|
+
if doc.kind == "user-story":
|
|
86
|
+
proj = str(doc.frontmatter.get("project", ""))
|
|
87
|
+
out.setdefault(proj, []).append(doc)
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def build_bridge_plan(documents_dir: str | Path = "documents") -> BridgePlan:
|
|
92
|
+
"""The gate (Decision D): a project qualifies when it has a user-story
|
|
93
|
+
document with approved status, and every approved story traces to an
|
|
94
|
+
existing product requirement. Draft/proposed stories never reach the board.
|
|
95
|
+
"""
|
|
96
|
+
documents_dir = Path(documents_dir)
|
|
97
|
+
graph = build_graph(documents_dir)
|
|
98
|
+
plan = BridgePlan()
|
|
99
|
+
|
|
100
|
+
for proj_id, meta in _anchors(documents_dir).items():
|
|
101
|
+
docs = _story_docs(documents_dir).get(proj_id, [])
|
|
102
|
+
if not docs:
|
|
103
|
+
plan.skipped.append({"id": proj_id, "reason": "no user-story document"})
|
|
104
|
+
continue
|
|
105
|
+
approved_docs = [d for d in docs
|
|
106
|
+
if str(d.frontmatter.get("status", "")).lower() in APPROVED]
|
|
107
|
+
if not approved_docs:
|
|
108
|
+
plan.skipped.append({"id": proj_id, "reason": "user stories not approved"})
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
code = meta["code"]
|
|
112
|
+
stories = [i for i in graph.by_type.get("US", [])
|
|
113
|
+
if i.project == code and i.doc_status.lower() in APPROVED]
|
|
114
|
+
features: dict[str, Feature] = {}
|
|
115
|
+
problems: list[str] = []
|
|
116
|
+
for s in stories:
|
|
117
|
+
targets = s.targets("traces")
|
|
118
|
+
parents = [t for t in targets if graph.canonical(t) is not None]
|
|
119
|
+
if not parents:
|
|
120
|
+
problems.append(f"{s.id} has no resolvable traces link")
|
|
121
|
+
continue
|
|
122
|
+
for pid in parents:
|
|
123
|
+
parent = graph.canonical(pid)
|
|
124
|
+
if parent is None: # filtered above; narrows the type
|
|
125
|
+
continue
|
|
126
|
+
feat = features.get(parent.id)
|
|
127
|
+
if feat is None:
|
|
128
|
+
feat = Feature(id=parent.id, text=parent.text,
|
|
129
|
+
doc_path=parent.doc_path, project=proj_id)
|
|
130
|
+
feat.acceptance = [(a.id, a.text) for a in
|
|
131
|
+
graph.children(parent.id, "verifies", "AC")]
|
|
132
|
+
for aid, _ in feat.acceptance:
|
|
133
|
+
feat.tests += [t.id for t in graph.children(aid, "tests", "TC")]
|
|
134
|
+
features[parent.id] = feat
|
|
135
|
+
feat.stories.append(Story(id=s.id, text=s.text,
|
|
136
|
+
feature_id=parent.id, doc_path=s.doc_path))
|
|
137
|
+
if problems:
|
|
138
|
+
plan.skipped.append({"id": proj_id,
|
|
139
|
+
"reason": "; ".join(problems)})
|
|
140
|
+
continue
|
|
141
|
+
plan.projects.append({"id": proj_id, "code": code, "name": meta["name"],
|
|
142
|
+
"features": list(features.values())})
|
|
143
|
+
return plan
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def feature_title(f: Feature) -> str:
|
|
147
|
+
return f"[{f.id}] {_title(f.text)}"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def story_title(s: Story) -> str:
|
|
151
|
+
return f"[{s.id}] {_title(s.text)}"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def feature_body(f: Feature, docs_url: str | None) -> str:
|
|
155
|
+
lines = [MARKER.format(f.id),
|
|
156
|
+
"_Managed by the docassert bridge. Scope lives in the documents;"
|
|
157
|
+
" edits here are overwritten._", "",
|
|
158
|
+
f.text, "", f"**Project:** {f.project}"]
|
|
159
|
+
if docs_url:
|
|
160
|
+
lines.append(f"**Source:** {docs_url}/{f.doc_path}")
|
|
161
|
+
if f.stories:
|
|
162
|
+
lines += ["", "**Stories:**"] + [f"- {s.id}" for s in f.stories]
|
|
163
|
+
if f.acceptance:
|
|
164
|
+
lines += ["", "**Acceptance criteria:**"] + \
|
|
165
|
+
[f"- [ ] {aid}: {text}" for aid, text in f.acceptance]
|
|
166
|
+
if f.tests:
|
|
167
|
+
lines += ["", "**Verified by:** " + ", ".join(sorted(set(f.tests)))]
|
|
168
|
+
return "\n".join(lines) + "\n"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def story_body(s: Story, docs_url: str | None) -> str:
|
|
172
|
+
lines = [MARKER.format(s.id),
|
|
173
|
+
"_Managed by the docassert bridge. Scope lives in the documents;"
|
|
174
|
+
" edits here are overwritten._", "",
|
|
175
|
+
s.text, "", f"**Feature:** {s.feature_id}"]
|
|
176
|
+
if docs_url:
|
|
177
|
+
lines.append(f"**Source:** {docs_url}/{s.doc_path}")
|
|
178
|
+
return "\n".join(lines) + "\n"
|
|
@@ -289,6 +289,42 @@ def cmd_new(args: argparse.Namespace) -> int:
|
|
|
289
289
|
return 0
|
|
290
290
|
|
|
291
291
|
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def cmd_bridge(args: argparse.Namespace) -> int:
|
|
295
|
+
"""Execution bridge: scaffold/police/read the GitHub board from the docs.
|
|
296
|
+
Scope flows documents -> GitHub only; the bridge never edits documents."""
|
|
297
|
+
from .bridge import build_bridge_plan, ops
|
|
298
|
+
from .bridge import gh as ghmod
|
|
299
|
+
|
|
300
|
+
plan = build_bridge_plan(args.documents_dir)
|
|
301
|
+
gh = ghmod.DryRunner() if getattr(args, "dry_run", False) else ghmod.GhRunner()
|
|
302
|
+
|
|
303
|
+
if args.action == "scaffold":
|
|
304
|
+
actions = ops.scaffold(plan, gh, args.repo, docs_url=args.docs_url)
|
|
305
|
+
for a in actions:
|
|
306
|
+
print(f"docassert bridge: {a}")
|
|
307
|
+
if isinstance(gh, ghmod.DryRunner):
|
|
308
|
+
print(f"docassert bridge: dry run — {len(gh.planned)} mutation(s) planned")
|
|
309
|
+
for m in gh.planned:
|
|
310
|
+
print(f" {m}")
|
|
311
|
+
return 0
|
|
312
|
+
if args.action == "reconcile":
|
|
313
|
+
lines, code = ops.reconcile(plan, gh, args.repo)
|
|
314
|
+
for line in lines:
|
|
315
|
+
print(f"docassert bridge: {line}")
|
|
316
|
+
return code
|
|
317
|
+
if args.action == "status":
|
|
318
|
+
data = ops.status(plan, gh, args.repo)
|
|
319
|
+
sys.stdout.write(ops.to_json(data) if args.json else
|
|
320
|
+
ops.render_status(data) + "\n")
|
|
321
|
+
if args.json and args.out:
|
|
322
|
+
Path(args.out).write_text(ops.to_json(data))
|
|
323
|
+
return 0
|
|
324
|
+
print(f"docassert bridge: unknown action {args.action!r}", file=sys.stderr)
|
|
325
|
+
return 2
|
|
326
|
+
|
|
327
|
+
|
|
292
328
|
def main(argv: list[str] | None = None) -> int:
|
|
293
329
|
from . import __version__
|
|
294
330
|
parser = argparse.ArgumentParser(prog="docassert",
|
|
@@ -367,6 +403,18 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
367
403
|
docs_dir_opt(n)
|
|
368
404
|
n.set_defaults(func=cmd_new)
|
|
369
405
|
|
|
406
|
+
|
|
407
|
+
b = sub.add_parser("bridge", help="Execution bridge: scaffold and police the GitHub board from approved stories.")
|
|
408
|
+
b.add_argument("action", choices=["scaffold", "reconcile", "status"],
|
|
409
|
+
help="scaffold: docs -> issues/board · reconcile: police the board · status: delivery figures.")
|
|
410
|
+
b.add_argument("--repo", required=True, help="Target GitHub repo (OWNER/NAME) holding the issues.")
|
|
411
|
+
b.add_argument("--docs-url", help="Base URL of the documents repo, for source links in issue bodies.")
|
|
412
|
+
b.add_argument("--dry-run", action="store_true", help="Print planned mutations without executing them.")
|
|
413
|
+
b.add_argument("--json", action="store_true", help="status: emit JSON.")
|
|
414
|
+
b.add_argument("--out", help="status --json: also write to this path.")
|
|
415
|
+
docs_dir_opt(b)
|
|
416
|
+
b.set_defaults(func=cmd_bridge)
|
|
417
|
+
|
|
370
418
|
args = parser.parse_args(argv)
|
|
371
419
|
return args.func(args)
|
|
372
420
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: docassert
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.1
|
|
4
4
|
Summary: Unit testing for business documents — validate structured Markdown docs against a configurable audit standard.
|
|
5
5
|
Author: C4G Enterprises Inc.
|
|
6
6
|
License: Apache-2.0
|
|
@@ -54,7 +54,7 @@ derived from the documents rather than self-reported.
|
|
|
54
54
|
|
|
55
55
|
docassert is the reference implementation of **[PMO as Code](https://c4g-john.github.io/pmo-as-code/)** —
|
|
56
56
|
a vendor-neutral standard for running a PMO from version-controlled, declarative
|
|
57
|
-
files. It implements the [PMO as Code specification](https://github.com/c4g-john/pmo-as-code-spec) **v0.
|
|
57
|
+
files. It implements the [PMO as Code specification](https://github.com/c4g-john/pmo-as-code-spec) **v0.3** and passes its [conformance suite](https://github.com/c4g-john/pmo-as-code-spec/tree/main/conformance) in CI.
|
|
58
58
|
|
|
59
59
|
## Install
|
|
60
60
|
|
|
@@ -98,6 +98,7 @@ flagged as TODOs, never invented). The skill's source is
|
|
|
98
98
|
| `docassert projects [--out] [--check]` | Generate / verify the project registry. |
|
|
99
99
|
| `docassert new <kind> --project ID` | Scaffold a document from its template with identity filled in (`new project --code XYZ` auto-numbers the id); suggests the next free item ids. |
|
|
100
100
|
| `docassert init [DIR]` | Scaffold the default config into a repo. |
|
|
101
|
+
| `docassert bridge <action> --repo O/N` | Execution bridge: `scaffold` Features/Stories from approved user stories, `reconcile` (police the board against the docs), `status` (delivery figures). Needs the GitHub CLI. |
|
|
101
102
|
| `docassert extract <file>` | Extract plain text from a source `.docx` / `.pdf` / `.md` / `.txt` (the first step of doc-to-pmo conversion). Needs the `convert` extra: `pip install "docassert[convert]"`. |
|
|
102
103
|
|
|
103
104
|
Every document-reading command accepts `--documents-dir` (default `documents/`).
|
|
@@ -89,10 +89,15 @@ docassert/_data/templates/runbook.template.md
|
|
|
89
89
|
docassert/_data/templates/status-report.template.md
|
|
90
90
|
docassert/_data/templates/test-cases.template.md
|
|
91
91
|
docassert/_data/templates/user-story.template.md
|
|
92
|
+
docassert/bridge/__init__.py
|
|
93
|
+
docassert/bridge/gh.py
|
|
94
|
+
docassert/bridge/ops.py
|
|
95
|
+
docassert/bridge/plan.py
|
|
92
96
|
docassert/status/__init__.py
|
|
93
97
|
docassert/status/derive.py
|
|
94
98
|
docassert/status/render.py
|
|
95
99
|
tests/test_badge.py
|
|
100
|
+
tests/test_bridge.py
|
|
96
101
|
tests/test_config.py
|
|
97
102
|
tests/test_consistency.py
|
|
98
103
|
tests/test_defects.py
|