Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/smart-pr-nudge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
---

ci: add smart PR nudge for stale PRs

New workflow that runs on weekdays and posts actionable diagnoses on PRs
stale for 7+ days. Checks CI status, unresolved threads, missing reviews,
outdated branches, and draft status. Won't nudge the same PR twice per week.
4 changes: 4 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,10 @@ Any PR that modifies files under `packages/squad-cli/src/` or `packages/squad-sd
- The `changelog-gate` CI check will fail without this
- Escape hatch: add the `skip-changelog` label (use sparingly)

## Automated PR Nudge

The **PR Nudge** workflow (`.github/workflows/squad-pr-nudge.yml`) runs on weekdays at 2pm UTC and posts actionable comments on open PRs that have been stale for 7+ days. It diagnoses specific blockers β€” failing CI checks, unresolved review threads, missing approvals, outdated branches, and draft status β€” so PR authors know exactly what to do next. Draft PRs get a 14-day grace period. The workflow won't nudge the same PR more than once per week.

## Decisions

If you make a decision that affects other team members, write it to:
Expand Down
184 changes: 184 additions & 0 deletions .github/workflows/squad-pr-nudge.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
name: PR Nudge
on:
schedule:
- cron: '0 14 * * 1-5' # 2pm UTC weekdays (morning US Pacific)
workflow_dispatch: {} # manual trigger for testing

permissions:
contents: read
pull-requests: write
checks: read
issues: read

jobs:
nudge-stale-prs:
name: "Nudge Stale PRs"
runs-on: ubuntu-latest
steps:
Comment thread
diberry marked this conversation as resolved.
- uses: actions/github-script@v7
with:
script: |
const STALE_DAYS = 7;
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - STALE_DAYS);

// Get all open PRs, oldest-updated first
const prs = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
sort: 'updated',
direction: 'asc',
per_page: 50
});

for (const pr of prs.data) {
// Skip PRs updated recently
const lastPush = new Date(pr.updated_at);
if (lastPush > cutoff) continue;

// Give draft PRs 14 days grace period instead of 7
if (pr.draft) {
const created = new Date(pr.created_at);
const draftCutoff = new Date();
draftCutoff.setDate(draftCutoff.getDate() - 14);
if (created > draftCutoff) continue;
}

// Don't nudge the same PR more than once per week
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
per_page: 5,
sort: 'created',
direction: 'desc'
});
const recentNudge = comments.data.find(c =>
c.user.login === 'github-actions[bot]' &&
c.body.includes('<!-- pr-nudge -->') &&
new Date(c.created_at) > cutoff
);
if (recentNudge) continue;

// Build the diagnosis β€” collect actionable items
const actions = [];
const daysSinceUpdate = Math.floor((Date.now() - lastPush) / (1000 * 60 * 60 * 24));

// 1. Check if still in draft
if (pr.draft) {
actions.push('πŸ“ **Still in draft** β€” mark as "Ready for review" when you\'re done, or close if abandoned.');
}

// 2. Check CI status for failures
const checks = await github.rest.checks.listForRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: pr.head.sha,
per_page: 50
});
const failedChecks = checks.data.check_runs.filter(c =>
c.conclusion === 'failure'
).map(c => c.name);
if (failedChecks.length > 0) {
actions.push(`πŸ”΄ **${failedChecks.length} CI check(s) failing:** ${failedChecks.join(', ')}. Fix these first.`);
}

// 3. Check for unresolved review threads (Copilot vs human)
const threads = await github.graphql(`
query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
reviewThreads(first: 50) {
nodes {
isResolved
isOutdated
comments(first: 1) {
nodes { author { login } }
}
}
}
}
}
}
`, {
owner: context.repo.owner,
repo: context.repo.repo,
number: pr.number
});
const unresolvedThreads = threads.repository.pullRequest.reviewThreads.nodes
.filter(t => !t.isResolved && !t.isOutdated);
if (unresolvedThreads.length > 0) {
const copilotThreads = unresolvedThreads.filter(t =>
t.comments.nodes[0]?.author?.login?.includes('copilot')
);
const humanThreads = unresolvedThreads.length - copilotThreads.length;
const parts = [];
if (copilotThreads.length > 0) parts.push(`${copilotThreads.length} from Copilot`);
if (humanThreads > 0) parts.push(`${humanThreads} from reviewers`);
actions.push(`πŸ’¬ **${unresolvedThreads.length} unresolved review thread(s)** (${parts.join(', ')}). Address and resolve them.`);
}

// 4. Check review state (changes requested vs approved vs none)
const reviews = await github.rest.pulls.listReviews({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number
});
const latestByUser = {};
for (const r of reviews.data) {
if (r.state === 'COMMENTED') continue;
latestByUser[r.user.login] = r;
}
const changesRequested = Object.values(latestByUser).filter(r => r.state === 'CHANGES_REQUESTED');
const approvals = Object.values(latestByUser).filter(r => r.state === 'APPROVED');
Comment thread
diberry marked this conversation as resolved.
if (changesRequested.length > 0) {
const reviewers = changesRequested.map(r => `@${r.user.login}`).join(', ');
actions.push(`πŸ”„ **Changes requested** by ${reviewers}. Address their feedback and request re-review.`);
} else if (approvals.length === 0 && !pr.draft) {
actions.push('πŸ‘€ **No approving reviews yet.** Request a review from a teammate.');
}

// 5. Check if branch is behind base
const comparison = await github.rest.repos.compareCommits({
owner: context.repo.owner,
repo: context.repo.repo,
base: pr.head.sha,
head: pr.base.ref
});
if (comparison.data.ahead_by > 10) {
actions.push(`⬇️ **${comparison.data.ahead_by} commits behind ${pr.base.ref}.** Rebase to pick up latest changes.`);
}
Comment thread
diberry marked this conversation as resolved.

// 6. If everything looks good and approved β€” it's ready to merge
if (actions.length === 0 && approvals.length > 0) {
actions.push('βœ… **Looks ready to merge!** All checks pass, approved β€” just needs someone to click merge.');
}

// Fallback if no specific blockers found
if (actions.length === 0) {
actions.push('πŸ€” **No obvious blockers found** β€” but this PR has been quiet. Is it still active?');
}

// Post the nudge comment
const body = [
'<!-- pr-nudge -->',
`πŸ‘‹ **Friendly nudge** β€” this PR has had no activity for **${daysSinceUpdate} days**.`,
'',
'**What needs attention:**',
...actions.map(a => `- ${a}`),
'',
'---',
'*If this PR is abandoned, please close it. If it\'s blocked on something external, leave a comment so the team knows.*',
'*This is an automated check that runs on weekdays. It won\'t nudge the same PR more than once per week.*'
].join('\n');

await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: body
});

core.info(`Nudged PR #${pr.number}: ${pr.title} (${daysSinceUpdate} days stale, ${actions.length} action items)`);
}
Loading