@torus-engineering/tas-kit 1.14.0 → 2.1.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 (99) hide show
  1. package/.tas/_platform/claude-code/settings.json +58 -46
  2. package/.tas/_platform/hooks/code-quality.js +127 -127
  3. package/.tas/_platform/hooks/session-end.js +111 -111
  4. package/.tas/agents/architect.md +53 -53
  5. package/.tas/agents/aws-reviewer.md +71 -71
  6. package/.tas/agents/build-resolver.md +89 -59
  7. package/.tas/agents/code-explorer.md +63 -63
  8. package/.tas/agents/csharp-reviewer.md +62 -62
  9. package/.tas/agents/database-reviewer.md +73 -73
  10. package/.tas/agents/doc-updater.md +68 -66
  11. package/.tas/agents/python-reviewer.md +67 -67
  12. package/.tas/agents/security-reviewer.md +79 -79
  13. package/.tas/agents/software-engineer.md +53 -0
  14. package/.tas/agents/typescript-reviewer.md +65 -65
  15. package/.tas/commands/ado-create.md +33 -28
  16. package/.tas/commands/ado-delete.md +26 -22
  17. package/.tas/commands/ado-get.md +24 -20
  18. package/.tas/commands/ado-status.md +22 -18
  19. package/.tas/commands/ado-update.md +31 -27
  20. package/.tas/commands/tas-adr.md +37 -33
  21. package/.tas/commands/tas-apitest-plan.md +177 -173
  22. package/.tas/commands/tas-apitest.md +147 -143
  23. package/.tas/commands/tas-brainstorm.md +23 -19
  24. package/.tas/commands/tas-brd.md +50 -0
  25. package/.tas/commands/tas-bug.md +127 -113
  26. package/.tas/commands/tas-checklist.md +180 -0
  27. package/.tas/commands/tas-debug.md +103 -0
  28. package/.tas/commands/tas-design.md +41 -37
  29. package/.tas/commands/tas-dev.md +225 -125
  30. package/.tas/commands/tas-e2e-mobile.md +146 -155
  31. package/.tas/commands/tas-e2e-web.md +150 -163
  32. package/.tas/commands/tas-e2e.md +289 -102
  33. package/.tas/commands/tas-feature.md +181 -47
  34. package/.tas/commands/tas-fix.md +72 -51
  35. package/.tas/commands/tas-functest-mobile.md +138 -144
  36. package/.tas/commands/tas-functest-web.md +176 -192
  37. package/.tas/commands/tas-functest.md +225 -76
  38. package/.tas/commands/tas-init.md +22 -17
  39. package/.tas/commands/tas-master-plan.md +300 -0
  40. package/.tas/commands/tas-orchestrate.md +159 -0
  41. package/.tas/commands/tas-plan.md +152 -117
  42. package/.tas/commands/tas-prd.md +57 -37
  43. package/.tas/commands/tas-review-pr.md +174 -0
  44. package/.tas/commands/tas-review.md +115 -113
  45. package/.tas/commands/tas-sad.md +47 -43
  46. package/.tas/commands/tas-security.md +91 -87
  47. package/.tas/commands/tas-spec.md +54 -50
  48. package/.tas/commands/tas-status.md +25 -16
  49. package/.tas/project-status-example.yaml +3 -1
  50. package/.tas/rules/ado-integration.md +67 -65
  51. package/.tas/rules/common/api-design.md +517 -517
  52. package/.tas/rules/common/build-debug-loop.md +233 -0
  53. package/.tas/rules/common/code-review.md +4 -0
  54. package/.tas/rules/common/feature-done.md +42 -0
  55. package/.tas/rules/common/post-implementation-review.md +4 -0
  56. package/.tas/rules/common/project-status.md +33 -16
  57. package/.tas/rules/common/sad-impact.md +81 -0
  58. package/.tas/rules/common/tdd.md +104 -89
  59. package/.tas/rules/csharp/api-testing.md +2 -2
  60. package/.tas/rules/csharp/torus-core-framework.md +128 -0
  61. package/.tas/tas-example.yaml +9 -32
  62. package/.tas/templates/AGENTS.md +13 -0
  63. package/.tas/templates/API-Test-Spec.md +5 -4
  64. package/.tas/templates/BRD.md +133 -0
  65. package/.tas/templates/Bug.md +15 -0
  66. package/.tas/templates/E2E-Execution-Report.md +8 -8
  67. package/.tas/templates/E2E-Mobile-Spec.md +6 -8
  68. package/.tas/templates/E2E-Report.md +2 -2
  69. package/.tas/templates/E2E-Scenario.md +22 -22
  70. package/.tas/templates/E2E-Test-Spec.md +274 -0
  71. package/.tas/templates/E2E-Web-Spec.md +4 -4
  72. package/.tas/templates/Feature-Technical-Part.md +69 -0
  73. package/.tas/templates/Feature-Technical-Stack.md +74 -0
  74. package/.tas/templates/Feature-Technical.md +329 -0
  75. package/.tas/templates/Feature.md +50 -26
  76. package/.tas/templates/Func-Test-Script.md +29 -56
  77. package/.tas/templates/Func-Test-Spec.md +144 -142
  78. package/.tas/templates/PRD.md +173 -142
  79. package/.tas/templates/TestChecklist.md +96 -0
  80. package/.tas/templates/torus-dotnet-bootstrap.md +223 -0
  81. package/.tas/tools/tas-ado-readme.md +24 -27
  82. package/.tas/tools/tas-ado.py +328 -25
  83. package/.tas/tools/tas-github.py +339 -0
  84. package/README.md +131 -54
  85. package/bin/cli.js +90 -90
  86. package/lib/adapters/antigravity.js +131 -131
  87. package/lib/adapters/claude-code.js +71 -35
  88. package/lib/adapters/codex.js +157 -157
  89. package/lib/adapters/cursor.js +80 -80
  90. package/lib/adapters/index.js +20 -20
  91. package/lib/adapters/utils.js +81 -81
  92. package/lib/deleted-files.json +7 -0
  93. package/lib/install.js +546 -546
  94. package/package.json +1 -1
  95. package/.tas/commands/tas-epic.md +0 -35
  96. package/.tas/commands/tas-story.md +0 -91
  97. package/.tas/rules/common/story-done.md +0 -30
  98. package/.tas/templates/Epic.md +0 -46
  99. package/.tas/templates/Story.md +0 -90
