Compare commits
2 commits
e2f1fa6d50
...
4e6175153d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e6175153d | ||
| 3e711a171c |
6 changed files with 553 additions and 54 deletions
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Shell scripts must keep LF endings — they run on Linux (pms1).
|
||||
*.sh text eol=lf
|
||||
|
|
@ -5,6 +5,7 @@ import { DiscardDraftButton } from "@/components/po/discard-draft-button";
|
|||
import { SubmitDraftButton } from "@/components/po/submit-draft-button";
|
||||
import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils";
|
||||
import { generateDownloadUrl } from "@/lib/storage";
|
||||
import { groupAttachments } from "@/lib/attachments";
|
||||
import { TC_FIXED_LINE } from "@/lib/validations/po";
|
||||
import type { LineItemInput } from "@/lib/validations/po";
|
||||
import type { Role } from "@prisma/client";
|
||||
|
|
@ -65,7 +66,6 @@ type PoWithRelations = {
|
|||
sortOrder: number;
|
||||
}[];
|
||||
documents: { id: string; fileName: string; fileSize: number; storageKey: string; uploadedAt: Date }[];
|
||||
receipt: { id: string; storageKey: string; fileName: string; notes: string | null; confirmedAt: Date } | null;
|
||||
actions: { id: string; actionType: string; note: string | null; metadata: import("@prisma/client").Prisma.JsonValue; createdAt: Date; actor: { name: string } }[];
|
||||
};
|
||||
|
||||
|
|
@ -150,10 +150,13 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
|
|||
? "Submitter updated these line items after edits were requested. Previous values shown with strikethrough."
|
||||
: "Line items were amended by manager. Current values shown; original values shown with strikethrough.";
|
||||
|
||||
const downloadUrlList = await Promise.all(
|
||||
po.documents.map((doc) => generateDownloadUrl(doc.storageKey))
|
||||
const docsWithUrls = await Promise.all(
|
||||
po.documents.map(async (doc) => ({
|
||||
...doc,
|
||||
url: await generateDownloadUrl(doc.storageKey),
|
||||
}))
|
||||
);
|
||||
const urlByDocId = new Map(po.documents.map((doc, i) => [doc.id, downloadUrlList[i]]));
|
||||
const attachmentGroups = groupAttachments(docsWithUrls);
|
||||
|
||||
const canConfirmReceipt =
|
||||
(po.status === "PAID_DELIVERED" || po.status === "PARTIALLY_CLOSED" || po.status === "PARTIALLY_PAID") &&
|
||||
|
|
@ -401,56 +404,42 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Documents — grouped by type */}
|
||||
{(() => {
|
||||
const submissionDocs = po.documents.filter((d) => !d.storageKey.startsWith("receipt/"));
|
||||
const receiptDocs = po.documents.filter((d) => d.storageKey.startsWith("receipt/"));
|
||||
const hasDeliverySection = receiptDocs.length > 0 || !!po.receipt?.notes;
|
||||
if (submissionDocs.length === 0 && !hasDeliverySection) return null;
|
||||
|
||||
const renderDocList = (docs: typeof po.documents) => (
|
||||
<ul className="space-y-2">
|
||||
{docs.map((doc) => (
|
||||
<li key={doc.id} className="flex items-center gap-3 text-sm">
|
||||
<a
|
||||
href={urlByDocId.get(doc.id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium text-primary-600 hover:underline"
|
||||
>
|
||||
{doc.fileName}
|
||||
</a>
|
||||
<span className="text-neutral-400 text-xs">
|
||||
{(doc.fileSize / 1024).toFixed(0)} KB · {formatDate(doc.uploadedAt)}
|
||||
</span>
|
||||
</li>
|
||||
{/* Documents — grouped by lifecycle stage (submission / payment / delivery) */}
|
||||
{attachmentGroups.length > 0 && (
|
||||
<div className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||
<h3 className="text-sm font-semibold text-neutral-900 mb-4">Attachments</h3>
|
||||
<div className="space-y-5">
|
||||
{attachmentGroups.map((group) => (
|
||||
<div key={group.meta.key}>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
{group.meta.label}
|
||||
<span className="ml-1.5 font-normal text-neutral-400">({group.items.length})</span>
|
||||
</h4>
|
||||
{group.meta.description && (
|
||||
<p className="mt-0.5 text-xs text-neutral-400">{group.meta.description}</p>
|
||||
)}
|
||||
<ul className="mt-2 space-y-2">
|
||||
{group.items.map((doc) => (
|
||||
<li key={doc.id} className="flex items-center gap-3 text-sm">
|
||||
<a
|
||||
href={doc.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium text-primary-600 hover:underline"
|
||||
>
|
||||
{doc.fileName}
|
||||
</a>
|
||||
<span className="text-neutral-400 text-xs">
|
||||
{(doc.fileSize / 1024).toFixed(0)} KB · {formatDate(doc.uploadedAt)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||
<h3 className="text-sm font-semibold text-neutral-900 mb-4">Attachments</h3>
|
||||
<div className="space-y-5">
|
||||
{submissionDocs.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide mb-2">Submission</p>
|
||||
{renderDocList(submissionDocs)}
|
||||
</div>
|
||||
)}
|
||||
{hasDeliverySection && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide mb-2">Delivery Receipt</p>
|
||||
{po.receipt?.notes && (
|
||||
<p className="text-sm text-neutral-600 italic mb-2">“{po.receipt.notes}”</p>
|
||||
)}
|
||||
{receiptDocs.length > 0 && renderDocList(receiptDocs)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirm receipt CTA */}
|
||||
{canConfirmReceipt && (
|
||||
|
|
|
|||
96
App/lib/attachments.ts
Normal file
96
App/lib/attachments.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
/**
|
||||
* Attachment grouping.
|
||||
*
|
||||
* All PO attachments are stored as `PODocument` rows. The lifecycle stage an
|
||||
* attachment belongs to is encoded in the leading segment of its `storageKey`
|
||||
* (see `buildStorageKey` in `lib/storage.ts`), e.g. `po-document/<poId>/...`
|
||||
* or `receipt/<poId>/...`. This module derives a user-facing grouping from
|
||||
* that prefix so the PO details screen can show every attachment grouped by
|
||||
* type (submission, payment, delivery).
|
||||
*/
|
||||
|
||||
export type AttachmentGroupKey = "submission" | "payment" | "delivery" | "other";
|
||||
|
||||
export interface AttachmentGroupMeta {
|
||||
key: AttachmentGroupKey;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** Display order for attachment groups (lifecycle order). */
|
||||
export const ATTACHMENT_GROUP_ORDER: AttachmentGroupKey[] = [
|
||||
"submission",
|
||||
"payment",
|
||||
"delivery",
|
||||
"other",
|
||||
];
|
||||
|
||||
export const ATTACHMENT_GROUP_META: Record<AttachmentGroupKey, AttachmentGroupMeta> = {
|
||||
submission: {
|
||||
key: "submission",
|
||||
label: "Submission documents",
|
||||
description: "Uploaded with the purchase order (e.g. invoice, quotation).",
|
||||
},
|
||||
payment: {
|
||||
key: "payment",
|
||||
label: "Payment documents",
|
||||
description: "Uploaded at payment (e.g. payment proof).",
|
||||
},
|
||||
delivery: {
|
||||
key: "delivery",
|
||||
label: "Delivery receipts",
|
||||
description: "Uploaded at delivery confirmation (e.g. delivery receipt).",
|
||||
},
|
||||
other: {
|
||||
key: "other",
|
||||
label: "Other attachments",
|
||||
description: "",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Derive the lifecycle group of an attachment from its storage key prefix.
|
||||
* Unknown prefixes fall back to "other" so nothing is ever hidden.
|
||||
*/
|
||||
export function categorizeAttachment(storageKey: string): AttachmentGroupKey {
|
||||
const prefix = storageKey.split("/")[0];
|
||||
switch (prefix) {
|
||||
case "po-document":
|
||||
return "submission";
|
||||
case "payment-document":
|
||||
case "payment":
|
||||
return "payment";
|
||||
case "receipt":
|
||||
return "delivery";
|
||||
default:
|
||||
return "other";
|
||||
}
|
||||
}
|
||||
|
||||
export interface AttachmentGroup<T> {
|
||||
meta: AttachmentGroupMeta;
|
||||
items: T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Group attachments by lifecycle stage, returning only non-empty groups in
|
||||
* canonical lifecycle order. Item order within each group is preserved.
|
||||
*/
|
||||
export function groupAttachments<T extends { storageKey: string }>(
|
||||
documents: T[]
|
||||
): AttachmentGroup<T>[] {
|
||||
const buckets = new Map<AttachmentGroupKey, T[]>();
|
||||
for (const doc of documents) {
|
||||
const key = categorizeAttachment(doc.storageKey);
|
||||
const bucket = buckets.get(key);
|
||||
if (bucket) bucket.push(doc);
|
||||
else buckets.set(key, [doc]);
|
||||
}
|
||||
|
||||
return ATTACHMENT_GROUP_ORDER.flatMap((key) => {
|
||||
const items = buckets.get(key);
|
||||
return items && items.length > 0
|
||||
? [{ meta: ATTACHMENT_GROUP_META[key], items }]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
67
App/tests/unit/attachments.test.ts
Normal file
67
App/tests/unit/attachments.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
categorizeAttachment,
|
||||
groupAttachments,
|
||||
} from "@/lib/attachments";
|
||||
|
||||
describe("categorizeAttachment", () => {
|
||||
it("maps po-document keys to the submission group", () => {
|
||||
expect(categorizeAttachment("po-document/po123/1700-invoice.pdf")).toBe("submission");
|
||||
});
|
||||
|
||||
it("maps receipt keys to the delivery group", () => {
|
||||
expect(categorizeAttachment("receipt/po123/1700-delivery.pdf")).toBe("delivery");
|
||||
});
|
||||
|
||||
it("maps payment keys to the payment group", () => {
|
||||
expect(categorizeAttachment("payment-document/po123/proof.pdf")).toBe("payment");
|
||||
expect(categorizeAttachment("payment/po123/proof.pdf")).toBe("payment");
|
||||
});
|
||||
|
||||
it("falls back to other for unknown prefixes", () => {
|
||||
expect(categorizeAttachment("something-else/x.pdf")).toBe("other");
|
||||
expect(categorizeAttachment("no-slash")).toBe("other");
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupAttachments", () => {
|
||||
const doc = (id: string, storageKey: string) => ({ id, storageKey });
|
||||
|
||||
it("groups documents by lifecycle stage in canonical order", () => {
|
||||
const groups = groupAttachments([
|
||||
doc("a", "receipt/po1/delivery.pdf"),
|
||||
doc("b", "po-document/po1/invoice.pdf"),
|
||||
doc("c", "po-document/po1/quote.pdf"),
|
||||
]);
|
||||
|
||||
expect(groups.map((g) => g.meta.key)).toEqual(["submission", "delivery"]);
|
||||
expect(groups[0].items.map((d) => d.id)).toEqual(["b", "c"]);
|
||||
expect(groups[1].items.map((d) => d.id)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("omits empty groups", () => {
|
||||
const groups = groupAttachments([doc("a", "po-document/po1/invoice.pdf")]);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].meta.key).toBe("submission");
|
||||
});
|
||||
|
||||
it("returns an empty array when there are no documents", () => {
|
||||
expect(groupAttachments([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves input order within a group", () => {
|
||||
const groups = groupAttachments([
|
||||
doc("first", "receipt/po1/a.pdf"),
|
||||
doc("second", "receipt/po1/b.pdf"),
|
||||
]);
|
||||
expect(groups[0].items.map((d) => d.id)).toEqual(["first", "second"]);
|
||||
});
|
||||
|
||||
it("collects unknown prefixes into the other group last", () => {
|
||||
const groups = groupAttachments([
|
||||
doc("x", "mystery/po1/file.pdf"),
|
||||
doc("y", "po-document/po1/invoice.pdf"),
|
||||
]);
|
||||
expect(groups.map((g) => g.meta.key)).toEqual(["submission", "other"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -38,11 +38,31 @@ requirements.
|
|||
|---|---|---|
|
||||
| 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) |
|
||||
| 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 |
|
||||
| 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`) |
|
||||
| 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` |
|
||||
|
||||
## 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.
|
||||
|
||||
## Issue label lifecycle
|
||||
|
||||
```
|
||||
|
|
|
|||
325
automation/claude-issue-watcher.sh
Normal file
325
automation/claude-issue-watcher.sh
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
#!/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' "## 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 you changed behaviour covered"
|
||||
printf '%s\n' " by unit tests, run the relevant tests. Do not start the dev server or any database."
|
||||
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
|
||||
Loading…
Add table
Reference in a new issue