From 0bda5c4fbb764ccbceadcc2fc20bdde754d3a69a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:34:30 +0000 Subject: [PATCH 1/5] Initial plan From cf37d5292e928a2f0dc208e76e6847869ec959bc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:36:39 +0000 Subject: [PATCH 2/5] Support https://launchpad.net/bugs/ URL format for Launchpad bugs Co-authored-by: almeidaraul <36135994+almeidaraul@users.noreply.github.com> --- .../controllers/issues/issue_url_parser.py | 8 ++++++- backend/test_observer/data_access/models.py | 23 ++++++++++++++----- .../issues/test_issue_url_parser.py | 8 ++++++- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/backend/test_observer/controllers/issues/issue_url_parser.py b/backend/test_observer/controllers/issues/issue_url_parser.py index a0bad3376..290a93e53 100644 --- a/backend/test_observer/controllers/issues/issue_url_parser.py +++ b/backend/test_observer/controllers/issues/issue_url_parser.py @@ -44,6 +44,11 @@ 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: + return IssueSource.LAUNCHPAD, "", match.group(1) + raise ValueError( f"Unrecognized issue URL format:\n" f" host = '{host}'\n" @@ -51,5 +56,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..29082a6fb 100644 --- a/backend/tests/controllers/issues/test_issue_url_parser.py +++ b/backend/tests/controllers/issues/test_issue_url_parser.py @@ -79,8 +79,14 @@ "https://bugs.launchpad.net/ABC/+bug/123", (IssueSource.LAUNCHPAD, "abc", "123"), ), - # Accept http + # Good launchpad with launchpad.net/bugs/ ( + "https://launchpad.net/bugs/1951586", + (IssueSource.LAUNCHPAD, "", "1951586"), + ), + # Bad launchpad.net/bugs format + ("https://launchpad.net/bugs/abc", None), + ("https://launchpad.net/unknown/1951586", None), "http://github.com/canonical/test_observer/issues/71", (IssueSource.GITHUB, "canonical/test_observer", "71"), ), From a408cf6262348eea485ac134d41e7ffd7cf81b24 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:37:20 +0000 Subject: [PATCH 3/5] Fix test file structure after adding new Launchpad URL test cases Co-authored-by: almeidaraul <36135994+almeidaraul@users.noreply.github.com> --- backend/tests/controllers/issues/test_issue_url_parser.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/tests/controllers/issues/test_issue_url_parser.py b/backend/tests/controllers/issues/test_issue_url_parser.py index 29082a6fb..e79f145a1 100644 --- a/backend/tests/controllers/issues/test_issue_url_parser.py +++ b/backend/tests/controllers/issues/test_issue_url_parser.py @@ -87,6 +87,8 @@ # Bad launchpad.net/bugs format ("https://launchpad.net/bugs/abc", None), ("https://launchpad.net/unknown/1951586", None), + # Accept http + ( "http://github.com/canonical/test_observer/issues/71", (IssueSource.GITHUB, "canonical/test_observer", "71"), ), From 99b78614d55c7b2cea0ec2e90eac84cabb05edc6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:37:06 +0000 Subject: [PATCH 4/5] Resolve launchpad.net/bugs/ redirect to extract project name Co-authored-by: almeidaraul <36135994+almeidaraul@users.noreply.github.com> --- .../controllers/issues/issue_url_parser.py | 32 +++++++++- .../issues/test_issue_url_parser.py | 64 ++++++++++++++++--- 2 files changed, 87 insertions(+), 9 deletions(-) diff --git a/backend/test_observer/controllers/issues/issue_url_parser.py b/backend/test_observer/controllers/issues/issue_url_parser.py index 290a93e53..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. @@ -47,7 +76,8 @@ def issue_source_project_key_from_url(url: HttpUrl) -> tuple[IssueSource, str, s elif host == "launchpad.net": match = re.match(r"^/bugs/(\d+)$", path) if match: - return IssueSource.LAUNCHPAD, "", match.group(1) + project, key = _resolve_launchpad_short_url(match.group(1)) + return IssueSource.LAUNCHPAD, project, key raise ValueError( f"Unrecognized issue URL format:\n" diff --git a/backend/tests/controllers/issues/test_issue_url_parser.py b/backend/tests/controllers/issues/test_issue_url_parser.py index e79f145a1..8607c99e2 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 ( @@ -79,14 +80,6 @@ "https://bugs.launchpad.net/ABC/+bug/123", (IssueSource.LAUNCHPAD, "abc", "123"), ), - # Good launchpad with launchpad.net/bugs/ - ( - "https://launchpad.net/bugs/1951586", - (IssueSource.LAUNCHPAD, "", "1951586"), - ), - # Bad launchpad.net/bugs format - ("https://launchpad.net/bugs/abc", None), - ("https://launchpad.net/unknown/1951586", None), # Accept http ( "http://github.com/canonical/test_observer/issues/71", @@ -106,3 +99,58 @@ 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", + url="https://bugs.launchpad.net/netplan/+bug/1951586", + ) + 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", + url="https://bugs.launchpad.net/ubuntu/+source/linux-meta/+bug/2137746", + ) + 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", + url="https://example.com/unexpected", + ) + with pytest.raises(ValueError, match="unrecognised URL"): + issue_source_project_key_from_url(HttpUrl("https://launchpad.net/bugs/1951586")) From 15d3a5c75c16c5f96817b78c4af9144d40592bbe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:38:19 +0000 Subject: [PATCH 5/5] Fix requests-mock redirect simulation in tests Co-authored-by: almeidaraul <36135994+almeidaraul@users.noreply.github.com> --- .../issues/test_issue_url_parser.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/backend/tests/controllers/issues/test_issue_url_parser.py b/backend/tests/controllers/issues/test_issue_url_parser.py index 8607c99e2..40e02e4c0 100644 --- a/backend/tests/controllers/issues/test_issue_url_parser.py +++ b/backend/tests/controllers/issues/test_issue_url_parser.py @@ -106,7 +106,12 @@ def test_launchpad_short_url_resolves_project(): with req_mock.Mocker() as m: m.head( "https://launchpad.net/bugs/1951586", - url="https://bugs.launchpad.net/netplan/+bug/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") @@ -117,7 +122,12 @@ def test_launchpad_short_url_with_source_package(): with req_mock.Mocker() as m: m.head( "https://launchpad.net/bugs/2137746", - url="https://bugs.launchpad.net/ubuntu/+source/linux-meta/+bug/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") @@ -150,7 +160,9 @@ def test_launchpad_short_url_unexpected_redirect_target(): with req_mock.Mocker() as m: m.head( "https://launchpad.net/bugs/1951586", - url="https://example.com/unexpected", + 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"))