mirror of
https://github.com/strapi/strapi.git
synced 2026-05-03 16:22:30 +00:00
feat(github-actions): add community PR lifecycle automation
This commit is contained in:
@@ -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
|
||||
@@ -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.
|
||||
@@ -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)' : ''}`);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
Reference in New Issue
Block a user