@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,315 @@
|
|
|
1
|
+
"""Standalone tests for the Bamboo artifact tools.
|
|
2
|
+
Run: python3 plugins/planetexpress/mcp/test_bamboo_artifacts.py
|
|
3
|
+
"""
|
|
4
|
+
import importlib.util
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import unittest
|
|
8
|
+
from unittest.mock import MagicMock, patch
|
|
9
|
+
|
|
10
|
+
os.environ.setdefault("JIRA_BASE_URL", "https://jira.example.com")
|
|
11
|
+
os.environ.setdefault("JIRA_TOKEN", "test-token")
|
|
12
|
+
os.environ.setdefault("BITBUCKET_BASE_URL", "https://bb.example.com")
|
|
13
|
+
os.environ.setdefault("BITBUCKET_TOKEN", "test-token")
|
|
14
|
+
os.environ.setdefault("BAMBOO_BASE_URL", "https://ci.example.com")
|
|
15
|
+
os.environ.setdefault("BAMBOO_TOKEN", "test-token")
|
|
16
|
+
|
|
17
|
+
_here = os.path.dirname(os.path.abspath(__file__))
|
|
18
|
+
_spec = importlib.util.spec_from_file_location(
|
|
19
|
+
"jira_tools", os.path.join(_here, "jira-tools.py")
|
|
20
|
+
)
|
|
21
|
+
_mod = importlib.util.module_from_spec(_spec)
|
|
22
|
+
_spec.loader.exec_module(_mod)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _mock_resp(body: bytes = b"", content_type: str = "application/json"):
|
|
26
|
+
resp = MagicMock()
|
|
27
|
+
resp.read.return_value = body
|
|
28
|
+
resp.headers = {"Content-Type": content_type}
|
|
29
|
+
resp.__enter__ = lambda s: s
|
|
30
|
+
resp.__exit__ = MagicMock(return_value=False)
|
|
31
|
+
return resp
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TestBambooRequestHeaders(unittest.TestCase):
|
|
35
|
+
|
|
36
|
+
@patch("urllib.request.urlopen")
|
|
37
|
+
def test_return_headers_gives_bytes_and_content_type(self, mock_urlopen):
|
|
38
|
+
mock_urlopen.return_value = _mock_resp(b"<html>x</html>", "text/html;charset=utf-8")
|
|
39
|
+
raw, ctype = _mod.bamboo_request("/artifact/x", accept_json=False, return_headers=True)
|
|
40
|
+
self.assertEqual(raw, b"<html>x</html>")
|
|
41
|
+
self.assertEqual(ctype, "text/html;charset=utf-8")
|
|
42
|
+
|
|
43
|
+
@patch("urllib.request.urlopen")
|
|
44
|
+
def test_default_contract_unchanged(self, mock_urlopen):
|
|
45
|
+
mock_urlopen.return_value = _mock_resp(json.dumps({"a": 1}).encode())
|
|
46
|
+
self.assertEqual(_mod.bamboo_request("/rest/x"), {"a": 1})
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# Mirrors real Bamboo output: the fetched URL is in /browse/ space, the parent
|
|
50
|
+
# link is UNQUOTED with "Parent Directory" text, and child links are quoted
|
|
51
|
+
# absolute /artifact/{plan}/build-N/ paths in a DIFFERENT prefix space.
|
|
52
|
+
INDEX_HTML = """<HTML><HEAD><TITLE>build</TITLE></HEAD><BODY>
|
|
53
|
+
<H1>build</H1><TABLE BORDER=0><TR><TD><A HREF=../label>Parent Directory</A></TD></TR>
|
|
54
|
+
<TR><TD><img src="/images/icons/icon_folder.gif" alt="(dir)"> <A HREF="/artifact/PLAN/BUILD/build-21/label/build/reports">reports</a> </TD></TR>
|
|
55
|
+
<TR><TD><A HREF="/artifact/PLAN/BUILD/build-21/label/build/index.html">index.html</a></TD></TR>
|
|
56
|
+
</TABLE></BODY></HTML>"""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class TestParseArtifactIndex(unittest.TestCase):
|
|
60
|
+
|
|
61
|
+
def test_parses_children_across_url_space(self):
|
|
62
|
+
base = "https://ci.example.com/browse/PLAN-21/artifact/BUILD/label/build"
|
|
63
|
+
entries = _mod._parse_artifact_index(INDEX_HTML, base)
|
|
64
|
+
by_name = {e["name"]: e for e in entries}
|
|
65
|
+
self.assertEqual(set(by_name), {"reports", "index.html"})
|
|
66
|
+
self.assertTrue(by_name["reports"]["isDir"])
|
|
67
|
+
self.assertFalse(by_name["index.html"]["isDir"])
|
|
68
|
+
self.assertEqual(
|
|
69
|
+
by_name["reports"]["href"],
|
|
70
|
+
"https://ci.example.com/artifact/PLAN/BUILD/build-21/label/build/reports")
|
|
71
|
+
|
|
72
|
+
def test_skips_parent_link(self):
|
|
73
|
+
base = "https://ci.example.com/browse/PLAN-21/artifact/BUILD/label/build"
|
|
74
|
+
entries = _mod._parse_artifact_index(INDEX_HTML, base)
|
|
75
|
+
for e in entries:
|
|
76
|
+
self.assertNotIn("parent", e["name"].lower())
|
|
77
|
+
self.assertNotIn("label", e["name"].lower())
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _result_with_artifacts():
|
|
81
|
+
return {
|
|
82
|
+
"buildResultKey": "PLAN-21",
|
|
83
|
+
"artifacts": {"size": 0, "artifact": []},
|
|
84
|
+
"stages": {"stage": [{"results": {"result": [{
|
|
85
|
+
"buildResultKey": "PLAN-BUILD-21",
|
|
86
|
+
"artifacts": {"size": 2, "artifact": [
|
|
87
|
+
{"name": "Build log", "shared": False,
|
|
88
|
+
"link": {"href": "https://ci.example.com/download/PLAN-BUILD/build_logs/PLAN-BUILD-21.log"}},
|
|
89
|
+
{"name": "owasp dependency check report", "shared": False,
|
|
90
|
+
"size": 2252875, "prettySizeDescription": "2 MB",
|
|
91
|
+
"producerJobKey": "PLAN-BUILD-21",
|
|
92
|
+
"link": {"href": "https://ci.example.com/browse/PLAN-21/artifact/BUILD/owasp-dependency-check-report/build"}},
|
|
93
|
+
]},
|
|
94
|
+
}]}}]},
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# The dep-check artifact href (browse space) lists a child in /artifact/ space,
|
|
99
|
+
# which in turn lists the report file — the real two-level, two-space tree.
|
|
100
|
+
DIR_BUILD = """<HTML><BODY><TABLE><TR><TD><A HREF=../owasp-dependency-check-report>Parent Directory</A></TD></TR>
|
|
101
|
+
<TR><TD><A HREF="/artifact/PLAN/BUILD/build-21/owasp-dependency-check-report/build/reports">reports</a></TD></TR></TABLE></BODY></HTML>"""
|
|
102
|
+
DIR_REPORTS = """<HTML><BODY><TABLE><TR><TD><A HREF=..>Parent Directory</A></TD></TR>
|
|
103
|
+
<TR><TD><A HREF="/artifact/PLAN/BUILD/build-21/owasp-dependency-check-report/build/reports/dependency-check-report.html">dependency-check-report.html</a></TD></TR></TABLE></BODY></HTML>"""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class TestListBambooArtifacts(unittest.TestCase):
|
|
107
|
+
|
|
108
|
+
@patch("urllib.request.urlopen")
|
|
109
|
+
def test_flattens_jobs_and_recurses_dir(self, mock_urlopen):
|
|
110
|
+
mock_urlopen.side_effect = [
|
|
111
|
+
_mock_resp(json.dumps(_result_with_artifacts()).encode()),
|
|
112
|
+
_mock_resp(DIR_BUILD.encode(), "text/html"),
|
|
113
|
+
_mock_resp(DIR_REPORTS.encode(), "text/html"),
|
|
114
|
+
]
|
|
115
|
+
result = _mod.list_bamboo_artifacts("PLAN-21")
|
|
116
|
+
names = {a["name"]: a for a in result["artifacts"]}
|
|
117
|
+
self.assertIn("owasp dependency check report", names)
|
|
118
|
+
self.assertIn("Build log", names)
|
|
119
|
+
self.assertFalse(names["Build log"]["isDirectory"])
|
|
120
|
+
dep = names["owasp dependency check report"]
|
|
121
|
+
self.assertTrue(dep["isDirectory"])
|
|
122
|
+
leaves = [f["name"] for f in dep["files"]]
|
|
123
|
+
self.assertIn("reports/dependency-check-report.html", leaves)
|
|
124
|
+
|
|
125
|
+
def test_registered_in_tools(self):
|
|
126
|
+
self.assertIn("list_bamboo_artifacts", _mod.TOOLS)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class TestGetBambooArtifact(unittest.TestCase):
|
|
130
|
+
|
|
131
|
+
@patch("urllib.request.urlopen")
|
|
132
|
+
def test_fetch_by_href_inlines_small_text(self, mock_urlopen):
|
|
133
|
+
mock_urlopen.return_value = _mock_resp(b"hello report", "text/plain")
|
|
134
|
+
r = _mod.get_bamboo_artifact(
|
|
135
|
+
"PLAN-21",
|
|
136
|
+
href="https://ci.example.com/artifact/PLAN/BUILD/build-21/label/reports/x.txt",
|
|
137
|
+
)
|
|
138
|
+
self.assertEqual(r["content"], "hello report")
|
|
139
|
+
self.assertEqual(r["contentType"], "text/plain")
|
|
140
|
+
self.assertTrue(os.path.exists(r["localPath"]))
|
|
141
|
+
self.assertEqual(r["file"], "x.txt")
|
|
142
|
+
|
|
143
|
+
@patch("urllib.request.urlopen")
|
|
144
|
+
def test_oversized_text_is_path_only(self, mock_urlopen):
|
|
145
|
+
big = b"A" * 5000
|
|
146
|
+
mock_urlopen.return_value = _mock_resp(big, "text/html")
|
|
147
|
+
r = _mod.get_bamboo_artifact(
|
|
148
|
+
"PLAN-21",
|
|
149
|
+
href="https://ci.example.com/artifact/PLAN/BUILD/build-21/label/big.html",
|
|
150
|
+
max_inline_bytes="1000",
|
|
151
|
+
)
|
|
152
|
+
self.assertIsNone(r["content"])
|
|
153
|
+
self.assertIn("too large", r["note"])
|
|
154
|
+
self.assertTrue(os.path.exists(r["localPath"]))
|
|
155
|
+
|
|
156
|
+
@patch("urllib.request.urlopen")
|
|
157
|
+
def test_binary_is_path_only(self, mock_urlopen):
|
|
158
|
+
mock_urlopen.return_value = _mock_resp(b"\x89PNG\x00\x01", "image/png")
|
|
159
|
+
r = _mod.get_bamboo_artifact(
|
|
160
|
+
"PLAN-21",
|
|
161
|
+
href="https://ci.example.com/artifact/PLAN/BUILD/build-21/label/img.png",
|
|
162
|
+
)
|
|
163
|
+
self.assertIsNone(r["content"])
|
|
164
|
+
self.assertIn("binary", r["note"])
|
|
165
|
+
|
|
166
|
+
@patch("urllib.request.urlopen")
|
|
167
|
+
def test_fetch_by_name_and_file_from_directory(self, mock_urlopen):
|
|
168
|
+
mock_urlopen.side_effect = [
|
|
169
|
+
_mock_resp(json.dumps(_result_with_artifacts()).encode()),
|
|
170
|
+
_mock_resp(DIR_BUILD.encode(), "text/html"),
|
|
171
|
+
_mock_resp(DIR_REPORTS.encode(), "text/html"),
|
|
172
|
+
_mock_resp(b"<html>dep-check</html>", "text/html"),
|
|
173
|
+
]
|
|
174
|
+
r = _mod.get_bamboo_artifact(
|
|
175
|
+
"PLAN-21",
|
|
176
|
+
name="owasp dependency check report",
|
|
177
|
+
file="dependency-check-report.html",
|
|
178
|
+
)
|
|
179
|
+
self.assertEqual(r["content"], "<html>dep-check</html>")
|
|
180
|
+
self.assertEqual(r["file"], "reports/dependency-check-report.html")
|
|
181
|
+
|
|
182
|
+
@patch("urllib.request.urlopen")
|
|
183
|
+
def test_unknown_name_lists_available(self, mock_urlopen):
|
|
184
|
+
mock_urlopen.return_value = _mock_resp(json.dumps(_result_with_artifacts()).encode())
|
|
185
|
+
with self.assertRaises(RuntimeError) as ctx:
|
|
186
|
+
_mod.get_bamboo_artifact("PLAN-21", name="nope")
|
|
187
|
+
self.assertIn("owasp dependency check report", str(ctx.exception))
|
|
188
|
+
|
|
189
|
+
def test_registered_in_tools(self):
|
|
190
|
+
self.assertIn("get_bamboo_artifact", _mod.TOOLS)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class TestKeyFromRef(unittest.TestCase):
|
|
194
|
+
|
|
195
|
+
def test_bare_key_unchanged(self):
|
|
196
|
+
self.assertEqual(_mod._bamboo_key_from_ref("CAASCAASCONNECT-BUILD"), "CAASCAASCONNECT-BUILD")
|
|
197
|
+
|
|
198
|
+
def test_browse_url_stripped(self):
|
|
199
|
+
self.assertEqual(
|
|
200
|
+
_mod._bamboo_key_from_ref("https://ci.example.com/browse/CAASCAASCONNECT-BUILD"),
|
|
201
|
+
"CAASCAASCONNECT-BUILD")
|
|
202
|
+
|
|
203
|
+
def test_query_and_trailing_slash_stripped(self):
|
|
204
|
+
self.assertEqual(
|
|
205
|
+
_mod._bamboo_key_from_ref("https://ci.example.com/browse/CAAS-BUILD/?x=1#frag"),
|
|
206
|
+
"CAAS-BUILD")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class TestListBambooBranchPlans(unittest.TestCase):
|
|
210
|
+
|
|
211
|
+
@patch("urllib.request.urlopen")
|
|
212
|
+
def test_parses_branches_and_accepts_url(self, mock_urlopen):
|
|
213
|
+
body = {
|
|
214
|
+
"key": "PLAN-BUILD",
|
|
215
|
+
"branches": {"size": 2, "branch": [
|
|
216
|
+
{"shortName": "feature/x", "key": "PLAN-BUILD2", "enabled": True},
|
|
217
|
+
{"shortName": "feature/y", "key": "PLAN-BUILD3", "enabled": False},
|
|
218
|
+
]},
|
|
219
|
+
}
|
|
220
|
+
mock_urlopen.return_value = _mock_resp(json.dumps(body).encode())
|
|
221
|
+
r = _mod.list_bamboo_branch_plans("https://ci.example.com/browse/PLAN-BUILD")
|
|
222
|
+
self.assertEqual(r["planKey"], "PLAN-BUILD")
|
|
223
|
+
self.assertEqual(r["branchCount"], 2)
|
|
224
|
+
self.assertEqual(r["branchPlans"][0],
|
|
225
|
+
{"shortName": "feature/x", "key": "PLAN-BUILD2", "enabled": True,
|
|
226
|
+
"url": "https://ci.example.com/browse/PLAN-BUILD2"})
|
|
227
|
+
called = mock_urlopen.call_args[0][0].full_url
|
|
228
|
+
self.assertIn("/rest/api/latest/plan/PLAN-BUILD/branch.json", called)
|
|
229
|
+
|
|
230
|
+
@patch("urllib.request.urlopen")
|
|
231
|
+
def test_master_only_plan_empty(self, mock_urlopen):
|
|
232
|
+
mock_urlopen.return_value = _mock_resp(json.dumps({"key": "PLAN-REL", "branches": {"size": 0}}).encode())
|
|
233
|
+
r = _mod.list_bamboo_branch_plans("PLAN-REL")
|
|
234
|
+
self.assertEqual(r["branchCount"], 0)
|
|
235
|
+
self.assertEqual(r["branchPlans"], [])
|
|
236
|
+
|
|
237
|
+
def test_registered_in_tools(self):
|
|
238
|
+
self.assertIn("list_bamboo_branch_plans", _mod.TOOLS)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class TestListBambooPlans(unittest.TestCase):
|
|
242
|
+
|
|
243
|
+
@patch("urllib.request.urlopen")
|
|
244
|
+
def test_parses_plans_and_accepts_url(self, mock_urlopen):
|
|
245
|
+
body = {
|
|
246
|
+
"key": "CAASCAASCONNECT",
|
|
247
|
+
"name": "caas-connect",
|
|
248
|
+
"plans": {"size": 1, "plan": [
|
|
249
|
+
{"shortName": "Build", "key": "CAASCAASCONNECT-BUILD", "name": "caas Build", "enabled": True},
|
|
250
|
+
]},
|
|
251
|
+
}
|
|
252
|
+
mock_urlopen.return_value = _mock_resp(json.dumps(body).encode())
|
|
253
|
+
r = _mod.list_bamboo_plans("https://ci.example.com/browse/CAASCAASCONNECT")
|
|
254
|
+
self.assertEqual(r["projectKey"], "CAASCAASCONNECT")
|
|
255
|
+
self.assertEqual(r["planCount"], 1)
|
|
256
|
+
self.assertEqual(r["plans"][0]["key"], "CAASCAASCONNECT-BUILD")
|
|
257
|
+
self.assertEqual(r["plans"][0]["url"], "https://ci.example.com/browse/CAASCAASCONNECT-BUILD")
|
|
258
|
+
called = mock_urlopen.call_args[0][0].full_url
|
|
259
|
+
self.assertIn("/rest/api/latest/project/CAASCAASCONNECT.json", called)
|
|
260
|
+
|
|
261
|
+
def test_registered_in_tools(self):
|
|
262
|
+
self.assertIn("list_bamboo_plans", _mod.TOOLS)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class TestListBambooPlanRuns(unittest.TestCase):
|
|
266
|
+
|
|
267
|
+
@patch("urllib.request.urlopen")
|
|
268
|
+
def test_newest_and_oldest_when_all_retained_fetched(self, mock_urlopen):
|
|
269
|
+
body = {"results": {"size": 2, "result": [
|
|
270
|
+
{"buildNumber": 12, "key": "PLAN-BUILD-12", "state": "Failed",
|
|
271
|
+
"buildCompletedTime": "2024-01-02", "buildRelativeTime": "1 day ago"},
|
|
272
|
+
{"buildNumber": 11, "key": "PLAN-BUILD-11", "state": "Successful",
|
|
273
|
+
"buildCompletedTime": "2024-01-01", "buildRelativeTime": "2 days ago"},
|
|
274
|
+
]}}
|
|
275
|
+
mock_urlopen.return_value = _mock_resp(json.dumps(body).encode())
|
|
276
|
+
r = _mod.list_bamboo_plan_runs("https://ci.example.com/browse/PLAN-BUILD")
|
|
277
|
+
self.assertEqual(r["retainedCount"], 2)
|
|
278
|
+
self.assertEqual(r["latestBuildNumber"], 12)
|
|
279
|
+
self.assertEqual(r["newest"]["buildNumber"], 12)
|
|
280
|
+
self.assertEqual(r["oldestRetained"]["buildNumber"], 11)
|
|
281
|
+
self.assertEqual(r["newest"]["url"], "https://ci.example.com/browse/PLAN-BUILD-12")
|
|
282
|
+
self.assertEqual(mock_urlopen.call_count, 1)
|
|
283
|
+
|
|
284
|
+
@patch("urllib.request.urlopen")
|
|
285
|
+
def test_oldest_fetched_via_tail_when_truncated(self, mock_urlopen):
|
|
286
|
+
page1 = {"results": {"size": 30, "result": [
|
|
287
|
+
{"buildNumber": 30, "key": "PLAN-BUILD-30", "state": "Successful"},
|
|
288
|
+
]}}
|
|
289
|
+
tail = {"results": {"size": 30, "result": [
|
|
290
|
+
{"buildNumber": 1, "key": "PLAN-BUILD-1", "state": "Failed"},
|
|
291
|
+
]}}
|
|
292
|
+
mock_urlopen.side_effect = [
|
|
293
|
+
_mock_resp(json.dumps(page1).encode()),
|
|
294
|
+
_mock_resp(json.dumps(tail).encode()),
|
|
295
|
+
]
|
|
296
|
+
r = _mod.list_bamboo_plan_runs("PLAN-BUILD", max_results="1")
|
|
297
|
+
self.assertEqual(r["newest"]["buildNumber"], 30)
|
|
298
|
+
self.assertEqual(r["oldestRetained"]["buildNumber"], 1)
|
|
299
|
+
self.assertIn("start-index=29", mock_urlopen.call_args_list[1][0][0].full_url)
|
|
300
|
+
|
|
301
|
+
@patch("urllib.request.urlopen")
|
|
302
|
+
def test_empty_plan(self, mock_urlopen):
|
|
303
|
+
mock_urlopen.return_value = _mock_resp(json.dumps({"results": {"size": 0}}).encode())
|
|
304
|
+
r = _mod.list_bamboo_plan_runs("PLAN-BUILD508")
|
|
305
|
+
self.assertEqual(r["retainedCount"], 0)
|
|
306
|
+
self.assertIsNone(r["newest"])
|
|
307
|
+
self.assertIsNone(r["oldestRetained"])
|
|
308
|
+
self.assertIsNone(r["latestBuildNumber"])
|
|
309
|
+
|
|
310
|
+
def test_registered_in_tools(self):
|
|
311
|
+
self.assertIn("list_bamboo_plan_runs", _mod.TOOLS)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
if __name__ == "__main__":
|
|
315
|
+
unittest.main()
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Standalone tests for the Jira issue-link tools.
|
|
2
|
+
Run: python3 plugins/planetexpress/mcp/test_jira_links.py
|
|
3
|
+
"""
|
|
4
|
+
import importlib.util
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import unittest
|
|
8
|
+
from unittest.mock import MagicMock, patch
|
|
9
|
+
|
|
10
|
+
os.environ.setdefault("JIRA_BASE_URL", "https://jira.example.com")
|
|
11
|
+
os.environ.setdefault("JIRA_TOKEN", "test-token")
|
|
12
|
+
os.environ.setdefault("BITBUCKET_BASE_URL", "https://bb.example.com")
|
|
13
|
+
os.environ.setdefault("BITBUCKET_TOKEN", "test-token")
|
|
14
|
+
os.environ.setdefault("BAMBOO_BASE_URL", "https://bamboo.example.com")
|
|
15
|
+
os.environ.setdefault("BAMBOO_TOKEN", "test-token")
|
|
16
|
+
|
|
17
|
+
_here = os.path.dirname(os.path.abspath(__file__))
|
|
18
|
+
_spec = importlib.util.spec_from_file_location(
|
|
19
|
+
"jira_tools", os.path.join(_here, "jira-tools.py")
|
|
20
|
+
)
|
|
21
|
+
_mod = importlib.util.module_from_spec(_spec)
|
|
22
|
+
_spec.loader.exec_module(_mod)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _mock_resp(body: bytes = b""):
|
|
26
|
+
resp = MagicMock()
|
|
27
|
+
resp.read.return_value = body
|
|
28
|
+
resp.__enter__ = lambda s: s
|
|
29
|
+
resp.__exit__ = MagicMock(return_value=False)
|
|
30
|
+
return resp
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Standard link-type catalogue used across tests.
|
|
34
|
+
LINK_TYPES = {
|
|
35
|
+
"issueLinkTypes": [
|
|
36
|
+
{"id": "10000", "name": "Blocks", "inward": "is blocked by", "outward": "blocks"},
|
|
37
|
+
{"id": "10001", "name": "Relates", "inward": "relates to", "outward": "relates to"},
|
|
38
|
+
{"id": "10002", "name": "Sequence", "inward": "follows on from", "outward": "precedes"},
|
|
39
|
+
]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _link_types_resp():
|
|
44
|
+
return _mock_resp(json.dumps(LINK_TYPES).encode())
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TestGetJiraLinkTypes(unittest.TestCase):
|
|
48
|
+
|
|
49
|
+
@patch("urllib.request.urlopen")
|
|
50
|
+
def test_returns_parsed_types(self, mock_urlopen):
|
|
51
|
+
mock_urlopen.return_value = _link_types_resp()
|
|
52
|
+
result = _mod.get_jira_link_types()
|
|
53
|
+
names = [t["name"] for t in result["link_types"]]
|
|
54
|
+
self.assertEqual(len(result["link_types"]), 3)
|
|
55
|
+
self.assertIn("Blocks", names)
|
|
56
|
+
self.assertEqual(result["link_types"][0]["outward"], "blocks")
|
|
57
|
+
self.assertEqual(result["link_types"][0]["inward"], "is blocked by")
|
|
58
|
+
req = mock_urlopen.call_args[0][0]
|
|
59
|
+
self.assertTrue(req.full_url.endswith("/rest/api/2/issueLinkType"))
|
|
60
|
+
|
|
61
|
+
def test_registered_in_tools(self):
|
|
62
|
+
self.assertIn("get_jira_link_types", _mod.TOOLS)
|
|
63
|
+
self.assertEqual(_mod.TOOLS["get_jira_link_types"]["parameters"], {})
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TestLinkJiraIssues(unittest.TestCase):
|
|
67
|
+
|
|
68
|
+
@patch("urllib.request.urlopen")
|
|
69
|
+
def test_outward_phrase(self, mock_urlopen):
|
|
70
|
+
mock_urlopen.side_effect = [_link_types_resp(), _mock_resp(b"")]
|
|
71
|
+
result = _mod.link_jira_issues("HP-1", "HP-2", "blocks")
|
|
72
|
+
self.assertEqual(result["type"], "Blocks")
|
|
73
|
+
self.assertEqual(result["summary"], "HP-1 blocks HP-2")
|
|
74
|
+
post_req = mock_urlopen.call_args_list[1][0][0]
|
|
75
|
+
self.assertEqual(post_req.method, "POST")
|
|
76
|
+
self.assertTrue(post_req.full_url.endswith("/rest/api/2/issueLink"))
|
|
77
|
+
sent = json.loads(post_req.data.decode())
|
|
78
|
+
self.assertEqual(sent["type"]["name"], "Blocks")
|
|
79
|
+
# Jira renders inwardIssue <outward> outwardIssue, so "HP-1 blocks HP-2"
|
|
80
|
+
# requires HP-1 in the inward slot.
|
|
81
|
+
self.assertEqual(sent["inwardIssue"]["key"], "HP-1")
|
|
82
|
+
self.assertEqual(sent["outwardIssue"]["key"], "HP-2")
|
|
83
|
+
|
|
84
|
+
@patch("urllib.request.urlopen")
|
|
85
|
+
def test_inward_phrase_swaps_direction(self, mock_urlopen):
|
|
86
|
+
mock_urlopen.side_effect = [_link_types_resp(), _mock_resp(b"")]
|
|
87
|
+
result = _mod.link_jira_issues("HP-1", "HP-2", "is blocked by")
|
|
88
|
+
sent = json.loads(mock_urlopen.call_args_list[1][0][0].data.decode())
|
|
89
|
+
self.assertEqual(sent["type"]["name"], "Blocks")
|
|
90
|
+
# "HP-1 is blocked by HP-2" == outwardIssue <inward> inwardIssue, so HP-1
|
|
91
|
+
# goes in the outward slot.
|
|
92
|
+
self.assertEqual(sent["outwardIssue"]["key"], "HP-1")
|
|
93
|
+
self.assertEqual(sent["inwardIssue"]["key"], "HP-2")
|
|
94
|
+
self.assertEqual(result["summary"], "HP-1 is blocked by HP-2")
|
|
95
|
+
|
|
96
|
+
@patch("urllib.request.urlopen")
|
|
97
|
+
def test_bare_type_name_defaults_outward(self, mock_urlopen):
|
|
98
|
+
mock_urlopen.side_effect = [_link_types_resp(), _mock_resp(b"")]
|
|
99
|
+
result = _mod.link_jira_issues("HP-1", "HP-2", "Relates")
|
|
100
|
+
sent = json.loads(mock_urlopen.call_args_list[1][0][0].data.decode())
|
|
101
|
+
self.assertEqual(sent["type"]["name"], "Relates")
|
|
102
|
+
self.assertEqual(sent["inwardIssue"]["key"], "HP-1")
|
|
103
|
+
self.assertEqual(result["relationship"], "relates to")
|
|
104
|
+
|
|
105
|
+
@patch("urllib.request.urlopen")
|
|
106
|
+
def test_symmetric_phrase_not_ambiguous(self, mock_urlopen):
|
|
107
|
+
mock_urlopen.side_effect = [_link_types_resp(), _mock_resp(b"")]
|
|
108
|
+
result = _mod.link_jira_issues("HP-1", "HP-2", "relates to")
|
|
109
|
+
self.assertEqual(result["type"], "Relates")
|
|
110
|
+
sent = json.loads(mock_urlopen.call_args_list[1][0][0].data.decode())
|
|
111
|
+
self.assertEqual(sent["type"]["name"], "Relates")
|
|
112
|
+
|
|
113
|
+
@patch("urllib.request.urlopen")
|
|
114
|
+
def test_no_match_raises_listing_phrases(self, mock_urlopen):
|
|
115
|
+
mock_urlopen.side_effect = [_link_types_resp()]
|
|
116
|
+
with self.assertRaises(ValueError) as ctx:
|
|
117
|
+
_mod.link_jira_issues("HP-1", "HP-2", "supersedes")
|
|
118
|
+
msg = str(ctx.exception)
|
|
119
|
+
self.assertIn("blocks", msg)
|
|
120
|
+
self.assertIn("relates to", msg)
|
|
121
|
+
|
|
122
|
+
@patch("urllib.request.urlopen")
|
|
123
|
+
def test_ambiguous_phrase_raises(self, mock_urlopen):
|
|
124
|
+
ambiguous = {"issueLinkTypes": [
|
|
125
|
+
{"id": "1", "name": "Cloners", "inward": "is cloned by", "outward": "clones"},
|
|
126
|
+
{"id": "2", "name": "Duplicate", "inward": "is duplicated by", "outward": "clones"},
|
|
127
|
+
]}
|
|
128
|
+
mock_urlopen.side_effect = [_mock_resp(json.dumps(ambiguous).encode())]
|
|
129
|
+
with self.assertRaises(ValueError) as ctx:
|
|
130
|
+
_mod.link_jira_issues("HP-1", "HP-2", "clones")
|
|
131
|
+
self.assertIn("ambiguous", str(ctx.exception).lower())
|
|
132
|
+
|
|
133
|
+
def test_empty_args_raise(self):
|
|
134
|
+
with self.assertRaises(ValueError):
|
|
135
|
+
_mod.link_jira_issues("", "HP-2", "blocks")
|
|
136
|
+
with self.assertRaises(ValueError):
|
|
137
|
+
_mod.link_jira_issues("HP-1", "HP-2", " ")
|
|
138
|
+
|
|
139
|
+
def test_registered_in_tools(self):
|
|
140
|
+
self.assertIn("link_jira_issues", _mod.TOOLS)
|
|
141
|
+
params = _mod.TOOLS["link_jira_issues"]["parameters"]
|
|
142
|
+
self.assertEqual(set(params), {"from_issue", "to_issue", "relationship"})
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class TestGetTicketLinks(unittest.TestCase):
|
|
146
|
+
|
|
147
|
+
@patch("urllib.request.urlopen")
|
|
148
|
+
def test_extracts_outward_and_inward_links(self, mock_urlopen):
|
|
149
|
+
issue = {
|
|
150
|
+
"key": "HP-1",
|
|
151
|
+
"fields": {
|
|
152
|
+
"summary": "Parent",
|
|
153
|
+
"issuelinks": [
|
|
154
|
+
{
|
|
155
|
+
"id": "1",
|
|
156
|
+
"type": {"name": "Blocks", "inward": "is blocked by", "outward": "blocks"},
|
|
157
|
+
"outwardIssue": {
|
|
158
|
+
"key": "HP-2",
|
|
159
|
+
"fields": {"summary": "Downstream", "status": {"name": "Open"}},
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
"id": "2",
|
|
164
|
+
"type": {"name": "Blocks", "inward": "is blocked by", "outward": "blocks"},
|
|
165
|
+
"inwardIssue": {
|
|
166
|
+
"key": "HP-3",
|
|
167
|
+
"fields": {"summary": "Upstream", "status": {"name": "Done"}},
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
},
|
|
172
|
+
}
|
|
173
|
+
mock_urlopen.return_value = _mock_resp(json.dumps(issue).encode())
|
|
174
|
+
result = _mod.get_ticket("HP-1")
|
|
175
|
+
links = result["links"]
|
|
176
|
+
self.assertEqual(len(links), 2)
|
|
177
|
+
self.assertEqual(links[0]["relationship"], "blocks")
|
|
178
|
+
self.assertEqual(links[0]["key"], "HP-2")
|
|
179
|
+
self.assertEqual(links[0]["status"], "Open")
|
|
180
|
+
self.assertEqual(links[1]["relationship"], "is blocked by")
|
|
181
|
+
self.assertEqual(links[1]["key"], "HP-3")
|
|
182
|
+
|
|
183
|
+
@patch("urllib.request.urlopen")
|
|
184
|
+
def test_no_links_yields_empty_list(self, mock_urlopen):
|
|
185
|
+
issue = {"key": "HP-9", "fields": {"summary": "Lonely"}}
|
|
186
|
+
mock_urlopen.return_value = _mock_resp(json.dumps(issue).encode())
|
|
187
|
+
result = _mod.get_ticket("HP-9")
|
|
188
|
+
self.assertEqual(result["links"], [])
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
if __name__ == "__main__":
|
|
192
|
+
unittest.main()
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Standalone tests for update_jira_ticket.
|
|
2
|
+
Run: python3 plugins/planetexpress/mcp/test_update_jira_ticket.py
|
|
3
|
+
"""
|
|
4
|
+
import importlib.util
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import unittest
|
|
8
|
+
from unittest.mock import MagicMock, patch
|
|
9
|
+
|
|
10
|
+
os.environ.setdefault("JIRA_BASE_URL", "https://jira.example.com")
|
|
11
|
+
os.environ.setdefault("JIRA_TOKEN", "test-token")
|
|
12
|
+
os.environ.setdefault("BITBUCKET_BASE_URL", "https://bb.example.com")
|
|
13
|
+
os.environ.setdefault("BITBUCKET_TOKEN", "test-token")
|
|
14
|
+
os.environ.setdefault("BAMBOO_BASE_URL", "https://bamboo.example.com")
|
|
15
|
+
os.environ.setdefault("BAMBOO_TOKEN", "test-token")
|
|
16
|
+
|
|
17
|
+
_here = os.path.dirname(os.path.abspath(__file__))
|
|
18
|
+
_spec = importlib.util.spec_from_file_location(
|
|
19
|
+
"jira_tools", os.path.join(_here, "jira-tools.py")
|
|
20
|
+
)
|
|
21
|
+
_mod = importlib.util.module_from_spec(_spec)
|
|
22
|
+
_spec.loader.exec_module(_mod)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _mock_resp(body: bytes = b""):
|
|
26
|
+
resp = MagicMock()
|
|
27
|
+
resp.read.return_value = body
|
|
28
|
+
resp.__enter__ = lambda s: s
|
|
29
|
+
resp.__exit__ = MagicMock(return_value=False)
|
|
30
|
+
return resp
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TestUpdateJiraTicket(unittest.TestCase):
|
|
34
|
+
|
|
35
|
+
@patch("urllib.request.urlopen")
|
|
36
|
+
def test_update_description_only(self, mock_urlopen):
|
|
37
|
+
mock_urlopen.return_value = _mock_resp(b"")
|
|
38
|
+
result = _mod.update_jira_ticket("HP-1234", description="Why and what.")
|
|
39
|
+
self.assertEqual(result["key"], "HP-1234")
|
|
40
|
+
self.assertTrue(result["updated"])
|
|
41
|
+
self.assertIn("HP-1234", result["link"])
|
|
42
|
+
req = mock_urlopen.call_args[0][0]
|
|
43
|
+
self.assertIn("HP-1234", req.full_url)
|
|
44
|
+
self.assertEqual(req.method, "PUT")
|
|
45
|
+
sent = json.loads(req.data.decode())
|
|
46
|
+
self.assertEqual(sent["fields"]["description"], "Why and what.")
|
|
47
|
+
self.assertNotIn("summary", sent["fields"])
|
|
48
|
+
|
|
49
|
+
@patch("urllib.request.urlopen")
|
|
50
|
+
def test_update_summary_only(self, mock_urlopen):
|
|
51
|
+
mock_urlopen.return_value = _mock_resp(b"")
|
|
52
|
+
_mod.update_jira_ticket("HP-1234", summary="New title")
|
|
53
|
+
sent = json.loads(mock_urlopen.call_args[0][0].data.decode())
|
|
54
|
+
self.assertEqual(sent["fields"]["summary"], "New title")
|
|
55
|
+
self.assertNotIn("description", sent["fields"])
|
|
56
|
+
|
|
57
|
+
@patch("urllib.request.urlopen")
|
|
58
|
+
def test_update_both_fields(self, mock_urlopen):
|
|
59
|
+
mock_urlopen.return_value = _mock_resp(b"")
|
|
60
|
+
_mod.update_jira_ticket("HP-1234", description="Why.", summary="New title")
|
|
61
|
+
sent = json.loads(mock_urlopen.call_args[0][0].data.decode())
|
|
62
|
+
self.assertEqual(sent["fields"]["description"], "Why.")
|
|
63
|
+
self.assertEqual(sent["fields"]["summary"], "New title")
|
|
64
|
+
|
|
65
|
+
def test_neither_field_raises(self):
|
|
66
|
+
with self.assertRaises(ValueError):
|
|
67
|
+
_mod.update_jira_ticket("HP-1234")
|
|
68
|
+
|
|
69
|
+
def test_empty_issue_key_raises(self):
|
|
70
|
+
with self.assertRaises(ValueError):
|
|
71
|
+
_mod.update_jira_ticket("", description="Why.")
|
|
72
|
+
|
|
73
|
+
@patch("urllib.request.urlopen")
|
|
74
|
+
def test_add_labels_uses_update_verb(self, mock_urlopen):
|
|
75
|
+
mock_urlopen.return_value = _mock_resp(b"")
|
|
76
|
+
result = _mod.update_jira_ticket("HP-1234", add_labels="cve, security")
|
|
77
|
+
sent = json.loads(mock_urlopen.call_args[0][0].data.decode())
|
|
78
|
+
self.assertNotIn("fields", sent)
|
|
79
|
+
self.assertEqual(
|
|
80
|
+
sent["update"]["labels"], [{"add": "cve"}, {"add": "security"}]
|
|
81
|
+
)
|
|
82
|
+
self.assertEqual(result["added_labels"], ["cve", "security"])
|
|
83
|
+
|
|
84
|
+
@patch("urllib.request.urlopen")
|
|
85
|
+
def test_add_and_remove_labels_with_description(self, mock_urlopen):
|
|
86
|
+
mock_urlopen.return_value = _mock_resp(b"")
|
|
87
|
+
_mod.update_jira_ticket(
|
|
88
|
+
"HP-1234", description="Why.", add_labels="a", remove_labels="b"
|
|
89
|
+
)
|
|
90
|
+
sent = json.loads(mock_urlopen.call_args[0][0].data.decode())
|
|
91
|
+
self.assertEqual(sent["fields"]["description"], "Why.")
|
|
92
|
+
self.assertEqual(
|
|
93
|
+
sent["update"]["labels"], [{"add": "a"}, {"remove": "b"}]
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def test_labels_only_no_field_does_not_raise(self):
|
|
97
|
+
with patch("urllib.request.urlopen", return_value=_mock_resp(b"")):
|
|
98
|
+
result = _mod.update_jira_ticket("HP-1234", add_labels="x")
|
|
99
|
+
self.assertTrue(result["updated"])
|
|
100
|
+
|
|
101
|
+
def test_tool_registered_in_tools_dict(self):
|
|
102
|
+
self.assertIn("update_jira_ticket", _mod.TOOLS)
|
|
103
|
+
params = _mod.TOOLS["update_jira_ticket"]["parameters"]
|
|
104
|
+
self.assertIn("issue_key", params)
|
|
105
|
+
self.assertIn("description", params)
|
|
106
|
+
self.assertIn("summary", params)
|
|
107
|
+
self.assertIn("add_labels", params)
|
|
108
|
+
self.assertIn("remove_labels", params)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class TestTransitionJiraTicket(unittest.TestCase):
|
|
112
|
+
|
|
113
|
+
_TRANSITIONS = json.dumps({
|
|
114
|
+
"transitions": [
|
|
115
|
+
{"id": "11", "name": "Backlog", "to": {"name": "Backlog"}},
|
|
116
|
+
{"id": "21", "name": "Commit", "to": {"name": "Committed"}},
|
|
117
|
+
]
|
|
118
|
+
}).encode()
|
|
119
|
+
|
|
120
|
+
@patch("urllib.request.urlopen")
|
|
121
|
+
def test_get_transitions(self, mock_urlopen):
|
|
122
|
+
mock_urlopen.return_value = _mock_resp(self._TRANSITIONS)
|
|
123
|
+
result = _mod.get_jira_transitions("PX-386")
|
|
124
|
+
self.assertEqual(result["key"], "PX-386")
|
|
125
|
+
self.assertEqual(
|
|
126
|
+
result["transitions"],
|
|
127
|
+
[
|
|
128
|
+
{"id": "11", "name": "Backlog", "to_status": "Backlog"},
|
|
129
|
+
{"id": "21", "name": "Commit", "to_status": "Committed"},
|
|
130
|
+
],
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
@patch("urllib.request.urlopen")
|
|
134
|
+
def test_transition_matches_target_status(self, mock_urlopen):
|
|
135
|
+
mock_urlopen.side_effect = [
|
|
136
|
+
_mock_resp(self._TRANSITIONS),
|
|
137
|
+
_mock_resp(b""),
|
|
138
|
+
]
|
|
139
|
+
result = _mod.transition_jira_ticket("PX-386", "committed")
|
|
140
|
+
self.assertEqual(result["status"], "Committed")
|
|
141
|
+
self.assertEqual(result["transition"], "Commit")
|
|
142
|
+
post_req = mock_urlopen.call_args_list[1][0][0]
|
|
143
|
+
self.assertEqual(post_req.method, "POST")
|
|
144
|
+
self.assertIn("PX-386/transitions", post_req.full_url)
|
|
145
|
+
sent = json.loads(post_req.data.decode())
|
|
146
|
+
self.assertEqual(sent["transition"]["id"], "21")
|
|
147
|
+
|
|
148
|
+
@patch("urllib.request.urlopen")
|
|
149
|
+
def test_transition_no_match_raises_with_options(self, mock_urlopen):
|
|
150
|
+
mock_urlopen.return_value = _mock_resp(self._TRANSITIONS)
|
|
151
|
+
with self.assertRaises(ValueError) as ctx:
|
|
152
|
+
_mod.transition_jira_ticket("PX-386", "Done")
|
|
153
|
+
self.assertIn("Committed", str(ctx.exception))
|
|
154
|
+
|
|
155
|
+
def test_transition_tools_registered(self):
|
|
156
|
+
self.assertIn("get_jira_transitions", _mod.TOOLS)
|
|
157
|
+
self.assertIn("transition_jira_ticket", _mod.TOOLS)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
if __name__ == "__main__":
|
|
161
|
+
unittest.main()
|