Commit bf64afa8c97 for woocommerce
commit bf64afa8c97f84bdca2373e6f72f07d5740acc90
Author: Darren Ethier <darren@roughsmootheng.in>
Date: Sun Oct 25 15:08:01 2026 -0400
Close linked PRs when their issue is closed (#70209)
* Close linked PRs when their issue is closed
Adds a workflow that runs when an issue is closed and closes open PRs
linked to it (closing keywords or Development sidebar links), leaving
a comment on each. PRs still linked to another open issue, targeting a
non-default branch, or from another repository are left open. Issues
closed as duplicates are skipped.
* Upload a report of closed and kept PRs as a run artifact
When the closed issue has open linked PRs, the script writes a JSON
report of the PRs it closed, the ones it left open and why, and any it
failed to close. The workflow uploads it as an artifact, even when some
closes fail.
* Skip reopened issues and close linked PRs before commenting
Re-check the issue's live state so a reopen while the job is queued
leaves its PRs open. Close each PR before commenting, so a failed close
never leaves a comment saying the PR was closed; a comment that fails
after a successful close is reported as a failure.
* Cover merged PRs, drop an unreachable check, and allow report re-uploads
Test that merged and closed PRs are ignored, since includeClosedPrs:
false still returns merged ones. Remove the exclusion of the closed
issue itself, which the reopen guard made unreachable. Let a re-run of
a failed job overwrite the report artifact.
* Drop a test that duplicates the open-linked-issue case
diff --git a/.github/workflows/issue-close-linked-prs.yml b/.github/workflows/issue-close-linked-prs.yml
new file mode 100644
index 00000000000..c628265a865
--- /dev/null
+++ b/.github/workflows/issue-close-linked-prs.yml
@@ -0,0 +1,46 @@
+name: 'Close linked PRs when an issue is closed'
+
+on:
+ issues:
+ types: [closed]
+
+permissions: {}
+
+jobs:
+ close-linked-prs:
+ name: 'Close linked PRs not tied to other open issues'
+ # Duplicates are skipped: the canonical issue is still open, so the PR's work may still be needed.
+ if: github.repository == 'woocommerce/woocommerce' && github.event.issue.state_reason != 'duplicate'
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ permissions:
+ contents: read
+ issues: read
+ pull-requests: write
+ env:
+ REPORT_PATH: close-linked-prs-report.json
+ steps:
+ - name: Checkout
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+ sparse-checkout: |
+ .github/workflows/scripts
+
+ - name: Close linked PRs
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ with:
+ script: |
+ const script = require('./.github/workflows/scripts/close-linked-prs-on-issue-close.js');
+ await script({ github, context, core });
+
+ # Only written when the issue had open linked PRs; uploaded even if some closes failed.
+ - name: Upload report
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: close-linked-prs-issue-${{ github.event.issue.number }}
+ path: ${{ env.REPORT_PATH }}
+ if-no-files-found: ignore
+ # A re-run of a failed job uploads under the same name again.
+ overwrite: true
diff --git a/.github/workflows/scripts/close-linked-prs-on-issue-close.js b/.github/workflows/scripts/close-linked-prs-on-issue-close.js
new file mode 100644
index 00000000000..06f72a90ca3
--- /dev/null
+++ b/.github/workflows/scripts/close-linked-prs-on-issue-close.js
@@ -0,0 +1,165 @@
+const fs = require('fs');
+
+const LINKED_PULL_REQUESTS_QUERY = `
+ query ($owner: String!, $repo: String!, $number: Int!) {
+ repository(owner: $owner, name: $repo) {
+ defaultBranchRef {
+ name
+ }
+ issue(number: $number) {
+ state
+ closedByPullRequestsReferences(first: 50, includeClosedPrs: false) {
+ nodes {
+ number
+ state
+ baseRefName
+ repository {
+ nameWithOwner
+ }
+ closingIssuesReferences(first: 50) {
+ nodes {
+ number
+ state
+ repository {
+ nameWithOwner
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+`;
+
+/**
+ * Splits the pull requests linked to a closed issue into the ones to close and the ones to keep open.
+ * A pull request stays open when it lives in another repository, targets a branch other than the
+ * default one (e.g. a release branch cherry-pick), or still resolves another open issue.
+ *
+ * @param {Object} params
+ * @param {Object[]} params.pullRequests Linked pull requests from the GraphQL query.
+ * @param {string} params.repository The closed issue's repository, as "owner/repo".
+ * @param {string} params.defaultBranch The repository's default branch.
+ * @return {{close: number[], keep: {number: number, reason: string}[]}}
+ */
+function selectPullRequestsToClose({ pullRequests, repository, defaultBranch }) {
+ const close = [];
+ const keep = [];
+
+ for (const pr of pullRequests) {
+ // `includeClosedPrs: false` still returns merged PRs, such as the one that closed the issue.
+ if (pr.state !== 'OPEN') {
+ continue;
+ }
+
+ if (pr.repository.nameWithOwner !== repository) {
+ keep.push({ number: pr.number, reason: `it belongs to ${pr.repository.nameWithOwner}` });
+ continue;
+ }
+
+ if (pr.baseRefName !== defaultBranch) {
+ keep.push({ number: pr.number, reason: `it targets ${pr.baseRefName}, not ${defaultBranch}` });
+ continue;
+ }
+
+ const otherOpenIssues = pr.closingIssuesReferences.nodes.filter((issue) => issue.state === 'OPEN');
+ if (otherOpenIssues.length > 0) {
+ const refs = otherOpenIssues.map((issue) => `${issue.repository.nameWithOwner}#${issue.number}`);
+ keep.push({ number: pr.number, reason: `it is still linked to open issue(s) ${refs.join(', ')}` });
+ continue;
+ }
+
+ close.push(pr.number);
+ }
+
+ return { close, keep };
+}
+
+/**
+ * Closes open pull requests that were linked to an issue that just got closed, leaving a comment on each.
+ * When REPORT_PATH is set, writes a JSON report of the closed, kept, and failed pull requests there.
+ *
+ * @param {Object} params - The parameters object
+ * @param {Object} params.github - GitHub API client
+ * @param {Object} params.context - GitHub Actions context
+ * @param {Object} params.core - GitHub Actions core utilities
+ */
+module.exports = async ({ github, context, core }) => {
+ const { owner, repo } = context.repo;
+ const issueNumber = context.payload.issue.number;
+
+ const { repository: result } = await github.graphql(LINKED_PULL_REQUESTS_QUERY, {
+ owner,
+ repo,
+ number: issueNumber,
+ });
+
+ // The issue may have been reopened while this job was queued.
+ if (result.issue.state === 'OPEN') {
+ core.info(`Issue #${issueNumber} has been reopened. Leaving its linked pull requests open.`);
+ return;
+ }
+
+ const { close, keep } = selectPullRequestsToClose({
+ pullRequests: result.issue.closedByPullRequestsReferences.nodes,
+ repository: `${owner}/${repo}`,
+ defaultBranch: result.defaultBranchRef.name,
+ });
+
+ if (close.length === 0 && keep.length === 0) {
+ core.info(`No open pull requests are linked to issue #${issueNumber}.`);
+ return;
+ }
+
+ for (const { number, reason } of keep) {
+ core.info(`Leaving PR #${number} open because ${reason}.`);
+ }
+
+ const closed = [];
+ const failed = [];
+ // Close before commenting, so a failed close never leaves a comment saying the PR was closed.
+ for (const prNumber of close) {
+ try {
+ await github.rest.pulls.update({
+ owner,
+ repo,
+ pull_number: prNumber,
+ state: 'closed',
+ });
+ } catch (error) {
+ core.warning(`Failed to close PR #${prNumber}: ${error.message}`);
+ failed.push({ number: prNumber, error: error.message });
+ continue;
+ }
+ closed.push(prNumber);
+ core.info(`Closed PR #${prNumber}.`);
+
+ try {
+ await github.rest.issues.createComment({
+ owner,
+ repo,
+ issue_number: prNumber,
+ body: [
+ `Closing this pull request because the issue it resolves, #${issueNumber}, was closed and it isn't linked to any other open issue.`,
+ '',
+ 'If this pull request is still needed, reopen it and link it to an open issue.',
+ ].join('\n'),
+ });
+ } catch (error) {
+ core.warning(`Closed PR #${prNumber} but failed to comment on it: ${error.message}`);
+ failed.push({ number: prNumber, error: `Closed, but the comment failed: ${error.message}` });
+ }
+ }
+
+ if (process.env.REPORT_PATH) {
+ const report = { issue: issueNumber, closed, kept: keep, failed };
+ fs.writeFileSync(process.env.REPORT_PATH, `${JSON.stringify(report, null, 2)}\n`);
+ }
+
+ if (failed.length > 0) {
+ core.setFailed(`Failed to close or comment on PR(s): ${failed.map(({ number }) => `#${number}`).join(', ')}`);
+ }
+};
+
+module.exports.selectPullRequestsToClose = selectPullRequestsToClose;
diff --git a/.github/workflows/scripts/close-linked-prs-on-issue-close.test.js b/.github/workflows/scripts/close-linked-prs-on-issue-close.test.js
new file mode 100644
index 00000000000..1e805843b12
--- /dev/null
+++ b/.github/workflows/scripts/close-linked-prs-on-issue-close.test.js
@@ -0,0 +1,63 @@
+const assert = require('node:assert/strict');
+const test = require('node:test');
+
+const { selectPullRequestsToClose } = require('./close-linked-prs-on-issue-close');
+
+const repository = 'woocommerce/woocommerce';
+
+const issue = (number, state, nameWithOwner = repository) => ({
+ number,
+ state,
+ repository: { nameWithOwner },
+});
+
+const pullRequest = (number, linkedIssues, overrides = {}) => ({
+ number,
+ state: 'OPEN',
+ baseRefName: 'trunk',
+ repository: { nameWithOwner: repository },
+ closingIssuesReferences: { nodes: linkedIssues },
+ ...overrides,
+});
+
+const select = (pullRequests) => selectPullRequestsToClose({ pullRequests, repository, defaultBranch: 'trunk' });
+
+test('closes a pull request linked only to the closed issue', () => {
+ assert.deepEqual(select([pullRequest(1, [issue(100, 'CLOSED')])]), { close: [1], keep: [] });
+});
+
+test('closes a pull request whose other linked issues are also closed', () => {
+ const result = select([pullRequest(1, [issue(100, 'CLOSED'), issue(101, 'CLOSED')])]);
+
+ assert.deepEqual(result.close, [1]);
+});
+
+test('keeps a pull request that is still linked to another open issue', () => {
+ const result = select([pullRequest(1, [issue(100, 'CLOSED'), issue(101, 'OPEN')])]);
+
+ assert.deepEqual(result.close, []);
+ assert.equal(result.keep[0].number, 1);
+});
+
+test('keeps a pull request that targets a non-default branch', () => {
+ const result = select([pullRequest(1, [issue(100, 'CLOSED')], { baseRefName: 'release/10.4' })]);
+
+ assert.deepEqual(result.close, []);
+});
+
+test('keeps a pull request from another repository', () => {
+ const result = select([
+ pullRequest(1, [issue(100, 'CLOSED')], { repository: { nameWithOwner: 'woocommerce/other' } }),
+ ]);
+
+ assert.deepEqual(result.close, []);
+});
+
+test('ignores merged and closed pull requests', () => {
+ const result = select([
+ pullRequest(1, [issue(100, 'CLOSED')], { state: 'MERGED' }),
+ pullRequest(2, [issue(100, 'CLOSED')], { state: 'CLOSED' }),
+ ]);
+
+ assert.deepEqual(result, { close: [], keep: [] });
+});