chore: add PR automation workflows for triage, formatting, staleness, and overlap detection (#263)
This commit is contained in:
129
.github/workflows/auto-format.yml
vendored
Normal file
129
.github/workflows/auto-format.yml
vendored
Normal file
@@ -0,0 +1,129 @@
|
||||
name: Auto Format
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
branches: [main]
|
||||
types: [opened, synchronize]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
format:
|
||||
name: Format
|
||||
# Skip bot-authored commits to avoid infinite loops
|
||||
if: github.actor != 'emdashbot[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Generate token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Get PR details
|
||||
id: pr
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
const isFork = pr.head.repo.fork || pr.head.repo.full_name !== `${context.repo.owner}/${context.repo.repo}`;
|
||||
core.setOutput('ref', pr.head.ref);
|
||||
core.setOutput('sha', pr.head.sha);
|
||||
core.setOutput('number', pr.number.toString());
|
||||
core.setOutput('is_fork', isFork.toString());
|
||||
core.setOutput('full_name', pr.head.repo.full_name);
|
||||
|
||||
# --- Same-repo PRs: checkout and push directly ---
|
||||
|
||||
- name: Checkout (same-repo)
|
||||
if: steps.pr.outputs.is_fork == 'false'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ steps.pr.outputs.ref }}
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
# --- Fork PRs: checkout the fork at the pinned SHA ---
|
||||
|
||||
- name: Checkout (fork)
|
||||
if: steps.pr.outputs.is_fork == 'true'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: ${{ steps.pr.outputs.full_name }}
|
||||
ref: ${{ steps.pr.outputs.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
# --- Install formatters directly (no pnpm install, no postinstall scripts) ---
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install formatters
|
||||
run: npm install --no-save --ignore-scripts oxfmt prettier prettier-plugin-astro
|
||||
|
||||
- name: Run formatters
|
||||
run: |
|
||||
npx oxfmt --ignore-path .gitignore
|
||||
npx prettier --write .
|
||||
|
||||
- name: Check for changes
|
||||
id: diff
|
||||
run: |
|
||||
git add -A
|
||||
if git diff --staged --quiet; then
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# --- Same-repo: push directly ---
|
||||
|
||||
- name: Commit and push (same-repo)
|
||||
if: steps.pr.outputs.is_fork == 'false' && steps.diff.outputs.changed == 'true'
|
||||
run: |
|
||||
git config user.name "emdashbot[bot]"
|
||||
git config user.email "emdashbot[bot]@users.noreply.github.com"
|
||||
git commit -m "style: format"
|
||||
git push
|
||||
|
||||
# --- Fork: push via git with GIT_ASKPASS ---
|
||||
|
||||
- name: Commit and push (fork)
|
||||
if: steps.pr.outputs.is_fork == 'true' && steps.diff.outputs.changed == 'true'
|
||||
id: push-fork
|
||||
run: |
|
||||
git config user.name "emdashbot[bot]"
|
||||
git config user.email "emdashbot[bot]@users.noreply.github.com"
|
||||
git commit -m "style: format"
|
||||
# Push to the fork using the app token via GIT_ASKPASS to avoid
|
||||
# leaking the token in process args or git config
|
||||
export GIT_ASKPASS="$RUNNER_TEMP/git-askpass.sh"
|
||||
printf '#!/bin/sh\necho "%s"\n' "$APP_TOKEN" > "$GIT_ASKPASS"
|
||||
chmod +x "$GIT_ASKPASS"
|
||||
git remote add fork "https://x-access-token@github.com/${{ steps.pr.outputs.full_name }}.git"
|
||||
if git push fork "HEAD:${{ steps.pr.outputs.ref }}"; then
|
||||
echo "push_failed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "push_failed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
rm -f "$GIT_ASKPASS"
|
||||
env:
|
||||
APP_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Comment on push failure
|
||||
if: steps.push-fork.outputs.push_failed == 'true'
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: ${{ steps.pr.outputs.number }},
|
||||
body: `Could not push formatting changes to this fork. The contributor may have "Allow edits by maintainers" disabled.\n\nPlease run the formatter locally:\n\n\`\`\`\nnpx oxfmt --ignore-path .gitignore\nnpx prettier --write .\n\`\`\``,
|
||||
});
|
||||
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@@ -17,6 +17,7 @@ jobs:
|
||||
typecheck:
|
||||
name: Typecheck
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
@@ -35,6 +36,7 @@ jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
@@ -51,6 +53,7 @@ jobs:
|
||||
test:
|
||||
name: Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17
|
||||
@@ -82,6 +85,7 @@ jobs:
|
||||
validate-plugins:
|
||||
name: Validate Plugins
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
@@ -114,6 +118,7 @@ jobs:
|
||||
test-smoke:
|
||||
name: Smoke Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17
|
||||
@@ -146,6 +151,7 @@ jobs:
|
||||
test-browser:
|
||||
name: Browser Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
@@ -171,6 +177,7 @@ jobs:
|
||||
if: always()
|
||||
needs: [test-e2e]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check E2E shard results
|
||||
run: |
|
||||
@@ -182,6 +189,7 @@ jobs:
|
||||
test-e2e:
|
||||
name: E2E tests (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
||||
35
.github/workflows/dependabot-approve.yml
vendored
Normal file
35
.github/workflows/dependabot-approve.yml
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
name: Dependabot Auto-Approve
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
approve:
|
||||
name: Auto-Approve
|
||||
if: github.actor == 'dependabot[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Fetch Dependabot metadata
|
||||
id: metadata
|
||||
uses: dependabot/fetch-metadata@d7267f607e9d3fb96fc2fbe83e0af444e40b2048 # v2.4.0
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Auto-approve patch and minor updates
|
||||
if: steps.metadata.outputs.update-type != 'version-update:semver-major'
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
script: |
|
||||
await github.rest.pulls.createReview({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.payload.pull_request.number,
|
||||
event: 'APPROVE',
|
||||
body: 'Auto-approved: ${{ steps.metadata.outputs.update-type }} update for ${{ steps.metadata.outputs.dependency-names }}.',
|
||||
});
|
||||
84
.github/workflows/format-command.yml
vendored
84
.github/workflows/format-command.yml
vendored
@@ -16,6 +16,7 @@ jobs:
|
||||
github.event.comment.author_association == 'COLLABORATOR'
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
@@ -27,7 +28,7 @@ jobs:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Get PR branch
|
||||
- name: Get PR details
|
||||
id: pr
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
@@ -38,8 +39,11 @@ jobs:
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
});
|
||||
const isFork = pr.data.head.repo.fork || pr.data.head.repo.full_name !== `${context.repo.owner}/${context.repo.repo}`;
|
||||
core.setOutput('ref', pr.data.head.ref);
|
||||
core.setOutput('sha', pr.data.head.sha);
|
||||
core.setOutput('is_fork', isFork.toString());
|
||||
core.setOutput('full_name', pr.data.head.repo.full_name);
|
||||
|
||||
- name: React to comment
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
@@ -53,47 +57,91 @@ jobs:
|
||||
content: 'eyes',
|
||||
});
|
||||
|
||||
- name: Checkout
|
||||
- name: Checkout (same-repo)
|
||||
if: steps.pr.outputs.is_fork == 'false'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ steps.pr.outputs.ref }}
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
- name: Checkout (fork)
|
||||
if: steps.pr.outputs.is_fork == 'true'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: ${{ steps.pr.outputs.full_name }}
|
||||
ref: ${{ steps.pr.outputs.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Install formatters
|
||||
run: npm install --no-save --ignore-scripts oxfmt prettier prettier-plugin-astro
|
||||
|
||||
- name: Run formatter
|
||||
run: pnpm format
|
||||
|
||||
- name: Commit and push
|
||||
id: commit
|
||||
- name: Run formatters
|
||||
run: |
|
||||
npx oxfmt --ignore-path .gitignore
|
||||
npx prettier --write .
|
||||
|
||||
- name: Check for changes
|
||||
id: diff
|
||||
run: |
|
||||
git config user.name "emdashbot[bot]"
|
||||
git config user.email "emdashbot[bot]@users.noreply.github.com"
|
||||
git add -A
|
||||
if git diff --staged --quiet; then
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
git commit -m "style: format"
|
||||
git push
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: React to comment
|
||||
- name: Commit and push (same-repo)
|
||||
if: steps.pr.outputs.is_fork == 'false' && steps.diff.outputs.changed == 'true'
|
||||
run: |
|
||||
git config user.name "emdashbot[bot]"
|
||||
git config user.email "emdashbot[bot]@users.noreply.github.com"
|
||||
git commit -m "style: format"
|
||||
git push
|
||||
|
||||
- name: Commit and push (fork)
|
||||
if: steps.pr.outputs.is_fork == 'true' && steps.diff.outputs.changed == 'true'
|
||||
id: push-fork
|
||||
run: |
|
||||
git config user.name "emdashbot[bot]"
|
||||
git config user.email "emdashbot[bot]@users.noreply.github.com"
|
||||
git commit -m "style: format"
|
||||
export GIT_ASKPASS="$RUNNER_TEMP/git-askpass.sh"
|
||||
printf '#!/bin/sh\necho "%s"\n' "$APP_TOKEN" > "$GIT_ASKPASS"
|
||||
chmod +x "$GIT_ASKPASS"
|
||||
git remote add fork "https://x-access-token@github.com/${{ steps.pr.outputs.full_name }}.git"
|
||||
if git push fork "HEAD:${{ steps.pr.outputs.ref }}"; then
|
||||
echo "push_failed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "push_failed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
rm -f "$GIT_ASKPASS"
|
||||
env:
|
||||
APP_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Comment on push failure
|
||||
if: steps.push-fork.outputs.push_failed == 'true'
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const changed = '${{ steps.commit.outputs.changed }}' === 'true';
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: `Could not push formatting changes to this fork. The contributor may have "Allow edits by maintainers" disabled.\n\nPlease run the formatter locally:\n\n\`\`\`\nnpx oxfmt --ignore-path .gitignore\nnpx prettier --write .\n\`\`\``,
|
||||
});
|
||||
|
||||
- name: React to comment (result)
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const changed = '${{ steps.diff.outputs.changed }}' === 'true';
|
||||
await github.rest.reactions.createForIssueComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
|
||||
1
.github/workflows/format.yml
vendored
1
.github/workflows/format.yml
vendored
@@ -13,6 +13,7 @@ jobs:
|
||||
format:
|
||||
name: Format
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
|
||||
127
.github/workflows/pr-compliance.yml
vendored
127
.github/workflows/pr-compliance.yml
vendored
@@ -7,56 +7,66 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
check-pr:
|
||||
name: Validate PR
|
||||
if: github.actor != 'dependabot[bot]' && github.actor != 'renovate[bot]' && github.actor != 'emdashbot[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check PR template
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
script: |
|
||||
const body = context.payload.pull_request.body || '';
|
||||
const pr = context.payload.pull_request;
|
||||
|
||||
const errors = [];
|
||||
|
||||
// Must have a description (not just the raw template placeholder)
|
||||
const descriptionSection = body.match(/## What does this PR do\?\s*\n([\s\S]*?)(?=\n## )/);
|
||||
const description = descriptionSection?.[1]?.replace(/<!--[\s\S]*?-->/g, '').trim() || '';
|
||||
if (!description || description === 'Closes #') {
|
||||
errors.push('Fill out the "What does this PR do?" section with a description of your change.');
|
||||
}
|
||||
// Check if the PR body looks like it used the template at all
|
||||
const hasTemplate = body.includes('## What does this PR do?') || body.includes('## Type of change');
|
||||
|
||||
// Must check at least one type
|
||||
const typeChecks = [
|
||||
/- \[x\] Bug fix/i,
|
||||
/- \[x\] Feature/i,
|
||||
/- \[x\] Refactor/i,
|
||||
/- \[x\] Documentation/i,
|
||||
/- \[x\] Performance/i,
|
||||
/- \[x\] Tests/i,
|
||||
/- \[x\] Chore/i,
|
||||
];
|
||||
const hasType = typeChecks.some(re => re.test(body));
|
||||
if (!hasType) {
|
||||
errors.push('Check at least one "Type of change" checkbox.');
|
||||
}
|
||||
|
||||
// If Feature is checked, require a discussion link
|
||||
const isFeature = /- \[x\] Feature/i.test(body);
|
||||
if (isFeature) {
|
||||
const hasDiscussionLink = /github\.com\/emdash-cms\/emdash\/discussions\/\d+/.test(body);
|
||||
if (!hasDiscussionLink) {
|
||||
errors.push('Feature PRs require a link to an approved Discussion (https://github.com/emdash-cms/emdash/discussions/categories/ideas). Open a Discussion first, get approval, then link it in the PR.');
|
||||
if (!hasTemplate) {
|
||||
errors.push('This PR does not use the required PR template. Please edit the description to use the [PR template](https://github.com/emdash-cms/emdash/blob/main/.github/PULL_REQUEST_TEMPLATE.md). Copy it into your PR description and fill out all sections.');
|
||||
} else {
|
||||
// Must have a description (not just the raw template placeholder)
|
||||
const descriptionSection = body.match(/## What does this PR do\?\s*\n([\s\S]*?)(?=\n## )/);
|
||||
const description = descriptionSection?.[1]?.replace(/<!--[\s\S]*?-->/g, '').trim() || '';
|
||||
if (!description || description === 'Closes #') {
|
||||
errors.push('Fill out the "What does this PR do?" section with a description of your change.');
|
||||
}
|
||||
}
|
||||
|
||||
// Must check the "I have read CONTRIBUTING.md" box
|
||||
const hasReadContributing = /- \[x\] I have read \[CONTRIBUTING\.md\]/i.test(body);
|
||||
if (!hasReadContributing) {
|
||||
errors.push('Check the "I have read CONTRIBUTING.md" checkbox.');
|
||||
// Must check at least one type
|
||||
const typeChecks = [
|
||||
/- \[x\] Bug fix/i,
|
||||
/- \[x\] Feature/i,
|
||||
/- \[x\] Refactor/i,
|
||||
/- \[x\] Documentation/i,
|
||||
/- \[x\] Performance/i,
|
||||
/- \[x\] Tests/i,
|
||||
/- \[x\] Chore/i,
|
||||
];
|
||||
const hasType = typeChecks.some(re => re.test(body));
|
||||
if (!hasType) {
|
||||
errors.push('Check at least one "Type of change" checkbox.');
|
||||
}
|
||||
|
||||
// If Feature is checked, require a discussion link
|
||||
const isFeature = /- \[x\] Feature/i.test(body);
|
||||
if (isFeature) {
|
||||
const hasDiscussionLink = /github\.com\/emdash-cms\/emdash\/discussions\/\d+/.test(body);
|
||||
if (!hasDiscussionLink) {
|
||||
errors.push('Feature PRs require a link to an approved Discussion (https://github.com/emdash-cms/emdash/discussions/categories/ideas). Open a Discussion first, get approval, then link it in the PR.');
|
||||
}
|
||||
}
|
||||
|
||||
// Must check the "I have read CONTRIBUTING.md" box
|
||||
const hasReadContributing = /- \[x\] I have read \[CONTRIBUTING\.md\]/i.test(body);
|
||||
if (!hasReadContributing) {
|
||||
errors.push('Check the "I have read CONTRIBUTING.md" checkbox.');
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
@@ -70,5 +80,58 @@ jobs:
|
||||
'See [CONTRIBUTING.md](https://github.com/emdash-cms/emdash/blob/main/CONTRIBUTING.md) for the full contribution policy.',
|
||||
].join('\n');
|
||||
|
||||
// Leave a comment so the author sees the errors directly
|
||||
// Find and update existing bot comment, or create a new one
|
||||
const marker = '<!-- pr-compliance-check -->';
|
||||
const commentBody = `${marker}\n${message}`;
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
});
|
||||
|
||||
const existing = comments.find(c =>
|
||||
c.user?.login === 'github-actions[bot]' &&
|
||||
c.body?.includes(marker)
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body: commentBody,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
body: commentBody,
|
||||
});
|
||||
}
|
||||
|
||||
core.setFailed(message);
|
||||
} else {
|
||||
// If passing now, remove any previous failure comment
|
||||
const marker = '<!-- pr-compliance-check -->';
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
});
|
||||
|
||||
const existing = comments.find(c =>
|
||||
c.user?.login === 'github-actions[bot]' &&
|
||||
c.body?.includes(marker)
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
288
.github/workflows/pr-sweep.yml
vendored
Normal file
288
.github/workflows/pr-sweep.yml
vendored
Normal file
@@ -0,0 +1,288 @@
|
||||
name: PR Sweep
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every 6 hours
|
||||
- cron: "0 */6 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
sweep:
|
||||
name: Sweep Open PRs
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Sweep PRs
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const now = new Date();
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Label color map for auto-creation
|
||||
const labelColors = {
|
||||
'needs-approval': 'fbca04',
|
||||
'needs-rebase': 'e11d48',
|
||||
'stale': 'ededed',
|
||||
'overlap': 'c5def5',
|
||||
};
|
||||
|
||||
// Ensure labels exist
|
||||
const existingLabels = new Set();
|
||||
for await (const response of github.paginate.iterator(
|
||||
github.rest.issues.listLabelsForRepo,
|
||||
{ owner, repo, per_page: 100 }
|
||||
)) {
|
||||
for (const label of response.data) {
|
||||
existingLabels.add(label.name);
|
||||
}
|
||||
}
|
||||
for (const [name, color] of Object.entries(labelColors)) {
|
||||
if (!existingLabels.has(name)) {
|
||||
await github.rest.issues.createLabel({ owner, repo, name, color });
|
||||
}
|
||||
}
|
||||
|
||||
// Get all open PRs
|
||||
const prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
core.info(`Sweeping ${prs.length} open PRs`);
|
||||
|
||||
// Build a file->PR map for overlap detection
|
||||
const fileToPRs = new Map();
|
||||
|
||||
for (const pr of prs) {
|
||||
const prLabels = new Set(pr.labels.map(l => l.name));
|
||||
const toAdd = [];
|
||||
const toRemove = [];
|
||||
|
||||
// --- needs-approval: PR has no CI check runs ---
|
||||
// (CLA runs via pull_request_target so it always runs)
|
||||
try {
|
||||
const { data: checkRuns } = await github.rest.checks.listForRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: pr.head.sha,
|
||||
per_page: 100,
|
||||
});
|
||||
// Filter to only CI-related checks (not CLA, not PR Triage)
|
||||
const ciChecks = checkRuns.check_runs.filter(c =>
|
||||
!c.name.includes('CLA') &&
|
||||
!c.name.includes('Label') &&
|
||||
!c.name.includes('Triage') &&
|
||||
!c.name.includes('Validate PR')
|
||||
);
|
||||
const prAge = now - new Date(pr.created_at);
|
||||
|
||||
if (ciChecks.length === 0 && prAge > 5 * 60 * 1000) {
|
||||
// No CI checks after 5 minutes -- likely needs approval
|
||||
if (!prLabels.has('needs-approval')) toAdd.push('needs-approval');
|
||||
} else {
|
||||
if (prLabels.has('needs-approval')) toRemove.push('needs-approval');
|
||||
}
|
||||
} catch {
|
||||
// Ignore check run lookup failures
|
||||
}
|
||||
|
||||
// --- needs-rebase: PR has merge conflicts ---
|
||||
// mergeable is not included in the list endpoint; fetch individually
|
||||
try {
|
||||
const { data: fullPr } = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
});
|
||||
if (fullPr.mergeable === false) {
|
||||
if (!prLabels.has('needs-rebase')) toAdd.push('needs-rebase');
|
||||
} else if (fullPr.mergeable === true) {
|
||||
if (prLabels.has('needs-rebase')) toRemove.push('needs-rebase');
|
||||
}
|
||||
// mergeable===null means GitHub is still computing; skip
|
||||
} catch {
|
||||
// Ignore merge status lookup failures
|
||||
}
|
||||
|
||||
// --- Stale detection ---
|
||||
const lastActivity = new Date(pr.updated_at);
|
||||
const daysSinceActivity = (now - lastActivity) / DAY;
|
||||
|
||||
if (daysSinceActivity > 21) {
|
||||
// 21 days with no activity -- close it
|
||||
if (!prLabels.has('stale')) {
|
||||
// Should already be labeled stale from 14-day mark, but just in case
|
||||
toAdd.push('stale');
|
||||
}
|
||||
|
||||
const marker = '<!-- stale-close -->';
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
body: `${marker}\nThis PR has been inactive for 21 days and is being closed automatically. If you'd like to continue working on it, feel free to reopen it.`,
|
||||
});
|
||||
|
||||
await github.rest.pulls.update({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
state: 'closed',
|
||||
});
|
||||
|
||||
core.info(`#${pr.number}: closed (stale, ${Math.floor(daysSinceActivity)} days inactive)`);
|
||||
continue; // Skip further processing for closed PRs
|
||||
} else if (daysSinceActivity > 14) {
|
||||
// 14 days -- label as stale and warn
|
||||
if (!prLabels.has('stale')) {
|
||||
toAdd.push('stale');
|
||||
|
||||
const marker = '<!-- stale-warning -->';
|
||||
// Check if we already left a stale warning
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
per_page: 10,
|
||||
});
|
||||
const hasWarning = comments.some(c =>
|
||||
c.user?.login === 'github-actions[bot]' && c.body?.includes(marker)
|
||||
);
|
||||
|
||||
if (!hasWarning) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
body: `${marker}\nThis PR has been inactive for 14 days. It will be closed automatically in 7 days if there is no further activity.\n\nIf you're still working on this, please push an update or leave a comment.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Active -- remove stale label if present
|
||||
if (prLabels.has('stale')) toRemove.push('stale');
|
||||
}
|
||||
|
||||
// --- Collect files for overlap detection ---
|
||||
try {
|
||||
const { data: files } = await github.rest.pulls.listFiles({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
per_page: 100,
|
||||
});
|
||||
for (const file of files) {
|
||||
if (!fileToPRs.has(file.filename)) {
|
||||
fileToPRs.set(file.filename, []);
|
||||
}
|
||||
fileToPRs.get(file.filename).push(pr.number);
|
||||
}
|
||||
} catch {
|
||||
// Ignore file listing failures
|
||||
}
|
||||
|
||||
// --- Apply label changes ---
|
||||
if (toAdd.length > 0) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
labels: toAdd,
|
||||
});
|
||||
}
|
||||
for (const label of toRemove) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
name: label,
|
||||
});
|
||||
} catch {
|
||||
// Label might not exist on the PR
|
||||
}
|
||||
}
|
||||
|
||||
if (toAdd.length > 0 || toRemove.length > 0) {
|
||||
core.info(`#${pr.number}: +[${toAdd.join(',')}] -[${toRemove.join(',')}]`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Overlap detection ---
|
||||
// Find files changed by multiple PRs
|
||||
const overlaps = new Map(); // pr number -> set of overlapping PR numbers
|
||||
for (const [file, prNumbers] of fileToPRs) {
|
||||
if (prNumbers.length > 1) {
|
||||
for (const prNum of prNumbers) {
|
||||
if (!overlaps.has(prNum)) overlaps.set(prNum, new Set());
|
||||
for (const other of prNumbers) {
|
||||
if (other !== prNum) overlaps.get(prNum).add(other);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Label PRs with significant overlap (3+ shared files with another PR)
|
||||
for (const [prNum, otherPRs] of overlaps) {
|
||||
// Count shared files per overlapping PR
|
||||
const sharedFileCounts = new Map();
|
||||
for (const [file, prNumbers] of fileToPRs) {
|
||||
if (prNumbers.includes(prNum)) {
|
||||
for (const other of prNumbers) {
|
||||
if (other !== prNum) {
|
||||
sharedFileCounts.set(other, (sharedFileCounts.get(other) || 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const significantOverlaps = [...sharedFileCounts.entries()]
|
||||
.filter(([_, count]) => count >= 3)
|
||||
.map(([otherPR, count]) => `#${otherPR} (${count} shared files)`);
|
||||
|
||||
if (significantOverlaps.length > 0) {
|
||||
const pr = prs.find(p => p.number === prNum);
|
||||
const prLabels = new Set(pr?.labels.map(l => l.name) || []);
|
||||
|
||||
if (!prLabels.has('overlap')) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNum,
|
||||
labels: ['overlap'],
|
||||
});
|
||||
|
||||
// Leave a comment about the overlap (only once)
|
||||
const marker = '<!-- overlap-notice -->';
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNum,
|
||||
per_page: 20,
|
||||
});
|
||||
const hasNotice = comments.some(c =>
|
||||
c.user?.login === 'github-actions[bot]' && c.body?.includes(marker)
|
||||
);
|
||||
|
||||
if (!hasNotice) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNum,
|
||||
body: `${marker}\n## Overlapping PRs\n\nThis PR modifies files that are also changed by other open PRs:\n\n${significantOverlaps.map(s => `- ${s}`).join('\n')}\n\nThis may cause merge conflicts or duplicated work. A maintainer will coordinate.`,
|
||||
});
|
||||
}
|
||||
|
||||
core.info(`#${prNum}: overlap with ${significantOverlaps.join(', ')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
240
.github/workflows/pr-triage.yml
vendored
Normal file
240
.github/workflows/pr-triage.yml
vendored
Normal file
@@ -0,0 +1,240 @@
|
||||
name: PR Triage
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
label:
|
||||
name: Label PR
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Triage PR
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
const labels = new Set();
|
||||
|
||||
// --- Bot detection ---
|
||||
const botLogins = ['dependabot[bot]', 'renovate[bot]', 'emdashbot[bot]'];
|
||||
if (botLogins.includes(pr.user.login)) {
|
||||
labels.add('bot');
|
||||
}
|
||||
|
||||
// --- Size labels ---
|
||||
const { data: files } = await github.rest.pulls.listFiles({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const linesChanged = files.reduce((sum, f) => sum + f.additions + f.deletions, 0);
|
||||
const sizeLabels = ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL'];
|
||||
let sizeLabel;
|
||||
if (linesChanged < 10) sizeLabel = 'size/XS';
|
||||
else if (linesChanged < 50) sizeLabel = 'size/S';
|
||||
else if (linesChanged < 200) sizeLabel = 'size/M';
|
||||
else if (linesChanged < 500) sizeLabel = 'size/L';
|
||||
else sizeLabel = 'size/XL';
|
||||
labels.add(sizeLabel);
|
||||
|
||||
// --- Area labels ---
|
||||
const areaMap = {
|
||||
'area/core': (f) => f.startsWith('packages/core/'),
|
||||
'area/admin': (f) => f.startsWith('packages/admin/'),
|
||||
'area/plugins': (f) => f.startsWith('packages/plugins/'),
|
||||
'area/docs': (f) => f.startsWith('docs/'),
|
||||
'area/templates': (f) => f.startsWith('templates/'),
|
||||
'area/ci': (f) => f.startsWith('.github/'),
|
||||
'area/auth': (f) => f.startsWith('packages/auth/'),
|
||||
'area/cloudflare': (f) => f.startsWith('packages/cloudflare/'),
|
||||
};
|
||||
|
||||
for (const file of files) {
|
||||
for (const [label, matcher] of Object.entries(areaMap)) {
|
||||
if (matcher(file.filename)) {
|
||||
labels.add(label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Merge conflict detection ---
|
||||
// mergeable is available on the full PR object (may need a separate fetch
|
||||
// since the webhook payload sometimes has mergeable=null while computing)
|
||||
try {
|
||||
const { data: fullPr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number,
|
||||
});
|
||||
if (fullPr.mergeable === false) {
|
||||
labels.add('needs-rebase');
|
||||
}
|
||||
} catch {
|
||||
// Ignore -- mergeable state may not be computed yet
|
||||
}
|
||||
|
||||
// --- CLA status ---
|
||||
// The CLA assistant sets a commit status named "license/cla".
|
||||
// Check the latest status for this PR's head SHA.
|
||||
try {
|
||||
const { data: statuses } = await github.rest.repos.listCommitStatusesForRef({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
ref: pr.head.sha,
|
||||
});
|
||||
const claStatus = statuses.find(s => s.context === 'license/cla');
|
||||
if (claStatus) {
|
||||
if (claStatus.state === 'success') {
|
||||
labels.add('cla: signed');
|
||||
} else {
|
||||
labels.add('cla: needed');
|
||||
}
|
||||
} else {
|
||||
// CLA check hasn't run yet -- mark as pending
|
||||
labels.add('cla: needed');
|
||||
}
|
||||
} catch {
|
||||
// If we can't read statuses, don't add CLA labels
|
||||
}
|
||||
|
||||
// --- Ensure all labels exist, then apply ---
|
||||
const labelColors = {
|
||||
'bot': 'ededed',
|
||||
'size/XS': '3cbf00',
|
||||
'size/S': '5dba3f',
|
||||
'size/M': 'fbca04',
|
||||
'size/L': 'ee9b00',
|
||||
'size/XL': 'd93f0b',
|
||||
'area/core': '0052cc',
|
||||
'area/admin': '7057ff',
|
||||
'area/plugins': '008672',
|
||||
'area/docs': '0075ca',
|
||||
'area/templates': 'bfdadc',
|
||||
'area/ci': '000000',
|
||||
'area/auth': 'd4c5f9',
|
||||
'area/cloudflare': 'f9a825',
|
||||
'cla: signed': '0e8a16',
|
||||
'cla: needed': 'b60205',
|
||||
'needs-rebase': 'e11d48',
|
||||
};
|
||||
|
||||
// Get existing labels on the repo
|
||||
const existingLabels = new Set();
|
||||
for await (const response of github.paginate.iterator(
|
||||
github.rest.issues.listLabelsForRepo,
|
||||
{ owner: context.repo.owner, repo: context.repo.repo, per_page: 100 }
|
||||
)) {
|
||||
for (const label of response.data) {
|
||||
existingLabels.add(label.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Create any missing labels
|
||||
for (const label of labels) {
|
||||
if (!existingLabels.has(label) && labelColors[label]) {
|
||||
await github.rest.issues.createLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: label,
|
||||
color: labelColors[label],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get current labels on the PR to remove stale ones
|
||||
const currentLabels = new Set(pr.labels.map(l => l.name));
|
||||
|
||||
// Remove stale size labels (only one size label at a time)
|
||||
for (const sl of sizeLabels) {
|
||||
if (sl !== sizeLabel && currentLabels.has(sl)) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
name: sl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Remove stale CLA labels
|
||||
const claLabels = ['cla: signed', 'cla: needed'];
|
||||
for (const cl of claLabels) {
|
||||
if (!labels.has(cl) && currentLabels.has(cl)) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
name: cl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Remove needs-rebase if PR is now mergeable
|
||||
if (!labels.has('needs-rebase') && currentLabels.has('needs-rebase')) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
name: 'needs-rebase',
|
||||
});
|
||||
}
|
||||
|
||||
// Add new labels
|
||||
const toAdd = [...labels].filter(l => !currentLabels.has(l));
|
||||
if (toAdd.length > 0) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
labels: toAdd,
|
||||
});
|
||||
}
|
||||
|
||||
core.info(`Applied labels: ${[...labels].join(', ')}`);
|
||||
|
||||
// --- Scope guard: comment on oversized PRs ---
|
||||
// Only on open (not synchronize) to avoid spamming on every push
|
||||
if (context.payload.action === 'opened') {
|
||||
const warnings = [];
|
||||
|
||||
if (linesChanged > 500) {
|
||||
warnings.push(`This PR changes **${linesChanged.toLocaleString()} lines** across **${files.length} files**. Large PRs are harder to review and more likely to be closed without review.`);
|
||||
} else if (files.length > 20) {
|
||||
warnings.push(`This PR touches **${files.length} files**. PRs with a broad scope are harder to review. Please confirm the scope hasn't drifted beyond the intended change.`);
|
||||
}
|
||||
|
||||
// Check for cross-area changes (touching multiple packages)
|
||||
const areas = Object.keys(areaMap).filter(a => labels.has(a));
|
||||
if (areas.length > 3) {
|
||||
warnings.push(`This PR spans ${areas.length} different areas (${areas.join(', ')}). Consider breaking it into smaller, focused PRs.`);
|
||||
}
|
||||
|
||||
if (warnings.length > 0) {
|
||||
const marker = '<!-- scope-guard -->';
|
||||
const body = [
|
||||
marker,
|
||||
'## Scope check',
|
||||
'',
|
||||
...warnings,
|
||||
'',
|
||||
'If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.',
|
||||
'',
|
||||
'See [CONTRIBUTING.md](https://github.com/emdash-cms/emdash/blob/main/CONTRIBUTING.md) for contribution guidelines.',
|
||||
].join('\n');
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user