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

View File

@@ -10,11 +10,12 @@ body:
- type: markdown - type: markdown
attributes: attributes:
value: Please make sure to [search for existing issues](https://github.com/microsoft/PowerToys/issues) before filing a new one! 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: attributes:
label: Microsoft PowerToys version label: Microsoft PowerToys version and release channel
placeholder: 0.70.0 placeholder: 0.100.2 - Stable or 0.101.2211.0 - Preview (Insider)
description: Hover over the system tray icon or look at Settings description: Find the version and update channel in PowerToys Settings > General.
validations: validations:
required: true required: true
- type: dropdown - 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 accept or decline it; acceptance closes the issue as a duplicate and links
it to the selected canonical issue. it to the selected canonical issue.
- Never close an issue directly from the model output. - 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 ## 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 automatic issue deduplicator is removed.
- The GitHub Models-based issue/PR area labeler is removed. This workflow - The GitHub Models-based issue/PR area labeler is removed. This workflow
replaces issue labeling only; automatic PR product labeling is intentionally replaces issue labeling; deterministic changed-path product labeling for pull
not replaced here. requests is handled by `.github/workflows/pr-intake.yml`.
- The Azure Pipelines XAML Styler verification step is removed. The local - The Azure Pipelines XAML Styler verification step is removed. The local
`.pipelines/applyXamlStyling.ps1` developer tool remains available. `.pipelines/applyXamlStyling.ps1` developer tool remains available.

View File

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

View File

@@ -1,4 +1,6 @@
import importlib.util import importlib.util
import json
import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest import mock 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.parse_version(BUG_BODY), "0.100.2")
self.assertEqual(CONTEXT.reproduction_quality(BUG_BODY), "SUFFICIENT") 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): def test_version_status_distinguishes_outdated_current_and_preview(self):
self.assertEqual( self.assertEqual(
CONTEXT.version_status("0.99.1", "0.100.2"), CONTEXT.version_status("0.99.1", "0.100.2"),
@@ -532,6 +544,31 @@ class IssueContextTests(unittest.TestCase):
self.assertFalse(should_process) self.assertFalse(should_process)
self.assertEqual(api.queries, []) 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): def test_prepare_emits_bounded_ranked_candidates(self):
issue = { issue = {
"number": 10, "number": 10,
@@ -593,6 +630,94 @@ class IssueContextTests(unittest.TestCase):
["Product-Screen Ruler"], ["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): def test_candidate_retrieval_only_returns_older_issues(self):
issue = { issue = {
"number": 10, "number": 10,

View File

@@ -52,6 +52,22 @@ class WorkflowContractTests(unittest.TestCase):
self.assertNotIn("desiredVersionLabel", workflow) self.assertNotIn("desiredVersionLabel", workflow)
self.assertRegex(source, r"never adds or removes\s+version labels") 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__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -1,20 +1,30 @@
# Pull request intake # Pull request intake
The workflow in `.github/workflows/pr-intake.yml` runs deterministic pull The workflow in `.github/workflows/pr-intake.yml` applies deterministic product
request intake checks on non-draft pull requests. It does not call any AI model labels to all pull requests and runs intake checks on non-draft pull requests.
and does not execute any code from the pull request head; the Node script reads It does not call any AI model and does not execute any code from the pull
all pull request data through the GitHub API. request head; the Node script reads all pull request data through the GitHub
API.
## Flow ## 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 times when GitHub still reports it as unknown so a conflicting PR is never
treated as ready by default. 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. whether visual evidence is present.
3. Require visual evidence only when the changed paths touch product UI files. 5. Require visual evidence only when the changed paths touch product UI files.
4. Keep a single canonical comment in sync and manage only the 6. Keep a single canonical comment in sync and manage the `Ready for review`
`Ready for review` and `Needs-Author-Feedback` labels. 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 ## Comment behavior
@@ -26,8 +36,9 @@ all pull request data through the GitHub API.
Missing issue references are advisory. Explicitly invalid references, merge Missing issue references are advisory. Explicitly invalid references, merge
conflicts, unknown mergeability, and missing required visual evidence block conflicts, unknown mergeability, and missing required visual evidence block
readiness. Draft PR events skip normal intake; conversion to draft runs only readiness. Draft PR events run deterministic path labeling, remove intake
lifecycle-label cleanup, and marking a draft ready triggers full 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 The existing resource-management policy closes PRs that retain
`Needs-Author-Feedback` for seven inactive days. PR synchronization is handled `Needs-Author-Feedback` for seven inactive days. PR synchronization is handled

View File

@@ -44,6 +44,53 @@ const VISUAL_PRODUCT_PREFIXES = [
'src/settings-ui/', '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 { export class ApiError extends Error {
constructor(message, status, details = '') { constructor(message, status, details = '') {
super(message); super(message);
@@ -129,6 +176,41 @@ export function normalizePath(value) {
.trim(); .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) { function isTestPath(changedPath) {
return /(^|\/)(test|tests|unittests|uitests)(\/|$)/i.test(changedPath) return /(^|\/)(test|tests|unittests|uitests)(\/|$)/i.test(changedPath)
|| /\.(?:spec|test)\.[^.]+$/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( export function planManagedLabelChanges(
currentLabels, currentLabels,
desiredLabels, 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) { for (const label of labelPlan.remove) {
await api.removeLabel(issueNumber, label); await api.removeLabel(issueNumber, label);
} }
@@ -735,26 +835,35 @@ export async function runPullRequestIntake({ api, event }) {
const issueNumber = pullNumber; const issueNumber = pullNumber;
const issue = await api.getIssue(issueNumber); 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) { if (pullRequest.draft === true) {
const labelPlan = planManagedLabelChanges( const labelPlan = planManagedLabelChanges(
parseIssueLabels(issue), currentLabels,
[], pathProductLabels,
[ [
READY_FOR_REVIEW_LABEL, READY_FOR_REVIEW_LABEL,
NEEDS_AUTHOR_FEEDBACK_LABEL, NEEDS_AUTHOR_FEEDBACK_LABEL,
LEGACY_READY_FOR_REVIEW_LABEL, LEGACY_READY_FOR_REVIEW_LABEL,
], ],
); );
await syncManagedLabels(api, issueNumber, labelPlan); await syncLabelChanges(api, issueNumber, labelPlan);
const deletedCanonicalCommentIds = await deleteCanonicalComments({
api,
issueNumber,
});
return { return {
issueNumber, issueNumber,
skippedDraft: true, skippedDraft: true,
changedPathCount: changedPaths.length,
pathProductLabels,
labelPlan, labelPlan,
deletedCanonicalCommentIds,
}; };
} }
const fileDetails = await listAllPullRequestFileDetails(api, issueNumber);
const changedPaths = changedPathsFromFileDetails(fileDetails);
const closingReferenceVerification = await verifyClosingIssueReferences({ const closingReferenceVerification = await verifyClosingIssueReferences({
api, api,
repositoryFullName: event.repository.full_name, repositoryFullName: event.repository.full_name,
@@ -781,13 +890,14 @@ export async function runPullRequestIntake({ api, event }) {
senderLogin: event.sender?.login, senderLogin: event.sender?.login,
authorLogin: pullRequest.user?.login, authorLogin: pullRequest.user?.login,
}); });
const desiredManagedLabels = [ const desiredLabels = [
...(report.readyForReview ? [READY_FOR_REVIEW_LABEL] : []), ...(report.readyForReview ? [READY_FOR_REVIEW_LABEL] : []),
...(report.needsAuthorFeedback ? [NEEDS_AUTHOR_FEEDBACK_LABEL] : []), ...(report.needsAuthorFeedback ? [NEEDS_AUTHOR_FEEDBACK_LABEL] : []),
...pathProductLabels,
]; ];
const labelPlan = planManagedLabelChanges( const labelPlan = planManagedLabelChanges(
parseIssueLabels(issue), currentLabels,
desiredManagedLabels, desiredLabels,
[ [
READY_FOR_REVIEW_LABEL, READY_FOR_REVIEW_LABEL,
NEEDS_AUTHOR_FEEDBACK_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 // 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 // consider. When a previously flagged PR becomes clean we replace the stale
@@ -827,6 +937,7 @@ export async function runPullRequestIntake({ api, event }) {
return { return {
issueNumber, issueNumber,
changedPathCount: changedPaths.length, changedPathCount: changedPaths.length,
pathProductLabels,
labelPlan, labelPlan,
commentResult: { commentResult: {
operation: commentResult.operation, operation: commentResult.operation,

View File

@@ -11,6 +11,8 @@ import {
ApiError, ApiError,
buildIntakeReport, buildIntakeReport,
classifyChangedPaths, classifyChangedPaths,
deleteCanonicalComments,
deriveProductLabelsFromPaths,
deriveVisualAssessment, deriveVisualAssessment,
determineFeedbackSince, determineFeedbackSince,
findClosingIssueReferences, 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( const workflow = fs.readFileSync(
new URL('../../../workflows/pr-intake.yml', import.meta.url), new URL('../../../workflows/pr-intake.yml', import.meta.url),
'utf8', 'utf8',
); );
assert.equal(READY_FOR_REVIEW_LABEL, 'Ready for review'); assert.equal(READY_FOR_REVIEW_LABEL, 'Ready for review');
assert.match( assert.doesNotMatch(workflow, /github\.event\.pull_request\.draft == false/);
workflow, assert.match(workflow, /- opened/);
/if: \$\{\{ github\.event\.pull_request\.draft == false \|\| github\.event\.action == 'converted_to_draft' \}\}/, 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, /- ready_for_review/);
assert.match(workflow, /- converted_to_draft/); assert.match(workflow, /- converted_to_draft/);
}); });
@@ -160,6 +163,80 @@ test('docs-only changes do not require visual evidence', () => {
assert.deepEqual(report.categories, ['docs']); 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', () => { test('deriveVisualAssessment maps the path hint to a requirement', () => {
assert.deepEqual(deriveVisualAssessment(true).visualEvidenceRequirement, 'REQUIRED'); assert.deepEqual(deriveVisualAssessment(true).visualEvidenceRequirement, 'REQUIRED');
assert.deepEqual(deriveVisualAssessment(false).visualEvidenceRequirement, 'NOT_NEEDED'); 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); 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', () => { test('all-clear comment carries the canonical marker', () => {
const body = renderAllClearComment(); const body = renderAllClearComment();
assert.match(body, /^<!-- powertoys-pr-intake:canonical:v1 -->/); 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]); 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({ const api = new MockApi({
comments: [canonical, untrusted],
issues: [{ issues: [{
number: 100, 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', title: 'Draft PR',
}], }],
pullRequests: [{ pullRequests: [{
@@ -659,6 +766,7 @@ test('converted draft removes lifecycle labels without running intake', async ()
base: { ref: 'main' }, base: { ref: 'main' },
user: { login: 'alice' }, user: { login: 'alice' },
}], }],
files: [{ filename: 'src/modules/cmdpal/src/App/AppHost.cs', status: 'modified' }],
}); });
const result = await runPullRequestIntake({ const result = await runPullRequestIntake({
@@ -668,14 +776,64 @@ test('converted draft removes lifecycle labels without running intake', async ()
assert.equal(result.skippedDraft, true); assert.equal(result.skippedDraft, true);
assert.deepEqual(result.labelPlan, { assert.deepEqual(result.labelPlan, {
add: [], add: ['Product-Command Palette'],
remove: [NEEDS_AUTHOR_FEEDBACK_LABEL, READY_FOR_REVIEW_LABEL], 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.created, 0);
assert.equal(api.updated, 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 () => { 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 existing = botComment(50, `${CANONICAL_MARKER}\n## 🧭 PR intake\nplease update`);
const api = new MockApi({ const api = new MockApi({

View File

@@ -130,6 +130,31 @@ jobs:
.sort((a, b) => b.id - a.id)[0] || null; .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 ------------------------------- // --- 1. Ensure the digest label exists -------------------------------
try { try {
await github.rest.issues.getLabel({ owner, repo, name: LABEL }); await github.rest.issues.getLabel({ owner, repo, name: LABEL });
@@ -202,7 +227,9 @@ jobs:
const triageComment = await findTriageComment(number); const triageComment = await findTriageComment(number);
if (!triageComment) continue; if (!triageComment) continue;
const candidates = parseDuplicateCandidates(triageComment.body); const candidates = await resolveOpenIssueCandidates(
parseDuplicateCandidates(triageComment.body)
);
if (!candidates.length) continue; if (!candidates.length) continue;
flagged.push({ 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"}]} # 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 # 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: with:
python-version: "3.12" python-version: "3.12"
- name: Rebuild current deterministic evidence - 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" 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: env:
GH_AW_AGENT_OUTPUT: ${{ runner.temp }}/gh-aw/safe-jobs/agent_output.json GH_AW_AGENT_OUTPUT: ${{ runner.temp }}/gh-aw/safe-jobs/agent_output.json
GITHUB_TOKEN: ${{ github.token }} GITHUB_TOKEN: ${{ github.token }}
ISSUE_TRIAGE_FORCE_EVIDENCE: "true" ISSUE_TRIAGE_FORCE_EVIDENCE: "true"
- name: Rebuild sanitized bug report context - 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" run: python .github/scripts/issue-triage/bug-report-analyzer.py "$RUNNER_TEMP/verified-triage-event.json" "$RUNNER_TEMP/verified-bug-report-context.md"
env: env:
GH_AW_AGENT_OUTPUT: ${{ runner.temp }}/gh-aw/safe-jobs/agent_output.json GH_AW_AGENT_OUTPUT: ${{ runner.temp }}/gh-aw/safe-jobs/agent_output.json
- name: Verify agent output against current evidence - 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" 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: env:
GH_AW_AGENT_OUTPUT: ${{ runner.temp }}/gh-aw/safe-jobs/agent_output.json GH_AW_AGENT_OUTPUT: ${{ runner.temp }}/gh-aw/safe-jobs/agent_output.json
- name: Upsert canonical triage summary - name: Upsert canonical triage summary
if: steps.refresh.outputs.should_process == 'true'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env: env:
GH_AW_AGENT_OUTPUT: ${{ runner.temp }}/gh-aw/safe-jobs/agent_output.json GH_AW_AGENT_OUTPUT: ${{ runner.temp }}/gh-aw/safe-jobs/agent_output.json
@@ -1835,6 +1839,19 @@ jobs:
} }
const issueNumber = context.issue.number; 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( const allowedDuplicateNumbers = new Set(
verified.requested_duplicate_numbers verified.requested_duplicate_numbers
); );
@@ -2024,6 +2041,7 @@ jobs:
); );
const body = bodyLines.join('\n'); const body = bodyLines.join('\n');
if (await skipWriteIfClosed('triage publication')) return;
const comments = await github.paginate(github.rest.issues.listComments, { const comments = await github.paginate(github.rest.issues.listComments, {
...context.repo, ...context.repo,
issue_number: issueNumber, issue_number: issueNumber,
@@ -2037,6 +2055,7 @@ jobs:
) )
.sort((left, right) => left.id - right.id)[0]; .sort((left, right) => left.id - right.id)[0];
if (await skipWriteIfClosed('triage publication')) return;
let comment; let comment;
if (canonical) { if (canonical) {
comment = await github.rest.issues.updateComment({ comment = await github.rest.issues.updateComment({
@@ -2059,6 +2078,7 @@ jobs:
); );
} }
if (await skipWriteIfClosed('triage label updates')) return;
const needsAuthorFeedback = const needsAuthorFeedback =
needsEnglishTranslation || hasMissingInformation; needsEnglishTranslation || hasMissingInformation;
const currentLabels = new Set( const currentLabels = new Set(
@@ -2090,7 +2110,9 @@ jobs:
}); });
} }
if (verifiedDuplicates.length) { if (verifiedDuplicates.length) {
if (await skipWriteIfClosed('the duplicate suggestion')) return;
const strongest = verifiedDuplicates[0]; const strongest = verifiedDuplicates[0];
const suggestionStartedAt = Math.floor(Date.now() / 1000);
const response = await github.request( const response = await github.request(
'PATCH /repos/{owner}/{repo}/issues/{issue_number}', 'PATCH /repos/{owner}/{repo}/issues/{issue_number}',
{ {
@@ -2113,14 +2135,27 @@ jobs:
); );
if (response.data?.state === 'closed') { if (response.data?.state === 'closed') {
await github.rest.issues.update({ const currentIssue = await getCurrentIssue();
...context.repo, const closedAt = Date.parse(currentIssue.closed_at);
issue_number: issueNumber, const closedBySuggestion =
state: 'open' currentIssue.state === 'closed' &&
}); currentIssue.closed_by?.login === 'github-actions[bot]' &&
core.setFailed( Number.isFinite(closedAt) &&
'GitHub applied the close instead of holding it for review; the issue was reopened' Math.floor(closedAt / 1000) >= suggestionStartedAt;
); if (closedBySuggestion) {
await github.rest.issues.update({
...context.repo,
issue_number: issueNumber,
state: 'open'
});
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: with:
python-version: "3.12" python-version: "3.12"
- name: Rebuild current deterministic evidence - name: Rebuild current deterministic evidence
id: refresh
env: env:
GITHUB_TOKEN: ${{ github.token }} GITHUB_TOKEN: ${{ github.token }}
ISSUE_TRIAGE_FORCE_EVIDENCE: "true" ISSUE_TRIAGE_FORCE_EVIDENCE: "true"
@@ -181,11 +182,13 @@ safe-outputs:
"$RUNNER_TEMP/verified-triage-event.json" "$RUNNER_TEMP/verified-triage-event.json"
"$RUNNER_TEMP/verified-evidence.json" "$RUNNER_TEMP/verified-evidence.json"
- name: Rebuild sanitized bug report context - name: Rebuild sanitized bug report context
if: steps.refresh.outputs.should_process == 'true'
run: >- run: >-
python .github/scripts/issue-triage/bug-report-analyzer.py python .github/scripts/issue-triage/bug-report-analyzer.py
"$RUNNER_TEMP/verified-triage-event.json" "$RUNNER_TEMP/verified-triage-event.json"
"$RUNNER_TEMP/verified-bug-report-context.md" "$RUNNER_TEMP/verified-bug-report-context.md"
- name: Verify agent output against current evidence - name: Verify agent output against current evidence
if: steps.refresh.outputs.should_process == 'true'
run: >- run: >-
python .github/scripts/issue-triage/verify-agent-output.py python .github/scripts/issue-triage/verify-agent-output.py
"$GH_AW_AGENT_OUTPUT" "$GH_AW_AGENT_OUTPUT"
@@ -193,6 +196,7 @@ safe-outputs:
"$RUNNER_TEMP/verified-bug-report-context.md" "$RUNNER_TEMP/verified-bug-report-context.md"
"$RUNNER_TEMP/verified-triage-output.json" "$RUNNER_TEMP/verified-triage-output.json"
- name: Upsert canonical triage summary - name: Upsert canonical triage summary
if: steps.refresh.outputs.should_process == 'true'
uses: actions/github-script@v9.0.0 uses: actions/github-script@v9.0.0
env: env:
ISSUE_TRIAGE_VERIFIED_OUTPUT: ${{ runner.temp }}/verified-triage-output.json ISSUE_TRIAGE_VERIFIED_OUTPUT: ${{ runner.temp }}/verified-triage-output.json
@@ -402,6 +406,19 @@ safe-outputs:
} }
const issueNumber = context.issue.number; 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( const allowedDuplicateNumbers = new Set(
verified.requested_duplicate_numbers verified.requested_duplicate_numbers
); );
@@ -591,6 +608,7 @@ safe-outputs:
); );
const body = bodyLines.join('\n'); const body = bodyLines.join('\n');
if (await skipWriteIfClosed('triage publication')) return;
const comments = await github.paginate(github.rest.issues.listComments, { const comments = await github.paginate(github.rest.issues.listComments, {
...context.repo, ...context.repo,
issue_number: issueNumber, issue_number: issueNumber,
@@ -604,6 +622,7 @@ safe-outputs:
) )
.sort((left, right) => left.id - right.id)[0]; .sort((left, right) => left.id - right.id)[0];
if (await skipWriteIfClosed('triage publication')) return;
let comment; let comment;
if (canonical) { if (canonical) {
comment = await github.rest.issues.updateComment({ comment = await github.rest.issues.updateComment({
@@ -626,6 +645,7 @@ safe-outputs:
); );
} }
if (await skipWriteIfClosed('triage label updates')) return;
const needsAuthorFeedback = const needsAuthorFeedback =
needsEnglishTranslation || hasMissingInformation; needsEnglishTranslation || hasMissingInformation;
const currentLabels = new Set( const currentLabels = new Set(
@@ -657,7 +677,9 @@ safe-outputs:
}); });
} }
if (verifiedDuplicates.length) { if (verifiedDuplicates.length) {
if (await skipWriteIfClosed('the duplicate suggestion')) return;
const strongest = verifiedDuplicates[0]; const strongest = verifiedDuplicates[0];
const suggestionStartedAt = Math.floor(Date.now() / 1000);
const response = await github.request( const response = await github.request(
'PATCH /repos/{owner}/{repo}/issues/{issue_number}', 'PATCH /repos/{owner}/{repo}/issues/{issue_number}',
{ {
@@ -680,14 +702,27 @@ safe-outputs:
); );
if (response.data?.state === 'closed') { if (response.data?.state === 'closed') {
await github.rest.issues.update({ const currentIssue = await getCurrentIssue();
...context.repo, const closedAt = Date.parse(currentIssue.closed_at);
issue_number: issueNumber, const closedBySuggestion =
state: 'open' currentIssue.state === 'closed' &&
}); currentIssue.closed_by?.login === 'github-actions[bot]' &&
core.setFailed( Number.isFinite(closedAt) &&
'GitHub applied the close instead of holding it for review; the issue was reopened' Math.floor(closedAt / 1000) >= suggestionStartedAt;
); if (closedBySuggestion) {
await github.rest.issues.update({
...context.repo,
issue_number: issueNumber,
state: 'open'
});
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_token: ${{ secrets.GITHUB_TOKEN }}
github_issue: ${{ matrix.issue }} github_issue: ${{ matrix.issue }}
label_as_duplicate: ${{ github.event.inputs.label_as_duplicate }} label_as_duplicate: ${{ github.event.inputs.label_as_duplicate }}
state: open

View File

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