From 52a427a7a34faecb078af407725f9c50fc641103 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Wed, 27 May 2026 08:28:01 -0400 Subject: [PATCH] fix(ci): close dev-released issues on stable release (#1396) --- .github/workflows/release.yml | 72 +--------- .../stable-release-issue-cleanup-lib.mjs | 113 +++++++++++++++ .../github/stable-release-issue-cleanup.mjs | 134 ++++++++++++++++++ .../stable-release-issue-cleanup.test.mjs | 84 +++++++++++ 4 files changed, 332 insertions(+), 71 deletions(-) create mode 100644 scripts/github/stable-release-issue-cleanup-lib.mjs create mode 100644 scripts/github/stable-release-issue-cleanup.mjs create mode 100644 tests/unit/github/stable-release-issue-cleanup.test.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 57e60922..120a14ab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,74 +102,4 @@ jobs: if: success() && steps.release.outputs.released == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - VERSION=$(jq -r '.version' package.json) - RELEASE_BODY=$(gh release view "v${VERSION}" --repo "${{ github.repository }}" --json body --jq '.body' 2>/dev/null || echo "") - PREVIOUS_STABLE_TAG=$(git tag -l "v[0-9]*.[0-9]*.[0-9]" --sort=-v:refname | \ - grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | \ - grep -vx "v${VERSION}" | head -1 || echo "") - RANGE="${PREVIOUS_STABLE_TAG:+${PREVIOUS_STABLE_TAG}..HEAD~1}" - if [[ -z "$RANGE" ]]; then - RANGE="HEAD~50..HEAD~1" - fi - - COMMIT_TEXT=$(git log $RANGE --pretty=format:"%s%n%b" 2>/dev/null || true) - PR_CANDIDATES=$(printf '%s\n' "$COMMIT_TEXT" | \ - grep -oE "Merge pull request #[0-9]+|\\(#[0-9]+\\)" | \ - grep -oE "[0-9]+" | sort -u || true) - - PR_TEXT="" - for PR_NUM in $PR_CANDIDATES; do - DETAILS=$(gh pr view "$PR_NUM" --repo "${{ github.repository }}" --json title,body --jq '.title + "\n" + (.body // "")' 2>/dev/null || true) - if [[ -n "$DETAILS" ]]; then - PR_TEXT="${PR_TEXT}"$'\n'"${DETAILS}" - fi - done - - RELEASE_ISSUES_FROM_BODY=$(printf '%s\n' "$RELEASE_BODY" | \ - perl -ne 'if (/(fixes|closes|resolves|refs?)(.*)/i) { print "$2\n"; }' | \ - grep -oE '#[0-9]+' | tr -d '#' || true) - RELEASE_ISSUES_FROM_COMMITS=$(printf '%s\n' "$COMMIT_TEXT" | \ - perl -ne 'if (/(fixes|closes|resolves|refs?)(.*)/i) { print "$2\n"; }' | \ - grep -oE '#[0-9]+' | tr -d '#' || true) - RELEASE_ISSUES_FROM_PRS=$(printf '%s\n' "$PR_TEXT" | \ - perl -ne 'if (/(fixes|closes|resolves|refs?)(.*)/i) { print "$2\n"; }' | \ - grep -oE '#[0-9]+' | tr -d '#' || true) - RELEASE_ISSUES=$(printf '%s\n%s\n%s\n' \ - "$RELEASE_ISSUES_FROM_BODY" \ - "$RELEASE_ISSUES_FROM_COMMITS" \ - "$RELEASE_ISSUES_FROM_PRS" | sort -u || true) - - RESOLVED_ISSUES_FROM_BODY=$(printf '%s\n' "$RELEASE_BODY" | \ - perl -ne 'if (/(fixes|closes|resolves)(.*)/i) { print "$2\n"; }' | \ - grep -oE '#[0-9]+' | tr -d '#' || true) - RESOLVED_ISSUES_FROM_COMMITS=$(printf '%s\n' "$COMMIT_TEXT" | \ - perl -ne 'if (/(fixes|closes|resolves)(.*)/i) { print "$2\n"; }' | \ - grep -oE '#[0-9]+' | tr -d '#' || true) - RESOLVED_ISSUES_FROM_PRS=$(printf '%s\n' "$PR_TEXT" | \ - perl -ne 'if (/(fixes|closes|resolves)(.*)/i) { print "$2\n"; }' | \ - grep -oE '#[0-9]+' | tr -d '#' || true) - RESOLVED_ISSUES=$(printf '%s\n%s\n%s\n' \ - "$RESOLVED_ISSUES_FROM_BODY" \ - "$RESOLVED_ISSUES_FROM_COMMITS" \ - "$RESOLVED_ISSUES_FROM_PRS" | sort -u || true) - - if [[ -z "$RELEASE_ISSUES" ]]; then - echo "No release-scoped issues found for v${VERSION}" - exit 0 - fi - - for NUM in $RELEASE_ISSUES; do - echo "Cleaning release state on issue #$NUM" - gh issue edit "$NUM" \ - --remove-label "released-dev" \ - --remove-label "pending-release" \ - --repo "${{ github.repository }}" 2>/dev/null || true - - HAS_RELEASED=$(gh issue view "$NUM" --repo "${{ github.repository }}" --json labels --jq '[.labels[].name | select(. == "released")] | length' 2>/dev/null || echo "0") - if [[ "$HAS_RELEASED" -gt 0 ]] && printf '%s\n' "$RESOLVED_ISSUES" | grep -qx "$NUM"; then - gh issue close "$NUM" \ - --comment "[bot] Closing issue because this fix/feature is now in stable release (@latest)." \ - --repo "${{ github.repository }}" || true - fi - done + run: node scripts/github/stable-release-issue-cleanup.mjs diff --git a/scripts/github/stable-release-issue-cleanup-lib.mjs b/scripts/github/stable-release-issue-cleanup-lib.mjs new file mode 100644 index 00000000..64d7057d --- /dev/null +++ b/scripts/github/stable-release-issue-cleanup-lib.mjs @@ -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(); +} diff --git a/scripts/github/stable-release-issue-cleanup.mjs b/scripts/github/stable-release-issue-cleanup.mjs new file mode 100644 index 00000000..11d9c597 --- /dev/null +++ b/scripts/github/stable-release-issue-cleanup.mjs @@ -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); + } +} diff --git a/tests/unit/github/stable-release-issue-cleanup.test.mjs b/tests/unit/github/stable-release-issue-cleanup.test.mjs new file mode 100644 index 00000000..81cc4f35 --- /dev/null +++ b/tests/unit/github/stable-release-issue-cleanup.test.mjs @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'bun:test'; +import { + buildReleaseIssueSet, + extractIssueNumbers, + extractPrNumbers, + planIssueCleanup, +} from '../../../scripts/github/stable-release-issue-cleanup-lib.mjs'; + +describe('stable release issue cleanup', () => { + it('extracts issue numbers from release action verbs', () => { + const text = [ + 'feat: promote dev to main (#1351), closes #1340 #1341', + 'fix: support aliases (#1197)', + 'Refs #760', + ].join('\n'); + + expect(extractIssueNumbers(text, { includeRefs: true })).toEqual([760, 1340, 1341]); + expect(extractIssueNumbers(text, { includeRefs: false })).toEqual([1340, 1341]); + }); + + it('extracts PR numbers from merge and squash commit subjects', () => { + const text = [ + 'Merge pull request #1392 from kaitranntt/kai/fix/foo', + 'fix(analytics): tighten top-bar layout (#1391)', + ].join('\n'); + + expect(extractPrNumbers(text)).toEqual([1391, 1392]); + }); + + it('combines release body, commit text, and PR body issue references', () => { + const result = buildReleaseIssueSet({ + releaseBody: 'closes #100', + commitText: 'fix: thing (#10)', + prText: 'Refs #200\nResolves #300', + }); + + expect(result.releaseIssues).toEqual([100, 200, 300]); + expect(result.resolvedIssues).toEqual([100, 300]); + }); + + it('closes dev-released issues when promoted to stable even if the PR used refs', () => { + const actions = planIssueCleanup({ + releaseIssues: [760], + resolvedIssues: [], + issueStates: new Map([[760, { state: 'OPEN', labels: ['released-dev'] }]]), + }); + + expect(actions[0]).toMatchObject({ + number: 760, + addReleasedLabel: true, + close: true, + reason: 'promoted from dev to stable', + }); + }); + + it('does not close weak refs unless the issue was already marked released-dev', () => { + const actions = planIssueCleanup({ + releaseIssues: [42], + resolvedIssues: [], + issueStates: new Map([[42, { state: 'OPEN', labels: ['enhancement'] }]]), + }); + + expect(actions[0]).toMatchObject({ + number: 42, + addReleasedLabel: false, + close: false, + }); + }); + + it('closes explicitly resolved issues without requiring a manual released label', () => { + const actions = planIssueCleanup({ + releaseIssues: [1340], + resolvedIssues: [1340], + issueStates: new Map([[1340, { state: 'OPEN', labels: ['bug'] }]]), + }); + + expect(actions[0]).toMatchObject({ + number: 1340, + addReleasedLabel: true, + close: true, + reason: 'resolved by stable release', + }); + }); +});