Skip to content
Closed
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
16 changes: 16 additions & 0 deletions api/integrations/azure_devops/client/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from integrations.azure_devops.client.api import list_projects
from integrations.azure_devops.client.exceptions import (
AzureDevOpsAuthError,
AzureDevOpsError,
AzureDevOpsNotFoundError,
)
from integrations.azure_devops.client.types import AdoProject, AdoProjectsPage

__all__ = [
"AdoProject",
"AdoProjectsPage",
"AzureDevOpsAuthError",
"AzureDevOpsError",
"AzureDevOpsNotFoundError",
"list_projects",
]
75 changes: 75 additions & 0 deletions api/integrations/azure_devops/client/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
from typing import Any

import requests

from integrations.azure_devops.client.exceptions import (
AzureDevOpsAuthError,
AzureDevOpsNotFoundError,
)
from integrations.azure_devops.client.types import AdoProject, AdoProjectsPage
from integrations.azure_devops.constants import (
AZURE_DEVOPS_API_VERSION,
AZURE_DEVOPS_CLIENT_TIMEOUT_SECONDS,
)


def _ado_request(
method: str,
organisation_url: str,
pat: str,
*,
path: str,
params: dict[str, Any] | None = None,
json_body: dict[str, Any] | None = None,
) -> requests.Response:
base = organisation_url.rstrip("/")
query: dict[str, Any] = {"api-version": AZURE_DEVOPS_API_VERSION}
if params:
query.update(params)
response = requests.request(
method,
f"{base}/_apis/{path}",
auth=("", pat),
params=query,
json=json_body,
timeout=AZURE_DEVOPS_CLIENT_TIMEOUT_SECONDS,
)
if response.status_code in (401, 403):
raise AzureDevOpsAuthError(
f"Azure DevOps rejected credentials ({response.status_code})"
)
if response.status_code == 404:
raise AzureDevOpsNotFoundError(
f"Azure DevOps reported the resource was not found ({response.status_code})"
)
response.raise_for_status()
return response


def list_projects(
*,
organisation_url: str,
pat: str,
top: int | None = None,
continuation_token: str | None = None,
) -> AdoProjectsPage:
params: dict[str, Any] = {}
if top is not None:
params["$top"] = top
if continuation_token is not None:
params["continuationToken"] = continuation_token

response = _ado_request(
"GET",
organisation_url,
pat,
path="projects",
params=params,
)
payload = response.json()
results: list[AdoProject] = [
AdoProject(id=p["id"], name=p["name"], url=p["url"])
for p in payload.get("value", [])
]
next_token = response.headers.get("x-ms-continuationtoken")
return AdoProjectsPage(results=results, continuation_token=next_token)
10 changes: 10 additions & 0 deletions api/integrations/azure_devops/client/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
class AzureDevOpsError(Exception):
"""Base class for all Azure DevOps client errors raised by this package."""


class AzureDevOpsAuthError(AzureDevOpsError):
"""Raised when ADO rejects credentials with 401 or 403."""


class AzureDevOpsNotFoundError(AzureDevOpsError):
"""Raised when ADO returns 404 for a single-resource lookup."""
12 changes: 12 additions & 0 deletions api/integrations/azure_devops/client/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from typing import TypedDict


class AdoProject(TypedDict):
id: str
name: str
url: str


class AdoProjectsPage(TypedDict):
results: list[AdoProject]
continuation_token: str | None
3 changes: 3 additions & 0 deletions api/integrations/azure_devops/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
AZURE_DEVOPS_CLIENT_TIMEOUT_SECONDS = 10

AZURE_DEVOPS_API_VERSION = "7.1"
6 changes: 6 additions & 0 deletions api/integrations/azure_devops/models.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import Any

from django.db import models

from core.models import SoftDeleteExportableModel
Expand All @@ -14,6 +16,10 @@ class AzureDevOpsConfiguration(SoftDeleteExportableModel):
labeling_enabled = models.BooleanField(default=False)
tagging_enabled = models.BooleanField(default=False)

def save(self, *args: Any, **kwargs: Any) -> None:
self.organisation_url = self.organisation_url.rstrip("/")
super().save(*args, **kwargs)


