215 lines
8.9 KiB
YAML
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}`);
|
|
}
|