Compare commits

...

16 Commits

Author SHA1 Message Date
Muyuan Li (from Dev Box)
db706236b7 Remove author-responded-comment job (fabricbot already handles it); simplify triggers 2026-06-26 16:47:52 +08:00
Muyuan Li (from Dev Box)
fbdaaf0e6b Remove Needs-Triage auto-labeling; focus PR on Needs-Author-Feedback stale lifecycle only 2026-06-25 15:13:17 +08:00
Muyuan Li (from Dev Box)
cb5e47ff8e Address review round 1: fix header, deduplicate comment, align indentation, add Ready-for-review guidance 2026-06-25 15:13:16 +08:00
Muyuan Li (from Dev Box)
ff7a34db8f Address review: document commit attribution limitation
Add inline comment explaining that commits with unlinked GitHub
accounts are not counted by the scheduled job, and why this is
acceptable (event-driven Job 2 catches pushes via synchronize).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-24 17:38:27 +08:00
Muyuan Li (from Dev Box)
d81b00a6b2 Address review: reorder label swap, close directly at 14d without warning
- Reorder add/remove in Jobs 2 & 3: add Needs-Triage before removing
  Needs-Author-Feedback so cancellation leaves a safe label state
- In the draft path, if no bot warning exists but inactivity >= 14 days,
  skip the warning and close directly instead of requiring an extra cycle

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-24 17:31:13 +08:00
Muyuan Li (from Dev Box)
06cd907027 Address review: add hasLabel re-check before all comment/close mutations
Ensures no stale warning comments or incorrect closes happen if the
label is removed concurrently while the scheduled batch is running.
Added re-checks before: draft warning post, post-conversion comment,
and fallback close.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-24 17:08:24 +08:00
Muyuan Li (from Dev Box)
df4103c9f5 Address review: drop unused contents:read permission
No job reads repository contents — only issues/PRs and GraphQL
mutations are used. Minimizes token scope for pull_request_target.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-24 16:56:20 +08:00
Muyuan Li (from Dev Box)
9c365e71cb Address review: soften '7 more days' wording to 'approximately 7 more days'
The scheduled job runs every 6 hours, so if draft conversion is
detected later than exactly day 7, the actual remaining time until
the 14-day close could be slightly less. 'Approximately' keeps the
message accurate regardless of scheduling delays.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-24 16:35:44 +08:00
Muyuan Li (from Dev Box)
2d59ef6642 Address review: count review submissions as activity, simplify concurrency group
- Include pulls.listReviews in author activity check so submitting a
  review resets the inactivity timer (matches documented behavior)
- Remove review.pull_request_url from concurrency group (pull_request.number
  already covers review events; keeps group names short and stable)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-24 16:25:15 +08:00
Muyuan Li (from Dev Box)
e5911aa5ef Address review: early skip for recent labels, re-check before mutations
- Skip expensive API pagination for PRs labeled < 7 days ago (can never
  meet draft/close threshold)
- Re-check that Needs-Author-Feedback label is still present before
  converting to draft or closing (guards against race with concurrent
  label removal)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-24 16:16:42 +08:00
Muyuan Li (from Dev Box)
99705ec2ab Fix BOT_DRAFT_MARKER to match contiguously in both comment variants
The previous marker phrase spanned a line break in the join()'d comment
body, so it never matched the conversion comment. Use the shorter
'requiring author feedback' which appears on a single line in both the
auto-conversion comment and the already-draft warning comment.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-24 14:44:21 +08:00
Muyuan Li (from Dev Box)
c8cefadc4b Add pull_request_review_comment trigger for inline diff comments
Extends the workflow to handle 'Add single comment' on diffs
(pull_request_review_comment events). Without this, inline review
comments from the author were counted as activity by the scheduled
job but did not remove the Needs-Author-Feedback label in real time.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-24 10:39:24 +08:00
Muyuan Li (from Dev Box)
42d49dd779 Address review round 3: concurrency, open-state check, label cycles, wording, fallback
- Use stable concurrency group ('maintenance') for schedule/dispatch runs
  so only one maintenance run is active at a time.
- Add open-state check for pull_request_review trigger to avoid mutating
  labels on closed PRs.
- Bot warning detection now requires the comment timestamp >= label-applied
  date, so label remove/re-apply cycles don't reuse stale warnings.
