Merge remote-tracking branch 'public-gh/master' into paperclip-subissues

* public-gh/master: (55 commits)
  fix(issue-documents): address greptile review
  Update packages/shared/src/validators/issue.ts
  feat(ui): add issue document copy and download actions
  fix(ui): unify new issue upload action
  feat(ui): stage issue files before create
  feat(ui): handle issue document edit conflicts
  fix(ui): refresh issue documents from live events
  feat(ui): deep link issue documents
  fix(ui): streamline issue document chrome
  fix(ui): collapse empty document and attachment states
  fix(ui): simplify document card body layout
  fix(issues): address document review comments
  feat(issues): add issue documents and inline editing
  docs: add agent evals framework plan
  fix(cli): quote env values with special characters
  Fix worktree seed source selection
  fix: address greptile follow-up
  docs: add paperclip skill tightening plan
  fix: isolate codex home in worktrees
  Add worktree UI branding
  ...

# Conflicts:
#	packages/db/src/migrations/meta/0028_snapshot.json
#	packages/db/src/migrations/meta/_journal.json
#	packages/shared/src/index.ts
#	server/src/routes/issues.ts
#	ui/src/api/issues.ts
#	ui/src/components/NewIssueDialog.tsx
#	ui/src/pages/IssueDetail.tsx
This commit is contained in:
Dotta
2026-03-14 12:24:40 -05:00
136 changed files with 17867 additions and 1511 deletions

View File

