qwen-code/.github/scripts/classify-release-notes.mjs
易良 cda0e0348d
ci: move release-note classifier from per-PR workflow to release-time batch (#7339)
* ci: move release-note classifier from per-PR workflow to release-time batch

* fix(ci): enumerate release PRs from generated notes

* fix(ci): reconcile release note labels

* fix(ci): address release classifier review

* fix(ci): surface release classifier failures
2026-07-20 23:52:09 +00:00

141 lines
3.7 KiB
JavaScript

#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const INTERNAL_LABELS = new Set([
'category/development',
'scope/build-system',
'scope/ci-cd',
'scope/github-actions',
'scope/testing',
]);
const AUTO_LABEL = 'skip-changelog-auto';
const RELEASE_AUTOMATION_RE =
/^\.github\/.*(?:changelog|release|publish|deploy|sync|prebuild|package|installer|artifact|image|cd-)/i;
const TEST_FILE_RE =
/(?:^|\/)(?:__tests__\/|[^/]+\.(?:test|spec)\.[^/]+$|(?:vitest|playwright)(?:\.[^/]+)?\.config\.[^/]+$)/;
export function shouldAutoSkipChangelog({ title, labels = [], files = [] }) {
const names = labels
.map((label) =>
(typeof label === 'string' ? label : label.name).toLowerCase(),
)
.filter((name) => name !== AUTO_LABEL);
if (names.includes('skip-changelog')) return false;
const subject = /^(\w+)(?:\([^)]*\))?(!)?:/.exec(title.trim());
const type = subject?.[1].toLowerCase();
if (
subject?.[2] ||
(type
? type !== 'ci'
: !names.some((label) =>
['scope/ci-cd', 'scope/github-actions'].includes(label),
)) ||
names.includes('bug') ||
names.includes('breaking-change') ||
names.some(
(label) =>
/^(?:type|category|scope)\//.test(label) && !INTERNAL_LABELS.has(label),
)
) {
return false;
}
return (
files.length > 0 &&
files.every(
(file) =>
TEST_FILE_RE.test(file) ||
(!RELEASE_AUTOMATION_RE.test(file) &&
(file.startsWith('.github/') || file.startsWith('.qwen/'))),
)
);
}
function fetchFiles(repo, number) {
return execFileSync(
'gh',
[
'api',
'--paginate',
`repos/${repo}/pulls/${number}/files`,
'--jq',
'.[] | .filename, (.previous_filename // empty)',
],
{ encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 },
)
.split(/\r?\n/)
.filter(Boolean);
}
function main() {
const repo = process.env.GITHUB_REPOSITORY || '';
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) {
throw new Error('GITHUB_REPOSITORY must be set to owner/repo.');
}
const input = JSON.parse(readFileSync(0, 'utf8'));
const prs = Array.isArray(input) ? input : [];
const labeled = [];
const unlabeled = [];
for (const pr of prs) {
const number = String(pr.number);
if (!/^[1-9]\d*$/.test(number)) continue;
try {
const files = fetchFiles(repo, number);
const shouldSkip = shouldAutoSkipChangelog({ ...pr, files });
const hasAutoLabel = pr.labels.some(
(label) =>
(typeof label === 'string' ? label : label.name).toLowerCase() ===
AUTO_LABEL,
);
if (shouldSkip && !hasAutoLabel) {
execFileSync('gh', [
'pr',
'edit',
number,
'--repo',
repo,
'--add-label',
AUTO_LABEL,
]);
labeled.push(number);
} else if (!shouldSkip && hasAutoLabel) {
execFileSync('gh', [
'pr',
'edit',
number,
'--repo',
repo,
'--remove-label',
AUTO_LABEL,
]);
unlabeled.push(number);
}
} catch (error) {
process.exitCode = 1;
process.stderr.write(
`::warning::Failed to process PR #${number}: ${error.message}; skipping.\n`,
);
}
}
if (labeled.length > 0) {
process.stdout.write(`Labeled: ${labeled.join(', ')}\n`);
}
if (unlabeled.length > 0) {
process.stdout.write(`Unlabeled: ${unlabeled.join(', ')}\n`);
}
}
if (
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
main();
}