Unverified 提交 56c2b0fc authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

feat: add needs-human PR status labels and unified review marker (#2589)

## Summary - Add two new `needs-human:*` labels to triage PRs: `needs-human:review-issue` (PR needs attention) and `needs-human:final-check` (PR is green and ready for merge) - Create shared `scripts/pr-status-labeler.js` that determines the correct label based on CI conclusion + latest Dyadbot code review comment - Create new `pr-status-labeler.yml` workflow for PRs not managed by the cc:request retry loop - Update `pr-review-responder.yml` to apply needs-human labels at terminal states (cc:done, cc:failed, retries exhausted) - Unify review comment headers to "Dyadbot Code Review Summary" across both multi-agent and swarm review skills for reliable detection ## Test plan - [ ] Open a test PR with passing CI and clean review → verify `needs-human:final-check` label is added - [ ] Open a test PR with failing CI → verify `needs-human:review-issue` label is added - [ ] Verify PRs with `cc:request*` labels are skipped by the new `pr-status-labeler` workflow - [ ] Verify labels are mutually exclusive (adding one removes the other) - [ ] Verify review comments show the new "Dyadbot Code Review Summary" header 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2589" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Automatically labels PRs as needs-human:review-issue or needs-human:final-check based on CI and the latest Dyadbot review. Standardizes the review comment header to “Dyadbot Code Review Summary” and hardens detection with stale-review checks, pagination, and atomic label updates. - New Features - Added scripts/pr-status-labeler.js to choose labels using CI conclusion + latest “Dyadbot Code Review Summary”. - New pr-status-labeler.yml runs on CI completion; skips PRs with cc:request* or cc:pending; checks out the default branch for trusted scripts. - pr-review-responder.yml applies needs-human labels at terminal states and checks out the base repo so the shared script is always present. - Bug Fixes - Hardened review parsing: match only non-zero severities, allow LOW-only pass, default fail-closed, and verify bot author. - Paginate comment fetch and detect stale reviews by comparing review time to the latest commit. - Make label changes atomic with setLabels; ensureLabel now only swallows 422 errors. <sup>Written for commit 2fc253289e509cddbf6ed6202f4bce2435cbc791. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches GitHub Actions automation that edits PR labels based on CI and bot comments, so misclassification could affect review/merge triage. Changes are localized to workflows/scripts and are fail-closed when review format is unrecognized. > > **Overview** > Adds automated PR status labeling via new `needs-human:review-issue` and `needs-human:final-check` labels, driven by CI conclusion plus the latest Dyadbot review summary comment. > > Introduces shared `scripts/pr-status-labeler.js` and a new `pr-status-labeler.yml` workflow to apply these labels for PRs *not* in the `cc:request*` retry loop; updates `pr-review-responder.yml` to apply the same labeling at terminal states and when retries are exhausted (including a base-repo checkout so the script is available for fork PRs). > > Standardizes the review summary header/footer text across multi-agent and swarm review outputs to `Dyadbot Code Review Summary` so the labeler can reliably detect and evaluate review cleanliness. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 2fc253289e509cddbf6ed6202f4bce2435cbc791. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
上级 06d25a33
......@@ -103,7 +103,7 @@ Options:
#### Example Summary Comment
```markdown
## :mag: Multi-Agent Code Review
## :mag: Dyadbot Code Review Summary
Found **4** new issue(s) flagged by 3 independent reviewers.
(2 issue(s) skipped - already commented)
......@@ -133,7 +133,7 @@ Found **4** new issue(s) flagged by 3 independent reviewers.
See inline comments for details.
_Generated by multi-agent consensus review_
_Generated by Dyadbot code review_
```
## File Structure
......
......@@ -186,7 +186,7 @@ def format_summary_comment(
low_issues = [i for i in issues if i.get('severity') == 'LOW']
lines = [
"## :mag: Multi-Agent Code Review",
"## :mag: Dyadbot Code Review Summary",
"",
]
......@@ -196,7 +196,7 @@ def format_summary_comment(
lines.append(f":white_check_mark: No new issues found. ({num_duplicates} issue(s) already commented on)")
else:
lines.append(":white_check_mark: No issues found by consensus review.")
lines.extend(["", "*Generated by multi-agent consensus review*"])
lines.extend(["", "*Generated by Dyadbot code review*"])
return "\n".join(lines)
total_new = len(issues)
......@@ -267,7 +267,7 @@ def format_summary_comment(
lines.append("See inline comments for details.")
lines.append("")
lines.append("*Generated by multi-agent consensus review*")
lines.append("*Generated by Dyadbot code review*")
return "\n".join(lines)
......
......@@ -215,7 +215,7 @@ Based on the confirmed issues:
Post a summary comment on the PR using `gh pr comment`:
```markdown
## :mag: Swarm Code Review
## :mag: Dyadbot Code Review Summary
**Verdict: [VERDICT EMOJI + TEXT]**
......@@ -245,7 +245,7 @@ Reviewed by 3 specialized agents: Correctness Expert, Code Health Expert, UX Wiz
</details>
---
*Generated by swarm code review — 3 agents collaborated on this review*
*Generated by Dyadbot code review*
````
#### Inline Comments
......
......@@ -105,22 +105,9 @@ jobs:
// Guard: do not loop more than 3 retries (4 total runs: initial + 3 retries)
// cc:request (0) -> cc:request:1 (1) -> cc:request:2 (2) -> cc:request:3 (3) -> blocked
if (requestCount >= 4) {
console.log(`PR #${prNumber} has reached max retry count (${requestCount}), adding cc:needs-human-review`);
console.log(`PR #${prNumber} has reached max retry count (${requestCount}), adding needs-human:review-issue`);
// Ensure the label exists
try {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: 'cc:needs-human-review',
color: 'd93f0b',
description: 'Claude Code has exhausted auto-retries; needs human review'
});
} catch (e) {
// Label already exists, ignore
}
// Remove current label, cc:pending, and add needs-human-review
// Remove current label, cc:pending
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
......@@ -135,13 +122,22 @@ jobs:
name: 'cc:pending'
}).catch(() => {});
// Also add needs-human:review-issue (retries exhausted = needs human attention).
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
labels: ['cc:needs-human-review']
labels: ['needs-human:review-issue']
});
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
name: 'needs-human:final-check'
}).catch(() => {});
core.setOutput('pr_number', prNumber);
core.setOutput('should_continue', 'false');
return;
}
......@@ -248,26 +244,6 @@ jobs:
echo "commits_pushed=false" >> $GITHUB_OUTPUT
fi
- name: Ensure next request label exists
# Use always() so retry loop continues even if Claude Code fails after pushing commits
if: steps.pr-info.outputs.should_continue == 'true' && always() && steps.retrigger.outputs.commits_pushed == 'true'
uses: actions/github-script@v7
with:
script: |
const nextCount = parseInt('${{ steps.pr-info.outputs.request_count }}', 10) + 1;
const labelName = `cc:request:${nextCount}`;
try {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: labelName,
color: '1d76db',
description: `Claude Code auto-re-request iteration ${nextCount}`
});
} catch (e) {
// Label already exists, ignore
}
- name: Update labels - re-request review (commits pushed)
# Use always() so retry loop continues even if Claude Code fails after pushing commits
if: steps.pr-info.outputs.should_continue == 'true' && always() && steps.retrigger.outputs.commits_pushed == 'true'
......@@ -292,3 +268,36 @@ jobs:
gh pr edit ${{ steps.pr-info.outputs.pr_number }} --repo ${{ github.repository }} --remove-label "cc:pending" --add-label "cc:failed"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Checkout base repo for labeler script
# The earlier checkout (line 186) checks out the PR head repo, which for
# fork PRs may not contain scripts/pr-status-labeler.js. Checkout the
# base repo's default branch to a separate path so the script is always available.
if: >-
steps.pr-info.outputs.should_continue == 'true' &&
always() &&
steps.retrigger.outputs.commits_pushed != 'true'
uses: actions/checkout@v5
with:
path: __base
sparse-checkout: scripts/pr-status-labeler.js
- name: Apply needs-human status label
# Run when PR reaches a terminal state (cc:done or cc:failed) but NOT when
# commits were pushed (retry loop continues). Uses always() to run even if
# the Claude Code step failed.
if: >-
steps.pr-info.outputs.should_continue == 'true' &&
always() &&
steps.retrigger.outputs.commits_pushed != 'true'
uses: actions/github-script@v7
with:
script: |
const { run } = require('./__base/scripts/pr-status-labeler.js');
await run({
github,
context,
core,
prNumber: parseInt('${{ steps.pr-info.outputs.pr_number }}', 10),
ciConclusion: '${{ github.event.workflow_run.conclusion }}'
});
name: PR Status Labeler
on:
workflow_run:
workflows: ["CI"]
types: [completed]
permissions:
contents: read
pull-requests: write
issues: write
jobs:
label-pr:
runs-on: ubuntu-latest
steps:
- name: Find PR and check eligibility
id: pr-info
uses: actions/github-script@v7
with:
script: |
const run = context.payload.workflow_run;
// Find associated PR (same approach as playwright-comment.yml)
let prNumber;
if (run.pull_requests && run.pull_requests.length > 0) {
prNumber = run.pull_requests[0].number;
} else {
if (!run.head_repository) {
console.log('Head repository not available');
core.setOutput('should_continue', 'false');
return;
}
const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
head: `${run.head_repository.owner.login}:${run.head_branch}`
});
if (prs.length === 0) {
console.log('No pull requests found for this workflow run');
core.setOutput('should_continue', 'false');
return;
}
prNumber = prs[0].number;
}
// Fetch full PR details to check labels
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber
});
// Skip PRs actively managed by pr-review-responder (cc:request* or cc:pending)
// PRs with cc:done/cc:failed/needs-human-review ARE eligible —
// a human may have pushed new commits after the automation loop ended.
const activeLabels = pr.labels.filter(l =>
l.name.startsWith('cc:request') || l.name === 'cc:pending'
);
if (activeLabels.length > 0) {
console.log(`PR #${prNumber} is in the cc: retry loop (${activeLabels.map(l => l.name).join(', ')}), skipping`);
core.setOutput('should_continue', 'false');
return;
}
console.log(`PR #${prNumber} is eligible for status labeling`);
core.setOutput('should_continue', 'true');
core.setOutput('pr_number', prNumber);
- name: Checkout repository (default branch for trusted scripts)
if: steps.pr-info.outputs.should_continue == 'true'
uses: actions/checkout@v4
- name: Apply status label
if: steps.pr-info.outputs.should_continue == 'true'
uses: actions/github-script@v7
with:
script: |
const { run } = require('./scripts/pr-status-labeler.js');
await run({
github,
context,
core,
prNumber: parseInt('${{ steps.pr-info.outputs.pr_number }}', 10),
ciConclusion: '${{ github.event.workflow_run.conclusion }}'
});
// Shared logic for applying needs-human:* labels to PRs based on CI status and code review results.
// Used by both pr-review-responder.yml (for cc:request PRs) and pr-status-labeler.yml (for all other PRs).
const LABEL_REVIEW_ISSUE = "needs-human:review-issue";
const LABEL_FINAL_CHECK = "needs-human:final-check";
const REVIEW_MARKER = "Dyadbot Code Review Summary";
// Review verdict strings — keep in sync with:
// Swarm verdicts: .claude/skills/swarm-pr-review/SKILL.md
// Multi-agent output: .claude/skills/multi-pr-review/scripts/post_comment.py
const SWARM_VERDICT_CLEAN = "YES - Ready to merge";
const SWARM_VERDICT_UNSURE = "NOT SURE - Potential issues";
const SWARM_VERDICT_REJECT = "NO - Do NOT merge";
const MULTI_AGENT_NO_ISSUES = ":white_check_mark: No issues found";
const MULTI_AGENT_NO_NEW_ISSUES = ":white_check_mark: No new issues found";
// Severity table regexes match "| :emoji: LEVEL | N |" rows with non-zero counts
const HIGH_ISSUES_RE = /:red_circle:.*?\|\s*[1-9]/;
const MEDIUM_ISSUES_RE = /:yellow_circle:.*?\|\s*[1-9]/;
const LOW_ISSUES_RE = /:green_circle:.*?\|\s*\d/;
function findLatestReviewComment(comments) {
for (let i = comments.length - 1; i >= 0; i--) {
const body = comments[i].body || "";
const user = comments[i].user || {};
if (body.includes(REVIEW_MARKER) && user.type === "Bot") {
return comments[i];
}
}
return null;
}
function isReviewClean(body) {
// Swarm verdict: explicit clean
if (body.includes(SWARM_VERDICT_CLEAN)) {
return true;
}
// Multi-agent: no issues found
if (
body.includes(MULTI_AGENT_NO_ISSUES) ||
body.includes(MULTI_AGENT_NO_NEW_ISSUES)
) {
return true;
}
// If there are HIGH or MEDIUM severity markers with non-zero counts, review has issues.
// The severity table always renders rows like "| :red_circle: HIGH | 0 |" even at count 0,
// so we match only rows where the count is >= 1.
if (body.match(HIGH_ISSUES_RE) || body.match(MEDIUM_ISSUES_RE)) {
return false;
}
// Multi-agent: severity table present with only LOW issues (HIGH=0 and MEDIUM=0
// already passed the regex check above, so reaching here means only LOW remain)
if (body.match(LOW_ISSUES_RE)) {
return true;
}
// Swarm verdicts indicating issues
if (
body.includes(SWARM_VERDICT_UNSURE) ||
body.includes(SWARM_VERDICT_REJECT)
) {
return false;
}
// No clear signal — fail-closed: flag for human review rather than
// silently treating an unrecognized format as clean.
return false;
}
async function applyLabel(github, owner, repo, prNumber, addLabel) {
const removeLabel =
addLabel === LABEL_REVIEW_ISSUE ? LABEL_FINAL_CHECK : LABEL_REVIEW_ISSUE;
// Atomically swap labels using setLabels to avoid a window where both exist
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
owner,
repo,
issue_number: prNumber,
});
const newLabelSet = new Set(currentLabels.map((label) => label.name));
newLabelSet.delete(removeLabel);
newLabelSet.add(addLabel);
await github.rest.issues.setLabels({
owner,
repo,
issue_number: prNumber,
labels: [...newLabelSet],
});
}
async function run({ github, context, core, prNumber, ciConclusion }) {
const owner = context.repo.owner;
const repo = context.repo.repo;
// Bail on cancelled/skipped runs — inconclusive
if (ciConclusion === "cancelled" || ciConclusion === "skipped") {
core.info(`CI conclusion is '${ciConclusion}', skipping label update`);
return;
}
const ciSuccess = ciConclusion === "success";
// Fetch all PR comments (paginated) to find the latest code review summary
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: prNumber,
});
const reviewComment = findLatestReviewComment(comments);
if (!reviewComment && ciSuccess) {
core.info("CI passed but no review comment found, skipping label update");
return;
}
if (!reviewComment && !ciSuccess) {
core.info(
"CI failed and no review comment found, adding review-issue label",
);
await applyLabel(github, owner, repo, prNumber, LABEL_REVIEW_ISSUE);
return;
}
// Check if the review is stale (posted before the latest commit)
const { data: pull } = await github.rest.pulls.get({
owner,
repo,
pull_number: prNumber,
});
const { data: headCommit } = await github.rest.repos.getCommit({
owner,
repo,
ref: pull.head.sha,
});
const commitDate = new Date(headCommit.commit.committer.date);
const reviewDate = new Date(reviewComment.created_at);
if (reviewDate < commitDate) {
core.info(
"Latest review is stale (posted before latest commit), adding review-issue label",
);
await applyLabel(github, owner, repo, prNumber, LABEL_REVIEW_ISSUE);
return;
}
const reviewClean = isReviewClean(reviewComment.body);
if (ciSuccess && reviewClean) {
core.info("CI passed and review is clean, adding final-check label");
await applyLabel(github, owner, repo, prNumber, LABEL_FINAL_CHECK);
} else {
core.info(
`CI ${ciSuccess ? "passed" : "failed"}, review ${reviewClean ? "clean" : "has issues"}, adding review-issue label`,
);
await applyLabel(github, owner, repo, prNumber, LABEL_REVIEW_ISSUE);
}
}
module.exports = { run };
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论