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:
|
typecheck:
|
||||||
name: Typecheck
|
name: Typecheck
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
@@ -35,6 +36,7 @@ jobs:
|
|||||||
lint:
|
lint:
|
||||||
name: Lint
|
name: Lint
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
@@ -51,6 +53,7 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
name: Tests
|
name: Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:17
|
image: postgres:17
|
||||||
@@ -82,6 +85,7 @@ jobs:
|
|||||||
validate-plugins:
|
validate-plugins:
|
||||||
name: Validate Plugins
|
name: Validate Plugins
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
@@ -114,6 +118,7 @@ jobs:
|
|||||||
test-smoke:
|
test-smoke:
|
||||||
name: Smoke Tests
|
name: Smoke Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:17
|
image: postgres:17
|
||||||
@@ -146,6 +151,7 @@ jobs:
|
|||||||
test-browser:
|
test-browser:
|
||||||
name: Browser Tests
|
name: Browser Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
@@ -171,6 +177,7 @@ jobs:
|
|||||||
if: always()
|
if: always()
|
||||||
needs: [test-e2e]
|
needs: [test-e2e]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
- name: Check E2E shard results
|
- name: Check E2E shard results
|
||||||
run: |
|
run: |
|
||||||
@@ -182,6 +189,7 @@ jobs:
|
|||||||
test-e2e:
|
test-e2e:
|
||||||
name: E2E tests (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
name: E2E tests (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
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'
|
github.event.comment.author_association == 'COLLABORATOR'
|
||||||
)
|
)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
@@ -27,7 +28,7 @@ jobs:
|
|||||||
app-id: ${{ secrets.APP_ID }}
|
app-id: ${{ secrets.APP_ID }}
|
||||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||||
|
|
||||||
- name: Get PR branch
|
- name: Get PR details
|
||||||
id: pr
|
id: pr
|
||||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||||
with:
|
with:
|
||||||
@@ -38,8 +39,11 @@ jobs:
|
|||||||
repo: context.repo.repo,
|
repo: context.repo.repo,
|
||||||
pull_number: context.issue.number,
|
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('ref', pr.data.head.ref);
|
||||||
core.setOutput('sha', pr.data.head.sha);
|
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
|
- name: React to comment
|
||||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||||
@@ -53,47 +57,91 @@ jobs:
|
|||||||
content: 'eyes',
|
content: 'eyes',
|
||||||
});
|
});
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout (same-repo)
|
||||||
|
if: steps.pr.outputs.is_fork == 'false'
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
ref: ${{ steps.pr.outputs.ref }}
|
ref: ${{ steps.pr.outputs.ref }}
|
||||||
token: ${{ steps.app-token.outputs.token }}
|
token: ${{ steps.app-token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Checkout (fork)
|
||||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
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
|
- name: Setup Node
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install formatters
|
||||||
run: pnpm install --frozen-lockfile
|
run: npm install --no-save --ignore-scripts oxfmt prettier prettier-plugin-astro
|
||||||
|
|
||||||
- name: Run formatter
|
- name: Run formatters
|
||||||
run: pnpm format
|
run: |
|
||||||
|
npx oxfmt --ignore-path .gitignore
|
||||||
- name: Commit and push
|
npx prettier --write .
|
||||||
id: commit
|
|
||||||
|
- name: Check for changes
|
||||||
|
id: diff
|
||||||
run: |
|
run: |
|
||||||
git config user.name "emdashbot[bot]"
|
|
||||||
git config user.email "emdashbot[bot]@users.noreply.github.com"
|
|
||||||
git add -A
|
git add -A
|
||||||
if git diff --staged --quiet; then
|
if git diff --staged --quiet; then
|
||||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
git commit -m "style: format"
|
|
||||||
git push
|
|
||||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
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
|
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||||
with:
|
with:
|
||||||
github-token: ${{ steps.app-token.outputs.token }}
|
github-token: ${{ steps.app-token.outputs.token }}
|
||||||
script: |
|
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({
|
await github.rest.reactions.createForIssueComment({
|
||||||
owner: context.repo.owner,
|
owner: context.repo.owner,
|
||||||
repo: context.repo.repo,
|
repo: context.repo.repo,
|
||||||
|
|||||||
1
.github/workflows/format.yml
vendored
1
.github/workflows/format.yml
vendored
@@ -13,6 +13,7 @@ jobs:
|
|||||||
format:
|
format:
|
||||||
name: Format
|
name: Format
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
|
|||||||
127
.github/workflows/pr-compliance.yml
vendored
127
.github/workflows/pr-compliance.yml
vendored
@@ -7,56 +7,66 @@ on:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-pr:
|
check-pr:
|
||||||
name: Validate PR
|
name: Validate PR
|
||||||
if: github.actor != 'dependabot[bot]' && github.actor != 'renovate[bot]' && github.actor != 'emdashbot[bot]'
|
if: github.actor != 'dependabot[bot]' && github.actor != 'renovate[bot]' && github.actor != 'emdashbot[bot]'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
- name: Check PR template
|
- name: Check PR template
|
||||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const body = context.payload.pull_request.body || '';
|
const body = context.payload.pull_request.body || '';
|
||||||
|
const pr = context.payload.pull_request;
|
||||||
|
|
||||||
const errors = [];
|
const errors = [];
|
||||||
|
|
||||||
// Must have a description (not just the raw template placeholder)
|
// Check if the PR body looks like it used the template at all
|
||||||
const descriptionSection = body.match(/## What does this PR do\?\s*\n([\s\S]*?)(?=\n## )/);
|
const hasTemplate = body.includes('## What does this PR do?') || body.includes('## Type of change');
|
||||||
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 at least one type
|
if (!hasTemplate) {
|
||||||
const typeChecks = [
|
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.');
|
||||||
/- \[x\] Bug fix/i,
|
} else {
|
||||||
/- \[x\] Feature/i,
|
// Must have a description (not just the raw template placeholder)
|
||||||
/- \[x\] Refactor/i,
|
const descriptionSection = body.match(/## What does this PR do\?\s*\n([\s\S]*?)(?=\n## )/);
|
||||||
/- \[x\] Documentation/i,
|
const description = descriptionSection?.[1]?.replace(/<!--[\s\S]*?-->/g, '').trim() || '';
|
||||||
/- \[x\] Performance/i,
|
if (!description || description === 'Closes #') {
|
||||||
/- \[x\] Tests/i,
|
errors.push('Fill out the "What does this PR do?" section with a description of your change.');
|
||||||
/- \[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
|
// Must check at least one type
|
||||||
const hasReadContributing = /- \[x\] I have read \[CONTRIBUTING\.md\]/i.test(body);
|
const typeChecks = [
|
||||||
if (!hasReadContributing) {
|
/- \[x\] Bug fix/i,
|
||||||
errors.push('Check the "I have read CONTRIBUTING.md" checkbox.');
|
/- \[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) {
|
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.',
|
'See [CONTRIBUTING.md](https://github.com/emdash-cms/emdash/blob/main/CONTRIBUTING.md) for the full contribution policy.',
|
||||||
].join('\n');
|
].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);
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:
|
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.
|
- **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.
|
- **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 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.
|
||||||
- **Check the AI disclosure box** in the PR template 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 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.
|
- **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.
|
- **All CI checks must pass.** Typecheck, lint, format, and tests. No exceptions.
|
||||||
|
|||||||
Reference in New Issue
Block a user