Skip to content
41 changes: 41 additions & 0 deletions api/integrations/azure_devops/constants.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,44 @@
from enum import Enum

from features.feature_external_resources.models import ResourceType

AZURE_DEVOPS_CLIENT_TIMEOUT_SECONDS = 10

AZURE_DEVOPS_API_VERSION = "7.1"

AZURE_DEVOPS_TAG_COLOR = "#0078D4"


class AzureDevOpsTagLabel(Enum):
PR_OPEN = "PR Open"
PR_MERGED = "PR Merged"
PR_ABANDONED = "PR Abandoned"
PR_DRAFT = "PR Draft"
WORK_ITEM_OPEN = "Work Item Open"
WORK_ITEM_CLOSED = "Work Item Closed"


AZURE_DEVOPS_TAG_KIND_BY_LABEL: dict[AzureDevOpsTagLabel, str] = {
AzureDevOpsTagLabel.PR_OPEN: "PR",
AzureDevOpsTagLabel.PR_MERGED: "PR",
AzureDevOpsTagLabel.PR_ABANDONED: "PR",
AzureDevOpsTagLabel.PR_DRAFT: "PR",
AzureDevOpsTagLabel.WORK_ITEM_OPEN: "Work Item",
AzureDevOpsTagLabel.WORK_ITEM_CLOSED: "Work Item",
}


AZURE_DEVOPS_TAG_KIND_BY_RESOURCE_TYPE: dict[str, str] = {
ResourceType.AZURE_DEVOPS_PULL_REQUEST.value: "PR",
ResourceType.AZURE_DEVOPS_WORK_ITEM.value: "Work Item",
}


AZURE_DEVOPS_TAG_DESCRIPTION_BY_LABEL: dict[AzureDevOpsTagLabel, str] = {
AzureDevOpsTagLabel.PR_OPEN: "Has a linked Azure DevOps pull request open",
AzureDevOpsTagLabel.PR_MERGED: "Has a linked Azure DevOps pull request merged",
AzureDevOpsTagLabel.PR_ABANDONED: "Has a linked Azure DevOps pull request abandoned",
AzureDevOpsTagLabel.PR_DRAFT: "Has a linked Azure DevOps pull request in draft",
AzureDevOpsTagLabel.WORK_ITEM_OPEN: "Has a linked Azure DevOps work item open",
AzureDevOpsTagLabel.WORK_ITEM_CLOSED: "Has a linked Azure DevOps work item closed",
}
92 changes: 92 additions & 0 deletions api/integrations/azure_devops/mappers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
from pydantic import TypeAdapter, ValidationError

from features.feature_external_resources.models import (
FeatureExternalResource,
ResourceType,
)
from integrations.azure_devops.constants import AzureDevOpsTagLabel
from integrations.azure_devops.types import AzureDevOpsResourceMetadata

_resource_metadata_adapter: TypeAdapter[AzureDevOpsResourceMetadata] = TypeAdapter(
AzureDevOpsResourceMetadata,
)


_PR_OPEN_STATES = {"active"}
_PR_MERGED_STATES = {"completed"}
_PR_ABANDONED_STATES = {"abandoned"}


_WORK_ITEM_OPEN_STATES = {
"new",
"active",
"to do",
"in progress",
"doing",
"approved",
"committed",
"open",
"proposed",
"resolved",
}
_WORK_ITEM_CLOSED_STATES = {"closed", "done", "removed"}


def map_pr_state_to_tag_label(
state: str | None,
*,
is_draft: bool,
) -> AzureDevOpsTagLabel | None:
"""Map an Azure DevOps pull-request state (+ draft flag) to a Flagsmith
tag label, or ``None`` if the state is unknown.
"""
if not state:
return None
normalised = state.lower()
if normalised in _PR_ABANDONED_STATES:
return AzureDevOpsTagLabel.PR_ABANDONED
if normalised in _PR_MERGED_STATES:
return AzureDevOpsTagLabel.PR_MERGED
if normalised in _PR_OPEN_STATES:
return AzureDevOpsTagLabel.PR_DRAFT if is_draft else AzureDevOpsTagLabel.PR_OPEN
return None


