Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,22 @@ frontend/ # React frontend
sdk/ # Python SDK
```

## Incident Action Labels

Action item sync adds the existing workspace-wide `incident-action` label to incident sub-issues, including pre-existing issues adopted as follow-ups. Other labels are preserved.

To backfill historical action items in production, open **Django admin → Incidents**:

1. Select a batch of incidents.
2. Run **Preview incident-action label backfill** to see how many labels are missing without updating Linear issues.
3. Select the same incidents and run **Backfill incident-action labels in Linear** to apply the additions.

Both actions require permission to change incidents and report counts and failures in admin. They read current sub-issues for the selected incident parents, including archived children, without changing parent relationships, SLAs, or cached action items. Use manageable batches to keep admin requests short; rerunning safely skips existing labels. The workspace label must already exist.

If Linear rate-limits a request, the action stops immediately and reports partial progress and any `Retry-After` guidance. It does not wait for quota to reset inside the admin request or resume automatically. Wait for the limit to reset, then rerun the same selection; issues already labeled are skipped.

The optional local script, `scripts/backfill_incident_action_labels.py`, uses the same backfill logic and still defaults to a dry run.

## Scheduled Tasks

Scheduled tasks are stored as database objects and are managed via migrations.
Expand Down
36 changes: 36 additions & 0 deletions scripts/backfill_incident_action_labels.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Backfill incident-action labels without changing issues' other fields.

Preview: uv run python scripts/backfill_incident_action_labels.py
Apply: uv run python scripts/backfill_incident_action_labels.py --apply
"""

import argparse
import logging
import os

import django
from django.conf import settings


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--apply", action="store_true", help="Apply label additions")
args = parser.parse_args(argv)

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "firetower.settings")
django.setup()

from firetower.incidents.models import Incident # noqa: PLC0415
from firetower.incidents.services import backfill_action_item_labels # noqa: PLC0415

if not settings.LINEAR:
parser.error("Linear integration is not configured")

stats = backfill_action_item_labels(Incident.objects.all(), dry_run=not args.apply)
for error in stats.errors:
logging.getLogger(__name__).error("%s", error)
return 1 if stats.errors else 0


if __name__ == "__main__":
raise SystemExit(main())
37 changes: 36 additions & 1 deletion src/firetower/incidents/admin.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from __future__ import annotations

from django.contrib import admin
from django.contrib import admin, messages
from django.db.models import QuerySet
from django.http import HttpRequest

from .models import ExternalLink, Incident, Tag
from .services import (
backfill_action_item_labels,
sync_action_items_from_linear,
sync_incident_participants_from_slack,
)
Expand Down Expand Up @@ -42,6 +43,8 @@ class IncidentAdmin(admin.ModelAdmin):
actions = [
"sync_participants_from_slack",
"sync_action_items",
"preview_action_item_labels",
"backfill_action_item_labels",
"clear_milestones",
]

Expand Down Expand Up @@ -149,6 +152,38 @@ def sync_action_items(

self.message_user(request, f"Action item sync: {', '.join(message_parts)}")

@admin.action(
description="Preview incident-action label backfill", permissions=["change"]
)
def preview_action_item_labels(
self, request: HttpRequest, queryset: QuerySet[Incident]
) -> None:
self._backfill_action_item_labels(request, queryset, dry_run=True)

@admin.action(
description="Backfill incident-action labels in Linear", permissions=["change"]
)
def backfill_action_item_labels(
self, request: HttpRequest, queryset: QuerySet[Incident]
) -> None:
self._backfill_action_item_labels(request, queryset, dry_run=False)

def _backfill_action_item_labels(
self, request: HttpRequest, queryset: QuerySet[Incident], *, dry_run: bool
) -> None:
stats = backfill_action_item_labels(queryset, dry_run=dry_run)
operation = "preview" if dry_run else "backfill"
message = (
f"Action label {operation}: {stats.checked} checked, "
f"{stats.missing} missing labels, {stats.added} added, "
f"{len(stats.errors)} failures."
)
if stats.errors:
message += f" {stats.errors[0]}"
self.message_user(
request, message, level=messages.ERROR if stats.errors else messages.SUCCESS
)

@admin.action(description="Clear all milestones")
def clear_milestones(
self, request: HttpRequest, queryset: QuerySet[Incident]
Expand Down
150 changes: 150 additions & 0 deletions src/firetower/incidents/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from django.conf import settings
from django.contrib.auth.models import User
from django.db import transaction
from django.db.models import QuerySet
from django.utils import timezone

from firetower.auth.models import ExternalProfile, ExternalProfileType
Expand All @@ -21,6 +22,10 @@
IncidentStatus,
)
from firetower.integrations.services import LinearService, SlackService
from firetower.integrations.services.linear import (
INCIDENT_ACTION_LABEL_NAME,
LinearRateLimitError,
)

logger = logging.getLogger(__name__)
_slack_service = SlackService()
Expand Down Expand Up @@ -149,6 +154,115 @@ class ActionItemsSyncStats:
skipped: bool = False


@dataclass
class ActionItemLabelBackfillStats:
checked: int = 0
missing: int = 0
added: int = 0
errors: list[str] = field(default_factory=list)


def backfill_action_item_labels(
Comment thread
spalmurray marked this conversation as resolved.
Resolved
incidents: QuerySet[Incident], *, dry_run: bool = True
) -> ActionItemLabelBackfillStats:
"""Label live incident sub-issues without running other sync side effects."""
stats = ActionItemLabelBackfillStats()
if not settings.LINEAR:
stats.errors.append("Linear integration is not configured")
return stats

try:
service = _get_linear_service()
label_id = service.get_issue_label_id(
INCIDENT_ACTION_LABEL_NAME, raise_on_error=True
)
except LinearRateLimitError as error:
stats.errors.insert(0, f"{error} Backfill stopped; rerun this selection later.")
return stats
except Exception:
logger.exception("Failed to fetch Linear incident action label")
stats.errors.append("Failed to fetch Linear incident action label")
return stats
if not label_id:
stats.errors.append(
f"Linear workspace label {INCIDENT_ACTION_LABEL_NAME} is unavailable"
)
return stats

incidents = incidents.exclude(linear_parent_issue_id__isnull=True).exclude(
linear_parent_issue_id=""
)
seen: set[str] = set()
for incident in incidents.iterator():
assert incident.linear_parent_issue_id is not None
try:
children = service.get_child_issues(
incident.linear_parent_issue_id,
include_archived=True,
raise_on_error=True,
)
except LinearRateLimitError as error:
stats.errors.insert(
0, f"{error} Backfill stopped; rerun this selection later."
)
return stats
except Exception:
logger.exception(
"Failed to fetch children for incident %s", incident.incident_number
)
children = None
if children is None:
stats.errors.append(
f"Failed to fetch children for incident {incident.incident_number}"
)
continue

for issue in children:
if issue["id"] in seen:
continue
seen.add(issue["id"])
stats.checked += 1
if label_id in issue.get("label_ids", []):
continue
stats.missing += 1
logger.info(
"%s %s to %s",
"Would add" if dry_run else "Adding",
INCIDENT_ACTION_LABEL_NAME,
issue["identifier"],
)
if dry_run:
continue
try:
success = service.add_issue_label(
issue["id"], label_id, raise_on_error=True
)
except LinearRateLimitError as error:
stats.errors.insert(
0, f"{error} Backfill stopped; rerun this selection later."
)
return stats
except Exception:
logger.exception("Failed to label action item %s", issue["identifier"])
success = False
if success:
stats.added += 1
else:
stats.errors.append(
f"Failed to label action item {issue['identifier']}"
)

logger.info(
"%s: %d checked, %d missing labels, %d added, %d failures",
"Dry run" if dry_run else "Backfill",
stats.checked,
stats.missing,
stats.added,
len(stats.errors),
)
return stats


def _get_action_item_slo_days(priority: int) -> int | None:
linear = settings.LINEAR or {}
if priority in (1, 2):
Expand Down Expand Up @@ -186,6 +300,38 @@ def _set_action_item_sla(linear_service: LinearService, issue: dict) -> None:
logger.error("Failed to set Linear SLA for action item %s", issue["identifier"])


def _set_action_item_labels(
linear_service: LinearService, issues: list[dict]
) -> list[str]:
if not issues:
return []

try:
label_id = linear_service.get_issue_label_id(INCIDENT_ACTION_LABEL_NAME)
except Exception:
logger.exception("Failed to fetch Linear incident action label")
return ["Failed to fetch Linear incident action label"]
if not label_id:
error = f"Linear workspace label {INCIDENT_ACTION_LABEL_NAME} is unavailable"
logger.warning(error)
return [error]

errors = []
for issue in issues:
if label_id in issue.get("label_ids", []):
continue
try:
success = linear_service.add_issue_label(issue["id"], label_id)
except Exception:
logger.exception("Failed to label action item %s", issue["identifier"])
success = False
if not success:
error = f"Failed to label action item {issue['identifier']}"
logger.warning(error)
errors.append(error)
return errors


def _resolve_assignees(
issues: dict[str, dict],
) -> dict[str, User | None]:
Expand Down Expand Up @@ -390,6 +536,10 @@ def sync_action_items_from_linear(
incident.action_items_last_synced_at = timezone.now()
incident.save(update_fields=["action_items_last_synced_at"])

stats.errors.extend(
_set_action_item_labels(linear_service, list(all_issues.values()))
)

if incident.severity in (IncidentSeverity.P0, IncidentSeverity.P1):
for issue in all_issues.values():
_set_action_item_sla(linear_service, issue)
Expand Down
Loading
Loading