devops-mcp 1.0.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.
- devops_mcp/__init__.py +1 -0
- devops_mcp/_app.py +40 -0
- devops_mcp/client.py +893 -0
- devops_mcp/models.py +954 -0
- devops_mcp/server.py +30 -0
- devops_mcp/tools/__init__.py +1 -0
- devops_mcp/tools/pipelines.py +412 -0
- devops_mcp/tools/pull_requests.py +1038 -0
- devops_mcp/tools/repositories.py +182 -0
- devops_mcp/tools/work_items.py +495 -0
- devops_mcp-1.0.0.dist-info/METADATA +319 -0
- devops_mcp-1.0.0.dist-info/RECORD +16 -0
- devops_mcp-1.0.0.dist-info/WHEEL +5 -0
- devops_mcp-1.0.0.dist-info/entry_points.txt +2 -0
- devops_mcp-1.0.0.dist-info/licenses/LICENSE +21 -0
- devops_mcp-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1038 @@
|
|
|
1
|
+
"""Pull request tools for Azure DevOps MCP."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from mcp.server.fastmcp import Context
|
|
9
|
+
|
|
10
|
+
from devops_mcp._app import mcp, write_tool
|
|
11
|
+
from devops_mcp.client import (
|
|
12
|
+
AppContext,
|
|
13
|
+
build_headers,
|
|
14
|
+
build_url,
|
|
15
|
+
extract_error_message,
|
|
16
|
+
finalize_response,
|
|
17
|
+
request_with_retry,
|
|
18
|
+
resolve_org,
|
|
19
|
+
resolve_project,
|
|
20
|
+
)
|
|
21
|
+
from devops_mcp.models import (
|
|
22
|
+
AddPullRequestCommentInput,
|
|
23
|
+
CreatePullRequestInput,
|
|
24
|
+
CreatePullRequestThreadInput,
|
|
25
|
+
GetPullRequestChangesInput,
|
|
26
|
+
GetPullRequestInput,
|
|
27
|
+
GetPullRequestThreadInput,
|
|
28
|
+
LinkWorkItemsToPullRequestInput,
|
|
29
|
+
ListPullRequestIterationsInput,
|
|
30
|
+
ListPullRequestsInput,
|
|
31
|
+
ListPullRequestThreadsInput,
|
|
32
|
+
SetPullRequestThreadStatusInput,
|
|
33
|
+
TagPullRequestInput,
|
|
34
|
+
UpdatePullRequestCommentInput,
|
|
35
|
+
UpdatePullRequestInput,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
_PR_API_VERSION = "7.2-preview.2"
|
|
41
|
+
_WIT_API_VERSION = "7.2-preview.3"
|
|
42
|
+
_THREAD_API_VERSION = "7.1"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _build_pull_request_artifact_uri(
|
|
46
|
+
project_id: str, repository_id: str, pull_request_id: int
|
|
47
|
+
) -> str:
|
|
48
|
+
"""Build the Azure DevOps artifact URI for a pull request work item link."""
|
|
49
|
+
artifact_key = quote(f"{project_id}/{repository_id}/{pull_request_id}", safe="")
|
|
50
|
+
return f"vstfs:///Git/PullRequestId/{artifact_key}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@mcp.tool(
|
|
54
|
+
name="devops_get_pull_request",
|
|
55
|
+
annotations={
|
|
56
|
+
"title": "Get Pull Request",
|
|
57
|
+
"readOnlyHint": True,
|
|
58
|
+
"destructiveHint": False,
|
|
59
|
+
"idempotentHint": True,
|
|
60
|
+
"openWorldHint": True,
|
|
61
|
+
},
|
|
62
|
+
)
|
|
63
|
+
async def devops_get_pull_request(params: GetPullRequestInput, ctx: Context) -> str:
|
|
64
|
+
"""Get details of a specific Azure DevOps pull request.
|
|
65
|
+
|
|
66
|
+
Returns the full pull request object including ID, title, description,
|
|
67
|
+
status, source and target branches, created-by identity, reviewers,
|
|
68
|
+
merge status, completion options, and optional commits and work item refs.
|
|
69
|
+
"""
|
|
70
|
+
app_ctx: AppContext = ctx.request_context.lifespan_context
|
|
71
|
+
try:
|
|
72
|
+
organization = resolve_org(app_ctx, params.organization)
|
|
73
|
+
project = resolve_project(app_ctx, params.project)
|
|
74
|
+
url = build_url(
|
|
75
|
+
organization,
|
|
76
|
+
project,
|
|
77
|
+
f"git/repositories/{params.repository_id}/pullrequests/{params.pull_request_id}",
|
|
78
|
+
)
|
|
79
|
+
query_params: dict = {"api-version": _PR_API_VERSION}
|
|
80
|
+
if params.include_commits:
|
|
81
|
+
query_params["includeCommits"] = "true"
|
|
82
|
+
if params.include_work_item_refs:
|
|
83
|
+
query_params["includeWorkItemRefs"] = "true"
|
|
84
|
+
|
|
85
|
+
response = await request_with_retry(
|
|
86
|
+
app_ctx.http_client,
|
|
87
|
+
"GET",
|
|
88
|
+
url,
|
|
89
|
+
headers=await build_headers(app_ctx),
|
|
90
|
+
params=query_params,
|
|
91
|
+
)
|
|
92
|
+
response.raise_for_status()
|
|
93
|
+
return finalize_response(response.json())
|
|
94
|
+
|
|
95
|
+
except ValueError as e:
|
|
96
|
+
return finalize_response({"error": True, "message": str(e)})
|
|
97
|
+
except httpx.HTTPStatusError as e:
|
|
98
|
+
msg = extract_error_message(e.response)
|
|
99
|
+
logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
|
|
100
|
+
return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
|
|
101
|
+
except Exception as e:
|
|
102
|
+
logger.exception("Unexpected error in devops_get_pull_request")
|
|
103
|
+
return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@mcp.tool(
|
|
107
|
+
name="devops_list_pull_requests",
|
|
108
|
+
annotations={
|
|
109
|
+
"title": "List Pull Requests",
|
|
110
|
+
"readOnlyHint": True,
|
|
111
|
+
"destructiveHint": False,
|
|
112
|
+
"idempotentHint": True,
|
|
113
|
+
"openWorldHint": True,
|
|
114
|
+
},
|
|
115
|
+
)
|
|
116
|
+
async def devops_list_pull_requests(params: ListPullRequestsInput, ctx: Context) -> str:
|
|
117
|
+
"""List pull requests in an Azure DevOps Git repository.
|
|
118
|
+
|
|
119
|
+
Returns a list of pull requests matching the given filters. By default
|
|
120
|
+
returns active pull requests. Supports filtering by status, source/target
|
|
121
|
+
branch, creator, reviewer, labels, and title substring. Use skip and top
|
|
122
|
+
for pagination.
|
|
123
|
+
"""
|
|
124
|
+
app_ctx: AppContext = ctx.request_context.lifespan_context
|
|
125
|
+
try:
|
|
126
|
+
organization = resolve_org(app_ctx, params.organization)
|
|
127
|
+
project = resolve_project(app_ctx, params.project)
|
|
128
|
+
url = build_url(
|
|
129
|
+
organization,
|
|
130
|
+
project,
|
|
131
|
+
f"git/repositories/{params.repository_id}/pullrequests",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# Build scalar search criteria params
|
|
135
|
+
query_params: list[tuple[str, str]] = [("api-version", _PR_API_VERSION)]
|
|
136
|
+
if params.status is not None:
|
|
137
|
+
query_params.append(("searchCriteria.status", params.status))
|
|
138
|
+
if params.source_ref_name is not None:
|
|
139
|
+
query_params.append(("searchCriteria.sourceRefName", params.source_ref_name))
|
|
140
|
+
if params.target_ref_name is not None:
|
|
141
|
+
query_params.append(("searchCriteria.targetRefName", params.target_ref_name))
|
|
142
|
+
if params.creator_id is not None:
|
|
143
|
+
query_params.append(("searchCriteria.creatorId", params.creator_id))
|
|
144
|
+
if params.reviewer_id is not None:
|
|
145
|
+
query_params.append(("searchCriteria.reviewerId", params.reviewer_id))
|
|
146
|
+
if params.title is not None:
|
|
147
|
+
query_params.append(("searchCriteria.title", params.title))
|
|
148
|
+
if params.top is not None:
|
|
149
|
+
query_params.append(("$top", str(params.top)))
|
|
150
|
+
if params.skip is not None:
|
|
151
|
+
query_params.append(("$skip", str(params.skip)))
|
|
152
|
+
# Labels require repeated query parameters
|
|
153
|
+
if params.labels:
|
|
154
|
+
for label in params.labels:
|
|
155
|
+
query_params.append(("searchCriteria.labels", label))
|
|
156
|
+
|
|
157
|
+
response = await request_with_retry(
|
|
158
|
+
app_ctx.http_client,
|
|
159
|
+
"GET",
|
|
160
|
+
url,
|
|
161
|
+
headers=await build_headers(app_ctx),
|
|
162
|
+
params=query_params,
|
|
163
|
+
)
|
|
164
|
+
response.raise_for_status()
|
|
165
|
+
data = response.json()
|
|
166
|
+
pull_requests = data.get("value", [])
|
|
167
|
+
return finalize_response({
|
|
168
|
+
"pullRequests": pull_requests,
|
|
169
|
+
"count": data.get("count", len(pull_requests)),
|
|
170
|
+
"has_more": params.top is not None and len(pull_requests) >= params.top,
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
except ValueError as e:
|
|
174
|
+
return finalize_response({"error": True, "message": str(e)})
|
|
175
|
+
except httpx.HTTPStatusError as e:
|
|
176
|
+
msg = extract_error_message(e.response)
|
|
177
|
+
logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
|
|
178
|
+
return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
|
|
179
|
+
except Exception as e:
|
|
180
|
+
logger.exception("Unexpected error in devops_list_pull_requests")
|
|
181
|
+
return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
async def _link_work_items(
|
|
185
|
+
app_ctx: AppContext,
|
|
186
|
+
organization: str,
|
|
187
|
+
project: str,
|
|
188
|
+
repository_id: str,
|
|
189
|
+
pull_request_id: int,
|
|
190
|
+
work_item_ids: list[int],
|
|
191
|
+
read_headers: dict,
|
|
192
|
+
) -> None:
|
|
193
|
+
"""Link work items to a pull request via ArtifactLink relations on the work item side.
|
|
194
|
+
|
|
195
|
+
Fetches repository details to obtain the project ID needed to build the
|
|
196
|
+
artifact URI, then for each work item GETs its current relations and
|
|
197
|
+
PATCHes an ArtifactLink add operation only when the link is not already
|
|
198
|
+
present. Uses the work-item JSON-Patch API (_WIT_API_VERSION).
|
|
199
|
+
|
|
200
|
+
Raises httpx.HTTPStatusError on any non-2xx response; callers are
|
|
201
|
+
responsible for catching it within their ordered error handlers.
|
|
202
|
+
"""
|
|
203
|
+
repo_url = build_url(organization, project, f"git/repositories/{repository_id}")
|
|
204
|
+
repo_response = await request_with_retry(
|
|
205
|
+
app_ctx.http_client,
|
|
206
|
+
"GET",
|
|
207
|
+
repo_url,
|
|
208
|
+
headers=read_headers,
|
|
209
|
+
params={"api-version": "7.1"},
|
|
210
|
+
)
|
|
211
|
+
repo_response.raise_for_status()
|
|
212
|
+
repository = repo_response.json()
|
|
213
|
+
|
|
214
|
+
resolved_repository_id = repository["id"]
|
|
215
|
+
project_id = repository["project"]["id"]
|
|
216
|
+
artifact_uri = _build_pull_request_artifact_uri(project_id, resolved_repository_id, pull_request_id)
|
|
217
|
+
|
|
218
|
+
patch_headers = await build_headers(
|
|
219
|
+
app_ctx,
|
|
220
|
+
extra={"Content-Type": "application/json-patch+json"},
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
for work_item_id in work_item_ids:
|
|
224
|
+
work_item_url = build_url(organization, project, f"wit/workitems/{work_item_id}")
|
|
225
|
+
work_item_response = await request_with_retry(
|
|
226
|
+
app_ctx.http_client,
|
|
227
|
+
"GET",
|
|
228
|
+
work_item_url,
|
|
229
|
+
headers=read_headers,
|
|
230
|
+
params={"api-version": _WIT_API_VERSION, "$expand": "relations"},
|
|
231
|
+
)
|
|
232
|
+
work_item_response.raise_for_status()
|
|
233
|
+
work_item = work_item_response.json()
|
|
234
|
+
|
|
235
|
+
relations = work_item.get("relations", [])
|
|
236
|
+
already_linked = any(
|
|
237
|
+
relation.get("rel") == "ArtifactLink" and relation.get("url") == artifact_uri
|
|
238
|
+
for relation in relations
|
|
239
|
+
)
|
|
240
|
+
if already_linked:
|
|
241
|
+
continue
|
|
242
|
+
|
|
243
|
+
patch_ops = [
|
|
244
|
+
{
|
|
245
|
+
"op": "add",
|
|
246
|
+
"path": "/relations/-",
|
|
247
|
+
"value": {
|
|
248
|
+
"rel": "ArtifactLink",
|
|
249
|
+
"url": artifact_uri,
|
|
250
|
+
"attributes": {"name": "Pull Request"},
|
|
251
|
+
},
|
|
252
|
+
}
|
|
253
|
+
]
|
|
254
|
+
patch_response = await request_with_retry(
|
|
255
|
+
app_ctx.http_client,
|
|
256
|
+
"PATCH",
|
|
257
|
+
work_item_url,
|
|
258
|
+
headers=patch_headers,
|
|
259
|
+
params={"api-version": _WIT_API_VERSION},
|
|
260
|
+
content=json.dumps(patch_ops).encode(),
|
|
261
|
+
)
|
|
262
|
+
patch_response.raise_for_status()
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@write_tool(
|
|
266
|
+
name="devops_create_pull_request",
|
|
267
|
+
annotations={
|
|
268
|
+
"title": "Create Pull Request",
|
|
269
|
+
"readOnlyHint": False,
|
|
270
|
+
"destructiveHint": False,
|
|
271
|
+
"idempotentHint": False,
|
|
272
|
+
"openWorldHint": True,
|
|
273
|
+
},
|
|
274
|
+
)
|
|
275
|
+
async def devops_create_pull_request(params: CreatePullRequestInput, ctx: Context) -> str:
|
|
276
|
+
"""Create a new pull request in an Azure DevOps Git repository.
|
|
277
|
+
|
|
278
|
+
Creates a PR from source_ref_name into target_ref_name. Optionally sets
|
|
279
|
+
a description, draft status, reviewers, labels, and work item associations.
|
|
280
|
+
Completion options (delete source branch, merge strategy) can also be set
|
|
281
|
+
at creation time.
|
|
282
|
+
|
|
283
|
+
When work_item_ids is supplied, each work item is linked to the newly
|
|
284
|
+
created PR by adding an ArtifactLink relation on the work item side (the
|
|
285
|
+
same mechanism used by devops_link_work_items_to_pull_request). Azure
|
|
286
|
+
DevOps does not honour workItemRefs on the PR create/PATCH API, so that
|
|
287
|
+
field is never set here. Returns the newly created pull request object.
|
|
288
|
+
"""
|
|
289
|
+
app_ctx: AppContext = ctx.request_context.lifespan_context
|
|
290
|
+
try:
|
|
291
|
+
organization = resolve_org(app_ctx, params.organization)
|
|
292
|
+
project = resolve_project(app_ctx, params.project)
|
|
293
|
+
url = build_url(
|
|
294
|
+
organization,
|
|
295
|
+
project,
|
|
296
|
+
f"git/repositories/{params.repository_id}/pullrequests",
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
body: dict = {
|
|
300
|
+
"sourceRefName": params.source_ref_name,
|
|
301
|
+
"targetRefName": params.target_ref_name,
|
|
302
|
+
"title": params.title,
|
|
303
|
+
"isDraft": params.is_draft,
|
|
304
|
+
}
|
|
305
|
+
if params.description is not None:
|
|
306
|
+
body["description"] = params.description
|
|
307
|
+
if params.reviewers:
|
|
308
|
+
body["reviewers"] = [{"id": uid} for uid in params.reviewers]
|
|
309
|
+
if params.labels:
|
|
310
|
+
body["labels"] = [{"name": name} for name in params.labels]
|
|
311
|
+
|
|
312
|
+
completion_options: dict = {}
|
|
313
|
+
if params.delete_source_branch:
|
|
314
|
+
completion_options["deleteSourceBranch"] = True
|
|
315
|
+
if params.merge_strategy is not None:
|
|
316
|
+
completion_options["mergeStrategy"] = params.merge_strategy
|
|
317
|
+
if completion_options:
|
|
318
|
+
body["completionOptions"] = completion_options
|
|
319
|
+
|
|
320
|
+
response = await request_with_retry(
|
|
321
|
+
app_ctx.http_client,
|
|
322
|
+
"POST",
|
|
323
|
+
url,
|
|
324
|
+
headers=await build_headers(app_ctx, include_content_type=True),
|
|
325
|
+
params={"api-version": _PR_API_VERSION},
|
|
326
|
+
json=body,
|
|
327
|
+
)
|
|
328
|
+
response.raise_for_status()
|
|
329
|
+
created_pr = response.json()
|
|
330
|
+
|
|
331
|
+
if params.work_item_ids:
|
|
332
|
+
read_headers = await build_headers(app_ctx)
|
|
333
|
+
await _link_work_items(
|
|
334
|
+
app_ctx,
|
|
335
|
+
organization,
|
|
336
|
+
project,
|
|
337
|
+
params.repository_id,
|
|
338
|
+
created_pr["pullRequestId"],
|
|
339
|
+
params.work_item_ids,
|
|
340
|
+
read_headers,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
return finalize_response(created_pr)
|
|
344
|
+
|
|
345
|
+
except ValueError as e:
|
|
346
|
+
return finalize_response({"error": True, "message": str(e)})
|
|
347
|
+
except httpx.HTTPStatusError as e:
|
|
348
|
+
msg = extract_error_message(e.response)
|
|
349
|
+
logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
|
|
350
|
+
return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
|
|
351
|
+
except Exception as e:
|
|
352
|
+
logger.exception("Unexpected error in devops_create_pull_request")
|
|
353
|
+
return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@write_tool(
|
|
357
|
+
name="devops_update_pull_request",
|
|
358
|
+
annotations={
|
|
359
|
+
"title": "Update Pull Request",
|
|
360
|
+
"readOnlyHint": False,
|
|
361
|
+
"destructiveHint": False,
|
|
362
|
+
"idempotentHint": True,
|
|
363
|
+
"openWorldHint": True,
|
|
364
|
+
},
|
|
365
|
+
)
|
|
366
|
+
async def devops_update_pull_request(params: UpdatePullRequestInput, ctx: Context) -> str:
|
|
367
|
+
"""Update an existing Azure DevOps pull request.
|
|
368
|
+
|
|
369
|
+
Supply only the fields you want to change. Supports updating title,
|
|
370
|
+
description, status (active/abandoned/completed), draft state, target
|
|
371
|
+
branch, auto-complete, and completion options (delete source branch,
|
|
372
|
+
merge strategy, merge commit message, work item transitions). Returns
|
|
373
|
+
the updated pull request object.
|
|
374
|
+
"""
|
|
375
|
+
app_ctx: AppContext = ctx.request_context.lifespan_context
|
|
376
|
+
try:
|
|
377
|
+
organization = resolve_org(app_ctx, params.organization)
|
|
378
|
+
project = resolve_project(app_ctx, params.project)
|
|
379
|
+
url = build_url(
|
|
380
|
+
organization,
|
|
381
|
+
project,
|
|
382
|
+
f"git/repositories/{params.repository_id}/pullrequests/{params.pull_request_id}",
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
body: dict = {}
|
|
386
|
+
if params.title is not None:
|
|
387
|
+
body["title"] = params.title
|
|
388
|
+
if params.description is not None:
|
|
389
|
+
body["description"] = params.description
|
|
390
|
+
if params.status is not None:
|
|
391
|
+
body["status"] = params.status
|
|
392
|
+
if params.is_draft is not None:
|
|
393
|
+
body["isDraft"] = params.is_draft
|
|
394
|
+
if params.target_ref_name is not None:
|
|
395
|
+
body["targetRefName"] = params.target_ref_name
|
|
396
|
+
if params.auto_complete_identity_id is not None:
|
|
397
|
+
body["autoCompleteSetBy"] = {"id": params.auto_complete_identity_id}
|
|
398
|
+
|
|
399
|
+
completion_options: dict = {}
|
|
400
|
+
if params.delete_source_branch is not None:
|
|
401
|
+
completion_options["deleteSourceBranch"] = params.delete_source_branch
|
|
402
|
+
if params.merge_strategy is not None:
|
|
403
|
+
completion_options["mergeStrategy"] = params.merge_strategy
|
|
404
|
+
if params.merge_commit_message is not None:
|
|
405
|
+
completion_options["mergeCommitMessage"] = params.merge_commit_message
|
|
406
|
+
if params.transition_work_items is not None:
|
|
407
|
+
completion_options["transitionWorkItems"] = params.transition_work_items
|
|
408
|
+
if completion_options:
|
|
409
|
+
body["completionOptions"] = completion_options
|
|
410
|
+
|
|
411
|
+
response = await request_with_retry(
|
|
412
|
+
app_ctx.http_client,
|
|
413
|
+
"PATCH",
|
|
414
|
+
url,
|
|
415
|
+
headers=await build_headers(app_ctx, include_content_type=True),
|
|
416
|
+
params={"api-version": _PR_API_VERSION},
|
|
417
|
+
json=body,
|
|
418
|
+
)
|
|
419
|
+
response.raise_for_status()
|
|
420
|
+
return finalize_response(response.json())
|
|
421
|
+
|
|
422
|
+
except ValueError as e:
|
|
423
|
+
return finalize_response({"error": True, "message": str(e)})
|
|
424
|
+
except httpx.HTTPStatusError as e:
|
|
425
|
+
msg = extract_error_message(e.response)
|
|
426
|
+
logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
|
|
427
|
+
return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
|
|
428
|
+
except Exception as e:
|
|
429
|
+
logger.exception("Unexpected error in devops_update_pull_request")
|
|
430
|
+
return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
@write_tool(
|
|
434
|
+
name="devops_tag_pull_request",
|
|
435
|
+
annotations={
|
|
436
|
+
"title": "Tag or Label Pull Request",
|
|
437
|
+
"readOnlyHint": False,
|
|
438
|
+
"destructiveHint": False,
|
|
439
|
+
"idempotentHint": False,
|
|
440
|
+
"openWorldHint": True,
|
|
441
|
+
},
|
|
442
|
+
)
|
|
443
|
+
async def devops_tag_pull_request(params: TagPullRequestInput, ctx: Context) -> str:
|
|
444
|
+
"""Add labels or tags to an Azure DevOps pull request (PR).
|
|
445
|
+
|
|
446
|
+
Use this tool when you want to tag, label, or categorize a pull request.
|
|
447
|
+
It adds each specified label using the dedicated labels endpoint. Labels are
|
|
448
|
+
created automatically if they do not already exist in the project. Returns
|
|
449
|
+
a list of the label objects that were created or applied. This operation is
|
|
450
|
+
additive — existing labels on the PR are not removed.
|
|
451
|
+
"""
|
|
452
|
+
app_ctx: AppContext = ctx.request_context.lifespan_context
|
|
453
|
+
try:
|
|
454
|
+
organization = resolve_org(app_ctx, params.organization)
|
|
455
|
+
project = resolve_project(app_ctx, params.project)
|
|
456
|
+
base_url = build_url(
|
|
457
|
+
organization,
|
|
458
|
+
project,
|
|
459
|
+
f"git/repositories/{params.repository_id}/pullRequests/{params.pull_request_id}/labels",
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
results = []
|
|
463
|
+
headers = await build_headers(app_ctx, include_content_type=True)
|
|
464
|
+
for label_name in params.labels:
|
|
465
|
+
response = await request_with_retry(
|
|
466
|
+
app_ctx.http_client,
|
|
467
|
+
"POST",
|
|
468
|
+
base_url,
|
|
469
|
+
headers=headers,
|
|
470
|
+
params={"api-version": _PR_API_VERSION},
|
|
471
|
+
json={"name": label_name},
|
|
472
|
+
)
|
|
473
|
+
response.raise_for_status()
|
|
474
|
+
results.append(response.json())
|
|
475
|
+
|
|
476
|
+
return finalize_response({"labels": results, "count": len(results)})
|
|
477
|
+
|
|
478
|
+
except ValueError as e:
|
|
479
|
+
return finalize_response({"error": True, "message": str(e)})
|
|
480
|
+
except httpx.HTTPStatusError as e:
|
|
481
|
+
msg = extract_error_message(e.response)
|
|
482
|
+
logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
|
|
483
|
+
return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
|
|
484
|
+
except Exception as e:
|
|
485
|
+
logger.exception("Unexpected error in devops_tag_pull_request")
|
|
486
|
+
return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
@write_tool(
|
|
490
|
+
name="devops_link_work_items_to_pull_request",
|
|
491
|
+
annotations={
|
|
492
|
+
"title": "Link Work Items or Boards Items to Pull Request",
|
|
493
|
+
"readOnlyHint": False,
|
|
494
|
+
"destructiveHint": False,
|
|
495
|
+
"idempotentHint": True,
|
|
496
|
+
"openWorldHint": True,
|
|
497
|
+
},
|
|
498
|
+
)
|
|
499
|
+
async def devops_link_work_items_to_pull_request(
|
|
500
|
+
params: LinkWorkItemsToPullRequestInput, ctx: Context
|
|
501
|
+
) -> str:
|
|
502
|
+
"""Link Azure Boards work items to an existing Azure DevOps pull request.
|
|
503
|
+
|
|
504
|
+
Use this tool when you want to link a work item, board item, story, bug,
|
|
505
|
+
task, or backlog item to a PR. It links one or more work items to a pull
|
|
506
|
+
request by adding ArtifactLink relations on the work item side. Azure
|
|
507
|
+
DevOps does not support updating workItemRefs via the pull request PATCH
|
|
508
|
+
API. For the most reliable work item linking, prefer supplying
|
|
509
|
+
work_item_ids when calling devops_create_pull_request. Returns the updated
|
|
510
|
+
pull request object with the workItemRefs included.
|
|
511
|
+
"""
|
|
512
|
+
app_ctx: AppContext = ctx.request_context.lifespan_context
|
|
513
|
+
try:
|
|
514
|
+
organization = resolve_org(app_ctx, params.organization)
|
|
515
|
+
project = resolve_project(app_ctx, params.project)
|
|
516
|
+
pr_url = build_url(
|
|
517
|
+
organization,
|
|
518
|
+
project,
|
|
519
|
+
f"git/repositories/{params.repository_id}/pullrequests/{params.pull_request_id}",
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
read_headers = await build_headers(app_ctx)
|
|
523
|
+
await _link_work_items(
|
|
524
|
+
app_ctx,
|
|
525
|
+
organization,
|
|
526
|
+
project,
|
|
527
|
+
params.repository_id,
|
|
528
|
+
params.pull_request_id,
|
|
529
|
+
params.work_item_ids,
|
|
530
|
+
read_headers,
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
# Return the updated PR with work item refs
|
|
534
|
+
pr_response = await request_with_retry(
|
|
535
|
+
app_ctx.http_client,
|
|
536
|
+
"GET",
|
|
537
|
+
pr_url,
|
|
538
|
+
headers=read_headers,
|
|
539
|
+
params={
|
|
540
|
+
"api-version": _PR_API_VERSION,
|
|
541
|
+
"includeWorkItemRefs": "true",
|
|
542
|
+
},
|
|
543
|
+
)
|
|
544
|
+
pr_response.raise_for_status()
|
|
545
|
+
return finalize_response(pr_response.json())
|
|
546
|
+
|
|
547
|
+
except ValueError as e:
|
|
548
|
+
return finalize_response({"error": True, "message": str(e)})
|
|
549
|
+
except httpx.HTTPStatusError as e:
|
|
550
|
+
msg = extract_error_message(e.response)
|
|
551
|
+
logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
|
|
552
|
+
return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
|
|
553
|
+
except Exception as e:
|
|
554
|
+
logger.exception("Unexpected error in devops_link_work_items_to_pull_request")
|
|
555
|
+
return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
@mcp.tool(
|
|
559
|
+
name="devops_list_pull_request_threads",
|
|
560
|
+
annotations={
|
|
561
|
+
"title": "List Pull Request Threads",
|
|
562
|
+
"readOnlyHint": True,
|
|
563
|
+
"destructiveHint": False,
|
|
564
|
+
"idempotentHint": True,
|
|
565
|
+
"openWorldHint": True,
|
|
566
|
+
},
|
|
567
|
+
)
|
|
568
|
+
async def devops_list_pull_request_threads(
|
|
569
|
+
params: ListPullRequestThreadsInput, ctx: Context
|
|
570
|
+
) -> str:
|
|
571
|
+
"""List all comment threads on an Azure DevOps pull request.
|
|
572
|
+
|
|
573
|
+
Returns a JSON object with keys:
|
|
574
|
+
- threads: array of thread objects, each containing id, status, comments,
|
|
575
|
+
threadContext (file path and line/offset when present), identities, and
|
|
576
|
+
timestamps.
|
|
577
|
+
- count: total number of threads returned.
|
|
578
|
+
"""
|
|
579
|
+
app_ctx: AppContext = ctx.request_context.lifespan_context
|
|
580
|
+
try:
|
|
581
|
+
organization = resolve_org(app_ctx, params.organization)
|
|
582
|
+
project = resolve_project(app_ctx, params.project)
|
|
583
|
+
url = build_url(
|
|
584
|
+
organization,
|
|
585
|
+
project,
|
|
586
|
+
f"git/repositories/{params.repository_id}/pullrequests/{params.pull_request_id}/threads",
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
response = await request_with_retry(
|
|
590
|
+
app_ctx.http_client,
|
|
591
|
+
"GET",
|
|
592
|
+
url,
|
|
593
|
+
headers=await build_headers(app_ctx),
|
|
594
|
+
params={"api-version": _THREAD_API_VERSION},
|
|
595
|
+
)
|
|
596
|
+
response.raise_for_status()
|
|
597
|
+
data = response.json()
|
|
598
|
+
threads = data.get("value", [])
|
|
599
|
+
return finalize_response({"threads": threads, "count": data.get("count", len(threads))})
|
|
600
|
+
|
|
601
|
+
except ValueError as e:
|
|
602
|
+
return finalize_response({"error": True, "message": str(e)})
|
|
603
|
+
except httpx.HTTPStatusError as e:
|
|
604
|
+
msg = extract_error_message(e.response)
|
|
605
|
+
logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
|
|
606
|
+
return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
|
|
607
|
+
except Exception as e:
|
|
608
|
+
logger.exception("Unexpected error in devops_list_pull_request_threads")
|
|
609
|
+
return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
@mcp.tool(
|
|
613
|
+
name="devops_get_pull_request_thread",
|
|
614
|
+
annotations={
|
|
615
|
+
"title": "Get Pull Request Thread",
|
|
616
|
+
"readOnlyHint": True,
|
|
617
|
+
"destructiveHint": False,
|
|
618
|
+
"idempotentHint": True,
|
|
619
|
+
"openWorldHint": True,
|
|
620
|
+
},
|
|
621
|
+
)
|
|
622
|
+
async def devops_get_pull_request_thread(
|
|
623
|
+
params: GetPullRequestThreadInput, ctx: Context
|
|
624
|
+
) -> str:
|
|
625
|
+
"""Get a single comment thread from an Azure DevOps pull request.
|
|
626
|
+
|
|
627
|
+
Returns the full thread object including id, status, comments (with content,
|
|
628
|
+
author, and timestamps), threadContext (file path and line/offset for inline
|
|
629
|
+
threads), and publishedDate / lastUpdatedDate.
|
|
630
|
+
"""
|
|
631
|
+
app_ctx: AppContext = ctx.request_context.lifespan_context
|
|
632
|
+
try:
|
|
633
|
+
organization = resolve_org(app_ctx, params.organization)
|
|
634
|
+
project = resolve_project(app_ctx, params.project)
|
|
635
|
+
url = build_url(
|
|
636
|
+
organization,
|
|
637
|
+
project,
|
|
638
|
+
f"git/repositories/{params.repository_id}/pullrequests/{params.pull_request_id}/threads/{params.thread_id}",
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
response = await request_with_retry(
|
|
642
|
+
app_ctx.http_client,
|
|
643
|
+
"GET",
|
|
644
|
+
url,
|
|
645
|
+
headers=await build_headers(app_ctx),
|
|
646
|
+
params={"api-version": _THREAD_API_VERSION},
|
|
647
|
+
)
|
|
648
|
+
response.raise_for_status()
|
|
649
|
+
return finalize_response(response.json())
|
|
650
|
+
|
|
651
|
+
except ValueError as e:
|
|
652
|
+
return finalize_response({"error": True, "message": str(e)})
|
|
653
|
+
except httpx.HTTPStatusError as e:
|
|
654
|
+
msg = extract_error_message(e.response)
|
|
655
|
+
logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
|
|
656
|
+
return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
|
|
657
|
+
except Exception as e:
|
|
658
|
+
logger.exception("Unexpected error in devops_get_pull_request_thread")
|
|
659
|
+
return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
@write_tool(
|
|
663
|
+
name="devops_create_pull_request_thread",
|
|
664
|
+
annotations={
|
|
665
|
+
"title": "Create Pull Request Thread",
|
|
666
|
+
"readOnlyHint": False,
|
|
667
|
+
"destructiveHint": False,
|
|
668
|
+
"idempotentHint": False,
|
|
669
|
+
"openWorldHint": True,
|
|
670
|
+
},
|
|
671
|
+
)
|
|
672
|
+
async def devops_create_pull_request_thread(
|
|
673
|
+
params: CreatePullRequestThreadInput, ctx: Context
|
|
674
|
+
) -> str:
|
|
675
|
+
"""Create a new comment thread on an Azure DevOps pull request.
|
|
676
|
+
|
|
677
|
+
When file_path is supplied, creates an inline thread anchored to the
|
|
678
|
+
specified file and line range (code comment). When file_path is omitted,
|
|
679
|
+
creates a general PR-level comment thread.
|
|
680
|
+
|
|
681
|
+
Returns the newly created thread object including its id, status, comments,
|
|
682
|
+
and threadContext when applicable.
|
|
683
|
+
"""
|
|
684
|
+
app_ctx: AppContext = ctx.request_context.lifespan_context
|
|
685
|
+
try:
|
|
686
|
+
organization = resolve_org(app_ctx, params.organization)
|
|
687
|
+
project = resolve_project(app_ctx, params.project)
|
|
688
|
+
url = build_url(
|
|
689
|
+
organization,
|
|
690
|
+
project,
|
|
691
|
+
f"git/repositories/{params.repository_id}/pullrequests/{params.pull_request_id}/threads",
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
body: dict = {
|
|
695
|
+
"comments": [
|
|
696
|
+
{
|
|
697
|
+
"parentCommentId": 0,
|
|
698
|
+
"content": params.content,
|
|
699
|
+
"commentType": "text",
|
|
700
|
+
}
|
|
701
|
+
],
|
|
702
|
+
"status": params.status,
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
if params.file_path is not None:
|
|
706
|
+
body["threadContext"] = {
|
|
707
|
+
"filePath": params.file_path,
|
|
708
|
+
"rightFileStart": {
|
|
709
|
+
"line": params.right_file_start_line,
|
|
710
|
+
"offset": params.right_file_start_offset,
|
|
711
|
+
},
|
|
712
|
+
"rightFileEnd": {
|
|
713
|
+
"line": params.right_file_end_line,
|
|
714
|
+
"offset": params.right_file_end_offset,
|
|
715
|
+
},
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
response = await request_with_retry(
|
|
719
|
+
app_ctx.http_client,
|
|
720
|
+
"POST",
|
|
721
|
+
url,
|
|
722
|
+
headers=await build_headers(app_ctx, include_content_type=True),
|
|
723
|
+
params={"api-version": _THREAD_API_VERSION},
|
|
724
|
+
json=body,
|
|
725
|
+
)
|
|
726
|
+
response.raise_for_status()
|
|
727
|
+
return finalize_response(response.json())
|
|
728
|
+
|
|
729
|
+
except ValueError as e:
|
|
730
|
+
return finalize_response({"error": True, "message": str(e)})
|
|
731
|
+
except httpx.HTTPStatusError as e:
|
|
732
|
+
msg = extract_error_message(e.response)
|
|
733
|
+
logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
|
|
734
|
+
return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
|
|
735
|
+
except Exception as e:
|
|
736
|
+
logger.exception("Unexpected error in devops_create_pull_request_thread")
|
|
737
|
+
return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
@write_tool(
|
|
741
|
+
name="devops_set_pull_request_thread_status",
|
|
742
|
+
annotations={
|
|
743
|
+
"title": "Set Pull Request Thread Status",
|
|
744
|
+
"readOnlyHint": False,
|
|
745
|
+
"destructiveHint": False,
|
|
746
|
+
"idempotentHint": True,
|
|
747
|
+
"openWorldHint": True,
|
|
748
|
+
},
|
|
749
|
+
)
|
|
750
|
+
async def devops_set_pull_request_thread_status(
|
|
751
|
+
params: SetPullRequestThreadStatusInput, ctx: Context
|
|
752
|
+
) -> str:
|
|
753
|
+
"""Update the status of a comment thread on an Azure DevOps pull request.
|
|
754
|
+
|
|
755
|
+
Valid status values: 'active', 'fixed', 'wontFix', 'closed', 'byDesign',
|
|
756
|
+
'pending'. Returns the updated thread object including id, status, comments,
|
|
757
|
+
and threadContext.
|
|
758
|
+
"""
|
|
759
|
+
app_ctx: AppContext = ctx.request_context.lifespan_context
|
|
760
|
+
try:
|
|
761
|
+
organization = resolve_org(app_ctx, params.organization)
|
|
762
|
+
project = resolve_project(app_ctx, params.project)
|
|
763
|
+
url = build_url(
|
|
764
|
+
organization,
|
|
765
|
+
project,
|
|
766
|
+
f"git/repositories/{params.repository_id}/pullrequests/{params.pull_request_id}/threads/{params.thread_id}",
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
response = await request_with_retry(
|
|
770
|
+
app_ctx.http_client,
|
|
771
|
+
"PATCH",
|
|
772
|
+
url,
|
|
773
|
+
headers=await build_headers(app_ctx, include_content_type=True),
|
|
774
|
+
params={"api-version": _THREAD_API_VERSION},
|
|
775
|
+
json={"status": params.status},
|
|
776
|
+
)
|
|
777
|
+
response.raise_for_status()
|
|
778
|
+
return finalize_response(response.json())
|
|
779
|
+
|
|
780
|
+
except ValueError as e:
|
|
781
|
+
return finalize_response({"error": True, "message": str(e)})
|
|
782
|
+
except httpx.HTTPStatusError as e:
|
|
783
|
+
msg = extract_error_message(e.response)
|
|
784
|
+
logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
|
|
785
|
+
return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
|
|
786
|
+
except Exception as e:
|
|
787
|
+
logger.exception("Unexpected error in devops_set_pull_request_thread_status")
|
|
788
|
+
return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
@write_tool(
|
|
792
|
+
name="devops_add_pull_request_comment",
|
|
793
|
+
annotations={
|
|
794
|
+
"title": "Add Pull Request Comment",
|
|
795
|
+
"readOnlyHint": False,
|
|
796
|
+
"destructiveHint": False,
|
|
797
|
+
"idempotentHint": False,
|
|
798
|
+
"openWorldHint": True,
|
|
799
|
+
},
|
|
800
|
+
)
|
|
801
|
+
async def devops_add_pull_request_comment(
|
|
802
|
+
params: AddPullRequestCommentInput, ctx: Context
|
|
803
|
+
) -> str:
|
|
804
|
+
"""Add a reply comment to an existing thread on an Azure DevOps pull request.
|
|
805
|
+
|
|
806
|
+
Use parent_comment_id=0 (default) to add a top-level reply on the thread.
|
|
807
|
+
Supply a non-zero parent_comment_id to nest the reply under a specific
|
|
808
|
+
comment within the thread.
|
|
809
|
+
|
|
810
|
+
Returns the newly created comment object including id, content, author,
|
|
811
|
+
commentType, and publishedDate.
|
|
812
|
+
"""
|
|
813
|
+
app_ctx: AppContext = ctx.request_context.lifespan_context
|
|
814
|
+
try:
|
|
815
|
+
organization = resolve_org(app_ctx, params.organization)
|
|
816
|
+
project = resolve_project(app_ctx, params.project)
|
|
817
|
+
url = build_url(
|
|
818
|
+
organization,
|
|
819
|
+
project,
|
|
820
|
+
f"git/repositories/{params.repository_id}/pullrequests/{params.pull_request_id}/threads/{params.thread_id}/comments",
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
body: dict = {
|
|
824
|
+
"parentCommentId": params.parent_comment_id,
|
|
825
|
+
"content": params.content,
|
|
826
|
+
"commentType": "text",
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
response = await request_with_retry(
|
|
830
|
+
app_ctx.http_client,
|
|
831
|
+
"POST",
|
|
832
|
+
url,
|
|
833
|
+
headers=await build_headers(app_ctx, include_content_type=True),
|
|
834
|
+
params={"api-version": _THREAD_API_VERSION},
|
|
835
|
+
json=body,
|
|
836
|
+
)
|
|
837
|
+
response.raise_for_status()
|
|
838
|
+
return finalize_response(response.json())
|
|
839
|
+
|
|
840
|
+
except ValueError as e:
|
|
841
|
+
return finalize_response({"error": True, "message": str(e)})
|
|
842
|
+
except httpx.HTTPStatusError as e:
|
|
843
|
+
msg = extract_error_message(e.response)
|
|
844
|
+
logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
|
|
845
|
+
return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
|
|
846
|
+
except Exception as e:
|
|
847
|
+
logger.exception("Unexpected error in devops_add_pull_request_comment")
|
|
848
|
+
return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
@write_tool(
|
|
852
|
+
name="devops_update_pull_request_comment",
|
|
853
|
+
annotations={
|
|
854
|
+
"title": "Update Pull Request Comment",
|
|
855
|
+
"readOnlyHint": False,
|
|
856
|
+
"destructiveHint": False,
|
|
857
|
+
"idempotentHint": True,
|
|
858
|
+
"openWorldHint": True,
|
|
859
|
+
},
|
|
860
|
+
)
|
|
861
|
+
async def devops_update_pull_request_comment(
|
|
862
|
+
params: UpdatePullRequestCommentInput, ctx: Context
|
|
863
|
+
) -> str:
|
|
864
|
+
"""Update the content of an existing comment in an Azure DevOps pull request thread.
|
|
865
|
+
|
|
866
|
+
Returns the updated comment object including id, content, author,
|
|
867
|
+
commentType, lastUpdatedDate, and isDeleted flag.
|
|
868
|
+
"""
|
|
869
|
+
app_ctx: AppContext = ctx.request_context.lifespan_context
|
|
870
|
+
try:
|
|
871
|
+
organization = resolve_org(app_ctx, params.organization)
|
|
872
|
+
project = resolve_project(app_ctx, params.project)
|
|
873
|
+
url = build_url(
|
|
874
|
+
organization,
|
|
875
|
+
project,
|
|
876
|
+
f"git/repositories/{params.repository_id}/pullrequests/{params.pull_request_id}/threads/{params.thread_id}/comments/{params.comment_id}",
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
response = await request_with_retry(
|
|
880
|
+
app_ctx.http_client,
|
|
881
|
+
"PATCH",
|
|
882
|
+
url,
|
|
883
|
+
headers=await build_headers(app_ctx, include_content_type=True),
|
|
884
|
+
params={"api-version": _THREAD_API_VERSION},
|
|
885
|
+
json={"content": params.content},
|
|
886
|
+
)
|
|
887
|
+
response.raise_for_status()
|
|
888
|
+
return finalize_response(response.json())
|
|
889
|
+
|
|
890
|
+
except ValueError as e:
|
|
891
|
+
return finalize_response({"error": True, "message": str(e)})
|
|
892
|
+
except httpx.HTTPStatusError as e:
|
|
893
|
+
msg = extract_error_message(e.response)
|
|
894
|
+
logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
|
|
895
|
+
return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
|
|
896
|
+
except Exception as e:
|
|
897
|
+
logger.exception("Unexpected error in devops_update_pull_request_comment")
|
|
898
|
+
return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
@mcp.tool(
|
|
902
|
+
name="devops_list_pull_request_iterations",
|
|
903
|
+
annotations={
|
|
904
|
+
"title": "List Pull Request Iterations",
|
|
905
|
+
"readOnlyHint": True,
|
|
906
|
+
"destructiveHint": False,
|
|
907
|
+
"idempotentHint": True,
|
|
908
|
+
"openWorldHint": True,
|
|
909
|
+
},
|
|
910
|
+
)
|
|
911
|
+
async def devops_list_pull_request_iterations(
|
|
912
|
+
params: ListPullRequestIterationsInput, ctx: Context
|
|
913
|
+
) -> str:
|
|
914
|
+
"""List the push iterations for an Azure DevOps pull request.
|
|
915
|
+
|
|
916
|
+
Each iteration represents a round of changes pushed to the source branch
|
|
917
|
+
after the PR was created. Useful for understanding the history of updates
|
|
918
|
+
and for targeting a specific iteration when calling
|
|
919
|
+
devops_get_pull_request_changes.
|
|
920
|
+
|
|
921
|
+
Returns a JSON object with keys:
|
|
922
|
+
- iterations: array of iteration objects, each containing id, description,
|
|
923
|
+
author, createdDate, sourceRefCommit, targetRefCommit, commonRefCommit,
|
|
924
|
+
and optionally commits when include_commits is True.
|
|
925
|
+
- count: total number of iterations returned.
|
|
926
|
+
"""
|
|
927
|
+
app_ctx: AppContext = ctx.request_context.lifespan_context
|
|
928
|
+
try:
|
|
929
|
+
organization = resolve_org(app_ctx, params.organization)
|
|
930
|
+
project = resolve_project(app_ctx, params.project)
|
|
931
|
+
url = build_url(
|
|
932
|
+
organization,
|
|
933
|
+
project,
|
|
934
|
+
f"git/repositories/{params.repository_id}/pullrequests/{params.pull_request_id}/iterations",
|
|
935
|
+
)
|
|
936
|
+
|
|
937
|
+
query_params: dict = {"api-version": _THREAD_API_VERSION}
|
|
938
|
+
if params.include_commits:
|
|
939
|
+
query_params["includeCommits"] = "true"
|
|
940
|
+
|
|
941
|
+
response = await request_with_retry(
|
|
942
|
+
app_ctx.http_client,
|
|
943
|
+
"GET",
|
|
944
|
+
url,
|
|
945
|
+
headers=await build_headers(app_ctx),
|
|
946
|
+
params=query_params,
|
|
947
|
+
)
|
|
948
|
+
response.raise_for_status()
|
|
949
|
+
data = response.json()
|
|
950
|
+
iterations = data.get("value", [])
|
|
951
|
+
return finalize_response({
|
|
952
|
+
"iterations": iterations,
|
|
953
|
+
"count": data.get("count", len(iterations)),
|
|
954
|
+
})
|
|
955
|
+
|
|
956
|
+
except ValueError as e:
|
|
957
|
+
return finalize_response({"error": True, "message": str(e)})
|
|
958
|
+
except httpx.HTTPStatusError as e:
|
|
959
|
+
msg = extract_error_message(e.response)
|
|
960
|
+
logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
|
|
961
|
+
return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
|
|
962
|
+
except Exception as e:
|
|
963
|
+
logger.exception("Unexpected error in devops_list_pull_request_iterations")
|
|
964
|
+
return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
@mcp.tool(
|
|
968
|
+
name="devops_get_pull_request_changes",
|
|
969
|
+
annotations={
|
|
970
|
+
"title": "Get Pull Request Changes",
|
|
971
|
+
"readOnlyHint": True,
|
|
972
|
+
"destructiveHint": False,
|
|
973
|
+
"idempotentHint": True,
|
|
974
|
+
"openWorldHint": True,
|
|
975
|
+
},
|
|
976
|
+
)
|
|
977
|
+
async def devops_get_pull_request_changes(
|
|
978
|
+
params: GetPullRequestChangesInput, ctx: Context
|
|
979
|
+
) -> str:
|
|
980
|
+
"""Get the file-change entries for a specific pull request iteration.
|
|
981
|
+
|
|
982
|
+
Returns the list of files added, edited, deleted, or renamed in the given
|
|
983
|
+
iteration. When compare_to is supplied, only the incremental changes between
|
|
984
|
+
that iteration and iteration_id are returned. Use top and skip to paginate
|
|
985
|
+
large change sets.
|
|
986
|
+
|
|
987
|
+
Returns a JSON object with keys:
|
|
988
|
+
- changes: array of change entry objects, each containing changeId,
|
|
989
|
+
item (with path and other metadata), and changeType
|
|
990
|
+
(e.g. 'edit', 'add', 'delete', 'rename').
|
|
991
|
+
- count: number of change entries in this response.
|
|
992
|
+
- nextSkip: present when more entries exist; pass as skip on the next call.
|
|
993
|
+
"""
|
|
994
|
+
app_ctx: AppContext = ctx.request_context.lifespan_context
|
|
995
|
+
try:
|
|
996
|
+
organization = resolve_org(app_ctx, params.organization)
|
|
997
|
+
project = resolve_project(app_ctx, params.project)
|
|
998
|
+
url = build_url(
|
|
999
|
+
organization,
|
|
1000
|
+
project,
|
|
1001
|
+
f"git/repositories/{params.repository_id}/pullrequests/{params.pull_request_id}/iterations/{params.iteration_id}/changes",
|
|
1002
|
+
)
|
|
1003
|
+
|
|
1004
|
+
query_params: dict = {"api-version": _THREAD_API_VERSION}
|
|
1005
|
+
if params.compare_to is not None:
|
|
1006
|
+
query_params["$compareTo"] = params.compare_to
|
|
1007
|
+
if params.top is not None:
|
|
1008
|
+
query_params["$top"] = params.top
|
|
1009
|
+
if params.skip is not None:
|
|
1010
|
+
query_params["$skip"] = params.skip
|
|
1011
|
+
|
|
1012
|
+
response = await request_with_retry(
|
|
1013
|
+
app_ctx.http_client,
|
|
1014
|
+
"GET",
|
|
1015
|
+
url,
|
|
1016
|
+
headers=await build_headers(app_ctx),
|
|
1017
|
+
params=query_params,
|
|
1018
|
+
)
|
|
1019
|
+
response.raise_for_status()
|
|
1020
|
+
data = response.json()
|
|
1021
|
+
changes = data.get("changeEntries", [])
|
|
1022
|
+
result: dict = {
|
|
1023
|
+
"changes": changes,
|
|
1024
|
+
"count": len(changes),
|
|
1025
|
+
}
|
|
1026
|
+
if "nextSkip" in data:
|
|
1027
|
+
result["nextSkip"] = data["nextSkip"]
|
|
1028
|
+
return finalize_response(result)
|
|
1029
|
+
|
|
1030
|
+
except ValueError as e:
|
|
1031
|
+
return finalize_response({"error": True, "message": str(e)})
|
|
1032
|
+
except httpx.HTTPStatusError as e:
|
|
1033
|
+
msg = extract_error_message(e.response)
|
|
1034
|
+
logger.error("Azure DevOps HTTP %d: %s", e.response.status_code, msg)
|
|
1035
|
+
return finalize_response({"error": True, "message": f"Azure DevOps returned HTTP {e.response.status_code}: {msg}"})
|
|
1036
|
+
except Exception as e:
|
|
1037
|
+
logger.exception("Unexpected error in devops_get_pull_request_changes")
|
|
1038
|
+
return finalize_response({"error": True, "message": f"Unexpected error: {type(e).__name__}: {e}"})
|