mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
519 lines
22 KiB
YAML
519 lines
22 KiB
YAML
name: PR author inactivity
|
|
|
|
# Remind PR authors only when the current blocker is clearly author-owned, then
|
|
# close after the blocker has gone stale.
|
|
#
|
|
# Inactivity heuristic:
|
|
# - We look for the PR author's latest response via:
|
|
# 1) an issue comment,
|
|
# 2) a submitted pull-request review,
|
|
# 3) an inline review comment reply, or
|
|
# 4) a PR commit / force-push timeline event.
|
|
# - We then find the first trusted human reviewer / maintainer feedback that
|
|
# arrived after that author response via:
|
|
# 1) issue comments,
|
|
# 2) non-approval reviews, or
|
|
# 3) inline review comments.
|
|
# - We also track the latest non-bot human activity on the PR. Reopens,
|
|
# comments, reviews, inline review comments, labels, assignments, review
|
|
# requests, and pushes all reset the inactivity window.
|
|
# - PRs waiting on product direction, QA validation, a non-author assignee,
|
|
# requested reviewers, looper, or maintainer-assisted stale handling are not
|
|
# author-owned and are skipped by this workflow.
|
|
# - Clear author-owned blockers include merge conflicts, failed CI, and
|
|
# outstanding trusted reviewer / maintainer feedback.
|
|
# - If an author-owned blocker remains and the whole PR has no human activity
|
|
# for 72h, we remind once. If it has no human activity for 120h, we close it
|
|
# for queue management.
|
|
#
|
|
# Bot feedback note: trusted human reviewers / maintainers are the trigger for
|
|
# this workflow. Bot-authored reviews and comments (e.g. CodeRabbit, Codex) are
|
|
# intentionally excluded from the feedback signal so authors are not pressured
|
|
# by automated nits alone. Trusted human review feedback — whether top-level or
|
|
# inline — is what starts the inactivity clock.
|
|
|
|
on:
|
|
schedule:
|
|
- cron: '0 */6 * * *'
|
|
workflow_dispatch:
|
|
inputs:
|
|
dry_run:
|
|
description: "Log planned actions without commenting or closing PRs"
|
|
required: false
|
|
default: false
|
|
type: boolean
|
|
|
|
permissions:
|
|
checks: read
|
|
issues: write
|
|
pull-requests: write
|
|
statuses: read
|
|
|
|
concurrency:
|
|
group: pr-author-inactivity
|
|
cancel-in-progress: false
|
|
|
|
jobs:
|
|
triage:
|
|
if: github.repository == 'nexu-io/open-design'
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Remind or close inactive PRs
|
|
uses: actions/github-script@v7
|
|
with:
|
|
script: |
|
|
const REMINDER_MARKER = '<!-- pr-author-inactivity:reminder -->';
|
|
const REMINDER_MS = 72 * 60 * 60 * 1000;
|
|
const CLOSE_MS = 120 * 60 * 60 * 1000;
|
|
const DRY_RUN = String(context.payload.inputs?.dry_run || 'false').toLowerCase() === 'true';
|
|
const NON_AUTHOR_BLOCKER_LABELS = new Set([
|
|
'exempt-from-stale',
|
|
'looper:worker-ready',
|
|
'needs-design-review',
|
|
'needs-maintainer-check',
|
|
'needs-maintainer-merge',
|
|
'needs-qa',
|
|
'needs-qa-validation',
|
|
'needs-product-direction',
|
|
'needs-validation',
|
|
'ready-to-merge',
|
|
'stale-pr/blocked',
|
|
'stale-pr/maintainer-assisted',
|
|
]);
|
|
const FAILED_CHECK_CONCLUSIONS = new Set(['action_required', 'cancelled', 'failure', 'startup_failure', 'timed_out']);
|
|
const FAILED_STATUS_STATES = new Set(['error', 'failure']);
|
|
const HUMAN_ACTIVITY_EVENTS = new Set([
|
|
'assigned',
|
|
'committed',
|
|
'head_ref_force_pushed',
|
|
'labeled',
|
|
'ready_for_review',
|
|
'reopened',
|
|
'review_requested',
|
|
'unassigned',
|
|
'unlabeled',
|
|
]);
|
|
|
|
function ts(value) {
|
|
return value ? new Date(value).getTime() : 0;
|
|
}
|
|
|
|
function isBot(login) {
|
|
return Boolean(login && login.endsWith('[bot]'));
|
|
}
|
|
|
|
function labelNames(pr) {
|
|
return new Set((pr.labels || []).map((label) => label.name).filter(Boolean));
|
|
}
|
|
|
|
function userLogins(users) {
|
|
return (users || []).map((user) => user.login).filter(Boolean);
|
|
}
|
|
|
|
function commitTimestamp(commit) {
|
|
return ts(commit.commit?.committer?.date || commit.commit?.author?.date);
|
|
}
|
|
|
|
function commitLogins(commit) {
|
|
return [commit.author?.login, commit.committer?.login].filter(Boolean);
|
|
}
|
|
|
|
const TRUSTED_PERMISSIONS = new Set(['admin', 'maintain', 'write', 'triage']);
|
|
const trustedReviewerCache = new Map();
|
|
|
|
async function isTrustedReviewer(login) {
|
|
if (!login || isBot(login)) {
|
|
return false;
|
|
}
|
|
|
|
if (trustedReviewerCache.has(login)) {
|
|
return trustedReviewerCache.get(login);
|
|
}
|
|
|
|
try {
|
|
const response = await github.rest.repos.getCollaboratorPermissionLevel({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
username: login,
|
|
});
|
|
|
|
const trusted = TRUSTED_PERMISSIONS.has(response.data.permission);
|
|
trustedReviewerCache.set(login, trusted);
|
|
return trusted;
|
|
} catch (error) {
|
|
trustedReviewerCache.set(login, false);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function paginate(method, params) {
|
|
return github.paginate(method, { per_page: 100, ...params });
|
|
}
|
|
|
|
const pulls = await paginate(github.rest.pulls.list, {
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
state: 'open',
|
|
sort: 'updated',
|
|
direction: 'asc',
|
|
});
|
|
|
|
function collectFeedbackAt(author, trustedLogins, collection, getLogin, getTimestamp, predicate = () => true) {
|
|
return collection
|
|
.filter((item) => {
|
|
const login = getLogin(item);
|
|
if (!login || login === author || isBot(login) || !trustedLogins.has(login)) {
|
|
return false;
|
|
}
|
|
return predicate(item);
|
|
})
|
|
.map((item) => getTimestamp(item))
|
|
.filter(Boolean);
|
|
}
|
|
|
|
const now = Date.now();
|
|
const summary = [];
|
|
const diagnostics = [];
|
|
|
|
for (const pr of pulls) {
|
|
const author = pr.user?.login;
|
|
if (!author || pr.draft || isBot(author)) {
|
|
if (DRY_RUN) {
|
|
diagnostics.push(`#${pr.number}: skipped (${!author ? 'missing-author' : pr.draft ? 'draft' : 'bot-author'})`);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const [comments, reviews, reviewComments, timeline, prCommits, detailedPr, checkRuns, combinedStatus] = await Promise.all([
|
|
paginate(github.rest.issues.listComments, {
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: pr.number,
|
|
}),
|
|
paginate(github.rest.pulls.listReviews, {
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
pull_number: pr.number,
|
|
}),
|
|
paginate(github.rest.pulls.listReviewComments, {
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
pull_number: pr.number,
|
|
}),
|
|
paginate(github.rest.issues.listEventsForTimeline, {
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: pr.number,
|
|
}),
|
|
paginate(github.rest.pulls.listCommits, {
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
pull_number: pr.number,
|
|
}),
|
|
github.rest.pulls.get({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
pull_number: pr.number,
|
|
}),
|
|
paginate(github.rest.checks.listForRef, {
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
ref: pr.head.sha,
|
|
}).catch(() => []),
|
|
github.rest.repos.getCombinedStatusForRef({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
ref: pr.head.sha,
|
|
}).catch(() => ({ data: { statuses: [] } })),
|
|
]);
|
|
|
|
const labels = labelNames(pr);
|
|
const nonAuthorLabels = [...labels].filter((label) => NON_AUTHOR_BLOCKER_LABELS.has(label));
|
|
if (nonAuthorLabels.length > 0) {
|
|
if (DRY_RUN) {
|
|
diagnostics.push(`#${pr.number}: skipped (non-author blocker label: ${nonAuthorLabels.join(', ')})`);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const nonAuthorAssignees = userLogins(pr.assignees).filter((login) => login !== author && !isBot(login));
|
|
if (nonAuthorAssignees.length > 0) {
|
|
if (DRY_RUN) {
|
|
diagnostics.push(`#${pr.number}: skipped (assigned to non-author: ${nonAuthorAssignees.join(', ')})`);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const requestedReviewers = userLogins(pr.requested_reviewers).filter((login) => login !== author && !isBot(login));
|
|
const requestedTeams = (pr.requested_teams || []).map((team) => team.name || team.slug).filter(Boolean);
|
|
if (requestedReviewers.length > 0 || requestedTeams.length > 0) {
|
|
if (DRY_RUN) {
|
|
const reviewers = [...requestedReviewers, ...requestedTeams.map((team) => `team:${team}`)].join(', ');
|
|
diagnostics.push(`#${pr.number}: skipped (waiting on requested reviewer: ${reviewers})`);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const candidateTrustedLogins = new Set();
|
|
for (const comment of comments) {
|
|
const login = comment.user?.login;
|
|
if (login && login !== author && !isBot(login)) {
|
|
candidateTrustedLogins.add(login);
|
|
}
|
|
}
|
|
for (const review of reviews) {
|
|
const login = review.user?.login;
|
|
if (login && login !== author && !isBot(login)) {
|
|
candidateTrustedLogins.add(login);
|
|
}
|
|
}
|
|
for (const reviewComment of reviewComments) {
|
|
const login = reviewComment.user?.login;
|
|
if (login && login !== author && !isBot(login)) {
|
|
candidateTrustedLogins.add(login);
|
|
}
|
|
}
|
|
|
|
const trustedLogins = new Set();
|
|
await Promise.all(
|
|
[...candidateTrustedLogins].map(async (login) => {
|
|
if (await isTrustedReviewer(login)) {
|
|
trustedLogins.add(login);
|
|
}
|
|
}),
|
|
);
|
|
|
|
let latestAuthorResponseAt = 0;
|
|
let latestHumanActivityAt = 0;
|
|
|
|
function recordHumanActivity(login, timestamp) {
|
|
if (!login || isBot(login)) {
|
|
return;
|
|
}
|
|
latestHumanActivityAt = Math.max(latestHumanActivityAt, timestamp);
|
|
}
|
|
|
|
for (const comment of comments) {
|
|
recordHumanActivity(comment.user?.login, ts(comment.created_at));
|
|
if (comment.user?.login !== author) {
|
|
continue;
|
|
}
|
|
latestAuthorResponseAt = Math.max(latestAuthorResponseAt, ts(comment.created_at));
|
|
}
|
|
|
|
for (const review of reviews) {
|
|
if (review.state?.toUpperCase() !== 'PENDING') {
|
|
recordHumanActivity(review.user?.login, ts(review.submitted_at || review.created_at));
|
|
}
|
|
if (review.user?.login !== author) {
|
|
continue;
|
|
}
|
|
if (review.state?.toUpperCase() === 'PENDING') {
|
|
continue;
|
|
}
|
|
latestAuthorResponseAt = Math.max(latestAuthorResponseAt, ts(review.submitted_at || review.created_at));
|
|
}
|
|
|
|
for (const reviewComment of reviewComments) {
|
|
recordHumanActivity(reviewComment.user?.login, ts(reviewComment.created_at));
|
|
if (reviewComment.user?.login !== author) {
|
|
continue;
|
|
}
|
|
latestAuthorResponseAt = Math.max(latestAuthorResponseAt, ts(reviewComment.created_at));
|
|
}
|
|
|
|
for (const commit of prCommits) {
|
|
const commitAt = commitTimestamp(commit);
|
|
const logins = commitLogins(commit);
|
|
if (logins.length === 0 && commitAt) {
|
|
latestHumanActivityAt = Math.max(latestHumanActivityAt, commitAt);
|
|
}
|
|
for (const login of logins) {
|
|
recordHumanActivity(login, commitAt);
|
|
if (login === author) {
|
|
latestAuthorResponseAt = Math.max(latestAuthorResponseAt, commitAt);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const event of timeline) {
|
|
const eventAt = ts(event.created_at);
|
|
if (HUMAN_ACTIVITY_EVENTS.has(event.event)) {
|
|
recordHumanActivity(event.actor?.login, eventAt);
|
|
}
|
|
if (event.actor?.login === author && event.event === 'head_ref_force_pushed') {
|
|
latestAuthorResponseAt = Math.max(latestAuthorResponseAt, eventAt);
|
|
}
|
|
}
|
|
|
|
const trustedApprovalAts = collectFeedbackAt(
|
|
author,
|
|
trustedLogins,
|
|
reviews,
|
|
(review) => review.user?.login,
|
|
(review) => ts(review.submitted_at || review.created_at),
|
|
(review) => review.state?.toUpperCase() === 'APPROVED',
|
|
).filter((approvalAt) => approvalAt > latestAuthorResponseAt);
|
|
const latestTrustedApprovalAt = trustedApprovalAts.length > 0 ? Math.max(...trustedApprovalAts) : 0;
|
|
|
|
const feedbackAts = [
|
|
...collectFeedbackAt(author, trustedLogins, comments, (comment) => comment.user?.login, (comment) => ts(comment.created_at)),
|
|
...collectFeedbackAt(
|
|
author,
|
|
trustedLogins,
|
|
reviews,
|
|
(review) => review.user?.login,
|
|
(review) => ts(review.submitted_at || review.created_at),
|
|
(review) => {
|
|
const state = review.state?.toUpperCase();
|
|
return state !== 'APPROVED' && state !== 'DISMISSED' && state !== 'PENDING';
|
|
},
|
|
),
|
|
...collectFeedbackAt(author, trustedLogins, reviewComments, (reviewComment) => reviewComment.user?.login, (reviewComment) => ts(reviewComment.created_at)),
|
|
].filter((feedbackAt) => feedbackAt > Math.max(latestAuthorResponseAt, latestTrustedApprovalAt));
|
|
|
|
const authorActionReasons = [];
|
|
const authorActionAts = [];
|
|
|
|
if (feedbackAts.length > 0) {
|
|
authorActionReasons.push('outstanding trusted reviewer or maintainer feedback');
|
|
authorActionAts.push(Math.min(...feedbackAts));
|
|
}
|
|
|
|
const prDetails = detailedPr.data || {};
|
|
if (prDetails.mergeable === false && prDetails.mergeable_state === 'dirty') {
|
|
authorActionReasons.push('merge conflict');
|
|
authorActionAts.push(Math.max(latestAuthorResponseAt, ts(pr.updated_at || pr.created_at)));
|
|
}
|
|
|
|
const failingCheckAts = checkRuns
|
|
.filter((checkRun) => FAILED_CHECK_CONCLUSIONS.has(String(checkRun.conclusion || '').toLowerCase()))
|
|
.map((checkRun) => ts(checkRun.completed_at || checkRun.started_at))
|
|
.filter(Boolean);
|
|
const failingStatusAts = (combinedStatus.data?.statuses || [])
|
|
.filter((status) => FAILED_STATUS_STATES.has(String(status.state || '').toLowerCase()))
|
|
.map((status) => ts(status.updated_at || status.created_at))
|
|
.filter(Boolean);
|
|
const failingSignalAts = [...failingCheckAts, ...failingStatusAts];
|
|
if (failingSignalAts.length > 0) {
|
|
authorActionReasons.push('failing CI');
|
|
authorActionAts.push(Math.max(...failingSignalAts));
|
|
}
|
|
|
|
if (authorActionReasons.length === 0) {
|
|
if (DRY_RUN) {
|
|
diagnostics.push(
|
|
`#${pr.number}: skipped (no current author-owned blocker; latestAuthorResponseAt=${latestAuthorResponseAt ? new Date(latestAuthorResponseAt).toISOString() : 'none'} latestTrustedApprovalAt=${latestTrustedApprovalAt ? new Date(latestTrustedApprovalAt).toISOString() : 'none'})`,
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const authorActionAt = Math.min(...authorActionAts);
|
|
const inactivityStartedAt = Math.max(authorActionAt, latestHumanActivityAt);
|
|
|
|
const reminderSent = comments.some((comment) => {
|
|
return ts(comment.created_at) > inactivityStartedAt && comment.body?.includes(REMINDER_MARKER);
|
|
});
|
|
|
|
const inactiveFor = now - inactivityStartedAt;
|
|
const inactiveHours = Math.floor(inactiveFor / 3600000);
|
|
const authorActionAtIso = new Date(authorActionAt).toISOString();
|
|
const latestHumanActivityAtIso = latestHumanActivityAt ? new Date(latestHumanActivityAt).toISOString() : 'none';
|
|
const inactivityStartedAtIso = new Date(inactivityStartedAt).toISOString();
|
|
const reasonText = authorActionReasons.join(', ');
|
|
|
|
if (inactiveFor >= CLOSE_MS) {
|
|
const line = DRY_RUN
|
|
? `#${pr.number}: would close after ${inactiveHours}h of no human activity (${reasonText})`
|
|
: `#${pr.number}: closed after ${inactiveHours}h of no human activity (${reasonText})`;
|
|
|
|
if (DRY_RUN) {
|
|
diagnostics.push(
|
|
`#${pr.number}: action=would-close reason=${reasonText} latestAuthorResponseAt=${latestAuthorResponseAt ? new Date(latestAuthorResponseAt).toISOString() : 'none'} authorActionAt=${authorActionAtIso} latestHumanActivityAt=${latestHumanActivityAtIso} inactivityStartedAt=${inactivityStartedAtIso} reminderSent=${reminderSent}`,
|
|
);
|
|
summary.push(line);
|
|
continue;
|
|
}
|
|
|
|
await github.rest.issues.createComment({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: pr.number,
|
|
body: [
|
|
`Closing this PR for now because it appears to be waiting on author action (${reasonText}) and has had no human activity for more than 5 days.`,
|
|
'',
|
|
'This is only a queue-management step, not a rejection of the work. If you would like to continue, please leave a comment or push an update and reopen the PR when ready.',
|
|
].join('\n'),
|
|
});
|
|
|
|
await github.rest.pulls.update({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
pull_number: pr.number,
|
|
state: 'closed',
|
|
});
|
|
|
|
summary.push(line);
|
|
continue;
|
|
}
|
|
|
|
if (inactiveFor >= REMINDER_MS && !reminderSent) {
|
|
const line = DRY_RUN
|
|
? `#${pr.number}: would remind after ${inactiveHours}h of no human activity (${reasonText})`
|
|
: `#${pr.number}: reminded after ${inactiveHours}h of no human activity (${reasonText})`;
|
|
|
|
if (DRY_RUN) {
|
|
diagnostics.push(
|
|
`#${pr.number}: action=would-remind reason=${reasonText} latestAuthorResponseAt=${latestAuthorResponseAt ? new Date(latestAuthorResponseAt).toISOString() : 'none'} authorActionAt=${authorActionAtIso} latestHumanActivityAt=${latestHumanActivityAtIso} inactivityStartedAt=${inactivityStartedAtIso} reminderSent=${reminderSent}`,
|
|
);
|
|
summary.push(line);
|
|
continue;
|
|
}
|
|
|
|
await github.rest.issues.createComment({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: pr.number,
|
|
body: [
|
|
REMINDER_MARKER,
|
|
'',
|
|
`@${author} friendly reminder: this PR appears to be waiting on author action (${reasonText}) and has had no human activity for more than 3 days.`,
|
|
'',
|
|
'When you have a chance, please reply here or push an update. To keep the queue manageable, PRs with no human activity for more than 5 days may be closed automatically, but they can be reopened when work resumes.',
|
|
].join('\n'),
|
|
});
|
|
|
|
summary.push(line);
|
|
continue;
|
|
}
|
|
|
|
if (DRY_RUN) {
|
|
diagnostics.push(
|
|
`#${pr.number}: skipped (already-reminded-or-below-threshold) reason=${reasonText} latestAuthorResponseAt=${latestAuthorResponseAt ? new Date(latestAuthorResponseAt).toISOString() : 'none'} authorActionAt=${authorActionAtIso} latestHumanActivityAt=${latestHumanActivityAtIso} inactivityStartedAt=${inactivityStartedAtIso} inactiveHours=${inactiveHours} reminderSent=${reminderSent}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (DRY_RUN) {
|
|
for (const line of diagnostics) {
|
|
core.info(line);
|
|
}
|
|
}
|
|
|
|
if (summary.length === 0) {
|
|
const line = DRY_RUN ? 'Dry run: no inactive PRs would need action.' : 'No inactive PRs needed action.';
|
|
core.info(line);
|
|
await core.summary.addRaw(line).write();
|
|
return;
|
|
}
|
|
|
|
for (const line of summary) {
|
|
core.info(line);
|
|
}
|
|
|
|
await core.summary
|
|
.addHeading(DRY_RUN ? 'PR author inactivity dry run' : 'PR author inactivity actions')
|
|
.addList(summary)
|
|
.write();
|