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.
- {docassert-0.8.1/docassert.egg-info → docassert-0.10.0}/PKG-INFO +1 -1
- {docassert-0.8.1 → docassert-0.10.0}/docassert/__init__.py +1 -1
- docassert-0.10.0/docassert/bridge/board.py +126 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/bridge/gh.py +15 -3
- {docassert-0.8.1 → docassert-0.10.0}/docassert/bridge/ops.py +52 -5
- {docassert-0.8.1 → docassert-0.10.0}/docassert/cli.py +42 -2
- {docassert-0.8.1 → docassert-0.10.0}/docassert/status/render.py +35 -0
- {docassert-0.8.1 → docassert-0.10.0/docassert.egg-info}/PKG-INFO +1 -1
- {docassert-0.8.1 → docassert-0.10.0}/docassert.egg-info/SOURCES.txt +1 -0
- {docassert-0.8.1 → docassert-0.10.0}/tests/test_badge.py +22 -0
- {docassert-0.8.1 → docassert-0.10.0}/tests/test_bridge.py +88 -0
- {docassert-0.8.1 → docassert-0.10.0}/LICENSE +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/NOTICE +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/README.md +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/__main__.py +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/consistency.yaml +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/adr.criteria.yaml +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/benefits-realization.criteria.yaml +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/brd.criteria.yaml +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/business-case.criteria.yaml +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/charter.criteria.yaml +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/data-migration-plan.criteria.yaml +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/frnfr.criteria.yaml +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/hypercare-plan.criteria.yaml +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/post-implementation-review.criteria.yaml +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/prd.criteria.yaml +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/project.criteria.yaml +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/qa-test-plan.criteria.yaml +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/raci-stakeholder.criteria.yaml +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/release-cutover-plan.criteria.yaml +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/risk-register.criteria.yaml +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/rollback-plan.criteria.yaml +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/runbook.criteria.yaml +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/status-report.criteria.yaml +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/test-cases.criteria.yaml +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/user-story.criteria.yaml +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/profiles/agile-delivery.yaml +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/profiles/lean-startup.yaml +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/profiles/regulated-industry.yaml +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/adr.schema.json +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/benefits-realization.schema.json +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/brd.schema.json +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/business-case.schema.json +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/charter.schema.json +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/data-migration-plan.schema.json +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/frnfr.schema.json +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/hypercare-plan.schema.json +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/post-implementation-review.schema.json +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/prd.schema.json +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/project.schema.json +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/qa-test-plan.schema.json +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/raci-stakeholder.schema.json +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/release-cutover-plan.schema.json +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/risk-register.schema.json +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/rollback-plan.schema.json +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/runbook.schema.json +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/status-report.schema.json +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/test-cases.schema.json +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/user-story.schema.json +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/skills/doc-to-pmo/SKILL.md +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/adr.template.md +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/benefits-realization.template.md +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/brd.template.md +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/business-case.template.md +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/charter.template.md +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/data-migration-plan.template.md +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/frnfr.template.md +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/hypercare-plan.template.md +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/post-implementation-review.template.md +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/prd.template.md +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/project.template.md +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/qa-test-plan.template.md +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/raci-stakeholder.template.md +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/release-cutover-plan.template.md +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/risk-register.template.md +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/rollback-plan.template.md +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/runbook.template.md +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/status-report.template.md +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/test-cases.template.md +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/user-story.template.md +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/bridge/__init__.py +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/bridge/plan.py +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/config.py +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/consistency.py +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/extract.py +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/graph.py +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/loader.py +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/models.py +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/profiles.py +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/projects.py +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/report.py +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/rtm.py +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/scaffold.py +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/semantic.py +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/status/__init__.py +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/status/derive.py +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert/structural.py +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert.egg-info/dependency_links.txt +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert.egg-info/entry_points.txt +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert.egg-info/requires.txt +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/docassert.egg-info/top_level.txt +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/pyproject.toml +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/setup.cfg +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/tests/test_config.py +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/tests/test_consistency.py +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/tests/test_defects.py +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/tests/test_draft_relaxation.py +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/tests/test_extract.py +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/tests/test_graph.py +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/tests/test_json_report.py +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/tests/test_kinds_delivery.py +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/tests/test_kinds_governance.py +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/tests/test_kinds_operate.py +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/tests/test_kinds_reporting.py +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/tests/test_profiles.py +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/tests/test_projects.py +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/tests/test_scaffold.py +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/tests/test_status.py +0 -0
- {docassert-0.8.1 → docassert-0.10.0}/tests/test_structural.py +0 -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
|
|
50
|
-
|
|
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
|
|
147
|
-
|
|
148
|
-
|
|
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>
|
|
@@ -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
|
{docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/benefits-realization.criteria.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/data-migration-plan.criteria.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/raci-stakeholder.criteria.yaml
RENAMED
|
File without changes
|
{docassert-0.8.1 → docassert-0.10.0}/docassert/_data/criteria/release-cutover-plan.criteria.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/benefits-realization.schema.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/post-implementation-review.schema.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{docassert-0.8.1 → docassert-0.10.0}/docassert/_data/schema/release-cutover-plan.schema.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/benefits-realization.template.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/data-migration-plan.template.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{docassert-0.8.1 → docassert-0.10.0}/docassert/_data/templates/release-cutover-plan.template.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|