docassert 0.13.0__tar.gz → 0.14.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.
Files changed (120) hide show
  1. {docassert-0.13.0/docassert.egg-info → docassert-0.14.1}/PKG-INFO +1 -1
  2. {docassert-0.13.0 → docassert-0.14.1}/docassert/__init__.py +1 -1
  3. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/schema/project.schema.json +5 -0
  4. {docassert-0.13.0 → docassert-0.14.1}/docassert/bridge/board.py +23 -17
  5. {docassert-0.13.0 → docassert-0.14.1}/docassert/bridge/ops.py +29 -0
  6. {docassert-0.13.0 → docassert-0.14.1}/docassert/bridge/plan.py +33 -1
  7. {docassert-0.13.0 → docassert-0.14.1}/docassert/cli.py +53 -15
  8. {docassert-0.13.0 → docassert-0.14.1}/docassert/status/render.py +1 -1
  9. {docassert-0.13.0 → docassert-0.14.1/docassert.egg-info}/PKG-INFO +1 -1
  10. {docassert-0.13.0 → docassert-0.14.1}/tests/test_bridge.py +121 -0
  11. {docassert-0.13.0 → docassert-0.14.1}/LICENSE +0 -0
  12. {docassert-0.13.0 → docassert-0.14.1}/NOTICE +0 -0
  13. {docassert-0.13.0 → docassert-0.14.1}/README.md +0 -0
  14. {docassert-0.13.0 → docassert-0.14.1}/docassert/__main__.py +0 -0
  15. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/consistency.yaml +0 -0
  16. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/criteria/adr.criteria.yaml +0 -0
  17. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/criteria/benefits-realization.criteria.yaml +0 -0
  18. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/criteria/brd.criteria.yaml +0 -0
  19. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/criteria/business-case.criteria.yaml +0 -0
  20. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/criteria/charter.criteria.yaml +0 -0
  21. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/criteria/data-migration-plan.criteria.yaml +0 -0
  22. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/criteria/frnfr.criteria.yaml +0 -0
  23. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/criteria/hypercare-plan.criteria.yaml +0 -0
  24. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/criteria/post-implementation-review.criteria.yaml +0 -0
  25. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/criteria/prd.criteria.yaml +0 -0
  26. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/criteria/project.criteria.yaml +0 -0
  27. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/criteria/qa-test-plan.criteria.yaml +0 -0
  28. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/criteria/raci-stakeholder.criteria.yaml +0 -0
  29. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/criteria/release-cutover-plan.criteria.yaml +0 -0
  30. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/criteria/risk-register.criteria.yaml +0 -0
  31. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/criteria/rollback-plan.criteria.yaml +0 -0
  32. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/criteria/runbook.criteria.yaml +0 -0
  33. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/criteria/status-report.criteria.yaml +0 -0
  34. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/criteria/test-cases.criteria.yaml +0 -0
  35. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/criteria/user-story.criteria.yaml +0 -0
  36. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/profiles/agile-delivery.yaml +0 -0
  37. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/profiles/lean-startup.yaml +0 -0
  38. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/profiles/regulated-industry.yaml +0 -0
  39. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/schema/adr.schema.json +0 -0
  40. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/schema/benefits-realization.schema.json +0 -0
  41. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/schema/brd.schema.json +0 -0
  42. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/schema/business-case.schema.json +0 -0
  43. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/schema/charter.schema.json +0 -0
  44. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/schema/data-migration-plan.schema.json +0 -0
  45. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/schema/frnfr.schema.json +0 -0
  46. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/schema/hypercare-plan.schema.json +0 -0
  47. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/schema/post-implementation-review.schema.json +0 -0
  48. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/schema/prd.schema.json +0 -0
  49. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/schema/qa-test-plan.schema.json +0 -0
  50. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/schema/raci-stakeholder.schema.json +0 -0
  51. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/schema/release-cutover-plan.schema.json +0 -0
  52. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/schema/risk-register.schema.json +0 -0
  53. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/schema/rollback-plan.schema.json +0 -0
  54. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/schema/runbook.schema.json +0 -0
  55. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/schema/status-report.schema.json +0 -0
  56. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/schema/test-cases.schema.json +0 -0
  57. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/schema/user-story.schema.json +0 -0
  58. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/skills/doc-to-pmo/SKILL.md +0 -0
  59. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/templates/adr.template.md +0 -0
  60. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/templates/benefits-realization.template.md +0 -0
  61. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/templates/brd.template.md +0 -0
  62. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/templates/business-case.template.md +0 -0
  63. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/templates/charter.template.md +0 -0
  64. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/templates/data-migration-plan.template.md +0 -0
  65. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/templates/frnfr.template.md +0 -0
  66. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/templates/hypercare-plan.template.md +0 -0
  67. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/templates/post-implementation-review.template.md +0 -0
  68. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/templates/prd.template.md +0 -0
  69. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/templates/project.template.md +0 -0
  70. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/templates/qa-test-plan.template.md +0 -0
  71. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/templates/raci-stakeholder.template.md +0 -0
  72. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/templates/release-cutover-plan.template.md +0 -0
  73. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/templates/risk-register.template.md +0 -0
  74. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/templates/rollback-plan.template.md +0 -0
  75. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/templates/runbook.template.md +0 -0
  76. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/templates/status-report.template.md +0 -0
  77. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/templates/test-cases.template.md +0 -0
  78. {docassert-0.13.0 → docassert-0.14.1}/docassert/_data/templates/user-story.template.md +0 -0
  79. {docassert-0.13.0 → docassert-0.14.1}/docassert/bridge/__init__.py +0 -0
  80. {docassert-0.13.0 → docassert-0.14.1}/docassert/bridge/gh.py +0 -0
  81. {docassert-0.13.0 → docassert-0.14.1}/docassert/config.py +0 -0
  82. {docassert-0.13.0 → docassert-0.14.1}/docassert/consistency.py +0 -0
  83. {docassert-0.13.0 → docassert-0.14.1}/docassert/extract.py +0 -0
  84. {docassert-0.13.0 → docassert-0.14.1}/docassert/graph.py +0 -0
  85. {docassert-0.13.0 → docassert-0.14.1}/docassert/loader.py +0 -0
  86. {docassert-0.13.0 → docassert-0.14.1}/docassert/models.py +0 -0
  87. {docassert-0.13.0 → docassert-0.14.1}/docassert/profiles.py +0 -0
  88. {docassert-0.13.0 → docassert-0.14.1}/docassert/projects.py +0 -0
  89. {docassert-0.13.0 → docassert-0.14.1}/docassert/report.py +0 -0
  90. {docassert-0.13.0 → docassert-0.14.1}/docassert/rtm.py +0 -0
  91. {docassert-0.13.0 → docassert-0.14.1}/docassert/scaffold.py +0 -0
  92. {docassert-0.13.0 → docassert-0.14.1}/docassert/semantic.py +0 -0
  93. {docassert-0.13.0 → docassert-0.14.1}/docassert/status/__init__.py +0 -0
  94. {docassert-0.13.0 → docassert-0.14.1}/docassert/status/derive.py +0 -0
  95. {docassert-0.13.0 → docassert-0.14.1}/docassert/structural.py +0 -0
  96. {docassert-0.13.0 → docassert-0.14.1}/docassert.egg-info/SOURCES.txt +0 -0
  97. {docassert-0.13.0 → docassert-0.14.1}/docassert.egg-info/dependency_links.txt +0 -0
  98. {docassert-0.13.0 → docassert-0.14.1}/docassert.egg-info/entry_points.txt +0 -0
  99. {docassert-0.13.0 → docassert-0.14.1}/docassert.egg-info/requires.txt +0 -0
  100. {docassert-0.13.0 → docassert-0.14.1}/docassert.egg-info/top_level.txt +0 -0
  101. {docassert-0.13.0 → docassert-0.14.1}/pyproject.toml +0 -0
  102. {docassert-0.13.0 → docassert-0.14.1}/setup.cfg +0 -0
  103. {docassert-0.13.0 → docassert-0.14.1}/tests/test_badge.py +0 -0
  104. {docassert-0.13.0 → docassert-0.14.1}/tests/test_config.py +0 -0
  105. {docassert-0.13.0 → docassert-0.14.1}/tests/test_consistency.py +0 -0
  106. {docassert-0.13.0 → docassert-0.14.1}/tests/test_defects.py +0 -0
  107. {docassert-0.13.0 → docassert-0.14.1}/tests/test_draft_relaxation.py +0 -0
  108. {docassert-0.13.0 → docassert-0.14.1}/tests/test_extract.py +0 -0
  109. {docassert-0.13.0 → docassert-0.14.1}/tests/test_graph.py +0 -0
  110. {docassert-0.13.0 → docassert-0.14.1}/tests/test_json_report.py +0 -0
  111. {docassert-0.13.0 → docassert-0.14.1}/tests/test_kinds_delivery.py +0 -0
  112. {docassert-0.13.0 → docassert-0.14.1}/tests/test_kinds_governance.py +0 -0
  113. {docassert-0.13.0 → docassert-0.14.1}/tests/test_kinds_operate.py +0 -0
  114. {docassert-0.13.0 → docassert-0.14.1}/tests/test_kinds_reporting.py +0 -0
  115. {docassert-0.13.0 → docassert-0.14.1}/tests/test_profiles.py +0 -0
  116. {docassert-0.13.0 → docassert-0.14.1}/tests/test_projects.py +0 -0
  117. {docassert-0.13.0 → docassert-0.14.1}/tests/test_scaffold.py +0 -0
  118. {docassert-0.13.0 → docassert-0.14.1}/tests/test_semantic.py +0 -0
  119. {docassert-0.13.0 → docassert-0.14.1}/tests/test_status.py +0 -0
  120. {docassert-0.13.0 → docassert-0.14.1}/tests/test_structural.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docassert
