feat(automation): issue-to-deploy pipeline — Report Issue button, Claude watcher, tag-triggered deploy
- Report Issue button in portal header files a Forgejo issue (portal + claude-queue labels) - Windows scheduled watcher runs headless Claude Code on queued issues and opens a PR - .forgejo/workflows/deploy.yml deploys v* release tags via the pms1 host runner (pm2 restart ppms) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
4c6b9c670f
commit
8b6d4e8ea6
11 changed files with 594 additions and 0 deletions
42
.forgejo/workflows/deploy.yml
Normal file
42
.forgejo/workflows/deploy.yml
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
name: Deploy release to production
|
||||
|
||||
# Pushing a release tag (vX.Y.Z) deploys that tag to the portal at
|
||||
# pms.pelagiamarine.com. Runs on the pms1 host runner (label: host),
|
||||
# which executes as shad0w with direct access to the pm2-managed app.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: host
|
||||
steps:
|
||||
- name: Deploy tag to ~/pms and restart ppms
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export NVM_DIR="$HOME/.nvm"
|
||||
. "$NVM_DIR/nvm.sh"
|
||||
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
echo "=== Deploying $TAG ==="
|
||||
|
||||
cd "$HOME/pms"
|
||||
git fetch origin --tags --force
|
||||
git checkout -f "refs/tags/$TAG"
|
||||
|
||||
cd App
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build # includes prisma generate
|
||||
pnpm db:migrate:deploy
|
||||
|
||||
pm2 restart ppms --update-env
|
||||
echo "=== Deployed $TAG ==="
|
||||
|
||||
- name: Verify portal responds
|
||||
run: |
|
||||
sleep 5
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/login)
|
||||
echo "Portal /login returned HTTP $code"
|
||||
test "$code" = "200"
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -27,6 +27,11 @@ App/coverage/
|
|||
.codex/
|
||||
.antigravity/
|
||||
|
||||
# Claude issue watcher (real token + logs stay local)
|
||||
automation/watcher.config.json
|
||||
automation/logs/
|
||||
automation/.watcher.lock
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
|
|
|||
|
|
@ -48,3 +48,9 @@ EMAIL_FROM_NAME="Pelagia Portal"
|
|||
# Development default (localhost:3002) is used if this is unset.
|
||||
# Start the service with: cd GstService && npm run dev
|
||||
GST_SERVICE_URL=http://localhost:3003
|
||||
|
||||
# ── Forgejo issue reporting (Report Issue button) ─────────────
|
||||
# Token needs write:issue scope on the repo below.
|
||||
FORGEJO_URL=https://git.pelagiamarine.com
|
||||
FORGEJO_REPO=shad0w/pelagia-portal
|
||||
FORGEJO_TOKEN=
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { LogOut } from "lucide-react";
|
|||
import type { Role } from "@prisma/client";
|
||||
import { CartIcon } from "./cart-icon";
|
||||
import { NotificationBell } from "./notification-bell";
|
||||
import { ReportIssueButton } from "./report-issue-button";
|
||||
|
||||
const ROLE_LABELS: Record<Role, string> = {
|
||||
TECHNICAL: "Technical",
|
||||
|
|
@ -39,6 +40,7 @@ export function Header({ user, initialUnreadCount, initialNotifications }: Heade
|
|||
<div />
|
||||
<div className="flex items-center gap-2">
|
||||
{CART_ROLES.includes(user.role) && <CartIcon />}
|
||||
<ReportIssueButton />
|
||||
<NotificationBell
|
||||
initialUnreadCount={initialUnreadCount}
|
||||
initialNotifications={initialNotifications}
|
||||
|
|
|
|||
52
App/components/layout/report-issue-actions.ts
Normal file
52
App/components/layout/report-issue-actions.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { createForgejoIssue } from "@/lib/forgejo";
|
||||
import { z } from "zod";
|
||||
|
||||
const PRIORITIES = ["P0 — Critical (broken / blocking)", "P1 — High", "P2 — Medium", "P3 — Low"] as const;
|
||||
|
||||
const reportSchema = z.object({
|
||||
title: z.string().min(5, "Please give the issue a short title (min 5 characters)").max(150),
|
||||
description: z.string().min(10, "Please describe the issue (min 10 characters)").max(5000),
|
||||
priority: z.enum(PRIORITIES),
|
||||
page: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
type Result = { ok: true; issueNumber: number; issueUrl: string } | { error: string };
|
||||
|
||||
export async function reportIssue(formData: FormData): Promise<Result> {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { error: "Unauthorized" };
|
||||
|
||||
const parsed = reportSchema.safeParse(Object.fromEntries(formData));
|
||||
if (!parsed.success) return { error: parsed.error.errors[0].message };
|
||||
const { title, description, priority, page } = parsed.data;
|
||||
|
||||
const body = [
|
||||
"### Raised by",
|
||||
`${session.user.name} (${session.user.email}, ${session.user.role}) — via portal Report Issue button`,
|
||||
"",
|
||||
"### Description",
|
||||
description,
|
||||
"",
|
||||
"### Priority",
|
||||
priority,
|
||||
"",
|
||||
"### Context",
|
||||
`- Page: \`${page || "unknown"}\``,
|
||||
`- Reported at: ${new Date().toISOString()}`,
|
||||
].join("\n");
|
||||
|
||||
try {
|
||||
const issue = await createForgejoIssue({
|
||||
title: `[Issue]: ${title}`,
|
||||
body,
|
||||
labels: ["portal", "claude-queue"],
|
||||
});
|
||||
return { ok: true, issueNumber: issue.number, issueUrl: issue.html_url };
|
||||
} catch (err) {
|
||||
console.error("reportIssue failed:", err);
|
||||
return { error: "Could not file the issue. Please try again or contact the administrator." };
|
||||
}
|
||||
}
|
||||
130
App/components/layout/report-issue-button.tsx
Normal file
130
App/components/layout/report-issue-button.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Bug } from "lucide-react";
|
||||
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||
import { reportIssue } from "./report-issue-actions";
|
||||
|
||||
const PRIORITIES = [
|
||||
"P0 — Critical (broken / blocking)",
|
||||
"P1 — High",
|
||||
"P2 — Medium",
|
||||
"P3 — Low",
|
||||
] as const;
|
||||
|
||||
export function ReportIssueButton() {
|
||||
const pathname = usePathname();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filedIssue, setFiledIssue] = useState<{ number: number; url: string } | null>(null);
|
||||
|
||||
function close() {
|
||||
setOpen(false);
|
||||
setError(null);
|
||||
setFiledIssue(null);
|
||||
}
|
||||
|
||||
function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
const formData = new FormData(e.currentTarget);
|
||||
formData.set("page", pathname);
|
||||
startTransition(async () => {
|
||||
const result = await reportIssue(formData);
|
||||
if ("error" in result) {
|
||||
setError(result.error);
|
||||
} else {
|
||||
setFiledIssue({ number: result.issueNumber, url: result.issueUrl });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex items-center gap-1.5 rounded-lg p-2 text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700 transition-colors"
|
||||
title="Report an issue"
|
||||
>
|
||||
<Bug className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<AdminDialog title="Report an issue" open={open} onClose={close}>
|
||||
{filedIssue ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-neutral-700">
|
||||
Thanks — issue <span className="font-semibold">#{filedIssue.number}</span> has been
|
||||
filed and queued for an automated fix. You'll see the change in an upcoming
|
||||
release.
|
||||
</p>
|
||||
<button
|
||||
onClick={close}
|
||||
className="w-full rounded-lg bg-neutral-900 px-4 py-2 text-sm font-medium text-white hover:bg-neutral-700 transition-colors"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-neutral-700" htmlFor="ri-title">
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
id="ri-title"
|
||||
name="title"
|
||||
required
|
||||
minLength={5}
|
||||
maxLength={150}
|
||||
placeholder="Short summary of the problem"
|
||||
className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-neutral-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-neutral-700" htmlFor="ri-description">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="ri-description"
|
||||
name="description"
|
||||
required
|
||||
minLength={10}
|
||||
maxLength={5000}
|
||||
rows={5}
|
||||
placeholder="What happened? What did you expect? Steps to reproduce if it's a bug."
|
||||
className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-neutral-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-neutral-700" htmlFor="ri-priority">
|
||||
Priority
|
||||
</label>
|
||||
<select
|
||||
id="ri-priority"
|
||||
name="priority"
|
||||
defaultValue={PRIORITIES[2]}
|
||||
className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-neutral-500 focus:outline-none"
|
||||
>
|
||||
{PRIORITIES.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="w-full rounded-lg bg-neutral-900 px-4 py-2 text-sm font-medium text-white hover:bg-neutral-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{pending ? "Filing issue…" : "Report issue"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</AdminDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
56
App/lib/forgejo.ts
Normal file
56
App/lib/forgejo.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// Forgejo issue integration for the in-app "Report Issue" button.
|
||||
// Issues are created on the configured repo and labelled so the
|
||||
// automated Claude watcher can pick them up.
|
||||
|
||||
const FORGEJO_URL = process.env.FORGEJO_URL ?? "https://git.pelagiamarine.com";
|
||||
const FORGEJO_REPO = process.env.FORGEJO_REPO ?? "shad0w/pelagia-portal";
|
||||
|
||||
interface CreateIssueInput {
|
||||
title: string;
|
||||
body: string;
|
||||
labels: string[];
|
||||
}
|
||||
|
||||
interface ForgejoIssue {
|
||||
number: number;
|
||||
html_url: string;
|
||||
}
|
||||
|
||||
async function forgejoFetch(path: string, init?: RequestInit): Promise<Response> {
|
||||
const token = process.env.FORGEJO_TOKEN;
|
||||
if (!token) throw new Error("FORGEJO_TOKEN is not configured");
|
||||
|
||||
return fetch(`${FORGEJO_URL}/api/v1${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolve label names to IDs (the create-issue API only accepts IDs). */
|
||||
async function resolveLabelIds(names: string[]): Promise<number[]> {
|
||||
const res = await forgejoFetch(`/repos/${FORGEJO_REPO}/labels?limit=50`);
|
||||
if (!res.ok) return [];
|
||||
const labels: { id: number; name: string }[] = await res.json();
|
||||
return labels.filter((l) => names.includes(l.name)).map((l) => l.id);
|
||||
}
|
||||
|
||||
export async function createForgejoIssue(input: CreateIssueInput): Promise<ForgejoIssue> {
|
||||
const labelIds = await resolveLabelIds(input.labels);
|
||||
|
||||
const res = await forgejoFetch(`/repos/${FORGEJO_REPO}/issues`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ title: input.title, body: input.body, labels: labelIds }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Forgejo issue creation failed (${res.status}): ${text.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
const issue = await res.json();
|
||||
return { number: issue.number, html_url: issue.html_url };
|
||||
}
|
||||
69
automation/README.md
Normal file
69
automation/README.md
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# Automated issue-to-deploy pipeline
|
||||
|
||||
End-to-end flow from a user clicking **Report Issue** in the portal to a fix
|
||||
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]
|
||||
│ 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)
|
||||
▼
|
||||
Human review: merge the PR, then create a release tag vX.Y.Z
|
||||
│ tag push triggers .forgejo/workflows/deploy.yml
|
||||
▼
|
||||
forgejo-runner on pms1 (pm2: forgejo-runner, label "host")
|
||||
│ checks out the tag in ~/pms, pnpm install + build + prisma migrate deploy
|
||||
▼
|
||||
pm2 restart ppms → live at pms.pelagiamarine.com
|
||||
```
|
||||
|
||||
## 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 |
|
||||
| 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 |
|
||||
| Deploy workflow | `.forgejo/workflows/deploy.yml` | Triggers on `v*` tags; runs on the `host` runner |
|
||||
| Runner | pms1 `~/forgejo-runner`, pm2 process `forgejo-runner` | Registered as `pms1-host` with labels `host`, `docker` |
|
||||
|
||||
## 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).
|
||||
|
||||
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.
|
||||
|
||||
## Releasing
|
||||
|
||||
After merging a Claude PR (or any change) on `master`:
|
||||
|
||||
```powershell
|
||||
git pull
|
||||
git tag v0.2.0 # semver: bump patch for fixes, minor for features
|
||||
git push pms1 master --tags
|
||||
```
|
||||
|
||||
The runner deploys the tag and restarts the app. Watch progress under
|
||||
**Actions** on the Forgejo repo, or `pm2 logs forgejo-runner` on pms1.
|
||||
|
||||
## Operational notes
|
||||
|
||||
- The watcher runs Claude Code with `--dangerously-skip-permissions` inside the
|
||||
dedicated `pelagia-autofix` clone — never point `workDir` at your main checkout.
|
||||
- Watcher only works issues while this PC is on; queued issues are picked up on
|
||||
the next run after boot (`-StartWhenAvailable`).
|
||||
- Tokens: `portal-report-issue` (write:issue, used by the app) and
|
||||
`claude-watcher` (write:issue + write:repository, used by the watcher).
|
||||
Both belong to the `shad0w` Forgejo account. Rotate via
|
||||
`docker exec -u 1000 forgejo forgejo admin user generate-access-token ...`.
|
||||
- Server-side env for the button lives in `~/pms/App/.env` on pms1
|
||||
(`FORGEJO_URL=http://127.0.0.1:3001` so it does not depend on the tunnel).
|
||||
197
automation/claude-issue-watcher.ps1
Normal file
197
automation/claude-issue-watcher.ps1
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
# 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
|
||||
}
|
||||
24
automation/register-watcher-task.ps1
Normal file
24
automation/register-watcher-task.ps1
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Registers the Claude issue watcher as a Windows Scheduled Task.
|
||||
# Runs every 10 minutes while the machine is on; skips if a run is in progress.
|
||||
# Run once: powershell -ExecutionPolicy Bypass -File register-watcher-task.ps1
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$taskName = 'PelagiaClaudeIssueWatcher'
|
||||
$scriptPath = Join-Path $PSScriptRoot 'claude-issue-watcher.ps1'
|
||||
|
||||
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
|
||||
-Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$scriptPath`""
|
||||
|
||||
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) `
|
||||
-RepetitionInterval (New-TimeSpan -Minutes 10)
|
||||
|
||||
$settings = New-ScheduledTaskSettingsSet `
|
||||
-MultipleInstances IgnoreNew `
|
||||
-ExecutionTimeLimit (New-TimeSpan -Hours 2) `
|
||||
-StartWhenAvailable
|
||||
|
||||
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger `
|
||||
-Settings $settings -Description 'Polls Forgejo for claude-queue issues and runs Claude Code on them' -Force
|
||||
|
||||
Write-Host "Scheduled task '$taskName' registered (every 10 minutes)."
|
||||
11
automation/watcher.config.example.json
Normal file
11
automation/watcher.config.example.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"forgejoUrl": "https://git.pelagiamarine.com",
|
||||
"repo": "shad0w/pelagia-portal",
|
||||
"token": "<forgejo token with write:issue,write:repository>",
|
||||
"workDir": "C:\\Users\\shad0w\\Documents\\src\\pelagia-autofix",
|
||||
"baseBranch": "master",
|
||||
"branchPrefix": "claude/issue-",
|
||||
"maxIssuesPerRun": 1,
|
||||
"claudeExe": "C:\\Users\\shad0w\\.local\\bin\\claude.exe",
|
||||
"claudeMaxTurns": 150
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue