mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-15 18:21:09 +00:00
fix(ci): close dev-released issues on stable release (#1396)
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const ISSUE_REF_PATTERN = /#([0-9]+)/g;
|
||||
const ACTION_VERB_PATTERN = /\b(fixes|closes|resolves|refs?)\b(.*)/gi;
|
||||
const RESOLVE_VERB_PATTERN = /\b(fixes|closes|resolves)\b(.*)/gi;
|
||||
const PR_REF_PATTERN = /(?:Merge pull request #|\(#)([0-9]+)/g;
|
||||
const STABLE_TAG_PATTERN = /^v[0-9]+\.[0-9]+\.[0-9]+$/;
|
||||
|
||||
export function extractIssueNumbers(text, { includeRefs = true } = {}) {
|
||||
const pattern = includeRefs ? ACTION_VERB_PATTERN : RESOLVE_VERB_PATTERN;
|
||||
const issues = new Set();
|
||||
let actionMatch;
|
||||
|
||||
pattern.lastIndex = 0;
|
||||
while ((actionMatch = pattern.exec(text || '')) !== null) {
|
||||
const tail = actionMatch[2] || '';
|
||||
let issueMatch;
|
||||
ISSUE_REF_PATTERN.lastIndex = 0;
|
||||
while ((issueMatch = ISSUE_REF_PATTERN.exec(tail)) !== null) {
|
||||
issues.add(Number(issueMatch[1]));
|
||||
}
|
||||
}
|
||||
|
||||
return [...issues].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
export function extractPrNumbers(text) {
|
||||
const prs = new Set();
|
||||
let match;
|
||||
|
||||
PR_REF_PATTERN.lastIndex = 0;
|
||||
while ((match = PR_REF_PATTERN.exec(text || '')) !== null) {
|
||||
prs.add(Number(match[1]));
|
||||
}
|
||||
|
||||
return [...prs].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
export function planIssueCleanup({ releaseIssues, resolvedIssues, issueStates }) {
|
||||
const resolved = new Set(resolvedIssues);
|
||||
return releaseIssues.map((number) => {
|
||||
const state = issueStates.get(number) || { labels: [], state: 'UNKNOWN' };
|
||||
const labels = new Set(state.labels);
|
||||
const wasReleasedDev = labels.has('released-dev');
|
||||
const shouldClose = state.state === 'OPEN' && (wasReleasedDev || resolved.has(number));
|
||||
|
||||
return {
|
||||
number,
|
||||
removeLabels: ['released-dev', 'pending-release'],
|
||||
addReleasedLabel: shouldClose,
|
||||
close: shouldClose,
|
||||
reason: wasReleasedDev ? 'promoted from dev to stable' : 'resolved by stable release',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getStableReleaseContext({ env = process.env, exec = runCommand } = {}) {
|
||||
const repo = env.GITHUB_REPOSITORY;
|
||||
if (!repo) throw new Error('GITHUB_REPOSITORY is required');
|
||||
|
||||
const version = JSON.parse(readFileSync('package.json', 'utf8')).version;
|
||||
const currentTag = `v${version}`;
|
||||
const releaseBody = exec('gh', [
|
||||
'release',
|
||||
'view',
|
||||
currentTag,
|
||||
'--repo',
|
||||
repo,
|
||||
'--json',
|
||||
'body',
|
||||
'--jq',
|
||||
'.body',
|
||||
]);
|
||||
const tags = exec('git', ['tag', '-l', 'v[0-9]*.[0-9]*.[0-9]*', '--sort=-v:refname'])
|
||||
.split('\n')
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => STABLE_TAG_PATTERN.test(tag) && tag !== currentTag);
|
||||
const previousStableTag = tags[0] || '';
|
||||
const range = previousStableTag ? `${previousStableTag}..HEAD~1` : 'HEAD~50..HEAD~1';
|
||||
const commitText = exec('git', ['log', range, '--pretty=format:%s%n%b'], { optional: true });
|
||||
|
||||
return { repo, version, currentTag, releaseBody, range, commitText };
|
||||
}
|
||||
|
||||
export function buildReleaseIssueSet({ releaseBody, commitText, prText }) {
|
||||
const releaseIssues = new Set([
|
||||
...extractIssueNumbers(releaseBody, { includeRefs: true }),
|
||||
...extractIssueNumbers(commitText, { includeRefs: true }),
|
||||
...extractIssueNumbers(prText, { includeRefs: true }),
|
||||
]);
|
||||
const resolvedIssues = new Set([
|
||||
...extractIssueNumbers(releaseBody, { includeRefs: false }),
|
||||
...extractIssueNumbers(commitText, { includeRefs: false }),
|
||||
...extractIssueNumbers(prText, { includeRefs: false }),
|
||||
]);
|
||||
|
||||
return {
|
||||
releaseIssues: [...releaseIssues].sort((a, b) => a - b),
|
||||
resolvedIssues: [...resolvedIssues].sort((a, b) => a - b),
|
||||
};
|
||||
}
|
||||
|
||||
export function runCommand(command, args, { optional = false } = {}) {
|
||||
const result = spawnSync(command, args, { encoding: 'utf8' });
|
||||
if (result.status !== 0) {
|
||||
if (optional) return '';
|
||||
throw new Error(
|
||||
`${command} ${args.join(' ')} failed: ${(result.stderr || result.stdout || '').trim()}`
|
||||
);
|
||||
}
|
||||
return result.stdout.trim();
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
buildReleaseIssueSet,
|
||||
extractPrNumbers,
|
||||
getStableReleaseContext,
|
||||
planIssueCleanup,
|
||||
runCommand,
|
||||
} from './stable-release-issue-cleanup-lib.mjs';
|
||||
|
||||
function gh(args, options) {
|
||||
return runCommand('gh', args, options);
|
||||
}
|
||||
|
||||
function fetchPrText(repo, commitText) {
|
||||
let prText = '';
|
||||
for (const prNumber of extractPrNumbers(commitText)) {
|
||||
const details = gh(
|
||||
[
|
||||
'pr',
|
||||
'view',
|
||||
String(prNumber),
|
||||
'--repo',
|
||||
repo,
|
||||
'--json',
|
||||
'title,body',
|
||||
'--jq',
|
||||
'.title + "\n" + (.body // "")',
|
||||
],
|
||||
{ optional: true }
|
||||
);
|
||||
if (details) prText += `\n${details}`;
|
||||
}
|
||||
return prText;
|
||||
}
|
||||
|
||||
function fetchIssueStates(repo, issueNumbers) {
|
||||
const states = new Map();
|
||||
for (const number of issueNumbers) {
|
||||
const raw = gh(
|
||||
[
|
||||
'issue',
|
||||
'view',
|
||||
String(number),
|
||||
'--repo',
|
||||
repo,
|
||||
'--json',
|
||||
'state,labels',
|
||||
'--jq',
|
||||
'{state:.state,labels:[.labels[].name]}',
|
||||
],
|
||||
{ optional: true }
|
||||
);
|
||||
if (!raw) continue;
|
||||
states.set(number, JSON.parse(raw));
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
function ensureReleasedLabel(repo) {
|
||||
gh(
|
||||
[
|
||||
'label',
|
||||
'create',
|
||||
'released',
|
||||
'--color',
|
||||
'ededed',
|
||||
'--description',
|
||||
'Fix available in stable npm channel',
|
||||
'--repo',
|
||||
repo,
|
||||
],
|
||||
{ optional: true }
|
||||
);
|
||||
}
|
||||
|
||||
function applyAction(repo, action) {
|
||||
console.log(`Cleaning release state on issue #${action.number}`);
|
||||
for (const label of action.removeLabels) {
|
||||
gh(['issue', 'edit', String(action.number), '--remove-label', label, '--repo', repo], {
|
||||
optional: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!action.close) return;
|
||||
|
||||
gh(['issue', 'edit', String(action.number), '--add-label', 'released', '--repo', repo], {
|
||||
optional: true,
|
||||
});
|
||||
gh(
|
||||
[
|
||||
'issue',
|
||||
'close',
|
||||
String(action.number),
|
||||
'--comment',
|
||||
'[bot] Closing issue because this fix/feature is now in stable release (@latest).',
|
||||
'--repo',
|
||||
repo,
|
||||
],
|
||||
{ optional: true }
|
||||
);
|
||||
console.log(`Closed issue #${action.number}: ${action.reason}`);
|
||||
}
|
||||
|
||||
export function main() {
|
||||
const context = getStableReleaseContext();
|
||||
console.log(`Checking stable release issue lifecycle for ${context.currentTag}`);
|
||||
console.log(`Checking commits in range: ${context.range}`);
|
||||
|
||||
const prText = fetchPrText(context.repo, context.commitText);
|
||||
const { releaseIssues, resolvedIssues } = buildReleaseIssueSet({
|
||||
releaseBody: context.releaseBody,
|
||||
commitText: context.commitText,
|
||||
prText,
|
||||
});
|
||||
|
||||
if (releaseIssues.length === 0) {
|
||||
console.log(`No release-scoped issues found for ${context.currentTag}`);
|
||||
return;
|
||||
}
|
||||
|
||||
ensureReleasedLabel(context.repo);
|
||||
const issueStates = fetchIssueStates(context.repo, releaseIssues);
|
||||
for (const action of planIssueCleanup({ releaseIssues, resolvedIssues, issueStates })) {
|
||||
applyAction(context.repo, action);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user