Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/automation-caller.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 11 additions & 2 deletions .github/workflows/automation-group-pull-request-target.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 }}
6 changes: 3 additions & 3 deletions .github/workflows/automation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down
41 changes: 41 additions & 0 deletions .github/workflows/pull-request-template.yml
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:
Comment thread
rtibblesbot marked this conversation as resolved.
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 });
10 changes: 10 additions & 0 deletions automation-registry.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions automation-template.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions docs/automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions docs/community-automations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<!-- REQUIRED -->` comment in the template:

```markdown
## Summary
<!-- REQUIRED -->
```

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.
Expand Down
16 changes: 16 additions & 0 deletions scripts/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -195,4 +209,6 @@ module.exports = {
HOLIDAY_MESSAGE,
PR_STATS_REPOS,
COMMUNITY_REPOS,
PR_TEMPLATE_PATHS,
PR_TEMPLATE_TARGET,
};
141 changes: 141 additions & 0 deletions scripts/pull-request-template.js
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 })) {
Comment thread
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) {
Comment thread
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 })) {
Comment thread
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}`);
}
};
Loading
Loading