Skip to content
Draft
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
12 changes: 12 additions & 0 deletions .github/DENOUNCED
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Denounced contributors.
#
# Users listed here have their pull requests automatically closed by the
# PR Gate workflow regardless of any other signal (codeowner status, org
# membership, repo collaborator role, issue assignment, /assign tag).
#
# Format: one GitHub username per line, with an optional reason after a
# colon. Comments start with `#`. Blank lines are ignored.
#
# Example:
# baduser
# spammer: opens AI-generated PRs that ignore review feedback
18 changes: 18 additions & 0 deletions .github/VOUCHED
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Vouched contributors.
#
# Users listed here bypass the issue-assignment requirement of the PR
# Gate workflow. Their pull requests are admitted as if they were org
# members or repo collaborators. This is the path for trusted external
# contributors who have demonstrated they understand the project well
# enough that requiring a fresh issue claim for every PR is overhead.
#
# Codeowners, repo collaborators, and circlefin org members are already
# trusted through earlier checks — they do not need to be vouched here.
#
# Format: one GitHub username per line, with an optional reason after a
# colon for maintainer reference. Comments start with `#`. Blank lines
# are ignored.
#
# Example:
# trusted-contributor
# long-time-helper: maintainer of the upstream foo crate
7 changes: 7 additions & 0 deletions .github/pr-gate-messages/default.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Hi @{{prAuthor}},

Thank you for your interest in contributing to Malachite.

This PR has been automatically closed because it does not meet our contribution requirements.

