mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
Show terminal blockers in blocked issue notice
This commit is contained in:
1 parent
1439708ea0
commit
98f6136a3e
5 files changed
+243
-34
No files matched your search
@@ -116,6 +116,7 @@ export interface IssueRelationIssueSummary {
|
||||
priority: IssuePriority;
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
terminalBlockers?: IssueRelationIssueSummary[];
|
||||
}
|
||||
|
||||
export type IssueBlockerAttentionState = "none" | "covered" | "needs_attention";
|
||||
|
||||
@@ -1401,6 +1401,49 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
|
||||
expect(blockedRelations.blockedBy.map((relation) => relation.id)).toEqual([blockerId]);
|
||||
});
|
||||
|
||||
it("adds terminal blockers to immediate blocked-by summaries", async () => {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
const issueA = randomUUID();
|
||||
const issueB = randomUUID();
|
||||
const issueC = randomUUID();
|
||||
const issueD = randomUUID();
|
||||
await db.insert(issues).values([
|
||||
{ id: issueA, companyId, identifier: "PAP-1", title: "Issue A", status: "blocked", priority: "medium" },
|
||||
{ id: issueB, companyId, identifier: "PAP-2", title: "Issue B", status: "blocked", priority: "medium" },
|
||||
{ id: issueC, companyId, identifier: "PAP-3", title: "Issue C", status: "blocked", priority: "medium" },
|
||||
{ id: issueD, companyId, identifier: "PAP-4", title: "Issue D", status: "todo", priority: "high" },
|
||||
]);
|
||||
|
||||
await svc.update(issueC, { blockedByIssueIds: [issueD] });
|
||||
await svc.update(issueB, { blockedByIssueIds: [issueC] });
|
||||
await svc.update(issueA, { blockedByIssueIds: [issueB] });
|
||||
|
||||
const relations = await svc.getRelationSummaries(issueA);
|
||||
|
||||
expect(relations.blockedBy).toHaveLength(1);
|
||||
expect(relations.blockedBy[0]).toMatchObject({
|
||||
id: issueB,
|
||||
identifier: "PAP-2",
|
||||
title: "Issue B",
|
||||
terminalBlockers: [
|
||||
expect.objectContaining({
|
||||
id: issueD,
|
||||
identifier: "PAP-4",
|
||||
title: "Issue D",
|
||||
status: "todo",
|
||||
priority: "high",
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects blocking cycles", async () => {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
|
||||
+117
-18
@@ -763,6 +763,109 @@ function appendBlockerAttentionEdges(
|
||||
}
|
||||
}
|
||||
|
||||
type IssueRelationSummaryRow = {
|
||||
relatedId: string;
|
||||
identifier: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
};
|
||||
|
||||
function summarizeIssueRelationRow(row: IssueRelationSummaryRow): IssueRelationIssueSummary {
|
||||
return {
|
||||
id: row.relatedId,
|
||||
identifier: row.identifier,
|
||||
title: row.title,
|
||||
status: row.status as IssueRelationIssueSummary["status"],
|
||||
priority: row.priority as IssueRelationIssueSummary["priority"],
|
||||
assigneeAgentId: row.assigneeAgentId,
|
||||
assigneeUserId: row.assigneeUserId,
|
||||
};
|
||||
}
|
||||
|
||||
async function terminalExplicitBlockersByRoot(
|
||||
companyId: string,
|
||||
roots: IssueRelationIssueSummary[],
|
||||
dbOrTx: DbReader,
|
||||
): Promise<Map<string, IssueRelationIssueSummary[]>> {
|
||||
const rootIds = [...new Set(roots.map((root) => root.id))];
|
||||
const terminalByRoot = new Map<string, IssueRelationIssueSummary[]>();
|
||||
if (rootIds.length === 0) return terminalByRoot;
|
||||
|
||||
const nodesById = new Map<string, IssueRelationIssueSummary>();
|
||||
const edgesByIssueId = new Map<string, string[]>();
|
||||
for (const root of roots) nodesById.set(root.id, root);
|
||||
|
||||
let frontier = rootIds;
|
||||
for (let depth = 0; frontier.length > 0 && depth < BLOCKER_ATTENTION_MAX_DEPTH; depth += 1) {
|
||||
const nextFrontier = new Set<string>();
|
||||
for (const chunk of chunkList([...new Set(frontier)], ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) {
|
||||
const rows = await dbOrTx
|
||||
.select({
|
||||
currentIssueId: issueRelations.relatedIssueId,
|
||||
relatedId: issues.id,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
priority: issues.priority,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
})
|
||||
.from(issueRelations)
|
||||
.innerJoin(issues, eq(issueRelations.issueId, issues.id))
|
||||
.where(
|
||||
and(
|
||||
eq(issueRelations.companyId, companyId),
|
||||
eq(issueRelations.type, "blocks"),
|
||||
inArray(issueRelations.relatedIssueId, chunk),
|
||||
eq(issues.companyId, companyId),
|
||||
ne(issues.status, "done"),
|
||||
),
|
||||
);
|
||||
|
||||
for (const row of rows) {
|
||||
const existingEdges = edgesByIssueId.get(row.currentIssueId) ?? [];
|
||||
if (!existingEdges.includes(row.relatedId)) {
|
||||
existingEdges.push(row.relatedId);
|
||||
edgesByIssueId.set(row.currentIssueId, existingEdges);
|
||||
}
|
||||
if (!nodesById.has(row.relatedId)) {
|
||||
nodesById.set(row.relatedId, summarizeIssueRelationRow(row));
|
||||
nextFrontier.add(row.relatedId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (nodesById.size > BLOCKER_ATTENTION_MAX_NODES) break;
|
||||
frontier = [...nextFrontier];
|
||||
}
|
||||
|
||||
const collectTerminal = (issueId: string, seen: Set<string>): IssueRelationIssueSummary[] => {
|
||||
if (seen.has(issueId)) return [];
|
||||
const node = nodesById.get(issueId);
|
||||
if (!node || node.status === "done") return [];
|
||||
const nextSeen = new Set(seen);
|
||||
nextSeen.add(issueId);
|
||||
const downstreamIds = edgesByIssueId.get(issueId) ?? [];
|
||||
if (downstreamIds.length === 0) return [node];
|
||||
return downstreamIds.flatMap((downstreamId) => collectTerminal(downstreamId, nextSeen));
|
||||
};
|
||||
|
||||
for (const rootId of rootIds) {
|
||||
const deduped = new Map<string, IssueRelationIssueSummary>();
|
||||
for (const blocker of collectTerminal(rootId, new Set())) {
|
||||
if (blocker.id !== rootId) deduped.set(blocker.id, blocker);
|
||||
}
|
||||
if (deduped.size > 0) {
|
||||
terminalByRoot.set(rootId, [...deduped.values()].sort((a, b) => a.title.localeCompare(b.title)));
|
||||
}
|
||||
}
|
||||
|
||||
return terminalByRoot;
|
||||
}
|
||||
|
||||
async function listIssueBlockerAttentionMap(
|
||||
dbOrTx: any,
|
||||
companyId: string,
|
||||
@@ -1425,30 +1528,26 @@ export function issueService(db: Db) {
|
||||
]);
|
||||
|
||||
for (const row of blockedByRows) {
|
||||
empty.get(row.currentIssueId)?.blockedBy.push({
|
||||
id: row.relatedId,
|
||||
identifier: row.identifier,
|
||||
title: row.title,
|
||||
status: row.status as IssueRelationIssueSummary["status"],
|
||||
priority: row.priority as IssueRelationIssueSummary["priority"],
|
||||
assigneeAgentId: row.assigneeAgentId,
|
||||
assigneeUserId: row.assigneeUserId,
|
||||
});
|
||||
empty.get(row.currentIssueId)?.blockedBy.push(summarizeIssueRelationRow(row));
|
||||
}
|
||||
for (const row of blockingRows) {
|
||||
empty.get(row.currentIssueId)?.blocks.push({
|
||||
id: row.relatedId,
|
||||
identifier: row.identifier,
|
||||
title: row.title,
|
||||
status: row.status as IssueRelationIssueSummary["status"],
|
||||
priority: row.priority as IssueRelationIssueSummary["priority"],
|
||||
assigneeAgentId: row.assigneeAgentId,
|
||||
assigneeUserId: row.assigneeUserId,
|
||||
});
|
||||
empty.get(row.currentIssueId)?.blocks.push(summarizeIssueRelationRow(row));
|
||||
}
|
||||
|
||||
const terminalByRoot = await terminalExplicitBlockersByRoot(
|
||||
companyId,
|
||||
[...empty.values()].flatMap((relations) => relations.blockedBy),
|
||||
dbOrTx,
|
||||
);
|
||||
|
||||
for (const relations of empty.values()) {
|
||||
relations.blockedBy.sort((a, b) => a.title.localeCompare(b.title));
|
||||
for (const blocker of relations.blockedBy) {
|
||||
const terminalBlockers = terminalByRoot.get(blocker.id);
|
||||
if (terminalBlockers && terminalBlockers.length > 0) {
|
||||
blocker.terminalBlockers = terminalBlockers;
|
||||
}
|
||||
}
|
||||
relations.blocks.sort((a, b) => a.title.localeCompare(b.title));
|
||||
}
|
||||
|
||||
|
||||
@@ -403,6 +403,59 @@ describe("IssueChatThread", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows terminal blocker context when an immediate blocker is transitively blocked", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={[]}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[]}
|
||||
issueStatus="blocked"
|
||||
blockedBy={[
|
||||
{
|
||||
id: "blocker-1",
|
||||
identifier: "PAP-2167",
|
||||
title: "Phase 7 review",
|
||||
status: "blocked",
|
||||
priority: "medium",
|
||||
assigneeAgentId: "agent-1",
|
||||
assigneeUserId: null,
|
||||
terminalBlockers: [
|
||||
{
|
||||
id: "terminal-1",
|
||||
identifier: "PAP-2201",
|
||||
title: "Security sign-off",
|
||||
status: "todo",
|
||||
priority: "high",
|
||||
assigneeAgentId: "agent-2",
|
||||
assigneeUserId: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
onAdd={async () => {}}
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("PAP-2167");
|
||||
expect(container.textContent).toContain("Phase 7 review");
|
||||
expect(container.textContent).toContain("Ultimately waiting on");
|
||||
expect(container.textContent).toContain("PAP-2201");
|
||||
expect(container.textContent).toContain("Security sign-off");
|
||||
expect(container.querySelector('[data-issue-path-id="PAP-2201"]')).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows paused assigned agent context above the composer", () => {
|
||||
const root = createRoot(container);
|
||||
const pausedAgent = {
|
||||
|
||||
@@ -354,6 +354,26 @@ function IssueBlockedNotice({
|
||||
if (blockers.length === 0 && issueStatus !== "blocked") return null;
|
||||
|
||||
const blockerLabel = blockers.length === 1 ? "the linked issue" : "the linked issues";
|
||||
const terminalBlockers = blockers
|
||||
.flatMap((blocker) => blocker.terminalBlockers ?? [])
|
||||
.filter((blocker, index, all) => all.findIndex((candidate) => candidate.id === blocker.id) === index);
|
||||
|
||||
const renderBlockerChip = (blocker: IssueRelationIssueSummary) => {
|
||||
const issuePathId = blocker.identifier ?? blocker.id;
|
||||
return (
|
||||
<IssueLinkQuicklook
|
||||
key={blocker.id}
|
||||
issuePathId={issuePathId}
|
||||
to={createIssueDetailPath(issuePathId)}
|
||||
className="inline-flex max-w-full items-center gap-1 rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 font-mono text-xs text-amber-950 transition-colors hover:border-amber-500 hover:bg-amber-100 hover:underline dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100 dark:hover:bg-amber-500/15"
|
||||
>
|
||||
<span>{blocker.identifier ?? blocker.id.slice(0, 8)}</span>
|
||||
<span className="max-w-[18rem] truncate font-sans text-[11px] text-amber-800 dark:text-amber-200">
|
||||
{blocker.title}
|
||||
</span>
|
||||
</IssueLinkQuicklook>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-3 rounded-md border border-amber-300/70 bg-amber-50/90 px-3 py-2.5 text-sm text-amber-950 shadow-sm dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100">
|
||||
@@ -367,22 +387,15 @@ function IssueBlockedNotice({
|
||||
</p>
|
||||
{blockers.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{blockers.map((blocker) => {
|
||||
const issuePathId = blocker.identifier ?? blocker.id;
|
||||
return (
|
||||
<IssueLinkQuicklook
|
||||
key={blocker.id}
|
||||
issuePathId={issuePathId}
|
||||
to={createIssueDetailPath(issuePathId)}
|
||||
className="inline-flex max-w-full items-center gap-1 rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 font-mono text-xs text-amber-950 transition-colors hover:border-amber-500 hover:bg-amber-100 hover:underline dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100 dark:hover:bg-amber-500/15"
|
||||
>
|
||||
<span>{blocker.identifier ?? blocker.id.slice(0, 8)}</span>
|
||||
<span className="max-w-[18rem] truncate font-sans text-[11px] text-amber-800 dark:text-amber-200">
|
||||
{blocker.title}
|
||||
</span>
|
||||
</IssueLinkQuicklook>
|
||||
);
|
||||
})}
|
||||
{blockers.map(renderBlockerChip)}
|
||||
</div>
|
||||
) : null}
|
||||
{terminalBlockers.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
|
||||
<span className="text-xs font-medium text-amber-800 dark:text-amber-200">
|
||||
Ultimately waiting on
|
||||
</span>
|
||||
{terminalBlockers.map(renderBlockerChip)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user