-
Notifications
You must be signed in to change notification settings - Fork 524
feat(integrations): Azure DevOps REST client + URL parsing + PAT validation (PR 3/N) #7624
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
82789e3
2945b3b
bbb323c
bc84222
f878535
e09e892
a7791fe
0099d52
a1d7802
6477b61
b24ca9c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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", | ||
| ] |
| 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) |
| 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.""" |
| 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 |
| 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" |
| 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")), | ||
| ) |
| 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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| class AzureDevOpsConfigurationViewSet(ProjectIntegrationBaseViewSet): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| serializer_class = AzureDevOpsConfigurationSerializer # type: ignore[assignment] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| model_class = AzureDevOpsConfiguration # type: ignore[assignment] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If a user updates the
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| instance: AzureDevOpsConfiguration = serializer.instance # type: ignore[assignment] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self._log_for(instance).info( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 a200 OKwith an unexpected payload (e.g., HTML or invalid JSON),list_projectswill raiseValueError,KeyError,TypeError, orAttributeError. These exceptions do not inherit fromRequestExceptionand will cause an unhandled 500 Internal Server Error. CatchingExceptionensures all failure modes are safely translated into a validation error.