Skip to content
4 changes: 4 additions & 0 deletions api/integrations/azure_devops/client/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from integrations.azure_devops.client.api import (
add_pull_request_comment,
add_work_item_comment,
list_projects,
list_pull_requests,
list_repositories,
Expand Down Expand Up @@ -30,6 +32,8 @@
"AzureDevOpsAuthError",
"AzureDevOpsError",
"AzureDevOpsNotFoundError",
"add_pull_request_comment",
"add_work_item_comment",
"list_projects",
"list_pull_requests",
"list_repositories",
Expand Down
47 changes: 47 additions & 0 deletions api/integrations/azure_devops/client/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,3 +253,50 @@ def list_work_items(
]
next_token = str(end) if end < len(all_ids) else None
return AdoWorkItemsPage(results=results, continuation_token=next_token)


def add_pull_request_comment(
*,
organisation_url: str,
pat: str,
project: str,
pull_request_id: int,
body: str,
) -> None:
"""Post a single-comment thread on an Azure DevOps pull request via
its project-scoped threads endpoint.

``project`` is the ADO project name from the resource URL; the
project-scoped form sidesteps needing the repository GUID. ``status: 1``
is the ADO enum value for "Active".
"""
_ado_request(
"POST",
organisation_url,
pat,
path=f"{project}/_apis/git/pullrequests/{pull_request_id}/threads",
json_body={
"comments": [{"content": body}],
"status": 1,
},
)


def add_work_item_comment(
*,
organisation_url: str,
pat: str,
project: str,
work_item_id: int,
body: str,
) -> None:
"""Post a comment on an Azure DevOps work item via the modern Comments
API.
"""
_ado_request(
"POST",
organisation_url,
pat,
path=f"{project}/_apis/wit/workItems/{work_item_id}/comments",
json_body={"text": body},
)
313 changes: 313 additions & 0 deletions api/integrations/azure_devops/services/comments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,313 @@
import requests
import structlog
from django.db.models import Q
from django.template.loader import render_to_string

from core.helpers import get_current_site_url
from features.feature_external_resources.models import (
AZURE_DEVOPS_RESOURCE_TYPES,
FeatureExternalResource,
ResourceType,
)
from features.models import Feature, FeatureState
from integrations.azure_devops.client import (
add_pull_request_comment,
add_work_item_comment,
)
from integrations.azure_devops.models import AzureDevOpsConfiguration
from integrations.azure_devops.services.url_parsing import (
parse_pull_request_url,
parse_work_item_url,
)
from integrations.azure_devops.types import AzureDevOpsEnvironmentState

logger = structlog.get_logger("azure_devops")


def _post_to_resource(
*,
config: AzureDevOpsConfiguration,
resource_url: str,
resource_type: str,
feature_id: int,
body: str,
) -> None:
"""Parse an ADO resource URL and post the comment via the right
endpoint. Used by every public function in this module.
"""
if resource_type == ResourceType.AZURE_DEVOPS_PULL_REQUEST.value:
ref = parse_pull_request_url(resource_url)
if ref is None:
return
log = logger.bind(
organisation__id=config.project.organisation_id,
project__id=config.project_id,
feature__id=feature_id,
ado__project=ref.project,
ado__resource__id=ref.pull_request_id,
)
try:
add_pull_request_comment(
organisation_url=config.organisation_url,
pat=config.personal_access_token,
project=ref.project,
pull_request_id=ref.pull_request_id,
body=body,
)
except requests.RequestException as exc:
log.warning("comment.post_failed", exc_info=exc)
return
log.info("comment.posted")
return

if resource_type == ResourceType.AZURE_DEVOPS_WORK_ITEM.value:
work_ref = parse_work_item_url(resource_url)
if work_ref is None:
return
log = logger.bind(
organisation__id=config.project.organisation_id,
project__id=config.project_id,
feature__id=feature_id,
ado__project=work_ref.project,
ado__resource__id=work_ref.work_item_id,
)
try:
add_work_item_comment(
organisation_url=config.organisation_url,
pat=config.personal_access_token,
project=work_ref.project,
work_item_id=work_ref.work_item_id,
body=body,
)
except requests.RequestException as exc:
log.warning("comment.post_failed", exc_info=exc)
return
log.info("comment.posted")


def _get_environment_states(feature: Feature) -> list[AzureDevOpsEnvironmentState]:
"""Gather the current enabled state and value for ``feature`` across all
environments in its project, suitable for rendering in a comment.
"""
from environments.models import Environment

site_url = get_current_site_url()
environments = Environment.objects.filter(
project=feature.project,
).order_by("id")

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


value = feature_state.get_feature_state_value()
env_url = (
f"{site_url}/project/{feature.project_id}"
f"/environment/{environment.api_key}"
f"/features?feature={feature.id}"
)
states.append(
{
"name": environment.name,
"url": env_url,
"enabled": feature_state.enabled,
"value": value if value not in (None, "") else None,
}
)
return states


def post_linked_comment(resource: FeatureExternalResource) -> None:
"""Post a comment on the linked ADO PR or work item showing the
feature flag's current state across all environments. No-op when the
project has no AzureDevOpsConfiguration.
"""
try:
config: AzureDevOpsConfiguration = AzureDevOpsConfiguration.objects.get(
project=resource.feature.project,
)
Comment on lines +137 to +139
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,
)

