diff --git a/src/sync-target.test.ts b/src/sync-target.test.ts new file mode 100644 index 0000000..ed3f2bf --- /dev/null +++ b/src/sync-target.test.ts @@ -0,0 +1,134 @@ +/// + +import assert from "node:assert/strict"; +import { test } from "node:test"; +// Excluded from tsc (tsconfig.json) — never bundled/emitted, run natively via +// `node --test`, which requires the literal `.ts` extension (Node's ESM +// resolver does not remap `.js` specifiers to `.ts` files at runtime). +import { pickSyncTargetIssue, type SyncCandidate } from "./sync-target.ts"; + +/** + * הכלל הישן (worker.ts:894 לפני #446): כל issue מקושר שסטטוסו שונה מהיעד — + * נכתב, בלי סינון parentId ובלי סינון-סטטוס. + */ +function legacyTargets( + candidates: readonly SyncCandidate[], + targetStatus: string, +): string[] { + return candidates.filter((c) => c.status !== targetStatus).map((c) => c.id); +} + +test("רגרסיית 8125-09-24 / CMPA-140: 11 ה-issues הממשיים של התיק, אף sub-task done לא נבחר", () => { + // נמדד ב-DB החי של Paperclip (2026-08-24) — כל ה-issues המקושרים לתיק + // 8125-09-24. CMPA-140 (זה שנדרס בפועל, done→in_progress) הוא **ילד** של + // CMPA-139, לא שורש. CMPA-139 עצמו אינו ברשימה בכוונה: נמדד שאין לו + // רשומת legal-case-number ב-plugin_state, ולכן הוא לא מגיע כמועמד כלל — + // לילד יש הורה שאינו מקושר. + const candidates: SyncCandidate[] = [ + { id: "CMPA-116", status: "cancelled", parentId: null }, + { id: "CMPA-84", status: "cancelled", parentId: null }, + { id: "CMPA-86", status: "cancelled", parentId: null }, + { id: "CMPA-140", status: "done", parentId: "CMPA-139" }, // זה שנדרס בפועל + { id: "CMPA-141", status: "cancelled", parentId: "CMPA-139" }, + { id: "CMPA-85", status: "done", parentId: "CMPA-84" }, + { id: "CMPA-146", status: "done", parentId: "CMPA-84" }, + { id: "CMPA-147", status: "done", parentId: "CMPA-84" }, + { id: "CMPA-87", status: "done", parentId: "CMPA-86" }, + { id: "CMPA-88", status: "done", parentId: "CMPA-86" }, + { id: "CMPA-89", status: "cancelled", parentId: "CMPA-86" }, + ]; + + // סטטוס-התיק היה drafted → in_progress (CASE_STATUS_TO_ISSUE_STATUS ב-worker.ts). + const targetStatus = "in_progress"; + + // הכלל הישן היה כותב על CMPA-140 — זה הבאג שתועד. + assert.ok(legacyTargets(candidates, targetStatus).includes("CMPA-140")); + + const result = pickSyncTargetIssue(candidates); + + // הכלל החדש אינו כותב על דבר. + assert.equal(result.target, null); + assert.equal(result.reason, "no_writable_root"); +}); + +test("sub-task יחיד ב-done ומקושר: sub-task לעולם לא נבחר", () => { + const candidates: SyncCandidate[] = [ + { id: "sub-1", status: "done", parentId: "some-root" }, + ]; + + const result = pickSyncTargetIssue(candidates); + + assert.equal(result.target, null); + assert.equal(result.reason, "no_writable_root"); +}); + +test("sub-task יחיד פתוח (in_progress) + שורש cancelled: פתוח אינו מספיק, חייב שורש", () => { + const candidates: SyncCandidate[] = [ + { id: "root-1", status: "cancelled", parentId: null }, + { id: "sub-1", status: "in_progress", parentId: "root-1" }, + ]; + + const result = pickSyncTargetIssue(candidates); + + assert.equal(result.target, null); + assert.equal(result.reason, "no_writable_root"); +}); + +test("שורש יחיד ב-todo: נבחר", () => { + const candidates: SyncCandidate[] = [ + { id: "root-1", status: "todo", parentId: null }, + ]; + + const result = pickSyncTargetIssue(candidates); + + assert.equal(result.target?.id, "root-1"); + assert.equal(result.reason, "ok"); + assert.equal(result.writableRoots, 1); +}); + +test("שורש יחיד ב-in_review: לא נכתב (הגנת auto-block)", () => { + const candidates: SyncCandidate[] = [ + { id: "root-1", status: "in_review", parentId: null }, + ]; + + const result = pickSyncTargetIssue(candidates); + + assert.equal(result.target, null); + assert.equal(result.reason, "no_writable_root"); +}); + +test("שורש יחיד ב-blocked: לא נכתב", () => { + const candidates: SyncCandidate[] = [ + { id: "root-1", status: "blocked", parentId: null }, + ]; + + const result = pickSyncTargetIssue(candidates); + + assert.equal(result.target, null); + assert.equal(result.reason, "no_writable_root"); +}); + +test("שני שורשים ברי-כתיבה: ambiguous, לא נכתב", () => { + const candidates: SyncCandidate[] = [ + { id: "root-1", status: "todo", parentId: null }, + { id: "root-2", status: "in_progress", parentId: null }, + ]; + + const result = pickSyncTargetIssue(candidates); + + assert.equal(result.target, null); + assert.equal(result.reason, "ambiguous_writable_roots"); + assert.equal(result.writableRoots, 2); +}); + +test("שורש todo + ילד in_progress: השורש נבחר", () => { + const candidates: SyncCandidate[] = [ + { id: "root-1", status: "todo", parentId: null }, + { id: "child-1", status: "in_progress", parentId: "root-1" }, + ]; + + const result = pickSyncTargetIssue(candidates); + + assert.equal(result.target?.id, "root-1"); + assert.equal(result.reason, "ok"); +}); diff --git a/src/sync-target.ts b/src/sync-target.ts new file mode 100644 index 0000000..ccea907 --- /dev/null +++ b/src/sync-target.ts @@ -0,0 +1,79 @@ +/** + * בחירת יעד-הסנכרון של הג'וב `sync-case-status` (issue #446). + * + * המקור לבאג: הג'וב הישן כתב על **כל** issue מקושר-לתיק, כולל sub-issues + * ו-issues בסטטוסים סגורים/בהמתנה-לאדם. זה החזיר CMPA-140 מ-`done` ל-`in_progress` + * (8125-09-24) — כי sub-task שהושלם עדיין נמנה כ"מקושר". + * + * המודול הזה טהור בכוונה — בלי import מה-SDK ובלי side effects — כדי שאפשר + * יהיה לייבא אותו ישירות בטסט בלי להריץ את `runWorker(plugin, import.meta.url)` + * שקורה בזמן import של worker.ts. + */ + +/** "סגור" — ההגדרה היחידה. מראה של web/paperclip_client.py:561 ב-legal-ai. */ +export const CLOSED_ISSUE_STATUSES: ReadonlySet = new Set([ + "done", + "cancelled", +]); + +/** + * מצב בבעלות Paperclip / בהמתנה-לאדם — **לא** הגדרה שנייה של "סגור". + * `in_review` הוא מצב-ההמתנה-ליו"ר המכוון: CEO שמשאיר issue ב-`in_progress` + * מקבל auto-block מ-Paperclip תוך דקה, ולכן הוא מעביר ל-`in_review` + * (legal-ai/docs/paperclip-quirks.md §3). כתיבת-סטטוס אוטומטית עליו גונבת את + * ה-issue מתור-הביקורת של היו"ר ומזמינה קרב-סטטוסים. + * `blocked` — כתיבה עליו מסתירה חוסם קיים. + */ +export const NON_WRITABLE_STATUSES: ReadonlySet = new Set([ + "in_review", + "blocked", +]); + +export interface SyncCandidate { + id: string; + status: string; + parentId: string | null; +} + +export type SyncTargetReason = + | "ok" + | "no_linked_issues" + | "no_writable_root" + | "ambiguous_writable_roots"; + +export interface SyncTargetResult { + target: SyncCandidate | null; + reason: SyncTargetReason; + /** כמה שורשים ברי-כתיבה נמצאו — לצורך לוג. */ + writableRoots: number; +} + +export function isWritableStatus(status: string): boolean { + return ( + !CLOSED_ISSUE_STATUSES.has(status) && !NON_WRITABLE_STATUSES.has(status) + ); +} + +export function pickSyncTargetIssue( + candidates: readonly SyncCandidate[], +): SyncTargetResult { + if (candidates.length === 0) { + return { target: null, reason: "no_linked_issues", writableRoots: 0 }; + } + + const roots = candidates.filter( + (c) => c.parentId === null && isWritableStatus(c.status), + ); + + if (roots.length === 1) { + return { target: roots[0], reason: "ok", writableRoots: 1 }; + } + if (roots.length === 0) { + return { target: null, reason: "no_writable_root", writableRoots: 0 }; + } + return { + target: null, + reason: "ambiguous_writable_roots", + writableRoots: roots.length, + }; +} diff --git a/src/worker.ts b/src/worker.ts index 2a82af6..f52f7d3 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -4,6 +4,7 @@ import type { } from "@paperclipai/plugin-sdk"; import { definePlugin, runWorker } from "@paperclipai/plugin-sdk"; import { LegalApi } from "./legal-api.js"; +import { pickSyncTargetIssue, type SyncCandidate } from "./sync-target.js"; // Hoisted so onWebhook can access the context after setup() completes. let pluginCtx: PluginContext | null = null; @@ -18,6 +19,43 @@ const CEO_AGENT_IDS: Record = { const DEFAULT_LEGAL_API_BASE = "http://localhost:8085"; +// Map 13 legal-ai case statuses to Paperclip issue status (used by the +// `sync-case-status` job). +const CASE_STATUS_TO_ISSUE_STATUS: Record< + string, + "todo" | "in_progress" | "done" +> = { + new: "todo", + uploading: "todo", + processing: "in_progress", + documents_ready: "in_progress", + outcome_set: "in_progress", + brainstorming: "in_progress", + direction_approved: "in_progress", + drafting: "in_progress", + qa_review: "in_progress", + drafted: "in_progress", + exported: "in_progress", + reviewed: "in_progress", + final: "done", +}; + +const CASE_STATUS_LABELS: Record = { + new: "תיק חדש", + uploading: "העלאת מסמכים", + processing: "עיבוד מסמכים", + documents_ready: "מסמכים מוכנים — הזן תוצאה", + outcome_set: "תוצאה הוזנה — נדרש סיעור מוחות", + brainstorming: "גיבוש כיוון בתהליך", + direction_approved: "כיוון אושר — מוכן לכתיבה", + drafting: "כתיבת החלטה בתהליך", + qa_review: "בדיקת איכות", + drafted: "טיוטה מוכנה — בדוק ושלח לדפנה", + exported: "DOCX נוצר — ממתין לדפנה", + reviewed: "דפנה הגיהה — העלה גרסה סופית", + final: "גרסה סופית — לולאת למידה", +}; + /** * Resolve `legalApiBaseUrl` from company-scoped plugin config. * @@ -845,71 +883,60 @@ const plugin = definePlugin({ const companyId = companies[0].id; const issues = await ctx.issues.list({ companyId }); + // מעבר אחד על ה-issues: state.get אחד ל-issue (לא לכל צירוף תיק×issue), + // וקיבוץ למפה case_number → מועמדים. הקיבוץ נדרש כדי לבחור "השורש היחיד". + const byCase = new Map(); + for (const issue of issues) { + const linkedCase = await ctx.state.get({ + scopeKind: "issue", + scopeId: issue.id, + stateKey: "legal-case-number", + }); + if (typeof linkedCase !== "string" || !linkedCase) continue; + const list = byCase.get(linkedCase) ?? []; + list.push({ + id: issue.id, + status: issue.status, + parentId: issue.parentId, + }); + byCase.set(linkedCase, list); + } + for (const legalCase of cases) { - for (const issue of issues) { - const linkedCase = await ctx.state.get({ - scopeKind: "issue", - scopeId: issue.id, - stateKey: "legal-case-number", + const targetStatus = CASE_STATUS_TO_ISSUE_STATUS[legalCase.status]; + if (!targetStatus) continue; + + const candidates = byCase.get(legalCase.case_number) ?? []; + const { target, reason, writableRoots } = + pickSyncTargetIssue(candidates); + + if (!target) { + // אין בליעה שקטה: מדווח למה לא נכתב כלום. + ctx.logger.info("sync-case-status: no sync target", { + caseNumber: legalCase.case_number, + reason, + linked: candidates.length, + writableRoots, }); - - if (linkedCase === legalCase.case_number) { - // Map 13 legal-ai statuses to Paperclip issue status - const statusMap: Record = - { - new: "todo", - uploading: "todo", - processing: "in_progress", - documents_ready: "in_progress", - outcome_set: "in_progress", - brainstorming: "in_progress", - direction_approved: "in_progress", - drafting: "in_progress", - qa_review: "in_progress", - drafted: "in_progress", - exported: "in_progress", - reviewed: "in_progress", - final: "done", - }; - - const statusLabels: Record = { - new: "תיק חדש", - uploading: "העלאת מסמכים", - processing: "עיבוד מסמכים", - documents_ready: "מסמכים מוכנים — הזן תוצאה", - outcome_set: "תוצאה הוזנה — נדרש סיעור מוחות", - brainstorming: "גיבוש כיוון בתהליך", - direction_approved: "כיוון אושר — מוכן לכתיבה", - drafting: "כתיבת החלטה בתהליך", - qa_review: "בדיקת איכות", - drafted: "טיוטה מוכנה — בדוק ושלח לדפנה", - exported: "DOCX נוצר — ממתין לדפנה", - reviewed: "דפנה הגיהה — העלה גרסה סופית", - final: "גרסה סופית — לולאת למידה", - }; - - const targetStatus = statusMap[legalCase.status]; - const label = statusLabels[legalCase.status] || legalCase.status; - - if (targetStatus && issue.status !== targetStatus) { - await ctx.issues.update( - issue.id, - { status: targetStatus }, - companyId, - ); - await ctx.issues.createComment( - issue.id, - `📋 ${label}`, - companyId, - ); - ctx.logger.info("Synced issue status", { - issueId: issue.id, - caseNumber: legalCase.case_number, - newStatus: targetStatus, - }); - } - } + continue; } + + if (target.status === targetStatus) continue; + + const label = + CASE_STATUS_LABELS[legalCase.status] || legalCase.status; + + await ctx.issues.update( + target.id, + { status: targetStatus }, + companyId, + ); + await ctx.issues.createComment(target.id, `📋 ${label}`, companyId); + ctx.logger.info("Synced issue status", { + issueId: target.id, + caseNumber: legalCase.case_number, + newStatus: targetStatus, + }); } ctx.logger.info("Case status sync completed", { diff --git a/tsconfig.json b/tsconfig.json index 66ec067..2cc8cc3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,5 +12,6 @@ "jsx": "react-jsx", "lib": ["ES2022", "DOM", "DOM.Iterable"] }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/**/*.test.ts"] }