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 ADDED
@@ -0,0 +1,3 @@
1
+ """azwork — Azure DevOps Work Items TUI."""
2
+
3
+ __version__ = "0.1.0"
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