Azure DevOps categorization rules

This article describes how to create categorization rules for ADO allocation reporting. A categorization rule is an ordered set SQL queries that define categories for grouping your ADO work items. DX executes categorization rules nightly. For testing purposes, you can execute categorization rules immediately while editing.

When defining a category, you can choose between two types:

  • Fixed category – Your query must return an ado_work_item_id column, which represents the specific set of work items which fall under your category.
  • Dynamic category – Your query must return an ado_work_item_id and value column. The value column will determine the category each work item is assigned to.

Below are additional best practices for creating and editing categories rules:

  • Keep categories unpublished while you’re working on them to avoid confusion.
  • Test changes by clicking Process work items and then Preview to see the output.
  • Order rules from most specific to broadest.
  • Prefer sub-queries over hardcoded IDs.

Core database tables

Below are the main database tables you will use when defining SQL categories:

Table Description
ado_work_items Table containing all ADO work_items
ado_work_item_types User Story, Bug, Issue, etc.
ado_custom_fields List of all custom fields
ado_work_item_custom_field_values Values of custom fields per work item
ado_work_item_tags List of tags associated with work items
ado_work_item_relationships Flattened representation of each item’s hierarchy

Tips for working with ado_work_item_relationships

ADO work items are often structured hierarchically, e.g. Epics > Features > User Stories. The ado_work_item_relationships table contains all ancestor-descendant relationships, not just direct parent-child links.

For example, given this hierarchy:

Epic A
└── Feature B
    └── User Story C

The ado_work_item_relationships table will include the following rules:

Ancestor Descendant
Epic A Feature B
Feature B User Story C
Epic A User Story C

You can use the ado_work_item_relationships table to join data from any ancestor level in your categorization logic, avoid complex recursive CTEs or multiple joins, and cleanly apply fallback logic (e.g. check Story metadata, then Epic).

Examples

Categorize by type, title, and tags

You can define a single Dynamic category like the one below:

SELECT DISTINCT
    ado_work_items.id AS ado_work_item_id,
    ado_work_item_types.name AS value
FROM ado_work_items
JOIN ado_work_item_types ON ado_work_item_types.id = ado_work_items.work_item_type_id

Or, you can define multiple Fixed categories, for example:

-- This SQL is for our "Tech debt" category

SELECT DISTINCT ado_work_items.id AS ado_work_item_id
FROM ado_work_items
JOIN ado_work_item_types ON ado_work_item_types.id = ado_work_items.work_item_type_id
LEFT JOIN ado_work_item_tags ON ado_work_item_tags.work_item_id = ado_work_items.id
    AND ado_work_item_tags.name = 'Maintenance'
WHERE
    ado_work_item_types.name = 'Bug' OR
    ado_work_items.title ILIKE '%refactor%' OR
    ado_work_item_tags.id IS NOT NULL
-- This SQL is for our "Innovation" category

SELECT DISTINCT ado_work_items.id AS ado_work_item_id
FROM ado_work_items
JOIN ado_work_item_types ON ado_work_item_types.id = ado_work_items.work_item_type_id
LEFT JOIN ado_work_item_tags ON ado_work_item_tags.work_item_id = ado_work_items.id
    AND ado_work_item_tags.name = 'Roadmap'
WHERE
    ado_work_item_types.name = 'User Story' OR
    ado_work_items.title ILIKE '%feature%' OR
    ado_work_item_tags.id IS NOT NULL

Categorize by custom field value

To categorize work items based on custom field values, define categories that group work items using the ado_custom_field_values tables. You may also want to use the ado_work_item_relationships the table to fallback to Feature/Epic custom field values if a field is not set for a work item.

You can define a single Dynamic category like the one below:

WITH custom_field_ids AS (
    SELECT id
    FROM ado_custom_fields
    WHERE name = 'Investment Category'
),
feature_work_item_type_ids AS (
    SELECT id
    FROM ado_work_item_types
    WHERE name = 'Feature'
),
epic_work_item_type_ids AS (
    SELECT id
    FROM ado_work_item_types
    WHERE name = 'Epic'
)
SELECT DISTINCT
    ado_work_items.id AS ado_work_item_id,
    COALESCE(
        item_values.value,
        feature_values.value,
        epic_values.value
    ) AS value
FROM ado_work_items
LEFT JOIN ado_work_item_relationships relationships
    ON ado_work_items.id = relationships.descendant_id
LEFT JOIN ado_work_items features
    ON relationships.ancestor_id = features.id
    AND features.work_item_type_id IN (
        SELECT id
        FROM feature_work_item_type_ids
    )
LEFT JOIN ado_work_items epics
    ON relationships.ancestor_id = epics.id
    AND epics.work_item_type_id IN (
        SELECT id
        FROM epic_work_item_type_ids
    )
LEFT JOIN ado_work_item_custom_field_values item_values
    ON ado_work_items.id = item_values.work_item_id
    AND item_values.custom_field_id IN (
        SELECT id
        FROM custom_field_ids
    )
LEFT JOIN ado_work_item_custom_field_values feature_values
    ON features.id = feature_values.work_item_id
    AND feature_values.custom_field_id IN (
        SELECT id
        FROM custom_field_ids
    )
LEFT JOIN ado_work_item_custom_field_values epic_values
    ON epics.id = epic_values.work_item_id
    AND epic_values.custom_field_id IN (
        SELECT id
        FROM custom_field_ids
    )
WHERE COALESCE(
    item_values.value,
    epic_values.value,
    epic_values.value) IS NOT NULL

Or, you can define multiple Fixed categories. Here’s one example:

-- This SQL is for our "Tech debt" category

WITH custom_field_ids AS (
    SELECT id
    FROM ado_custom_fields
    WHERE name = 'Investment Category'
),
feature_work_item_type_ids AS (
    SELECT id
    FROM ado_work_item_types
    WHERE name = 'Feature'
),
epic_work_item_type_ids AS (
    SELECT id
    FROM ado_work_item_types
    WHERE name = 'Epic'
)
SELECT DISTINCT ado_work_items.id AS ado_work_item_id
FROM ado_work_items
LEFT JOIN ado_work_item_relationships relationships
    ON ado_work_items.id = relationships.descendant_id
LEFT JOIN ado_work_items features
    ON relationships.ancestor_id = features.id
    AND features.work_item_type_id IN (
        SELECT id
        FROM feature_work_item_type_ids
    )
LEFT JOIN ado_work_items epics
    ON relationships.ancestor_id = epics.id
    AND epics.work_item_type_id IN (
        SELECT id
        FROM epic_work_item_type_ids
    )
LEFT JOIN ado_work_item_custom_field_values item_values
    ON ado_work_items.id = item_values.work_item_id
    AND item_values.custom_field_id IN (
        SELECT id
        FROM custom_field_ids
    )
LEFT JOIN ado_work_item_custom_field_values feature_values
    ON features.id = feature_values.work_item_id
    AND feature_values.custom_field_id IN (
        SELECT id
        FROM custom_field_ids
    )
LEFT JOIN ado_work_item_custom_field_values epic_values
    ON epics.id = epic_values.work_item_id
    AND epic_values.custom_field_id IN (
        SELECT id
        FROM custom_field_ids
    )
WHERE COALESCE(
    item_values.value,
    feature_values.value,
    epic_values.value) IN ('Bug', 'Fix', 'Maintenance')