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.
- ado_workflows/__init__.py +99 -0
- ado_workflows/auth.py +83 -0
- ado_workflows/client.py +76 -0
- ado_workflows/comments.py +346 -0
- ado_workflows/context.py +258 -0
- ado_workflows/discovery.py +156 -0
- ado_workflows/lifecycle.py +92 -0
- ado_workflows/models.py +217 -0
- ado_workflows/parsing.py +135 -0
- ado_workflows/pr.py +201 -0
- ado_workflows/py.typed +0 -0
- ado_workflows/review.py +577 -0
- ado_workflows/votes.py +112 -0
- ado_workflows-0.1.0.dist-info/METADATA +176 -0
- ado_workflows-0.1.0.dist-info/RECORD +17 -0
- ado_workflows-0.1.0.dist-info/WHEEL +4 -0
- ado_workflows-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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("/")
|
ado_workflows/client.py
ADDED
|
@@ -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)
|