Skip to content

feat: Inline tag creation in /inc lifecycle modals#244

Draft
spalmurray wants to merge 5 commits into
mainfrom
spalmurray/RELENG-768
Draft

feat: Inline tag creation in /inc lifecycle modals#244
spalmurray wants to merge 5 commits into
mainfrom
spalmurray/RELENG-768

Conversation

@spalmurray
Copy link
Copy Markdown
Contributor

Adds inline affected-service/region tag creation from the Slack lifecycle modals. The tag autocomplete offers a "+ Create " option (service/region only; impact_type stays curated) that get_or_creates the tag with the right type on submission, dedupes case-insensitively, handles uniqueness races, and degrades gracefully when the DB is unreachable. Closes RELENG-768.

@linear-code
Copy link
Copy Markdown

linear-code Bot commented Jun 5, 2026

RELENG-768

Comment on lines +214 to +229
affected_service_tags = _resolve_tag_values(
[opt["value"] for opt in affected_service_selections],
TagType.AFFECTED_SERVICE,
resolve_tags=resolve_tags,
)

affected_region_selections = (
values.get("affected_region_block", {})
.get("affected_region_tags", {})
.get("selected_options")
or []
)
affected_region_tags = [opt["value"] for opt in affected_region_selections]
affected_region_tags = _resolve_tag_values(
[opt["value"] for opt in affected_region_selections],
TagType.AFFECTED_REGION,
resolve_tags=resolve_tags,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline tag creation runs before form validation, persisting orphaned tags on rejected submissions

Because _resolve_tag_values (called at this line) issues Tag.objects.create() inside parse_incident_form_values, tags are written to the DB before validate_lifecycle_form is checked in resolved.py and mitigated.py — so if the submission is rejected (e.g. missing captain), newly created tags persist in the DB without being associated with any incident. Consider passing resolve_tags=False for the initial parse, validating, then resolving tags only on a clean form.

Evidence
  • resolved.py:50 calls parse_incident_form_values(view) (default resolve_tags=True), which triggers _resolve_tag_values at utils.py:214–229 and may issue Tag.objects.create() for any __create__:-prefixed values.
  • validate_lifecycle_form(form) is called at resolved.py:53, after the tag writes; if it returns errors the handler calls ack(response_action="errors", ...) and returns, leaving created tags in the DB with no incident association.
  • mitigated.py:52–55 has the identical call ordering.
  • update_incident.py:235 calls parse_incident_form_values then performs its own title check at line 244, with the same pre-write pattern.
  • test_missing_captain_returns_error in test_resolved.py confirms the rejection path exists but does not assert that no tags were written, so this case is untested.

Identified by Warden code-review · W2L-P8N

resolved = _resolve_tag_values(
["__create__: ", "__create__:payments"],
TagType.AFFECTED_SERVICE,
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test_recovers_existing_tag_when_create_races never exercises the race-condition recovery path

The test pre-creates "Payments" in the DB before calling _resolve_tag_values(["__create__:payments"], ...). Because name__iexact="payments" matches the pre-created "Payments", the initial Tag.objects.filter(...).first() returns the tag and name = existing.name is set, so the else branch containing Tag.objects.create is never reached. The patched IntegrityError side-effect is therefore dead code, and the assertion resolved == ["Payments"] passes via the pre-creation read path, not the race-recovery path. The intended scenario (first filter returns None, create raises IntegrityError, second filter recovers the concurrently-inserted tag) has no effective coverage.

Evidence
  • _resolve_tag_values (utils.py:47) calls existing = Tag.objects.filter(type=tag_type, name__iexact=name).first() before the try/create block.
  • The test (test_new_incident.py:749) pre-creates Tag(name="Payments"), so for input __create__:payments the name__iexact filter matches immediately and name = existing.name is set (utils.py:49), skipping the else branch entirely.
  • The patch on Tag.objects.create (test_new_incident.py:751-754) is consequently never invoked, so the patched IntegrityError and the second recovery filter are never executed.
  • The success branch of the recovery (existing found after IntegrityError, utils.py:62-67) thus remains untested; to exercise it the tag must be absent at first read and only inserted concurrently before create.
Also found at 1 additional location
  • src/firetower/slack_app/handlers/utils.py:57

Identified by Warden code-review · NZW-FF6

Comment on lines 303 to +304
try:
form = parse_incident_form_values(view)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tag creation runs before ack() in resolved and mitigated submission handlers

This PR adds DB writes to parse_incident_form_values via _resolve_tag_values, but handle_resolved_submission and handle_mitigated_submission still call parse_incident_form_values(view) before ack() — the same pre-ack pattern explicitly fixed here in new_incident.py. An OperationalError during tag creation (or any DB call exceeding Slack's 3-second window) will prevent ack() from ever being called, causing Slack to show a timeout error. Additionally, tags are written to the database even when validate_lifecycle_form subsequently finds errors and returns them to the user via ack(response_action="errors"), leaving orphaned approved=False tags on every failed submission.

Evidence
  • resolved.py:50: form = parse_incident_form_values(view) is called before ack() at line 58; same pattern in mitigated.py:54 before its ack() at line 62.
  • utils.py _resolve_tag_values (added in this PR) calls Tag.objects.filter(...).first() and Tag.objects.create(...) when values carry the __create__: prefix, making parse_incident_form_values a DB-writing call for the first time.
  • new_incident.py hunk (lines 303–304) demonstrates the correct fix: parse_incident_form_values(view) is placed inside the try block after ack(), so a DB failure is caught and handled rather than silently dropping the ack.
  • validate_lifecycle_form in resolved.py is invoked after parse_incident_form_values, meaning tags are already written before form errors are returned to the user; no rollback occurs.
Also found at 2 additional locations
  • src/firetower/slack_app/tests/handlers/test_new_incident.py:19
  • src/firetower/slack_app/handlers/utils.py:46-62

Identified by Warden find-bugs · LBG-LVU

Comment on lines +214 to +228
affected_service_tags = _resolve_tag_values(
[opt["value"] for opt in affected_service_selections],
TagType.AFFECTED_SERVICE,
resolve_tags=resolve_tags,
)

affected_region_selections = (
values.get("affected_region_block", {})
.get("affected_region_tags", {})
.get("selected_options")
or []
)
affected_region_tags = [opt["value"] for opt in affected_region_selections]
affected_region_tags = _resolve_tag_values(
[opt["value"] for opt in affected_region_selections],
TagType.AFFECTED_REGION,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concurrent inline tag creation with different letter cases bypasses uniqueness guard

When two concurrent requests each create the same tag name with different casing (e.g. foo vs FOO), both can pass Tag.clean()'s case-insensitive check before either commits, and the DB-level unique_together = [("name", "type")] is case-sensitive so it won't fire — leaving two duplicate case-variant tags in the database. Use a UniqueConstraint with Upper("name") (or a CITextField) to make the uniqueness guarantee atomic at the DB level.

Evidence
  • Tag.unique_together = [("name", "type")] is a case-sensitive DB constraint (models.py:110).
  • Case-insensitive uniqueness is enforced only inside Tag.clean(), which runs app-side before the INSERT (models.py:116-127).
  • _resolve_tag_values pre-checks with filter(name__iexact=name).first(), but if two threads both get None and both pass clean() before either commits, both INSERTs succeed because 'foo''FOO' for the DB constraint.
  • The except (ValidationError, IntegrityError) recovery block at lines 58-63 of utils.py handles ValidationError (same-case duplicate caught by clean()) and IntegrityError (exact-case DB collision), but neither fires for different-case concurrent inserts.
  • Result: Tag table accumulates both foo and FOO with the same type, breaking the case-insensitive deduplication assumption the rest of the code relies on.

Identified by Warden find-bugs · Q3L-9LV

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant