diff --git a/.github/workflows/automation-caller.yml b/.github/workflows/automation-caller.yml index e465ad5..5b00f1f 100644 --- a/.github/workflows/automation-caller.yml +++ b/.github/workflows/automation-caller.yml @@ -21,8 +21,10 @@ on: # zizmor: ignore[dangerous-triggers] - created pull_request_target: types: + - edited - labeled - opened + - reopened - review_requested schedule: - cron: 1 0 * * 1 diff --git a/.github/workflows/automation-group-pull-request-target.yml b/.github/workflows/automation-group-pull-request-target.yml index 6bb2f97..fcedadd 100644 --- a/.github/workflows/automation-group-pull-request-target.yml +++ b/.github/workflows/automation-group-pull-request-target.yml @@ -8,10 +8,10 @@ on: workflow_call: secrets: LE_BOT_APP_ID: - description: "Forwarded to: review-requested, pull-request-label, dependabot-reviewer, contributor-pr-reply" + description: "Forwarded to: review-requested, pull-request-label, dependabot-reviewer, contributor-pr-reply, pull-request-template" required: true LE_BOT_PRIVATE_KEY: - description: "Forwarded to: review-requested, pull-request-label, dependabot-reviewer, contributor-pr-reply" + description: "Forwarded to: review-requested, pull-request-label, dependabot-reviewer, contributor-pr-reply, pull-request-template" required: true SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: description: "Forwarded to: contributor-pr-reply" @@ -54,3 +54,12 @@ jobs: LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: ${{ secrets.SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL }} + pull-request-template: + name: Pull Request Template + if: ${{ github.event_name == 'pull_request_target' && contains(fromJSON('["opened","edited","reopened"]'), github.event.action) }} + uses: learningequality/.github/.github/workflows/pull-request-template.yml@main + permissions: + contents: read + secrets: + LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} + LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} diff --git a/.github/workflows/automation.yml b/.github/workflows/automation.yml index fea55fd..18d6ccf 100644 --- a/.github/workflows/automation.yml +++ b/.github/workflows/automation.yml @@ -18,10 +18,10 @@ on: description: Unused - accepted until every consumer stops forwarding it required: false LE_BOT_APP_ID: - description: "Forwarded to: review-requested, pull-request-label, dependabot-reviewer, contributor-pr-reply, issue-open, issue-label-header, good-first-issue-comment, community-contribution-label, contributor-issue-comment, unassign-inactive-issues" + description: "Forwarded to: review-requested, pull-request-label, dependabot-reviewer, contributor-pr-reply, pull-request-template, issue-open, issue-label-header, good-first-issue-comment, community-contribution-label, contributor-issue-comment, unassign-inactive-issues" required: true LE_BOT_PRIVATE_KEY: - description: "Forwarded to: review-requested, pull-request-label, dependabot-reviewer, contributor-pr-reply, issue-open, issue-label-header, good-first-issue-comment, community-contribution-label, contributor-issue-comment, unassign-inactive-issues" + description: "Forwarded to: review-requested, pull-request-label, dependabot-reviewer, contributor-pr-reply, pull-request-template, issue-open, issue-label-header, good-first-issue-comment, community-contribution-label, contributor-issue-comment, unassign-inactive-issues" required: true SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: description: "Forwarded to: contributor-pr-reply, contributor-issue-comment, unassign-inactive-issues" @@ -32,7 +32,7 @@ on: jobs: pull-request-target: name: Pull Request Target - if: ${{ (github.event_name == 'pull_request_target' && github.event.action == 'review_requested') || (github.event_name == 'pull_request_target' && github.event.action == 'labeled') || (github.event_name == 'pull_request_target' && github.event.action == 'opened' && github.event.pull_request.user.login == 'dependabot[bot]') || (github.event_name == 'pull_request_target' && github.event.action == 'opened') }} + if: ${{ (github.event_name == 'pull_request_target' && github.event.action == 'review_requested') || (github.event_name == 'pull_request_target' && github.event.action == 'labeled') || (github.event_name == 'pull_request_target' && github.event.action == 'opened' && github.event.pull_request.user.login == 'dependabot[bot]') || (github.event_name == 'pull_request_target' && github.event.action == 'opened') || (github.event_name == 'pull_request_target' && contains(fromJSON('["opened","edited","reopened"]'), github.event.action)) }} uses: learningequality/.github/.github/workflows/automation-group-pull-request-target.yml@main permissions: contents: read diff --git a/.github/workflows/pull-request-template.yml b/.github/workflows/pull-request-template.yml new file mode 100644 index 0000000..91610e9 --- /dev/null +++ b/.github/workflows/pull-request-template.yml @@ -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 }); diff --git a/automation-registry.yml b/automation-registry.yml index 2080521..27a0ac9 100644 --- a/automation-registry.yml +++ b/automation-registry.yml @@ -64,6 +64,16 @@ automations: LE_BOT_APP_ID: required LE_BOT_PRIVATE_KEY: required SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: optional + - name: pull-request-template + enabled: true + leaf: pull-request-template.yml + on: + pull_request_target: + types: [opened, edited, reopened] + if: "github.event_name == 'pull_request_target' && contains(fromJSON('[\"opened\",\"edited\",\"reopened\"]'), github.event.action)" + secrets: + LE_BOT_APP_ID: required + LE_BOT_PRIVATE_KEY: required - name: holiday-message # Seasonal. The leaf has no date guard, so this must be flipped on before # the holidays and off after them. diff --git a/automation-template.yml b/automation-template.yml index 321b23a..bb4e2cf 100644 --- a/automation-template.yml +++ b/automation-template.yml @@ -22,8 +22,10 @@ on: # zizmor: ignore[dangerous-triggers] - created pull_request_target: types: + - edited - labeled - opened + - reopened - review_requested schedule: - cron: 1 0 * * 1 diff --git a/docs/automation.md b/docs/automation.md index 13ca3f7..943fe89 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -5,9 +5,9 @@ contributor replies, issue header management, etc.) copies a single file: `autom There is nothing else to maintain per repo — no per-automation caller, no hand-written `on:` block to keep in sync. -The community-contribution spreadsheet is not one of them. `update-pr-spreadsheet.yml` runs daily -in this repo and upserts every public contributor PR in the org updated in the last two days. Run it -by hand with `dry_run` to log the rows without writing, or a larger `days` to backfill. +The community-contribution spreadsheet is not one of those automations. `update-pr-spreadsheet.yml` +runs daily in this repo and upserts every public contributor PR in the org updated in the last two +days. Run it by hand with `dry_run` to log the rows without writing, or a larger `days` to backfill. ## How it fits together @@ -95,9 +95,9 @@ every repo whose `.github/workflows/automation.yml` calls this repo's `automatio request targets that repo's default branch, which is the only branch GitHub evaluates workflow triggers from. -For each consumer it compares the copy with the template and opens a pull request when they differ. -A repo that is already in sync gets nothing, while a repo with an existing sync pull request has -that pull request updated rather than a second one opened. +For each consumer, the workflow compares its copy of `automation.yml` with the template and opens a +pull request if they differ. Repositories with no drift are left unchanged, and existing sync pull +requests are updated rather than duplicated. The workflow only proposes changes. It opens pull requests on a branch, never commits to a default branch, and never merges, approves, or enables auto-merge. A core maintainer in each consumer repo diff --git a/docs/community-automations.md b/docs/community-automations.md index 87153e9..561c198 100644 --- a/docs/community-automations.md +++ b/docs/community-automations.md @@ -78,6 +78,30 @@ In `scripts/constants.js` set: - `BOT_MESSAGE_LINK_ISSUE`: Text added when no issue is linked - `BOT_MESSAGE_PULL_REQUEST_CLOSED`: Closing message text +# `pull-request-template` + +Fails the run when a pull request description does not carry the sections its template requires. It runs on `opened`, `edited` and `reopened`, so a corrected description clears the check. Bot authors are exempt. + +The template is resolved the way GitHub resolves it: the repo's own copy first, in all six places GitHub reads them from, and this repo's `.github/PULL_REQUEST_TEMPLATE.md` next. It is read from the pull request's base branch, so a pull request cannot relax the rules it is judged by. + +A section is required when its heading carries a `` comment in the template: + +```markdown +## Summary + +``` + +A template that marks no section requires every level-two heading, so that a repo is strict rather than silently unchecked. To change what a repo requires, edit the markers in that repo's template. No change is required here. + +Four states fail, and the log names every one of them rather than stopping at the first: + +- the heading is missing from the description +- the section holds no text, once HTML comments are stripped +- the section still holds the `…` placeholder +- the section still reads word for word as the template wrote it + +The failed check is the only report. Nothing is posted on the pull request. + # `holiday-message` Sends a holiday message to community pull requests and issue comments. diff --git a/scripts/constants.js b/scripts/constants.js index 41479a4..d92e6e3 100644 --- a/scripts/constants.js +++ b/scripts/constants.js @@ -167,6 +167,20 @@ const PR_STATS_REPOS = [ // Repositories in which we accept open-source contributions const COMMUNITY_REPOS = [...PR_STATS_REPOS]; +// Every place GitHub reads a pull request template from. The contents API is +// case sensitive, so both spellings are needed. +const PR_TEMPLATE_PATHS = [ + '.github/pull_request_template.md', + '.github/PULL_REQUEST_TEMPLATE.md', + 'pull_request_template.md', + 'PULL_REQUEST_TEMPLATE.md', + 'docs/pull_request_template.md', + 'docs/PULL_REQUEST_TEMPLATE.md', +]; + +// This repo's own template, which applies to a repo holding none of its own. +const PR_TEMPLATE_TARGET = '.github/PULL_REQUEST_TEMPLATE.md'; + module.exports = { LE_BOT_USERNAME, BOT_USERNAMES, @@ -195,4 +209,6 @@ module.exports = { HOLIDAY_MESSAGE, PR_STATS_REPOS, COMMUNITY_REPOS, + PR_TEMPLATE_PATHS, + PR_TEMPLATE_TARGET, }; diff --git a/scripts/pull-request-template.js b/scripts/pull-request-template.js new file mode 100644 index 0000000..17f093a --- /dev/null +++ b/scripts/pull-request-template.js @@ -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*$/; +const HTML_COMMENT = //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 })) { + 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) { + 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 })) { + 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}`); + } +}; diff --git a/scripts/pull-request-template.test.js b/scripts/pull-request-template.test.js new file mode 100644 index 0000000..cadfce0 --- /dev/null +++ b/scripts/pull-request-template.test.js @@ -0,0 +1,333 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const pullRequestTemplate = require('./pull-request-template'); +const { LE_BOT_USERNAME } = require('./constants'); + +const MARKED = [ + '## Summary', + '', + '', + '', + '…', + '', + '## Notes', + '', + 'anything', + '', +].join('\n'); + +const UNMARKED = ['## Summary', '', '…', '', '## Notes', '', 'anything', ''].join('\n'); + +// Shaped like morango's template, which fills the section with prose rather than +// with the placeholder. +const PROSE_PLACEHOLDER = [ + '## Summary', + '', + '', + '*Short description*', + '', + '## Notes', + '', + 'anything', + '', +].join('\n'); + +// A required section of more than one line, as kolibri-design-system's Changelog +// is. The GitHub web form returns the body with CRLF line endings. +const MULTILINE = [ + '## Changelog', + '', + '', + '- **Description:** Summary of change(s)', + '- **Breaking:** yes / no', + '', +].join('\n'); + +const GOOD_BODY = ['## Summary', '', 'What this does.', '', '## Notes', '', 'A note.'].join('\n'); + +function fakeGithub({ templates = {} } = {}) { + const reads = []; + return { + reads, + rest: { + repos: { + getContent: async ({ path, ref }) => { + reads.push({ path, ref }); + if (!(path in templates)) { + const error = new Error('Not Found'); + error.status = 404; + throw error; + } + return { data: { content: Buffer.from(templates[path], 'utf8').toString('base64') } }; + }, + }, + }, + }; +} + +function fakeContext({ body = GOOD_BODY, author = 'contributor', base = 'develop' } = {}) { + return { + repo: { owner: 'learningequality', repo: 'kolibri' }, + payload: { + pull_request: { number: 42, body, user: { login: author }, base: { ref: base } }, + }, + }; +} + +function fakeCore() { + const failures = []; + const errors = []; + const warnings = []; + return { + failures, + errors, + warnings, + info() {}, + warning(message) { + warnings.push(message); + }, + error(message) { + errors.push(message); + }, + setFailed(message) { + failures.push(message); + }, + }; +} + +const repoTemplate = body => ({ '.github/pull_request_template.md': body }); + +test('a body carrying every marked section passes', async () => { + const core = fakeCore(); + await pullRequestTemplate({ + github: fakeGithub({ templates: repoTemplate(MARKED) }), + context: fakeContext(), + core, + }); + + assert.deepEqual(core.failures, []); +}); + +test('a missing marked section fails and is named', async () => { + const core = fakeCore(); + await pullRequestTemplate({ + github: fakeGithub({ templates: repoTemplate(MARKED) }), + context: fakeContext({ body: '## Notes\n\nA note.' }), + core, + }); + + assert.equal(core.failures.length, 1); + assert.match(core.failures[0], /`## Summary` is missing/); +}); + +test('an empty marked section fails', async () => { + const core = fakeCore(); + await pullRequestTemplate({ + github: fakeGithub({ templates: repoTemplate(MARKED) }), + context: fakeContext({ body: '## Summary\n\n\n' }), + core, + }); + + assert.match(core.failures[0], /`## Summary` is empty/); +}); + +test('a marked section still holding the placeholder fails', async () => { + const core = fakeCore(); + await pullRequestTemplate({ + github: fakeGithub({ templates: repoTemplate(MARKED) }), + context: fakeContext({ body: '## Summary\n\n…\n' }), + core, + }); + + assert.match(core.failures[0], /placeholder/); +}); + +test('an unmarked section is not required', async () => { + const core = fakeCore(); + await pullRequestTemplate({ + github: fakeGithub({ templates: repoTemplate(MARKED) }), + context: fakeContext({ body: '## Summary\n\nWhat this does.' }), + core, + }); + + assert.deepEqual(core.failures, []); +}); + +test('a template marking nothing requires every section', async () => { + const core = fakeCore(); + await pullRequestTemplate({ + github: fakeGithub({ templates: repoTemplate(UNMARKED) }), + context: fakeContext({ body: '## Summary\n\nWhat this does.' }), + core, + }); + + assert.equal(core.failures.length, 1); + assert.match(core.failures[0], /`## Notes` is missing/); +}); + +test('a template holding no sections warns and fails nothing', async () => { + const core = fakeCore(); + await pullRequestTemplate({ + github: fakeGithub({ templates: repoTemplate('Open a pull request and describe it.\n') }), + context: fakeContext({ body: '' }), + core, + }); + + assert.deepEqual(core.failures, []); + assert.equal(core.warnings.length, 1); + assert.match(core.warnings[0], /holds no sections/); +}); + +test('a heading matches whatever case the body writes it in', async () => { + const core = fakeCore(); + await pullRequestTemplate({ + github: fakeGithub({ templates: repoTemplate(MARKED) }), + context: fakeContext({ body: '## summary\n\nWhat this does.' }), + core, + }); + + assert.deepEqual(core.failures, []); +}); + +test('every failure is named, not only the first', async () => { + const core = fakeCore(); + await pullRequestTemplate({ + github: fakeGithub({ templates: repoTemplate(UNMARKED) }), + context: fakeContext({ body: '' }), + core, + }); + + assert.equal(core.errors.length, 2); + assert.match(core.failures[0], /## Summary/); + assert.match(core.failures[0], /## Notes/); +}); + +test('an empty body fails rather than throwing', async () => { + const core = fakeCore(); + await pullRequestTemplate({ + github: fakeGithub({ templates: repoTemplate(MARKED) }), + context: fakeContext({ body: null }), + core, + }); + + assert.equal(core.failures.length, 1); +}); + +test('a bot author passes without a template read', async () => { + const core = fakeCore(); + const github = fakeGithub({ templates: repoTemplate(MARKED) }); + await pullRequestTemplate({ + github, + context: fakeContext({ body: '', author: LE_BOT_USERNAME }), + core, + }); + + assert.deepEqual(core.failures, []); + assert.deepEqual(github.reads, []); +}); + +test('the template is read from the base branch, never the head', async () => { + const github = fakeGithub({ templates: repoTemplate(MARKED) }); + await pullRequestTemplate({ github, context: fakeContext({ base: 'release-v1' }), core: fakeCore() }); + + assert.ok(github.reads.length); + for (const read of github.reads) { + assert.equal(read.ref, 'release-v1'); + } +}); + +test('an uppercase template in the repo is found', async () => { + const core = fakeCore(); + // A marked heading the shared template does not carry, so falling back to the + // shared one could not produce this failure. + const own = ['## Evidence', '', '', 'what to paste', ''].join('\n'); + const github = fakeGithub({ templates: { '.github/PULL_REQUEST_TEMPLATE.md': own } }); + await pullRequestTemplate({ github, context: fakeContext({ body: '' }), core }); + + assert.equal(core.failures.length, 1); + assert.match(core.failures[0], /## Evidence/); + assert.ok(!core.failures[0].includes('## AI usage'), 'the shared template must not be reached'); +}); + +test('a repo holding no template falls back to the shared one', async () => { + const core = fakeCore(); + await pullRequestTemplate({ + github: fakeGithub(), + context: fakeContext({ body: '## Summary\n\nWhat this does.' }), + core, + }); + + // The shared template asks for References, Reviewer guidance and AI usage too. + assert.equal(core.failures.length, 1); + assert.match(core.failures[0], /## AI usage/); +}); + +test('an unreadable candidate path skips the check rather than falling back', async () => { + const core = fakeCore(); + const github = fakeGithub(); + github.rest.repos.getContent = async () => { + const error = new Error('server error'); + error.status = 500; + throw error; + }; + // An empty body: falling back to the shared template would fail it. + await pullRequestTemplate({ github, context: fakeContext({ body: '' }), core }); + + assert.equal(core.warnings.length, 1); + assert.match(core.warnings[0], /Could not read/); + assert.deepEqual(core.failures, [], 'a body may follow a template this run could not read'); +}); + +test('a section left as the template wrote it fails, placeholder or not', async () => { + const core = fakeCore(); + await pullRequestTemplate({ + github: fakeGithub({ templates: repoTemplate(PROSE_PLACEHOLDER) }), + context: fakeContext({ body: '## Summary\n\n*Short description*\n' }), + core, + }); + + assert.equal(core.failures.length, 1); + assert.match(core.failures[0], /`## Summary` still holds the text from the template/); +}); + +test('rewriting that text passes', async () => { + const core = fakeCore(); + await pullRequestTemplate({ + github: fakeGithub({ templates: repoTemplate(PROSE_PLACEHOLDER) }), + context: fakeContext({ body: '## Summary\n\nRetries the push on a timeout.\n' }), + core, + }); + + assert.deepEqual(core.failures, []); +}); + +test('a CRLF body left as the template wrote it fails too', async () => { + const core = fakeCore(); + const body = [ + '## Changelog', + '', + '- **Description:** Summary of change(s)', + '- **Breaking:** yes / no', + '', + ].join('\r\n'); + await pullRequestTemplate({ + github: fakeGithub({ templates: repoTemplate(MULTILINE) }), + context: fakeContext({ body }), + core, + }); + + assert.equal(core.failures.length, 1); + assert.match(core.failures[0], /`## Changelog` still holds the text from the template/); +}); + +test('an ellipsis inside a sentence is prose, not the placeholder', async () => { + const core = fakeCore(); + await pullRequestTemplate({ + github: fakeGithub({ templates: repoTemplate(MARKED) }), + context: fakeContext({ body: '## Summary\n\nAdds a Loading… spinner.\n' }), + core, + }); + + assert.deepEqual(core.failures, []); +});