@@ -1,15 +1,17 @@
1
1
  # TAS Kit Skill Specification: ado-integration
2
2
 
3
3
  **Skill name**: ado-integration
4
- **Version**: 0.2 (concept & spec date: March 17, 2026)
4
+ **Version**: 0.3 (kit v3 Feature is the only TAS work unit; Epic / User Story removed)
5
5
  **Purpose**:
6
- Allows AI agent in coding environment (Claude Code, Cursor, VS Code + terminal) to perform **CRUD** and two-way sync between Markdown (.md) files in repository and work items on Azure DevOps (Epic, Feature, User Story, Bug), including automatic hierarchy management (parent-child relations).
6
+ Allows AI agent in coding environment (Claude Code, Cursor, VS Code + terminal) to perform **CRUD** and two-way sync between Markdown (.md) files in repository and work items on Azure DevOps (Feature, Bug).
7
7
 
8
8
  Main objectives:
9
9
  - Transform AI into "team member" capable of self-managing backlog without leaving editor
10
10
  - Ensure .md file is single source of truth (local ↔ ADO sync)
11
11
  - Record sync timestamp so agent knows data freshness
12
12
 
13
+ > **Migration note (v3):** Epic and User Story are no longer kit-managed. If your ADO project still uses Epic / User Story templates, treat each TAS Feature as the ADO `Feature` work item; legacy work items pulled with `get` are converted to local `feature-*.md` files.
14
+
13
15
  ## 1. Environment Requirements & Dependencies
14
16
 
15
17
  **Required**:
@@ -45,16 +47,14 @@ python tools/tas-ado.py <command> [arguments]
45
47
  ### Create commands (create new + auto rename file + optional parent)
46
48
 
47
49
  ```bash
48
- create-epic <temp-id> [--parent-id <id>]
49
50
  create-feature <temp-id> [--parent-id <id>]
50
- create-story <temp-id> [--parent-id <id>]
51
51
  create-bug <temp-id> [--parent-id <id>]
52
52
  ```
53
53
 
54
54
  Example:
55
55
  ```bash
56
- python tools/tas-ado.py create-story 789 --parent-id 456
57
- # → reads epic-789-*.md → creates User Story → renames to story-1234-*.md → adds parent relation to 456
56
+ python tools/tas-ado.py create-feature 042
57
+ # → reads feature-042-*.md → creates Feature on ADO → renames file to feature-1234-*.md
58
58
  ```
59
59
 
60
60
  ### Pull / Get commands
@@ -67,33 +67,30 @@ pull <ado-id> # alias
67
67
  Example:
68
68
  ```bash
69
69
  python tools/tas-ado.py get 5345
70
- # → creates/recreates file epic-5345-slug-title.md with frontmatter + content
70
+ # → creates/updates file feature-5345-slug-title.md
71
+ # → if ADO type is Epic or User Story (legacy), saves as feature-*.md anyway
71
72
  ```
72
73
 
73
74
  ### Update commands
74
75
 
