@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.
Files changed (123) hide show
  1. package/.tas/README.md +334 -334
  2. package/{.claude → .tas/_platform/claude-code}/settings.json +0 -12
  3. package/{.claude → .tas/_platform}/hooks/code-quality.js +1 -1
  4. package/{.claude → .tas/_platform}/hooks/session-end.js +20 -25
  5. package/{.claude → .tas}/commands/ado-create.md +5 -4
  6. package/{.claude → .tas}/commands/ado-delete.md +5 -4
  7. package/{.claude → .tas}/commands/ado-update.md +5 -4
  8. package/{.claude → .tas}/commands/tas-adr.md +3 -3
  9. package/{.claude → .tas}/commands/tas-apitest-plan.md +2 -2
  10. package/{.claude → .tas}/commands/tas-apitest.md +4 -4
  11. package/{.claude → .tas}/commands/tas-bug.md +6 -6
  12. package/{.claude → .tas}/commands/tas-design.md +3 -3
  13. package/{.claude → .tas}/commands/tas-dev.md +11 -14
  14. package/{.claude → .tas}/commands/tas-epic.md +3 -3
  15. package/{.claude → .tas}/commands/tas-feature.md +4 -4
  16. package/{.claude → .tas}/commands/tas-fix.md +5 -5
  17. package/{.claude → .tas}/commands/tas-init.md +1 -1
  18. package/{.claude → .tas}/commands/tas-plan.md +198 -198
  19. package/{.claude → .tas}/commands/tas-prd.md +3 -3
  20. package/{.claude → .tas}/commands/tas-review.md +17 -15
  21. package/{.claude → .tas}/commands/tas-sad.md +3 -3
  22. package/{.claude → .tas}/commands/tas-security.md +4 -4
  23. package/{.claude → .tas}/commands/tas-story.md +3 -3
  24. package/.tas/platforms.json +5 -0
  25. package/.tas/project-status-example.yaml +17 -17
  26. package/{.claude/skills/ado-integration/SKILL.md → .tas/rules/ado-integration.md} +5 -15
  27. package/{.claude/skills/api-design/SKILL.md → .tas/rules/common/api-design.md} +517 -530
  28. package/{.claude → .tas}/rules/common/code-review.md +30 -6
  29. package/{.claude/rules/common/post-review-agent.md → .tas/rules/common/post-implementation-review.md} +51 -49
  30. package/{.claude → .tas}/rules/common/project-status.md +80 -80
  31. package/{.claude → .tas}/rules/common/stack-detection.md +29 -29
  32. package/.tas/{checklists → rules/common}/story-done.md +12 -5
  33. package/{.claude/skills/tas-tdd/SKILL.md → .tas/rules/common/tdd.md} +4 -38
  34. package/{.claude → .tas}/rules/common/testing.md +3 -8
  35. package/{.claude → .tas}/rules/common/token-logging.md +36 -27
  36. package/{.claude → .tas}/rules/csharp/api-testing.md +171 -171
  37. package/{.claude → .tas}/rules/csharp/coding-style.md +0 -2
  38. package/{.claude → .tas}/rules/csharp/security.md +10 -0
  39. package/{.claude → .tas}/rules/python/coding-style.md +0 -2
  40. package/{.claude → .tas}/rules/typescript/coding-style.md +0 -2
  41. package/.tas/rules/typescript/patterns.md +142 -0
  42. package/.tas/rules/typescript/security.md +88 -0
  43. package/{.claude → .tas}/rules/typescript/testing.md +0 -4
  44. package/{.claude → .tas}/rules/web/coding-style.md +0 -2
  45. package/.tas/tas-example.yaml +125 -126
  46. package/.tas/templates/ADR.md +47 -47
  47. package/.tas/templates/Bug.md +67 -67
  48. package/.tas/templates/Design-Spec.md +36 -36
  49. package/.tas/templates/Epic.md +46 -46
  50. package/.tas/templates/Feature.md +1 -1
  51. package/.tas/templates/Security-Report.md +27 -27
  52. package/.tas/tools/tas-ado-readme.md +169 -169
  53. package/.tas/tools/tas-ado.py +621 -621
  54. package/README.md +334 -334
  55. package/bin/cli.js +91 -73
  56. package/lib/adapters/antigravity.js +131 -0
  57. package/lib/adapters/claude-code.js +35 -0
  58. package/lib/adapters/codex.js +157 -0
  59. package/lib/adapters/cursor.js +80 -0
  60. package/lib/adapters/index.js +20 -0
  61. package/lib/adapters/utils.js +81 -0
  62. package/lib/deleted-files.json +99 -0
  63. package/lib/install.js +543 -327
  64. package/package.json +5 -4
  65. package/.claude/agents/code-reviewer.md +0 -41
  66. package/.claude/agents/e2e-runner.md +0 -61
  67. package/.claude/agents/planner.md +0 -82
  68. package/.claude/agents/tdd-guide.md +0 -84
  69. package/.claude/commands/tas-verify.md +0 -51
  70. package/.claude/rules/typescript/patterns.md +0 -62
  71. package/.claude/rules/typescript/security.md +0 -28
  72. package/.claude/settings.local.json +0 -38
  73. package/.claude/skills/ai-regression-testing/SKILL.md +0 -364
  74. package/.claude/skills/architecture-decision-records/SKILL.md +0 -184
  75. package/.claude/skills/benchmark/SKILL.md +0 -98
  76. package/.claude/skills/browser-qa/SKILL.md +0 -92
  77. package/.claude/skills/canary-watch/SKILL.md +0 -104
  78. package/.claude/skills/js-backend-patterns/SKILL.md +0 -603
  79. package/.claude/skills/tas-conventions/SKILL.md +0 -65
  80. package/.claude/skills/tas-implementation-complete/SKILL.md +0 -100
  81. package/.claude/skills/token-logger/SKILL.md +0 -19
  82. package/.tas/checklists/code-review.md +0 -29
  83. package/.tas/checklists/security.md +0 -21
  84. /package/{.claude → .tas}/agents/architect.md +0 -0
  85. /package/{.claude → .tas}/agents/aws-reviewer.md +0 -0
  86. /package/{.claude → .tas}/agents/build-resolver.md +0 -0
  87. /package/{.claude → .tas}/agents/code-explorer.md +0 -0
  88. /package/{.claude → .tas}/agents/csharp-reviewer.md +0 -0
  89. /package/{.claude → .tas}/agents/database-reviewer.md +0 -0
  90. /package/{.claude → .tas}/agents/doc-updater.md +0 -0
  91. /package/{.claude → .tas}/agents/python-reviewer.md +0 -0
  92. /package/{.claude → .tas}/agents/security-reviewer.md +0 -0
  93. /package/{.claude → .tas}/agents/typescript-reviewer.md +0 -0
  94. /package/{.claude → .tas}/commands/ado-get.md +0 -0
  95. /package/{.claude → .tas}/commands/ado-status.md +0 -0
  96. /package/{.claude → .tas}/commands/tas-brainstorm.md +0 -0
  97. /package/{.claude → .tas}/commands/tas-e2e-mobile.md +0 -0
  98. /package/{.claude → .tas}/commands/tas-e2e-web.md +0 -0
  99. /package/{.claude → .tas}/commands/tas-e2e.md +0 -0
  100. /package/{.claude → .tas}/commands/tas-functest-mobile.md +0 -0
  101. /package/{.claude → .tas}/commands/tas-functest-web.md +0 -0
  102. /package/{.claude → .tas}/commands/tas-functest.md +0 -0
  103. /package/{.claude → .tas}/commands/tas-spec.md +0 -0
  104. /package/{.claude → .tas}/commands/tas-status.md +0 -0
  105. /package/{.claude → .tas}/rules/.gitkeep +0 -0
  106. /package/{.claude → .tas}/rules/common/hooks.md +0 -0
  107. /package/{.claude → .tas}/rules/common/patterns.md +0 -0
  108. /package/{.claude → .tas}/rules/common/security.md +0 -0
  109. /package/{.claude → .tas}/rules/csharp/hooks.md +0 -0
  110. /package/{.claude → .tas}/rules/csharp/patterns.md +0 -0
  111. /package/{.claude → .tas}/rules/csharp/testing.md +0 -0
  112. /package/{.claude → .tas}/rules/python/hooks.md +0 -0
  113. /package/{.claude → .tas}/rules/python/patterns.md +0 -0
  114. /package/{.claude → .tas}/rules/python/security.md +0 -0
  115. /package/{.claude → .tas}/rules/python/testing.md +0 -0
  116. /package/{.claude → .tas}/rules/typescript/hooks.md +0 -0
  117. /package/{.claude → .tas}/rules/web/design-quality.md +0 -0
  118. /package/{.claude → .tas}/rules/web/hooks.md +0 -0
  119. /package/{.claude → .tas}/rules/web/patterns.md +0 -0
  120. /package/{.claude → .tas}/rules/web/performance.md +0 -0
  121. /package/{.claude → .tas}/rules/web/security.md +0 -0
  122. /package/{.claude → .tas}/rules/web/testing.md +0 -0
  123. /package/{CLAUDE-Example.md → .tas/templates/AGENTS.md} +0 -0
@@ -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()