@torus-engineering/tas-kit 1.11.1 → 1.13.0
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.
- package/.tas/README.md +334 -334
- package/{.claude → .tas/_platform/claude-code}/settings.json +0 -12
- package/{.claude → .tas/_platform}/hooks/code-quality.js +1 -1
- package/{.claude → .tas/_platform}/hooks/session-end.js +20 -25
- package/{.claude → .tas}/commands/ado-create.md +5 -4
- package/{.claude → .tas}/commands/ado-delete.md +5 -4
- package/{.claude → .tas}/commands/ado-update.md +5 -4
- package/{.claude → .tas}/commands/tas-adr.md +3 -3
- package/{.claude → .tas}/commands/tas-apitest-plan.md +2 -2
- package/{.claude → .tas}/commands/tas-apitest.md +4 -4
- package/{.claude → .tas}/commands/tas-bug.md +6 -6
- package/{.claude → .tas}/commands/tas-design.md +3 -3
- package/{.claude → .tas}/commands/tas-dev.md +11 -14
- package/{.claude → .tas}/commands/tas-epic.md +3 -3
- package/{.claude → .tas}/commands/tas-feature.md +4 -4
- package/{.claude → .tas}/commands/tas-fix.md +5 -5
- package/{.claude → .tas}/commands/tas-init.md +1 -1
- package/{.claude → .tas}/commands/tas-plan.md +198 -198
- package/{.claude → .tas}/commands/tas-prd.md +3 -3
- package/{.claude → .tas}/commands/tas-review.md +17 -15
- package/{.claude → .tas}/commands/tas-sad.md +3 -3
- package/{.claude → .tas}/commands/tas-security.md +4 -4
- package/{.claude → .tas}/commands/tas-story.md +3 -3
- package/.tas/platforms.json +5 -0
- package/.tas/project-status-example.yaml +17 -17
- package/{.claude/skills/ado-integration/SKILL.md → .tas/rules/ado-integration.md} +5 -15
- package/{.claude/skills/api-design/SKILL.md → .tas/rules/common/api-design.md} +517 -530
- package/{.claude → .tas}/rules/common/code-review.md +30 -6
- package/{.claude/rules/common/post-review-agent.md → .tas/rules/common/post-implementation-review.md} +51 -49
- package/{.claude → .tas}/rules/common/project-status.md +80 -80
- package/{.claude → .tas}/rules/common/stack-detection.md +29 -29
- package/.tas/{checklists → rules/common}/story-done.md +12 -5
- package/{.claude/skills/tas-tdd/SKILL.md → .tas/rules/common/tdd.md} +4 -38
- package/{.claude → .tas}/rules/common/testing.md +3 -8
- package/{.claude → .tas}/rules/common/token-logging.md +36 -27
- package/{.claude → .tas}/rules/csharp/api-testing.md +171 -171
- package/{.claude → .tas}/rules/csharp/coding-style.md +0 -2
- package/{.claude → .tas}/rules/csharp/security.md +10 -0
- package/{.claude → .tas}/rules/python/coding-style.md +0 -2
- package/{.claude → .tas}/rules/typescript/coding-style.md +0 -2
- package/.tas/rules/typescript/patterns.md +142 -0
- package/.tas/rules/typescript/security.md +88 -0
- package/{.claude → .tas}/rules/typescript/testing.md +0 -4
- package/{.claude → .tas}/rules/web/coding-style.md +0 -2
- package/.tas/tas-example.yaml +125 -126
- package/.tas/templates/ADR.md +47 -47
- package/.tas/templates/Bug.md +67 -67
- package/.tas/templates/Design-Spec.md +36 -36
- package/.tas/templates/Epic.md +46 -46
- package/.tas/templates/Feature.md +1 -1
- package/.tas/templates/Security-Report.md +27 -27
- package/.tas/tools/tas-ado-readme.md +169 -169
- package/.tas/tools/tas-ado.py +621 -621
- package/README.md +334 -334
- package/bin/cli.js +91 -73
- package/lib/adapters/antigravity.js +131 -0
- package/lib/adapters/claude-code.js +35 -0
- package/lib/adapters/codex.js +157 -0
- package/lib/adapters/cursor.js +80 -0
- package/lib/adapters/index.js +20 -0
- package/lib/adapters/utils.js +81 -0
- package/lib/deleted-files.json +99 -0
- package/lib/install.js +543 -327
- package/package.json +5 -4
- package/.claude/agents/code-reviewer.md +0 -41
- package/.claude/agents/e2e-runner.md +0 -61
- package/.claude/agents/planner.md +0 -82
- package/.claude/agents/tdd-guide.md +0 -84
- package/.claude/commands/tas-verify.md +0 -51
- package/.claude/rules/typescript/patterns.md +0 -62
- package/.claude/rules/typescript/security.md +0 -28
- package/.claude/settings.local.json +0 -38
- package/.claude/skills/ai-regression-testing/SKILL.md +0 -364
- package/.claude/skills/architecture-decision-records/SKILL.md +0 -184
- package/.claude/skills/benchmark/SKILL.md +0 -98
- package/.claude/skills/browser-qa/SKILL.md +0 -92
- package/.claude/skills/canary-watch/SKILL.md +0 -104
- package/.claude/skills/js-backend-patterns/SKILL.md +0 -603
- package/.claude/skills/tas-conventions/SKILL.md +0 -65
- package/.claude/skills/tas-implementation-complete/SKILL.md +0 -100
- package/.claude/skills/token-logger/SKILL.md +0 -19
- package/.tas/checklists/code-review.md +0 -29
- package/.tas/checklists/security.md +0 -21
- /package/{.claude → .tas}/agents/architect.md +0 -0
- /package/{.claude → .tas}/agents/aws-reviewer.md +0 -0
- /package/{.claude → .tas}/agents/build-resolver.md +0 -0
- /package/{.claude → .tas}/agents/code-explorer.md +0 -0
- /package/{.claude → .tas}/agents/csharp-reviewer.md +0 -0
- /package/{.claude → .tas}/agents/database-reviewer.md +0 -0
- /package/{.claude → .tas}/agents/doc-updater.md +0 -0
- /package/{.claude → .tas}/agents/python-reviewer.md +0 -0
- /package/{.claude → .tas}/agents/security-reviewer.md +0 -0
- /package/{.claude → .tas}/agents/typescript-reviewer.md +0 -0
- /package/{.claude → .tas}/commands/ado-get.md +0 -0
- /package/{.claude → .tas}/commands/ado-status.md +0 -0
- /package/{.claude → .tas}/commands/tas-brainstorm.md +0 -0
- /package/{.claude → .tas}/commands/tas-e2e-mobile.md +0 -0
- /package/{.claude → .tas}/commands/tas-e2e-web.md +0 -0
- /package/{.claude → .tas}/commands/tas-e2e.md +0 -0
- /package/{.claude → .tas}/commands/tas-functest-mobile.md +0 -0
- /package/{.claude → .tas}/commands/tas-functest-web.md +0 -0
- /package/{.claude → .tas}/commands/tas-functest.md +0 -0
- /package/{.claude → .tas}/commands/tas-spec.md +0 -0
- /package/{.claude → .tas}/commands/tas-status.md +0 -0
- /package/{.claude → .tas}/rules/.gitkeep +0 -0
- /package/{.claude → .tas}/rules/common/hooks.md +0 -0
- /package/{.claude → .tas}/rules/common/patterns.md +0 -0
- /package/{.claude → .tas}/rules/common/security.md +0 -0
- /package/{.claude → .tas}/rules/csharp/hooks.md +0 -0
- /package/{.claude → .tas}/rules/csharp/patterns.md +0 -0
- /package/{.claude → .tas}/rules/csharp/testing.md +0 -0
- /package/{.claude → .tas}/rules/python/hooks.md +0 -0
- /package/{.claude → .tas}/rules/python/patterns.md +0 -0
- /package/{.claude → .tas}/rules/python/security.md +0 -0
- /package/{.claude → .tas}/rules/python/testing.md +0 -0
- /package/{.claude → .tas}/rules/typescript/hooks.md +0 -0
- /package/{.claude → .tas}/rules/web/design-quality.md +0 -0
- /package/{.claude → .tas}/rules/web/hooks.md +0 -0
- /package/{.claude → .tas}/rules/web/patterns.md +0 -0
- /package/{.claude → .tas}/rules/web/performance.md +0 -0
- /package/{.claude → .tas}/rules/web/security.md +0 -0
- /package/{.claude → .tas}/rules/web/testing.md +0 -0
- /package/{CLAUDE-Example.md → .tas/templates/AGENTS.md} +0 -0
package/.tas/tools/tas-ado.py
CHANGED
|
@@ -1,621 +1,621 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
TAS ADO Integration Script
|
|
4
|
-
Two-way sync between local .md files and Azure DevOps work items.
|
|
5
|
-
|
|
6
|
-
Usage: python tools/tas-ado.py <command> [arguments]
|
|
7
|
-
|
|
8
|
-
Commands:
|
|
9
|
-
create-epic <temp-id> [--parent-id <id>]
|
|
10
|
-
create-feature <temp-id> [--parent-id <id>]
|
|
11
|
-
create-story <temp-id> [--parent-id <id>]
|
|
12
|
-
create-bug <temp-id> [--parent-id <id>]
|
|
13
|
-
get <ado-id>
|
|
14
|
-
pull <ado-id> (alias for get)
|
|
15
|
-
update-epic <ado-id> [--assign <name>] [--status <state>]
|
|
16
|
-
update-feature <ado-id> [--assign <name>] [--status <state>]
|
|
17
|
-
update-story <ado-id> [--assign <name>] [--status <state>]
|
|
18
|
-
update-bug <ado-id> [--assign <name>] [--status <state>]
|
|
19
|
-
update-status <ado-id> --status <state> [--assign <name>]
|
|
20
|
-
delete-epic <ado-id>
|
|
21
|
-
delete-feature <ado-id>
|
|
22
|
-
delete-story <ado-id>
|
|
23
|
-
delete-bug <ado-id>
|
|
24
|
-
|
|
25
|
-
Prerequisites:
|
|
26
|
-
- Azure CLI + azure-devops extension
|
|
27
|
-
- Python 3.8+ with pyyaml
|
|
28
|
-
- PAT in tas.yaml, AZURE_DEVOPS_PAT, or AzureDevops_Personal_AccessToken env var
|
|
29
|
-
"""
|
|
30
|
-
|
|
31
|
-
import argparse
|
|
32
|
-
import json
|
|
33
|
-
import os
|
|
34
|
-
import re
|
|
35
|
-
import subprocess
|
|
36
|
-
import sys
|
|
37
|
-
import tempfile
|
|
38
|
-
from datetime import datetime
|
|
39
|
-
from pathlib import Path
|
|
40
|
-
|
|
41
|
-
try:
|
|
42
|
-
import yaml
|
|
43
|
-
except ImportError:
|
|
44
|
-
print("ERROR: pyyaml required. Run: pip install pyyaml")
|
|
45
|
-
sys.exit(1)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
# --- Config ---
|
|
49
|
-
|
|
50
|
-
def find_repo_root():
|
|
51
|
-
"""Walk up from cwd to find tas.yaml at repo root"""
|
|
52
|
-
path = Path.cwd()
|
|
53
|
-
while path != path.parent:
|
|
54
|
-
if (path / "tas.yaml").exists():
|
|
55
|
-
return path
|
|
56
|
-
path = path.parent
|
|
57
|
-
print("ERROR: tas.yaml not found. Run /tas-init first.")
|
|
58
|
-
sys.exit(1)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
def load_dotenv(root):
|
|
62
|
-
"""Load .env file from repo root into os.environ."""
|
|
63
|
-
env_file = root / ".env"
|
|
64
|
-
if env_file.exists():
|
|
65
|
-
with open(env_file, "r", encoding="utf-8") as f:
|
|
66
|
-
for line in f:
|
|
67
|
-
line = line.strip()
|
|
68
|
-
if line and not line.startswith("#") and "=" in line:
|
|
69
|
-
key, _, value = line.partition("=")
|
|
70
|
-
os.environ.setdefault(key.strip(), value.strip())
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def load_config():
|
|
74
|
-
root = find_repo_root()
|
|
75
|
-
load_dotenv(root)
|
|
76
|
-
config_path = root / "tas.yaml"
|
|
77
|
-
with open(config_path, "r", encoding="utf-8") as f:
|
|
78
|
-
config = yaml.safe_load(f)
|
|
79
|
-
ado = config.get("ado", {})
|
|
80
|
-
org = ado.get("organization", "")
|
|
81
|
-
project = ado.get("project_id", "")
|
|
82
|
-
pat = (ado.get("pat")
|
|
83
|
-
or os.environ.get("AZURE_DEVOPS_PAT", "")
|
|
84
|
-
or os.environ.get("AzureDevops_Personal_AccessToken", ""))
|
|
85
|
-
if not org or not project:
|
|
86
|
-
print("ERROR: ado.organization and ado.project_id required in .tas/tas.yaml")
|
|
87
|
-
sys.exit(1)
|
|
88
|
-
|
|
89
|
-
project_code = config.get("project", {}).get("code", "")
|
|
90
|
-
|
|
91
|
-
team = config.get("team", [])
|
|
92
|
-
assignee_by_role = {}
|
|
93
|
-
for member in team:
|
|
94
|
-
role = member.get("role", "").lower()
|
|
95
|
-
if role not in assignee_by_role:
|
|
96
|
-
assignee_by_role[role] = member.get("ado_id", "")
|
|
97
|
-
|
|
98
|
-
return {
|
|
99
|
-
"root": root,
|
|
100
|
-
"org": org,
|
|
101
|
-
"project": project,
|
|
102
|
-
"pat": pat,
|
|
103
|
-
"project_code": project_code,
|
|
104
|
-
"assignee_by_role": assignee_by_role,
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def az_cmd(args, config):
|
|
109
|
-
"""Run az CLI command and return parsed JSON output.
|
|
110
|
-
|
|
111
|
-
Multiline string args (e.g. --description) are written to a temp file
|
|
112
|
-
and passed as @filepath so newlines never break az.cmd on Windows.
|
|
113
|
-
"""
|
|
114
|
-
az_bin = "az.cmd" if sys.platform == "win32" else "az"
|
|
115
|
-
|
|
116
|
-
tmp_files = []
|
|
117
|
-
safe_args = []
|
|
118
|
-
i = 0
|
|
119
|
-
while i < len(args):
|
|
120
|
-
if (len(str(args[i])) > 200 or "\n" in str(args[i])) and i > 0 and str(args[i - 1]).startswith("--"):
|
|
121
|
-
tmp = tempfile.NamedTemporaryFile(
|
|
122
|
-
mode="wb", suffix=".txt", delete=False
|
|
123
|
-
)
|
|
124
|
-
tmp.write(args[i].encode("utf-8"))
|
|
125
|
-
tmp.close()
|
|
126
|
-
tmp_files.append(tmp.name)
|
|
127
|
-
safe_args.append(f"@{tmp.name}")
|
|
128
|
-
else:
|
|
129
|
-
safe_args.append(args[i])
|
|
130
|
-
i += 1
|
|
131
|
-
|
|
132
|
-
# work-item update/show/delete/relation operate by global ID — --project is not accepted
|
|
133
|
-
_no_project_cmds = {"update", "show", "delete", "relation"}
|
|
134
|
-
wi_action = args[2] if len(args) > 2 else ""
|
|
135
|
-
needs_project = wi_action not in _no_project_cmds
|
|
136
|
-
|
|
137
|
-
tail = ["--org", config["org"], "--output", "json"]
|
|
138
|
-
if needs_project:
|
|
139
|
-
tail = ["--org", config["org"], "--project", config["project"], "--output", "json"]
|
|
140
|
-
|
|
141
|
-
cmd = [az_bin] + safe_args + tail
|
|
142
|
-
env = os.environ.copy()
|
|
143
|
-
if config["pat"]:
|
|
144
|
-
env["AZURE_DEVOPS_EXT_PAT"] = config["pat"]
|
|
145
|
-
try:
|
|
146
|
-
result = subprocess.run(cmd, capture_output=True, env=env)
|
|
147
|
-
finally:
|
|
148
|
-
for f in tmp_files:
|
|
149
|
-
try:
|
|
150
|
-
os.unlink(f)
|
|
151
|
-
except OSError:
|
|
152
|
-
pass
|
|
153
|
-
|
|
154
|
-
def _decode(b):
|
|
155
|
-
try:
|
|
156
|
-
return (b or b"").decode("utf-8")
|
|
157
|
-
except UnicodeDecodeError:
|
|
158
|
-
return (b or b"").decode("cp1252", errors="replace")
|
|
159
|
-
|
|
160
|
-
if result.returncode != 0:
|
|
161
|
-
print(f"ERROR: az command failed:\n{_decode(result.stderr)}")
|
|
162
|
-
sys.exit(1)
|
|
163
|
-
stdout = _decode(result.stdout).strip()
|
|
164
|
-
if not stdout:
|
|
165
|
-
return {}
|
|
166
|
-
# Some az commands (e.g. delete) prefix output with a plain-text status line
|
|
167
|
-
json_start = re.search(r"[{\[]", stdout)
|
|
168
|
-
if json_start:
|
|
169
|
-
return json.loads(stdout[json_start.start():])
|
|
170
|
-
return {}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
# --- File helpers ---
|
|
174
|
-
|
|
175
|
-
TYPE_MAP = {
|
|
176
|
-
"epic": "Epic",
|
|
177
|
-
"feature": "Feature",
|
|
178
|
-
"story": "User Story",
|
|
179
|
-
"bug": "Bug",
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
def slugify(text):
|
|
184
|
-
text = text.lower().strip()
|
|
185
|
-
text = re.sub(r"[^\w\s-]", "", text)
|
|
186
|
-
text = re.sub(r"[\s]+", "-", text)
|
|
187
|
-
return text[:60]
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
def find_file(config, file_type, id_str):
|
|
191
|
-
"""Find .md file matching {type}-{id}-*.md or {type}-tmp-{id}-*.md recursively (case-insensitive)."""
|
|
192
|
-
root = config["root"]
|
|
193
|
-
docs_dir = root / "docs"
|
|
194
|
-
id_lower = id_str.lower()
|
|
195
|
-
type_lower = file_type.lower() if file_type != "*" else None
|
|
196
|
-
for md_file in docs_dir.rglob("*.md"):
|
|
197
|
-
name_lower = md_file.name.lower()
|
|
198
|
-
parts = name_lower.split("-", 3)
|
|
199
|
-
if len(parts) < 2:
|
|
200
|
-
continue
|
|
201
|
-
if type_lower is not None and parts[0] != type_lower:
|
|
202
|
-
continue
|
|
203
|
-
# Match: {type}-{id}-*
|
|
204
|
-
if parts[1] == id_lower:
|
|
205
|
-
return str(md_file)
|
|
206
|
-
# Match: {type}-tmp-{id}-* (e.g. story-tmp-A-title.md with id="A")
|
|
207
|
-
if parts[1] == "tmp" and len(parts) >= 3 and parts[2] == id_lower:
|
|
208
|
-
return str(md_file)
|
|
209
|
-
return None
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
def now_str():
|
|
213
|
-
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
def read_md_file(filepath):
|
|
217
|
-
"""Read .md file, return (frontmatter_dict, body_str)."""
|
|
218
|
-
with open(filepath, "r", encoding="utf-8") as f:
|
|
219
|
-
content = f.read()
|
|
220
|
-
fm = {}
|
|
221
|
-
body = content
|
|
222
|
-
if content.startswith("---"):
|
|
223
|
-
parts = content.split("---", 2)
|
|
224
|
-
if len(parts) >= 3:
|
|
225
|
-
fm = yaml.safe_load(parts[1]) or {}
|
|
226
|
-
body = parts[2].strip()
|
|
227
|
-
return fm, body
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
def write_md_file(filepath, frontmatter, body):
|
|
231
|
-
"""Write .md file with YAML frontmatter."""
|
|
232
|
-
fm_str = yaml.dump(frontmatter, default_flow_style=False, allow_unicode=True).strip()
|
|
233
|
-
with open(filepath, "w", encoding="utf-8") as f:
|
|
234
|
-
f.write(f"---\n{fm_str}\n---\n{body}\n")
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
def extract_title(body):
|
|
238
|
-
"""Extract first # heading as title."""
|
|
239
|
-
for line in body.split("\n"):
|
|
240
|
-
line = line.strip()
|
|
241
|
-
if line.startswith("# "):
|
|
242
|
-
return line[2:].strip()
|
|
243
|
-
return "Untitled"
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
def extract_description(body):
|
|
247
|
-
"""Extract body after first # heading."""
|
|
248
|
-
lines = body.split("\n")
|
|
249
|
-
found_title = False
|
|
250
|
-
desc_lines = []
|
|
251
|
-
for line in lines:
|
|
252
|
-
if not found_title and line.strip().startswith("# "):
|
|
253
|
-
found_title = True
|
|
254
|
-
continue
|
|
255
|
-
if found_title:
|
|
256
|
-
desc_lines.append(line)
|
|
257
|
-
return "\n".join(desc_lines).strip()
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
def extract_status(fm, body):
|
|
261
|
-
"""Read status: frontmatter ado_state first, then body '> **Status:** ...' as fallback."""
|
|
262
|
-
if fm.get("ado_state"):
|
|
263
|
-
return fm["ado_state"]
|
|
264
|
-
for line in body.split("\n"):
|
|
265
|
-
m = re.match(r"^>\s*\*{0,2}Status\*{0,2}:\*{0,2}\s*(.+)", line.strip(), re.IGNORECASE)
|
|
266
|
-
if m:
|
|
267
|
-
return m.group(1).strip().strip("*").strip()
|
|
268
|
-
return None
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
def sync_body_status(body, status):
|
|
272
|
-
"""Update '> **Status:** ...' line in body to match status. Add if not present."""
|
|
273
|
-
new_line = f"> **Status:** {status}"
|
|
274
|
-
lines = body.split("\n")
|
|
275
|
-
for i, line in enumerate(lines):
|
|
276
|
-
if re.match(r"^>\s*\*{0,2}Status\*{0,2}:", line.strip(), re.IGNORECASE):
|
|
277
|
-
lines[i] = new_line
|
|
278
|
-
return "\n".join(lines)
|
|
279
|
-
# Not found — insert after first # heading
|
|
280
|
-
for i, line in enumerate(lines):
|
|
281
|
-
if line.strip().startswith("# "):
|
|
282
|
-
lines.insert(i + 1, new_line)
|
|
283
|
-
return "\n".join(lines)
|
|
284
|
-
return body
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
def html_to_md(html):
|
|
288
|
-
"""Basic HTML to Markdown conversion for cmd_get."""
|
|
289
|
-
if not html:
|
|
290
|
-
return ""
|
|
291
|
-
text = re.sub(r"<br\s*/?>", "\n", html)
|
|
292
|
-
text = re.sub(r"<p>(.*?)</p>", r"\1\n\n", text, flags=re.DOTALL)
|
|
293
|
-
text = re.sub(r"<strong>(.*?)</strong>", r"**\1**", text)
|
|
294
|
-
text = re.sub(r"<em>(.*?)</em>", r"*\1*", text)
|
|
295
|
-
text = re.sub(r"<li>(.*?)</li>", r"- \1", text, flags=re.DOTALL)
|
|
296
|
-
text = re.sub(r"<[^>]+>", "", text)
|
|
297
|
-
return text.strip()
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
# --- Commands ---
|
|
301
|
-
|
|
302
|
-
def cmd_create(args, config):
|
|
303
|
-
file_type = args.type
|
|
304
|
-
temp_id = args.temp_id
|
|
305
|
-
ado_type = TYPE_MAP.get(file_type)
|
|
306
|
-
if not ado_type:
|
|
307
|
-
print(f"ERROR: Unknown type '{file_type}'. Use: epic, feature, story, bug")
|
|
308
|
-
sys.exit(1)
|
|
309
|
-
|
|
310
|
-
filepath = find_file(config, file_type, temp_id)
|
|
311
|
-
if not filepath:
|
|
312
|
-
print(f"ERROR: File not found: {file_type}-{temp_id}-*.md")
|
|
313
|
-
sys.exit(1)
|
|
314
|
-
|
|
315
|
-
fm, body = read_md_file(filepath)
|
|
316
|
-
raw_title = fm.get("ado_title") or extract_title(body)
|
|
317
|
-
|
|
318
|
-
# Strip "Type-NNN: " prefix (e.g. "Epic-001: Foo" -> "Foo")
|
|
319
|
-
core_title = re.sub(
|
|
320
|
-
r"^(?:Epic|Feature|Story|User Story|Bug)-\d+[:\s\-]+\s*",
|
|
321
|
-
"",
|
|
322
|
-
raw_title,
|
|
323
|
-
flags=re.IGNORECASE,
|
|
324
|
-
).strip() or raw_title
|
|
325
|
-
|
|
326
|
-
# Build ADO title with project code prefix (strip existing prefix to avoid duplicates)
|
|
327
|
-
project_code = config.get("project_code", "")
|
|
328
|
-
if project_code:
|
|
329
|
-
core_title = re.sub(rf"^\[{re.escape(project_code)}\]\s*", "", core_title).strip()
|
|
330
|
-
ado_title = f"[{project_code}] {core_title}" if project_code else core_title
|
|
331
|
-
|
|
332
|
-
description = extract_description(body).replace("\n", "<br>")
|
|
333
|
-
|
|
334
|
-
# Default assignee: PE for epic, SE for everything else
|
|
335
|
-
assignee_by_role = config.get("assignee_by_role", {})
|
|
336
|
-
default_assignee = assignee_by_role.get("pe" if file_type == "epic" else "se", "")
|
|
337
|
-
|
|
338
|
-
status = extract_status(fm, body)
|
|
339
|
-
|
|
340
|
-
# Create on ADO (without --state; set state in follow-up update if needed)
|
|
341
|
-
create_args = [
|
|
342
|
-
"boards", "work-item", "create",
|
|
343
|
-
"--type", ado_type,
|
|
344
|
-
"--title", ado_title,
|
|
345
|
-
]
|
|
346
|
-
if description:
|
|
347
|
-
create_args += ["--description", description]
|
|
348
|
-
if default_assignee:
|
|
349
|
-
create_args += ["--assigned-to", default_assignee]
|
|
350
|
-
|
|
351
|
-
result = az_cmd(create_args, config)
|
|
352
|
-
ado_id = result.get("id")
|
|
353
|
-
if not ado_id:
|
|
354
|
-
print("ERROR: Failed to get ADO ID from response")
|
|
355
|
-
sys.exit(1)
|
|
356
|
-
|
|
357
|
-
print(f"Created {ado_type} #{ado_id}: {ado_title}")
|
|
358
|
-
|
|
359
|
-
# Set state via separate update if not default "New"
|
|
360
|
-
if status and status.lower() != "new":
|
|
361
|
-
az_cmd([
|
|
362
|
-
"boards", "work-item", "update",
|
|
363
|
-
"--id", str(ado_id),
|
|
364
|
-
"--state", status,
|
|
365
|
-
], config)
|
|
366
|
-
print(f" Set state: {status}")
|
|
367
|
-
|
|
368
|
-
# Auto-detect parent if not explicitly provided:
|
|
369
|
-
# 1. from frontmatter parent_ado_id
|
|
370
|
-
# 2. from parent directory name (e.g. epic-22893-*, feature-22919-*)
|
|
371
|
-
if not args.parent_id:
|
|
372
|
-
fm_parent = str(fm.get("parent_ado_id", "")).strip()
|
|
373
|
-
if fm_parent and fm_parent.isdigit():
|
|
374
|
-
args.parent_id = fm_parent
|
|
375
|
-
print(f" Auto-detected parent from frontmatter: #{args.parent_id}")
|
|
376
|
-
else:
|
|
377
|
-
# Check immediate parent dir first, then grandparent
|
|
378
|
-
# (stories live directly in feature folder; features live in their own subfolder inside epic)
|
|
379
|
-
for parent_dir in [Path(filepath).parent, Path(filepath).parent.parent]:
|
|
380
|
-
m = re.match(r"^(?:epic|feature|story)-(\d+)", parent_dir.name, re.IGNORECASE)
|
|
381
|
-
if m:
|
|
382
|
-
args.parent_id = m.group(1)
|
|
383
|
-
print(f" Auto-detected parent from directory: #{args.parent_id}")
|
|
384
|
-
break
|
|
385
|
-
|
|
386
|
-
# Add parent relation
|
|
387
|
-
if args.parent_id:
|
|
388
|
-
az_cmd([
|
|
389
|
-
"boards", "work-item", "relation", "add",
|
|
390
|
-
"--id", str(ado_id),
|
|
391
|
-
"--relation-type", "parent",
|
|
392
|
-
"--target-id", str(args.parent_id),
|
|
393
|
-
], config)
|
|
394
|
-
print(f" Added parent relation to #{args.parent_id}")
|
|
395
|
-
|
|
396
|
-
# Rename file and parent folder — slug from core_title only
|
|
397
|
-
old_path = Path(filepath)
|
|
398
|
-
slug = slugify(core_title)
|
|
399
|
-
new_name = f"{file_type}-{ado_id}-{slug}.md"
|
|
400
|
-
new_folder_name = f"{file_type}-{ado_id}-{slug}"
|
|
401
|
-
|
|
402
|
-
old_folder = old_path.parent
|
|
403
|
-
new_folder = old_folder
|
|
404
|
-
is_work_item_folder = file_type in ("epic", "feature") and bool(
|
|
405
|
-
re.match(rf"(?i)^{re.escape(file_type)}-", old_folder.name)
|
|
406
|
-
)
|
|
407
|
-
if is_work_item_folder:
|
|
408
|
-
new_folder = old_folder.parent / new_folder_name
|
|
409
|
-
if old_folder != new_folder:
|
|
410
|
-
old_folder.rename(new_folder)
|
|
411
|
-
print(f" Renamed folder: {old_folder.name} -> {new_folder_name}")
|
|
412
|
-
|
|
413
|
-
current_path = new_folder / old_path.name
|
|
414
|
-
new_path = new_folder / new_name
|
|
415
|
-
if current_path != new_path:
|
|
416
|
-
current_path.rename(new_path)
|
|
417
|
-
print(f" Renamed: {old_path.name} -> {new_name}")
|
|
418
|
-
|
|
419
|
-
# Sync status between frontmatter and body
|
|
420
|
-
if status:
|
|
421
|
-
fm["ado_state"] = status
|
|
422
|
-
body = sync_body_status(body, status)
|
|
423
|
-
else:
|
|
424
|
-
fm["ado_state"] = "New"
|
|
425
|
-
|
|
426
|
-
# Update frontmatter
|
|
427
|
-
fm["ado_id"] = ado_id
|
|
428
|
-
fm["ado_type"] = ado_type
|
|
429
|
-
fm["ado_title"] = ado_title
|
|
430
|
-
fm["last_ado_sync"] = now_str()
|
|
431
|
-
if args.parent_id:
|
|
432
|
-
fm["parent_ado_id"] = int(args.parent_id)
|
|
433
|
-
write_md_file(new_path, fm, body)
|
|
434
|
-
print(f" Updated frontmatter with ado_id={ado_id}")
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
def cmd_get(args, config):
|
|
438
|
-
ado_id = args.ado_id
|
|
439
|
-
result = az_cmd([
|
|
440
|
-
"boards", "work-item", "show",
|
|
441
|
-
"--id", str(ado_id),
|
|
442
|
-
"--expand", "all",
|
|
443
|
-
], config)
|
|
444
|
-
|
|
445
|
-
fields = result.get("fields", {})
|
|
446
|
-
title = fields.get("System.Title", "Untitled")
|
|
447
|
-
work_item_type = fields.get("System.WorkItemType", "User Story")
|
|
448
|
-
state = fields.get("System.State", "New")
|
|
449
|
-
assigned = fields.get("System.AssignedTo", {})
|
|
450
|
-
assigned_name = assigned.get("displayName", "") if isinstance(assigned, dict) else str(assigned)
|
|
451
|
-
assigned_email = assigned.get("uniqueName", "") if isinstance(assigned, dict) else ""
|
|
452
|
-
description = fields.get("System.Description", "")
|
|
453
|
-
created = fields.get("System.CreatedDate", "")[:10]
|
|
454
|
-
|
|
455
|
-
type_reverse = {v: k for k, v in TYPE_MAP.items()}
|
|
456
|
-
file_type = type_reverse.get(work_item_type, "story")
|
|
457
|
-
|
|
458
|
-
slug = slugify(title)
|
|
459
|
-
filename = f"{file_type}-{ado_id}-{slug}.md"
|
|
460
|
-
|
|
461
|
-
existing = find_file(config, file_type, str(ado_id))
|
|
462
|
-
if existing:
|
|
463
|
-
filepath = existing
|
|
464
|
-
print(f"Updating existing file: {filepath}")
|
|
465
|
-
else:
|
|
466
|
-
filepath = config["root"] / "docs" / filename
|
|
467
|
-
print(f"Creating new file: {filepath}")
|
|
468
|
-
|
|
469
|
-
fm = {
|
|
470
|
-
"ado_id": ado_id,
|
|
471
|
-
"ado_type": work_item_type,
|
|
472
|
-
"ado_title": title,
|
|
473
|
-
"ado_state": state,
|
|
474
|
-
"ado_assigned_to": f"{assigned_name} <{assigned_email}>" if assigned_email else assigned_name,
|
|
475
|
-
"ado_created": created,
|
|
476
|
-
"last_ado_sync": now_str(),
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
body_md = html_to_md(description)
|
|
480
|
-
body = f"# {title}\n\n{body_md}" if body_md else f"# {title}\n"
|
|
481
|
-
|
|
482
|
-
write_md_file(filepath, fm, body)
|
|
483
|
-
print(f" Synced #{ado_id}: {title} [{state}]")
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
def cmd_update(args, config):
|
|
487
|
-
file_type = args.type
|
|
488
|
-
ado_id = args.ado_id
|
|
489
|
-
|
|
490
|
-
filepath = find_file(config, file_type, str(ado_id))
|
|
491
|
-
if not filepath:
|
|
492
|
-
filepath = find_file(config, "*", str(ado_id))
|
|
493
|
-
if not filepath:
|
|
494
|
-
print(f"ERROR: File not found for {file_type}-{ado_id}")
|
|
495
|
-
sys.exit(1)
|
|
496
|
-
|
|
497
|
-
fm, body = read_md_file(filepath)
|
|
498
|
-
title = fm.get("ado_title") or extract_title(body)
|
|
499
|
-
description = extract_description(body).replace("\n", "<br>")
|
|
500
|
-
|
|
501
|
-
status = args.status or extract_status(fm, body)
|
|
502
|
-
|
|
503
|
-
update_args = ["boards", "work-item", "update", "--id", str(ado_id), "--title", title]
|
|
504
|
-
if description:
|
|
505
|
-
update_args += ["--description", description]
|
|
506
|
-
if args.assign:
|
|
507
|
-
update_args += ["--assigned-to", args.assign]
|
|
508
|
-
if status:
|
|
509
|
-
update_args += ["--state", status]
|
|
510
|
-
|
|
511
|
-
az_cmd(update_args, config)
|
|
512
|
-
print(f"Updated {file_type} #{ado_id}: {title}")
|
|
513
|
-
|
|
514
|
-
fm["last_ado_sync"] = now_str()
|
|
515
|
-
if status:
|
|
516
|
-
fm["ado_state"] = status
|
|
517
|
-
body = sync_body_status(body, status)
|
|
518
|
-
if args.assign:
|
|
519
|
-
fm["ado_assigned_to"] = args.assign
|
|
520
|
-
write_md_file(filepath, fm, body)
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
def cmd_update_status(args, config):
|
|
524
|
-
ado_id = args.ado_id
|
|
525
|
-
update_args = ["boards", "work-item", "update", "--id", str(ado_id)]
|
|
526
|
-
if args.status:
|
|
527
|
-
update_args += ["--state", args.status]
|
|
528
|
-
if args.assign:
|
|
529
|
-
update_args += ["--assigned-to", args.assign]
|
|
530
|
-
|
|
531
|
-
az_cmd(update_args, config)
|
|
532
|
-
print(f"Updated status #{ado_id} -> {args.status}")
|
|
533
|
-
|
|
534
|
-
filepath = find_file(config, "*", str(ado_id))
|
|
535
|
-
if filepath:
|
|
536
|
-
fm, body = read_md_file(filepath)
|
|
537
|
-
fm["last_ado_sync"] = now_str()
|
|
538
|
-
if args.status:
|
|
539
|
-
fm["ado_state"] = args.status
|
|
540
|
-
if args.assign:
|
|
541
|
-
fm["ado_assigned_to"] = args.assign
|
|
542
|
-
write_md_file(filepath, fm, body)
|
|
543
|
-
print(f" Updated local file: {filepath}")
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
def cmd_delete(args, config):
|
|
547
|
-
file_type = args.type
|
|
548
|
-
ado_id = args.ado_id
|
|
549
|
-
|
|
550
|
-
az_cmd([
|
|
551
|
-
"boards", "work-item", "delete",
|
|
552
|
-
"--id", str(ado_id),
|
|
553
|
-
"--yes",
|
|
554
|
-
], config)
|
|
555
|
-
print(f"Deleted {file_type} #{ado_id} on ADO")
|
|
556
|
-
|
|
557
|
-
filepath = find_file(config, file_type, str(ado_id))
|
|
558
|
-
if filepath:
|
|
559
|
-
fm, body = read_md_file(filepath)
|
|
560
|
-
fm["ado_state"] = "Removed"
|
|
561
|
-
fm["last_ado_sync"] = now_str()
|
|
562
|
-
write_md_file(filepath, fm, body)
|
|
563
|
-
print(f" Updated local file status to Removed (file preserved)")
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
# --- Main ---
|
|
567
|
-
|
|
568
|
-
def main():
|
|
569
|
-
parser = argparse.ArgumentParser(description="TAS ADO Integration")
|
|
570
|
-
subparsers = parser.add_subparsers(dest="command", help="Command to run")
|
|
571
|
-
|
|
572
|
-
for t in ["epic", "feature", "story", "bug"]:
|
|
573
|
-
p = subparsers.add_parser(f"create-{t}", help=f"Create {t} on ADO")
|
|
574
|
-
p.add_argument("temp_id", help="Temporary ID in filename")
|
|
575
|
-
p.add_argument("--parent-id", dest="parent_id", help="Parent work item ID")
|
|
576
|
-
p.set_defaults(type=t)
|
|
577
|
-
|
|
578
|
-
for cmd_name in ["get", "pull"]:
|
|
579
|
-
p = subparsers.add_parser(cmd_name, help="Pull work item from ADO")
|
|
580
|
-
p.add_argument("ado_id", type=int, help="ADO work item ID")
|
|
581
|
-
|
|
582
|
-
for t in ["epic", "feature", "story", "bug"]:
|
|
583
|
-
p = subparsers.add_parser(f"update-{t}", help=f"Update {t} on ADO")
|
|
584
|
-
p.add_argument("ado_id", type=int, help="ADO work item ID")
|
|
585
|
-
p.add_argument("--assign", help="Assign to name/email")
|
|
586
|
-
p.add_argument("--status", help="Set state")
|
|
587
|
-
p.set_defaults(type=t)
|
|
588
|
-
|
|
589
|
-
p = subparsers.add_parser("update-status", help="Quick status update")
|
|
590
|
-
p.add_argument("ado_id", type=int, help="ADO work item ID")
|
|
591
|
-
p.add_argument("--status", required=True, help="New state")
|
|
592
|
-
p.add_argument("--assign", help="Assign to name/email")
|
|
593
|
-
|
|
594
|
-
for t in ["epic", "feature", "story", "bug"]:
|
|
595
|
-
p = subparsers.add_parser(f"delete-{t}", help=f"Delete {t} on ADO")
|
|
596
|
-
p.add_argument("ado_id", type=int, help="ADO work item ID")
|
|
597
|
-
p.set_defaults(type=t)
|
|
598
|
-
|
|
599
|
-
args = parser.parse_args()
|
|
600
|
-
if not args.command:
|
|
601
|
-
parser.print_help()
|
|
602
|
-
sys.exit(1)
|
|
603
|
-
|
|
604
|
-
config = load_config()
|
|
605
|
-
|
|
606
|
-
if args.command.startswith("create-"):
|
|
607
|
-
cmd_create(args, config)
|
|
608
|
-
elif args.command in ("get", "pull"):
|
|
609
|
-
cmd_get(args, config)
|
|
610
|
-
elif args.command.startswith("update-") and args.command != "update-status":
|
|
611
|
-
cmd_update(args, config)
|
|
612
|
-
elif args.command == "update-status":
|
|
613
|
-
cmd_update_status(args, config)
|
|
614
|
-
elif args.command.startswith("delete-"):
|
|
615
|
-
cmd_delete(args, config)
|
|
616
|
-
else:
|
|
617
|
-
parser.print_help()
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
if __name__ == "__main__":
|
|
621
|
-
main()
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
TAS ADO Integration Script
|
|
4
|
+
Two-way sync between local .md files and Azure DevOps work items.
|
|
5
|
+
|
|
6
|
+
Usage: python tools/tas-ado.py <command> [arguments]
|
|
7
|
+
|
|
8
|
+
Commands:
|
|
9
|
+
create-epic <temp-id> [--parent-id <id>]
|
|
10
|
+
create-feature <temp-id> [--parent-id <id>]
|
|
11
|
+
create-story <temp-id> [--parent-id <id>]
|
|
12
|
+
create-bug <temp-id> [--parent-id <id>]
|
|
13
|
+
get <ado-id>
|
|
14
|
+
pull <ado-id> (alias for get)
|
|
15
|
+
update-epic <ado-id> [--assign <name>] [--status <state>]
|
|
16
|
+
update-feature <ado-id> [--assign <name>] [--status <state>]
|
|
17
|
+
update-story <ado-id> [--assign <name>] [--status <state>]
|
|
18
|
+
update-bug <ado-id> [--assign <name>] [--status <state>]
|
|
19
|
+
update-status <ado-id> --status <state> [--assign <name>]
|
|
20
|
+
delete-epic <ado-id>
|
|
21
|
+
delete-feature <ado-id>
|
|
22
|
+
delete-story <ado-id>
|
|
23
|
+
delete-bug <ado-id>
|
|
24
|
+
|
|
25
|
+
Prerequisites:
|
|
26
|
+
- Azure CLI + azure-devops extension
|
|
27
|
+
- Python 3.8+ with pyyaml
|
|
28
|
+
- PAT in tas.yaml, AZURE_DEVOPS_PAT, or AzureDevops_Personal_AccessToken env var
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
import argparse
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import re
|
|
35
|
+
import subprocess
|
|
36
|
+
import sys
|
|
37
|
+
import tempfile
|
|
38
|
+
from datetime import datetime
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
import yaml
|
|
43
|
+
except ImportError:
|
|
44
|
+
print("ERROR: pyyaml required. Run: pip install pyyaml")
|
|
45
|
+
sys.exit(1)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# --- Config ---
|
|
49
|
+
|
|
50
|
+
def find_repo_root():
|
|
51
|
+
"""Walk up from cwd to find tas.yaml at repo root"""
|
|
52
|
+
path = Path.cwd()
|
|
53
|
+
while path != path.parent:
|
|
54
|
+
if (path / "tas.yaml").exists():
|
|
55
|
+
return path
|
|
56
|
+
path = path.parent
|
|
57
|
+
print("ERROR: tas.yaml not found. Run /tas-init first.")
|
|
58
|
+
sys.exit(1)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load_dotenv(root):
|
|
62
|
+
"""Load .env file from repo root into os.environ."""
|
|
63
|
+
env_file = root / ".env"
|
|
64
|
+
if env_file.exists():
|
|
65
|
+
with open(env_file, "r", encoding="utf-8") as f:
|
|
66
|
+
for line in f:
|
|
67
|
+
line = line.strip()
|
|
68
|
+
if line and not line.startswith("#") and "=" in line:
|
|
69
|
+
key, _, value = line.partition("=")
|
|
70
|
+
os.environ.setdefault(key.strip(), value.strip())
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def load_config():
|
|
74
|
+
root = find_repo_root()
|
|
75
|
+
load_dotenv(root)
|
|
76
|
+
config_path = root / "tas.yaml"
|
|
77
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
78
|
+
config = yaml.safe_load(f)
|
|
79
|
+
ado = config.get("ado", {})
|
|
80
|
+
org = ado.get("organization", "")
|
|
81
|
+
project = ado.get("project_id", "")
|
|
82
|
+
pat = (ado.get("pat")
|
|
83
|
+
or os.environ.get("AZURE_DEVOPS_PAT", "")
|
|
84
|
+
or os.environ.get("AzureDevops_Personal_AccessToken", ""))
|
|
85
|
+
if not org or not project:
|
|
86
|
+
print("ERROR: ado.organization and ado.project_id required in .tas/tas.yaml")
|
|
87
|
+
sys.exit(1)
|
|
88
|
+
|
|
89
|
+
project_code = config.get("project", {}).get("code", "")
|
|
90
|
+
|
|
91
|
+
team = config.get("team", [])
|
|
92
|
+
assignee_by_role = {}
|
|
93
|
+
for member in team:
|
|
94
|
+
role = member.get("role", "").lower()
|
|
95
|
+
if role not in assignee_by_role:
|
|
96
|
+
assignee_by_role[role] = member.get("ado_id", "")
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
"root": root,
|
|
100
|
+
"org": org,
|
|
101
|
+
"project": project,
|
|
102
|
+
"pat": pat,
|
|
103
|
+
"project_code": project_code,
|
|
104
|
+
"assignee_by_role": assignee_by_role,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def az_cmd(args, config):
|
|
109
|
+
"""Run az CLI command and return parsed JSON output.
|
|
110
|
+
|
|
111
|
+
Multiline string args (e.g. --description) are written to a temp file
|
|
112
|
+
and passed as @filepath so newlines never break az.cmd on Windows.
|
|
113
|
+
"""
|
|
114
|
+
az_bin = "az.cmd" if sys.platform == "win32" else "az"
|
|
115
|
+
|
|
116
|
+
tmp_files = []
|
|
117
|
+
safe_args = []
|
|
118
|
+
i = 0
|
|
119
|
+
while i < len(args):
|
|
120
|
+
if (len(str(args[i])) > 200 or "\n" in str(args[i])) and i > 0 and str(args[i - 1]).startswith("--"):
|
|
121
|
+
tmp = tempfile.NamedTemporaryFile(
|
|
122
|
+
mode="wb", suffix=".txt", delete=False
|
|
123
|
+
)
|
|
124
|
+
tmp.write(args[i].encode("utf-8"))
|
|
125
|
+
tmp.close()
|
|
126
|
+
tmp_files.append(tmp.name)
|
|
127
|
+
safe_args.append(f"@{tmp.name}")
|
|
128
|
+
else:
|
|
129
|
+
safe_args.append(args[i])
|
|
130
|
+
i += 1
|
|
131
|
+
|
|
132
|
+
# work-item update/show/delete/relation operate by global ID — --project is not accepted
|
|
133
|
+
_no_project_cmds = {"update", "show", "delete", "relation"}
|
|
134
|
+
wi_action = args[2] if len(args) > 2 else ""
|
|
135
|
+
needs_project = wi_action not in _no_project_cmds
|
|
136
|
+
|
|
137
|
+
tail = ["--org", config["org"], "--output", "json"]
|
|
138
|
+
if needs_project:
|
|
139
|
+
tail = ["--org", config["org"], "--project", config["project"], "--output", "json"]
|
|
140
|
+
|
|
141
|
+
cmd = [az_bin] + safe_args + tail
|
|
142
|
+
env = os.environ.copy()
|
|
143
|
+
if config["pat"]:
|
|
144
|
+
env["AZURE_DEVOPS_EXT_PAT"] = config["pat"]
|
|
145
|
+
try:
|
|
146
|
+
result = subprocess.run(cmd, capture_output=True, env=env)
|
|
147
|
+
finally:
|
|
148
|
+
for f in tmp_files:
|
|
149
|
+
try:
|
|
150
|
+
os.unlink(f)
|
|
151
|
+
except OSError:
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
def _decode(b):
|
|
155
|
+
try:
|
|
156
|
+
return (b or b"").decode("utf-8")
|
|
157
|
+
except UnicodeDecodeError:
|
|
158
|
+
return (b or b"").decode("cp1252", errors="replace")
|
|
159
|
+
|
|
160
|
+
if result.returncode != 0:
|
|
161
|
+
print(f"ERROR: az command failed:\n{_decode(result.stderr)}")
|
|
162
|
+
sys.exit(1)
|
|
163
|
+
stdout = _decode(result.stdout).strip()
|
|
164
|
+
if not stdout:
|
|
165
|
+
return {}
|
|
166
|
+
# Some az commands (e.g. delete) prefix output with a plain-text status line
|
|
167
|
+
json_start = re.search(r"[{\[]", stdout)
|
|
168
|
+
if json_start:
|
|
169
|
+
return json.loads(stdout[json_start.start():])
|
|
170
|
+
return {}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# --- File helpers ---
|
|
174
|
+
|
|
175
|
+
TYPE_MAP = {
|
|
176
|
+
"epic": "Epic",
|
|
177
|
+
"feature": "Feature",
|
|
178
|
+
"story": "User Story",
|
|
179
|
+
"bug": "Bug",
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def slugify(text):
|
|
184
|
+
text = text.lower().strip()
|
|
185
|
+
text = re.sub(r"[^\w\s-]", "", text)
|
|
186
|
+
text = re.sub(r"[\s]+", "-", text)
|
|
187
|
+
return text[:60]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def find_file(config, file_type, id_str):
|
|
191
|
+
"""Find .md file matching {type}-{id}-*.md or {type}-tmp-{id}-*.md recursively (case-insensitive)."""
|
|
192
|
+
root = config["root"]
|
|
193
|
+
docs_dir = root / "docs"
|
|
194
|
+
id_lower = id_str.lower()
|
|
195
|
+
type_lower = file_type.lower() if file_type != "*" else None
|
|
196
|
+
for md_file in docs_dir.rglob("*.md"):
|
|
197
|
+
name_lower = md_file.name.lower()
|
|
198
|
+
parts = name_lower.split("-", 3)
|
|
199
|
+
if len(parts) < 2:
|
|
200
|
+
continue
|
|
201
|
+
if type_lower is not None and parts[0] != type_lower:
|
|
202
|
+
continue
|
|
203
|
+
# Match: {type}-{id}-*
|
|
204
|
+
if parts[1] == id_lower:
|
|
205
|
+
return str(md_file)
|
|
206
|
+
# Match: {type}-tmp-{id}-* (e.g. story-tmp-A-title.md with id="A")
|
|
207
|
+
if parts[1] == "tmp" and len(parts) >= 3 and parts[2] == id_lower:
|
|
208
|
+
return str(md_file)
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def now_str():
|
|
213
|
+
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def read_md_file(filepath):
|
|
217
|
+
"""Read .md file, return (frontmatter_dict, body_str)."""
|
|
218
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
219
|
+
content = f.read()
|
|
220
|
+
fm = {}
|
|
221
|
+
body = content
|
|
222
|
+
if content.startswith("---"):
|
|
223
|
+
parts = content.split("---", 2)
|
|
224
|
+
if len(parts) >= 3:
|
|
225
|
+
fm = yaml.safe_load(parts[1]) or {}
|
|
226
|
+
body = parts[2].strip()
|
|
227
|
+
return fm, body
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def write_md_file(filepath, frontmatter, body):
|
|
231
|
+
"""Write .md file with YAML frontmatter."""
|
|
232
|
+
fm_str = yaml.dump(frontmatter, default_flow_style=False, allow_unicode=True).strip()
|
|
233
|
+
with open(filepath, "w", encoding="utf-8") as f:
|
|
234
|
+
f.write(f"---\n{fm_str}\n---\n{body}\n")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def extract_title(body):
|
|
238
|
+
"""Extract first # heading as title."""
|
|
239
|
+
for line in body.split("\n"):
|
|
240
|
+
line = line.strip()
|
|
241
|
+
if line.startswith("# "):
|
|
242
|
+
return line[2:].strip()
|
|
243
|
+
return "Untitled"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def extract_description(body):
|
|
247
|
+
"""Extract body after first # heading."""
|
|
248
|
+
lines = body.split("\n")
|
|
249
|
+
found_title = False
|
|
250
|
+
desc_lines = []
|
|
251
|
+
for line in lines:
|
|
252
|
+
if not found_title and line.strip().startswith("# "):
|
|
253
|
+
found_title = True
|
|
254
|
+
continue
|
|
255
|
+
if found_title:
|
|
256
|
+
desc_lines.append(line)
|
|
257
|
+
return "\n".join(desc_lines).strip()
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def extract_status(fm, body):
|
|
261
|
+
"""Read status: frontmatter ado_state first, then body '> **Status:** ...' as fallback."""
|
|
262
|
+
if fm.get("ado_state"):
|
|
263
|
+
return fm["ado_state"]
|
|
264
|
+
for line in body.split("\n"):
|
|
265
|
+
m = re.match(r"^>\s*\*{0,2}Status\*{0,2}:\*{0,2}\s*(.+)", line.strip(), re.IGNORECASE)
|
|
266
|
+
if m:
|
|
267
|
+
return m.group(1).strip().strip("*").strip()
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def sync_body_status(body, status):
|
|
272
|
+
"""Update '> **Status:** ...' line in body to match status. Add if not present."""
|
|
273
|
+
new_line = f"> **Status:** {status}"
|
|
274
|
+
lines = body.split("\n")
|
|
275
|
+
for i, line in enumerate(lines):
|
|
276
|
+
if re.match(r"^>\s*\*{0,2}Status\*{0,2}:", line.strip(), re.IGNORECASE):
|
|
277
|
+
lines[i] = new_line
|
|
278
|
+
return "\n".join(lines)
|
|
279
|
+
# Not found — insert after first # heading
|
|
280
|
+
for i, line in enumerate(lines):
|
|
281
|
+
if line.strip().startswith("# "):
|
|
282
|
+
lines.insert(i + 1, new_line)
|
|
283
|
+
return "\n".join(lines)
|
|
284
|
+
return body
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def html_to_md(html):
|
|
288
|
+
"""Basic HTML to Markdown conversion for cmd_get."""
|
|
289
|
+
if not html:
|
|
290
|
+
return ""
|
|
291
|
+
text = re.sub(r"<br\s*/?>", "\n", html)
|
|
292
|
+
text = re.sub(r"<p>(.*?)</p>", r"\1\n\n", text, flags=re.DOTALL)
|
|
293
|
+
text = re.sub(r"<strong>(.*?)</strong>", r"**\1**", text)
|
|
294
|
+
text = re.sub(r"<em>(.*?)</em>", r"*\1*", text)
|
|
295
|
+
text = re.sub(r"<li>(.*?)</li>", r"- \1", text, flags=re.DOTALL)
|
|
296
|
+
text = re.sub(r"<[^>]+>", "", text)
|
|
297
|
+
return text.strip()
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# --- Commands ---
|
|
301
|
+
|
|
302
|
+
def cmd_create(args, config):
|
|
303
|
+
file_type = args.type
|
|
304
|
+
temp_id = args.temp_id
|
|
305
|
+
ado_type = TYPE_MAP.get(file_type)
|
|
306
|
+
if not ado_type:
|
|
307
|
+
print(f"ERROR: Unknown type '{file_type}'. Use: epic, feature, story, bug")
|
|
308
|
+
sys.exit(1)
|
|
309
|
+
|
|
310
|
+
filepath = find_file(config, file_type, temp_id)
|
|
311
|
+
if not filepath:
|
|
312
|
+
print(f"ERROR: File not found: {file_type}-{temp_id}-*.md")
|
|
313
|
+
sys.exit(1)
|
|
314
|
+
|
|
315
|
+
fm, body = read_md_file(filepath)
|
|
316
|
+
raw_title = fm.get("ado_title") or extract_title(body)
|
|
317
|
+
|
|
318
|
+
# Strip "Type-NNN: " prefix (e.g. "Epic-001: Foo" -> "Foo")
|
|
319
|
+
core_title = re.sub(
|
|
320
|
+
r"^(?:Epic|Feature|Story|User Story|Bug)-\d+[:\s\-]+\s*",
|
|
321
|
+
"",
|
|
322
|
+
raw_title,
|
|
323
|
+
flags=re.IGNORECASE,
|
|
324
|
+
).strip() or raw_title
|
|
325
|
+
|
|
326
|
+
# Build ADO title with project code prefix (strip existing prefix to avoid duplicates)
|
|
327
|
+
project_code = config.get("project_code", "")
|
|
328
|
+
if project_code:
|
|
329
|
+
core_title = re.sub(rf"^\[{re.escape(project_code)}\]\s*", "", core_title).strip()
|
|
330
|
+
ado_title = f"[{project_code}] {core_title}" if project_code else core_title
|
|
331
|
+
|
|
332
|
+
description = extract_description(body).replace("\n", "<br>")
|
|
333
|
+
|
|
334
|
+
# Default assignee: PE for epic, SE for everything else
|
|
335
|
+
assignee_by_role = config.get("assignee_by_role", {})
|
|
336
|
+
default_assignee = assignee_by_role.get("pe" if file_type == "epic" else "se", "")
|
|
337
|
+
|
|
338
|
+
status = extract_status(fm, body)
|
|
339
|
+
|
|
340
|
+
# Create on ADO (without --state; set state in follow-up update if needed)
|
|
341
|
+
create_args = [
|
|
342
|
+
"boards", "work-item", "create",
|
|
343
|
+
"--type", ado_type,
|
|
344
|
+
"--title", ado_title,
|
|
345
|
+
]
|
|
346
|
+
if description:
|
|
347
|
+
create_args += ["--description", description]
|
|
348
|
+
if default_assignee:
|
|
349
|
+
create_args += ["--assigned-to", default_assignee]
|
|
350
|
+
|
|
351
|
+
result = az_cmd(create_args, config)
|
|
352
|
+
ado_id = result.get("id")
|
|
353
|
+
if not ado_id:
|
|
354
|
+
print("ERROR: Failed to get ADO ID from response")
|
|
355
|
+
sys.exit(1)
|
|
356
|
+
|
|
357
|
+
print(f"Created {ado_type} #{ado_id}: {ado_title}")
|
|
358
|
+
|
|
359
|
+
# Set state via separate update if not default "New"
|
|
360
|
+
if status and status.lower() != "new":
|
|
361
|
+
az_cmd([
|
|
362
|
+
"boards", "work-item", "update",
|
|
363
|
+
"--id", str(ado_id),
|
|
364
|
+
"--state", status,
|
|
365
|
+
], config)
|
|
366
|
+
print(f" Set state: {status}")
|
|
367
|
+
|
|
368
|
+
# Auto-detect parent if not explicitly provided:
|
|
369
|
+
# 1. from frontmatter parent_ado_id
|
|
370
|
+
# 2. from parent directory name (e.g. epic-22893-*, feature-22919-*)
|
|
371
|
+
if not args.parent_id:
|
|
372
|
+
fm_parent = str(fm.get("parent_ado_id", "")).strip()
|
|
373
|
+
if fm_parent and fm_parent.isdigit():
|
|
374
|
+
args.parent_id = fm_parent
|
|
375
|
+
print(f" Auto-detected parent from frontmatter: #{args.parent_id}")
|
|
376
|
+
else:
|
|
377
|
+
# Check immediate parent dir first, then grandparent
|
|
378
|
+
# (stories live directly in feature folder; features live in their own subfolder inside epic)
|
|
379
|
+
for parent_dir in [Path(filepath).parent, Path(filepath).parent.parent]:
|
|
380
|
+
m = re.match(r"^(?:epic|feature|story)-(\d+)", parent_dir.name, re.IGNORECASE)
|
|
381
|
+
if m:
|
|
382
|
+
args.parent_id = m.group(1)
|
|
383
|
+
print(f" Auto-detected parent from directory: #{args.parent_id}")
|
|
384
|
+
break
|
|
385
|
+
|
|
386
|
+
# Add parent relation
|
|
387
|
+
if args.parent_id:
|
|
388
|
+
az_cmd([
|
|
389
|
+
"boards", "work-item", "relation", "add",
|
|
390
|
+
"--id", str(ado_id),
|
|
391
|
+
"--relation-type", "parent",
|
|
392
|
+
"--target-id", str(args.parent_id),
|
|
393
|
+
], config)
|
|
394
|
+
print(f" Added parent relation to #{args.parent_id}")
|
|
395
|
+
|
|
396
|
+
# Rename file and parent folder — slug from core_title only
|
|
397
|
+
old_path = Path(filepath)
|
|
398
|
+
slug = slugify(core_title)
|
|
399
|
+
new_name = f"{file_type}-{ado_id}-{slug}.md"
|
|
400
|
+
new_folder_name = f"{file_type}-{ado_id}-{slug}"
|
|
401
|
+
|
|
402
|
+
old_folder = old_path.parent
|
|
403
|
+
new_folder = old_folder
|
|
404
|
+
is_work_item_folder = file_type in ("epic", "feature") and bool(
|
|
405
|
+
re.match(rf"(?i)^{re.escape(file_type)}-", old_folder.name)
|
|
406
|
+
)
|
|
407
|
+
if is_work_item_folder:
|
|
408
|
+
new_folder = old_folder.parent / new_folder_name
|
|
409
|
+
if old_folder != new_folder:
|
|
410
|
+
old_folder.rename(new_folder)
|
|
411
|
+
print(f" Renamed folder: {old_folder.name} -> {new_folder_name}")
|
|
412
|
+
|
|
413
|
+
current_path = new_folder / old_path.name
|
|
414
|
+
new_path = new_folder / new_name
|
|
415
|
+
if current_path != new_path:
|
|
416
|
+
current_path.rename(new_path)
|
|
417
|
+
print(f" Renamed: {old_path.name} -> {new_name}")
|
|
418
|
+
|
|
419
|
+
# Sync status between frontmatter and body
|
|
420
|
+
if status:
|
|
421
|
+
fm["ado_state"] = status
|
|
422
|
+
body = sync_body_status(body, status)
|
|
423
|
+
else:
|
|
424
|
+
fm["ado_state"] = "New"
|
|
425
|
+
|
|
426
|
+
# Update frontmatter
|
|
427
|
+
fm["ado_id"] = ado_id
|
|
428
|
+
fm["ado_type"] = ado_type
|
|
429
|
+
fm["ado_title"] = ado_title
|
|
430
|
+
fm["last_ado_sync"] = now_str()
|
|
431
|
+
if args.parent_id:
|
|
432
|
+
fm["parent_ado_id"] = int(args.parent_id)
|
|
433
|
+
write_md_file(new_path, fm, body)
|
|
434
|
+
print(f" Updated frontmatter with ado_id={ado_id}")
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def cmd_get(args, config):
|
|
438
|
+
ado_id = args.ado_id
|
|
439
|
+
result = az_cmd([
|
|
440
|
+
"boards", "work-item", "show",
|
|
441
|
+
"--id", str(ado_id),
|
|
442
|
+
"--expand", "all",
|
|
443
|
+
], config)
|
|
444
|
+
|
|
445
|
+
fields = result.get("fields", {})
|
|
446
|
+
title = fields.get("System.Title", "Untitled")
|
|
447
|
+
work_item_type = fields.get("System.WorkItemType", "User Story")
|
|
448
|
+
state = fields.get("System.State", "New")
|
|
449
|
+
assigned = fields.get("System.AssignedTo", {})
|
|
450
|
+
assigned_name = assigned.get("displayName", "") if isinstance(assigned, dict) else str(assigned)
|
|
451
|
+
assigned_email = assigned.get("uniqueName", "") if isinstance(assigned, dict) else ""
|
|
452
|
+
description = fields.get("System.Description", "")
|
|
453
|
+
created = fields.get("System.CreatedDate", "")[:10]
|
|
454
|
+
|
|
455
|
+
type_reverse = {v: k for k, v in TYPE_MAP.items()}
|
|
456
|
+
file_type = type_reverse.get(work_item_type, "story")
|
|
457
|
+
|
|
458
|
+
slug = slugify(title)
|
|
459
|
+
filename = f"{file_type}-{ado_id}-{slug}.md"
|
|
460
|
+
|
|
461
|
+
existing = find_file(config, file_type, str(ado_id))
|
|
462
|
+
if existing:
|
|
463
|
+
filepath = existing
|
|
464
|
+
print(f"Updating existing file: {filepath}")
|
|
465
|
+
else:
|
|
466
|
+
filepath = config["root"] / "docs" / filename
|
|
467
|
+
print(f"Creating new file: {filepath}")
|
|
468
|
+
|
|
469
|
+
fm = {
|
|
470
|
+
"ado_id": ado_id,
|
|
471
|
+
"ado_type": work_item_type,
|
|
472
|
+
"ado_title": title,
|
|
473
|
+
"ado_state": state,
|
|
474
|
+
"ado_assigned_to": f"{assigned_name} <{assigned_email}>" if assigned_email else assigned_name,
|
|
475
|
+
"ado_created": created,
|
|
476
|
+
"last_ado_sync": now_str(),
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
body_md = html_to_md(description)
|
|
480
|
+
body = f"# {title}\n\n{body_md}" if body_md else f"# {title}\n"
|
|
481
|
+
|
|
482
|
+
write_md_file(filepath, fm, body)
|
|
483
|
+
print(f" Synced #{ado_id}: {title} [{state}]")
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def cmd_update(args, config):
|
|
487
|
+
file_type = args.type
|
|
488
|
+
ado_id = args.ado_id
|
|
489
|
+
|
|
490
|
+
filepath = find_file(config, file_type, str(ado_id))
|
|
491
|
+
if not filepath:
|
|
492
|
+
filepath = find_file(config, "*", str(ado_id))
|
|
493
|
+
if not filepath:
|
|
494
|
+
print(f"ERROR: File not found for {file_type}-{ado_id}")
|
|
495
|
+
sys.exit(1)
|
|
496
|
+
|
|
497
|
+
fm, body = read_md_file(filepath)
|
|
498
|
+
title = fm.get("ado_title") or extract_title(body)
|
|
499
|
+
description = extract_description(body).replace("\n", "<br>")
|
|
500
|
+
|
|
501
|
+
status = args.status or extract_status(fm, body)
|
|
502
|
+
|
|
503
|
+
update_args = ["boards", "work-item", "update", "--id", str(ado_id), "--title", title]
|
|
504
|
+
if description:
|
|
505
|
+
update_args += ["--description", description]
|
|
506
|
+
if args.assign:
|
|
507
|
+
update_args += ["--assigned-to", args.assign]
|
|
508
|
+
if status:
|
|
509
|
+
update_args += ["--state", status]
|
|
510
|
+
|
|
511
|
+
az_cmd(update_args, config)
|
|
512
|
+
print(f"Updated {file_type} #{ado_id}: {title}")
|
|
513
|
+
|
|
514
|
+
fm["last_ado_sync"] = now_str()
|
|
515
|
+
if status:
|
|
516
|
+
fm["ado_state"] = status
|
|
517
|
+
body = sync_body_status(body, status)
|
|
518
|
+
if args.assign:
|
|
519
|
+
fm["ado_assigned_to"] = args.assign
|
|
520
|
+
write_md_file(filepath, fm, body)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def cmd_update_status(args, config):
|
|
524
|
+
ado_id = args.ado_id
|
|
525
|
+
update_args = ["boards", "work-item", "update", "--id", str(ado_id)]
|
|
526
|
+
if args.status:
|
|
527
|
+
update_args += ["--state", args.status]
|
|
528
|
+
if args.assign:
|
|
529
|
+
update_args += ["--assigned-to", args.assign]
|
|
530
|
+
|
|
531
|
+
az_cmd(update_args, config)
|
|
532
|
+
print(f"Updated status #{ado_id} -> {args.status}")
|
|
533
|
+
|
|
534
|
+
filepath = find_file(config, "*", str(ado_id))
|
|
535
|
+
if filepath:
|
|
536
|
+
fm, body = read_md_file(filepath)
|
|
537
|
+
fm["last_ado_sync"] = now_str()
|
|
538
|
+
if args.status:
|
|
539
|
+
fm["ado_state"] = args.status
|
|
540
|
+
if args.assign:
|
|
541
|
+
fm["ado_assigned_to"] = args.assign
|
|
542
|
+
write_md_file(filepath, fm, body)
|
|
543
|
+
print(f" Updated local file: {filepath}")
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def cmd_delete(args, config):
|
|
547
|
+
file_type = args.type
|
|
548
|
+
ado_id = args.ado_id
|
|
549
|
+
|
|
550
|
+
az_cmd([
|
|
551
|
+
"boards", "work-item", "delete",
|
|
552
|
+
"--id", str(ado_id),
|
|
553
|
+
"--yes",
|
|
554
|
+
], config)
|
|
555
|
+
print(f"Deleted {file_type} #{ado_id} on ADO")
|
|
556
|
+
|
|
557
|
+
filepath = find_file(config, file_type, str(ado_id))
|
|
558
|
+
if filepath:
|
|
559
|
+
fm, body = read_md_file(filepath)
|
|
560
|
+
fm["ado_state"] = "Removed"
|
|
561
|
+
fm["last_ado_sync"] = now_str()
|
|
562
|
+
write_md_file(filepath, fm, body)
|
|
563
|
+
print(f" Updated local file status to Removed (file preserved)")
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
# --- Main ---
|
|
567
|
+
|
|
568
|
+
def main():
|
|
569
|
+
parser = argparse.ArgumentParser(description="TAS ADO Integration")
|
|
570
|
+
subparsers = parser.add_subparsers(dest="command", help="Command to run")
|
|
571
|
+
|
|
572
|
+
for t in ["epic", "feature", "story", "bug"]:
|
|
573
|
+
p = subparsers.add_parser(f"create-{t}", help=f"Create {t} on ADO")
|
|
574
|
+
p.add_argument("temp_id", help="Temporary ID in filename")
|
|
575
|
+
p.add_argument("--parent-id", dest="parent_id", help="Parent work item ID")
|
|
576
|
+
p.set_defaults(type=t)
|
|
577
|
+
|
|
578
|
+
for cmd_name in ["get", "pull"]:
|
|
579
|
+
p = subparsers.add_parser(cmd_name, help="Pull work item from ADO")
|
|
580
|
+
p.add_argument("ado_id", type=int, help="ADO work item ID")
|
|
581
|
+
|
|
582
|
+
for t in ["epic", "feature", "story", "bug"]:
|
|
583
|
+
p = subparsers.add_parser(f"update-{t}", help=f"Update {t} on ADO")
|
|
584
|
+
p.add_argument("ado_id", type=int, help="ADO work item ID")
|
|
585
|
+
p.add_argument("--assign", help="Assign to name/email")
|
|
586
|
+
p.add_argument("--status", help="Set state")
|
|
587
|
+
p.set_defaults(type=t)
|
|
588
|
+
|
|
589
|
+
p = subparsers.add_parser("update-status", help="Quick status update")
|
|
590
|
+
p.add_argument("ado_id", type=int, help="ADO work item ID")
|
|
591
|
+
p.add_argument("--status", required=True, help="New state")
|
|
592
|
+
p.add_argument("--assign", help="Assign to name/email")
|
|
593
|
+
|
|
594
|
+
for t in ["epic", "feature", "story", "bug"]:
|
|
595
|
+
p = subparsers.add_parser(f"delete-{t}", help=f"Delete {t} on ADO")
|
|
596
|
+
p.add_argument("ado_id", type=int, help="ADO work item ID")
|
|
597
|
+
p.set_defaults(type=t)
|
|
598
|
+
|
|
599
|
+
args = parser.parse_args()
|
|
600
|
+
if not args.command:
|
|
601
|
+
parser.print_help()
|
|
602
|
+
sys.exit(1)
|
|
603
|
+
|
|
604
|
+
config = load_config()
|
|
605
|
+
|
|
606
|
+
if args.command.startswith("create-"):
|
|
607
|
+
cmd_create(args, config)
|
|
608
|
+
elif args.command in ("get", "pull"):
|
|
609
|
+
cmd_get(args, config)
|
|
610
|
+
elif args.command.startswith("update-") and args.command != "update-status":
|
|
611
|
+
cmd_update(args, config)
|
|
612
|
+
elif args.command == "update-status":
|
|
613
|
+
cmd_update_status(args, config)
|
|
614
|
+
elif args.command.startswith("delete-"):
|
|
615
|
+
cmd_delete(args, config)
|
|
616
|
+
else:
|
|
617
|
+
parser.print_help()
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
if __name__ == "__main__":
|
|
621
|
+
main()
|