docassert 0.8.1__tar.gz → 0.10.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.
Files changed (119) hide show
  1. {docassert-0.8.1/docassert.egg-info → docassert-0.10.0}/PKG-INFO +1 -1
  2. {docassert-0.8.1 → docassert-0.10.0}/docassert/__init__.py +1 -1
  3. docassert-0.10.0/docassert/bridge/board.py +126 -0
  4. {docassert-0.8.1 → docassert-0.10.0}/docassert/bridge/gh.py +15 -3
  5. {docassert-0.8.1 → docassert-0.10.0}/docassert/bridge/ops.py +52 -5
  6. {docassert-0.8.1 → docassert-0.10.0}/docassert/cli.py +42 -2
  7. {docassert-0.8.1 → docassert-0.10.0}/docassert/status/render.py +35 -0
  8. {docassert-0.8.1 → docassert-0.10.0/docassert.egg-info}/PKG-INFO +1 -1
  9. {docassert-0.8.1 → docassert-0.10.0}/docassert.egg-info/SOURCES.txt +1 -0
  10. {docassert-0.8.1 → docassert-0.10.0}/tests/test_badge.py +22 -0
  11. {docassert-0.8.1 → docassert-0.10.0}/tests/test_bridge.py +88 -0
  12. {docassert-0.8.1 → docassert-0.10.0}/LICENSE +0 -0
  13. {docassert-0.8.1 → docassert-0.10.0}/NOTICE +0 -0
  14. {docassert-0.8.1 → docassert-0.10.0}/README.md +0 -0
  15. {docassert-0.8.1 → docassert-0.10.0}/docassert/__main__.py +0 -0
  16. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/consistency.yaml +0 -0
  17. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/adr.criteria.yaml +0 -0
  18. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/benefits-realization.criteria.yaml +0 -0
  19. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/brd.criteria.yaml +0 -0
  20. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/business-case.criteria.yaml +0 -0
  21. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/charter.criteria.yaml +0 -0
  22. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/data-migration-plan.criteria.yaml +0 -0
  23. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/frnfr.criteria.yaml +0 -0
  24. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/hypercare-plan.criteria.yaml +0 -0
  25. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/post-implementation-review.criteria.yaml +0 -0
  26. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/prd.criteria.yaml +0 -0
  27. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/project.criteria.yaml +0 -0
  28. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/qa-test-plan.criteria.yaml +0 -0
  29. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/raci-stakeholder.criteria.yaml +0 -0
  30. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/release-cutover-plan.criteria.yaml +0 -0
  31. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/risk-register.criteria.yaml +0 -0
  32. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/rollback-plan.criteria.yaml +0 -0
  33. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/runbook.criteria.yaml +0 -0
  34. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/status-report.criteria.yaml +0 -0
  35. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/test-cases.criteria.yaml +0 -0
  36. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/user-story.criteria.yaml +0 -0
  37. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/profiles/agile-delivery.yaml +0 -0
  38. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/profiles/lean-startup.yaml +0 -0
  39. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/profiles/regulated-industry.yaml +0 -0
  40. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/adr.schema.json +0 -0
  41. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/benefits-realization.schema.json +0 -0
  42. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/brd.schema.json +0 -0
  43. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/business-case.schema.json +0 -0
  44. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/charter.schema.json +0 -0
  45. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/data-migration-plan.schema.json +0 -0
  46. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/frnfr.schema.json +0 -0
  47. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/hypercare-plan.schema.json +0 -0
  48. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/post-implementation-review.schema.json +0 -0
  49. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/prd.schema.json +0 -0
  50. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/project.schema.json +0 -0
  51. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/qa-test-plan.schema.json +0 -0
  52. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/raci-stakeholder.schema.json +0 -0
  53. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/release-cutover-plan.schema.json +0 -0
  54. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/risk-register.schema.json +0 -0
  55. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/rollback-plan.schema.json +0 -0
  56. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/runbook.schema.json +0 -0
  57. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/status-report.schema.json +0 -0
  58. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/test-cases.schema.json +0 -0
  59. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/user-story.schema.json +0 -0
  60. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/skills/doc-to-pmo/SKILL.md +0 -0
  61. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/adr.template.md +0 -0
  62. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/benefits-realization.template.md +0 -0
  63. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/brd.template.md +0 -0
  64. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/business-case.template.md +0 -0
  65. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/charter.template.md +0 -0
  66. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/data-migration-plan.template.md +0 -0
  67. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/frnfr.template.md +0 -0
  68. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/hypercare-plan.template.md +0 -0
  69. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/post-implementation-review.template.md +0 -0
  70. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/prd.template.md +0 -0
  71. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/project.template.md +0 -0
  72. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/qa-test-plan.template.md +0 -0
  73. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/raci-stakeholder.template.md +0 -0
  74. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/release-cutover-plan.template.md +0 -0
  75. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/risk-register.template.md +0 -0
  76. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/rollback-plan.template.md +0 -0
  77. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/runbook.template.md +0 -0
  78. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/status-report.template.md +0 -0
  79. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/test-cases.template.md +0 -0
  80. {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/user-story.template.md +0 -0
  81. {docassert-0.8.1 → docassert-0.10.0}/docassert/bridge/__init__.py +0 -0
  82. {docassert-0.8.1 → docassert-0.10.0}/docassert/bridge/plan.py +0 -0
  83. {docassert-0.8.1 → docassert-0.10.0}/docassert/config.py +0 -0
  84. {docassert-0.8.1 → docassert-0.10.0}/docassert/consistency.py +0 -0
  85. {docassert-0.8.1 → docassert-0.10.0}/docassert/extract.py +0 -0
  86. {docassert-0.8.1 → docassert-0.10.0}/docassert/graph.py +0 -0
  87. {docassert-0.8.1 → docassert-0.10.0}/docassert/loader.py +0 -0
  88. {docassert-0.8.1 → docassert-0.10.0}/docassert/models.py +0 -0
  89. {docassert-0.8.1 → docassert-0.10.0}/docassert/profiles.py +0 -0
  90. {docassert-0.8.1 → docassert-0.10.0}/docassert/projects.py +0 -0
  91. {docassert-0.8.1 → docassert-0.10.0}/docassert/report.py +0 -0
  92. {docassert-0.8.1 → docassert-0.10.0}/docassert/rtm.py +0 -0
  93. {docassert-0.8.1 → docassert-0.10.0}/docassert/scaffold.py +0 -0
  94. {docassert-0.8.1 → docassert-0.10.0}/docassert/semantic.py +0 -0
  95. {docassert-0.8.1 → docassert-0.10.0}/docassert/status/__init__.py +0 -0
  96. {docassert-0.8.1 → docassert-0.10.0}/docassert/status/derive.py +0 -0
  97. {docassert-0.8.1 → docassert-0.10.0}/docassert/structural.py +0 -0
  98. {docassert-0.8.1 → docassert-0.10.0}/docassert.egg-info/dependency_links.txt +0 -0
  99. {docassert-0.8.1 → docassert-0.10.0}/docassert.egg-info/entry_points.txt +0 -0
  100. {docassert-0.8.1 → docassert-0.10.0}/docassert.egg-info/requires.txt +0 -0
  101. {docassert-0.8.1 → docassert-0.10.0}/docassert.egg-info/top_level.txt +0 -0
  102. {docassert-0.8.1 → docassert-0.10.0}/pyproject.toml +0 -0
  103. {docassert-0.8.1 → docassert-0.10.0}/setup.cfg +0 -0
  104. {docassert-0.8.1 → docassert-0.10.0}/tests/test_config.py +0 -0
  105. {docassert-0.8.1 → docassert-0.10.0}/tests/test_consistency.py +0 -0
  106. {docassert-0.8.1 → docassert-0.10.0}/tests/test_defects.py +0 -0
  107. {docassert-0.8.1 → docassert-0.10.0}/tests/test_draft_relaxation.py +0 -0
  108. {docassert-0.8.1 → docassert-0.10.0}/tests/test_extract.py +0 -0
  109. {docassert-0.8.1 → docassert-0.10.0}/tests/test_graph.py +0 -0
  110. {docassert-0.8.1 → docassert-0.10.0}/tests/test_json_report.py +0 -0
  111. {docassert-0.8.1 → docassert-0.10.0}/tests/test_kinds_delivery.py +0 -0
  112. {docassert-0.8.1 → docassert-0.10.0}/tests/test_kinds_governance.py +0 -0
  113. {docassert-0.8.1 → docassert-0.10.0}/tests/test_kinds_operate.py +0 -0
  114. {docassert-0.8.1 → docassert-0.10.0}/tests/test_kinds_reporting.py +0 -0
  115. {docassert-0.8.1 → docassert-0.10.0}/tests/test_profiles.py +0 -0
  116. {docassert-0.8.1 → docassert-0.10.0}/tests/test_projects.py +0 -0
  117. {docassert-0.8.1 → docassert-0.10.0}/tests/test_scaffold.py +0 -0
  118. {docassert-0.8.1 → docassert-0.10.0}/tests/test_status.py +0 -0
  119. {docassert-0.8.1 → docassert-0.10.0}/tests/test_structural.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docassert
3
- Version: 0.8.1
3
+ Version: 0.10.0
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
@@ -5,4 +5,4 @@ standard: deterministic structural checks that gate a merge, plus optional
5
5
  AI-graded semantic checks that advise.
6
6
  """
7
7
 
8
- __version__ = "0.8.1"
8
+ __version__ = "0.10.0"
@@ -0,0 +1,126 @@
1
+ """Projects v2 board operations (require a token with the `project` scope).
2
+
3
+ The board is a *view* over the bridge-managed issues: every Feature and Story
4
+ issue becomes a project item carrying Type, Doc, and PMO Project fields. All
5
+ mutations are idempotent: re-adding an existing item returns it, and field
6
+ writes converge on the same values.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from .gh import GhRunner
11
+
12
+ FIELD_SPECS: dict[str, dict] = {
13
+ "Type": {"dataType": "SINGLE_SELECT", "options": ["Feature", "Story"]},
14
+ "Doc": {"dataType": "TEXT"},
15
+ "PMO Project": {"dataType": "TEXT"},
16
+ }
17
+
18
+ _FIELDS_QUERY = """
19
+ query($owner: String!, $number: Int!) {
20
+ user(login: $owner) {
21
+ projectV2(number: $number) {
22
+ id
23
+ title
24
+ fields(first: 30) {
25
+ nodes {
26
+ ... on ProjectV2FieldCommon { id name dataType }
27
+ ... on ProjectV2SingleSelectField { id name dataType options { id name } }
28
+ }
29
+ }
30
+ }
31
+ }
32
+ }
33
+ """
34
+
35
+
36
+ def create_project(gh: GhRunner, title: str) -> dict:
37
+ viewer = gh.graphql("query { viewer { id login } }")["viewer"]
38
+ data = gh.graphql(
39
+ """
40
+ mutation($owner: ID!, $title: String!) {
41
+ createProjectV2(input: {ownerId: $owner, title: $title}) {
42
+ projectV2 { id number title }
43
+ }
44
+ }
45
+ """,
46
+ owner=viewer["id"], title=title)
47
+ return data["createProjectV2"]["projectV2"]
48
+
49
+
50
+ def get_project(gh: GhRunner, owner: str, number: int) -> dict:
51
+ proj = gh.graphql(_FIELDS_QUERY, owner=owner, number=number)["user"]["projectV2"]
52
+ fields = {}
53
+ for node in proj["fields"]["nodes"]:
54
+ if not node:
55
+ continue
56
+ fields[node["name"]] = {
57
+ "id": node["id"],
58
+ "dataType": node.get("dataType"),
59
+ "options": {o["name"]: o["id"] for o in node.get("options", [])},
60
+ }
61
+ return {"id": proj["id"], "title": proj["title"], "fields": fields}
62
+
63
+
64
+ def ensure_fields(gh: GhRunner, owner: str, number: int) -> dict:
65
+ """Create any missing bridge fields, then return the refreshed project."""
66
+ project = get_project(gh, owner, number)
67
+ for name, spec in FIELD_SPECS.items():
68
+ if name in project["fields"]:
69
+ continue
70
+ if spec["dataType"] == "SINGLE_SELECT":
71
+ opts = ", ".join(
72
+ f'{{name: "{o}", color: GRAY, description: ""}}'
73
+ for o in spec["options"])
74
+ gh.graphql(
75
+ "mutation($p: ID!, $n: String!) { createProjectV2Field(input: {"
76
+ "projectId: $p, dataType: SINGLE_SELECT, name: $n, "
77
+ f"singleSelectOptions: [{opts}]"
78
+ "}) { projectV2Field { ... on ProjectV2FieldCommon { id } } } }",
79
+ p=project["id"], n=name)
80
+ else:
81
+ gh.graphql(
82
+ "mutation($p: ID!, $n: String!) { createProjectV2Field(input: {"
83
+ "projectId: $p, dataType: TEXT, name: $n"
84
+ "}) { projectV2Field { ... on ProjectV2FieldCommon { id } } } }",
85
+ p=project["id"], n=name)
86
+ return get_project(gh, owner, number)
87
+
88
+
89
+ def add_item(gh: GhRunner, project_id: str, content_id: str) -> str:
90
+ data = gh.graphql(
91
+ "mutation($p: ID!, $c: ID!) { addProjectV2ItemById(input: {"
92
+ "projectId: $p, contentId: $c}) { item { id } } }",
93
+ p=project_id, c=content_id)
94
+ return data["addProjectV2ItemById"]["item"]["id"]
95
+
96
+
97
+ def set_text(gh: GhRunner, project_id: str, item_id: str, field_id: str,
98
+ value: str) -> None:
99
+ gh.graphql(
100
+ "mutation($p: ID!, $i: ID!, $f: ID!, $v: String!) {"
101
+ "updateProjectV2ItemFieldValue(input: {projectId: $p, itemId: $i, "
102
+ "fieldId: $f, value: {text: $v}}) { projectV2Item { id } } }",
103
+ p=project_id, i=item_id, f=field_id, v=value)
104
+
105
+
106
+ def set_option(gh: GhRunner, project_id: str, item_id: str, field_id: str,
107
+ option_id: str) -> None:
108
+ gh.graphql(
109
+ "mutation($p: ID!, $i: ID!, $f: ID!, $o: String!) {"
110
+ "updateProjectV2ItemFieldValue(input: {projectId: $p, itemId: $i, "
111
+ "fieldId: $f, value: {singleSelectOptionId: $o}}) "
112
+ "{ projectV2Item { id } } }",
113
+ p=project_id, i=item_id, f=field_id, o=option_id)
114
+
115
+
116
+ def sync_items(gh: GhRunner, project: dict, entries: list[dict]) -> int:
117
+ """entries: [{node_id, doc_id, type ('Feature'|'Story'), pmo_project}]."""
118
+ f = project["fields"]
119
+ type_field, doc_field, pmo_field = f["Type"], f["Doc"], f["PMO Project"]
120
+ for e in entries:
121
+ item = add_item(gh, project["id"], e["node_id"])
122
+ set_option(gh, project["id"], item, type_field["id"],
123
+ type_field["options"][e["type"]])
124
+ set_text(gh, project["id"], item, doc_field["id"], e["doc_id"])
125
+ set_text(gh, project["id"], item, pmo_field["id"], e["pmo_project"])
126
+ return len(entries)
@@ -15,11 +15,22 @@ class GhError(RuntimeError):
15
15
 
16
16
 
17
17
  class GhRunner:
18
- """Executes gh commands. Tests substitute a fake with the same interface."""
18
+ """Executes gh commands. Tests substitute a fake with the same interface.
19
+
20
+ `token` overrides GH_TOKEN for this runner only, so board operations can
21
+ use the project-scoped credential while issue operations keep the
22
+ default one.
23
+ """
24
+
25
+ def __init__(self, token: str | None = None) -> None:
26
+ self._env: dict[str, str] | None = None
27
+ if token:
28
+ import os
29
+ self._env = dict(os.environ, GH_TOKEN=token)
19
30
 
20
31
  def run(self, args: list[str], input_: str | None = None) -> str:
21
32
  proc = subprocess.run(["gh", *args], capture_output=True, text=True,
22
- input=input_)
33
+ input=input_, env=self._env)
23
34
  if proc.returncode != 0:
