diff --git a/App/components/layout/report-issue-actions.ts b/App/components/layout/report-issue-actions.ts index ebde632..2e2b09d 100644 --- a/App/components/layout/report-issue-actions.ts +++ b/App/components/layout/report-issue-actions.ts @@ -39,10 +39,13 @@ export async function reportIssue(formData: FormData): Promise { ].join("\n"); 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({ title: `[Issue]: ${title}`, body, - labels: ["portal", "claude-queue"], + labels: ["portal"], }); return { ok: true, issueNumber: issue.number, issueUrl: issue.html_url }; } catch (err) { diff --git a/automation/README.md b/automation/README.md index 264a7a0..f23a247 100644 --- a/automation/README.md +++ b/automation/README.md @@ -7,12 +7,16 @@ running in production: Portal header (bug icon) [App/components/layout/report-issue-button.tsx] │ 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" ▼ -claude-issue-watcher.ps1 (this folder) [dev PC, runs headless Claude Code] - │ Claude implements + verifies fix in C:\...\src\pelagia-autofix - │ watcher pushes branch claude/issue-N and opens a PR (label: claude-pr) +TRIAGE (watcher phase 1) [dev PC, headless Claude Code, analysis only] + │ Claude reads the issue + repo, posts a requirements-breakdown comment, + │ 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 │ 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 ``` +`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 | 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`) | | 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 | @@ -36,11 +45,18 @@ pm2 restart ppms → live at pms.pelagiamarine.com ## 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. -To queue any manually-created issue for Claude, just add the `claude-queue` label. +- A `portal` issue with no decision label yet is triaged once (`maxTriagePerRun` + 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 diff --git a/automation/claude-issue-watcher.ps1 b/automation/claude-issue-watcher.ps1 index 3800d13..931e4aa 100644 --- a/automation/claude-issue-watcher.ps1 +++ b/automation/claude-issue-watcher.ps1 @@ -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 -# Claude Code on a dedicated clone to implement a fix, pushes a -# `claude/issue-N` branch, and opens a PR that closes the issue. -# Label lifecycle: claude-queue -> claude-working -> claude-pr | claude-failed +# 1. TRIAGE -- find open `portal` issues with no decision label yet. Claude +# reads each (analysis only, no code changes), posts a requirements +# breakdown comment, and routes it to `claude-queue` or `interactive`. +# 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 # 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) { $params = @{ Method = $Method; Uri = "$apiBase$Path"; Headers = $headers } 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' } 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) | 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" $current = @($issue.labels | ForEach-Object { $_.name }) $wanted = @(($current | Where-Object { $Remove -notcontains $_ }) + $Add | Select-Object -Unique) - $ids = @($allLabels | Where-Object { $wanted -contains $_.name } | ForEach-Object { $_.id }) - Api PUT "/repos/$($cfg.repo)/issues/$IssueNumber/labels" @{ labels = $ids } | Out-Null + $ids = Resolve-LabelIds $wanted + # 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) { @@ -114,20 +151,41 @@ function Run-Git([string[]]$GitArgs) { } } -# ── Find queued issues ────────────────────────────────────────────── -# NB: capture the API result into a variable before filtering. Piping the Api -# function's output straight into Where-Object does NOT unroll the array in -# PS 5.1 — it collapses all issues into one object whose props are arrays. -$queuedResp = Api GET "/repos/$($cfg.repo)/issues?state=open&labels=claude-queue&type=issues&limit=20" -$queued = @(@($queuedResp) | Where-Object { $_ -and $_.number }) -if ($queued.Count -eq 0) { - Log "No queued issues." - exit 0 +# List open issues carrying a given label. Capture to a variable before filtering: +# piping the Api function's array output straight into Where-Object does NOT unroll +# in PS 5.1 -- it collapses every issue into one object whose props are arrays. +function Get-OpenIssuesByLabel([string]$Label) { + $resp = Api GET "/repos/$($cfg.repo)/issues?state=open&labels=$Label&type=issues&limit=50" + @(@($resp) | Where-Object { $_ -and $_.number }) } -$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 $owner = $cfg.repo.Split('/')[0] $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 } +$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) { $n = $issue.number $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" Log "Running Claude Code on #$n (log: $claudeLog)" - # 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 $($cfg.claudeMaxTurns) --output-format text < `"$promptFile`" > `"$claudeLog`" 2>&1" - Log "Claude exited with code $LASTEXITCODE for #$n" - } finally { - Pop-Location - } + $rc = Invoke-Claude $promptFile $claudeLog ([int]$cfg.claudeMaxTurns) + Log "Claude exited with code $rc for #$n" # Relay an abort explanation if Claude declined the fix $resultFile = Join-Path $cfg.workDir 'CLAUDE_RESULT.md' diff --git a/automation/watcher.config.example.json b/automation/watcher.config.example.json index 2bfb20b..c08341b 100644 --- a/automation/watcher.config.example.json +++ b/automation/watcher.config.example.json @@ -6,6 +6,8 @@ "baseBranch": "master", "branchPrefix": "claude/issue-", "maxIssuesPerRun": 1, + "maxTriagePerRun": 3, "claudeExe": "C:\\Users\\shad0w\\.local\\bin\\claude.exe", - "claudeMaxTurns": 150 + "claudeMaxTurns": 150, + "triageMaxTurns": 80 }