@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.
- package/.tas/_platform/claude-code/settings.json +58 -46
- package/.tas/_platform/hooks/code-quality.js +127 -127
- package/.tas/_platform/hooks/session-end.js +111 -111
- package/.tas/agents/architect.md +53 -53
- package/.tas/agents/aws-reviewer.md +71 -71
- package/.tas/agents/build-resolver.md +89 -59
- package/.tas/agents/code-explorer.md +63 -63
- package/.tas/agents/csharp-reviewer.md +62 -62
- package/.tas/agents/database-reviewer.md +73 -73
- package/.tas/agents/doc-updater.md +68 -66
- package/.tas/agents/python-reviewer.md +67 -67
- package/.tas/agents/security-reviewer.md +79 -79
- package/.tas/agents/software-engineer.md +53 -0
- package/.tas/agents/typescript-reviewer.md +65 -65
- package/.tas/commands/ado-create.md +33 -28
- package/.tas/commands/ado-delete.md +26 -22
- package/.tas/commands/ado-get.md +24 -20
- package/.tas/commands/ado-status.md +22 -18
- package/.tas/commands/ado-update.md +31 -27
- package/.tas/commands/tas-adr.md +37 -33
- package/.tas/commands/tas-apitest-plan.md +177 -173
- package/.tas/commands/tas-apitest.md +147 -143
- package/.tas/commands/tas-brainstorm.md +23 -19
- package/.tas/commands/tas-brd.md +50 -0
- package/.tas/commands/tas-bug.md +127 -113
- package/.tas/commands/tas-checklist.md +180 -0
- package/.tas/commands/tas-debug.md +103 -0
- package/.tas/commands/tas-design.md +41 -37
- package/.tas/commands/tas-dev.md +225 -125
- package/.tas/commands/tas-e2e-mobile.md +146 -155
- package/.tas/commands/tas-e2e-web.md +150 -163
- package/.tas/commands/tas-e2e.md +289 -102
- package/.tas/commands/tas-feature.md +181 -47
- package/.tas/commands/tas-fix.md +72 -51
- package/.tas/commands/tas-functest-mobile.md +138 -144
- package/.tas/commands/tas-functest-web.md +176 -192
- package/.tas/commands/tas-functest.md +225 -76
- package/.tas/commands/tas-init.md +22 -17
- package/.tas/commands/tas-master-plan.md +300 -0
- package/.tas/commands/tas-orchestrate.md +159 -0
- package/.tas/commands/tas-plan.md +152 -117
- package/.tas/commands/tas-prd.md +57 -37
- package/.tas/commands/tas-review-pr.md +174 -0
- package/.tas/commands/tas-review.md +115 -113
- package/.tas/commands/tas-sad.md +47 -43
- package/.tas/commands/tas-security.md +91 -87
- package/.tas/commands/tas-spec.md +54 -50
- package/.tas/commands/tas-status.md +25 -16
- package/.tas/project-status-example.yaml +3 -1
- package/.tas/rules/ado-integration.md +67 -65
- package/.tas/rules/common/api-design.md +517 -517
- package/.tas/rules/common/build-debug-loop.md +233 -0
- package/.tas/rules/common/code-review.md +4 -0
- package/.tas/rules/common/feature-done.md +42 -0
- package/.tas/rules/common/post-implementation-review.md +4 -0
- package/.tas/rules/common/project-status.md +33 -16
- package/.tas/rules/common/sad-impact.md +81 -0
- package/.tas/rules/common/tdd.md +104 -89
- package/.tas/rules/csharp/api-testing.md +2 -2
- package/.tas/rules/csharp/torus-core-framework.md +128 -0
- package/.tas/tas-example.yaml +9 -32
- package/.tas/templates/AGENTS.md +13 -0
- package/.tas/templates/API-Test-Spec.md +5 -4
- package/.tas/templates/BRD.md +133 -0
- package/.tas/templates/Bug.md +15 -0
- package/.tas/templates/E2E-Execution-Report.md +8 -8
- package/.tas/templates/E2E-Mobile-Spec.md +6 -8
- package/.tas/templates/E2E-Report.md +2 -2
- package/.tas/templates/E2E-Scenario.md +22 -22
- package/.tas/templates/E2E-Test-Spec.md +274 -0
- package/.tas/templates/E2E-Web-Spec.md +4 -4
- package/.tas/templates/Feature-Technical-Part.md +69 -0
- package/.tas/templates/Feature-Technical-Stack.md +74 -0
- package/.tas/templates/Feature-Technical.md +329 -0
- package/.tas/templates/Feature.md +50 -26
- package/.tas/templates/Func-Test-Script.md +29 -56
- package/.tas/templates/Func-Test-Spec.md +144 -142
- package/.tas/templates/PRD.md +173 -142
- package/.tas/templates/TestChecklist.md +96 -0
- package/.tas/templates/torus-dotnet-bootstrap.md +223 -0
- package/.tas/tools/tas-ado-readme.md +24 -27
- package/.tas/tools/tas-ado.py +328 -25
- package/.tas/tools/tas-github.py +339 -0
- package/README.md +131 -54
- package/bin/cli.js +90 -90
- package/lib/adapters/antigravity.js +131 -131
- package/lib/adapters/claude-code.js +71 -35
- package/lib/adapters/codex.js +157 -157
- package/lib/adapters/cursor.js +80 -80
- package/lib/adapters/index.js +20 -20
- package/lib/adapters/utils.js +81 -81
- package/lib/deleted-files.json +7 -0
- package/lib/install.js +546 -546
- package/package.json +1 -1
- package/.tas/commands/tas-epic.md +0 -35
- package/.tas/commands/tas-story.md +0 -91
- package/.tas/rules/common/story-done.md +0 -30
- package/.tas/templates/Epic.md +0 -46
- 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.
|
|
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 (
|
|
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-
|
|
57
|
-
# → reads
|
|
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/
|
|
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-
|
|
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
|
|
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:
|
|
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:
|
|
105
|
-
ado_title:
|
|
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
|
|
107
|
+
# optional: parent_ado_id: 100
|
|
111
108
|
---
|
|
112
109
|
# Title (must match ado_title)
|
|
113
110
|
|
|
114
|
-
Content description, acceptance criteria,
|
|
111
|
+
Content description, acceptance criteria, etc.
|
|
115
112
|
```
|
|
116
113
|
|
|
117
114
|
**Important**:
|
|
118
|
-
- Every
|
|
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`
|
|
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.
|
|
135
|
-
4.
|
|
136
|
-
5.
|
|
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.
|
|
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
|
|
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
|
|
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.
|
package/.tas/tools/tas-ado.py
CHANGED
|
@@ -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
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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:
|
|
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. "
|
|
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
|
|
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 == "
|
|
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
|
|
378
|
-
#
|
|
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
|
|
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
|
|
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
|
-
|
|
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 ["
|
|
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 ["
|
|
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 ["
|
|
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
|
|