Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions api/integrations/azure_devops/client/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
from integrations.azure_devops.client.api import (
add_pull_request_comment,
add_tag_to_pull_request,
add_tag_to_work_item,
add_work_item_comment,
list_projects,
list_pull_requests,
list_repositories,
list_work_items,
remove_tag_from_pull_request,
remove_tag_from_work_item,
)
from integrations.azure_devops.client.exceptions import (
AzureDevOpsAuthError,
Expand Down Expand Up @@ -33,9 +37,13 @@
"AzureDevOpsError",
"AzureDevOpsNotFoundError",
"add_pull_request_comment",
"add_tag_to_pull_request",
"add_tag_to_work_item",
"add_work_item_comment",
"list_projects",
"list_pull_requests",
"list_repositories",
"list_work_items",
"remove_tag_from_pull_request",
"remove_tag_from_work_item",
]
148 changes: 147 additions & 1 deletion api/integrations/azure_devops/client/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ def _ado_request(
*,
path: str,
params: dict[str, Any] | None = None,
json_body: dict[str, Any] | None = None,
json_body: dict[str, Any] | list[Any] | None = None,
content_type: str | None = None,
) -> requests.Response:
base = organisation_url.rstrip("/")
# `path` may be either a bare segment ("projects") or already contain
Expand All @@ -41,12 +42,14 @@ def _ado_request(
query: dict[str, Any] = {"api-version": AZURE_DEVOPS_API_VERSION}
if params:
query.update(params)
headers = {"Content-Type": content_type} if content_type else {}
response = requests.request(
method,
url,
auth=("", pat),
params=query,
json=json_body,
headers=headers,
timeout=AZURE_DEVOPS_CLIENT_TIMEOUT_SECONDS,
)
if response.status_code in (401, 403):
Expand Down Expand Up @@ -300,3 +303,146 @@ def add_work_item_comment(
path=f"{project}/_apis/wit/workItems/{work_item_id}/comments",
json_body={"text": body},
)


def add_tag_to_pull_request(
*,
organisation_url: str,
pat: str,
project: str,
pull_request_id: int,
tag: str,
) -> None:
"""Add a label to a pull request. Idempotent on the ADO side — POSTing
a label that already exists returns 200 with the existing record.
"""
_ado_request(
"POST",
organisation_url,
pat,
path=f"{project}/_apis/git/pullrequests/{pull_request_id}/labels",
json_body={"name": tag},
)


def remove_tag_from_pull_request(
*,
organisation_url: str,
pat: str,
project: str,
pull_request_id: int,
tag: str,
) -> None:
"""Delete a label from a pull request. Swallows 404 (label-already-gone
is the desired terminal state).
"""
try:
_ado_request(
"DELETE",
organisation_url,
pat,
path=f"{project}/_apis/git/pullrequests/{pull_request_id}/labels/{tag}",
)
except AzureDevOpsNotFoundError:
return


def _get_work_item_tags(
*,
organisation_url: str,
pat: str,
project: str,
work_item_id: int,
) -> list[str]:
"""Fetch a work item's current System.Tags field, parsed as a list."""
response = _ado_request(
"GET",
organisation_url,
pat,
path=f"{project}/_apis/wit/workitems/{work_item_id}",
params={"fields": "System.Tags"},
)
payload = response.json()
raw = payload.get("fields", {}).get("System.Tags", "") or ""
return [t.strip() for t in raw.split(";") if t.strip()]


def _patch_work_item_tags(
*,
organisation_url: str,
pat: str,
project: str,
work_item_id: int,
tags: list[str],
) -> None:
"""PATCH a work item's System.Tags field with the supplied tag list."""
_ado_request(
"PATCH",
organisation_url,
pat,
path=f"{project}/_apis/wit/workitems/{work_item_id}",
json_body=[
{
"op": "add",
"path": "/fields/System.Tags",
"value": "; ".join(tags),
}
],
content_type="application/json-patch+json",
)


def add_tag_to_work_item(
*,
organisation_url: str,
pat: str,
project: str,
work_item_id: int,
tag: str,
) -> None:
"""Append ``tag`` to the work item's System.Tags field, preserving
existing tags. No-op if the tag is already present.
"""
current = _get_work_item_tags(
organisation_url=organisation_url,
pat=pat,
project=project,
work_item_id=work_item_id,
)
if tag in current:
return
_patch_work_item_tags(
organisation_url=organisation_url,
pat=pat,
project=project,
work_item_id=work_item_id,
tags=[*current, tag],
)


def remove_tag_from_work_item(
*,
organisation_url: str,
pat: str,
project: str,
work_item_id: int,
tag: str,
) -> None:
"""Remove ``tag`` from the work item's System.Tags field, preserving
every other tag. No-op if the tag isn't present.
"""
current = _get_work_item_tags(
organisation_url=organisation_url,
pat=pat,
project=project,
work_item_id=work_item_id,
)
if tag not in current:
return
_patch_work_item_tags(
organisation_url=organisation_url,
pat=pat,
project=project,
work_item_id=work_item_id,
tags=[t for t in current if t != tag],
)
2 changes: 2 additions & 0 deletions api/integrations/azure_devops/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,5 @@ class AzureDevOpsTagLabel(Enum):
AzureDevOpsTagLabel.WORK_ITEM_OPEN: "Has a linked Azure DevOps work item open",
AzureDevOpsTagLabel.WORK_ITEM_CLOSED: "Has a linked Azure DevOps work item closed",
}