@@ -9,7 +9,9 @@ import {
checkoutIssueSchema,
createIssueSchema,
linkIssueApprovalSchema,
issueDocumentKeySchema,
updateIssueWorkProductSchema,
upsertIssueDocumentSchema,
updateIssueSchema,
} from "@paperclipai/shared";
import type { StorageService } from "../storage/types.js";
@@ -22,6 +24,7 @@ import {
heartbeatService,
issueApprovalService,
issueService,
documentService,
logActivity,
projectService,
workProductService,
@@ -32,6 +35,8 @@ import { assertCompanyAccess, getActorInfo } from "./authz.js";
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
const MAX_ISSUE_COMMENT_LIMIT = 500;
export function issueRoutes(db: Db, storage: StorageService) {
const router = Router();
const svc = issueService(db);
@@ -43,6 +48,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
const issueApprovalsSvc = issueApprovalService(db);
const executionWorkspacesSvc = executionWorkspaceService(db);
const workProductsSvc = workProductService(db);
const documentsSvc = documentService(db);
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
@@ -297,7 +303,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
return;
}
assertCompanyAccess(req, issue.companyId);
const [ancestors, project, goal, mentionedProjectIds] = await Promise.all([
const [ancestors, project, goal, mentionedProjectIds, documentPayload] = await Promise.all([
svc.getAncestors(issue.id),
issue.projectId ? projectsSvc.getById(issue.projectId) : null,
issue.goalId
@@ -306,6 +312,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
? goalsSvc.getDefaultCompanyGoal(issue.companyId)
: null,
svc.findMentionedProjectIds(issue.id),
documentsSvc.getIssueDocumentPayload(issue),
]);
const mentionedProjects = mentionedProjectIds.length > 0
? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
@@ -318,6 +325,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
...issue,
goalId: goal?.id ?? issue.goalId,
ancestors,
...documentPayload,
project: project ?? null,
goal: goal ?? null,
mentionedProjects,
@@ -326,6 +334,79 @@ export function issueRoutes(db: Db, storage: StorageService) {
});
});
router.get("/issues/:id/heartbeat-context", 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 wakeCommentId =
typeof req.query.wakeCommentId === "string" && req.query.wakeCommentId.trim().length > 0
? req.query.wakeCommentId.trim()
: null;
const [ancestors, project, goal, commentCursor, wakeComment] = await Promise.all([
svc.getAncestors(issue.id),
issue.projectId ? projectsSvc.getById(issue.projectId) : null,
issue.goalId
? goalsSvc.getById(issue.goalId)
: !issue.projectId
? goalsSvc.getDefaultCompanyGoal(issue.companyId)
: null,
svc.getCommentCursor(issue.id),
wakeCommentId ? svc.getComment(wakeCommentId) : null,
]);
res.json({
issue: {
id: issue.id,
identifier: issue.identifier,
title: issue.title,
description: issue.description,
status: issue.status,
priority: issue.priority,
projectId: issue.projectId,
goalId: goal?.id ?? issue.goalId,
parentId: issue.parentId,
assigneeAgentId: issue.assigneeAgentId,
assigneeUserId: issue.assigneeUserId,
updatedAt: issue.updatedAt,
},
ancestors: ancestors.map((ancestor) => ({
id: ancestor.id,
identifier: ancestor.identifier,
title: ancestor.title,
status: ancestor.status,
priority: ancestor.priority,
})),
project: project
? {
id: project.id,
name: project.name,
status: project.status,
targetDate: project.targetDate,
}
: null,
goal: goal
? {
id: goal.id,
title: goal.title,
status: goal.status,
level: goal.level,
parentId: goal.parentId,
}
: null,
commentCursor,
wakeComment:
wakeComment && wakeComment.issueId === issue.id
? wakeComment
: null,
});
});
router.get("/issues/:id/work-products", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
@@ -338,6 +419,146 @@ export function issueRoutes(db: Db, storage: StorageService) {
res.json(workProducts);
});
router.get("/issues/:id/documents", 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 docs = await documentsSvc.listIssueDocuments(issue.id);
res.json(docs);
});
router.get("/issues/:id/documents/:key", 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 keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
if (!keyParsed.success) {
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
return;
}
const doc = await documentsSvc.getIssueDocumentByKey(issue.id, keyParsed.data);
if (!doc) {
res.status(404).json({ error: "Document not found" });
return;
}
res.json(doc);
});
router.put("/issues/:id/documents/:key", validate(upsertIssueDocumentSchema), 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 keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
if (!keyParsed.success) {
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
return;
}
const actor = getActorInfo(req);
const result = await documentsSvc.upsertIssueDocument({
issueId: issue.id,
key: keyParsed.data,
title: req.body.title ?? null,
format: req.body.format,
body: req.body.body,
changeSummary: req.body.changeSummary ?? null,
baseRevisionId: req.body.baseRevisionId ?? null,
createdByAgentId: actor.agentId ?? null,
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
});
const doc = result.document;
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: result.created ? "issue.document_created" : "issue.document_updated",
entityType: "issue",
entityId: issue.id,
details: {
key: doc.key,
documentId: doc.id,
title: doc.title,
format: doc.format,
revisionNumber: doc.latestRevisionNumber,
},
});
res.status(result.created ? 201 : 200).json(doc);
});
router.get("/issues/:id/documents/:key/revisions", 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 keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
if (!keyParsed.success) {
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
return;
}
const revisions = await documentsSvc.listIssueDocumentRevisions(issue.id, keyParsed.data);
res.json(revisions);
});
router.delete("/issues/:id/documents/:key", 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);
if (req.actor.type !== "board") {
res.status(403).json({ error: "Board authentication required" });
return;
}
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
if (!keyParsed.success) {
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
return;
}
const removed = await documentsSvc.deleteIssueDocument(issue.id, keyParsed.data);
if (!removed) {
res.status(404).json({ error: "Document not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.document_deleted",
entityType: "issue",
entityId: issue.id,
details: {
key: removed.key,
documentId: removed.id,
title: removed.title,
},
});
res.json({ ok: true });
});
router.post("/issues/:id/work-products", validate(createIssueWorkProductSchema), async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
@@ -902,7 +1123,29 @@ export function issueRoutes(db: Db, storage: StorageService) {
return;
}
assertCompanyAccess(req, issue.companyId);
const comments = await svc.listComments(id);
const afterCommentId =
typeof req.query.after === "string" && req.query.after.trim().length > 0
? req.query.after.trim()
: typeof req.query.afterCommentId === "string" && req.query.afterCommentId.trim().length > 0
? req.query.afterCommentId.trim()
: null;
const order =
typeof req.query.order === "string" && req.query.order.trim().toLowerCase() === "asc"
? "asc"
: "desc";
const limitRaw =
typeof req.query.limit === "string" && req.query.limit.trim().length > 0
? Number(req.query.limit)
: null;
const limit =
limitRaw && Number.isFinite(limitRaw) && limitRaw > 0
? Math.min(Math.floor(limitRaw), MAX_ISSUE_COMMENT_LIMIT)
: null;
const comments = await svc.listComments(id, {
afterCommentId,
order,
limit,
});
res.json(comments);
});