3
- Version: 0.13.0
3
+ Version: 0.14.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
@@ -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.13.0"
8
+ __version__ = "0.14.1"
@@ -7,6 +7,11 @@
7
7
  "additionalProperties": true,
8
8
  "properties": {
9
9
  "kind": { "const": "project" },
10
+ "repo": {
11
+ "type": "string",
12
+ "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$",
13
+ "description": "Optional OWNER/NAME of the code repository this project's execution bridge targets."
14
+ },
10
15
  "id": {
11
16
  "type": "string",
12
17
  "pattern": "^PRJ-\\d{3,}-[A-Z]{2,6}$",
@@ -7,7 +7,7 @@ writes converge on the same values.
7
7
  """
8
8
  from __future__ import annotations
9
9
 
10
- from .gh import GhRunner
10
+ from .gh import GhError, GhRunner
11
11
 
12
12
  FIELD_SPECS: dict[str, dict] = {
13
13
  "Type": {"dataType": "SINGLE_SELECT", "options": ["Feature", "Story"]},
@@ -67,22 +67,28 @@ def ensure_fields(gh: GhRunner, owner: str, number: int) -> dict:
67
67
  for name, spec in FIELD_SPECS.items():
68
68
  if name in project["fields"]:
69
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)
70
+ try:
71
+ if spec["dataType"] == "SINGLE_SELECT":
72
+ opts = ", ".join(
73
+ f'{{name: "{o}", color: GRAY, description: ""}}'
74
+ for o in spec["options"])
75
+ gh.graphql(
76
+ "mutation($p: ID!, $n: String!) { createProjectV2Field(input: {"
77
+ "projectId: $p, dataType: SINGLE_SELECT, name: $n, "
78
+ f"singleSelectOptions: [{opts}]"
79
+ "}) { projectV2Field { ... on ProjectV2FieldCommon { id } } } }",
80
+ p=project["id"], n=name)
81
+ else:
82
+ gh.graphql(
83
+ "mutation($p: ID!, $n: String!) { createProjectV2Field(input: {"
84
+ "projectId: $p, dataType: TEXT, name: $n"
85
+ "}) { projectV2Field { ... on ProjectV2FieldCommon { id } } } }",
86
+ p=project["id"], n=name)
87
+ except GhError as exc:
88
+ # A concurrent sync created it between our read and our write;
89
+ # an existing field is the outcome we wanted.
90
+ if "already been taken" not in str(exc).lower():
91
+ raise
86
92
  return get_project(gh, owner, number)
87
93
 
88
94
 
@@ -45,6 +45,33 @@ def _index(issues: list[dict]) -> dict[str, dict]:
45
45
  return out
46
46
 
47
47
 
48
+ def _converge_duplicates(gh, repo: str, issues: list[dict]) -> tuple[list[dict], list[str]]:
49
+ """One open issue per bridge marker: racing scaffolds may have created
50
+ duplicates, so close every open duplicate except the lowest-numbered one
51
+ and drop the closed ones from the working set. First writer wins."""
52
+ from collections import defaultdict
53
+ by_marker: dict[str, list[dict]] = defaultdict(list)
54
+ for issue in issues:
55
+ m = MARKER_RE.search(issue.get("body") or "")
56
+ if m and issue.get("state") == "open":
57
+ by_marker[m.group(1)].append(issue)
58
+ actions: list[str] = []
59
+ losers: set[int] = set()
60
+ for marker, dupes in by_marker.items():
61
+ if len(dupes) < 2:
62
+ continue
63
+ dupes.sort(key=lambda i: i["number"])
64
+ for extra in dupes[1:]:
65
+ G.update_issue(gh, repo, extra["number"], state="closed")
66
+ G.comment(gh, repo, extra["number"],
67
+ f"Duplicate of #{dupes[0]['number']} (bridge marker "
68
+ f"{marker}); racing scaffold runs converge on the "
69
+ "lowest-numbered issue.")
70
+ losers.add(extra["number"])
71
+ actions.append(f"converged duplicate #{extra['number']} -> #{dupes[0]['number']}")
72
+ return [i for i in issues if i["number"] not in losers], actions
73
+
74
+
48
75
  def scaffold(plan: BridgePlan, gh: G.GhRunner, repo: str,
49
76
  docs_url: str | None = None,
50
77
  board_cfg: dict | None = None) -> list[str]:
@@ -59,6 +86,8 @@ def scaffold(plan: BridgePlan, gh: G.GhRunner, repo: str,
59
86
  G.ensure_label(gh, repo, name, color, desc)
60
87
 
61
88
  issues = G.list_issues(gh, repo)
89
+ issues, converge_actions = _converge_duplicates(gh, repo, issues)
90
+ actions.extend(converge_actions)
62
91
  by_key = _index(issues)
63
92
 
64
93
  def ensure(key: str, title: str, body: str, label: str) -> dict:
@@ -83,7 +83,8 @@ def _anchors(documents_dir: Path) -> dict[str, dict]:
83
83
  continue
84
84
  if doc.kind == "project" and doc.id:
85
85
  out[doc.id] = {"code": str(doc.frontmatter.get("code", "")),
86
- "name": str(doc.frontmatter.get("name", ""))}
86
+ "name": str(doc.frontmatter.get("name", "")),
87
+ "repo": str(doc.frontmatter.get("repo", "") or "")}
87
88
  return out
88
89
 
89
90
 
@@ -155,10 +156,41 @@ def build_bridge_plan(documents_dir: str | Path = "documents") -> BridgePlan:
155
156
  "reason": "; ".join(problems)})
156
157
  continue
157
158
  plan.projects.append({"id": proj_id, "code": code, "name": meta["name"],
159
+ "repo": meta.get("repo", ""),
158
160
  "features": list(features.values())})
159
161
  return plan
160
162
 
161
163
 
164
+ def filter_plan(plan: BridgePlan, project: str) -> BridgePlan:
165
+ """A plan reduced to one project (matched by id or code). Unknown ids
166
+ yield an empty plan whose skipped list explains why."""
167
+ key = project.strip()
168
+ keep = [p for p in plan.projects
169
+ if p["id"] == key or p["code"] == key]
170
+ out = BridgePlan(projects=keep,
171
+ skipped=[s for s in plan.skipped if s["id"] == key])
172
+ if not keep and not out.skipped:
173
+ out.skipped.append({"id": key, "reason": "no such project in the plan"})
174
+ return out
175
+
176
+
177
+ def plans_by_repo(plan: BridgePlan) -> dict[str, BridgePlan]:
178
+ """Group a plan by each project's mapped repository (the `repo` field on
179
+ the project anchor). Projects sharing a repo share one sub-plan, so
180
+ reconciliation in that repo sees the union of their managed ids.
181
+ Raises ValueError naming any project without a mapping."""
182
+ unmapped = [p["id"] for p in plan.projects if not p.get("repo")]
183
+ if unmapped:
184
+ raise ValueError(
185
+ "no repo mapping on project anchor(s): " + ", ".join(unmapped)
186
+ + " (add `repo: OWNER/NAME` to project.md or pass --repo)")
187
+ groups: dict[str, BridgePlan] = {}
188
+ for proj in plan.projects:
189
+ groups.setdefault(proj["repo"],
190
+ BridgePlan(skipped=plan.skipped)).projects.append(proj)
191
+ return groups
192
+
193
+
162
194
  def feature_title(f: Feature) -> str:
163
195
  return f"[{f.id}] {_title(f.text)}"
164
196
 
@@ -238,7 +238,7 @@ def cmd_pages(args: argparse.Namespace) -> int:
238
238
  data = _json.loads(Path(args.execution).read_text())
239
239
  for proj in data.get("projects", []):
240
240
  execution[proj["id"]] = {**proj, "scope": data.get("scope"),
241
- "repo": data.get("repo")}
241
+ "repo": proj.get("repo") or data.get("repo")}
242
242
 
243
243
  plist = projects_mod.load_projects(docs_dir)
244
244
  for p in plist:
@@ -306,10 +306,30 @@ def cmd_bridge(args: argparse.Namespace) -> int:
306
306
  Scope flows documents -> GitHub only; the bridge never edits documents."""
307
307
  from .bridge import build_bridge_plan, ops
308
308
  from .bridge import gh as ghmod
309
+ from .bridge.plan import filter_plan, plans_by_repo
309
310
 
310
311
  plan = build_bridge_plan(args.documents_dir)
312
+ if getattr(args, "project", None):
313
+ plan = filter_plan(plan, args.project)
311
314
  gh = ghmod.DryRunner() if getattr(args, "dry_run", False) else ghmod.GhRunner()
312
315
 
316
+ # Repo resolution: an explicit --repo applies the whole plan to one
317
+ # repository (single-repo portfolios, back-compatible); without it, each
318
+ # project routes to the repo mapped on its anchor.
319
+ if args.action != "create-board":
320
+ if args.repo:
321
+ repo_plans = {args.repo: plan}
322
+ else:
323
+ try:
324
+ repo_plans = plans_by_repo(plan)
325
+ except ValueError as exc:
326
+ print(f"docassert bridge: {exc}", file=sys.stderr)
327
+ return 2
328
+ if not repo_plans and plan.skipped:
329
+ for s in plan.skipped:
330
+ print(f"docassert bridge: skipped {s['id']}: {s['reason']}")
331
+ return 0
332
+
313
333
  if args.action == "create-board":
314
334
  from .bridge import board as board_mod
315
335
  token = os.environ.get(args.board_token_env, "")
@@ -329,30 +349,46 @@ def cmd_bridge(args: argparse.Namespace) -> int:
329
349
  "(needed for --project-number)", file=sys.stderr)
