Skip to content

feat(integrations): Azure DevOps comments service + templates + tasks (PR 6/N)#7631

Closed
asaphko wants to merge 8 commits into
feat/azure-devops-05-browsefrom
feat/azure-devops-06-comments
Closed

feat(integrations): Azure DevOps comments service + templates + tasks (PR 6/N)#7631
asaphko wants to merge 8 commits into
feat/azure-devops-05-browsefrom
feat/azure-devops-06-comments

Conversation

@asaphko
Copy link
Copy Markdown
Contributor

@asaphko asaphko commented May 28, 2026

Summary

PR 6 of the stacked Azure DevOps integration rollout. Adds the Flagsmith → ADO comment-posting layer:

  • Client: add_pull_request_comment (POSTs a status-1 thread to /_apis/git/pullrequests/{id}/threads) and add_work_item_comment (POSTs {text} to /_apis/wit/workItems/{id}/comments).
  • Templates: four markdown templates (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: services/comments.py with 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 for requests.RequestException → log comment.post_failed, never raise. No-ops cleanly on three independent failure modes: missing config, unparseable URL, ADO HTTP failure.
  • Tasks: four @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

  • Two-endpoint dispatch via _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. Logs comment.posted on success, comment.post_failed on requests.RequestException.
  • for_feature_state deferred import. post_state_change_comment_for_feature_state imports tasks.py lazily to break the services↔tasks circular. Mirrors GitLab.
  • Tests for the dispatcher helper use mocker.patch.object(TaskHandler, "delay") because TaskHandler.delay is read-only at the instance level (the descriptor lives on the class). Matches the existing pattern in tests/unit/audit/test_unit_audit_tasks.py.
  • PR 4 conftest fixture fixed. Previously produced URLs like pullrequest/active (state name as ID) that don't parse through parse_pull_request_url (regex requires \d+). Switched to numeric pr_id per fixture (open=1, draft=2, merged=3). PR 4's tagging/mapper tests still pass — they read metadata.state, not the URL.

Caught + handled during review

  • Conftest URL-parsing bug noticed during plan self-review.
  • mocker.patch on TaskHandler.delay (read-only instance attribute) — switched to patch.object on the class.
  • responses.calls[0] mypy-strict issue — switched to destructuring [call] = responses.calls.

Out of scope

  • The vcs/services.py dispatcher wiring that calls these tasks on FeatureExternalResource lifecycle events — lands in a later PR.
  • The FeatureState save-hook call site that invokes post_state_change_comment_for_feature_state — same story.
  • The Feature soft-delete hook that fans post_feature_deleted_comment out — same story.
  • Frontend changes — separate PR.

Test plan

  • make lint clean
  • make typecheck clean
  • make 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 guard
  • make django-make-migrations opts='--check --dry-run' — no drift (PR 6 introduces no schema changes)

🤖 Generated with Claude Code

asaphko and others added 8 commits May 28, 2026 15:29
…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>
@vercel
Copy link
Copy Markdown

vercel Bot commented May 28, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

3 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs Ignored Ignored May 28, 2026 3:08pm
flagsmith-frontend-preview Ignored Ignored May 28, 2026 3:08pm
flagsmith-frontend-staging Ignored Ignored May 28, 2026 3:08pm

Request Review

@github-actions github-actions Bot added api Issue related to the REST API docs Documentation updates feature New feature or request and removed docs Documentation updates labels May 28, 2026
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

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.

Comment on lines +99 to +112
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
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

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.

Suggested change
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

Comment on lines +137 to +139
config: AzureDevOpsConfiguration = AzureDevOpsConfiguration.objects.get(
project=resource.feature.project,
)
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

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.

Suggested change
config: AzureDevOpsConfiguration = AzureDevOpsConfiguration.objects.get(
project=resource.feature.project,
)
try:
config: AzureDevOpsConfiguration = AzureDevOpsConfiguration.objects.select_related("project").get(
project=resource.feature.project,
)

Comment on lines +177 to +179
config: AzureDevOpsConfiguration = AzureDevOpsConfiguration.objects.get(
project_id=project_id,
)
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

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.

Suggested change
config: AzureDevOpsConfiguration = AzureDevOpsConfiguration.objects.get(
project_id=project_id,
)
try:
config: AzureDevOpsConfiguration = AzureDevOpsConfiguration.objects.select_related("project").get(
project_id=project_id,
)

Comment on lines +210 to +212
config: AzureDevOpsConfiguration = AzureDevOpsConfiguration.objects.get(
project_id=project_id,
)
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

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.

Suggested change
config: AzureDevOpsConfiguration = AzureDevOpsConfiguration.objects.get(
project_id=project_id,
)
try:
config: AzureDevOpsConfiguration = AzureDevOpsConfiguration.objects.select_related("project").get(
project_id=project_id,
)

Comment on lines +252 to +253
if not hasattr(feature_state.environment.project, "azure_devops_config"):
return
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

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.

Suggested change
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

Comment on lines +265 to +267
config: AzureDevOpsConfiguration = AzureDevOpsConfiguration.objects.get(
project=feature.project,
)
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

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.

Suggested change
config: AzureDevOpsConfiguration = AzureDevOpsConfiguration.objects.get(
project=feature.project,
)
try:
config: AzureDevOpsConfiguration = AzureDevOpsConfiguration.objects.select_related("project").get(
project=feature.project,
)

@codecov
Copy link
Copy Markdown

codecov Bot commented May 28, 2026

Codecov Report

❌ Patch coverage is 96.48094% with 12 lines in your changes missing coverage. Please review.
✅ Project coverage is 98.53%. Comparing base (bed431a) to head (e8ae15b).

Files with missing lines Patch % Lines
api/integrations/azure_devops/services/comments.py 88.88% 12 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api Issue related to the REST API feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants