Repository navigation
Check that a pull request follows the template #116
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+589
−11
Merged
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
e4975dc
Check that a pull request body follows the template
akolson bd86259
Carry the pull request template to consumers
akolson 784d984
Fix the review findings on the template check
akolson b1b7bac
Fail a required section left as the template wrote it
akolson 568391c
Clarify the pull request template notes in the automation docs
akolson 30053d3
Put the spreadsheet note back beside the automations it refers to
akolson 46964b9
Reword the template read docstring
akolson 2b495ee
Name the source repo in the template marker
akolson d017cf1
Split the body and the template on CRLF as well as LF
akolson 4f4e7cf
Drop the unused body fixture from the template tests
akolson c494075
Revert the pull request template propagation
akolson 81286a9
Cover the sectionless template and the heading case
akolson File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| name: Check that a pull request follows the template | ||
| on: | ||
| workflow_call: | ||
| secrets: | ||
| LE_BOT_APP_ID: | ||
| description: 'GitHub App ID for authentication' | ||
| required: true | ||
| LE_BOT_PRIVATE_KEY: | ||
| description: 'GitHub App Private Key for authentication' | ||
| required: true | ||
| jobs: | ||
| check-template: | ||
| name: Check template | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - name: Generate App Token | ||
| id: generate-token | ||
| uses: actions/create-github-app-token@v3 | ||
| with: | ||
| app-id: ${{ secrets.LE_BOT_APP_ID }} | ||
| private-key: ${{ secrets.LE_BOT_PRIVATE_KEY }} | ||
| - name: Checkout .github repository | ||
| uses: actions/checkout@v7 | ||
| with: | ||
| repository: learningequality/.github | ||
| ref: main | ||
| token: ${{ steps.generate-token.outputs.token }} | ||
| - name: Setup Node.js | ||
| uses: actions/setup-node@v7 | ||
| with: | ||
| node-version: '20' | ||
| - name: Install dependencies | ||
| run: yarn install --frozen-lockfile | ||
| - name: Run script | ||
| id: script | ||
| uses: actions/github-script@v9 | ||
| with: | ||
| github-token: ${{ steps.generate-token.outputs.token }} | ||
| script: | | ||
| const script = require('./scripts/pull-request-template.js'); | ||
| return await script({ github, context, core }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,141 @@ | ||
| /** | ||
| * Fails the run when a pull request body does not carry the sections that its | ||
| * template marks as required. | ||
| */ | ||
|
|
||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
|
|
||
| const { isBot } = require('./utils'); | ||
| const { PR_TEMPLATE_PATHS, PR_TEMPLATE_TARGET } = require('./constants'); | ||
|
|
||
| const ORG_TEMPLATE_PATH = path.join(__dirname, '..', PR_TEMPLATE_TARGET); | ||
|
|
||
| const HEADING = /^## [^#]/; | ||
| const REQUIRED_MARKER = /^\s*<!--\s*REQUIRED\s*-->\s*$/; | ||
| const HTML_COMMENT = /<!--[\s\S]*?-->/g; | ||
| const PLACEHOLDER = '…'; | ||
|
|
||
| function sections(markdown) { | ||
| const out = []; | ||
| for (const line of (markdown || '').split(/\r?\n/)) { | ||
| if (HEADING.test(line)) out.push({ heading: line.trim(), lines: [] }); | ||
| else if (out.length) out[out.length - 1].lines.push(line); | ||
| } | ||
| return out; | ||
| } | ||
|
|
||
| function isMarked(section) { | ||
| const first = section.lines.find(line => line.trim() !== ''); | ||
| return first !== undefined && REQUIRED_MARKER.test(first); | ||
| } | ||
|
|
||
| // A template that marks nothing requires every section, so a repo whose template | ||
| // has no markers is strict rather than unchecked. | ||
| function requiredSections(template) { | ||
| const all = sections(template); | ||
| const marked = all.filter(isMarked); | ||
| return marked.length ? marked : all; | ||
| } | ||
|
|
||
| function content(section) { | ||
| return section.lines.join('\n').replace(HTML_COMMENT, '').trim(); | ||
| } | ||
|
|
||
| function failures(body, required) { | ||
| const found = new Map(sections(body).map(section => [section.heading.toLowerCase(), section])); | ||
| const problems = []; | ||
| for (const { heading, lines } of required) { | ||
| const section = found.get(heading.toLowerCase()); | ||
| if (!section) { | ||
| problems.push(`\`${heading}\` is missing.`); | ||
| continue; | ||
| } | ||
| const text = content(section); | ||
| if (text === '') { | ||
| problems.push(`\`${heading}\` is empty.`); | ||
| } else if (text.split('\n').some(line => line.trim() === PLACEHOLDER)) { | ||
| // The template puts the placeholder on a line of its own, so an ellipsis | ||
| // inside a sentence is prose rather than an unfilled section. | ||
| problems.push(`\`${heading}\` still holds the \`${PLACEHOLDER}\` placeholder.`); | ||
| } else if (text === content({ lines })) { | ||
|
rtibblesbot marked this conversation as resolved.
|
||
| problems.push(`\`${heading}\` still holds the text from the template.`); | ||
| } | ||
| } | ||
| return problems; | ||
| } | ||
|
|
||
| /** | ||
| * The repo's own template, `null` if none exists, or `{ unreadable }` if a | ||
| * candidate path returns anything other than a 404. An unreadable path may | ||
| * contain a repo-owned template, so validating against the shared template | ||
| * could reject a body that follows the template the author was given. | ||
| */ | ||
| async function readRepoTemplate({ github, context, core }, ref) { | ||
| for (const templatePath of PR_TEMPLATE_PATHS) { | ||
| try { | ||
| const response = await github.rest.repos.getContent({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| path: templatePath, | ||
| ref, | ||
| }); | ||
| if (response.data?.content) { | ||
| core.info(`Checking against ${templatePath} on ${ref}.`); | ||
| return { template: Buffer.from(response.data.content, 'base64').toString('utf8') }; | ||
| } | ||
| } catch (error) { | ||
| if (error.status !== 404) { | ||
|
rtibblesbot marked this conversation as resolved.
|
||
| return { unreadable: `${templatePath}: ${error.message}` }; | ||
| } | ||
| } | ||
| } | ||
| return { template: null }; | ||
| } | ||
|
|
||
| module.exports = async ({ github, context, core }) => { | ||
| try { | ||
| const pullRequest = context.payload.pull_request; | ||
| const author = pullRequest.user.login; | ||
|
|
||
| if (await isBot(author, { core })) { | ||
|
rtibblesbot marked this conversation as resolved.
|
||
| core.info(`${author} is a bot, so the template is not checked.`); | ||
| return; | ||
| } | ||
|
|
||
| const found = await readRepoTemplate({ github, context, core }, pullRequest.base.ref); | ||
| if (found.unreadable) { | ||
| core.warning(`Could not read ${found.unreadable}, so the template is not checked.`); | ||
| return; | ||
| } | ||
|
|
||
| const template = found.template ?? fs.readFileSync(ORG_TEMPLATE_PATH, 'utf8'); | ||
| if (!found.template) { | ||
| core.info('No template in this repo, so the shared template applies.'); | ||
| } | ||
|
|
||
| const required = requiredSections(template); | ||
| if (!required.length) { | ||
| core.warning('The template holds no sections, so there is nothing to check.'); | ||
| return; | ||
| } | ||
|
|
||
| const problems = failures(pullRequest.body, required); | ||
| if (!problems.length) { | ||
| core.info(`The body carries all ${required.length} required sections.`); | ||
| return; | ||
| } | ||
|
|
||
| for (const problem of problems) { | ||
| core.error(problem); | ||
| } | ||
| core.setFailed( | ||
| `The description does not follow the pull request template:\n` + | ||
| problems.map(problem => `- ${problem}`).join('\n') + | ||
| `\n\nRequired: ${required.map(section => section.heading).join(', ')}. ` + | ||
| `Edit the description and the check runs again.`, | ||
| ); | ||
| } catch (error) { | ||
| core.setFailed(`Action failed with error: ${error.message}`); | ||
| } | ||
| }; | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.