From e5a19c4ac544b18d79da42895e7f5c116aee15cd Mon Sep 17 00:00:00 2001 From: Niels Laute Date: Wed, 26 Aug 2026 21:13:16 +0200 Subject: [PATCH] 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 --- .github/ISSUE_TEMPLATE/bug_report.yml | 7 +- .github/ISSUE_TEMPLATE/translation_issue.yml | 9 +- .github/scripts/issue-triage/README.md | 7 +- .github/scripts/issue-triage/issue-context.py | 20 +- .../issue-triage/tests/test_issue_context.py | 125 +++++++++++++ .../tests/test_workflow_contract.py | 16 ++ .github/scripts/pr-intake/README.md | 33 ++-- .github/scripts/pr-intake/pr-intake.mjs | 131 ++++++++++++- .../pr-intake/tests/pr-intake.test.mjs | 176 +++++++++++++++++- .github/workflows/dedupe-digest.yml | 29 ++- .github/workflows/issue-triage.lock.yml | 53 +++++- .github/workflows/issue-triage.md | 51 ++++- .../manual-batch-issue-deduplication.yml | 2 +- .github/workflows/pr-intake.yml | 20 +- 14 files changed, 608 insertions(+), 71 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 75ba5a3491..7c846233f3 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/translation_issue.yml b/.github/ISSUE_TEMPLATE/translation_issue.yml index 63b998822f..89a004e02f 100644 --- a/.github/ISSUE_TEMPLATE/translation_issue.yml +++ b/.github/ISSUE_TEMPLATE/translation_issue.yml @@ -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 diff --git a/.github/scripts/issue-triage/README.md b/.github/scripts/issue-triage/README.md index 2cfbd96471..d235e60d45 100644 --- a/.github/scripts/issue-triage/README.md +++ b/.github/scripts/issue-triage/README.md @@ -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. diff --git a/.github/scripts/issue-triage/issue-context.py b/.github/scripts/issue-triage/issue-context.py index 7f359c35b4..a399412323 100644 --- a/.github/scripts/issue-triage/issue-context.py +++ b/.github/scripts/issue-triage/issue-context.py @@ -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,7 +249,9 @@ def reproduction_quality(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) 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) diff --git a/.github/scripts/issue-triage/tests/test_issue_context.py b/.github/scripts/issue-triage/tests/test_issue_context.py index b3546296bb..c0c8c0994e 100644 --- a/.github/scripts/issue-triage/tests/test_issue_context.py +++ b/.github/scripts/issue-triage/tests/test_issue_context.py @@ -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, diff --git a/.github/scripts/issue-triage/tests/test_workflow_contract.py b/.github/scripts/issue-triage/tests/test_workflow_contract.py index bf3e9ac401..fd244eb97b 100644 --- a/.github/scripts/issue-triage/tests/test_workflow_contract.py +++ b/.github/scripts/issue-triage/tests/test_workflow_contract.py @@ -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() diff --git a/.github/scripts/pr-intake/README.md b/.github/scripts/pr-intake/README.md index f55fdad0c8..d0e8baeeaf 100644 --- a/.github/scripts/pr-intake/README.md +++ b/.github/scripts/pr-intake/README.md @@ -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 diff --git a/.github/scripts/pr-intake/pr-intake.mjs b/.github/scripts/pr-intake/pr-intake.mjs index d8c8da20d5..b9eeb12583 100644 --- a/.github/scripts/pr-intake/pr-intake.mjs +++ b/.github/scripts/pr-intake/pr-intake.mjs @@ -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, diff --git a/.github/scripts/pr-intake/tests/pr-intake.test.mjs b/.github/scripts/pr-intake/tests/pr-intake.test.mjs index 3e05f0e4e3..15d8273a1e 100644 --- a/.github/scripts/pr-intake/tests/pr-intake.test.mjs +++ b/.github/scripts/pr-intake/tests/pr-intake.test.mjs @@ -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, /^/); @@ -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({ diff --git a/.github/workflows/dedupe-digest.yml b/.github/workflows/dedupe-digest.yml index 4cd28e4bf0..4e85bea9cc 100644 --- a/.github/workflows/dedupe-digest.yml +++ b/.github/workflows/dedupe-digest.yml @@ -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({ diff --git a/.github/workflows/issue-triage.lock.yml b/.github/workflows/issue-triage.lock.yml index e5f7da96b1..dd74b18c37 100644 --- a/.github/workflows/issue-triage.lock.yml +++ b/.github/workflows/issue-triage.lock.yml @@ -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,14 +2135,27 @@ jobs: ); if (response.data?.state === 'closed') { - 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' - ); + 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, + 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' + ); + } } } diff --git a/.github/workflows/issue-triage.md b/.github/workflows/issue-triage.md index 5334e51f92..600d4e3bf2 100644 --- a/.github/workflows/issue-triage.md +++ b/.github/workflows/issue-triage.md @@ -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,14 +702,27 @@ safe-outputs: ); if (response.data?.state === 'closed') { - 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' - ); + 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, + 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' + ); + } } } --- diff --git a/.github/workflows/manual-batch-issue-deduplication.yml b/.github/workflows/manual-batch-issue-deduplication.yml index 003c2d673c..9a53023de5 100644 --- a/.github/workflows/manual-batch-issue-deduplication.yml +++ b/.github/workflows/manual-batch-issue-deduplication.yml @@ -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 diff --git a/.github/workflows/pr-intake.yml b/.github/workflows/pr-intake.yml index 157278549a..02ce7d6ac8 100644 --- a/.github/workflows/pr-intake.yml +++ b/.github/workflows/pr-intake.yml @@ -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