feat(status-cards): join summary-mentioned issues to watched set (#10205)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Status cards summarize changing company work and watch issues so later changes can produce useful deltas > - A summary can explicitly reference issues that are important to the update even when those issues do not match the card's configured queries > - Previously, those referenced issues were not retained in the watched set, so their later status, assignee, or comment changes could be missed > - The watched snapshot must avoid artificial additions or removals caused only by a summary changing which issues it references > - This pull request resolves issue references when a summary is written, persists them, and joins them to the watched snapshot with stable delta semantics > - The benefit is that status cards continue tracking the exact issues their latest update called out while keeping follow-up updates relevant and non-duplicative ## Linked Issues or Issue Description ### Pre-submission checklist - [x] I have searched existing open and closed issues and this is not a duplicate. - [x] I am on the latest released version of Paperclip (or can reproduce on `master`). - [x] I have confirmed the error originates in Paperclip itself — not in my agent adapter, API provider, or local configuration. ### What happened? When a status-card summary explicitly referenced an issue by identifier or `/issues/<uuid>` URL, that issue was not automatically retained in the card's watched set unless it independently matched a configured query. Later status, assignee, or comment changes to an issue highlighted by the latest update could therefore be omitted. ### Expected behavior References in the latest summary should resolve only within the card's company, appear in dry runs and the watched-issues UI, count and fingerprint like query matches, and enter or leave the watched set without artificial added/removed deltas already represented by the summary change. ### Steps to reproduce 1. Create a status card whose query does not match a second issue in the same company. 2. Write a summary that references the second issue by identifier or issue URL. 3. Inspect the card's watched count or Watched issues tab. 4. Change the referenced issue's status, assignee, or comments and run the next update. 5. Before this change, the referenced issue is absent from the watched snapshot and its later change does not produce the expected delta. ### Paperclip version or commit - Reproduced on `master` before this PR (base commit `762ce5b4ef`). ### Deployment mode - Local dev (`pnpm dev`), built from source. ### Agent adapter(s) involved - Not adapter-specific (core bug). ### Database mode - Embedded Postgres test environment; the schema change uses standard PostgreSQL JSONB. ### Access context - Board (human operator). ## What Changed - Added migration `0191` and schema support for persisted `status_cards.mentioned_issue_ids`. - Resolved summary references by issue identifier or `/issues/<uuid>` URL within the status card's company when summaries are written. - Joined mentioned issues into watched counts and fingerprints so later status, assignee, and comment changes generate normal update deltas. - Suppressed artificial added/removed deltas when the latest summary starts or stops mentioning an issue. - Added `mentionedIssues` to dry-run responses and a “Mentioned in the latest update” group in the Watched issues tab. - Updated the summarizer prompt to explain that referenced issues automatically join the watched set. - Added focused server and UI coverage for reference resolution, snapshot behavior, deltas, API responses, and rendering. ## Verification - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/status-cards.test.ts src/__tests__/status-card-update-engine.test.ts` — 31 tests passed. - `pnpm --filter @paperclipai/ui exec vitest run src/pages/StatusCards/StatusCardTile.test.tsx` — 11 tests passed. - Earlier implementation verification also passed database/shared/server typechecks, UI `tsc -b`, the broader StatusCards UI test set, and embedded-Postgres migration application. ### Visual Verification - Greptile T-Rex ran Playwright browser checks successfully and captured the Status Card drawer Watched tab showing the new “Mentioned in the latest update” grouping: https://app.greptile.com/trex/runs/15796101/artifacts ## Risks - The migration adds a nullable JSONB column and is backward-compatible; existing cards have no mentioned issues until their next summary write. - Reference extraction is company-scoped to prevent cross-company issue association. - Watched counts and future fingerprints change for cards whose latest summaries reference issues; tests cover additions, removals, and suppression of spurious deltas. - This targeted status-card fix does not introduce a new roadmap subsystem or external integration. ## Model Used - Anthropic Claude Fable 5 (Paperclip model alias; exact underlying provider model ID and context window were not recorded in the implementation task metadata), with extended reasoning, tool use, and code execution. - OpenAI Codex coding agent (runtime model identifier and context window not exposed to this task) prepared the PR, rebased the branch, and ran focused verification with terminal tool use. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [ ] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
665408c6d0
commit
8f08ec5ce6
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "status_cards" ADD COLUMN IF NOT EXISTS "mentioned_issue_ids" jsonb DEFAULT '[]'::jsonb NOT NULL;
|
||||
|
|
@ -1324,6 +1324,13 @@
|
|||
"when": 1784916885226,
|
||||
"tag": "0190_status_card_single_prompt",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 191,
|
||||
"version": "7",
|
||||
"when": 1784916885227,
|
||||
"tag": "0191_status_card_mentioned_issue_ids",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,9 @@ export const statusCards = pgTable(
|
|||
lastChangeAt: timestamp("last_change_at", { withTimezone: true }),
|
||||
fingerprint: jsonb("fingerprint").$type<StatusCardFingerprint>(),
|
||||
fingerprintAt: timestamp("fingerprint_at", { withTimezone: true }),
|
||||
// Issues referenced in the latest summary markdown (by identifier or issue
|
||||
// link) that join the watched set alongside the compiled-query matches.
|
||||
mentionedIssueIds: jsonb("mentioned_issue_ids").$type<string[]>().notNull().default(sql`'[]'::jsonb`),
|
||||
documentId: uuid("document_id").references(() => documents.id, { onDelete: "set null" }),
|
||||
lastUpdateRunKind: text("last_update_run_kind").$type<"full" | "incremental">(),
|
||||
lastGeneratedAt: timestamp("last_generated_at", { withTimezone: true }),
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ export const statusCardSchema = z.object({
|
|||
lastChangeAt: z.string().datetime().nullable(),
|
||||
fingerprint: statusCardFingerprintSchema.nullable(),
|
||||
fingerprintAt: z.string().datetime().nullable(),
|
||||
mentionedIssueIds: z.array(z.string().uuid()).default([]),
|
||||
documentId: z.string().uuid().nullable(),
|
||||
lastUpdateRunKind: z.enum(["full", "incremental"]).nullable(),
|
||||
lastGeneratedAt: z.string().datetime().nullable(),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
chooseStatusCardUpdateKind,
|
||||
diffStatusCardFingerprint,
|
||||
evaluateStatusCardPolicy,
|
||||
extractIssueMentions,
|
||||
filterStatusCardChanges,
|
||||
isWithinStatusCardActiveHours,
|
||||
nextStatusCardEvaluationAt,
|
||||
|
|
@ -87,4 +88,17 @@ describe("status card update engine", () => {
|
|||
expect(chooseStatusCardUpdateKind({ ...base, explicitFull: true })).toBe("full");
|
||||
expect(chooseStatusCardUpdateKind({ ...base, restoreRefresh: true })).toBe("full");
|
||||
});
|
||||
|
||||
it("extracts identifier and issue-link mentions from summary markdown", () => {
|
||||
const markdown = [
|
||||
"**Decide:** [PAP-15357](/issues/PAP-15357) is blocked; PAP-15357 and pap-99 (lowercase) plus SC2-4 moved.",
|
||||
"See [the launch issue](/issues/0F5A2C71-9F5C-4B6C-8A9E-1B2C3D4E5F60#comment-1) and /issues/not-a-uuid.",
|
||||
].join("\n");
|
||||
|
||||
expect(extractIssueMentions(markdown)).toEqual({
|
||||
identifiers: ["PAP-15357", "SC2-4"],
|
||||
issueIds: ["0f5a2c71-9f5c-4b6c-8a9e-1b2c3d4e5f60"],
|
||||
});
|
||||
expect(extractIssueMentions("No references here.")).toEqual({ identifiers: [], issueIds: [] });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -916,6 +916,124 @@ describeEmbeddedPostgres("status card routes", () => {
|
|||
expect(summaryWrite.status).toBe(404);
|
||||
});
|
||||
|
||||
it("joins issues mentioned in the summary to the watched set and tracks their later changes", async () => {
|
||||
const company = await seedCompany();
|
||||
await enableStatusCards();
|
||||
const summarizer = await seedSummarizer(company.id);
|
||||
const boardApp = createApp(db, localBoardActor());
|
||||
const service = statusCardService(db);
|
||||
|
||||
const created = await request(boardApp)
|
||||
.post(`/api/companies/${company.id}/status-cards`)
|
||||
.send({ interestPrompt: "Blocked launch tasks" });
|
||||
const compileIssueId = created.body.generatingIssueId as string;
|
||||
const compileRun = await seedRun(company.id, summarizer.id);
|
||||
await db.update(issues).set({ checkoutRunId: compileRun.id }).where(eq(issues.id, compileIssueId));
|
||||
const matchedIssue = await db.insert(issues).values({ companyId: company.id, title: "Launch blocked on approval", status: "blocked", priority: "high" }).returning().then((rows) => rows[0]!);
|
||||
const mentionedIdentifier = `M${randomUUID().replace(/[^0-9]/g, "").slice(0, 6)}X-7`;
|
||||
const mentionedIssue = await db.insert(issues).values({ companyId: company.id, identifier: mentionedIdentifier, title: "Related migration follow-up", status: "in_progress", priority: "medium" }).returning().then((rows) => rows[0]!);
|
||||
|
||||
const compileApp = createApp(db, agentActor(company.id, summarizer.id, compileRun.id));
|
||||
const queryWrite = await request(compileApp).put(`/api/status-cards/${created.body.id}/query`).send({
|
||||
queries: [{ scope: "issues", status: ["blocked"], updatedWithin: "7d", sort: "updated", limit: 20, offset: 0 }],
|
||||
title: "Launch blockers",
|
||||
changeSummary: "Compiled the blocker query.",
|
||||
generationIssueId: compileIssueId,
|
||||
});
|
||||
expect(queryWrite.status).toBe(200);
|
||||
|
||||
// The first summary mentions an issue the compiled query does not match —
|
||||
// by identifier and by issue link — plus noise that must not resolve.
|
||||
const summaryWrite = await request(compileApp).put(`/api/status-cards/${created.body.id}/summary`).send({
|
||||
markdown: `**Decide:** unblock approval.\n\nAlso tracking ${mentionedIdentifier} ([details](/issues/${mentionedIssue.id})) and the unrelated UTF-8 / NOPE-99 tokens.`,
|
||||
title: "Launch blockers",
|
||||
changeSummary: "First full summary.",
|
||||
generationIssueId: compileIssueId,
|
||||
model: "gpt-5.4",
|
||||
});
|
||||
expect(summaryWrite.status).toBe(200);
|
||||
|
||||
const afterFirstSummary = await db.select().from(statusCards).where(eq(statusCards.id, created.body.id)).then((rows) => rows[0]!);
|
||||
expect(afterFirstSummary.mentionedIssueIds).toEqual([mentionedIssue.id]);
|
||||
expect(Object.keys(afterFirstSummary.fingerprint ?? {}).sort()).toEqual([matchedIssue.id, mentionedIssue.id].sort());
|
||||
|
||||
const detail = await request(boardApp).get(`/api/status-cards/${created.body.id}`);
|
||||
expect(detail.status).toBe(200);
|
||||
expect(detail.body.watchedIssueCount).toBe(2);
|
||||
|
||||
const dryRun = await request(boardApp).get(`/api/status-cards/${created.body.id}/dry-run`);
|
||||
expect(dryRun.status).toBe(200);
|
||||
expect(dryRun.body.mentionedIssues).toEqual([
|
||||
expect.objectContaining({ id: mentionedIssue.id, identifier: mentionedIdentifier, status: "in_progress" }),
|
||||
]);
|
||||
|
||||
// Joining the mention must not by itself produce a pending delta.
|
||||
const interval = { ...defaultStatusCardRefreshPolicy, mode: "interval" as const, intervalMinutes: 15 };
|
||||
await db.update(statusCards).set({ refreshPolicy: interval, nextEvalAt: new Date(Date.now() - 1000) }).where(eq(statusCards.id, created.body.id));
|
||||
expect(await service.tickDueStatusCards(new Date())).toMatchObject({ evaluated: 1, enqueued: [] });
|
||||
|
||||
// A status change on the mentioned issue now fires like any watched issue.
|
||||
await db.update(issues).set({ status: "todo", updatedAt: new Date() }).where(eq(issues.id, mentionedIssue.id));
|
||||
await db.update(statusCards).set({ nextEvalAt: new Date(Date.now() - 1000) }).where(eq(statusCards.id, created.body.id));
|
||||
const tick = await service.tickDueStatusCards(new Date());
|
||||
expect(tick.enqueued).toHaveLength(1);
|
||||
const updateIssueId = tick.enqueued[0]!.generatingIssue.id;
|
||||
const updateRow = await db.select().from(statusCardUpdates).then((rows) => rows.find((row) => row.generationIssueId === updateIssueId)!);
|
||||
expect(updateRow.changes).toEqual([
|
||||
expect.objectContaining({ issueId: mentionedIssue.id, changeKind: "status", from: "in_progress", to: "todo" }),
|
||||
]);
|
||||
|
||||
// If the issue changes again while the update summary is being written,
|
||||
// continuing to mention it refreshes the snapshot to the latest state so
|
||||
// the same change is not queued again on the next tick.
|
||||
await db.update(issues).set({ status: "in_review", updatedAt: new Date() }).where(eq(issues.id, mentionedIssue.id));
|
||||
const updateRun = await seedRun(company.id, summarizer.id);
|
||||
await db.update(issues).set({ checkoutRunId: updateRun.id }).where(eq(issues.id, updateIssueId));
|
||||
const secondSummary = await request(createApp(db, agentActor(company.id, summarizer.id, updateRun.id)))
|
||||
.put(`/api/status-cards/${created.body.id}/summary`)
|
||||
.send({
|
||||
markdown: `**Decide:** unblock approval. ${mentionedIdentifier} remains in the launch scope.`,
|
||||
changeSummary: "Covered the follow-up issue's latest state.",
|
||||
generationIssueId: updateIssueId,
|
||||
model: "gpt-5.4",
|
||||
});
|
||||
expect(secondSummary.status).toBe(200);
|
||||
|
||||
const afterSecondSummary = await db.select().from(statusCards).where(eq(statusCards.id, created.body.id)).then((rows) => rows[0]!);
|
||||
expect(afterSecondSummary.mentionedIssueIds).toEqual([mentionedIssue.id]);
|
||||
expect(afterSecondSummary.fingerprint?.[mentionedIssue.id]).toEqual(expect.objectContaining({ status: "in_review" }));
|
||||
|
||||
await db.update(statusCards).set({ nextEvalAt: new Date(Date.now() - 1000) }).where(eq(statusCards.id, created.body.id));
|
||||
expect(await service.tickDueStatusCards(new Date())).toMatchObject({ evaluated: 1, enqueued: [] });
|
||||
|
||||
// A later summary that stops mentioning the issue drops it from the
|
||||
// watched set without queuing a spurious "removed" delta afterwards.
|
||||
await db.update(issues).set({ status: "done", updatedAt: new Date() }).where(eq(issues.id, mentionedIssue.id));
|
||||
await db.update(statusCards).set({ nextEvalAt: new Date(Date.now() - 1000) }).where(eq(statusCards.id, created.body.id));
|
||||
const nextTick = await service.tickDueStatusCards(new Date());
|
||||
expect(nextTick.enqueued).toHaveLength(1);
|
||||
const nextUpdateIssueId = nextTick.enqueued[0]!.generatingIssue.id;
|
||||
const nextUpdateRun = await seedRun(company.id, summarizer.id);
|
||||
await db.update(issues).set({ checkoutRunId: nextUpdateRun.id }).where(eq(issues.id, nextUpdateIssueId));
|
||||
const thirdSummary = await request(createApp(db, agentActor(company.id, summarizer.id, nextUpdateRun.id)))
|
||||
.put(`/api/status-cards/${created.body.id}/summary`)
|
||||
.send({
|
||||
markdown: "**Decide:** unblock approval. The follow-up left the launch scope.",
|
||||
changeSummary: "Dropped the follow-up issue.",
|
||||
generationIssueId: nextUpdateIssueId,
|
||||
model: "gpt-5.4",
|
||||
});
|
||||
expect(thirdSummary.status).toBe(200);
|
||||
|
||||
const afterThirdSummary = await db.select().from(statusCards).where(eq(statusCards.id, created.body.id)).then((rows) => rows[0]!);
|
||||
expect(afterThirdSummary.mentionedIssueIds).toEqual([]);
|
||||
expect(Object.keys(afterThirdSummary.fingerprint ?? {})).toEqual([matchedIssue.id]);
|
||||
expect((await request(boardApp).get(`/api/status-cards/${created.body.id}`)).body.watchedIssueCount).toBe(1);
|
||||
|
||||
await db.update(statusCards).set({ nextEvalAt: new Date(Date.now() - 1000) }).where(eq(statusCards.id, created.body.id));
|
||||
expect(await service.tickDueStatusCards(new Date())).toMatchObject({ evaluated: 1, enqueued: [] });
|
||||
});
|
||||
|
||||
it("writes a compiled query and first summary, dry-runs live rows, and bumps the version after recompile", async () => {
|
||||
const company = await seedCompany();
|
||||
await enableStatusCards();
|
||||
|
|
|
|||
|
|
@ -260,7 +260,12 @@ export function statusCardRoutes(db: Db, opts: { heartbeat?: IssueAssignmentWake
|
|||
if (!decision.allowed) {
|
||||
throw forbidden("Status-card dry-run is outside this actor's low-trust authorization boundary", authorizationDeniedDetails(decision));
|
||||
}
|
||||
res.json({ cardId: card.id, queryVersion: card.queryVersion, queries: await service.dryRun(card) });
|
||||
res.json({
|
||||
cardId: card.id,
|
||||
queryVersion: card.queryVersion,
|
||||
queries: await service.dryRun(card),
|
||||
mentionedIssues: await service.listMentionedIssues(card),
|
||||
});
|
||||
});
|
||||
|
||||
router.put("/status-cards/:id/query", validate(writeStatusCardQuerySchema), async (req, res) => {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,25 @@ export type StatusCardDeltaChange = {
|
|||
changeKind: "new" | "removed" | "status" | "assignee" | "human_comment" | "updated";
|
||||
};
|
||||
|
||||
/** Upper bound on summary-mentioned issues joined to a card's watched set. */
|
||||
export const STATUS_CARD_MAX_MENTIONED_ISSUES = 200;
|
||||
|
||||
const ISSUE_IDENTIFIER_MENTION_PATTERN = /\b[A-Z][A-Z0-9]{0,9}-\d{1,7}\b/g;
|
||||
const ISSUE_LINK_MENTION_PATTERN = /\/issues\/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\b/g;
|
||||
|
||||
/**
|
||||
* Pull issue references out of summary markdown: bare identifiers ("PAP-123")
|
||||
* and issue links carrying a UUID ("/issues/<id>"). Callers must resolve the
|
||||
* candidates against the card's company before trusting them.
|
||||
*/
|
||||
export function extractIssueMentions(markdown: string) {
|
||||
const identifiers = new Set<string>();
|
||||
const issueIds = new Set<string>();
|
||||
for (const match of markdown.matchAll(ISSUE_IDENTIFIER_MENTION_PATTERN)) identifiers.add(match[0]);
|
||||
for (const match of markdown.matchAll(ISSUE_LINK_MENTION_PATTERN)) issueIds.add(match[1]!.toLowerCase());
|
||||
return { identifiers: [...identifiers], issueIds: [...issueIds] };
|
||||
}
|
||||
|
||||
export function buildStatusCardFingerprint(issues: Array<CompanySearchIssueSummary & { latestHumanCommentAt?: string | null }>): StatusCardFingerprint {
|
||||
return Object.fromEntries(issues.map((issue) => [issue.id, {
|
||||
status: issue.status,
|
||||
|
|
|
|||
|
|
@ -31,8 +31,10 @@ import {
|
|||
chooseStatusCardUpdateKind,
|
||||
diffStatusCardFingerprint,
|
||||
evaluateStatusCardPolicy,
|
||||
extractIssueMentions,
|
||||
filterStatusCardChanges,
|
||||
nextStatusCardEvaluationAt,
|
||||
STATUS_CARD_MAX_MENTIONED_ISSUES,
|
||||
statusCardChangesHash,
|
||||
statusCardFingerprintHash,
|
||||
type StatusCardDeltaChange,
|
||||
|
|
@ -86,7 +88,7 @@ function updateDescription(input: {
|
|||
previousSummary: string | null;
|
||||
snapshot: CompanySearchIssueSummary[];
|
||||
}) {
|
||||
const mechanical = `Return the completed Markdown through \`PUT /api/status-cards/${input.card.id}/summary\` with \`generationIssueId\`, a short non-empty \`changeSummary\`, and the model id. Do not call issue-list endpoints. Preserve the streaming STATUS and <<<SUMMARY-DRAFT>>> sentinels used by the Summarizer.`;
|
||||
const mechanical = `Return the completed Markdown through \`PUT /api/status-cards/${input.card.id}/summary\` with \`generationIssueId\`, a short non-empty \`changeSummary\`, and the model id. Do not call issue-list endpoints. Preserve the streaming STATUS and <<<SUMMARY-DRAFT>>> sentinels used by the Summarizer. Issues the Markdown references by identifier (e.g. ABC-123) or issue link automatically join the card's watched set, so reference an issue only when the board should keep tracking it.`;
|
||||
// The card prompt is the board's single standing request: it already says
|
||||
// what to watch and how the update should read, so it doubles as the
|
||||
// summary instructions — there is no separate default prompt to append to
|
||||
|
|
@ -153,7 +155,7 @@ export function statusCardService(
|
|||
const searchSvc = companySearchService(db);
|
||||
|
||||
async function readWatchedIssueCount(card: StatusCardRow) {
|
||||
if (card.queries.length === 0) return 0;
|
||||
if (card.queries.length === 0 && (card.mentionedIssueIds?.length ?? 0) === 0) return 0;
|
||||
try {
|
||||
return (await executeQueries(card)).length;
|
||||
} catch (err) {
|
||||
|
|
@ -513,6 +515,54 @@ export function statusCardService(
|
|||
});
|
||||
}
|
||||
|
||||
async function loadIssueSummaries(companyId: string, issueIds: string[]): Promise<CompanySearchIssueSummary[]> {
|
||||
if (issueIds.length === 0) return [];
|
||||
const rows = await db
|
||||
.select({
|
||||
id: issues.id,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
priority: issues.priority,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
projectId: issues.projectId,
|
||||
updatedAt: issues.updatedAt,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, companyId), inArray(issues.id, issueIds)));
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
status: row.status as CompanySearchIssueSummary["status"],
|
||||
priority: row.priority as CompanySearchIssueSummary["priority"],
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve markdown issue mentions to real issue ids in the card's company.
|
||||
* Unknown identifiers and foreign-company links drop out here, so a summary
|
||||
* cannot join arbitrary ids to the watched set.
|
||||
*/
|
||||
async function resolveMentionedIssueIds(companyId: string, markdown: string) {
|
||||
const mentions = extractIssueMentions(markdown);
|
||||
const conditions = [
|
||||
...(mentions.identifiers.length > 0 ? [inArray(issues.identifier, mentions.identifiers)] : []),
|
||||
...(mentions.issueIds.length > 0 ? [inArray(issues.id, mentions.issueIds)] : []),
|
||||
];
|
||||
if (conditions.length === 0) return [];
|
||||
const rows = await db
|
||||
.select({ id: issues.id })
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, companyId), or(...conditions)))
|
||||
.limit(STATUS_CARD_MAX_MENTIONED_ISSUES);
|
||||
return rows.map((row) => row.id).sort();
|
||||
}
|
||||
|
||||
async function listMentionedIssues(card: StatusCardRow) {
|
||||
return loadIssueSummaries(card.companyId, card.mentionedIssueIds ?? []);
|
||||
}
|
||||
|
||||
async function executeQueries(card: StatusCardRow) {
|
||||
const issueMap = new Map<string, CompanySearchIssueSummary>();
|
||||
for (const storedQuery of card.queries) {
|
||||
|
|
@ -522,6 +572,13 @@ export function statusCardService(
|
|||
if (result.type === "issue" && result.issue) issueMap.set(result.issue.id, result.issue);
|
||||
}
|
||||
}
|
||||
// Issues mentioned in the latest summary join the watched set alongside the
|
||||
// compiled-query matches, so their later changes fire deltas too.
|
||||
const mentioned = await loadIssueSummaries(
|
||||
card.companyId,
|
||||
(card.mentionedIssueIds ?? []).filter((issueId) => !issueMap.has(issueId)),
|
||||
);
|
||||
for (const issue of mentioned) issueMap.set(issue.id, issue);
|
||||
const snapshot = [...issueMap.values()];
|
||||
if (snapshot.length === 0) return snapshot;
|
||||
const latestHumanComments = await db
|
||||
|
|
@ -733,9 +790,33 @@ export function statusCardService(
|
|||
const trigger = payload?.operation === "update" && ["manual", "interval", "reactive", "restore"].includes(String(payload.trigger))
|
||||
? payload.trigger as "manual" | "interval" | "reactive" | "restore"
|
||||
: "manual";
|
||||
const snapshot = payload?.operation === "update" && payload.fingerprint && typeof payload.fingerprint === "object"
|
||||
const payloadFingerprint = payload?.operation === "update" && payload.fingerprint && typeof payload.fingerprint === "object"
|
||||
? payload.fingerprint as StatusCardFingerprint
|
||||
: buildStatusCardFingerprint(await executeQueries(current));
|
||||
: null;
|
||||
const mentionedIssueIds = await resolveMentionedIssueIds(current.companyId, input.markdown);
|
||||
// Current watched membership: compiled-query matches plus the issues this
|
||||
// summary mentions.
|
||||
const watchedNow = buildStatusCardFingerprint(
|
||||
await executeQueries({ ...current, mentionedIssueIds }),
|
||||
);
|
||||
let snapshot: StatusCardFingerprint;
|
||||
if (payloadFingerprint) {
|
||||
// Keep the generation-time fingerprint as the change baseline: issues
|
||||
// that changed (or newly matched the query) while this summary was
|
||||
// being written must still fire at the next diff. Mentions are the
|
||||
// exception — the summary just covered them, so they join silently —
|
||||
// and mention-only entries whose reference dropped out leave the set.
|
||||
snapshot = { ...payloadFingerprint };
|
||||
for (const droppedId of current.mentionedIssueIds ?? []) {
|
||||
if (!mentionedIssueIds.includes(droppedId) && !watchedNow[droppedId]) delete snapshot[droppedId];
|
||||
}
|
||||
for (const issueId of mentionedIssueIds) {
|
||||
const entry = watchedNow[issueId];
|
||||
if (entry) snapshot[issueId] = entry;
|
||||
}
|
||||
} else {
|
||||
snapshot = watchedNow;
|
||||
}
|
||||
const existing = current.documentId
|
||||
? await tx.select().from(documents).where(and(eq(documents.id, current.documentId), eq(documents.companyId, current.companyId))).then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
|
|
@ -784,6 +865,7 @@ export function statusCardService(
|
|||
lastModel: input.model ?? null,
|
||||
fingerprint: snapshot,
|
||||
fingerprintAt: now,
|
||||
mentionedIssueIds,
|
||||
pendingChangeCount: 0,
|
||||
pendingChangeHash: null,
|
||||
lastChangeAt: null,
|
||||
|
|
@ -831,5 +913,5 @@ export function statusCardService(
|
|||
return Promise.all(card.queries.map(async (query) => ({ query, result: await searchSvc.search(card.companyId, query) })));
|
||||
}
|
||||
|
||||
return { list, getById, hydrate, create, update, remove, listUpdates, listSummaryRevisions, requestCompile, requestRefresh, tickDueStatusCards, writeQuery, writeSummary, dryRun };
|
||||
return { list, getById, hydrate, create, update, remove, listUpdates, listSummaryRevisions, listMentionedIssues, requestCompile, requestRefresh, tickDueStatusCards, writeQuery, writeSummary, dryRun };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type {
|
||||
CompanySearchIssueSummary,
|
||||
CompanySearchQuery,
|
||||
CompanySearchResponse,
|
||||
CreateStatusCard,
|
||||
|
|
@ -13,6 +14,8 @@ export interface StatusCardDryRun {
|
|||
cardId: string;
|
||||
queryVersion: number;
|
||||
queries: Array<{ query: CompanySearchQuery; result: CompanySearchResponse }>;
|
||||
/** Issues referenced in the latest summary that joined the watched set. */
|
||||
mentionedIssues: CompanySearchIssueSummary[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ export function StatusCardDetailDrawer({
|
|||
const dryRunQuery = useQuery({
|
||||
queryKey: card ? queryKeys.statusCards.dryRun(card.id) : ["status-cards", "detail", "none", "dry-run"],
|
||||
queryFn: () => statusCardsApi.dryRun(card!.id),
|
||||
enabled: Boolean(card && open && tab === "watched" && card.queries.length > 0),
|
||||
enabled: Boolean(card && open && tab === "watched" && (card.queries.length > 0 || (card.mentionedIssueIds?.length ?? 0) > 0)),
|
||||
});
|
||||
const lifecycle = card ? deriveStatusCardLifecycle(card) : "fresh";
|
||||
const generatingIssue = useMemo<SummarySlotIssueRef | null>(
|
||||
|
|
@ -417,7 +417,7 @@ export function StatusCardDetailDrawer({
|
|||
</TabsContent>
|
||||
|
||||
<TabsContent value="watched" className="mt-0 space-y-3">
|
||||
{card.queries.length === 0 ? (
|
||||
{card.queries.length === 0 && (card.mentionedIssueIds?.length ?? 0) === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border px-3 py-4 text-sm text-muted-foreground">
|
||||
This card is still setting up — the issues it watches appear here once it's ready.
|
||||
</div>
|
||||
|
|
@ -430,7 +430,10 @@ export function StatusCardDetailDrawer({
|
|||
{dryRunQuery.error instanceof Error ? dryRunQuery.error.message : "Try again."}
|
||||
</InlineBanner>
|
||||
) : (
|
||||
<MatchedIssueList queries={dryRunQuery.data?.queries ?? []} />
|
||||
<MatchedIssueList
|
||||
queries={dryRunQuery.data?.queries ?? []}
|
||||
mentioned={dryRunQuery.data?.mentionedIssues ?? []}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
|
|
@ -523,9 +526,10 @@ function QueryDebugSection({ card }: { card: StatusCardView }) {
|
|||
/**
|
||||
* Live matched-issue list for the Watched tab, fed by the dry-run endpoint.
|
||||
* Queries in the compiled array are a union, so issues matched by more than
|
||||
* one query are deduplicated by id.
|
||||
* one query are deduplicated by id. Issues mentioned in the latest summary
|
||||
* join the watched set too and render as their own group below the matches.
|
||||
*/
|
||||
function MatchedIssueList({ queries }: { queries: StatusCardDryRun["queries"] }) {
|
||||
function MatchedIssueList({ queries, mentioned }: { queries: StatusCardDryRun["queries"]; mentioned: CompanySearchIssueSummary[] }) {
|
||||
const seen = new Set<string>();
|
||||
const matched: CompanySearchIssueSummary[] = [];
|
||||
for (const { result } of queries) {
|
||||
|
|
@ -535,7 +539,8 @@ function MatchedIssueList({ queries }: { queries: StatusCardDryRun["queries"] })
|
|||
matched.push(item.issue);
|
||||
}
|
||||
}
|
||||
if (matched.length === 0) {
|
||||
const mentionedOnly = mentioned.filter((issue) => !seen.has(issue.id));
|
||||
if (matched.length === 0 && mentionedOnly.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-border px-3 py-4 text-sm text-muted-foreground">
|
||||
The compiled query matches no issues right now.
|
||||
|
|
@ -543,20 +548,38 @@ function MatchedIssueList({ queries }: { queries: StatusCardDryRun["queries"] })
|
|||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{matched.map((issue) => (
|
||||
<div key={issue.id} className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-xs">
|
||||
<Link
|
||||
to={`/issues/${issue.identifier ?? issue.id}`}
|
||||
className="shrink-0 font-medium text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
||||
>
|
||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||
</Link>
|
||||
<IssueStatusBadge status={issue.status} />
|
||||
<span className="min-w-0 flex-1 truncate">{issue.title}</span>
|
||||
<span className="shrink-0 text-muted-foreground">{relativeTime(issue.updatedAt)}</span>
|
||||
<div className="space-y-3">
|
||||
{matched.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
{matched.map((issue) => (
|
||||
<WatchedIssueRow key={issue.id} issue={issue} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
) : null}
|
||||
{mentionedOnly.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">Mentioned in the latest update</p>
|
||||
{mentionedOnly.map((issue) => (
|
||||
<WatchedIssueRow key={issue.id} issue={issue} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WatchedIssueRow({ issue }: { issue: CompanySearchIssueSummary }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-xs">
|
||||
<Link
|
||||
to={`/issues/${issue.identifier ?? issue.id}`}
|
||||
className="shrink-0 font-medium text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
||||
>
|
||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||
</Link>
|
||||
<IssueStatusBadge status={issue.status} />
|
||||
<span className="min-w-0 flex-1 truncate">{issue.title}</span>
|
||||
<span className="shrink-0 text-muted-foreground">{relativeTime(issue.updatedAt)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ function baseCard(overrides: Partial<StatusCardView>): StatusCardView {
|
|||
lastChangeAt: null,
|
||||
fingerprint: null,
|
||||
fingerprintAt: null,
|
||||
mentionedIssueIds: [],
|
||||
documentId: null,
|
||||
lastUpdateRunKind: "full",
|
||||
lastGeneratedAt: "2026-07-22T11:00:00.000Z",
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import type { StatusCard, StatusCardUpdate } from "@paperclipai/shared";
|
|||
export interface StatusCardView extends StatusCard {
|
||||
/** Latest summary markdown (from the card's summary document). */
|
||||
summaryBody?: string | null;
|
||||
/** Number of issues currently matched by the compiled query. */
|
||||
/** Watched-issue count: compiled-query matches plus summary mentions. */
|
||||
watchedIssueCount?: number;
|
||||
/** Tokens spent by this card so far today. */
|
||||
todayTokens?: number;
|
||||
|
|
|
|||
Loading…
Reference in New Issue