feat(automation): add triage phase to issue watcher
Portal issues now file with only the 'portal' label. The watcher runs two phases:
1. Triage — Claude reads each untriaged 'portal' issue (analysis only), posts a
requirements-breakdown comment, and routes it to 'claude-queue' (auto-fixable)
or 'interactive' (needs human steering).
2. Fix — unchanged; processes 'claude-queue' issues into PRs.
The triage breakdown is posted without the bot marker so the fix stage reads it
back as refined requirements.
PS 5.1 fixes found while validating:
- Send API bodies as UTF-8 bytes (Invoke-RestMethod mangled non-ASCII, e.g. the
em-dash in Claude's breakdown, so Forgejo rejected the JSON)
- Build the labels array body by hand (ConvertTo-Json unwraps a single-element
array to a scalar, which Forgejo rejects)
- Triage output via two plain files (label + markdown) instead of one JSON blob
(embedded-newline markdown broke ConvertFrom-Json)
- Read triage files as UTF-8; additive label POST + a guard so Set-IssueLabels
can never wipe an issue's labels
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
23e5243442
commit
080dafb473
4 changed files with 212 additions and 40 deletions
|
|
@ -39,10 +39,13 @@ export async function reportIssue(formData: FormData): Promise<Result> {
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// File with only `portal`. The watcher triages portal issues — Claude reads
|
||||||
|
// the issue, posts a requirements breakdown, and routes it to `claude-queue`
|
||||||
|
// (auto-fixable) or `interactive` (needs human steering).
|
||||||
const issue = await createForgejoIssue({
|
const issue = await createForgejoIssue({
|
||||||
title: `[Issue]: ${title}`,
|
title: `[Issue]: ${title}`,
|
||||||
body,
|
body,
|
||||||
labels: ["portal", "claude-queue"],
|
labels: ["portal"],
|
||||||
});
|
});
|
||||||
return { ok: true, issueNumber: issue.number, issueUrl: issue.html_url };
|
return { ok: true, issueNumber: issue.number, issueUrl: issue.html_url };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,16 @@ running in production:
|
||||||
Portal header (bug icon) [App/components/layout/report-issue-button.tsx]
|
Portal header (bug icon) [App/components/layout/report-issue-button.tsx]
|
||||||
│ server action → Forgejo API
|
│ server action → Forgejo API
|
||||||
▼
|
▼
|
||||||
Forgejo issue (labels: portal, claude-queue) [git.pelagiamarine.com/shad0w/pelagia-portal]
|
Forgejo issue (label: portal) [git.pelagiamarine.com/shad0w/pelagia-portal]
|
||||||
│ polled every 10 min by Windows Scheduled Task "PelagiaClaudeIssueWatcher"
|
│ polled every 10 min by Windows Scheduled Task "PelagiaClaudeIssueWatcher"
|
||||||
▼
|
▼
|
||||||
claude-issue-watcher.ps1 (this folder) [dev PC, runs headless Claude Code]
|
TRIAGE (watcher phase 1) [dev PC, headless Claude Code, analysis only]
|
||||||
│ Claude implements + verifies fix in C:\...\src\pelagia-autofix
|
│ Claude reads the issue + repo, posts a requirements-breakdown comment,
|
||||||
│ watcher pushes branch claude/issue-N and opens a PR (label: claude-pr)
|
│ and routes it: adds `claude-queue` (auto-fixable) or `interactive` (human)
|
||||||
|
▼
|
||||||
|
FIX (watcher phase 2, only for claude-queue) [headless Claude Code in C:\...\src\pelagia-autofix]
|
||||||
|
│ Claude implements + verifies fix; watcher pushes branch claude/issue-N
|
||||||
|
│ and opens a PR (label: claude-pr)
|
||||||
▼
|
▼
|
||||||
Human review: merge the PR, then create a release tag vX.Y.Z
|
Human review: merge the PR, then create a release tag vX.Y.Z
|
||||||
│ tag push triggers .forgejo/workflows/deploy.yml
|
│ tag push triggers .forgejo/workflows/deploy.yml
|
||||||
|
|
@ -23,11 +27,16 @@ forgejo-runner on pms1 (pm2: forgejo-runner, label "host")
|
||||||
pm2 restart ppms → live at pms.pelagiamarine.com
|
pm2 restart ppms → live at pms.pelagiamarine.com
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`interactive`-routed issues stop after triage for a human to pick up (run with
|
||||||
|
Claude in a steered session). The triage breakdown comment is plain (no bot
|
||||||
|
marker) so, for `claude-queue` issues, the fix stage reads it back as refined
|
||||||
|
requirements.
|
||||||
|
|
||||||
## Components
|
## Components
|
||||||
|
|
||||||
| Piece | Where | Notes |
|
| Piece | Where | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Report Issue button | `App/components/layout/report-issue-button.tsx` + `report-issue-actions.ts` | Any signed-in user; files issue with `portal` + `claude-queue` labels |
|
| Report Issue button | `App/components/layout/report-issue-button.tsx` + `report-issue-actions.ts` | Any signed-in user; files issue with only the `portal` label (triage routes it) |
|
||||||
| Forgejo helper | `App/lib/forgejo.ts` | Needs `FORGEJO_URL`, `FORGEJO_REPO`, `FORGEJO_TOKEN` env (token scope: `write:issue`) |
|
| Forgejo helper | `App/lib/forgejo.ts` | Needs `FORGEJO_URL`, `FORGEJO_REPO`, `FORGEJO_TOKEN` env (token scope: `write:issue`) |
|
||||||
| Issue watcher | `automation/claude-issue-watcher.ps1` | Config in `watcher.config.json` (gitignored — copy from the example). Logs in `automation/logs/` |
|
| Issue watcher | `automation/claude-issue-watcher.ps1` | Config in `watcher.config.json` (gitignored — copy from the example). Logs in `automation/logs/` |
|
||||||
| Scheduled task | `automation/register-watcher-task.ps1` | Registers `PelagiaClaudeIssueWatcher`, every 10 min, single-instance |
|
| Scheduled task | `automation/register-watcher-task.ps1` | Registers `PelagiaClaudeIssueWatcher`, every 10 min, single-instance |
|
||||||
|
|
@ -36,11 +45,18 @@ pm2 restart ppms → live at pms.pelagiamarine.com
|
||||||
|
|
||||||
## Issue label lifecycle
|
## Issue label lifecycle
|
||||||
|
|
||||||
`claude-queue` → `claude-working` → `claude-pr` (PR opened, awaiting review)
|
```
|
||||||
or `claude-failed` (no verified fix; reason posted as an issue comment).
|
portal ──(triage)──▶ claude-queue ─▶ claude-working ─▶ claude-pr | claude-failed
|
||||||
|
└────▶ interactive (stops here — handle with Claude interactively)
|
||||||
|
```
|
||||||
|
|
||||||
To retry a failed issue, re-add the `claude-queue` label.
|
- A `portal` issue with no decision label yet is triaged once (`maxTriagePerRun`
|
||||||
To queue any manually-created issue for Claude, just add the `claude-queue` label.
|
per run). Triage adds `claude-queue` or `interactive` and posts a breakdown.
|
||||||
|
- `claude-queue` → `claude-working` → `claude-pr` (PR opened) or `claude-failed`.
|
||||||
|
- To retry a failed issue, re-add `claude-queue`.
|
||||||
|
- To queue any manually-created issue for Claude (skipping triage), add
|
||||||
|
`claude-queue` directly. To force human handling, add `interactive`.
|
||||||
|
- Triage is skipped for issues that already carry any decision label.
|
||||||
|
|
||||||
## Releasing
|
## Releasing
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,14 @@
|
||||||
# Claude issue watcher for the Pelagia portal.
|
# Claude issue watcher for the Pelagia portal. Two phases per run:
|
||||||
#
|
#
|
||||||
# Polls Forgejo for open issues labelled `claude-queue`, runs headless
|
# 1. TRIAGE -- find open `portal` issues with no decision label yet. Claude
|
||||||
# Claude Code on a dedicated clone to implement a fix, pushes a
|
# reads each (analysis only, no code changes), posts a requirements
|
||||||
# `claude/issue-N` branch, and opens a PR that closes the issue.
|
# breakdown comment, and routes it to `claude-queue` or `interactive`.
|
||||||
# Label lifecycle: claude-queue -> claude-working -> claude-pr | claude-failed
|
# 2. FIX -- find open `claude-queue` issues. Claude implements a fix on a
|
||||||
|
# dedicated clone, pushes a `claude/issue-N` branch, and opens a PR.
|
||||||
|
#
|
||||||
|
# Label lifecycle:
|
||||||
|
# portal -> (triage) -> claude-queue | interactive
|
||||||
|
# claude-queue -> claude-working -> claude-pr | claude-failed
|
||||||
#
|
#
|
||||||
# Intended to run unattended via Windows Task Scheduler (see
|
# Intended to run unattended via Windows Task Scheduler (see
|
||||||
# register-watcher-task.ps1). Logs to automation/logs/.
|
# register-watcher-task.ps1). Logs to automation/logs/.
|
||||||
|
|
@ -54,19 +59,51 @@ $headers = @{ Authorization = "token $($cfg.token)" }
|
||||||
function Api([string]$Method, [string]$Path, $Body = $null) {
|
function Api([string]$Method, [string]$Path, $Body = $null) {
|
||||||
$params = @{ Method = $Method; Uri = "$apiBase$Path"; Headers = $headers }
|
$params = @{ Method = $Method; Uri = "$apiBase$Path"; Headers = $headers }
|
||||||
if ($null -ne $Body) {
|
if ($null -ne $Body) {
|
||||||
$params.Body = ($Body | ConvertTo-Json -Depth 5)
|
# Send UTF-8 bytes, not a string. PS 5.1's Invoke-RestMethod encodes a
|
||||||
|
# string body in a non-UTF-8 charset, which mangles any non-ASCII (e.g.
|
||||||
|
# an em-dash in Claude's triage breakdown) and makes Forgejo reject the JSON.
|
||||||
|
$json = $Body | ConvertTo-Json -Depth 5
|
||||||
|
$params.Body = [System.Text.Encoding]::UTF8.GetBytes($json)
|
||||||
$params.ContentType = 'application/json'
|
$params.ContentType = 'application/json'
|
||||||
}
|
}
|
||||||
Invoke-RestMethod @params
|
Invoke-RestMethod @params
|
||||||
}
|
}
|
||||||
|
|
||||||
function Set-IssueLabels([int]$IssueNumber, [string[]]$Remove, [string[]]$Add) {
|
# Resolve label names to their numeric ids (always an array).
|
||||||
|
function Resolve-LabelIds([string[]]$Names) {
|
||||||
$allLabels = Api GET "/repos/$($cfg.repo)/labels?limit=50"
|
$allLabels = Api GET "/repos/$($cfg.repo)/labels?limit=50"
|
||||||
|
@(@($allLabels) | Where-Object { $Names -contains $_.name } | ForEach-Object { [int]$_.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
# PUT/POST a labels body. Build the JSON by hand: PS 5.1 ConvertTo-Json unwraps a
|
||||||
|
# single-element array to a scalar, which Forgejo rejects ("cannot unmarshal number
|
||||||
|
# into ... []interface {}"). [int[]] also coerces a lone scalar id back to an array.
|
||||||
|
function Send-IssueLabels([int]$IssueNumber, [string]$Method, [int[]]$Ids) {
|
||||||
|
$body = '{"labels":[' + (@($Ids) -join ',') + ']}'
|
||||||
|
$bytes = [System.Text.Encoding]::UTF8.GetBytes($body)
|
||||||
|
Invoke-RestMethod -Method $Method -Uri "$apiBase/repos/$($cfg.repo)/issues/$IssueNumber/labels" -Headers $headers -Body $bytes -ContentType 'application/json' | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Additively attach labels (Forgejo POST does not replace existing ones). Safe:
|
||||||
|
# it can never clear labels, unlike the replace-the-whole-set PUT below.
|
||||||
|
function Add-IssueLabels([int]$IssueNumber, [string[]]$Add) {
|
||||||
|
$ids = Resolve-LabelIds $Add
|
||||||
|
if (@($ids).Count -eq 0) { Log "Add-IssueLabels: no ids resolved for [$($Add -join ',')] on #$IssueNumber"; return }
|
||||||
|
Send-IssueLabels $IssueNumber 'POST' $ids
|
||||||
|
}
|
||||||
|
|
||||||
|
# Replace the issue's label set (used for fix-phase transitions that remove labels).
|
||||||
|
function Set-IssueLabels([int]$IssueNumber, [string[]]$Remove, [string[]]$Add) {
|
||||||
$issue = Api GET "/repos/$($cfg.repo)/issues/$IssueNumber"
|
$issue = Api GET "/repos/$($cfg.repo)/issues/$IssueNumber"
|
||||||
$current = @($issue.labels | ForEach-Object { $_.name })
|
$current = @($issue.labels | ForEach-Object { $_.name })
|
||||||
$wanted = @(($current | Where-Object { $Remove -notcontains $_ }) + $Add | Select-Object -Unique)
|
$wanted = @(($current | Where-Object { $Remove -notcontains $_ }) + $Add | Select-Object -Unique)
|
||||||
$ids = @($allLabels | Where-Object { $wanted -contains $_.name } | ForEach-Object { $_.id })
|
$ids = Resolve-LabelIds $wanted
|
||||||
Api PUT "/repos/$($cfg.repo)/issues/$IssueNumber/labels" @{ labels = $ids } | Out-Null
|
# Guard: never wipe labels because an id match unexpectedly came back empty.
|
||||||
|
if ($wanted.Count -gt 0 -and @($ids).Count -eq 0) {
|
||||||
|
Log "Set-IssueLabels: refusing to clear all labels on #$IssueNumber (wanted [$($wanted -join ',')] resolved to no ids)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Send-IssueLabels $IssueNumber 'PUT' $ids
|
||||||
}
|
}
|
||||||
|
|
||||||
function Add-IssueComment([int]$IssueNumber, [string]$Text) {
|
function Add-IssueComment([int]$IssueNumber, [string]$Text) {
|
||||||
|
|
@ -114,20 +151,41 @@ function Run-Git([string[]]$GitArgs) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Find queued issues ──────────────────────────────────────────────
|
# List open issues carrying a given label. Capture to a variable before filtering:
|
||||||
# NB: capture the API result into a variable before filtering. Piping the Api
|
# piping the Api function's array output straight into Where-Object does NOT unroll
|
||||||
# function's output straight into Where-Object does NOT unroll the array in
|
# in PS 5.1 -- it collapses every issue into one object whose props are arrays.
|
||||||
# PS 5.1 — it collapses all issues into one object whose props are arrays.
|
function Get-OpenIssuesByLabel([string]$Label) {
|
||||||
$queuedResp = Api GET "/repos/$($cfg.repo)/issues?state=open&labels=claude-queue&type=issues&limit=20"
|
$resp = Api GET "/repos/$($cfg.repo)/issues?state=open&labels=$Label&type=issues&limit=50"
|
||||||
$queued = @(@($queuedResp) | Where-Object { $_ -and $_.number })
|
@(@($resp) | Where-Object { $_ -and $_.number })
|
||||||
if ($queued.Count -eq 0) {
|
|
||||||
Log "No queued issues."
|
|
||||||
exit 0
|
|
||||||
}
|
}
|
||||||
$queued = @($queued | Sort-Object { [int]$_.number } | Select-Object -First ([int]$cfg.maxIssuesPerRun))
|
|
||||||
Log "Found $($queued.Count) queued issue(s): $(($queued | ForEach-Object { '#' + $_.number }) -join ', ')"
|
|
||||||
|
|
||||||
# ── Prepare the dedicated work clone ────────────────────────────────
|
# True if the issue object carries any of the given label names.
|
||||||
|
function Test-IssueHasLabel($Issue, [string[]]$Names) {
|
||||||
|
$have = @($Issue.labels | ForEach-Object { $_.name })
|
||||||
|
foreach ($x in $Names) { if ($have -contains $x) { return $true } }
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run headless Claude on a prompt file inside the clone; output -> $LogPath. Returns exit code.
|
||||||
|
function Invoke-Claude([string]$PromptFile, [string]$LogPath, [int]$MaxTurns) {
|
||||||
|
# cmd handles the redirects so native stderr never becomes a PS ErrorRecord.
|
||||||
|
Push-Location $cfg.workDir
|
||||||
|
try {
|
||||||
|
cmd /c "`"$($cfg.claudeExe)`" -p --dangerously-skip-permissions --max-turns $MaxTurns --output-format text < `"$PromptFile`" > `"$LogPath`" 2>&1"
|
||||||
|
return $LASTEXITCODE
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reset the work clone to a clean checkout of the base branch (discards stray files).
|
||||||
|
function Reset-CloneToBase {
|
||||||
|
Run-Git @('-C', $cfg.workDir, 'fetch', 'origin') | Out-Null
|
||||||
|
Run-Git @('-C', $cfg.workDir, 'checkout', '-f', "origin/$($cfg.baseBranch)") | Out-Null
|
||||||
|
Run-Git @('-C', $cfg.workDir, 'clean', '-fd') | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Prepare the dedicated work clone (needed by both phases) ─────────
|
||||||
$repoHost = ([Uri]$cfg.forgejoUrl).Host
|
$repoHost = ([Uri]$cfg.forgejoUrl).Host
|
||||||
$owner = $cfg.repo.Split('/')[0]
|
$owner = $cfg.repo.Split('/')[0]
|
||||||
$cloneUrl = "https://$($owner):$($cfg.token)@$repoHost/$($cfg.repo).git"
|
$cloneUrl = "https://$($owner):$($cfg.token)@$repoHost/$($cfg.repo).git"
|
||||||
|
|
@ -139,6 +197,105 @@ if (-not (Test-Path (Join-Path $cfg.workDir '.git'))) {
|
||||||
Run-Git @('-C', $cfg.workDir, 'config', 'user.email', 'claude-autofix@pelagiamarine.com') | Out-Null
|
Run-Git @('-C', $cfg.workDir, 'config', 'user.email', 'claude-autofix@pelagiamarine.com') | Out-Null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$DecisionLabels = @('claude-queue', 'interactive', 'claude-working', 'claude-pr', 'claude-failed')
|
||||||
|
$maxTriage = if ($cfg.maxTriagePerRun) { [int]$cfg.maxTriagePerRun } else { 3 }
|
||||||
|
$triageTurns = if ($cfg.triageMaxTurns) { [int]$cfg.triageMaxTurns } else { 80 }
|
||||||
|
|
||||||
|
# ── Phase 1: triage new portal issues ───────────────────────────────
|
||||||
|
$portalIssues = Get-OpenIssuesByLabel 'portal'
|
||||||
|
$toTriage = @($portalIssues |
|
||||||
|
Where-Object { -not (Test-IssueHasLabel $_ $DecisionLabels) } |
|
||||||
|
Sort-Object { [int]$_.number } |
|
||||||
|
Select-Object -First $maxTriage)
|
||||||
|
Log "Triage: $($toTriage.Count) portal issue(s) awaiting triage"
|
||||||
|
|
||||||
|
foreach ($issue in $toTriage) {
|
||||||
|
$n = $issue.number
|
||||||
|
Log "-- Triaging #${n}: $($issue.title)"
|
||||||
|
Reset-CloneToBase
|
||||||
|
|
||||||
|
$commentsBlock = Get-IssueCommentsBlock $n
|
||||||
|
# Two plain output files instead of one JSON blob: a JSON object with a big
|
||||||
|
# embedded markdown string is fragile (Claude often emits literal newlines,
|
||||||
|
# which PS 5.1 ConvertFrom-Json rejects). A bare label file + a raw markdown
|
||||||
|
# file need no escaping and parse trivially.
|
||||||
|
$labelFile = Join-Path $cfg.workDir 'CLAUDE_TRIAGE_LABEL.txt'
|
||||||
|
$breakdownFile = Join-Path $cfg.workDir 'CLAUDE_TRIAGE.md'
|
||||||
|
foreach ($f in $labelFile, $breakdownFile) { if (Test-Path $f) { Remove-Item $f -Force } }
|
||||||
|
|
||||||
|
$tprompt = @"
|
||||||
|
You are TRIAGING issue #$n of the Pelagia Portal (PPMS), a Next.js 15 purchase-order management
|
||||||
|
system for a maritime company. The web app is in App/ -- read App/CLAUDE.md and explore the relevant
|
||||||
|
code to judge feasibility. This is ANALYSIS ONLY: do NOT modify any existing file, do NOT run builds
|
||||||
|
or tests, do NOT commit. You only create the two output files described below.
|
||||||
|
|
||||||
|
## Issue #${n}: $($issue.title)
|
||||||
|
|
||||||
|
$($issue.body)
|
||||||
|
|
||||||
|
$commentsBlock
|
||||||
|
|
||||||
|
## Your job
|
||||||
|
1. Interpret the request and break it into concrete technical action item(s), the way a developer
|
||||||
|
would in review -- note the files/areas likely involved and any open questions.
|
||||||
|
2. Decide whether an UNATTENDED automated coding run can safely and verifiably implement it:
|
||||||
|
- "claude-queue" = localized change, clear acceptance, verifiable by type-check / lint / unit
|
||||||
|
tests, and NOT touching DB migrations, auth/permissions, payments/money, external live systems
|
||||||
|
(e.g. the GST website), or large multi-file features.
|
||||||
|
- "interactive" = needs human steering: ambiguous or underspecified, needs business content or a
|
||||||
|
design decision, a schema migration, permissions/payments changes, an external dependency, or a
|
||||||
|
large feature needing visual verification.
|
||||||
|
3. Write TWO files in the repository root, nothing else:
|
||||||
|
- CLAUDE_TRIAGE_LABEL.txt -- a single line containing EXACTLY one word: claude-queue OR interactive
|
||||||
|
- CLAUDE_TRIAGE.md -- your requirements breakdown as markdown: action items, files/areas involved,
|
||||||
|
open questions, and a final one-line "Routing rationale: ..." explaining the choice.
|
||||||
|
"@
|
||||||
|
|
||||||
|
$tpromptFile = Join-Path $env:TEMP "claude-triage-$n-prompt.txt"
|
||||||
|
$tprompt | Out-File -FilePath $tpromptFile -Encoding utf8
|
||||||
|
$tlog = Join-Path $logDir "claude-triage-$n-$(Get-Date -Format 'yyyyMMdd-HHmmss').log"
|
||||||
|
|
||||||
|
Log "Running Claude triage on #$n (log: $tlog)"
|
||||||
|
$rc = Invoke-Claude $tpromptFile $tlog $triageTurns
|
||||||
|
Log "Claude triage exited with code $rc for #$n"
|
||||||
|
|
||||||
|
$label = $null
|
||||||
|
if (Test-Path $labelFile) {
|
||||||
|
$raw = Get-Content $labelFile -Raw -Encoding UTF8
|
||||||
|
if ($raw -match 'interactive') { $label = 'interactive' }
|
||||||
|
elseif ($raw -match 'claude-queue') { $label = 'claude-queue' }
|
||||||
|
}
|
||||||
|
# Read as UTF-8 so non-ASCII in the breakdown (em-dash etc.) is not mojibaked.
|
||||||
|
$breakdown = if (Test-Path $breakdownFile) { (Get-Content $breakdownFile -Raw -Encoding UTF8).Trim() } else { "" }
|
||||||
|
Reset-CloneToBase # discard the triage output files and any stray edits
|
||||||
|
|
||||||
|
if (-not $label) {
|
||||||
|
Log "Triage for #$n produced no valid decision; leaving for a human"
|
||||||
|
Add-IssueComment $n "$BotMarker`n[Claude triage] Could not auto-triage this issue. A human should review it and add either ``claude-queue`` or ``interactive``."
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
# Label FIRST: it marks the issue as triaged, so a failure while posting the
|
||||||
|
# comment below cannot cause a re-triage next run that double-posts the breakdown.
|
||||||
|
Add-IssueLabels $n @($label)
|
||||||
|
# NB: deliberately NO bot marker on the breakdown -- it is genuine refined
|
||||||
|
# requirements and SHOULD be fed to the fix stage (Get-IssueCommentsBlock
|
||||||
|
# includes it). The routing line is bot chatter but harmless as fix context.
|
||||||
|
$note = if ($breakdown) { $breakdown } else { "(no breakdown produced)" }
|
||||||
|
Add-IssueComment $n "## Claude triage`n`n$note`n`n**Routing:** ``$label``"
|
||||||
|
Log "Triaged #$n -> $label"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Phase 2: fix queued issues ──────────────────────────────────────
|
||||||
|
# Assign to a variable before piping (see Get-OpenIssuesByLabel note).
|
||||||
|
$queuedAll = Get-OpenIssuesByLabel 'claude-queue'
|
||||||
|
$queued = @($queuedAll | Sort-Object { [int]$_.number } | Select-Object -First ([int]$cfg.maxIssuesPerRun))
|
||||||
|
if ($queued.Count -eq 0) {
|
||||||
|
Log "No queued issues to fix."
|
||||||
|
} else {
|
||||||
|
Log "Found $($queued.Count) queued issue(s) to fix: $(($queued | ForEach-Object { '#' + $_.number }) -join ', ')"
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($issue in $queued) {
|
foreach ($issue in $queued) {
|
||||||
$n = $issue.number
|
$n = $issue.number
|
||||||
$branch = "$($cfg.branchPrefix)$n"
|
$branch = "$($cfg.branchPrefix)$n"
|
||||||
|
|
@ -189,14 +346,8 @@ explanation to a file named CLAUDE_RESULT.md in the repository root (it will be
|
||||||
$claudeLog = Join-Path $logDir "claude-issue-$n-$(Get-Date -Format 'yyyyMMdd-HHmmss').log"
|
$claudeLog = Join-Path $logDir "claude-issue-$n-$(Get-Date -Format 'yyyyMMdd-HHmmss').log"
|
||||||
|
|
||||||
Log "Running Claude Code on #$n (log: $claudeLog)"
|
Log "Running Claude Code on #$n (log: $claudeLog)"
|
||||||
# cmd handles the redirects so native stderr never becomes a PS ErrorRecord
|
$rc = Invoke-Claude $promptFile $claudeLog ([int]$cfg.claudeMaxTurns)
|
||||||
Push-Location $cfg.workDir
|
Log "Claude exited with code $rc for #$n"
|
||||||
try {
|
|
||||||
cmd /c "`"$($cfg.claudeExe)`" -p --dangerously-skip-permissions --max-turns $($cfg.claudeMaxTurns) --output-format text < `"$promptFile`" > `"$claudeLog`" 2>&1"
|
|
||||||
Log "Claude exited with code $LASTEXITCODE for #$n"
|
|
||||||
} finally {
|
|
||||||
Pop-Location
|
|
||||||
}
|
|
||||||
|
|
||||||
# Relay an abort explanation if Claude declined the fix
|
# Relay an abort explanation if Claude declined the fix
|
||||||
$resultFile = Join-Path $cfg.workDir 'CLAUDE_RESULT.md'
|
$resultFile = Join-Path $cfg.workDir 'CLAUDE_RESULT.md'
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@
|
||||||
"baseBranch": "master",
|
"baseBranch": "master",
|
||||||
"branchPrefix": "claude/issue-",
|
"branchPrefix": "claude/issue-",
|
||||||
"maxIssuesPerRun": 1,
|
"maxIssuesPerRun": 1,
|
||||||
|
"maxTriagePerRun": 3,
|
||||||
"claudeExe": "C:\\Users\\shad0w\\.local\\bin\\claude.exe",
|
"claudeExe": "C:\\Users\\shad0w\\.local\\bin\\claude.exe",
|
||||||
"claudeMaxTurns": 150
|
"claudeMaxTurns": 150,
|
||||||
|
"triageMaxTurns": 80
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue