feat(github-actions): add community PR lifecycle automation

This commit is contained in:
Ziyi Yuan
2026-04-29 10:41:48 +02:00
parent 62a6230772
commit 3cf862bc4e
6 changed files with 477 additions and 0 deletions
+4
View File
@@ -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
+57
View File
@@ -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.
+295
View File
@@ -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<typeof getOctokit>;
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<string, string> = {}
): Promise<LinearResponse> {
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<LinearResponse>;
}
async function findLinearTicket(
apiKey: string,
teamId: string,
prNumber: number
): Promise<LinearNode | null> {
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<boolean> {
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<number | null> {
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<void> {
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<void> {
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)' : ''}`);
}
+9
View File
@@ -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"
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"lib": ["ES2020"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist"
},
"include": ["*.ts"]
}
@@ -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 })