mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
Add issue work product deliverables view
This commit is contained in:
1 parent
16b2b84d84
commit
96b98c58e8
21 files changed
+1261
No files matched your search
@@ -267,6 +267,13 @@ export type {
|
||||
IssueWorkProductProvider,
|
||||
IssueWorkProductStatus,
|
||||
IssueWorkProductReviewState,
|
||||
IssueDeliverableSourceType,
|
||||
IssueDeliverableKind,
|
||||
IssueDeliverableItem,
|
||||
IssueDeliverablesRuntimeServiceSummary,
|
||||
IssueDeliverablesWorkspaceSummary,
|
||||
IssueDeliverablesSummary,
|
||||
IssueDeliverablesResponse,
|
||||
Issue,
|
||||
IssueAssigneeAdapterOverrides,
|
||||
IssueRelation,
|
||||
|
||||
@@ -99,6 +99,15 @@ export type {
|
||||
IssueWorkProductStatus,
|
||||
IssueWorkProductReviewState,
|
||||
} from "./work-product.js";
|
||||
export type {
|
||||
IssueDeliverableSourceType,
|
||||
IssueDeliverableKind,
|
||||
IssueDeliverableItem,
|
||||
IssueDeliverablesRuntimeServiceSummary,
|
||||
IssueDeliverablesWorkspaceSummary,
|
||||
IssueDeliverablesSummary,
|
||||
IssueDeliverablesResponse,
|
||||
} from "./issue-deliverables.js";
|
||||
export type {
|
||||
Issue,
|
||||
IssueAssigneeAdapterOverrides,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { ExecutionWorkspaceProviderType, ExecutionWorkspaceStatus } from "./workspace-runtime.js";
|
||||
import type { IssueWorkProductType } from "./work-product.js";
|
||||
|
||||
export type IssueDeliverableSourceType =
|
||||
| "work_product"
|
||||
| "document"
|
||||
| "attachment"
|
||||
| "legacy_plan_document";
|
||||
|
||||
export type IssueDeliverableKind = IssueWorkProductType | "attachment";
|
||||
|
||||
export interface IssueDeliverableItem {
|
||||
id: string;
|
||||
sourceType: IssueDeliverableSourceType;
|
||||
kind: IssueDeliverableKind;
|
||||
title: string;
|
||||
url: string | null;
|
||||
summary: string | null;
|
||||
status: string | null;
|
||||
reviewState: string | null;
|
||||
healthStatus: "unknown" | "healthy" | "unhealthy" | null;
|
||||
provider: string | null;
|
||||
updatedAt: Date;
|
||||
createdAt: Date | null;
|
||||
isPrimary: boolean;
|
||||
metadata: Record<string, unknown> | null;
|
||||
documentKey: string | null;
|
||||
revisionNumber: number | null;
|
||||
contentType: string | null;
|
||||
byteSize: number | null;
|
||||
}
|
||||
|
||||
export interface IssueDeliverablesRuntimeServiceSummary {
|
||||
id: string;
|
||||
serviceName: string;
|
||||
status: string;
|
||||
healthStatus: "unknown" | "healthy" | "unhealthy";
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
export interface IssueDeliverablesWorkspaceSummary {
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectWorkspaceId: string | null;
|
||||
name: string;
|
||||
mode: string;
|
||||
status: ExecutionWorkspaceStatus;
|
||||
providerType: ExecutionWorkspaceProviderType;
|
||||
branchName: string | null;
|
||||
baseRef: string | null;
|
||||
lastUsedAt: Date;
|
||||
runtimeServiceCount: number;
|
||||
runtimeServiceHealth: "unknown" | "healthy" | "unhealthy" | null;
|
||||
runtimeServices: IssueDeliverablesRuntimeServiceSummary[];
|
||||
}
|
||||
|
||||
export interface IssueDeliverablesSummary {
|
||||
hasAny: boolean;
|
||||
previewCount: number;
|
||||
pullRequestCount: number;
|
||||
branchCount: number;
|
||||
commitCount: number;
|
||||
documentCount: number;
|
||||
fileCount: number;
|
||||
workspaceMode: string | null;
|
||||
workspaceStatus: ExecutionWorkspaceStatus | null;
|
||||
previewHealth: "unknown" | "healthy" | "unhealthy" | null;
|
||||
pullRequestStatus: string | null;
|
||||
pullRequestReviewState: string | null;
|
||||
}
|
||||
|
||||
export interface IssueDeliverablesResponse {
|
||||
workspace: IssueDeliverablesWorkspaceSummary | null;
|
||||
summary: IssueDeliverablesSummary;
|
||||
primaryItem: IssueDeliverableItem | null;
|
||||
previews: IssueDeliverableItem[];
|
||||
pullRequests: IssueDeliverableItem[];
|
||||
branches: IssueDeliverableItem[];
|
||||
commits: IssueDeliverableItem[];
|
||||
documents: IssueDeliverableItem[];
|
||||
files: IssueDeliverableItem[];
|
||||
}
|
||||
@@ -49,6 +49,9 @@ vi.mock("../services/index.js", () => ({
|
||||
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueDeliverableService: () => ({
|
||||
getForIssue: vi.fn(async () => null),
|
||||
}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => ({}),
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ExecutionWorkspace, IssueDocument, IssueWorkProduct } from "@paperclipai/shared";
|
||||
import { buildIssueDeliverables } from "../services/issue-deliverables.ts";
|
||||
|
||||
function createIssueDocument(overrides: Partial<IssueDocument> = {}): IssueDocument {
|
||||
return {
|
||||
id: "document-1",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
key: "plan",
|
||||
title: null,
|
||||
format: "markdown",
|
||||
body: "# Plan\n\nShip the thing.",
|
||||
latestRevisionId: "revision-1",
|
||||
latestRevisionNumber: 1,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-1",
|
||||
updatedByAgentId: null,
|
||||
updatedByUserId: "user-1",
|
||||
createdAt: new Date("2026-04-01T12:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-01T12:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkProduct(overrides: Partial<IssueWorkProduct> = {}): IssueWorkProduct {
|
||||
return {
|
||||
id: "work-product-1",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
issueId: "issue-1",
|
||||
executionWorkspaceId: "workspace-1",
|
||||
runtimeServiceId: null,
|
||||
type: "pull_request",
|
||||
provider: "github",
|
||||
externalId: null,
|
||||
title: "PR 42",
|
||||
url: "https://example.com/pr/42",
|
||||
status: "ready_for_review",
|
||||
reviewState: "needs_board_review",
|
||||
isPrimary: false,
|
||||
healthStatus: "unknown",
|
||||
summary: "Ready for board review.",
|
||||
metadata: null,
|
||||
createdByRunId: null,
|
||||
createdAt: new Date("2026-04-01T13:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-01T13:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkspace(overrides: Partial<ExecutionWorkspace> = {}): ExecutionWorkspace {
|
||||
return {
|
||||
id: "workspace-1",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "project-workspace-1",
|
||||
sourceIssueId: "issue-1",
|
||||
mode: "adapter_managed",
|
||||
strategyType: "adapter_managed",
|
||||
name: "Remote sandbox",
|
||||
status: "active",
|
||||
cwd: null,
|
||||
repoUrl: "https://github.com/paperclipai/paperclip",
|
||||
baseRef: "main",
|
||||
branchName: "pap-1280-work-product",
|
||||
providerType: "adapter_managed",
|
||||
providerRef: "sandbox-42",
|
||||
derivedFromExecutionWorkspaceId: null,
|
||||
lastUsedAt: new Date("2026-04-01T14:00:00.000Z"),
|
||||
openedAt: new Date("2026-04-01T12:00:00.000Z"),
|
||||
closedAt: null,
|
||||
cleanupEligibleAt: null,
|
||||
cleanupReason: null,
|
||||
config: null,
|
||||
metadata: null,
|
||||
runtimeServices: [
|
||||
{
|
||||
id: "service-1",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "project-workspace-1",
|
||||
executionWorkspaceId: "workspace-1",
|
||||
issueId: "issue-1",
|
||||
scopeType: "execution_workspace",
|
||||
scopeId: "workspace-1",
|
||||
serviceName: "Preview",
|
||||
status: "running",
|
||||
lifecycle: "ephemeral",
|
||||
reuseKey: null,
|
||||
command: null,
|
||||
cwd: null,
|
||||
port: null,
|
||||
url: "https://preview.example.com",
|
||||
provider: "adapter_managed",
|
||||
providerRef: "preview-1",
|
||||
ownerAgentId: null,
|
||||
startedByRunId: null,
|
||||
lastUsedAt: new Date("2026-04-01T14:00:00.000Z"),
|
||||
startedAt: new Date("2026-04-01T12:30:00.000Z"),
|
||||
stoppedAt: null,
|
||||
stopPolicy: null,
|
||||
healthStatus: "healthy",
|
||||
createdAt: new Date("2026-04-01T12:30:00.000Z"),
|
||||
updatedAt: new Date("2026-04-01T14:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
createdAt: new Date("2026-04-01T12:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-01T14:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildIssueDeliverables", () => {
|
||||
it("pins the plan first in the documents list while choosing the newest document as primary", () => {
|
||||
const response = buildIssueDeliverables({
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
projectId: "project-1",
|
||||
description: null,
|
||||
executionWorkspaceId: null,
|
||||
createdAt: new Date("2026-04-01T12:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-01T15:00:00.000Z"),
|
||||
},
|
||||
workspace: null,
|
||||
workProducts: [],
|
||||
documents: [
|
||||
createIssueDocument(),
|
||||
createIssueDocument({
|
||||
id: "document-2",
|
||||
key: "report",
|
||||
title: "Weekly report",
|
||||
body: "Latest findings.",
|
||||
updatedAt: new Date("2026-04-01T16:00:00.000Z"),
|
||||
}),
|
||||
],
|
||||
legacyPlanDocument: null,
|
||||
attachments: [],
|
||||
});
|
||||
|
||||
expect(response.summary.documentCount).toBe(2);
|
||||
expect(response.documents[0]?.documentKey).toBe("plan");
|
||||
expect(response.primaryItem?.title).toBe("Weekly report");
|
||||
});
|
||||
|
||||
it("prefers explicit primary work products and merges artifacts with attachments into files", () => {
|
||||
const response = buildIssueDeliverables({
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
projectId: "project-1",
|
||||
description: null,
|
||||
executionWorkspaceId: "workspace-1",
|
||||
createdAt: new Date("2026-04-01T12:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-01T15:00:00.000Z"),
|
||||
},
|
||||
workspace: createWorkspace(),
|
||||
workProducts: [
|
||||
createWorkProduct({ isPrimary: true }),
|
||||
createWorkProduct({
|
||||
id: "work-product-2",
|
||||
type: "preview_url",
|
||||
title: "Preview deploy",
|
||||
provider: "vercel",
|
||||
url: "https://preview.example.com",
|
||||
healthStatus: "healthy",
|
||||
status: "active",
|
||||
reviewState: "none",
|
||||
updatedAt: new Date("2026-04-01T14:30:00.000Z"),
|
||||
}),
|
||||
createWorkProduct({
|
||||
id: "work-product-3",
|
||||
type: "artifact",
|
||||
title: "Run report.pdf",
|
||||
provider: "paperclip",
|
||||
url: "/api/attachments/artifact/content",
|
||||
status: "ready_for_review",
|
||||
reviewState: "none",
|
||||
metadata: { contentType: "application/pdf", byteSize: 2048 },
|
||||
}),
|
||||
],
|
||||
documents: [],
|
||||
legacyPlanDocument: null,
|
||||
attachments: [
|
||||
{
|
||||
id: "attachment-1",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
issueCommentId: null,
|
||||
assetId: "asset-1",
|
||||
provider: "paperclip",
|
||||
objectKey: "attachments/build-log.txt",
|
||||
contentType: "text/plain",
|
||||
byteSize: 512,
|
||||
sha256: "abc",
|
||||
originalFilename: "build-log.txt",
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-1",
|
||||
createdAt: new Date("2026-04-01T15:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-01T15:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(response.primaryItem?.id).toBe("work-product-1");
|
||||
expect(response.summary.previewCount).toBe(1);
|
||||
expect(response.summary.fileCount).toBe(2);
|
||||
expect(response.workspace?.runtimeServiceCount).toBe(1);
|
||||
expect(response.workspace?.runtimeServiceHealth).toBe("healthy");
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,9 @@ vi.mock("../services/index.js", () => ({
|
||||
listCompanyIds: vi.fn(),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueDeliverableService: () => ({
|
||||
getForIssue: vi.fn(async () => null),
|
||||
}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: vi.fn(async () => undefined),
|
||||
projectService: () => ({
|
||||
|
||||
@@ -42,6 +42,9 @@ vi.mock("../services/index.js", () => ({
|
||||
getGeneral: vi.fn(async () => ({ feedbackDataSharingPreference: "prompt" })),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueDeliverableService: () => ({
|
||||
getForIssue: vi.fn(async () => null),
|
||||
}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => ({}),
|
||||
|
||||
@@ -50,6 +50,9 @@ function registerModuleMocks() {
|
||||
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueDeliverableService: () => ({
|
||||
getForIssue: vi.fn(async () => null),
|
||||
}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: vi.fn(async () => undefined),
|
||||
projectService: () => ({}),
|
||||
|
||||
@@ -53,6 +53,9 @@ vi.mock("../services/index.js", () => ({
|
||||
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueDeliverableService: () => ({
|
||||
getForIssue: vi.fn(async () => null),
|
||||
}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: vi.fn(async () => undefined),
|
||||
projectService: () => ({}),
|
||||
|
||||
@@ -59,6 +59,9 @@ vi.mock("../services/index.js", () => ({
|
||||
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueDeliverableService: () => ({
|
||||
getForIssue: vi.fn(async () => null),
|
||||
}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: vi.fn(async () => undefined),
|
||||
projectService: () => mockProjectService,
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
issueApprovalService,
|
||||
ISSUE_LIST_DEFAULT_LIMIT,
|
||||
ISSUE_LIST_MAX_LIMIT,
|
||||
issueDeliverableService,
|
||||
issueService,
|
||||
clampIssueListLimit,
|
||||
documentService,
|
||||
@@ -313,6 +314,7 @@ export function issueRoutes(
|
||||
const issueApprovalsSvc = issueApprovalService(db);
|
||||
const executionWorkspacesSvc = executionWorkspaceService(db);
|
||||
const workProductsSvc = workProductService(db);
|
||||
const issueDeliverablesSvc = issueDeliverableService(db);
|
||||
const documentsSvc = documentService(db);
|
||||
const routinesSvc = routineService(db);
|
||||
const feedbackExportService = opts?.feedbackExportService;
|
||||
@@ -2195,6 +2197,18 @@ export function issueRoutes(
|
||||
res.json(released);
|
||||
});
|
||||
|
||||
router.get("/issues/:id/deliverables", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const deliverables = await issueDeliverablesSvc.getForIssue(issue);
|
||||
res.json(deliverables);
|
||||
});
|
||||
|
||||
router.get("/issues/:id/comments", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
|
||||
@@ -40,6 +40,7 @@ export { companyPortabilityService } from "./company-portability.js";
|
||||
export { executionWorkspaceService } from "./execution-workspaces.js";
|
||||
export { workspaceOperationService } from "./workspace-operations.js";
|
||||
export { workProductService } from "./work-products.js";
|
||||
export { issueDeliverableService, buildIssueDeliverables } from "./issue-deliverables.js";
|
||||
export { logActivity, type LogActivityInput } from "./activity-log.js";
|
||||
export { notifyHireApproved, type NotifyHireApprovedInput } from "./hire-hook.js";
|
||||
export { publishLiveEvent, subscribeCompanyLiveEvents } from "./live-events.js";
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
import type {
|
||||
ExecutionWorkspace,
|
||||
IssueDeliverableItem,
|
||||
IssueDeliverablesResponse,
|
||||
IssueDeliverablesWorkspaceSummary,
|
||||
IssueDocument,
|
||||
IssueWorkProduct,
|
||||
LegacyPlanDocument,
|
||||
} from "@paperclipai/shared";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { documentService, extractLegacyPlanBody } from "./documents.js";
|
||||
import { executionWorkspaceService } from "./execution-workspaces.js";
|
||||
import { issueService } from "./issues.js";
|
||||
import { workProductService } from "./work-products.js";
|
||||
|
||||
type AttachmentInput = Awaited<ReturnType<ReturnType<typeof issueService>["listAttachments"]>>[number];
|
||||
|
||||
type IssueInput = {
|
||||
id: string;
|
||||
projectId: string | null;
|
||||
description: string | null;
|
||||
executionWorkspaceId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
type BuildIssueDeliverablesInput = {
|
||||
issue: IssueInput;
|
||||
workspace: ExecutionWorkspace | null;
|
||||
workProducts: IssueWorkProduct[];
|
||||
documents: IssueDocument[];
|
||||
legacyPlanDocument: LegacyPlanDocument | null;
|
||||
attachments: AttachmentInput[];
|
||||
};
|
||||
|
||||
function compareItemsByRecency(a: IssueDeliverableItem, b: IssueDeliverableItem) {
|
||||
if (a.isPrimary !== b.isPrimary) return a.isPrimary ? -1 : 1;
|
||||
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||
}
|
||||
|
||||
function summarizeMarkdown(body: string, maxLength = 180) {
|
||||
const normalized = body
|
||||
.replace(/```[\s\S]*?```/g, " ")
|
||||
.replace(/`([^`]+)`/g, "$1")
|
||||
.replace(/!\[[^\]]*\]\([^)]+\)/g, " ")
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
.replace(/[#>*_~-]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
if (!normalized) return null;
|
||||
if (normalized.length <= maxLength) return normalized;
|
||||
return `${normalized.slice(0, maxLength - 1)}...`;
|
||||
}
|
||||
|
||||
function formatDocumentTitle(document: Pick<IssueDocument, "key" | "title">) {
|
||||
if (document.key === "plan") return "Plan";
|
||||
if (document.title?.trim()) return document.title.trim();
|
||||
return document.key
|
||||
.split(/[-_]+/g)
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function toWorkProductItem(product: IssueWorkProduct): IssueDeliverableItem {
|
||||
const metadata = product.metadata ?? null;
|
||||
const contentType = typeof metadata?.contentType === "string" ? metadata.contentType : null;
|
||||
const byteSize = typeof metadata?.byteSize === "number" ? metadata.byteSize : null;
|
||||
return {
|
||||
id: product.id,
|
||||
sourceType: "work_product",
|
||||
kind: product.type,
|
||||
title: product.title,
|
||||
url: product.url,
|
||||
summary: product.summary,
|
||||
status: product.status ?? null,
|
||||
reviewState: product.reviewState ?? null,
|
||||
healthStatus: product.healthStatus ?? null,
|
||||
provider: product.provider,
|
||||
updatedAt: product.updatedAt,
|
||||
createdAt: product.createdAt,
|
||||
isPrimary: product.isPrimary,
|
||||
metadata,
|
||||
documentKey: null,
|
||||
revisionNumber: null,
|
||||
contentType,
|
||||
byteSize,
|
||||
};
|
||||
}
|
||||
|
||||
function toDocumentItem(document: IssueDocument): IssueDeliverableItem {
|
||||
return {
|
||||
id: document.id,
|
||||
sourceType: "document",
|
||||
kind: "document",
|
||||
title: formatDocumentTitle(document),
|
||||
url: `#document-${encodeURIComponent(document.key)}`,
|
||||
summary: summarizeMarkdown(document.body),
|
||||
status: null,
|
||||
reviewState: null,
|
||||
healthStatus: null,
|
||||
provider: "paperclip",
|
||||
updatedAt: document.updatedAt,
|
||||
createdAt: document.createdAt,
|
||||
isPrimary: false,
|
||||
metadata: null,
|
||||
documentKey: document.key,
|
||||
revisionNumber: document.latestRevisionNumber,
|
||||
contentType: "text/markdown",
|
||||
byteSize: null,
|
||||
};
|
||||
}
|
||||
|
||||
function toLegacyPlanItem(issue: IssueInput, legacyPlanDocument: LegacyPlanDocument): IssueDeliverableItem {
|
||||
return {
|
||||
id: `legacy-plan:${issue.id}`,
|
||||
sourceType: "legacy_plan_document",
|
||||
kind: "document",
|
||||
title: "Plan",
|
||||
url: "#document-plan",
|
||||
summary: summarizeMarkdown(legacyPlanDocument.body),
|
||||
status: null,
|
||||
reviewState: null,
|
||||
healthStatus: null,
|
||||
provider: "paperclip",
|
||||
updatedAt: issue.updatedAt,
|
||||
createdAt: issue.createdAt,
|
||||
isPrimary: false,
|
||||
metadata: { source: legacyPlanDocument.source },
|
||||
documentKey: legacyPlanDocument.key,
|
||||
revisionNumber: null,
|
||||
contentType: "text/markdown",
|
||||
byteSize: null,
|
||||
};
|
||||
}
|
||||
|
||||
function toAttachmentItem(attachment: AttachmentInput): IssueDeliverableItem {
|
||||
return {
|
||||
id: attachment.id,
|
||||
sourceType: "attachment",
|
||||
kind: "attachment",
|
||||
title: attachment.originalFilename ?? attachment.objectKey,
|
||||
url: `/api/attachments/${attachment.id}/content`,
|
||||
summary: null,
|
||||
status: null,
|
||||
reviewState: null,
|
||||
healthStatus: null,
|
||||
provider: attachment.provider,
|
||||
updatedAt: attachment.updatedAt,
|
||||
createdAt: attachment.createdAt,
|
||||
isPrimary: false,
|
||||
metadata: null,
|
||||
documentKey: null,
|
||||
revisionNumber: null,
|
||||
contentType: attachment.contentType,
|
||||
byteSize: attachment.byteSize,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeRuntimeServiceHealth(
|
||||
workspace: ExecutionWorkspace | null,
|
||||
): IssueDeliverablesWorkspaceSummary["runtimeServiceHealth"] {
|
||||
if (!workspace?.runtimeServices || workspace.runtimeServices.length === 0) return null;
|
||||
if (workspace.runtimeServices.some((service) => service.healthStatus === "unhealthy" || service.status === "failed")) {
|
||||
return "unhealthy";
|
||||
}
|
||||
if (workspace.runtimeServices.every((service) => service.healthStatus === "healthy" || service.status === "running")) {
|
||||
return "healthy";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function summarizeWorkspace(workspace: ExecutionWorkspace | null): IssueDeliverablesWorkspaceSummary | null {
|
||||
if (!workspace) return null;
|
||||
return {
|
||||
id: workspace.id,
|
||||
projectId: workspace.projectId,
|
||||
projectWorkspaceId: workspace.projectWorkspaceId,
|
||||
name: workspace.name,
|
||||
mode: workspace.mode,
|
||||
status: workspace.status,
|
||||
providerType: workspace.providerType,
|
||||
branchName: workspace.branchName,
|
||||
baseRef: workspace.baseRef,
|
||||
lastUsedAt: workspace.lastUsedAt,
|
||||
runtimeServiceCount: workspace.runtimeServices?.length ?? 0,
|
||||
runtimeServiceHealth: summarizeRuntimeServiceHealth(workspace),
|
||||
runtimeServices: (workspace.runtimeServices ?? []).map((service) => ({
|
||||
id: service.id,
|
||||
serviceName: service.serviceName,
|
||||
status: service.status,
|
||||
healthStatus: service.healthStatus,
|
||||
url: service.url,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function sortDocuments(items: IssueDeliverableItem[]) {
|
||||
return [...items].sort((a, b) => {
|
||||
if (a.documentKey === "plan" && b.documentKey !== "plan") return -1;
|
||||
if (a.documentKey !== "plan" && b.documentKey === "plan") return 1;
|
||||
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||
});
|
||||
}
|
||||
|
||||
function pickPrimaryItem(groups: {
|
||||
workProducts: IssueWorkProduct[];
|
||||
previews: IssueDeliverableItem[];
|
||||
pullRequests: IssueDeliverableItem[];
|
||||
documents: IssueDeliverableItem[];
|
||||
files: IssueDeliverableItem[];
|
||||
}) {
|
||||
const explicitPrimary = [...groups.workProducts]
|
||||
.filter((product) => product.isPrimary)
|
||||
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())[0];
|
||||
if (explicitPrimary) return toWorkProductItem(explicitPrimary);
|
||||
|
||||
const preview = [...groups.previews]
|
||||
.filter((item) => item.kind === "preview_url")
|
||||
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())[0];
|
||||
if (preview) return preview;
|
||||
|
||||
const pullRequest = [...groups.pullRequests].sort(compareItemsByRecency)[0];
|
||||
if (pullRequest) return pullRequest;
|
||||
|
||||
const document = [...groups.documents]
|
||||
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())[0];
|
||||
if (document) return document;
|
||||
|
||||
return [...groups.files].sort(compareItemsByRecency)[0] ?? null;
|
||||
}
|
||||
|
||||
export function buildIssueDeliverables(input: BuildIssueDeliverablesInput): IssueDeliverablesResponse {
|
||||
const workspace = summarizeWorkspace(input.workspace);
|
||||
const workProductItems = input.workProducts.map(toWorkProductItem);
|
||||
const previews = workProductItems
|
||||
.filter((item) => item.kind === "preview_url" || item.kind === "runtime_service")
|
||||
.sort(compareItemsByRecency);
|
||||
const pullRequests = workProductItems
|
||||
.filter((item) => item.kind === "pull_request")
|
||||
.sort(compareItemsByRecency);
|
||||
const branches = workProductItems
|
||||
.filter((item) => item.kind === "branch")
|
||||
.sort(compareItemsByRecency);
|
||||
const commits = workProductItems
|
||||
.filter((item) => item.kind === "commit")
|
||||
.sort(compareItemsByRecency);
|
||||
|
||||
const documentItems = [
|
||||
...workProductItems.filter((item) => item.kind === "document"),
|
||||
...input.documents.map(toDocumentItem),
|
||||
...(input.legacyPlanDocument ? [toLegacyPlanItem(input.issue, input.legacyPlanDocument)] : []),
|
||||
];
|
||||
|
||||
const fileItems = [
|
||||
...workProductItems.filter((item) => item.kind === "artifact"),
|
||||
...input.attachments.map(toAttachmentItem),
|
||||
].sort(compareItemsByRecency);
|
||||
|
||||
const primaryItem = pickPrimaryItem({
|
||||
workProducts: input.workProducts,
|
||||
previews,
|
||||
pullRequests,
|
||||
documents: documentItems,
|
||||
files: fileItems,
|
||||
});
|
||||
|
||||
return {
|
||||
workspace,
|
||||
summary: {
|
||||
hasAny: Boolean(
|
||||
workspace ||
|
||||
previews.length ||
|
||||
pullRequests.length ||
|
||||
branches.length ||
|
||||
commits.length ||
|
||||
documentItems.length ||
|
||||
fileItems.length
|
||||
),
|
||||
previewCount: previews.length,
|
||||
pullRequestCount: pullRequests.length,
|
||||
branchCount: branches.length,
|
||||
commitCount: commits.length,
|
||||
documentCount: documentItems.length,
|
||||
fileCount: fileItems.length,
|
||||
workspaceMode: workspace?.mode ?? null,
|
||||
workspaceStatus: workspace?.status ?? null,
|
||||
previewHealth: previews.length === 1 ? previews[0]?.healthStatus ?? null : null,
|
||||
pullRequestStatus: pullRequests.length === 1 ? pullRequests[0]?.status ?? null : null,
|
||||
pullRequestReviewState: pullRequests.length === 1 ? pullRequests[0]?.reviewState ?? null : null,
|
||||
},
|
||||
primaryItem,
|
||||
previews,
|
||||
pullRequests,
|
||||
branches,
|
||||
commits,
|
||||
documents: sortDocuments(documentItems),
|
||||
files: fileItems,
|
||||
};
|
||||
}
|
||||
|
||||
export function issueDeliverableService(db: Db) {
|
||||
const issuesSvc = issueService(db);
|
||||
const workProductsSvc = workProductService(db);
|
||||
const documentsSvc = documentService(db);
|
||||
const executionWorkspacesSvc = executionWorkspaceService(db);
|
||||
|
||||
return {
|
||||
getForIssue: async (issue: IssueInput) => {
|
||||
const legacyPlanBody = extractLegacyPlanBody(issue.description);
|
||||
const [workspace, workProducts, documents, attachments] = await Promise.all([
|
||||
issue.executionWorkspaceId ? executionWorkspacesSvc.getById(issue.executionWorkspaceId) : Promise.resolve(null),
|
||||
workProductsSvc.listForIssue(issue.id),
|
||||
documentsSvc.listIssueDocuments(issue.id).then((rows) => rows as IssueDocument[]),
|
||||
issuesSvc.listAttachments(issue.id),
|
||||
]);
|
||||
|
||||
return buildIssueDeliverables({
|
||||
issue,
|
||||
workspace,
|
||||
workProducts,
|
||||
documents,
|
||||
legacyPlanDocument: legacyPlanBody
|
||||
? {
|
||||
key: "plan",
|
||||
body: legacyPlanBody,
|
||||
source: "issue_description",
|
||||
}
|
||||
: null,
|
||||
attachments,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -23,4 +23,10 @@ describe("issuesApi.list", () => {
|
||||
"/companies/company-1/issues?parentId=issue-parent-1&limit=25",
|
||||
);
|
||||
});
|
||||
|
||||
it("calls the deliverables endpoint for an issue", async () => {
|
||||
await issuesApi.getDeliverables("issue-1");
|
||||
|
||||
expect(mockApi.get).toHaveBeenCalledWith("/issues/issue-1/deliverables");
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
Issue,
|
||||
IssueAttachment,
|
||||
IssueComment,
|
||||
IssueDeliverablesResponse,
|
||||
IssueDocument,
|
||||
IssueLabel,
|
||||
IssueWorkProduct,
|
||||
@@ -163,6 +164,7 @@ export const issuesApi = {
|
||||
api.post<Approval[]>(`/issues/${id}/approvals`, { approvalId }),
|
||||
unlinkApproval: (id: string, approvalId: string) =>
|
||||
api.delete<{ ok: true }>(`/issues/${id}/approvals/${approvalId}`),
|
||||
getDeliverables: (id: string) => api.get<IssueDeliverablesResponse>(`/issues/${id}/deliverables`),
|
||||
listWorkProducts: (id: string) => api.get<IssueWorkProduct[]>(`/issues/${id}/work-products`),
|
||||
createWorkProduct: (id: string, data: Record<string, unknown>) =>
|
||||
api.post<IssueWorkProduct>(`/issues/${id}/work-products`, data),
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { IssueDeliverablesSummary } from "@paperclipai/shared";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
function labelize(value: string) {
|
||||
return value.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
function workspaceModeLabel(mode: string) {
|
||||
switch (mode) {
|
||||
case "shared_workspace":
|
||||
return "Shared";
|
||||
case "isolated_workspace":
|
||||
return "Isolated";
|
||||
case "operator_branch":
|
||||
return "Operator branch";
|
||||
case "adapter_managed":
|
||||
return "Adapter managed";
|
||||
case "cloud_sandbox":
|
||||
return "Cloud sandbox";
|
||||
default:
|
||||
return labelize(mode);
|
||||
}
|
||||
}
|
||||
|
||||
export function IssueDeliverablesSummaryChips({ summary }: { summary: IssueDeliverablesSummary | null | undefined }) {
|
||||
if (!summary) return null;
|
||||
|
||||
const chips: string[] = [];
|
||||
if (summary.workspaceMode) {
|
||||
chips.push(`Workspace: ${workspaceModeLabel(summary.workspaceMode)}`);
|
||||
}
|
||||
if (summary.pullRequestCount > 0) {
|
||||
if (summary.pullRequestCount === 1) {
|
||||
const prState = summary.pullRequestReviewState && summary.pullRequestReviewState !== "none"
|
||||
? summary.pullRequestReviewState
|
||||
: summary.pullRequestStatus;
|
||||
chips.push(`PR: ${prState ? labelize(prState) : "1"}`);
|
||||
} else {
|
||||
chips.push(`PRs: ${summary.pullRequestCount}`);
|
||||
}
|
||||
}
|
||||
if (summary.previewCount > 0) {
|
||||
if (summary.previewCount === 1 && summary.previewHealth) {
|
||||
chips.push(`Preview: ${labelize(summary.previewHealth)}`);
|
||||
} else {
|
||||
chips.push(`Previews: ${summary.previewCount}`);
|
||||
}
|
||||
}
|
||||
if (summary.documentCount > 0) {
|
||||
chips.push(`Docs: ${summary.documentCount}`);
|
||||
}
|
||||
if (summary.fileCount > 0) {
|
||||
chips.push(`Files: ${summary.fileCount}`);
|
||||
}
|
||||
|
||||
if (chips.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{chips.map((chip) => (
|
||||
<Badge key={chip} variant="secondary" className="text-[11px] font-medium">
|
||||
{chip}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -194,6 +194,7 @@ export function IssueDocumentsSection({
|
||||
|
||||
const invalidateIssueDocuments = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.deliverables(issue.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(issue.id) });
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { IssueDeliverablesResponse } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { IssueWorkProductTab } from "./IssueWorkProductTab";
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ to, children }: { to: string; children: React.ReactNode }) => <a href={to}>{children}</a>,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function createDeliverables(): IssueDeliverablesResponse {
|
||||
return {
|
||||
workspace: {
|
||||
id: "workspace-1",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "project-workspace-1",
|
||||
name: "Remote sandbox",
|
||||
mode: "adapter_managed",
|
||||
status: "active",
|
||||
providerType: "adapter_managed",
|
||||
branchName: "pap-1280-work-product",
|
||||
baseRef: "main",
|
||||
lastUsedAt: new Date("2026-04-01T14:00:00.000Z"),
|
||||
runtimeServiceCount: 1,
|
||||
runtimeServiceHealth: "healthy",
|
||||
runtimeServices: [
|
||||
{
|
||||
id: "service-1",
|
||||
serviceName: "Preview",
|
||||
status: "running",
|
||||
healthStatus: "healthy",
|
||||
url: "https://preview.example.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
summary: {
|
||||
hasAny: true,
|
||||
previewCount: 1,
|
||||
pullRequestCount: 1,
|
||||
branchCount: 0,
|
||||
commitCount: 0,
|
||||
documentCount: 1,
|
||||
fileCount: 1,
|
||||
workspaceMode: "adapter_managed",
|
||||
workspaceStatus: "active",
|
||||
previewHealth: "healthy",
|
||||
pullRequestStatus: "ready_for_review",
|
||||
pullRequestReviewState: "needs_board_review",
|
||||
},
|
||||
primaryItem: {
|
||||
id: "pr-1",
|
||||
sourceType: "work_product",
|
||||
kind: "pull_request",
|
||||
title: "PR 42",
|
||||
url: "https://example.com/pr/42",
|
||||
summary: "Ready for board review.",
|
||||
status: "ready_for_review",
|
||||
reviewState: "needs_board_review",
|
||||
healthStatus: null,
|
||||
provider: "github",
|
||||
updatedAt: new Date("2026-04-01T15:00:00.000Z"),
|
||||
createdAt: new Date("2026-04-01T14:00:00.000Z"),
|
||||
isPrimary: true,
|
||||
metadata: null,
|
||||
documentKey: null,
|
||||
revisionNumber: null,
|
||||
contentType: null,
|
||||
byteSize: null,
|
||||
},
|
||||
previews: [
|
||||
{
|
||||
id: "preview-1",
|
||||
sourceType: "work_product",
|
||||
kind: "preview_url",
|
||||
title: "Preview deploy",
|
||||
url: "https://preview.example.com",
|
||||
summary: null,
|
||||
status: "active",
|
||||
reviewState: "none",
|
||||
healthStatus: "healthy",
|
||||
provider: "vercel",
|
||||
updatedAt: new Date("2026-04-01T15:00:00.000Z"),
|
||||
createdAt: new Date("2026-04-01T14:00:00.000Z"),
|
||||
isPrimary: false,
|
||||
metadata: null,
|
||||
documentKey: null,
|
||||
revisionNumber: null,
|
||||
contentType: null,
|
||||
byteSize: null,
|
||||
},
|
||||
],
|
||||
pullRequests: [
|
||||
{
|
||||
id: "pr-1",
|
||||
sourceType: "work_product",
|
||||
kind: "pull_request",
|
||||
title: "PR 42",
|
||||
url: "https://example.com/pr/42",
|
||||
summary: "Ready for board review.",
|
||||
status: "ready_for_review",
|
||||
reviewState: "needs_board_review",
|
||||
healthStatus: null,
|
||||
provider: "github",
|
||||
updatedAt: new Date("2026-04-01T15:00:00.000Z"),
|
||||
createdAt: new Date("2026-04-01T14:00:00.000Z"),
|
||||
isPrimary: true,
|
||||
metadata: null,
|
||||
documentKey: null,
|
||||
revisionNumber: null,
|
||||
contentType: null,
|
||||
byteSize: null,
|
||||
},
|
||||
],
|
||||
branches: [],
|
||||
commits: [],
|
||||
documents: [
|
||||
{
|
||||
id: "document-1",
|
||||
sourceType: "document",
|
||||
kind: "document",
|
||||
title: "Plan",
|
||||
url: "#document-plan",
|
||||
summary: "Ship the thing.",
|
||||
status: null,
|
||||
reviewState: null,
|
||||
healthStatus: null,
|
||||
provider: "paperclip",
|
||||
updatedAt: new Date("2026-04-01T12:00:00.000Z"),
|
||||
createdAt: new Date("2026-04-01T12:00:00.000Z"),
|
||||
isPrimary: false,
|
||||
metadata: null,
|
||||
documentKey: "plan",
|
||||
revisionNumber: 2,
|
||||
contentType: "text/markdown",
|
||||
byteSize: null,
|
||||
},
|
||||
],
|
||||
files: [
|
||||
{
|
||||
id: "attachment-1",
|
||||
sourceType: "attachment",
|
||||
kind: "attachment",
|
||||
title: "build-log.txt",
|
||||
url: "/api/attachments/attachment-1/content",
|
||||
summary: null,
|
||||
status: null,
|
||||
reviewState: null,
|
||||
healthStatus: null,
|
||||
provider: "paperclip",
|
||||
updatedAt: new Date("2026-04-01T12:00:00.000Z"),
|
||||
createdAt: new Date("2026-04-01T12:00:00.000Z"),
|
||||
isPrimary: false,
|
||||
metadata: null,
|
||||
documentKey: null,
|
||||
revisionNumber: null,
|
||||
contentType: "text/plain",
|
||||
byteSize: 512,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
describe("IssueWorkProductTab", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("renders grouped deliverables and document deep links", async () => {
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<IssueWorkProductTab
|
||||
deliverables={createDeliverables()}
|
||||
projectId="project-1"
|
||||
projectWorkspaceId="project-workspace-1"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(container.textContent).toContain("Primary output");
|
||||
expect(container.textContent).toContain("Current workspace");
|
||||
expect(container.textContent).toContain("Pull requests");
|
||||
expect(container.textContent).toContain("Documents");
|
||||
expect(container.querySelector('a[href="#document-plan"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders the empty state when nothing has been produced", async () => {
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<IssueWorkProductTab
|
||||
deliverables={{
|
||||
...createDeliverables(),
|
||||
workspace: null,
|
||||
summary: {
|
||||
hasAny: false,
|
||||
previewCount: 0,
|
||||
pullRequestCount: 0,
|
||||
branchCount: 0,
|
||||
commitCount: 0,
|
||||
documentCount: 0,
|
||||
fileCount: 0,
|
||||
workspaceMode: null,
|
||||
workspaceStatus: null,
|
||||
previewHealth: null,
|
||||
pullRequestStatus: null,
|
||||
pullRequestReviewState: null,
|
||||
},
|
||||
primaryItem: null,
|
||||
previews: [],
|
||||
pullRequests: [],
|
||||
documents: [],
|
||||
files: [],
|
||||
}}
|
||||
projectId="project-1"
|
||||
projectWorkspaceId="project-workspace-1"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(container.textContent).toContain("No deliverables are registered for this issue yet.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import type { IssueDeliverableItem, IssueDeliverablesResponse } from "@paperclipai/shared";
|
||||
import { Link } from "@/lib/router";
|
||||
import { projectWorkspaceUrl, relativeTime } from "@/lib/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FileText, FolderOpen, GitBranch, GitCommitHorizontal, Link2, MonitorUp, Package } from "lucide-react";
|
||||
|
||||
function labelize(value: string | null | undefined) {
|
||||
if (!value) return null;
|
||||
return value.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
function formatByteSize(byteSize: number | null) {
|
||||
if (!byteSize || byteSize <= 0) return null;
|
||||
if (byteSize < 1024 * 1024) return `${(byteSize / 1024).toFixed(1)} KB`;
|
||||
return `${(byteSize / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function itemActionLabel(item: IssueDeliverableItem) {
|
||||
if (!item.url) return null;
|
||||
if (item.kind === "document" && item.url.startsWith("#document-")) return "Jump to document";
|
||||
if (item.kind === "preview_url" || item.kind === "runtime_service") return "Open preview";
|
||||
if (item.kind === "pull_request") return "Open PR";
|
||||
if (item.kind === "branch") return "View branch";
|
||||
if (item.kind === "commit") return "View commit";
|
||||
if (item.kind === "artifact" || item.kind === "attachment") return "Download";
|
||||
return "Open";
|
||||
}
|
||||
|
||||
function itemIcon(item: IssueDeliverableItem) {
|
||||
switch (item.kind) {
|
||||
case "preview_url":
|
||||
case "runtime_service":
|
||||
return <MonitorUp className="h-4 w-4 text-muted-foreground" />;
|
||||
case "pull_request":
|
||||
case "branch":
|
||||
return <GitBranch className="h-4 w-4 text-muted-foreground" />;
|
||||
case "commit":
|
||||
return <GitCommitHorizontal className="h-4 w-4 text-muted-foreground" />;
|
||||
case "document":
|
||||
return <FileText className="h-4 w-4 text-muted-foreground" />;
|
||||
default:
|
||||
return <Package className="h-4 w-4 text-muted-foreground" />;
|
||||
}
|
||||
}
|
||||
|
||||
function DeliverableAction({ href, label }: { href: string | null; label: string | null }) {
|
||||
if (!href || !label) return null;
|
||||
if (href.startsWith("#")) {
|
||||
return (
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<a href={href}>{label}</a>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<a href={href} target="_blank" rel="noreferrer">
|
||||
{label}
|
||||
</a>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemCard({ item }: { item: IssueDeliverableItem }) {
|
||||
const actionLabel = itemActionLabel(item);
|
||||
const sizeLabel = formatByteSize(item.byteSize);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{itemIcon(item)}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{item.title}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
updated {relativeTime(item.updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Badge variant="outline">{labelize(item.kind) ?? item.kind}</Badge>
|
||||
{item.provider ? <Badge variant="secondary">{item.provider}</Badge> : null}
|
||||
{item.status ? <Badge variant="secondary">{labelize(item.status)}</Badge> : null}
|
||||
{item.reviewState && item.reviewState !== "none" ? (
|
||||
<Badge variant="secondary">{labelize(item.reviewState)}</Badge>
|
||||
) : null}
|
||||
{item.healthStatus && item.healthStatus !== "unknown" ? (
|
||||
<Badge variant="secondary">{labelize(item.healthStatus)}</Badge>
|
||||
) : null}
|
||||
{item.documentKey ? <Badge variant="secondary">{item.documentKey}</Badge> : null}
|
||||
{item.contentType ? <Badge variant="secondary">{item.contentType}</Badge> : null}
|
||||
{sizeLabel ? <Badge variant="secondary">{sizeLabel}</Badge> : null}
|
||||
</div>
|
||||
{item.summary ? <p className="text-sm text-muted-foreground">{item.summary}</p> : null}
|
||||
</div>
|
||||
<DeliverableAction href={item.url} label={actionLabel} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeliverableSection({
|
||||
title,
|
||||
items,
|
||||
}: {
|
||||
title: string;
|
||||
items: IssueDeliverableItem[];
|
||||
}) {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
<p className="text-xs text-muted-foreground">{items.length} item{items.length === 1 ? "" : "s"}</p>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
{items.map((item) => <ItemCard key={`${item.sourceType}:${item.id}`} item={item} />)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function workspaceDetailLink(input: {
|
||||
deliverables: IssueDeliverablesResponse;
|
||||
projectId: string | null;
|
||||
projectWorkspaceId: string | null;
|
||||
}) {
|
||||
const workspace = input.deliverables.workspace;
|
||||
if (!workspace) return null;
|
||||
const linkedProjectWorkspaceId = workspace.projectWorkspaceId ?? input.projectWorkspaceId ?? null;
|
||||
if (workspace.mode === "shared_workspace" && input.projectId && linkedProjectWorkspaceId) {
|
||||
return projectWorkspaceUrl({ id: input.projectId }, linkedProjectWorkspaceId);
|
||||
}
|
||||
return `/execution-workspaces/${workspace.id}`;
|
||||
}
|
||||
|
||||
export function IssueWorkProductTab({
|
||||
deliverables,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
isLoading = false,
|
||||
}: {
|
||||
deliverables: IssueDeliverablesResponse | null | undefined;
|
||||
projectId: string | null;
|
||||
projectWorkspaceId: string | null;
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
if (isLoading && !deliverables) {
|
||||
return <div className="rounded-lg border border-border p-4 text-sm text-muted-foreground">Loading work product…</div>;
|
||||
}
|
||||
|
||||
if (!deliverables) {
|
||||
return <div className="rounded-lg border border-border p-4 text-sm text-muted-foreground">Work product is unavailable right now.</div>;
|
||||
}
|
||||
|
||||
const workspaceLink = workspaceDetailLink({ deliverables, projectId, projectWorkspaceId });
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{deliverables.primaryItem ? (
|
||||
<section className="space-y-3 rounded-lg border border-border bg-muted/20 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Primary output</h3>
|
||||
<p className="text-xs text-muted-foreground">The current best deliverable for this issue.</p>
|
||||
</div>
|
||||
</div>
|
||||
<ItemCard item={deliverables.primaryItem} />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{deliverables.workspace ? (
|
||||
<section className="space-y-3 rounded-lg border border-border p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderOpen className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Current workspace</h3>
|
||||
<p className="text-xs text-muted-foreground">Where the latest execution context lives.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium">{deliverables.workspace.name}</span>
|
||||
<Badge variant="outline">{labelize(deliverables.workspace.mode)}</Badge>
|
||||
<Badge variant="secondary">{deliverables.workspace.providerType}</Badge>
|
||||
<Badge variant="secondary">{labelize(deliverables.workspace.status)}</Badge>
|
||||
{deliverables.workspace.runtimeServiceCount > 0 ? (
|
||||
<Badge variant="secondary">
|
||||
runtime services: {deliverables.workspace.runtimeServiceCount}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
{deliverables.workspace.branchName ? <span>branch {deliverables.workspace.branchName}</span> : null}
|
||||
{deliverables.workspace.baseRef ? <span>base {deliverables.workspace.baseRef}</span> : null}
|
||||
<span>last used {relativeTime(deliverables.workspace.lastUsedAt)}</span>
|
||||
</div>
|
||||
{deliverables.workspace.runtimeServices.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{deliverables.workspace.runtimeServices.map((service) => (
|
||||
<Badge key={service.id} variant="secondary">
|
||||
{service.serviceName}: {labelize(service.status)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{workspaceLink ? (
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link to={workspaceLink}>Open workspace details</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<DeliverableSection title="Previews" items={deliverables.previews} />
|
||||
<DeliverableSection title="Pull requests" items={deliverables.pullRequests} />
|
||||
<DeliverableSection title="Branches" items={deliverables.branches} />
|
||||
<DeliverableSection title="Commits" items={deliverables.commits} />
|
||||
<DeliverableSection title="Documents" items={deliverables.documents} />
|
||||
<DeliverableSection title="Files" items={deliverables.files} />
|
||||
|
||||
{!deliverables.summary.hasAny ? (
|
||||
<div className="rounded-lg border border-dashed border-border p-5 text-sm text-muted-foreground">
|
||||
No deliverables are registered for this issue yet. Documents, uploaded files, previews, PRs, and other work
|
||||
product will appear here when they are created.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{deliverables.summary.hasAny && !deliverables.primaryItem ? (
|
||||
<div className="rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
|
||||
Outputs exist for this issue, but there is no single primary deliverable yet.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -50,6 +50,7 @@ export const queryKeys = {
|
||||
documents: (issueId: string) => ["issues", "documents", issueId] as const,
|
||||
document: (issueId: string, key: string) => ["issues", "document", issueId, key] as const,
|
||||
documentRevisions: (issueId: string, key: string) => ["issues", "document-revisions", issueId, key] as const,
|
||||
deliverables: (issueId: string) => ["issues", "deliverables", issueId] as const,
|
||||
activity: (issueId: string) => ["issues", "activity", issueId] as const,
|
||||
runs: (issueId: string) => ["issues", "runs", issueId] as const,
|
||||
approvals: (issueId: string) => ["issues", "approvals", issueId] as const,
|
||||
|
||||
@@ -61,10 +61,12 @@ import { ApprovalCard } from "../components/ApprovalCard";
|
||||
import { InlineEditor } from "../components/InlineEditor";
|
||||
import { IssueChatThread, type IssueChatComposerHandle } from "../components/IssueChatThread";
|
||||
import { IssueContinuationHandoff } from "../components/IssueContinuationHandoff";
|
||||
import { IssueDeliverablesSummaryChips } from "../components/IssueDeliverablesSummaryChips";
|
||||
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
|
||||
import { IssuesList } from "../components/IssuesList";
|
||||
import { IssueProperties } from "../components/IssueProperties";
|
||||
import { IssueRunLedger } from "../components/IssueRunLedger";
|
||||
import { IssueWorkProductTab } from "../components/IssueWorkProductTab";
|
||||
import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard";
|
||||
import type { MentionOption } from "../components/MarkdownEditor";
|
||||
import { ImageGalleryModal } from "../components/ImageGalleryModal";
|
||||
@@ -987,6 +989,12 @@ export function IssueDetail() {
|
||||
enabled: !!issueId,
|
||||
placeholderData: keepPreviousDataForSameQueryTail<IssueAttachment[]>(issueId ?? "pending"),
|
||||
});
|
||||
const { data: deliverables, isLoading: deliverablesLoading } = useQuery({
|
||||
queryKey: queryKeys.issues.deliverables(issueId!),
|
||||
queryFn: () => issuesApi.getDeliverables(issueId!),
|
||||
enabled: !!issueId,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const { data: liveRunCount = 0 } = useQuery<LiveRunForIssue[], Error, number>({
|
||||
queryKey: queryKeys.issues.liveRuns(issueId!),
|
||||
@@ -1167,10 +1175,12 @@ export function IssueDetail() {
|
||||
|
||||
const invalidateIssueDetail = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.deliverables(issueId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) });
|
||||
}, [issueId, queryClient]);
|
||||
const invalidateIssueThreadLazily = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!), refetchType: "inactive" });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.deliverables(issueId!), refetchType: "inactive" });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!), refetchType: "inactive" });
|
||||
}, [issueId, queryClient]);
|
||||
|
||||
@@ -2406,6 +2416,8 @@ export function IssueDetail() {
|
||||
className="text-xl font-bold"
|
||||
/>
|
||||
|
||||
<IssueDeliverablesSummaryChips summary={deliverables?.summary} />
|
||||
|
||||
<InlineEditor
|
||||
value={issue.description ?? ""}
|
||||
onSave={(description) => updateIssue.mutateAsync({ description })}
|
||||
@@ -2670,6 +2682,10 @@ export function IssueDetail() {
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
Chat
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="work-product" className="gap-1.5">
|
||||
<Paperclip className="h-3.5 w-3.5" />
|
||||
Work Product
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="activity" className="gap-1.5">
|
||||
<ActivityIcon className="h-3.5 w-3.5" />
|
||||
Activity
|
||||
@@ -2719,6 +2735,15 @@ export function IssueDetail() {
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="work-product">
|
||||
<IssueWorkProductTab
|
||||
deliverables={deliverables}
|
||||
projectId={issue.projectId}
|
||||
projectWorkspaceId={issue.projectWorkspaceId}
|
||||
isLoading={deliverablesLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="activity">
|
||||
{detailTab === "activity" ? (
|
||||
<IssueDetailActivityTab
|
||||
|
||||
Reference in new issue
Block a user