@groupby/ai-dev 0.5.15 → 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.
Files changed (41) hide show
  1. package/package.json +1 -1
  2. package/teams/fhr-orchestration/skills/create-jira/SKILL.md +47 -0
  3. package/teams/fhr-orchestration/skills/create-jira/TEMPLATE.md +10 -0
  4. package/teams/fhr-orchestration/skills/create-plan/SKILL.md +48 -0
  5. package/teams/fhr-orchestration/skills/create-plan/TEMPLATE.md +13 -0
  6. package/teams/fhr-orchestration/skills/init-harness/SKILL.md +164 -0
  7. package/teams/fhr-orchestration/skills/init-harness/assets/TEMPLATE.CLAUDE.md +88 -0
  8. package/teams/fhr-orchestration/skills/init-harness/assets/TEMPLATE.architecture.md +152 -0
  9. package/teams/fhr-orchestration/skills/init-harness/assets/TEMPLATE.coding-guidelines.md +320 -0
  10. package/teams/fhr-orchestration/skills/init-harness/assets/TEMPLATE.glossary.md +55 -0
  11. package/teams/fhr-orchestration/skills/init-harness/assets/TEMPLATE.harness-gap-log.md +59 -0
  12. package/teams/fhr-orchestration/skills/use-case-writer/SKILL.md +123 -0
  13. package/teams/fhr-orchestration/skills/use-case-writer/TEMPLATE.md +39 -0
  14. package/teams/fhr-orchestration/skills/write-spec/SKILL.md +56 -0
  15. package/teams/fhr-orchestration/skills/write-spec/TEMPLATE.md +29 -0
  16. package/teams/firstspirit-caas/mcp/jira-tools.py +2122 -0
  17. package/teams/firstspirit-caas/mcp/test_bamboo_artifacts.py +315 -0
  18. package/teams/firstspirit-caas/mcp/test_jira_links.py +192 -0
  19. package/teams/firstspirit-caas/mcp/test_update_jira_ticket.py +161 -0
  20. package/teams/firstspirit-caas/resources/mcp-setup.md +57 -0
  21. package/teams/firstspirit-caas/skills/chronicle/SKILL.md +105 -0
  22. package/teams/firstspirit-caas/skills/create-jira-ticket/SKILL.md +183 -0
  23. package/teams/firstspirit-caas/skills/get-jira-ticket/SKILL.md +125 -0
  24. package/teams/firstspirit-caas/skills/handle-red-cve-plan/SKILL.md +316 -0
  25. package/teams/firstspirit-caas/skills/handle-red-cve-plan/references/suppression.md +79 -0
  26. package/teams/firstspirit-caas/skills/handle-red-cve-plan/scripts/gen_suppression.py +116 -0
  27. package/teams/firstspirit-caas/skills/handle-red-cve-plan/scripts/parse_json_report.py +183 -0
  28. package/teams/firstspirit-caas/skills/handle-red-cve-plan/scripts/parse_report.py +255 -0
  29. package/teams/firstspirit-caas/skills/handle-red-cve-plan/scripts/test_gen_suppression.py +171 -0
  30. package/teams/firstspirit-caas/skills/handle-red-cve-plan/scripts/test_parse_json_report.py +244 -0
  31. package/teams/firstspirit-caas/skills/handle-red-cve-plan/scripts/test_parse_report.py +464 -0
  32. package/teams/firstspirit-caas/skills/handle-red-cve-plan/scripts/testdata/sample-report.html +39 -0
  33. package/teams/firstspirit-caas/skills/handle-red-cve-plan/scripts/testdata/sample-report.json +76 -0
  34. package/teams/firstspirit-caas/skills/handle-weekly-cve-plans/SKILL.md +238 -0
  35. package/teams/firstspirit-caas/skills/retrospect/SKILL.md +94 -0
  36. package/teams/firstspirit-caas/skills/retrospect/extract_prompts.py +392 -0
  37. package/teams/firstspirit-caas/skills/review/SKILL.md +131 -0
  38. package/teams/firstspirit-caas/skills/review-finalize/SKILL.md +103 -0
  39. package/teams/firstspirit-caas/skills/specify/SKILL.md +239 -0
  40. package/teams/firstspirit-caas/skills/summarise-for-jira-comment/SKILL.md +162 -0
  41. 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()