ado-workflows 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.
@@ -0,0 +1,99 @@
1
+ """ado-workflows: Azure DevOps workflow automation library.
2
+
3
+ Three-layer API for Azure DevOps operations:
4
+ - Layer 1 — Primitives: pure functions (URL parsing, git inspection, date parsing)
5
+ - Layer 2 — Context: stateful caching (RepositoryContext, thread-safe)
6
+ - Layer 3 — PR Context: composed workflows (AzureDevOpsPRContext)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from ado_workflows.auth import AZURE_DEVOPS_RESOURCE_ID, ConnectionFactory
12
+ from ado_workflows.client import AdoClient
13
+ from ado_workflows.comments import (
14
+ analyze_pr_comments,
15
+ post_comment,
16
+ reply_to_comment,
17
+ resolve_comments,
18
+ sanitize_ado_response,
19
+ )
20
+ from ado_workflows.context import (
21
+ RepositoryContext,
22
+ clear_repository_context,
23
+ get_context_status,
24
+ get_repository_context,
25
+ set_repository_context,
26
+ )
27
+ from ado_workflows.discovery import (
28
+ discover_repositories,
29
+ infer_target_repository,
30
+ inspect_git_repository,
31
+ )
32
+ from ado_workflows.lifecycle import create_pull_request
33
+ from ado_workflows.models import (
34
+ VOTE_TEXT,
35
+ ApprovalStatus,
36
+ AuthorSample,
37
+ CommentAnalysis,
38
+ CommentInfo,
39
+ CommentSummary,
40
+ CreatedPR,
41
+ PendingPR,
42
+ PendingReviewResult,
43
+ ResolveResult,
44
+ ReviewerInfo,
45
+ ReviewStatus,
46
+ VoteStatus,
47
+ )
48
+ from ado_workflows.parsing import parse_ado_date, parse_ado_url
49
+ from ado_workflows.pr import AzureDevOpsPRContext, establish_pr_context
50
+ from ado_workflows.review import (
51
+ analyze_pending_reviews,
52
+ fetch_required_approvals,
53
+ fetch_vote_timestamps,
54
+ get_review_status,
55
+ )
56
+ from ado_workflows.votes import deduplicate_team_containers, determine_vote_status
57
+
58
+ __all__: list[str] = [
59
+ "AZURE_DEVOPS_RESOURCE_ID",
60
+ "VOTE_TEXT",
61
+ "AdoClient",
62
+ "ApprovalStatus",
63
+ "AuthorSample",
64
+ "AzureDevOpsPRContext",
65
+ "CommentAnalysis",
66
+ "CommentInfo",
67
+ "CommentSummary",
68
+ "ConnectionFactory",
69
+ "CreatedPR",
70
+ "PendingPR",
71
+ "PendingReviewResult",
72
+ "RepositoryContext",
73
+ "ResolveResult",
74
+ "ReviewStatus",
75
+ "ReviewerInfo",
76
+ "VoteStatus",
77
+ "analyze_pending_reviews",
78
+ "analyze_pr_comments",
79
+ "clear_repository_context",
80
+ "create_pull_request",
81
+ "deduplicate_team_containers",
82
+ "determine_vote_status",
83
+ "discover_repositories",
84
+ "establish_pr_context",
85
+ "fetch_required_approvals",
86
+ "fetch_vote_timestamps",
87
+ "get_context_status",
88
+ "get_repository_context",
89
+ "get_review_status",
90
+ "infer_target_repository",
91
+ "inspect_git_repository",
92
+ "parse_ado_date",
93
+ "parse_ado_url",
94
+ "post_comment",
95
+ "reply_to_comment",
96
+ "resolve_comments",
97
+ "sanitize_ado_response",
98
+ "set_repository_context",
99
+ ]
ado_workflows/auth.py ADDED
@@ -0,0 +1,83 @@
1
+ """Azure DevOps authentication — DefaultAzureCredential → Connection bridge.
2
+
3
+ Bridges azure-identity's ``DefaultAzureCredential`` into the azure-devops SDK's
4
+ msrest-based auth layer. ``ConnectionFactory`` handles per-org connection caching
5
+ and proactive token refresh before expiry.
6
+
7
+ Typical usage::
8
+
9
+ factory = ConnectionFactory() # uses DefaultAzureCredential
10
+ connection = factory.get_connection("https://dev.azure.com/MyOrg")
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import time
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ from azure.devops.connection import Connection
19
+ from azure.identity import DefaultAzureCredential
20
+ from msrest.authentication import BasicTokenAuthentication
21
+
22
+ if TYPE_CHECKING:
23
+ from azure.core.credentials import TokenCredential
24
+
25
+ AZURE_DEVOPS_RESOURCE_ID: str = "499b84ac-1321-427f-aa17-267ca6975798"
26
+ """Well-known Azure DevOps resource identifier for token acquisition."""
27
+
28
+ _SCOPE: str = f"{AZURE_DEVOPS_RESOURCE_ID}/.default"
29
+ _TOKEN_REFRESH_BUFFER_SECONDS: int = 300 # refresh 5 min before expiry
30
+
31
+
32
+ class ConnectionFactory:
33
+ """Creates and caches Azure DevOps SDK connections per organization URL.
34
+
35
+ Bridges ``azure-identity``'s :class:`DefaultAzureCredential` into the
36
+ azure-devops SDK's msrest-based auth layer, handling token refresh and
37
+ per-org connection caching.
38
+
39
+ Parameters
40
+ ----------
41
+ credential:
42
+ Optional :class:`TokenCredential` for dependency injection / testing.
43
+ Defaults to :class:`DefaultAzureCredential` when *None*.
44
+ """
45
+
46
+ def __init__(self, credential: TokenCredential | None = None) -> None:
47
+ self._credential: Any = credential or DefaultAzureCredential()
48
+ self._connections: dict[str, Connection] = {}
49
+ self._token_expiry: dict[str, float] = {}
50
+
51
+ def get_connection(self, org_url: str) -> Connection:
52
+ """Get or create a cached connection for *org_url*.
53
+
54
+ Returns a cached :class:`Connection` if the token is still valid
55
+ (more than 5 minutes until expiry). Otherwise acquires a fresh
56
+ token and creates a new connection.
57
+ """
58
+ key = _normalize_org_url(org_url)
59
+ now = time.time()
60
+
61
+ if key in self._connections:
62
+ expiry = self._token_expiry.get(key, 0.0)
63
+ if now < expiry - _TOKEN_REFRESH_BUFFER_SECONDS:
64
+ return self._connections[key]
65
+
66
+ token = self._credential.get_token(_SCOPE)
67
+ creds = BasicTokenAuthentication({"access_token": token.token})
68
+ connection = Connection(base_url=key, creds=creds)
69
+
70
+ self._connections[key] = connection
71
+ self._token_expiry[key] = token.expires_on
72
+
73
+ return connection
74
+
75
+ def clear_cache(self) -> None:
76
+ """Remove all cached connections and token expiry records."""
77
+ self._connections.clear()
78
+ self._token_expiry.clear()
79
+
80
+
81
+ def _normalize_org_url(org_url: str) -> str:
82
+ """Normalize *org_url* to a consistent form for cache-key usage."""
83
+ return org_url.rstrip("/")
@@ -0,0 +1,76 @@
1
+ """Typed accessors for Azure DevOps SDK clients.
2
+
3
+ Wraps :meth:`Connection.get_client` with lazy, cached properties that
4
+ provide type-safe access to the Git, Core, and Work Item Tracking clients.
5
+
6
+ Typical usage::
7
+
8
+ from ado_workflows.auth import ConnectionFactory
9
+ from ado_workflows.client import AdoClient
10
+
11
+ factory = ConnectionFactory()
12
+ connection = factory.get_connection("https://dev.azure.com/MyOrg")
13
+ client = AdoClient(connection)
14
+
15
+ repos = client.git.get_repositories("MyProject")
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from functools import cached_property
21
+ from typing import TYPE_CHECKING, Any
22
+
23
+ if TYPE_CHECKING:
24
+ from azure.devops.connection import Connection
25
+ from azure.devops.v7_1.core.core_client import CoreClient
26
+ from azure.devops.v7_1.git.git_client import GitClient
27
+ from azure.devops.v7_1.policy.policy_client import PolicyClient
28
+ from azure.devops.v7_1.work_item_tracking.work_item_tracking_client import (
29
+ WorkItemTrackingClient,
30
+ )
31
+
32
+ _GIT_CLIENT_PATH = "azure.devops.v7_1.git.git_client.GitClient"
33
+ _CORE_CLIENT_PATH = "azure.devops.v7_1.core.core_client.CoreClient"
34
+ _POLICY_CLIENT_PATH = "azure.devops.v7_1.policy.policy_client.PolicyClient"
35
+ _WIT_CLIENT_PATH = (
36
+ "azure.devops.v7_1.work_item_tracking"
37
+ ".work_item_tracking_client.WorkItemTrackingClient"
38
+ )
39
+
40
+
41
+ class AdoClient:
42
+ """Typed, lazy accessor for Azure DevOps SDK clients.
43
+
44
+ Each client property is initialized on first access and cached for
45
+ the lifetime of this instance. If the underlying connection's token
46
+ expires, create a new ``AdoClient`` from a fresh connection.
47
+
48
+ Parameters
49
+ ----------
50
+ connection:
51
+ An authenticated :class:`Connection` (typically from
52
+ :meth:`ConnectionFactory.get_connection`).
53
+ """
54
+
55
+ def __init__(self, connection: Connection) -> None:
56
+ self._connection: Any = connection
57
+
58
+ @cached_property
59
+ def git(self) -> GitClient:
60
+ """Git operations: repositories, pull requests, threads, commits."""
61
+ return self._connection.get_client(_GIT_CLIENT_PATH)
62
+
63
+ @cached_property
64
+ def core(self) -> CoreClient:
65
+ """Core operations: projects, teams."""
66
+ return self._connection.get_client(_CORE_CLIENT_PATH)
67
+
68
+ @cached_property
69
+ def policy(self) -> PolicyClient:
70
+ """Policy operations: evaluations, configurations, types."""
71
+ return self._connection.get_client(_POLICY_CLIENT_PATH)
72
+
73
+ @cached_property
74
+ def work_items(self) -> WorkItemTrackingClient:
75
+ """Work item operations: queries, work items."""
76
+ return self._connection.get_client(_WIT_CLIENT_PATH)
@@ -0,0 +1,346 @@
1
+ """Comment analysis, response sanitization, and write operations for Azure DevOps PRs.
2
+
3
+ Provides :func:`sanitize_ado_response` (pure utility for Windows-1252
4
+ smart-quote fix), :func:`analyze_pr_comments` (SDK-based thread
5
+ analysis returning a typed :class:`~models.CommentAnalysis`),
6
+ :func:`post_comment`, :func:`reply_to_comment`, and
7
+ :func:`resolve_comments` (SDK-based write operations).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ from actionable_errors import ActionableError
15
+
16
+ from ado_workflows.models import (
17
+ AuthorSample,
18
+ CommentAnalysis,
19
+ CommentInfo,
20
+ CommentSummary,
21
+ ResolveResult,
22
+ )
23
+
24
+ if TYPE_CHECKING:
25
+ from ado_workflows.client import AdoClient
26
+
27
+ from azure.devops.v7_1.git.models import (
28
+ Comment,
29
+ GitPullRequestCommentThread,
30
+ )
31
+
32
+
33
+ def sanitize_ado_response(raw_data: bytes | str) -> str:
34
+ """Fix Windows-1252 smart-quote corruption in ADO responses.
35
+
36
+ Azure DevOps REST API sometimes returns Windows-1252 smart quotes
37
+ (0x91-0x94) which are invalid UTF-8. This function replaces those
38
+ bytes with their UTF-8 equivalents before decoding.
39
+
40
+ String inputs pass through unchanged.
41
+
42
+ Args:
43
+ raw_data: Raw bytes or string from an ADO API response.
44
+
45
+ Returns:
46
+ A properly decoded UTF-8 string.
47
+ """
48
+ if isinstance(raw_data, str):
49
+ return raw_data
50
+
51
+ raw_bytes: bytes = bytes(raw_data) if not isinstance(raw_data, bytes) else raw_data
52
+
53
+ # Windows-1252 → UTF-8 smart quote replacements
54
+ # 0x91 = left single quote (') → U+2018
55
+ # 0x92 = right single quote (') → U+2019
56
+ # 0x93 = left double quote (") → U+201C
57
+ # 0x94 = right double quote (") → U+201D
58
+ sanitized = raw_bytes.replace(b"\x91", b"\xe2\x80\x98")
59
+ sanitized = sanitized.replace(b"\x92", b"\xe2\x80\x99")
60
+ sanitized = sanitized.replace(b"\x93", b"\xe2\x80\x9c")
61
+ sanitized = sanitized.replace(b"\x94", b"\xe2\x80\x9d")
62
+
63
+ return sanitized.decode("utf-8")
64
+
65
+
66
+ def analyze_pr_comments(
67
+ client: AdoClient,
68
+ pr_id: int,
69
+ project: str,
70
+ repository: str,
71
+ ) -> CommentAnalysis:
72
+ """Analyze all comment threads on a pull request.
73
+
74
+ Fetches threads via ``client.git.get_threads()``, categorizes by
75
+ status, extracts author statistics, and identifies active (unresolved)
76
+ comments. Applies :func:`sanitize_ado_response` to comment content
77
+ strings.
78
+
79
+ Args:
80
+ client: An authenticated :class:`~client.AdoClient`.
81
+ pr_id: Pull request ID.
82
+ project: Azure DevOps project name or GUID.
83
+ repository: Repository name or GUID.
84
+
85
+ Returns:
86
+ A :class:`~models.CommentAnalysis` with thread statistics,
87
+ author breakdowns, and active comments.
88
+ """
89
+ threads: list[Any] = client.git.get_threads(repository, pr_id, project=project)
90
+
91
+ # Categorize threads by status
92
+ active_threads = [t for t in threads if t.status == "active"]
93
+ fixed_threads = [t for t in threads if t.status == "fixed"]
94
+ total_threads = len(threads)
95
+
96
+ active_percentage = (
97
+ round(len(active_threads) / total_threads * 100, 1)
98
+ if total_threads > 0
99
+ else 0.0
100
+ )
101
+
102
+ # Analyze comments: authors, content, file context
103
+ comment_authors: dict[str, int] = {}
104
+ all_comments: list[CommentInfo] = []
105
+ active_comments: list[CommentInfo] = []
106
+
107
+ for thread in threads:
108
+ # Extract file context from thread_context
109
+ file_path: str | None = None
110
+ line_start: int | None = None
111
+ line_end: int | None = None
112
+
113
+ thread_context = thread.thread_context
114
+ if thread_context is not None:
115
+ file_path = thread_context.file_path
116
+
117
+ # Prefer right (new) side, fall back to left (old) side
118
+ if thread_context.right_file_start is not None:
119
+ line_start = thread_context.right_file_start.line
120
+ if line_start is None and thread_context.left_file_start is not None:
121
+ line_start = thread_context.left_file_start.line
122
+
123
+ if thread_context.right_file_end is not None:
124
+ line_end = thread_context.right_file_end.line
125
+ if line_end is None and thread_context.left_file_end is not None:
126
+ line_end = thread_context.left_file_end.line
127
+
128
+ for comment in thread.comments or []:
129
+ author_name: str = comment.author.display_name
130
+ comment_authors[author_name] = comment_authors.get(author_name, 0) + 1
131
+
132
+ content: str = sanitize_ado_response(comment.content or "")
133
+ preview = (
134
+ content[:200] + "..." if len(content) > 200 else content
135
+ )
136
+
137
+ info = CommentInfo(
138
+ thread_id=thread.id,
139
+ thread_status=thread.status or "unknown",
140
+ author=author_name,
141
+ content_preview=preview,
142
+ full_content=content,
143
+ created_date=comment.published_date,
144
+ is_deleted=comment.is_deleted or False,
145
+ file_path=file_path,
146
+ line_start=line_start,
147
+ line_end=line_end,
148
+ )
149
+ all_comments.append(info)
150
+ if thread.status == "active":
151
+ active_comments.append(info)
152
+
153
+ # Build author samples (latest non-deleted comment per author)
154
+ author_samples: dict[str, AuthorSample] = {}
155
+ for author_name in comment_authors:
156
+ author_comments = [
157
+ c for c in all_comments
158
+ if c.author == author_name and not c.is_deleted
159
+ ]
160
+ if author_comments:
161
+ latest = author_comments[-1]
162
+ author_samples[author_name] = AuthorSample(
163
+ count=comment_authors[author_name],
164
+ latest_comment=latest.content_preview,
165
+ latest_status=latest.thread_status,
166
+ )
167
+
168
+ return CommentAnalysis(
169
+ pr_id=pr_id,
170
+ comment_summary=CommentSummary(
171
+ total_threads=total_threads,
172
+ active_threads=len(active_threads),
173
+ fixed_threads=len(fixed_threads),
174
+ active_percentage=active_percentage,
175
+ ),
176
+ comment_authors=comment_authors,
177
+ author_samples=author_samples,
178
+ active_comments=active_comments,
179
+ resolution_ready=len(active_threads) == 0,
180
+ )
181
+
182
+
183
+ # ---------------------------------------------------------------------------
184
+ # Phase 6d — Comment write operations
185
+ # ---------------------------------------------------------------------------
186
+
187
+
188
+ def post_comment(
189
+ client: AdoClient,
190
+ repository: str,
191
+ pr_id: int,
192
+ content: str,
193
+ project: str,
194
+ *,
195
+ status: str = "active",
196
+ ) -> int:
197
+ """Create a new comment thread on a pull request.
198
+
199
+ Args:
200
+ client: An authenticated :class:`~client.AdoClient`.
201
+ repository: Repository name or GUID.
202
+ pr_id: Pull request ID.
203
+ content: Comment body text (must not be empty).
204
+ project: Azure DevOps project name or GUID.
205
+ status: Thread status (default ``"active"``).
206
+
207
+ Returns:
208
+ The new thread ID.
209
+
210
+ Raises:
211
+ ActionableError: When *content* is empty/whitespace or the SDK fails.
212
+ """
213
+ if not content or not content.strip():
214
+ raise ActionableError.validation(
215
+ service="AzureDevOps",
216
+ field_name="content",
217
+ reason="Cannot post a comment with empty content.",
218
+ )
219
+
220
+ thread = GitPullRequestCommentThread(
221
+ comments=[Comment(content=content)],
222
+ status=status,
223
+ )
224
+
225
+ try:
226
+ response = client.git.create_thread(thread, repository, pr_id, project=project)
227
+ except Exception as exc:
228
+ raise ActionableError.connection(
229
+ service="AzureDevOps",
230
+ url=f"{repository}/pullrequests/{pr_id}/threads",
231
+ raw_error=str(exc),
232
+ ) from exc
233
+
234
+ return int(response.id)
235
+
236
+
237
+ def reply_to_comment(
238
+ client: AdoClient,
239
+ repository: str,
240
+ pr_id: int,
241
+ thread_id: int,
242
+ content: str,
243
+ project: str,
244
+ ) -> int:
245
+ """Add a reply to an existing comment thread.
246
+
247
+ Args:
248
+ client: An authenticated :class:`~client.AdoClient`.
249
+ repository: Repository name or GUID.
250
+ pr_id: Pull request ID.
251
+ thread_id: Existing thread ID to reply to.
252
+ content: Reply body text (must not be empty).
253
+ project: Azure DevOps project name or GUID.
254
+
255
+ Returns:
256
+ The new comment ID.
257
+
258
+ Raises:
259
+ ActionableError: When *content* is empty/whitespace or the SDK fails.
260
+ """
261
+ if not content or not content.strip():
262
+ raise ActionableError.validation(
263
+ service="AzureDevOps",
264
+ field_name="content",
265
+ reason="Cannot reply with empty content.",
266
+ )
267
+
268
+ comment = Comment(content=content, parent_comment_id=1)
269
+
270
+ try:
271
+ response = client.git.create_comment(
272
+ comment, repository, pr_id, thread_id, project=project,
273
+ )
274
+ except Exception as exc:
275
+ raise ActionableError.connection(
276
+ service="AzureDevOps",
277
+ url=f"{repository}/pullrequests/{pr_id}/threads/{thread_id}/comments",
278
+ raw_error=str(exc),
279
+ ) from exc
280
+
281
+ return int(response.id)
282
+
283
+
284
+ def resolve_comments(
285
+ client: AdoClient,
286
+ repository: str,
287
+ pr_id: int,
288
+ thread_ids: list[int],
289
+ project: str,
290
+ *,
291
+ status: str = "fixed",
292
+ ) -> ResolveResult:
293
+ """Batch-resolve comment threads with partial-success reporting.
294
+
295
+ Iterates *thread_ids* and calls ``client.git.update_thread()`` for
296
+ each. Threads already in the target status are skipped. Individual
297
+ failures are collected — the function never raises on a single thread
298
+ failure.
299
+
300
+ Args:
301
+ client: An authenticated :class:`~client.AdoClient`.
302
+ repository: Repository name or GUID.
303
+ pr_id: Pull request ID.
304
+ thread_ids: Thread IDs to resolve.
305
+ project: Azure DevOps project name or GUID.
306
+ status: Target thread status (default ``"fixed"``).
307
+
308
+ Returns:
309
+ A :class:`~models.ResolveResult` partitioning threads into
310
+ *resolved*, *errors*, and *skipped*.
311
+ """
312
+ resolved: list[int] = []
313
+ errors: list[ActionableError] = []
314
+ skipped: list[int] = []
315
+
316
+ if not thread_ids:
317
+ return ResolveResult(resolved=resolved, errors=errors, skipped=skipped)
318
+
319
+ # Fetch current thread statuses for skip detection
320
+ all_threads: list[Any] = client.git.get_threads(
321
+ repository, pr_id, project=project,
322
+ )
323
+ current_status: dict[int, str] = {t.id: t.status for t in all_threads}
324
+
325
+ for tid in thread_ids:
326
+ # Skip threads already in the target status
327
+ if current_status.get(tid) == status:
328
+ skipped.append(tid)
329
+ continue
330
+
331
+ thread_update = GitPullRequestCommentThread(status=status)
332
+ try:
333
+ client.git.update_thread(
334
+ thread_update, repository, pr_id, tid, project=project,
335
+ )
336
+ resolved.append(tid)
337
+ except Exception as exc:
338
+ err = ActionableError.internal(
339
+ service="AzureDevOps",
340
+ operation=f"resolve_thread({tid})",
341
+ raw_error=str(exc),
342
+ )
343
+ err.context = {"thread_id": tid}
344
+ errors.append(err)
345
+
346
+ return ResolveResult(resolved=resolved, errors=errors, skipped=skipped)