Improve issue triage product-label detection (#49905)

## Summary

Issues that put the module in a `[Module]` title prefix (a common
PowerToys convention) but omit the bug template's **"Area(s) with
issue?"** section were left **Unclassified** with no `Product-*` label —
e.g. #49899 *"[Screen Ruler] Settings crashes ..."* got no
`Product-Screen Ruler` label despite the title.

Root cause: product-label detection was purely deterministic and narrow.
`parse_area` (`.github/scripts/issue-triage/issue-context.py`) only read
the template area section or a 6-entry keyword map, and the agent prompt
instructed the model to copy that candidate verbatim (and send `None`
otherwise). The `[Module]` title convention was never consulted.

## Change (two layers)

**1. Deterministic title-prefix matching (primary).** Parse the leading
`[Module]` bracket(s) in the title and match against existing
`Product-*` labels; upgrade the detected area when the body has no area
signal. Fully deterministic and auditable — this alone fixes Screen
Ruler and every other bracketed title.

**2. Constrained AI fallback (secondary).** Expose the repo's
`Product-*` labels as `Available product labels` in the deterministic
evidence, and allow the agent — **only when the deterministic candidate
is `None`** — to select the single best-matching existing label. This is
safe because the publisher already validates the agent's `product_label`
against the real label set, so the agent can only ever **add a valid
existing label**, never invent one or remove/change others.

The workflow prompt is `{{#runtime-import}}`-ed from `issue-triage.md`,
so the lock file changes only by its `body_hash` (sync check);
recompiled with the repo's current gh-aw `v0.84.3` to avoid unrelated
version drift.

## Tests

New unit tests in `tests/test_issue_context.py`:
- `test_title_prefix_maps_to_existing_product_label`
- `test_available_product_labels_are_sorted_and_filtered`
- `test_prepare_labels_bracketed_title_without_area_section`

All 30 tests pass (`python -m unittest tests.test_issue_context`).

## Files
- `.github/scripts/issue-triage/issue-context.py` — title-prefix
detection, available-label list, wiring
- `.github/scripts/issue-triage/tests/test_issue_context.py` — new tests
- `.github/workflows/issue-triage.md` — prompt allows constrained
fallback
- `.github/workflows/issue-triage.lock.yml` — recompiled (`body_hash`
only)

Generated with the GitHub Copilot CLI.

Co-authored-by: niels9001 <niels9001@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 26025067-259e-43e3-9dc7-a9fc4b5ba58b
This commit is contained in:
Niels Laute
2026-08-14 23:14:55 +02:00
committed by GitHub
parent 105ef0abcb
commit 8088120b06
8 changed files with 680 additions and 224 deletions

View File

@@ -45,9 +45,16 @@ _No response_
class FakeApi:
repository = "owner/repo"
def __init__(self, results=None, comments=None, latest_release=None):
def __init__(
self,
results=None,
comments=None,
latest_release=None,
current_issue=None,
):
self.results = results or []
self.comments = comments or []
self.current_issue = current_issue
self.latest_release = latest_release or {
"tag_name": "v0.100.2",
"prerelease": False,
@@ -57,6 +64,9 @@ class FakeApi:
def list_comments(self, _issue_number):
return self.comments
def get_issue(self, _issue_number):
return self.current_issue
def list_labels(self):
return [{"name": "Product-Keyboard Manager"}]
@@ -272,6 +282,130 @@ class IssueContextTests(unittest.TestCase):
"Product-General",
)
def test_title_prefix_maps_to_existing_product_label(self):
labels = [{"name": "Product-Screen Ruler"}, {"name": "Product-FancyZones"}]
self.assertEqual(
CONTEXT.title_bracket_segments("[Screen Ruler] Settings crash"),
["Screen Ruler"],
)
self.assertEqual(
CONTEXT.title_bracket_segments("[Screen Ruler][Settings] crash"),
["Screen Ruler", "Settings"],
)
self.assertEqual(CONTEXT.title_bracket_segments("No brackets here"), [])
self.assertEqual(
CONTEXT.title_product_label("[Screen Ruler] Settings crash", labels),
"Product-Screen Ruler",
)
self.assertEqual(
CONTEXT.title_product_label("[Bug] something broke", labels),
"None",
)
def test_available_product_labels_are_sorted_and_filtered(self):
labels = [
{"name": "Product-Screen Ruler"},
{"name": "Needs-Triage"},
{"name": "Product-Awake"},
]
self.assertEqual(
CONTEXT.available_product_labels(labels),
["Product-Awake", "Product-Screen Ruler"],
)
def test_allowed_product_labels_require_explicit_issue_text(self):
labels = [
{"name": "Product-Screen Ruler"},
{"name": "Product-FancyZones"},
{"name": "Product-General"},
]
self.assertEqual(
CONTEXT.allowed_product_labels(
"Ruler settings problem",
"The Screen Ruler overlay is misplaced.",
labels,
"None",
),
["Product-Screen Ruler"],
)
def test_prepare_labels_bracketed_title_without_area_section(self):
issue = {
"number": 42,
"title": "[Screen Ruler] Settings crashes on legacy units value",
"body": (
"## Description\n\nSettings crashes when leaving the Screen "
"Ruler page with a legacy measurement-unit value."
),
"user": {"login": "alice"},
"labels": [],
}
class ScreenRulerApi(FakeApi):
def list_labels(self):
return [{"name": "Product-Screen Ruler"}]
context, _, should_process = CONTEXT.prepare(
{"action": "opened", "issue": issue},
ScreenRulerApi(),
)
self.assertTrue(should_process)
self.assertIn("Detected area: Screen Ruler", context)
self.assertIn("Candidate product label: Product-Screen Ruler", context)
self.assertIn(
"Allowed product label candidates: Product-Screen Ruler",
context,
)
def test_title_prefix_overrides_inferred_area_without_template_selection(self):
issue = {
"number": 42,
"title": "[Screen Ruler] FancyZones-style overlay is misplaced",
"body": "The FancyZones overlay comparison shows the ruler is misplaced.",
"user": {"login": "alice"},
"labels": [],
}
class ProductApi(FakeApi):
def list_labels(self):
return [
{"name": "Product-FancyZones"},
{"name": "Product-Screen Ruler"},
]
context, _, _ = CONTEXT.prepare(
{"action": "opened", "issue": issue},
ProductApi(),
)
self.assertIn("Detected area: Screen Ruler", context)
self.assertIn("Candidate product label: Product-Screen Ruler", context)
def test_explicit_template_area_takes_precedence_over_title_prefix(self):
issue = {
"number": 42,
"title": "[Screen Ruler] FancyZones editor issue",
"body": (
"### Area(s) with issue?\n\nFancyZones\n\n"
"### Description\n\nThe FancyZones editor is misplaced."
),
"user": {"login": "alice"},
"labels": [],
}
class ProductApi(FakeApi):
def list_labels(self):
return [
{"name": "Product-FancyZones"},
{"name": "Product-Screen Ruler"},
]
context, _, _ = CONTEXT.prepare(
{"action": "opened", "issue": issue},
ProductApi(),
)
self.assertIn("Detected area: FancyZones", context)
self.assertIn("Candidate product label: Product-FancyZones", context)
def test_context_redacts_report_attachment_urls(self):
context = CONTEXT.render_context(
{
@@ -413,6 +547,38 @@ class IssueContextTests(unittest.TestCase):
self.assertEqual(normalized["issue"]["number"], 10)
self.assertTrue(should_process)
def test_force_evidence_uses_current_issue_and_emits_allowlists(self):
stale_issue = {
"number": 10,
"title": "Old title",
"body": "",
"user": {"login": "alice"},
"labels": [],
}
current_issue = {
"number": 10,
"title": "Screen Ruler overlay problem",
"body": "The Screen Ruler overlay is misplaced.",
"user": {"login": "alice"},
"labels": [],
}
class VerificationApi(FakeApi):
def list_labels(self):
return [{"name": "Product-Screen Ruler"}]
_, normalized, should_process, evidence = CONTEXT.prepare_with_evidence(
{"action": "edited", "issue": stale_issue},
VerificationApi(current_issue=current_issue),
force_evidence=True,
)
self.assertTrue(should_process)
self.assertEqual(normalized["issue"]["title"], current_issue["title"])
self.assertEqual(
evidence["allowed_product_labels"],
["Product-Screen Ruler"],
)
def test_candidate_retrieval_only_returns_older_issues(self):
issue = {
"number": 10,