AZURE_DEVOPS_FLAGSMITH_LABEL = "flagsmith"
133 changes: 133 additions & 0 deletions api/integrations/azure_devops/services/labels.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import requests
import structlog

from features.feature_external_resources.models import (
FeatureExternalResource,
ResourceType,
)
from integrations.azure_devops.client import (
add_tag_to_pull_request,
add_tag_to_work_item,
remove_tag_from_pull_request,
remove_tag_from_work_item,
)
Comment on lines +8 to +13
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

Import AzureDevOpsError to allow catching custom client exceptions when applying or removing labels, preventing unhandled exceptions from crashing the task processor.

Suggested change
from integrations.azure_devops.client import (
add_tag_to_pull_request,
add_tag_to_work_item,
remove_tag_from_pull_request,
remove_tag_from_work_item,
)
from integrations.azure_devops.client import (
AzureDevOpsError,
add_tag_to_pull_request,
add_tag_to_work_item,
remove_tag_from_pull_request,
remove_tag_from_work_item,
)

from integrations.azure_devops.constants import AZURE_DEVOPS_FLAGSMITH_LABEL
from integrations.azure_devops.models import AzureDevOpsConfiguration
from integrations.azure_devops.services.url_parsing import (
parse_pull_request_url,
parse_work_item_url,
)

logger = structlog.get_logger("azure_devops")


def _config_for_project(project_id: int) -> AzureDevOpsConfiguration | None:
"""Load the AzureDevOpsConfiguration with labeling_enabled set, or
return None.
"""
config: AzureDevOpsConfiguration | None = AzureDevOpsConfiguration.objects.filter(
project_id=project_id
).first()
Comment on lines +28 to +30
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

Use select_related("project") to prefetch the project relation. This avoids an additional database query when accessing config.project.organisation_id later in the logging context binding.

Suggested change
config: AzureDevOpsConfiguration | None = AzureDevOpsConfiguration.objects.filter(
project_id=project_id
).first()
config: AzureDevOpsConfiguration | None = AzureDevOpsConfiguration.objects.select_related("project").filter(
project_id=project_id
).first()

if not config or not config.labeling_enabled:
return None
return config


def apply_flagsmith_label_to_resource(
resource: FeatureExternalResource,
) -> None:
"""Apply the "flagsmith" label/tag to the linked ADO resource. No-op
if labelling is disabled or unconfigured. Never raises — failures are
logged via ``label.apply_failed``.
"""
config = _config_for_project(resource.feature.project_id)
if config is None:
return

log = logger.bind(
organisation__id=config.project.organisation_id,
project__id=config.project_id,
feature__id=resource.feature_id,
resource__type=resource.type,
)

try:
if resource.type == ResourceType.AZURE_DEVOPS_PULL_REQUEST.value:
ref = parse_pull_request_url(resource.url)
if ref is None:
return
add_tag_to_pull_request(
organisation_url=config.organisation_url,
pat=config.personal_access_token,
project=ref.project,
pull_request_id=ref.pull_request_id,
tag=AZURE_DEVOPS_FLAGSMITH_LABEL,
)
log.info("label.applied", ado__resource__id=ref.pull_request_id)
return

