Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion backend/test_observer/controllers/issues/issue_url_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>) 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.
Expand All @@ -44,12 +73,19 @@ 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"
f" path = '{path}'\n\n"
f"Expected formats:\n"
f" GitHub: https://github.com/<owner>/<repo>/issues/<number>\n"
f" JIRA: https://warthogs.atlassian.net/browse/<PROJECT-123>\n"
f" Launchpad: https://bugs.launchpad.net/<project>/+bug/<number>"
f" Launchpad: https://bugs.launchpad.net/<project>/+bug/<number>\n"
f" Launchpad: https://launchpad.net/bugs/<number>"
)
23 changes: 17 additions & 6 deletions backend/test_observer/data_access/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand Down
68 changes: 68 additions & 0 deletions backend/tests/controllers/issues/test_issue_url_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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/<id> 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"))