24
35
  raise GhError(proc.stderr.strip() or f"gh {' '.join(args)} failed")
25
36
  return proc.stdout
@@ -44,7 +55,8 @@ class DryRunner(GhRunner):
44
55
 
45
56
  MUTATING = ("-X POST", "-X PATCH", "-X PUT", "-X DELETE", "mutation")
46
57
 
47
- def __init__(self) -> None:
58
+ def __init__(self, token: str | None = None) -> None:
59
+ super().__init__(token)
48
60
  self.planned: list[str] = []
49
61
 
50
62
  def run(self, args: list[str], input_: str | None = None) -> str:
@@ -46,8 +46,14 @@ def _index(issues: list[dict]) -> dict[str, dict]:
46
46
 
47
47
 
48
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."""
49
+ docs_url: str | None = None,
50
+ board_cfg: dict | None = None) -> list[str]:
51
+ """Make the repo's issues match the plan. Returns human-readable actions.
52
+
53
+ board_cfg (optional): {"gh": GhRunner with project scope, "owner": login,
54
+ "number": int, "init": bool} — mirrors every managed issue onto the
55
+ Projects v2 board with Type/Doc/PMO Project fields.
56
+ """
51
57
  actions: list[str] = []
52
58
  for name, (color, desc) in LABELS.items():
53
59
  G.ensure_label(gh, repo, name, color, desc)
@@ -80,6 +86,28 @@ def scaffold(plan: BridgePlan, gh: G.GhRunner, repo: str,
80
86
  if pnode and cnode:
81
87
  G.add_sub_issue(gh, pnode, cnode)
82
88
 
89
+ if board_cfg:
90
+ from . import board as B
91
+ bgh = board_cfg["gh"]
92
+ if board_cfg.get("init"):
93
+ project = B.ensure_fields(bgh, board_cfg["owner"], board_cfg["number"])
94
+ actions.append(f"board: fields ensured on '{project['title']}'")
95
+ else:
96
+ project = B.get_project(bgh, board_cfg["owner"], board_cfg["number"])
97
+ entries = []
98
+ for f in plan.features:
99
+ issue = by_key.get(f.id)
100
+ if issue and issue.get("node_id"):
101
+ entries.append({"node_id": issue["node_id"], "doc_id": f.id,
102
+ "type": "Feature", "pmo_project": f.project})
103
+ for s in f.stories:
104
+ sissue = by_key.get(s.id)
105
+ if sissue and sissue.get("node_id"):
106
+ entries.append({"node_id": sissue["node_id"], "doc_id": s.id,
107
+ "type": "Story", "pmo_project": f.project})
108
+ n = B.sync_items(bgh, project, entries)
109
+ actions.append(f"board: {n} item(s) synced to '{project['title']}'")
110
+
83
111
  for sk in plan.skipped:
84
112
  actions.append(f"skipped {sk['id']}: {sk['reason']}")
85
113
  return actions
@@ -143,9 +171,28 @@ def reconcile(plan: BridgePlan, gh: G.GhRunner, repo: str) -> tuple[list[str], i
143
171
 
144
172
 
145
173
  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": []}
174
+ """Read-only: delivery progress per feature, plus scope classification.
175
+
176
+ Unlike reconcile, this never labels or comments; it only reports, so it is
177
+ safe to run from the dashboard build.
178
+ """
179
+ issues = G.list_issues(gh, repo)
180
+ by_key = _index(issues)
181
+ managed = plan.managed_ids
182
+ unverified, orphaned = [], []
183
+ for issue in issues:
184
+ if issue.get("state") != "open":
185
+ continue
186
+ key = _marker_of(issue)
187
+ if key == SCOPE_REPORT_KEY:
188
+ continue
189
+ if key is None:
190
+ unverified.append({"number": issue["number"], "title": issue["title"]})
191
+ elif key not in managed:
192
+ orphaned.append({"number": issue["number"], "title": issue["title"],
193
+ "doc": key})
194
+ out: dict = {"repo": repo, "projects": [],
195
+ "scope": {"unverified": unverified, "orphaned": orphaned}}
149
196
  for p in plan.projects:
150
197
  feats = []
151
198
  for f in p["features"]:
@@ -232,9 +232,19 @@ def cmd_pages(args: argparse.Namespace) -> int:
232
232
  status_mod.render_badge_json(index["overall"]["rag"]))
233
233
  (out / "badges").mkdir(exist_ok=True)
234
234
 
235
+ execution = {}
236
+ if getattr(args, "execution", None):
237
+ import json as _json
238
+ data = _json.loads(Path(args.execution).read_text())
239
+ for proj in data.get("projects", []):
240
+ execution[proj["id"]] = {**proj, "scope": data.get("scope"),
241
+ "repo": data.get("repo")}
242
+
235
243
  plist = projects_mod.load_projects(docs_dir)
236
244
  for p in plist:
237
245
  model = status_mod.build_status(docs_dir, project=p["id"])
246
+ if p["id"] in execution:
247
+ model["execution"] = execution[p["id"]]
238
248
  (out / f"{p['id']}.html").write_text(status_mod.render_html(model))
239
249
  (out / "badges" / f"{p['id']}.json").write_text(
240
250
  status_mod.render_badge_json(model["rag"], label=p["code"].lower()))
@@ -300,8 +310,31 @@ def cmd_bridge(args: argparse.Namespace) -> int:
300
310
  plan = build_bridge_plan(args.documents_dir)
301
311
  gh = ghmod.DryRunner() if getattr(args, "dry_run", False) else ghmod.GhRunner()
302
312
 
313
+ if args.action == "create-board":
314
+ from .bridge import board as board_mod
315
+ token = os.environ.get(args.board_token_env, "")
316
+ if not token and not getattr(args, "dry_run", False):
317
+ print(f"docassert bridge: no token in ${args.board_token_env}", file=sys.stderr)
318
+ return 2
319
+ bgh = gh if isinstance(gh, ghmod.DryRunner) else ghmod.GhRunner(token=token)
320
+ proj = board_mod.create_project(bgh, args.title or "PMO board")
321
+ print(f"docassert bridge: created project #{proj.get('number')} '{proj.get('title')}'")
322
+ return 0
323
+
324
+ board_cfg = None
325
+ if args.project_number:
326
+ token = os.environ.get(args.board_token_env, "")
327
+ if not token and not getattr(args, "dry_run", False):
328
+ print(f"docassert bridge: no token in ${args.board_token_env} "
329
+ "(needed for --project-number)", file=sys.stderr)
330
+ return 2
331
+ bgh = gh if isinstance(gh, ghmod.DryRunner) else ghmod.GhRunner(token=token)
332
+ board_cfg = {"gh": bgh, "owner": args.project_owner or args.repo.split("/")[0],
333
+ "number": args.project_number, "init": args.init_board}
334
+
303
335
  if args.action == "scaffold":
304
- actions = ops.scaffold(plan, gh, args.repo, docs_url=args.docs_url)
336
+ actions = ops.scaffold(plan, gh, args.repo, docs_url=args.docs_url,
337
+ board_cfg=board_cfg)
305
338
  for a in actions:
306
339
  print(f"docassert bridge: {a}")
307
340
  if isinstance(gh, ghmod.DryRunner):
@@ -375,6 +408,7 @@ def main(argv: list[str] | None = None) -> int:
375
408
 
376
409
  pg = sub.add_parser("pages", help="Build the full Pages site (portfolio index + a page per project).")
377
410
  pg.add_argument("--out", default="_site", help="Output directory (default: _site).")
411
+ pg.add_argument("--execution", help="Optional bridge-status JSON; adds Delivery and Scope panels to project pages.")
378
412
  docs_dir_opt(pg)
379
413
  pg.set_defaults(func=cmd_pages)
380
414
 
@@ -405,13 +439,19 @@ def main(argv: list[str] | None = None) -> int:
405
439
 
406
440
 
407
441
  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"],
442
+ b.add_argument("action", choices=["scaffold", "reconcile", "status", "create-board"],
409
443
  help="scaffold: docs -> issues/board · reconcile: police the board · status: delivery figures.")
410
444
  b.add_argument("--repo", required=True, help="Target GitHub repo (OWNER/NAME) holding the issues.")
411
445
  b.add_argument("--docs-url", help="Base URL of the documents repo, for source links in issue bodies.")
412
446
  b.add_argument("--dry-run", action="store_true", help="Print planned mutations without executing them.")
413
447
  b.add_argument("--json", action="store_true", help="status: emit JSON.")
414
448
  b.add_argument("--out", help="status --json: also write to this path.")
449
+ b.add_argument("--title", help="create-board: the new project's title.")
450
+ b.add_argument("--project-owner", help="Board owner login (default: the repo owner).")
451
+ b.add_argument("--project-number", type=int, help="Projects v2 board number to sync items onto.")
452
+ b.add_argument("--init-board", action="store_true", help="Ensure the Type/Doc/PMO Project fields exist.")
453
+ b.add_argument("--board-token-env", default="PROJECTS_TOKEN",
454
+ help="Env var holding the project-scoped token (default: PROJECTS_TOKEN).")
415
455
  docs_dir_opt(b)
416
456
  b.set_defaults(func=cmd_bridge)
417
457
 
@@ -205,6 +205,40 @@ def render_html(model) -> str:
205
205
  f'{note}<table><thead><tr><th>Kind</th><th>State</th></tr></thead>'
206
206
  f'<tbody>{req_rows}{sep}{rec_rows}</tbody></table></section>')
207
207
 
208
+ execution_html = ""
209
+ ex = model.get("execution")
210
+ if ex:
211
+ feat_rows: list[str] = []
212
+ for f in ex.get("features", []):
213
+ total = f.get("stories_total", 0) or 0
214
+ done = f.get("stories_closed", 0) or 0
215
+ pct = (100 * done // total) if total else 0
216
+ mark = "✓" if f.get("closed") else ""
217
+ issue = f' · <a href="https://github.com/{esc(ex.get("repo", ""))}/issues/{f["issue"]}">#{f["issue"]}</a>' if f.get("issue") else ""
218
+ feat_rows.append(
219
+ f'<div class="sig"><div class="sig-h"><span>{esc(f["id"])} {mark}</span>'
220
+ f'<span>{done}/{total} stories{issue}</span></div>'
221
+ f'<div class="bar"><div class="bar-f" style="width:{pct}%;background:var(--ok)"></div></div></div>')
222
+ overall_t = ex.get("stories_total", 0) or 0
223
+ overall_d = ex.get("stories_closed", 0) or 0
224
+ execution_html = (
225
+ f'<section><h2>Delivery · {overall_d}/{overall_t} stories closed</h2>'
226
+ '<p style="color:var(--muted);font-size:13px;margin:0 0 10px">'
227
+ "Read from the bridge-managed issues. Scope lives in these documents; "
228
+ "delivery lives on the board.</p>" + "".join(feat_rows) + "</section>")
229
+ scope = ex.get("scope") or {}
230
+ bad = scope.get("unverified", []) + scope.get("orphaned", [])
231
+ if bad:
232
+ items = "".join(
233
+ f'<li><a href="https://github.com/{esc(ex.get("repo", ""))}/issues/{i["number"]}">#{i["number"]}</a> {esc(i["title"])}</li>'
234
+ for i in bad)
235
+ execution_html += (
236
+ f'<section><h2 style="color:var(--bad)">Scope · {len(bad)} unmatched item(s)</h2>'
237
+ f'<ul>{items}</ul></section>')
238
+ else:
239
+ execution_html += ('<section><h2>Scope</h2><p style="color:var(--ok)">'
240
+ "Every open issue matches an approved item in the documents.</p></section>")
241
+
208
242
  title = esc(model.get("title", "Project Status"))
209
243
  pid = model.get("project")
210
244
  back = '<a class="back" href="index.html">← all projects</a>' if pid else ""
@@ -226,6 +260,7 @@ def render_html(model) -> str:
226
260
  {c['approved']} approved{scope} · generated {gen}. Do not edit — regenerated from the documents.</p>
227
261
  <section><h2>Traceability coverage</h2>{coverage}</section>
228
262
  {document_set}
263
+ {execution_html}
229
264
  {problems}
230
265
  <section><h2>Open risks ({len(model['risks'])})</h2><ul>{risks}</ul></section>
231
266
  <section><h2>Latest status report</h2><p>{report}</p></section>
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docassert
3
- Version: 0.8.1
3
+ Version: 0.10.0
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
@@ -90,6 +90,7 @@ 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
92
  docassert/bridge/__init__.py
93
+ docassert/bridge/board.py
93
94
  docassert/bridge/gh.py
94
95
  docassert/bridge/ops.py
95
96
  docassert/bridge/plan.py
@@ -24,3 +24,25 @@ def test_pages_emits_badges(tmp_path, monkeypatch):
24
24
  assert overall["schemaVersion"] == 1 and overall["message"] in {"green", "amber", "red"}
25
25
  aur = json.loads((out / "badges" / "PRJ-001-AUR.json").read_text())
26
26
  assert aur["label"] == "aur" and aur["message"] in {"green", "amber", "red"}
27
+
28
+
29
+ def test_pages_execution_panels(tmp_path, monkeypatch):
30
+ import json
31
+ monkeypatch.chdir(ROOT)
32
+ exec_file = tmp_path / "execution.json"
33
+ exec_file.write_text(json.dumps({
34
+ "repo": "o/r",
35
+ "projects": [{"id": "PRJ-001-AUR",
36
+ "features": [{"id": "AUR-PR-014", "issue": 5,
37
+ "stories_total": 2, "stories_closed": 1,
38
+ "closed": False}],
39
+ "stories_closed": 1, "stories_total": 2}],
40
+ "scope": {"unverified": [], "orphaned": []},
41
+ }))
42
+ out = tmp_path / "site"
43
+ assert main(["pages", "--out", str(out), "--execution", str(exec_file)]) == 0
44
+ html = (out / "PRJ-001-AUR.html").read_text()
45
+ assert "Delivery · 1/2 stories closed" in html
46
+ assert "AUR-PR-014" in html and "Every open issue matches" in html
47
+ other = (out / "PRJ-002-ATL.html").read_text()
48
+ assert "Delivery ·" not in other # no execution data, no panel
@@ -247,3 +247,91 @@ def test_add_sub_issue_still_raises_on_real_errors(tmp_path):
247
247
  import pytest
248
248
  with pytest.raises(GhError):
249
249
  add_sub_issue(BoomGh(), "N1", "N2")
250
+
251
+
252
+ # ── board ops ────────────────────────────────────────────────────────────────
253
+ class BoardGh(FakeGh):
254
+ """GraphQL router for Projects v2 shapes."""
255
+
256
+ def __init__(self, existing_fields=()):
257
+ super().__init__()
258
+ self.gql: list[str] = []
259
+ self._fields = list(existing_fields)
260
+
261
+ def graphql(self, query, **vars_):
262
+ self.gql.append(query)
263
+ if "viewer" in query:
264
+ return {"viewer": {"id": "U1", "login": "tester"}}
265
+ if "createProjectV2(" in query:
266
+ return {"createProjectV2": {"projectV2": {"id": "P1", "number": 7,
267
+ "title": vars_.get("title")}}}
268
+ if "projectV2(number" in query:
269
+ nodes = [{"id": f"F-{n}", "name": n, "dataType": "TEXT"}
270
+ for n in self._fields]
271
+ if "Type" in self._fields:
272
+ for node in nodes:
273
+ if node["name"] == "Type":
274
+ node["dataType"] = "SINGLE_SELECT"
275
+ node["options"] = [{"id": "O1", "name": "Feature"},
276
+ {"id": "O2", "name": "Story"}]
277
+ return {"user": {"projectV2": {"id": "P1", "title": "Board",
278
+ "fields": {"nodes": nodes}}}}
279
+ if "createProjectV2Field" in query:
280
+ self._fields.append(vars_["n"])
281
+ return {"createProjectV2Field": {"projectV2Field": {"id": "F-new"}}}
282
+ if "addProjectV2ItemById" in query:
283
+ return {"addProjectV2ItemById": {"item": {"id": "I1"}}}
284
+ if "updateProjectV2ItemFieldValue" in query:
285
+ return {"updateProjectV2ItemFieldValue": {"projectV2Item": {"id": "I1"}}}
286
+ return {}
287
+
288
+
289
+ def test_create_project():
290
+ from docassert.bridge import board
291
+ gh = BoardGh()
292
+ proj = board.create_project(gh, "Refuge for Humans")
293
+ assert proj["number"] == 7 and proj["title"] == "Refuge for Humans"
294
+
295
+
296
+ def test_ensure_fields_creates_missing():
297
+ from docassert.bridge import board
298
+ gh = BoardGh(existing_fields=["Status"])
299
+ project = board.ensure_fields(gh, "tester", 7)
300
+ assert set(project["fields"]) >= {"Type", "Doc", "PMO Project"}
301
+
302
+
303
+ def test_scaffold_syncs_board(tmp_path):
304
+ plan = build_bridge_plan(_tree(tmp_path))
305
+ existing = []
306
+ for i, f in enumerate(plan.features):
307
+ e = _mk_issue(200 + i, f.id, feature_title(f))
308
+ e["body"] = feature_body(f, None)
309
+ existing.append(e)
310
+ for j, s in enumerate(f.stories):
311
+ from docassert.bridge.plan import story_title
312
+ e2 = _mk_issue(300 + i * 10 + j, s.id, story_title(s))
313
+ e2["body"] = story_body(s, None)
314
+ existing.append(e2)
315
+ gh = FakeGh(existing)
316
+ bgh = BoardGh(existing_fields=["Type", "Doc", "PMO Project"])
317
+ actions = ops.scaffold(plan, gh, "o/r",
318
+ board_cfg={"gh": bgh, "owner": "tester", "number": 7,
319
+ "init": False})
320
+ assert any("5 item(s) synced" in a for a in actions)
321
+ adds = [q for q in bgh.gql if "addProjectV2ItemById" in q]
322
+ assert len(adds) == 5
323
+
324
+
325
+ def test_status_includes_scope_classification(tmp_path):
326
+ plan = build_bridge_plan(_tree(tmp_path))
327
+ issues = [
328
+ _mk_issue(1, "TST-US-001"),
329
+ {"number": 2, "node_id": "N2", "state": "open", "title": "rogue",
330
+ "body": "no marker", "labels": []},
331
+ _mk_issue(3, "TST-US-999"),
332
+ _mk_issue(4, "TST-US-002", state="closed"), # closed: not scope-checked
333
+ ]
334
+ data = ops.status(plan, FakeGh(issues), "o/r")
335
+ assert data["repo"] == "o/r"
336
+ assert [i["number"] for i in data["scope"]["unverified"]] == [2]
337
+ assert [i["doc"] for i in data["scope"]["orphaned"]] == ["TST-US-999"]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes