mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
ci: add PR-author and stale-issue inactivity workflows (#2055)
* ci: add PR-author and stale-issue inactivity workflows Adds two queue-management automations: - pr-author-inactivity: reminds PR authors after 72h of inactivity following human reviewer/maintainer feedback (issue comments, non-approval reviews, or inline review comments) and closes after 120h. Author response is detected via issue comments, inline review replies, or commit/force-push events. Bot-authored reviews are intentionally excluded so authors are not pressured by automated nits alone. - stale-issues: marks issues stale after 30 days of inactivity and closes after a further 7 days. Exempts good first issue, help wanted, and security labels. A pre-step also auto-applies 'exempt-from-stale' to issues opened by org members/owners/ collaborators, since actions/stale only supports label-based exemptions. PR processing is disabled (handled by the workflow above). * fix: limit PR inactivity feedback to trusted reviewers Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode) * fix: count author PR reviews as inactivity responses Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)
This commit is contained in:
parent
bd1b41a9d4
commit
f403ffbfce
2 changed files with 346 additions and 0 deletions
242
.github/workflows/pr-author-inactivity.yml
vendored
Normal file
242
.github/workflows/pr-author-inactivity.yml
vendored
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
name: PR author inactivity
|
||||
|
||||
# Remind PR authors when reviewer / maintainer feedback has gone unanswered
|
||||
# for more than 3 days, and close after more than 5 days.
|
||||
#
|
||||
# Inactivity heuristic:
|
||||
# - We look for the latest trusted non-author feedback on an open PR via:
|
||||
# 1) issue comments,
|
||||
# 2) non-approval reviews, or
|
||||
# 3) inline review comments.
|
||||
# - We then check whether the PR author responded after that feedback via:
|
||||
# 1) an issue comment,
|
||||
# 2) a submitted pull-request review,
|
||||
# 3) an inline review comment reply, or
|
||||
# 4) a commit / force-push timeline event.
|
||||
# - If not, we remind once after 72h and close after 120h.
|
||||
#
|
||||
# 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: {}
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
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;
|
||||
|
||||
function ts(value) {
|
||||
return value ? new Date(value).getTime() : 0;
|
||||
}
|
||||
|
||||
function isBot(login) {
|
||||
return Boolean(login && login.endsWith('[bot]'));
|
||||
}
|
||||
|
||||
const TRUSTED_ASSOCIATIONS = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
|
||||
|
||||
function isTrustedReviewer(actor) {
|
||||
return Boolean(actor?.user?.login) && TRUSTED_ASSOCIATIONS.has(actor.author_association);
|
||||
}
|
||||
|
||||
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',
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
const summary = [];
|
||||
|
||||
for (const pr of pulls) {
|
||||
const author = pr.user?.login;
|
||||
if (!author || pr.draft || isBot(author)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [comments, reviews, reviewComments, timeline] = 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,
|
||||
}),
|
||||
]);
|
||||
|
||||
let latestFeedbackAt = 0;
|
||||
|
||||
for (const comment of comments) {
|
||||
const login = comment.user?.login;
|
||||
if (login === author || isBot(login) || !isTrustedReviewer(comment)) {
|
||||
continue;
|
||||
}
|
||||
latestFeedbackAt = Math.max(latestFeedbackAt, ts(comment.created_at));
|
||||
}
|
||||
|
||||
for (const review of reviews) {
|
||||
const login = review.user?.login;
|
||||
const state = review.state?.toUpperCase();
|
||||
if (login === author || isBot(login) || !isTrustedReviewer(review)) {
|
||||
continue;
|
||||
}
|
||||
if (state === 'APPROVED' || state === 'DISMISSED' || state === 'PENDING') {
|
||||
continue;
|
||||
}
|
||||
latestFeedbackAt = Math.max(latestFeedbackAt, ts(review.submitted_at || review.created_at));
|
||||
}
|
||||
|
||||
for (const reviewComment of reviewComments) {
|
||||
const login = reviewComment.user?.login;
|
||||
if (login === author || isBot(login) || !isTrustedReviewer(reviewComment)) {
|
||||
continue;
|
||||
}
|
||||
latestFeedbackAt = Math.max(latestFeedbackAt, ts(reviewComment.created_at));
|
||||
}
|
||||
|
||||
if (!latestFeedbackAt) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let latestAuthorResponseAt = 0;
|
||||
|
||||
for (const comment of comments) {
|
||||
if (comment.user?.login !== author) {
|
||||
continue;
|
||||
}
|
||||
latestAuthorResponseAt = Math.max(latestAuthorResponseAt, ts(comment.created_at));
|
||||
}
|
||||
|
||||
for (const review of reviews) {
|
||||
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) {
|
||||
if (reviewComment.user?.login !== author) {
|
||||
continue;
|
||||
}
|
||||
latestAuthorResponseAt = Math.max(latestAuthorResponseAt, ts(reviewComment.created_at));
|
||||
}
|
||||
|
||||
for (const event of timeline) {
|
||||
if (event.actor?.login !== author) {
|
||||
continue;
|
||||
}
|
||||
if (!['committed', 'head_ref_force_pushed'].includes(event.event)) {
|
||||
continue;
|
||||
}
|
||||
latestAuthorResponseAt = Math.max(latestAuthorResponseAt, ts(event.created_at));
|
||||
}
|
||||
|
||||
if (latestAuthorResponseAt > latestFeedbackAt) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const reminderSent = comments.some((comment) => {
|
||||
return ts(comment.created_at) > latestFeedbackAt && comment.body?.includes(REMINDER_MARKER);
|
||||
});
|
||||
|
||||
const inactiveFor = now - latestFeedbackAt;
|
||||
|
||||
if (inactiveFor >= CLOSE_MS) {
|
||||
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 has been waiting on an author response for more than 5 days after reviewer or maintainer feedback.',
|
||||
'',
|
||||
'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(`#${pr.number}: closed after ${Math.floor(inactiveFor / 3600000)}h of author inactivity`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inactiveFor >= REMINDER_MS && !reminderSent) {
|
||||
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 has been waiting on an author response for more than 3 days after reviewer or maintainer feedback.`,
|
||||
'',
|
||||
'When you have a chance, please reply here or push an update. To keep the queue manageable, PRs with no author activity for more than 5 days after feedback may be closed automatically, but they can be reopened when work resumes.',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
summary.push(`#${pr.number}: reminded after ${Math.floor(inactiveFor / 3600000)}h of author inactivity`);
|
||||
}
|
||||
}
|
||||
|
||||
if (summary.length === 0) {
|
||||
core.info('No inactive PRs needed action.');
|
||||
await core.summary.addRaw('No inactive PRs needed action.').write();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const line of summary) {
|
||||
core.info(line);
|
||||
}
|
||||
|
||||
await core.summary
|
||||
.addHeading('PR author inactivity actions')
|
||||
.addList(summary)
|
||||
.write();
|
||||
104
.github/workflows/stale-issues.yml
vendored
Normal file
104
.github/workflows/stale-issues.yml
vendored
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
name: Stale issues
|
||||
|
||||
# Mark issues as stale after 30 days of inactivity, and close them after a
|
||||
# further 7 days if no activity resumes.
|
||||
#
|
||||
# Exemption policy:
|
||||
# - Label-based exemptions: issues carrying any of the labels in
|
||||
# EXEMPT_LABELS are never marked stale (security, contributor-invite,
|
||||
# priority, awaiting product direction, etc.).
|
||||
# - Author-based exemption: issues opened by org members, owners, or
|
||||
# collaborators are also exempt. Since actions/stale only supports
|
||||
# label-based exemptions, a pre-step labels qualifying issues with
|
||||
# `exempt-from-stale` so the stale step honors the same rule.
|
||||
#
|
||||
# PRs are handled separately by pr-author-inactivity.yml; this workflow
|
||||
# disables PR processing by setting the PR thresholds to -1.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 1 * * *'
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: stale-issues
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
if: github.repository == 'nexu-io/open-design'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Exempt org-member issues from staling
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const EXEMPT_LABEL = 'exempt-from-stale';
|
||||
const EXEMPT_ASSOCIATIONS = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
|
||||
|
||||
// Ensure the exempt label exists so the stale step can match it.
|
||||
try {
|
||||
await github.rest.issues.getLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: EXEMPT_LABEL,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.status !== 404) throw err;
|
||||
await github.rest.issues.createLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: EXEMPT_LABEL,
|
||||
color: 'cccccc',
|
||||
description: 'Issue is exempt from automatic stale handling (set by stale-issues workflow).',
|
||||
});
|
||||
}
|
||||
|
||||
const issues = await github.paginate(github.rest.issues.listForRepo, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
let labeled = 0;
|
||||
|
||||
for (const issue of issues) {
|
||||
if (issue.pull_request) continue;
|
||||
if (!EXEMPT_ASSOCIATIONS.has(issue.author_association)) continue;
|
||||
if (issue.labels.some((l) => (typeof l === 'string' ? l : l.name) === EXEMPT_LABEL)) continue;
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: [EXEMPT_LABEL],
|
||||
});
|
||||
labeled += 1;
|
||||
}
|
||||
|
||||
core.info(`Applied ${EXEMPT_LABEL} to ${labeled} org-member issue(s).`);
|
||||
|
||||
- name: Mark and close stale issues
|
||||
uses: actions/stale@v9
|
||||
with:
|
||||
days-before-issue-stale: 30
|
||||
days-before-issue-close: 7
|
||||
# Disable PR processing; PRs are handled by pr-author-inactivity.yml.
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
stale-issue-label: 'stale'
|
||||
exempt-issue-labels: 'good first issue,help wanted,security,exempt-from-stale'
|
||||
stale-issue-message: |
|
||||
This issue has been inactive for 30 days and is being marked as stale. If it is still relevant, please leave a comment or push an update — otherwise it will be closed in 7 days.
|
||||
|
||||
This is only a queue-management step, not a rejection of the report. Closed issues can be reopened when work resumes.
|
||||
close-issue-message: |
|
||||
Closing this issue for now because it has been inactive for more than 37 days. If it is still relevant, please leave a comment and reopen — context is preserved and we can pick it back up.
|
||||
remove-issue-stale-when-updated: true
|
||||
operations-per-run: 100
|
||||
ascending: true
|
||||
Loading…
Reference in a new issue