- Already-draft PRs get accurate wording ('This draft pull request has been
  marked as requiring author feedback...') instead of falsely claiming
  conversion.
- If GraphQL convertPullRequestToDraft fails and inactivity >= 14 days,
  close the PR as a fallback instead of leaving it open indefinitely.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-24 10:31:25 +08:00
Muyuan Li (from Dev Box)
3c4887d158 Address review round 2: concurrency, review triggers, label-based timer
- Add concurrency guard to prevent overlapping runs from racing on the
  same PR (consistent with auto-labeler.yml pattern).
- Add pull_request_review trigger so author review submissions also
  remove the Needs-Author-Feedback label.
- Include PR review comments (pulls.listReviewComments) in the scheduled
  inactivity check, not just issue comments.
- Use the label-applied timestamp as inactivity baseline: timer starts
  from max(label_applied_date, last_author_activity), so labeling a
  long-stale PR gives the author a fresh 7-day window.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-23 17:02:23 +08:00
Muyuan Li (from Dev Box)
d5c15dbb1d Address review feedback: fix timing logic, author filtering, v9, pagination
- Fix: close only after 14 days of author inactivity (not 7 days after
  draft conversion). The previous logic closed on the next 6h cycle after
  converting to draft.
- Fix: filter commits by author login so maintainer pushes don't reset the
  inactivity timer.
- Fix: use paginate() for both listComments and listCommits to handle PRs
  with >100 items.
- Fix: only post the draft-conversion comment if the GraphQL mutation
  actually succeeded.
- Bump actions/github-script from v7 to v9 for consistency with the rest
  of the repo.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-23 16:43:45 +08:00
Muyuan Li (from Dev Box)
626cdb27ca Add PR Needs-Author-Feedback lifecycle workflow
Adds a GitHub Actions workflow that manages the Needs-Author-Feedback
label lifecycle for pull requests:

- New non-draft PRs automatically receive the Needs-Triage label
- When author pushes commits or comments, Needs-Author-Feedback is
  removed and Needs-Triage is re-added
- After 7 days of inactivity, PR is converted to draft with a warning
- After 14 days of inactivity, PR is closed with a clear message

This complements the existing fabricbot-based issue management in
resourceManagement.yml by adding PR-specific behaviors (draft conversion)
that require GitHub Actions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-23 16:16:01 +08:00

View File

@@ -0,0 +1,413 @@
name: PR Needs-Author-Feedback lifecycle
# Handles the lifecycle of the "Needs-Author-Feedback" label on pull requests:
# 1. When the author pushes commits → remove "Needs-Author-Feedback".
# (Comment/review-based label removal is already handled by fabricbot in
# resourceManagement.yml via the Issue_Comment eventResponderTask.)
# 2. Scheduled check every 6 hours:
# - After 7 days of author inactivity (measured from label application or last
# author activity, whichever is later) → convert PR to draft + comment.
# - After 14 days → close PR + comment.
on:
pull_request_target:
types: [synchronize]
schedule:
# Run every 6 hours to check for stale PRs
- cron: '0 */6 * * *'
workflow_dispatch:
inputs:
dry_run:
description: 'If true, only log actions without making changes'
required: false
default: 'false'
type: choice
options:
- 'false'
- 'true'
permissions:
pull-requests: write
issues: write
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number || 'maintenance' }}
cancel-in-progress: true
jobs:
# ─────────────────────────────────────────────────────────────────────────────
# Job 1: When author pushes commits → remove "Needs-Author-Feedback"
# ─────────────────────────────────────────────────────────────────────────────
author-responded-push:
if: >-
github.event_name == 'pull_request_target' &&
github.event.action == 'synchronize' &&
github.event.sender.id == github.event.pull_request.user.id
runs-on: ubuntu-latest
steps:
- name: Remove Needs-Author-Feedback
uses: actions/github-script@v9
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const prNumber = context.payload.pull_request.number;
const labels = (await github.rest.issues.listLabelsOnIssue({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
})).data.map(l => l.name);
if (labels.includes('Needs-Author-Feedback')) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
name: 'Needs-Author-Feedback',
});
console.log(`Removed Needs-Author-Feedback from PR #${prNumber}`);
}
# ─────────────────────────────────────────────────────────────────────────────
# Job 2: Scheduled check — convert to draft after 7 days, close after 14 days
# ─────────────────────────────────────────────────────────────────────────────
check-stale-prs:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- name: Process stale PRs with Needs-Author-Feedback
uses: actions/github-script@v9
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const DRY_RUN = '${{ github.event.inputs.dry_run }}' === 'true';
const DAYS_TO_DRAFT = 7;
const DAYS_TO_CLOSE = 14;
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const now = Date.now();
if (DRY_RUN) {
console.log('🔍 DRY RUN MODE — no changes will be made');
}
// Re-check that the label is still present before mutating.
// Guards against races where a maintainer removes the label while
// this batch job is running.
async function hasLabel(prNumber, labelName) {
const { data: labels } = await github.rest.issues.listLabelsOnIssue({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
});
return labels.some(l => l.name === labelName);
}
// Find all open PRs with "Needs-Author-Feedback" label
const prs = await github.paginate(github.rest.issues.listForRepo, {
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
labels: 'Needs-Author-Feedback',
per_page: 100,
});
for (const issue of prs) {
// Skip issues (only process PRs)
if (!issue.pull_request) continue;
const prNumber = issue.number;
// Get the full PR object to check draft status
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
const authorLogin = pr.user.login;
// Determine when "Needs-Author-Feedback" was applied.
// The inactivity timer starts from label application, not from the
// PR's overall last activity, so labeling a long-stale PR doesn't
// cause immediate draft/close.
const events = await github.paginate(github.rest.issues.listEvents, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
per_page: 100,
});
let labelAppliedDate = new Date(pr.created_at).getTime();
for (const event of events) {
if (event.event === 'labeled' && event.label?.name === 'Needs-Author-Feedback') {
const eventDate = new Date(event.created_at).getTime();
if (eventDate > labelAppliedDate) {
labelAppliedDate = eventDate;
}
}
}
// Early skip: if the label was applied less than DAYS_TO_DRAFT ago,
// the PR can never meet the draft/close thresholds yet — avoid
// expensive commit/comment pagination.
const daysSinceLabelApplied = (now - labelAppliedDate) / MS_PER_DAY;
if (daysSinceLabelApplied < DAYS_TO_DRAFT) {
console.log(`PR #${prNumber}: label applied only ${daysSinceLabelApplied.toFixed(1)} days ago (need ${DAYS_TO_DRAFT}); skipping`);
continue;
}
// Fetch all commits on the PR for author-activity detection.
const allCommits = await github.paginate(github.rest.pulls.listCommits, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
per_page: 100,
});
// Find last commit authored by the PR author.
// Note: if GitHub cannot associate the git committer with a GitHub
// user (commit.author is null), those commits are not counted here.
// This is acceptable because the event-driven job (Job 2) reliably
// catches author pushes via the synchronize event and removes the
// label immediately — the scheduled job is a secondary fallback.
let lastAuthorCommitDate = 0;
for (const commit of allCommits) {
const commitAuthorLogin = commit.author?.login || commit.committer?.login;
if (commitAuthorLogin === authorLogin) {
const commitDate = commit.commit?.committer?.date || commit.commit?.author?.date;
if (commitDate) {
const ts = new Date(commitDate).getTime();
if (ts > lastAuthorCommitDate) {
lastAuthorCommitDate = ts;
}
}
}
}
// Get all comments (issue comments + review comments) from the author.
const allIssueComments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
per_page: 100,
});
const allReviewComments = await github.paginate(github.rest.pulls.listReviewComments, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
per_page: 100,
});
const allComments = [...allIssueComments, ...allReviewComments];
let lastAuthorCommentDate = 0;
for (const comment of allComments) {
if (comment.user?.login === authorLogin && comment.user?.type !== 'Bot') {
const commentDate = new Date(comment.created_at || comment.submitted_at).getTime();
if (commentDate > lastAuthorCommentDate) {
lastAuthorCommentDate = commentDate;
}
}
}
// Also count review submissions (e.g., author submitting a review)
// as author activity.
const allReviews = await github.paginate(github.rest.pulls.listReviews, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
per_page: 100,
});
let lastAuthorReviewDate = 0;
for (const review of allReviews) {
if (review.user?.login === authorLogin) {
const reviewDate = new Date(review.submitted_at).getTime();
if (reviewDate > lastAuthorReviewDate) {
lastAuthorReviewDate = reviewDate;
}
}
}
// The inactivity baseline is the later of: when the label was applied,
// or the author's last activity. This ensures that labeling an already-
// stale PR starts a fresh 7-day window rather than acting immediately.
const lastAuthorActivity = Math.max(lastAuthorCommitDate, lastAuthorCommentDate, lastAuthorReviewDate);
const inactivityBaseline = Math.max(lastAuthorActivity, labelAppliedDate);
const daysSinceBaseline = (now - inactivityBaseline) / MS_PER_DAY;
console.log(`PR #${prNumber}: ${daysSinceBaseline.toFixed(1)} days since inactivity baseline (author activity: ${((now - lastAuthorActivity) / MS_PER_DAY).toFixed(1)}d ago, label applied: ${((now - labelAppliedDate) / MS_PER_DAY).toFixed(1)}d ago), draft=${pr.draft}`);
// If already draft and still inactive → check whether to warn or close.
if (pr.draft) {
const BOT_DRAFT_MARKER = 'requiring author feedback';
// Only consider bot warnings posted AFTER the label was (re-)applied.
const botWarningComment = allComments.find(c =>
c.user?.type === 'Bot' &&
c.body?.includes(BOT_DRAFT_MARKER) &&
new Date(c.created_at).getTime() >= labelAppliedDate
);
if (!botWarningComment) {
// No prior warning exists for this label cycle.
if (daysSinceBaseline < DAYS_TO_DRAFT) {
continue;
}
// If already past the close threshold, skip the warning and
// fall through to the close logic below.
if (daysSinceBaseline < DAYS_TO_CLOSE) {
if (DRY_RUN) {
console.log(`[DRY RUN] Would post draft warning on PR #${prNumber} (draft, no prior warning this cycle)`);
continue;
}
if (!(await hasLabel(prNumber, 'Needs-Author-Feedback'))) {
console.log(`PR #${prNumber}: label was removed while job was running; skipping`);
continue;
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: [
'This draft pull request has been marked as requiring author feedback but has',
'not had any activity for **7 days**.',
'',
'If no further activity occurs within approximately **7 more days**, this PR',
'will be closed automatically.',
'',
'To continue working on this PR, please push your changes or leave a comment.',
'Once the label is removed, please also click **"Ready for review"** to move',
'your PR out of draft state.',
].join('\n'),
});
console.log(`PR #${prNumber} is draft; posted warning (will close on next cycle if still inactive)`);
continue;
}
}
// Close if DAYS_TO_CLOSE have passed since the inactivity baseline.
if (daysSinceBaseline < DAYS_TO_CLOSE) {
console.log(`PR #${prNumber}: draft with warning, but only ${daysSinceBaseline.toFixed(1)} days since baseline (need ${DAYS_TO_CLOSE}); skipping`);
continue;
}
if (DRY_RUN) {
console.log(`[DRY RUN] Would close PR #${prNumber} (inactive ${daysSinceBaseline.toFixed(1)} days)`);
continue;
}
// Re-check label before closing (guards against concurrent removal).
if (!(await hasLabel(prNumber, 'Needs-Author-Feedback'))) {
console.log(`PR #${prNumber}: label was removed while job was running; skipping`);
continue;
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: [
'This pull request has been automatically closed because it has been marked as',
'requiring author feedback but has not had any activity for **14 days**.',
'',
'If you would like to continue working on this, please reopen the PR and push',
'your changes. The `Needs-Author-Feedback` label will be removed automatically',
'when you do so.',
].join('\n'),
});
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
state: 'closed',
});
console.log(`Closed PR #${prNumber} (inactive ${daysSinceBaseline.toFixed(1)} days)`);
continue;
}
// Not draft yet — only convert after DAYS_TO_DRAFT since baseline.
if (daysSinceBaseline < DAYS_TO_DRAFT) {
continue;
}
if (DRY_RUN) {
console.log(`[DRY RUN] Would convert PR #${prNumber} to draft (inactive ${daysSinceBaseline.toFixed(1)} days)`);
continue;
}
// Re-check label before mutating (guards against concurrent removal).
if (!(await hasLabel(prNumber, 'Needs-Author-Feedback'))) {
console.log(`PR #${prNumber}: label was removed while job was running; skipping`);
continue;
}
// GitHub REST API does not support converting to draft; use GraphQL.
const mutation = `
mutation($pullRequestId: ID!) {
convertPullRequestToDraft(input: { pullRequestId: $pullRequestId }) {
pullRequest { id isDraft }
}
}
`;
let draftConversionSucceeded = false;
try {
await github.graphql(mutation, { pullRequestId: pr.node_id });
draftConversionSucceeded = true;
console.log(`Converted PR #${prNumber} to draft (inactive ${daysSinceBaseline.toFixed(1)} days)`);
} catch (err) {
console.log(`Failed to convert PR #${prNumber} to draft: ${err.message}`);
}
if (draftConversionSucceeded) {
if (!(await hasLabel(prNumber, 'Needs-Author-Feedback'))) {
console.log(`PR #${prNumber}: label was removed after draft conversion; skipping comment`);
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: [
'This pull request has been automatically converted to a draft because it has',
'been marked as requiring author feedback but has not had any activity for',
'**7 days**.',
'',
'If no further activity occurs within approximately **7 more days**, this PR',
'will be closed automatically.',
'',
'To continue working on this PR, please push your changes or leave a comment.',
'Once the label is removed, please also click **"Ready for review"** to move',
'your PR out of draft state.',
].join('\n'),
});
}
} else if (daysSinceBaseline >= DAYS_TO_CLOSE) {
// Fallback: if draft conversion keeps failing and we've exceeded
// the close threshold, close the PR anyway.
if (!(await hasLabel(prNumber, 'Needs-Author-Feedback'))) {
console.log(`PR #${prNumber}: label was removed while job was running; skipping fallback close`);
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: [
'This pull request has been automatically closed because it has been marked as',
'requiring author feedback but has not had any activity for **14 days**.',
'',
'If you would like to continue working on this, please reopen the PR and push',
'your changes. The `Needs-Author-Feedback` label will be removed automatically',
'when you do so.',
].join('\n'),
});
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
state: 'closed',
});
console.log(`Closed PR #${prNumber} as fallback (draft conversion failed, inactive ${daysSinceBaseline.toFixed(1)} days)`);
}
}
}