Skip to content

PR Auto Approval

PR Auto Approval #404

name: PR Auto Approval
on:
workflow_run:
workflows: ["CI"]
types: [completed]
permissions:
contents: read
pull-requests: write
jobs:
check-whether-to-approve:
if: github.event.workflow_run.conclusion == 'success'
permissions:
contents: read
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
sparse-checkout: |
./.github/actions
- name: Get associated PR
id: associated-pr
uses: ./.github/actions/associated-pr
with:
ref: ${{ github.event.workflow_run.head_sha }}
- name: Evaluate auto-approval criteria
id: evaluate
if: steps.associated-pr.outputs.number != '' && steps.associated-pr.outputs.closed != 'true'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
PR_NUMBER: ${{ steps.associated-pr.outputs.number }}
PR_TITLE: ${{ steps.associated-pr.outputs.title }}
PR_LABELS: ${{ steps.associated-pr.outputs.labels }}
PR_AUTHOR: ${{ steps.associated-pr.outputs.author }}
PR_HEAD_REPO: ${{ steps.associated-pr.outputs.head_repo }}
with:
script: | # js
const prNumber = parseInt(process.env.PR_NUMBER || "");
const baseRepo = `${context.repo.owner}/${context.repo.repo}`;
const headRepo = process.env.PR_HEAD_REPO ?? "";
if (headRepo && headRepo !== baseRepo) {
core.info("Skipping: PR is from a fork");
return;
}
const login = process.env.PR_AUTHOR ?? "";
if (/bot/i.test(login)) {
core.info(`Skipping: author "${login}" appears to be a bot`);
return;
}
const labels = JSON.parse(process.env.PR_LABELS || "[]");
core.info(`PR labels: ${labels.join(", ")}`);
if (!labels.includes("experienced-contributor")) {
core.info("Skipping: missing experienced-contributor label");
return;
}
const allowedSizes = ["size: XS", "size: S", "size: M"];
if (!allowedSizes.some(size => labels.includes(size))) {
core.info("Skipping: missing required size label (XS, S, or M)");
return;
}
const title = process.env.PR_TITLE ?? "";
const isChore = title.startsWith("chore:") || title.startsWith("chore(");
const isTest = title.startsWith("test:") || title.startsWith("test(");
const isDocs = title.startsWith("docs:") || title.startsWith("docs(");
if (!isChore && !isTest && !isDocs) {
core.info(`Skipping: commit is not one of these types: chore, test, or docs`);
return;
}
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
per_page: 100,
});
const filenames = files.map(f => f.filename);
if (isTest) {
const testFilePattern = /(\.(spec|integration)\.(ts|tsx)$|\/tests?\/)|(jest|vitest)\.config\.[tj]s$/;
const onlyTestFiles = filenames.every(f => testFilePattern.test(f));
if (!onlyTestFiles) {
core.info("Skipping test: PR changes non-test files");
return;
}
core.setOutput("approval-reason", "all changes are related to tests");
} else if (isDocs) {
const imagePattern = /\.(png|jpg|jpeg|gif|svg|webp|ico|bmp)$/i;
const docsFilePattern = f => f.endsWith(".md") || imagePattern.test(f);
const onlyDocFiles = filenames.every(docsFilePattern);
if (!onlyDocFiles) {
core.info("Skipping docs: PR changes non-documentation files");
return;
}
core.setOutput("approval-reason", "all changes are related to documentation");
} else if (isChore) {
const sourceCodeFile = /\.(ts|tsx|js|jsx)$/;
const touchesCode = filenames.some(f => {
return sourceCodeFile.test(f) && (f.includes("/packages/") || f.includes("/apps/"));
});
if (touchesCode) {
core.info("Skipping chore: PR touches code files in packages/ or apps/");
return;
}
core.setOutput("approval-reason", "chore that does not touch source code files");
}
- name: Approve PR
if: steps.evaluate.outputs.approval-reason != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ steps.associated-pr.outputs.number }}
APPROVAL_REASON: ${{ steps.evaluate.outputs.approval-reason }}
run: | # shell
should_skip=$(gh pr view "$PR_NUMBER" \
-R "$GITHUB_REPOSITORY" \
--json closed,reviews \
--jq '.closed or any(
.reviews[];
.state == "APPROVED"
and .author.login == "github-actions[bot]"
)'
);
if [ "$should_skip" = "true" ]; then
echo "PR #$PR_NUMBER is already closed or approved, skipping approval";
else
gh pr review "$PR_NUMBER" -R "$GITHUB_REPOSITORY" --approve --body "Auto-approved: $APPROVAL_REASON."
fi