chore: add PR automation workflows for triage, formatting, staleness, and overlap detection (#263)

This commit is contained in:
Matt Kane
2026-04-05 08:03:06 +01:00
committed by GitHub
parent 9d10d2791f
commit c4977e1fd1
9 changed files with 864 additions and 52 deletions

129
.github/workflows/auto-format.yml vendored Normal file
View 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\`\`\``,
});

View File

@@ -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:

View 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 }}.',
});

View File

@@ -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,

View File

@@ -13,6 +13,7 @@ jobs:
format:
name: Format
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:

View File

@@ -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
View 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
View 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,
});
}
}

View File

@@ -28,10 +28,10 @@ This is a pre-release project. Do not add backwards compatibility or legacy patt
Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a PR. Key rules:
- **You MUST use the PR template.** Every PR must include the PR template with all sections filled out. The template is loaded automatically when you create a PR via the GitHub UI. If you create a PR via the API or CLI, copy the template from `.github/PULL_REQUEST_TEMPLATE.md` into the PR body. **PRs that do not use the template will be closed automatically by CI.**
- **Features require a prior approved Discussion.** Do not open a feature PR without one. It will be closed. Open a [Discussion](https://github.com/emdash-cms/emdash/discussions/categories/ideas) in the Ideas category first.
- **Bug fixes and docs** can be PRed directly.
- **Fill out the PR template completely.** Every section. Check every applicable checkbox. PRs with empty or skipped templates will be closed.
- **Check the AI disclosure box** in the PR template if any part of the code was AI-generated.
- **Check every applicable checkbox** in the PR template, including the "I have read CONTRIBUTING.md" box and the AI disclosure box if any part of the code was AI-generated.
- **Do not make bulk/spray changes** (e.g., "fix all lint warnings", "add types everywhere", "improve error handling across codebase"). If you see a systemic issue, open a Discussion.
- **Do not touch code outside the scope of your change.** No drive-by refactors, no "while I'm here" improvements, no added comments or logging in unrelated files.
- **All CI checks must pass.** Typecheck, lint, format, and tests. No exceptions.