docassert 0.8.0__tar.gz → 0.9.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.0/docassert.egg-info → docassert-0.9.0}/PKG-INFO +1 -1
  2. {docassert-0.8.0 → docassert-0.9.0}/docassert/__init__.py +1 -1
  3. docassert-0.9.0/docassert/bridge/board.py +126 -0
  4. {docassert-0.8.0 → docassert-0.9.0}/docassert/bridge/gh.py +20 -4
  5. {docassert-0.8.0 → docassert-0.9.0}/docassert/bridge/ops.py +30 -2
  6. {docassert-0.8.0 → docassert-0.9.0}/docassert/cli.py +31 -2
  7. {docassert-0.8.0 → docassert-0.9.0/docassert.egg-info}/PKG-INFO +1 -1
  8. {docassert-0.8.0 → docassert-0.9.0}/docassert.egg-info/SOURCES.txt +1 -0
  9. {docassert-0.8.0 → docassert-0.9.0}/tests/test_bridge.py +101 -3
  10. {docassert-0.8.0 → docassert-0.9.0}/LICENSE +0 -0
  11. {docassert-0.8.0 → docassert-0.9.0}/NOTICE +0 -0
  12. {docassert-0.8.0 → docassert-0.9.0}/README.md +0 -0
  13. {docassert-0.8.0 → docassert-0.9.0}/docassert/__main__.py +0 -0
  14. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/consistency.yaml +0 -0
  15. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/criteria/adr.criteria.yaml +0 -0
  16. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/criteria/benefits-realization.criteria.yaml +0 -0
  17. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/criteria/brd.criteria.yaml +0 -0
  18. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/criteria/business-case.criteria.yaml +0 -0
  19. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/criteria/charter.criteria.yaml +0 -0
  20. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/criteria/data-migration-plan.criteria.yaml +0 -0
  21. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/criteria/frnfr.criteria.yaml +0 -0
  22. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/criteria/hypercare-plan.criteria.yaml +0 -0
  23. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/criteria/post-implementation-review.criteria.yaml +0 -0
  24. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/criteria/prd.criteria.yaml +0 -0
  25. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/criteria/project.criteria.yaml +0 -0
  26. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/criteria/qa-test-plan.criteria.yaml +0 -0
  27. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/criteria/raci-stakeholder.criteria.yaml +0 -0
  28. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/criteria/release-cutover-plan.criteria.yaml +0 -0
  29. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/criteria/risk-register.criteria.yaml +0 -0
  30. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/criteria/rollback-plan.criteria.yaml +0 -0
  31. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/criteria/runbook.criteria.yaml +0 -0
  32. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/criteria/status-report.criteria.yaml +0 -0
  33. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/criteria/test-cases.criteria.yaml +0 -0
  34. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/criteria/user-story.criteria.yaml +0 -0
  35. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/profiles/agile-delivery.yaml +0 -0
  36. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/profiles/lean-startup.yaml +0 -0
  37. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/profiles/regulated-industry.yaml +0 -0
  38. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/schema/adr.schema.json +0 -0
  39. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/schema/benefits-realization.schema.json +0 -0
  40. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/schema/brd.schema.json +0 -0
  41. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/schema/business-case.schema.json +0 -0
  42. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/schema/charter.schema.json +0 -0
  43. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/schema/data-migration-plan.schema.json +0 -0
  44. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/schema/frnfr.schema.json +0 -0
  45. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/schema/hypercare-plan.schema.json +0 -0
  46. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/schema/post-implementation-review.schema.json +0 -0
  47. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/schema/prd.schema.json +0 -0
  48. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/schema/project.schema.json +0 -0
  49. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/schema/qa-test-plan.schema.json +0 -0
  50. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/schema/raci-stakeholder.schema.json +0 -0
  51. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/schema/release-cutover-plan.schema.json +0 -0
  52. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/schema/risk-register.schema.json +0 -0
  53. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/schema/rollback-plan.schema.json +0 -0
  54. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/schema/runbook.schema.json +0 -0
  55. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/schema/status-report.schema.json +0 -0
  56. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/schema/test-cases.schema.json +0 -0
  57. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/schema/user-story.schema.json +0 -0
  58. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/skills/doc-to-pmo/SKILL.md +0 -0
  59. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/templates/adr.template.md +0 -0
  60. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/templates/benefits-realization.template.md +0 -0
  61. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/templates/brd.template.md +0 -0
  62. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/templates/business-case.template.md +0 -0
  63. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/templates/charter.template.md +0 -0
  64. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/templates/data-migration-plan.template.md +0 -0
  65. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/templates/frnfr.template.md +0 -0
  66. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/templates/hypercare-plan.template.md +0 -0
  67. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/templates/post-implementation-review.template.md +0 -0
  68. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/templates/prd.template.md +0 -0
  69. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/templates/project.template.md +0 -0
  70. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/templates/qa-test-plan.template.md +0 -0
  71. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/templates/raci-stakeholder.template.md +0 -0
  72. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/templates/release-cutover-plan.template.md +0 -0
  73. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/templates/risk-register.template.md +0 -0
  74. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/templates/rollback-plan.template.md +0 -0
  75. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/templates/runbook.template.md +0 -0
  76. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/templates/status-report.template.md +0 -0
  77. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/templates/test-cases.template.md +0 -0
  78. {docassert-0.8.0 → docassert-0.9.0}/docassert/_data/templates/user-story.template.md +0 -0
  79. {docassert-0.8.0 → docassert-0.9.0}/docassert/bridge/__init__.py +0 -0
  80. {docassert-0.8.0 → docassert-0.9.0}/docassert/bridge/plan.py +0 -0
  81. {docassert-0.8.0 → docassert-0.9.0}/docassert/config.py +0 -0
  82. {docassert-0.8.0 → docassert-0.9.0}/docassert/consistency.py +0 -0
  83. {docassert-0.8.0 → docassert-0.9.0}/docassert/extract.py +0 -0
  84. {docassert-0.8.0 → docassert-0.9.0}/docassert/graph.py +0 -0
  85. {docassert-0.8.0 → docassert-0.9.0}/docassert/loader.py +0 -0
  86. {docassert-0.8.0 → docassert-0.9.0}/docassert/models.py +0 -0
  87. {docassert-0.8.0 → docassert-0.9.0}/docassert/profiles.py +0 -0
  88. {docassert-0.8.0 → docassert-0.9.0}/docassert/projects.py +0 -0
  89. {docassert-0.8.0 → docassert-0.9.0}/docassert/report.py +0 -0
  90. {docassert-0.8.0 → docassert-0.9.0}/docassert/rtm.py +0 -0
  91. {docassert-0.8.0 → docassert-0.9.0}/docassert/scaffold.py +0 -0
  92. {docassert-0.8.0 → docassert-0.9.0}/docassert/semantic.py +0 -0
  93. {docassert-0.8.0 → docassert-0.9.0}/docassert/status/__init__.py +0 -0
  94. {docassert-0.8.0 → docassert-0.9.0}/docassert/status/derive.py +0 -0
  95. {docassert-0.8.0 → docassert-0.9.0}/docassert/status/render.py +0 -0
  96. {docassert-0.8.0 → docassert-0.9.0}/docassert/structural.py +0 -0
  97. {docassert-0.8.0 → docassert-0.9.0}/docassert.egg-info/dependency_links.txt +0 -0
  98. {docassert-0.8.0 → docassert-0.9.0}/docassert.egg-info/entry_points.txt +0 -0
  99. {docassert-0.8.0 → docassert-0.9.0}/docassert.egg-info/requires.txt +0 -0
  100. {docassert-0.8.0 → docassert-0.9.0}/docassert.egg-info/top_level.txt +0 -0
  101. {docassert-0.8.0 → docassert-0.9.0}/pyproject.toml +0 -0
  102. {docassert-0.8.0 → docassert-0.9.0}/setup.cfg +0 -0
  103. {docassert-0.8.0 → docassert-0.9.0}/tests/test_badge.py +0 -0
  104. {docassert-0.8.0 → docassert-0.9.0}/tests/test_config.py +0 -0
  105. {docassert-0.8.0 → docassert-0.9.0}/tests/test_consistency.py +0 -0
  106. {docassert-0.8.0 → docassert-0.9.0}/tests/test_defects.py +0 -0
  107. {docassert-0.8.0 → docassert-0.9.0}/tests/test_draft_relaxation.py +0 -0
  108. {docassert-0.8.0 → docassert-0.9.0}/tests/test_extract.py +0 -0
  109. {docassert-0.8.0 → docassert-0.9.0}/tests/test_graph.py +0 -0
  110. {docassert-0.8.0 → docassert-0.9.0}/tests/test_json_report.py +0 -0
  111. {docassert-0.8.0 → docassert-0.9.0}/tests/test_kinds_delivery.py +0 -0
  112. {docassert-0.8.0 → docassert-0.9.0}/tests/test_kinds_governance.py +0 -0
  113. {docassert-0.8.0 → docassert-0.9.0}/tests/test_kinds_operate.py +0 -0
  114. {docassert-0.8.0 → docassert-0.9.0}/tests/test_kinds_reporting.py +0 -0
  115. {docassert-0.8.0 → docassert-0.9.0}/tests/test_profiles.py +0 -0
  116. {docassert-0.8.0 → docassert-0.9.0}/tests/test_projects.py +0 -0
  117. {docassert-0.8.0 → docassert-0.9.0}/tests/test_scaffold.py +0 -0
  118. {docassert-0.8.0 → docassert-0.9.0}/tests/test_status.py +0 -0
  119. {docassert-0.8.0 → docassert-0.9.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.0
3
+ Version: 0.9.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.0"
8
+ __version__ = "0.9.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:
@@ -110,5 +122,9 @@ def add_sub_issue(gh: GhRunner, parent_node: str, child_node: str) -> None:
110
122
  try:
111
123
  gh.graphql(q, p=parent_node, c=child_node)
112
124
  except GhError as exc:
113
- if "already" not in str(exc).lower(): # re-link is fine
125
+ # An existing identical link is success, not failure. GitHub words it
126
+ # several ways ("duplicate sub-issues", "may only have one parent").
127
+ msg = str(exc).lower()
128
+ if not any(s in msg for s in ("already", "duplicate sub-issue",
129
+ "only have one parent")):
114
130
  raise
@@ -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
@@ -300,8 +300,31 @@ def cmd_bridge(args: argparse.Namespace) -> int:
300
300
  plan = build_bridge_plan(args.documents_dir)
301
301
  gh = ghmod.DryRunner() if getattr(args, "dry_run", False) else ghmod.GhRunner()
302
302
 
303
+ if args.action == "create-board":
304
+ from .bridge import board as board_mod
305
+ token = os.environ.get(args.board_token_env, "")
306
+ if not token and not getattr(args, "dry_run", False):
307
+ print(f"docassert bridge: no token in ${args.board_token_env}", file=sys.stderr)
308
+ return 2
309
+ bgh = gh if isinstance(gh, ghmod.DryRunner) else ghmod.GhRunner(token=token)
310
+ proj = board_mod.create_project(bgh, args.title or "PMO board")
311
+ print(f"docassert bridge: created project #{proj.get('number')} '{proj.get('title')}'")
312
+ return 0
313
+
314
+ board_cfg = None
315
+ if args.project_number:
316
+ token = os.environ.get(args.board_token_env, "")
317
+ if not token and not getattr(args, "dry_run", False):
318
+ print(f"docassert bridge: no token in ${args.board_token_env} "
319
+ "(needed for --project-number)", file=sys.stderr)
320
+ return 2
321
+ bgh = gh if isinstance(gh, ghmod.DryRunner) else ghmod.GhRunner(token=token)
322
+ board_cfg = {"gh": bgh, "owner": args.project_owner or args.repo.split("/")[0],
323
+ "number": args.project_number, "init": args.init_board}
324
+
303
325
  if args.action == "scaffold":
304
- actions = ops.scaffold(plan, gh, args.repo, docs_url=args.docs_url)
326
+ actions = ops.scaffold(plan, gh, args.repo, docs_url=args.docs_url,
327
+ board_cfg=board_cfg)
305
328
  for a in actions:
306
329
  print(f"docassert bridge: {a}")
307
330
  if isinstance(gh, ghmod.DryRunner):
@@ -405,13 +428,19 @@ def main(argv: list[str] | None = None) -> int:
405
428
 
406
429
 
407
430
  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"],
431
+ b.add_argument("action", choices=["scaffold", "reconcile", "status", "create-board"],
409
432
  help="scaffold: docs -> issues/board · reconcile: police the board · status: delivery figures.")
410
433
  b.add_argument("--repo", required=True, help="Target GitHub repo (OWNER/NAME) holding the issues.")
411
434
  b.add_argument("--docs-url", help="Base URL of the documents repo, for source links in issue bodies.")
412
435
  b.add_argument("--dry-run", action="store_true", help="Print planned mutations without executing them.")
413
436
  b.add_argument("--json", action="store_true", help="status: emit JSON.")
414
437
  b.add_argument("--out", help="status --json: also write to this path.")
438
+ b.add_argument("--title", help="create-board: the new project's title.")
439
+ b.add_argument("--project-owner", help="Board owner login (default: the repo owner).")
440
+ b.add_argument("--project-number", type=int, help="Projects v2 board number to sync items onto.")
441
+ b.add_argument("--init-board", action="store_true", help="Ensure the Type/Doc/PMO Project fields exist.")
442
+ b.add_argument("--board-token-env", default="PROJECTS_TOKEN",
443
+ help="Env var holding the project-scoped token (default: PROJECTS_TOKEN).")
415
444
  docs_dir_opt(b)
416
445
  b.set_defaults(func=cmd_bridge)
417
446
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docassert
3
- Version: 0.8.0
3
+ Version: 0.9.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
@@ -153,9 +153,7 @@ def test_scaffold_creates_then_is_idempotent(tmp_path):
153
153
  gh = FakeGh()
154
154
  actions = ops.scaffold(plan, gh, "o/r")
155
155
  assert sum(1 for a in actions if a.startswith("created")) == 5 # 2 feat + 3 stories
156
- # second run against the issues the first created
157
- issues = [_mk_issue(101, "TST-PR-001", feature_title(plan.features[0]))]
158
- # simulate full existing state: everything exists with correct title/body
156
+ # second run: simulate full existing state with correct titles/bodies
159
157
  existing = []
160
158
  for i, f in enumerate(plan.features):
161
159
  e = _mk_issue(200 + i, f.id, feature_title(f))
@@ -222,3 +220,103 @@ def test_status_counts_closed_stories(tmp_path):
222
220
  assert feats["TST-PR-001"]["stories_closed"] == 1
223
221
  assert feats["TST-PR-001"]["stories_total"] == 2
224
222
  assert "1/2" in ops.render_status(data)
223
+
224
+
225
+ def test_scaffold_tolerates_existing_sub_issue_link(tmp_path):
226
+ """GitHub's duplicate-link error (observed live 2026-07-02) is success."""
227
+ from docassert.bridge.gh import GhError
228
+
229
+ class LinkedGh(FakeGh):
230
+ def graphql(self, query, **vars_):
231
+ raise GhError("gh: Failed to add sub-issue #2 to parent #1. "
232
+ "Issue may not contain duplicate sub-issues and "
233
+ "Sub issue may only have one parent")
234
+
235
+ plan = build_bridge_plan(_tree(tmp_path))
236
+ gh = LinkedGh()
237
+ ops.scaffold(plan, gh, "o/r") # must not raise
238
+
239
+
240
+ def test_add_sub_issue_still_raises_on_real_errors(tmp_path):
241
+ from docassert.bridge.gh import GhError, add_sub_issue
242
+
243
+ class BoomGh(FakeGh):
244
+ def graphql(self, query, **vars_):
245
+ raise GhError("gh: Something else entirely")
246
+
247
+ import pytest
248
+ with pytest.raises(GhError):
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
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