feat(integrations): Azure DevOps comments service + templates + tasks (PR 6/N)#7631
feat(integrations): Azure DevOps comments service + templates + tasks (PR 6/N)#7631asaphko wants to merge 8 commits into
Conversation
…gration Sixth plan in the stacked-PRs rollout. Covers the Flagsmith → ADO comment-posting layer: client functions for PR-thread and work-item- comment endpoints, four markdown templates, a comments service module with five public functions, and four async task wrappers. Notable: the plan also fixes a latent bug in the PR 4 conftest fixture which produced URLs like "pullrequest/active" (state name as ID) that don't parse through parse_pull_request_url. Task 3 Step 0 updates the fixtures to use distinct numeric pr_ids. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
add_pull_request_comment posts a single-comment thread (status:1
Active) on the project-scoped PR threads endpoint, avoiding the
need for the repository GUID.
add_work_item_comment uses the modern /_apis/wit/workItems/{id}/comments
endpoint with a JSON body of {"text": "..."}.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four markdown templates mirroring GitLab's set: feature_linked, feature_unlinked, feature_state_changed, feature_deleted. Uses plain Unicode emojis instead of GitLab-style ✅ shortcodes because ADO's markdown renderer doesn't support shortcodes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Loads the project's AzureDevOpsConfiguration, gathers per-environment
state, renders the linked-comment template, and dispatches to the
right ADO endpoint based on resource type via _post_to_resource. PR
URLs go to /pullrequests/{id}/threads; work-item URLs go to
/workItems/{id}/comments.
Also fixes the PR 4 conftest fixture which produced URLs like
"pullrequest/active" that don't parse through parse_pull_request_url
(the regex requires digits). The three fixtures now use distinct
numeric pr_ids: open=1, draft=2, merged=3.
Failures (requests.RequestException) are caught and logged as
"comment.post_failed" so the triggering link action still succeeds.
Successes log "comment.posted". No-op when the configuration is
absent or the URL can't be parsed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…d_comment post_unlinked_comment renders the unlinked template and dispatches to the right ADO endpoint. Takes resource fields explicitly because the FER row is gone by the time this runs. post_feature_deleted_comment loads every linked AZURE_DEVOPS_* resource for the feature and posts the deletion notice to each one. Both no-op when the project has no AzureDevOpsConfiguration. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
post_state_change_comment fans the state-changed template out to every linked ADO resource on the feature, with scope (environment/segment/ identity) and value baked into the rendered body. post_state_change_comment_for_feature_state is the entry point the FeatureState save hook will invoke in a later PR — it short-circuits when the project has no azure_devops_config (cheap hasattr check) and otherwise queues the task. The task itself lands in the next commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four @register_task_handler() decorated wrappers that the dispatcher PR will queue via .delay(): post_azure_devops_linked_comment, post_azure_devops_unlinked_comment, post_azure_devops_state_change_comment, post_azure_devops_feature_deleted_comment. Each task is a thin loader-and-forwarder onto the underlying service function — the service functions hold the business logic (config lookup, URL parsing, template rendering, HTTP) and are independently tested. Also un-skips the two _for_feature_state tests in test_comments.py and drops the # type: ignore[import-not-found] in services/comments.py that previously guarded the deferred import of tasks.py. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub. 3 Skipped Deployments
|
There was a problem hiding this comment.
Code Review
This pull request introduces the comment-posting layer for the Azure DevOps integration, adding client functions, markdown templates, a comments service, and asynchronous task wrappers to handle linking, unlinking, state changes, and deletion of feature flags. The review feedback focuses on database query optimization to prevent N+1 query patterns and unnecessary lazy loading. Specifically, it is recommended to batch the retrieval of live feature states across environments, use select_related("project") when fetching the Azure DevOps configuration to avoid extra queries for the organization ID, and optimize the configuration existence check using a single exists() query.
| states: list[AzureDevOpsEnvironmentState] = [] | ||
| for environment in environments: | ||
| feature_state: FeatureState | None = ( | ||
| FeatureState.objects.get_live_feature_states( | ||
| environment=environment, | ||
| additional_filters=Q( | ||
| feature=feature, | ||
| identity__isnull=True, | ||
| feature_segment__isnull=True, | ||
| ), | ||
| ).first() | ||
| ) | ||
| if feature_state is None: | ||
| continue # pragma: no cover — initial states are always created |
There was a problem hiding this comment.
This loop performs a database query (get_live_feature_states) for each environment, resulting in an N+1 query pattern. We can optimize this by fetching all live feature states for the environments in a single query and mapping them in memory.
| states: list[AzureDevOpsEnvironmentState] = [] | |
| for environment in environments: | |
| feature_state: FeatureState | None = ( | |
| FeatureState.objects.get_live_feature_states( | |
| environment=environment, | |
| additional_filters=Q( | |
| feature=feature, | |
| identity__isnull=True, | |
| feature_segment__isnull=True, | |
| ), | |
| ).first() | |
| ) | |
| if feature_state is None: | |
| continue # pragma: no cover — initial states are always created | |
| feature_states = FeatureState.objects.get_live_feature_states( | |
| additional_filters=Q( | |
| feature=feature, | |
| environment__in=environments, | |
| identity__isnull=True, | |
| feature_segment__isnull=True, | |
| ) | |
| ) | |
| feature_states_by_env = {fs.environment_id: fs for fs in feature_states} | |
| states: list[AzureDevOpsEnvironmentState] = [] | |
| for environment in environments: | |
| feature_state = feature_states_by_env.get(environment.id) | |
| if feature_state is None: | |
| continue # pragma: no cover — initial states are always created |
| config: AzureDevOpsConfiguration = AzureDevOpsConfiguration.objects.get( | ||
| project=resource.feature.project, | ||
| ) |
There was a problem hiding this comment.
Accessing config.project.organisation_id later in _post_to_resource will trigger an additional database query to fetch the Project relation. We can avoid this by using select_related("project") when retrieving the configuration.
| config: AzureDevOpsConfiguration = AzureDevOpsConfiguration.objects.get( | |
| project=resource.feature.project, | |
| ) | |
| try: | |
| config: AzureDevOpsConfiguration = AzureDevOpsConfiguration.objects.select_related("project").get( | |
| project=resource.feature.project, | |
| ) |
| config: AzureDevOpsConfiguration = AzureDevOpsConfiguration.objects.get( | ||
| project_id=project_id, | ||
| ) |
There was a problem hiding this comment.
Accessing config.project.organisation_id later in _post_to_resource will trigger an additional database query to fetch the Project relation. We can avoid this by using select_related("project") when retrieving the configuration.
| config: AzureDevOpsConfiguration = AzureDevOpsConfiguration.objects.get( | |
| project_id=project_id, | |
| ) | |
| try: | |
| config: AzureDevOpsConfiguration = AzureDevOpsConfiguration.objects.select_related("project").get( | |
| project_id=project_id, | |
| ) |
| config: AzureDevOpsConfiguration = AzureDevOpsConfiguration.objects.get( | ||
| project_id=project_id, | ||
| ) |
There was a problem hiding this comment.
Accessing config.project.organisation_id later in _post_to_resource will trigger an additional database query to fetch the Project relation. We can avoid this by using select_related("project") when retrieving the configuration.
| config: AzureDevOpsConfiguration = AzureDevOpsConfiguration.objects.get( | |
| project_id=project_id, | |
| ) | |
| try: | |
| config: AzureDevOpsConfiguration = AzureDevOpsConfiguration.objects.select_related("project").get( | |
| project_id=project_id, | |
| ) |
| if not hasattr(feature_state.environment.project, "azure_devops_config"): | ||
| return |
There was a problem hiding this comment.
Accessing feature_state.environment.project triggers a database query to fetch the Project object, and checking hasattr on it triggers another query to check for azure_devops_config. We can optimize this to a single fast EXISTS query using project_id directly from the environment.
| if not hasattr(feature_state.environment.project, "azure_devops_config"): | |
| return | |
| if not AzureDevOpsConfiguration.objects.filter( | |
| project_id=feature_state.environment.project_id | |
| ).exists(): | |
| return |
| config: AzureDevOpsConfiguration = AzureDevOpsConfiguration.objects.get( | ||
| project=feature.project, | ||
| ) |
There was a problem hiding this comment.
Accessing config.project.organisation_id later in _post_to_resource will trigger an additional database query to fetch the Project relation. We can avoid this by using select_related("project") when retrieving the configuration.
| config: AzureDevOpsConfiguration = AzureDevOpsConfiguration.objects.get( | |
| project=feature.project, | |
| ) | |
| try: | |
| config: AzureDevOpsConfiguration = AzureDevOpsConfiguration.objects.select_related("project").get( | |
| project=feature.project, | |
| ) |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## feat/azure-devops-05-browse #7631 +/- ##
===============================================================
- Coverage 98.54% 98.53% -0.02%
===============================================================
Files 1472 1476 +4
Lines 55992 56329 +337
===============================================================
+ Hits 55179 55504 +325
- Misses 813 825 +12 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
Summary
PR 6 of the stacked Azure DevOps integration rollout. Adds the Flagsmith → ADO comment-posting layer:
add_pull_request_comment(POSTs a status-1 thread to/_apis/git/pullrequests/{id}/threads) andadd_work_item_comment(POSTs{text}to/_apis/wit/workItems/{id}/comments).feature_linked,feature_unlinked,feature_state_changed,feature_deleted) using Unicode emojis directly — ADO's markdown renderer doesn't support GitLab-style shortcodes like:white_check_mark:.services/comments.pywith five public functions (post_linked_comment,post_unlinked_comment,post_state_change_comment,post_feature_deleted_comment,post_state_change_comment_for_feature_state). All wrapped in try/except forrequests.RequestException→ logcomment.post_failed, never raise. No-ops cleanly on three independent failure modes: missing config, unparseable URL, ADO HTTP failure.@register_task_handler()wrappers (tasks.py) that the future dispatcher PR will queue via.delay().Stack
Plan:
docs/superpowers/plans/2026-05-28-azure-devops-06-comments.md.Notable implementation details
_post_to_resource. Parses the URL through the PR 3 URL parsers, picks the right ADO endpoint (PR thread vs work-item comment), and posts. Logscomment.postedon success,comment.post_failedonrequests.RequestException.for_feature_statedeferred import.post_state_change_comment_for_feature_stateimportstasks.pylazily to break the services↔tasks circular. Mirrors GitLab.mocker.patch.object(TaskHandler, "delay")becauseTaskHandler.delayis read-only at the instance level (the descriptor lives on the class). Matches the existing pattern intests/unit/audit/test_unit_audit_tasks.py.pullrequest/active(state name as ID) that don't parse throughparse_pull_request_url(regex requires\d+). Switched to numericpr_idper fixture (open=1,draft=2,merged=3). PR 4's tagging/mapper tests still pass — they readmetadata.state, not the URL.Caught + handled during review
mocker.patchonTaskHandler.delay(read-only instance attribute) — switched topatch.objecton the class.responses.calls[0]mypy-strict issue — switched to destructuring[call] = responses.calls.Out of scope
vcs/services.pydispatcher wiring that calls these tasks onFeatureExternalResourcelifecycle events — lands in a later PR.FeatureStatesave-hook call site that invokespost_state_change_comment_for_feature_state— same story.Featuresoft-delete hook that fanspost_feature_deleted_commentout — same story.Test plan
make lintcleanmake typecheckcleanmake test opts='-n0 tests/unit/integrations/azure_devops/'— 198 passed (PR 5 baseline 173 + 4 client + 15 comments + 6 tasks)make 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'— adjacent-integration regression guardmake django-make-migrations opts='--check --dry-run'— no drift (PR 6 introduces no schema changes)🤖 Generated with Claude Code