diff --git a/.github/scripts/.env b/.github/scripts/.env new file mode 100644 index 0000000000..c193b495a8 --- /dev/null +++ b/.github/scripts/.env @@ -0,0 +1,4 @@ +# Linear CMS team workflow state IDs +# Source: Linear → Settings → Workflow → CMS team +LINEAR_CMS_STATUS_WAITING_ON_AUTHOR=db8d4a0e-34bd-427f-87d0-116a4d4c8590 +LINEAR_CMS_STATUS_CANCELED=921c5413-26ed-4fa3-b82d-34fb2c4efef5 diff --git a/.github/scripts/README.md b/.github/scripts/README.md new file mode 100644 index 0000000000..80cb87962c --- /dev/null +++ b/.github/scripts/README.md @@ -0,0 +1,57 @@ +# Community PR Lifecycle + +Automates the lifecycle of community PRs after a team review, from "waiting on author" through to automatic closure. + +## Flow + +``` +Team reviews PR → leaves feedback → manually adds "waiting on author" label + │ + ▼ +[GitHub Action] Syncs Linear ticket → "Waiting on Author" + Posts a comment on the PR with the timeline + │ + │ (daily cron, no author activity for 14 days) + ▼ +[GitHub Action] Removes "waiting on author", adds "stale" + Posts a stale warning comment + │ + │ (daily cron, still no activity after 7 more days) + ▼ +[GitHub Action] Closes the PR + Posts a closing comment + Syncs Linear ticket → "Canceled" +``` + +## Thresholds + +Configurable via **GitHub repository variables** (Settings → Secrets and variables → Variables) without any code change. Falls back to the defaults below if not set. + +| Variable | Default | Meaning | +| ------------------------ | ------- | -------------------------------------------------- | +| `WAITING_ON_AUTHOR_DAYS` | 14 | Days before a "waiting on author" PR becomes stale | +| `STALE_DAYS` | 30 | Days before a stale PR is closed | + +## Linear config + +State IDs are stored in `.github/scripts/.env` (committed, not secrets — these are not sensitive): + +| Variable | Linear state | +| ------------------------------------- | ------------------------------ | +| `LINEAR_CMS_STATUS_WAITING_ON_AUTHOR` | CMS team → "Waiting on Author" | +| `LINEAR_CMS_STATUS_CANCELED` | CMS team → "Canceled" | + +> Only CMS team tickets are synced. By the time "waiting on author" is added, the ticket has been picked up from the CPR team into CMS. + +## Required secrets + +These already exist in the repo from the community PR triage workflow: + +| Secret | Description | +| -------------------- | ----------------------- | +| `LINEAR_API_KEY` | Linear personal API key | +| `LINEAR_CMS_TEAM_ID` | CMS team ID | + +## Manual dry run + +Trigger the workflow manually from the GitHub Actions tab. The `dry_run` input defaults to `true` — it logs which PRs would be promoted or closed without making any changes. diff --git a/.github/scripts/community-pr-lifecycle.ts b/.github/scripts/community-pr-lifecycle.ts new file mode 100644 index 0000000000..e23ec3eca4 --- /dev/null +++ b/.github/scripts/community-pr-lifecycle.ts @@ -0,0 +1,295 @@ +import type { getOctokit } from '@actions/github'; +import type { Context } from '@actions/github/lib/context'; +import type * as Core from '@actions/core'; + +type Octokit = ReturnType; + +interface ScriptArgs { + github: Octokit; + context: Context; + core: typeof Core; +} + +interface LinearNode { + id: string; + title: string; +} + +interface LinearResponse { + data?: { + issues?: { nodes: LinearNode[] }; + issueUpdate?: { success: boolean }; + }; + errors?: Array<{ message: string }>; +} + +interface IssueEvent { + event: string; + label?: { name: string }; + created_at: string; +} + +interface PR { + number: number; + labels: Array<{ name: string }>; + user: { login: string }; +} + +async function linearGQL( + apiKey: string, + query: string, + variables: Record = {} +): Promise { + const res = await fetch('https://api.linear.app/graphql', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: apiKey }, + body: JSON.stringify({ query, variables }), + }); + return res.json() as Promise; +} + +async function findLinearTicket( + apiKey: string, + teamId: string, + prNumber: number +): Promise { + const data = await linearGQL( + apiKey, + `query ($teamId: ID!, $title: String!) { + issues( + filter: { team: { id: { eq: $teamId } }, title: { startsWith: $title } } + first: 1 + ) { nodes { id title } } + }`, + { teamId, title: `PR #${prNumber}:` } + ); + return data.data?.issues?.nodes?.[0] ?? null; +} + +async function updateLinearState( + apiKey: string, + issueId: string, + stateId: string +): Promise { + const data = await linearGQL( + apiKey, + `mutation ($id: String!, $stateId: String!) { + issueUpdate(id: $id, input: { stateId: $stateId }) { success } + }`, + { id: issueId, stateId } + ); + return data.data?.issueUpdate?.success ?? false; +} + +async function getLabelAddedAt( + github: Octokit, + owner: string, + repo: string, + issueNumber: number, + labelName: string +): Promise { + let page = 1; + let lastTimestamp: number | null = null; + + while (true) { + const { data } = await github.rest.issues.listEvents({ + owner, + repo, + issue_number: issueNumber, + per_page: 100, + page, + }); + + for (const event of data as IssueEvent[]) { + if (event.event === 'labeled' && event.label?.name === labelName) { + lastTimestamp = new Date(event.created_at).getTime(); + } + } + + if (data.length < 100) break; + page++; + } + + return lastTimestamp; +} + +/** + * Triggered when "waiting on author" label is added to a PR. + * Syncs the Linear ticket to "Waiting on Author" and posts a comment. + */ +export async function syncWaitingOnAuthor({ github, context, core }: ScriptArgs): Promise { + const pr = context.payload.pull_request as { + number: number; + labels: Array<{ name: string }>; + user: { login: string }; + }; + const prNumber = pr.number; + const labels = pr.labels.map((l) => l.name); + const login = pr.user.login; + const { owner, repo } = context.repo; + + const apiKey = process.env.LINEAR_API_KEY; + const teamId = process.env.LINEAR_CMS_TEAM_ID; + const waitingStateId = process.env.LINEAR_CMS_STATUS_WAITING_ON_AUTHOR; + const waitingDays = process.env.WAITING_ON_AUTHOR_DAYS; + const staleDays = process.env.STALE_DAYS; + + if (!labels.includes('community')) { + core.info('Not a community PR — skipping'); + return; + } + + if (!apiKey || !teamId || !waitingStateId) { + core.warning('Missing Linear config — skipping Linear sync'); + } else { + const issue = await findLinearTicket(apiKey, teamId, prNumber); + if (!issue) { + core.warning(`No Linear ticket found in CMS team for PR #${prNumber}`); + } else { + core.info(`Found Linear ticket: ${issue.title}`); + const ok = await updateLinearState(apiKey, issue.id, waitingStateId); + if (ok) { + core.info(`PR #${prNumber}: Linear → "Waiting on Author"`); + } else { + core.warning(`Linear update failed for PR #${prNumber}`); + } + } + } + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body: + `> This is a templated message\n\n` + + `Hello @${login},\n\n` + + `Thank you for your contribution! We have left some feedback that we'd love for you to address.\n\n` + + `Just so you're aware of the timeline:\n` + + `- If there is no new activity within **${waitingDays} days**, this PR will be marked as **stale**\n` + + `- Stale PRs are automatically closed after **${staleDays} more days**\n\n` + + `Once you've pushed updates, feel free to re-request a review. We'd love to get this merged! 💜`, + }); +} + +/** + * Runs on a daily schedule. Advances the lifecycle of community PRs: + * "waiting on author" (≥ WAITING_ON_AUTHOR_DAYS) → stale + * "stale" (≥ STALE_DAYS) → closed + Linear canceled + */ +export async function processLifecycle({ github, context, core }: ScriptArgs): Promise { + const waitingOnAuthorDays = parseInt(process.env.WAITING_ON_AUTHOR_DAYS ?? '14', 10); + const staleDays = parseInt(process.env.STALE_DAYS ?? '30', 10); + const isDryRun = process.env.DRY_RUN === 'true'; + const apiKey = process.env.LINEAR_API_KEY; + const teamId = process.env.LINEAR_CMS_TEAM_ID; + const canceledStateId = process.env.LINEAR_CMS_STATUS_CANCELED; + const { owner, repo } = context.repo; + + const DAY_MS = 86_400_000; + const now = Date.now(); + + if (isDryRun) core.info('[DRY RUN] No changes will be made'); + + const allPRs = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: 'open', + per_page: 100, + }); + + const communityPRs = (allPRs as PR[]).filter((pr) => + pr.labels.some((l) => l.name === 'community') + ); + core.info(`Found ${communityPRs.length} open community PRs`); + + let promoted = 0; + let closed = 0; + + for (const pr of communityPRs) { + const labelNames = pr.labels.map((l) => l.name); + + // ── stale long enough → close ───────────────────────────────────────── + if (labelNames.includes('stale')) { + const labeledAt = await getLabelAddedAt(github, owner, repo, pr.number, 'stale'); + if (!labeledAt) continue; + const daysStale = (now - labeledAt) / DAY_MS; + core.info(`PR #${pr.number}: stale for ${Math.floor(daysStale)}d (threshold: ${staleDays}d)`); + + if (daysStale >= staleDays) { + if (!isDryRun) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr.number, + body: + `> This is a templated message\n\n` + + `Hello @${pr.user.login},\n\n` + + `This PR has been stale for **${staleDays}+ days** with no new activity, so we are closing it to keep the backlog manageable.\n\n` + + `If you'd like to continue working on it, please reopen the PR — we'd love to get it merged! 💜`, + }); + await github.rest.pulls.update({ + owner, + repo, + pull_number: pr.number, + state: 'closed', + }); + if (apiKey && teamId && canceledStateId) { + const issue = await findLinearTicket(apiKey, teamId, pr.number); + if (issue) { + await updateLinearState(apiKey, issue.id, canceledStateId); + core.info(`PR #${pr.number}: Linear → "Canceled"`); + } + } + } + closed++; + continue; + } + } + + // ── waiting on author long enough → stale ───────────────────────────── + if (labelNames.includes('waiting on author') && !labelNames.includes('stale')) { + const labeledAt = await getLabelAddedAt(github, owner, repo, pr.number, 'waiting on author'); + if (!labeledAt) continue; + const daysWaiting = (now - labeledAt) / DAY_MS; + core.info( + `PR #${pr.number}: waiting on author for ${Math.floor(daysWaiting)}d (threshold: ${waitingOnAuthorDays}d)` + ); + + if (daysWaiting >= waitingOnAuthorDays) { + if (!isDryRun) { + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: pr.number, + name: 'waiting on author', + }); + } catch { + // Label may have been removed concurrently — not fatal + } + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: pr.number, + labels: ['stale'], + }); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr.number, + body: + `> This is a templated message\n\n` + + `Hello @${pr.user.login},\n\n` + + `We haven't heard back from you in **${waitingOnAuthorDays}+ days**, so this PR has been marked as **stale**.\n\n` + + `If you're still interested in getting this merged, please address the review feedback and push new commits. ` + + `It will be automatically closed in **${staleDays} days** if there is no further activity.\n\n` + + `Thank you for your contribution! 💜`, + }); + } + promoted++; + } + } + } + + core.info(`Done: ${promoted} promoted to stale, ${closed} closed${isDryRun ? ' (dry run)' : ''}`); +} diff --git a/.github/scripts/package.json b/.github/scripts/package.json new file mode 100644 index 0000000000..6bf3533294 --- /dev/null +++ b/.github/scripts/package.json @@ -0,0 +1,9 @@ +{ + "private": true, + "devDependencies": { + "@actions/core": "1.11.1", + "@actions/github": "6.0.1", + "tsup": "8.5.1", + "typescript": "5.9.3" + } +} diff --git a/.github/scripts/tsconfig.json b/.github/scripts/tsconfig.json new file mode 100644 index 0000000000..5529469832 --- /dev/null +++ b/.github/scripts/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "CommonJS", + "lib": ["ES2020"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist" + }, + "include": ["*.ts"] +} diff --git a/.github/workflows/community-pr-lifecycle.yml b/.github/workflows/community-pr-lifecycle.yml new file mode 100644 index 0000000000..c3ad921b25 --- /dev/null +++ b/.github/workflows/community-pr-lifecycle.yml @@ -0,0 +1,100 @@ +# SECURITY (read before editing): +# Both jobs checkout the BASE branch only — PR head code is never executed. +# run: steps only use env vars — no ${{ }} interpolation of user-controlled data. + +name: Community PR Lifecycle + +on: + pull_request_target: + types: [labeled] + schedule: + - cron: '0 10 * * *' # Daily at 10am UTC + workflow_dispatch: + inputs: + dry_run: + description: 'Dry run — report what would change without making changes' + type: boolean + default: true + +env: + WAITING_ON_AUTHOR_DAYS: ${{ vars.WAITING_ON_AUTHOR_DAYS || '14' }} + STALE_DAYS: ${{ vars.STALE_DAYS || '7' }} + +permissions: {} + +jobs: + # ────────────────────────────────────────────────────────────────────────── + # Job 1: "waiting on author" label added → sync Linear + comment on PR + # ────────────────────────────────────────────────────────────────────────── + waiting-on-author: + name: Sync "waiting on author" to Linear + runs-on: ubuntu-latest + if: | + github.event_name == 'pull_request_target' && + github.event.label.name == 'waiting on author' + permissions: + contents: read + pull-requests: write + + steps: + - name: Verify run context + env: + EVENT_NAME: ${{ github.event_name }} + run: | + if [ "$EVENT_NAME" != "pull_request_target" ]; then + echo "::error::Expected pull_request_target, got $EVENT_NAME" + exit 1 + fi + echo "Running from base branch; PR head code is never executed." + + - name: Checkout base branch + uses: actions/checkout@v4 + + - name: Load Linear config + run: grep -v '^#' .github/scripts/.env | grep -v '^$' >> "$GITHUB_ENV" + + - name: Install and build scripts + run: cd .github/scripts && npm install && npx tsup community-pr-lifecycle.ts --format cjs --target node20 --out-dir dist --no-splitting + + - name: Sync to Linear and comment + uses: actions/github-script@v7 + env: + LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} + LINEAR_CMS_TEAM_ID: ${{ secrets.LINEAR_CMS_TEAM_ID }} + with: + script: | + const { syncWaitingOnAuthor } = require('./.github/scripts/dist/community-pr-lifecycle.js') + await syncWaitingOnAuthor({ github, context, core }) + + # ────────────────────────────────────────────────────────────────────────── + # Job 2: daily cron — advance waiting → stale → closed + # ────────────────────────────────────────────────────────────────────────── + lifecycle-check: + name: Advance stale / close lifecycle + runs-on: ubuntu-latest + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + permissions: + contents: read + pull-requests: write + issues: write + + steps: + - name: Checkout base branch + uses: actions/checkout@v4 + + - name: Load Linear config + run: grep -v '^#' .github/scripts/.env | grep -v '^$' >> "$GITHUB_ENV" + + - name: Install and build scripts + run: cd .github/scripts && npm install && npx tsup community-pr-lifecycle.ts --format cjs --target node20 --out-dir dist --no-splitting + + - name: Process community PR lifecycle + uses: actions/github-script@v7 + env: + LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} + LINEAR_CMS_TEAM_ID: ${{ secrets.LINEAR_CMS_TEAM_ID }} + DRY_RUN: ${{ inputs.dry_run || 'false' }} + with: + script: | + const { processLifecycle } = require('./.github/scripts/dist/community-pr-lifecycle.js') + await processLifecycle({ github, context, core })