330
350
  return 2
331
351
  bgh = gh if isinstance(gh, ghmod.DryRunner) else ghmod.GhRunner(token=token)
332
- board_cfg = {"gh": bgh, "owner": args.project_owner or args.repo.split("/")[0],
352
+ owner = args.project_owner or next(iter(repo_plans)).split("/")[0]
353
+ board_cfg = {"gh": bgh, "owner": owner,
333
354
  "number": args.project_number, "init": args.init_board}
334
355
 
335
356
  if args.action == "scaffold":
336
- actions = ops.scaffold(plan, gh, args.repo, docs_url=args.docs_url,
337
- board_cfg=board_cfg)
338
- for a in actions:
339
- print(f"docassert bridge: {a}")
357
+ for repo, sub in repo_plans.items():
358
+ actions = ops.scaffold(sub, gh, repo, docs_url=args.docs_url,
359
+ board_cfg=board_cfg)
360
+ for a in actions:
361
+ print(f"docassert bridge: [{repo}] {a}")
340
362
  if isinstance(gh, ghmod.DryRunner):
341
363
  print(f"docassert bridge: dry run — {len(gh.planned)} mutation(s) planned")
342
364
  for m in gh.planned:
343
365
  print(f" {m}")
344
366
  return 0
345
367
  if args.action == "reconcile":
346
- lines, code = ops.reconcile(plan, gh, args.repo)
347
- for line in lines:
348
- print(f"docassert bridge: {line}")
349
- return code
368
+ worst = 0
369
+ for repo, sub in repo_plans.items():
370
+ lines, code = ops.reconcile(sub, gh, repo)
371
+ for line in lines:
372
+ print(f"docassert bridge: [{repo}] {line}")
373
+ worst = max(worst, code)
374
+ return worst
350
375
  if args.action == "status":
351
- data = ops.status(plan, gh, args.repo)
352
- sys.stdout.write(ops.to_json(data) if args.json else
353
- ops.render_status(data) + "\n")
376
+ merged: dict = {"repo": None, "projects": [], "scope": {"unverified": [], "orphaned": []}}
377
+ for repo, sub in repo_plans.items():
378
+ data = ops.status(sub, gh, repo)
379
+ for proj in data.get("projects", []):
380
+ proj["repo"] = repo
381
+ merged["projects"].append(proj)
382
+ for key in ("unverified", "orphaned"):
383
+ for item in data.get("scope", {}).get(key, []):
384
+ item["repo"] = repo
385
+ merged["scope"][key].append(item)
386
+ if len(repo_plans) == 1:
387
+ merged["repo"] = next(iter(repo_plans))
388
+ sys.stdout.write(ops.to_json(merged) if args.json else
389
+ ops.render_status(merged) + "\n")
354
390
  if args.json and args.out:
355
- Path(args.out).write_text(ops.to_json(data))
391
+ Path(args.out).write_text(ops.to_json(merged))
356
392
  return 0
357
393
  print(f"docassert bridge: unknown action {args.action!r}", file=sys.stderr)
358
394
  return 2
@@ -441,7 +477,9 @@ def main(argv: list[str] | None = None) -> int:
441
477
  b = sub.add_parser("bridge", help="Execution bridge: scaffold and police the GitHub board from approved stories.")
442
478
  b.add_argument("action", choices=["scaffold", "reconcile", "status", "create-board"],
443
479
  help="scaffold: docs -> issues/board · reconcile: police the board · status: delivery figures.")
444
- b.add_argument("--repo", required=True, help="Target GitHub repo (OWNER/NAME) holding the issues.")
480
+ b.add_argument("--repo", help="Target GitHub repo (OWNER/NAME) for the whole plan; "
481
+ "omit to route each project to the `repo:` on its anchor.")
482
+ b.add_argument("--project", help="Scope to one project (PRJ-NNN-CODE id or CODE).")
445
483
  b.add_argument("--docs-url", help="Base URL of the documents repo, for source links in issue bodies.")
446
484
  b.add_argument("--dry-run", action="store_true", help="Print planned mutations without executing them.")
447
485
  b.add_argument("--json", action="store_true", help="status: emit JSON.")
@@ -248,7 +248,7 @@ def render_html(model) -> str:
248
248
  bad = scope.get("unverified", []) + scope.get("orphaned", [])
249
249
  if bad:
250
250
  items = "".join(
251
- f'<li><a href="https://github.com/{esc(ex.get("repo", ""))}/issues/{i["number"]}">#{i["number"]}</a> {esc(i["title"])}</li>'
251
+ f'<li><a href="https://github.com/{esc(i.get("repo") or ex.get("repo") or "")}/issues/{i["number"]}">#{i["number"]}</a> {esc(i["title"])}</li>'
252
252
  for i in bad)
253
253
  execution_html += (
254
254
  f'<section><h2 style="color:var(--bad)">Scope · {len(bad)} unmatched item(s)</h2>'
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docassert
3
- Version: 0.13.0
3
+ Version: 0.14.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
@@ -349,3 +349,124 @@ def test_status_includes_scope_classification(tmp_path):
349
349
  assert data["repo"] == "o/r"
350
350
  assert [i["number"] for i in data["scope"]["unverified"]] == [2]
351
351
  assert [i["doc"] for i in data["scope"]["orphaned"]] == ["TST-US-999"]
352
+
353
+
354
+ # ── project filter and per-repo mapping (ENG-US-001 / ENG-US-002) ────────────
355
+ from docassert.bridge.plan import filter_plan, plans_by_repo # noqa: E402
356
+
357
+ ANCHOR2 = """---
358
+ kind: project
359
+ id: PRJ-002-TWO
360
+ code: TWO
361
+ name: Second Project
362
+ sponsor: S
363
+ status: active
364
+ repo: owner/two-repo
365
+ ---
366
+
367
+ ## Overview
368
+
369
+ x
370
+
371
+ ## Scope
372
+
373
+ x
374
+ """
375
+
376
+ PRD2 = PRD.replace("PRJ-001-TST", "PRJ-002-TWO").replace("TST", "TWO")
377
+ STORIES2 = STORIES.replace("PRJ-001-TST", "PRJ-002-TWO").replace("TST", "TWO")
378
+
379
+
380
+ def _two_project_tree(tmp_path, repo_one="owner/one-repo"):
381
+ docs = _tree(tmp_path)
382
+ if repo_one:
383
+ anchor = docs / "PRJ-001-TST" / "project.md"
384
+ anchor.write_text(anchor.read_text().replace(
385
+ "status: active", f"status: active\nrepo: {repo_one}"), encoding="utf-8")
386
+ d = docs / "PRJ-002-TWO"
387
+ d.mkdir()
388
+ (d / "project.md").write_text(ANCHOR2, encoding="utf-8")
389
+ (d / "prd.md").write_text(PRD2, encoding="utf-8")
390
+ (d / "user-story.md").write_text(STORIES2.format(status="approved"), encoding="utf-8")
391
+ return docs
392
+
393
+
394
+ def test_filter_plan_by_id_and_code(tmp_path):
395
+ plan = build_bridge_plan(_two_project_tree(tmp_path))
396
+ assert len(plan.projects) == 2
397
+ only = filter_plan(plan, "PRJ-002-TWO")
398
+ assert [p["id"] for p in only.projects] == ["PRJ-002-TWO"]
399
+ by_code = filter_plan(plan, "TST")
400
+ assert [p["code"] for p in by_code.projects] == ["TST"]
401
+ missing = filter_plan(plan, "PRJ-999-NOPE")
402
+ assert not missing.projects and missing.skipped
403
+
404
+
405
+ def test_plans_by_repo_routes_each_project(tmp_path):
406
+ plan = build_bridge_plan(_two_project_tree(tmp_path))
407
+ groups = plans_by_repo(plan)
408
+ assert set(groups) == {"owner/one-repo", "owner/two-repo"}
409
+ assert [p["id"] for p in groups["owner/one-repo"].projects] == ["PRJ-001-TST"]
410
+ assert [p["id"] for p in groups["owner/two-repo"].projects] == ["PRJ-002-TWO"]
411
+
412
+
413
+ def test_plans_by_repo_shares_a_repo_as_a_union(tmp_path):
414
+ docs = _two_project_tree(tmp_path)
415
+ anchor = docs / "PRJ-002-TWO" / "project.md"
416
+ anchor.write_text(anchor.read_text().replace("owner/two-repo", "owner/one-repo"),
417
+ encoding="utf-8")
418
+ plan = build_bridge_plan(docs)
419
+ groups = plans_by_repo(plan)
420
+ assert set(groups) == {"owner/one-repo"}
421
+ union = groups["owner/one-repo"]
422
+ assert len(union.projects) == 2
423
+ # both projects' ids are managed, so neither would be orphaned in reconcile
424
+ assert any(i.startswith("TST-") for i in union.managed_ids)
425
+ assert any(i.startswith("TWO-") for i in union.managed_ids)
426
+
427
+
428
+ def test_plans_by_repo_names_unmapped_projects(tmp_path):
429
+ plan = build_bridge_plan(_two_project_tree(tmp_path, repo_one=None))
430
+ try:
431
+ plans_by_repo(plan)
432
+ raise AssertionError("expected ValueError")
433
+ except ValueError as exc:
434
+ assert "PRJ-001-TST" in str(exc) and "--repo" in str(exc)
435
+
436
+
437
+ def test_anchor_without_repo_keeps_single_repo_mode_working(tmp_path):
438
+ # the original single-repo path: no repo fields, explicit --repo, unchanged
439
+ plan = build_bridge_plan(_tree(tmp_path))
440
+ assert plan.projects[0].get("repo", "") == ""
441
+ gh = FakeGh()
442
+ ops.scaffold(plan, gh, "o/r", docs_url=None)
443
+ assert any("issues" in " ".join(c) for c in gh.calls)
444
+
445
+
446
+ # ── concurrency safety (ENG-PR-006) ──────────────────────────────────────────
447
+ def test_scaffold_converges_racing_duplicates(tmp_path):
448
+ plan = build_bridge_plan(_tree(tmp_path))
449
+ fid = plan.features[0].id
450
+ dupes = [_mk_issue(5, fid), _mk_issue(9, fid)]
451
+ gh = FakeGh(issues=dupes)
452
+ actions = ops.scaffold(plan, gh, "o/r", docs_url=None)
453
+ assert any("converged duplicate #9" in a for a in actions)
454
+ patches = [c for c in gh.calls if "repos/o/r/issues/9" in " ".join(c)
455
+ and "state=closed" in " ".join(c)]
456
+ assert patches, "the higher-numbered duplicate is closed"
457
+
458
+
459
+ def test_ensure_fields_tolerates_existing_field():
460
+ gh = BoardGh()
461
+ original = gh.graphql
462
+
463
+ def racing(query, **kw):
464
+ if "createProjectV2Field" in query:
465
+ from docassert.bridge.gh import GhError
466
+ raise GhError('[{"message": "Name has already been taken"}]')
467
+ return original(query, **kw)
468
+
469
+ gh.graphql = racing
470
+ from docassert.bridge import board
471
+ project = board.ensure_fields(gh, "o", 1) # must not raise
472
+ assert project["id"]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes