mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-08-29 10:09:43 +02:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user