# 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 } # 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 ────────────────────────────────────────────── $queued = @(Api GET "/repos/$($cfg.repo)/issues?state=open&labels=claude-queue&type=issues&limit=20" | Where-Object { $_ -and $_.number }) if ($queued.Count -eq 0) { Log "No queued issues." exit 0 } $queued = @($queued | Sort-Object number | Select-Object -First $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 "🤖 Claude has 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 } $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) ## 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 "🤖 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 "🤖 Automated fix attempt did not produce a change.`n`n$reason" } } } finally { Remove-Item $lockFile -Force -ErrorAction SilentlyContinue }