Skip to content

Commit 5b0a503

Browse files
feat(GLA-1114): issue-level approve gate — block approve when no visual attached
Implements the visual-mandatory check at the [review-and-ship] issue level: - lib/asset-type.ts: add IssueApprovalGate type, evaluateIssueApprovalGate(), hasVisualAsset(), hasVisualWaiver() — detects image/video docs; recognises [visual-waiver] comments as a founder override (warning, not hard block) - app/components/IssueApproveBar.tsx: new client component — red banner + disabled Approve button when gate blocked; amber banner + enabled when visual-waiver; approve POSTs to the issue-level approve endpoint - app/components/AssetIssueSummaryView.tsx: accepts gate + paperclipUrl props; renders IssueApproveBar below the description section - app/api/issues/[issueId]/approve/route.ts: handles issue-level approve when docKey is absent — fetches docs + comments, runs evaluateIssueApprovalGate, posts approval comment + PATCHes status to todo on pass; per-doc path unchanged - app/asset/[issueId]/page.tsx: fetches comments server-side, computes issue gate, passes gate + paperclipUrl to AssetIssueSummaryView - app/asset/[issueId]/[docKey]/page.tsx: same gate computation for the canonical-slug fallback render path Co-Authored-By: Paperclip <noreply@paperclip.ing>
1 parent 2747059 commit 5b0a503

6 files changed

Lines changed: 685 additions & 90 deletions

File tree

scripts/asset-library/app/api/issues/[issueId]/approve/route.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { NextResponse } from "next/server";
22
import {
33
evaluateApprovalGate,
4+
evaluateIssueApprovalGate,
45
parseProvenance,
56
type ExceptionRef,
67
type IssueDocument,
@@ -64,6 +65,98 @@ export async function POST(
6465
}
6566
const docKey = body.docKey ?? "";
6667

68+
// -----------------------------------------------------------------------
69+
// Issue-level approve: no docKey → visual-mandatory gate
70+
// -----------------------------------------------------------------------
71+
if (!docKey) {
72+
let docs: IssueDocument[] = [];
73+
try {
74+
const r = await fetch(
75+
`${apiUrl.replace(/\/$/, "")}/api/issues/${encodeURIComponent(params.issueId)}/documents`,
76+
{ headers: { Authorization: `Bearer ${apiKey}` }, cache: "no-store" },
77+
);
78+
if (r.ok) docs = (await r.json()) as IssueDocument[];
79+
} catch { /* noop */ }
80+
81+
let comments: { body?: string | null }[] = [];
82+
try {
83+
const r = await fetch(
84+
`${apiUrl.replace(/\/$/, "")}/api/issues/${encodeURIComponent(params.issueId)}/comments`,
85+
{ headers: { Authorization: `Bearer ${apiKey}` }, cache: "no-store" },
86+
);
87+
if (r.ok) {
88+
const data = (await r.json()) as unknown;
89+
comments = Array.isArray(data) ? (data as { body?: string | null }[]) : [];
90+
}
91+
} catch { /* noop */ }
92+
93+
const issueGate = evaluateIssueApprovalGate(docs, comments);
94+
if (!issueGate.allowed) {
95+
return NextResponse.json(
96+
{ error: "gate_blocked", gate: issueGate },
97+
{ status: 422 },
98+
);
99+
}
100+
101+
const ts = new Date().toISOString();
102+
const commentBody = `## ✅ Brief approved by founder via asset library — ${ts}
103+
104+
- Visual gate: ${issueGate.hasVisual ? "passed (image/video asset present)" : "waived ([visual-waiver] comment on issue)"}
105+
- Status → \`todo\` (ready to ship)`;
106+
107+
const headersOut: Record<string, string> = {
108+
Authorization: `Bearer ${apiKey}`,
109+
"content-type": "application/json",
110+
};
111+
if (runId) headersOut["X-Paperclip-Run-Id"] = runId;
112+
113+
const commentRes = await fetch(
114+
`${apiUrl.replace(/\/$/, "")}/api/issues/${encodeURIComponent(params.issueId)}/comments`,
115+
{
116+
method: "POST",
117+
headers: headersOut,
118+
body: JSON.stringify({ body: commentBody }),
119+
cache: "no-store",
120+
},
121+
);
122+
if (!commentRes.ok) {
123+
const text = await commentRes.text();
124+
return NextResponse.json(
125+
{ error: "comment_failed", upstreamStatus: commentRes.status, body: text },
126+
{ status: 502 },
127+
);
128+
}
129+
130+
const patchRes = await fetch(
131+
`${apiUrl.replace(/\/$/, "")}/api/issues/${encodeURIComponent(params.issueId)}`,
132+
{
133+
method: "PATCH",
134+
headers: headersOut,
135+
body: JSON.stringify({ status: "todo" }),
136+
cache: "no-store",
137+
},
138+
);
139+
if (!patchRes.ok) {
140+
const text = await patchRes.text();
141+
return NextResponse.json(
142+
{
143+
ok: false,
144+
error: "patch_failed",
145+
upstreamStatus: patchRes.status,
146+
body: text,
147+
commentPosted: true,
148+
},
149+
{ status: 502 },
150+
);
151+
}
152+
153+
return NextResponse.json({ ok: true, status: "todo", gate: issueGate });
154+
}
155+
156+
// -----------------------------------------------------------------------
157+
// Per-document approve: docKey present → provenance gate
158+
// -----------------------------------------------------------------------
159+
67160
// Server-side gate evaluation — defence in depth, never trust the client.
68161
let doc: IssueDocument | null = null;
69162
if (docKey) {
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import Link from "next/link";
2+
import { redirect } from "next/navigation";
3+
import { headers } from "next/headers";
4+
import AssetRenderer from "@/app/components/AssetRenderer";
5+
import ProvenancePanel from "@/app/components/ProvenancePanel";
6+
import ApproveRejectBar from "./ApproveRejectBar";
7+
import AssetIssueSummaryView from "@/app/components/AssetIssueSummaryView";
8+
import {
9+
evaluateApprovalGate,
10+
evaluateIssueApprovalGate,
11+
parseProvenance,
12+
type ExceptionRef,
13+
type IssueDocument,
14+
} from "@/lib/asset-type";
15+
import { toCard, titleSlug, type RawIssue } from "@/lib/queue";
16+
17+
export const dynamic = "force-dynamic";
18+
export const revalidate = 0;
19+
20+
async function fetchJson<T>(path: string): Promise<T | null> {
21+
const h = headers();
22+
const host = h.get("host") ?? "127.0.0.1:7700";
23+
const proto = h.get("x-forwarded-proto") ?? "http";
24+
const url = `${proto}://${host}${path}`;
25+
try {
26+
const res = await fetch(url, { cache: "no-store" });
27+
if (!res.ok) return null;
28+
return (await res.json()) as T;
29+
} catch {
30+
return null;
31+
}
32+
}
33+
34+
async function fetchIssue(issueId: string): Promise<RawIssue | null> {
35+
// Use the detail proxy — list endpoint truncates description at ~1200 chars,
36+
// which clips the right-hand description fallback when no docs are attached.
37+
const detail = await fetchJson<RawIssue>(
38+
`/api/issues/${encodeURIComponent(issueId)}`,
39+
);
40+
if (detail) return detail;
41+
const list = await fetchJson<RawIssue[]>("/api/issues");
42+
if (!list) return null;
43+
return (
44+
list.find((i) => i.id === issueId || i.identifier === issueId) ?? null
45+
);
46+
}
47+
48+
async function fetchDoc(
49+
issueId: string,
50+
docKey: string,
51+
): Promise<IssueDocument | null> {
52+
return fetchJson<IssueDocument>(
53+
`/api/issues/${encodeURIComponent(issueId)}/documents/${encodeURIComponent(docKey)}`,
54+
);
55+
}
56+
57+
async function fetchDocs(issueId: string): Promise<IssueDocument[]> {
58+
return (
59+
(await fetchJson<IssueDocument[]>(
60+
`/api/issues/${encodeURIComponent(issueId)}/documents`,
61+
)) ?? []
62+
);
63+
}
64+
65+
async function fetchComments(issueId: string): Promise<{ body?: string | null }[]> {
66+
const apiUrl = process.env.PAPERCLIP_API_URL;
67+
const apiKey = process.env.PAPERCLIP_API_KEY;
68+
if (!apiUrl || !apiKey) return [];
69+
try {
70+
const res = await fetch(
71+
`${apiUrl.replace(/\/$/, "")}/api/issues/${encodeURIComponent(issueId)}/comments`,
72+
{ headers: { Authorization: `Bearer ${apiKey}` }, cache: "no-store" },
73+
);
74+
if (!res.ok) return [];
75+
const data = (await res.json()) as unknown;
76+
return Array.isArray(data) ? (data as { body?: string | null }[]) : [];
77+
} catch {
78+
return [];
79+
}
80+
}
81+
82+
function paperclipIssueUrl(identifier: string): string {
83+
const base = process.env.PAPERCLIP_WEB_URL ?? "http://127.0.0.1:5173";
84+
return `${base.replace(/\/$/, "")}/issues/${identifier}`;
85+
}
86+
87+
export default async function AssetDocDetail({
88+
params,
89+
}: {
90+
params: { issueId: string; docKey: string };
91+
}) {
92+
const [doc, issue] = await Promise.all([
93+
fetchDoc(params.issueId, params.docKey),
94+
fetchIssue(params.issueId),
95+
]);
96+
97+
if (!doc) {
98+
if (!issue) {
99+
return (
100+
<div className="rounded-lg border border-neutral-800 bg-neutral-900/40 p-6">
101+
<Link
102+
href={`/asset/${params.issueId}`}
103+
className="text-xs text-neutral-500 hover:text-neutral-300 inline-block mb-3"
104+
>
105+
← Back to issue
106+
</Link>
107+
<p className="text-sm text-neutral-300">
108+
Document <code className="text-neutral-100">{params.docKey}</code>{" "}
109+
not found on issue <code>{params.issueId}</code>.
110+
</p>
111+
</div>
112+
);
113+
}
114+
115+
// No doc found but issue exists → treat second segment as title slug.
116+
const canonicalSlug = titleSlug(issue.title);
117+
if (params.docKey !== canonicalSlug) {
118+
// Wrong or stale slug → 307 to canonical.
119+
redirect(`/asset/${issue.identifier}${canonicalSlug ? `/${canonicalSlug}` : ""}`);
120+
}
121+
// Canonical slug matches — render issue summary.
122+
const [docs, comments] = await Promise.all([
123+
fetchDocs(params.issueId),
124+
fetchComments(params.issueId),
125+
]);
126+
const issueGate = evaluateIssueApprovalGate(docs, comments);
127+
return (
128+
<AssetIssueSummaryView
129+
issueId={issue.identifier}
130+
issue={issue}
131+
docs={docs}
132+
gate={issueGate}
133+
paperclipUrl={paperclipIssueUrl(issue.identifier)}
134+
/>
135+
);
136+
}
137+
138+
const card = issue ? toCard(issue) : null;
139+
const prov = parseProvenance(doc);
140+
141+
const exceptionId = prov.raw["exception_issue_id"] ?? prov.raw["exception"] ?? null;
142+
let exception: ExceptionRef | null = null;
143+
if (exceptionId) {
144+
exception = await fetchJson<ExceptionRef>(
145+
`/api/exception-check?id=${encodeURIComponent(exceptionId)}`,
146+
);
147+
}
148+
const gate = evaluateApprovalGate(prov, params.docKey, exception);
149+
const paperclipUrl = issue ? paperclipIssueUrl(issue.identifier) : null;
150+
151+
return (
152+
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_22rem] gap-6">
153+
<div>
154+
<Link
155+
href={card ? `/asset/${issue?.identifier ?? params.issueId}` : "/"}
156+
className="text-xs text-neutral-500 hover:text-neutral-300 inline-block mb-4"
157+
>
158+
← Back {card ? "to issue" : "to queue"}
159+
</Link>
160+
161+
<header className="mb-4">
162+
<div className="flex flex-wrap items-baseline gap-3 mb-2">
163+
<h2 className="text-lg font-semibold text-white">
164+
{doc.title || doc.key}
165+
</h2>
166+
<span className="font-mono text-xs text-neutral-500">
167+
{doc.key}
168+
</span>
169+
</div>
170+
<div className="flex flex-wrap gap-2 text-[11px]">
171+
{card ? <Pill label={card.platform} /> : null}
172+
{card?.postDate ? <Pill label={`Post: ${card.postDate}`} /> : null}
173+
{card ? <Pill label={`Author: ${card.author}`} /> : null}
174+
<Pill label={`Format: ${doc.format ?? "—"}`} />
175+
{doc.latestRevisionNumber ? (
176+
<Pill label={`Rev ${doc.latestRevisionNumber}`} />
177+
) : null}
178+
</div>
179+
</header>
180+
181+
<section className="mb-6">
182+
<AssetRenderer doc={doc} />
183+
</section>
184+
185+
<ApproveRejectBar
186+
issueId={params.issueId}
187+
identifier={card?.identifier ?? params.issueId}
188+
docKey={params.docKey}
189+
gate={gate}
190+
paperclipUrl={paperclipUrl}
191+
/>
192+
</div>
193+
194+
<ProvenancePanel prov={prov} />
195+
</div>
196+
);
197+
}
198+
199+
function Pill({ label }: { label: string }) {
200+
return (
201+
<span className="inline-flex items-center px-2 py-0.5 rounded border border-neutral-700 bg-neutral-900 text-neutral-300">
202+
{label}
203+
</span>
204+
);
205+
}

0 commit comments

Comments
 (0)