except AzureDevOpsConfiguration.DoesNotExist:
return

feature = resource.feature
environment_states = _get_environment_states(feature)
body = render_to_string(
"azure_devops/feature_linked_comment.md",
{
"feature_name": feature.name,
"environment_states": environment_states,
},
)

_post_to_resource(
config=config,
resource_url=resource.url,
resource_type=resource.type,
feature_id=feature.id,
body=body,
)


def post_unlinked_comment(
feature_name: str,
feature_id: int,
resource_url: str,
resource_type: str,
project_id: int,
) -> None:
"""Post a comment on the ADO resource informing that the feature flag
has been unlinked.

All parameters are passed explicitly because the
``FeatureExternalResource`` row no longer exists by the time this
runs asynchronously.
"""
try:
config: AzureDevOpsConfiguration = AzureDevOpsConfiguration.objects.get(
project_id=project_id,
)
Comment on lines +177 to +179
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,
)

except AzureDevOpsConfiguration.DoesNotExist:
return

body = render_to_string(
"azure_devops/feature_unlinked_comment.md",
{"feature_name": feature_name},
)

_post_to_resource(
config=config,
resource_url=resource_url,
resource_type=resource_type,
feature_id=feature_id,
body=body,
)


def post_feature_deleted_comment(
feature_name: str,
feature_id: int,
project_id: int,
) -> None:
"""Post a comment on every linked Azure DevOps resource informing that
the feature flag has been deleted.

All parameters are passed explicitly because the feature is being
soft-deleted and may no longer be fully usable as an ORM object by
the time this runs asynchronously.
"""
try:
config: AzureDevOpsConfiguration = AzureDevOpsConfiguration.objects.get(
project_id=project_id,
)
Comment on lines +210 to +212
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,
)

except AzureDevOpsConfiguration.DoesNotExist:
return

resources = FeatureExternalResource.objects.filter(
feature_id=feature_id,
type__in=AZURE_DEVOPS_RESOURCE_TYPES,
)
if not resources.exists():
return

body = render_to_string(
"azure_devops/feature_deleted_comment.md",
{"feature_name": feature_name},
)

for resource in resources:
_post_to_resource(
config=config,
resource_url=resource.url,
resource_type=resource.type,
feature_id=feature_id,
body=body,
)


def post_state_change_comment_for_feature_state(
feature_state: FeatureState,
) -> None:
"""Dispatch a state-change comment task for ``feature_state`` when the
project has an Azure DevOps integration configured. No-op otherwise
so projects without ADO don't pay for a queue entry and a
``AzureDevOpsConfiguration`` lookup per feature-state save.
"""
from integrations.azure_devops.tasks import (
post_azure_devops_state_change_comment,
)

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

post_azure_devops_state_change_comment.delay(args=(feature_state.id,))


def post_state_change_comment(feature_state: FeatureState) -> None:
"""Post a comment on every linked ADO resource when a feature flag's
state changes, covering environment-level, segment override, and
identity override scopes.
"""
feature = feature_state.feature

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

except AzureDevOpsConfiguration.DoesNotExist:
return

resources = feature.external_resources.filter(type__in=AZURE_DEVOPS_RESOURCE_TYPES)
if not resources.exists():
return

environment = feature_state.environment
if environment is None:
return

if feature_state.feature_segment_id is not None:
feature_segment = feature_state.feature_segment
scope = "segment"
scope_name: str | None = (
feature_segment.segment.name if feature_segment else None
)
elif feature_state.identity_id is not None:
identity = feature_state.identity
scope = "identity"
scope_name = identity.identifier if identity else None
else:
scope = "environment"
scope_name = None

value = feature_state.get_feature_state_value()
body = render_to_string(
"azure_devops/feature_state_changed_comment.md",
{
"feature_name": feature.name,
"environment_name": environment.name,
"enabled": feature_state.enabled,
"value": value if value not in (None, "") else None,
"scope": scope,
"scope_name": scope_name,
},
)

for resource in resources:
_post_to_resource(
config=config,
resource_url=resource.url,
resource_type=resource.type,
feature_id=feature.id,
body=body,
)
Loading
Loading