if resource.type == ResourceType.AZURE_DEVOPS_WORK_ITEM.value:
work_ref = parse_work_item_url(resource.url)
if work_ref is None:
return
add_tag_to_work_item(
organisation_url=config.organisation_url,
pat=config.personal_access_token,
project=work_ref.project,
work_item_id=work_ref.work_item_id,
tag=AZURE_DEVOPS_FLAGSMITH_LABEL,
)
log.info("label.applied", ado__resource__id=work_ref.work_item_id)
except requests.RequestException:
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

Catch AzureDevOpsError in addition to requests.RequestException. Since custom exceptions like AzureDevOpsAuthError and AzureDevOpsNotFoundError are raised by the client and do not inherit from requests.RequestException, they must be explicitly caught to prevent task crashes.

Suggested change
except requests.RequestException:
except (requests.RequestException, AzureDevOpsError):

log.exception("label.apply_failed")


def remove_flagsmith_label_from_resource(
*,
project_id: int,
resource_url: str,
resource_type: str,
) -> None:
"""Remove the "flagsmith" label/tag from the ADO resource. Takes fields
directly because this is called from the unlink task after the FER row
is gone. No-op if labelling is disabled or unconfigured. Never raises.
"""
config = _config_for_project(project_id)
if config is None:
return

log = logger.bind(
organisation__id=config.project.organisation_id,
project__id=config.project_id,
resource__type=resource_type,
)

try:
if resource_type == ResourceType.AZURE_DEVOPS_PULL_REQUEST.value:
ref = parse_pull_request_url(resource_url)
if ref is None:
return
remove_tag_from_pull_request(
organisation_url=config.organisation_url,
pat=config.personal_access_token,
project=ref.project,
pull_request_id=ref.pull_request_id,
tag=AZURE_DEVOPS_FLAGSMITH_LABEL,
)
log.info("label.removed", ado__resource__id=ref.pull_request_id)
return

if resource_type == ResourceType.AZURE_DEVOPS_WORK_ITEM.value:
work_ref = parse_work_item_url(resource_url)
if work_ref is None:
return
remove_tag_from_work_item(
organisation_url=config.organisation_url,
pat=config.personal_access_token,
project=work_ref.project,
work_item_id=work_ref.work_item_id,
tag=AZURE_DEVOPS_FLAGSMITH_LABEL,
)
log.info("label.removed", ado__resource__id=work_ref.work_item_id)
except requests.RequestException:
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

Catch AzureDevOpsError in addition to requests.RequestException to ensure custom client exceptions are safely handled and do not crash the task processor during label removal.

Suggested change
except requests.RequestException:
except (requests.RequestException, AzureDevOpsError):

log.exception("label.removal_failed")
34 changes: 34 additions & 0 deletions api/integrations/azure_devops/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
post_state_change_comment,
post_unlinked_comment,
)
from integrations.azure_devops.services.labels import (
apply_flagsmith_label_to_resource,
remove_flagsmith_label_from_resource,
)

logger = structlog.get_logger("azure_devops")

Expand Down Expand Up @@ -79,3 +83,33 @@ def post_azure_devops_feature_deleted_comment(
feature_id=feature_id,
project_id=project_id,
)


@register_task_handler()
def apply_azure_devops_label(resource_id: int) -> None:
"""Apply the "flagsmith" label/tag to the linked ADO resource.
Dispatched at link time. No-op if labelling is disabled.
"""
try:
resource = FeatureExternalResource.objects.get(id=resource_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

Use select_related("feature") when fetching the FeatureExternalResource. This avoids a lazy-loading database query when apply_flagsmith_label_to_resource accesses resource.feature.project_id.

Suggested change
resource = FeatureExternalResource.objects.get(id=resource_id)
resource = FeatureExternalResource.objects.select_related("feature").get(id=resource_id)

except FeatureExternalResource.DoesNotExist:
return
apply_flagsmith_label_to_resource(resource)


@register_task_handler()
def remove_azure_devops_label(
*,
project_id: int,
resource_url: str,
resource_type: str,
) -> None:
"""Remove the "flagsmith" label/tag from the ADO resource.
Dispatched at unlink time. Takes fields directly because the FER row
is gone.
"""
remove_flagsmith_label_from_resource(
project_id=project_id,
resource_url=resource_url,
resource_type=resource_type,
)
Loading
Loading