azwork 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- azwork/__init__.py +3 -0
- azwork/__main__.py +84 -0
- azwork/api/__init__.py +0 -0
- azwork/api/client.py +215 -0
- azwork/api/models.py +182 -0
- azwork/api/wiql.py +61 -0
- azwork/config.py +75 -0
- azwork/export/__init__.py +0 -0
- azwork/export/markdown.py +122 -0
- azwork/export/prompt.py +37 -0
- azwork/tui/__init__.py +0 -0
- azwork/tui/app.py +33 -0
- azwork/tui/screens/__init__.py +0 -0
- azwork/tui/screens/detail_screen.py +239 -0
- azwork/tui/screens/list_screen.py +175 -0
- azwork/tui/widgets/__init__.py +0 -0
- azwork/tui/widgets/filter_bar.py +213 -0
- azwork/tui/widgets/item_table.py +109 -0
- azwork/utils.py +210 -0
- azwork-0.1.0.dist-info/METADATA +259 -0
- azwork-0.1.0.dist-info/RECORD +24 -0
- azwork-0.1.0.dist-info/WHEEL +5 -0
- azwork-0.1.0.dist-info/entry_points.txt +2 -0
- azwork-0.1.0.dist-info/top_level.txt +1 -0
azwork/__init__.py
ADDED
azwork/__main__.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""CLI entry point for azwork."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from azwork.config import Config, CONFIG_PATH
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main() -> None:
|
|
12
|
+
parser = argparse.ArgumentParser(
|
|
13
|
+
prog="azwork",
|
|
14
|
+
description="TUI for triaging Azure DevOps work items",
|
|
15
|
+
)
|
|
16
|
+
parser.add_argument("--org", help="Azure DevOps organization")
|
|
17
|
+
parser.add_argument("--project", help="Azure DevOps project")
|
|
18
|
+
parser.add_argument("--output-dir", help="Default output directory for exports")
|
|
19
|
+
parser.add_argument("--setup", action="store_true", help="Run setup wizard")
|
|
20
|
+
|
|
21
|
+
args = parser.parse_args()
|
|
22
|
+
|
|
23
|
+
if args.setup:
|
|
24
|
+
_run_setup()
|
|
25
|
+
return
|
|
26
|
+
|
|
27
|
+
config = Config.load(
|
|
28
|
+
cli_org=args.org,
|
|
29
|
+
cli_project=args.project,
|
|
30
|
+
cli_output_dir=args.output_dir,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# If no config exists and no CLI args, run setup
|
|
34
|
+
if not config.org or not config.project:
|
|
35
|
+
if not CONFIG_PATH.exists():
|
|
36
|
+
print("No configuration found. Running setup wizard...")
|
|
37
|
+
_run_setup()
|
|
38
|
+
config = Config.load(
|
|
39
|
+
cli_org=args.org,
|
|
40
|
+
cli_project=args.project,
|
|
41
|
+
cli_output_dir=args.output_dir,
|
|
42
|
+
)
|
|
43
|
+
else:
|
|
44
|
+
errors = config.validate()
|
|
45
|
+
for err in errors:
|
|
46
|
+
print(f"Error: {err}", file=sys.stderr)
|
|
47
|
+
sys.exit(1)
|
|
48
|
+
|
|
49
|
+
errors = config.validate()
|
|
50
|
+
if errors:
|
|
51
|
+
for err in errors:
|
|
52
|
+
print(f"Error: {err}", file=sys.stderr)
|
|
53
|
+
sys.exit(1)
|
|
54
|
+
|
|
55
|
+
from azwork.tui.app import AzworkApp
|
|
56
|
+
|
|
57
|
+
app = AzworkApp(config)
|
|
58
|
+
app.run()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _run_setup() -> None:
|
|
62
|
+
"""Interactive setup wizard to create ~/.azwork.yml."""
|
|
63
|
+
print("=== azwork Setup ===\n")
|
|
64
|
+
|
|
65
|
+
org = input("Azure DevOps Organization: ").strip()
|
|
66
|
+
project = input("Project name: ").strip()
|
|
67
|
+
output_dir = input("Default export directory [./bugs]: ").strip() or "./bugs"
|
|
68
|
+
types_input = input("Work item types (comma-separated) [Bug,Task,User Story]: ").strip()
|
|
69
|
+
types = [t.strip() for t in types_input.split(",")] if types_input else ["Bug", "Task", "User Story"]
|
|
70
|
+
|
|
71
|
+
config = Config(
|
|
72
|
+
org=org,
|
|
73
|
+
project=project,
|
|
74
|
+
default_output_dir=output_dir,
|
|
75
|
+
work_item_types=types,
|
|
76
|
+
)
|
|
77
|
+
config.save_default()
|
|
78
|
+
print(f"\nConfiguration saved to {CONFIG_PATH}")
|
|
79
|
+
print("Set AZURE_DEVOPS_PAT environment variable with your Personal Access Token.")
|
|
80
|
+
print("Run 'azwork' to start.")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
if __name__ == "__main__":
|
|
84
|
+
main()
|
azwork/api/__init__.py
ADDED
|
File without changes
|
azwork/api/client.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Azure DevOps REST API client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from azwork.api.models import Comment, WorkItem
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
API_VERSION = "7.0"
|
|
14
|
+
COMMENTS_API_VERSION = "7.0-preview.4"
|
|
15
|
+
BATCH_SIZE = 200
|
|
16
|
+
MAX_RETRIES = 3
|
|
17
|
+
RETRY_BACKOFF = 1.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AzureDevOpsError(Exception):
|
|
21
|
+
"""Base error for API calls."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AuthenticationError(AzureDevOpsError):
|
|
25
|
+
"""PAT is invalid or missing required scope."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class NotFoundError(AzureDevOpsError):
|
|
29
|
+
"""Organization or project not found."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class RateLimitError(AzureDevOpsError):
|
|
33
|
+
"""Rate limit exceeded."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AzureDevOpsClient:
|
|
37
|
+
"""Client for Azure DevOps REST APIs."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, org: str, project: str, pat: str) -> None:
|
|
40
|
+
self.org = org
|
|
41
|
+
self.project = project
|
|
42
|
+
self.base_url = f"https://dev.azure.com/{org}"
|
|
43
|
+
self.session = requests.Session()
|
|
44
|
+
self.session.auth = ("", pat)
|
|
45
|
+
self.session.headers["Content-Type"] = "application/json"
|
|
46
|
+
# In-memory cache for work items
|
|
47
|
+
self._cache: dict[int, WorkItem] = {}
|
|
48
|
+
|
|
49
|
+
def _request(
|
|
50
|
+
self,
|
|
51
|
+
method: str,
|
|
52
|
+
url: str,
|
|
53
|
+
json: dict | None = None,
|
|
54
|
+
params: dict | None = None,
|
|
55
|
+
) -> dict:
|
|
56
|
+
"""Make an API request with retry logic."""
|
|
57
|
+
for attempt in range(MAX_RETRIES):
|
|
58
|
+
try:
|
|
59
|
+
resp = self.session.request(method, url, json=json, params=params, timeout=30)
|
|
60
|
+
except requests.ConnectionError:
|
|
61
|
+
raise AzureDevOpsError(
|
|
62
|
+
"Unable to connect to Azure DevOps. Check your network connection."
|
|
63
|
+
)
|
|
64
|
+
except requests.Timeout:
|
|
65
|
+
raise AzureDevOpsError("Request timed out. Please try again.")
|
|
66
|
+
|
|
67
|
+
if resp.status_code == 200:
|
|
68
|
+
return resp.json()
|
|
69
|
+
if resp.status_code == 401:
|
|
70
|
+
raise AuthenticationError(
|
|
71
|
+
"Authentication failed (401). Verify your AZURE_DEVOPS_PAT "
|
|
72
|
+
"and ensure it has 'Work Items → Read' scope."
|
|
73
|
+
)
|
|
74
|
+
if resp.status_code == 404:
|
|
75
|
+
raise NotFoundError(
|
|
76
|
+
f"Not found (404). Verify org='{self.org}' and project='{self.project}' are correct."
|
|
77
|
+
)
|
|
78
|
+
if resp.status_code == 429:
|
|
79
|
+
if attempt < MAX_RETRIES - 1:
|
|
80
|
+
wait = RETRY_BACKOFF * (2 ** attempt)
|
|
81
|
+
time.sleep(wait)
|
|
82
|
+
continue
|
|
83
|
+
raise RateLimitError("Rate limit exceeded. Please wait and try again.")
|
|
84
|
+
if resp.status_code >= 500:
|
|
85
|
+
if attempt < MAX_RETRIES - 1:
|
|
86
|
+
time.sleep(RETRY_BACKOFF)
|
|
87
|
+
continue
|
|
88
|
+
raise AzureDevOpsError(f"Server error ({resp.status_code}). Please try again later.")
|
|
89
|
+
|
|
90
|
+
raise AzureDevOpsError(f"API error {resp.status_code}: {resp.text[:200]}")
|
|
91
|
+
|
|
92
|
+
raise AzureDevOpsError("Max retries exceeded.")
|
|
93
|
+
|
|
94
|
+
def query_work_item_ids(self, wiql: str) -> list[int]:
|
|
95
|
+
"""Execute a WIQL query and return work item IDs."""
|
|
96
|
+
url = f"{self.base_url}/{self.project}/_apis/wit/wiql"
|
|
97
|
+
params = {"api-version": API_VERSION}
|
|
98
|
+
data = self._request("POST", url, json={"query": wiql}, params=params)
|
|
99
|
+
return [item["id"] for item in data.get("workItems", [])]
|
|
100
|
+
|
|
101
|
+
def get_work_items(
|
|
102
|
+
self,
|
|
103
|
+
ids: list[int],
|
|
104
|
+
progress_callback: Any | None = None,
|
|
105
|
+
) -> list[WorkItem]:
|
|
106
|
+
"""Fetch work items by ID in batches. Returns cached items when available."""
|
|
107
|
+
if not ids:
|
|
108
|
+
return []
|
|
109
|
+
|
|
110
|
+
# Separate cached vs uncached
|
|
111
|
+
result: dict[int, WorkItem] = {}
|
|
112
|
+
to_fetch: list[int] = []
|
|
113
|
+
for wid in ids:
|
|
114
|
+
if wid in self._cache:
|
|
115
|
+
result[wid] = self._cache[wid]
|
|
116
|
+
else:
|
|
117
|
+
to_fetch.append(wid)
|
|
118
|
+
|
|
119
|
+
# Fetch uncached in batches
|
|
120
|
+
total_batches = (len(to_fetch) + BATCH_SIZE - 1) // BATCH_SIZE
|
|
121
|
+
for batch_idx in range(total_batches):
|
|
122
|
+
start = batch_idx * BATCH_SIZE
|
|
123
|
+
batch_ids = to_fetch[start : start + BATCH_SIZE]
|
|
124
|
+
ids_csv = ",".join(str(i) for i in batch_ids)
|
|
125
|
+
|
|
126
|
+
url = f"{self.base_url}/_apis/wit/workitems"
|
|
127
|
+
params = {
|
|
128
|
+
"ids": ids_csv,
|
|
129
|
+
"$expand": "all",
|
|
130
|
+
"api-version": API_VERSION,
|
|
131
|
+
}
|
|
132
|
+
data = self._request("GET", url, params=params)
|
|
133
|
+
|
|
134
|
+
for item_data in data.get("value", []):
|
|
135
|
+
item = WorkItem.from_api(item_data)
|
|
136
|
+
self._cache[item.id] = item
|
|
137
|
+
result[item.id] = item
|
|
138
|
+
|
|
139
|
+
if progress_callback:
|
|
140
|
+
progress_callback(batch_idx + 1, total_batches)
|
|
141
|
+
|
|
142
|
+
# Return in the original order
|
|
143
|
+
return [result[wid] for wid in ids if wid in result]
|
|
144
|
+
|
|
145
|
+
def get_comments(self, work_item_id: int) -> list[Comment]:
|
|
146
|
+
"""Fetch comments for a work item."""
|
|
147
|
+
url = f"{self.base_url}/{self.project}/_apis/wit/workitems/{work_item_id}/comments"
|
|
148
|
+
params = {"api-version": COMMENTS_API_VERSION}
|
|
149
|
+
data = self._request("GET", url, params=params)
|
|
150
|
+
return [Comment.from_api(c) for c in data.get("comments", [])]
|
|
151
|
+
|
|
152
|
+
def get_fields(self) -> list[dict]:
|
|
153
|
+
"""Fetch all available fields for the project."""
|
|
154
|
+
url = f"{self.base_url}/{self.project}/_apis/wit/fields"
|
|
155
|
+
params = {"api-version": API_VERSION}
|
|
156
|
+
data = self._request("GET", url, params=params)
|
|
157
|
+
return data.get("value", [])
|
|
158
|
+
|
|
159
|
+
def get_iterations(self) -> list[str]:
|
|
160
|
+
"""Fetch iteration paths for the project."""
|
|
161
|
+
url = (
|
|
162
|
+
f"{self.base_url}/{self.project}/_apis/wit/classificationnodes/iterations"
|
|
163
|
+
)
|
|
164
|
+
params = {"api-version": API_VERSION, "$depth": 10}
|
|
165
|
+
try:
|
|
166
|
+
data = self._request("GET", url, params=params)
|
|
167
|
+
return _extract_paths(data, [])
|
|
168
|
+
except AzureDevOpsError:
|
|
169
|
+
return []
|
|
170
|
+
|
|
171
|
+
def get_work_item_url(self, work_item_id: int) -> str:
|
|
172
|
+
"""Get the browser URL for a work item."""
|
|
173
|
+
return f"https://dev.azure.com/{self.org}/{self.project}/_workitems/edit/{work_item_id}"
|
|
174
|
+
|
|
175
|
+
def download_image(self, url: str) -> bytes:
|
|
176
|
+
"""Download an image from an authenticated Azure DevOps URL."""
|
|
177
|
+
for attempt in range(MAX_RETRIES):
|
|
178
|
+
try:
|
|
179
|
+
resp = self.session.get(url, timeout=30)
|
|
180
|
+
except requests.ConnectionError:
|
|
181
|
+
raise AzureDevOpsError("Unable to connect to download image.")
|
|
182
|
+
except requests.Timeout:
|
|
183
|
+
raise AzureDevOpsError("Image download timed out.")
|
|
184
|
+
|
|
185
|
+
if resp.status_code == 200:
|
|
186
|
+
return resp.content
|
|
187
|
+
if resp.status_code == 401:
|
|
188
|
+
raise AuthenticationError("Authentication failed downloading image.")
|
|
189
|
+
if resp.status_code == 429:
|
|
190
|
+
if attempt < MAX_RETRIES - 1:
|
|
191
|
+
time.sleep(RETRY_BACKOFF * (2 ** attempt))
|
|
192
|
+
continue
|
|
193
|
+
raise RateLimitError("Rate limit exceeded downloading image.")
|
|
194
|
+
if resp.status_code >= 500 and attempt < MAX_RETRIES - 1:
|
|
195
|
+
time.sleep(RETRY_BACKOFF)
|
|
196
|
+
continue
|
|
197
|
+
|
|
198
|
+
raise AzureDevOpsError(f"Failed to download image ({resp.status_code}).")
|
|
199
|
+
|
|
200
|
+
raise AzureDevOpsError("Max retries exceeded downloading image.")
|
|
201
|
+
|
|
202
|
+
def clear_cache(self) -> None:
|
|
203
|
+
"""Clear the work item cache."""
|
|
204
|
+
self._cache.clear()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _extract_paths(node: dict, parts: list[str]) -> list[str]:
|
|
208
|
+
"""Recursively extract iteration paths from classification node tree."""
|
|
209
|
+
name = node.get("name", "")
|
|
210
|
+
current_parts = [*parts, name] if name else parts
|
|
211
|
+
path = "\\".join(current_parts)
|
|
212
|
+
paths = [path] if path else []
|
|
213
|
+
for child in node.get("children", []):
|
|
214
|
+
paths.extend(_extract_paths(child, current_parts))
|
|
215
|
+
return paths
|
azwork/api/models.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Data models for Azure DevOps work items."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Comment:
|
|
11
|
+
id: int
|
|
12
|
+
text: str
|
|
13
|
+
author: str
|
|
14
|
+
created_date: str
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def from_api(cls, data: dict) -> Comment:
|
|
18
|
+
author = data.get("createdBy", {}).get("displayName", "Unknown")
|
|
19
|
+
return cls(
|
|
20
|
+
id=data.get("id", 0),
|
|
21
|
+
text=data.get("text", ""),
|
|
22
|
+
author=author,
|
|
23
|
+
created_date=data.get("createdDate", ""),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Relation:
|
|
29
|
+
rel_type: str
|
|
30
|
+
url: str
|
|
31
|
+
target_id: int | None = None
|
|
32
|
+
title: str | None = None
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_api(cls, data: dict) -> Relation:
|
|
36
|
+
url = data.get("url", "")
|
|
37
|
+
# Extract work item ID from URL
|
|
38
|
+
target_id = None
|
|
39
|
+
if "/workItems/" in url:
|
|
40
|
+
try:
|
|
41
|
+
target_id = int(url.rsplit("/", 1)[-1])
|
|
42
|
+
except ValueError:
|
|
43
|
+
pass
|
|
44
|
+
attributes = data.get("attributes", {})
|
|
45
|
+
return cls(
|
|
46
|
+
rel_type=attributes.get("name", data.get("rel", "")),
|
|
47
|
+
url=url,
|
|
48
|
+
target_id=target_id,
|
|
49
|
+
title=None,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class WorkItem:
|
|
55
|
+
id: int
|
|
56
|
+
title: str = ""
|
|
57
|
+
work_item_type: str = ""
|
|
58
|
+
state: str = ""
|
|
59
|
+
area_path: str = ""
|
|
60
|
+
iteration_path: str = ""
|
|
61
|
+
assigned_to: str = ""
|
|
62
|
+
created_date: str = ""
|
|
63
|
+
changed_date: str = ""
|
|
64
|
+
tags: str = ""
|
|
65
|
+
priority: int | None = None
|
|
66
|
+
severity: str = ""
|
|
67
|
+
description: str = ""
|
|
68
|
+
repro_steps: str = ""
|
|
69
|
+
acceptance_criteria: str = ""
|
|
70
|
+
custom_fields: dict[str, str] = field(default_factory=dict)
|
|
71
|
+
relations: list[Relation] = field(default_factory=list)
|
|
72
|
+
comments: list[Comment] = field(default_factory=list)
|
|
73
|
+
url: str = ""
|
|
74
|
+
|
|
75
|
+
# Standard field reference names
|
|
76
|
+
STANDARD_FIELDS: set[str] = field(
|
|
77
|
+
default_factory=lambda: {
|
|
78
|
+
"System.Id",
|
|
79
|
+
"System.Title",
|
|
80
|
+
"System.Description",
|
|
81
|
+
"System.State",
|
|
82
|
+
"System.WorkItemType",
|
|
83
|
+
"System.AreaPath",
|
|
84
|
+
"System.IterationPath",
|
|
85
|
+
"System.AssignedTo",
|
|
86
|
+
"System.CreatedDate",
|
|
87
|
+
"System.ChangedDate",
|
|
88
|
+
"System.Tags",
|
|
89
|
+
"System.Reason",
|
|
90
|
+
"System.Rev",
|
|
91
|
+
"System.TeamProject",
|
|
92
|
+
"System.BoardColumn",
|
|
93
|
+
"System.BoardColumnDone",
|
|
94
|
+
"System.CommentCount",
|
|
95
|
+
"System.History",
|
|
96
|
+
"System.Watermark",
|
|
97
|
+
"System.AuthorizedAs",
|
|
98
|
+
"System.NodeName",
|
|
99
|
+
"System.RelatedLinkCount",
|
|
100
|
+
"System.ExternalLinkCount",
|
|
101
|
+
"System.HyperLinkCount",
|
|
102
|
+
"System.AttachedFileCount",
|
|
103
|
+
"System.AuthorizedDate",
|
|
104
|
+
"System.RevisedDate",
|
|
105
|
+
"System.PersonId",
|
|
106
|
+
"System.CreatedBy",
|
|
107
|
+
"System.ChangedBy",
|
|
108
|
+
"Microsoft.VSTS.Common.Priority",
|
|
109
|
+
"Microsoft.VSTS.Common.Severity",
|
|
110
|
+
"Microsoft.VSTS.Common.StateChangeDate",
|
|
111
|
+
"Microsoft.VSTS.Common.ActivatedDate",
|
|
112
|
+
"Microsoft.VSTS.Common.ActivatedBy",
|
|
113
|
+
"Microsoft.VSTS.Common.ResolvedDate",
|
|
114
|
+
"Microsoft.VSTS.Common.ResolvedBy",
|
|
115
|
+
"Microsoft.VSTS.Common.ResolvedReason",
|
|
116
|
+
"Microsoft.VSTS.Common.ClosedDate",
|
|
117
|
+
"Microsoft.VSTS.Common.ClosedBy",
|
|
118
|
+
"Microsoft.VSTS.Common.ValueArea",
|
|
119
|
+
"Microsoft.VSTS.Common.StackRank",
|
|
120
|
+
"Microsoft.VSTS.TCM.ReproSteps",
|
|
121
|
+
"Microsoft.VSTS.Common.AcceptanceCriteria",
|
|
122
|
+
"Microsoft.VSTS.Scheduling.StoryPoints",
|
|
123
|
+
"Microsoft.VSTS.Scheduling.Effort",
|
|
124
|
+
"Microsoft.VSTS.Scheduling.RemainingWork",
|
|
125
|
+
"Microsoft.VSTS.Scheduling.OriginalEstimate",
|
|
126
|
+
"Microsoft.VSTS.Scheduling.CompletedWork",
|
|
127
|
+
"WEF_",
|
|
128
|
+
},
|
|
129
|
+
repr=False,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
@classmethod
|
|
133
|
+
def from_api(cls, data: dict) -> WorkItem:
|
|
134
|
+
fields = data.get("fields", {})
|
|
135
|
+
assigned_to = fields.get("System.AssignedTo", {})
|
|
136
|
+
if isinstance(assigned_to, dict):
|
|
137
|
+
assigned_to = assigned_to.get("displayName", "")
|
|
138
|
+
|
|
139
|
+
item = cls(
|
|
140
|
+
id=data.get("id", fields.get("System.Id", 0)),
|
|
141
|
+
title=fields.get("System.Title", ""),
|
|
142
|
+
work_item_type=fields.get("System.WorkItemType", ""),
|
|
143
|
+
state=fields.get("System.State", ""),
|
|
144
|
+
area_path=fields.get("System.AreaPath", ""),
|
|
145
|
+
iteration_path=fields.get("System.IterationPath", ""),
|
|
146
|
+
assigned_to=assigned_to,
|
|
147
|
+
created_date=_format_date(fields.get("System.CreatedDate", "")),
|
|
148
|
+
changed_date=_format_date(fields.get("System.ChangedDate", "")),
|
|
149
|
+
tags=fields.get("System.Tags", ""),
|
|
150
|
+
priority=fields.get("Microsoft.VSTS.Common.Priority"),
|
|
151
|
+
severity=fields.get("Microsoft.VSTS.Common.Severity", ""),
|
|
152
|
+
description=fields.get("System.Description", "") or "",
|
|
153
|
+
repro_steps=fields.get("Microsoft.VSTS.TCM.ReproSteps", "") or "",
|
|
154
|
+
acceptance_criteria=fields.get("Microsoft.VSTS.Common.AcceptanceCriteria", "") or "",
|
|
155
|
+
url=data.get("_links", {}).get("html", {}).get("href", ""),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# Extract custom fields
|
|
159
|
+
standard = item.STANDARD_FIELDS
|
|
160
|
+
for key, value in fields.items():
|
|
161
|
+
if value is None or value == "":
|
|
162
|
+
continue
|
|
163
|
+
is_standard = any(key.startswith(prefix) for prefix in ("System.", "Microsoft.VSTS."))
|
|
164
|
+
is_wef = key.startswith("WEF_")
|
|
165
|
+
if not is_standard and not is_wef:
|
|
166
|
+
item.custom_fields[key] = str(value)
|
|
167
|
+
|
|
168
|
+
# Extract relations
|
|
169
|
+
for rel_data in data.get("relations", []) or []:
|
|
170
|
+
item.relations.append(Relation.from_api(rel_data))
|
|
171
|
+
|
|
172
|
+
return item
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _format_date(date_str: str) -> str:
|
|
176
|
+
if not date_str:
|
|
177
|
+
return ""
|
|
178
|
+
try:
|
|
179
|
+
dt = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
|
|
180
|
+
return dt.strftime("%Y-%m-%d")
|
|
181
|
+
except (ValueError, TypeError):
|
|
182
|
+
return date_str
|
azwork/api/wiql.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""WIQL query builder for Azure DevOps."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def build_wiql(
|
|
7
|
+
project: str,
|
|
8
|
+
work_item_types: list[str] | None = None,
|
|
9
|
+
states: list[str] | None = None,
|
|
10
|
+
iteration_path: str | None = None,
|
|
11
|
+
area_path: str | None = None,
|
|
12
|
+
assigned_to: str | None = None,
|
|
13
|
+
title_contains: str | None = None,
|
|
14
|
+
order_by: str = "System.ChangedDate",
|
|
15
|
+
order_dir: str = "DESC",
|
|
16
|
+
) -> str:
|
|
17
|
+
"""Build a WIQL query string with optional filters."""
|
|
18
|
+
conditions: list[str] = [
|
|
19
|
+
f"[System.TeamProject] = '{_escape(project)}'"
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
if work_item_types:
|
|
23
|
+
type_list = ", ".join(f"'{_escape(t)}'" for t in work_item_types)
|
|
24
|
+
conditions.append(f"[System.WorkItemType] IN ({type_list})")
|
|
25
|
+
|
|
26
|
+
if states:
|
|
27
|
+
state_list = ", ".join(f"'{_escape(s)}'" for s in states)
|
|
28
|
+
conditions.append(f"[System.State] IN ({state_list})")
|
|
29
|
+
|
|
30
|
+
if iteration_path:
|
|
31
|
+
conditions.append(
|
|
32
|
+
f"[System.IterationPath] UNDER '{_escape(iteration_path)}'"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
if area_path:
|
|
36
|
+
conditions.append(
|
|
37
|
+
f"[System.AreaPath] UNDER '{_escape(area_path)}'"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
if assigned_to:
|
|
41
|
+
conditions.append(
|
|
42
|
+
f"[System.AssignedTo] = '{_escape(assigned_to)}'"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
if title_contains:
|
|
46
|
+
conditions.append(
|
|
47
|
+
f"[System.Title] CONTAINS '{_escape(title_contains)}'"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
where_clause = " AND ".join(conditions)
|
|
51
|
+
query = (
|
|
52
|
+
f"SELECT [System.Id] FROM WorkItems "
|
|
53
|
+
f"WHERE {where_clause} "
|
|
54
|
+
f"ORDER BY [{order_by}] {order_dir}"
|
|
55
|
+
)
|
|
56
|
+
return query
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _escape(value: str) -> str:
|
|
60
|
+
"""Escape single quotes in WIQL values."""
|
|
61
|
+
return value.replace("'", "''")
|
azwork/config.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Configuration loading from ~/.azwork.yml and CLI args."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
CONFIG_PATH = Path.home() / ".azwork.yml"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Config:
|
|
17
|
+
org: str = ""
|
|
18
|
+
project: str = ""
|
|
19
|
+
default_output_dir: str = "./bugs"
|
|
20
|
+
work_item_types: list[str] = field(default_factory=lambda: ["Bug", "Task", "User Story"])
|
|
21
|
+
pat: str = ""
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def load(cls, cli_org: str | None = None, cli_project: str | None = None,
|
|
25
|
+
cli_output_dir: str | None = None) -> Config:
|
|
26
|
+
cfg = cls()
|
|
27
|
+
|
|
28
|
+
# Load from YAML if exists
|
|
29
|
+
if CONFIG_PATH.exists():
|
|
30
|
+
try:
|
|
31
|
+
with open(CONFIG_PATH) as f:
|
|
32
|
+
data = yaml.safe_load(f) or {}
|
|
33
|
+
cfg.org = data.get("org", "")
|
|
34
|
+
cfg.project = data.get("project", "")
|
|
35
|
+
cfg.default_output_dir = data.get("default_output_dir", "./bugs")
|
|
36
|
+
cfg.work_item_types = data.get("work_item_types", cfg.work_item_types)
|
|
37
|
+
except Exception:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
# CLI overrides
|
|
41
|
+
if cli_org:
|
|
42
|
+
cfg.org = cli_org
|
|
43
|
+
if cli_project:
|
|
44
|
+
cfg.project = cli_project
|
|
45
|
+
if cli_output_dir:
|
|
46
|
+
cfg.default_output_dir = cli_output_dir
|
|
47
|
+
|
|
48
|
+
# PAT from environment
|
|
49
|
+
cfg.pat = os.environ.get("AZURE_DEVOPS_PAT", "")
|
|
50
|
+
|
|
51
|
+
return cfg
|
|
52
|
+
|
|
53
|
+
def save_default(self) -> None:
|
|
54
|
+
data = {
|
|
55
|
+
"org": self.org,
|
|
56
|
+
"project": self.project,
|
|
57
|
+
"default_output_dir": self.default_output_dir,
|
|
58
|
+
"work_item_types": self.work_item_types,
|
|
59
|
+
}
|
|
60
|
+
with open(CONFIG_PATH, "w") as f:
|
|
61
|
+
yaml.dump(data, f, default_flow_style=False)
|
|
62
|
+
|
|
63
|
+
def validate(self) -> list[str]:
|
|
64
|
+
errors = []
|
|
65
|
+
if not self.pat:
|
|
66
|
+
errors.append(
|
|
67
|
+
"AZURE_DEVOPS_PAT environment variable is not set.\n"
|
|
68
|
+
"Create a PAT at https://dev.azure.com/{org}/_usersSettings/tokens\n"
|
|
69
|
+
"Required scope: Work Items → Read"
|
|
70
|
+
)
|
|
71
|
+
if not self.org:
|
|
72
|
+
errors.append("Organization not set. Use --org or set 'org' in ~/.azwork.yml")
|
|
73
|
+
if not self.project:
|
|
74
|
+
errors.append("Project not set. Use --project or set 'project' in ~/.azwork.yml")
|
|
75
|
+
return errors
|
|
File without changes
|