feat(integrations): Azure DevOps REST client + URL parsing + PAT validation (PR 3/N)#7624
feat(integrations): Azure DevOps REST client + URL parsing + PAT validation (PR 3/N)#7624asaphko wants to merge 11 commits into
Conversation
…gration Third plan in the stacked-PRs rollout. Covers the Azure DevOps REST client (limited to list_projects for v1), URL parsing for PR and work-item URLs (cloud + on-prem), organisation_url trailing-slash normalisation, serializer-side placeholder-PAT preservation on update, and viewset PAT validation against ADO on create/update. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add empty client/ and services/ packages plus a constants module holding the request timeout and pinned REST API version (7.1). Subsequent commits in this PR fill in the client modules and the URL-parsing service. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three classes: AzureDevOpsError (base), AzureDevOpsAuthError (401/403), AzureDevOpsNotFoundError (404 on single-resource). Callers can catch the base for "anything went wrong with ADO" or the specific subclass for targeted handling. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two typed dicts for v1: AdoProject (id/name/url) and AdoProjectsPage (results + continuation_token). Subsequent PRs will extend this module with AdoRepository, AdoPullRequest, AdoWorkItem, AdoSubscription, and the resource-metadata shape as their corresponding client functions land. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stand up the client's HTTP plumbing (Basic auth with empty username, api-version=7.1 query param, 10s timeout) and the first concrete function: list_projects. Status codes 401/403 map to AzureDevOpsAuthError, 404 to AzureDevOpsNotFoundError; 5xx and other 4xx bubble up as requests.HTTPError. Subsequent client functions (repos, PRs, work items, comments, labels, subscriptions) land in their respective consumer PRs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two NamedTuple types (AdoPullRequestRef, AdoWorkItemRef) and two parser
functions that handle both ADO cloud (dev.azure.com/{org}/...) and
Azure DevOps Server (host/{collection}/...) URL shapes. Parsing never
raises; unparseable URLs return None. Subsequent PRs (browse, comments,
webhook dispatcher) use these structured refs instead of raw URL string
comparisons.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Strip trailing slashes from AzureDevOpsConfiguration.organisation_url before persisting. This guarantees downstream URL composition (client.api._ado_request) never produces double-slash paths regardless of how the user supplied the URL. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…lder Override the serializer's update() to drop personal_access_token from validated_data when its value equals WRITE_ONLY_PLACEHOLDER. This lets the frontend safely round-trip the masked representation without overwriting the stored PAT, and keeps the imminent PAT-validation hook in the viewset from validating a sentinel string against ADO. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The configuration viewset now probes ADO with list_projects(top=1) when the PAT is being set. On 401/403 ADO replies, the viewset returns 400 with a clear message and does not persist. On update, validation only runs when the request actually changes the PAT (the serializer override from the previous commit drops placeholder values). Network and 5xx failures still propagate so transient unavailability is not silently ignored. The existing tests are updated to mock the ADO probe via the responses library; two new tests exercise the invalid-PAT and placeholder-on-update paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The three trailing-slash normalisation tests added in the previous "normalise organisation_url on save" commit used 4 __-separated parts (subject__qualifier__condition__outcome) which violates the repo's FT003 lint rule. Rename to the canonical 3-part form using organisation_url as the subject and "on_save" baked into the outcome. Also commit the auto-regenerated events catalogue that lint produces when the new azure_devops.configuration.* events are detected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… to 400 Also closes the diff-coverage gap on list_projects's continuation_token request parameter. Previously, if ADO replied 5xx during the create/update PAT probe (or the request failed with a network error), the underlying requests.HTTPError / requests.RequestException would bubble out and DRF would return 500. Translate to a ValidationError with a "could not be reached, please try again" message so the user gets an actionable 400. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
2 Skipped Deployments
|
There was a problem hiding this comment.
Code Review
This pull request introduces the Azure DevOps integration client, URL parsing services, and configuration validation. Specifically, it implements a REST client with a list_projects endpoint, URL parsing for pull requests and work items, automatic trailing slash normalization for organization URLs, and PAT validation during configuration creation and updates. Feedback on the changes highlights two critical issues in the configuration viewset: first, the PAT validation helper should catch generic exceptions to prevent unhandled 500 errors on malformed API responses; second, validation should also run when the organization URL is updated, even if the PAT remains unchanged, to prevent saving mismatched configurations.
| except requests.RequestException: | ||
| raise ValidationError( | ||
| "Azure DevOps could not be reached to validate the credentials. " | ||
| "Please try again." | ||
| ) from None |
There was a problem hiding this comment.
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.
| 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 |
| 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) |
There was a problem hiding this comment.
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.
| 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) |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## feat/azure-devops-02-models #7624 +/- ##
=============================================================
Coverage 98.52% 98.53%
=============================================================
Files 1454 1464 +10
Lines 55018 55312 +294
=============================================================
+ Hits 54209 54503 +294
Misses 809 809 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
Summary
PR 3 of the stacked Azure DevOps integration rollout. Stands up:
list_projects(the only client function v1 needs). Status mapping: 401/403 →AzureDevOpsAuthError, 404 →AzureDevOpsNotFoundError, 5xx/other 4xx →requests.HTTPError. Basic auth with empty username + PAT as password;api-version=7.1always in query; 10s timeout.dev.azure.com/{org}) and on-prem ({host}/{collection}) shapes. ReturnsNamedTuplerefs; never raises on malformed input.AzureDevOpsConfiguration.save()override that strips trailing slashes fromorganisation_url.update()override that dropspersonal_access_token == WRITE_ONLY_PLACEHOLDERfrom validated data, preserving the stored PAT when the frontend round-trips the masked representation.perform_createalways validates against ADO;perform_updatevalidates when the PAT actually changes (and isn't the placeholder). Auth failures → 400 with a clear message; 5xx/network failures → 400 with a "could not be reached, please retry" message (rather than 500).Stack
Plan:
docs/superpowers/plans/2026-05-28-azure-devops-03-client.md.Deliberately out of scope
list_repositories,list_pull_requests,list_work_items, comment-posting, label add/remove, subscription create/delete) — added incrementally by the consumer PRs that need them.AzureDevOpsConflictErrorwhen subscriptions/labels need it, PR 4+).visualstudio.comhost shape and nested-collection on-prem URLs — current regex doesn't match these. The spec references them; we'll either extend the regex (and tests) in PR 4+ or drop the claim from the spec.metrics.pylater in the stack.Notable deviations from the planned skeleton
perform_createadds an explicitself.get_queryset().exists()short-circuit before the ADO probe so the existing duplicate-config test doesn't need to mock the ADO call. Duplicates the parent class's check; a cleaner refactor (move uniqueness into the serializer'svalidate()) is a follow-up.perform_updatebelt-and-braces the placeholder check (pat != WRITE_ONLY_PLACEHOLDER) alongside the serializer'supdate()pop — both layers correctly skip work on placeholder updates.Test plan
make lintcleanmake typecheckcleanmake test opts='-n0 tests/unit/integrations/azure_devops/'— 71 passedmake test opts='tests/unit/integrations/gitlab tests/unit/integrations/github tests/unit/features/test_unit_feature_external_resources_views.py tests/unit/features/test_migrations.py'— 235 passed (adjacent-integration regression guard)make django-make-migrations opts='--check --dry-run'—No changes detectedfor all apps (PR 3 introduces no schema changes)🤖 Generated with Claude Code