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