PR Redirect #172
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: PR Redirect | |
| # Nightly sweep that redirects open non-maintainer PRs into issues or | |
| # discussions. Running on a schedule (rather than on PR open) gives | |
| # contributors time to share their PR and gives maintainers a veto | |
| # window: a 👀 (eyes) reaction from a maintainer on the PR description | |
| # means "handle this one specially" and excludes it from the sweep. | |
| # | |
| # SECURITY: This workflow processes untrusted fork PRs. The agent here | |
| # is safe ONLY because we check out the default branch (never a PR | |
| # head), and the agent reads PR data exclusively through `gh pr view | |
| # --json` rather than executing fork-controlled code or interpolating | |
| # PR strings into shell scripts. | |
| # | |
| # Do NOT add steps that check out, build, test, or otherwise execute | |
| # code from a PR. Doing so makes a fork PR author able to exfiltrate | |
| # FREDKBOT_GITHUB_TOKEN. | |
| on: | |
| schedule: | |
| # Nightly at 09:43 UTC (~2:40am US Pacific). Off the :00/:30 marks | |
| # to dodge GitHub's top-of-hour scheduler congestion. | |
| - cron: '43 9 * * *' | |
| # Manual re-run of the full sweep (useful if the scheduled run failed | |
| # or a PR needs redirecting before tonight). | |
| workflow_dispatch: | |
| # One sweep at a time. Queued (not cancelled) so an in-flight write can | |
| # finish before the next run starts. | |
| concurrency: | |
| group: pr-redirect | |
| cancel-in-progress: false | |
| permissions: {} | |
| jobs: | |
| collect: | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| issues: read | |
| outputs: | |
| pr-numbers: ${{ steps.collect.outputs.pr-numbers }} | |
| steps: | |
| - name: Collect redirectable PRs | |
| id: collect | |
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | |
| with: | |
| script: | | |
| const { owner, repo } = context.repo; | |
| // Approved contributors keep normal PR privileges. | |
| const path = '.github/APPROVED_CONTRIBUTORS'; | |
| const { data } = await github.rest.repos.getContent({ owner, repo, path }); | |
| if (!('content' in data) || typeof data.content !== 'string') { | |
| throw new Error(`Expected file content for ${path}`); | |
| } | |
| const approved = Buffer.from(data.content, 'base64') | |
| .toString('utf8') | |
| .split('\n') | |
| .map((line) => line.trim().toLowerCase()) | |
| .filter((line) => line && !line.startsWith('#')); | |
| // A maintainer reacting with 👀 on the PR description vetoes | |
| // the redirect ("handle this one specially"). Same role bar | |
| // as the `lgtm+` command in approve-contributor.yml. | |
| const VETO_REACTION = 'eyes'; | |
| const maintainerCache = new Map(); | |
| async function isMaintainer(username) { | |
| if (!maintainerCache.has(username)) { | |
| let role = 'none'; | |
| try { | |
| const { data: access } = await github.rest.repos.getCollaboratorPermissionLevel({ | |
| owner, | |
| repo, | |
| username, | |
| }); | |
| role = access.role_name; | |
| } catch { | |
| // 404 = not a collaborator. | |
| } | |
| maintainerCache.set(username, ['admin', 'maintain'].includes(role)); | |
| } | |
| return maintainerCache.get(username); | |
| } | |
| const prs = await github.paginate(github.rest.pulls.list, { | |
| owner, | |
| repo, | |
| state: 'open', | |
| per_page: 100, | |
| }); | |
| // Guarantee a veto window even for PRs opened just before the | |
| // sweep: anything younger than this is left for the next night. | |
| const MIN_AGE_MS = 3 * 60 * 60 * 1000; | |
| const numbers = []; | |
| for (const pr of prs) { | |
| if (['OWNER', 'MEMBER'].includes(pr.author_association)) continue; | |
| if (approved.includes(pr.user.login.toLowerCase())) continue; | |
| if (Date.now() - Date.parse(pr.created_at) < MIN_AGE_MS) { | |
| core.info(`Skipping #${pr.number}: opened less than 3 hours ago`); | |
| continue; | |
| } | |
| const reactions = await github.paginate(github.rest.reactions.listForIssue, { | |
| owner, | |
| repo, | |
| issue_number: pr.number, | |
| per_page: 100, | |
| }); | |
| let vetoed = false; | |
| for (const reaction of reactions) { | |
| if (reaction.content !== VETO_REACTION) continue; | |
| if (reaction.user && (await isMaintainer(reaction.user.login))) { | |
| vetoed = true; | |
| break; | |
| } | |
| } | |
| if (vetoed) { | |
| core.info(`Skipping #${pr.number}: maintainer 👀 veto`); | |
| continue; | |
| } | |
| numbers.push(pr.number); | |
| } | |
| core.info(`Redirecting ${numbers.length} PR(s): ${numbers.join(', ') || '(none)'}`); | |
| core.setOutput('pr-numbers', JSON.stringify(numbers)); | |
| redirect: | |
| needs: collect | |
| if: needs.collect.outputs.pr-numbers != '[]' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 60 | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| issues: write | |
| discussions: write | |
| steps: | |
| - name: Checkout (default branch only — never a PR head) | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Setup PNPM | |
| uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 | |
| - name: Setup Node | |
| uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 | |
| with: | |
| node-version: 24 | |
| cache: pnpm | |
| - name: Install deps | |
| run: pnpm install --frozen-lockfile | |
| - name: Build Flue CLI + workspace dependencies | |
| # `...` includes the CLI's workspace dependencies (runtime, sdk, | |
| # vite). The built CLI imports @flue/vite/internal at runtime, so | |
| # listing packages by hand under-builds when its deps change. | |
| run: pnpm --filter "@flue/cli..." build | |
| - name: Run pr-redirect agent | |
| env: | |
| # Read-only token for the agent's in-sandbox `gh` calls. | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| # Privileged write token (astrobot-houston PAT) for the | |
| # deterministic phase. Must not be exposed to the sandbox. | |
| FREDKBOT_GITHUB_TOKEN: ${{ secrets.FREDKBOT_GITHUB_TOKEN }} | |
| ANTHROPIC_API_KEY: ${{ secrets.CI_ANTHROPIC_API_KEY }} | |
| # A JSON array of GitHub-assigned integers built by the collect | |
| # job (never PR-author-controlled text), so interpolating it | |
| # below cannot smuggle data into the shell. | |
| PR_NUMBERS: ${{ needs.collect.outputs.pr-numbers }} | |
| GITHUB_REPOSITORY: ${{ github.repository }} | |
| # One agent run per PR, sequentially. A failed PR doesn't stop | |
| # the rest of the sweep; the job fails at the end so the failure | |
| # is visible and the PR is retried on the next sweep. | |
| run: | | |
| failed=0 | |
| for pr in $(echo "$PR_NUMBERS" | jq '.[]'); do | |
| echo "::group::PR #$pr" | |
| node packages/cli/bin/flue.mjs run .flue/agents/pr-redirect.ts \ | |
| --id "pr-redirect-$pr" \ | |
| --message "Redirect PR #$pr" || { | |
| echo "::error::pr-redirect failed for PR #$pr" | |
| failed=1 | |
| } | |
| echo "::endgroup::" | |
| done | |
| exit $failed |