Skip to content

Commit e986505

Browse files
authored
Merge pull request #1128 from cloudbees-oss/LCHUX-181
Extend --link flag to support explicit kinds
2 parents 2c5254b + 56ee7f5 commit e986505

File tree

9 files changed

+392
-30
lines changed

9 files changed

+392
-30
lines changed

launchable/commands/helper.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,8 @@ def find_or_create_session(
124124
session_id = read_session(saved_build_name)
125125
if session_id:
126126
_check_observation_mode_status(session_id, is_observation, tracking_client=tracking_client, app=context.obj)
127+
if links:
128+
click.echo(click.style("WARNING: --link option is ignored since session already exists."), err=True)
127129
return session_id
128130

129131
context.invoke(

launchable/commands/record/build.py

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import click
88
from tabulate import tabulate
99

10-
from launchable.utils.link import CIRCLECI_KEY, GITHUB_ACTIONS_KEY, JENKINS_URL_KEY, LinkKind, capture_link
10+
from launchable.utils.link import CIRCLECI_KEY, GITHUB_ACTIONS_KEY, JENKINS_URL_KEY, capture_links
1111
from launchable.utils.tracking import Tracking, TrackingClient
1212

1313
from ...utils import subprocess
@@ -316,14 +316,7 @@ def synthesize_workspaces() -> List[Workspace]:
316316
def send(ws: List[Workspace]) -> Optional[str]:
317317
# figure out all the CI links to capture
318318
def compute_links():
319-
_links = capture_link(os.environ)
320-
for k, v in links:
321-
_links.append({
322-
"title": k,
323-
"url": v,
324-
"kind": LinkKind.CUSTOM_LINK.name,
325-
})
326-
return _links
319+
return capture_links(links, os.environ)
327320

328321
try:
329322
payload = {

launchable/commands/record/session.py

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import click
99

1010
from launchable.utils.click import DATETIME_WITH_TZ, validate_past_datetime
11-
from launchable.utils.link import LinkKind, capture_link
11+
from launchable.utils.link import capture_links
1212
from launchable.utils.tracking import Tracking, TrackingClient
1313

1414
from ...utils.click import KEY_VALUE
@@ -181,25 +181,17 @@ def session(
181181

182182
flavor_dict = dict(flavor)
183183

184-
payload = {
185-
"flavors": flavor_dict,
186-
"isObservation": is_observation,
187-
"noBuild": is_no_build,
188-
"lineage": lineage,
189-
"testSuite": test_suite,
190-
"timestamp": timestamp.isoformat() if timestamp else None,
191-
}
192-
193-
_links = capture_link(os.environ)
194-
for link in links:
195-
_links.append({
196-
"title": link[0],
197-
"url": link[1],
198-
"kind": LinkKind.CUSTOM_LINK.name,
199-
})
200-
payload["links"] = _links
201-
202184
try:
185+
payload = {
186+
"flavors": flavor_dict,
187+
"isObservation": is_observation,
188+
"noBuild": is_no_build,
189+
"lineage": lineage,
190+
"testSuite": test_suite,
191+
"timestamp": timestamp.isoformat() if timestamp else None,
192+
"links": capture_links(links, os.environ),
193+
}
194+
203195
sub_path = "builds/{}/test_sessions".format(build_name)
204196
res = client.request("post", sub_path, payload=payload)
205197

launchable/commands/record/tests.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,11 @@ def tests(
229229

230230
is_no_build = False
231231

232+
if session and links:
233+
warn_and_exit_if_fail_fast_mode(
234+
"WARNING: `--link` and `--session` are set together.\n--link option can't be used with existing sessions."
235+
)
236+
232237
try:
233238
if is_no_build:
234239
session_id = "builds/{}/test_sessions/{}".format(NO_BUILD_BUILD_NAME, NO_BUILD_TEST_SESSION_ID)

launchable/utils/link.py

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import re
12
from enum import Enum
2-
from typing import Dict, List, Mapping
3+
from typing import Dict, List, Mapping, Sequence, Tuple
4+
5+
import click
36

47
JENKINS_URL_KEY = 'JENKINS_URL'
58
JENKINS_BUILD_URL_KEY = 'BUILD_URL'
@@ -18,6 +21,8 @@
1821
CIRCLECI_BUILD_NUM_KEY = 'CIRCLE_BUILD_NUM'
1922
CIRCLECI_JOB_KEY = 'CIRCLE_JOB'
2023

24+
GITHUB_PR_REGEX = re.compile(r"^https://github\.com/[^/]+/[^/]+/pull/\d+$")
25+
2126

2227
class LinkKind(Enum):
2328

@@ -71,3 +76,81 @@ def capture_link(env: Mapping[str, str]) -> List[Dict[str, str]]:
7176
})
7277

7378
return links
79+
80+
81+
def capture_links_from_options(link_options: Sequence[Tuple[str, str]]) -> List[Dict[str, str]]:
82+
"""
83+
Validate user-provided link options, inferring the kind when not explicitly specified.
84+
85+
Each link option is expected in the format "kind|title=url" or "title=url".
86+
If the kind is not provided, it infers the kind based on the URL pattern.
87+
88+
Returns:
89+
A list of dictionaries, where each dictionary contains the validated title, URL, and kind for each link.
90+
91+
Raises:
92+
click.UsageError: If an invalid kind is provided or URL doesn't match with the specified kind.
93+
"""
94+
links = []
95+
for k, url in link_options:
96+
url = url.strip()
97+
98+
# if k,v in format "kind|title=url"
99+
if '|' in k:
100+
kind, title = (part.strip() for part in k.split('|', 1))
101+
if kind not in _valid_kinds():
102+
msg = ("Invalid kind '{}' passed to --link option.\n"
103+
"Supported kinds are: {}".format(kind, _valid_kinds()))
104+
raise click.UsageError(click.style(msg, fg="red"))
105+
106+
if not _url_matches_kind(url, kind):
107+
msg = ("Invalid url '{}' passed to --link option.\n"
108+
"URL doesn't match with the specified kind: {}".format(url, kind))
109+
raise click.UsageError(click.style(msg, fg="red"))
110+
111+
# if k,v in format "title=url"
112+
else:
113+
kind = _infer_kind(url)
114+
title = k.strip()
115+
116+
links.append({
117+
"title": title,
118+
"url": url,
119+
"kind": kind,
120+
})
121+
122+
return links
123+
124+
125+
def capture_links(link_options: Sequence[Tuple[str, str]], env: Mapping[str, str]) -> List[Dict[str, str]]:
126+
127+
links = capture_links_from_options(link_options)
128+
129+
env_links = capture_link(env)
130+
for env_link in env_links:
131+
if not _has_kind(links, env_link['kind']):
132+
links.append(env_link)
133+
134+
return links
135+
136+
137+
def _infer_kind(url: str) -> str:
138+
if GITHUB_PR_REGEX.match(url):
139+
return LinkKind.GITHUB_PULL_REQUEST.name
140+
141+
return LinkKind.CUSTOM_LINK.name
142+
143+
144+
def _has_kind(input_links: List[Dict[str, str]], kind: str) -> bool:
145+
return any(link for link in input_links if link['kind'] == kind)
146+
147+
148+
def _valid_kinds() -> List[str]:
149+
return [kind.name for kind in LinkKind if kind != LinkKind.LINK_KIND_UNSPECIFIED]
150+
151+
152+
def _url_matches_kind(url: str, kind: str) -> bool:
153+
if kind == LinkKind.GITHUB_PULL_REQUEST.name:
154+
return bool(GITHUB_PR_REGEX.match(url))
155+
156+
return True

tests/commands/record/test_build.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,3 +308,50 @@ def test_with_timestamp(self, mock_check_output):
308308
}, payload)
309309

310310
self.assertEqual(read_build(), self.build_name)
311+
312+
@responses.activate
313+
@mock.patch.dict(os.environ, {"LAUNCHABLE_TOKEN": CliTestCase.launchable_token})
314+
def test_build_with_links(self):
315+
# Invalid kind
316+
result = self.cli(
317+
"record",
318+
"build",
319+
"--no-commit-collection",
320+
"--link",
321+
"UNKNOWN_KIND|PR=https://github.com/launchableinc/cli/pull/1",
322+
"--name",
323+
self.build_name)
324+
self.assertIn("Invalid kind 'UNKNOWN_KIND' passed to --link option", result.output)
325+
326+
# Invalid URL
327+
result = self.cli(
328+
"record",
329+
"build",
330+
"--no-commit-collection",
331+
"--link",
332+
"GITHUB_PULL_REQUEST|PR=https://github.com/launchableinc/cli/pull/1/files",
333+
"--name",
334+
self.build_name)
335+
self.assertIn("Invalid url 'https://github.com/launchableinc/cli/pull/1/files' passed to --link option", result.output)
336+
337+
# Infer kind
338+
result = self.cli(
339+
"record",
340+
"build",
341+
"--no-commit-collection",
342+
"--link",
343+
"PR=https://github.com/launchableinc/cli/pull/1",
344+
"--name",
345+
self.build_name)
346+
self.assert_success(result)
347+
348+
# Explicit kind
349+
result = self.cli(
350+
"record",
351+
"build",
352+
"--no-commit-collection",
353+
"--link",
354+
"GITHUB_PULL_REQUEST|PR=https://github.com/launchableinc/cli/pull/1",
355+
"--name",
356+
self.build_name)
357+
self.assert_success(result)

tests/commands/record/test_session.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import responses # type: ignore
66

77
from launchable.utils.http_client import get_base_url
8+
from launchable.utils.link import LinkKind
89
from tests.cli_test_case import CliTestCase
910

1011

@@ -188,3 +189,86 @@ def test_run_session_with_timestamp(self):
188189
"testSuite": None,
189190
"timestamp": "2023-10-01T12:00:00+00:00",
190191
}, payload)
192+
193+
@responses.activate
194+
@mock.patch.dict(os.environ, {
195+
"LAUNCHABLE_TOKEN": CliTestCase.launchable_token,
196+
'LANG': 'C.UTF-8',
197+
"GITHUB_PULL_REQUEST_URL": "https://github.com/launchableinc/cli/pull/1",
198+
}, clear=True)
199+
def test_run_session_with_links(self):
200+
# Endpoint to assert
201+
endpoint = "{}/intake/organizations/{}/workspaces/{}/builds/{}/test_sessions".format(
202+
get_base_url(),
203+
self.organization,
204+
self.workspace,
205+
self.build_name)
206+
207+
# Capture from environment
208+
result = self.cli("record", "session", "--build", self.build_name)
209+
self.assert_success(result)
210+
payload = json.loads(self.find_request(endpoint, 0).request.body.decode())
211+
self.assertEqual([{
212+
"kind": LinkKind.GITHUB_PULL_REQUEST.name,
213+
"title": "",
214+
"url": "https://github.com/launchableinc/cli/pull/1",
215+
}], payload["links"])
216+
217+
# Priority check
218+
result = self.cli("record", "session", "--build", self.build_name, "--link",
219+
"GITHUB_PULL_REQUEST|PR=https://github.com/launchableinc/cli/pull/2")
220+
self.assert_success(result)
221+
payload = json.loads(self.find_request(endpoint, 1).request.body.decode())
222+
self.assertEqual([{
223+
"kind": LinkKind.GITHUB_PULL_REQUEST.name,
224+
"title": "PR",
225+
"url": "https://github.com/launchableinc/cli/pull/2",
226+
}], payload["links"])
227+
228+
# Infer kind
229+
result = self.cli("record", "session", "--build", self.build_name, "--link",
230+
"PR=https://github.com/launchableinc/cli/pull/2")
231+
self.assert_success(result)
232+
payload = json.loads(self.find_request(endpoint, 2).request.body.decode())
233+
self.assertEqual([{
234+
"kind": LinkKind.GITHUB_PULL_REQUEST.name,
235+
"title": "PR",
236+
"url": "https://github.com/launchableinc/cli/pull/2",
237+
}], payload["links"])
238+
239+
# Explicit kind
240+
result = self.cli("record", "session", "--build", self.build_name, "--link",
241+
"GITHUB_PULL_REQUEST|PR=https://github.com/launchableinc/cli/pull/2")
242+
self.assert_success(result)
243+
payload = json.loads(self.find_request(endpoint, 3).request.body.decode())
244+
self.assertEqual([{
245+
"kind": LinkKind.GITHUB_PULL_REQUEST.name,
246+
"title": "PR",
247+
"url": "https://github.com/launchableinc/cli/pull/2",
248+
}], payload["links"])
249+
250+
# Multiple kinds
251+
result = self.cli("record", "session", "--build", self.build_name, "--link",
252+
"GITHUB_ACTIONS|=https://github.com/launchableinc/mothership/actions/runs/3747451612")
253+
self.assert_success(result)
254+
payload = json.loads(self.find_request(endpoint, 4).request.body.decode())
255+
self.assertEqual([{
256+
"kind": LinkKind.GITHUB_ACTIONS.name,
257+
"title": "",
258+
"url": "https://github.com/launchableinc/mothership/actions/runs/3747451612",
259+
},
260+
{
261+
"kind": LinkKind.GITHUB_PULL_REQUEST.name,
262+
"title": "",
263+
"url": "https://github.com/launchableinc/cli/pull/1",
264+
}], payload["links"])
265+
266+
# Invalid kind
267+
result = self.cli("record", "session", "--build", self.build_name, "--link",
268+
"UNKNOWN_KIND|PR=https://github.com/launchableinc/cli/pull/2")
269+
self.assertIn("Invalid kind 'UNKNOWN_KIND' passed to --link option", result.output)
270+
271+
# Invalid URL
272+
result = self.cli("record", "session", "--build", self.build_name, "--link",
273+
"GITHUB_PULL_REQUEST|PR=https://github.com/launchableinc/cli/pull/2/files")
274+
self.assertIn("Invalid url 'https://github.com/launchableinc/cli/pull/2/files' passed to --link option", result.output)

0 commit comments

Comments
 (0)