pelagia-portal/automation/claude-issue-watcher.ps1
Hardik f3557aca97 fix(automation): feed issue comments to Claude; fix PS 5.1 array-unroll bug
- Get-IssueCommentsBlock includes human issue comments in Claude's prompt so
  scope/repro added as comments is acted on (requested for delivery-dropdown #19)
- Critical: capture Api responses to a variable before filtering. Piping the
  Api function's array output straight into Where-Object collapses all issues
  into one object in PS 5.1, so the watcher tried to process #12/#8/#7 at once
- Bot status comments now carry an ASCII <!-- ppms-bot --> marker and are
  filtered out (incl. legacy emoji comments via stable ASCII phrase match) so
  they are never fed back as human input; script kept ASCII-only for ANSI load
- Harden numeric sort/select on issue numbers

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 03:13:34 +05:30

237 lines
11 KiB
PowerShell

# Claude issue watcher for the Pelagia portal.
#
# 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
#
# 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) {
$params.Body = ($Body | ConvertTo-Json -Depth 5)
$params.ContentType = 'application/json'
}
Invoke-RestMethod @params
}
function Set-IssueLabels([int]$IssueNumber, [string[]]$Remove, [string[]]$Add) {
$allLabels = Api GET "/repos/$($cfg.repo)/labels?limit=50"
$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
}
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
}
}
# ── 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
}
$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 ────────────────────────────────
$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
}
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)"
# 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
}
# 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
}