Compare commits
No commits in common. "master" and "claude/issue-1" have entirely different histories.
master
...
claude/iss
27 changed files with 104 additions and 1179 deletions
2
.gitattributes
vendored
2
.gitattributes
vendored
|
|
@ -1,2 +0,0 @@
|
||||||
# Shell scripts must keep LF endings — they run on Linux (pms1).
|
|
||||||
*.sh text eol=lf
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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 } },
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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 } });
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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>}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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: [],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
-- Add optional submitter-entered PO date to PurchaseOrder
|
|
||||||
ALTER TABLE "PurchaseOrder" ADD COLUMN "poDate" TIMESTAMP(3);
|
|
||||||
|
|
@ -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?
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
|
|
@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -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.)
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue