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/
|
.codex/
|
||||||
.antigravity/
|
.antigravity/
|
||||||
|
|
||||||
|
# Claude issue watcher (real token + logs stay local)
|
||||||
|
automation/watcher.config.json
|
||||||
|
automation/logs/
|
||||||
|
automation/.watcher.lock
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
|
||||||
|
|
@ -48,3 +48,9 @@ EMAIL_FROM_NAME="Pelagia Portal"
|
||||||
# Development default (localhost:3002) is used if this is unset.
|
# Development default (localhost:3002) is used if this is unset.
|
||||||
# Start the service with: cd GstService && npm run dev
|
# Start the service with: cd GstService && npm run dev
|
||||||
GST_SERVICE_URL=http://localhost:3003
|
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 type { Role } from "@prisma/client";
|
||||||
import { CartIcon } from "./cart-icon";
|
import { CartIcon } from "./cart-icon";
|
||||||
import { NotificationBell } from "./notification-bell";
|
import { NotificationBell } from "./notification-bell";
|
||||||
|
import { ReportIssueButton } from "./report-issue-button";
|
||||||
|
|
||||||
const ROLE_LABELS: Record<Role, string> = {
|
const ROLE_LABELS: Record<Role, string> = {
|
||||||
TECHNICAL: "Technical",
|
TECHNICAL: "Technical",
|
||||||
|
|
@ -39,6 +40,7 @@ export function Header({ user, initialUnreadCount, initialNotifications }: Heade
|
||||||
<div />
|
<div />
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{CART_ROLES.includes(user.role) && <CartIcon />}
|
{CART_ROLES.includes(user.role) && <CartIcon />}
|
||||||
|
<ReportIssueButton />
|
||||||
<NotificationBell
|
<NotificationBell
|
||||||
initialUnreadCount={initialUnreadCount}
|
initialUnreadCount={initialUnreadCount}
|
||||||
initialNotifications={initialNotifications}
|
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