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;