def map_work_item_state_to_tag_label(
state: str | None,
) -> AzureDevOpsTagLabel | None:
"""Map an Azure DevOps work-item state to a Flagsmith tag label, or
``None`` if the state is unknown. Covers the common states across
Agile, Scrum, and Basic process templates.
"""
if not state:
return None
normalised = state.lower()
if normalised in _WORK_ITEM_CLOSED_STATES:
return AzureDevOpsTagLabel.WORK_ITEM_CLOSED
if normalised in _WORK_ITEM_OPEN_STATES:
return AzureDevOpsTagLabel.WORK_ITEM_OPEN
return None


def map_resource_to_tag_label(
resource: FeatureExternalResource,
) -> AzureDevOpsTagLabel | None:
"""Derive the Azure DevOps tag label for ``resource.feature`` from the
JSON metadata snapshot the client supplied at link time. Returns
``None`` if the metadata is missing, malformed, or the state isn't
recognised.
"""
try:
metadata = _resource_metadata_adapter.validate_json(resource.metadata or "")
except ValidationError:
return None
state = metadata.get("state")
if resource.type == ResourceType.AZURE_DEVOPS_PULL_REQUEST.value:
return map_pr_state_to_tag_label(
state,
is_draft=bool(metadata.get("is_draft")),
)
if resource.type == ResourceType.AZURE_DEVOPS_WORK_ITEM.value:
return map_work_item_state_to_tag_label(state)
return None
103 changes: 103 additions & 0 deletions api/integrations/azure_devops/services/tagging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
from features.feature_external_resources.models import (
FeatureExternalResource,
)
from features.models import Feature
from integrations.azure_devops.constants import (
AZURE_DEVOPS_TAG_COLOR,
AZURE_DEVOPS_TAG_DESCRIPTION_BY_LABEL,
AZURE_DEVOPS_TAG_KIND_BY_LABEL,
AZURE_DEVOPS_TAG_KIND_BY_RESOURCE_TYPE,
AzureDevOpsTagLabel,
)
from integrations.azure_devops.mappers import map_resource_to_tag_label
from integrations.azure_devops.models import AzureDevOpsConfiguration
from projects.tags.models import Tag, TagType


def _tagging_enabled_for_resource(resource: FeatureExternalResource) -> bool:
"""True if the resource's project has an AzureDevOpsConfiguration with
tagging_enabled set. False if there's no configuration or the toggle
is off.
"""
config = AzureDevOpsConfiguration.objects.filter(
project=resource.feature.project,
).first()
return bool(config and config.tagging_enabled)


def set_azure_devops_tag(feature: Feature, new_label: AzureDevOpsTagLabel) -> None:
"""Apply an Azure DevOps system tag to ``feature``, replacing any
existing Azure DevOps tag of the same kind (PR / Work Item) first.
"""
kind = AZURE_DEVOPS_TAG_KIND_BY_LABEL[new_label]
feature.tags.remove(
*feature.tags.filter(
type=TagType.AZURE_DEVOPS.value,
label__startswith=kind,
)
)
tag, _ = Tag.objects.get_or_create(
label=new_label.value,
project=feature.project,
is_system_tag=True,
type=TagType.AZURE_DEVOPS.value,
defaults={
"color": AZURE_DEVOPS_TAG_COLOR,
"description": AZURE_DEVOPS_TAG_DESCRIPTION_BY_LABEL[new_label],
},
)
feature.tags.add(tag)


def apply_initial_tag(resource: FeatureExternalResource) -> None:
"""Tag ``resource.feature`` based on the linked ADO resource's state
at link time. No-op when the project has no AzureDevOpsConfiguration,
when tagging_enabled is False, or when the metadata can't be mapped
to a known label.
"""
if not _tagging_enabled_for_resource(resource):
return
label = map_resource_to_tag_label(resource)
if label is None:
return
set_azure_devops_tag(resource.feature, label)


