From 7d7b29c530726e086240f408483324903288fb58 Mon Sep 17 00:00:00 2001 From: Myron Chen <125855279+myronchen-git@users.noreply.github.com> Date: Wed, 19 Nov 2025 17:35:04 -0800 Subject: [PATCH 1/3] Create workflow to check linked PR --- .../check-closed-issue-for-linked-pr.yml | 44 +++++ .../check-issue-labels-and-linked-prs.js | 71 +++++++ .../check-issue-labels-and-linked-prs.test.js | 176 ++++++++++++++++++ .../check-for-linked-issue/reopen-issue.js | 80 ++++++++ 4 files changed, 371 insertions(+) create mode 100644 .github/workflows/check-closed-issue-for-linked-pr.yml create mode 100644 github-actions/check-closed-issue-for-linked-pr/check-for-linked-issue/check-issue-labels-and-linked-prs.js create mode 100644 github-actions/check-closed-issue-for-linked-pr/check-for-linked-issue/check-issue-labels-and-linked-prs.test.js create mode 100644 github-actions/check-closed-issue-for-linked-pr/check-for-linked-issue/reopen-issue.js diff --git a/.github/workflows/check-closed-issue-for-linked-pr.yml b/.github/workflows/check-closed-issue-for-linked-pr.yml new file mode 100644 index 0000000000..d84395e2b0 --- /dev/null +++ b/.github/workflows/check-closed-issue-for-linked-pr.yml @@ -0,0 +1,44 @@ +name: Check Closed Issue for Linked PR + +on: + issues: + types: [closed] + +jobs: + check-for-linked-issue: + runs-on: ubuntu-latest + steps: + - name: Check Out Repository + uses: actions/checkout@v4 + + - name: Check Issue Labels And Linked PRs + uses: actions/github-script@v8 + id: check-issue-labels-and-linked-prs + with: + script: | + const script = require( + './github-actions' + + '/check-closed-issue-for-linked-pr' + + '/check-for-linked-issue' + + '/check-issue-labels-and-linked-prs.js' + ); + const isValidClose = await script({github, context}); + console.log( + `Issue is ${isValidClose ? '' : 'not '}allowed to be closed.` + ); + core.setOutput('isValidClose', isValidClose); + + - name: Reopen Issue + if: steps.check-issue-labels-and-linked-prs.outputs.isValidClose == 'false' + uses: actions/github-script@v8 + id: reopen-issue + with: + github-token: ${{ secrets.HACKFORLA_GRAPHQL_TOKEN }} + script: | + const script = require( + './github-actions' + + '/check-closed-issue-for-linked-pr' + + '/check-for-linked-issue' + + '/reopen-issue.js' + ); + await script({github, context}); diff --git a/github-actions/check-closed-issue-for-linked-pr/check-for-linked-issue/check-issue-labels-and-linked-prs.js b/github-actions/check-closed-issue-for-linked-pr/check-for-linked-issue/check-issue-labels-and-linked-prs.js new file mode 100644 index 0000000000..81fd3f9120 --- /dev/null +++ b/github-actions/check-closed-issue-for-linked-pr/check-for-linked-issue/check-issue-labels-and-linked-prs.js @@ -0,0 +1,71 @@ +/** + * Checks whether a closed issue has a linked PR or one of the labels to excuse + * this GitHub Actions workflow. + * + * @param {{github: object, context: object}} actionsGithubScriptArgs - GitHub + * objects from actions/github-script + * @returns {boolean} False if the issue does not have a linked PR, a "non-PR + * contribution" label, or an "Ignore..." label. + */ +async function hasLinkedPrOrExcusableLabel({ github, context }) { + const repoOwner = context.repo.owner; + const repoName = context.repo.repo; + const issueNumber = context.payload.issue.number; + + const labels = context.payload.issue.labels.map((label) => label.name); + + // -------------------------------------------------- + + // Check if the issue has the labels that will avoid re-opening it. + if ( + labels.some( + (label) => label === 'non-PR contribution' || label.includes('Ignore') + ) + ) + return true; + console.info( + `Issue #${issueNumber} does not have ` + + `the necessary labels to excuse reopening it.` + ); + + // Use GitHub's GraphQL's closedByPullRequestsReferences to more reliably + // determine if there is a linked PR. + const query = `query($owner: String!, $repo: String!, $issue: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $issue) { + closedByPullRequestsReferences(includeClosedPrs: true, first: 1) { + totalCount + } + } + } + }`; + + const variables = { + owner: repoOwner, + repo: repoName, + issue: issueNumber, + }; + + try { + const response = await github.graphql(query, variables); + + const numLinkedPrs = + response.repository.issue.closedByPullRequestsReferences.totalCount; + + console.debug(`Number of linked PRs found: ${numLinkedPrs}.`); + + if (numLinkedPrs > 0) return true; + } catch (err) { + throw new Error( + `Can not find issue #${issueNumber} or its PR count; error = ${err}` + ); + } + console.info(`Issue #${issueNumber} does not have a linked PR.`); + + // If the issue does not have a linked PR or any of the excusable labels. + return false; +} + +// ================================================== + +module.exports = hasLinkedPrOrExcusableLabel; diff --git a/github-actions/check-closed-issue-for-linked-pr/check-for-linked-issue/check-issue-labels-and-linked-prs.test.js b/github-actions/check-closed-issue-for-linked-pr/check-for-linked-issue/check-issue-labels-and-linked-prs.test.js new file mode 100644 index 0000000000..234cb42ae4 --- /dev/null +++ b/github-actions/check-closed-issue-for-linked-pr/check-for-linked-issue/check-issue-labels-and-linked-prs.test.js @@ -0,0 +1,176 @@ +'use strict'; + +const hasLinkedPrOrExcusableLabel = require('./check-issue-labels-and-linked-prs'); + +// ================================================== + +// Create the github and context mocks. Freezing Objects to prevent accidental +// changes. +const github = Object.freeze({ graphql: jest.fn() }); +const context = deepFreeze({ + repo: { + owner: 'owner1', + repo: 'repo1', + }, + payload: { + issue: { + number: 1, + labels: [], + }, + }, +}); + +describe('hasLinkedPrOrExcusableLabel', () => { + let contextCopy; + + beforeEach(() => { + contextCopy = structuredClone(context); + jest.resetAllMocks(); + }); + + test.each([ + [[{ name: 'non-PR contribution' }]], + [ + [ + { name: 'non-PR contribution' }, + { name: 'good first issue' }, + { name: 'size: 1pt' }, + ], + ], + ])( + 'If the issue has the "non-PR contribution" label, then return true. ' + + 'Labels: %j.', + async (labelsList) => { + // Arrange + contextCopy.payload.issue.labels = labelsList; + + // Act + const result = await hasLinkedPrOrExcusableLabel({ + github, + context: contextCopy, + }); + + // Assert + expect(result).toBe(true); + expect(github.graphql).not.toHaveBeenCalled(); + } + ); + + test.each([ + [[{ name: 'Ignore: Test' }]], + [ + [ + { name: 'Ignore: Test' }, + { name: 'good first issue' }, + { name: 'size: 1pt' }, + ], + ], + ])( + 'If the issue has a label that includes "Ignore", then return true. ' + + 'Labels: %j', + async (labelsList) => { + // Arrange + contextCopy.payload.issue.labels = labelsList; + + // Act + const result = await hasLinkedPrOrExcusableLabel({ + github, + context: contextCopy, + }); + + // Assert + expect(result).toBe(true); + expect(github.graphql).not.toHaveBeenCalled(); + } + ); + + test('If the issue has a linked PR, then return true.', async () => { + // Arrange + github.graphql.mockResolvedValue({ + repository: { + issue: { + closedByPullRequestsReferences: { + totalCount: 1, + }, + }, + }, + }); + + // Act + const result = await hasLinkedPrOrExcusableLabel({ + github, + context: contextCopy, + }); + + // Assert + expect(result).toBe(true); + expect(github.graphql).toHaveBeenCalledWith( + expect.stringContaining('query'), + { + owner: context.repo.owner, + repo: context.repo.repo, + issue: context.payload.issue.number, + } + ); + }); + + test( + 'If there is no linked PR nor any of the excusable labels, ' + + 'then return false.', + async () => { + // Arrange + github.graphql.mockResolvedValue({ + repository: { + issue: { + closedByPullRequestsReferences: { + totalCount: 0, + }, + }, + }, + }); + + // Act + const result = await hasLinkedPrOrExcusableLabel({ + github, + context: contextCopy, + }); + + // Assert + expect(result).toBe(false); + expect(github.graphql).toHaveBeenCalledWith( + expect.stringContaining('query'), + { + owner: context.repo.owner, + repo: context.repo.repo, + issue: context.payload.issue.number, + } + ); + } + ); +}); + +// ================================================== + +/** + * Helper function taken from MDN. Freezes nested Objects. + * + * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze#deep_freezing + * + * @param {*} object - Any JavaScript Object. + * @returns Passed-in Object. + */ +function deepFreeze(object) { + // Retrieve the property names defined on object + const propNames = Reflect.ownKeys(object); + + // Freeze properties before freezing self + for (const name of propNames) { + const value = object[name]; + + if ((value && typeof value === 'object') || typeof value === 'function') { + deepFreeze(value); + } + } + + return Object.freeze(object); +} diff --git a/github-actions/check-closed-issue-for-linked-pr/check-for-linked-issue/reopen-issue.js b/github-actions/check-closed-issue-for-linked-pr/check-for-linked-issue/reopen-issue.js new file mode 100644 index 0000000000..26f6a7e4ee --- /dev/null +++ b/github-actions/check-closed-issue-for-linked-pr/check-for-linked-issue/reopen-issue.js @@ -0,0 +1,80 @@ +const queryIssueInfo = require('../../utils/query-issue-info'); +const mutateIssueStatus = require('../../utils/mutate-issue-status'); +const postComment = require('../../utils/post-issue-comment'); + +const statusFieldIds = require('../../utils/_data/status-field-ids'); +const labelDirectory = require('../../utils/_data/label-directory.json'); + +// ================================================== + +/** + * Reopens an issue that does not have a linked PR or excusable labels. Adds a + * "ready for product" label, sets the project status to Questions / In Review", + * and posts a comment to the issue. + * + * @param {{github: object, context: object}} actionsGithubScriptArgs - + * GitHub objects from actions/github-script + */ +async function reopenIssue({ github, context }) { + const repoOwner = context.repo.owner; + const repoName = context.repo.repo; + const issueNumber = context.payload.issue.number; + + const labelsToAdd = [labelDirectory.readyForPM[0]]; + + const newStatusFieldId = statusFieldIds('Questions_In_Review'); + + const comment = + 'This issue was reopened because ' + + `it did not have any of the following: +- A linked PR, +- An \`Ignore\` label +- A \`non-PR contribution\` label`; + + // -------------------------------------------------- + + // Add the "ready for product" label. + try { + await github.rest.issues.addLabels({ + owner: repoOwner, + repo: repoName, + issue_number: issueNumber, + labels: labelsToAdd, + }); + } catch (err) { + throw new Error( + `Unable to add "ready for product" label to issue #${issueNumber}; ` + + `error = ${err}` + ); + } + console.info(`Added "ready for product" label to issue #${issueNumber}.`); + + // Change the project status of the issue to "Questions / In Review". + const issueInfo = await queryIssueInfo(github, context, issueNumber); + await mutateIssueStatus(github, context, issueInfo.id, newStatusFieldId); + console.info( + `Changed project status to ` + + `"Questions / In Review" in issue #${issueNumber}.` + ); + + // Post comment to the issue. + await postComment(issueNumber, comment, github, context); + console.info(`Posted comment to issue #${issueNumber}.`); + + // Re-opening the issue. + try { + await github.rest.issues.update({ + owner: repoOwner, + repo: repoName, + issue_number: issueNumber, + state: 'open', + }); + } catch (err) { + throw new Error(`Unable to reopen issue #${issueNumber}; error = ${err}`); + } + console.info(`Reopened issue #${issueNumber}.`); +} + +// ================================================== + +module.exports = reopenIssue; From ee6c1774874e0994927509d4f2653de648568f6d Mon Sep 17 00:00:00 2001 From: Myron Chen <125855279+myronchen-git@users.noreply.github.com> Date: Sat, 17 Jan 2026 08:31:09 -0800 Subject: [PATCH 2/3] update workflow in response to review --- .../check-closed-issue-for-linked-pr.yml | 5 +--- .../check-issue-labels-and-linked-prs.js | 29 +++++++++++++++++-- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/.github/workflows/check-closed-issue-for-linked-pr.yml b/.github/workflows/check-closed-issue-for-linked-pr.yml index d84395e2b0..183270ca99 100644 --- a/.github/workflows/check-closed-issue-for-linked-pr.yml +++ b/.github/workflows/check-closed-issue-for-linked-pr.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check Out Repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Check Issue Labels And Linked PRs uses: actions/github-script@v8 @@ -23,9 +23,6 @@ jobs: + '/check-issue-labels-and-linked-prs.js' ); const isValidClose = await script({github, context}); - console.log( - `Issue is ${isValidClose ? '' : 'not '}allowed to be closed.` - ); core.setOutput('isValidClose', isValidClose); - name: Reopen Issue diff --git a/github-actions/check-closed-issue-for-linked-pr/check-for-linked-issue/check-issue-labels-and-linked-prs.js b/github-actions/check-closed-issue-for-linked-pr/check-for-linked-issue/check-issue-labels-and-linked-prs.js index 81fd3f9120..717be66d2f 100644 --- a/github-actions/check-closed-issue-for-linked-pr/check-for-linked-issue/check-issue-labels-and-linked-prs.js +++ b/github-actions/check-closed-issue-for-linked-pr/check-for-linked-issue/check-issue-labels-and-linked-prs.js @@ -1,3 +1,14 @@ +const retrieveLabelDirectory = require('../../utils/retrieve-label-directory'); + +// Use labelKeys to retrieve current labelNames from directory +const [ + nonPrContribution +] = [ + 'NEW-nonPrContribution' +].map(retrieveLabelDirectory); + +// ================================================== + /** * Checks whether a closed issue has a linked PR or one of the labels to excuse * this GitHub Actions workflow. @@ -14,15 +25,22 @@ async function hasLinkedPrOrExcusableLabel({ github, context }) { const labels = context.payload.issue.labels.map((label) => label.name); + const consoleMessageAllowClose = + `Issue #${issueNumber} is allowed to be closed.`; + // -------------------------------------------------- // Check if the issue has the labels that will avoid re-opening it. if ( labels.some( - (label) => label === 'non-PR contribution' || label.includes('Ignore') + (label) => + label === nonPrContribution || label.toLowerCase().includes('ignore') ) - ) + ) { + console.info(consoleMessageAllowClose); return true; + } + console.info( `Issue #${issueNumber} does not have ` + `the necessary labels to excuse reopening it.` @@ -46,6 +64,7 @@ async function hasLinkedPrOrExcusableLabel({ github, context }) { issue: issueNumber, }; + // Determine if there is a linked PR. try { const response = await github.graphql(query, variables); @@ -54,7 +73,10 @@ async function hasLinkedPrOrExcusableLabel({ github, context }) { console.debug(`Number of linked PRs found: ${numLinkedPrs}.`); - if (numLinkedPrs > 0) return true; + if (numLinkedPrs > 0) { + console.info(consoleMessageAllowClose); + return true; + } } catch (err) { throw new Error( `Can not find issue #${issueNumber} or its PR count; error = ${err}` @@ -63,6 +85,7 @@ async function hasLinkedPrOrExcusableLabel({ github, context }) { console.info(`Issue #${issueNumber} does not have a linked PR.`); // If the issue does not have a linked PR or any of the excusable labels. + console.info(`Issue #${issueNumber} is not allowed to be closed.`); return false; } From 7646a1d21ed87ea0573f2891205b2e378b2cda09 Mon Sep 17 00:00:00 2001 From: Myron Chen <125855279+myronchen-git@users.noreply.github.com> Date: Sat, 24 Jan 2026 10:27:37 -0800 Subject: [PATCH 3/3] Add delay to allow other GH Action changes --- .github/workflows/check-closed-issue-for-linked-pr.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/check-closed-issue-for-linked-pr.yml b/.github/workflows/check-closed-issue-for-linked-pr.yml index 183270ca99..794ec7cb0e 100644 --- a/.github/workflows/check-closed-issue-for-linked-pr.yml +++ b/.github/workflows/check-closed-issue-for-linked-pr.yml @@ -25,6 +25,12 @@ jobs: const isValidClose = await script({github, context}); core.setOutput('isValidClose', isValidClose); + # Sleep to allow other GitHub Actions to change project status. + - name: Sleep + id: sleep + shell: bash + run: sleep 30s + - name: Reopen Issue if: steps.check-issue-labels-and-linked-prs.outputs.isValidClose == 'false' uses: actions/github-script@v8