fix(ci): correct issue and PR triage automation (#50052)

## Summary

- skip AI issue triage when the latest issue state is closed and recheck
state before publishing
- restrict duplicate searches and digest canonical candidates to live
open issues
- add deterministic, additive `Product-*` labels from changed paths for
draft and non-draft PRs
- map Window Hopper, Cursor Wrap, and all Mouse Jump project paths to
their specific product labels before generic fallbacks
- preserve manually applied product labels while managing only intake
lifecycle labels and trusted comments
- collect the PowerToys version and release channel in one required
bug/localization field
- add Window Hopper to the bug report area dropdown and preserve parsing
of both old and combined version headings

This consolidates and supersedes #49961 and #49804.

## Validation

- `node --test .github\scripts\pr-intake\tests\pr-intake.test.mjs` - 39
passing
- `python -m unittest discover .github\scripts\issue-triage\tests -v` -
55 passing
- parsed all modified issue-form and workflow YAML files with PyYAML
- verified every current `src/modules/*` root maps to a product label
- verified the Window Hopper path maps to `Product-Window Hopper`
- `git diff --check`

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: b313a270-e6e8-4c4a-89b2-880c1d79027c
Copilot-Session: 498721d4-1098-4298-ac8a-147066fbfea3
This commit is contained in:
Niels Laute
2026-08-26 21:13:16 +02:00
committed by GitHub
parent 885459a2f4
commit e5a19c4ac5
14 changed files with 608 additions and 71 deletions

View File

@@ -17,9 +17,9 @@ body:
- id: version
type: input
attributes:
label: Microsoft PowerToys version
placeholder: X.XX.X
description: Hover over the system tray icon or look at Settings
label: Microsoft PowerToys version and release channel
placeholder: 0.100.2 - Stable or 0.101.2211.0 - Preview (Insider)
description: Find the version and update channel in PowerToys Settings > General.
validations:
required: true
@@ -80,6 +80,7 @@ body:
- Shortcut Guide
- System tray interaction
- TextExtractor
- Window Hopper
- Workspaces
- Welcome / PowerToys Tour window
- ZoomIt

View File

@@ -10,11 +10,12 @@ body:
- type: markdown
attributes:
value: Please make sure to [search for existing issues](https://github.com/microsoft/PowerToys/issues) before filing a new one!
- type: input
- id: version
type: input
attributes:
label: Microsoft PowerToys version
placeholder: 0.70.0
description: Hover over the system tray icon or look at Settings
label: Microsoft PowerToys version and release channel
placeholder: 0.100.2 - Stable or 0.101.2211.0 - Preview (Insider)
description: Find the version and update channel in PowerToys Settings > General.
validations:
required: true
- type: dropdown

View File

@@ -26,6 +26,9 @@ combines deterministic preprocessing with one bounded GitHub Copilot pass.
accept or decline it; acceptance closes the issue as a duplicate and links
it to the selected canonical issue.
- Never close an issue directly from the model output.
- Skip closed issues before agent execution and recheck their state immediately
before publishing. Never reopen a closure that cannot be attributed to the
workflow's duplicate-suggestion request.
## Reproduction and diagnostics
@@ -78,8 +81,8 @@ that retain `Needs-Author-Feedback` for seven days without activity.
- The GitHub Models-based automatic issue deduplicator is removed.
- The GitHub Models-based issue/PR area labeler is removed. This workflow
replaces issue labeling only; automatic PR product labeling is intentionally
not replaced here.
replaces issue labeling; deterministic changed-path product labeling for pull
requests is handled by `.github/workflows/pr-intake.yml`.
- The Azure Pipelines XAML Styler verification step is removed. The local
`.pipelines/applyXamlStyling.ps1` developer tool remains available.

View File

@@ -31,6 +31,7 @@ TECHNICAL_PATTERN = re.compile(
VERSION_PATTERN = re.compile(
r"\b(?:v)?(\d+(?:\.\d+){1,3}(?:-[A-Za-z0-9.-]+)?)\b"
)
VERSION_CHANNEL_HEADING = "Microsoft PowerToys version and release channel"
BUG_HEADINGS = (
"Microsoft PowerToys version",
"Installation method",
@@ -248,6 +249,8 @@ def reproduction_quality(body):
def parse_version(body):
section = extract_section(body, VERSION_CHANNEL_HEADING)
if not section:
section = extract_section(body, "Microsoft PowerToys version")
match = VERSION_PATTERN.search(section)
return match.group(1) if match else "Not provided"
@@ -332,7 +335,10 @@ def language_signal(title, body):
prose = re.sub(r"https?://\S+", " ", prose)
prose = re.sub(
r"^###\s+(?:[^\w\r\n]+\s*)?(?:"
+ "|".join(re.escape(heading) for heading in BUG_HEADINGS)
+ "|".join(
re.escape(heading)
for heading in (VERSION_CHANNEL_HEADING, *BUG_HEADINGS)
)
+ r")\s*$",
" ",
prose,
@@ -771,6 +777,14 @@ def prepare_with_evidence(event, api, force_evidence=False):
issue = api.get_issue(issue["number"])
event = dict(event)
event["issue"] = issue
if str(issue.get("state") or "").lower() == "closed":
write_noop("Closed issues are not triaged")
return (
"# Deterministic issue evidence\n\nAgent execution was skipped.\n",
event,
False,
None,
)
comments = api.list_comments(issue["number"])
author = issue.get("user", {}).get("login")
report_comment = latest_author_report_comment(comments, author)
@@ -847,7 +861,7 @@ def main():
output_file.write(payload)
if not payload.endswith("\n"):
output_file.write("\n")
if evidence_path:
if evidence_path and should_process_event:
if evidence is None:
raise ValueError("Deterministic evidence was not generated")
os.makedirs(os.path.dirname(os.path.abspath(evidence_path)), exist_ok=True)

View File

@@ -1,4 +1,6 @@
import importlib.util
import json
import tempfile
import unittest
from pathlib import Path
from unittest import mock
@@ -87,6 +89,16 @@ class IssueContextTests(unittest.TestCase):
self.assertEqual(CONTEXT.parse_version(BUG_BODY), "0.100.2")
self.assertEqual(CONTEXT.reproduction_quality(BUG_BODY), "SUFFICIENT")
def test_parses_combined_version_and_release_channel(self):
body = BUG_BODY.replace(
"### Microsoft PowerToys version\n\n0.100.2",
"### Microsoft PowerToys version and release channel\n\n"
"0.101.2211.0 - Preview (Insider)",
)
self.assertTrue(CONTEXT.is_bug_template(body))
self.assertEqual(CONTEXT.parse_version(body), "0.101.2211.0")
def test_version_status_distinguishes_outdated_current_and_preview(self):
self.assertEqual(
CONTEXT.version_status("0.99.1", "0.100.2"),
@@ -532,6 +544,31 @@ class IssueContextTests(unittest.TestCase):
self.assertFalse(should_process)
self.assertEqual(api.queries, [])
def test_closed_issue_writes_noop_without_api_reads(self):
event = {
"action": "edited",
"issue": {
"number": 10,
"state": "closed",
"title": "Keyboard Manager exits",
"body": BUG_BODY,
"user": {"login": "alice"},
"labels": [],
},
}
api = FakeApi()
with (
mock.patch.object(CONTEXT, "write_noop") as noop,
mock.patch.object(
api,
"list_comments",
side_effect=AssertionError("Closed issues must not read comments"),
),
):
_, _, should_process = CONTEXT.prepare(event, api)
noop.assert_called_once_with("Closed issues are not triaged")
self.assertFalse(should_process)
def test_prepare_emits_bounded_ranked_candidates(self):
issue = {
"number": 10,
@@ -593,6 +630,94 @@ class IssueContextTests(unittest.TestCase):
["Product-Screen Ruler"],
)
def test_force_evidence_skips_issue_closed_after_trigger(self):
stale_issue = {
"number": 10,
"state": "open",
"title": "Keyboard Manager exits",
"body": BUG_BODY,
"user": {"login": "alice"},
"labels": [],
}
current_issue = {**stale_issue, "state": "closed"}
api = FakeApi(current_issue=current_issue)
with (
mock.patch.object(CONTEXT, "write_noop") as noop,
mock.patch.object(
api,
"list_comments",
side_effect=AssertionError("Closed issues must not read comments"),
),
):
_, normalized, should_process, evidence = CONTEXT.prepare_with_evidence(
{"action": "opened", "issue": stale_issue},
api,
force_evidence=True,
)
noop.assert_called_once_with("Closed issues are not triaged")
self.assertEqual(normalized["issue"]["state"], "closed")
self.assertFalse(should_process)
self.assertIsNone(evidence)
def test_main_cleanly_skips_closed_issue_during_evidence_refresh(self):
stale_issue = {
"number": 10,
"state": "open",
"title": "Keyboard Manager exits",
"body": BUG_BODY,
"user": {"login": "alice"},
"labels": [],
}
current_issue = {**stale_issue, "state": "closed"}
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
event_path = root / "event.json"
context_path = root / "context.md"
normalized_event_path = root / "normalized-event.json"
evidence_path = root / "evidence.json"
output_path = root / "github-output.txt"
event_path.write_text(
json.dumps({"action": "opened", "issue": stale_issue}),
encoding="utf-8",
)
with (
mock.patch.object(
CONTEXT,
"GitHubApi",
return_value=FakeApi(current_issue=current_issue),
),
mock.patch.object(
CONTEXT.sys,
"argv",
[
"issue-context.py",
str(event_path),
str(context_path),
str(normalized_event_path),
str(evidence_path),
],
),
mock.patch.dict(
CONTEXT.os.environ,
{
"GITHUB_OUTPUT": str(output_path),
"ISSUE_TRIAGE_FORCE_EVIDENCE": "true",
},
clear=True,
),
):
self.assertEqual(CONTEXT.main(), 0)
self.assertFalse(evidence_path.exists())
self.assertEqual(
output_path.read_text(encoding="utf-8"),
"should_process=false\n",
)
def test_candidate_retrieval_only_returns_older_issues(self):
issue = {
"number": 10,

View File

@@ -52,6 +52,22 @@ class WorkflowContractTests(unittest.TestCase):
self.assertNotIn("desiredVersionLabel", workflow)
self.assertRegex(source, r"never adds or removes\s+version labels")
def test_closed_issues_are_gated_before_publication(self):
source = WORKFLOW_SOURCE.read_text(encoding="utf-8")
generated = WORKFLOW_LOCK.read_text(encoding="utf-8")
self.assertIn("id: refresh", source)
self.assertEqual(
source.count("if: steps.refresh.outputs.should_process == 'true'"),
3,
)
for workflow in (source, generated):
self.assertIn("Issue is closed; ${action} was skipped.", workflow)
self.assertIn(
"currentIssue.closed_by?.login === 'github-actions[bot]'",
workflow,
)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,20 +1,30 @@
# Pull request intake
The workflow in `.github/workflows/pr-intake.yml` runs deterministic pull
request intake checks on non-draft pull requests. It does not call any AI model
and does not execute any code from the pull request head; the Node script reads
all pull request data through the GitHub API.
The workflow in `.github/workflows/pr-intake.yml` applies deterministic product
labels to all pull requests and runs intake checks on non-draft pull requests.
It does not call any AI model and does not execute any code from the pull
request head; the Node script reads all pull request data through the GitHub
API.
## Flow
1. Read the current PR through the GitHub API. Mergeability is re-fetched a few
1. Read the current PR and changed files through the GitHub API.
2. Add recognized `Product-*` labels from the historical path mapping. The
longest path prefix wins, generic Settings/General labels are suppressed
when a more specific product matches, and existing product labels are never
removed.
3. Re-fetch mergeability a few
times when GitHub still reports it as unknown so a conflicting PR is never
treated as ready by default.
2. Deterministically validate closing issue references, merge conflicts, and
4. Deterministically validate closing issue references, merge conflicts, and
whether visual evidence is present.
3. Require visual evidence only when the changed paths touch product UI files.
4. Keep a single canonical comment in sync and manage only the
`Ready for review` and `Needs-Author-Feedback` labels.
5. Require visual evidence only when the changed paths touch product UI files.
6. Keep a single canonical comment in sync and manage the `Ready for review`
and `Needs-Author-Feedback` lifecycle labels.
Product labeling is additive. Intake never removes an existing product label,
and a pull request that touches multiple mapped roots receives each matching
label. Draft pull requests receive product labels but skip readiness checks.
## Comment behavior
@@ -26,8 +36,9 @@ all pull request data through the GitHub API.
Missing issue references are advisory. Explicitly invalid references, merge
conflicts, unknown mergeability, and missing required visual evidence block
readiness. Draft PR events skip normal intake; conversion to draft runs only
lifecycle-label cleanup, and marking a draft ready triggers full intake.
readiness. Draft PR events run deterministic path labeling, remove intake
lifecycle labels, delete trusted canonical intake comments, and then stop.
Marking a draft ready triggers full intake.
The existing resource-management policy closes PRs that retain
`Needs-Author-Feedback` for seven inactive days. PR synchronization is handled

View File

@@ -44,6 +44,53 @@ const VISUAL_PRODUCT_PREFIXES = [
'src/settings-ui/',
];
export const PRODUCT_PATH_LABEL_MAP = [
['src/modules/MouseUtils/MousePointerCrosshairs/', 'Product-Mouse Pointer Crosshairs'],
['src/modules/MouseUtils/MouseHighlighter/', 'Product-Mouse Highlighter'],
['src/modules/MouseUtils/FindMyMouse/', 'Product-Find My Mouse'],
['src/modules/MouseUtils/CursorWrap/', 'Product-Cursor Wrap'],
['src/modules/MouseUtils/MouseJump', 'Product-Mouse Jump'],
['src/modules/AltWindowCycle/', 'Product-Window Hopper'],
['src/modules/MouseUtils/', 'Product-Mouse Utilities'],
['src/modules/AdvancedPaste/', 'Product-Advanced Paste'],
['src/modules/alwaysontop/', 'Product-Always On Top'],
['src/modules/awake/', 'Product-Awake'],
['src/modules/cmdNotFound/', 'Product-CommandNotFound'],
['src/modules/cmdpal/', 'Product-Command Palette'],
['src/modules/colorPicker/', 'Product-Color Picker'],
['src/modules/CropAndLock/', 'Product-CropAndLock'],
['src/modules/EnvironmentVariables/', 'Product-Environment Variables'],
['src/modules/fancyzones/', 'Product-FancyZones'],
['src/modules/FileLocksmith/', 'Product-File Locksmith'],
['src/modules/GrabAndMove/', 'Product-Grab And Move'],
['src/modules/Hosts/', 'Product-Hosts File Editor'],
['src/modules/imageresizer/', 'Product-Image Resizer'],
['src/modules/interface/', 'Product-General'],
['src/modules/keyboardmanager/', 'Product-Keyboard Manager'],
['src/modules/launcher/', 'Product-PowerToys Run'],
['src/modules/LightSwitch/', 'Product-LightSwitch'],
['src/modules/MeasureTool/', 'Product-Screen Ruler'],
['src/modules/MouseWithoutBorders/', 'Product-Mouse Without Borders'],
['src/modules/NewPlus/', 'Product-New+'],
['src/modules/peek/', 'Product-Peek'],
['src/modules/poweraccent/', 'Product-Quick Accent'],
['src/modules/powerdisplay/', 'Product-PowerDisplay'],
['src/modules/PowerOCR/', 'Product-Text Extractor'],
['src/modules/powerrename/', 'Product-PowerRename'],
['src/modules/previewpane/', 'Product-File Explorer'],
['src/modules/registrypreview/', 'Product-Registry Preview'],
['src/modules/ShortcutGuide/', 'Product-Shortcut Guide'],
['src/modules/shortcut_guide/', 'Product-Shortcut Guide'],
['src/modules/Workspaces/', 'Product-Workspaces'],
['src/modules/ZoomIt/', 'Product-ZoomIt'],
['src/runner/', 'Product-General'],
['src/common/', 'Product-General'],
['src/settings-ui/', 'Product-Settings'],
];
const SORTED_PRODUCT_PATH_LABEL_MAP = [...PRODUCT_PATH_LABEL_MAP]
.sort((left, right) => right[0].length - left[0].length);
export class ApiError extends Error {
constructor(message, status, details = '') {
super(message);
@@ -129,6 +176,41 @@ export function normalizePath(value) {
.trim();
}
export function deriveProductLabelsFromPaths(paths, currentLabels = []) {
if (!Array.isArray(paths)) {
throw new Error('Changed paths must be an array');
}
const labels = new Set();
for (const rawPath of paths) {
const changedPath = normalizePath(rawPath).toLowerCase();
if (!changedPath) {
continue;
}
const match = SORTED_PRODUCT_PATH_LABEL_MAP.find(
([prefix]) => changedPath.startsWith(prefix.toLowerCase()),
);
if (match) {
labels.add(match[1]);
}
}
const existingProductLabels = (Array.isArray(currentLabels) ? currentLabels : [])
.map((entry) => typeof entry === 'string' ? entry : entry?.name)
.filter((label) => typeof label === 'string' && label.startsWith('Product-'));
const matchedProductLabels = [...existingProductLabels, ...labels];
if (matchedProductLabels.some((label) => label !== 'Product-Settings')) {
labels.delete('Product-Settings');
}
if (matchedProductLabels.some(
(label) => !['Product-General', 'Product-Settings'].includes(label),
)) {
labels.delete('Product-General');
}
return uniqueSorted([...labels]);
}
function isTestPath(changedPath) {
return /(^|\/)(test|tests|unittests|uitests)(\/|$)/i.test(changedPath)
|| /\.(?:spec|test)\.[^.]+$/i.test(changedPath);
@@ -649,6 +731,24 @@ export async function upsertCanonicalComment({
};
}
export async function deleteCanonicalComments({
api,
issueNumber,
comments = null,
}) {
const existingComments = Array.isArray(comments)
? comments
: await listAllComments(api, issueNumber);
const { canonical, extras } = selectCanonicalComment(existingComments);
const trustedComments = canonical ? [canonical, ...extras] : [];
const deletedCommentIds = [];
for (const comment of trustedComments) {
await api.deleteIssueComment(comment.id);
deletedCommentIds.push(comment.id);
}
return deletedCommentIds;
}
export function planManagedLabelChanges(
currentLabels,
desiredLabels,
@@ -677,7 +777,7 @@ export function planManagedLabelChanges(
};
}
async function syncManagedLabels(api, issueNumber, labelPlan) {
async function syncLabelChanges(api, issueNumber, labelPlan) {
for (const label of labelPlan.remove) {
await api.removeLabel(issueNumber, label);
}
@@ -735,26 +835,35 @@ export async function runPullRequestIntake({ api, event }) {
const issueNumber = pullNumber;
const issue = await api.getIssue(issueNumber);
const currentLabels = parseIssueLabels(issue);
const fileDetails = await listAllPullRequestFileDetails(api, issueNumber);
const changedPaths = changedPathsFromFileDetails(fileDetails);
const pathProductLabels = deriveProductLabelsFromPaths(changedPaths, currentLabels);
if (pullRequest.draft === true) {
const labelPlan = planManagedLabelChanges(
parseIssueLabels(issue),
[],
currentLabels,
pathProductLabels,
[
READY_FOR_REVIEW_LABEL,
NEEDS_AUTHOR_FEEDBACK_LABEL,
LEGACY_READY_FOR_REVIEW_LABEL,
],
);
await syncManagedLabels(api, issueNumber, labelPlan);
await syncLabelChanges(api, issueNumber, labelPlan);
const deletedCanonicalCommentIds = await deleteCanonicalComments({
api,
issueNumber,
});
return {
issueNumber,
skippedDraft: true,
changedPathCount: changedPaths.length,
pathProductLabels,
labelPlan,
deletedCanonicalCommentIds,
};
}
const fileDetails = await listAllPullRequestFileDetails(api, issueNumber);
const changedPaths = changedPathsFromFileDetails(fileDetails);
const closingReferenceVerification = await verifyClosingIssueReferences({
api,
repositoryFullName: event.repository.full_name,
@@ -781,13 +890,14 @@ export async function runPullRequestIntake({ api, event }) {
senderLogin: event.sender?.login,
authorLogin: pullRequest.user?.login,
});
const desiredManagedLabels = [
const desiredLabels = [
...(report.readyForReview ? [READY_FOR_REVIEW_LABEL] : []),
...(report.needsAuthorFeedback ? [NEEDS_AUTHOR_FEEDBACK_LABEL] : []),
...pathProductLabels,
];
const labelPlan = planManagedLabelChanges(
parseIssueLabels(issue),
desiredManagedLabels,
currentLabels,
desiredLabels,
[
READY_FOR_REVIEW_LABEL,
NEEDS_AUTHOR_FEEDBACK_LABEL,
@@ -795,7 +905,7 @@ export async function runPullRequestIntake({ api, event }) {
],
);
await syncManagedLabels(api, issueNumber, labelPlan);
await syncLabelChanges(api, issueNumber, labelPlan);
// Only surface a comment when there is something for the author to act on or
// consider. When a previously flagged PR becomes clean we replace the stale
@@ -827,6 +937,7 @@ export async function runPullRequestIntake({ api, event }) {
return {
issueNumber,
changedPathCount: changedPaths.length,
pathProductLabels,
labelPlan,
commentResult: {
operation: commentResult.operation,

View File

@@ -11,6 +11,8 @@ import {
ApiError,
buildIntakeReport,
classifyChangedPaths,
deleteCanonicalComments,
deriveProductLabelsFromPaths,
deriveVisualAssessment,
determineFeedbackSince,
findClosingIssueReferences,
@@ -40,17 +42,18 @@ function botComment(id, body) {
};
}
test('workflow skips normal draft intake and handles draft transitions', () => {
test('workflow runs product labeling for drafts and handles draft transitions', () => {
const workflow = fs.readFileSync(
new URL('../../../workflows/pr-intake.yml', import.meta.url),
'utf8',
);
assert.equal(READY_FOR_REVIEW_LABEL, 'Ready for review');
assert.match(
workflow,
/if: \$\{\{ github\.event\.pull_request\.draft == false \|\| github\.event\.action == 'converted_to_draft' \}\}/,
);
assert.doesNotMatch(workflow, /github\.event\.pull_request\.draft == false/);
assert.match(workflow, /- opened/);
assert.match(workflow, /- edited/);
assert.match(workflow, /- synchronize/);
assert.match(workflow, /ref: \$\{\{ github\.event\.repository\.default_branch \}\}/);
assert.match(workflow, /- ready_for_review/);
assert.match(workflow, /- converted_to_draft/);
});
@@ -160,6 +163,80 @@ test('docs-only changes do not require visual evidence', () => {
assert.deepEqual(report.categories, ['docs']);
});
test('path product labels use longest prefixes and suppress Settings side effects', () => {
assert.deepEqual(
deriveProductLabelsFromPaths([
'src/modules/MouseUtils/MouseJump/MouseJump.Common/Helpers.cs',
'src/settings-ui/Settings.UI/SettingsXAML/Views/MouseJumpPage.xaml',
]),
['Product-Mouse Jump'],
);
assert.deepEqual(
deriveProductLabelsFromPaths(['src/modules/cmdpal/src/App/App.xaml']),
['Product-Command Palette'],
);
assert.deepEqual(
deriveProductLabelsFromPaths(['src/modules/AltWindowCycle/AltWindowCycle.cpp']),
['Product-Window Hopper'],
);
assert.deepEqual(
deriveProductLabelsFromPaths(
['src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml'],
['Product-FancyZones'],
),
[],
);
});
test('path product labels cover module and shared roots additively', () => {
assert.deepEqual(deriveProductLabelsFromPaths([
'src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs',
'src/modules/cmdpal/ext/WindowWalker/ListPage.cs',
'src/settings-ui/Settings.UI/ViewModels/AdvancedPasteViewModel.cs',
'src/runner/main.cpp',
'src/common/utils/helpers.cpp',
'src/modules/UnknownModule/main.cpp',
]), [
'Product-Advanced Paste',
'Product-Command Palette',
]);
assert.deepEqual(
deriveProductLabelsFromPaths(['src/common/utils/helpers.cpp']),
['Product-General'],
);
assert.deepEqual(
deriveProductLabelsFromPaths(
['src/runner/main.cpp'],
['Product-FancyZones'],
),
[],
);
});
test('mouse utility paths map to specific products before the umbrella label', () => {
assert.deepEqual(deriveProductLabelsFromPaths([
'src/modules/MouseUtils/CursorWrap/CursorWrap.cpp',
'src/modules/MouseUtils/MouseJump.Common/Helpers.cs',
'src/modules/MouseUtils/MouseUtils.UITests/TestHelpers.cs',
]), [
'Product-Cursor Wrap',
'Product-Mouse Jump',
'Product-Mouse Utilities',
]);
});
test('every current module root has a deterministic product mapping', () => {
const modulesUrl = new URL('../../../../src/modules/', import.meta.url);
const moduleRoots = fs.readdirSync(modulesUrl, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name);
const unmappedRoots = moduleRoots.filter(
(root) => deriveProductLabelsFromPaths([`src/modules/${root}/placeholder.cpp`]).length === 0,
);
assert.deepEqual(unmappedRoots, []);
});
test('deriveVisualAssessment maps the path hint to a requirement', () => {
assert.deepEqual(deriveVisualAssessment(true).visualEvidenceRequirement, 'REQUIRED');
assert.deepEqual(deriveVisualAssessment(false).visualEvidenceRequirement, 'NOT_NEEDED');
@@ -601,6 +678,24 @@ test('canonical upsert updates the oldest trusted comment and deletes extras', a
assert.equal(api.comments.length, 1);
});
test('canonical deletion removes only trusted intake comments', async () => {
const body = `${CANONICAL_MARKER}\nfirst`;
const untrusted = {
id: 10,
body,
user: { login: 'attacker', id: 1, type: 'User' },
};
const api = new MockApi({
comments: [untrusted, botComment(20, body), botComment(40, body)],
});
const deleted = await deleteCanonicalComments({ api, issueNumber: 12 });
assert.deepEqual(deleted, [20, 40]);
assert.deepEqual(api.deleted, [20, 40]);
assert.deepEqual(api.comments, [untrusted]);
});
test('all-clear comment carries the canonical marker', () => {
const body = renderAllClearComment();
assert.match(body, /^<!-- powertoys-pr-intake:canonical:v1 -->/);
@@ -643,11 +738,23 @@ test('runPullRequestIntake stays silent on a clean PR with no prior comment', as
assert.deepEqual(result.labelPlan.add, [READY_FOR_REVIEW_LABEL]);
});
test('converted draft removes lifecycle labels without running intake', async () => {
test('draft intake adds path labels, removes lifecycle labels, and deletes canonical comments', async () => {
const canonical = botComment(50, `${CANONICAL_MARKER}\n## PR intake`);
const untrusted = {
id: 51,
body: `${CANONICAL_MARKER}\nspoof`,
user: { login: 'attacker', id: 1, type: 'User' },
};
const api = new MockApi({
comments: [canonical, untrusted],
issues: [{
number: 100,
labels: [READY_FOR_REVIEW_LABEL, NEEDS_AUTHOR_FEEDBACK_LABEL, 'Product-FancyZones'],
labels: [
READY_FOR_REVIEW_LABEL,
NEEDS_AUTHOR_FEEDBACK_LABEL,
'Product-FancyZones',
'Area-Setup/Install',
],
title: 'Draft PR',
}],
pullRequests: [{
@@ -659,6 +766,7 @@ test('converted draft removes lifecycle labels without running intake', async ()
base: { ref: 'main' },
user: { login: 'alice' },
}],
files: [{ filename: 'src/modules/cmdpal/src/App/AppHost.cs', status: 'modified' }],
});
const result = await runPullRequestIntake({
@@ -668,14 +776,64 @@ test('converted draft removes lifecycle labels without running intake', async ()
assert.equal(result.skippedDraft, true);
assert.deepEqual(result.labelPlan, {
add: [],
add: ['Product-Command Palette'],
remove: [NEEDS_AUTHOR_FEEDBACK_LABEL, READY_FOR_REVIEW_LABEL],
});
assert.equal(api.listPullRequestFilesCalls, 0);
assert.deepEqual(result.pathProductLabels, ['Product-Command Palette']);
assert.equal(api.listPullRequestFilesCalls, 1);
assert.deepEqual(api.addedLabels, [{
issueNumber: 100,
labels: ['Product-Command Palette'],
}]);
assert.deepEqual(api.deleted, [50]);
assert.deepEqual(result.deletedCanonicalCommentIds, [50]);
assert.deepEqual(api.comments, [untrusted]);
assert.equal(api.created, 0);
assert.equal(api.updated, 0);
});
test('non-draft intake adds deterministic product labels with lifecycle labels', async () => {
const api = new MockApi({
issues: [{
number: 100,
labels: ['Product-FancyZones'],
title: 'Advanced Paste change',
}],
pullRequests: [{
number: 100,
draft: false,
mergeable: true,
mergeable_state: 'clean',
body: 'Closes #12',
base: { ref: 'main' },
user: { login: 'alice' },
}],
files: [
{
filename: 'src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs',
status: 'modified',
},
{
filename: 'src/settings-ui/Settings.UI/ViewModels/AdvancedPasteViewModel.cs',
status: 'modified',
},
],
});
api.issues.push({ number: 12, title: 'Tracked issue' });
const result = await runPullRequestIntake({ api, event: intakeEvent() });
assert.deepEqual(result.pathProductLabels, ['Product-Advanced Paste']);
assert.deepEqual(result.labelPlan, {
add: ['Product-Advanced Paste', READY_FOR_REVIEW_LABEL],
remove: [],
});
assert.deepEqual(api.addedLabels, [{
issueNumber: 100,
labels: ['Product-Advanced Paste', READY_FOR_REVIEW_LABEL],
}]);
});
test('runPullRequestIntake replaces a stale comment with an all-clear note when the PR is clean', async () => {
const existing = botComment(50, `${CANONICAL_MARKER}\n## 🧭 PR intake\nplease update`);
const api = new MockApi({

View File

@@ -130,6 +130,31 @@ jobs:
.sort((a, b) => b.id - a.id)[0] || null;
};
const liveCandidateCache = new Map();
const resolveOpenIssueCandidates = async (candidates) => {
const openCandidates = [];
for (const candidate of candidates) {
let live = liveCandidateCache.get(candidate.number);
if (live === undefined) {
try {
live = (await github.rest.issues.get({
owner, repo, issue_number: candidate.number
})).data;
} catch (e) {
if (e.status !== 404) throw e;
live = null;
}
liveCandidateCache.set(candidate.number, live);
}
if (!live || live.pull_request || live.state !== 'open') continue;
openCandidates.push({
...candidate,
title: sanitize(live.title, 160)
});
}
return openCandidates;
};
// --- 1. Ensure the digest label exists -------------------------------
try {
await github.rest.issues.getLabel({ owner, repo, name: LABEL });
@@ -202,7 +227,9 @@ jobs:
const triageComment = await findTriageComment(number);
if (!triageComment) continue;
const candidates = parseDuplicateCandidates(triageComment.body);
const candidates = await resolveOpenIssueCandidates(
parseDuplicateCandidates(triageComment.body)
);
if (!candidates.length) continue;
flagged.push({

View File

@@ -1,4 +1,4 @@
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"37d2de768f6656597223c037f94e7b29071085a39ace175b8ac6d42b41e74eda","body_hash":"6888a8616b1e836e084e3cd296ca09ccf40e28cfe6b7d9b09ec734319ea2faba","compiler_version":"v0.86.2","strict":true,"agent_id":"copilot","agent_model":"small","engine_versions":{"copilot":"1.0.79"}}
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"29e76e9c357b6b875470f7efc39814c20dad1a04f3fa0522fdac7879ee0fe426","body_hash":"6888a8616b1e836e084e3cd296ca09ccf40e28cfe6b7d9b09ec734319ea2faba","compiler_version":"v0.86.2","strict":true,"agent_id":"copilot","agent_model":"small","engine_versions":{"copilot":"1.0.79"}}
# gh-aw-manifest: {"version":1,"secrets":["GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/setup-python","sha":"5fda3b95a4ea91299a34e894583c3862153e4b97","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"6aab9e5b5c91c615506061f09bedd81a23babe3c","version":"v0.86.2"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44","digest":"sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44","digest":"sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44","digest":"sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.9","digest":"sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196","pinned_image":"ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196"}]}
# This file was automatically generated by gh-aw (v0.86.2). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md
#
@@ -1611,20 +1611,24 @@ jobs:
with:
python-version: "3.12"
- name: Rebuild current deterministic evidence
id: refresh
run: python .github/scripts/issue-triage/issue-context.py "$GITHUB_EVENT_PATH" "$RUNNER_TEMP/verified-issue-context.md" "$RUNNER_TEMP/verified-triage-event.json" "$RUNNER_TEMP/verified-evidence.json"
env:
GH_AW_AGENT_OUTPUT: ${{ runner.temp }}/gh-aw/safe-jobs/agent_output.json
GITHUB_TOKEN: ${{ github.token }}
ISSUE_TRIAGE_FORCE_EVIDENCE: "true"
- name: Rebuild sanitized bug report context
if: steps.refresh.outputs.should_process == 'true'
run: python .github/scripts/issue-triage/bug-report-analyzer.py "$RUNNER_TEMP/verified-triage-event.json" "$RUNNER_TEMP/verified-bug-report-context.md"
env:
GH_AW_AGENT_OUTPUT: ${{ runner.temp }}/gh-aw/safe-jobs/agent_output.json
- name: Verify agent output against current evidence
if: steps.refresh.outputs.should_process == 'true'
run: python .github/scripts/issue-triage/verify-agent-output.py "$GH_AW_AGENT_OUTPUT" "$RUNNER_TEMP/verified-evidence.json" "$RUNNER_TEMP/verified-bug-report-context.md" "$RUNNER_TEMP/verified-triage-output.json"
env:
GH_AW_AGENT_OUTPUT: ${{ runner.temp }}/gh-aw/safe-jobs/agent_output.json
- name: Upsert canonical triage summary
if: steps.refresh.outputs.should_process == 'true'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
GH_AW_AGENT_OUTPUT: ${{ runner.temp }}/gh-aw/safe-jobs/agent_output.json
@@ -1835,6 +1839,19 @@ jobs:
}
const issueNumber = context.issue.number;
const getCurrentIssue = async () => {
const response = await github.rest.issues.get({
...context.repo,
issue_number: issueNumber
});
return response.data;
};
const skipWriteIfClosed = async action => {
const currentIssue = await getCurrentIssue();
if (currentIssue.state === 'open') return false;
core.notice(`Issue is closed; ${action} was skipped.`);
return true;
};
const allowedDuplicateNumbers = new Set(
verified.requested_duplicate_numbers
);
@@ -2024,6 +2041,7 @@ jobs:
);
const body = bodyLines.join('\n');
if (await skipWriteIfClosed('triage publication')) return;
const comments = await github.paginate(github.rest.issues.listComments, {
...context.repo,
issue_number: issueNumber,
@@ -2037,6 +2055,7 @@ jobs:
)
.sort((left, right) => left.id - right.id)[0];
if (await skipWriteIfClosed('triage publication')) return;
let comment;
if (canonical) {
comment = await github.rest.issues.updateComment({
@@ -2059,6 +2078,7 @@ jobs:
);
}
if (await skipWriteIfClosed('triage label updates')) return;
const needsAuthorFeedback =
needsEnglishTranslation || hasMissingInformation;
const currentLabels = new Set(
@@ -2090,7 +2110,9 @@ jobs:
});
}
if (verifiedDuplicates.length) {
if (await skipWriteIfClosed('the duplicate suggestion')) return;
const strongest = verifiedDuplicates[0];
const suggestionStartedAt = Math.floor(Date.now() / 1000);
const response = await github.request(
'PATCH /repos/{owner}/{repo}/issues/{issue_number}',
{
@@ -2113,6 +2135,14 @@ jobs:
);
if (response.data?.state === 'closed') {
const currentIssue = await getCurrentIssue();
const closedAt = Date.parse(currentIssue.closed_at);
const closedBySuggestion =
currentIssue.state === 'closed' &&
currentIssue.closed_by?.login === 'github-actions[bot]' &&
Number.isFinite(closedAt) &&
Math.floor(closedAt / 1000) >= suggestionStartedAt;
if (closedBySuggestion) {
await github.rest.issues.update({
...context.repo,
issue_number: issueNumber,
@@ -2121,6 +2151,11 @@ jobs:
core.setFailed(
'GitHub applied the close instead of holding it for review; the issue was reopened'
);
} else {
core.setFailed(
'GitHub returned the issue as closed after the duplicate suggestion, but the workflow did not reopen it because the closure was not caused by this request'
);
}
}
}

View File

@@ -171,6 +171,7 @@ safe-outputs:
with:
python-version: "3.12"
- name: Rebuild current deterministic evidence
id: refresh
env:
GITHUB_TOKEN: ${{ github.token }}
ISSUE_TRIAGE_FORCE_EVIDENCE: "true"
@@ -181,11 +182,13 @@ safe-outputs:
"$RUNNER_TEMP/verified-triage-event.json"
"$RUNNER_TEMP/verified-evidence.json"
- name: Rebuild sanitized bug report context
if: steps.refresh.outputs.should_process == 'true'
run: >-
python .github/scripts/issue-triage/bug-report-analyzer.py
"$RUNNER_TEMP/verified-triage-event.json"
"$RUNNER_TEMP/verified-bug-report-context.md"
- name: Verify agent output against current evidence
if: steps.refresh.outputs.should_process == 'true'
run: >-
python .github/scripts/issue-triage/verify-agent-output.py
"$GH_AW_AGENT_OUTPUT"
@@ -193,6 +196,7 @@ safe-outputs:
"$RUNNER_TEMP/verified-bug-report-context.md"
"$RUNNER_TEMP/verified-triage-output.json"
- name: Upsert canonical triage summary
if: steps.refresh.outputs.should_process == 'true'
uses: actions/github-script@v9.0.0
env:
ISSUE_TRIAGE_VERIFIED_OUTPUT: ${{ runner.temp }}/verified-triage-output.json
@@ -402,6 +406,19 @@ safe-outputs:
}
const issueNumber = context.issue.number;
const getCurrentIssue = async () => {
const response = await github.rest.issues.get({
...context.repo,
issue_number: issueNumber
});
return response.data;
};
const skipWriteIfClosed = async action => {
const currentIssue = await getCurrentIssue();
if (currentIssue.state === 'open') return false;
core.notice(`Issue is closed; ${action} was skipped.`);
return true;
};
const allowedDuplicateNumbers = new Set(
verified.requested_duplicate_numbers
);
@@ -591,6 +608,7 @@ safe-outputs:
);
const body = bodyLines.join('\n');
if (await skipWriteIfClosed('triage publication')) return;
const comments = await github.paginate(github.rest.issues.listComments, {
...context.repo,
issue_number: issueNumber,
@@ -604,6 +622,7 @@ safe-outputs:
)
.sort((left, right) => left.id - right.id)[0];
if (await skipWriteIfClosed('triage publication')) return;
let comment;
if (canonical) {
comment = await github.rest.issues.updateComment({
@@ -626,6 +645,7 @@ safe-outputs:
);
}
if (await skipWriteIfClosed('triage label updates')) return;
const needsAuthorFeedback =
needsEnglishTranslation || hasMissingInformation;
const currentLabels = new Set(
@@ -657,7 +677,9 @@ safe-outputs:
});
}
if (verifiedDuplicates.length) {
if (await skipWriteIfClosed('the duplicate suggestion')) return;
const strongest = verifiedDuplicates[0];
const suggestionStartedAt = Math.floor(Date.now() / 1000);
const response = await github.request(
'PATCH /repos/{owner}/{repo}/issues/{issue_number}',
{
@@ -680,6 +702,14 @@ safe-outputs:
);
if (response.data?.state === 'closed') {
const currentIssue = await getCurrentIssue();
const closedAt = Date.parse(currentIssue.closed_at);
const closedBySuggestion =
currentIssue.state === 'closed' &&
currentIssue.closed_by?.login === 'github-actions[bot]' &&
Number.isFinite(closedAt) &&
Math.floor(closedAt / 1000) >= suggestionStartedAt;
if (closedBySuggestion) {
await github.rest.issues.update({
...context.repo,
issue_number: issueNumber,
@@ -688,6 +718,11 @@ safe-outputs:
core.setFailed(
'GitHub applied the close instead of holding it for review; the issue was reopened'
);
} else {
core.setFailed(
'GitHub returned the issue as closed after the duplicate suggestion, but the workflow did not reopen it because the closure was not caused by this request'
);
}
}
}
---

View File

@@ -35,4 +35,4 @@ jobs:
github_token: ${{ secrets.GITHUB_TOKEN }}
github_issue: ${{ matrix.issue }}
label_as_duplicate: ${{ github.event.inputs.label_as_duplicate }}
state: open

View File

@@ -1,14 +1,15 @@
# PR intake
#
# Deterministic pull request intake. Runs on every pull request and posts a
# single canonical comment (only when there is something for the author to act
# on or consider), keeps the Ready for review / Needs-Author-Feedback lifecycle
# labels in sync, and nudges for visual evidence on product UI changes.
# Deterministic pull request intake. Applies additive Product-* labels from
# changed path roots, posts a single canonical comment when there is something
# for the author to act on or consider, keeps the Ready for review /
# Needs-Author-Feedback lifecycle labels in sync, and nudges for visual evidence
# on product UI changes.
#
# This workflow does not run any code from the pull request head. The Node
# script reads all pull request data through the GitHub API, so checking out
# the base ref is only needed to run the script itself. 3rd-party actions are
# pinned to a commit hash per Microsoft's security guidelines.
# script reads all pull request data through the GitHub API, so the trusted
# default branch is checked out only to run the script itself. 3rd-party
# actions are pinned to a commit hash per Microsoft's security guidelines.
name: PR intake
on:
@@ -32,13 +33,12 @@ concurrency:
jobs:
pr-intake:
if: ${{ github.event.pull_request.draft == false || github.event.action == 'converted_to_draft' }}
runs-on: ubuntu-latest
steps:
- name: Checkout base ref
- name: Checkout trusted workflow source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.base.sha }}
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- name: Set up Node.js