75
76
  ```bash
76
- update-epic <ado-id> [--assign <name/email>] [--status <state>]
77
- update-feature <ado-id> [--assign <...>] [--status <...>]
78
- update-story <ado-id> [--assign <...>] [--status <...>]
77
+ update-feature <ado-id> [--assign <name/email>] [--status <state>]
79
78
  update-bug <ado-id> [--assign <...>] [--status <...>]
80
79
 
81
- update-status <ado-id> --status <state> [--assign <name/email>]
80
+ update-status <ado-id> --status <state> [--assign <name/email>]
82
81
  ```
83
82
 
84
83
  ### Delete commands
85
84
 
86
85
  ```bash
87
- delete-epic <ado-id>
88
86
  delete-feature <ado-id>
89
- delete-story <ado-id>
90
87
  delete-bug <ado-id>
91
88
  ```
92
89
 
93
90
  ## 4. File .md Convention
94
91
 
95
92
  - File name pattern: `{type}-{ado_id or temp_id}-{slug-title}.md`
96
- - type: epic / feature / story / bug
93
+ - type: feature / bug
97
94
  - slug-title: lowercase, replace spaces with -, remove special characters
98
95
 
99
96
  - Frontmatter YAML (always at file start):
@@ -101,21 +98,21 @@ delete-bug <ado-id>
101
98
  ```yaml
102
99
  ---
103
100
  ado_id: 5345
104
- ado_type: Epic # Epic / Feature / User Story / Bug
105
- ado_title: Landing Page Redesign
101
+ ado_type: Feature # Feature / Bug (legacy: Epic / User Story still accepted on get)
102
+ ado_title: Checkout Refactor
106
103
  ado_state: Active
107
104
  ado_assigned_to: Nguyen Van A <email@domain.com>
108
105
  ado_created: 2026-03-10
109
106
  last_ado_sync: 2026-03-17 15:42:08
110
- # optional: parent_ado_id: 100 (if want to support auto detect parent later)
107
+ # optional: parent_ado_id: 100
111
108
  ---
112
109
  # Title (must match ado_title)
113
110
 
114
- Content description, acceptance criteria, steps to reproduce, etc.
111
+ Content description, acceptance criteria, etc.
115
112
  ```
116
113
 
117
114
  **Important**:
118
- - Every time **create / pull / update / update-status** succeeds → **update** (or add) `last_ado_sync` field with current time (format: YYYY-MM-DD HH:MM:SS)
115
+ - Every successful create / pull / update / update-status → **update** `last_ado_sync` (format: YYYY-MM-DD HH:MM:SS)
119
116
 
120
117
  ## 5. Detailed Logic Requirements
121
118
 
@@ -125,15 +122,16 @@ Content description, acceptance criteria, steps to reproduce, etc.
125
122
  3. Create work item using `az boards work-item create`
126
123
  4. Get new ID → rename file to `{type}-{new_id}-*.md`
127
124
  5. If `--parent-id` → run `az boards work-item relation add --id <new_id> --relation-type parent --target-id <parent_id>`
128
- 6. Auto detect parent_id from frontmatter `parent_ado_id` or file name
125
+ 6. Auto detect parent_id from frontmatter `parent_ado_id`
129
126
  7. Update `last_ado_sync` in frontmatter
130
127
 
131
128
  ### When pull/get:
132
129
  1. Get work item using `az boards work-item show --id <id> --expand fields`
133
130
  2. Convert description (HTML → basic Markdown)
134
- 3. Create file `{type}-{id}-{slug}.md`
135
- 4. Write frontmatter + content
136
- 5. Update `last_ado_sync`
131
+ 3. Map ADO type → local file type via `TYPE_REVERSE` (Epic / User Story → feature)
132
+ 4. Create file `{type}-{id}-{slug}.md`
133
+ 5. Write frontmatter + content
134
+ 6. Update `last_ado_sync`
137
135
 
138
136
  ### When update:
139
137
  1. Find file by pattern `*-<id>-*.md`
@@ -149,7 +147,7 @@ Content description, acceptance criteria, steps to reproduce, etc.
149
147
  ### When delete:
150
148
  - Only delete on ADO, don't touch local files
151
149
 
152
- ## 6. Extension Suggestions (not required for v0.2)
150
+ ## 6. Extension Suggestions (not required for v0.3)
153
151
 
154
152
  - Support additional fields: priority, effort, labels, acceptance criteria
155
153
  - Auto git add/commit/push after sync
@@ -160,10 +158,9 @@ Content description, acceptance criteria, steps to reproduce, etc.
160
158
  Example prompt template for AI agent:
161
159
 
162
160
  ```
163
- /ado-create-feature 042 --parent-id 100
164
- /ado-create-story 789 --parent-id <feature-id just created>
161
+ /ado-create feature 042 --parent-id 100
165
162
  /ado-get 1234 # fetch new bug to fix
166
- /ado-update-bug 1234 --status Resolved
163
+ /ado-update bug 1234 --status Resolved
167
164
  ```
