mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-08-29 01:59:34 +02:00
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:
7
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
7
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -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
|
||||||
|
|||||||
9
.github/ISSUE_TEMPLATE/translation_issue.yml
vendored
9
.github/ISSUE_TEMPLATE/translation_issue.yml
vendored
@@ -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
|
||||||
|
|||||||
7
.github/scripts/issue-triage/README.md
vendored
7
.github/scripts/issue-triage/README.md
vendored
@@ -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.
|
||||||
|
|
||||||
|
|||||||
20
.github/scripts/issue-triage/issue-context.py
vendored
20
.github/scripts/issue-triage/issue-context.py
vendored
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
33
.github/scripts/pr-intake/README.md
vendored
33
.github/scripts/pr-intake/README.md
vendored
@@ -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
|
||||||
|
|||||||
131
.github/scripts/pr-intake/pr-intake.mjs
vendored
131
.github/scripts/pr-intake/pr-intake.mjs
vendored
@@ -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,
|
||||||
|
|||||||
176
.github/scripts/pr-intake/tests/pr-intake.test.mjs
vendored
176
.github/scripts/pr-intake/tests/pr-intake.test.mjs
vendored
@@ -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({
|
||||||
|
|||||||
29
.github/workflows/dedupe-digest.yml
vendored
29
.github/workflows/dedupe-digest.yml
vendored
@@ -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({
|
||||||
|
|||||||
53
.github/workflows/issue-triage.lock.yml
generated
vendored
53
.github/workflows/issue-triage.lock.yml
generated
vendored
@@ -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'
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
51
.github/workflows/issue-triage.md
vendored
51
.github/workflows/issue-triage.md
vendored
@@ -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'
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
20
.github/workflows/pr-intake.yml
vendored
20
.github/workflows/pr-intake.yml
vendored
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user