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.
- ado_mcp.py +49 -0
- ado_provider.py +245 -0
- ai_dev_workflow-0.1.0.dist-info/METADATA +348 -0
- ai_dev_workflow-0.1.0.dist-info/RECORD +20 -0
- ai_dev_workflow-0.1.0.dist-info/WHEEL +5 -0
- ai_dev_workflow-0.1.0.dist-info/entry_points.txt +2 -0
- ai_dev_workflow-0.1.0.dist-info/top_level.txt +15 -0
- ai_provider.py +149 -0
- ai_workflow_cli.py +761 -0
- composite_work_item.py +62 -0
- framework_update.py +145 -0
- github_work_item_provider.py +134 -0
- issue_publisher.py +243 -0
- planning_context.py +151 -0
- setup_context.py +99 -0
- setup_wizard.py +221 -0
- source_connection.py +21 -0
- work_item_provider.py +20 -0
- work_item_registry.py +25 -0
- worker_registry.py +170 -0
ai_workflow_cli.py
ADDED
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
"""CLI bootstrap for ai-dev-workflow."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
import yaml
|
|
11
|
+
except ImportError:
|
|
12
|
+
yaml = None
|
|
13
|
+
|
|
14
|
+
from ado_provider import AzureDevOps, build_state_mapping, validate_mapping, validate_field_mapping
|
|
15
|
+
from work_item_provider import REQUIRED_AGENT_ROLES, REQUIRED_CANONICAL_STATES
|
|
16
|
+
from setup_wizard import build_setup_plan, discover_provider, interactive_mapping, interactive_ado_field_mapping, render_user_steps, apply_github_status_bootstrap
|
|
17
|
+
from github_work_item_provider import validate_github_mapping
|
|
18
|
+
from ai_provider import provider_template, validate_provider_registry
|
|
19
|
+
from source_connection import resolve_github_token
|
|
20
|
+
from work_item_registry import validate_work_item_provider
|
|
21
|
+
from ado_mcp import build_ado_mcp
|
|
22
|
+
from worker_registry import build_worker, validate_worker_registry
|
|
23
|
+
from framework_update import DEFAULT_FRAMEWORK_SOURCE, ensure_framework_metadata, build_update_plan, apply_update
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
DEFAULT_ROLE_PROVIDERS = {
|
|
27
|
+
"SetupAgent": "manual",
|
|
28
|
+
"ArchitectAgent": "manual",
|
|
29
|
+
"TesterAgent": "codex",
|
|
30
|
+
"ImplementerAgent": "codex",
|
|
31
|
+
"ReviewerAgent": "manual",
|
|
32
|
+
"RuntimeVerifierAgent": "project-defined",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
PACKAGE_VERSION = "0.1.0"
|
|
36
|
+
|
|
37
|
+
ROLE_KEYS = {
|
|
38
|
+
"SetupAgent": "setup",
|
|
39
|
+
"ArchitectAgent": "architect",
|
|
40
|
+
"TesterAgent": "tester",
|
|
41
|
+
"ImplementerAgent": "implementer",
|
|
42
|
+
"ReviewerAgent": "reviewer",
|
|
43
|
+
"RuntimeVerifierAgent": "runtime_verifier",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _load_yaml(path: Path) -> dict:
|
|
48
|
+
if yaml is None:
|
|
49
|
+
raise RuntimeError("PyYAML is required")
|
|
50
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
51
|
+
return data or {}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _dump_yaml(data: dict) -> str:
|
|
55
|
+
if yaml is None:
|
|
56
|
+
raise RuntimeError("PyYAML is required")
|
|
57
|
+
return yaml.safe_dump(data, sort_keys=False, allow_unicode=True)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def build_agents(role_providers: dict | None = None) -> dict:
|
|
61
|
+
role_providers = role_providers or DEFAULT_ROLE_PROVIDERS
|
|
62
|
+
return {
|
|
63
|
+
ROLE_KEYS[role]: {"role": role, "provider": provider}
|
|
64
|
+
for role, provider in role_providers.items()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def build_ado_config(discovery: dict, role_providers: dict | None = None) -> dict:
|
|
69
|
+
return {
|
|
70
|
+
"version": 1,
|
|
71
|
+
"work_items": {
|
|
72
|
+
"provider": "azure-devops",
|
|
73
|
+
"azure_devops": {
|
|
74
|
+
"organization": discovery["organization"],
|
|
75
|
+
"project": discovery["project"],
|
|
76
|
+
"project_id": discovery["project_id"],
|
|
77
|
+
"process_id": discovery["process_id"],
|
|
78
|
+
"state_mapping": build_state_mapping(discovery),
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
"agents": build_agents(role_providers),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def build_github_config(role_providers: dict | None = None) -> dict:
|
|
86
|
+
return {
|
|
87
|
+
"version": 1,
|
|
88
|
+
"work_items": {"provider": "github-issues"},
|
|
89
|
+
"agents": build_agents(role_providers),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def merge_owned(existing: dict, generated: dict) -> dict:
|
|
94
|
+
merged = dict(existing)
|
|
95
|
+
merged["version"] = existing.get("version", generated.get("version", 1))
|
|
96
|
+
for key in ("work_items", "agents", "execution_environment"):
|
|
97
|
+
if key in generated:
|
|
98
|
+
merged[key] = generated[key]
|
|
99
|
+
if "framework" in generated:
|
|
100
|
+
merged["framework"] = {
|
|
101
|
+
**generated["framework"],
|
|
102
|
+
**(existing.get("framework") or {}),
|
|
103
|
+
"source": generated["framework"]["source"],
|
|
104
|
+
"ref": generated["framework"]["ref"],
|
|
105
|
+
"schema_version": generated["framework"]["schema_version"],
|
|
106
|
+
}
|
|
107
|
+
return merged
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def ensure_file(path: Path, content: str, force: bool = False) -> bool:
|
|
111
|
+
if path.exists() and not force:
|
|
112
|
+
return False
|
|
113
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
path.write_text(content, encoding="utf-8")
|
|
115
|
+
return True
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def generated_agents_md() -> str:
|
|
119
|
+
return """# AGENTS.md
|
|
120
|
+
|
|
121
|
+
This repository uses ai-dev-workflow.
|
|
122
|
+
|
|
123
|
+
Start from CURRENT.md when present, then load only context relevant to the assigned role and task.
|
|
124
|
+
Treat the configured WorkItemProvider as the task source of truth.
|
|
125
|
+
Do not change frozen acceptance criteria merely to make tests pass.
|
|
126
|
+
Runtime verification must not be inferred from mocks.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def generated_current_md() -> str:
|
|
131
|
+
return """# CURRENT
|
|
132
|
+
|
|
133
|
+
## Task
|
|
134
|
+
None
|
|
135
|
+
|
|
136
|
+
## Status
|
|
137
|
+
Idle
|
|
138
|
+
|
|
139
|
+
## Required Context
|
|
140
|
+
AGENTS.md
|
|
141
|
+
|
|
142
|
+
## Relevant Code
|
|
143
|
+
None
|
|
144
|
+
|
|
145
|
+
## Allowed Scope
|
|
146
|
+
Project-defined
|
|
147
|
+
|
|
148
|
+
## Done When
|
|
149
|
+
Project-defined
|
|
150
|
+
|
|
151
|
+
## Completed
|
|
152
|
+
None
|
|
153
|
+
|
|
154
|
+
## Next Action
|
|
155
|
+
Select or publish a work item.
|
|
156
|
+
|
|
157
|
+
## Blocker
|
|
158
|
+
None
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def generated_devcontainer() -> str:
|
|
163
|
+
return """{
|
|
164
|
+
"name": "AI Workflow Workspace",
|
|
165
|
+
"image": "mcr.microsoft.com/devcontainers/python:1-3.12-bookworm",
|
|
166
|
+
"features": {
|
|
167
|
+
"ghcr.io/devcontainers/features/node:1": {
|
|
168
|
+
"version": "lts"
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
"postCreateCommand": "python -m pip install --user PyYAML",
|
|
172
|
+
"remoteUser": "vscode"
|
|
173
|
+
}
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def parse_framework_ref(framework_ref: str) -> tuple[str, str]:
|
|
178
|
+
marker = "/.github/workflows/"
|
|
179
|
+
if marker not in framework_ref or "@" not in framework_ref:
|
|
180
|
+
return DEFAULT_FRAMEWORK_SOURCE, "main"
|
|
181
|
+
source = framework_ref.split(marker, 1)[0]
|
|
182
|
+
ref = framework_ref.rsplit("@", 1)[1]
|
|
183
|
+
return source, ref
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def generated_github_caller(framework_ref: str) -> str:
|
|
187
|
+
return f"""name: AI Workflow Queue
|
|
188
|
+
|
|
189
|
+
on:
|
|
190
|
+
issues:
|
|
191
|
+
types: [labeled, closed, unassigned]
|
|
192
|
+
workflow_dispatch:
|
|
193
|
+
schedule:
|
|
194
|
+
- cron: '*/10 * * * *'
|
|
195
|
+
|
|
196
|
+
permissions:
|
|
197
|
+
contents: write
|
|
198
|
+
issues: write
|
|
199
|
+
pull-requests: write
|
|
200
|
+
|
|
201
|
+
jobs:
|
|
202
|
+
dispatch:
|
|
203
|
+
if: github.event_name != 'issues' || github.event.action != 'labeled' || github.event.label.name == 'status:ready'
|
|
204
|
+
uses: {framework_ref}
|
|
205
|
+
with:
|
|
206
|
+
codex_cloud_enabled: false
|
|
207
|
+
codespaces_enabled: false
|
|
208
|
+
codespaces_mode: on-demand
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def doctor(config: dict, discovery: dict | None = None) -> list[str]:
|
|
213
|
+
errors = []
|
|
214
|
+
work_items = config.get("work_items", {})
|
|
215
|
+
provider = work_items.get("provider")
|
|
216
|
+
if provider not in ("github-issues", "azure-devops", "composite"):
|
|
217
|
+
errors.append("work_items.provider must be github-issues, azure-devops or composite")
|
|
218
|
+
if provider == "github-issues" and "github_issues" in work_items:
|
|
219
|
+
github = work_items.get("github_issues", {})
|
|
220
|
+
mapping = github.get("state_mapping", {})
|
|
221
|
+
if discovery is not None and discovery.get("labels") is not None:
|
|
222
|
+
errors.extend(validate_github_mapping(discovery, mapping))
|
|
223
|
+
canonical = set(mapping.values())
|
|
224
|
+
for required in REQUIRED_CANONICAL_STATES:
|
|
225
|
+
if required not in canonical:
|
|
226
|
+
errors.append(f"canonical state is not mapped by any GitHub status: {required}")
|
|
227
|
+
if provider in ("azure-devops", "composite"):
|
|
228
|
+
ado = work_items.get("azure_devops", {})
|
|
229
|
+
for field in ("organization", "project", "state_mapping"):
|
|
230
|
+
if not ado.get(field):
|
|
231
|
+
errors.append(f"missing Azure DevOps config: {field}")
|
|
232
|
+
mapping = ado.get("state_mapping", {})
|
|
233
|
+
if discovery is not None:
|
|
234
|
+
errors.extend(validate_mapping(discovery, mapping))
|
|
235
|
+
canonical = {value for wit in mapping.values() for value in wit.values()}
|
|
236
|
+
if provider == "azure-devops":
|
|
237
|
+
for required in REQUIRED_CANONICAL_STATES:
|
|
238
|
+
if required not in canonical:
|
|
239
|
+
errors.append(f"canonical state is not mapped by any ADO state: {required}")
|
|
240
|
+
if discovery is not None:
|
|
241
|
+
errors.extend(validate_field_mapping(discovery, ado.get("field_mapping", {})))
|
|
242
|
+
|
|
243
|
+
agents = config.get("agents", {})
|
|
244
|
+
by_role = {
|
|
245
|
+
item.get("role"): item.get("provider")
|
|
246
|
+
for item in agents.values()
|
|
247
|
+
if isinstance(item, dict)
|
|
248
|
+
}
|
|
249
|
+
for role in REQUIRED_AGENT_ROLES:
|
|
250
|
+
provider_name = by_role.get(role)
|
|
251
|
+
if not provider_name or provider_name == "none":
|
|
252
|
+
errors.append(f"required agent role has no provider: {role}")
|
|
253
|
+
|
|
254
|
+
providers = config.get("providers", {})
|
|
255
|
+
if providers:
|
|
256
|
+
errors.extend(validate_provider_registry(providers, agents))
|
|
257
|
+
|
|
258
|
+
workers = config.get("workers", [])
|
|
259
|
+
if workers:
|
|
260
|
+
errors.extend(validate_worker_registry(workers, providers))
|
|
261
|
+
|
|
262
|
+
env = config.get("execution_environment", {})
|
|
263
|
+
if env:
|
|
264
|
+
env_type = env.get("type")
|
|
265
|
+
if env_type not in ("none", "codespaces"):
|
|
266
|
+
errors.append("execution_environment.type must be none or codespaces")
|
|
267
|
+
if env_type == "codespaces":
|
|
268
|
+
mode = (env.get("codespaces") or {}).get("mode")
|
|
269
|
+
if mode not in ("off", "on-demand", "always-on"):
|
|
270
|
+
errors.append("codespaces mode must be off, on-demand or always-on")
|
|
271
|
+
return errors
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def parse_role_provider(values: list[str]) -> dict:
|
|
275
|
+
result = dict(DEFAULT_ROLE_PROVIDERS)
|
|
276
|
+
for item in values:
|
|
277
|
+
if "=" not in item:
|
|
278
|
+
raise ValueError("role provider must use Role=provider")
|
|
279
|
+
role, provider = item.split("=", 1)
|
|
280
|
+
role, provider = role.strip(), provider.strip()
|
|
281
|
+
if role not in DEFAULT_ROLE_PROVIDERS or not provider:
|
|
282
|
+
raise ValueError(f"invalid role provider assignment: {item}")
|
|
283
|
+
result[role] = provider
|
|
284
|
+
return result
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def parse_ai_provider(values: list[str]) -> dict:
|
|
288
|
+
providers = {
|
|
289
|
+
"manual": provider_template("interactive"),
|
|
290
|
+
"codex": provider_template("chatgpt-codex"),
|
|
291
|
+
}
|
|
292
|
+
for raw in values:
|
|
293
|
+
if "=" not in raw:
|
|
294
|
+
raise ValueError("ai provider must use id=type[,key=value...]")
|
|
295
|
+
provider_id, spec = raw.split("=", 1)
|
|
296
|
+
provider_id = provider_id.strip()
|
|
297
|
+
parts = [p.strip() for p in spec.split(",") if p.strip()]
|
|
298
|
+
if not provider_id or not parts:
|
|
299
|
+
raise ValueError(f"invalid ai provider: {raw}")
|
|
300
|
+
provider_type = parts[0]
|
|
301
|
+
options = {}
|
|
302
|
+
for item in parts[1:]:
|
|
303
|
+
if "=" not in item:
|
|
304
|
+
raise ValueError(f"invalid ai provider option: {item}")
|
|
305
|
+
key, value = item.split("=", 1)
|
|
306
|
+
options[key.strip()] = value.strip()
|
|
307
|
+
providers[provider_id] = provider_template(
|
|
308
|
+
provider_type,
|
|
309
|
+
model=options.get("model"),
|
|
310
|
+
base_url=options.get("base_url"),
|
|
311
|
+
auth_type=options.get("auth"),
|
|
312
|
+
env=options.get("env"),
|
|
313
|
+
)
|
|
314
|
+
return providers
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def parse_worker(values: list[str]) -> list[dict]:
|
|
318
|
+
workers = []
|
|
319
|
+
for raw in values:
|
|
320
|
+
if "=" not in raw:
|
|
321
|
+
raise ValueError("worker must use id=mode,provider=...,environment=...,roles=A|B")
|
|
322
|
+
worker_id, spec = raw.split("=", 1)
|
|
323
|
+
worker_id = worker_id.strip()
|
|
324
|
+
parts = [p.strip() for p in spec.split(",") if p.strip()]
|
|
325
|
+
if not worker_id or not parts:
|
|
326
|
+
raise ValueError(f"invalid worker: {raw}")
|
|
327
|
+
mode = parts[0]
|
|
328
|
+
options = {}
|
|
329
|
+
for item in parts[1:]:
|
|
330
|
+
if "=" not in item:
|
|
331
|
+
raise ValueError(f"invalid worker option: {item}")
|
|
332
|
+
key, value = item.split("=", 1)
|
|
333
|
+
options[key.strip()] = value.strip()
|
|
334
|
+
roles = [x for x in options.get("roles", "").split("|") if x]
|
|
335
|
+
capabilities = [x for x in options.get("capabilities", "").split("|") if x]
|
|
336
|
+
managed = options.get("managed")
|
|
337
|
+
managed_value = None if managed is None else managed.lower() in ("1","true","yes","on")
|
|
338
|
+
workers.append(build_worker(
|
|
339
|
+
worker_id,
|
|
340
|
+
mode=mode,
|
|
341
|
+
provider=options.get("provider", "manual"),
|
|
342
|
+
environment=options.get("environment", "local"),
|
|
343
|
+
roles=roles,
|
|
344
|
+
capabilities=capabilities,
|
|
345
|
+
capacity=int(options.get("capacity", "1")),
|
|
346
|
+
managed=managed_value,
|
|
347
|
+
assignee=options.get("assignee"),
|
|
348
|
+
planning_updates=options.get("planning_updates", "approval-required"),
|
|
349
|
+
))
|
|
350
|
+
return workers
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def parse_ado_field_mapping(values: list[str]) -> dict:
|
|
354
|
+
"""Parse WIT:key=Field.Ref[,default=value]. Values are user-defined; fields must already exist."""
|
|
355
|
+
result = {}
|
|
356
|
+
for raw in values:
|
|
357
|
+
if "=" not in raw or ":" not in raw.split("=",1)[0]:
|
|
358
|
+
raise ValueError("ado field must use WIT:key=Field.Ref[,default=value]")
|
|
359
|
+
left, spec = raw.split("=",1)
|
|
360
|
+
wit, key = left.split(":",1)
|
|
361
|
+
parts = [p.strip() for p in spec.split(",") if p.strip()]
|
|
362
|
+
field_ref = parts[0]
|
|
363
|
+
config = {"field": field_ref}
|
|
364
|
+
for item in parts[1:]:
|
|
365
|
+
if "=" not in item:
|
|
366
|
+
raise ValueError(f"invalid ado field option: {item}")
|
|
367
|
+
k, v = item.split("=",1)
|
|
368
|
+
if k.strip() != "default":
|
|
369
|
+
raise ValueError(f"unsupported ado field option: {k}")
|
|
370
|
+
config["default"] = v
|
|
371
|
+
result.setdefault(wit.strip(), {})[key.strip()] = config
|
|
372
|
+
return result
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def build_environment(environment: str, codespaces_mode: str) -> dict:
|
|
376
|
+
if environment == "none":
|
|
377
|
+
return {"type": "none"}
|
|
378
|
+
return {
|
|
379
|
+
"type": "codespaces",
|
|
380
|
+
"codespaces": {
|
|
381
|
+
"enabled": codespaces_mode != "off",
|
|
382
|
+
"mode": codespaces_mode,
|
|
383
|
+
},
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def perform_init(args, ado_api_factory=AzureDevOps) -> tuple[dict, dict | None]:
|
|
388
|
+
roles = parse_role_provider(args.role_provider)
|
|
389
|
+
discovery = None
|
|
390
|
+
if args.work_items == "azure-devops":
|
|
391
|
+
token = os.environ.get(args.ado_token_env)
|
|
392
|
+
if not token:
|
|
393
|
+
raise RuntimeError(f"{args.ado_token_env} is required")
|
|
394
|
+
api = ado_api_factory(args.ado_organization, token)
|
|
395
|
+
discovery = api.discover(args.ado_project)
|
|
396
|
+
generated = build_ado_config(discovery, roles)
|
|
397
|
+
else:
|
|
398
|
+
generated = build_github_config(roles)
|
|
399
|
+
|
|
400
|
+
generated["execution_environment"] = build_environment(
|
|
401
|
+
args.environment, args.codespaces_mode
|
|
402
|
+
)
|
|
403
|
+
framework_source, framework_version = parse_framework_ref(args.framework_ref)
|
|
404
|
+
generated = ensure_framework_metadata(
|
|
405
|
+
generated, source=framework_source, ref=framework_version
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
output = Path(args.output)
|
|
409
|
+
existing = _load_yaml(output) if output.exists() else {}
|
|
410
|
+
config = generated if args.force or not existing else merge_owned(existing, generated)
|
|
411
|
+
output.write_text(_dump_yaml(config), encoding="utf-8")
|
|
412
|
+
|
|
413
|
+
if discovery is not None:
|
|
414
|
+
discovery_path = output.with_suffix(output.suffix + ".ado-discovery.json")
|
|
415
|
+
discovery_path.write_text(
|
|
416
|
+
json.dumps(discovery, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
|
417
|
+
encoding="utf-8",
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
if not args.no_files:
|
|
421
|
+
ensure_file(Path("AGENTS.md"), generated_agents_md(), args.force)
|
|
422
|
+
ensure_file(Path("CURRENT.md"), generated_current_md(), args.force)
|
|
423
|
+
if args.environment == "codespaces":
|
|
424
|
+
ensure_file(
|
|
425
|
+
Path(".devcontainer/devcontainer.json"),
|
|
426
|
+
generated_devcontainer(),
|
|
427
|
+
args.force,
|
|
428
|
+
)
|
|
429
|
+
if args.work_items == "github-issues":
|
|
430
|
+
ensure_file(
|
|
431
|
+
Path(".github/workflows/ai-workflow.yml"),
|
|
432
|
+
generated_github_caller(args.framework_ref),
|
|
433
|
+
args.force,
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
return config, discovery
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def init_general(args) -> int:
|
|
440
|
+
config, discovery = perform_init(args)
|
|
441
|
+
errors = doctor(config, discovery)
|
|
442
|
+
print(f"Wrote {args.output}")
|
|
443
|
+
if errors:
|
|
444
|
+
print("Doctor findings:")
|
|
445
|
+
for error in errors:
|
|
446
|
+
print("- " + error)
|
|
447
|
+
return 2
|
|
448
|
+
print("Doctor: OK")
|
|
449
|
+
return 0
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def init_ado(args) -> int:
|
|
453
|
+
class Compat:
|
|
454
|
+
pass
|
|
455
|
+
compat = Compat()
|
|
456
|
+
compat.work_items = "azure-devops"
|
|
457
|
+
compat.ado_organization = args.organization
|
|
458
|
+
compat.ado_project = args.project
|
|
459
|
+
compat.ado_token_env = args.token_env
|
|
460
|
+
compat.role_provider = args.role_provider
|
|
461
|
+
compat.output = args.output
|
|
462
|
+
compat.force = args.force
|
|
463
|
+
compat.environment = "none"
|
|
464
|
+
compat.codespaces_mode = "off"
|
|
465
|
+
compat.no_files = True
|
|
466
|
+
compat.framework_ref = ""
|
|
467
|
+
config, discovery = perform_init(compat)
|
|
468
|
+
errors = doctor(config, discovery)
|
|
469
|
+
discovery_path = Path(args.output).with_suffix(Path(args.output).suffix + ".ado-discovery.json")
|
|
470
|
+
print(f"Wrote {args.output}")
|
|
471
|
+
print(f"Wrote {discovery_path}")
|
|
472
|
+
if errors:
|
|
473
|
+
print("Doctor findings:")
|
|
474
|
+
for error in errors:
|
|
475
|
+
print("- " + error)
|
|
476
|
+
return 2
|
|
477
|
+
print("Doctor: OK")
|
|
478
|
+
return 0
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def setup_cmd(args) -> int:
|
|
482
|
+
github_auth = None
|
|
483
|
+
if args.github_repository:
|
|
484
|
+
_, github_auth = resolve_github_token(args.github_auth, args.github_token_env)
|
|
485
|
+
provider_errors = validate_work_item_provider(args.work_items)
|
|
486
|
+
if provider_errors:
|
|
487
|
+
raise RuntimeError(provider_errors[0])
|
|
488
|
+
|
|
489
|
+
roles = parse_role_provider(args.role_provider)
|
|
490
|
+
agents = build_agents(roles)
|
|
491
|
+
providers = parse_ai_provider(args.ai_provider)
|
|
492
|
+
workers = parse_worker(args.worker)
|
|
493
|
+
environment = build_environment(args.environment, args.codespaces_mode)
|
|
494
|
+
|
|
495
|
+
discovery, suggestions = discover_provider(args)
|
|
496
|
+
ado_field_mapping = parse_ado_field_mapping(args.ado_field)
|
|
497
|
+
ado_mcp = None
|
|
498
|
+
if args.work_items in ("azure-devops", "composite") and args.ado_mcp_mode != "none":
|
|
499
|
+
ado_mcp = build_ado_mcp(
|
|
500
|
+
args.ado_organization,
|
|
501
|
+
mode=args.ado_mcp_mode,
|
|
502
|
+
auth=args.ado_mcp_auth,
|
|
503
|
+
project=args.ado_project,
|
|
504
|
+
team=args.ado_team,
|
|
505
|
+
)
|
|
506
|
+
mapping = suggestions
|
|
507
|
+
if not args.non_interactive:
|
|
508
|
+
print("Step 1/10: source host connected")
|
|
509
|
+
print("Step 2/10: repository context analyzed")
|
|
510
|
+
print("Step 3/10: work item provider selected:", args.work_items)
|
|
511
|
+
print("Step 4/10: work item provider connected")
|
|
512
|
+
print("Step 5/10: existing statuses discovered")
|
|
513
|
+
print("Step 6/10: confirm status mapping")
|
|
514
|
+
print("Step 8/10: workers registered:", len(workers))
|
|
515
|
+
mapping = interactive_mapping("azure-devops" if args.work_items == "composite" else args.work_items, discovery, suggestions)
|
|
516
|
+
if args.work_items in ("azure-devops", "composite") and not ado_field_mapping:
|
|
517
|
+
ado_field_mapping = interactive_ado_field_mapping(discovery)
|
|
518
|
+
|
|
519
|
+
workflow = {
|
|
520
|
+
"tdd": args.tdd,
|
|
521
|
+
"reviewer": {"mode": args.reviewer_mode},
|
|
522
|
+
"runtime_verification": {"mode": args.runtime_mode},
|
|
523
|
+
}
|
|
524
|
+
plan = build_setup_plan(
|
|
525
|
+
root=args.root,
|
|
526
|
+
work_items=args.work_items,
|
|
527
|
+
discovery=discovery,
|
|
528
|
+
mapping=mapping,
|
|
529
|
+
workflow=workflow,
|
|
530
|
+
agents=agents,
|
|
531
|
+
environment=environment,
|
|
532
|
+
providers=providers,
|
|
533
|
+
github_auth=github_auth,
|
|
534
|
+
ado_mcp=ado_mcp,
|
|
535
|
+
workers=workers,
|
|
536
|
+
ado_field_mapping=ado_field_mapping,
|
|
537
|
+
)
|
|
538
|
+
plan["work_items"]["mapping_confirmed"] = not args.plan_only
|
|
539
|
+
Path(args.plan_output).write_text(_dump_yaml(plan), encoding="utf-8")
|
|
540
|
+
print(render_user_steps(plan))
|
|
541
|
+
print(f"\nWrote setup plan: {args.plan_output}")
|
|
542
|
+
|
|
543
|
+
if args.plan_only:
|
|
544
|
+
return 0
|
|
545
|
+
|
|
546
|
+
if args.work_items == "github-issues":
|
|
547
|
+
discovery, mapping, created = apply_github_status_bootstrap(args, mapping)
|
|
548
|
+
if created:
|
|
549
|
+
print("Created managed GitHub status labels: " + ", ".join(created))
|
|
550
|
+
|
|
551
|
+
class InitArgs:
|
|
552
|
+
pass
|
|
553
|
+
init = InitArgs()
|
|
554
|
+
init.work_items = "azure-devops" if args.work_items == "composite" else args.work_items
|
|
555
|
+
init.ado_organization = args.ado_organization
|
|
556
|
+
init.ado_project = args.ado_project
|
|
557
|
+
init.ado_token_env = args.ado_token_env
|
|
558
|
+
init.role_provider = args.role_provider
|
|
559
|
+
init.output = args.output
|
|
560
|
+
init.force = args.force
|
|
561
|
+
init.environment = args.environment
|
|
562
|
+
init.codespaces_mode = args.codespaces_mode
|
|
563
|
+
init.no_files = args.no_files
|
|
564
|
+
init.framework_ref = args.framework_ref
|
|
565
|
+
|
|
566
|
+
config, _ = perform_init(init)
|
|
567
|
+
config["workflow"] = workflow
|
|
568
|
+
config["providers"] = providers
|
|
569
|
+
config["workers"] = workers
|
|
570
|
+
config["source_connection"] = {"github": github_auth or {}}
|
|
571
|
+
if ado_mcp:
|
|
572
|
+
config["mcp"] = {"azure_devops": ado_mcp}
|
|
573
|
+
if args.work_items == "github-issues":
|
|
574
|
+
config["work_items"]["github_issues"] = {
|
|
575
|
+
"repository": args.github_repository,
|
|
576
|
+
"state_mapping": mapping,
|
|
577
|
+
}
|
|
578
|
+
else:
|
|
579
|
+
config["work_items"]["azure_devops"]["state_mapping"] = mapping
|
|
580
|
+
config["work_items"]["azure_devops"]["field_mapping"] = ado_field_mapping
|
|
581
|
+
if args.work_items == "composite":
|
|
582
|
+
config["work_items"]["provider"] = "composite"
|
|
583
|
+
config["work_items"]["github_issues"] = {"repository": args.github_repository}
|
|
584
|
+
config["work_items"]["sync"] = {
|
|
585
|
+
"business_provider": "azure-devops",
|
|
586
|
+
"execution_provider": "github-issues",
|
|
587
|
+
"state_mode": "coarse",
|
|
588
|
+
}
|
|
589
|
+
framework_source, framework_version = parse_framework_ref(args.framework_ref)
|
|
590
|
+
config = ensure_framework_metadata(
|
|
591
|
+
config, source=framework_source, ref=framework_version
|
|
592
|
+
)
|
|
593
|
+
config["framework"]["source"] = framework_source
|
|
594
|
+
config["framework"]["ref"] = framework_version
|
|
595
|
+
Path(args.output).write_text(_dump_yaml(config), encoding="utf-8")
|
|
596
|
+
errors = doctor(config, discovery)
|
|
597
|
+
if errors:
|
|
598
|
+
print("Doctor findings:")
|
|
599
|
+
for error in errors:
|
|
600
|
+
print("- " + error)
|
|
601
|
+
return 2
|
|
602
|
+
print("Step 10/10: Doctor: OK")
|
|
603
|
+
return 0
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def update_cmd(args) -> int:
|
|
607
|
+
root = Path(args.root)
|
|
608
|
+
config_path = Path(args.config)
|
|
609
|
+
if not config_path.is_absolute():
|
|
610
|
+
config_path = root / config_path
|
|
611
|
+
if not config_path.exists():
|
|
612
|
+
raise RuntimeError(f"config not found: {config_path}")
|
|
613
|
+
config = _load_yaml(config_path)
|
|
614
|
+
meta = ensure_framework_metadata(config).get("framework", {})
|
|
615
|
+
target_ref = args.to or "main"
|
|
616
|
+
target_source = args.source or meta.get("source", DEFAULT_FRAMEWORK_SOURCE)
|
|
617
|
+
plan = build_update_plan(
|
|
618
|
+
config,
|
|
619
|
+
target_ref=target_ref,
|
|
620
|
+
target_source=target_source,
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
print(
|
|
624
|
+
f"Framework: {plan['current']['source']}@{plan['current']['ref']} "
|
|
625
|
+
f"-> {plan['target']['source']}@{plan['target']['ref']}"
|
|
626
|
+
)
|
|
627
|
+
print("Update available:", "yes" if plan["update_available"] else "no")
|
|
628
|
+
if args.check:
|
|
629
|
+
return 0
|
|
630
|
+
|
|
631
|
+
workflow_ref = (
|
|
632
|
+
f"{target_source}/.github/workflows/queue-dispatcher.yml@{target_ref}"
|
|
633
|
+
)
|
|
634
|
+
workflow = generated_github_caller(workflow_ref)
|
|
635
|
+
current_workflow_ref = (
|
|
636
|
+
f"{plan['current']['source']}/.github/workflows/queue-dispatcher.yml@"
|
|
637
|
+
f"{plan['current']['ref']}"
|
|
638
|
+
)
|
|
639
|
+
expected_current_workflow = generated_github_caller(current_workflow_ref)
|
|
640
|
+
updated, report = apply_update(
|
|
641
|
+
config,
|
|
642
|
+
target_ref=target_ref,
|
|
643
|
+
target_source=target_source,
|
|
644
|
+
generated_workflow=workflow,
|
|
645
|
+
expected_current_workflow=expected_current_workflow,
|
|
646
|
+
root=args.root,
|
|
647
|
+
force=args.force,
|
|
648
|
+
)
|
|
649
|
+
config_path.write_text(_dump_yaml(updated), encoding="utf-8")
|
|
650
|
+
|
|
651
|
+
print("Migrations:", ", ".join(report["migrations"]) or "none")
|
|
652
|
+
print("Changed files:", ", ".join(report["changed_files"]) or "none")
|
|
653
|
+
print("Preserved project files:", ", ".join(report["preserved_project_files"]))
|
|
654
|
+
|
|
655
|
+
errors = doctor(updated)
|
|
656
|
+
if errors:
|
|
657
|
+
print("Doctor findings:")
|
|
658
|
+
for error in errors:
|
|
659
|
+
print("- " + error)
|
|
660
|
+
return 2
|
|
661
|
+
print("Doctor: OK")
|
|
662
|
+
return 0
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def doctor_cmd(args) -> int:
|
|
666
|
+
config = _load_yaml(Path(args.config))
|
|
667
|
+
discovery = None
|
|
668
|
+
if args.discovery:
|
|
669
|
+
discovery = json.loads(Path(args.discovery).read_text(encoding="utf-8"))
|
|
670
|
+
errors = doctor(config, discovery)
|
|
671
|
+
if errors:
|
|
672
|
+
for error in errors:
|
|
673
|
+
print("ERROR: " + error)
|
|
674
|
+
return 2
|
|
675
|
+
print("Doctor: OK")
|
|
676
|
+
return 0
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def main() -> int:
|
|
680
|
+
parser = argparse.ArgumentParser(prog="ai-workflow")
|
|
681
|
+
parser.add_argument("--version", action="version", version=f"ai-workflow {PACKAGE_VERSION}")
|
|
682
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
683
|
+
|
|
684
|
+
init = sub.add_parser("init")
|
|
685
|
+
init.add_argument("--work-items", choices=["github-issues", "azure-devops"], default="github-issues")
|
|
686
|
+
init.add_argument("--ado-organization")
|
|
687
|
+
init.add_argument("--ado-project")
|
|
688
|
+
init.add_argument("--ado-token-env", default="AZURE_DEVOPS_TOKEN")
|
|
689
|
+
init.add_argument("--environment", choices=["none", "codespaces"], default="none")
|
|
690
|
+
init.add_argument("--codespaces-mode", choices=["off", "on-demand", "always-on"], default="on-demand")
|
|
691
|
+
init.add_argument("--role-provider", action="append", default=[])
|
|
692
|
+
init.add_argument("--output", default=".ai-workflow.yml")
|
|
693
|
+
init.add_argument("--framework-ref", default="vuthethienlong/ai-dev-workflow/.github/workflows/queue-dispatcher.yml@main")
|
|
694
|
+
init.add_argument("--no-files", action="store_true")
|
|
695
|
+
init.add_argument("--force", action="store_true")
|
|
696
|
+
init.set_defaults(func=init_general)
|
|
697
|
+
|
|
698
|
+
init_ado_parser = sub.add_parser("init-ado")
|
|
699
|
+
init_ado_parser.add_argument("--organization", required=True)
|
|
700
|
+
init_ado_parser.add_argument("--project", required=True)
|
|
701
|
+
init_ado_parser.add_argument("--output", default=".ai-workflow.yml")
|
|
702
|
+
init_ado_parser.add_argument("--token-env", default="AZURE_DEVOPS_TOKEN")
|
|
703
|
+
init_ado_parser.add_argument("--role-provider", action="append", default=[])
|
|
704
|
+
init_ado_parser.add_argument("--force", action="store_true")
|
|
705
|
+
init_ado_parser.set_defaults(func=init_ado)
|
|
706
|
+
|
|
707
|
+
setup = sub.add_parser("setup")
|
|
708
|
+
setup.add_argument("--root", default=".")
|
|
709
|
+
setup.add_argument("--work-items", choices=["github-issues", "azure-devops", "composite"], default="github-issues")
|
|
710
|
+
setup.add_argument("--github-repository")
|
|
711
|
+
setup.add_argument("--github-token-env", default="GH_TOKEN")
|
|
712
|
+
setup.add_argument("--github-auth", choices=["gh-cli","token-env"], default="gh-cli")
|
|
713
|
+
setup.add_argument("--ado-organization")
|
|
714
|
+
setup.add_argument("--ado-project")
|
|
715
|
+
setup.add_argument("--ado-token-env", default="AZURE_DEVOPS_TOKEN")
|
|
716
|
+
setup.add_argument("--ado-mcp-mode", choices=["none","remote","local"], default="remote")
|
|
717
|
+
setup.add_argument("--ado-mcp-auth", choices=["interactive","azcli","env","envvar","pat"], default="interactive")
|
|
718
|
+
setup.add_argument("--ado-team")
|
|
719
|
+
setup.add_argument("--ado-field", action="append", default=[])
|
|
720
|
+
setup.add_argument("--environment", choices=["none", "codespaces"], default="none")
|
|
721
|
+
setup.add_argument("--codespaces-mode", choices=["off", "on-demand", "always-on"], default="on-demand")
|
|
722
|
+
setup.add_argument("--role-provider", action="append", default=[])
|
|
723
|
+
setup.add_argument("--ai-provider", action="append", default=[])
|
|
724
|
+
setup.add_argument("--worker", action="append", default=[])
|
|
725
|
+
setup.add_argument("--tdd", action=argparse.BooleanOptionalAction, default=True)
|
|
726
|
+
setup.add_argument("--reviewer-mode", choices=["manual", "automatic", "none"], default="manual")
|
|
727
|
+
setup.add_argument("--runtime-mode", choices=["project-defined", "batched", "none"], default="project-defined")
|
|
728
|
+
setup.add_argument("--output", default=".ai-workflow.yml")
|
|
729
|
+
setup.add_argument("--plan-output", default=".ai-setup-plan.yml")
|
|
730
|
+
setup.add_argument("--framework-ref", default="vuthethienlong/ai-dev-workflow/.github/workflows/queue-dispatcher.yml@main")
|
|
731
|
+
setup.add_argument("--plan-only", action="store_true")
|
|
732
|
+
setup.add_argument("--non-interactive", action="store_true")
|
|
733
|
+
setup.add_argument("--no-files", action="store_true")
|
|
734
|
+
setup.add_argument("--force", action="store_true")
|
|
735
|
+
setup.set_defaults(func=setup_cmd)
|
|
736
|
+
|
|
737
|
+
update = sub.add_parser("update")
|
|
738
|
+
update.add_argument("--config", default=".ai-workflow.yml")
|
|
739
|
+
update.add_argument("--root", default=".")
|
|
740
|
+
update.add_argument("--check", action="store_true")
|
|
741
|
+
update.add_argument("--to")
|
|
742
|
+
update.add_argument("--source")
|
|
743
|
+
update.add_argument("--force", action="store_true")
|
|
744
|
+
update.set_defaults(func=update_cmd)
|
|
745
|
+
|
|
746
|
+
doc = sub.add_parser("doctor")
|
|
747
|
+
doc.add_argument("--config", default=".ai-workflow.yml")
|
|
748
|
+
doc.add_argument("--discovery")
|
|
749
|
+
doc.set_defaults(func=doctor_cmd)
|
|
750
|
+
|
|
751
|
+
args = parser.parse_args()
|
|
752
|
+
if args.command in ("init", "setup") and args.work_items in ("azure-devops", "composite"):
|
|
753
|
+
if not args.ado_organization or not args.ado_project:
|
|
754
|
+
parser.error("--ado-organization and --ado-project are required for azure-devops")
|
|
755
|
+
if args.command == "setup" and args.work_items in ("github-issues", "composite") and not args.github_repository:
|
|
756
|
+
parser.error("--github-repository is required for GitHub status discovery")
|
|
757
|
+
return args.func(args)
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
if __name__ == "__main__":
|
|
761
|
+
raise SystemExit(main())
|