@groupby/ai-dev 0.5.16 → 0.5.17
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.
- package/package.json +1 -1
- package/teams/firstspirit-caas/mcp/jira-tools.py +2122 -0
- package/teams/firstspirit-caas/mcp/test_bamboo_artifacts.py +315 -0
- package/teams/firstspirit-caas/mcp/test_jira_links.py +192 -0
- package/teams/firstspirit-caas/mcp/test_update_jira_ticket.py +161 -0
- package/teams/firstspirit-caas/resources/mcp-setup.md +57 -0
- package/teams/firstspirit-caas/skills/chronicle/SKILL.md +105 -0
- package/teams/firstspirit-caas/skills/create-jira-ticket/SKILL.md +183 -0
- package/teams/firstspirit-caas/skills/get-jira-ticket/SKILL.md +125 -0
- package/teams/firstspirit-caas/skills/handle-red-cve-plan/SKILL.md +316 -0
- package/teams/firstspirit-caas/skills/handle-red-cve-plan/references/suppression.md +79 -0
- package/teams/firstspirit-caas/skills/handle-red-cve-plan/scripts/gen_suppression.py +116 -0
- package/teams/firstspirit-caas/skills/handle-red-cve-plan/scripts/parse_json_report.py +183 -0
- package/teams/firstspirit-caas/skills/handle-red-cve-plan/scripts/parse_report.py +255 -0
- package/teams/firstspirit-caas/skills/handle-red-cve-plan/scripts/test_gen_suppression.py +171 -0
- package/teams/firstspirit-caas/skills/handle-red-cve-plan/scripts/test_parse_json_report.py +244 -0
- package/teams/firstspirit-caas/skills/handle-red-cve-plan/scripts/test_parse_report.py +464 -0
- package/teams/firstspirit-caas/skills/handle-red-cve-plan/scripts/testdata/sample-report.html +39 -0
- package/teams/firstspirit-caas/skills/handle-red-cve-plan/scripts/testdata/sample-report.json +76 -0
- package/teams/firstspirit-caas/skills/handle-weekly-cve-plans/SKILL.md +238 -0
- package/teams/firstspirit-caas/skills/retrospect/SKILL.md +94 -0
- package/teams/firstspirit-caas/skills/retrospect/extract_prompts.py +392 -0
- package/teams/firstspirit-caas/skills/review/SKILL.md +131 -0
- package/teams/firstspirit-caas/skills/review-finalize/SKILL.md +103 -0
- package/teams/firstspirit-caas/skills/specify/SKILL.md +239 -0
- package/teams/firstspirit-caas/skills/summarise-for-jira-comment/SKILL.md +162 -0
- package/teams/firstspirit-caas/skills/write-releasenotes/SKILL.md +177 -0
|
@@ -0,0 +1,2122 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
import ssl
|
|
5
|
+
import sys
|
|
6
|
+
import os
|
|
7
|
+
import urllib.request
|
|
8
|
+
from urllib.request import Request
|
|
9
|
+
from urllib.parse import quote, urljoin, urlparse
|
|
10
|
+
import tempfile
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# Some corporate hosts (notably Bamboo / ci.e-spirit.de) present only a leaf
|
|
14
|
+
# cert and rely on AIA chasing for the intermediate. macOS curl handles that
|
|
15
|
+
# via Security framework; Python's stdlib SSL does not. ``truststore`` (PyPI)
|
|
16
|
+
# delegates to the OS trust store and resolves this transparently — install
|
|
17
|
+
# it with ``pip install truststore`` if Bamboo calls fail with
|
|
18
|
+
# ``unable to get local issuer certificate``. We inject lazily so the MCP
|
|
19
|
+
# keeps working when truststore is absent (the JIRA and Bitbucket hosts ship
|
|
20
|
+
# full chains and don't need it).
|
|
21
|
+
try:
|
|
22
|
+
import truststore as _truststore
|
|
23
|
+
_truststore.inject_into_ssl()
|
|
24
|
+
except ImportError:
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
import socket as _socket
|
|
28
|
+
# Force IPv4: jira.crownpeak.com (AWS ALB) publishes AAAA records whose IPv6
|
|
29
|
+
# path is unreachable from some corporate networks, so urllib burns a full
|
|
30
|
+
# connect-timeout on IPv6 before falling back to IPv4 on every call. Filtering
|
|
31
|
+
# getaddrinfo to AF_INET keeps DNS dynamic (ALB IPs rotate) but skips IPv6.
|
|
32
|
+
_orig_getaddrinfo = _socket.getaddrinfo
|
|
33
|
+
def _getaddrinfo_ipv4(host, port, family=0, *a, **kw):
|
|
34
|
+
return _orig_getaddrinfo(host, port, _socket.AF_INET, *a, **kw)
|
|
35
|
+
_socket.getaddrinfo = _getaddrinfo_ipv4
|
|
36
|
+
|
|
37
|
+
# Jira configuration
|
|
38
|
+
JIRA_BASE = os.environ.get("JIRA_BASE_URL", "").rstrip("/")
|
|
39
|
+
JIRA_TOKEN = os.environ.get("JIRA_TOKEN")
|
|
40
|
+
|
|
41
|
+
# Bitbucket configuration
|
|
42
|
+
BITBUCKET_BASE = os. environ.get("BITBUCKET_BASE_URL", "").rstrip("/")
|
|
43
|
+
BITBUCKET_TOKEN = os.environ.get("BITBUCKET_TOKEN")
|
|
44
|
+
|
|
45
|
+
# Bamboo configuration (optional — only needed for get_bamboo_* tools)
|
|
46
|
+
BAMBOO_BASE = os.environ.get("BAMBOO_BASE_URL", "").rstrip("/")
|
|
47
|
+
BAMBOO_TOKEN = os.environ.get("BAMBOO_TOKEN")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def jira_request(endpoint, method="GET", data=None):
|
|
51
|
+
"""Make a request to Jira REST API v2."""
|
|
52
|
+
if not JIRA_BASE:
|
|
53
|
+
raise RuntimeError(
|
|
54
|
+
"JIRA_BASE_URL is empty. The planetexpress plugin sets this from "
|
|
55
|
+
"its bundled .mcp.json — reinstall or update the plugin to restore "
|
|
56
|
+
"the default."
|
|
57
|
+
)
|
|
58
|
+
if not JIRA_TOKEN:
|
|
59
|
+
raise RuntimeError(
|
|
60
|
+
"JIRA_TOKEN is empty. The planetexpress plugin reads it from your "
|
|
61
|
+
"user config (~/.claude/settings.json → pluginConfigs.planetexpress."
|
|
62
|
+
"options.jira_token). Run `/plugin` and reconfigure planetexpress "
|
|
63
|
+
"to set the Jira personal access token, then restart Claude Code."
|
|
64
|
+
)
|
|
65
|
+
auth_header = f"Bearer {JIRA_TOKEN}"
|
|
66
|
+
url = f"{JIRA_BASE}/rest/api/2/{endpoint}"
|
|
67
|
+
body = json.dumps(data).encode() if data is not None else None
|
|
68
|
+
req = Request(
|
|
69
|
+
url,
|
|
70
|
+
headers={"Authorization": auth_header, "Content-Type": "application/json"},
|
|
71
|
+
data=body, method=method
|
|
72
|
+
)
|
|
73
|
+
try:
|
|
74
|
+
with urllib.request.urlopen(req) as resp:
|
|
75
|
+
raw = resp.read()
|
|
76
|
+
return json.loads(raw) if raw else {}
|
|
77
|
+
except Exception as e:
|
|
78
|
+
raise RuntimeError(f"{e} (URL: {url})") from e
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _bitbucket_call(api_root, endpoint, method="GET", data=None):
|
|
82
|
+
"""Shared Bitbucket call — used by bitbucket_request (api/1.0) and the
|
|
83
|
+
build-status endpoint (build-status/1.0). ``api_root`` is the full path
|
|
84
|
+
prefix between host and endpoint, e.g. ``/rest/api/1.0/``."""
|
|
85
|
+
if not BITBUCKET_BASE:
|
|
86
|
+
raise RuntimeError(
|
|
87
|
+
"BITBUCKET_BASE_URL is empty. The planetexpress plugin sets this "
|
|
88
|
+
"from its bundled .mcp.json — reinstall or update the plugin to "
|
|
89
|
+
"restore the default."
|
|
90
|
+
)
|
|
91
|
+
if not BITBUCKET_TOKEN:
|
|
92
|
+
raise RuntimeError(
|
|
93
|
+
"BITBUCKET_TOKEN is empty. The planetexpress plugin reads it from "
|
|
94
|
+
"your user config (~/.claude/settings.json → pluginConfigs."
|
|
95
|
+
"planetexpress.options.bitbucket_token). Run `/plugin` and "
|
|
96
|
+
"reconfigure planetexpress to set the Bitbucket HTTP access token, "
|
|
97
|
+
"then restart Claude Code."
|
|
98
|
+
)
|
|
99
|
+
body = json.dumps(data).encode() if data else None
|
|
100
|
+
url = f"{BITBUCKET_BASE}{api_root}{endpoint}"
|
|
101
|
+
req = Request(
|
|
102
|
+
url,
|
|
103
|
+
headers={"Authorization": f"Bearer {BITBUCKET_TOKEN}", "Content-Type": "application/json"},
|
|
104
|
+
data=body, method=method
|
|
105
|
+
)
|
|
106
|
+
try:
|
|
107
|
+
with urllib.request.urlopen(req) as resp:
|
|
108
|
+
raw = resp.read()
|
|
109
|
+
return json.loads(raw) if raw else {}
|
|
110
|
+
except Exception as e:
|
|
111
|
+
raise RuntimeError(f"{e} (URL: {url})") from e
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def bitbucket_request(endpoint, method="GET", data=None):
|
|
115
|
+
"""Make a request to Bitbucket REST API 1.0."""
|
|
116
|
+
return _bitbucket_call("/rest/api/1.0/", endpoint, method, data)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def jira_devstatus_request(endpoint):
|
|
120
|
+
"""Make a GET request to Jira's internal /rest/dev-status endpoint.
|
|
121
|
+
|
|
122
|
+
Used to fetch Development-panel data (linked PRs, branches, commits)
|
|
123
|
+
that is *not* exposed via the standard /rest/api/2 issue API.
|
|
124
|
+
Atlassian flags this endpoint as undocumented and subject to change.
|
|
125
|
+
"""
|
|
126
|
+
if not JIRA_BASE or not JIRA_TOKEN:
|
|
127
|
+
raise RuntimeError(
|
|
128
|
+
"JIRA_BASE_URL or JIRA_TOKEN is unset — see jira_request() for "
|
|
129
|
+
"configuration details."
|
|
130
|
+
)
|
|
131
|
+
url = f"{JIRA_BASE}/rest/dev-status/1.0/{endpoint}"
|
|
132
|
+
req = Request(url, headers={"Authorization": f"Bearer {JIRA_TOKEN}"})
|
|
133
|
+
try:
|
|
134
|
+
with urllib.request.urlopen(req) as resp:
|
|
135
|
+
raw = resp.read()
|
|
136
|
+
return json.loads(raw) if raw else {}
|
|
137
|
+
except Exception as e:
|
|
138
|
+
raise RuntimeError(f"{e} (URL: {url})") from e
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def bamboo_request(path, accept_json=True, return_headers=False):
|
|
142
|
+
"""Make a request to Bamboo. ``path`` includes leading slash (e.g.
|
|
143
|
+
``/rest/api/latest/result/...`` or ``/download/...``).
|
|
144
|
+
|
|
145
|
+
When ``accept_json=False`` returns raw bytes (used for log/artifact
|
|
146
|
+
downloads). When ``return_headers=True`` returns ``(raw_bytes,
|
|
147
|
+
content_type)`` instead — used by the artifact fetch tool to detect text
|
|
148
|
+
vs binary from the real Content-Type.
|
|
149
|
+
"""
|
|
150
|
+
if not BAMBOO_BASE:
|
|
151
|
+
raise RuntimeError(
|
|
152
|
+
"BAMBOO_BASE_URL is empty. The planetexpress plugin sets this "
|
|
153
|
+
"from its bundled .mcp.json — reinstall or update the plugin to "
|
|
154
|
+
"restore the default."
|
|
155
|
+
)
|
|
156
|
+
if not BAMBOO_TOKEN:
|
|
157
|
+
raise RuntimeError(
|
|
158
|
+
"BAMBOO_TOKEN is empty. The planetexpress plugin reads it from "
|
|
159
|
+
"your user config (planetexpress.options.bamboo_token). Run "
|
|
160
|
+
"`/plugin` and configure the Bamboo personal access token, then "
|
|
161
|
+
"restart Claude Code."
|
|
162
|
+
)
|
|
163
|
+
url = f"{BAMBOO_BASE}{path}"
|
|
164
|
+
headers = {"Authorization": f"Bearer {BAMBOO_TOKEN}"}
|
|
165
|
+
if accept_json:
|
|
166
|
+
headers["Accept"] = "application/json"
|
|
167
|
+
req = Request(url, headers=headers)
|
|
168
|
+
try:
|
|
169
|
+
with urllib.request.urlopen(req) as resp:
|
|
170
|
+
raw = resp.read()
|
|
171
|
+
if return_headers:
|
|
172
|
+
try:
|
|
173
|
+
ctype = resp.headers.get("Content-Type", "") or ""
|
|
174
|
+
except Exception:
|
|
175
|
+
ctype = ""
|
|
176
|
+
return raw, ctype
|
|
177
|
+
return (json.loads(raw) if raw else {}) if accept_json else raw
|
|
178
|
+
except ssl.SSLCertVerificationError as e:
|
|
179
|
+
raise RuntimeError(
|
|
180
|
+
f"{e} (URL: {url}). The server may serve an incomplete cert chain "
|
|
181
|
+
"(e.g. ci.e-spirit.de). Install the `truststore` package — "
|
|
182
|
+
"`pip3 install truststore` — to use the OS trust store for "
|
|
183
|
+
"AIA chasing, then restart the MCP."
|
|
184
|
+
) from e
|
|
185
|
+
except Exception as e:
|
|
186
|
+
raise RuntimeError(f"{e} (URL: {url})") from e
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def get_ticket(ticket_id: str) -> dict:
|
|
190
|
+
"""Fetch Jira ticket details including comments and attachment metadata."""
|
|
191
|
+
data = jira_request(f"issue/{quote(ticket_id)}")
|
|
192
|
+
fields = data. get("fields", {})
|
|
193
|
+
|
|
194
|
+
# Extract comments
|
|
195
|
+
comment_data = fields.get("comment", {})
|
|
196
|
+
comments = []
|
|
197
|
+
for c in comment_data.get("comments", []):
|
|
198
|
+
comments.append({
|
|
199
|
+
"id": c.get("id"),
|
|
200
|
+
"author": (c.get("author") or {}).get("displayName", "Unknown"),
|
|
201
|
+
"body": c.get("body", ""),
|
|
202
|
+
"created": c.get("created", ""),
|
|
203
|
+
"updated": c.get("updated", ""),
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
# Extract attachment metadata (content is fetched separately via download_jira_attachment)
|
|
207
|
+
attachments = []
|
|
208
|
+
for a in fields.get("attachment", []):
|
|
209
|
+
attachments.append({
|
|
210
|
+
"id": a.get("id"),
|
|
211
|
+
"filename": a.get("filename", ""),
|
|
212
|
+
"size": a.get("size"),
|
|
213
|
+
"mimeType": a.get("mimeType", ""),
|
|
214
|
+
"author": (a.get("author") or {}).get("displayName", "Unknown"),
|
|
215
|
+
"created": a.get("created", ""),
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
# Extract issue links (relationships to other tickets). The relationship
|
|
219
|
+
# phrase is taken from THIS ticket's perspective: a link carrying an
|
|
220
|
+
# outwardIssue uses the type's outward phrase, an inwardIssue the inward.
|
|
221
|
+
links = []
|
|
222
|
+
for link in fields.get("issuelinks", []) or []:
|
|
223
|
+
link_type = link.get("type", {}) or {}
|
|
224
|
+
if link.get("outwardIssue"):
|
|
225
|
+
relationship = link_type.get("outward", "")
|
|
226
|
+
other = link["outwardIssue"]
|
|
227
|
+
elif link.get("inwardIssue"):
|
|
228
|
+
relationship = link_type.get("inward", "")
|
|
229
|
+
other = link["inwardIssue"]
|
|
230
|
+
else:
|
|
231
|
+
continue
|
|
232
|
+
other_fields = other.get("fields", {}) or {}
|
|
233
|
+
links.append({
|
|
234
|
+
"relationship": relationship,
|
|
235
|
+
"type": link_type.get("name", ""),
|
|
236
|
+
"key": other.get("key", ""),
|
|
237
|
+
"summary": other_fields.get("summary", ""),
|
|
238
|
+
"status": (other_fields.get("status") or {}).get("name", ""),
|
|
239
|
+
"link": f"{JIRA_BASE}/browse/{other.get('key', '')}",
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
"key": data["key"],
|
|
244
|
+
"summary": fields.get("summary", "No summary"),
|
|
245
|
+
"description": fields.get("description") or "No description",
|
|
246
|
+
"issuetype": (fields.get("issuetype") or {}).get("name", "Unknown"),
|
|
247
|
+
"status": fields.get("status", {}).get("name", "Unknown"),
|
|
248
|
+
"labels": fields.get("labels", []) or [],
|
|
249
|
+
"priority": fields.get("priority", {}).get("name", "Unknown"),
|
|
250
|
+
"assignee": (fields.get("assignee") or {}).get("displayName", "Unassigned"),
|
|
251
|
+
"reporter": (fields.get("reporter") or {}).get("displayName", "Unknown"),
|
|
252
|
+
"created": fields.get("created", ""),
|
|
253
|
+
"updated": fields.get("updated", ""),
|
|
254
|
+
"link": f"{JIRA_BASE}/browse/{data['key']}",
|
|
255
|
+
"comments": comments,
|
|
256
|
+
"attachments": attachments,
|
|
257
|
+
"links": links,
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def add_jira_comment(ticket_id: str, body: str) -> dict:
|
|
262
|
+
"""Add a comment to a Jira ticket. ``body`` uses Jira wiki markup."""
|
|
263
|
+
data = jira_request(
|
|
264
|
+
f"issue/{quote(ticket_id)}/comment",
|
|
265
|
+
method="POST",
|
|
266
|
+
data={"body": body},
|
|
267
|
+
)
|
|
268
|
+
return {
|
|
269
|
+
"id": data.get("id"),
|
|
270
|
+
"author": (data.get("author") or {}).get("displayName", "Unknown"),
|
|
271
|
+
"created": data.get("created", ""),
|
|
272
|
+
"link": f"{JIRA_BASE}/browse/{ticket_id}?focusedCommentId={data.get('id')}",
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def create_jira_ticket(
|
|
277
|
+
project_key: str,
|
|
278
|
+
summary: str,
|
|
279
|
+
issue_type: str = "Story",
|
|
280
|
+
description: str = "",
|
|
281
|
+
priority: str = "",
|
|
282
|
+
assignee: str = "",
|
|
283
|
+
labels: str = "",
|
|
284
|
+
parent: str = "",
|
|
285
|
+
) -> dict:
|
|
286
|
+
"""Create a new Jira ticket.
|
|
287
|
+
|
|
288
|
+
``description`` uses Jira wiki markup. ``labels`` is a comma-separated list.
|
|
289
|
+
``parent`` is the key of the parent ticket (required when ``issue_type`` is
|
|
290
|
+
``Sub-task``).
|
|
291
|
+
"""
|
|
292
|
+
fields: dict = {
|
|
293
|
+
"project": {"key": project_key},
|
|
294
|
+
"summary": summary,
|
|
295
|
+
"issuetype": {"name": issue_type},
|
|
296
|
+
}
|
|
297
|
+
if description:
|
|
298
|
+
fields["description"] = description
|
|
299
|
+
if priority:
|
|
300
|
+
fields["priority"] = {"name": priority}
|
|
301
|
+
if assignee:
|
|
302
|
+
fields["assignee"] = {"name": assignee}
|
|
303
|
+
label_list = [l.strip() for l in labels.split(",") if l.strip()]
|
|
304
|
+
if label_list:
|
|
305
|
+
fields["labels"] = label_list
|
|
306
|
+
if parent:
|
|
307
|
+
fields["parent"] = {"key": parent}
|
|
308
|
+
|
|
309
|
+
data = jira_request("issue", method="POST", data={"fields": fields})
|
|
310
|
+
key = data.get("key", "")
|
|
311
|
+
return {
|
|
312
|
+
"key": key,
|
|
313
|
+
"id": data.get("id"),
|
|
314
|
+
"link": f"{JIRA_BASE}/browse/{key}" if key else "",
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def update_jira_ticket(
|
|
319
|
+
issue_key: str,
|
|
320
|
+
description: str = "",
|
|
321
|
+
summary: str = "",
|
|
322
|
+
add_labels: str = "",
|
|
323
|
+
remove_labels: str = "",
|
|
324
|
+
) -> dict:
|
|
325
|
+
"""Update an existing Jira ticket's description, summary and/or labels.
|
|
326
|
+
|
|
327
|
+
Uses PUT /rest/api/2/issue/{key} which returns 204 No Content on success.
|
|
328
|
+
``add_labels`` / ``remove_labels`` are comma-separated label lists applied
|
|
329
|
+
additively via Jira's ``update`` verb, so existing labels are preserved.
|
|
330
|
+
At least one of description, summary, add_labels or remove_labels must be
|
|
331
|
+
provided.
|
|
332
|
+
"""
|
|
333
|
+
if not issue_key:
|
|
334
|
+
raise ValueError("issue_key must not be empty")
|
|
335
|
+
to_add = [l.strip() for l in add_labels.split(",") if l.strip()]
|
|
336
|
+
to_remove = [l.strip() for l in remove_labels.split(",") if l.strip()]
|
|
337
|
+
if not description and not summary and not to_add and not to_remove:
|
|
338
|
+
raise ValueError(
|
|
339
|
+
"At least one of 'description', 'summary', 'add_labels' or "
|
|
340
|
+
"'remove_labels' must be provided"
|
|
341
|
+
)
|
|
342
|
+
payload: dict = {}
|
|
343
|
+
fields: dict = {}
|
|
344
|
+
if description:
|
|
345
|
+
fields["description"] = description
|
|
346
|
+
if summary:
|
|
347
|
+
fields["summary"] = summary
|
|
348
|
+
if fields:
|
|
349
|
+
payload["fields"] = fields
|
|
350
|
+
label_ops = [{"add": l} for l in to_add] + [{"remove": l} for l in to_remove]
|
|
351
|
+
if label_ops:
|
|
352
|
+
payload["update"] = {"labels": label_ops}
|
|
353
|
+
jira_request(f"issue/{quote(issue_key)}", method="PUT", data=payload)
|
|
354
|
+
return {
|
|
355
|
+
"key": issue_key,
|
|
356
|
+
"updated": True,
|
|
357
|
+
"added_labels": to_add,
|
|
358
|
+
"removed_labels": to_remove,
|
|
359
|
+
"link": f"{JIRA_BASE}/browse/{issue_key}",
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def get_jira_transitions(issue_key: str) -> dict:
|
|
364
|
+
"""List the workflow transitions currently available on a Jira ticket.
|
|
365
|
+
|
|
366
|
+
Jira only exposes transitions reachable from the ticket's *current* status,
|
|
367
|
+
so a multi-step move (e.g. Triage -> Backlog -> Committed) is walked one
|
|
368
|
+
transition at a time. Each entry gives the transition ``name`` and the
|
|
369
|
+
``to_status`` it lands the ticket in — pass that status to
|
|
370
|
+
``transition_jira_ticket``.
|
|
371
|
+
"""
|
|
372
|
+
if not issue_key:
|
|
373
|
+
raise ValueError("issue_key must not be empty")
|
|
374
|
+
data = jira_request(f"issue/{quote(issue_key)}/transitions")
|
|
375
|
+
transitions = []
|
|
376
|
+
for t in data.get("transitions", []) or []:
|
|
377
|
+
transitions.append({
|
|
378
|
+
"id": t.get("id"),
|
|
379
|
+
"name": t.get("name"),
|
|
380
|
+
"to_status": (t.get("to") or {}).get("name"),
|
|
381
|
+
})
|
|
382
|
+
return {"key": issue_key, "transitions": transitions}
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def transition_jira_ticket(issue_key: str, status: str) -> dict:
|
|
386
|
+
"""Move a Jira ticket to a new status by performing a workflow transition.
|
|
387
|
+
|
|
388
|
+
``status`` is matched (case-insensitively) against the target status of the
|
|
389
|
+
transitions currently available on the ticket — matching either the target
|
|
390
|
+
status name or the transition name. Only one step is performed per call;
|
|
391
|
+
for a multi-step move call this repeatedly (the reachable set changes after
|
|
392
|
+
each transition). If no available transition matches, raises with the list
|
|
393
|
+
of transitions that ARE available from the current status.
|
|
394
|
+
"""
|
|
395
|
+
if not issue_key:
|
|
396
|
+
raise ValueError("issue_key must not be empty")
|
|
397
|
+
if not status:
|
|
398
|
+
raise ValueError("status must not be empty")
|
|
399
|
+
available = get_jira_transitions(issue_key)["transitions"]
|
|
400
|
+
target = status.strip().lower()
|
|
401
|
+
match = next(
|
|
402
|
+
(
|
|
403
|
+
t for t in available
|
|
404
|
+
if (t.get("to_status") or "").lower() == target
|
|
405
|
+
or (t.get("name") or "").lower() == target
|
|
406
|
+
),
|
|
407
|
+
None,
|
|
408
|
+
)
|
|
409
|
+
if not match:
|
|
410
|
+
options = ", ".join(
|
|
411
|
+
f"{t['name']} -> {t['to_status']}" for t in available
|
|
412
|
+
) or "(none)"
|
|
413
|
+
raise ValueError(
|
|
414
|
+
f"No transition to '{status}' available from the current status of "
|
|
415
|
+
f"{issue_key}. Available transitions: {options}"
|
|
416
|
+
)
|
|
417
|
+
jira_request(
|
|
418
|
+
f"issue/{quote(issue_key)}/transitions",
|
|
419
|
+
method="POST",
|
|
420
|
+
data={"transition": {"id": match["id"]}},
|
|
421
|
+
)
|
|
422
|
+
return {
|
|
423
|
+
"key": issue_key,
|
|
424
|
+
"transitioned": True,
|
|
425
|
+
"transition": match["name"],
|
|
426
|
+
"status": match["to_status"],
|
|
427
|
+
"link": f"{JIRA_BASE}/browse/{issue_key}",
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def get_jira_link_types() -> dict:
|
|
432
|
+
"""Fetch the issue-link relationship types configured on this Jira
|
|
433
|
+
instance (Relates, Blocks, custom types like 'follows on from', ...).
|
|
434
|
+
|
|
435
|
+
Each type carries its human-readable ``outward`` and ``inward`` phrases,
|
|
436
|
+
which are the valid relationship values for ``link_jira_issues``.
|
|
437
|
+
"""
|
|
438
|
+
data = jira_request("issueLinkType")
|
|
439
|
+
types = []
|
|
440
|
+
for t in data.get("issueLinkTypes", []) or []:
|
|
441
|
+
types.append({
|
|
442
|
+
"id": t.get("id"),
|
|
443
|
+
"name": t.get("name"),
|
|
444
|
+
"inward": t.get("inward"),
|
|
445
|
+
"outward": t.get("outward"),
|
|
446
|
+
})
|
|
447
|
+
return {"link_types": types}
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def link_jira_issues(from_issue: str, to_issue: str, relationship: str) -> dict:
|
|
451
|
+
"""Create a directional issue link between two tickets using a natural
|
|
452
|
+
relationship phrase.
|
|
453
|
+
|
|
454
|
+
``relationship`` is a phrase such as ``blocks``, ``is blocked by``,
|
|
455
|
+
``relates to`` or ``follows on from`` (discover valid phrases via
|
|
456
|
+
``get_jira_link_types``). The phrase is resolved against the instance's
|
|
457
|
+
link types to pick the link type AND direction: the result reads
|
|
458
|
+
``from_issue <relationship> to_issue``.
|
|
459
|
+
"""
|
|
460
|
+
if not from_issue or not to_issue:
|
|
461
|
+
raise ValueError("from_issue and to_issue must not be empty")
|
|
462
|
+
if not relationship or not relationship.strip():
|
|
463
|
+
raise ValueError("relationship must not be empty")
|
|
464
|
+
|
|
465
|
+
rel = relationship.strip().lower()
|
|
466
|
+
raw = jira_request("issueLinkType")
|
|
467
|
+
link_types = raw.get("issueLinkTypes", []) or []
|
|
468
|
+
|
|
469
|
+
# Resolve the phrase to (type, direction, applied-phrase) candidates.
|
|
470
|
+
candidates = []
|
|
471
|
+
for t in link_types:
|
|
472
|
+
name = (t.get("name") or "")
|
|
473
|
+
outward = (t.get("outward") or "")
|
|
474
|
+
inward = (t.get("inward") or "")
|
|
475
|
+
if rel == outward.lower():
|
|
476
|
+
candidates.append((t, "outward", outward))
|
|
477
|
+
# Symmetry guard: a symmetric type (inward == outward) must yield only
|
|
478
|
+
# the outward candidate, otherwise it would falsely look ambiguous.
|
|
479
|
+
if rel == inward.lower() and inward.lower() != outward.lower():
|
|
480
|
+
candidates.append((t, "inward", inward))
|
|
481
|
+
if rel == name.lower():
|
|
482
|
+
candidates.append((t, "outward", outward))
|
|
483
|
+
|
|
484
|
+
# De-duplicate by (type name, direction).
|
|
485
|
+
seen = {}
|
|
486
|
+
for t, direction, phrase in candidates:
|
|
487
|
+
seen.setdefault((t.get("name"), direction), (t, direction, phrase))
|
|
488
|
+
unique = list(seen.values())
|
|
489
|
+
|
|
490
|
+
if not unique:
|
|
491
|
+
phrases = sorted({
|
|
492
|
+
p for t in link_types
|
|
493
|
+
for p in (t.get("outward"), t.get("inward")) if p
|
|
494
|
+
})
|
|
495
|
+
raise ValueError(
|
|
496
|
+
f"No link type matches relationship {relationship!r}. "
|
|
497
|
+
f"Available phrases: {', '.join(phrases)}"
|
|
498
|
+
)
|
|
499
|
+
if len(unique) > 1:
|
|
500
|
+
opts = "; ".join(
|
|
501
|
+
f"{t.get('name')} ({direction}: {phrase})"
|
|
502
|
+
for t, direction, phrase in unique
|
|
503
|
+
)
|
|
504
|
+
raise ValueError(
|
|
505
|
+
f"Relationship {relationship!r} is ambiguous across link types: "
|
|
506
|
+
f"{opts}. Use a more specific phrase."
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
link_type, direction, phrase = unique[0]
|
|
510
|
+
# Jira renders a stored link as ``inwardIssue <outward-phrase> outwardIssue``
|
|
511
|
+
# (equivalently ``outwardIssue <inward-phrase> inwardIssue``). So to make the
|
|
512
|
+
# link read ``from_issue <relationship> to_issue`` the from_issue goes into
|
|
513
|
+
# the slot OPPOSITE the matched phrase's name.
|
|
514
|
+
if direction == "outward":
|
|
515
|
+
inward_key, outward_key = from_issue, to_issue
|
|
516
|
+
else:
|
|
517
|
+
outward_key, inward_key = from_issue, to_issue
|
|
518
|
+
|
|
519
|
+
jira_request(
|
|
520
|
+
"issueLink",
|
|
521
|
+
method="POST",
|
|
522
|
+
data={
|
|
523
|
+
"type": {"name": link_type.get("name")},
|
|
524
|
+
"outwardIssue": {"key": outward_key},
|
|
525
|
+
"inwardIssue": {"key": inward_key},
|
|
526
|
+
},
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
return {
|
|
530
|
+
"from": from_issue,
|
|
531
|
+
"to": to_issue,
|
|
532
|
+
"type": link_type.get("name"),
|
|
533
|
+
"relationship": phrase,
|
|
534
|
+
"summary": f"{from_issue} {phrase} {to_issue}",
|
|
535
|
+
"from_link": f"{JIRA_BASE}/browse/{from_issue}",
|
|
536
|
+
"to_link": f"{JIRA_BASE}/browse/{to_issue}",
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def download_jira_attachment(attachment_id: str) -> dict:
|
|
541
|
+
"""Download a Jira attachment by ID and save it to a temp file.
|
|
542
|
+
|
|
543
|
+
Returns the local file path so the AI can read/inspect the file.
|
|
544
|
+
Text-based files (source code, XML, JSON, CSV, plain text, etc.) are
|
|
545
|
+
returned inline as ``content`` to avoid an extra read round-trip.
|
|
546
|
+
"""
|
|
547
|
+
# Fetch attachment metadata to get the download URL and filename
|
|
548
|
+
meta = jira_request(f"attachment/{quote(attachment_id)}")
|
|
549
|
+
content_url = meta.get("content", "")
|
|
550
|
+
filename = meta.get("filename", f"attachment_{attachment_id}")
|
|
551
|
+
mime_type = meta.get("mimeType", "application/octet-stream")
|
|
552
|
+
|
|
553
|
+
if not content_url:
|
|
554
|
+
return {"error": "No download URL found for this attachment"}
|
|
555
|
+
|
|
556
|
+
# Download the actual file content
|
|
557
|
+
auth_header = f"Bearer {JIRA_TOKEN}"
|
|
558
|
+
req = Request(content_url, headers={"Authorization": auth_header})
|
|
559
|
+
with urllib.request.urlopen(req) as resp:
|
|
560
|
+
file_bytes = resp.read()
|
|
561
|
+
|
|
562
|
+
# Save to a temp file (preserving the original extension)
|
|
563
|
+
suffix = ""
|
|
564
|
+
if "." in filename:
|
|
565
|
+
suffix = "." + filename.rsplit(".", 1)[1]
|
|
566
|
+
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix, prefix="jira_att_")
|
|
567
|
+
tmp.write(file_bytes)
|
|
568
|
+
tmp.close()
|
|
569
|
+
|
|
570
|
+
result = {
|
|
571
|
+
"id": attachment_id,
|
|
572
|
+
"filename": filename,
|
|
573
|
+
"mimeType": mime_type,
|
|
574
|
+
"size": len(file_bytes),
|
|
575
|
+
"localPath": tmp.name,
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
# For text-based files, include content inline so the AI can read it directly
|
|
579
|
+
TEXT_MIMES = (
|
|
580
|
+
"text/", "application/json", "application/xml", "application/xhtml",
|
|
581
|
+
"application/javascript", "application/typescript", "application/x-yaml",
|
|
582
|
+
"application/yaml", "application/csv", "application/sql",
|
|
583
|
+
"application/x-sh", "application/x-python",
|
|
584
|
+
)
|
|
585
|
+
if any(mime_type.startswith(m) for m in TEXT_MIMES):
|
|
586
|
+
try:
|
|
587
|
+
result["content"] = file_bytes.decode("utf-8")
|
|
588
|
+
except UnicodeDecodeError:
|
|
589
|
+
try:
|
|
590
|
+
result["content"] = file_bytes.decode("latin-1")
|
|
591
|
+
except UnicodeDecodeError:
|
|
592
|
+
pass # Binary after all — the AI can read via localPath
|
|
593
|
+
|
|
594
|
+
return result
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def get_bitbucket_pr(project: str, repo: str, pr_id: str) -> dict:
|
|
598
|
+
"""Fetch Bitbucket pull request details."""
|
|
599
|
+
data = bitbucket_request(f"projects/{quote(project)}/repos/{quote(repo)}/pull-requests/{quote(pr_id)}")
|
|
600
|
+
from_ref = data.get("fromRef", {}) or {}
|
|
601
|
+
to_ref = data.get("toRef", {}) or {}
|
|
602
|
+
return {
|
|
603
|
+
"id": data. get("id"),
|
|
604
|
+
"title": data.get("title"),
|
|
605
|
+
"description": data.get("description") or "No description",
|
|
606
|
+
"state": data.get("state"),
|
|
607
|
+
"author": data.get("author", {}).get("user", {}).get("displayName", "Unknown"),
|
|
608
|
+
"fromRef": from_ref.get("displayId", ""),
|
|
609
|
+
"fromCommit": from_ref.get("latestCommit", ""),
|
|
610
|
+
"toRef": to_ref.get("displayId", ""),
|
|
611
|
+
"toCommit": to_ref.get("latestCommit", ""),
|
|
612
|
+
"reviewers": [r.get("user", {}).get("displayName", "") for r in data.get("reviewers", [])],
|
|
613
|
+
"link": data.get("links", {}).get("self", [{}])[0].get("href", "")
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
_PR_URL_RE = re.compile(r"/projects/([^/]+)/repos/([^/]+)/pull-requests/")
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _parse_pr_url(url: str):
|
|
621
|
+
"""Parse a Bitbucket Server PR URL into (project, slug). Returns (None, None) on failure."""
|
|
622
|
+
if not url:
|
|
623
|
+
return None, None
|
|
624
|
+
m = _PR_URL_RE.search(url)
|
|
625
|
+
return (m.group(1), m.group(2)) if m else (None, None)
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def get_jira_dev_info(ticket_id: str, data_type: str = "pullrequest") -> dict:
|
|
629
|
+
"""Fetch the Development-panel data for a Jira ticket: linked Bitbucket
|
|
630
|
+
PRs, branches, commits, or repositories.
|
|
631
|
+
|
|
632
|
+
Resolves the ticket key to a numeric id (required by the dev-status
|
|
633
|
+
endpoint), then flattens results across all configured Bitbucket
|
|
634
|
+
application links. Each item carries its source ``_instance.baseUrl``
|
|
635
|
+
so callers can tell which Bitbucket the link belongs to.
|
|
636
|
+
"""
|
|
637
|
+
valid = {"pullrequest", "branch", "commit", "repository"}
|
|
638
|
+
if data_type not in valid:
|
|
639
|
+
raise ValueError(f"data_type must be one of {sorted(valid)}, got {data_type!r}")
|
|
640
|
+
|
|
641
|
+
issue = jira_request(f"issue/{quote(ticket_id)}?fields=*none")
|
|
642
|
+
issue_id = issue.get("id")
|
|
643
|
+
if not issue_id:
|
|
644
|
+
raise RuntimeError(f"Could not resolve numeric id for ticket {ticket_id}")
|
|
645
|
+
|
|
646
|
+
raw = jira_devstatus_request(
|
|
647
|
+
f"issue/detail?issueId={quote(str(issue_id))}"
|
|
648
|
+
f"&applicationType=stash&dataType={quote(data_type)}"
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
items = []
|
|
652
|
+
instances = []
|
|
653
|
+
for detail in raw.get("detail", []) or []:
|
|
654
|
+
inst = detail.get("_instance") or {}
|
|
655
|
+
if inst:
|
|
656
|
+
instances.append({
|
|
657
|
+
"name": inst.get("name"),
|
|
658
|
+
"baseUrl": inst.get("baseUrl"),
|
|
659
|
+
"primary": inst.get("primary"),
|
|
660
|
+
})
|
|
661
|
+
instance_url = inst.get("baseUrl", "")
|
|
662
|
+
|
|
663
|
+
if data_type == "pullrequest":
|
|
664
|
+
for pr in detail.get("pullRequests", []) or []:
|
|
665
|
+
project, slug = _parse_pr_url(pr.get("url", ""))
|
|
666
|
+
src = pr.get("source") or {}
|
|
667
|
+
dst = pr.get("destination") or {}
|
|
668
|
+
items.append({
|
|
669
|
+
"id": pr.get("id"),
|
|
670
|
+
"name": pr.get("name"),
|
|
671
|
+
"url": pr.get("url"),
|
|
672
|
+
"status": pr.get("status"),
|
|
673
|
+
"lastUpdate": pr.get("lastUpdate"),
|
|
674
|
+
"source": src.get("branch"),
|
|
675
|
+
"destination": dst.get("branch"),
|
|
676
|
+
"author": (pr.get("author") or {}).get("name"),
|
|
677
|
+
"reviewers": [r.get("name") for r in pr.get("reviewers", []) or []],
|
|
678
|
+
"commentCount": pr.get("commentCount"),
|
|
679
|
+
"repository": {
|
|
680
|
+
"project": project,
|
|
681
|
+
"slug": slug,
|
|
682
|
+
"name": (src.get("repository") or {}).get("name"),
|
|
683
|
+
"instanceUrl": instance_url,
|
|
684
|
+
},
|
|
685
|
+
})
|
|
686
|
+
elif data_type == "branch":
|
|
687
|
+
for b in detail.get("branches", []) or []:
|
|
688
|
+
items.append({
|
|
689
|
+
"name": b.get("name"),
|
|
690
|
+
"url": b.get("url"),
|
|
691
|
+
"createPullRequestUrl": b.get("createPullRequestUrl"),
|
|
692
|
+
"repository": (b.get("repository") or {}).get("name"),
|
|
693
|
+
"instanceUrl": instance_url,
|
|
694
|
+
})
|
|
695
|
+
elif data_type == "commit":
|
|
696
|
+
for c in detail.get("commits", []) or []:
|
|
697
|
+
items.append({
|
|
698
|
+
"id": c.get("id"),
|
|
699
|
+
"displayId": c.get("displayId"),
|
|
700
|
+
"message": c.get("message"),
|
|
701
|
+
"url": c.get("url"),
|
|
702
|
+
"author": (c.get("author") or {}).get("name"),
|
|
703
|
+
"instanceUrl": instance_url,
|
|
704
|
+
})
|
|
705
|
+
elif data_type == "repository":
|
|
706
|
+
for r in detail.get("repositories", []) or []:
|
|
707
|
+
items.append({
|
|
708
|
+
"name": r.get("name"),
|
|
709
|
+
"url": r.get("url"),
|
|
710
|
+
"avatarDescription": r.get("avatarDescription"),
|
|
711
|
+
"instanceUrl": instance_url,
|
|
712
|
+
})
|
|
713
|
+
|
|
714
|
+
return {
|
|
715
|
+
"ticket": ticket_id,
|
|
716
|
+
"issueId": issue_id,
|
|
717
|
+
"dataType": data_type,
|
|
718
|
+
"errors": raw.get("errors", []),
|
|
719
|
+
"instances": instances,
|
|
720
|
+
"items": items,
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def get_bitbucket_commit_build_status(commit_id: str) -> dict:
|
|
725
|
+
"""Fetch CI build statuses posted to a commit (Bamboo/etc. report here).
|
|
726
|
+
|
|
727
|
+
Returns the raw statuses plus a derived ``aggregateState`` so callers
|
|
728
|
+
can branch on a single field: ``GREEN`` (all SUCCESSFUL), ``RED`` (any
|
|
729
|
+
FAILED with no SUCCESSFUL), ``RUNNING`` (any INPROGRESS), ``MIXED``
|
|
730
|
+
(FAILED + SUCCESSFUL or other combos), ``NONE`` (no statuses posted).
|
|
731
|
+
"""
|
|
732
|
+
data = _bitbucket_call("/rest/build-status/1.0/", f"commits/{quote(commit_id)}")
|
|
733
|
+
statuses = []
|
|
734
|
+
for v in data.get("values", []) or []:
|
|
735
|
+
statuses.append({
|
|
736
|
+
"state": v.get("state"),
|
|
737
|
+
"key": v.get("key"),
|
|
738
|
+
"name": v.get("name"),
|
|
739
|
+
"url": v.get("url"),
|
|
740
|
+
"description": v.get("description"),
|
|
741
|
+
"dateAdded": v.get("dateAdded"),
|
|
742
|
+
})
|
|
743
|
+
|
|
744
|
+
states = {s["state"] for s in statuses if s.get("state")}
|
|
745
|
+
if not states:
|
|
746
|
+
agg = "NONE"
|
|
747
|
+
elif "INPROGRESS" in states:
|
|
748
|
+
agg = "RUNNING"
|
|
749
|
+
elif states == {"SUCCESSFUL"}:
|
|
750
|
+
agg = "GREEN"
|
|
751
|
+
elif "SUCCESSFUL" in states and ("FAILED" in states or "CANCELLED" in states):
|
|
752
|
+
agg = "MIXED"
|
|
753
|
+
else:
|
|
754
|
+
agg = "RED"
|
|
755
|
+
|
|
756
|
+
return {
|
|
757
|
+
"commit": commit_id,
|
|
758
|
+
"aggregateState": agg,
|
|
759
|
+
"statuses": statuses,
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def get_bamboo_build(build_key: str) -> dict:
|
|
764
|
+
"""Fetch a Bamboo build (chain or job) with per-job results and failed
|
|
765
|
+
tests in one call.
|
|
766
|
+
|
|
767
|
+
``build_key`` is e.g. ``HAUPIA-HAUPIATESTMETRICS36-8`` (chain build) or
|
|
768
|
+
``HAUPIA-HAUPIATESTMETRICS36-CC-1`` (job build). The chain key returns
|
|
769
|
+
aggregated test counts plus per-job results; the job key returns just
|
|
770
|
+
that job. Failed-test details are included in both — empty array when
|
|
771
|
+
the build is green.
|
|
772
|
+
"""
|
|
773
|
+
data = bamboo_request(
|
|
774
|
+
f"/rest/api/latest/result/{quote(build_key)}"
|
|
775
|
+
f"?expand=stages.stage.results.result,testResults.failedTests.testResult.errors"
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
jobs = []
|
|
779
|
+
for stage in (data.get("stages") or {}).get("stage", []) or []:
|
|
780
|
+
for r in (stage.get("results") or {}).get("result", []) or []:
|
|
781
|
+
jobs.append({
|
|
782
|
+
"key": r.get("buildResultKey"),
|
|
783
|
+
"shortName": r.get("shortName") or (r.get("plan") or {}).get("shortName"),
|
|
784
|
+
"state": r.get("state"),
|
|
785
|
+
"successful": r.get("successful"),
|
|
786
|
+
"durationDescription": r.get("buildDurationDescription"),
|
|
787
|
+
})
|
|
788
|
+
|
|
789
|
+
def _extract_failed(payload):
|
|
790
|
+
out = []
|
|
791
|
+
ft_root = ((payload.get("testResults") or {}).get("failedTests") or {})
|
|
792
|
+
for t in ft_root.get("testResult", []) or []:
|
|
793
|
+
errs = (t.get("errors") or {}).get("error", []) or []
|
|
794
|
+
out.append({
|
|
795
|
+
"className": t.get("className"),
|
|
796
|
+
"methodName": t.get("methodName"),
|
|
797
|
+
"status": t.get("status"),
|
|
798
|
+
"duration": t.get("duration"),
|
|
799
|
+
"buildResultKey": t.get("buildResultKey") or payload.get("buildResultKey"),
|
|
800
|
+
"errors": [{"message": (e.get("message") or "")[:4096]} for e in errs],
|
|
801
|
+
})
|
|
802
|
+
return out
|
|
803
|
+
|
|
804
|
+
failed = _extract_failed(data)
|
|
805
|
+
|
|
806
|
+
# Chain-level results return failedTestCount but an empty failedTests list;
|
|
807
|
+
# the per-test details live on each failed job. Fan out so callers don't
|
|
808
|
+
# need a second tool round-trip when a chain build is red.
|
|
809
|
+
if data.get("failedTestCount") and not failed:
|
|
810
|
+
for job in jobs:
|
|
811
|
+
if job.get("successful") is False or job.get("state") == "Failed":
|
|
812
|
+
try:
|
|
813
|
+
job_data = bamboo_request(
|
|
814
|
+
f"/rest/api/latest/result/{quote(job['key'])}"
|
|
815
|
+
f"?expand=testResults.failedTests.testResult.errors"
|
|
816
|
+
)
|
|
817
|
+
failed.extend(_extract_failed(job_data))
|
|
818
|
+
except Exception:
|
|
819
|
+
continue
|
|
820
|
+
|
|
821
|
+
plan = data.get("plan") or {}
|
|
822
|
+
key = data.get("buildResultKey")
|
|
823
|
+
browse_url = f"{BAMBOO_BASE}/browse/{key}" if key else ""
|
|
824
|
+
|
|
825
|
+
return {
|
|
826
|
+
"key": key,
|
|
827
|
+
"planKey": plan.get("key"),
|
|
828
|
+
"planName": plan.get("name"),
|
|
829
|
+
"branchName": data.get("planName"),
|
|
830
|
+
"state": data.get("state"),
|
|
831
|
+
"lifeCycleState": data.get("lifeCycleState"),
|
|
832
|
+
"successful": data.get("successful"),
|
|
833
|
+
"started": data.get("buildStartedTime"),
|
|
834
|
+
"completed": data.get("buildCompletedTime"),
|
|
835
|
+
"durationSeconds": data.get("buildDurationInSeconds"),
|
|
836
|
+
"vcsRevision": data.get("vcsRevisionKey"),
|
|
837
|
+
"testSummary": data.get("buildTestSummary"),
|
|
838
|
+
"successfulTestCount": data.get("successfulTestCount"),
|
|
839
|
+
"failedTestCount": data.get("failedTestCount"),
|
|
840
|
+
"skippedTestCount": data.get("skippedTestCount"),
|
|
841
|
+
"url": browse_url,
|
|
842
|
+
"jobs": jobs,
|
|
843
|
+
"failedTests": failed,
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def get_bamboo_build_log(
|
|
848
|
+
job_key: str,
|
|
849
|
+
build_number: str,
|
|
850
|
+
tail_lines: str = "200",
|
|
851
|
+
grep: str = "",
|
|
852
|
+
) -> dict:
|
|
853
|
+
"""Fetch the build log for a Bamboo job. Returns a tail of the log
|
|
854
|
+
(optionally filtered with a regex first) plus the full log saved to a
|
|
855
|
+
temp file so the caller can ``Read`` it when more lines are needed.
|
|
856
|
+
|
|
857
|
+
``job_key`` is the job-level key (e.g. ``HAUPIA-HAUPIATESTMETRICS36-CC``),
|
|
858
|
+
not the chain key. ``build_number`` is the build number (e.g. ``8``).
|
|
859
|
+
``tail_lines`` is clamped to [1, 2000]; default 200.
|
|
860
|
+
"""
|
|
861
|
+
raw = bamboo_request(
|
|
862
|
+
f"/download/{quote(job_key)}/build_logs/{quote(job_key)}-{quote(str(build_number))}.log",
|
|
863
|
+
accept_json=False,
|
|
864
|
+
)
|
|
865
|
+
text = raw.decode("utf-8", errors="replace")
|
|
866
|
+
lines = text.splitlines()
|
|
867
|
+
total = len(lines)
|
|
868
|
+
|
|
869
|
+
if grep:
|
|
870
|
+
try:
|
|
871
|
+
pat = re.compile(grep)
|
|
872
|
+
lines = [l for l in lines if pat.search(l)]
|
|
873
|
+
except re.error as e:
|
|
874
|
+
raise ValueError(f"Invalid grep regex {grep!r}: {e}")
|
|
875
|
+
|
|
876
|
+
try:
|
|
877
|
+
n = max(1, min(2000, int(tail_lines)))
|
|
878
|
+
except (TypeError, ValueError):
|
|
879
|
+
n = 200
|
|
880
|
+
|
|
881
|
+
returned = lines[-n:]
|
|
882
|
+
|
|
883
|
+
tmp = tempfile.NamedTemporaryFile(
|
|
884
|
+
delete=False,
|
|
885
|
+
suffix=".log",
|
|
886
|
+
prefix=f"bamboo_{job_key}_{build_number}_",
|
|
887
|
+
)
|
|
888
|
+
tmp.write(text.encode("utf-8"))
|
|
889
|
+
tmp.close()
|
|
890
|
+
|
|
891
|
+
return {
|
|
892
|
+
"jobKey": job_key,
|
|
893
|
+
"buildNumber": build_number,
|
|
894
|
+
"totalLines": total,
|
|
895
|
+
"returnedLines": len(returned),
|
|
896
|
+
"content": "\n".join(returned),
|
|
897
|
+
"localPath": tmp.name,
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
_TEXT_EXTS = (
|
|
902
|
+
".html", ".htm", ".json", ".txt", ".xml", ".log",
|
|
903
|
+
".csv", ".md", ".yaml", ".yml",
|
|
904
|
+
)
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def _looks_like_file(path):
|
|
908
|
+
"""True when the last path segment has a filename extension."""
|
|
909
|
+
seg = path.rstrip("/").rsplit("/", 1)[-1]
|
|
910
|
+
return "." in seg and not seg.startswith(".")
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def _parse_artifact_index(html, base_url):
|
|
914
|
+
"""Parse a Bamboo artifact directory index (Apache-style HTML). Returns
|
|
915
|
+
child entries as ``{"href", "name", "isDir"}`` (``name`` is the child's
|
|
916
|
+
last path segment), excluding the parent-directory link.
|
|
917
|
+
|
|
918
|
+
Bamboo serves child links as absolute ``/artifact/{plan}/…/build-N/…``
|
|
919
|
+
paths that do NOT share the fetched ``/browse/…`` URL's prefix, so a child
|
|
920
|
+
cannot be identified by prefix-matching the base path. Instead the parent
|
|
921
|
+
link is excluded (by its "Parent Directory" text and by resolving to an
|
|
922
|
+
ancestor of the current path) and everything else on these minimal listing
|
|
923
|
+
pages is a child."""
|
|
924
|
+
base_path = urlparse(base_url).path.rstrip("/")
|
|
925
|
+
out = []
|
|
926
|
+
seen = set()
|
|
927
|
+
for m in re.finditer(r'<a\s+href="?([^"\s>]+)"?[^>]*>(.*?)</a>', html, re.IGNORECASE | re.DOTALL):
|
|
928
|
+
raw_href, text = m.group(1), m.group(2)
|
|
929
|
+
if "parent" in re.sub(r"<[^>]*>", "", text).strip().lower():
|
|
930
|
+
continue
|
|
931
|
+
abs_url = urljoin(base_url + "/", raw_href)
|
|
932
|
+
child_path = urlparse(abs_url).path.rstrip("/")
|
|
933
|
+
if child_path == base_path or base_path.startswith(child_path + "/"):
|
|
934
|
+
continue
|
|
935
|
+
if abs_url in seen:
|
|
936
|
+
continue
|
|
937
|
+
seen.add(abs_url)
|
|
938
|
+
name = child_path.rsplit("/", 1)[-1]
|
|
939
|
+
out.append({"href": abs_url, "name": name, "isDir": not _looks_like_file(child_path)})
|
|
940
|
+
return out
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def _rel(url):
|
|
944
|
+
"""Strip BAMBOO_BASE so the result can be re-prepended by bamboo_request."""
|
|
945
|
+
if url.startswith(BAMBOO_BASE):
|
|
946
|
+
return url[len(BAMBOO_BASE):]
|
|
947
|
+
return urlparse(url).path
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def _collect_dir_files(dir_href, max_files, prefix="", _depth=0):
|
|
951
|
+
"""Recurse a Bamboo artifact directory index, returning leaf files as
|
|
952
|
+
``{"name": <path relative to the artifact root>, "path": <download URL>}``.
|
|
953
|
+
The relative name is accumulated from each child's segment during descent,
|
|
954
|
+
which is robust to the browse→artifact URL-space change. Bounded by
|
|
955
|
+
``max_files`` and depth 8."""
|
|
956
|
+
if _depth > 8:
|
|
957
|
+
return []
|
|
958
|
+
try:
|
|
959
|
+
raw, _ = bamboo_request(_rel(dir_href), accept_json=False, return_headers=True)
|
|
960
|
+
except Exception:
|
|
961
|
+
return []
|
|
962
|
+
html = raw.decode("utf-8", errors="replace")
|
|
963
|
+
files = []
|
|
964
|
+
for entry in _parse_artifact_index(html, dir_href):
|
|
965
|
+
if len(files) >= max_files:
|
|
966
|
+
break
|
|
967
|
+
rel = f"{prefix}/{entry['name']}" if prefix else entry["name"]
|
|
968
|
+
if entry["isDir"]:
|
|
969
|
+
files.extend(_collect_dir_files(entry["href"], max_files - len(files), rel, _depth + 1))
|
|
970
|
+
else:
|
|
971
|
+
files.append({"name": rel, "path": entry["href"]})
|
|
972
|
+
return files
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def list_bamboo_artifacts(build_key, max_files="200"):
|
|
976
|
+
"""List a Bamboo build's published artifacts, expanding directory
|
|
977
|
+
artifacts into their contained files.
|
|
978
|
+
|
|
979
|
+
``build_key`` may be a chain key (e.g. ``PLAN-21``) or a job key
|
|
980
|
+
(``PLAN-BUILD-21``); artifacts attach to job results, so chain keys are
|
|
981
|
+
walked across all jobs. Each artifact reports ``isDirectory`` and, for
|
|
982
|
+
directories, a ``files`` list of {name (relative), path (download URL)}.
|
|
983
|
+
"""
|
|
984
|
+
try:
|
|
985
|
+
cap = max(1, min(2000, int(max_files)))
|
|
986
|
+
except (TypeError, ValueError):
|
|
987
|
+
cap = 200
|
|
988
|
+
|
|
989
|
+
data = bamboo_request(
|
|
990
|
+
f"/rest/api/latest/result/{quote(build_key)}"
|
|
991
|
+
f"?expand=stages.stage.results.result.artifacts,artifacts"
|
|
992
|
+
)
|
|
993
|
+
|
|
994
|
+
raw_artifacts = list((data.get("artifacts") or {}).get("artifact") or [])
|
|
995
|
+
for stage in (data.get("stages") or {}).get("stage", []) or []:
|
|
996
|
+
for r in (stage.get("results") or {}).get("result", []) or []:
|
|
997
|
+
raw_artifacts.extend((r.get("artifacts") or {}).get("artifact") or [])
|
|
998
|
+
|
|
999
|
+
seen = set()
|
|
1000
|
+
artifacts = []
|
|
1001
|
+
truncated = False
|
|
1002
|
+
for a in raw_artifacts:
|
|
1003
|
+
href = (a.get("link") or {}).get("href") or ""
|
|
1004
|
+
if not href or href in seen:
|
|
1005
|
+
continue
|
|
1006
|
+
seen.add(href)
|
|
1007
|
+
is_dir = not _looks_like_file(urlparse(href).path)
|
|
1008
|
+
files = []
|
|
1009
|
+
if is_dir:
|
|
1010
|
+
files = _collect_dir_files(href, cap)
|
|
1011
|
+
if len(files) >= cap:
|
|
1012
|
+
truncated = True
|
|
1013
|
+
artifacts.append({
|
|
1014
|
+
"name": a.get("name"),
|
|
1015
|
+
"href": href,
|
|
1016
|
+
"producerJobKey": a.get("producerJobKey"),
|
|
1017
|
+
"shared": a.get("shared"),
|
|
1018
|
+
"size": a.get("size"),
|
|
1019
|
+
"prettySize": a.get("prettySizeDescription"),
|
|
1020
|
+
"isDirectory": is_dir,
|
|
1021
|
+
"files": files,
|
|
1022
|
+
})
|
|
1023
|
+
|
|
1024
|
+
return {"buildKey": build_key, "artifacts": artifacts, "truncated": truncated}
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def _is_textual(content_type, filename):
|
|
1028
|
+
ct = (content_type or "").lower()
|
|
1029
|
+
if ct.startswith("text/"):
|
|
1030
|
+
return True
|
|
1031
|
+
if any(t in ct for t in ("application/json", "application/xml", "application/javascript")):
|
|
1032
|
+
return True
|
|
1033
|
+
return filename.lower().endswith(_TEXT_EXTS)
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def get_bamboo_artifact(build_key, name="", href="", file="", max_inline_bytes="262144"):
|
|
1037
|
+
"""Download one Bamboo artifact file, save it to a temp file, and inline
|
|
1038
|
+
its text content when small enough.
|
|
1039
|
+
|
|
1040
|
+
Provide ``href`` for a direct download, or ``name`` to resolve an artifact
|
|
1041
|
+
by name (case-insensitive) via list_bamboo_artifacts. For a directory
|
|
1042
|
+
artifact, ``file`` selects the contained file (exact relative match, else
|
|
1043
|
+
suffix match); if omitted and the directory holds exactly one file, that
|
|
1044
|
+
file is used. Text under ``max_inline_bytes`` is returned in ``content``;
|
|
1045
|
+
otherwise ``content`` is null and the caller Reads ``localPath``.
|
|
1046
|
+
"""
|
|
1047
|
+
try:
|
|
1048
|
+
cap = max(0, min(5_000_000, int(max_inline_bytes)))
|
|
1049
|
+
except (TypeError, ValueError):
|
|
1050
|
+
cap = 262144
|
|
1051
|
+
|
|
1052
|
+
artifact_name = name or ""
|
|
1053
|
+
rel_file = ""
|
|
1054
|
+
url = href
|
|
1055
|
+
|
|
1056
|
+
if not url:
|
|
1057
|
+
if not name:
|
|
1058
|
+
raise ValueError("Provide either 'href' or 'name'.")
|
|
1059
|
+
listing = list_bamboo_artifacts(build_key)
|
|
1060
|
+
arts = listing["artifacts"]
|
|
1061
|
+
match = next((a for a in arts if (a.get("name") or "").lower() == name.lower()), None)
|
|
1062
|
+
if match is None:
|
|
1063
|
+
avail = ", ".join(repr(a.get("name")) for a in arts) or "(none)"
|
|
1064
|
+
raise RuntimeError(f"No artifact named {name!r}. Available: {avail}")
|
|
1065
|
+
artifact_name = match.get("name") or name
|
|
1066
|
+
if not match["isDirectory"]:
|
|
1067
|
+
url = match["href"]
|
|
1068
|
+
else:
|
|
1069
|
+
files = match["files"]
|
|
1070
|
+
if not files:
|
|
1071
|
+
raise RuntimeError(f"Artifact {artifact_name!r} is an empty directory.")
|
|
1072
|
+
if file:
|
|
1073
|
+
exact = [f for f in files if f["name"] == file]
|
|
1074
|
+
suffix = [f for f in files if f["name"].endswith(file)]
|
|
1075
|
+
chosen = exact or suffix
|
|
1076
|
+
if not chosen:
|
|
1077
|
+
avail = ", ".join(repr(f["name"]) for f in files)
|
|
1078
|
+
raise RuntimeError(f"No file {file!r} in {artifact_name!r}. Available: {avail}")
|
|
1079
|
+
if len(chosen) > 1:
|
|
1080
|
+
matched = ", ".join(repr(f["name"]) for f in chosen)
|
|
1081
|
+
raise RuntimeError(f"Ambiguous file {file!r}: matches {matched}")
|
|
1082
|
+
url = chosen[0]["path"]
|
|
1083
|
+
rel_file = chosen[0]["name"]
|
|
1084
|
+
elif len(files) == 1:
|
|
1085
|
+
url = files[0]["path"]
|
|
1086
|
+
rel_file = files[0]["name"]
|
|
1087
|
+
else:
|
|
1088
|
+
avail = ", ".join(repr(f["name"]) for f in files)
|
|
1089
|
+
raise RuntimeError(
|
|
1090
|
+
f"Artifact {artifact_name!r} is a directory; specify 'file'. Available: {avail}"
|
|
1091
|
+
)
|
|
1092
|
+
|
|
1093
|
+
raw, ctype = bamboo_request(_rel(url), accept_json=False, return_headers=True)
|
|
1094
|
+
filename = rel_file or urlparse(url).path.rstrip("/").rsplit("/", 1)[-1] or "artifact"
|
|
1095
|
+
|
|
1096
|
+
content = None
|
|
1097
|
+
note = ""
|
|
1098
|
+
if _is_textual(ctype, filename):
|
|
1099
|
+
if len(raw) > cap:
|
|
1100
|
+
note = f"too large ({len(raw)} bytes) — Read localPath"
|
|
1101
|
+
else:
|
|
1102
|
+
try:
|
|
1103
|
+
content = raw.decode("utf-8")
|
|
1104
|
+
except UnicodeDecodeError:
|
|
1105
|
+
note = "not valid UTF-8 — Read localPath"
|
|
1106
|
+
else:
|
|
1107
|
+
note = f"binary ({ctype or 'unknown content-type'}) — Read localPath"
|
|
1108
|
+
|
|
1109
|
+
suffix = "." + filename.rsplit(".", 1)[-1] if "." in filename else ""
|
|
1110
|
+
tmp = tempfile.NamedTemporaryFile(
|
|
1111
|
+
delete=False, suffix=suffix,
|
|
1112
|
+
prefix=f"bamboo_artifact_{re.sub(r'[^A-Za-z0-9]+', '_', build_key)}_",
|
|
1113
|
+
)
|
|
1114
|
+
tmp.write(raw)
|
|
1115
|
+
tmp.close()
|
|
1116
|
+
|
|
1117
|
+
return {
|
|
1118
|
+
"buildKey": build_key,
|
|
1119
|
+
"artifactName": artifact_name,
|
|
1120
|
+
"file": rel_file or filename,
|
|
1121
|
+
"url": url,
|
|
1122
|
+
"contentType": ctype,
|
|
1123
|
+
"sizeBytes": len(raw),
|
|
1124
|
+
"content": content,
|
|
1125
|
+
"note": note,
|
|
1126
|
+
"localPath": tmp.name,
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
def _bamboo_key_from_ref(ref):
|
|
1131
|
+
"""Accept a Bamboo browse URL or a bare key and return the key.
|
|
1132
|
+
|
|
1133
|
+
e.g. ``https://ci.e-spirit.de/browse/CAASCAASCONNECT-BUILD`` and
|
|
1134
|
+
``CAASCAASCONNECT-BUILD`` both yield ``CAASCAASCONNECT-BUILD``.
|
|
1135
|
+
"""
|
|
1136
|
+
ref = (ref or "").strip()
|
|
1137
|
+
if "/browse/" in ref:
|
|
1138
|
+
ref = ref.split("/browse/", 1)[1]
|
|
1139
|
+
return ref.split("?", 1)[0].split("#", 1)[0].strip("/")
|
|
1140
|
+
|
|
1141
|
+
|
|
1142
|
+
def _bamboo_max_results(value, default=200):
|
|
1143
|
+
try:
|
|
1144
|
+
return max(1, min(int(str(value)), 2000))
|
|
1145
|
+
except (TypeError, ValueError):
|
|
1146
|
+
return default
|
|
1147
|
+
|
|
1148
|
+
|
|
1149
|
+
def list_bamboo_branch_plans(plan, max_results="200"):
|
|
1150
|
+
"""List the branch plans of a Bamboo plan.
|
|
1151
|
+
|
|
1152
|
+
``plan`` is a plan key (e.g. ``CAASCAASCONNECT-BUILD``) or a browse URL
|
|
1153
|
+
(``https://ci.e-spirit.de/browse/CAASCAASCONNECT-BUILD``). Returns the base
|
|
1154
|
+
plan plus one entry per branch plan (shortName = git branch, key = the
|
|
1155
|
+
branch plan's build key, enabled flag, browse url). Empty branchPlans means
|
|
1156
|
+
the plan tracks no feature branches (master only).
|
|
1157
|
+
"""
|
|
1158
|
+
key = _bamboo_key_from_ref(plan)
|
|
1159
|
+
n = _bamboo_max_results(max_results)
|
|
1160
|
+
data = bamboo_request(
|
|
1161
|
+
f"/rest/api/latest/plan/{quote(key)}/branch.json"
|
|
1162
|
+
f"?max-results={n}&expand=branches.branch"
|
|
1163
|
+
)
|
|
1164
|
+
root = data.get("branches") or {}
|
|
1165
|
+
branches = []
|
|
1166
|
+
for b in root.get("branch", []) or []:
|
|
1167
|
+
bkey = b.get("key")
|
|
1168
|
+
branches.append({
|
|
1169
|
+
"shortName": b.get("shortName"),
|
|
1170
|
+
"key": bkey,
|
|
1171
|
+
"enabled": b.get("enabled"),
|
|
1172
|
+
"url": f"{BAMBOO_BASE}/browse/{bkey}" if bkey else "",
|
|
1173
|
+
})
|
|
1174
|
+
return {
|
|
1175
|
+
"planKey": data.get("key") or key,
|
|
1176
|
+
"url": f"{BAMBOO_BASE}/browse/{key}",
|
|
1177
|
+
"branchCount": root.get("size", len(branches)),
|
|
1178
|
+
"branchPlans": branches,
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
def list_bamboo_plans(project, max_results="200"):
|
|
1183
|
+
"""List the plans in a Bamboo project.
|
|
1184
|
+
|
|
1185
|
+
``project`` is a project key (e.g. ``CAASCAASCONNECT``) or a browse URL
|
|
1186
|
+
(``https://ci.e-spirit.de/browse/CAASCAASCONNECT``). Returns one entry per
|
|
1187
|
+
plan (shortName, key, planName, enabled, browse url). To then list a plan's
|
|
1188
|
+
feature-branch builds, feed a plan key into list_bamboo_branch_plans.
|
|
1189
|
+
"""
|
|
1190
|
+
key = _bamboo_key_from_ref(project)
|
|
1191
|
+
n = _bamboo_max_results(max_results)
|
|
1192
|
+
data = bamboo_request(
|
|
1193
|
+
f"/rest/api/latest/project/{quote(key)}.json"
|
|
1194
|
+
f"?max-results={n}&expand=plans.plan"
|
|
1195
|
+
)
|
|
1196
|
+
root = data.get("plans") or {}
|
|
1197
|
+
plans = []
|
|
1198
|
+
for p in root.get("plan", []) or []:
|
|
1199
|
+
pkey = p.get("key")
|
|
1200
|
+
plans.append({
|
|
1201
|
+
"shortName": p.get("shortName"),
|
|
1202
|
+
"key": pkey,
|
|
1203
|
+
"planName": p.get("name"),
|
|
1204
|
+
"enabled": p.get("enabled"),
|
|
1205
|
+
"url": f"{BAMBOO_BASE}/browse/{pkey}" if pkey else "",
|
|
1206
|
+
})
|
|
1207
|
+
return {
|
|
1208
|
+
"projectKey": data.get("key") or key,
|
|
1209
|
+
"projectName": data.get("name"),
|
|
1210
|
+
"url": f"{BAMBOO_BASE}/browse/{key}",
|
|
1211
|
+
"planCount": root.get("size", len(plans)),
|
|
1212
|
+
"plans": plans,
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
def _bamboo_run(x):
|
|
1217
|
+
rkey = x.get("key")
|
|
1218
|
+
return {
|
|
1219
|
+
"buildNumber": x.get("buildNumber"),
|
|
1220
|
+
"key": rkey,
|
|
1221
|
+
"state": x.get("state"),
|
|
1222
|
+
"completed": x.get("buildCompletedTime"),
|
|
1223
|
+
"relativeTime": x.get("buildRelativeTime"),
|
|
1224
|
+
"url": f"{BAMBOO_BASE}/browse/{rkey}" if rkey else "",
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
def list_bamboo_plan_runs(plan, max_results="25"):
|
|
1229
|
+
"""List the retained build results (runs) of a Bamboo plan or branch plan.
|
|
1230
|
+
|
|
1231
|
+
``plan`` is a plan key (e.g. ``CAASCAASCONNECT-BUILD``), a branch plan key
|
|
1232
|
+
(e.g. ``CAASCAASCONNECT-BUILD502``) or a browse URL. Runs come back
|
|
1233
|
+
newest-first. Bamboo expires old build results, so retainedCount and
|
|
1234
|
+
oldestRetained describe what is still kept, NOT the all-time history;
|
|
1235
|
+
latestBuildNumber is the newest run's number and — because build numbers are
|
|
1236
|
+
monotonic — also the total number of times the plan has ever run.
|
|
1237
|
+
"""
|
|
1238
|
+
key = _bamboo_key_from_ref(plan)
|
|
1239
|
+
n = _bamboo_max_results(max_results, default=25)
|
|
1240
|
+
data = bamboo_request(
|
|
1241
|
+
f"/rest/api/latest/result/{quote(key)}.json"
|
|
1242
|
+
f"?max-results={n}&expand=results.result"
|
|
1243
|
+
)
|
|
1244
|
+
root = data.get("results") or {}
|
|
1245
|
+
runs = [_bamboo_run(x) for x in root.get("result", []) or []]
|
|
1246
|
+
size = root.get("size", len(runs))
|
|
1247
|
+
newest = runs[0] if runs else None
|
|
1248
|
+
oldest = runs[-1] if runs else None
|
|
1249
|
+
if size and len(runs) < size:
|
|
1250
|
+
tail = bamboo_request(
|
|
1251
|
+
f"/rest/api/latest/result/{quote(key)}.json"
|
|
1252
|
+
f"?max-results=1&start-index={size - 1}&expand=results.result"
|
|
1253
|
+
)
|
|
1254
|
+
tr = (tail.get("results") or {}).get("result", []) or []
|
|
1255
|
+
if tr:
|
|
1256
|
+
oldest = _bamboo_run(tr[0])
|
|
1257
|
+
return {
|
|
1258
|
+
"planKey": key,
|
|
1259
|
+
"url": f"{BAMBOO_BASE}/browse/{key}",
|
|
1260
|
+
"retainedCount": size,
|
|
1261
|
+
"latestBuildNumber": newest["buildNumber"] if newest else None,
|
|
1262
|
+
"newest": newest,
|
|
1263
|
+
"oldestRetained": oldest,
|
|
1264
|
+
"runs": runs,
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
|
|
1268
|
+
def get_bitbucket_pr_changes(project: str, repo: str, pr_id: str) -> dict:
|
|
1269
|
+
"""Fetch changed files in a Bitbucket pull request."""
|
|
1270
|
+
data = bitbucket_request(f"projects/{quote(project)}/repos/{quote(repo)}/pull-requests/{quote(pr_id)}/changes")
|
|
1271
|
+
changes = []
|
|
1272
|
+
for value in data.get("values", []):
|
|
1273
|
+
path = value.get("path", {})
|
|
1274
|
+
changes.append({
|
|
1275
|
+
"path": path.get("toString", path.get("name", "")),
|
|
1276
|
+
"type": value.get("type", "UNKNOWN")
|
|
1277
|
+
})
|
|
1278
|
+
return {
|
|
1279
|
+
"project": project,
|
|
1280
|
+
"repo": repo,
|
|
1281
|
+
"pr_id": pr_id,
|
|
1282
|
+
"changes": changes
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
|
|
1286
|
+
def search_bitbucket_prs(project: str, repo: str, state: str = "OPEN") -> dict:
|
|
1287
|
+
"""Search for pull requests in a Bitbucket repository."""
|
|
1288
|
+
data = bitbucket_request(f"projects/{quote(project)}/repos/{quote(repo)}/pull-requests?state={quote(state)}")
|
|
1289
|
+
prs = []
|
|
1290
|
+
for pr in data.get("values", []):
|
|
1291
|
+
prs.append({
|
|
1292
|
+
"id": pr.get("id"),
|
|
1293
|
+
"title": pr.get("title"),
|
|
1294
|
+
"state": pr.get("state"),
|
|
1295
|
+
"author": pr.get("author", {}).get("user", {}).get("displayName", "Unknown"),
|
|
1296
|
+
"fromRef": pr.get("fromRef", {}).get("displayId", ""),
|
|
1297
|
+
"toRef": pr. get("toRef", {}).get("displayId", "")
|
|
1298
|
+
})
|
|
1299
|
+
return {"project": project, "repo": repo, "state": state, "pull_requests": prs}
|
|
1300
|
+
|
|
1301
|
+
|
|
1302
|
+
def get_commits_for_branch(project: str, repo: str, branch: str, limit: str = "25") -> dict:
|
|
1303
|
+
"""Fetch commits for a specific branch."""
|
|
1304
|
+
data = bitbucket_request(
|
|
1305
|
+
f"projects/{quote(project)}/repos/{quote(repo)}/commits?until={quote(branch, safe='')}&limit={quote(limit)}"
|
|
1306
|
+
)
|
|
1307
|
+
commits = []
|
|
1308
|
+
for commit in data.get("values", []):
|
|
1309
|
+
commits.append({
|
|
1310
|
+
"id": commit.get("id"),
|
|
1311
|
+
"displayId": commit.get("displayId"),
|
|
1312
|
+
"message": commit.get("message", "").strip(),
|
|
1313
|
+
"author": commit.get("author", {}).get("name", "Unknown"),
|
|
1314
|
+
"authorEmail": commit.get("author", {}).get("emailAddress", ""),
|
|
1315
|
+
"authorTimestamp": commit.get("authorTimestamp"),
|
|
1316
|
+
"committer": commit.get("committer", {}).get("name", "Unknown"),
|
|
1317
|
+
"committerTimestamp": commit.get("committerTimestamp")
|
|
1318
|
+
})
|
|
1319
|
+
return {"project": project, "repo": repo, "branch": branch, "commits": commits}
|
|
1320
|
+
|
|
1321
|
+
|
|
1322
|
+
def get_commit_details(project: str, repo: str, commit_id: str) -> dict:
|
|
1323
|
+
"""Fetch details of a specific commit."""
|
|
1324
|
+
data = bitbucket_request(
|
|
1325
|
+
f"projects/{quote(project)}/repos/{quote(repo)}/commits/{quote(commit_id)}"
|
|
1326
|
+
)
|
|
1327
|
+
parents = [{"id": p.get("id"), "displayId": p.get("displayId")} for p in data.get("parents", [])]
|
|
1328
|
+
return {
|
|
1329
|
+
"id": data.get("id"),
|
|
1330
|
+
"displayId": data.get("displayId"),
|
|
1331
|
+
"message": data.get("message", "").strip(),
|
|
1332
|
+
"author": data.get("author", {}).get("name", "Unknown"),
|
|
1333
|
+
"authorEmail": data.get("author", {}).get("emailAddress", ""),
|
|
1334
|
+
"authorTimestamp": data.get("authorTimestamp"),
|
|
1335
|
+
"committer": data.get("committer", {}).get("name", "Unknown"),
|
|
1336
|
+
"committerTimestamp": data.get("committerTimestamp"),
|
|
1337
|
+
"parents": parents
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
|
|
1341
|
+
def get_commit_comments(project: str, repo: str, commit_id: str) -> dict:
|
|
1342
|
+
"""Fetch comments on a specific commit.
|
|
1343
|
+
|
|
1344
|
+
Bitbucket Server requires fetching the diff with withComments=true to get
|
|
1345
|
+
comment IDs, then fetching each comment individually.
|
|
1346
|
+
"""
|
|
1347
|
+
# First, get the diff with comment IDs
|
|
1348
|
+
diff_data = bitbucket_request(
|
|
1349
|
+
f"projects/{quote(project)}/repos/{quote(repo)}/commits/{quote(commit_id)}/diff?withComments=true"
|
|
1350
|
+
)
|
|
1351
|
+
|
|
1352
|
+
# Extract all comment IDs from the diff
|
|
1353
|
+
comment_ids = set()
|
|
1354
|
+
for diff in diff_data.get("diffs", []):
|
|
1355
|
+
for hunk in diff.get("hunks", []):
|
|
1356
|
+
for segment in hunk.get("segments", []):
|
|
1357
|
+
for line in segment.get("lines", []):
|
|
1358
|
+
for cid in line.get("commentIds", []):
|
|
1359
|
+
comment_ids.add(cid)
|
|
1360
|
+
|
|
1361
|
+
# Fetch each comment individually
|
|
1362
|
+
comments = []
|
|
1363
|
+
for cid in comment_ids:
|
|
1364
|
+
try:
|
|
1365
|
+
comment = bitbucket_request(
|
|
1366
|
+
f"projects/{quote(project)}/repos/{quote(repo)}/commits/{quote(commit_id)}/comments/{cid}"
|
|
1367
|
+
)
|
|
1368
|
+
comments.append({
|
|
1369
|
+
"id": comment.get("id"),
|
|
1370
|
+
"text": comment.get("text", ""),
|
|
1371
|
+
"author": comment.get("author", {}).get("displayName", "Unknown"),
|
|
1372
|
+
"authorEmail": comment.get("author", {}).get("emailAddress", ""),
|
|
1373
|
+
"createdDate": comment.get("createdDate"),
|
|
1374
|
+
"updatedDate": comment.get("updatedDate"),
|
|
1375
|
+
"state": comment.get("state"),
|
|
1376
|
+
"severity": comment.get("severity"),
|
|
1377
|
+
"threadResolved": comment.get("threadResolved", False)
|
|
1378
|
+
})
|
|
1379
|
+
except Exception:
|
|
1380
|
+
pass # Skip comments that can't be fetched
|
|
1381
|
+
|
|
1382
|
+
return {"project": project, "repo": repo, "commit_id": commit_id, "comments": comments}
|
|
1383
|
+
|
|
1384
|
+
|
|
1385
|
+
def _build_pr_anchor_map(project: str, repo: str, pr_id: str) -> dict:
|
|
1386
|
+
"""Build a mapping from comment ID to file/line anchor using the PR diff.
|
|
1387
|
+
|
|
1388
|
+
The activities endpoint does not always include anchor information for
|
|
1389
|
+
inline comments. The diff endpoint with ``withComments=true`` embeds
|
|
1390
|
+
``commentIds`` on each line, so we can reliably reconstruct the mapping.
|
|
1391
|
+
"""
|
|
1392
|
+
try:
|
|
1393
|
+
diff_data = bitbucket_request(
|
|
1394
|
+
f"projects/{quote(project)}/repos/{quote(repo)}/pull-requests/{quote(pr_id)}/diff?withComments=true"
|
|
1395
|
+
)
|
|
1396
|
+
except Exception:
|
|
1397
|
+
return {}
|
|
1398
|
+
|
|
1399
|
+
anchor_map: dict = {}
|
|
1400
|
+
for diff in diff_data.get("diffs", []):
|
|
1401
|
+
dest_path = (diff.get("destination") or {}).get("toString", "")
|
|
1402
|
+
src_path = (diff.get("source") or {}).get("toString", "")
|
|
1403
|
+
for hunk in diff.get("hunks", []):
|
|
1404
|
+
for segment in hunk.get("segments", []):
|
|
1405
|
+
seg_type = segment.get("type", "")
|
|
1406
|
+
for line in segment.get("lines", []):
|
|
1407
|
+
for cid in line.get("commentIds", []):
|
|
1408
|
+
if cid in anchor_map:
|
|
1409
|
+
continue
|
|
1410
|
+
if seg_type == "REMOVED":
|
|
1411
|
+
anchor_map[cid] = {
|
|
1412
|
+
"path": src_path or dest_path,
|
|
1413
|
+
"srcPath": src_path if src_path != dest_path else None,
|
|
1414
|
+
"line": line.get("source"),
|
|
1415
|
+
"lineType": "REMOVED",
|
|
1416
|
+
"fileType": "FROM",
|
|
1417
|
+
"orphaned": False,
|
|
1418
|
+
"diffType": "EFFECTIVE",
|
|
1419
|
+
}
|
|
1420
|
+
else:
|
|
1421
|
+
anchor_map[cid] = {
|
|
1422
|
+
"path": dest_path or src_path,
|
|
1423
|
+
"srcPath": src_path if src_path != dest_path else None,
|
|
1424
|
+
"line": line.get("destination"),
|
|
1425
|
+
"lineType": seg_type if seg_type in ("ADDED", "REMOVED") else "CONTEXT",
|
|
1426
|
+
"fileType": "TO",
|
|
1427
|
+
"orphaned": False,
|
|
1428
|
+
"diffType": "EFFECTIVE",
|
|
1429
|
+
}
|
|
1430
|
+
return anchor_map
|
|
1431
|
+
|
|
1432
|
+
|
|
1433
|
+
def get_bitbucket_pr_comments(project: str, repo: str, pr_id: str, limit: str = "25") -> dict:
|
|
1434
|
+
"""Fetch comments on a Bitbucket pull request via the activities endpoint."""
|
|
1435
|
+
data = bitbucket_request(
|
|
1436
|
+
f"projects/{quote(project)}/repos/{quote(repo)}/pull-requests/{quote(pr_id)}/activities?limit={quote(limit)}"
|
|
1437
|
+
)
|
|
1438
|
+
|
|
1439
|
+
# Build anchor map from diff as fallback for missing anchors
|
|
1440
|
+
anchor_map = _build_pr_anchor_map(project, repo, pr_id)
|
|
1441
|
+
|
|
1442
|
+
def extract_anchor(anchor):
|
|
1443
|
+
"""Extract anchor information from a Bitbucket anchor object."""
|
|
1444
|
+
if not anchor:
|
|
1445
|
+
return None
|
|
1446
|
+
return {
|
|
1447
|
+
"path": anchor.get("path", ""),
|
|
1448
|
+
"srcPath": anchor.get("srcPath"),
|
|
1449
|
+
"line": anchor.get("line"),
|
|
1450
|
+
"lineType": anchor.get("lineType"),
|
|
1451
|
+
"fileType": anchor.get("fileType"),
|
|
1452
|
+
"orphaned": anchor.get("orphaned", False),
|
|
1453
|
+
"diffType": anchor.get("diffType"),
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
def flatten_comment(comment, parent_id=None, inherited_anchor=None):
|
|
1457
|
+
"""Recursively flatten a comment and its replies.
|
|
1458
|
+
|
|
1459
|
+
Replies don't carry their own anchor — they inherit it from the
|
|
1460
|
+
root comment of the thread so every entry in the flat list knows
|
|
1461
|
+
which file/line it belongs to.
|
|
1462
|
+
"""
|
|
1463
|
+
own_anchor = extract_anchor(comment.get("anchor"))
|
|
1464
|
+
anchor = own_anchor or inherited_anchor
|
|
1465
|
+
entry = {
|
|
1466
|
+
"id": comment.get("id"),
|
|
1467
|
+
"text": comment.get("text", ""),
|
|
1468
|
+
"author": comment.get("author", {}).get("displayName", "Unknown"),
|
|
1469
|
+
"createdDate": comment.get("createdDate"),
|
|
1470
|
+
"updatedDate": comment.get("updatedDate"),
|
|
1471
|
+
"state": comment.get("state"),
|
|
1472
|
+
"severity": comment.get("severity"),
|
|
1473
|
+
"threadResolved": comment.get("threadResolved", False),
|
|
1474
|
+
"parent": parent_id,
|
|
1475
|
+
"anchor": anchor
|
|
1476
|
+
}
|
|
1477
|
+
result = [entry]
|
|
1478
|
+
for reply in comment.get("comments", []):
|
|
1479
|
+
result.extend(flatten_comment(reply, parent_id=comment.get("id"), inherited_anchor=anchor))
|
|
1480
|
+
return result
|
|
1481
|
+
|
|
1482
|
+
comments = []
|
|
1483
|
+
for activity in data.get("values", []):
|
|
1484
|
+
if activity.get("action") == "COMMENTED":
|
|
1485
|
+
comment = activity.get("comment") or {}
|
|
1486
|
+
# Anchor can live on the comment itself, at the activity level
|
|
1487
|
+
# (commentAnchor), or in the diff-based anchor map.
|
|
1488
|
+
root_anchor = (
|
|
1489
|
+
extract_anchor(comment.get("anchor"))
|
|
1490
|
+
or extract_anchor(activity.get("commentAnchor"))
|
|
1491
|
+
or anchor_map.get(comment.get("id"))
|
|
1492
|
+
)
|
|
1493
|
+
comments.extend(flatten_comment(comment, inherited_anchor=root_anchor))
|
|
1494
|
+
|
|
1495
|
+
return {"project": project, "repo": repo, "pr_id": pr_id, "comments": comments}
|
|
1496
|
+
|
|
1497
|
+
|
|
1498
|
+
def reply_to_bitbucket_pr_comment(project: str, repo: str, pr_id: str, comment_id: str, text: str) -> dict:
|
|
1499
|
+
"""Reply to a comment on a Bitbucket pull request."""
|
|
1500
|
+
prefixed_text = f"**[Claude Code]** {text}"
|
|
1501
|
+
data = bitbucket_request(
|
|
1502
|
+
f"projects/{quote(project)}/repos/{quote(repo)}/pull-requests/{quote(pr_id)}/comments",
|
|
1503
|
+
method="POST",
|
|
1504
|
+
data={"parent": {"id": int(comment_id)}, "text": prefixed_text}
|
|
1505
|
+
)
|
|
1506
|
+
return {
|
|
1507
|
+
"id": data.get("id"),
|
|
1508
|
+
"text": data.get("text", ""),
|
|
1509
|
+
"author": data.get("author", {}).get("displayName", "Unknown"),
|
|
1510
|
+
"createdDate": data.get("createdDate"),
|
|
1511
|
+
"parent": int(comment_id)
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
|
|
1515
|
+
def add_bitbucket_pr_comment(
|
|
1516
|
+
project: str,
|
|
1517
|
+
repo: str,
|
|
1518
|
+
pr_id: str,
|
|
1519
|
+
text: str,
|
|
1520
|
+
path: str = "",
|
|
1521
|
+
line: str = "",
|
|
1522
|
+
line_type: str = "ADDED",
|
|
1523
|
+
) -> dict:
|
|
1524
|
+
"""Post a new top-level or inline comment on a Bitbucket pull request.
|
|
1525
|
+
|
|
1526
|
+
When ``path`` is empty the comment is posted as a general (non-anchored)
|
|
1527
|
+
PR comment. When ``path`` is provided the comment is anchored to that file;
|
|
1528
|
+
if ``line`` is also provided, it is anchored to that specific line.
|
|
1529
|
+
|
|
1530
|
+
``line_type`` is one of ``ADDED``, ``REMOVED`` or ``CONTEXT`` and defaults
|
|
1531
|
+
to ``ADDED`` (the lineType used by Bitbucket for newly added lines in the
|
|
1532
|
+
to-side of the diff).
|
|
1533
|
+
"""
|
|
1534
|
+
prefixed_text = f"**[Claude Code]** {text}"
|
|
1535
|
+
body: dict = {"text": prefixed_text}
|
|
1536
|
+
if path:
|
|
1537
|
+
anchor: dict = {"path": path, "fileType": "TO"}
|
|
1538
|
+
if line:
|
|
1539
|
+
anchor["line"] = int(line)
|
|
1540
|
+
anchor["lineType"] = line_type
|
|
1541
|
+
body["anchor"] = anchor
|
|
1542
|
+
posted = bitbucket_request(
|
|
1543
|
+
f"projects/{quote(project)}/repos/{quote(repo)}/pull-requests/{quote(pr_id)}/comments",
|
|
1544
|
+
method="POST",
|
|
1545
|
+
data=body,
|
|
1546
|
+
)
|
|
1547
|
+
# Bitbucket's POST response omits the anchor field even when anchoring
|
|
1548
|
+
# succeeded. Re-fetch the comment so the returned anchor is authoritative.
|
|
1549
|
+
comment_id = posted.get("id")
|
|
1550
|
+
data = posted
|
|
1551
|
+
if comment_id is not None:
|
|
1552
|
+
try:
|
|
1553
|
+
data = bitbucket_request(
|
|
1554
|
+
f"projects/{quote(project)}/repos/{quote(repo)}/pull-requests/{quote(pr_id)}/comments/{comment_id}"
|
|
1555
|
+
)
|
|
1556
|
+
except Exception:
|
|
1557
|
+
data = posted
|
|
1558
|
+
return {
|
|
1559
|
+
"id": data.get("id"),
|
|
1560
|
+
"text": data.get("text", ""),
|
|
1561
|
+
"author": data.get("author", {}).get("displayName", "Unknown"),
|
|
1562
|
+
"createdDate": data.get("createdDate"),
|
|
1563
|
+
"anchor": data.get("anchor"),
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
|
|
1567
|
+
def create_bitbucket_pr(
|
|
1568
|
+
project: str,
|
|
1569
|
+
repo: str,
|
|
1570
|
+
title: str,
|
|
1571
|
+
from_branch: str,
|
|
1572
|
+
to_branch: str = "master",
|
|
1573
|
+
description: str = "",
|
|
1574
|
+
reviewers: str = ""
|
|
1575
|
+
) -> dict:
|
|
1576
|
+
"""Create a Bitbucket pull request.
|
|
1577
|
+
|
|
1578
|
+
``from_branch`` and ``to_branch`` are plain branch names (no ``refs/heads/`` prefix).
|
|
1579
|
+
``reviewers`` is an optional comma-separated list of Bitbucket usernames.
|
|
1580
|
+
"""
|
|
1581
|
+
body = {
|
|
1582
|
+
"title": title,
|
|
1583
|
+
"description": description,
|
|
1584
|
+
"fromRef": {
|
|
1585
|
+
"id": f"refs/heads/{from_branch}",
|
|
1586
|
+
"repository": {"slug": repo, "project": {"key": project}},
|
|
1587
|
+
},
|
|
1588
|
+
"toRef": {
|
|
1589
|
+
"id": f"refs/heads/{to_branch}",
|
|
1590
|
+
"repository": {"slug": repo, "project": {"key": project}},
|
|
1591
|
+
},
|
|
1592
|
+
}
|
|
1593
|
+
reviewer_list = [{"user": {"name": r.strip()}} for r in reviewers.split(",") if r.strip()]
|
|
1594
|
+
if reviewer_list:
|
|
1595
|
+
body["reviewers"] = reviewer_list
|
|
1596
|
+
|
|
1597
|
+
data = bitbucket_request(
|
|
1598
|
+
f"projects/{quote(project)}/repos/{quote(repo)}/pull-requests",
|
|
1599
|
+
method="POST",
|
|
1600
|
+
data=body,
|
|
1601
|
+
)
|
|
1602
|
+
return {
|
|
1603
|
+
"id": data.get("id"),
|
|
1604
|
+
"title": data.get("title"),
|
|
1605
|
+
"state": data.get("state"),
|
|
1606
|
+
"fromRef": data.get("fromRef", {}).get("displayId", ""),
|
|
1607
|
+
"toRef": data.get("toRef", {}).get("displayId", ""),
|
|
1608
|
+
"link": data.get("links", {}).get("self", [{}])[0].get("href", ""),
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
|
|
1612
|
+
def resolve_bitbucket_pr_comment(project: str, repo: str, pr_id: str, comment_id: str) -> dict:
|
|
1613
|
+
"""Resolve a comment thread on a Bitbucket pull request."""
|
|
1614
|
+
comment_endpoint = (
|
|
1615
|
+
f"projects/{quote(project)}/repos/{quote(repo)}/pull-requests/{quote(pr_id)}/comments/{quote(comment_id)}"
|
|
1616
|
+
)
|
|
1617
|
+
# Fetch current comment to get version and text (optimistic locking)
|
|
1618
|
+
current = bitbucket_request(comment_endpoint)
|
|
1619
|
+
data = bitbucket_request(
|
|
1620
|
+
comment_endpoint,
|
|
1621
|
+
method="PUT",
|
|
1622
|
+
data={
|
|
1623
|
+
"text": current.get("text", ""),
|
|
1624
|
+
"version": current.get("version"),
|
|
1625
|
+
"state": "RESOLVED"
|
|
1626
|
+
}
|
|
1627
|
+
)
|
|
1628
|
+
return {
|
|
1629
|
+
"id": data.get("id"),
|
|
1630
|
+
"text": data.get("text", ""),
|
|
1631
|
+
"state": data.get("state"),
|
|
1632
|
+
"threadResolved": data.get("threadResolved", False),
|
|
1633
|
+
"version": data.get("version")
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
|
|
1637
|
+
# MCP Protocol handling
|
|
1638
|
+
TOOLS = {
|
|
1639
|
+
"get_jira_ticket": {
|
|
1640
|
+
"description": "Fetch details of a Jira ticket (summary, description, comments, status, labels, assignee, priority, issuetype). Also lists attachments with id/filename/size/mimeType so you can selectively download them via download_jira_attachment.",
|
|
1641
|
+
"parameters": {"ticket_id": "Jira ticket ID, e.g. CORE-4711"},
|
|
1642
|
+
"fn": get_ticket
|
|
1643
|
+
},
|
|
1644
|
+
"add_jira_comment": {
|
|
1645
|
+
"description": (
|
|
1646
|
+
"Add a comment to a Jira ticket. Body uses Jira wiki markup "
|
|
1647
|
+
"(h3., *bold*, {{monospace}}, bullets with *, links with [text|url]). "
|
|
1648
|
+
"MANDATORY: before calling this tool you MUST show the full verbatim comment body to the user "
|
|
1649
|
+
"and obtain explicit approval to post it. Never call this tool without that explicit go-ahead — "
|
|
1650
|
+
"not even if the user asked you to 'post a comment' earlier. Posting to Jira is user-visible and "
|
|
1651
|
+
"not easily reversible."
|
|
1652
|
+
),
|
|
1653
|
+
"parameters": {
|
|
1654
|
+
"ticket_id": "Jira ticket ID, e.g. NAV-399",
|
|
1655
|
+
"body": "Comment body in Jira wiki markup"
|
|
1656
|
+
},
|
|
1657
|
+
"fn": add_jira_comment
|
|
1658
|
+
},
|
|
1659
|
+
"download_jira_attachment": {
|
|
1660
|
+
"description": "Download a single Jira attachment by its ID (from get_jira_ticket attachments list). Saves the file locally and returns the path. For text-based files the content is also returned inline.",
|
|
1661
|
+
"parameters": {"attachment_id": "Attachment ID from the ticket's attachments list"},
|
|
1662
|
+
"fn": download_jira_attachment
|
|
1663
|
+
},
|
|
1664
|
+
"create_jira_ticket": {
|
|
1665
|
+
"description": (
|
|
1666
|
+
"Create a new Jira ticket in the given project. ``description`` uses Jira wiki "
|
|
1667
|
+
"markup. ``labels`` is a comma-separated list. ``parent`` is the key of the parent "
|
|
1668
|
+
"ticket (required when issue_type is 'Sub-task'). "
|
|
1669
|
+
"MANDATORY: before calling this tool you MUST show the user the full verbatim ticket "
|
|
1670
|
+
"fields (project, summary, issue type, description, priority, assignee, labels, "
|
|
1671
|
+
"parent) and obtain explicit approval to create it. Never call this tool without "
|
|
1672
|
+
"that explicit go-ahead — not even if the user asked you to 'create a ticket' "
|
|
1673
|
+
"earlier. Creating a Jira ticket is user-visible and not easily reversible."
|
|
1674
|
+
),
|
|
1675
|
+
"parameters": {
|
|
1676
|
+
"project_key": "Jira project key, e.g. HP, NAV, CORE",
|
|
1677
|
+
"summary": "Ticket summary / title (one line)",
|
|
1678
|
+
"issue_type": "Issue type name, e.g. Story, Task, Bug, Sub-task. Default: Story",
|
|
1679
|
+
"description": "Optional ticket body in Jira wiki markup",
|
|
1680
|
+
"priority": "Optional priority name, e.g. Highest, High, Medium, Low, Lowest",
|
|
1681
|
+
"assignee": "Optional Jira username to assign",
|
|
1682
|
+
"labels": "Optional comma-separated labels",
|
|
1683
|
+
"parent": "Parent ticket key — required when issue_type is Sub-task"
|
|
1684
|
+
},
|
|
1685
|
+
"optional": ("issue_type", "description", "priority", "assignee", "labels", "parent"),
|
|
1686
|
+
"fn": create_jira_ticket
|
|
1687
|
+
},
|
|
1688
|
+
"update_jira_ticket": {
|
|
1689
|
+
"description": (
|
|
1690
|
+
"Update an existing Jira ticket's description, summary and/or labels. "
|
|
1691
|
+
"description uses Jira wiki markup. add_labels/remove_labels are comma-separated and applied "
|
|
1692
|
+
"additively (existing labels are preserved). At least one of description, summary, add_labels "
|
|
1693
|
+
"or remove_labels must be provided. "
|
|
1694
|
+
"MANDATORY: before calling this tool you MUST show the user the full verbatim changes "
|
|
1695
|
+
"(new description/summary and the exact labels being added or removed) and obtain explicit "
|
|
1696
|
+
"approval to apply them. Never call this tool without that explicit go-ahead — not even if the "
|
|
1697
|
+
"user asked you to 'update the ticket' earlier. Updating a Jira ticket is user-visible and not "
|
|
1698
|
+
"easily reversible."
|
|
1699
|
+
),
|
|
1700
|
+
"parameters": {
|
|
1701
|
+
"issue_key": "Jira ticket key, e.g. HP-1234",
|
|
1702
|
+
"description": "New ticket description in Jira wiki markup (optional)",
|
|
1703
|
+
"summary": "New ticket title / summary line (optional)",
|
|
1704
|
+
"add_labels": "Optional comma-separated labels to add",
|
|
1705
|
+
"remove_labels": "Optional comma-separated labels to remove",
|
|
1706
|
+
},
|
|
1707
|
+
"optional": ("description", "summary", "add_labels", "remove_labels"),
|
|
1708
|
+
"fn": update_jira_ticket,
|
|
1709
|
+
},
|
|
1710
|
+
"get_jira_transitions": {
|
|
1711
|
+
"description": (
|
|
1712
|
+
"List the workflow transitions currently available on a Jira ticket. Jira only exposes "
|
|
1713
|
+
"transitions reachable from the ticket's current status, so a multi-step move (e.g. "
|
|
1714
|
+
"Triage -> Backlog -> Committed) is walked one transition at a time. Each entry gives the "
|
|
1715
|
+
"transition name and the to_status it lands the ticket in. Use this to discover the valid "
|
|
1716
|
+
"status value to pass to transition_jira_ticket."
|
|
1717
|
+
),
|
|
1718
|
+
"parameters": {"issue_key": "Jira ticket key, e.g. PX-386"},
|
|
1719
|
+
"fn": get_jira_transitions,
|
|
1720
|
+
},
|
|
1721
|
+
"transition_jira_ticket": {
|
|
1722
|
+
"description": (
|
|
1723
|
+
"Move a Jira ticket to a new status by performing a workflow transition. 'status' is matched "
|
|
1724
|
+
"case-insensitively against the target status (or transition name) of the transitions currently "
|
|
1725
|
+
"available on the ticket. Only ONE step is performed per call; for a multi-step move "
|
|
1726
|
+
"(e.g. Triage -> Backlog -> Committed) call this repeatedly, discovering the next step with "
|
|
1727
|
+
"get_jira_transitions. If no available transition matches, the error lists the transitions that "
|
|
1728
|
+
"ARE available from the current status. "
|
|
1729
|
+
"MANDATORY: before calling this tool you MUST show the user the exact status change "
|
|
1730
|
+
"(from -> to) and obtain explicit approval. Never call this tool without that explicit "
|
|
1731
|
+
"go-ahead — not even if the user asked you to 'move the ticket' earlier. Transitioning a Jira "
|
|
1732
|
+
"ticket is user-visible and not easily reversible."
|
|
1733
|
+
),
|
|
1734
|
+
"parameters": {
|
|
1735
|
+
"issue_key": "Jira ticket key, e.g. PX-386",
|
|
1736
|
+
"status": "Target status name, e.g. Backlog, Committed, Specify, In Implementation",
|
|
1737
|
+
},
|
|
1738
|
+
"fn": transition_jira_ticket,
|
|
1739
|
+
},
|
|
1740
|
+
"get_jira_link_types": {
|
|
1741
|
+
"description": (
|
|
1742
|
+
"Fetch the issue-link relationship types configured on this Jira "
|
|
1743
|
+
"instance (e.g. Relates, Blocks, 'follows on from'). Each type lists "
|
|
1744
|
+
"its outward and inward phrases. Use this to discover the valid "
|
|
1745
|
+
"relationship phrases to pass to link_jira_issues."
|
|
1746
|
+
),
|
|
1747
|
+
"parameters": {},
|
|
1748
|
+
"fn": get_jira_link_types,
|
|
1749
|
+
},
|
|
1750
|
+
"link_jira_issues": {
|
|
1751
|
+
"description": (
|
|
1752
|
+
"Create a directional link between two Jira tickets using a natural "
|
|
1753
|
+
"relationship phrase. The link reads from_issue <relationship> "
|
|
1754
|
+
"to_issue (e.g. from_issue='HP-1', to_issue='HP-2', "
|
|
1755
|
+
"relationship='blocks' means HP-1 blocks HP-2). 'relationship' is a "
|
|
1756
|
+
"phrase like 'blocks', 'is blocked by', 'relates to' or 'follows on "
|
|
1757
|
+
"from' — discover valid phrases with get_jira_link_types. "
|
|
1758
|
+
"MANDATORY: before calling this tool you MUST show the user the exact "
|
|
1759
|
+
"link to be created (from_issue, relationship, to_issue) and obtain "
|
|
1760
|
+
"explicit approval to create it. Never call this tool without that "
|
|
1761
|
+
"explicit go-ahead — not even if the user asked you to 'link the "
|
|
1762
|
+
"tickets' earlier. Creating a Jira link is user-visible and not "
|
|
1763
|
+
"easily reversible."
|
|
1764
|
+
),
|
|
1765
|
+
"parameters": {
|
|
1766
|
+
"from_issue": "Jira ticket key the relationship starts from, e.g. HP-1234",
|
|
1767
|
+
"to_issue": "Jira ticket key the relationship points to, e.g. HP-5678",
|
|
1768
|
+
"relationship": "Relationship phrase, e.g. 'blocks', 'is blocked by', 'relates to', 'follows on from'",
|
|
1769
|
+
},
|
|
1770
|
+
"fn": link_jira_issues,
|
|
1771
|
+
},
|
|
1772
|
+
"get_bitbucket_pr": {
|
|
1773
|
+
"description": "Fetch details of a Bitbucket pull request",
|
|
1774
|
+
"parameters": {
|
|
1775
|
+
"project": "Bitbucket project key, e.g. CXT",
|
|
1776
|
+
"repo": "Repository slug, e.g. cxt-mixpanel-analytics-module",
|
|
1777
|
+
"pr_id": "Pull request ID number"
|
|
1778
|
+
},
|
|
1779
|
+
"fn": get_bitbucket_pr
|
|
1780
|
+
},
|
|
1781
|
+
"get_bitbucket_pr_changes": {
|
|
1782
|
+
"description": "Fetch the list of changed files in a Bitbucket pull request",
|
|
1783
|
+
"parameters": {
|
|
1784
|
+
"project": "Bitbucket project key, e.g. CXT",
|
|
1785
|
+
"repo": "Repository slug, e.g. cxt-mixpanel-analytics-module",
|
|
1786
|
+
"pr_id": "Pull request ID number"
|
|
1787
|
+
},
|
|
1788
|
+
"fn": get_bitbucket_pr_changes
|
|
1789
|
+
},
|
|
1790
|
+
"search_bitbucket_prs": {
|
|
1791
|
+
"description": "Search for pull requests in a Bitbucket repository",
|
|
1792
|
+
"parameters": {
|
|
1793
|
+
"project": "Bitbucket project key, e.g. CXT",
|
|
1794
|
+
"repo": "Repository slug, e.g. cxt-mixpanel-analytics-module",
|
|
1795
|
+
"state": "PR state: OPEN, MERGED, DECLINED, or ALL (default: OPEN)"
|
|
1796
|
+
},
|
|
1797
|
+
"fn": search_bitbucket_prs
|
|
1798
|
+
},
|
|
1799
|
+
"get_commits_for_branch": {
|
|
1800
|
+
"description": "Fetch commits for a specific branch in a Bitbucket repository",
|
|
1801
|
+
"parameters": {
|
|
1802
|
+
"project": "Bitbucket project key, e.g. CXT",
|
|
1803
|
+
"repo": "Repository slug, e.g. cxt-mixpanel-analytics-module",
|
|
1804
|
+
"branch": "Branch name, e.g. feature/my-branch or master",
|
|
1805
|
+
"limit": "Maximum number of commits to return (default: 25)"
|
|
1806
|
+
},
|
|
1807
|
+
"fn": get_commits_for_branch
|
|
1808
|
+
},
|
|
1809
|
+
"get_commit_details": {
|
|
1810
|
+
"description": "Fetch details of a specific commit in a Bitbucket repository",
|
|
1811
|
+
"parameters": {
|
|
1812
|
+
"project": "Bitbucket project key, e.g. CXT",
|
|
1813
|
+
"repo": "Repository slug, e.g. cxt-mixpanel-analytics-module",
|
|
1814
|
+
"commit_id": "Commit SHA (full or abbreviated)"
|
|
1815
|
+
},
|
|
1816
|
+
"fn": get_commit_details
|
|
1817
|
+
},
|
|
1818
|
+
"get_commit_comments": {
|
|
1819
|
+
"description": "Fetch comments on a specific commit in a Bitbucket repository",
|
|
1820
|
+
"parameters": {
|
|
1821
|
+
"project": "Bitbucket project key, e.g. CXT",
|
|
1822
|
+
"repo": "Repository slug, e.g. cxt-mixpanel-analytics-module",
|
|
1823
|
+
"commit_id": "Commit SHA (full or abbreviated)"
|
|
1824
|
+
},
|
|
1825
|
+
"fn": get_commit_comments
|
|
1826
|
+
},
|
|
1827
|
+
"get_bitbucket_pr_comments": {
|
|
1828
|
+
"description": "Fetch comments on a Bitbucket pull request (general and inline comments with thread structure)",
|
|
1829
|
+
"parameters": {
|
|
1830
|
+
"project": "Bitbucket project key, e.g. CXT",
|
|
1831
|
+
"repo": "Repository slug, e.g. cxt-mixpanel-analytics-module",
|
|
1832
|
+
"pr_id": "Pull request ID number",
|
|
1833
|
+
"limit": "Maximum number of activities to return (default: 25)"
|
|
1834
|
+
},
|
|
1835
|
+
"fn": get_bitbucket_pr_comments
|
|
1836
|
+
},
|
|
1837
|
+
"reply_to_bitbucket_pr_comment": {
|
|
1838
|
+
"description": "Reply to a comment on a Bitbucket pull request. The reply is automatically prefixed with **[Claude Code]**",
|
|
1839
|
+
"parameters": {
|
|
1840
|
+
"project": "Bitbucket project key, e.g. CXT",
|
|
1841
|
+
"repo": "Repository slug, e.g. cxt-mixpanel-analytics-module",
|
|
1842
|
+
"pr_id": "Pull request ID number",
|
|
1843
|
+
"comment_id": "ID of the comment to reply to",
|
|
1844
|
+
"text": "The reply text (will be prefixed with **[Claude Code]** automatically)"
|
|
1845
|
+
},
|
|
1846
|
+
"fn": reply_to_bitbucket_pr_comment
|
|
1847
|
+
},
|
|
1848
|
+
"add_bitbucket_pr_comment": {
|
|
1849
|
+
"description": (
|
|
1850
|
+
"Post a new comment on a Bitbucket pull request — either a general top-level "
|
|
1851
|
+
"comment (omit path/line) or an inline comment anchored to a file (path) or a "
|
|
1852
|
+
"specific line of a file (path + line). The comment body is automatically prefixed "
|
|
1853
|
+
"with **[Claude Code]**. Use this for new review remarks; use "
|
|
1854
|
+
"reply_to_bitbucket_pr_comment to continue an existing thread. "
|
|
1855
|
+
"MANDATORY: before calling this tool you MUST show the full verbatim comment body "
|
|
1856
|
+
"to the user and obtain explicit approval to post it. Never call this tool without "
|
|
1857
|
+
"that explicit go-ahead — not even if the user asked you to 'post a comment' "
|
|
1858
|
+
"earlier. Posting to Bitbucket is user-visible and not easily reversible."
|
|
1859
|
+
),
|
|
1860
|
+
"parameters": {
|
|
1861
|
+
"project": "Bitbucket project key, e.g. CXT",
|
|
1862
|
+
"repo": "Repository slug, e.g. cxt-mixpanel-analytics-module",
|
|
1863
|
+
"pr_id": "Pull request ID number",
|
|
1864
|
+
"text": "The comment body (will be prefixed with **[Claude Code]** automatically)",
|
|
1865
|
+
"path": "Optional file path to anchor the comment to (e.g. src/foo/Bar.kt). Omit for a general PR comment.",
|
|
1866
|
+
"line": "Optional line number within path for an inline comment. Requires path.",
|
|
1867
|
+
"line_type": "Optional line type for anchoring: ADDED, REMOVED or CONTEXT. Default ADDED."
|
|
1868
|
+
},
|
|
1869
|
+
"optional": ("path", "line", "line_type"),
|
|
1870
|
+
"fn": add_bitbucket_pr_comment
|
|
1871
|
+
},
|
|
1872
|
+
"resolve_bitbucket_pr_comment": {
|
|
1873
|
+
"description": "Resolve a comment thread on a Bitbucket pull request",
|
|
1874
|
+
"parameters": {
|
|
1875
|
+
"project": "Bitbucket project key, e.g. CXT",
|
|
1876
|
+
"repo": "Repository slug, e.g. cxt-mixpanel-analytics-module",
|
|
1877
|
+
"pr_id": "Pull request ID number",
|
|
1878
|
+
"comment_id": "ID of the root comment of the thread to resolve"
|
|
1879
|
+
},
|
|
1880
|
+
"fn": resolve_bitbucket_pr_comment
|
|
1881
|
+
},
|
|
1882
|
+
"create_bitbucket_pr": {
|
|
1883
|
+
"description": "Create a Bitbucket pull request from a source branch into a target branch",
|
|
1884
|
+
"parameters": {
|
|
1885
|
+
"project": "Bitbucket project key, e.g. NAV",
|
|
1886
|
+
"repo": "Repository slug, e.g. navigation-service-deployment",
|
|
1887
|
+
"title": "PR title",
|
|
1888
|
+
"from_branch": "Source branch name without refs/heads/ prefix, e.g. issue/NAV-399",
|
|
1889
|
+
"to_branch": "Target branch name without refs/heads/ prefix (default: master)",
|
|
1890
|
+
"description": "PR description, markdown supported (default: empty)",
|
|
1891
|
+
"reviewers": "Comma-separated Bitbucket usernames to add as reviewers (optional)"
|
|
1892
|
+
},
|
|
1893
|
+
"optional": ("to_branch", "description", "reviewers"),
|
|
1894
|
+
"fn": create_bitbucket_pr
|
|
1895
|
+
},
|
|
1896
|
+
"get_jira_dev_info": {
|
|
1897
|
+
"description": (
|
|
1898
|
+
"Fetch the Jira Development-panel data for a ticket — the linked "
|
|
1899
|
+
"Bitbucket pull requests, branches, commits, or repositories that "
|
|
1900
|
+
"do NOT appear via get_jira_ticket (different API). Use this as the "
|
|
1901
|
+
"first step when asked about a ticket's PRs/builds: each returned "
|
|
1902
|
+
"item carries `repository.project` + `slug` + `id`, ready to pass "
|
|
1903
|
+
"into get_bitbucket_pr. Then take the PR's `fromCommit` and call "
|
|
1904
|
+
"get_bitbucket_commit_build_status to see if CI is green."
|
|
1905
|
+
),
|
|
1906
|
+
"parameters": {
|
|
1907
|
+
"ticket_id": "Jira ticket key, e.g. HP-1615",
|
|
1908
|
+
"data_type": "One of: pullrequest (default), branch, commit, repository"
|
|
1909
|
+
},
|
|
1910
|
+
"optional": ("data_type",),
|
|
1911
|
+
"fn": get_jira_dev_info
|
|
1912
|
+
},
|
|
1913
|
+
"get_bitbucket_commit_build_status": {
|
|
1914
|
+
"description": (
|
|
1915
|
+
"Fetch CI build statuses (Bamboo etc.) posted to a commit. Returns "
|
|
1916
|
+
"raw statuses plus an aggregateState in {GREEN, RED, RUNNING, "
|
|
1917
|
+
"MIXED, NONE}. Feed a PR's fromCommit here. For any non-GREEN "
|
|
1918
|
+
"result, parse the build key out of the returned status URL "
|
|
1919
|
+
"(e.g. /browse/HAUPIA-HAUPIATESTMETRICS36-8) and pass it to "
|
|
1920
|
+
"get_bamboo_build for failure details."
|
|
1921
|
+
),
|
|
1922
|
+
"parameters": {
|
|
1923
|
+
"commit_id": "Full commit SHA (40-char hex). Project/repo not needed — Bitbucket resolves SHAs globally."
|
|
1924
|
+
},
|
|
1925
|
+
"fn": get_bitbucket_commit_build_status
|
|
1926
|
+
},
|
|
1927
|
+
"get_bamboo_build": {
|
|
1928
|
+
"description": (
|
|
1929
|
+
"Fetch a Bamboo build (chain or job) with per-job results AND "
|
|
1930
|
+
"failed-test details in one call. Empty failedTests when green. "
|
|
1931
|
+
"Use when get_bitbucket_commit_build_status returns RED/MIXED. If "
|
|
1932
|
+
"a specific job failed and you need more than the test errors "
|
|
1933
|
+
"(e.g. Gradle output, artifact errors), call get_bamboo_build_log "
|
|
1934
|
+
"with that job's key and build number."
|
|
1935
|
+
),
|
|
1936
|
+
"parameters": {
|
|
1937
|
+
"build_key": "Bamboo build result key, e.g. HAUPIA-HAUPIATESTMETRICS36-8 (chain) or HAUPIA-HAUPIATESTMETRICS36-CC-1 (job)"
|
|
1938
|
+
},
|
|
1939
|
+
"fn": get_bamboo_build
|
|
1940
|
+
},
|
|
1941
|
+
"get_bamboo_build_log": {
|
|
1942
|
+
"description": (
|
|
1943
|
+
"Fetch the build log for a specific Bamboo job. Returns the last "
|
|
1944
|
+
"tail_lines lines (default 200, max 2000), optionally filtered by "
|
|
1945
|
+
"a regex (grep), plus the full log saved to a temp file at "
|
|
1946
|
+
"localPath so you can Read more if needed. job_key is the job-"
|
|
1947
|
+
"level key (e.g. HAUPIA-HAUPIATESTMETRICS36-CC), NOT the chain key."
|
|
1948
|
+
),
|
|
1949
|
+
"parameters": {
|
|
1950
|
+
"job_key": "Bamboo job key, e.g. HAUPIA-HAUPIATESTMETRICS36-CC",
|
|
1951
|
+
"build_number": "Build number, e.g. 8",
|
|
1952
|
+
"tail_lines": "Number of trailing lines to include (default 200, clamped to 1-2000)",
|
|
1953
|
+
"grep": "Optional regex; if set, only matching lines are kept before tailing"
|
|
1954
|
+
},
|
|
1955
|
+
"optional": ("tail_lines", "grep"),
|
|
1956
|
+
"fn": get_bamboo_build_log
|
|
1957
|
+
},
|
|
1958
|
+
"list_bamboo_artifacts": {
|
|
1959
|
+
"description": (
|
|
1960
|
+
"List a Bamboo build's published artifacts, expanding directory "
|
|
1961
|
+
"artifacts into their files. Accepts a chain key (e.g. PLAN-21) or "
|
|
1962
|
+
"job key (PLAN-BUILD-21) — artifacts attach to jobs, so chain keys "
|
|
1963
|
+
"are walked across all jobs. Each artifact reports isDirectory and, "
|
|
1964
|
+
"for directories, a files[] list of {name (relative path), path "
|
|
1965
|
+
"(download URL)}. Feed a file's artifact name + relative name into "
|
|
1966
|
+
"get_bamboo_artifact to download it. Use after get_bamboo_build to "
|
|
1967
|
+
"grab reports (e.g. the OWASP dependency-check HTML)."
|
|
1968
|
+
),
|
|
1969
|
+
"parameters": {
|
|
1970
|
+
"build_key": "Bamboo build key — chain (PLAN-21) or job (PLAN-BUILD-21)",
|
|
1971
|
+
"max_files": "Max files to collect from directory artifacts (default 200, clamped 1-2000)"
|
|
1972
|
+
},
|
|
1973
|
+
"optional": ("max_files",),
|
|
1974
|
+
"fn": list_bamboo_artifacts
|
|
1975
|
+
},
|
|
1976
|
+
"get_bamboo_artifact": {
|
|
1977
|
+
"description": (
|
|
1978
|
+
"Download one Bamboo artifact file, save it to a temp file "
|
|
1979
|
+
"(localPath), and inline its text content when small. Provide href "
|
|
1980
|
+
"for a direct download, or name to resolve via list_bamboo_artifacts; "
|
|
1981
|
+
"for a directory artifact pass file to pick the contained file "
|
|
1982
|
+
"(relative name or suffix). Text under max_inline_bytes is returned "
|
|
1983
|
+
"in content; larger or binary files return content=null with a note "
|
|
1984
|
+
"— Read localPath then. Large HTML reports (e.g. dependency-check) "
|
|
1985
|
+
"come back as localPath; grep that file for findings."
|
|
1986
|
+
),
|
|
1987
|
+
"parameters": {
|
|
1988
|
+
"build_key": "Bamboo build key (chain or job)",
|
|
1989
|
+
"name": "Artifact name to resolve (case-insensitive), e.g. 'owasp dependency check report'",
|
|
1990
|
+
"href": "Direct artifact download URL (from list_bamboo_artifacts); alternative to name",
|
|
1991
|
+
"file": "For directory artifacts: contained file's relative path or suffix, e.g. 'dependency-check-report.html'",
|
|
1992
|
+
"max_inline_bytes": "Inline text size cap (default 262144, clamped 0-5000000)"
|
|
1993
|
+
},
|
|
1994
|
+
"optional": ("name", "href", "file", "max_inline_bytes"),
|
|
1995
|
+
"fn": get_bamboo_artifact
|
|
1996
|
+
},
|
|
1997
|
+
"list_bamboo_plans": {
|
|
1998
|
+
"description": (
|
|
1999
|
+
"List the plans in a Bamboo project. Accepts a project key "
|
|
2000
|
+
"(e.g. CAASCAASCONNECT) or a browse URL "
|
|
2001
|
+
"(https://ci.e-spirit.de/browse/CAASCAASCONNECT). Each entry gives "
|
|
2002
|
+
"the plan key, name, enabled flag and browse url. Feed a returned "
|
|
2003
|
+
"plan key into list_bamboo_branch_plans to list its feature-branch "
|
|
2004
|
+
"builds."
|
|
2005
|
+
),
|
|
2006
|
+
"parameters": {
|
|
2007
|
+
"project": "Bamboo project key or browse URL, e.g. CAASCAASCONNECT",
|
|
2008
|
+
"max_results": "Max plans to return (default 200, clamped 1-2000)",
|
|
2009
|
+
},
|
|
2010
|
+
"optional": ("max_results",),
|
|
2011
|
+
"fn": list_bamboo_plans,
|
|
2012
|
+
},
|
|
2013
|
+
"list_bamboo_branch_plans": {
|
|
2014
|
+
"description": (
|
|
2015
|
+
"List the branch plans of a Bamboo plan. Accepts a plan key "
|
|
2016
|
+
"(e.g. CAASCAASCONNECT-BUILD) or a browse URL "
|
|
2017
|
+
"(https://ci.e-spirit.de/browse/CAASCAASCONNECT-BUILD). Each entry "
|
|
2018
|
+
"gives shortName (the git branch), key (the branch plan's build "
|
|
2019
|
+
"key, e.g. CAASCAASCONNECT-BUILD502), enabled flag and browse url. "
|
|
2020
|
+
"Empty branchPlans means the plan builds master only. Use "
|
|
2021
|
+
"list_bamboo_plans first if you only have a project key."
|
|
2022
|
+
),
|
|
2023
|
+
"parameters": {
|
|
2024
|
+
"plan": "Bamboo plan key or browse URL, e.g. CAASCAASCONNECT-BUILD",
|
|
2025
|
+
"max_results": "Max branch plans to return (default 200, clamped 1-2000)",
|
|
2026
|
+
},
|
|
2027
|
+
"optional": ("max_results",),
|
|
2028
|
+
"fn": list_bamboo_branch_plans,
|
|
2029
|
+
},
|
|
2030
|
+
"list_bamboo_plan_runs": {
|
|
2031
|
+
"description": (
|
|
2032
|
+
"List the retained build results (runs) of a Bamboo plan or branch "
|
|
2033
|
+
"plan, newest-first. Accepts a plan key (CAASCAASCONNECT-BUILD), a "
|
|
2034
|
+
"branch plan key (CAASCAASCONNECT-BUILD502) or a browse URL. Returns "
|
|
2035
|
+
"newest and oldestRetained runs (buildNumber, key, state, completed "
|
|
2036
|
+
"time), retainedCount and latestBuildNumber. NOTE: Bamboo expires "
|
|
2037
|
+
"old results, so oldestRetained/retainedCount reflect what is still "
|
|
2038
|
+
"kept, not the full history; latestBuildNumber is the total times "
|
|
2039
|
+
"the plan has ever run (build numbers are monotonic)."
|
|
2040
|
+
),
|
|
2041
|
+
"parameters": {
|
|
2042
|
+
"plan": "Bamboo plan/branch-plan key or browse URL, e.g. CAASCAASCONNECT-BUILD",
|
|
2043
|
+
"max_results": "Max runs to list (default 25, clamped 1-2000)",
|
|
2044
|
+
},
|
|
2045
|
+
"optional": ("max_results",),
|
|
2046
|
+
"fn": list_bamboo_plan_runs,
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
|
|
2051
|
+
def handle_request(req):
|
|
2052
|
+
"""Handle MCP JSON-RPC requests."""
|
|
2053
|
+
method = req.get("method")
|
|
2054
|
+
|
|
2055
|
+
if method == "initialize":
|
|
2056
|
+
return {
|
|
2057
|
+
"protocolVersion": "2024-11-05",
|
|
2058
|
+
"serverInfo": {
|
|
2059
|
+
"name": "jira-tools",
|
|
2060
|
+
"version": "1.0.0"
|
|
2061
|
+
},
|
|
2062
|
+
"capabilities": {
|
|
2063
|
+
"tools": {}
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
if method == "notifications/initialized":
|
|
2068
|
+
return None
|
|
2069
|
+
|
|
2070
|
+
if method == "tools/list":
|
|
2071
|
+
return {"tools": [
|
|
2072
|
+
{
|
|
2073
|
+
"name": name,
|
|
2074
|
+
"description": t["description"],
|
|
2075
|
+
"inputSchema": {
|
|
2076
|
+
"type": "object",
|
|
2077
|
+
"properties": {k: {"type": "string", "description": v} for k, v in t["parameters"].items()},
|
|
2078
|
+
"required": [
|
|
2079
|
+
k for k in t["parameters"].keys()
|
|
2080
|
+
if k not in ("state", "limit") and k not in t.get("optional", ())
|
|
2081
|
+
]
|
|
2082
|
+
}
|
|
2083
|
+
} for name, t in TOOLS.items()
|
|
2084
|
+
]}
|
|
2085
|
+
|
|
2086
|
+
if method == "tools/call":
|
|
2087
|
+
tool_name = req["params"]["name"]
|
|
2088
|
+
args = req["params"]["arguments"]
|
|
2089
|
+
try:
|
|
2090
|
+
result = TOOLS[tool_name]["fn"](**args)
|
|
2091
|
+
return {"content": [{"type": "text", "text": json.dumps(result, indent=2)}]}
|
|
2092
|
+
except Exception as e:
|
|
2093
|
+
error_detail = {
|
|
2094
|
+
"error": str(e),
|
|
2095
|
+
"tool": tool_name,
|
|
2096
|
+
"hint": "Check environment variables (JIRA_BASE_URL, JIRA_TOKEN, BITBUCKET_BASE_URL, BITBUCKET_TOKEN). Do NOT retry via WebFetch — the URL requires authentication headers."
|
|
2097
|
+
}
|
|
2098
|
+
return {"content": [{"type": "text", "text": json.dumps(error_detail, indent=2)}], "isError": True}
|
|
2099
|
+
|
|
2100
|
+
return {"error": {"code": -32601, "message": f"Unknown method: {method}"}}
|
|
2101
|
+
|
|
2102
|
+
|
|
2103
|
+
def main():
|
|
2104
|
+
"""Main entry point for MCP server."""
|
|
2105
|
+
for line in sys.stdin:
|
|
2106
|
+
req = json.loads(line)
|
|
2107
|
+
result = handle_request(req)
|
|
2108
|
+
|
|
2109
|
+
# Notifications don't get responses
|
|
2110
|
+
if result is None:
|
|
2111
|
+
continue
|
|
2112
|
+
|
|
2113
|
+
resp = {"jsonrpc": "2.0", "id": req. get("id")}
|
|
2114
|
+
if "error" in result and "code" in result. get("error", {}):
|
|
2115
|
+
resp["error"] = result["error"]
|
|
2116
|
+
else:
|
|
2117
|
+
resp["result"] = result
|
|
2118
|
+
print(json.dumps(resp), flush=True)
|
|
2119
|
+
|
|
2120
|
+
|
|
2121
|
+
if __name__ == "__main__":
|
|
2122
|
+
main()
|