- 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>
237 lines
11 KiB
PowerShell
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
|
|
}
|