diff --git a/CHANGELOG.md b/CHANGELOG.md index fa6c5bf..2fb96d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +### Features + +- Danger - Add legal boilerplate validation for external contributors ([#145](https://github.com/getsentry/github-workflows/pull/145)) + - Verify that PRs from non-organization members include the required legal boilerplate from the PR template + - Show actionable markdown hints when the boilerplate is missing or doesn't match + ### Fixes - Sentry-CLI integration test action - Accept chunked ProGuard uploads for compatibility with Sentry CLI 3.x ([#140](https://github.com/getsentry/github-workflows/pull/140)) diff --git a/danger/CONTRIBUTING.md b/danger/CONTRIBUTING.md index 5bd26ee..1fd5e14 100644 --- a/danger/CONTRIBUTING.md +++ b/danger/CONTRIBUTING.md @@ -1,5 +1,16 @@ # Contributing +## Running tests + +The test suite uses Node.js's built-in test runner (requires Node 18+): + +```sh +cd danger +node --test +``` + +No dependencies to install — the tests use only `node:test` and `node:assert`. + ## How to run dangerfile locally - [Working on your Dangerfile](https://danger.systems/js/guides/the_dangerfile.html#working-on-your-dangerfile) diff --git a/danger/README.md b/danger/README.md index e979ab4..d904254 100644 --- a/danger/README.md +++ b/danger/README.md @@ -61,6 +61,7 @@ The Danger action runs the following checks: - **Action pinning**: Verifies GitHub Actions are pinned to specific commits for security - **Conventional commits**: Validates commit message format and PR title conventions - **Cross-repo links**: Checks for proper formatting of links in changelog entries +- **Legal boilerplate validation**: For external contributors (non-organization members), verifies the presence of required legal notices in PR descriptions when the repository's PR template includes a "Legal Boilerplate" section For detailed rule implementations, see [dangerfile.js](dangerfile.js). diff --git a/danger/dangerfile-utils.js b/danger/dangerfile-utils.js index daaed77..7689705 100644 --- a/danger/dangerfile-utils.js +++ b/danger/dangerfile-utils.js @@ -86,8 +86,122 @@ function extractPRFlavor(prTitle, prBranchRef) { return ""; } +/** @returns {string} The legal boilerplate section extracted from the content, or empty string if none found */ +function extractLegalBoilerplateSection(content) { + const lines = content.split('\n'); + const legalHeaderIndex = lines.findIndex(line => /^#{1,6}\s+Legal\s+Boilerplate/i.test(line)); + + if (legalHeaderIndex === -1) { + return ''; + } + + const sectionLines = [lines[legalHeaderIndex]]; + + for (let i = legalHeaderIndex + 1; i < lines.length; i++) { + if (/^#{1,6}\s+/.test(lines[i])) { + break; + } + sectionLines.push(lines[i]); + } + + return sectionLines.join('\n').trim(); +} + +const INTERNAL_ASSOCIATIONS = ['OWNER', 'MEMBER', 'COLLABORATOR']; + +const PR_TEMPLATE_PATHS = [ + '.github/PULL_REQUEST_TEMPLATE.md', + '.github/pull_request_template.md', + 'PULL_REQUEST_TEMPLATE.md', + 'pull_request_template.md', + '.github/PULL_REQUEST_TEMPLATE/pull_request_template.md' +]; + +/// Collapse all whitespace runs into single spaces for comparison. +function normalizeWhitespace(str) { + return str.replace(/\s+/g, ' ').trim(); +} + +/// Try each known PR template path and return the first one with content. +async function findPRTemplate(danger) { + for (const templatePath of PR_TEMPLATE_PATHS) { + const content = await danger.github.utils.fileContents(templatePath); + if (content) { + console.log(`::debug:: Found PR template at ${templatePath}`); + return content; + } + } + return null; +} + +/// Build a markdown hint showing the expected boilerplate text. +function formatBoilerplateHint(title, description, expectedBoilerplate) { + return `### ⚖️ ${title} + +${description} + +\`\`\`markdown +${expectedBoilerplate} +\`\`\` + +This is required to ensure proper intellectual property rights for your contributions.`; +} + +/// Check that external contributors include the required legal boilerplate in their PR body. +/// Accepts danger context and reporting functions as parameters for testability. +async function checkLegalBoilerplate({ danger, fail, markdown }) { + console.log('::debug:: Checking legal boilerplate requirements...'); + + const authorAssociation = danger.github.pr.author_association; + console.log(`::debug:: PR author_association: ${authorAssociation}`); + if (INTERNAL_ASSOCIATIONS.includes(authorAssociation)) { + console.log('::debug:: Skipping legal boilerplate check for organization member/collaborator'); + return; + } + + const prTemplateContent = await findPRTemplate(danger); + if (!prTemplateContent) { + console.log('::debug:: No PR template found, skipping legal boilerplate check'); + return; + } + + const expectedBoilerplate = extractLegalBoilerplateSection(prTemplateContent); + if (!expectedBoilerplate) { + console.log('::debug:: PR template does not contain a Legal Boilerplate section'); + return; + } + + const actualBoilerplate = extractLegalBoilerplateSection(danger.github.pr.body || ''); + + if (!actualBoilerplate) { + fail('This PR is missing the required legal boilerplate. As an external contributor, please include the "Legal Boilerplate" section from the PR template in your PR description.'); + markdown(formatBoilerplateHint( + 'Legal Boilerplate Required', + 'As an external contributor, your PR must include the legal boilerplate from the PR template.\n\nPlease add the following section to your PR description:', + expectedBoilerplate + )); + return; + } + + if (normalizeWhitespace(expectedBoilerplate) !== normalizeWhitespace(actualBoilerplate)) { + fail('The legal boilerplate in your PR description does not match the template. Please ensure you include the complete, unmodified legal text from the PR template.'); + markdown(formatBoilerplateHint( + 'Legal Boilerplate Mismatch', + 'Your PR contains a "Legal Boilerplate" section, but it doesn\'t match the required text from the template.\n\nPlease replace it with the exact text from the template:', + expectedBoilerplate + )); + return; + } + + console.log('::debug:: Legal boilerplate validated successfully'); +} + module.exports = { FLAVOR_CONFIG, getFlavorConfig, - extractPRFlavor + extractPRFlavor, + extractLegalBoilerplateSection, + normalizeWhitespace, + formatBoilerplateHint, + checkLegalBoilerplate }; diff --git a/danger/dangerfile-utils.test.js b/danger/dangerfile-utils.test.js index cfd1fe1..2ee97aa 100644 --- a/danger/dangerfile-utils.test.js +++ b/danger/dangerfile-utils.test.js @@ -1,6 +1,6 @@ const { describe, it } = require('node:test'); const assert = require('node:assert'); -const { getFlavorConfig, extractPRFlavor, FLAVOR_CONFIG } = require('./dangerfile-utils.js'); +const { getFlavorConfig, extractPRFlavor, extractLegalBoilerplateSection, normalizeWhitespace, formatBoilerplateHint, checkLegalBoilerplate, FLAVOR_CONFIG } = require('./dangerfile-utils.js'); describe('dangerfile-utils', () => { describe('getFlavorConfig', () => { @@ -275,4 +275,529 @@ describe('dangerfile-utils', () => { }); }); }); -}); \ No newline at end of file + + describe('extractLegalBoilerplateSection', () => { + it('should extract legal boilerplate section with ### header', () => { + const template = ` +# Pull Request Template + +## Description +Please describe your changes + +### Legal Boilerplate +Look, I get it. The entity doing business as "Sentry" was incorporated in the State of Delaware in 2015 as Functional Software, Inc. and is gonna need some rights from me in order to utilize my contributions in this here PR. So here's the deal: I retain all rights, title and interest in and to my contributions, and by keeping this boilerplate intact I confirm that Sentry can use, modify, copy, and redistribute my contributions, under Sentry's choice of terms. + +## Checklist +- [ ] Tests added +`; + + const result = extractLegalBoilerplateSection(template); + + assert.ok(result.includes('### Legal Boilerplate'), 'Should include the header'); + assert.ok(result.includes('Functional Software, Inc.'), 'Should include the legal text'); + assert.ok(!result.includes('## Checklist'), 'Should not include the next section'); + }); + + it('should extract legal boilerplate section with ## header', () => { + const template = ` +# Pull Request Template + +## Legal Boilerplate + +This is a legal notice. + +## Other Section +More content +`; + + const result = extractLegalBoilerplateSection(template); + + assert.strictEqual(result.trim(), '## Legal Boilerplate\n\nThis is a legal notice.'); + }); + + it('should extract legal boilerplate section with different heading levels', () => { + const testCases = [ + { header: '# Legal Boilerplate', text: 'Level 1 header' }, + { header: '## Legal Boilerplate', text: 'Level 2 header' }, + { header: '### Legal Boilerplate', text: 'Level 3 header' }, + { header: '#### Legal Boilerplate', text: 'Level 4 header' }, + { header: '##### Legal Boilerplate', text: 'Level 5 header' }, + { header: '###### Legal Boilerplate', text: 'Level 6 header' } + ]; + + testCases.forEach(({ header, text }) => { + const template = `${header}\n${text}\n## Next Section`; + const result = extractLegalBoilerplateSection(template); + + assert.ok(result.includes(header), `Should extract section with ${header}`); + assert.ok(result.includes(text), `Should include text for ${header}`); + assert.ok(!result.includes('Next Section'), `Should not include next section for ${header}`); + }); + }); + + it('should handle case-insensitive matching', () => { + const templates = [ + '### Legal Boilerplate\nContent', + '### legal boilerplate\nContent', + '### LEGAL BOILERPLATE\nContent', + '### Legal BOILERPLATE\nContent' + ]; + + templates.forEach(template => { + const result = extractLegalBoilerplateSection(template); + assert.ok(result.length > 0, `Should extract from: ${template.split('\n')[0]}`); + assert.ok(result.includes('Content'), `Should include content from: ${template.split('\n')[0]}`); + }); + }); + + it('should handle legal boilerplate with multiple paragraphs', () => { + const template = ` +### Legal Boilerplate + +First paragraph of legal text. + +Second paragraph of legal text. + +Third paragraph of legal text. + +## Next Section +`; + + const result = extractLegalBoilerplateSection(template); + + assert.ok(result.includes('First paragraph'), 'Should include first paragraph'); + assert.ok(result.includes('Second paragraph'), 'Should include second paragraph'); + assert.ok(result.includes('Third paragraph'), 'Should include third paragraph'); + assert.ok(!result.includes('Next Section'), 'Should not include next section'); + }); + + it('should handle legal boilerplate at end of template', () => { + const template = ` +# PR Template + +## Description +Content + +### Legal Boilerplate +Legal text at the end. +`; + + const result = extractLegalBoilerplateSection(template); + + assert.ok(result.includes('### Legal Boilerplate'), 'Should include header'); + assert.ok(result.includes('Legal text at the end.'), 'Should include text'); + }); + + it('should return empty string when no legal boilerplate section exists', () => { + const template = ` +# Pull Request Template + +## Description +Please describe your changes + +## Checklist +- [ ] Tests added +`; + + const result = extractLegalBoilerplateSection(template); + + assert.strictEqual(result, '', 'Should return empty string when no legal section found'); + }); + + it('should handle empty template', () => { + const result = extractLegalBoilerplateSection(''); + assert.strictEqual(result, '', 'Should return empty string for empty template'); + }); + + it('should handle template with only legal boilerplate section', () => { + const template = '### Legal Boilerplate\nThis is the only content.'; + const result = extractLegalBoilerplateSection(template); + + assert.ok(result.includes('### Legal Boilerplate'), 'Should include header'); + assert.ok(result.includes('This is the only content.'), 'Should include content'); + }); + + it('should handle legal boilerplate with special characters', () => { + const template = ` +### Legal Boilerplate +Text with special chars: @#$%^&*()_+-={}[]|\\:";'<>?,./ +And some unicode: 你好世界 🎉 + +## Next +`; + + const result = extractLegalBoilerplateSection(template); + + assert.ok(result.includes('special chars'), 'Should handle special characters'); + assert.ok(result.includes('你好世界'), 'Should handle unicode'); + assert.ok(result.includes('🎉'), 'Should handle emoji'); + }); + + it('should handle legal boilerplate with code blocks', () => { + const template = ` +### Legal Boilerplate + +Some text with code: + +\`\`\`javascript +const legal = true; +\`\`\` + +More text. + +## Next Section +`; + + const result = extractLegalBoilerplateSection(template); + + assert.ok(result.includes('const legal = true;'), 'Should include code blocks'); + assert.ok(result.includes('More text.'), 'Should include text after code block'); + assert.ok(!result.includes('Next Section'), 'Should not include next section'); + }); + + it('should handle legal boilerplate with lists', () => { + const template = ` +### Legal Boilerplate + +You agree to: +- Item 1 +- Item 2 +- Item 3 + +## Other +`; + + const result = extractLegalBoilerplateSection(template); + + assert.ok(result.includes('- Item 1'), 'Should include list items'); + assert.ok(result.includes('- Item 2'), 'Should include list items'); + assert.ok(result.includes('- Item 3'), 'Should include list items'); + }); + + it('should handle legal boilerplate with extra whitespace', () => { + const template = ` +### Legal Boilerplate +Content with spaces. +## Next +`; + + const result = extractLegalBoilerplateSection(template); + + assert.ok(result.includes('Content with spaces.'), 'Should handle extra whitespace in header'); + }); + + it('should stop at first subsequent header', () => { + const template = ` +### Legal Boilerplate +First section content. +### Another Legal Boilerplate +This should not be included. +`; + + const result = extractLegalBoilerplateSection(template); + + assert.ok(result.includes('First section content.'), 'Should include first section'); + assert.ok(!result.includes('This should not be included.'), 'Should stop at next header'); + }); + + it('should handle blank lines within legal section', () => { + const template = ` +### Legal Boilerplate + +First paragraph. + + +Second paragraph with blank lines above. + +## Next +`; + + const result = extractLegalBoilerplateSection(template); + + assert.ok(result.includes('First paragraph.'), 'Should include first paragraph'); + assert.ok(result.includes('Second paragraph'), 'Should include second paragraph'); + // Should preserve blank lines + const blankLineCount = (result.match(/\n\n/g) || []).length; + assert.ok(blankLineCount >= 1, 'Should preserve blank lines'); + }); + }); + + describe('normalizeWhitespace', () => { + it('should collapse multiple spaces into one', () => { + assert.strictEqual(normalizeWhitespace('hello world'), 'hello world'); + }); + + it('should collapse tabs and newlines into spaces', () => { + assert.strictEqual(normalizeWhitespace("hello\t\nworld"), 'hello world'); + }); + + it('should trim leading and trailing whitespace', () => { + assert.strictEqual(normalizeWhitespace(' hello world '), 'hello world'); + }); + + it('should return empty string for whitespace-only input', () => { + assert.strictEqual(normalizeWhitespace(' \t\n '), ''); + }); + + it('should leave single-spaced text unchanged', () => { + assert.strictEqual(normalizeWhitespace('already clean'), 'already clean'); + }); + }); + + describe('formatBoilerplateHint', () => { + it('should include the title with emoji prefix', () => { + const result = formatBoilerplateHint('My Title', 'Some description.', 'boilerplate text'); + assert.ok(result.includes('### ⚖️ My Title')); + }); + + it('should include the description', () => { + const result = formatBoilerplateHint('Title', 'Please fix this.', 'boilerplate'); + assert.ok(result.includes('Please fix this.')); + }); + + it('should include the boilerplate in a markdown code block', () => { + const result = formatBoilerplateHint('Title', 'Desc', 'expected legal text'); + assert.ok(result.includes('```markdown\nexpected legal text\n```')); + }); + + it('should include the IP rights notice', () => { + const result = formatBoilerplateHint('Title', 'Desc', 'text'); + assert.ok(result.includes('intellectual property rights')); + }); + }); + + describe('checkLegalBoilerplate', () => { + const PR_TEMPLATE_WITH_BOILERPLATE = `# Pull Request Template + +## Description +Please describe your changes + +### Legal Boilerplate +Look, I get it. The entity doing business as "Sentry" was incorporated in the State of Delaware in 2015 as Functional Software, Inc. and is gonna need some rights from me in order to utilize my contributions in this here PR. So here's the deal: I retain all rights, title and interest in and to my contributions, and by keeping this boilerplate intact I confirm that Sentry can use, modify, copy, and redistribute my contributions, under Sentry's choice of terms. + +## Checklist +- [ ] Tests added`; + + // Derived from the template to stay in sync automatically + const LEGAL_BOILERPLATE_SECTION = extractLegalBoilerplateSection(PR_TEMPLATE_WITH_BOILERPLATE); + const LEGAL_TEXT = LEGAL_BOILERPLATE_SECTION.replace('### Legal Boilerplate\n', ''); + + function buildMockContext({ prOverrides = {}, templateContent = PR_TEMPLATE_WITH_BOILERPLATE } = {}) { + const failMessages = []; + const markdownMessages = []; + + const danger = { + github: { + pr: { + author_association: 'CONTRIBUTOR', + body: '', + ...prOverrides + }, + utils: { + fileContents: async (path) => { + if (templateContent && path === '.github/PULL_REQUEST_TEMPLATE.md') { + return templateContent; + } + return ''; + } + } + } + }; + + return { + danger, + fail: (msg) => failMessages.push(msg), + markdown: (msg) => markdownMessages.push(msg), + failMessages, + markdownMessages + }; + } + + // --- Skips for internal contributors --- + + it('should skip check for OWNER association', async () => { + const ctx = buildMockContext({ prOverrides: { author_association: 'OWNER' } }); + await checkLegalBoilerplate(ctx); + assert.strictEqual(ctx.failMessages.length, 0); + assert.strictEqual(ctx.markdownMessages.length, 0); + }); + + it('should skip check for MEMBER association', async () => { + const ctx = buildMockContext({ prOverrides: { author_association: 'MEMBER' } }); + await checkLegalBoilerplate(ctx); + assert.strictEqual(ctx.failMessages.length, 0); + }); + + it('should skip check for COLLABORATOR association', async () => { + const ctx = buildMockContext({ prOverrides: { author_association: 'COLLABORATOR' } }); + await checkLegalBoilerplate(ctx); + assert.strictEqual(ctx.failMessages.length, 0); + }); + + // --- External contributor associations that should be checked --- + + it('should check for CONTRIBUTOR association', async () => { + const ctx = buildMockContext({ prOverrides: { author_association: 'CONTRIBUTOR', body: '' } }); + await checkLegalBoilerplate(ctx); + assert.strictEqual(ctx.failMessages.length, 1, 'Should fail for external CONTRIBUTOR without boilerplate'); + }); + + it('should check for FIRST_TIME_CONTRIBUTOR association', async () => { + const ctx = buildMockContext({ prOverrides: { author_association: 'FIRST_TIME_CONTRIBUTOR', body: '' } }); + await checkLegalBoilerplate(ctx); + assert.strictEqual(ctx.failMessages.length, 1); + }); + + it('should check for NONE association', async () => { + const ctx = buildMockContext({ prOverrides: { author_association: 'NONE', body: '' } }); + await checkLegalBoilerplate(ctx); + assert.strictEqual(ctx.failMessages.length, 1); + }); + + // --- Template discovery --- + + it('should skip when no PR template is found', async () => { + const ctx = buildMockContext({ templateContent: null }); + await checkLegalBoilerplate(ctx); + assert.strictEqual(ctx.failMessages.length, 0, 'Should not fail when no template exists'); + }); + + it('should skip when template has no Legal Boilerplate section', async () => { + const ctx = buildMockContext({ templateContent: '# Template\n\n## Description\nJust a normal template.' }); + await checkLegalBoilerplate(ctx); + assert.strictEqual(ctx.failMessages.length, 0, 'Should not fail when template lacks legal section'); + }); + + it('should find template at the first matching path', async () => { + const calledPaths = []; + const ctx = buildMockContext(); + ctx.danger.github.utils.fileContents = async (path) => { + calledPaths.push(path); + if (path === '.github/pull_request_template.md') { + return PR_TEMPLATE_WITH_BOILERPLATE; + } + return ''; + }; + ctx.danger.github.pr.body = `## My PR\n\n### Legal Boilerplate\n${LEGAL_TEXT}`; + await checkLegalBoilerplate(ctx); + assert.ok(calledPaths.includes('.github/PULL_REQUEST_TEMPLATE.md'), 'Should try uppercase path first'); + assert.ok(calledPaths.includes('.github/pull_request_template.md'), 'Should try lowercase path second'); + assert.ok(!calledPaths.includes('PULL_REQUEST_TEMPLATE.md'), 'Should stop after finding template'); + }); + + // --- Missing boilerplate in PR body --- + + it('should fail when external contributor PR body is empty', async () => { + const ctx = buildMockContext({ prOverrides: { author_association: 'NONE', body: '' } }); + await checkLegalBoilerplate(ctx); + assert.strictEqual(ctx.failMessages.length, 1); + assert.ok(ctx.failMessages[0].includes('missing the required legal boilerplate')); + assert.strictEqual(ctx.markdownMessages.length, 1); + assert.ok(ctx.markdownMessages[0].includes('Legal Boilerplate Required')); + }); + + it('should fail when external contributor PR body is null', async () => { + const ctx = buildMockContext({ prOverrides: { author_association: 'CONTRIBUTOR', body: null } }); + await checkLegalBoilerplate(ctx); + assert.strictEqual(ctx.failMessages.length, 1); + assert.ok(ctx.failMessages[0].includes('missing the required legal boilerplate')); + }); + + it('should fail when PR body has no legal section', async () => { + const ctx = buildMockContext({ + prOverrides: { + author_association: 'FIRST_TIME_CONTRIBUTOR', + body: '## Description\nMy cool changes\n\n## Checklist\n- [x] Tests' + } + }); + await checkLegalBoilerplate(ctx); + assert.strictEqual(ctx.failMessages.length, 1); + assert.ok(ctx.failMessages[0].includes('missing the required legal boilerplate')); + }); + + // --- Boilerplate mismatch --- + + it('should fail when boilerplate text is modified', async () => { + const ctx = buildMockContext({ + prOverrides: { + author_association: 'CONTRIBUTOR', + body: '### Legal Boilerplate\nI changed the legal text to something else entirely.' + } + }); + await checkLegalBoilerplate(ctx); + assert.strictEqual(ctx.failMessages.length, 1); + assert.ok(ctx.failMessages[0].includes('does not match the template')); + assert.strictEqual(ctx.markdownMessages.length, 1); + assert.ok(ctx.markdownMessages[0].includes('Legal Boilerplate Mismatch')); + }); + + it('should fail when boilerplate is truncated', async () => { + const ctx = buildMockContext({ + prOverrides: { + author_association: 'CONTRIBUTOR', + body: '### Legal Boilerplate\nLook, I get it. The entity doing business as "Sentry" was incorporated in the State of Delaware in 2015.' + } + }); + await checkLegalBoilerplate(ctx); + assert.strictEqual(ctx.failMessages.length, 1); + assert.ok(ctx.failMessages[0].includes('does not match the template')); + }); + + // --- Matching boilerplate (success cases) --- + + it('should pass when boilerplate matches exactly', async () => { + const ctx = buildMockContext({ + prOverrides: { + author_association: 'CONTRIBUTOR', + body: `## Description\nMy changes\n\n### Legal Boilerplate\n${LEGAL_TEXT}\n\n## Checklist\n- [x] Done` + } + }); + await checkLegalBoilerplate(ctx); + assert.strictEqual(ctx.failMessages.length, 0, 'Should not fail when boilerplate matches'); + assert.strictEqual(ctx.markdownMessages.length, 0); + }); + + it('should pass when boilerplate matches with different whitespace', async () => { + const ctx = buildMockContext({ + prOverrides: { + author_association: 'CONTRIBUTOR', + body: `### Legal Boilerplate\n${LEGAL_TEXT.replace(/\. /g, '.\n')}` + } + }); + await checkLegalBoilerplate(ctx); + assert.strictEqual(ctx.failMessages.length, 0, 'Should pass with normalized whitespace differences'); + }); + + it('should pass when boilerplate has extra surrounding whitespace', async () => { + const ctx = buildMockContext({ + prOverrides: { + author_association: 'CONTRIBUTOR', + body: `### Legal Boilerplate\n\n ${LEGAL_TEXT} \n\n## Next` + } + }); + await checkLegalBoilerplate(ctx); + assert.strictEqual(ctx.failMessages.length, 0); + }); + + // --- Markdown message content --- + + it('should include expected boilerplate in the markdown hint when missing', async () => { + const ctx = buildMockContext({ prOverrides: { author_association: 'NONE', body: '' } }); + await checkLegalBoilerplate(ctx); + assert.strictEqual(ctx.markdownMessages.length, 1); + assert.ok(ctx.markdownMessages[0].includes('Functional Software, Inc.'), 'Markdown should include the expected legal text'); + }); + + it('should include expected boilerplate in the markdown hint on mismatch', async () => { + const ctx = buildMockContext({ + prOverrides: { + author_association: 'CONTRIBUTOR', + body: '### Legal Boilerplate\nWrong text here.' + } + }); + await checkLegalBoilerplate(ctx); + assert.strictEqual(ctx.markdownMessages.length, 1); + assert.ok(ctx.markdownMessages[0].includes('Functional Software, Inc.')); + }); + }); +}); diff --git a/danger/dangerfile.js b/danger/dangerfile.js index d5feaa4..84eaef5 100644 --- a/danger/dangerfile.js +++ b/danger/dangerfile.js @@ -1,4 +1,4 @@ -const { getFlavorConfig, extractPRFlavor } = require('./dangerfile-utils.js'); +const { getFlavorConfig, extractPRFlavor, checkLegalBoilerplate } = require('./dangerfile-utils.js'); const headRepoName = danger.github.pr.head.repo.git_url; const baseRepoName = danger.github.pr.base.repo.git_url; @@ -231,6 +231,7 @@ async function checkAll() { await checkDocs(); await checkChangelog(); await checkActionsArePinned(); + await checkLegalBoilerplate({ danger, fail, markdown }); await checkFromExternalChecks(); }