Please see our [CONTRIBUTING.md](https://github.com/{{owner}}/{{repo}}/blob/main/CONTRIBUTING.md) for details on how to contribute properly.
5 changes: 5 additions & 0 deletions .github/pr-gate-messages/denounced.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Hi @{{prAuthor}},

This PR has been automatically closed because you are listed in this repository's [`DENOUNCED`](https://github.com/{{owner}}/{{repo}}/blob/main/.github/DENOUNCED) file.{{reasonBlock}}

If you believe this is in error, please reach out to the maintainers.
12 changes: 12 additions & 0 deletions .github/pr-gate-messages/issue_not_found.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
Hi @{{prAuthor}},

Thank you for your interest in contributing to Malachite.

This PR has been automatically closed because the referenced issue could not be found. Please ensure you reference a valid, existing issue using the format `Closes: #XXX`.

**To contribute properly:**
1. Find an existing issue you'd like to work on, or [open a new issue](https://github.com/{{owner}}/{{repo}}/issues/new) describing your proposed change
2. Comment on the issue requesting assignment and wait for maintainer approval
3. Only submit a PR after you have been assigned to the issue

Please see our [CONTRIBUTING.md](https://github.com/{{owner}}/{{repo}}/blob/main/CONTRIBUTING.md) for more details.
12 changes: 12 additions & 0 deletions .github/pr-gate-messages/no_issue_reference.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
Hi @{{prAuthor}},

Thank you for your interest in contributing to Malachite.

This PR has been automatically closed because it does not reference a GitHub issue. All PRs must reference an existing issue using the format `Closes: #XXX`.

**To contribute properly:**
1. Find an existing issue you'd like to work on, or [open a new issue](https://github.com/{{owner}}/{{repo}}/issues/new) describing your proposed change
2. Comment on the issue requesting assignment and wait for maintainer approval
3. Only submit a PR after you have been assigned to the issue

Please see our [CONTRIBUTING.md](https://github.com/{{owner}}/{{repo}}/blob/main/CONTRIBUTING.md) for more details.
12 changes: 12 additions & 0 deletions .github/pr-gate-messages/not_assigned_to_issue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
Hi @{{prAuthor}},

Thank you for your interest in contributing to Malachite.

This PR has been automatically closed because you are not assigned to issue #{{issueNumber}}. We require contributors to be explicitly assigned to an issue before submitting a PR.

**To contribute properly:**
1. Comment on issue #{{issueNumber}} requesting assignment
2. Wait for maintainer approval
3. Only submit a PR after you have been assigned

Please see our [CONTRIBUTING.md](https://github.com/{{owner}}/{{repo}}/blob/main/CONTRIBUTING.md) for more details.
95 changes: 95 additions & 0 deletions .github/scripts/pr-gate/close.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Close-action for the PR Gate workflow.
//
// Reads a markdown template from the base ref of the repo, substitutes
// {{placeholder}} variables, posts the rendered message as a comment,
// labels the PR `need-triage`, then closes the PR.
//
// Templates live under .github/pr-gate-messages/ and the template name
// equals the rejection reason emitted by evaluate.js — falls back to
// `default.md` for unknown reasons.

const FALLBACK_TEMPLATE = 'Hi @{{prAuthor}}, this PR was closed because it does not meet our contribution requirements. See CONTRIBUTING.md.';

const KNOWN_TEMPLATES = new Set([
'denounced',
'no_issue_reference',
'not_assigned_to_issue',
'issue_not_found',
]);

function renderTemplate(template, vars) {
return Object.entries(vars).reduce(
(acc, [k, v]) => acc.replaceAll(`{{${k}}}`, v),
template,
);
}

function pickTemplateName(reason) {
return KNOWN_TEMPLATES.has(reason) ? reason : 'default';
}

function buildVars({ prAuthor, issueNumber, owner, repo, denounceReason }) {
return {
prAuthor,
issueNumber,
owner,
repo,
reasonBlock: denounceReason ? ` Reason: ${denounceReason}.` : '',
};
}

async function close({ github, context, core, inputs }) {
const { reason, issueNumber, denounceReason } = inputs;
const prAuthor = context.payload.pull_request.user.login;
const { owner, repo } = context.repo;

const templateName = pickTemplateName(reason);

let template;
try {
const response = await github.rest.repos.getContent({
owner,
repo,
path: `.github/pr-gate-messages/${templateName}.md`,
ref: context.payload.pull_request.base.ref,
});
template = Buffer.from(response.data.content, 'base64').toString('utf-8');
} catch (error) {
core.info(`Could not load template ${templateName}.md: ${error.message}`);
template = FALLBACK_TEMPLATE;
}

const message = renderTemplate(
template,
buildVars({ prAuthor, issueNumber, owner, repo, denounceReason }),
);

await github.rest.issues.createComment({
owner,
repo,
issue_number: context.payload.pull_request.number,
body: message,
});

try {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: context.payload.pull_request.number,
labels: ['need-triage'],
});
} catch (error) {
core.info(`Could not add label (may not exist): ${error.message}`);
}

await github.rest.pulls.update({
owner,
repo,
pull_number: context.payload.pull_request.number,
state: 'closed',
});

core.info('PR closed successfully');
}

module.exports = { close, renderTemplate, pickTemplateName, buildVars, KNOWN_TEMPLATES };
70 changes: 70 additions & 0 deletions .github/scripts/pr-gate/close.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
const test = require('node:test');
const assert = require('node:assert/strict');

const {
renderTemplate,
pickTemplateName,
buildVars,
KNOWN_TEMPLATES,
} = require('./close');

test('renderTemplate: substitutes single placeholder', () => {
assert.equal(
renderTemplate('Hi @{{prAuthor}}!', { prAuthor: 'alice' }),
'Hi @alice!',
);
});

test('renderTemplate: substitutes multiple placeholders, repeated', () => {
const out = renderTemplate(
'{{owner}}/{{repo}} — see {{owner}}/{{repo}}/blob/main',
{ owner: 'circlefin', repo: 'malachite' },
);
assert.equal(out, 'circlefin/malachite — see circlefin/malachite/blob/main');
});

test('renderTemplate: missing placeholder is left literal', () => {
// We don't pre-validate — placeholders in templates that aren't in vars
// pass through, which is intentional so non-overlapping templates can
// share a single vars object.
assert.equal(
renderTemplate('Hi @{{prAuthor}}, issue #{{issueNumber}}', {
prAuthor: 'alice',
}),
'Hi @alice, issue #{{issueNumber}}',
);
});

test('pickTemplateName: known reason returned verbatim', () => {
for (const reason of KNOWN_TEMPLATES) {
assert.equal(pickTemplateName(reason), reason);
}
});

test('pickTemplateName: unknown reason falls back to default', () => {
assert.equal(pickTemplateName('something-else'), 'default');
assert.equal(pickTemplateName(''), 'default');
assert.equal(pickTemplateName(undefined), 'default');
});

test('buildVars: includes reasonBlock when denounceReason provided', () => {
const v = buildVars({
prAuthor: 'a',
issueNumber: '1',
owner: 'o',
repo: 'r',
denounceReason: 'spammer',
});
assert.equal(v.reasonBlock, ' Reason: spammer.');
});

test('buildVars: omits reasonBlock when no denounceReason', () => {
const v = buildVars({
prAuthor: 'a',
issueNumber: '1',
owner: 'o',
repo: 'r',
denounceReason: '',
});
assert.equal(v.reasonBlock, '');
});
Loading
Loading