diff --git a/backend/test_observer/controllers/issues/issue_url_parser.py b/backend/test_observer/controllers/issues/issue_url_parser.py index a0bad3376..bbacb8edf 100644 --- a/backend/test_observer/controllers/issues/issue_url_parser.py +++ b/backend/test_observer/controllers/issues/issue_url_parser.py @@ -14,12 +14,41 @@ # SPDX-License-Identifier: AGPL-3.0-only import re +from urllib.parse import urlparse +import requests from pydantic import HttpUrl from test_observer.data_access.models_enums import IssueSource +def _resolve_launchpad_short_url(bug_id: str) -> tuple[str, str]: + """ + Resolve a short Launchpad bug URL (launchpad.net/bugs/) by following + its redirect and extracting the project and key from the canonical URL. + + Returns: + (project, key) extracted from the redirect target. + Raises: + ValueError if the redirect cannot be followed or parsed. + """ + short_url = f"https://launchpad.net/bugs/{bug_id}" + try: + response = requests.head(short_url, allow_redirects=True, timeout=10) + resolved_url = response.url + except requests.RequestException as e: + raise ValueError(f"Could not resolve Launchpad short URL {short_url}: {e}") from e + + parsed = urlparse(resolved_url) + match = re.match(r"^/([^/]+)(?:/\+source/[^/]+)?/\+bug/(\d+)$", parsed.path) + if match and parsed.hostname == "bugs.launchpad.net": + return match.group(1).lower(), match.group(2) + + raise ValueError( + f"Launchpad short URL {short_url} resolved to an unrecognised URL: {resolved_url}" + ) + + def issue_source_project_key_from_url(url: HttpUrl) -> tuple[IssueSource, str, str]: """ Extract (source, project, key) from an issue URL. @@ -44,6 +73,12 @@ def issue_source_project_key_from_url(url: HttpUrl) -> tuple[IssueSource, str, s if match: return IssueSource.LAUNCHPAD, match.group(1).lower(), match.group(2) + elif host == "launchpad.net": + match = re.match(r"^/bugs/(\d+)$", path) + if match: + project, key = _resolve_launchpad_short_url(match.group(1)) + return IssueSource.LAUNCHPAD, project, key + raise ValueError( f"Unrecognized issue URL format:\n" f" host = '{host}'\n" @@ -51,5 +86,6 @@ def issue_source_project_key_from_url(url: HttpUrl) -> tuple[IssueSource, str, s f"Expected formats:\n" f" GitHub: https://github.com///issues/\n" f" JIRA: https://warthogs.atlassian.net/browse/\n" - f" Launchpad: https://bugs.launchpad.net//+bug/" + f" Launchpad: https://bugs.launchpad.net//+bug/\n" + f" Launchpad: https://launchpad.net/bugs/" ) diff --git a/backend/test_observer/data_access/models.py b/backend/test_observer/data_access/models.py index fd095409b..4d6138765 100644 --- a/backend/test_observer/data_access/models.py +++ b/backend/test_observer/data_access/models.py @@ -879,7 +879,9 @@ def url(self) -> str: elif self.source == IssueSource.JIRA: return f"https://warthogs.atlassian.net/browse/{self.project}-{self.key}" elif self.source == IssueSource.LAUNCHPAD: - return f"https://bugs.launchpad.net/{self.project}/+bug/{self.key}" + if self.project: + return f"https://bugs.launchpad.net/{self.project}/+bug/{self.key}" + return f"https://launchpad.net/bugs/{self.key}" raise ValueError("Unrecognized issue source") @url.inplace.expression @@ -906,11 +908,20 @@ def _url_expression(cls) -> ColumnElement[str]: ), ( cls.source == IssueSource.LAUNCHPAD, - func.concat( - "https://bugs.launchpad.net/", - cls.project, - "/+bug/", - cls.key, + case( + ( + cls.project != "", + func.concat( + "https://bugs.launchpad.net/", + cls.project, + "/+bug/", + cls.key, + ), + ), + else_=func.concat( + "https://launchpad.net/bugs/", + cls.key, + ), ), ), else_="https://invalid", diff --git a/backend/tests/controllers/issues/test_issue_url_parser.py b/backend/tests/controllers/issues/test_issue_url_parser.py index 8320c0c0b..40e02e4c0 100644 --- a/backend/tests/controllers/issues/test_issue_url_parser.py +++ b/backend/tests/controllers/issues/test_issue_url_parser.py @@ -14,6 +14,7 @@ # SPDX-License-Identifier: AGPL-3.0-only import pytest +import requests_mock as req_mock from pydantic import HttpUrl from test_observer.controllers.issues.issue_url_parser import ( @@ -98,3 +99,70 @@ def test_from_url(url: str, expected: tuple[IssueSource, str, str] | None): assert expected is None else: assert result == expected + + +def test_launchpad_short_url_resolves_project(): + """launchpad.net/bugs/ should follow the redirect and return the project.""" + with req_mock.Mocker() as m: + m.head( + "https://launchpad.net/bugs/1951586", + status_code=301, + headers={"Location": "https://bugs.launchpad.net/netplan/+bug/1951586"}, + ) + m.head( + "https://bugs.launchpad.net/netplan/+bug/1951586", + status_code=200, + ) + result = issue_source_project_key_from_url(HttpUrl("https://launchpad.net/bugs/1951586")) + assert result == (IssueSource.LAUNCHPAD, "netplan", "1951586") + + +def test_launchpad_short_url_with_source_package(): + """+source/ paths in the resolved URL are handled correctly.""" + with req_mock.Mocker() as m: + m.head( + "https://launchpad.net/bugs/2137746", + status_code=301, + headers={"Location": "https://bugs.launchpad.net/ubuntu/+source/linux-meta/+bug/2137746"}, + ) + m.head( + "https://bugs.launchpad.net/ubuntu/+source/linux-meta/+bug/2137746", + status_code=200, + ) + result = issue_source_project_key_from_url(HttpUrl("https://launchpad.net/bugs/2137746")) + assert result == (IssueSource.LAUNCHPAD, "ubuntu", "2137746") + + +def test_launchpad_short_url_bad_key(): + """Non-numeric bug ID in launchpad.net/bugs/ path raises ValueError.""" + with pytest.raises(ValueError): + issue_source_project_key_from_url(HttpUrl("https://launchpad.net/bugs/abc")) + + +def test_launchpad_short_url_unknown_path(): + """Unrecognised launchpad.net path raises ValueError.""" + with pytest.raises(ValueError): + issue_source_project_key_from_url(HttpUrl("https://launchpad.net/unknown/1951586")) + + +def test_launchpad_short_url_network_error(): + """Network failure while resolving the redirect raises ValueError.""" + import requests + + with req_mock.Mocker() as m: + m.head("https://launchpad.net/bugs/1951586", exc=requests.ConnectionError("network down")) + with pytest.raises(ValueError, match="Could not resolve"): + issue_source_project_key_from_url(HttpUrl("https://launchpad.net/bugs/1951586")) + + +def test_launchpad_short_url_unexpected_redirect_target(): + """If the redirect resolves to an unexpected URL, ValueError is raised.""" + with req_mock.Mocker() as m: + m.head( + "https://launchpad.net/bugs/1951586", + status_code=301, + headers={"Location": "https://example.com/unexpected"}, + ) + m.head("https://example.com/unexpected", status_code=200) + with pytest.raises(ValueError, match="unrecognised URL"): + issue_source_project_key_from_url(HttpUrl("https://launchpad.net/bugs/1951586"))