feat(web): real linked Signal projection from signalIssueAttachments
Replace the deliberate signalCount of 0 with authenticated linked-Signal data from the Lane A signalIssueAttachments relation. - Add listLinkedSignalsForProject query (user-authenticated via requireProjectMember) returning issue-grouped LinkedSignalView data using the existing by_issue index. One query serves both card counts and expanded detail. - Update work-unit-projection builders to accept linkedSignals param; LinkedSignal type with signalId, title, summary, sourceCount, createdAt. - Feed real attachment data into collapsed card signalCount and expanded detail linked-signal list with provenance (source count, relative time). - Add signal.attached event handler to activity timeline projection. - 32 tests including 4 linked-signal projection tests, 1 signal.attached activity mapping test, and cross-issue signal isolation test proving issue A cannot inherit issue B's linked signals.
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
|||||||
TriangleAlert,
|
TriangleAlert,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { formatRelativeTime } from "@/lib/work-os/work-unit-projection";
|
||||||
import type { WorkUnitDetail as WorkUnitDetailData } from "@/lib/work-os/work-unit-projection";
|
import type { WorkUnitDetail as WorkUnitDetailData } from "@/lib/work-os/work-unit-projection";
|
||||||
|
|
||||||
const formatStatus = (
|
const formatStatus = (
|
||||||
@@ -113,15 +114,32 @@ export const WorkUnitDetail = ({
|
|||||||
</Section>
|
</Section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Signals — no signal-to-issue relation exists yet. Show explicit
|
{/* Linked Signals — authenticated via signalIssueAttachments */}
|
||||||
unavailable state rather than falsely presenting project Signals. */}
|
<Section
|
||||||
<Section icon={<Radio className="size-3" />} title="Signals">
|
icon={<Radio className="size-3" />}
|
||||||
|
title={`Signals (${detail.signalCount})`}
|
||||||
|
>
|
||||||
|
{detail.linkedSignals.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{detail.linkedSignals.map((signal) => (
|
||||||
|
<div key={String(signal.signalId)}>
|
||||||
|
<p className="text-sm font-medium">{signal.title}</p>
|
||||||
|
<p className="mt-0.5 line-clamp-2 text-xs leading-5 text-muted-foreground">
|
||||||
|
{signal.summary}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[10px] text-muted-foreground/60">
|
||||||
|
{signal.sourceCount} source
|
||||||
|
{signal.sourceCount === 1 ? "" : "s"} ·{" "}
|
||||||
|
{formatRelativeTime(signal.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<p className="text-xs leading-5 text-muted-foreground">
|
<p className="text-xs leading-5 text-muted-foreground">
|
||||||
No Signals linked to this Work Unit yet.
|
No Signals linked to this Work Unit.
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-[10px] leading-4 text-muted-foreground/60">
|
|
||||||
Linked Signals will appear here once attachment is available.
|
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Activity timeline */}
|
{/* Activity timeline */}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
eventsForIssue,
|
eventsForIssue,
|
||||||
} from "@/lib/work-os/work-unit-projection";
|
} from "@/lib/work-os/work-unit-projection";
|
||||||
import type {
|
import type {
|
||||||
|
LinkedSignal,
|
||||||
WorkUnitCard,
|
WorkUnitCard,
|
||||||
WorkUnitDetail,
|
WorkUnitDetail,
|
||||||
} from "@/lib/work-os/work-unit-projection";
|
} from "@/lib/work-os/work-unit-projection";
|
||||||
@@ -28,7 +29,6 @@ export interface SignalItem {
|
|||||||
export type ComposerMode = "project" | "work-unit";
|
export type ComposerMode = "project" | "work-unit";
|
||||||
|
|
||||||
export interface WorkOsState {
|
export interface WorkOsState {
|
||||||
// Project
|
|
||||||
readonly projects: ReturnType<typeof useProjectWorkspace>["projects"];
|
readonly projects: ReturnType<typeof useProjectWorkspace>["projects"];
|
||||||
readonly selectedProject: ReturnType<
|
readonly selectedProject: ReturnType<
|
||||||
typeof useProjectWorkspace
|
typeof useProjectWorkspace
|
||||||
@@ -40,7 +40,6 @@ export interface WorkOsState {
|
|||||||
readonly setRepository: (value: string) => void;
|
readonly setRepository: (value: string) => void;
|
||||||
readonly connectRepository: () => Promise<void>;
|
readonly connectRepository: () => Promise<void>;
|
||||||
|
|
||||||
// Work units
|
|
||||||
readonly workUnitCards: readonly WorkUnitCard[];
|
readonly workUnitCards: readonly WorkUnitCard[];
|
||||||
readonly selectedIssue: ReturnType<
|
readonly selectedIssue: ReturnType<
|
||||||
typeof useProjectWorkspace
|
typeof useProjectWorkspace
|
||||||
@@ -55,20 +54,36 @@ export interface WorkOsState {
|
|||||||
readonly issueBody: string;
|
readonly issueBody: string;
|
||||||
readonly setIssueBody: (value: string) => void;
|
readonly setIssueBody: (value: string) => void;
|
||||||
|
|
||||||
// Signals (project-scoped, displayed globally — not attributed to any issue)
|
|
||||||
readonly signals: readonly SignalItem[];
|
readonly signals: readonly SignalItem[];
|
||||||
|
|
||||||
// Chat
|
|
||||||
readonly composerMode: ComposerMode;
|
readonly composerMode: ComposerMode;
|
||||||
readonly setComposerMode: (mode: ComposerMode) => void;
|
readonly setComposerMode: (mode: ComposerMode) => void;
|
||||||
readonly projectAgent: ChatAgentState;
|
readonly projectAgent: ChatAgentState;
|
||||||
readonly workUnitAgent: ChatAgentState;
|
readonly workUnitAgent: ChatAgentState;
|
||||||
|
|
||||||
// Meta
|
|
||||||
readonly pendingAction: string | null;
|
readonly pendingAction: string | null;
|
||||||
readonly error: string | null;
|
readonly error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toLinkedSignals = (
|
||||||
|
entries:
|
||||||
|
| readonly {
|
||||||
|
readonly signalId: Id<"signals">;
|
||||||
|
readonly title: string;
|
||||||
|
readonly summary: string;
|
||||||
|
readonly sourceCount: number;
|
||||||
|
readonly createdAt: number;
|
||||||
|
}[]
|
||||||
|
| undefined
|
||||||
|
): readonly LinkedSignal[] =>
|
||||||
|
(entries ?? []).map((entry) => ({
|
||||||
|
createdAt: entry.createdAt,
|
||||||
|
signalId: entry.signalId,
|
||||||
|
sourceCount: entry.sourceCount,
|
||||||
|
summary: entry.summary,
|
||||||
|
title: entry.title,
|
||||||
|
}));
|
||||||
|
|
||||||
export const useWorkOs = (): WorkOsState => {
|
export const useWorkOs = (): WorkOsState => {
|
||||||
const workspace = useProjectWorkspace();
|
const workspace = useProjectWorkspace();
|
||||||
const orgState = usePersonalOrganization();
|
const orgState = usePersonalOrganization();
|
||||||
@@ -76,8 +91,6 @@ export const useWorkOs = (): WorkOsState => {
|
|||||||
|
|
||||||
const activeProjectId = workspace.selectedProjectId;
|
const activeProjectId = workspace.selectedProjectId;
|
||||||
|
|
||||||
// Work-unit-scoped project-manager agent. Stays dormant until an issue is
|
|
||||||
// selected, then follows that issue identity across re-renders.
|
|
||||||
const workUnitFlue = useFlueAgent({
|
const workUnitFlue = useFlueAgent({
|
||||||
id: workspace.selectedIssueId
|
id: workspace.selectedIssueId
|
||||||
? String(workspace.selectedIssueId)
|
? String(workspace.selectedIssueId)
|
||||||
@@ -93,9 +106,7 @@ export const useWorkOs = (): WorkOsState => {
|
|||||||
status: workspace.selectedIssueId ? workUnitFlue.status : "idle",
|
status: workspace.selectedIssueId ? workUnitFlue.status : "idle",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Signals scoped to the active project. These are displayed as project-level
|
// Project-level signals for the global sidebar panel.
|
||||||
// evidence, not attributed to any specific work unit (no signal-to-issue
|
|
||||||
// relation exists yet).
|
|
||||||
const allSignals = useQuery(
|
const allSignals = useQuery(
|
||||||
api.signals.list,
|
api.signals.list,
|
||||||
orgState.organizationId
|
orgState.organizationId
|
||||||
@@ -116,7 +127,13 @@ export const useWorkOs = (): WorkOsState => {
|
|||||||
}));
|
}));
|
||||||
}, [allSignals, activeProjectId]);
|
}, [allSignals, activeProjectId]);
|
||||||
|
|
||||||
// Composer mode tracks the selection by default but can be overridden.
|
// All linked signals for the active project, grouped by issue id.
|
||||||
|
// Used for card counts and detail display.
|
||||||
|
const linkedByIssue = useQuery(
|
||||||
|
api.signals.listLinkedSignalsForProject,
|
||||||
|
activeProjectId ? { projectId: activeProjectId } : "skip"
|
||||||
|
);
|
||||||
|
|
||||||
const [composerMode, setComposerMode] = useState<ComposerMode>("project");
|
const [composerMode, setComposerMode] = useState<ComposerMode>("project");
|
||||||
|
|
||||||
const selectIssue = (issueId: Id<"projectIssues"> | null) => {
|
const selectIssue = (issueId: Id<"projectIssues"> | null) => {
|
||||||
@@ -124,18 +141,21 @@ export const useWorkOs = (): WorkOsState => {
|
|||||||
setComposerMode(issueId ? "work-unit" : "project");
|
setComposerMode(issueId ? "work-unit" : "project");
|
||||||
};
|
};
|
||||||
|
|
||||||
// Work unit card projections — derived from per-issue events only.
|
|
||||||
const workUnitCards = useMemo<readonly WorkUnitCard[]>(() => {
|
const workUnitCards = useMemo<readonly WorkUnitCard[]>(() => {
|
||||||
if (!workspace.issues) {
|
if (!workspace.issues) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return workspace.issues.map((issue) => {
|
return workspace.issues.map((issue) => {
|
||||||
const issueEvents = eventsForIssue(workspace.events, issue._id);
|
const issueEvents = eventsForIssue(workspace.events, issue._id);
|
||||||
return buildWorkUnitCard({ issue, issueEvents });
|
const linked = toLinkedSignals(linkedByIssue?.[String(issue._id)]);
|
||||||
|
return buildWorkUnitCard({
|
||||||
|
issue,
|
||||||
|
issueEvents,
|
||||||
|
linkedSignals: linked,
|
||||||
});
|
});
|
||||||
}, [workspace.issues, workspace.events]);
|
});
|
||||||
|
}, [workspace.issues, workspace.events, linkedByIssue]);
|
||||||
|
|
||||||
// Selected work unit detail — same per-issue derivation.
|
|
||||||
const selectedDetail = useMemo<WorkUnitDetail | null>(() => {
|
const selectedDetail = useMemo<WorkUnitDetail | null>(() => {
|
||||||
if (!workspace.selectedIssue) {
|
if (!workspace.selectedIssue) {
|
||||||
return null;
|
return null;
|
||||||
@@ -144,11 +164,15 @@ export const useWorkOs = (): WorkOsState => {
|
|||||||
workspace.events,
|
workspace.events,
|
||||||
workspace.selectedIssue._id
|
workspace.selectedIssue._id
|
||||||
);
|
);
|
||||||
|
const linked = toLinkedSignals(
|
||||||
|
linkedByIssue?.[String(workspace.selectedIssue._id)]
|
||||||
|
);
|
||||||
return buildWorkUnitDetail({
|
return buildWorkUnitDetail({
|
||||||
issue: workspace.selectedIssue,
|
issue: workspace.selectedIssue,
|
||||||
issueEvents,
|
issueEvents,
|
||||||
|
linkedSignals: linked,
|
||||||
});
|
});
|
||||||
}, [workspace.selectedIssue, workspace.events]);
|
}, [workspace.selectedIssue, workspace.events, linkedByIssue]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
composerMode,
|
composerMode,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
extractLatestSummary,
|
extractLatestSummary,
|
||||||
extractPullRequestFromEvents,
|
extractPullRequestFromEvents,
|
||||||
} from "./work-unit-projection";
|
} from "./work-unit-projection";
|
||||||
|
import type { LinkedSignal } from "./work-unit-projection";
|
||||||
|
|
||||||
type Issue = Doc<"projectIssues">;
|
type Issue = Doc<"projectIssues">;
|
||||||
type Event = Doc<"projectEvents">;
|
type Event = Doc<"projectEvents">;
|
||||||
@@ -17,6 +18,17 @@ type Event = Doc<"projectEvents">;
|
|||||||
const ISSUE_A = "issue-a" as Id<"projectIssues">;
|
const ISSUE_A = "issue-a" as Id<"projectIssues">;
|
||||||
const ISSUE_B = "issue-b" as Id<"projectIssues">;
|
const ISSUE_B = "issue-b" as Id<"projectIssues">;
|
||||||
|
|
||||||
|
const makeLinkedSignal = (
|
||||||
|
overrides: Partial<LinkedSignal> = {}
|
||||||
|
): LinkedSignal => ({
|
||||||
|
createdAt: 5000,
|
||||||
|
signalId: "sig-1" as Id<"signals">,
|
||||||
|
sourceCount: 2,
|
||||||
|
summary: "Three users hit the same error.",
|
||||||
|
title: "OAuth crash on Safari",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
const makeIssue = (overrides: Partial<Issue> = {}): Issue =>
|
const makeIssue = (overrides: Partial<Issue> = {}): Issue =>
|
||||||
({
|
({
|
||||||
_creationTime: 1,
|
_creationTime: 1,
|
||||||
@@ -91,10 +103,11 @@ describe("extractArtifactPaths", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("buildWorkUnitCard", () => {
|
describe("buildWorkUnitCard", () => {
|
||||||
test("projects a fresh issue with no events", () => {
|
test("projects a fresh issue with no events and no linked signals", () => {
|
||||||
const card = buildWorkUnitCard({
|
const card = buildWorkUnitCard({
|
||||||
issue: makeIssue(),
|
issue: makeIssue(),
|
||||||
issueEvents: [],
|
issueEvents: [],
|
||||||
|
linkedSignals: [],
|
||||||
});
|
});
|
||||||
expect(card.title).toBe("Fix Safari OAuth callback");
|
expect(card.title).toBe("Fix Safari OAuth callback");
|
||||||
expect(card.status).toBe("open");
|
expect(card.status).toBe("open");
|
||||||
@@ -123,6 +136,7 @@ describe("buildWorkUnitCard", () => {
|
|||||||
const card = buildWorkUnitCard({
|
const card = buildWorkUnitCard({
|
||||||
issue: makeIssue({ status: "working" }),
|
issue: makeIssue({ status: "working" }),
|
||||||
issueEvents: events,
|
issueEvents: events,
|
||||||
|
linkedSignals: [],
|
||||||
});
|
});
|
||||||
expect(card.artifactCount).toBe(2);
|
expect(card.artifactCount).toBe(2);
|
||||||
expect(card.stepCount).toBe(2);
|
expect(card.stepCount).toBe(2);
|
||||||
@@ -137,6 +151,7 @@ describe("buildWorkUnitCard", () => {
|
|||||||
const card = buildWorkUnitCard({
|
const card = buildWorkUnitCard({
|
||||||
issue: makeIssue({ status: "needs-input" }),
|
issue: makeIssue({ status: "needs-input" }),
|
||||||
issueEvents: events,
|
issueEvents: events,
|
||||||
|
linkedSignals: [],
|
||||||
});
|
});
|
||||||
expect(card.needsInput).toBe(true);
|
expect(card.needsInput).toBe(true);
|
||||||
expect(card.stepCount).toBe(3);
|
expect(card.stepCount).toBe(3);
|
||||||
@@ -157,11 +172,68 @@ describe("buildWorkUnitCard", () => {
|
|||||||
const card = buildWorkUnitCard({
|
const card = buildWorkUnitCard({
|
||||||
issue: makeIssue({ status: "completed" }),
|
issue: makeIssue({ status: "completed" }),
|
||||||
issueEvents: events,
|
issueEvents: events,
|
||||||
|
linkedSignals: [],
|
||||||
});
|
});
|
||||||
expect(card.hasPullRequest).toBe(true);
|
expect(card.hasPullRequest).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("linked signal projection", () => {
|
||||||
|
test("card signalCount reflects real linked signals", () => {
|
||||||
|
const card = buildWorkUnitCard({
|
||||||
|
issue: makeIssue(),
|
||||||
|
issueEvents: [],
|
||||||
|
linkedSignals: [
|
||||||
|
makeLinkedSignal(),
|
||||||
|
makeLinkedSignal({
|
||||||
|
signalId: "sig-2" as Id<"signals">,
|
||||||
|
title: "Build error",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(card.signalCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("card signalCount is 0 when no signals are linked", () => {
|
||||||
|
const card = buildWorkUnitCard({
|
||||||
|
issue: makeIssue(),
|
||||||
|
issueEvents: [],
|
||||||
|
linkedSignals: [],
|
||||||
|
});
|
||||||
|
expect(card.signalCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detail exposes linked signals with title, summary, and source count", () => {
|
||||||
|
const detail = buildWorkUnitDetail({
|
||||||
|
issue: makeIssue(),
|
||||||
|
issueEvents: [],
|
||||||
|
linkedSignals: [
|
||||||
|
makeLinkedSignal(),
|
||||||
|
makeLinkedSignal({
|
||||||
|
signalId: "sig-2" as Id<"signals">,
|
||||||
|
sourceCount: 5,
|
||||||
|
summary: "CI broke",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(detail.signalCount).toBe(2);
|
||||||
|
expect(detail.linkedSignals).toHaveLength(2);
|
||||||
|
expect(detail.linkedSignals[0].title).toBe("OAuth crash on Safari");
|
||||||
|
expect(detail.linkedSignals[0].sourceCount).toBe(2);
|
||||||
|
expect(detail.linkedSignals[1].sourceCount).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detail linked signals empty when no attachment exists", () => {
|
||||||
|
const detail = buildWorkUnitDetail({
|
||||||
|
issue: makeIssue(),
|
||||||
|
issueEvents: [],
|
||||||
|
linkedSignals: [],
|
||||||
|
});
|
||||||
|
expect(detail.signalCount).toBe(0);
|
||||||
|
expect(detail.linkedSignals).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("extractLatestSummary", () => {
|
describe("extractLatestSummary", () => {
|
||||||
test("returns the most recent agent summary", () => {
|
test("returns the most recent agent summary", () => {
|
||||||
const events = [
|
const events = [
|
||||||
@@ -228,6 +300,19 @@ describe("buildActivityTimeline", () => {
|
|||||||
expect(timeline[1].kind).toBe("issue.created");
|
expect(timeline[1].kind).toBe("issue.created");
|
||||||
expect(timeline[1].label).toBe("Work created");
|
expect(timeline[1].label).toBe("Work created");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("maps signal.attached event to activity", () => {
|
||||||
|
const events = [
|
||||||
|
makeEvent(
|
||||||
|
"signal.attached",
|
||||||
|
{ signalId: "sig-1", signalProblemTitle: "OAuth crash" },
|
||||||
|
{ createdAt: 3000 }
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const timeline = buildActivityTimeline(events);
|
||||||
|
expect(timeline[0].label).toBe("Signal linked");
|
||||||
|
expect(timeline[0].detail).toContain("OAuth crash");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("buildWorkUnitDetail", () => {
|
describe("buildWorkUnitDetail", () => {
|
||||||
@@ -253,11 +338,12 @@ describe("buildWorkUnitDetail", () => {
|
|||||||
const detail = buildWorkUnitDetail({
|
const detail = buildWorkUnitDetail({
|
||||||
issue: makeIssue({ status: "completed" }),
|
issue: makeIssue({ status: "completed" }),
|
||||||
issueEvents: events,
|
issueEvents: events,
|
||||||
|
linkedSignals: [makeLinkedSignal()],
|
||||||
});
|
});
|
||||||
expect(detail.stepCount).toBe(4);
|
expect(detail.stepCount).toBe(4);
|
||||||
expect(detail.artifactCount).toBe(2);
|
expect(detail.artifactCount).toBe(2);
|
||||||
expect(detail.artifactPaths).toEqual(["artifacts.md", "work.md"]);
|
expect(detail.artifactPaths).toEqual(["artifacts.md", "work.md"]);
|
||||||
expect(detail.signalCount).toBe(0);
|
expect(detail.signalCount).toBe(1);
|
||||||
expect(detail.hasPullRequest).toBe(true);
|
expect(detail.hasPullRequest).toBe(true);
|
||||||
expect(detail.pullRequestUrl).toBe("https://git.example.com/pulls/3");
|
expect(detail.pullRequestUrl).toBe("https://git.example.com/pulls/3");
|
||||||
expect(detail.pullRequestNumber).toBe(3);
|
expect(detail.pullRequestNumber).toBe(3);
|
||||||
@@ -272,6 +358,12 @@ describe("buildWorkUnitDetail", () => {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe("cross-issue isolation", () => {
|
describe("cross-issue isolation", () => {
|
||||||
|
const SIGNAL_A = makeLinkedSignal({ signalId: "sig-a" as Id<"signals"> });
|
||||||
|
const SIGNAL_B = makeLinkedSignal({
|
||||||
|
signalId: "sig-b" as Id<"signals">,
|
||||||
|
title: "Different signal",
|
||||||
|
});
|
||||||
|
|
||||||
const sharedEvents: Event[] = [
|
const sharedEvents: Event[] = [
|
||||||
makeEvent(
|
makeEvent(
|
||||||
"issue.created",
|
"issue.created",
|
||||||
@@ -316,6 +408,7 @@ describe("cross-issue isolation", () => {
|
|||||||
const cardA = buildWorkUnitCard({
|
const cardA = buildWorkUnitCard({
|
||||||
issue: makeIssue({ _id: ISSUE_A, number: 1 }),
|
issue: makeIssue({ _id: ISSUE_A, number: 1 }),
|
||||||
issueEvents: eventsA,
|
issueEvents: eventsA,
|
||||||
|
linkedSignals: [SIGNAL_A],
|
||||||
});
|
});
|
||||||
expect(cardA.artifactCount).toBe(1);
|
expect(cardA.artifactCount).toBe(1);
|
||||||
expect(cardA.stepCount).toBe(4);
|
expect(cardA.stepCount).toBe(4);
|
||||||
@@ -324,6 +417,7 @@ describe("cross-issue isolation", () => {
|
|||||||
const cardB = buildWorkUnitCard({
|
const cardB = buildWorkUnitCard({
|
||||||
issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }),
|
issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }),
|
||||||
issueEvents: eventsB,
|
issueEvents: eventsB,
|
||||||
|
linkedSignals: [SIGNAL_B],
|
||||||
});
|
});
|
||||||
expect(cardB.artifactCount).toBe(1);
|
expect(cardB.artifactCount).toBe(1);
|
||||||
expect(cardB.stepCount).toBe(3);
|
expect(cardB.stepCount).toBe(3);
|
||||||
@@ -334,18 +428,17 @@ describe("cross-issue isolation", () => {
|
|||||||
const cardB = buildWorkUnitCard({
|
const cardB = buildWorkUnitCard({
|
||||||
issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }),
|
issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }),
|
||||||
issueEvents: eventsB,
|
issueEvents: eventsB,
|
||||||
|
linkedSignals: [],
|
||||||
});
|
});
|
||||||
expect(cardB.hasPullRequest).toBe(false);
|
expect(cardB.hasPullRequest).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("issue A does not inherit issue B's summary", () => {
|
test("issue A does not inherit issue B's summary", () => {
|
||||||
const eventsA = eventsForIssue(sharedEvents, ISSUE_A);
|
const eventsA = eventsForIssue(sharedEvents, ISSUE_A);
|
||||||
const summaryA = extractLatestSummary(eventsA);
|
expect(extractLatestSummary(eventsA)).toBe("Done for A.");
|
||||||
expect(summaryA).toBe("Done for A.");
|
|
||||||
|
|
||||||
const eventsB = eventsForIssue(sharedEvents, ISSUE_B);
|
const eventsB = eventsForIssue(sharedEvents, ISSUE_B);
|
||||||
const summaryB = extractLatestSummary(eventsB);
|
expect(extractLatestSummary(eventsB)).toBe("Failed for B.");
|
||||||
expect(summaryB).toBe("Failed for B.");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("detail for issue A lists only issue A's artifact paths", () => {
|
test("detail for issue A lists only issue A's artifact paths", () => {
|
||||||
@@ -353,6 +446,7 @@ describe("cross-issue isolation", () => {
|
|||||||
const detailA = buildWorkUnitDetail({
|
const detailA = buildWorkUnitDetail({
|
||||||
issue: makeIssue({ _id: ISSUE_A, number: 1 }),
|
issue: makeIssue({ _id: ISSUE_A, number: 1 }),
|
||||||
issueEvents: eventsA,
|
issueEvents: eventsA,
|
||||||
|
linkedSignals: [SIGNAL_A],
|
||||||
});
|
});
|
||||||
expect(detailA.artifactPaths).toEqual(["work.md"]);
|
expect(detailA.artifactPaths).toEqual(["work.md"]);
|
||||||
expect(detailA.pullRequestNumber).toBe(5);
|
expect(detailA.pullRequestNumber).toBe(5);
|
||||||
@@ -363,60 +457,28 @@ describe("cross-issue isolation", () => {
|
|||||||
const detailB = buildWorkUnitDetail({
|
const detailB = buildWorkUnitDetail({
|
||||||
issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }),
|
issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }),
|
||||||
issueEvents: eventsB,
|
issueEvents: eventsB,
|
||||||
|
linkedSignals: [],
|
||||||
});
|
});
|
||||||
expect(detailB.artifactPaths).toEqual(["artifacts.md"]);
|
expect(detailB.artifactPaths).toEqual(["artifacts.md"]);
|
||||||
expect(detailB.hasPullRequest).toBe(false);
|
expect(detailB.hasPullRequest).toBe(false);
|
||||||
expect(detailB.pullRequestUrl).toBeNull();
|
expect(detailB.pullRequestUrl).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
test("issue A does not inherit issue B's linked signals", () => {
|
||||||
// Signal isolation: a project may have signals, but no Work Unit should
|
const detailA = buildWorkUnitDetail({
|
||||||
// claim them until a signal-to-issue relation is integrated.
|
issue: makeIssue({ _id: ISSUE_A, number: 1 }),
|
||||||
// ---------------------------------------------------------------------------
|
issueEvents: eventsForIssue(sharedEvents, ISSUE_A),
|
||||||
|
linkedSignals: [SIGNAL_A],
|
||||||
describe("signal isolation", () => {
|
|
||||||
test("card signalCount is 0 even when the project has signals", () => {
|
|
||||||
// Simulate a project that has signals — these are NOT passed to the
|
|
||||||
// card builder. The card must show 0 regardless.
|
|
||||||
const projectHasSignals = [
|
|
||||||
{
|
|
||||||
sourceCount: 3,
|
|
||||||
summary: "Three tickets about Safari",
|
|
||||||
title: "Users report OAuth crash",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
sourceCount: 1,
|
|
||||||
summary: "CI broke after merge",
|
|
||||||
title: "Build failure on staging",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const card = buildWorkUnitCard({
|
|
||||||
issue: makeIssue(),
|
|
||||||
issueEvents: [],
|
|
||||||
});
|
|
||||||
// The card never sees projectSignals — signalCount is always 0.
|
|
||||||
expect(card.signalCount).toBe(0);
|
|
||||||
expect(projectHasSignals.length).toBeGreaterThan(0);
|
|
||||||
});
|
});
|
||||||
|
expect(detailA.signalCount).toBe(1);
|
||||||
|
expect(detailA.linkedSignals[0].title).toBe("OAuth crash on Safari");
|
||||||
|
|
||||||
test("detail signalCount is 0 even when the project has signals", () => {
|
const detailB = buildWorkUnitDetail({
|
||||||
const projectHasSignals = [
|
issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }),
|
||||||
{
|
issueEvents: eventsForIssue(sharedEvents, ISSUE_B),
|
||||||
sourceCount: 3,
|
linkedSignals: [SIGNAL_B],
|
||||||
summary: "Three tickets",
|
|
||||||
title: "Users report OAuth crash",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const detail = buildWorkUnitDetail({
|
|
||||||
issue: makeIssue(),
|
|
||||||
issueEvents: [
|
|
||||||
makeEvent("issue.created", { number: 1 }, { createdAt: 1000 }),
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
expect(detail.signalCount).toBe(0);
|
expect(detailB.signalCount).toBe(1);
|
||||||
expect(projectHasSignals.length).toBeGreaterThan(0);
|
expect(detailB.linkedSignals[0].title).toBe("Different signal");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,16 +4,23 @@ import type { Doc, Id } from "@code/backend/convex/_generated/dataModel";
|
|||||||
// Work Unit card projection — pure functions that turn Convex documents into
|
// Work Unit card projection — pure functions that turn Convex documents into
|
||||||
// the collapsed/expanded Work Unit views used by the Work OS surface.
|
// the collapsed/expanded Work Unit views used by the Work OS surface.
|
||||||
//
|
//
|
||||||
// Every count is derived from issue-scoped events only. We never use
|
// Counts are derived from issue-scoped evidence: events for artifacts/steps/
|
||||||
// project-wide artifact lists or signal lists, because those would
|
// PRs, and authenticated signalIssueAttachments for linked Signals. We never
|
||||||
// incorrectly attribute evidence to every issue in the project. Signal
|
// use project-wide artifact lists or signal lists.
|
||||||
// linkage is intentionally 0 until a Lane A signal-to-issue relation is
|
|
||||||
// integrated.
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export type ProjectEvent = Doc<"projectEvents">;
|
export type ProjectEvent = Doc<"projectEvents">;
|
||||||
export type ProjectIssue = Doc<"projectIssues">;
|
export type ProjectIssue = Doc<"projectIssues">;
|
||||||
|
|
||||||
|
/** A Signal linked to a Work Unit via signalIssueAttachments. */
|
||||||
|
export interface LinkedSignal {
|
||||||
|
readonly signalId: Id<"signals">;
|
||||||
|
readonly title: string;
|
||||||
|
readonly summary: string;
|
||||||
|
readonly sourceCount: number;
|
||||||
|
readonly createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorkUnitCard {
|
export interface WorkUnitCard {
|
||||||
readonly issueId: Id<"projectIssues">;
|
readonly issueId: Id<"projectIssues">;
|
||||||
readonly number: number;
|
readonly number: number;
|
||||||
@@ -44,6 +51,7 @@ export interface WorkUnitDetail {
|
|||||||
readonly artifactCount: number;
|
readonly artifactCount: number;
|
||||||
readonly artifactPaths: readonly string[];
|
readonly artifactPaths: readonly string[];
|
||||||
readonly signalCount: number;
|
readonly signalCount: number;
|
||||||
|
readonly linkedSignals: readonly LinkedSignal[];
|
||||||
readonly hasPullRequest: boolean;
|
readonly hasPullRequest: boolean;
|
||||||
readonly pullRequestUrl: string | null;
|
readonly pullRequestUrl: string | null;
|
||||||
readonly pullRequestNumber: number | null;
|
readonly pullRequestNumber: number | null;
|
||||||
@@ -148,6 +156,10 @@ const ACTIVITY_HANDLERS: Record<
|
|||||||
detail: `Queued #${data.number ?? "?"} for the project manager`,
|
detail: `Queued #${data.number ?? "?"} for the project manager`,
|
||||||
label: "Work queued",
|
label: "Work queued",
|
||||||
}),
|
}),
|
||||||
|
"signal.attached": (data) => ({
|
||||||
|
detail: `Signal "${getString(data, "signalProblemTitle", "Unknown")}" linked to this Work Unit`,
|
||||||
|
label: "Signal linked",
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const projectEventToActivity = (event: ProjectEvent): ActivityProjection => {
|
const projectEventToActivity = (event: ProjectEvent): ActivityProjection => {
|
||||||
@@ -246,15 +258,17 @@ export const extractArtifactPaths = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Card builder — derives all counts from issue-scoped events only
|
// Card builder — derives counts from issue-scoped events + linked signals
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export const buildWorkUnitCard = ({
|
export const buildWorkUnitCard = ({
|
||||||
issue,
|
issue,
|
||||||
issueEvents,
|
issueEvents,
|
||||||
|
linkedSignals,
|
||||||
}: {
|
}: {
|
||||||
readonly issue: ProjectIssue;
|
readonly issue: ProjectIssue;
|
||||||
readonly issueEvents: readonly ProjectEvent[];
|
readonly issueEvents: readonly ProjectEvent[];
|
||||||
|
readonly linkedSignals: readonly LinkedSignal[];
|
||||||
}): WorkUnitCard => {
|
}): WorkUnitCard => {
|
||||||
const sorted = [...issueEvents].toSorted((a, b) => b.createdAt - a.createdAt);
|
const sorted = [...issueEvents].toSorted((a, b) => b.createdAt - a.createdAt);
|
||||||
const [latestEvent] = sorted;
|
const [latestEvent] = sorted;
|
||||||
@@ -272,9 +286,7 @@ export const buildWorkUnitCard = ({
|
|||||||
issueId: issue._id,
|
issueId: issue._id,
|
||||||
needsInput: issue.status === "needs-input",
|
needsInput: issue.status === "needs-input",
|
||||||
number: issue.number,
|
number: issue.number,
|
||||||
// No signal-to-issue relation exists in the event model yet; Lane A will
|
signalCount: linkedSignals.length,
|
||||||
// add one. Show 0 rather than falsely claiming project-wide signals.
|
|
||||||
signalCount: 0,
|
|
||||||
status: issue.status,
|
status: issue.status,
|
||||||
stepCount: issueEvents.length,
|
stepCount: issueEvents.length,
|
||||||
summary,
|
summary,
|
||||||
@@ -284,15 +296,17 @@ export const buildWorkUnitCard = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Detail builder — same per-issue derivation, plus artifact paths
|
// Detail builder — same per-issue derivation, plus linked signals
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export const buildWorkUnitDetail = ({
|
export const buildWorkUnitDetail = ({
|
||||||
issue,
|
issue,
|
||||||
issueEvents,
|
issueEvents,
|
||||||
|
linkedSignals,
|
||||||
}: {
|
}: {
|
||||||
readonly issue: ProjectIssue;
|
readonly issue: ProjectIssue;
|
||||||
readonly issueEvents: readonly ProjectEvent[];
|
readonly issueEvents: readonly ProjectEvent[];
|
||||||
|
readonly linkedSignals: readonly LinkedSignal[];
|
||||||
}): WorkUnitDetail => {
|
}): WorkUnitDetail => {
|
||||||
const activity = buildActivityTimeline(issueEvents);
|
const activity = buildActivityTimeline(issueEvents);
|
||||||
const prInfo = extractPullRequestFromEvents(issueEvents);
|
const prInfo = extractPullRequestFromEvents(issueEvents);
|
||||||
@@ -305,10 +319,11 @@ export const buildWorkUnitDetail = ({
|
|||||||
hasPullRequest: prInfo.hasPR,
|
hasPullRequest: prInfo.hasPR,
|
||||||
issue,
|
issue,
|
||||||
latestSummary: extractLatestSummary(issueEvents),
|
latestSummary: extractLatestSummary(issueEvents),
|
||||||
|
linkedSignals,
|
||||||
needsInput: issue.status === "needs-input",
|
needsInput: issue.status === "needs-input",
|
||||||
pullRequestNumber: prInfo.number,
|
pullRequestNumber: prInfo.number,
|
||||||
pullRequestUrl: prInfo.url,
|
pullRequestUrl: prInfo.url,
|
||||||
signalCount: 0,
|
signalCount: linkedSignals.length,
|
||||||
stepCount: issueEvents.length,
|
stepCount: issueEvents.length,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
type QueryCtx,
|
type QueryCtx,
|
||||||
query,
|
query,
|
||||||
} from "./_generated/server";
|
} from "./_generated/server";
|
||||||
import { requireOrganizationMember } from "./authz";
|
import { requireOrganizationMember, requireProjectMember } from "./authz";
|
||||||
|
|
||||||
const PROBLEM_STATEMENT = v.object({
|
const PROBLEM_STATEMENT = v.object({
|
||||||
title: v.string(),
|
title: v.string(),
|
||||||
@@ -435,3 +435,70 @@ export const get = query({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Linked Signal query for the Work OS surface.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface LinkedSignalView {
|
||||||
|
readonly signalId: Id<"signals">;
|
||||||
|
readonly title: string;
|
||||||
|
readonly summary: string;
|
||||||
|
readonly sourceCount: number;
|
||||||
|
readonly createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all Signal-issue attachments for a project, grouped by issue id.
|
||||||
|
* User-authenticated: the caller must be a member of the project organization.
|
||||||
|
* Used by the Work OS card list to show per-issue linked Signal counts.
|
||||||
|
*/
|
||||||
|
export const listLinkedSignalsForProject = query({
|
||||||
|
args: {
|
||||||
|
projectId: v.id("projects"),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args): Promise<Record<string, LinkedSignalView[]>> => {
|
||||||
|
const { organizationId } = await requireProjectMember(ctx, args.projectId);
|
||||||
|
const issues = await ctx.db
|
||||||
|
.query("projectIssues")
|
||||||
|
.withIndex("by_project_and_number", (q) =>
|
||||||
|
q.eq("projectId", args.projectId)
|
||||||
|
)
|
||||||
|
.take(100);
|
||||||
|
const result: Record<string, LinkedSignalView[]> = {};
|
||||||
|
for (const issue of issues) {
|
||||||
|
const attachments = await ctx.db
|
||||||
|
.query("signalIssueAttachments")
|
||||||
|
.withIndex("by_issue", (q) => q.eq("issueId", issue._id))
|
||||||
|
.collect();
|
||||||
|
if (attachments.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const signals = await Promise.all(
|
||||||
|
attachments.map(async (attachment) => {
|
||||||
|
const signal = await ctx.db.get(attachment.signalId);
|
||||||
|
if (!signal || signal.organizationId !== organizationId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const sources = await ctx.db
|
||||||
|
.query("signalSources")
|
||||||
|
.withIndex("by_signal_and_ordinal", (q) =>
|
||||||
|
q.eq("signalId", signal._id)
|
||||||
|
)
|
||||||
|
.collect();
|
||||||
|
return {
|
||||||
|
createdAt: signal.createdAt,
|
||||||
|
signalId: signal._id,
|
||||||
|
sourceCount: sources.length,
|
||||||
|
summary: signal.problemStatement.summary,
|
||||||
|
title: signal.problemStatement.title,
|
||||||
|
} satisfies LinkedSignalView;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
result[String(issue._id)] = signals.filter(
|
||||||
|
(entry): entry is LinkedSignalView => entry !== null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user