/** * בחירת יעד-הסנכרון של הג'וב `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. ה-import היחיד למטה הוא type-only (`import * type`), כך שהוא לא נדרס בזמן ריצה ולא שובר את הטוהר הזה. */ import type { StatusModelEntry } from "./legal-api.ts"; /** "סגור" — ההגדרה היחידה. מראה של web/paperclip_client.py:561 ב-legal-ai. */ export const CLOSED_ISSUE_STATUSES: ReadonlySet = new Set([ "done", "cancelled", ]); /** * מצב בבעלות Paperclip — **לא** הגדרה שנייה של "סגור". `blocked` — כתיבה * עליו מסתירה חוסם קיים. * * `in_review` **הוסר מהסט** בהכרעת חיים מ-2026-08-26 (legal-ai issue #626): * נשאל במפורש אם השחרור נקודתי או שיטתי, והשיב "התכוונתי לשיטתי". * * המחיר, בכנות: `in_review` הוא מצב-ההמתנה-ליו"ר המכוון — CEO שמשאיר issue * ב-`in_progress` מקבל auto-block מ-Paperclip תוך דקה, ולכן מעביר אותו * ל-`in_review` כדי לחמוק מזה (legal-ai/docs/paperclip-quirks.md §3). מעתה * issue שממתין לביקורת היו"ר עשוי להידרס בחזרה ל-`in_progress` בריצת- * הסנכרון הבאה (כל 15 דק') ולרדת מתור-הביקורת. זה מכוון ומתועד, לא תקלה. * * מסלול-חזרה: להחזיר `"in_review"` לסט למטה + build + התקנה + * `pm2 restart paperclip`. */ export const NON_WRITABLE_STATUSES: ReadonlySet = new Set(["blocked"]); export interface SyncCandidate { id: string; status: string; parentId: string | null; companyId: string; } 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, }; } /** * Derive the coarse Paperclip issue status for a legal-ai case status, using * only generic fields already returned by legal-ai's `/api/status-model` * (the canonical `case_status_model.py` registry) — never a hardcoded list of * case-status keys. This is what makes a new case status "just work" without * editing this plugin: `terminal: true` → done; the very first status in the * ordered list → todo (nothing has started yet); everything else → in_progress. * Returns `null` when `caseStatus` is not present in `statuses` at all (a * genuinely unknown status — the caller must log this loudly, never swallow * it silently; see legal-ai issue #604). */ export function resolveIssueStatus( statuses: readonly StatusModelEntry[], caseStatus: string, ): "todo" | "in_progress" | "done" | null { const index = statuses.findIndex((s) => s.key === caseStatus); if (index === -1) return null; if (statuses[index].terminal) return "done"; return index === 0 ? "todo" : "in_progress"; } /** Hebrew label for a case status from the canonical model, or null if unknown. */ export function labelFor( statuses: readonly StatusModelEntry[], caseStatus: string, ): string | null { return statuses.find((s) => s.key === caseStatus)?.label ?? null; } /** למה נפלנו לערך-הגולמי, או `null` כשהסטטוס מוכר. הקורא **חייב** לדווח על ערך לא-null. */ export type StatusLabelFallback = "unknown_status" | "model_unavailable" | null; export interface StatusLabelResolution { label: string; fallback: StatusLabelFallback; } /** * התווית להצגה עבור `caseStatus`, יחד עם **סיבת** נפילה-לגולמי אם הייתה. * * ההחלטה מופרדת כאן מהדיווח (`ctx.logger`) בכוונה: `worker.ts` אינו ניתן * ל-import בטסט (הוא מריץ `runWorker(...)` ברמת-המודול), ולכן ההיגיון שחייב * כיסוי-טסט חי במודול הטהור הזה — בדיוק כמו `pickSyncTargetIssue` (#446) * ו-`resolveIssueStatus` (#604). legal-ai issue #616. * * `statuses === null` = מודל-הסטטוסים לא נטען כלל (כשל-רשת) — נבדל מסטטוס * שנטען ולא נמצא, כי הן שתי תקלות שונות עם אבחון שונה. */ export function resolveStatusLabel( statuses: readonly StatusModelEntry[] | null, caseStatus: string, ): StatusLabelResolution { if (statuses === null) { return { label: caseStatus, fallback: "model_unavailable" }; } const label = labelFor(statuses, caseStatus); if (label === null) { return { label: caseStatus, fallback: "unknown_status" }; } return { label, fallback: null }; }