mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-08-29 10:09:43 +02:00
## 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
418 lines
18 KiB
YAML
418 lines
18 KiB
YAML
# Daily Dedupe Digest
|
|
#
|
|
# Aggregates the possible-duplicate issues that the "AI Issue Triage" workflow
|
|
# has already surfaced, into a single daily review issue assigned to a
|
|
# maintainer. It does NOT run its own duplicate detection — the triage workflow
|
|
# is the single source of truth. Each run:
|
|
# 1. Re-checks issues carried over from the previous digest plus issues
|
|
# updated in the recent lookback window.
|
|
# 2. Reads each issue's triage comment (marker
|
|
# `<!-- powertoys-ai-triage:canonical:v1 -->`) and extracts the
|
|
# `### 🔁 Possible duplicates` candidates.
|
|
# 3. Closes the previous digest and opens a fresh one listing every flagged
|
|
# issue (the duplicate to close) with its suggested canonical issue.
|
|
# 4. If nothing is flagged, it just closes the previous digest and opens
|
|
# nothing.
|
|
name: Daily Dedupe Digest
|
|
|
|
on:
|
|
schedule:
|
|
- cron: "0 8 * * *" # 08:00 UTC daily
|
|
workflow_dispatch:
|
|
inputs:
|
|
lookback_hours:
|
|
description: "How many hours back to scan for freshly triaged issues"
|
|
required: false
|
|
default: "26"
|
|
|
|
permissions:
|
|
issues: write
|
|
|
|
concurrency:
|
|
group: dedupe-digest
|
|
cancel-in-progress: false
|
|
|
|
env:
|
|
DIGEST_ASSIGNEE: niels9001
|
|
DIGEST_LABEL: dedupe-digest
|
|
DIGEST_TITLE_PREFIX: "[Dedupe Digest]"
|
|
DIGEST_BODY_MARKER: "<!-- dedupe-digest:v1 -->"
|
|
TRIAGE_MARKER: "<!-- powertoys-ai-triage:canonical:v1 -->"
|
|
TRIAGE_BOT_LOGIN: "github-actions[bot]"
|
|
LOOKBACK_HOURS: "26"
|
|
MAX_ISSUES_SCANNED: "400"
|
|
MAX_FLAGGED_IN_DIGEST: "75"
|
|
# Labels that mean an issue is already resolved as a duplicate and should
|
|
# drop out of the digest.
|
|
RESOLVED_DUP_LABELS: "duplicate,Resolution-Duplicate"
|
|
|
|
jobs:
|
|
build-digest:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Build daily dedupe digest
|
|
uses: actions/github-script@v9.0.0
|
|
with:
|
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
script: |
|
|
const { owner, repo } = context.repo;
|
|
|
|
const ASSIGNEE = process.env.DIGEST_ASSIGNEE;
|
|
const LABEL = process.env.DIGEST_LABEL;
|
|
const PREFIX = process.env.DIGEST_TITLE_PREFIX;
|
|
const BODY_MARKER = process.env.DIGEST_BODY_MARKER;
|
|
const TRIAGE_MARKER = process.env.TRIAGE_MARKER;
|
|
const TRIAGE_BOT = process.env.TRIAGE_BOT_LOGIN;
|
|
const RESOLVED_LABELS = (process.env.RESOLVED_DUP_LABELS || '')
|
|
.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
|
|
const LOOKBACK_HOURS = Number(
|
|
context.payload?.inputs?.lookback_hours || process.env.LOOKBACK_HOURS || 26
|
|
) || 26;
|
|
const MAX_SCAN = Number(process.env.MAX_ISSUES_SCANNED) || 400;
|
|
const MAX_FLAGGED = Number(process.env.MAX_FLAGGED_IN_DIGEST) || 75;
|
|
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
|
|
// Neutralize untrusted issue/comment text before the bot republishes
|
|
// it: strip HTML comments (marker injection) and control chars, then
|
|
// escape Markdown and `@` mentions so a crafted title cannot inject
|
|
// links/formatting or notify users/teams on every carried-over digest.
|
|
const sanitize = (text, max = 200) => {
|
|
const cleaned = String(text == null ? '' : text)
|
|
.replace(/<!--[\s\S]*?-->/g, ' ')
|
|
.replace(/[\u0000-\u001F\u007F]/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
.slice(0, max);
|
|
return cleaned
|
|
.replace(/\\/g, '\\\\')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/@/g, '@\u200B')
|
|
.replace(/([`*_{}\[\]()#+.!|~])/g, '\\$1')
|
|
.replace(/-/g, '\\-');
|
|
};
|
|
|
|
const labelNames = (issue) =>
|
|
(issue.labels || []).map(l => (typeof l === 'string' ? l : l.name) || '');
|
|
|
|
const isResolvedDuplicate = (issue) =>
|
|
issue.state === 'closed' ||
|
|
labelNames(issue).some(n => RESOLVED_LABELS.includes(n.toLowerCase()));
|
|
|
|
// Parse the `### 🔁 Possible duplicates` candidates out of a triage
|
|
// canonical comment. Only those <details> blocks use a `#<number> —`
|
|
// summary, so scanning the whole body is safe.
|
|
const parseDuplicateCandidates = (body) => {
|
|
const out = [];
|
|
const re = /<summary>#(\d+)\s*[—-]\s*([^<]*)<\/summary>([\s\S]*?)<\/details>/g;
|
|
let m;
|
|
while ((m = re.exec(body)) !== null) {
|
|
const number = Number(m[1]);
|
|
if (!Number.isSafeInteger(number) || number <= 0) continue;
|
|
const title = sanitize(m[2], 160);
|
|
const reasonMatch = m[3].match(/\*\*Why this may be a duplicate:\*\*\s*([\s\S]*)/);
|
|
const reason = sanitize(reasonMatch ? reasonMatch[1] : '', 300);
|
|
out.push({ number, title, reason });
|
|
}
|
|
return out;
|
|
};
|
|
|
|
const findTriageComment = async (issueNumber) => {
|
|
const comments = await github.paginate(github.rest.issues.listComments, {
|
|
owner, repo, issue_number: issueNumber, per_page: 100
|
|
});
|
|
return comments
|
|
.filter(c =>
|
|
c.user && c.user.login === TRIAGE_BOT &&
|
|
typeof c.body === 'string' && c.body.includes(TRIAGE_MARKER))
|
|
.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 });
|
|
} catch (e) {
|
|
if (e.status === 404) {
|
|
await github.rest.issues.createLabel({
|
|
owner, repo, name: LABEL, color: 'B60205',
|
|
description: 'Daily aggregated duplicate-issue review digest'
|
|
});
|
|
} else {
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
// --- 2. Find prior open digest issues --------------------------------
|
|
const priorDigests = (await github.paginate(github.rest.issues.listForRepo, {
|
|
owner, repo, state: 'open', labels: LABEL, per_page: 100
|
|
})).filter(i => !i.pull_request && i.title.startsWith(PREFIX));
|
|
|
|
// --- 3. Build the candidate set --------------------------------------
|
|
// Carry-over: issue numbers remembered from the most recent digest.
|
|
const carryNumbers = new Set();
|
|
const mostRecentDigest = priorDigests
|
|
.slice()
|
|
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))[0];
|
|
if (mostRecentDigest && typeof mostRecentDigest.body === 'string') {
|
|
const re = /<!--\s*dup:ISSUE=(\d+)\s+CANON=(\d+)\s*-->/g;
|
|
let m;
|
|
while ((m = re.exec(mostRecentDigest.body)) !== null) {
|
|
carryNumbers.add(Number(m[1]));
|
|
}
|
|
}
|
|
|
|
// Fresh: open issues updated within the lookback window.
|
|
const sinceIso = new Date(Date.now() - LOOKBACK_HOURS * 3600 * 1000).toISOString();
|
|
const freshNumbers = new Set();
|
|
for await (const { data } of github.paginate.iterator(github.rest.issues.listForRepo, {
|
|
owner, repo, state: 'open', since: sinceIso,
|
|
sort: 'updated', direction: 'desc', per_page: 100
|
|
})) {
|
|
for (const it of data) {
|
|
if (it.pull_request) continue;
|
|
if (labelNames(it).includes(LABEL)) continue;
|
|
freshNumbers.add(it.number);
|
|
}
|
|
if (freshNumbers.size >= MAX_SCAN) break;
|
|
}
|
|
|
|
const candidateNumbers = Array.from(new Set([...carryNumbers, ...freshNumbers]))
|
|
.slice(0, MAX_SCAN);
|
|
|
|
core.info(
|
|
`Scanning ${candidateNumbers.length} candidate issue(s) ` +
|
|
`(${carryNumbers.size} carried over, ${freshNumbers.size} fresh).`
|
|
);
|
|
|
|
// --- 4. Resolve each candidate against its live triage comment -------
|
|
const flagged = [];
|
|
for (const number of candidateNumbers) {
|
|
let issue;
|
|
try {
|
|
issue = (await github.rest.issues.get({ owner, repo, issue_number: number })).data;
|
|
} catch (e) {
|
|
if (e.status === 404) continue;
|
|
throw e;
|
|
}
|
|
if (issue.pull_request) continue;
|
|
if (labelNames(issue).includes(LABEL)) continue; // never digest a digest
|
|
if (isResolvedDuplicate(issue)) continue; // already closed/resolved
|
|
|
|
const triageComment = await findTriageComment(number);
|
|
if (!triageComment) continue;
|
|
const candidates = await resolveOpenIssueCandidates(
|
|
parseDuplicateCandidates(triageComment.body)
|
|
);
|
|
if (!candidates.length) continue;
|
|
|
|
flagged.push({
|
|
number: issue.number,
|
|
title: sanitize(issue.title, 200),
|
|
url: issue.html_url,
|
|
triageUrl: triageComment.html_url,
|
|
candidates
|
|
});
|
|
}
|
|
|
|
// Newest issues first, then keep at most MAX_FLAGGED. Because every
|
|
// candidate was inspected above, capping the *sorted* list here means
|
|
// a full carry-over set can never starve fresher issues out of the
|
|
// scan — the newest ones always win the cap.
|
|
flagged.sort((a, b) => b.number - a.number);
|
|
const totalFlagged = flagged.length;
|
|
const tracked = flagged.slice(0, MAX_FLAGGED);
|
|
core.info(
|
|
`Flagged ${totalFlagged} issue(s) as possible duplicates; ` +
|
|
`tracking ${tracked.length}.`
|
|
);
|
|
|
|
// --- 5a. Nothing flagged: close priors, open nothing -----------------
|
|
const closePrior = async (noteBody) => {
|
|
for (const d of priorDigests) {
|
|
try {
|
|
await github.rest.issues.createComment({
|
|
owner, repo, issue_number: d.number, body: noteBody
|
|
});
|
|
await github.rest.issues.update({
|
|
owner, repo, issue_number: d.number,
|
|
state: 'closed', state_reason: 'completed'
|
|
});
|
|
} catch (e) {
|
|
core.warning(`Failed to close prior digest #${d.number}: ${e.message}`);
|
|
}
|
|
}
|
|
};
|
|
|
|
if (flagged.length === 0) {
|
|
await closePrior(
|
|
`${BODY_MARKER}\nNo open duplicate candidates remained as of ${today}; closing this digest.`
|
|
);
|
|
core.info('No duplicates flagged; closed prior digest(s) and created none.');
|
|
return;
|
|
}
|
|
|
|
// --- 5b. Group tracked issues under their strongest canonical ------
|
|
// Each tracked issue is a duplicate to close; candidates[0] is the
|
|
// strongest suggested canonical (the one to keep).
|
|
const groups = new Map();
|
|
for (const f of tracked) {
|
|
const primary = f.candidates[0];
|
|
if (!groups.has(primary.number)) {
|
|
groups.set(primary.number, {
|
|
canonical: { number: primary.number, title: primary.title },
|
|
dupes: []
|
|
});
|
|
}
|
|
groups.get(primary.number).dupes.push({
|
|
number: f.number,
|
|
title: f.title,
|
|
reason: primary.reason,
|
|
triageUrl: f.triageUrl,
|
|
others: f.candidates.slice(1).map(c => c.number),
|
|
allCandidates: f.candidates.map(c => c.number)
|
|
});
|
|
}
|
|
const groupList = Array.from(groups.values())
|
|
.sort((a, b) => b.canonical.number - a.canonical.number);
|
|
for (const g of groupList) {
|
|
g.dupes.sort((a, b) => b.number - a.number);
|
|
}
|
|
|
|
// --- 5c. Build the digest body within GitHub's body-size limit ------
|
|
// GitHub rejects issue bodies over 65,536 chars with a 422. Render
|
|
// whole groups only while they fit inside a conservative budget, then
|
|
// note how many issues were deferred so nothing looks silently lost.
|
|
const MAX_BODY = 60000;
|
|
const footer = '<!-- gh-aw-workflow-id: dedupe-digest -->';
|
|
|
|
const head = [];
|
|
head.push(BODY_MARKER);
|
|
head.push(`## 🧭 Daily Duplicate Digest — ${today}`);
|
|
head.push('');
|
|
head.push(
|
|
`AI Issue Triage flagged **${totalFlagged}** open issue(s) as possible ` +
|
|
`duplicates, grouped under **${groupList.length}** canonical issue(s) below. ` +
|
|
`Triage may have already filed a duplicate-close suggestion on each one — ` +
|
|
`open the linked triage summary and accept or decline it on the issue itself.`
|
|
);
|
|
|
|
const renderGroup = (g) => {
|
|
const block = [];
|
|
block.push('');
|
|
block.push('---');
|
|
block.push('');
|
|
block.push(`### ✅ Keep #${g.canonical.number} — ${g.canonical.title}`);
|
|
block.push('');
|
|
block.push(
|
|
g.dupes.length === 1
|
|
? `The following issue looks like a duplicate of #${g.canonical.number} and can be closed:`
|
|
: `The following issues look like duplicates of #${g.canonical.number} and can be closed:`
|
|
);
|
|
block.push('');
|
|
for (const d of g.dupes) {
|
|
const reason = d.reason ? ` — ${d.reason}` : '';
|
|
const others = d.others.length
|
|
? ` _(also similar to ${d.others.map(n => `#${n}`).join(', ')})_`
|
|
: '';
|
|
block.push(
|
|
`- **#${d.number} — ${d.title}**${reason}${others} ` +
|
|
`([triage summary](${d.triageUrl}))`
|
|
);
|
|
}
|
|
return block;
|
|
};
|
|
|
|
const body = head.slice();
|
|
const markerLines = [];
|
|
let renderedGroups = 0;
|
|
let renderedDupes = 0;
|
|
for (const g of groupList) {
|
|
const block = renderGroup(g);
|
|
const blockMarkers = g.dupes.flatMap(d =>
|
|
d.allCandidates.map(cn => `<!-- dup:ISSUE=${d.number} CANON=${cn} -->`)
|
|
);
|
|
// +240 leaves room for the deferral note and footer.
|
|
const projected = body.join('\n').length + block.join('\n').length +
|
|
markerLines.join('\n').length + blockMarkers.join('\n').length +
|
|
footer.length + 240;
|
|
if (projected > MAX_BODY && renderedGroups > 0) break;
|
|
body.push(...block);
|
|
markerLines.push(...blockMarkers);
|
|
renderedGroups++;
|
|
renderedDupes += g.dupes.length;
|
|
}
|
|
|
|
const deferred = totalFlagged - renderedDupes;
|
|
if (deferred > 0) {
|
|
body.push('');
|
|
body.push('---');
|
|
body.push('');
|
|
body.push(
|
|
`_${deferred} additional flagged issue(s) are still being tracked but ` +
|
|
`were not shown here (digest size or daily cap). They will surface in a ` +
|
|
`later digest as capacity allows._`
|
|
);
|
|
}
|
|
|
|
// Hidden machine-readable carry-over markers (one per dup→candidate).
|
|
body.push('');
|
|
body.push('<!-- carry-over state; do not edit -->');
|
|
body.push(...markerLines);
|
|
body.push(footer);
|
|
|
|
const created = await github.rest.issues.create({
|
|
owner, repo,
|
|
title: `${PREFIX} ${today}`,
|
|
body: body.join('\n'),
|
|
labels: [LABEL]
|
|
});
|
|
core.info(`Created digest issue #${created.data.number}.`);
|
|
|
|
// Assign the maintainer (best-effort; ignore if not assignable).
|
|
if (ASSIGNEE) {
|
|
try {
|
|
await github.rest.issues.addAssignees({
|
|
owner, repo, issue_number: created.data.number, assignees: [ASSIGNEE]
|
|
});
|
|
} catch (e) {
|
|
core.warning(`Could not assign @${ASSIGNEE}: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
// Close previous digests, pointing at the new one.
|
|
await closePrior(
|
|
`${BODY_MARKER}\nSuperseded by #${created.data.number}.`
|
|
);
|
|
|
|
core.notice(
|
|
`Dedupe digest #${created.data.number} created: ` +
|
|
`${renderedDupes} shown, ${deferred} deferred, ${totalFlagged} total.`
|
|
);
|