diff --git a/desktop/src/features/workstream-board/lib/workstreamCardParser.test.mjs b/desktop/src/features/workstream-board/lib/workstreamCardParser.test.mjs index d9be71d42..d5b4d6c33 100644 --- a/desktop/src/features/workstream-board/lib/workstreamCardParser.test.mjs +++ b/desktop/src/features/workstream-board/lib/workstreamCardParser.test.mjs @@ -14,8 +14,11 @@ function withCardFence(prose, rawPayload) { const VALID_PAYLOAD = { version: 1, synopsis: "Implementing the canvas card slice.", - orchestrator: "loganj", - assignees: ["alice", "bob"], + orchestrator: { pubkey: "loganj-pubkey", name: "Logan" }, + assignees: [ + { pubkey: "alice-pubkey", name: "Alice" }, + { pubkey: "bob-pubkey", name: "Bob" }, + ], }; // ── Happy path ──────────────────────────────────────────────────────────────── @@ -34,7 +37,7 @@ test("parses a valid v1 card with explicit optional arrays", () => { version: 1, synopsis: VALID_PAYLOAD.synopsis, orchestrator: VALID_PAYLOAD.orchestrator, - assignees: ["alice", "bob"], + assignees: VALID_PAYLOAD.assignees, pullRequests: ["https://github.com/block/buzz/pull/1"], waitingOn: ["review"], }); @@ -46,7 +49,7 @@ test("defaults assignees/pullRequests/waitingOn to empty arrays when omitted", ( ); assert.equal(result.ok, true); - assert.deepEqual(result.card.assignees, ["alice", "bob"]); + assert.deepEqual(result.card.assignees, VALID_PAYLOAD.assignees); assert.deepEqual(result.card.pullRequests, []); assert.deepEqual(result.card.waitingOn, []); }); @@ -144,10 +147,10 @@ test("returns invalid-fields when synopsis is missing", () => { }); }); -test("returns invalid-fields when orchestrator is an empty string", () => { +test("returns invalid-fields when orchestrator is missing its identity fields", () => { const content = withCardFence("Status:", { ...VALID_PAYLOAD, - orchestrator: "", + orchestrator: { name: "Logan" }, }); assert.deepEqual(parseWorkstreamCard(content), { ok: false, @@ -166,10 +169,10 @@ test("returns invalid-fields when assignees is not an array", () => { }); }); -test("returns invalid-fields when assignees contains a non-string", () => { +test("returns invalid-fields when assignees contains an invalid identity", () => { const content = withCardFence("Status:", { ...VALID_PAYLOAD, - assignees: ["alice", 2], + assignees: [{ pubkey: "alice-pubkey", name: "Alice" }, "bob"], }); assert.deepEqual(parseWorkstreamCard(content), { ok: false, @@ -199,6 +202,28 @@ test("returns invalid-fields when waitingOn is not an array", () => { }); }); +test("rejects the former string identity schema for orchestrator", () => { + const content = withCardFence("Status:", { + ...VALID_PAYLOAD, + orchestrator: "loganj", + }); + assert.deepEqual(parseWorkstreamCard(content), { + ok: false, + reason: "invalid-fields", + }); +}); + +test("rejects the former string identity schema for assignees", () => { + const content = withCardFence("Status:", { + ...VALID_PAYLOAD, + assignees: ["alice"], + }); + assert.deepEqual(parseWorkstreamCard(content), { + ok: false, + reason: "invalid-fields", + }); +}); + test("returns invalid-fields when the payload is a JSON array, not an object", () => { const content = withCardFence("Status:", [VALID_PAYLOAD]); assert.deepEqual(parseWorkstreamCard(content), { @@ -206,3 +231,25 @@ test("returns invalid-fields when the payload is a JSON array, not an object", ( reason: "invalid-fields", }); }); + +test("returns invalid-fields when synopsis spans multiple lines", () => { + const content = withCardFence("Status:", { + ...VALID_PAYLOAD, + synopsis: "line one\nline two", + }); + assert.deepEqual(parseWorkstreamCard(content), { + ok: false, + reason: "invalid-fields", + }); +}); + +test("returns invalid-fields when optional arrays are explicitly null", () => { + const content = withCardFence("Status:", { + ...VALID_PAYLOAD, + pullRequests: null, + }); + assert.deepEqual(parseWorkstreamCard(content), { + ok: false, + reason: "invalid-fields", + }); +}); diff --git a/desktop/src/features/workstream-board/lib/workstreamCardParser.ts b/desktop/src/features/workstream-board/lib/workstreamCardParser.ts index f77d3d910..630831895 100644 --- a/desktop/src/features/workstream-board/lib/workstreamCardParser.ts +++ b/desktop/src/features/workstream-board/lib/workstreamCardParser.ts @@ -6,7 +6,7 @@ * * ``` * ```buzz-workstream-card - * {"version":1,"synopsis":"…","orchestrator":"…","assignees":[…]} + * {"version":1,"synopsis":"…","orchestrator":{"pubkey":"…","name":"…"},"assignees":[{"pubkey":"…","name":"…"}]} * ``` * ``` * @@ -19,11 +19,16 @@ const FENCE_OPEN = "```buzz-workstream-card"; const FENCE_CLOSE = "```"; +export type WorkstreamIdentity = { + pubkey: string; + name: string; +}; + export type WorkstreamCardV1 = { version: 1; synopsis: string; - orchestrator: string; - assignees: string[]; + orchestrator: WorkstreamIdentity; + assignees: WorkstreamIdentity[]; pullRequests: unknown[]; waitingOn: unknown[]; }; @@ -40,30 +45,51 @@ export type WorkstreamCardParseResult = | { ok: false; reason: WorkstreamCardParseFailureReason }; function findFencedBlocks(content: string): string[] { + const lines = content.split(/\r?\n/); const blocks: string[] = []; - let cursor = 0; - while (true) { - const openIdx = content.indexOf(FENCE_OPEN, cursor); - if (openIdx === -1) break; + for (let index = 0; index < lines.length; index += 1) { + if (lines[index].trimEnd() !== FENCE_OPEN) continue; - const jsonStart = content.indexOf("\n", openIdx); - if (jsonStart === -1) break; + const body: string[] = []; + let closeIndex = index + 1; + while ( + closeIndex < lines.length && + lines[closeIndex].trim() !== FENCE_CLOSE + ) { + body.push(lines[closeIndex]); + closeIndex += 1; + } + if (closeIndex === lines.length) break; - const closeIdx = content.indexOf(`\n${FENCE_CLOSE}`, jsonStart); - if (closeIdx === -1) break; - - blocks.push(content.slice(jsonStart + 1, closeIdx).trim()); - cursor = closeIdx + `\n${FENCE_CLOSE}`.length; + blocks.push(body.join("\n").trim()); + index = closeIndex; } return blocks; } -function isStringArray(value: unknown): value is string[] { - return ( - Array.isArray(value) && value.every((item) => typeof item === "string") - ); +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim() !== ""; +} + +function isOneLineString(value: unknown): value is string { + return isNonEmptyString(value) && !/[\r\n]/.test(value); +} + +function isWorkstreamIdentity(value: unknown): value is WorkstreamIdentity { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + + const raw = value as Record; + return isNonEmptyString(raw.pubkey) && isNonEmptyString(raw.name); +} + +function isWorkstreamIdentityArray( + value: unknown, +): value is WorkstreamIdentity[] { + return Array.isArray(value) && value.every(isWorkstreamIdentity); } /** @@ -102,24 +128,27 @@ export function parseWorkstreamCard( return { ok: false, reason: "unknown-version" }; } - if (typeof raw.synopsis !== "string" || raw.synopsis.trim() === "") { - return { ok: false, reason: "invalid-fields" }; - } - if (typeof raw.orchestrator !== "string" || raw.orchestrator.trim() === "") { + const synopsis = raw.synopsis; + if (!isOneLineString(synopsis)) { return { ok: false, reason: "invalid-fields" }; } - const assignees = raw.assignees ?? []; - if (!isStringArray(assignees)) { + const orchestrator = raw.orchestrator; + if (!isWorkstreamIdentity(orchestrator)) { return { ok: false, reason: "invalid-fields" }; } - const pullRequests = raw.pullRequests ?? []; + const assignees = raw.assignees === undefined ? [] : raw.assignees; + if (!isWorkstreamIdentityArray(assignees)) { + return { ok: false, reason: "invalid-fields" }; + } + + const pullRequests = raw.pullRequests === undefined ? [] : raw.pullRequests; if (!Array.isArray(pullRequests)) { return { ok: false, reason: "invalid-fields" }; } - const waitingOn = raw.waitingOn ?? []; + const waitingOn = raw.waitingOn === undefined ? [] : raw.waitingOn; if (!Array.isArray(waitingOn)) { return { ok: false, reason: "invalid-fields" }; } @@ -128,8 +157,8 @@ export function parseWorkstreamCard( ok: true, card: { version: 1, - synopsis: raw.synopsis, - orchestrator: raw.orchestrator, + synopsis, + orchestrator, assignees, pullRequests, waitingOn, diff --git a/desktop/src/features/workstream-board/lib/workstreamCardViewModel.test.mjs b/desktop/src/features/workstream-board/lib/workstreamCardViewModel.test.mjs index 41ac9e14f..b2b9f37cf 100644 --- a/desktop/src/features/workstream-board/lib/workstreamCardViewModel.test.mjs +++ b/desktop/src/features/workstream-board/lib/workstreamCardViewModel.test.mjs @@ -8,8 +8,8 @@ const VALID_CARD_CONTENT = [ JSON.stringify({ version: 1, synopsis: "Shipping the canvas card slice.", - orchestrator: "loganj", - assignees: ["alice"], + orchestrator: { pubkey: "loganj-pubkey", name: "Logan" }, + assignees: [{ pubkey: "alice-pubkey", name: "Alice" }], }), "```", ].join("\n"); @@ -72,8 +72,13 @@ test("returns a ready card when the canvas parses successfully", () => { assert.equal(viewModel.status, "ready"); assert.equal(viewModel.card.synopsis, "Shipping the canvas card slice."); - assert.equal(viewModel.card.orchestrator, "loganj"); - assert.deepEqual(viewModel.card.assignees, ["alice"]); + assert.deepEqual(viewModel.card.orchestrator, { + pubkey: "loganj-pubkey", + name: "Logan", + }); + assert.deepEqual(viewModel.card.assignees, [ + { pubkey: "alice-pubkey", name: "Alice" }, + ]); }); test("loading takes priority over content even if content happens to be malformed", () => { diff --git a/desktop/src/features/workstream-board/ui/WorkstreamCard.tsx b/desktop/src/features/workstream-board/ui/WorkstreamCard.tsx index 111f8d88e..6d07ba0d3 100644 --- a/desktop/src/features/workstream-board/ui/WorkstreamCard.tsx +++ b/desktop/src/features/workstream-board/ui/WorkstreamCard.tsx @@ -48,7 +48,7 @@ export function WorkstreamCard({ channel, onSelect }: WorkstreamCardProps) {

Orchestrator:{" "} - {viewModel.card.orchestrator} + {viewModel.card.orchestrator.name}

{viewModel.card.assignees.length > 0 ? ( @@ -56,9 +56,9 @@ export function WorkstreamCard({ channel, onSelect }: WorkstreamCardProps) { {viewModel.card.assignees.map((assignee) => ( - {assignee} + {assignee.name} ))}