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..794ec7cb0e --- /dev/null +++ b/.github/workflows/check-closed-issue-for-linked-pr.yml @@ -0,0 +1,47 @@ +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@v6 + + - 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}); + 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 + 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..717be66d2f --- /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,94 @@ +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. + * + * @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); + + 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 === nonPrContribution || label.toLowerCase().includes('ignore') + ) + ) { + console.info(consoleMessageAllowClose); + 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, + }; + + // Determine if there is a linked PR. + 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) { + console.info(consoleMessageAllowClose); + 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. + console.info(`Issue #${issueNumber} is not allowed to be closed.`); + return false; +} + +// ================================================== + +module.exports = hasLinkedPrOrExcusableLabel; 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;