168
165
 
169
166
  This skill is designed to integrate smoothly into agentic frameworks like TAS Kit, Superpowers, BMAD.
@@ -3,25 +3,28 @@
3
3
  TAS ADO Integration Script
4
4
  Two-way sync between local .md files and Azure DevOps work items.
5
5
 
6
+ Kit v3: Epic and User Story types are removed — Feature is the only TAS work unit.
7
+
6
8
  Usage: python tools/tas-ado.py <command> [arguments]
7
9
 
8
10
  Commands:
9
- create-epic <temp-id> [--parent-id <id>]
10
11
  create-feature <temp-id> [--parent-id <id>]
11
- create-story <temp-id> [--parent-id <id>]
12
12
  create-bug <temp-id> [--parent-id <id>]
13
13
  get <ado-id>
14
14
  pull <ado-id> (alias for get)
15
- update-epic <ado-id> [--assign <name>] [--status <state>]
16
15
  update-feature <ado-id> [--assign <name>] [--status <state>]
17
- update-story <ado-id> [--assign <name>] [--status <state>]
18
16
  update-bug <ado-id> [--assign <name>] [--status <state>]
19
17
  update-status <ado-id> --status <state> [--assign <name>]
20
- delete-epic <ado-id>
21
18
  delete-feature <ado-id>
22
- delete-story <ado-id>
23
19
  delete-bug <ado-id>
24
20
 
21
+ PR commands:
22
+ pr-get <pr-id>
23
+ pr-diff <pr-id>
24
+ pr-comment <pr-id> --comment <text>
25
+ pr-inline <pr-id> --repo-id <guid> --file <path> --line <n> --comment <text>
26
+ pr-vote <pr-id> --vote <approve|reject|wait-for-author|reset>
27
+
25
28
  Prerequisites:
26
29
  - Azure CLI + azure-devops extension
27
30
  - Python 3.8+ with pyyaml
