diff --git a/.github/workflows/auto_pr_review.yaml b/.github/workflows/auto_pr_review.yaml index 0b748063bc..74911b9100 100644 --- a/.github/workflows/auto_pr_review.yaml +++ b/.github/workflows/auto_pr_review.yaml @@ -1,6 +1,7 @@ name: "PR review" on: pull_request_target: + types: [opened, reopened, synchronize, edited, edited] jobs: labeler: @@ -32,3 +33,166 @@ jobs: change-to: ${{ github.base_ref }} already-exists-action: close_this already-exists-comment: "Your PR should be made against the `master` branch" + + check-pr-template: + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + pull-requests: write + actions: read + if: github.event.pull_request.head.repo.full_name != 'commaai/openpilot' + steps: + - uses: actions/github-script@v7 + with: + script: | + // Comment to add to the PR if no template has been used + const NO_TEMPLATE_MESSAGE = + "It looks like you didn't use on of the Pull Request templates. Please check [the contributing docs](https://github.com/commaai/openpilot/blob/master/docs/CONTRIBUTING.md). \ + Also make sure that you didn't modify any of the checkboxes or headings within the template."; + // body data for future requests + const body_data = { + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + }; + + // Utility function to extract all headings + const extractHeadings = (markdown) => { + const headingRegex = /^(#{1,6})\s+(.+)$/gm; + const boldTextRegex = /^(?:\*\*|__)(.+?)(?:\*\*|__)\s*$/gm; + const headings = []; + let headingMatch; + while ((headingMatch = headingRegex.exec(markdown))) { + headings.push(headingMatch[2].trim()); + } + let boldMatch; + while ((boldMatch = boldTextRegex.exec(markdown))) { + headings.push(boldMatch[1].trim()); + } + return headings; + }; + + // Utility function to extract all check box descriptions + const extractCheckBoxTexts = (markdown) => { + const checkboxRegex = /^\s*-\s*\[( |x)\]\s+(.+)$/gm; + const checkboxes = []; + let match; + while ((match = checkboxRegex.exec(markdown))) { + checkboxes.push(match[2].trim()); + } + return checkboxes; + }; + + // Utility function to check if a list is a subset of another list + isSubset = (subset, superset) => { + return subset.every((item) => superset.includes(item)); + }; + + // Add an 'in-bot-review' label while this PR is under review + github.rest.issues.addLabels({ + ...body_data, + labels: ["in-bot-review"], + }); + + // Get filenames of all currently checked-in PR templates + const template_contents = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path: ".github/PULL_REQUEST_TEMPLATE", + }); + var template_filenames = []; + for (const content of template_contents.data) { + template_filenames.push(content.path); + } + console.debug("Received template filenames: " + template_filenames); + // Retrieve templates + var templates = []; + for (const template_filename of template_filenames) { + const template_response = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path: template_filename, + }); + // Convert Base64 content back + const decoded_template = atob(template_response.data.content); + const headings = extractHeadings(decoded_template); + const checkboxes = extractCheckBoxTexts(decoded_template); + if (!headings.length && !checkboxes.length) { + console.warn( + "Invalid template! Contains neither headings nor checkboxes, ignoring it: \n" + + decoded_template + ); + } else { + templates.push({ headings: headings, checkboxes: checkboxes }); + } + } + // Retrieve the PR Body + const pull_request = await github.rest.issues.get({ + ...body_data, + }); + const pull_request_text = pull_request.data.body; + console.debug("Received Pull Request body: \n" + pull_request_text); + + /* Check if the PR Body matches one of the templates + A template is defined by all headings and checkboxes it contains + We extract all Headings and Checkboxes from the PR text and check if any of the templates is a subset of that + */ + const pr_headings = extractHeadings(pull_request_text); + const pr_checkboxes = extractCheckBoxTexts(pull_request_text); + console.debug("Found Headings in PR body:\n" + pr_headings); + console.debug("Found Checkboxes in PR body:\n" + pr_checkboxes); + var template_found = false; + // Iterate over each template to check if it applies + for (const template of templates) { + console.log( + "Checking for headings: [" + + template.headings + + "] and checkboxes: [" + + template.checkboxes + "]" + ); + if ( + isSubset(template.checkboxes, pr_checkboxes) && + isSubset(template.headings, pr_headings) + ) { + console.debug("Found matching template!"); + template_found = true; + } + } + + // List comments from previous runs + var existing_comments = []; + const comments = await github.rest.issues.listComments({ + ...body_data, + }); + for (const comment of comments.data) { + if (comment.body === NO_TEMPLATE_MESSAGE) { + existing_comments.push(comment); + } + } + + // Add a comment to the PR that it is not using a the template (but only if this comment does not exist already) + if (!template_found) { + var comment_already_sent = false; + + if (existing_comments.length < 1) { + github.rest.issues.createComment({ + ...body_data, + body: NO_TEMPLATE_MESSAGE, + }); + } + } else { + // If template has been found, delete any old comment about missing template + for (const existing_comment of existing_comments) { + github.rest.issues.deleteComment({ + ...body_data, + comment_id: existing_comment.id, + }); + } + // Remove the 'in-bot-review' label after the review is done and the PR has passed + github.rest.issues.removeLabel({ + ...body_data, + name: "in-bot-review", + }); + } +