Compare commits

..

No commits in common. "master" and "claude/issue-1" have entirely different histories.

27 changed files with 104 additions and 1179 deletions

2
.gitattributes vendored
View file

@ -1,2 +0,0 @@
# Shell scripts must keep LF endings — they run on Linux (pms1).
*.sh text eol=lf

View file

@ -8,7 +8,7 @@ import { revalidatePath } from "next/cache";
type ActionResult = { ok: true } | { error: string }; type ActionResult = { ok: true } | { error: string };
export async function approvePo({ export async function approvepo({
poId, poId,
note, note,
withNote = false, withNote = false,
@ -22,7 +22,7 @@ export async function approvePo({
const po = await db.purchaseOrder.findUnique({ const po = await db.purchaseOrder.findUnique({
where: { id: poId }, where: { id: poId },
include: { submitter: true, lineItems: true }, include: { submitter: true },
}); });
if (!po) return { error: "PO not found" }; if (!po) return { error: "PO not found" };
@ -51,20 +51,6 @@ export async function approvePo({
}, },
}); });
// Add line items to site inventory immediately on approval (not on closure)
const siteId = po.siteId ?? null;
if (siteId) {
for (const li of po.lineItems) {
if (!li.productId) continue;
await db.itemInventory.upsert({
where: { productId_siteId: { productId: li.productId, siteId } },
update: { quantity: { increment: Number(li.quantity) } },
create: { productId: li.productId, siteId, quantity: Number(li.quantity) },
});
}
revalidatePath(`/admin/sites/${siteId}`);
}
const accounts = await db.user.findMany({ where: { role: "ACCOUNTS", isActive: true } }); const accounts = await db.user.findMany({ where: { role: "ACCOUNTS", isActive: true } });
await notify({ await notify({
event: withNote ? "PO_APPROVED_WITH_NOTE" : "PO_APPROVED", event: withNote ? "PO_APPROVED_WITH_NOTE" : "PO_APPROVED",

View file

@ -2,7 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { approvePo, rejectPo, requestEdits, requestVendorId } from "./actions"; import { approvepo, rejectPo, requestEdits, requestVendorId } from "./actions";
import type { POStatus } from "@prisma/client"; import type { POStatus } from "@prisma/client";
export function ApprovalActions({ export function ApprovalActions({
@ -26,8 +26,8 @@ export function ApprovalActions({
setPending(action); setPending(action);
setError(""); setError("");
let result: { ok: true } | { error: string } | undefined; let result: { ok: true } | { error: string } | undefined;
if (action === "approve") result = await approvePo({ poId, note }); if (action === "approve") result = await approvepo({ poId, note });
else if (action === "approve_note") result = await approvePo({ poId, note, withNote: true }); else if (action === "approve_note") result = await approvepo({ poId, note, withNote: true });
else if (action === "reject") result = await rejectPo({ poId, note }); else if (action === "reject") result = await rejectPo({ poId, note });
else if (action === "request_edits") result = await requestEdits({ poId, note }); else if (action === "request_edits") result = await requestEdits({ poId, note });
else if (action === "request_vendor_id") result = await requestVendorId({ poId }); else if (action === "request_vendor_id") result = await requestVendorId({ poId });

View file

@ -15,12 +15,11 @@ export default async function MyOrdersPage() {
const { role, id: userId } = session.user; const { role, id: userId } = session.user;
if (!["TECHNICAL", "MANNING", "MANAGER", "SUPERUSER"].includes(role)) redirect("/dashboard"); if (!["TECHNICAL", "MANNING", "MANAGER", "SUPERUSER"].includes(role)) redirect("/dashboard");
const isManager = role === "MANAGER" || role === "SUPERUSER";
const closed = await db.purchaseOrder.findMany({ const closed = await db.purchaseOrder.findMany({
where: isManager where: {
? { status: "CLOSED" } submitterId: userId,
: { submitterId: userId, status: "CLOSED" }, status: { in: ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PAID_DELIVERED", "CLOSED", "REJECTED"] },
},
orderBy: { updatedAt: "desc" }, orderBy: { updatedAt: "desc" },
include: { include: {
vessel: { select: { name: true } }, vessel: { select: { name: true } },

View file

@ -48,7 +48,6 @@ export async function updatePo(
vesselId: formData.get("vesselId"), vesselId: formData.get("vesselId"),
accountId: formData.get("accountId"), accountId: formData.get("accountId"),
companyId: (formData.get("companyId") as string) || undefined, companyId: (formData.get("companyId") as string) || undefined,
poDate: formData.get("poDate") || undefined,
projectCode: formData.get("projectCode") || undefined, projectCode: formData.get("projectCode") || undefined,
dateRequired: formData.get("dateRequired") || undefined, dateRequired: formData.get("dateRequired") || undefined,
vendorId: formData.get("vendorId") || undefined, vendorId: formData.get("vendorId") || undefined,
@ -92,7 +91,7 @@ export async function updatePo(
vessel: string | null; vesselId: string; vessel: string | null; vesselId: string;
account: string; accountId: string; account: string; accountId: string;
vendor: string | null; vendorId: string | null; vendor: string | null; vendorId: string | null;
poDate: string | null; projectCode: string | null; dateRequired: string | null; placeOfDelivery: string | null; projectCode: string | null; dateRequired: string | null; placeOfDelivery: string | null;
}; };
} | null = null; } | null = null;
@ -125,7 +124,6 @@ export async function updatePo(
accountId: currentPo.accountId, accountId: currentPo.accountId,
vendor: currentPo.vendor?.name ?? null, vendor: currentPo.vendor?.name ?? null,
vendorId: currentPo.vendorId, vendorId: currentPo.vendorId,
poDate: currentPo.poDate?.toISOString() ?? null,
projectCode: currentPo.projectCode, projectCode: currentPo.projectCode,
dateRequired: currentPo.dateRequired?.toISOString() ?? null, dateRequired: currentPo.dateRequired?.toISOString() ?? null,
placeOfDelivery: currentPo.placeOfDelivery, placeOfDelivery: currentPo.placeOfDelivery,
@ -142,7 +140,6 @@ export async function updatePo(
accountId: data.accountId, accountId: data.accountId,
companyId: data.companyId ?? null, companyId: data.companyId ?? null,
vendorId: data.vendorId ?? null, vendorId: data.vendorId ?? null,
poDate: data.poDate ? new Date(data.poDate) : null,
projectCode: data.projectCode ?? null, projectCode: data.projectCode ?? null,
dateRequired: data.dateRequired ? new Date(data.dateRequired) : null, dateRequired: data.dateRequired ? new Date(data.dateRequired) : null,
piQuotationNo: data.piQuotationNo ?? null, piQuotationNo: data.piQuotationNo ?? null,

View file

@ -92,9 +92,6 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, managerN
} }
} }
const poDateValue = po.poDate
? new Date(po.poDate).toISOString().split("T")[0]
: "";
const dateValue = po.dateRequired const dateValue = po.dateRequired
? new Date(po.dateRequired).toISOString().split("T")[0] ? new Date(po.dateRequired).toISOString().split("T")[0]
: ""; : "";
@ -178,11 +175,6 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, managerN
required required
/> />
</div> </div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">PO Date</label>
<input name="poDate" type="date" defaultValue={poDateValue} className={INPUT_CLS} />
<p className="mt-1 text-xs text-neutral-400">Optional can be back-dated or forward-dated. Defaults to the approved date if left blank.</p>
</div>
<div> <div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Project Code</label> <label className="block text-sm font-medium text-neutral-700 mb-1.5">Project Code</label>
<input name="projectCode" defaultValue={po.projectCode ?? ""} className={INPUT_CLS} placeholder="Optional" /> <input name="projectCode" defaultValue={po.projectCode ?? ""} className={INPUT_CLS} placeholder="Optional" />

View file

@ -109,12 +109,7 @@ export async function confirmReceipt({
status: newStatus, status: newStatus,
closedAt: newStatus === "CLOSED" ? new Date() : undefined, closedAt: newStatus === "CLOSED" ? new Date() : undefined,
receipt: notes receipt: notes
? { ? { create: { storageKey: "", fileName: "no-file", notes } }
upsert: {
create: { storageKey: "", fileName: "no-file", notes },
update: { notes },
},
}
: undefined, : undefined,
actions: { actions: {
create: { create: {
@ -136,6 +131,23 @@ export async function confirmReceipt({
}, },
}); });
// Auto-update inventory for delivered quantities
const siteId =
(po as typeof po & { siteId?: string | null }).siteId ??
null;
if (siteId) {
for (const u of lineUpdates) {
if (!u.productId || u.nowDelivered <= 0) continue;
await db.itemInventory.upsert({
where: { productId_siteId: { productId: u.productId, siteId } },
update: { quantity: { increment: u.nowDelivered } },
create: { productId: u.productId, siteId, quantity: u.nowDelivered },
});
}
revalidatePath(`/admin/sites/${siteId}`);
}
// Closing a PO auto-verifies its vendor (proof of a real, completed transaction). // Closing a PO auto-verifies its vendor (proof of a real, completed transaction).
if (newStatus === "CLOSED" && po.vendorId) { if (newStatus === "CLOSED" && po.vendorId) {
await db.vendor.update({ where: { id: po.vendorId }, data: { isVerified: true } }); await db.vendor.update({ where: { id: po.vendorId }, data: { isVerified: true } });

View file

@ -54,7 +54,6 @@ export async function createPo(
vesselId: formData.get("vesselId"), vesselId: formData.get("vesselId"),
accountId: formData.get("accountId"), accountId: formData.get("accountId"),
companyId: (formData.get("companyId") as string) || undefined, companyId: (formData.get("companyId") as string) || undefined,
poDate: formData.get("poDate") || undefined,
projectCode: formData.get("projectCode") || undefined, projectCode: formData.get("projectCode") || undefined,
dateRequired: formData.get("dateRequired") || undefined, dateRequired: formData.get("dateRequired") || undefined,
vendorId: formData.get("vendorId") || undefined, vendorId: formData.get("vendorId") || undefined,
@ -94,7 +93,6 @@ export async function createPo(
accountId: data.accountId, accountId: data.accountId,
companyId: data.companyId ?? null, companyId: data.companyId ?? null,
vendorId: data.vendorId ?? null, vendorId: data.vendorId ?? null,
poDate: data.poDate ? new Date(data.poDate) : null,
projectCode: data.projectCode ?? null, projectCode: data.projectCode ?? null,
dateRequired: data.dateRequired ? new Date(data.dateRequired) : null, dateRequired: data.dateRequired ? new Date(data.dateRequired) : null,
piQuotationNo: data.piQuotationNo ?? null, piQuotationNo: data.piQuotationNo ?? null,

View file

@ -143,11 +143,6 @@ export function NewPoForm({ vessels, accounts, vendors, companies, initialLineIt
/> />
</div> </div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">PO Date</label>
<input name="poDate" type="date" className={INPUT_CLS} />
<p className="mt-1 text-xs text-neutral-400">Optional can be back-dated or forward-dated. Defaults to the approved date if left blank.</p>
</div>
<div> <div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Project Code</label> <label className="block text-sm font-medium text-neutral-700 mb-1.5">Project Code</label>
<input name="projectCode" className={INPUT_CLS} placeholder="Optional" /> <input name="projectCode" className={INPUT_CLS} placeholder="Optional" />

View file

@ -91,9 +91,7 @@ export async function GET(request: NextRequest, { params }: Props) {
const gstAmt = taxable * gstRate; const gstAmt = taxable * gstRate;
const li_ = li as typeof li & { name?: string }; const li_ = li as typeof li & { name?: string };
const desc = li_.name ?? li.description ?? ""; const desc = li_.name ?? li.description ?? "";
// When both name and description exist, include the optional description separately return { sn: i + 1, desc, unit: li.unit, qty, unitPrice, gstRate, taxable, gstAmt, total: taxable + gstAmt };
const optionalDesc = li_.name && li.description ? li.description : "";
return { sn: i + 1, desc, optionalDesc, unit: li.unit, qty, unitPrice, gstRate, taxable, gstAmt, total: taxable + gstAmt };
}); });
const totalTaxable = items.reduce((s, i) => s + i.taxable, 0); const totalTaxable = items.reduce((s, i) => s + i.taxable, 0);
@ -104,9 +102,6 @@ export async function GET(request: NextRequest, { params }: Props) {
.find(a => a.actionType === "APPROVED" || a.actionType === "APPROVED_WITH_NOTE"); .find(a => a.actionType === "APPROVED" || a.actionType === "APPROVED_WITH_NOTE");
const approvedBy = approvalAction?.actor.name ?? ""; const approvedBy = approvalAction?.actor.name ?? "";
// PO date: submitter-set date → approved date → creation date
const poDisplayDate = po.poDate ?? po.approvedAt ?? po.createdAt;
// Fetch approver's signature for embedding in the document // Fetch approver's signature for embedding in the document
let signatureBase64: string | null = null; let signatureBase64: string | null = null;
let signatureMime = "image/png"; let signatureMime = "image/png";
@ -262,7 +257,7 @@ export async function GET(request: NextRequest, { params }: Props) {
sc(5, 3, po.poNumber, { font: { ...fBold, color: { argb: "FF1A1A1A" } }, border: bordAll, align: alignL }); sc(5, 3, po.poNumber, { font: { ...fBold, color: { argb: "FF1A1A1A" } }, border: bordAll, align: alignL });
ws.mergeCells("C5:G5"); ws.mergeCells("C5:G5");
sc(5, 8, "Date:", { font: fBold, fill: fillLbl, border: bordAll, align: alignR }); sc(5, 8, "Date:", { font: fBold, fill: fillLbl, border: bordAll, align: alignR });
sc(5, 9, fmtDate(poDisplayDate), { font: fBase, border: bordAll, align: alignL }); sc(5, 9, fmtDate(po.createdAt), { font: fBase, border: bordAll, align: alignL });
// ══ ROW 6: PI / Quotation ════════════════════════════════════════════════ // ══ ROW 6: PI / Quotation ════════════════════════════════════════════════
ws.getRow(6).height = 16; ws.getRow(6).height = 16;
@ -351,18 +346,15 @@ export async function GET(request: NextRequest, { params }: Props) {
for (let idx = 0; idx < BODY_ROWS; idx++) { for (let idx = 0; idx < BODY_ROWS; idx++) {
const r = HDR_ROW + 1 + idx; const r = HDR_ROW + 1 + idx;
// Taller rows: long item names + potential description sub-line need room // Taller rows: long item names + potential description sub-line need room
const descLen = (items[idx]?.desc ?? "").length + (items[idx]?.optionalDesc ?? "").length; const descLen = (items[idx]?.desc ?? "").length;
ws.getRow(r).height = descLen > 40 || items[idx]?.optionalDesc ? 32 : 20; ws.getRow(r).height = descLen > 40 ? 28 : 20;
const item = items[idx]; const item = items[idx];
const fillAlt = idx % 2 === 1 const fillAlt = idx % 2 === 1
? { type: "pattern" as const, pattern: "solid" as const, fgColor: { argb: "FFFAFAFA" } } ? { type: "pattern" as const, pattern: "solid" as const, fgColor: { argb: "FFFAFAFA" } }
: undefined; : undefined;
sc(r, 1, item?.sn ?? null, { font: fBase, fill: fillAlt, border: bordAll, align: alignC }); sc(r, 1, item?.sn ?? null, { font: fBase, fill: fillAlt, border: bordAll, align: alignC });
const xlsxDesc = item sc(r, 2, item?.desc ?? "", { font: fBase, fill: fillAlt, border: bordAll, align: alignL });
? (item.optionalDesc ? `${item.desc}\n${item.optionalDesc}` : item.desc)
: "";
sc(r, 2, xlsxDesc, { font: fBase, fill: fillAlt, border: bordAll, align: alignL });
ws.mergeCells(`B${r}:C${r}`); ws.mergeCells(`B${r}:C${r}`);
sc(r, 4, item?.unit ?? "", { font: fBase, fill: fillAlt, border: bordAll, align: alignC }); sc(r, 4, item?.unit ?? "", { font: fBase, fill: fillAlt, border: bordAll, align: alignC });
sc(r, 5, item ? item.qty : null, { font: fBase, fill: fillAlt, border: bordAll, align: alignR, numFmt: NUM_FMT }); sc(r, 5, item ? item.qty : null, { font: fBase, fill: fillAlt, border: bordAll, align: alignR, numFmt: NUM_FMT });
@ -472,7 +464,7 @@ export async function GET(request: NextRequest, { params }: Props) {
const itemRows = items.map((item, i) => ` const itemRows = items.map((item, i) => `
<tr style="background:${i % 2 === 0 ? "#fff" : "#fafafa"}"> <tr style="background:${i % 2 === 0 ? "#fff" : "#fafafa"}">
<td style="text-align:center">${item.sn}</td> <td style="text-align:center">${item.sn}</td>
<td>${item.desc}${item.optionalDesc ? `<br/><span style="font-size:7.5pt;color:#666;font-style:italic">${item.optionalDesc}</span>` : ""}</td> <td>${item.desc}</td>
<td style="text-align:center">${item.unit}</td> <td style="text-align:center">${item.unit}</td>
<td style="text-align:right">${fmtNum(item.qty, item.qty % 1 === 0 ? 0 : 3)}</td> <td style="text-align:right">${fmtNum(item.qty, item.qty % 1 === 0 ? 0 : 3)}</td>
<td style="text-align:right">${fmtNum(item.unitPrice)}</td> <td style="text-align:right">${fmtNum(item.unitPrice)}</td>
@ -609,7 +601,7 @@ export async function GET(request: NextRequest, { params }: Props) {
<td class="lbl" style="width:22%">Purchase Order No:</td> <td class="lbl" style="width:22%">Purchase Order No:</td>
<td style="width:28%;font-weight:bold">${po.poNumber}</td> <td style="width:28%;font-weight:bold">${po.poNumber}</td>
<td class="lbl" style="width:14%;text-align:right">Date:</td> <td class="lbl" style="width:14%;text-align:right">Date:</td>
<td style="width:36%">${fmtDate(poDisplayDate)}</td> <td style="width:36%">${fmtDate(po.createdAt)}</td>
</tr> </tr>
<tr> <tr>
<td class="lbl">Performa Invoice / Quotation No:</td> <td class="lbl">Performa Invoice / Quotation No:</td>

View file

@ -39,13 +39,10 @@ export async function reportIssue(formData: FormData): Promise<Result> {
].join("\n"); ].join("\n");
try { try {
// File with only `portal`. The watcher triages portal issues — Claude reads
// the issue, posts a requirements breakdown, and routes it to `claude-queue`
// (auto-fixable) or `interactive` (needs human steering).
const issue = await createForgejoIssue({ const issue = await createForgejoIssue({
title: `[Issue]: ${title}`, title: `[Issue]: ${title}`,
body, body,
labels: ["portal"], labels: ["portal", "claude-queue"],
}); });
return { ok: true, issueNumber: issue.number, issueUrl: issue.html_url }; return { ok: true, issueNumber: issue.number, issueUrl: issue.html_url };
} catch (err) { } catch (err) {

View file

@ -16,7 +16,6 @@ type PoWithRelations = {
status: import("@prisma/client").POStatus; status: import("@prisma/client").POStatus;
totalAmount: import("@prisma/client").Prisma.Decimal; totalAmount: import("@prisma/client").Prisma.Decimal;
currency: string; currency: string;
poDate: Date | null;
projectCode: string | null; projectCode: string | null;
dateRequired: Date | null; dateRequired: Date | null;
managerNote: string | null; managerNote: string | null;
@ -125,7 +124,7 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
vessel: string | null; vesselId: string; vessel: string | null; vesselId: string;
account: string; accountId: string; account: string; accountId: string;
vendor: string | null; vendorId: string | null; vendor: string | null; vendorId: string | null;
poDate: string | null; projectCode: string | null; dateRequired: string | null; placeOfDelivery: string | null; projectCode: string | null; dateRequired: string | null; placeOfDelivery: string | null;
}; };
}; };
const resubmitAction = [...po.actions] const resubmitAction = [...po.actions]
@ -163,9 +162,6 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
.reverse() .reverse()
.find((a) => a.actionType === "APPROVED" || a.actionType === "APPROVED_WITH_NOTE"); .find((a) => a.actionType === "APPROVED" || a.actionType === "APPROVED_WITH_NOTE");
// PO date: submitter-set date → approved date → creation date
const poDisplayDate = po.poDate ?? po.approvedAt ?? po.createdAt;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
@ -238,8 +234,6 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
const currentVendor = po.vendor?.name ?? null; const currentVendor = po.vendor?.name ?? null;
const currentDateRequired = po.dateRequired?.toISOString() ?? null; const currentDateRequired = po.dateRequired?.toISOString() ?? null;
const currentPoDate = po.poDate?.toISOString() ?? null;
const fieldChanges: { label: string; before: string | null; after: string | null }[] = []; const fieldChanges: { label: string; before: string | null; after: string | null }[] = [];
if (snap.title !== po.title) if (snap.title !== po.title)
fieldChanges.push({ label: "Title", before: snap.title, after: po.title }); fieldChanges.push({ label: "Title", before: snap.title, after: po.title });
@ -249,12 +243,6 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
fieldChanges.push({ label: "Account", before: snap.account, after: currentAccount }); fieldChanges.push({ label: "Account", before: snap.account, after: currentAccount });
if (snap.vendorId !== (po.vendor?.id ?? null)) if (snap.vendorId !== (po.vendor?.id ?? null))
fieldChanges.push({ label: "Vendor", before: snap.vendor ?? "None", after: currentVendor ?? "None" }); fieldChanges.push({ label: "Vendor", before: snap.vendor ?? "None", after: currentVendor ?? "None" });
if ((snap.poDate ?? null) !== currentPoDate)
fieldChanges.push({
label: "PO Date",
before: snap.poDate ? formatDate(snap.poDate) : "—",
after: po.poDate ? formatDate(po.poDate) : "—",
});
if (snap.projectCode !== po.projectCode) if (snap.projectCode !== po.projectCode)
fieldChanges.push({ label: "Project Code", before: snap.projectCode ?? "—", after: po.projectCode ?? "—" }); fieldChanges.push({ label: "Project Code", before: snap.projectCode ?? "—", after: po.projectCode ?? "—" });
if (snap.dateRequired !== currentDateRequired) if (snap.dateRequired !== currentDateRequired)
@ -305,7 +293,6 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
{approvalAction && ( {approvalAction && (
<div><dt className="text-neutral-500">Approved By</dt><dd className="font-medium text-neutral-900">{approvalAction.actor.name}</dd></div> <div><dt className="text-neutral-500">Approved By</dt><dd className="font-medium text-neutral-900">{approvalAction.actor.name}</dd></div>
)} )}
<div><dt className="text-neutral-500">PO Date</dt><dd className="font-medium text-neutral-900">{formatDate(poDisplayDate)}</dd></div>
{po.projectCode && <div><dt className="text-neutral-500">Project Code</dt><dd className="font-medium text-neutral-900">{po.projectCode}</dd></div>} {po.projectCode && <div><dt className="text-neutral-500">Project Code</dt><dd className="font-medium text-neutral-900">{po.projectCode}</dd></div>}
{po.dateRequired && <div><dt className="text-neutral-500">Delivery Date Required</dt><dd className="font-medium text-neutral-900">{formatDate(po.dateRequired)}</dd></div>} {po.dateRequired && <div><dt className="text-neutral-500">Delivery Date Required</dt><dd className="font-medium text-neutral-900">{formatDate(po.dateRequired)}</dd></div>}
{po.piQuotationNo && <div><dt className="text-neutral-500">PI / Quotation No.</dt><dd className="font-medium text-neutral-900">{po.piQuotationNo}</dd></div>} {po.piQuotationNo && <div><dt className="text-neutral-500">PI / Quotation No.</dt><dd className="font-medium text-neutral-900">{po.piQuotationNo}</dd></div>}

View file

@ -42,8 +42,6 @@ const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
"manage_vessels_accounts", "manage_vessels_accounts",
"manage_products", "manage_products",
"manage_sites", "manage_sites",
"confirm_receipt",
"process_payment"
], ],
SUPERUSER: [ SUPERUSER: [
"create_po", "create_po",

View file

@ -100,13 +100,13 @@ const TRANSITIONS: Partial<Record<POStatus, TransitionMap>> = {
SENT_FOR_PAYMENT: { SENT_FOR_PAYMENT: {
mark_paid: { mark_paid: {
to: "PAID_DELIVERED", to: "PAID_DELIVERED",
allowedRoles: ["ACCOUNTS", "SUPERUSER", "MANAGER"], allowedRoles: ["ACCOUNTS", "SUPERUSER"],
requiresNote: false, requiresNote: false,
sideEffects: ["EMAIL_SUBMITTER", "EMAIL_MANAGER"], sideEffects: ["EMAIL_SUBMITTER", "EMAIL_MANAGER"],
}, },
mark_partial_payment: { mark_partial_payment: {
to: "PARTIALLY_PAID", to: "PARTIALLY_PAID",
allowedRoles: ["ACCOUNTS", "SUPERUSER", "MANAGER"], allowedRoles: ["ACCOUNTS", "SUPERUSER"],
requiresNote: false, requiresNote: false,
sideEffects: [], sideEffects: [],
}, },
@ -114,25 +114,25 @@ const TRANSITIONS: Partial<Record<POStatus, TransitionMap>> = {
PARTIALLY_PAID: { PARTIALLY_PAID: {
mark_paid: { mark_paid: {
to: "PAID_DELIVERED", to: "PAID_DELIVERED",
allowedRoles: ["ACCOUNTS", "SUPERUSER", "MANAGER"], allowedRoles: ["ACCOUNTS", "SUPERUSER"],
requiresNote: false, requiresNote: false,
sideEffects: [], sideEffects: [],
}, },
mark_partial_payment: { mark_partial_payment: {
to: "PARTIALLY_PAID", to: "PARTIALLY_PAID",
allowedRoles: ["ACCOUNTS", "SUPERUSER", "MANAGER"], allowedRoles: ["ACCOUNTS", "SUPERUSER"],
requiresNote: false, requiresNote: false,
sideEffects: [], sideEffects: [],
}, },
confirm_receipt: { confirm_receipt: {
to: "CLOSED", to: "CLOSED",
allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"], allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER"],
requiresNote: false, requiresNote: false,
sideEffects: [], sideEffects: [],
}, },
confirm_partial_receipt: { confirm_partial_receipt: {
to: "PARTIALLY_PAID", to: "PARTIALLY_PAID",
allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"], allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER"],
requiresNote: false, requiresNote: false,
sideEffects: [], sideEffects: [],
}, },
@ -140,13 +140,13 @@ const TRANSITIONS: Partial<Record<POStatus, TransitionMap>> = {
PAID_DELIVERED: { PAID_DELIVERED: {
confirm_receipt: { confirm_receipt: {
to: "CLOSED", to: "CLOSED",
allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"], allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER"],
requiresNote: false, requiresNote: false,
sideEffects: ["EMAIL_MANAGER", "EMAIL_ACCOUNTS"], sideEffects: ["EMAIL_MANAGER", "EMAIL_ACCOUNTS"],
}, },
confirm_partial_receipt: { confirm_partial_receipt: {
to: "PARTIALLY_CLOSED", to: "PARTIALLY_CLOSED",
allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"], allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER"],
requiresNote: false, requiresNote: false,
sideEffects: [], sideEffects: [],
}, },
@ -154,13 +154,13 @@ const TRANSITIONS: Partial<Record<POStatus, TransitionMap>> = {
PARTIALLY_CLOSED: { PARTIALLY_CLOSED: {
confirm_receipt: { confirm_receipt: {
to: "CLOSED", to: "CLOSED",
allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"], allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER"],
requiresNote: false, requiresNote: false,
sideEffects: ["EMAIL_MANAGER", "EMAIL_ACCOUNTS"], sideEffects: ["EMAIL_MANAGER", "EMAIL_ACCOUNTS"],
}, },
confirm_partial_receipt: { confirm_partial_receipt: {
to: "PARTIALLY_CLOSED", to: "PARTIALLY_CLOSED",
allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"], allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER"],
requiresNote: false, requiresNote: false,
sideEffects: [], sideEffects: [],
}, },

View file

@ -32,7 +32,6 @@ export const createPoSchema = z.object({
vesselId: z.string().min(1, "Cost Centre is required"), vesselId: z.string().min(1, "Cost Centre is required"),
accountId: z.string().min(1, "Accounting Code is required"), accountId: z.string().min(1, "Accounting Code is required"),
companyId: z.string().optional(), companyId: z.string().optional(),
poDate: z.string().optional(),
projectCode: z.string().optional(), projectCode: z.string().optional(),
dateRequired: z.string().optional(), dateRequired: z.string().optional(),
vendorId: z.string().optional(), vendorId: z.string().optional(),

View file

@ -1,2 +0,0 @@
-- Add optional submitter-entered PO date to PurchaseOrder
ALTER TABLE "PurchaseOrder" ADD COLUMN "poDate" TIMESTAMP(3);

View file

@ -263,7 +263,6 @@ model PurchaseOrder {
tcTransitInsurance String? tcTransitInsurance String?
tcPaymentTerms String? tcPaymentTerms String?
tcOthers String? tcOthers String?
poDate DateTime?
submittedAt DateTime? submittedAt DateTime?
approvedAt DateTime? approvedAt DateTime?
paidAt DateTime? paidAt DateTime?

View file

@ -201,135 +201,6 @@ describe("S-06 — provide vendor ID", () => {
}); });
}); });
// ── Inventory update on approval ─────────────────────────────────────────────
describe("inventory — updated at MGR_APPROVED, not at closure", () => {
it("increments site inventory for line items with productId on approval", async () => {
const site = await db.site.findFirstOrThrow({ where: { code: "BOM" } });
const product = await db.product.findFirstOrThrow({ where: { code: "LUBE-EP80W90" } });
const before = await db.itemInventory.findUnique({
where: { productId_siteId: { productId: product.id, siteId: site.id } },
});
const qtyBefore = Number(before?.quantity ?? 0);
const po = await db.purchaseOrder.create({
data: {
poNumber: `INVTEST-${Date.now()}`,
title: `${PREFIX}InvApproval`,
status: "MGR_REVIEW",
totalAmount: 1000,
currency: "INR",
vesselId,
accountId,
vendorId,
siteId: site.id,
submitterId: techId,
submittedAt: new Date(),
lineItems: {
create: [{
name: "Gear Oil 80W90",
quantity: 5,
unit: "L",
unitPrice: 182,
totalPrice: 910,
gstRate: 0.18,
sortOrder: 0,
productId: product.id,
}],
},
actions: { create: { actionType: "SUBMITTED", actorId: techId } },
},
});
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
const result = await approvePo({ poId: po.id });
expect(result).toEqual({ ok: true });
const after = await db.itemInventory.findUnique({
where: { productId_siteId: { productId: product.id, siteId: site.id } },
});
expect(Number(after?.quantity)).toBe(qtyBefore + 5);
});
it("skips inventory update for line items without a productId", async () => {
const site = await db.site.findFirstOrThrow({ where: { code: "BOM" } });
const countBefore = await db.itemInventory.count({ where: { siteId: site.id } });
const po = await db.purchaseOrder.create({
data: {
poNumber: `INVTEST-NOPROD-${Date.now()}`,
title: `${PREFIX}InvNoProduct`,
status: "MGR_REVIEW",
totalAmount: 500,
currency: "INR",
vesselId,
accountId,
vendorId,
siteId: site.id,
submitterId: techId,
submittedAt: new Date(),
lineItems: {
create: [{
name: "Ad-hoc supply",
quantity: 2,
unit: "pc",
unitPrice: 100,
totalPrice: 200,
gstRate: 0.18,
sortOrder: 0,
productId: null,
}],
},
actions: { create: { actionType: "SUBMITTED", actorId: techId } },
},
});
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
await approvePo({ poId: po.id });
const countAfter = await db.itemInventory.count({ where: { siteId: site.id } });
expect(countAfter).toBe(countBefore);
});
it("does not touch inventory for vessel-only POs (no siteId)", async () => {
const totalBefore = await db.itemInventory.count();
const po = await db.purchaseOrder.create({
data: {
poNumber: `INVTEST-VESSEL-${Date.now()}`,
title: `${PREFIX}InvVessel`,
status: "MGR_REVIEW",
totalAmount: 300,
currency: "INR",
vesselId,
accountId,
vendorId,
submitterId: techId,
submittedAt: new Date(),
lineItems: {
create: [{
name: "Vessel supply",
quantity: 3,
unit: "pc",
unitPrice: 50,
totalPrice: 150,
gstRate: 0.18,
sortOrder: 0,
}],
},
actions: { create: { actionType: "SUBMITTED", actorId: techId } },
},
});
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
await approvePo({ poId: po.id });
const totalAfter = await db.itemInventory.count();
expect(totalAfter).toBe(totalBefore);
});
});
// ── S-07: Edit and resubmit ────────────────────────────────────────────────── // ── S-07: Edit and resubmit ──────────────────────────────────────────────────
describe("S-07 — edit and resubmit after edits requested", () => { describe("S-07 — edit and resubmit after edits requested", () => {

View file

@ -1,197 +0,0 @@
/**
* Integration tests for the confirmReceipt server action.
* Covers: full receipt, partial receipt, upsert notes on repeated confirmation,
* and permission guards.
*/
import { vi, describe, it, expect, beforeAll, afterEach } from "vitest";
vi.mock("@/auth", () => ({ auth: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("@/lib/notifier", () => ({ notify: vi.fn() }));
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { createPo } from "@/app/(portal)/po/new/actions";
import { approvePo } from "@/app/(portal)/approvals/[id]/actions";
import { processPayment, markPaid } from "@/app/(portal)/payments/actions";
import { confirmReceipt } from "@/app/(portal)/po/[id]/receipt/actions";
import {
makeSession,
getSeedUser,
getSeedVessel,
getSeedAccount,
makePoForm,
deletePosByTitle,
} from "./helpers";
const PREFIX = "INTTEST_RECEIPT_";
const TODAY = new Date().toISOString().slice(0, 10);
let techId: string;
let managerId: string;
let accountsId: string;
let vesselId: string;
let accountId: string;
beforeAll(async () => {
const [tech, mgr, acct, vessel, account] = await Promise.all([
getSeedUser("tech@pelagia.local"),
getSeedUser("manager@pelagia.local"),
getSeedUser("accounts@pelagia.local"),
getSeedVessel("MV Sea Breeze"),
getSeedAccount("700202"),
]);
techId = tech.id;
managerId = mgr.id;
accountsId = acct.id;
vesselId = vessel.id;
accountId = account.id;
});
afterEach(async () => {
await deletePosByTitle(PREFIX);
});
/** Create a PO and drive it to PAID_DELIVERED (fully paid). */
async function createPaidPo(title: string): Promise<string> {
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const form = makePoForm({ title, vesselId, accountId, intent: "submit" });
const { id: poId } = (await createPo(form)) as { id: string };
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
await approvePo({ poId });
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
await processPayment({ poId });
await markPaid({ poId, paymentRef: "NEFT-TEST-RECEIPT", paymentDate: TODAY });
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
return poId;
}
describe("confirmReceipt — full delivery", () => {
it("transitions PAID_DELIVERED to CLOSED when all items delivered", async () => {
const poId = await createPaidPo(`${PREFIX}Full`);
const result = await confirmReceipt({ poId });
expect(result).toEqual({ ok: true, partial: false });
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
expect(po?.status).toBe("CLOSED");
expect(po?.closedAt).not.toBeNull();
});
it("records RECEIPT_CONFIRMED in audit log", async () => {
const poId = await createPaidPo(`${PREFIX}Audit`);
await confirmReceipt({ poId });
const action = await db.pOAction.findFirst({
where: { poId, actionType: "RECEIPT_CONFIRMED" },
});
expect(action).not.toBeNull();
});
it("saves delivery notes on the Receipt record", async () => {
const poId = await createPaidPo(`${PREFIX}Notes`);
await confirmReceipt({ poId, notes: "All items received in good condition." });
const receipt = await db.receipt.findUnique({ where: { poId } });
expect(receipt?.notes).toBe("All items received in good condition.");
});
});
describe("confirmReceipt — partial delivery", () => {
it("transitions PAID_DELIVERED to PARTIALLY_CLOSED when some items remain", async () => {
const poId = await createPaidPo(`${PREFIX}Partial`);
const lineItems = await db.pOLineItem.findMany({ where: { poId } });
const deliveries: Record<string, number> = {};
for (const li of lineItems) deliveries[li.id] = 0; // deliver nothing
const result = await confirmReceipt({ poId, deliveries });
// delivering 0 of everything → nothingDelivered guard is in the UI, not the action
// action still proceeds and computes PARTIALLY_CLOSED (paid but 0 delivered)
expect(result).toEqual({ ok: true, partial: true });
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
// fully paid but nothing delivered → PARTIALLY_CLOSED
expect(po?.status).toBe("PARTIALLY_CLOSED");
});
it("returns partial:true for a partial delivery", async () => {
const poId = await createPaidPo(`${PREFIX}PartialQty`);
const lineItems = await db.pOLineItem.findMany({ where: { poId } });
const half = Math.floor(Number(lineItems[0].quantity) / 2);
const deliveries = { [lineItems[0].id]: half };
const result = await confirmReceipt({ poId, deliveries });
expect(result).toEqual({ ok: true, partial: true });
});
});
describe("confirmReceipt — repeated notes upsert (regression for partial → full flow)", () => {
it("succeeds on second call with notes after first partial confirmation also had notes", async () => {
const poId = await createPaidPo(`${PREFIX}Upsert`);
const lineItems = await db.pOLineItem.findMany({ where: { poId } });
const half = Math.floor(Number(lineItems[0].quantity) / 2);
const remaining = Number(lineItems[0].quantity) - half;
// First confirmation: partial delivery with notes — creates Receipt row
const first = await confirmReceipt({
poId,
notes: "First batch received.",
deliveries: { [lineItems[0].id]: half },
});
expect(first).toEqual({ ok: true, partial: true });
// Second confirmation: deliver the rest, also with notes — must not throw
// (previously crashed due to unique constraint on Receipt.poId when using `create`)
const second = await confirmReceipt({
poId,
notes: "Remaining items received.",
deliveries: { [lineItems[0].id]: remaining },
});
expect(second).toEqual({ ok: true, partial: false });
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
expect(po?.status).toBe("CLOSED");
// Notes should reflect the latest confirmation
const receipt = await db.receipt.findUnique({ where: { poId } });
expect(receipt?.notes).toBe("Remaining items received.");
});
});
describe("confirmReceipt — permission guards", () => {
it("rejects non-submitter who is not SUPERUSER", async () => {
const poId = await createPaidPo(`${PREFIX}PermFail`);
const otherTech = await getSeedUser("tech@pelagia.local");
// Use a different user id to simulate a different submitter
const fakeSession = makeSession(managerId, "TECHNICAL");
vi.mocked(auth).mockResolvedValue(fakeSession);
const result = await confirmReceipt({ poId });
expect(result).toHaveProperty("error");
void otherTech; // suppress unused warning
});
it("rejects confirmation on a PO in wrong status", async () => {
// Create a PO that is still DRAFT (no payment yet)
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const form = makePoForm({ title: `${PREFIX}WrongStatus`, vesselId, accountId, intent: "draft" });
const { id: poId } = (await createPo(form)) as { id: string };
const result = await confirmReceipt({ poId });
expect(result).toHaveProperty("error");
});
it("returns error when PO does not exist", async () => {
const result = await confirmReceipt({ poId: "nonexistent-po-id" });
expect(result).toHaveProperty("error");
});
it("returns error when not authenticated", async () => {
vi.mocked(auth).mockResolvedValue(null);
const result = await confirmReceipt({ poId: "any-id" });
expect(result).toHaveProperty("error");
});
});

View file

@ -71,7 +71,7 @@ describe("lineItemSchema", () => {
const baseValidPo = { const baseValidPo = {
title: "Test Purchase Order", title: "Test Purchase Order",
vesselId: "vessel-123", costCentreRef: "v:vessel-123",
accountId: "account-456", accountId: "account-456",
lineItems: [{ name: "Item A", description: "Item A", quantity: "5", unit: "pc", unitPrice: "200" }], lineItems: [{ name: "Item A", description: "Item A", quantity: "5", unit: "pc", unitPrice: "200" }],
}; };
@ -97,11 +97,21 @@ describe("createPoSchema", () => {
expect(result.success).toBe(false); expect(result.success).toBe(false);
}); });
it("rejects missing vesselId", () => { it("rejects missing costCentreRef", () => {
const result = createPoSchema.safeParse({ ...baseValidPo, vesselId: "" }); const result = createPoSchema.safeParse({ ...baseValidPo, costCentreRef: "" });
expect(result.success).toBe(false); expect(result.success).toBe(false);
}); });
it("rejects invalid costCentreRef format", () => {
const result = createPoSchema.safeParse({ ...baseValidPo, costCentreRef: "invalid-id" });
expect(result.success).toBe(false);
});
it("accepts site costCentreRef (s: prefix)", () => {
const result = createPoSchema.safeParse({ ...baseValidPo, costCentreRef: "s:site-123" });
expect(result.success).toBe(true);
});
it("rejects empty lineItems array", () => { it("rejects empty lineItems array", () => {
const result = createPoSchema.safeParse({ ...baseValidPo, lineItems: [] }); const result = createPoSchema.safeParse({ ...baseValidPo, lineItems: [] });
expect(result.success).toBe(false); expect(result.success).toBe(false);
@ -142,27 +152,6 @@ describe("createPoSchema", () => {
}); });
expect(result.success).toBe(true); expect(result.success).toBe(true);
}); });
it("accepts poDate as an optional date string", () => {
const result = createPoSchema.safeParse({ ...baseValidPo, poDate: "2026-01-15" });
expect(result.success).toBe(true);
expect(result.success && result.data.poDate).toBe("2026-01-15");
});
it("accepts back-dated poDate", () => {
const result = createPoSchema.safeParse({ ...baseValidPo, poDate: "2025-06-01" });
expect(result.success).toBe(true);
});
it("accepts forward-dated poDate", () => {
const result = createPoSchema.safeParse({ ...baseValidPo, poDate: "2027-12-31" });
expect(result.success).toBe(true);
});
it("leaves poDate undefined when omitted", () => {
const result = createPoSchema.safeParse(baseValidPo);
expect(result.success && result.data.poDate).toBeUndefined();
});
}); });
// ── Constants ───────────────────────────────────────────────────────────────── // ── Constants ─────────────────────────────────────────────────────────────────

View file

@ -112,15 +112,15 @@
- mobile version for accounts [open-question] - mobile version for accounts [open-question]
26/05/2026 26/05/2026
- who assigns vendor?
- do technical/manning get to make po from excel?
- autogeneration and validation of id fields [DONE] - autogeneration and validation of id fields [DONE]
- pincode to geocode in bg. works on initial save - do technical/manning get to make po from excel?
- site edit does not work? location updates but does not save - site edit does not work? location updates but does not save
-- WAIT SAVING WORKS, THE BUTTON JUST SHOWS SAVING FOR SOME REASON -- WAIT SAVING WORKS, THE BUTTON JUST SHOWS SAVING FOR SOME REASON
- pincode to geocode in bg. works on initial save
- confirm button while deleting [DONE] - confirm button while deleting [DONE]
- vessel edit does not save? - vessel edit does not save?
- 0 percent gst does not work [DONE] - 0 percent gst does not work [DONE]
- who assigns vendor?
- email notification take you to po [DONE] - email notification take you to po [DONE]
- po word appears twice in subject [DONE] - po word appears twice in subject [DONE]
- notification overflows on phone for manager [DONE] - notification overflows on phone for manager [DONE]
@ -136,31 +136,3 @@
- partial payment / advance payment for accounts [DONE] - partial payment / advance payment for accounts [DONE]
- please confirm receipt - only for submitter not for manager [DONE] - please confirm receipt - only for submitter not for manager [DONE]
- rename My Purchase Orders to Closed Purchase Orders [DONE] - rename My Purchase Orders to Closed Purchase Orders [DONE]
30/05/26
- add a string code for company
- imported po should not need to be approved. it can directly be submitted and will show up in closed state in history.
- vendors from imported po should be auto added
- items from imported PO should be auto added
- hide item inventory features under an environment flag. only keep vendor and item list for pos(including cart) outside the flag.
- po number should be in the format - Company_code/Cost_centre/PO_ID/Financial_Year. Start PO_ID from 200 - 201,202, etc this number should also show on mails and on the exported PO
- based on the above format - extract company code, cost centre, po id from the imported pos
10/06:
- Allow the submitter to select a PO date. This should be picked up as the PO date and can be back/forward dated. Optional field - if not selected temporarily use creation date.
- Once approved, PO date should show date of approval, not creation. Also ensure the same is reflected in exported PO.
- For manager, closed purchase orders should show all the POs that are closed.
- For submitter, closed Purchase Order is also showing approved POs. It should only show Closed POs
- Items should get added to inventory as soon as PO is approved. Do not wait for Closed
- Line item Optional description needs to be shown in exported POs
- allow adding attachments at the delivery confirmation screen
- on po details screen show all attachments - submitted, payment and delivery. currently attachments not visible
- work order terms and conditions
- delivery receipt when confirming
- On manager screen - total approved this month is not updating/showing 0
- For Accounts User, have a similar dashboard card for Payments Completed this Month.
- add a email to vendor option once po is approved and full/partial payment is done that exports the PO as pdf and opens it in outlook with vendor email
- revive the reports feature - make it a section on the sidebar. implement the mockup
open questions -
- what granularity in the accounting code(heading, subheading, etc.)

View file

@ -7,16 +7,12 @@ running in production:
Portal header (bug icon) [App/components/layout/report-issue-button.tsx] Portal header (bug icon) [App/components/layout/report-issue-button.tsx]
│ server action → Forgejo API │ server action → Forgejo API
Forgejo issue (label: portal) [git.pelagiamarine.com/shad0w/pelagia-portal] Forgejo issue (labels: portal, claude-queue) [git.pelagiamarine.com/shad0w/pelagia-portal]
│ polled every 10 min by Windows Scheduled Task "PelagiaClaudeIssueWatcher" │ polled every 10 min by Windows Scheduled Task "PelagiaClaudeIssueWatcher"
TRIAGE (watcher phase 1) [dev PC, headless Claude Code, analysis only] claude-issue-watcher.ps1 (this folder) [dev PC, runs headless Claude Code]
│ Claude reads the issue + repo, posts a requirements-breakdown comment, │ Claude implements + verifies fix in C:\...\src\pelagia-autofix
│ and routes it: adds `claude-queue` (auto-fixable) or `interactive` (human) │ watcher pushes branch claude/issue-N and opens a PR (label: claude-pr)
FIX (watcher phase 2, only for claude-queue) [headless Claude Code in C:\...\src\pelagia-autofix]
│ Claude implements + verifies fix; 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 Human review: merge the PR, then create a release tag vX.Y.Z
│ tag push triggers .forgejo/workflows/deploy.yml │ tag push triggers .forgejo/workflows/deploy.yml
@ -27,75 +23,24 @@ forgejo-runner on pms1 (pm2: forgejo-runner, label "host")
pm2 restart ppms → live at pms.pelagiamarine.com pm2 restart ppms → live at pms.pelagiamarine.com
``` ```
`interactive`-routed issues stop after triage for a human to pick up (run with
Claude in a steered session). The triage breakdown comment is plain (no bot
marker) so, for `claude-queue` issues, the fix stage reads it back as refined
requirements.
## Components ## Components
| Piece | Where | Notes | | Piece | Where | Notes |
|---|---|---| |---|---|---|
| Report Issue button | `App/components/layout/report-issue-button.tsx` + `report-issue-actions.ts` | Any signed-in user; files issue with only the `portal` label (triage routes it) | | 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 (active) | `automation/claude-issue-watcher.sh` on pms1 | Bash port; runs 24/7 via cron. Config + logs under `~/issue-watcher/` |
| Issue watcher (Windows, disabled) | `automation/claude-issue-watcher.ps1` | PowerShell original. `PelagiaClaudeIssueWatcher` task is **disabled** (pms1 is the sole worker; two pollers would race) |
| Forgejo helper | `App/lib/forgejo.ts` | Needs `FORGEJO_URL`, `FORGEJO_REPO`, `FORGEJO_TOKEN` env (token scope: `write:issue`) | | 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 | | 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` | | Runner | pms1 `~/forgejo-runner`, pm2 process `forgejo-runner` | Registered as `pms1-host` with labels `host`, `docker` |
## Where the watcher runs (pms1)
The watcher runs on **pms1** under cron (every 10 min), polling Forgejo over the
local loopback (`http://127.0.0.1:3001`).
- Script: `~/issue-watcher/claude-issue-watcher.sh` (source: `automation/claude-issue-watcher.sh`)
- Config: `~/issue-watcher/watcher.config.json` (gitignored; holds the token + `claudeExe` = the nvm `claude` path)
- Work clone: `~/pelagia-autofix` (separate from the deployed `~/pms`)
- Logs: `~/issue-watcher/logs/` (`watcher-<date>.log`, per-issue `claude-*.log`, `cron.log`)
- Crontab: `*/10 * * * * PATH=<nvm bin>:... ~/issue-watcher/claude-issue-watcher.sh >> ~/issue-watcher/logs/cron.log 2>&1`
**Auth:** Claude Code must be signed in on pms1 (`ssh` in, run `claude`, complete
the login → writes `~/.claude/.credentials.json`). The watcher has a preflight that
no-ops until those credentials exist, so cron can be enabled before sign-in and
activates automatically once signed in. (An `ANTHROPIC_API_KEY` env var also satisfies it.)
The Windows variant (`.ps1` + `register-watcher-task.ps1`) is the portable fallback;
re-enable its task only if pms1 is unavailable, and disable one before enabling the other.
## Test database (for autofix verification)
So the fix stage can verify against realistic data without touching production:
- **`pelagia_test`** — a PostgreSQL database on pms1, owned by `pelagia_user`, that is
a **daily mirror of production** (`pelagia`). Created once as superuser; refreshed by
`automation/refresh-test-db.sh` via cron at **03:30** (`pg_dump pelagia | psql pelagia_test`).
- The autofix clone's `~/pelagia-autofix/App/.env` points `DATABASE_URL` at `pelagia_test`
and runs in **safe dev mode** — no Resend/SSO secrets, so email is console-logged and
storage is local. `NEXTAUTH_URL`/`PORT` are set to **3100** (production app is on 3000).
- The fix prompt tells Claude it may run integration tests against this DB
(`set -a; . ./.env; set +a; pnpm test:integration`) and may start a dev server on
**port 3100 only**, stopping it by port (`fuser -k 3100/tcp`) — never a broad `pkill next`,
which would take down production (it also runs a `next-server`).
Because the test DB is refreshed daily, anything the autofix writes to it (test data,
schema experiments) is disposable. Schema-migration issues are routed to `interactive`
by triage, so the unattended fixer should not be altering the schema anyway.
## Issue label lifecycle ## Issue label lifecycle
``` `claude-queue``claude-working``claude-pr` (PR opened, awaiting review)
portal ──(triage)──▶ claude-queue ─▶ claude-working ─▶ claude-pr | claude-failed or `claude-failed` (no verified fix; reason posted as an issue comment).
└────▶ interactive (stops here — handle with Claude interactively)
```
- A `portal` issue with no decision label yet is triaged once (`maxTriagePerRun` To retry a failed issue, re-add the `claude-queue` label.
per run). Triage adds `claude-queue` or `interactive` and posts a breakdown. To queue any manually-created issue for Claude, just add the `claude-queue` label.
- `claude-queue``claude-working``claude-pr` (PR opened) or `claude-failed`.
- To retry a failed issue, re-add `claude-queue`.
- To queue any manually-created issue for Claude (skipping triage), add
`claude-queue` directly. To force human handling, add `interactive`.
- Triage is skipped for issues that already carry any decision label.
## Releasing ## Releasing
@ -122,17 +67,3 @@ The runner deploys the tag and restarts the app. Watch progress under
`docker exec -u 1000 forgejo forgejo admin user generate-access-token ...`. `docker exec -u 1000 forgejo forgejo admin user generate-access-token ...`.
- Server-side env for the button lives in `~/pms/App/.env` on pms1 - 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). (`FORGEJO_URL=http://127.0.0.1:3001` so it does not depend on the tunnel).
- **Known Forgejo 10 bug:** clicking *Update branch* on a PR (or pushing to its
head branch) can make the page show "This pull request is broken due to
missing fork information" even though the PR is fine (API still reports
`mergeable: true`). Fix: close and reopen the PR — via the UI, or:
```powershell
$h = @{ Authorization = "token <claude-watcher token>" }
Invoke-RestMethod -Method Patch -Headers $h -ContentType application/json `
-Uri https://git.pelagiamarine.com/api/v1/repos/shad0w/pelagia-portal/pulls/<N> -Body '{"state":"closed"}'
Invoke-RestMethod -Method Patch -Headers $h -ContentType application/json `
-Uri https://git.pelagiamarine.com/api/v1/repos/shad0w/pelagia-portal/pulls/<N> -Body '{"state":"open"}'
```
Fixed upstream in newer Gitea/Forgejo — resolves itself if Forgejo is upgraded past v10.

View file

@ -1,14 +1,9 @@
# Claude issue watcher for the Pelagia portal. Two phases per run: # Claude issue watcher for the Pelagia portal.
# #
# 1. TRIAGE -- find open `portal` issues with no decision label yet. Claude # Polls Forgejo for open issues labelled `claude-queue`, runs headless
# reads each (analysis only, no code changes), posts a requirements # Claude Code on a dedicated clone to implement a fix, pushes a
# breakdown comment, and routes it to `claude-queue` or `interactive`. # `claude/issue-N` branch, and opens a PR that closes the issue.
# 2. FIX -- find open `claude-queue` issues. Claude implements a fix on a # Label lifecycle: claude-queue -> claude-working -> claude-pr | claude-failed
# dedicated clone, pushes a `claude/issue-N` branch, and opens a PR.
#
# Label lifecycle:
# portal -> (triage) -> claude-queue | interactive
# claude-queue -> claude-working -> claude-pr | claude-failed
# #
# Intended to run unattended via Windows Task Scheduler (see # Intended to run unattended via Windows Task Scheduler (see
# register-watcher-task.ps1). Logs to automation/logs/. # register-watcher-task.ps1). Logs to automation/logs/.
@ -59,85 +54,25 @@ $headers = @{ Authorization = "token $($cfg.token)" }
function Api([string]$Method, [string]$Path, $Body = $null) { function Api([string]$Method, [string]$Path, $Body = $null) {
$params = @{ Method = $Method; Uri = "$apiBase$Path"; Headers = $headers } $params = @{ Method = $Method; Uri = "$apiBase$Path"; Headers = $headers }
if ($null -ne $Body) { if ($null -ne $Body) {
# Send UTF-8 bytes, not a string. PS 5.1's Invoke-RestMethod encodes a $params.Body = ($Body | ConvertTo-Json -Depth 5)
# string body in a non-UTF-8 charset, which mangles any non-ASCII (e.g.
# an em-dash in Claude's triage breakdown) and makes Forgejo reject the JSON.
$json = $Body | ConvertTo-Json -Depth 5
$params.Body = [System.Text.Encoding]::UTF8.GetBytes($json)
$params.ContentType = 'application/json' $params.ContentType = 'application/json'
} }
Invoke-RestMethod @params Invoke-RestMethod @params
} }
# Resolve label names to their numeric ids (always an array).
function Resolve-LabelIds([string[]]$Names) {
$allLabels = Api GET "/repos/$($cfg.repo)/labels?limit=50"
@(@($allLabels) | Where-Object { $Names -contains $_.name } | ForEach-Object { [int]$_.id })
}
# PUT/POST a labels body. Build the JSON by hand: PS 5.1 ConvertTo-Json unwraps a
# single-element array to a scalar, which Forgejo rejects ("cannot unmarshal number
# into ... []interface {}"). [int[]] also coerces a lone scalar id back to an array.
function Send-IssueLabels([int]$IssueNumber, [string]$Method, [int[]]$Ids) {
$body = '{"labels":[' + (@($Ids) -join ',') + ']}'
$bytes = [System.Text.Encoding]::UTF8.GetBytes($body)
Invoke-RestMethod -Method $Method -Uri "$apiBase/repos/$($cfg.repo)/issues/$IssueNumber/labels" -Headers $headers -Body $bytes -ContentType 'application/json' | Out-Null
}
# Additively attach labels (Forgejo POST does not replace existing ones). Safe:
# it can never clear labels, unlike the replace-the-whole-set PUT below.
function Add-IssueLabels([int]$IssueNumber, [string[]]$Add) {
$ids = Resolve-LabelIds $Add
if (@($ids).Count -eq 0) { Log "Add-IssueLabels: no ids resolved for [$($Add -join ',')] on #$IssueNumber"; return }
Send-IssueLabels $IssueNumber 'POST' $ids
}
# Replace the issue's label set (used for fix-phase transitions that remove labels).
function Set-IssueLabels([int]$IssueNumber, [string[]]$Remove, [string[]]$Add) { 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" $issue = Api GET "/repos/$($cfg.repo)/issues/$IssueNumber"
$current = @($issue.labels | ForEach-Object { $_.name }) $current = @($issue.labels | ForEach-Object { $_.name })
$wanted = @(($current | Where-Object { $Remove -notcontains $_ }) + $Add | Select-Object -Unique) $wanted = @(($current | Where-Object { $Remove -notcontains $_ }) + $Add | Select-Object -Unique)
$ids = Resolve-LabelIds $wanted $ids = @($allLabels | Where-Object { $wanted -contains $_.name } | ForEach-Object { $_.id })
# Guard: never wipe labels because an id match unexpectedly came back empty. Api PUT "/repos/$($cfg.repo)/issues/$IssueNumber/labels" @{ labels = $ids } | Out-Null
if ($wanted.Count -gt 0 -and @($ids).Count -eq 0) {
Log "Set-IssueLabels: refusing to clear all labels on #$IssueNumber (wanted [$($wanted -join ',')] resolved to no ids)"
return
}
Send-IssueLabels $IssueNumber 'PUT' $ids
} }
function Add-IssueComment([int]$IssueNumber, [string]$Text) { function Add-IssueComment([int]$IssueNumber, [string]$Text) {
Api POST "/repos/$($cfg.repo)/issues/$IssueNumber/comments" @{ body = $Text } | Out-Null 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 # Run git without tripping ErrorActionPreference=Stop on stderr output
# (native stderr lines become ErrorRecords in PS 5.1). Returns exit code. # (native stderr lines become ErrorRecords in PS 5.1). Returns exit code.
function Run-Git([string[]]$GitArgs) { function Run-Git([string[]]$GitArgs) {
@ -151,41 +86,16 @@ function Run-Git([string[]]$GitArgs) {
} }
} }
# List open issues carrying a given label. Capture to a variable before filtering: # ── Find queued issues ──────────────────────────────────────────────
# piping the Api function's array output straight into Where-Object does NOT unroll $queued = @(Api GET "/repos/$($cfg.repo)/issues?state=open&labels=claude-queue&type=issues&limit=20" | Where-Object { $_ -and $_.number })
# in PS 5.1 -- it collapses every issue into one object whose props are arrays. if ($queued.Count -eq 0) {
function Get-OpenIssuesByLabel([string]$Label) { Log "No queued issues."
$resp = Api GET "/repos/$($cfg.repo)/issues?state=open&labels=$Label&type=issues&limit=50" exit 0
@(@($resp) | Where-Object { $_ -and $_.number })
} }
$queued = @($queued | Sort-Object number | Select-Object -First $cfg.maxIssuesPerRun)
Log "Found $($queued.Count) queued issue(s): $(($queued | ForEach-Object { '#' + $_.number }) -join ', ')"
# True if the issue object carries any of the given label names. # ── Prepare the dedicated work clone ────────────────────────────────
function Test-IssueHasLabel($Issue, [string[]]$Names) {
$have = @($Issue.labels | ForEach-Object { $_.name })
foreach ($x in $Names) { if ($have -contains $x) { return $true } }
return $false
}
# Run headless Claude on a prompt file inside the clone; output -> $LogPath. Returns exit code.
function Invoke-Claude([string]$PromptFile, [string]$LogPath, [int]$MaxTurns) {
# 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 $MaxTurns --output-format text < `"$PromptFile`" > `"$LogPath`" 2>&1"
return $LASTEXITCODE
} finally {
Pop-Location
}
}
# Reset the work clone to a clean checkout of the base branch (discards stray files).
function Reset-CloneToBase {
Run-Git @('-C', $cfg.workDir, 'fetch', 'origin') | Out-Null
Run-Git @('-C', $cfg.workDir, 'checkout', '-f', "origin/$($cfg.baseBranch)") | Out-Null
Run-Git @('-C', $cfg.workDir, 'clean', '-fd') | Out-Null
}
# ── Prepare the dedicated work clone (needed by both phases) ─────────
$repoHost = ([Uri]$cfg.forgejoUrl).Host $repoHost = ([Uri]$cfg.forgejoUrl).Host
$owner = $cfg.repo.Split('/')[0] $owner = $cfg.repo.Split('/')[0]
$cloneUrl = "https://$($owner):$($cfg.token)@$repoHost/$($cfg.repo).git" $cloneUrl = "https://$($owner):$($cfg.token)@$repoHost/$($cfg.repo).git"
@ -197,135 +107,28 @@ if (-not (Test-Path (Join-Path $cfg.workDir '.git'))) {
Run-Git @('-C', $cfg.workDir, 'config', 'user.email', 'claude-autofix@pelagiamarine.com') | Out-Null Run-Git @('-C', $cfg.workDir, 'config', 'user.email', 'claude-autofix@pelagiamarine.com') | Out-Null
} }
$DecisionLabels = @('claude-queue', 'interactive', 'claude-working', 'claude-pr', 'claude-failed')
$maxTriage = if ($cfg.maxTriagePerRun) { [int]$cfg.maxTriagePerRun } else { 3 }
$triageTurns = if ($cfg.triageMaxTurns) { [int]$cfg.triageMaxTurns } else { 80 }
# ── Phase 1: triage new portal issues ───────────────────────────────
$portalIssues = Get-OpenIssuesByLabel 'portal'
$toTriage = @($portalIssues |
Where-Object { -not (Test-IssueHasLabel $_ $DecisionLabels) } |
Sort-Object { [int]$_.number } |
Select-Object -First $maxTriage)
Log "Triage: $($toTriage.Count) portal issue(s) awaiting triage"
foreach ($issue in $toTriage) {
$n = $issue.number
Log "-- Triaging #${n}: $($issue.title)"
Reset-CloneToBase
$commentsBlock = Get-IssueCommentsBlock $n
# Two plain output files instead of one JSON blob: a JSON object with a big
# embedded markdown string is fragile (Claude often emits literal newlines,
# which PS 5.1 ConvertFrom-Json rejects). A bare label file + a raw markdown
# file need no escaping and parse trivially.
$labelFile = Join-Path $cfg.workDir 'CLAUDE_TRIAGE_LABEL.txt'
$breakdownFile = Join-Path $cfg.workDir 'CLAUDE_TRIAGE.md'
foreach ($f in $labelFile, $breakdownFile) { if (Test-Path $f) { Remove-Item $f -Force } }
$tprompt = @"
You are TRIAGING issue #$n of the Pelagia Portal (PPMS), a Next.js 15 purchase-order management
system for a maritime company. The web app is in App/ -- read App/CLAUDE.md and explore the relevant
code to judge feasibility. This is ANALYSIS ONLY: do NOT modify any existing file, do NOT run builds
or tests, do NOT commit. You only create the two output files described below.
## Issue #${n}: $($issue.title)
$($issue.body)
$commentsBlock
## Your job
1. Interpret the request and break it into concrete technical action item(s), the way a developer
would in review -- note the files/areas likely involved and any open questions.
2. Decide whether an UNATTENDED automated coding run can safely and verifiably implement it:
- "claude-queue" = localized change, clear acceptance, verifiable by type-check / lint / unit
tests, and NOT touching DB migrations, auth/permissions, payments/money, external live systems
(e.g. the GST website), or large multi-file features.
- "interactive" = needs human steering: ambiguous or underspecified, needs business content or a
design decision, a schema migration, permissions/payments changes, an external dependency, or a
large feature needing visual verification.
3. Write TWO files in the repository root, nothing else:
- CLAUDE_TRIAGE_LABEL.txt -- a single line containing EXACTLY one word: claude-queue OR interactive
- CLAUDE_TRIAGE.md -- your requirements breakdown as markdown: action items, files/areas involved,
open questions, and a final one-line "Routing rationale: ..." explaining the choice.
"@
$tpromptFile = Join-Path $env:TEMP "claude-triage-$n-prompt.txt"
$tprompt | Out-File -FilePath $tpromptFile -Encoding utf8
$tlog = Join-Path $logDir "claude-triage-$n-$(Get-Date -Format 'yyyyMMdd-HHmmss').log"
Log "Running Claude triage on #$n (log: $tlog)"
$rc = Invoke-Claude $tpromptFile $tlog $triageTurns
Log "Claude triage exited with code $rc for #$n"
$label = $null
if (Test-Path $labelFile) {
$raw = Get-Content $labelFile -Raw -Encoding UTF8
if ($raw -match 'interactive') { $label = 'interactive' }
elseif ($raw -match 'claude-queue') { $label = 'claude-queue' }
}
# Read as UTF-8 so non-ASCII in the breakdown (em-dash etc.) is not mojibaked.
$breakdown = if (Test-Path $breakdownFile) { (Get-Content $breakdownFile -Raw -Encoding UTF8).Trim() } else { "" }
Reset-CloneToBase # discard the triage output files and any stray edits
if (-not $label) {
Log "Triage for #$n produced no valid decision; leaving for a human"
Add-IssueComment $n "$BotMarker`n[Claude triage] Could not auto-triage this issue. A human should review it and add either ``claude-queue`` or ``interactive``."
continue
}
# Label FIRST: it marks the issue as triaged, so a failure while posting the
# comment below cannot cause a re-triage next run that double-posts the breakdown.
Add-IssueLabels $n @($label)
# NB: deliberately NO bot marker on the breakdown -- it is genuine refined
# requirements and SHOULD be fed to the fix stage (Get-IssueCommentsBlock
# includes it). The routing line is bot chatter but harmless as fix context.
$note = if ($breakdown) { $breakdown } else { "(no breakdown produced)" }
Add-IssueComment $n "## Claude triage`n`n$note`n`n**Routing:** ``$label``"
Log "Triaged #$n -> $label"
}
# ── Phase 2: fix queued issues ──────────────────────────────────────
# Assign to a variable before piping (see Get-OpenIssuesByLabel note).
$queuedAll = Get-OpenIssuesByLabel 'claude-queue'
$queued = @($queuedAll | Sort-Object { [int]$_.number } | Select-Object -First ([int]$cfg.maxIssuesPerRun))
if ($queued.Count -eq 0) {
Log "No queued issues to fix."
} else {
Log "Found $($queued.Count) queued issue(s) to fix: $(($queued | ForEach-Object { '#' + $_.number }) -join ', ')"
}
foreach ($issue in $queued) { foreach ($issue in $queued) {
$n = $issue.number $n = $issue.number
$branch = "$($cfg.branchPrefix)$n" $branch = "$($cfg.branchPrefix)$n"
Log "-- Working issue #${n}: $($issue.title)" Log "-- Working issue #${n}: $($issue.title)"
Set-IssueLabels $n -Remove @('claude-queue', 'claude-failed') -Add @('claude-working') 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``." Add-IssueComment $n "🤖 Claude has started working on this issue on branch ``$branch``."
Run-Git @('-C', $cfg.workDir, 'fetch', 'origin') | Out-Null Run-Git @('-C', $cfg.workDir, 'fetch', 'origin') | Out-Null
if ((Run-Git @('-C', $cfg.workDir, 'checkout', '-B', $branch, "origin/$($cfg.baseBranch)")) -ne 0) { if ((Run-Git @('-C', $cfg.workDir, 'checkout', '-B', $branch, "origin/$($cfg.baseBranch)")) -ne 0) {
Log "checkout failed for #$n"; continue 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 = @" $prompt = @"
You are working autonomously on issue #$n of the Pelagia Portal (PPMS), a Next.js 15 purchase-order 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 management system for a maritime company. The web app lives in the App/ directory read App/CLAUDE.md
first for architecture, conventions, and commands. first for architecture, conventions, and commands.
## Issue #${n}: $($issue.title) ## Issue #${n}: $($issue.title)
$($issue.body) $($issue.body)
$commentsBlock
## Your job ## Your job
1. Investigate the issue and implement a focused, minimal fix in this repository. 1. Investigate the issue and implement a focused, minimal fix in this repository.
@ -346,8 +149,14 @@ explanation to a file named CLAUDE_RESULT.md in the repository root (it will be
$claudeLog = Join-Path $logDir "claude-issue-$n-$(Get-Date -Format 'yyyyMMdd-HHmmss').log" $claudeLog = Join-Path $logDir "claude-issue-$n-$(Get-Date -Format 'yyyyMMdd-HHmmss').log"
Log "Running Claude Code on #$n (log: $claudeLog)" Log "Running Claude Code on #$n (log: $claudeLog)"
$rc = Invoke-Claude $promptFile $claudeLog ([int]$cfg.claudeMaxTurns) # cmd handles the redirects so native stderr never becomes a PS ErrorRecord
Log "Claude exited with code $rc for #$n" 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 # Relay an abort explanation if Claude declined the fix
$resultFile = Join-Path $cfg.workDir 'CLAUDE_RESULT.md' $resultFile = Join-Path $cfg.workDir 'CLAUDE_RESULT.md'
@ -373,13 +182,13 @@ explanation to a file named CLAUDE_RESULT.md in the repository root (it will be
body = "Automated fix by Claude Code for #$n.`n`nCloses #$n`n`nReview, merge, then create a release tag (vX.Y.Z) to deploy." 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') 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." 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" Log "PR #$($pr.number) opened for issue #$n"
} else { } else {
Log "No commits produced for #$n; marking claude-failed" Log "No commits produced for #$n; marking claude-failed"
Set-IssueLabels $n -Remove @('claude-working') -Add @('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" } $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" Add-IssueComment $n "🤖 Automated fix attempt did not produce a change.`n`n$reason"
} }
} }

View file

@ -1,337 +0,0 @@
#!/usr/bin/env bash
# Claude issue watcher -- Linux port (runs on pms1 via cron). Two phases per run:
#
# 1. TRIAGE -- find open `portal` issues with no decision label yet. Claude
# reads each (analysis only), writes a label + a markdown breakdown, the
# watcher posts the breakdown as a comment and adds `claude-queue` or
# `interactive`.
# 2. FIX -- find open `claude-queue` issues. Claude implements a fix on a
# dedicated clone, pushes `claude/issue-N`, and opens a PR.
#
# Label lifecycle:
# portal -> (triage) -> claude-queue | interactive
# claude-queue -> claude-working -> claude-pr | claude-failed
#
# Config: watcher.config.json next to this script (or pass a path as $1).
# Mirrors the Windows claude-issue-watcher.ps1; see automation/README.md.
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG="${1:-$SCRIPT_DIR/watcher.config.json}"
[ -f "$CONFIG" ] || { echo "Config not found: $CONFIG (copy watcher.config.example.json and fill in the token)"; exit 1; }
cfg() { jq -r "$1" "$CONFIG"; }
FORGEJO_URL=$(cfg .forgejoUrl)
REPO=$(cfg .repo)
TOKEN=$(cfg .token)
WORKDIR=$(cfg .workDir)
BASE_BRANCH=$(cfg .baseBranch)
BRANCH_PREFIX=$(cfg .branchPrefix)
MAX_FIX=$(cfg '.maxIssuesPerRun // 1')
MAX_TRIAGE=$(cfg '.maxTriagePerRun // 3')
CLAUDE=$(cfg .claudeExe)
FIX_TURNS=$(cfg '.claudeMaxTurns // 150')
TRIAGE_TURNS=$(cfg '.triageMaxTurns // 80')
API="$FORGEJO_URL/api/v1"
LOG_DIR="$SCRIPT_DIR/logs"
mkdir -p "$LOG_DIR"
LOG_FILE="$LOG_DIR/watcher-$(date +%F).log"
log() { echo "$(date +%T) $*" | tee -a "$LOG_FILE"; }
BOT_MARKER='<!-- ppms-bot -->'
# Bot status comments are excluded from the context fed back to Claude. New ones
# carry the marker; legacy ones are matched by stable phrases.
BOT_PATTERN='ppms-bot|has started working on this issue|Claude opened PR \[#|Automated fix attempt did not produce'
# --- single-instance lock ---
exec 9>"$SCRIPT_DIR/.watcher.lock"
if ! flock -n 9; then log "Another watcher run is active; exiting."; exit 0; fi
# --- preflight: idle until Claude Code is authenticated on this host ---
# Lets cron be enabled before sign-in: the watcher no-ops until creds appear,
# then activates on its own. Avoids wrongly marking issues claude-failed.
if [ ! -f "$HOME/.claude/.credentials.json" ] && [ -z "${ANTHROPIC_API_KEY:-}" ]; then
log "Claude Code not authenticated yet (no ~/.claude/.credentials.json or ANTHROPIC_API_KEY); skipping."
exit 0
fi
# --- Forgejo API helpers (curl + jq; UTF-8 and JSON arrays are handled natively) ---
api() { # METHOD PATH [JSON_BODY]
local method=$1 path=$2 body=${3:-}
if [ -n "$body" ]; then
curl -fsS -X "$method" "$API$path" -H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" --data "$body"
else
curl -fsS -X "$method" "$API$path" -H "Authorization: token $TOKEN"
fi
}
issues_by_label() { api GET "/repos/$REPO/issues?state=open&labels=$1&type=issues&limit=50"; }
add_comment() { # NUMBER TEXT
api POST "/repos/$REPO/issues/$1/comments" "$(jq -nc --arg b "$2" '{body:$b}')" >/dev/null
}
# Build {"labels":[ids]} for the given label names from the live label list.
label_ids_body() { # NAME...
local names; names=$(printf '%s\n' "$@" | jq -R . | jq -sc .)
issues_labels_cache=${issues_labels_cache:-$(api GET "/repos/$REPO/labels?limit=50")}
printf '%s' "$issues_labels_cache" | jq -c --argjson want "$names" '{labels: [ .[] | select(.name as $n | $want|index($n)) | .id ]}'
}
# Additive: never clears existing labels.
add_labels() { # NUMBER NAME...
local num=$1; shift
local body; body=$(label_ids_body "$@")
if [ "$(printf '%s' "$body" | jq '.labels|length')" -eq 0 ]; then
log "add_labels: no ids resolved for [$*] on #$num"; return
fi
api POST "/repos/$REPO/issues/$num/labels" "$body" >/dev/null
}
# Replace the label set: (current - remove) + add. Guards against wiping.
set_labels() { # NUMBER "remove names" "add names"
local num=$1 remove="$2" add="$3"
local cur kept wanted body n wn
cur=$(api GET "/repos/$REPO/issues/$num" | jq -r '.labels[].name')
if [ -n "${remove// /}" ]; then
kept=$(printf '%s\n' $cur | grep -vxF "$(printf '%s\n' $remove)")
else
kept=$cur
fi
wanted=$(printf '%s\n' $kept $add | grep -v '^$' | sort -u)
body=$(label_ids_body $wanted)
n=$(printf '%s' "$body" | jq '.labels|length')
wn=$(printf '%s\n' $wanted | grep -vc '^$')
if [ "$wn" -gt 0 ] && [ "$n" -eq 0 ]; then
log "set_labels: refusing to clear all labels on #$num"; return
fi
api PUT "/repos/$REPO/issues/$num/labels" "$body" >/dev/null
}
# Human comments as a markdown block (bot status comments excluded). Empty if none.
comments_block() { # NUMBER
local human
human=$(api GET "/repos/$REPO/issues/$1/comments?limit=50" \
| jq -r --arg pat "$BOT_PATTERN" '[.[] | select(.body != null) | select(.body | test($pat) | not)]')
[ "$(printf '%s' "$human" | jq 'length')" -eq 0 ] && return
printf '## Comments on the issue (read these -- they refine the scope/repro)\n\n'
printf '%s' "$human" | jq -r '.[] | "**\(.user.login) commented:**\n\(.body)\n"'
}
run_claude() { # PROMPT_FILE LOG_FILE MAX_TURNS
( cd "$WORKDIR" && "$CLAUDE" -p --dangerously-skip-permissions \
--max-turns "$3" --output-format text < "$1" > "$2" 2>&1 )
}
reset_clone() {
git -C "$WORKDIR" fetch origin -q
git -C "$WORKDIR" checkout -f "origin/$BASE_BRANCH" -q 2>/dev/null
git -C "$WORKDIR" clean -fdq
}
# --- prepare the dedicated work clone (needed by both phases) ---
host_no_scheme=$(printf '%s' "$FORGEJO_URL" | sed 's#^https\?://##')
owner=${REPO%%/*}
CLONE_URL="http://${owner}:${TOKEN}@${host_no_scheme}/${REPO}.git"
[ "${FORGEJO_URL#https}" != "$FORGEJO_URL" ] && CLONE_URL="https://${owner}:${TOKEN}@${host_no_scheme}/${REPO}.git"
if [ ! -d "$WORKDIR/.git" ]; then
log "Cloning $REPO into $WORKDIR"
if ! git clone -q "$CLONE_URL" "$WORKDIR"; then log "git clone failed"; exit 1; fi
git -C "$WORKDIR" config user.name "Claude (auto-fix)"
git -C "$WORKDIR" config user.email "claude-autofix@pelagiamarine.com"
fi
DECISION_LABELS="claude-queue interactive claude-working claude-pr claude-failed"
# =====================================================================
# Phase 1: triage new portal issues
# =====================================================================
dl_json=$(printf '%s\n' $DECISION_LABELS | jq -R . | jq -sc .)
to_triage=$(issues_by_label portal | jq -c --argjson dl "$dl_json" \
'[ .[] | select((.labels|map(.name)) as $have | ($dl | any(. as $d | $have|index($d))) | not) ] | sort_by(.number)')
to_triage=$(printf '%s' "$to_triage" | jq -c ".[:$MAX_TRIAGE]")
n_triage=$(printf '%s' "$to_triage" | jq 'length')
log "Triage: $n_triage portal issue(s) awaiting triage"
t=0
while [ "$t" -lt "$n_triage" ]; do
issue=$(printf '%s' "$to_triage" | jq -c ".[$t]")
t=$((t+1))
num=$(printf '%s' "$issue" | jq -r .number)
title=$(printf '%s' "$issue" | jq -r .title)
body=$(printf '%s' "$issue" | jq -r '.body // ""')
log "-- Triaging #$num: $title"
reset_clone
comments=$(comments_block "$num")
rm -f "$WORKDIR/CLAUDE_TRIAGE_LABEL.txt" "$WORKDIR/CLAUDE_TRIAGE.md"
prompt_file=$(mktemp)
{
printf '%s\n' "You are TRIAGING issue #$num of the Pelagia Portal (PPMS), a Next.js 15 purchase-order"
printf '%s\n' "management system for a maritime company. The web app is in App/ -- read App/CLAUDE.md and"
printf '%s\n' "explore the relevant code to judge feasibility. This is ANALYSIS ONLY: do NOT modify any"
printf '%s\n' "existing file, do NOT run builds or tests, do NOT commit. You only create two output files."
printf '\n## Issue #%s: %s\n\n' "$num" "$title"
printf '%s\n\n' "$body"
printf '%s\n\n' "$comments"
printf '%s\n' "## Your job"
printf '%s\n' "1. Interpret the request and break it into concrete technical action item(s), the way a"
printf '%s\n' " developer would in review -- note the files/areas likely involved and any open questions."
printf '%s\n' "2. Decide whether an UNATTENDED automated coding run can safely and verifiably implement it:"
printf '%s\n' " - claude-queue = localized change, clear acceptance, verifiable by type-check / lint / unit"
printf '%s\n' " tests, and NOT touching DB migrations, auth/permissions, payments/money, external live"
printf '%s\n' " systems (e.g. the GST website), or large multi-file features."
printf '%s\n' " - interactive = needs human steering: ambiguous or underspecified, needs business content"
printf '%s\n' " or a design decision, a schema migration, permissions/payments changes, an external"
printf '%s\n' " dependency, or a large feature needing visual verification."
printf '%s\n' "3. Write TWO files in the repository root, nothing else:"
printf '%s\n' " - CLAUDE_TRIAGE_LABEL.txt -- a single line with EXACTLY one word: claude-queue OR interactive"
printf '%s\n' " - CLAUDE_TRIAGE.md -- your requirements breakdown as markdown: action items, files/areas"
printf '%s\n' " involved, open questions, and a final one-line 'Routing rationale: ...'."
} > "$prompt_file"
tlog="$LOG_DIR/claude-triage-$num-$(date +%Y%m%d-%H%M%S).log"
log "Running Claude triage on #$num (log: $tlog)"
run_claude "$prompt_file" "$tlog" "$TRIAGE_TURNS"; rc=$?
log "Claude triage exited with code $rc for #$num"
rm -f "$prompt_file"
label=""
if [ -f "$WORKDIR/CLAUDE_TRIAGE_LABEL.txt" ]; then
raw=$(cat "$WORKDIR/CLAUDE_TRIAGE_LABEL.txt")
if printf '%s' "$raw" | grep -q interactive; then label=interactive
elif printf '%s' "$raw" | grep -q claude-queue; then label=claude-queue; fi
fi
breakdown=""
[ -f "$WORKDIR/CLAUDE_TRIAGE.md" ] && breakdown=$(cat "$WORKDIR/CLAUDE_TRIAGE.md")
reset_clone
if [ -z "$label" ]; then
log "Triage for #$num produced no valid decision; leaving for a human"
add_comment "$num" "$BOT_MARKER
[Claude triage] Could not auto-triage this issue. A human should review it and add either \`claude-queue\` or \`interactive\`."
continue
fi
# Label FIRST so a comment failure cannot trigger a re-triage that double-posts.
add_labels "$num" "$label"
# No bot marker on the breakdown: it is genuine refined requirements and SHOULD
# be fed to the fix stage (comments_block includes it).
note=${breakdown:-"(no breakdown produced)"}
add_comment "$num" "## Claude triage
$note
**Routing:** \`$label\`"
log "Triaged #$num -> $label"
done
# =====================================================================
# Phase 2: fix queued issues
# =====================================================================
queued=$(issues_by_label claude-queue | jq -c "sort_by(.number) | .[:$MAX_FIX]")
n_fix=$(printf '%s' "$queued" | jq 'length')
if [ "$n_fix" -eq 0 ]; then
log "No queued issues to fix."
else
log "Found $n_fix queued issue(s) to fix: $(printf '%s' "$queued" | jq -r '[.[].number|"#\(.)"]|join(", ")')"
fi
f=0
while [ "$f" -lt "$n_fix" ]; do
issue=$(printf '%s' "$queued" | jq -c ".[$f]")
f=$((f+1))
num=$(printf '%s' "$issue" | jq -r .number)
title=$(printf '%s' "$issue" | jq -r .title)
body=$(printf '%s' "$issue" | jq -r '.body // ""')
branch="${BRANCH_PREFIX}${num}"
log "-- Working issue #$num: $title"
set_labels "$num" "claude-queue claude-failed" "claude-working"
add_comment "$num" "$BOT_MARKER
[Claude] Started working on this issue on branch \`$branch\`."
git -C "$WORKDIR" fetch origin -q
if ! git -C "$WORKDIR" checkout -B "$branch" "origin/$BASE_BRANCH" -q 2>>"$LOG_FILE"; then
log "checkout failed for #$num"; continue
fi
comments=$(comments_block "$num")
[ -n "$comments" ] && log "Including human comment(s) for #$num"
prompt_file=$(mktemp)
{
printf '%s\n' "You are working autonomously on issue #$num of the Pelagia Portal (PPMS), a Next.js 15"
printf '%s\n' "purchase-order management system. The web app lives in App/ -- read App/CLAUDE.md first."
printf '\n## Issue #%s: %s\n\n' "$num" "$title"
printf '%s\n\n' "$body"
printf '%s\n\n' "$comments"
printf '%s\n' "## Test environment available to you"
printf '%s\n' "- App/.env points DATABASE_URL at a TEST database (pelagia_test) -- a daily mirror of"
printf '%s\n' " production, safe to read and write. It is NOT production. Email is console-logged and"
printf '%s\n' " storage is local in this dev mode (no real emails/uploads)."
printf '%s\n' "- To run integration tests against it, load the env first:"
printf '%s\n' " cd App && set -a && . ./.env && set +a && pnpm test:integration"
printf '%s\n' "- If you need runtime verification, you MAY start a dev server ON PORT 3100 ONLY:"
printf '%s\n' " cd App && pnpm dev -p 3100 (production runs on 3000 -- NEVER touch 3000)"
printf '%s\n' " When done, stop ONLY your own server by port: 'fuser -k 3100/tcp' (or kill its exact PID)."
printf '%s\n' " NEVER use a broad 'pkill -f next' -- it would kill the production app."
printf '%s\n' "- Never connect to or modify the production database or the production app."
printf '%s\n' ""
printf '%s\n' "## Your job"
printf '%s\n' "1. Investigate the issue and implement a focused, minimal fix in this repository."
printf '%s\n' "2. Verify: run 'pnpm type-check' and 'pnpm lint' in App/. If behaviour is covered by unit"
printf '%s\n' " tests, run them; for DB-backed behaviour, run integration tests against the test DB above."
printf '%s\n' "3. Add or adjust tests when it makes sense."
printf '%s\n' "4. Commit ALL changes to the current branch with a conventional message ending: Fixes #$num"
printf '%s\n' "5. Do NOT push, do NOT create tags, do NOT switch branches. The supervisor handles push and PR."
printf '%s\n' "If the issue is unclear, too risky (migrations, payments, permissions), or you cannot verify"
printf '%s\n' "the fix, make NO commits and write a short explanation to CLAUDE_RESULT.md in the repo root."
} > "$prompt_file"
clog="$LOG_DIR/claude-issue-$num-$(date +%Y%m%d-%H%M%S).log"
log "Running Claude Code on #$num (log: $clog)"
run_claude "$prompt_file" "$clog" "$FIX_TURNS"; rc=$?
log "Claude exited with code $rc for #$num"
rm -f "$prompt_file"
abort_note=""
if [ -f "$WORKDIR/CLAUDE_RESULT.md" ]; then
abort_note=$(cat "$WORKDIR/CLAUDE_RESULT.md")
rm -f "$WORKDIR/CLAUDE_RESULT.md"
git -C "$WORKDIR" checkout -- . 2>/dev/null
fi
commits=$(git -C "$WORKDIR" rev-list "origin/$BASE_BRANCH..HEAD" --count)
if [ "$commits" -gt 0 ]; then
log "Claude made $commits commit(s); pushing $branch"
if ! git -C "$WORKDIR" push -f -u origin "$branch" -q 2>>"$LOG_FILE"; then
log "push failed for #$num"; set_labels "$num" "claude-working" "claude-failed"; continue
fi
pr_title="fix: $(printf '%s' "$title" | sed 's/^\[Issue\]: //')"
pr_body="Automated fix by Claude Code for #$num.
Closes #$num
Review, merge, then create a release tag (vX.Y.Z) to deploy."
pr=$(api POST "/repos/$REPO/pulls" "$(jq -nc --arg base "$BASE_BRANCH" --arg head "$branch" --arg t "$pr_title" --arg b "$pr_body" '{base:$base,head:$head,title:$t,body:$b}')")
prnum=$(printf '%s' "$pr" | jq -r .number)
prurl=$(printf '%s' "$pr" | jq -r .html_url)
set_labels "$num" "claude-working" "claude-pr"
add_comment "$num" "$BOT_MARKER
[Claude] Opened PR [#$prnum]($prurl) with a proposed fix. Review and merge it, then create a release tag to deploy."
log "PR #$prnum opened for issue #$num"
else
log "No commits produced for #$num; marking claude-failed"
set_labels "$num" "claude-working" "claude-failed"
reason=${abort_note:-"Claude did not produce a verified fix. See watcher logs on pms1: $clog"}
add_comment "$num" "$BOT_MARKER
[Claude] Automated fix attempt did not produce a change.
$reason"
fi
done

View file

@ -1,46 +0,0 @@
#!/usr/bin/env bash
# Refresh the test database from production. Runs daily via cron on pms1.
#
# pelagia_test is a throwaway mirror of prod (pelagia) so the autofix Claude can
# run integration tests / a dev server against realistic data WITHOUT touching
# production. The test DB is owned by pelagia_user (created once as superuser);
# this refresh runs purely as pelagia_user using the prod connection string.
set -uo pipefail
ENV_FILE="${1:-/home/shad0w/pms/App/.env}"
PROD_DB="pelagia"
TEST_DB="pelagia_test"
log() { echo "$(date '+%F %T') $*"; }
PROD_URL=$(grep -E '^DATABASE_URL' "$ENV_FILE" | sed -E 's/^DATABASE_URL=//; s/^"//; s/"$//')
[ -n "$PROD_URL" ] || { log "ERROR: no DATABASE_URL in $ENV_FILE"; exit 1; }
# Derive the test URL by swapping ONLY the database-name path segment (anchored on
# @host/ so the 'pelagia' inside the username is never touched).
TEST_URL=$(printf '%s' "$PROD_URL" | sed -E "s#(@[^/]+/)$PROD_DB([?]|\$)#\1$TEST_DB\2#")
if [ "$TEST_URL" = "$PROD_URL" ]; then
log "ERROR: failed to derive test URL (db name not found in connection string)"; exit 1
fi
log "Refreshing $TEST_DB from $PROD_DB ..."
# --clean --if-exists drops+recreates each object in place (first run on an empty
# DB is a no-op for the DROPs). --no-owner/--no-privileges keep it portable.
errfile=$(mktemp)
pg_dump --clean --if-exists --no-owner --no-privileges "$PROD_URL" \
| psql "$TEST_URL" >/dev/null 2>"$errfile"
prod_tables=$(psql -tAc "SELECT count(*) FROM information_schema.tables WHERE table_schema='public';" "$PROD_URL")
test_tables=$(psql -tAc "SELECT count(*) FROM information_schema.tables WHERE table_schema='public';" "$TEST_URL")
if [ "$test_tables" = "$prod_tables" ] && [ "$test_tables" -gt 0 ]; then
log "Done. $TEST_DB has $test_tables public tables (prod has $prod_tables)."
rm -f "$errfile"
else
log "WARNING: table counts differ (test=$test_tables prod=$prod_tables). Recent errors:"
tail -8 "$errfile"
rm -f "$errfile"
exit 1
fi

View file

@ -1,12 +0,0 @@
@echo off
REM Manual launcher for the Claude issue watcher.
REM Double-click (or use the desktop shortcut) to run a poll on demand
REM instead of waiting for the 10-minute scheduled task.
title Pelagia - Claude Issue Watcher
echo Running the Claude issue watcher...
echo (this can take ~12 min if it picks up a queued issue and runs Claude)
echo ----------------------------------------------------------------------
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0claude-issue-watcher.ps1"
echo ----------------------------------------------------------------------
echo Done. Review any new PR on Forgejo, then close this window.
pause

View file

@ -6,8 +6,6 @@
"baseBranch": "master", "baseBranch": "master",
"branchPrefix": "claude/issue-", "branchPrefix": "claude/issue-",
"maxIssuesPerRun": 1, "maxIssuesPerRun": 1,
"maxTriagePerRun": 3,
"claudeExe": "C:\\Users\\shad0w\\.local\\bin\\claude.exe", "claudeExe": "C:\\Users\\shad0w\\.local\\bin\\claude.exe",
"claudeMaxTurns": 150, "claudeMaxTurns": 150
"triageMaxTurns": 80
} }