@@ -29,12 +32,15 @@ Prerequisites:
29
32
  """
30
33
 
31
34
  import argparse
35
+ import base64
32
36
  import json
33
37
  import os
34
38
  import re
35
39
  import subprocess
36
40
  import sys
37
41
  import tempfile
42
+ import urllib.error
43
+ import urllib.request
38
44
  from datetime import datetime
39
45
  from pathlib import Path
40
46
 
@@ -59,15 +65,21 @@ def find_repo_root():
59
65
 
60
66
 
61
67
  def load_dotenv(root):
62
- """Load .env file from repo root into os.environ."""
63
- env_file = root / ".env"
64
- if env_file.exists():
68
+ """Load .env and .env.local from repo root into os.environ.
69
+
70
+ .env.local takes precedence (loaded first; setdefault keeps first value).
71
+ """
72
+ for filename in (".env.local", ".env"):
73
+ env_file = root / filename
74
+ if not env_file.exists():
75
+ continue
65
76
  with open(env_file, "r", encoding="utf-8") as f:
66
77
  for line in f:
67
78
  line = line.strip()
68
79
  if line and not line.startswith("#") and "=" in line:
69
80
  key, _, value = line.partition("=")
70
- os.environ.setdefault(key.strip(), value.strip())
81
+ value = value.strip().strip('"').strip("'")
82
+ os.environ.setdefault(key.strip(), value)
71
83
 
72
84
 
73
85
  def load_config():
@@ -173,12 +185,18 @@ def az_cmd(args, config):
173
185
  # --- File helpers ---
174
186
 
175
187
  TYPE_MAP = {
176
- "epic": "Epic",
177
188
  "feature": "Feature",
178
- "story": "User Story",
179
189
  "bug": "Bug",
180
190
  }
181
191
 
192
+ # Reverse map for cmd_get — accept legacy ADO types but map them to local Feature.
193
+ TYPE_REVERSE = {
194
+ "Feature": "feature",
195
+ "Bug": "bug",
196
+ "Epic": "feature", # legacy: any Epic pulled from ADO becomes a local Feature
197
+ "User Story": "feature", # legacy: any Story pulled from ADO becomes a local Feature
198
+ }
199
+
182
200
 
183
201
  def slugify(text):
184
202
  text = text.lower().strip()
@@ -304,7 +322,7 @@ def cmd_create(args, config):
304
322
  temp_id = args.temp_id
305
323
  ado_type = TYPE_MAP.get(file_type)
306
324
  if not ado_type:
307
- print(f"ERROR: Unknown type '{file_type}'. Use: epic, feature, story, bug")
325
+ print(f"ERROR: Unknown type '{file_type}'. Use: feature, bug")
308
326
  sys.exit(1)
309
327
 
310
328
  filepath = find_file(config, file_type, temp_id)
@@ -315,7 +333,8 @@ def cmd_create(args, config):
315
333
  fm, body = read_md_file(filepath)
316
334
  raw_title = fm.get("ado_title") or extract_title(body)
317
335
 
318
- # Strip "Type-NNN: " prefix (e.g. "Epic-001: Foo" -> "Foo")
336
+ # Strip "Type-NNN: " prefix (e.g. "Feature-001: Foo" -> "Foo")
337
+ # Accept legacy Epic/Story prefixes too in case user pulled in old files.
319
338
  core_title = re.sub(
320
339
  r"^(?:Epic|Feature|Story|User Story|Bug)-\d+[:\s\-]+\s*",
321
340
  "",
@@ -331,9 +350,9 @@ def cmd_create(args, config):
331
350
 
332
351
  description = extract_description(body).replace("\n", "<br>")
333
352
 
334
- # Default assignee: PE for epic, SE for everything else
353
+ # Default assignee: PE for feature, SE for bug
335
354
  assignee_by_role = config.get("assignee_by_role", {})
336
- default_assignee = assignee_by_role.get("pe" if file_type == "epic" else "se", "")
355
+ default_assignee = assignee_by_role.get("pe" if file_type == "feature" else "se", "")
337
356
 
338
357
  status = extract_status(fm, body)
339
358
 
@@ -374,10 +393,10 @@ def cmd_create(args, config):
374
393
  args.parent_id = fm_parent
375
394
  print(f" Auto-detected parent from frontmatter: #{args.parent_id}")
376
395
  else:
377
- # Check immediate parent dir first, then grandparent
378
- # (stories live directly in feature folder; features live in their own subfolder inside epic)
396
+ # Check immediate parent dir for a numeric ID match (legacy/optional).
397
+ # Kit v3 features live flat in docs/features/ usually no parent to detect.
379
398
  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)
399
+ m = re.match(r"^(?:epic|feature)-(\d+)", parent_dir.name, re.IGNORECASE)
381
400
  if m:
382
401
  args.parent_id = m.group(1)
383
402
  print(f" Auto-detected parent from directory: #{args.parent_id}")
@@ -401,7 +420,7 @@ def cmd_create(args, config):
401
420
 
402
421
  old_folder = old_path.parent
403
422
  new_folder = old_folder
404
- is_work_item_folder = file_type in ("epic", "feature") and bool(
423
+ is_work_item_folder = file_type == "feature" and bool(
405
424
  re.match(rf"(?i)^{re.escape(file_type)}-", old_folder.name)
406
425
  )
407
426
  if is_work_item_folder:
@@ -452,8 +471,7 @@ def cmd_get(args, config):
452
471
  description = fields.get("System.Description", "")
453
472
  created = fields.get("System.CreatedDate", "")[:10]
454
473
 
455
- type_reverse = {v: k for k, v in TYPE_MAP.items()}
456
- file_type = type_reverse.get(work_item_type, "story")
474
+ file_type = TYPE_REVERSE.get(work_item_type, "feature")
457
475
 
458
476
  slug = slugify(title)
459
477
  filename = f"{file_type}-{ado_id}-{slug}.md"
@@ -563,13 +581,265 @@ def cmd_delete(args, config):
563
581
  print(f" Updated local file status to Removed (file preserved)")
564
582
 
565
583
 
584
+ # --- PR helpers ---
585
+
586
+ def get_org_name(org):
587
+ """Extract bare org name from org URL or name."""
588
+ org = org.rstrip("/")
589
+ if "dev.azure.com/" in org:
590
+ return org.split("dev.azure.com/")[-1]
591
+ if "visualstudio.com" in org:
592
+ return org.split(".visualstudio.com")[0].split("/")[-1]
593
+ return org
594
+
595
+
596
+ def az_repos_cmd(args, config):
597
+ """Run az repos command with explicit org/project from config."""
598
+ az_bin = "az.cmd" if sys.platform == "win32" else "az"
599
+
600
+ tmp_files = []
601
+ safe_args = []
602
+ i = 0
603
+ while i < len(args):
604
+ if (len(str(args[i])) > 200 or "\n" in str(args[i])) and i > 0 and str(args[i - 1]).startswith("--"):
605
+ tmp = tempfile.NamedTemporaryFile(mode="wb", suffix=".txt", delete=False)
606
+ tmp.write(str(args[i]).encode("utf-8"))
607
+ tmp.close()
608
+ tmp_files.append(tmp.name)
609
+ safe_args.append(f"@{tmp.name}")
610
+ else:
611
+ safe_args.append(args[i])
612
+ i += 1
613
+
614
+ # az repos pr show/list/comment/vote use globally-unique PR id; --project not accepted
615
+ is_repos_pr = len(safe_args) >= 2 and safe_args[0] == "repos" and safe_args[1] == "pr"
616
+ base_cmd = [az_bin] + safe_args + ["--org", config["org"]]
617
+ if not is_repos_pr:
618
+ base_cmd += ["--project", config["project"]]
619
+ cmd = base_cmd + ["--output", "json"]
620
+ env = os.environ.copy()
621
+ if config["pat"]:
622
+ env["AZURE_DEVOPS_EXT_PAT"] = config["pat"]
623
+
624
+ try:
625
+ result = subprocess.run(cmd, capture_output=True, env=env)
626
+ finally:
627
+ for f in tmp_files:
628
+ try:
629
+ os.unlink(f)
630
+ except OSError:
631
+ pass
632
+
633
+ def _decode(b):
634
+ try:
635
+ return (b or b"").decode("utf-8")
636
+ except UnicodeDecodeError:
637
+ return (b or b"").decode("cp1252", errors="replace")
638
+
639
+ if result.returncode != 0:
640
+ print(f"ERROR: az command failed:\n{_decode(result.stderr)}")
641
+ sys.exit(1)
642
+ stdout = _decode(result.stdout).strip()
643
+ if not stdout:
644
+ return {}
645
+ json_start = re.search(r"[{\[]", stdout)
646
+ if json_start:
647
+ return json.loads(stdout[json_start.start():])
648
+ return {}
649
+
650
+
651
+ def ado_rest(method, url, data, config):
652
+ """Call ADO REST API using PAT Basic auth."""
653
+ token = base64.b64encode(f":{config['pat']}".encode("utf-8")).decode("ascii")
654
+ body = json.dumps(data).encode("utf-8")
655
+ req = urllib.request.Request(url, data=body, method=method)
656
+ req.add_header("Authorization", f"Basic {token}")
657
+ req.add_header("Content-Type", "application/json")
658
+ req.add_header("Accept", "application/json")
659
+ try:
660
+ with urllib.request.urlopen(req) as resp:
661
+ return json.loads(resp.read().decode("utf-8"))
662
+ except urllib.error.HTTPError as e:
663
+ err_body = e.read().decode("utf-8")
664
+ print(f"ERROR: ADO REST API {method} {url} failed ({e.code}): {err_body}")
665
+ sys.exit(1)
666
+
667
+
668
+ # --- PR Commands ---
669
+
670
+ def cmd_pr_get(args, config):
671
+ pr_id = args.pr_id
672
+ result = az_repos_cmd([
673
+ "repos", "pr", "show",
674
+ "--id", str(pr_id),
675
+ ], config)
676
+
677
+ source_ref = result.get("sourceRefName", "")
678
+ target_ref = result.get("targetRefName", "")
679
+ source_branch = source_ref.replace("refs/heads/", "")
680
+ target_branch = target_ref.replace("refs/heads/", "")
681
+ created_by = result.get("createdBy", {})
682
+ creator = created_by.get("displayName", "") if isinstance(created_by, dict) else str(created_by)
683
+ repo = result.get("repository", {})
684
+ repo_id = repo.get("id", "")
685
+ repo_name = repo.get("name", "")
686
+ work_item_refs = result.get("workItemRefs") or []
687
+ work_items = [str(wi.get("id", "")) for wi in work_item_refs if wi.get("id")]
688
+
689
+ print(f"PR_ID: {pr_id}")
690
+ print(f"TITLE: {result.get('title', '')}")
691
+ print(f"DESCRIPTION: {result.get('description', '')}")
692
+ print(f"SOURCE_BRANCH: {source_branch}")
693
+ print(f"TARGET_BRANCH: {target_branch}")
694
+ print(f"CREATOR: {creator}")
695
+ print(f"STATUS: {result.get('status', '')}")
696
+ print(f"REPO_ID: {repo_id}")
697
+ print(f"REPO_NAME: {repo_name}")
698
+ print(f"WORK_ITEMS: {','.join(work_items)}")
699
+
700
+
701
+ def cmd_pr_diff(args, config):
702
+ pr_id = args.pr_id
703
+ result = az_repos_cmd([
704
+ "repos", "pr", "show",
705
+ "--id", str(pr_id),
706
+ ], config)
707
+
708
+ source_ref = result.get("sourceRefName", "")
709
+ target_ref = result.get("targetRefName", "")
710
+ source_branch = source_ref.replace("refs/heads/", "")
711
+ target_branch = target_ref.replace("refs/heads/", "")
712
+
713
+ print(f"SOURCE_BRANCH: {source_branch}")
714
+ print(f"TARGET_BRANCH: {target_branch}")
715
+
716
+ fetch_result = subprocess.run(
717
+ ["git", "fetch", "origin", source_branch],
718
+ capture_output=True,
719
+ cwd=str(config["root"]),
720
+ )
721
+
722
+ fetch_head = ""
723
+ diff_base = f"origin/{target_branch}"
724
+
725
+ if fetch_result.returncode == 0:
726
+ head_result = subprocess.run(
727
+ ["git", "rev-parse", "FETCH_HEAD"],
728
+ capture_output=True,
729
+ cwd=str(config["root"]),
730
+ )
731
+ fetch_head = head_result.stdout.decode("utf-8").strip() if head_result.returncode == 0 else ""
732
+ else:
733
+ # Fallback for merged PRs where source branch was deleted
734
+ src_commit = (result.get("lastMergeSourceCommit") or {}).get("commitId", "")
735
+ tgt_commit = (result.get("lastMergeTargetCommit") or {}).get("commitId", "")
736
+ if src_commit and tgt_commit:
737
+ subprocess.run(["git", "fetch", "origin", src_commit], capture_output=True, cwd=str(config["root"]))
738
+ subprocess.run(["git", "fetch", "origin", tgt_commit], capture_output=True, cwd=str(config["root"]))
739
+ fetch_head = src_commit
740
+ diff_base = tgt_commit
741
+ else:
742
+ warn = (fetch_result.stderr or b"").decode("utf-8", errors="replace")
743
+ print(f"WARNING: git fetch failed and no merge commits available: {warn}")
744
+ return
745
+
746
+ print(f"FETCH_HEAD: {fetch_head}")
747
+
748
+ diff_result = subprocess.run(
749
+ ["git", "diff", "--name-status", f"{diff_base}...{fetch_head}"],
750
+ capture_output=True,
751
+ cwd=str(config["root"]),
752
+ )
753
+ if diff_result.returncode == 0:
754
+ print("CHANGED_FILES:")
755
+ print(diff_result.stdout.decode("utf-8", errors="replace").strip())
756
+ else:
757
+ warn = (diff_result.stderr or b"").decode("utf-8", errors="replace")
758
+ print(f"WARNING: git diff failed: {warn}")
759
+
760
+
761
+ def cmd_pr_comment(args, config):
762
+ """Post a PR-level (non-inline) comment thread via REST.
763
+
764
+ Older azure-devops CLI extensions don't ship `az repos pr comment`, so we
765
+ talk to the Threads REST endpoint directly. Resolves the repo id from
766
+ `pr show`.
767
+ """
768
+ pr_id = args.pr_id
769
+ pr_info = az_repos_cmd([
770
+ "repos", "pr", "show",
771
+ "--id", str(pr_id),
772
+ ], config)
773
+ repo_id = (pr_info.get("repository") or {}).get("id", "")
774
+ if not repo_id:
775
+ print("ERROR: could not resolve repo id for PR")
776
+ sys.exit(1)
777
+
778
+ org_name = get_org_name(config["org"])
779
+ project = urllib.request.quote(config["project"], safe="")
780
+ url = (
781
+ f"https://dev.azure.com/{org_name}/{project}/_apis/git/repositories"
782
+ f"/{repo_id}/pullRequests/{pr_id}/threads?api-version=7.1"
783
+ )
784
+ data = {
785
+ "comments": [{"parentCommentId": 0, "content": args.comment, "commentType": "text"}],
786
+ "status": "active",
787
+ }
788
+ result = ado_rest("POST", url, data, config)
789
+ thread_id = result.get("id", "")
790
+ print(f"Posted comment thread #{thread_id} on PR #{pr_id}")
791
+
792
+
793
+ def cmd_pr_inline(args, config):
794
+ pr_id = args.pr_id
795
+ repo_id = args.repo_id
796
+ file_path = "/" + args.file_path.lstrip("/").replace("\\", "/")
797
+ line = args.line
798
+
799
+ org_name = get_org_name(config["org"])
800
+ project = urllib.request.quote(config["project"], safe="")
801
+ url = (
802
+ f"https://dev.azure.com/{org_name}/{project}/_apis/git/repositories"
803
+ f"/{repo_id}/pullRequests/{pr_id}/threads?api-version=7.1"
804
+ )
805
+ data = {
806
+ "comments": [{"parentCommentId": 0, "content": args.comment, "commentType": "text"}],
807
+ "status": "active",
808
+ "threadContext": {
809
+ "filePath": file_path,
810
+ "rightFileStart": {"line": line, "offset": 1},
811
+ "rightFileEnd": {"line": line, "offset": 1},
812
+ },
813
+ }
814
+ result = ado_rest("POST", url, data, config)
815
+ thread_id = result.get("id", "")
816
+ print(f"Posted inline comment on {file_path}:{line} (thread #{thread_id})")
817
+
818
+
819
+ def cmd_pr_vote(args, config):
820
+ vote_map = {
821
+ "approve": "approve",
822
+ "reject": "reject",
823
+ "wait": "wait-for-author",
824
+ "wait-for-author": "wait-for-author",
825
+ "reset": "reset",
826
+ }
827
+ vote = vote_map.get(args.vote.lower(), args.vote)
828
+ az_repos_cmd([
829
+ "repos", "pr", "set-vote",
830
+ "--id", str(args.pr_id),
831
+ "--vote", vote,
832
+ ], config)
833
+ print(f"Voted '{vote}' on PR #{args.pr_id}")
834
+
835
+
566
836
  # --- Main ---
567
837
 
568
838
  def main():
569
839
  parser = argparse.ArgumentParser(description="TAS ADO Integration")
570
840
  subparsers = parser.add_subparsers(dest="command", help="Command to run")
571
841
 
572
- for t in ["epic", "feature", "story", "bug"]:
842
+ for t in ["feature", "bug"]:
573
843
  p = subparsers.add_parser(f"create-{t}", help=f"Create {t} on ADO")
574
844
  p.add_argument("temp_id", help="Temporary ID in filename")
575
845
  p.add_argument("--parent-id", dest="parent_id", help="Parent work item ID")
@@ -579,7 +849,7 @@ def main():
579
849
  p = subparsers.add_parser(cmd_name, help="Pull work item from ADO")
580
850
  p.add_argument("ado_id", type=int, help="ADO work item ID")
581
851
 
582
- for t in ["epic", "feature", "story", "bug"]:
852
+ for t in ["feature", "bug"]:
583
853
  p = subparsers.add_parser(f"update-{t}", help=f"Update {t} on ADO")
584
854
  p.add_argument("ado_id", type=int, help="ADO work item ID")
585
855
  p.add_argument("--assign", help="Assign to name/email")
@@ -591,11 +861,34 @@ def main():
591
861
  p.add_argument("--status", required=True, help="New state")
592
862
  p.add_argument("--assign", help="Assign to name/email")
593
863
 
594
- for t in ["epic", "feature", "story", "bug"]:
864
+ for t in ["feature", "bug"]:
595
865
  p = subparsers.add_parser(f"delete-{t}", help=f"Delete {t} on ADO")
596
866
  p.add_argument("ado_id", type=int, help="ADO work item ID")
597
867
  p.set_defaults(type=t)
598
868
 
869
+ p = subparsers.add_parser("pr-get", help="Get PR metadata")
870
+ p.add_argument("pr_id", type=int, help="Pull Request ID")
871
+
872
+ p = subparsers.add_parser("pr-diff", help="Fetch PR branch and list changed files")
873
+ p.add_argument("pr_id", type=int, help="Pull Request ID")
874
+
875
+ p = subparsers.add_parser("pr-comment", help="Post summary comment thread on PR")
876
+ p.add_argument("pr_id", type=int, help="Pull Request ID")
877
+ p.add_argument("--comment", required=True, help="Comment text (markdown supported)")
878
+
879
+ p = subparsers.add_parser("pr-inline", help="Post inline comment on PR diff")
880
+ p.add_argument("pr_id", type=int, help="Pull Request ID")
881
+ p.add_argument("--repo-id", dest="repo_id", required=True, help="Repository GUID (from pr-get REPO_ID)")
882
+ p.add_argument("--file", dest="file_path", required=True, help="File path (e.g. src/api/users.ts)")
883
+ p.add_argument("--line", type=int, required=True, help="Line number")
884
+ p.add_argument("--comment", required=True, help="Inline comment text")
885
+
886
+ p = subparsers.add_parser("pr-vote", help="Set vote on PR")
887
+ p.add_argument("pr_id", type=int, help="Pull Request ID")
888
+ p.add_argument("--vote", required=True,
889
+ choices=["approve", "reject", "wait-for-author", "reset"],
890
+ help="Vote to cast")
891
+
599
892
  args = parser.parse_args()
600
893
  if not args.command:
601
894
  parser.print_help()
@@ -613,6 +906,16 @@ def main():
613
906
  cmd_update_status(args, config)
614
907
  elif args.command.startswith("delete-"):
615
908
  cmd_delete(args, config)
909
+ elif args.command == "pr-get":
910
+ cmd_pr_get(args, config)
911
+ elif args.command == "pr-diff":
912
+ cmd_pr_diff(args, config)
913
+ elif args.command == "pr-comment":
914
+ cmd_pr_comment(args, config)
915
+ elif args.command == "pr-inline":
916
+ cmd_pr_inline(args, config)
917
+ elif args.command == "pr-vote":
918
+ cmd_pr_vote(args, config)
616
919
  else:
617
920
  parser.print_help()
618
921