ai-dev-workflow 0.1.0__py3-none-any.whl

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.
composite_work_item.py ADDED
@@ -0,0 +1,62 @@
1
+ """Composite WorkItem sync: ADO business item + GitHub execution overlay."""
2
+ from __future__ import annotations
3
+
4
+ COARSE_DEFAULT = {
5
+ "new": "New",
6
+ "brainstorming": "New",
7
+ "defining": "New",
8
+ "approval-pending": "New",
9
+ "approved": "Active",
10
+ "test-design": "Active",
11
+ "test-ready": "Active",
12
+ "implementing": "Active",
13
+ "code-review": "Active",
14
+ "verify": "Active",
15
+ "runtime-verify": "Active",
16
+ "runtime-pending": "Active",
17
+ "done": "Done",
18
+ }
19
+
20
+
21
+ def build_composite_config(
22
+ *,
23
+ ado: dict,
24
+ github: dict,
25
+ field_mapping: dict | None = None,
26
+ state_sync: dict | None = None,
27
+ ) -> dict:
28
+ return {
29
+ "provider": "composite",
30
+ "business": {
31
+ "provider": "azure-devops",
32
+ **ado,
33
+ },
34
+ "execution": {
35
+ "provider": "github-issues",
36
+ **github,
37
+ },
38
+ "sync": {
39
+ "state": dict(state_sync or COARSE_DEFAULT),
40
+ "field_mapping": field_mapping or {},
41
+ },
42
+ }
43
+
44
+
45
+ def sync_projection(config: dict, *, wit_ref: str, phase: str, values: dict | None = None) -> dict:
46
+ """Return provider-neutral desired ADO mutations for an execution phase change."""
47
+ sync = config.get("sync", {})
48
+ state_map = sync.get("state", {})
49
+ field_mapping = sync.get("field_mapping", {})
50
+ state = state_map.get(phase)
51
+
52
+ updates = {}
53
+ values = {"workflow_phase": phase, **(values or {})}
54
+ for key, value in values.items():
55
+ mapping = (field_mapping.get(wit_ref, {}) or {}).get(key)
56
+ if mapping and mapping.get("field"):
57
+ updates[mapping["field"]] = value
58
+
59
+ return {
60
+ "state": state,
61
+ "fields": updates,
62
+ }
framework_update.py ADDED
@@ -0,0 +1,145 @@
1
+ """Safe update engine for installed ai-dev-workflow projects."""
2
+ from __future__ import annotations
3
+
4
+ from copy import deepcopy
5
+ from pathlib import Path
6
+
7
+ CURRENT_SCHEMA_VERSION = 1
8
+ DEFAULT_FRAMEWORK_SOURCE = "vuthethienlong/ai-dev-workflow"
9
+
10
+ # Only these files are owned by the framework after installation.
11
+ # AGENTS.md, CURRENT.md, project docs/config and workspace files are project-owned.
12
+ FRAMEWORK_MANAGED_FILES = (
13
+ ".github/workflows/ai-workflow.yml",
14
+ )
15
+
16
+
17
+ def framework_metadata(config: dict) -> dict:
18
+ meta = config.get("framework") or {}
19
+ return {
20
+ "source": meta.get("source", DEFAULT_FRAMEWORK_SOURCE),
21
+ "ref": meta.get("ref", "main"),
22
+ "schema_version": int(meta.get("schema_version", config.get("version", 1))),
23
+ "update_policy": meta.get("update_policy", "manual"),
24
+ }
25
+
26
+
27
+ def ensure_framework_metadata(config: dict, *, source=DEFAULT_FRAMEWORK_SOURCE, ref="main") -> dict:
28
+ result = deepcopy(config)
29
+ existing = result.get("framework") or {}
30
+ result["framework"] = {
31
+ "source": existing.get("source", source),
32
+ "ref": existing.get("ref", ref),
33
+ "schema_version": int(existing.get("schema_version", CURRENT_SCHEMA_VERSION)),
34
+ "update_policy": existing.get("update_policy", "manual"),
35
+ }
36
+ return result
37
+
38
+
39
+ def migrate_config(config: dict, target_schema: int = CURRENT_SCHEMA_VERSION) -> tuple[dict, list[str]]:
40
+ """Run deterministic idempotent migrations without replacing project-owned sections."""
41
+ result = deepcopy(config)
42
+ meta = framework_metadata(result)
43
+ current = meta["schema_version"]
44
+ if current > target_schema:
45
+ raise ValueError(
46
+ f"config schema {current} is newer than supported schema {target_schema}"
47
+ )
48
+ migrations = []
49
+ # Reserved migration chain. Add explicit N -> N+1 transforms here.
50
+ while current < target_schema:
51
+ next_version = current + 1
52
+ migration = f"{current}-to-{next_version}"
53
+ raise ValueError(f"missing framework migration: {migration}")
54
+ result = ensure_framework_metadata(
55
+ result, source=meta["source"], ref=meta["ref"]
56
+ )
57
+ result["framework"]["schema_version"] = target_schema
58
+ return result, migrations
59
+
60
+
61
+ def build_update_plan(
62
+ config: dict,
63
+ *,
64
+ target_ref: str | None = None,
65
+ target_source: str | None = None,
66
+ workflow_path: str = ".github/workflows/ai-workflow.yml",
67
+ ) -> dict:
68
+ meta = framework_metadata(config)
69
+ source = target_source or meta["source"]
70
+ ref = target_ref or "main"
71
+ return {
72
+ "current": meta,
73
+ "target": {
74
+ "source": source,
75
+ "ref": ref,
76
+ "schema_version": CURRENT_SCHEMA_VERSION,
77
+ },
78
+ "update_available": (
79
+ meta["source"] != source
80
+ or meta["ref"] != ref
81
+ or meta["schema_version"] != CURRENT_SCHEMA_VERSION
82
+ ),
83
+ "managed_files": [workflow_path],
84
+ "preserved": [
85
+ ".ai-workflow.yml custom/project sections",
86
+ ".ai-workspace.yml",
87
+ "AGENTS.md",
88
+ "CURRENT.md",
89
+ "work item mappings",
90
+ "ADO field defaults",
91
+ "AI providers",
92
+ "workers",
93
+ "project workflow overrides",
94
+ ],
95
+ }
96
+
97
+
98
+ def apply_update(
99
+ config: dict,
100
+ *,
101
+ target_ref: str,
102
+ generated_workflow: str | None = None,
103
+ expected_current_workflow: str | None = None,
104
+ root: str | Path = ".",
105
+ target_source: str | None = None,
106
+ force: bool = False,
107
+ ) -> tuple[dict, dict]:
108
+ """Apply metadata/migrations and update framework-managed files only."""
109
+ root = Path(root)
110
+ migrated, migrations = migrate_config(config)
111
+ meta = framework_metadata(migrated)
112
+ source = target_source or meta["source"]
113
+ migrated["framework"] = {
114
+ **migrated.get("framework", {}),
115
+ "source": source,
116
+ "ref": target_ref,
117
+ "schema_version": CURRENT_SCHEMA_VERSION,
118
+ }
119
+
120
+ changed, skipped = [], []
121
+ if generated_workflow is not None:
122
+ path = root / ".github/workflows/ai-workflow.yml"
123
+ path.parent.mkdir(parents=True, exist_ok=True)
124
+ if path.exists():
125
+ current = path.read_text(encoding="utf-8")
126
+ relative = str(path.relative_to(root))
127
+ if current == generated_workflow:
128
+ skipped.append(relative)
129
+ elif expected_current_workflow is not None and current != expected_current_workflow and not force:
130
+ skipped.append(relative + " (locally modified; use --force)")
131
+ else:
132
+ path.write_text(generated_workflow, encoding="utf-8")
133
+ changed.append(relative)
134
+ else:
135
+ path.write_text(generated_workflow, encoding="utf-8")
136
+ changed.append(str(path.relative_to(root)))
137
+
138
+ return migrated, {
139
+ "migrations": migrations,
140
+ "changed_files": changed,
141
+ "skipped_files": skipped,
142
+ "preserved_project_files": [
143
+ "AGENTS.md", "CURRENT.md", ".ai-workspace.yml"
144
+ ],
145
+ }
@@ -0,0 +1,134 @@
1
+ """GitHub Issues WorkItemProvider discovery."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ from urllib.parse import quote
7
+ from urllib.request import Request, urlopen
8
+ from urllib.error import HTTPError
9
+
10
+ from work_item_provider import CANONICAL_STATES
11
+
12
+
13
+ class GitHubIssues:
14
+ def __init__(self, repository: str, token: str, api_url="https://api.github.com"):
15
+ self.repository = repository
16
+ self.base = api_url.rstrip("/") + "/repos/" + repository
17
+ self.token = token
18
+
19
+ def request(self, path: str):
20
+ req = Request(
21
+ self.base + path,
22
+ headers={
23
+ "Authorization": "Bearer " + self.token,
24
+ "Accept": "application/vnd.github+json",
25
+ "X-GitHub-Api-Version": "2022-11-28",
26
+ },
27
+ )
28
+ try:
29
+ with urlopen(req, timeout=30) as response:
30
+ return json.load(response)
31
+ except HTTPError as exc:
32
+ body = exc.read().decode("utf-8", "replace")
33
+ raise RuntimeError(f"GitHub API {exc.code}: {body}") from exc
34
+
35
+ def labels(self):
36
+ return self.request("/labels?per_page=100")
37
+
38
+ def create_label(self, name: str, color: str, description: str):
39
+ req = Request(
40
+ self.base + "/labels",
41
+ method="POST",
42
+ data=json.dumps({"name": name, "color": color, "description": description}).encode(),
43
+ headers={
44
+ "Authorization": "Bearer " + self.token,
45
+ "Accept": "application/vnd.github+json",
46
+ "Content-Type": "application/json",
47
+ "X-GitHub-Api-Version": "2022-11-28",
48
+ },
49
+ )
50
+ try:
51
+ with urlopen(req, timeout=30) as response:
52
+ return json.load(response)
53
+ except HTTPError as exc:
54
+ body = exc.read().decode("utf-8", "replace")
55
+ raise RuntimeError(f"GitHub API {exc.code}: {body}") from exc
56
+
57
+ def discover(self) -> dict:
58
+ labels = [
59
+ {
60
+ "name": item.get("name"),
61
+ "description": item.get("description") or "",
62
+ "color": item.get("color") or "",
63
+ }
64
+ for item in self.labels()
65
+ if item.get("name")
66
+ ]
67
+ return {
68
+ "repository": self.repository,
69
+ "labels": sorted(labels, key=lambda x: x["name"].lower()),
70
+ "system_states": ["open", "closed"],
71
+ }
72
+
73
+
74
+ def _normalize(value: str) -> str:
75
+ return re.sub(r"[^a-z0-9]+", " ", value.lower()).strip()
76
+
77
+
78
+ def suggest_github_state(label_name: str, description: str = "") -> str | None:
79
+ text = _normalize(label_name + " " + description)
80
+ rules = (
81
+ ("runtime-pending", ("runtime pending", "runtime verify", "runtime verification", "awaiting runtime", "uat")),
82
+ ("release-pending", ("release pending", "ready release", "deploy pending")),
83
+ ("in-review", ("in review", "review", "qa", "validation")),
84
+ ("in-progress", ("in progress", "working", "doing", "active")),
85
+ ("blocked", ("blocked", "hold", "waiting")),
86
+ ("ready", ("status ready", "ready", "approved", "todo", "to do")),
87
+ ("completed", ("completed", "done", "closed")),
88
+ ("draft", ("draft", "new", "backlog")),
89
+ )
90
+ for canonical, phrases in rules:
91
+ if any(phrase in text for phrase in phrases):
92
+ return canonical
93
+ return None
94
+
95
+
96
+ MANAGED_STATUS_LABELS = {
97
+ "status:ready": ("0E8A16", "Frozen and ready for execution"),
98
+ "status:in-progress": ("1D76DB", "Accepted by a real agent/provider and currently executing"),
99
+ "status:blocked": ("B60205", "Blocked and requires recovery or external action"),
100
+ "status:in-review": ("5319E7", "Implementation finished and under review"),
101
+ "status:runtime-pending": ("FBCA04", "Offline work done; real runtime verification still required"),
102
+ "status:release-pending": ("D4C5F9", "Ready for release/promotion after required gates"),
103
+ }
104
+
105
+
106
+ def ensure_managed_status_labels(api: GitHubIssues, discovery: dict) -> list[str]:
107
+ existing = {item["name"] for item in discovery.get("labels", [])}
108
+ created = []
109
+ for name, (color, description) in MANAGED_STATUS_LABELS.items():
110
+ if name not in existing:
111
+ api.create_label(name, color, description)
112
+ created.append(name)
113
+ return created
114
+
115
+
116
+ def build_github_mapping(discovery: dict) -> dict:
117
+ mapping = {"@closed": "completed"}
118
+ for label in discovery.get("labels", []):
119
+ suggestion = suggest_github_state(label["name"], label.get("description", ""))
120
+ if suggestion is not None:
121
+ mapping[label["name"]] = suggestion
122
+ return mapping
123
+
124
+
125
+ def validate_github_mapping(discovery: dict, mapping: dict) -> list[str]:
126
+ errors = []
127
+ known = {item["name"] for item in discovery.get("labels", [])}
128
+ known.add("@closed")
129
+ for label, canonical in mapping.items():
130
+ if label not in known:
131
+ errors.append(f"mapped GitHub label does not exist: {label}")
132
+ if canonical not in CANONICAL_STATES:
133
+ errors.append(f"invalid canonical state for GitHub label {label}: {canonical}")
134
+ return errors
issue_publisher.py ADDED
@@ -0,0 +1,243 @@
1
+ """Publish a frozen planning decision to GitHub Issue + optional Project v2."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ import re
8
+ from pathlib import Path
9
+ from urllib.parse import quote
10
+ from urllib.request import Request, urlopen
11
+ from urllib.error import HTTPError
12
+
13
+ REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
14
+ STATUS_READY = "status:ready"
15
+
16
+
17
+ class GitHub:
18
+ def __init__(self, token: str, api_url="https://api.github.com"):
19
+ self.token = token
20
+ self.api_url = api_url.rstrip("/")
21
+
22
+ def _request(self, url, method="GET", data=None):
23
+ req = Request(
24
+ url, method=method,
25
+ data=json.dumps(data).encode() if data is not None else None,
26
+ headers={
27
+ "Authorization": "Bearer " + self.token,
28
+ "Accept": "application/vnd.github+json",
29
+ "Content-Type": "application/json",
30
+ "X-GitHub-Api-Version": "2022-11-28",
31
+ },
32
+ )
33
+ try:
34
+ with urlopen(req, timeout=30) as response:
35
+ if response.status == 204:
36
+ return {}
37
+ return json.load(response)
38
+ except HTTPError as exc:
39
+ body = exc.read().decode("utf-8", "replace")
40
+ raise RuntimeError(f"GitHub API {exc.code}: {body}") from exc
41
+
42
+ def repo(self, full_name):
43
+ return self._request(f"{self.api_url}/repos/{full_name}")
44
+
45
+ def graphql(self, query, variables):
46
+ return self._request(
47
+ f"{self.api_url}/graphql", "POST", {"query": query, "variables": variables}
48
+ )
49
+
50
+ def resolve_project_v2(self, owner, number):
51
+ query = """
52
+ query($owner:String!, $number:Int!) {
53
+ user(login:$owner) { projectV2(number:$number) { id title } }
54
+ organization(login:$owner) { projectV2(number:$number) { id title } }
55
+ }
56
+ """
57
+ data = self.graphql(query, {"owner": owner, "number": number})
58
+ if data.get("errors"):
59
+ raise RuntimeError("GitHub Project lookup failed: " + json.dumps(data["errors"]))
60
+ root = data.get("data") or {}
61
+ for key in ("user", "organization"):
62
+ obj = root.get(key)
63
+ if obj and obj.get("projectV2"):
64
+ return obj["projectV2"]
65
+ raise RuntimeError(f"GitHub Project v2 not found: {owner}#{number}")
66
+
67
+ def add_issue_to_project(self, project_id, issue_node_id):
68
+ query = """
69
+ mutation($project:ID!, $content:ID!) {
70
+ addProjectV2ItemById(input:{projectId:$project, contentId:$content}) {
71
+ item { id }
72
+ }
73
+ }
74
+ """
75
+ data = self.graphql(query, {"project": project_id, "content": issue_node_id})
76
+ if data.get("errors"):
77
+ raise RuntimeError("GitHub Project add failed: " + json.dumps(data["errors"]))
78
+ return data["data"]["addProjectV2ItemById"]["item"]
79
+
80
+
81
+ def validate_decision(context: dict, decision: dict) -> dict:
82
+ planning = context.get("planning_context") if "planning_context" in context else context
83
+ if not isinstance(planning, dict):
84
+ raise ValueError("planning_context is required")
85
+ repos = {r["repository"]: r for r in planning.get("repositories", [])}
86
+ target = decision.get("target_repository")
87
+ if target not in repos:
88
+ raise ValueError("target_repository is not registered in workspace")
89
+ if repos[target].get("access") != "read-write":
90
+ raise ValueError("target_repository must have read-write access")
91
+
92
+ affected = decision.get("affected_repositories", [])
93
+ if not isinstance(affected, list):
94
+ raise ValueError("affected_repositories must be a list")
95
+ for item in affected:
96
+ if not isinstance(item, dict) or item.get("repository") not in repos:
97
+ raise ValueError("affected repository is not registered in workspace")
98
+ access = item.get("access", repos[item["repository"]].get("access"))
99
+ if access not in ("read", "read-write"):
100
+ raise ValueError("invalid affected repository access")
101
+ if access == "read-write" and repos[item["repository"]].get("access") != "read-write":
102
+ raise ValueError("planning decision escalates repository access")
103
+
104
+ initiative = decision.get("initiative")
105
+ if initiative:
106
+ initiatives = planning.get("initiatives", {})
107
+ if initiative not in initiatives:
108
+ raise ValueError("unknown initiative")
109
+
110
+ project = decision.get("github_project", planning.get("github_project"))
111
+ if project is not None:
112
+ if not isinstance(project, dict) or not project.get("owner") or type(project.get("number")) is not int:
113
+ raise ValueError("invalid github_project")
114
+
115
+ frozen = bool(decision.get("frozen", False))
116
+ priority = decision.get("priority", "priority:P2")
117
+ if priority not in ("priority:P0", "priority:P1", "priority:P2"):
118
+ raise ValueError("invalid priority")
119
+
120
+ return {
121
+ "target_repository": target,
122
+ "affected_repositories": affected,
123
+ "github_project": project,
124
+ "initiative": initiative,
125
+ "dependencies": decision.get("dependencies", []),
126
+ "issue_type": decision.get("issue_type", "feature"),
127
+ "runtime_required": bool(decision.get("runtime_required", False)),
128
+ "frozen": frozen,
129
+ "priority": priority,
130
+ }
131
+
132
+
133
+ def render_issue_body(spec: dict, normalized: dict) -> str:
134
+ dependencies = normalized["dependencies"] or []
135
+ dep_text = "\n".join(f"- {d}" for d in dependencies) if dependencies else "None"
136
+ runtime = "Required" if normalized["runtime_required"] else "None"
137
+ return f"""## Status
138
+ {"Ready Freeze" if normalized["frozen"] else "Draft"}
139
+
140
+ ## Goal
141
+ {spec["goal"]}
142
+
143
+ ## Context / links
144
+ {spec.get("context", "Workspace planning context")}
145
+
146
+ ## Scope
147
+ {spec.get("scope", "")}
148
+
149
+ ## Out of scope
150
+ {spec.get("out_of_scope", "")}
151
+
152
+ ## Dependencies
153
+ {dep_text}
154
+
155
+ ## Acceptance Criteria / Done When
156
+ {spec["acceptance_criteria"]}
157
+
158
+ ## Verification offline
159
+ {spec.get("verification_offline", "Project-defined")}
160
+
161
+ ## Runtime Verification
162
+ {runtime}
163
+
164
+ ## Spec
165
+ {spec.get("spec", "")}
166
+
167
+ ## ADR
168
+ {spec.get("adr", "")}
169
+
170
+ ## Planning metadata
171
+ - Initiative: {normalized.get("initiative") or "None"}
172
+ - Issue type: {normalized["issue_type"]}
173
+ - Affected repositories: {", ".join(x["repository"] for x in normalized["affected_repositories"]) or normalized["target_repository"]}
174
+ """
175
+
176
+
177
+ def publish(api, context: dict, decision: dict, spec: dict) -> dict:
178
+ normalized = validate_decision(context, decision)
179
+
180
+ title = spec.get("title")
181
+ goal = spec.get("goal")
182
+ acceptance = spec.get("acceptance_criteria")
183
+ if not all(isinstance(v, str) and v.strip() for v in (title, goal, acceptance)):
184
+ raise ValueError("spec title, goal and acceptance_criteria are required")
185
+
186
+ repo = normalized["target_repository"]
187
+ api.repo(repo) # fail before mutation if repository is inaccessible
188
+
189
+
190
+ labels = [normalized["priority"]]
191
+ labels.append(STATUS_READY if normalized["frozen"] else "status:blocked")
192
+ issue = api.create_issue(
193
+ repo,
194
+ title.strip(),
195
+ render_issue_body(spec, normalized),
196
+ labels
197
+ )
198
+
199
+ project_item = None
200
+ if normalized["github_project"]:
201
+ project = api.resolve_project_v2(
202
+ normalized["github_project"]["owner"],
203
+ normalized["github_project"]["number"],
204
+ )
205
+ node_id = issue.get("node_id")
206
+ if not node_id:
207
+ raise RuntimeError("created Issue did not return node_id for Project v2")
208
+ project_item = api.add_issue_to_project(project["id"], node_id)
209
+
210
+ return {
211
+ "repository": repo,
212
+ "issue_number": issue["number"],
213
+ "issue_url": issue.get("html_url"),
214
+ "project_item_id": project_item.get("id") if project_item else None,
215
+ "status": STATUS_READY if normalized["frozen"] else "draft",
216
+ }
217
+
218
+
219
+ def main() -> int:
220
+ parser = argparse.ArgumentParser()
221
+ parser.add_argument("--context", required=True, type=Path)
222
+ parser.add_argument("--decision", required=True, type=Path)
223
+ parser.add_argument("--spec", required=True, type=Path)
224
+ parser.add_argument("--output", type=Path)
225
+ args = parser.parse_args()
226
+
227
+ api = GitHub(os.environ["GH_TOKEN"], os.environ.get("GITHUB_API_URL", "https://api.github.com"))
228
+ result = publish(
229
+ api,
230
+ json.loads(args.context.read_text(encoding="utf-8")),
231
+ json.loads(args.decision.read_text(encoding="utf-8")),
232
+ json.loads(args.spec.read_text(encoding="utf-8")),
233
+ )
234
+ text = json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
235
+ if args.output:
236
+ args.output.write_text(text, encoding="utf-8")
237
+ else:
238
+ print(text, end="")
239
+ return 0
240
+
241
+
242
+ if __name__ == "__main__":
243
+ raise SystemExit(main())