pelagia-portal/automation/claude-issue-watcher.ps1
Hardik 080dafb473 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>
2026-06-19 04:20:21 +05:30

388 lines
19 KiB
PowerShell

# Claude issue watcher for the Pelagia portal. Two phases per run:
#
# 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/.
[CmdletBinding()]
param(
[string]$ConfigPath
)
$ErrorActionPreference = 'Stop'
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
if (-not $ConfigPath) { $ConfigPath = Join-Path $scriptDir 'watcher.config.json' }
if (-not (Test-Path $ConfigPath)) {
throw "Config not found: $ConfigPath (copy watcher.config.example.json and fill in the token)"
}
$cfg = Get-Content $ConfigPath -Raw | ConvertFrom-Json
$logDir = Join-Path $scriptDir 'logs'
if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir | Out-Null }
$logFile = Join-Path $logDir ("watcher-{0}.log" -f (Get-Date -Format 'yyyy-MM-dd'))
function Log([string]$msg) {
$line = "{0} {1}" -f (Get-Date -Format 'HH:mm:ss'), $msg
$line | Out-File -FilePath $logFile -Append -Encoding utf8
Write-Host $line
}
# ── Single-instance lock ────────────────────────────────────────────
$lockFile = Join-Path $scriptDir '.watcher.lock'
if (Test-Path $lockFile) {
$lockPid = Get-Content $lockFile -TotalCount 1
if ($lockPid -and (Get-Process -Id $lockPid -ErrorAction SilentlyContinue)) {
Log "Another watcher run (PID $lockPid) is active; exiting."
exit 0
}
}
$PID | Out-File -FilePath $lockFile -Encoding ascii
try {
# ── Forgejo API helpers ─────────────────────────────────────────────
$apiBase = "$($cfg.forgejoUrl)/api/v1"
$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) {
# 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
}
# 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 = 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) {
Api POST "/repos/$($cfg.repo)/issues/$IssueNumber/comments" @{ body = $Text } | Out-Null
}
# Hidden ASCII marker on every comment the watcher posts, so human comments can
# be told apart from bot status comments regardless of who the API token posts as.
# Kept ASCII-only: PS 5.1 reads BOM-less scripts as ANSI, so non-ASCII here is unsafe.
$BotMarker = '<!-- ppms-bot -->'
# Identifies the watcher's own status comments so they are not fed back to Claude
# as if they were human input. New comments carry the ppms-bot marker; legacy ones
# (posted before the marker existed) are matched by their stable ASCII phrases —
# the emoji they used got mojibake-mangled in storage, so it is unmatchable.
# Bot posts under the same account as humans, so we match on content, not author.
$BotCommentPattern = 'ppms-bot|has started working on this issue|Claude opened PR \[#|Automated fix attempt did not produce'
# Fetch human comments on an issue as a markdown block for Claude's prompt.
function Get-IssueCommentsBlock([int]$IssueNumber) {
# Capture before wrapping: @(Api ...) alone collapses a multi-comment array
# into a single object in PS 5.1 (same quirk as the queued-issues fetch).
$resp = Api GET "/repos/$($cfg.repo)/issues/$IssueNumber/comments?limit=50"
$human = @(@($resp) | Where-Object {
$_.body -and ($_.body -notmatch $BotCommentPattern)
})
if ($human.Count -eq 0) { return "" }
$lines = foreach ($c in $human) {
$who = $c.user.login
"**$who commented:**`n$($c.body)`n"
}
return "## Comments on the issue (read these -- they refine the scope/repro)`n`n" + ($lines -join "`n")
}
# Run git without tripping ErrorActionPreference=Stop on stderr output
# (native stderr lines become ErrorRecords in PS 5.1). Returns exit code.
function Run-Git([string[]]$GitArgs) {
$prev = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
& git @GitArgs 2>&1 | ForEach-Object { $_.ToString() } | Out-File $logFile -Append -Encoding utf8
return $LASTEXITCODE
} finally {
$ErrorActionPreference = $prev
}
}
# 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 })
}
# 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"
if (-not (Test-Path (Join-Path $cfg.workDir '.git'))) {
Log "Cloning $($cfg.repo) into $($cfg.workDir)"
if ((Run-Git @('clone', $cloneUrl, $cfg.workDir)) -ne 0) { throw "git clone failed" }
Run-Git @('-C', $cfg.workDir, 'config', 'user.name', 'Claude (auto-fix)') | 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) {
$n = $issue.number
$branch = "$($cfg.branchPrefix)$n"
Log "-- Working issue #${n}: $($issue.title)"
Set-IssueLabels $n -Remove @('claude-queue', 'claude-failed') -Add @('claude-working')
Add-IssueComment $n "$BotMarker`n[Claude] Started working on this issue on branch ``$branch``."
Run-Git @('-C', $cfg.workDir, 'fetch', 'origin') | Out-Null
if ((Run-Git @('-C', $cfg.workDir, 'checkout', '-B', $branch, "origin/$($cfg.baseBranch)")) -ne 0) {
Log "checkout failed for #$n"; continue
}
$commentsBlock = Get-IssueCommentsBlock $n
if ($commentsBlock) {
$cCount = ([regex]::Matches($commentsBlock, 'commented:\*\*')).Count
Log "Including $cCount human comment(s) for #$n"
}
$prompt = @"
You are working autonomously on issue #$n of the Pelagia Portal (PPMS), a Next.js 15 purchase-order
management system for a maritime company. The web app lives in the App/ directory -- read App/CLAUDE.md
first for architecture, conventions, and commands.
## Issue #${n}: $($issue.title)
$($issue.body)
$commentsBlock
## Your job
1. Investigate the issue and implement a focused, minimal fix in this repository.
2. Verify your change: run ``pnpm type-check`` and ``pnpm lint`` in App/. If you changed behaviour
covered by unit tests, run the relevant tests. Do not start the dev server or any database.
3. Add or adjust tests when it makes sense for the change.
4. Commit ALL your changes to the current branch with a conventional commit message that ends with
the line: Fixes #$n
5. Do NOT push, do NOT create tags, do NOT switch branches. The supervisor script handles push and PR.
If the issue is unclear, too risky to change without human input (e.g. data migrations, payments,
permissions changes), or you cannot verify the fix, make NO commits and instead write a short
explanation to a file named CLAUDE_RESULT.md in the repository root (it will be relayed to the issue).
"@
$promptFile = Join-Path $env:TEMP "claude-issue-$n-prompt.txt"
$prompt | Out-File -FilePath $promptFile -Encoding utf8
$claudeLog = Join-Path $logDir "claude-issue-$n-$(Get-Date -Format 'yyyyMMdd-HHmmss').log"
Log "Running Claude Code on #$n (log: $claudeLog)"
$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'
$abortNote = $null
if (Test-Path $resultFile) {
$abortNote = Get-Content $resultFile -Raw
Remove-Item $resultFile -Force
Run-Git @('-C', $cfg.workDir, 'checkout', '--', '.') | Out-Null
}
$commitCount = [int](& git -C $cfg.workDir rev-list "origin/$($cfg.baseBranch)..HEAD" --count)
if ($commitCount -gt 0) {
Log "Claude made $commitCount commit(s); pushing $branch"
if ((Run-Git @('-C', $cfg.workDir, 'push', '-f', '-u', 'origin', $branch)) -ne 0) {
Log "push failed for #$n"; Set-IssueLabels $n -Remove @('claude-working') -Add @('claude-failed'); continue
}
$prTitle = $issue.title -replace '^\[Issue\]:\s*', ''
$pr = Api POST "/repos/$($cfg.repo)/pulls" @{
base = $cfg.baseBranch
head = $branch
title = "fix: $prTitle"
body = "Automated fix by Claude Code for #$n.`n`nCloses #$n`n`nReview, merge, then create a release tag (vX.Y.Z) to deploy."
}
Set-IssueLabels $n -Remove @('claude-working') -Add @('claude-pr')
Add-IssueComment $n "$BotMarker`n[Claude] Opened PR [#$($pr.number)]($($pr.html_url)) with a proposed fix. Review and merge it, then create a release tag to deploy."
Log "PR #$($pr.number) opened for issue #$n"
} else {
Log "No commits produced for #$n; marking claude-failed"
Set-IssueLabels $n -Remove @('claude-working') -Add @('claude-failed')
$reason = if ($abortNote) { $abortNote } else { "Claude did not produce a verified fix. See watcher logs on the dev machine: $claudeLog" }
Add-IssueComment $n "$BotMarker`n[Claude] Automated fix attempt did not produce a change.`n`n$reason"
}
}
} finally {
Remove-Item $lockFile -Force -ErrorAction SilentlyContinue
}