def clear_tag_for_resource(resource: FeatureExternalResource) -> None:
"""Remove the Azure DevOps tag for ``resource``'s kind (PR / Work Item)
from its feature when no other linked FeatureExternalResource of the
same kind remains. Safe to call whether ``resource`` is still in the
DB or has already been deleted.
"""
kind = AZURE_DEVOPS_TAG_KIND_BY_RESOURCE_TYPE.get(resource.type)
if kind is None:
return
if (
FeatureExternalResource.objects.filter(
feature=resource.feature,
type=resource.type,
)
.exclude(pk=resource.pk)
.exists()
):
return
resource.feature.tags.remove(
*resource.feature.tags.filter(
type=TagType.AZURE_DEVOPS.value,
label__startswith=kind,
)
)


def refresh_tags_for_resource(resource: FeatureExternalResource) -> None:
"""Re-apply the right tag for ``resource``'s current metadata. Called
by the inbound-webhook handler (later PR) after it updates the
metadata in place. No-op when tagging is disabled or when the state
can't be mapped to a known label.
"""
if not _tagging_enabled_for_resource(resource):
return
label = map_resource_to_tag_label(resource)
if label is None:
return
set_azure_devops_tag(resource.feature, label)
18 changes: 18 additions & 0 deletions api/integrations/azure_devops/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from typing_extensions import TypedDict


class AzureDevOpsResourceMetadata(TypedDict, total=False):
"""Client-supplied snapshot persisted on ``FeatureExternalResource.metadata``
when linking an Azure DevOps pull request or work item. Sent by the
frontend as part of the link request; the backend stores it verbatim
as a JSON string.

Fields are typed for both PR and work-item resources; not every field
applies to both — ``state`` is universal, ``is_draft`` is PR-only,
``work_item_type`` is work-item-only, ``title`` is universal.
"""

title: str
state: str
work_item_type: str
is_draft: bool
62 changes: 62 additions & 0 deletions api/tests/unit/integrations/azure_devops/conftest.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import pytest

from features.feature_external_resources.models import (
FeatureExternalResource,
ResourceType,
)
from features.models import Feature
from integrations.azure_devops.models import AzureDevOpsConfiguration
from projects.models import Project

Expand All @@ -11,3 +16,60 @@ def azure_devops_configuration(project: Project) -> AzureDevOpsConfiguration:
organisation_url="https://dev.azure.com/test-org",
personal_access_token="ado-test-token",
)


def _make_pr_resource(
feature: Feature, *, state: str, is_draft: bool = False
) -> FeatureExternalResource:
metadata = (
'{"state": "'
+ state
+ '", "is_draft": '
+ ("true" if is_draft else "false")
+ "}"
)
draft_suffix = "-draft" if is_draft else ""
return FeatureExternalResource.objects.create(
feature=feature,
url=f"https://dev.azure.com/test-org/proj/_git/repo/pullrequest/{state}{draft_suffix}",
type=ResourceType.AZURE_DEVOPS_PULL_REQUEST.value,
metadata=metadata,
)


def _make_work_item_resource(
feature: Feature, *, state: str
) -> FeatureExternalResource:
return FeatureExternalResource.objects.create(
feature=feature,
url=f"https://dev.azure.com/test-org/proj/_workitems/edit/{abs(hash(state)) % 10000}",
type=ResourceType.AZURE_DEVOPS_WORK_ITEM.value,
metadata='{"state": "' + state + '"}',
)


@pytest.fixture()
def azure_devops_pr_resource_open(feature: Feature) -> FeatureExternalResource:
return _make_pr_resource(feature, state="active", is_draft=False)


@pytest.fixture()
def azure_devops_pr_resource_draft(feature: Feature) -> FeatureExternalResource:
return _make_pr_resource(feature, state="active", is_draft=True)


@pytest.fixture()
def azure_devops_pr_resource_merged(feature: Feature) -> FeatureExternalResource:
return _make_pr_resource(feature, state="completed")


@pytest.fixture()
def azure_devops_work_item_resource_open(feature: Feature) -> FeatureExternalResource:
return _make_work_item_resource(feature, state="Active")


@pytest.fixture()
def azure_devops_work_item_resource_closed(
feature: Feature,
) -> FeatureExternalResource:
return _make_work_item_resource(feature, state="Closed")
Loading
Loading