class AzureDevOpsServiceHook(SoftDeleteExportableModel):
configuration = models.ForeignKey(
Expand Down
12 changes: 12 additions & 0 deletions api/integrations/azure_devops/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,15 @@ def to_representation(self, instance: AzureDevOpsConfiguration) -> dict[str, Any
data = super().to_representation(instance)
data["personal_access_token"] = WRITE_ONLY_PLACEHOLDER
return data

def update(
self,
instance: AzureDevOpsConfiguration,
validated_data: dict[str, Any],
) -> AzureDevOpsConfiguration:
# Treat the masked placeholder on input as "no change" so the
# frontend can round-trip the masked representation without
# accidentally overwriting the real PAT.
if validated_data.get("personal_access_token") == WRITE_ONLY_PLACEHOLDER:
validated_data.pop("personal_access_token", None)
return super().update(instance, validated_data) # type: ignore[no-any-return]
Empty file.
75 changes: 75 additions & 0 deletions api/integrations/azure_devops/services/url_parsing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import re
from typing import NamedTuple
from urllib.parse import unquote, urlparse

# Path captures (after stripping query/fragment, normalising trailing slash):
# /{org_or_collection}/{project}/_git/{repo}/pullrequest/{id}
# /{org_or_collection}/{project}/_workitems/edit/{id}
#
# For ADO cloud, {org_or_collection} is a single org segment (e.g. "test-org").
# For Azure DevOps Server (on-prem), {org_or_collection} is a collection name,
# and the same path pattern applies under whatever host the server runs on.

_PR_PATH_PATTERN = re.compile(
r"^/(?P<org_or_collection>[^/]+)/(?P<project>[^/]+)"
r"/_git/(?P<repo>[^/]+)/pullrequest/(?P<pr_id>\d+)/?$"
)

_WORK_ITEM_PATH_PATTERN = re.compile(
r"^/(?P<org_or_collection>[^/]+)/(?P<project>[^/]+)"
r"/_workitems/edit/(?P<work_item_id>\d+)/?$"
)


class AdoPullRequestRef(NamedTuple):
organisation_root: str
project: str
repository: str
pull_request_id: int


class AdoWorkItemRef(NamedTuple):
organisation_root: str
project: str
work_item_id: int


def parse_pull_request_url(url: str | None) -> AdoPullRequestRef | None:
"""Return a structured reference for an Azure DevOps pull-request URL,
or ``None`` if the URL does not match the cloud or on-prem PR shape.
Parsing never raises.
"""
if not url:
return None
parsed = urlparse(url)
if not parsed.scheme or not parsed.netloc:
return None
match = _PR_PATH_PATTERN.match(parsed.path)
if not match:
return None
return AdoPullRequestRef(
organisation_root=f"{parsed.scheme}://{parsed.netloc}/{match.group('org_or_collection')}",
project=unquote(match.group("project")),
repository=unquote(match.group("repo")),
pull_request_id=int(match.group("pr_id")),
)


def parse_work_item_url(url: str | None) -> AdoWorkItemRef | None:
"""Return a structured reference for an Azure DevOps work-item URL,
or ``None`` if the URL does not match the cloud or on-prem work-item
shape. Parsing never raises.
"""
if not url:
return None
parsed = urlparse(url)
if not parsed.scheme or not parsed.netloc:
return None
match = _WORK_ITEM_PATH_PATTERN.match(parsed.path)
if not match:
return None
return AdoWorkItemRef(
organisation_root=f"{parsed.scheme}://{parsed.netloc}/{match.group('org_or_collection')}",
project=unquote(match.group("project")),
work_item_id=int(match.group("work_item_id")),
)
47 changes: 47 additions & 0 deletions api/integrations/azure_devops/views/configuration.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,43 @@
import requests
import structlog
from rest_framework.exceptions import ValidationError
from structlog.typing import FilteringBoundLogger

from integrations.azure_devops.client import list_projects
from integrations.azure_devops.client.exceptions import AzureDevOpsAuthError
from integrations.azure_devops.models import AzureDevOpsConfiguration
from integrations.azure_devops.serializers import (
WRITE_ONLY_PLACEHOLDER,
AzureDevOpsConfigurationSerializer,
)
from integrations.common.views import ProjectIntegrationBaseViewSet

logger = structlog.get_logger("azure_devops")


def _validate_pat_against_ado(*, organisation_url: str, pat: str) -> None:
"""Probe ADO with a minimal request to confirm the PAT is valid.

Raises ``ValidationError`` on 401/403 (bad credentials) and on any
other failure mode (5xx, network, malformed response). Surfacing a
user-facing 400 on transient failure is better than a 500 because the
user can act on the message; the persistence path should not silently
accept an unverified PAT either.
"""
try:
list_projects(organisation_url=organisation_url, pat=pat, top=1)
except AzureDevOpsAuthError:
raise ValidationError(
"Azure DevOps rejected the credentials. "
"Check the organisation URL and personal access token."
) from None
except requests.RequestException:
raise ValidationError(
"Azure DevOps could not be reached to validate the credentials. "
"Please try again."
) from None
Comment on lines +34 to +38
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The validation helper intends to handle malformed responses as well, but it only catches requests.RequestException. If Azure DevOps (or an intermediate proxy/firewall) returns a 200 OK with an unexpected payload (e.g., HTML or invalid JSON), list_projects will raise ValueError, KeyError, TypeError, or AttributeError. These exceptions do not inherit from RequestException and will cause an unhandled 500 Internal Server Error. Catching Exception ensures all failure modes are safely translated into a validation error.

Suggested change
except requests.RequestException:
raise ValidationError(
"Azure DevOps could not be reached to validate the credentials. "
"Please try again."
) from None
except Exception:
raise ValidationError(
"Azure DevOps could not be reached to validate the credentials. "
"Please try again."
) from None



class AzureDevOpsConfigurationViewSet(ProjectIntegrationBaseViewSet):
serializer_class = AzureDevOpsConfigurationSerializer # type: ignore[assignment]
model_class = AzureDevOpsConfiguration # type: ignore[assignment]
Expand All @@ -22,6 +50,13 @@ def _log_for(self, config: AzureDevOpsConfiguration) -> FilteringBoundLogger:
)

def perform_create(self, serializer: AzureDevOpsConfigurationSerializer) -> None: # type: ignore[override]
# Surface the "already exists" error before making an external call.
if self.get_queryset().exists():
raise ValidationError("This integration already exists for this project.")
_validate_pat_against_ado(
organisation_url=serializer.validated_data["organisation_url"],
pat=serializer.validated_data["personal_access_token"],
)
super().perform_create(serializer)
instance: AzureDevOpsConfiguration = serializer.instance # type: ignore[assignment]
self._log_for(instance).info(
Expand All @@ -30,6 +65,18 @@ def perform_create(self, serializer: AzureDevOpsConfigurationSerializer) -> None
)

def perform_update(self, serializer: AzureDevOpsConfigurationSerializer) -> None: # type: ignore[override]
pat = serializer.validated_data.get("personal_access_token")
# Treat the masked placeholder as "no change" — the serializer drops
# it during ``update`` so it never reaches the database, and there is
# nothing meaningful to validate against ADO either.
if pat is not None and pat != WRITE_ONLY_PLACEHOLDER:
_validate_pat_against_ado(
organisation_url=serializer.validated_data.get(
"organisation_url",
serializer.instance.organisation_url, # type: ignore[union-attr]
),
pat=pat,
)
super().perform_update(serializer)
Comment on lines +68 to 80
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If a user updates the organisation_url but does not change the personal access token (meaning pat is None or WRITE_ONLY_PLACEHOLDER), the validation is skipped entirely. This allows saving an invalid or mismatched organization URL without verifying that the existing PAT has access to it. We should perform validation if either the PAT or the organization URL is updated.

Suggested change
pat = serializer.validated_data.get("personal_access_token")
# Treat the masked placeholder as "no change" — the serializer drops
# it during ``update`` so it never reaches the database, and there is
# nothing meaningful to validate against ADO either.
if pat is not None and pat != WRITE_ONLY_PLACEHOLDER:
_validate_pat_against_ado(
organisation_url=serializer.validated_data.get(
"organisation_url",
serializer.instance.organisation_url, # type: ignore[union-attr]
),
pat=pat,
)
super().perform_update(serializer)
organisation_url = serializer.validated_data.get("organisation_url")
pat = serializer.validated_data.get("personal_access_token")
is_pat_updated = pat is not None and pat != WRITE_ONLY_PLACEHOLDER
is_url_updated = (
organisation_url is not None
and organisation_url != serializer.instance.organisation_url # type: ignore[union-attr]
)
if is_pat_updated or is_url_updated:
validation_url = organisation_url or serializer.instance.organisation_url # type: ignore[union-attr]
validation_pat = (
pat if is_pat_updated else serializer.instance.personal_access_token # type: ignore[union-attr]
)
_validate_pat_against_ado(
organisation_url=validation_url,
pat=validation_pat,
)
super().perform_update(serializer)

instance: AzureDevOpsConfiguration = serializer.instance # type: ignore[assignment]
self._log_for(instance).info(
Expand Down
Loading
Loading