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
4 changes: 2 additions & 2 deletions .agents/skills/issue-pr-loop/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ Run unattended. At each iteration, run `pwsh scripts/Remove-MergedWorktrees.ps1`

1. Fix a red `main`, following [Red main](#red-main).
2. Merge an eligible PR.
3. Fix conflicts, failed CI, or review findings on an open PR and push.
3. Fix conflicts, failed CI, or review findings on an open PR and push. Handle review feedback that has no disposition yet (an unresolved thread, or changes requested since the last push) even while that PR's checks still run.
4. Publish finished work stranded on a local `issue-<N>-*` branch that has no PR, or recover valuable changes from a preserved merged-PR worktree, following [recovery guidance](references/recovery.md).
5. Tidy the issue queue: close a parent whose native sub-issues are all closed, and close an issue that `origin/main` already implements or that another issue supersedes.
6. Claim an available issue, implement its full scope, and open a PR. Take the highest priority (`P0` to `P3`) first, then prefer issues that block other issues.
6. Claim an available issue, implement its full scope, and open a PR, but only while fewer than 8 of your PRs are open without `needs-human` or `on-hold` (`-MaxOpenPrs` in `scripts/Start-IssuePrLoop.ps1`): each PR in flight is another chance of a conflict, so at the limit finish existing work, then triage. Take the highest priority (`P0` to `P3`) first, then prefer issues that block other issues.
7. When nothing above is actionable, run a [triage unit](#triage) instead of waiting.

After pushing or deferring, survey again. Pending CI/review means work another item; do not watch, sleep, poll one item, or schedule monitors while work is queueable. When only CI/review remains, periodically survey the whole queue and stay responsive.
Expand Down
113 changes: 94 additions & 19 deletions scripts/Start-IssuePrLoop.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,18 @@
# 2. Runs the merged-worktree sweep and surveys open PRs and issues with `gh`. When
# the latest push CI run of any workflow on main failed, fixing main comes first.
# 3. Includes draft PRs, which need review processing and promotion. Skips `needs-human`
# and `on-hold` items, PRs whose checks are still running, and items whose GitHub
# state is unchanged since an agent last deferred them. Also skips issues that
# cannot be worked yet: issues with open native sub-issues or open `blocked_by`
# dependencies, issues that already have an open closing PR, and bot-owned tracking
# issues. Stranded branches follow the same rules. Ranks local `issue-*` branches
# with unpublished commits and no open PR above new issues, then parents whose
# sub-issues are all closed, then issues that block the most other issues. An item
# that a deferral named as its blocker goes first, so the next unit works the blocker
# instead of the blocked item.
# and `on-hold` items, PRs whose checks are still running (unless they conflict, have
# unresolved review threads, or have a changes-requested review newer than the last
# push), and items whose GitHub state is unchanged since an agent last deferred them.
# Also skips issues that cannot be worked yet: issues with open native sub-issues or
# open `blocked_by` dependencies, issues that already have an open closing PR, and
# bot-owned tracking issues. Stranded branches follow the same rules. Ranks local
# `issue-*` branches with unpublished commits and no open PR above new issues, then
# parents whose sub-issues are all closed, then issues that block the most other
# issues. Starts no new issue while -MaxOpenPrs or more of the loop's PRs are open and
# not parked, because every PR in flight is another chance of a conflict; existing PRs,
# stranded branches, and stale claims still run. An item that a deferral named as its
# blocker goes first, so the next unit works the blocker instead of the blocked item.
# 4. Acquires the item's Redis lock, moving on to the next candidate when another
# worker holds it.
# 5. Starts the agent on that one item, with lower reasoning effort for PRs that
Expand Down Expand Up @@ -60,7 +63,7 @@
#
# Stop: create the stop file printed at startup (checked between units), or Ctrl+C.
# Usage: pwsh scripts/Start-IssuePrLoop.ps1 [-Agent auto|claude|codex] [-IdleSeconds n] [-MaxUnits n]
# [-DeferSeconds n] [-MaxDeferSeconds n] [-LightEffort level] [-HeavyEffort level]
# [-DeferSeconds n] [-MaxDeferSeconds n] [-MaxOpenPrs n] [-LightEffort level] [-HeavyEffort level]
# [-TriageEvery n] [-TriageBatch n] [-AuditDays n] [-ParkedAuditHours n] [-DiscoveryHours n] [-DryRun]
# pwsh scripts/Start-IssuePrLoop.ps1 -Report [-ReportHours n]

Expand All @@ -79,6 +82,9 @@ param(
# Upper bound for a repeatedly deferred item's backoff.
[ValidateRange(60, 604800)]
[int]$MaxDeferSeconds = 259200,
# Open, unparked loop PRs at which no new issue is started. 0 means no limit.
[ValidateRange(0, 1000)]
[int]$MaxOpenPrs = 8,
# Reasoning effort for PRs that look ready to merge, and for all other units.
# An empty value keeps the agent's configured default.
[string]$LightEffort = 'medium',
Expand Down Expand Up @@ -260,6 +266,62 @@ function Get-PrFingerprint($Pr) {
Get-Hash ((@($Pr.headRefOid, $Pr.isDraft, $Pr.mergeable, ($labels -join ','), $review, $comment) + $checks + $codexReactions) -join '|')
}

function ConvertTo-Instant($Value) {
if ($Value -is [datetime]) { return [DateTimeOffset]$Value }
if ($Value) { return [DateTimeOffset]::Parse([string]$Value, [Globalization.CultureInfo]::InvariantCulture) }
return $null
}

# True when a PR has review feedback that no unit has dispositioned: an unresolved review
# thread (units resolve each thread they disposition), or a changes-requested review newer
# than both the last push and the loop's last reply. Such feedback can be handled while
# checks still run. Conversation comments do not count: a finished review with no
# findings is also a comment, and would send units to PRs with nothing to do.
function Test-HasNewFeedback($Pr, $Activity) {
if (-not $Activity) { return $false }
if ($Activity.UnresolvedThreads -gt 0) { return $true }
$since = ConvertTo-Instant $Activity.HeadCommittedAt
if (-not $since) { return $false }
$requested = [Collections.Generic.List[object]]::new()
foreach ($review in @($Pr.reviews)) {
if (-not $review -or -not $review.submittedAt) { continue }
$at = ConvertTo-Instant $review.submittedAt
# The loop replies to review threads as the PR author, which GitHub records as reviews.
if ($review.author.login -eq $Pr.author.login) { if ($at -gt $since) { $since = $at } }
elseif ($review.state -eq 'CHANGES_REQUESTED') { $requested.Add($at) }
}
foreach ($comment in @($Pr.comments)) {
if ($comment -and $comment.createdAt -and ([string]$comment.body).Contains($loopMarker)) {
$at = ConvertTo-Instant $comment.createdAt
if ($at -gt $since) { $since = $at }
Comment on lines +294 to +296

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Distinguish disposition comments from other loop comments

When a changes-requested review has no unresolved inline threads, any later comment containing $loopMarker suppresses that feedback, even if the comment does not address it. The draft lifecycle explicitly uses the same marker for Codex review requests and retries, so posting such a request after an outstanding review makes Test-HasNewFeedback return false and excludes the PR while checks remain pending. Advance the disposition timestamp only for an actual response to the feedback, not every loop-authored comment.

AGENTS.md reference: AGENTS.md:L78-L78

Useful? React with 👍 / 👎.

}
}
@($requested | Where-Object { $_ -gt $since }).Count -gt 0
}

# Returns PR number -> head commit time and unresolved review thread count for open PRs.
# `gh pr list --json commits` asks for every commit's authors and exceeds GitHub's
# GraphQL node limit, so this asks only for the head commit.
function Get-PrActivity {
$query = 'query($owner: String!, $name: String!, $cursor: String) { repository(owner: $owner, name: $name) { ' +
'pullRequests(states: OPEN, first: 100, after: $cursor) { pageInfo { hasNextPage endCursor } ' +
'nodes { number commits(last: 1) { nodes { commit { committedDate } } } reviewThreads(first: 100) { nodes { isResolved } } } } } }'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Paginate review threads when counting unresolved feedback

For a PR with more than 100 review threads, this query examines only the first 100 and never requests subsequent thread pages. If those earlier threads are resolved but a later thread remains open, UnresolvedThreads becomes zero and the pending-check filter skips the PR despite actionable feedback. Paginating the outer PR connection does not paginate each nested thread connection; fetch subsequent thread pages or otherwise ensure the unresolved count covers every thread.

AGENTS.md reference: AGENTS.md:L79-L79

Useful? React with 👍 / 👎.

$activity = @{}
$cursor = $null
do {
$variables = if ($cursor) { @('-f', "cursor=$cursor") } else { @() }
$page = (Invoke-GraphQl $query $variables).pullRequests
foreach ($node in @($page.nodes)) {
$activity[[int]$node.number] = [pscustomobject]@{
HeadCommittedAt = @($node.commits.nodes)[0].commit.committedDate
UnresolvedThreads = @($node.reviewThreads.nodes | Where-Object { -not $_.isResolved }).Count
}
}
$cursor = $page.pageInfo.endCursor
} while ($page.pageInfo.hasNextPage)
return $activity
}

function Get-IssueFingerprint($Issue) {
$labels = @($Issue.labels.nodes | ForEach-Object { $_.name } | Sort-Object)
$assignees = @($Issue.assignees.nodes | ForEach-Object { $_.login } | Sort-Object)
Expand Down Expand Up @@ -326,18 +388,18 @@ $deferCategories = @('human', 'upstream', 'credentials', 'locked')
$surveyStats = [ordered]@{}

function Reset-SurveyStats {
foreach ($key in 'issues', 'issue-deferred', 'issue-locked', 'issue-excluded', 'issue-parent', 'issue-blocked', 'issue-has-pr', 'issue-claimed',
foreach ($key in 'issues', 'issue-wip', 'issue-deferred', 'issue-locked', 'issue-excluded', 'issue-parent', 'issue-blocked', 'issue-has-pr', 'issue-claimed',
'prs', 'pr-deferred', 'pr-locked', 'pr-pending', 'pr-excluded', 'pr-draft') {
$surveyStats[$key] = 0
}
}

function Format-SurveyStats {
$s = $surveyStats
('issues: {0} open, {1} deferred, {2} locked, {3} excluded by label/assignee/bot, {4} with open sub-issues, {5} blocked, {6} with open PR, {7} claimed; ' +
'PRs: {8} open, {9} deferred, {10} locked, {11} pending checks, {12} needs-human/on-hold, {13} draft') -f `
('issues: {0} open, {1} deferred, {2} locked, {3} excluded by label/assignee/bot, {4} with open sub-issues, {5} blocked, {6} with open PR, {7} claimed, ' +
'{14} held by the open-PR limit; PRs: {8} open, {9} deferred, {10} locked, {11} pending checks, {12} needs-human/on-hold, {13} draft') -f `
$s['issues'], $s['issue-deferred'], $s['issue-locked'], $s['issue-excluded'], $s['issue-parent'], $s['issue-blocked'], $s['issue-has-pr'], $s['issue-claimed'],
$s['prs'], $s['pr-deferred'], $s['pr-locked'], $s['pr-pending'], $s['pr-excluded'], $s['pr-draft']
$s['prs'], $s['pr-deferred'], $s['pr-locked'], $s['pr-pending'], $s['pr-excluded'], $s['pr-draft'], $s['issue-wip']
}

function Invoke-GraphQl([string]$Query, [string[]]$Variables) {
Expand Down Expand Up @@ -472,22 +534,31 @@ function Get-Candidates {
$prs = gh pr list --repo $repoSlug --author '@me' --state open --limit 500 --json $prFields | ConvertFrom-Json
if ($LASTEXITCODE -ne 0) { throw 'gh pr list failed.' }
$surveyStats['prs'] = @($prs).Count
$inFlight = 0
$prActivity = if (@($prs).Count -gt 0) { Get-PrActivity } else { @{} }
$ranked = foreach ($pr in $prs) {
if ($pr.isDraft) { $surveyStats['pr-draft']++ }
if (@($pr.labels | Where-Object { $_.name -in $excludedPrLabels }).Count -gt 0) { $surveyStats['pr-excluded']++; continue }
$inFlight++
$checks = @($pr.statusCheckRollup)
$pending = @($checks | Where-Object { (Get-CheckState $_) -in 'PENDING', 'EXPECTED', 'QUEUED', 'IN_PROGRESS', 'WAITING', 'REQUESTED', '' })
$failed = @($checks | Where-Object { (Get-CheckState $_) -in 'FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE' })
$conflicting = $pr.mergeable -eq 'CONFLICTING'
# Running checks leave nothing to do yet; the next survey picks the PR up.
if ($pending.Count -gt 0 -and -not $conflicting) { $surveyStats['pr-pending']++; continue }
$feedback = Test-HasNewFeedback $pr $prActivity[[int]$pr.number]
# Running checks leave nothing to do yet, unless the PR conflicts or has new review
# feedback: finishing open PRs beats starting new ones. The next survey picks it up.
if ($pending.Count -gt 0 -and -not $conflicting -and -not $feedback) { $surveyStats['pr-pending']++; continue }
# A draft is never a merge candidate: it needs review processing and promotion first.
$rank = if (-not $pr.isDraft -and $pr.mergeStateStatus -eq 'CLEAN' -and $failed.Count -eq 0) { 0 }
$rank = if (-not $pr.isDraft -and $pr.mergeStateStatus -eq 'CLEAN' -and $failed.Count -eq 0 -and -not $feedback) { 0 }
elseif ($conflicting) { 1 }
elseif ($failed.Count -gt 0) { 2 }
elseif ($failed.Count -gt 0 -or $feedback) { 2 }
else { 3 }
$hint = if ($feedback -and $pending.Count -gt 0) {
"PR #$($pr.number) has review feedback without a disposition while checks are still running. Handle the feedback now instead of waiting for the checks."
}
else { '' }
# A green, clean PR usually needs only the merge gate or a disposition reply.
New-Candidate 'pr' ([int]$pr.number) $rank (Get-PrFingerprint $pr) ($rank -eq 0)
New-Candidate 'pr' ([int]$pr.number) $rank (Get-PrFingerprint $pr) ($rank -eq 0) $hint
}
foreach ($c in @($ranked | Sort-Object Rank, Number)) { $candidates.Add($c) }

Expand All @@ -496,6 +567,9 @@ function Get-Candidates {
$openIssues = @{}
foreach ($issue in $issues) { $openIssues[[int]$issue.number] = $issue }
$script:lastIssues = $issues
# Every open PR is another chance of a conflict, so finish existing work before
# starting more once enough PRs are in flight.
$atPrLimit = $MaxOpenPrs -gt 0 -and $inFlight -ge $MaxOpenPrs
$stranded = Get-StrandedBranches $openIssues
foreach ($number in @($stranded.Keys | Sort-Object)) {
$s = $stranded[$number]
Expand Down Expand Up @@ -547,6 +621,7 @@ function Get-Candidates {
New-Candidate 'issue' $number 5 $fingerprint $true $hint
continue
}
if ($atPrLimit) { $surveyStats['issue-wip']++; continue }
$candidate = New-Candidate 'issue' $number 6 $fingerprint $false
$candidate.Blocking = [int]$issue.issueDependenciesSummary.blocking
$candidate.Comments = [int]$issue.comments.totalCount
Expand Down
Loading
Loading