Files

215 lines
8.9 KiB
YAML

name: PR Gate
on:
pull_request_target:
types: [opened, reopened, ready_for_review, edited]
issues:
types: [labeled]
concurrency:
group: pr-gate-${{ github.event_name }}-${{ github.event.action }}-${{ github.event.pull_request.number || github.event.issue.number }}
cancel-in-progress: ${{ github.event_name == 'pull_request_target' }}
env:
GATE_EFFECTIVE_FROM: '2026-08-12T00:00:00Z'
permissions:
contents: read
pull-requests: write
issues: read
jobs:
gate:
if: >-
github.event_name == 'pull_request_target' &&
github.event.action != 'edited' &&
github.event.pull_request.draft == false &&
github.event.pull_request.user.type != 'Bot' &&
github.event.pull_request.head.repo.full_name != github.repository &&
!contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association)
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
const { owner, repo } = context.repo;
const effectiveFrom = process.env.GATE_EFFECTIVE_FROM;
if (effectiveFrom && Date.parse(pr.created_at) < Date.parse(effectiveFrom)) {
core.info(`Opened ${pr.created_at}, before the gate took effect ${effectiveFrom}. Skipped.`);
return;
}
const { data: current } = await github.rest.pulls.get({
owner, repo, pull_number: pr.number,
});
if (current.state !== 'open') {
core.info(`#${pr.number} is already ${current.state}. Skipped.`);
return;
}
const files = await github.paginate(github.rest.pulls.listFiles, {
owner, repo, pull_number: pr.number, per_page: 100,
});
const rootDocs = new Set(['README.md', 'CONTRIBUTING.md', 'CODE_OF_CONDUCT.md', 'SECURITY.md']);
const isDocs = (filename) => filename.startsWith('docs/') || rootDocs.has(filename);
if (files.length > 0 && files.every((file) => isDocs(file.filename))) {
core.info('Docs-only PR, gate skipped');
return;
}
const { repository } = await github.graphql(
`query ($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
closingIssuesReferences(first: 20) {
nodes { number labels(first: 50) { nodes { name } } }
}
}
}
}`,
{ owner, repo, number: pr.number },
);
const accepted = repository.pullRequest.closingIssuesReferences.nodes
.filter((issue) => issue.labels.nodes.some((label) => label.name === 'accepted'))
.map((issue) => issue.number);
if (accepted.length > 0) {
core.info(`Accepted issue linked: #${accepted.join(', #')}`);
return;
}
const body = [
'<!-- pr-gate -->',
'Thanks for taking the time to open this.',
'',
'We only review pull requests that fix an issue we have already agreed to take on, so this one is closed for now.',
'**Closed does not mean rejected.** It means it is not in the queue yet, and reopening takes about a minute.',
'',
'To get it reviewed:',
'',
'1. Make sure an issue describes the problem, with the version you are on, a runnable reproduction, and the real output or traceback you saw.',
'2. Link it from this pull request description with `Closes #<number>`.',
'3. Ask a maintainer to label that issue `accepted`. This pull request reopens by itself when they do.',
'',
'Issue already labeled `accepted`? Just add `Closes #<number>` to the description. That reopens this too.',
'',
'Documentation-only changes skip this gate entirely.',
'',
'See [CONTRIBUTING.md](https://github.com/mem0ai/mem0/blob/main/CONTRIBUTING.md) for the full policy.',
].join('\n');
await github.rest.issues.createComment({
owner, repo, issue_number: pr.number, body,
});
await github.rest.pulls.update({
owner, repo, pull_number: pr.number, state: 'closed',
});
core.info(`Closed #${pr.number}: no accepted issue linked`);
reopen:
if: >-
(github.event_name == 'issues' && github.event.label.name == 'accepted') ||
(github.event.action == 'edited' && github.event.pull_request.state == 'closed')
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
with:
script: |
const { owner, repo } = context.repo;
const marker = '<!-- pr-gate -->';
const denounced = await (async () => {
try {
const { data } = await github.rest.repos.getContent({
owner, repo, path: '.github/VOUCHED.td',
ref: context.payload.repository.default_branch,
});
return new Set(Buffer.from(data.content, 'base64').toString('utf8')
.split('\n')
.map((line) => line.trim())
.filter((line) => line.startsWith('-'))
.map((line) => line.slice(1).split(/\s+/)[0].split(':').pop().toLowerCase())
.filter(Boolean));
} catch (error) {
core.warning(`Could not read VOUCHED.td, treating nobody as denounced: ${error.message}`);
return new Set();
}
})();
const isReopenable = async (number) => {
const { repository } = await github.graphql(
`query ($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
state
author { login }
closingIssuesReferences(first: 20) {
nodes { labels(first: 50) { nodes { name } } }
}
}
}
}`,
{ owner, repo, number },
);
const pullRequest = repository.pullRequest;
if (denounced.has(pullRequest.author?.login?.toLowerCase())) {
core.info(`#${number} is from a denounced author. Vouch outranks this gate.`);
return false;
}
return pullRequest.state === 'CLOSED' &&
pullRequest.closingIssuesReferences.nodes.some((issue) =>
issue.labels.nodes.some((label) => label.name === 'accepted'));
};
let candidates;
if (context.eventName === 'issues') {
const { repository } = await github.graphql(
`query ($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
issue(number: $number) {
closedByPullRequestsReferences(first: 20, includeClosedPrs: true) {
nodes { number }
}
}
}
}`,
{ owner, repo, number: context.payload.issue.number },
);
candidates = repository.issue.closedByPullRequestsReferences.nodes.map((pr) => pr.number);
} else {
candidates = [context.payload.pull_request.number];
}
for (const number of candidates) {
if (!(await isReopenable(number))) {
core.info(`#${number} is not a closed pull request linking an accepted issue. Skipped.`);
continue;
}
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: number, per_page: 100,
});
if (!comments.some((comment) => comment.body?.startsWith(marker))) {
core.info(`#${number} was not closed by this gate. Left alone.`);
continue;
}
try {
await github.rest.pulls.update({
owner, repo, pull_number: number, state: 'open',
});
} catch (error) {
core.warning(`Could not reopen #${number}: ${error.message}`);
continue;
}
await github.rest.issues.createComment({
owner, repo, issue_number: number,
body: 'An `accepted` issue is linked now, so this is open again and ready for review.',
});
core.info(`Reopened #${number}`);
}