From 3556736445cac5e65d0490a5d2f89b7183decaf0 Mon Sep 17 00:00:00 2001 From: Hamed Ahmadi <107760095+hamedtkd@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:25:45 +0330 Subject: [PATCH 1/2] feat(worklog): export Jira descriptions and opt-in embedded images --- docs/CAPABILITIES.md | 2 +- public/background.js | 4 +- public/jira-bridge.js | 2 +- src/features/worklog/WorklogExportSheet.tsx | 97 +++++++++++++++++++++ src/features/worklog/WorklogIssuePicker.tsx | 10 +-- src/features/worklog/WorklogScreen.tsx | 12 ++- src/features/worklog/worklog-rich-export.ts | 96 ++++++++++++++++++++ src/lib/jira.ts | 4 +- src/lib/jira/issue-details.ts | 12 +++ src/lib/jira/request.ts | 11 +++ tests/worklog-rich-export.test.mjs | 61 +++++++++++++ 11 files changed, 292 insertions(+), 19 deletions(-) create mode 100644 src/features/worklog/WorklogExportSheet.tsx create mode 100644 src/features/worklog/worklog-rich-export.ts create mode 100644 tests/worklog-rich-export.test.mjs diff --git a/docs/CAPABILITIES.md b/docs/CAPABILITIES.md index e8b4351..28c25af 100644 --- a/docs/CAPABILITIES.md +++ b/docs/CAPABILITIES.md @@ -217,7 +217,7 @@ QueueMint includes a preview-first time logging workflow while keeping Jira work - The relevant-today filter requires evidence from today: an existing worklog, an update today, or completion today. In Progress by itself is not enough. At most eight issues are ranked. - Estimates never decide what the user worked on. After selection, they can either weight a chosen target duration or be used exactly through Estimate only mode, which uses Jira remaining estimate and does not stretch worklogs to the daily target. - Each issue row exposes status, assignee, sprint/backlog, estimate, time already logged today, and last update. -- Worklog context can be copied/downloaded as an AI-ready JSON package with selected/visible issue metadata and already-logged time. +- Worklog context can be copied/downloaded as an AI-ready JSON package with selected/visible issue metadata, already-logged time, and the full per-issue Jira description. Images are opt-in, embedded as Base64 from Jira attachments only on explicit export, with 20-image, 1.5 MiB/image, and 10 MiB total caps; skipped images are reported in the JSON and UI. - Worklog descriptions never submit blank: per-issue descriptions override the shared draft description, and otherwise QueueMint generates `Worked on ISSUE-KEY: issue summary` with a final issue-key-only fallback in the Jira writer. - AI-produced JSON can be pasted or uploaded and is converted into the same editable review table before any Jira write. - Optional OpenAI allocation uses only the explicit selected issue set plus the user's work note and disclosed context. diff --git a/public/background.js b/public/background.js index 9211884..5bc7cab 100644 --- a/public/background.js +++ b/public/background.js @@ -258,12 +258,12 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { return true } - if (message.type === "QUEUEMINT_JIRA_FETCH_AVATAR") { + if (message.type === "QUEUEMINT_JIRA_FETCH_AVATAR" || message.type === "QUEUEMINT_JIRA_FETCH_IMAGE") { loadConnection().then((connection) => { if (!connection || !isSafeSameOriginAssetUrl(message.url, connection.origin)) { return { ok: false, error: { code: "INVALID_AVATAR_URL", message: "Blocked invalid Jira avatar URL." } } } - return forwardToJiraTab({ type: "QUEUEMINT_JIRA_FETCH_AVATAR", url: message.url }) + return forwardToJiraTab({ type: message.type, url: message.url }) }).then(sendResponse) return true } diff --git a/public/jira-bridge.js b/public/jira-bridge.js index f8ae7cf..dc06a22 100644 --- a/public/jira-bridge.js +++ b/public/jira-bridge.js @@ -106,7 +106,7 @@ return true } - if (message.type === "QUEUEMINT_JIRA_FETCH_AVATAR") { + if (message.type === "QUEUEMINT_JIRA_FETCH_AVATAR" || message.type === "QUEUEMINT_JIRA_FETCH_IMAGE") { const url = safeSameOriginAssetUrl(message.url) if (!url) { sendResponse({ ok: false, error: { code: "INVALID_AVATAR_URL", message: "Blocked unsafe Jira avatar URL." } }) diff --git a/src/features/worklog/WorklogExportSheet.tsx b/src/features/worklog/WorklogExportSheet.tsx new file mode 100644 index 0000000..a14c8a2 --- /dev/null +++ b/src/features/worklog/WorklogExportSheet.tsx @@ -0,0 +1,97 @@ +import { useEffect, useState } from "react" +import { ClipboardCopy, Download, LoaderCircle } from "lucide-react" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { Sheet, SheetBody, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet" +import { getJiraImageDataUrl, getJiraIssueExportDetails } from "@/lib/jira" +import { downloadJson } from "@/lib/utils" +import type { AppLocale, JiraLiveIssue } from "@/types" +import type { buildWorklogAiPackage } from "./worklog-json" +import { enrichWorklogAiPackage, type ExportProgress } from "./worklog-rich-export" + +type PreparedExport = { withImages: boolean; payload: unknown; skipped: number } + +export function WorklogExportSheet({ open, onOpenChange, locale, issues, date, buildPackage }: { + open: boolean + onOpenChange: (open: boolean) => void + locale: AppLocale + issues: JiraLiveIssue[] + date: Date + buildPackage: () => ReturnType +}) { + const isFa = locale === "fa" + const [includeImages, setIncludeImages] = useState(false) + const [busy, setBusy] = useState(false) + const [progress, setProgress] = useState(null) + const [prepared, setPrepared] = useState(null) + const [skipped, setSkipped] = useState(0) + const [error, setError] = useState("") + + useEffect(() => { + if (!open) { setIncludeImages(false); setPrepared(null); setProgress(null); setSkipped(0); setError("") } + }, [open]) + + async function exportData(action: "copy" | "download") { + if (busy || !issues.length) return + setBusy(true) + setError("") + try { + let payload: unknown = prepared?.payload + let missing = prepared?.skipped ?? 0 + if (!prepared || prepared.withImages !== includeImages) { + const result = await enrichWorklogAiPackage(buildPackage(), { + includeImages, + loadDetails: getJiraIssueExportDetails, + loadImage: getJiraImageDataUrl, + onProgress: setProgress, + }) + payload = result.payload + missing = result.skipped.length + setPrepared({ withImages: includeImages, payload, skipped: missing }) + } + if (action === "copy") await navigator.clipboard.writeText(JSON.stringify(payload, null, 2)) + else { + const day = date.getFullYear() + "-" + String(date.getMonth() + 1).padStart(2, "0") + "-" + String(date.getDate()).padStart(2, "0") + downloadJson("queuemint-worklog-ai-" + day + ".json", payload) + } + setSkipped(missing) + if (missing) toast.warning(isFa ? "برخی تصاویر در خروجی قرار نگرفتند؛ جزئیات در JSON ثبت شده است." : "Some images were skipped; details are in the JSON.") + else toast.success(action === "copy" ? (isFa ? "JSON برای AI کپی شد" : "AI JSON copied") : (isFa ? "خروجی JSON آماده شد" : "JSON downloaded")) + } catch (reason) { + const message = reason instanceof Error ? reason.message : "Could not export Jira issues." + setError(message) + toast.error(isFa ? "ساخت خروجی ناموفق بود" : "Could not export Jira issues") + } finally { setBusy(false); setProgress(null) } + } + + return ( + { if (!busy) onOpenChange(value) }}> + + + {isFa ? "خروجی تسک‌ها برای AI" : "Export Jira issues for AI"} + {isFa ? "عنوان، وضعیت و توضیحات کامل هر تسک در خروجی قرار می‌گیرد." : "Export issue metadata and full Jira descriptions. Images are optional."} + + +

{isFa ? "تعداد تسک‌ها" : "Issues to export"}: {issues.length}

+ + {includeImages ?

{isFa ? "تصاویر ممکن است اطلاعات حساس داشته باشند. فقط در صورتی که قصد اشتراک‌گذاری دارید آن‌ها را اضافه کنید." : "Images may contain sensitive information. Only enable this when you intend to share them."}

: null} + {progress ?
{progress.phase === "details" ? (isFa ? "دریافت توضیحات" : "Loading descriptions") : (isFa ? "دریافت تصاویر" : "Loading images")} {progress.completed}/{progress.total}
: null} + {skipped > 0 ?

{isFa ? "تعداد تصاویر اضافه‌نشده: " : "Skipped images: "}{skipped}

: null} + {error ?

{error}

: null} +
+ + + + + +
+
+ ) +} diff --git a/src/features/worklog/WorklogIssuePicker.tsx b/src/features/worklog/WorklogIssuePicker.tsx index 17766a5..e0c93a2 100644 --- a/src/features/worklog/WorklogIssuePicker.tsx +++ b/src/features/worklog/WorklogIssuePicker.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from "react" -import { Activity, ArrowUpDown, BookOpen, Bug, CheckSquare2, Clock3, ClipboardCopy, Columns3, Download, Filter, Grid2X2, ListFilter, Search, Shapes, Table2, Upload, UserRound, UserX, UsersRound, X } from "lucide-react" +import { Activity, ArrowUpDown, BookOpen, Bug, CheckSquare2, Clock3, Columns3, Download, Filter, Grid2X2, ListFilter, Search, Shapes, Table2, Upload, UserRound, UserX, UsersRound, X } from "lucide-react" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" @@ -20,7 +20,7 @@ function itemLabel(items: WorklogFilterOption[], value: string) { return items.f function userAvatar(user?: JiraUser) { return user?.avatarUrls?.["24x24"] ?? user?.avatarUrls?.["32x32"] ?? user?.avatarUrls?.["48x48"] } function typeIcon(type: string) { const value = type.toLowerCase(); return value.includes("bug") ? Bug : value.includes("story") ? BookOpen : value.includes("task") ? CheckSquare2 : Shapes } -export function WorklogIssuePicker({ locale, date, issues, selectedKeys, currentUser, boardId, sprintFilter, dailyCandidateKeys, loggedMinutesByIssue, onSelectedKeysChange, onMoveIssueStatus, onCopyForAi, onDownloadForAi, onOpenImport }: { +export function WorklogIssuePicker({ locale, date, issues, selectedKeys, currentUser, boardId, sprintFilter, dailyCandidateKeys, loggedMinutesByIssue, onSelectedKeysChange, onMoveIssueStatus, onOpenExport, onOpenImport }: { locale: AppLocale date: Date issues: JiraLiveIssue[] @@ -32,8 +32,7 @@ export function WorklogIssuePicker({ locale, date, issues, selectedKeys, current loggedMinutesByIssue: Record onSelectedKeysChange: (keys: Set) => void onMoveIssueStatus?: (issueKey: string, target: Pick) => Promise - onCopyForAi: (issues: JiraLiveIssue[]) => void - onDownloadForAi: (issues: JiraLiveIssue[]) => void + onOpenExport: (issues: JiraLiveIssue[]) => void onOpenImport: () => void }) { const isFa = locale === "fa" @@ -101,8 +100,7 @@ export function WorklogIssuePicker({ locale, date, issues, selectedKeys, current setSort(value as WorklogIssueSort)} className="h-9 min-w-[210px]" /> - - + diff --git a/src/features/worklog/WorklogScreen.tsx b/src/features/worklog/WorklogScreen.tsx index 3ef1283..bd9c3c0 100644 --- a/src/features/worklog/WorklogScreen.tsx +++ b/src/features/worklog/WorklogScreen.tsx @@ -4,12 +4,12 @@ import { toast } from "sonner" import { Button } from "@/components/ui/button" import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet" -import { downloadJson } from "@/lib/utils" import type { ActivityEntry } from "@/lib/storage" import type { AppLocale, JiraBoard, JiraBoardColumn, JiraLiveIssue, JiraProject, JiraSprint, JiraUser } from "@/types" import { WorklogContextBar } from "./WorklogContextBar" import { WorklogDateToolbar } from "./WorklogDateToolbar" import { WorklogDraftSidebar } from "./WorklogDraftSidebar" +import { WorklogExportSheet } from "./WorklogExportSheet" import { WorklogImportSheet } from "./WorklogImportSheet" import { WorklogIssuePicker } from "./WorklogIssuePicker" import { buildWorklogAiPackage } from "./worklog-json" @@ -42,6 +42,8 @@ export function WorklogScreen({ locale, issues, selectedKeys, onSelectedKeysChan const [strategy, setStrategy] = useState("estimate") const [sprintFilter, setSprintFilter] = useState("all") const [importOpen, setImportOpen] = useState(false) + const [exportOpen, setExportOpen] = useState(false) + const [exportIssues, setExportIssues] = useState([]) const [compactDraftOpen, setCompactDraftOpen] = useState(false) const scopeRef = useRef(null) const issueRef = useRef(null) @@ -68,11 +70,6 @@ export function WorklogScreen({ locale, issues, selectedKeys, onSelectedKeysChan return buildWorklogAiPackage(exportIssues, model.targetMinutes, model.dayMinutes, target, model.note, model.loggedMinutesByIssue, date) } - async function copyForAi(exportIssues: JiraLiveIssue[]) { - try { await navigator.clipboard.writeText(JSON.stringify(aiPackage(exportIssues), null, 2)); toast.success(isFa ? "JSON برای AI کپی شد" : "AI JSON copied") } - catch { toast.error(isFa ? "کپی JSON ناموفق بود" : "Could not copy JSON") } - } - function changeDate(next: Date) { setDate(next) } function deselect(issueKey: string) { @@ -109,7 +106,7 @@ export function WorklogScreen({ locale, issues, selectedKeys, onSelectedKeysChan
-
void copyForAi(items)} onDownloadForAi={(items) => downloadJson(`queuemint-worklog-ai-${date.toISOString().slice(0, 10)}.json`, aiPackage(items))} onOpenImport={() => setImportOpen(true)} />
+
{ setExportIssues(items); setExportOpen(true) }} onOpenImport={() => setImportOpen(true)} />
{draftSidebar()}
@@ -125,6 +122,7 @@ export function WorklogScreen({ locale, issues, selectedKeys, onSelectedKeysChan + aiPackage(exportIssues)} /> { model.setDraft(entries); onSelectedKeysChange(new Set(entries.map((entry) => entry.issueKey))); autoBuiltFor.current = entries.map((entry) => entry.issueKey).sort().join("|"); window.setTimeout(() => scrollTo(4), 40) }} /> ) diff --git a/src/features/worklog/worklog-rich-export.ts b/src/features/worklog/worklog-rich-export.ts new file mode 100644 index 0000000..d3293a9 --- /dev/null +++ b/src/features/worklog/worklog-rich-export.ts @@ -0,0 +1,96 @@ +import type { JiraIssueAttachment } from "@/types" + +const IMAGE_TYPE = /^image\/(?:png|jpeg|gif|webp)$/i +const IMAGE_FILE = /\.(?:png|jpe?g|gif|webp)$/i +const MAX_IMAGE_BYTES = 1536 * 1024 +const MAX_TOTAL_BYTES = 10 * 1024 * 1024 +const MAX_IMAGES = 20 + +type IssueDetails = { description?: string; attachments: JiraIssueAttachment[] } +type BasePackage = { issues: Array<{ issueKey: string }> } +export type ExportProgress = { phase: "details" | "images"; completed: number; total: number } +export type ExportedImage = { id: string; filename: string; mimeType: string; sizeBytes: number; dataUrl: string } +export type SkippedImage = { issueKey: string; filename: string; reason: "size-limit" | "count-limit" | "unavailable" } + +type ExportOptions = { + includeImages: boolean + loadDetails: (key: string) => Promise + loadImage: (url: string) => Promise + onProgress?: (progress: ExportProgress) => void +} + +function imageAttachment(item: JiraIssueAttachment) { + return IMAGE_TYPE.test(item.mimeType ?? "") || (IMAGE_FILE.test(item.filename) && (!item.mimeType || item.mimeType === "application/octet-stream")) +} + +function imageData(dataUrl: string | null) { + const matched = /^data:(image\/(?:png|jpeg|gif|webp));base64,([A-Za-z0-9+/]+={0,2})$/i.exec(dataUrl ?? "") + if (!matched) return null + const bytes = Math.floor(matched[2].length * 3 / 4) - (matched[2].endsWith("==") ? 2 : matched[2].endsWith("=") ? 1 : 0) + return bytes > 0 ? { mimeType: matched[1], bytes } : null +} + +export async function enrichWorklogAiPackage(base: T, options: ExportOptions) { + const details: IssueDetails[] = new Array(base.issues.length) + let next = 0 + let finished = 0 + await Promise.all(Array.from({ length: Math.min(4, base.issues.length) }, async () => { + while (next < base.issues.length) { + const index = next++ + const issueKey = base.issues[index].issueKey + try { + details[index] = await options.loadDetails(issueKey) + } catch (error) { + const reason = error instanceof Error ? error.message : "Unknown Jira error" + throw new Error("Could not read " + issueKey + " description: " + reason) + } + finished++ + options.onProgress?.({ phase: "details", completed: finished, total: base.issues.length }) + } + })) + + const issues = base.issues.map((issue, index) => ({ ...issue, description: details[index].description ?? "", images: undefined as ExportedImage[] | undefined })) + const skipped: SkippedImage[] = [] + let included = 0 + let totalBytes = 0 + + if (options.includeImages) { + const candidates = details.flatMap((item, index) => item.attachments.filter(imageAttachment).map((attachment) => ({ index, attachment }))) + for (const [index, candidate] of candidates.entries()) { + const { attachment } = candidate + const issueKey = base.issues[candidate.index].issueKey + const skip = (reason: SkippedImage["reason"]) => skipped.push({ issueKey, filename: attachment.filename, reason }) + if (included >= MAX_IMAGES) skip("count-limit") + else if ((attachment.size ?? 0) > MAX_IMAGE_BYTES || totalBytes + (attachment.size ?? 0) > MAX_TOTAL_BYTES) skip("size-limit") + else if (!attachment.content) skip("unavailable") + else { + let dataUrl: string | null = null + try { dataUrl = await options.loadImage(attachment.content) } catch { /* A single inaccessible image must not discard descriptions. */ } + const data = imageData(dataUrl) + if (!data) skip("unavailable") + else if (data.bytes > MAX_IMAGE_BYTES || totalBytes + data.bytes > MAX_TOTAL_BYTES) skip("size-limit") + else { + included++ + totalBytes += data.bytes + const image: ExportedImage = { id: attachment.id, filename: attachment.filename, mimeType: data.mimeType, sizeBytes: data.bytes, dataUrl: dataUrl! } + issues[candidate.index].images ??= [] + issues[candidate.index].images.push(image) + } + } + options.onProgress?.({ phase: "images", completed: index + 1, total: candidates.length }) + } + } + + const outputIssues = issues.map((issue) => { + if (!issue.images) { const { images: _unused, ...withoutImages } = issue; return withoutImages } + return issue + }) + return { + payload: { + ...base, + issues: outputIssues, + ...(options.includeImages ? { imageExport: { requested: true, included, skipped } } : {}), + }, + skipped, + } +} diff --git a/src/lib/jira.ts b/src/lib/jira.ts index f7f08e5..34d22e1 100644 --- a/src/lib/jira.ts +++ b/src/lib/jira.ts @@ -1,11 +1,11 @@ -export { configureJiraConnection, getJiraAvatarDataUrl, getJiraConnectionStatus, jiraBrowseUrl, openJira, selectJiraTab } from "./jira/request" +export { configureJiraConnection, getJiraAvatarDataUrl, getJiraImageDataUrl, getJiraConnectionStatus, jiraBrowseUrl, openJira, selectJiraTab } from "./jira/request" export { clearCreateFieldCache, discoverJira, getBoardsForProject, getCreateFieldMatrix, getCreateFieldsForIssueType, getCreateMeta, getProject, getSprintsForBoard } from "./jira/metadata" export { getLiveBoardIssues, searchRecentProjectIssues } from "./jira/board-issues" export { getJiraBoardColumns } from "./jira/board-columns" export { transitionIssueToBoardColumn } from "./jira/transitions" export { pickJiraTransitionForBoardColumn } from "./jira/transition-utils" export { getBulkEditableFields, getBulkFieldSupport } from "./jira/editable-fields" -export { cloneJiraIssue, getJiraIssueDetails } from "./jira/issue-details" +export { cloneJiraIssue, getJiraIssueDetails, getJiraIssueExportDetails } from "./jira/issue-details" export { moveJiraIssue } from "./jira/move-issue" export { bulkEditIssues, deleteJiraIssues, getIssueFieldSnapshots, getProjectPermissions, moveIssueKeysToBacklog, restoreIssueFieldSnapshots } from "./jira/bulk-actions" export { getAssignableUsers, getEpicsForBoard, getLabelSuggestions, getProjectEpics, getProjectLabels, uploadIssueAttachments } from "./jira/project-options" diff --git a/src/lib/jira/issue-details.ts b/src/lib/jira/issue-details.ts index a0d71b6..c59c402 100644 --- a/src/lib/jira/issue-details.ts +++ b/src/lib/jira/issue-details.ts @@ -39,6 +39,18 @@ export async function getJiraIssueDetails(issueKey: string, storyPointsFieldId?: } } +// Export only the fields needed by Worklog. Avoid fetching Jira comments and unrelated private fields. +export async function getJiraIssueExportDetails(issueKey: string) { + if (!/^[A-Z][A-Z0-9_]*-\d+$/i.test(issueKey)) throw new Error("Invalid Jira issue key.") + const raw = await sendJiraRequest("/rest/api/2/issue/" + encodeURIComponent(issueKey) + "?fields=description%2Cattachment") + if (!raw?.id || !raw?.key) throw new Error("Jira did not return the requested issue.") + return { + description: typeof raw.fields?.description === "string" ? raw.fields.description : "", + attachments: (raw.fields?.attachment ?? []).filter((item): item is { id: string; filename: string; size?: number; mimeType?: string; content?: string; thumbnail?: string; author?: JiraUser } => + typeof item?.id === "string" && typeof item?.filename === "string"), + } +} + export async function cloneJiraIssue(issueKey: string, options: CloneIssueOptions) { const details = await getJiraIssueDetails(issueKey) const targetProjectKey = options.targetProjectKey.replace(/[^A-Za-z0-9_-]/g, "") diff --git a/src/lib/jira/request.ts b/src/lib/jira/request.ts index 5833604..2be8b98 100644 --- a/src/lib/jira/request.ts +++ b/src/lib/jira/request.ts @@ -73,6 +73,17 @@ export async function getJiraAvatarDataUrl(value: string) { return request } +// Request a one-off, authenticated same-origin image; unlike avatars, do not cache attachments. +export async function getJiraImageDataUrl(value: string) { + if (!value) return null + const response = await sendRuntimeMessage>( + { type: "QUEUEMINT_JIRA_FETCH_IMAGE", url: value }, + "Could not load Jira attachment image.", + ) + if (!response?.ok) throw jiraErrorFromResponse(response) + return typeof response.data?.dataUrl === "string" ? response.data.dataUrl : null +} + export async function openJira() { if (typeof chrome === "undefined" || !chrome.runtime?.id) throw new Error("Chrome extension APIs are unavailable. Load the built dist folder as an unpacked extension.") await sendRuntimeMessage({ type: "QUEUEMINT_OPEN_JIRA" }, "Could not open Jira.") diff --git a/tests/worklog-rich-export.test.mjs b/tests/worklog-rich-export.test.mjs new file mode 100644 index 0000000..7df241e --- /dev/null +++ b/tests/worklog-rich-export.test.mjs @@ -0,0 +1,61 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { enrichWorklogAiPackage } from "../src/features/worklog/worklog-rich-export.ts" + +const original = { schema: "queuemint-worklog-v1", issues: [{ issueKey: "SLID-191", summary: "Search focus", status: "To Do" }, { issueKey: "SLID-190", summary: "Card hover" }] } +const image = { id: "12", filename: "screenshot.png", mimeType: "image/png", size: 4, content: "https://jira.example/secure/attachment/12/screenshot.png" } +const details = async (key) => ({ description: "Description for " + key, attachments: [image, { id: "13", filename: "report.pdf", mimeType: "application/pdf", content: "https://jira.example/13" }] }) +const encoded = "data:image/png;base64,YWJjZA==" + +test("export always includes Jira descriptions without fetching images by default", async () => { + let fetches = 0 + const { payload, skipped } = await enrichWorklogAiPackage(original, { includeImages: false, loadDetails: details, loadImage: async () => { fetches++; return encoded } }) + assert.equal(fetches, 0) + assert.equal(payload.schema, original.schema) + assert.equal(payload.issues[0].status, "To Do") + assert.equal(payload.issues[0].description, "Description for SLID-191") + assert.equal(payload.issues[1].description, "Description for SLID-190") + assert.ok(!("images" in payload.issues[0])) + assert.ok(!("imageExport" in payload)) + assert.deepEqual(skipped, []) +}) + +test("opt-in embeds image bytes and ignores non-image attachments", async () => { + const { payload } = await enrichWorklogAiPackage(original, { includeImages: true, loadDetails: details, loadImage: async () => encoded }) + assert.equal(payload.issues[0].images[0].filename, "screenshot.png") + assert.equal(payload.issues[0].images[0].dataUrl, encoded) + assert.equal(payload.issues[0].images[0].sizeBytes, 4) + assert.equal(payload.issues[0].images.length, 1) + assert.equal(payload.imageExport.included, 2) +}) + +test("too-large and inaccessible image files are reported without losing description", async () => { + const { payload, skipped } = await enrichWorklogAiPackage({ issues: [original.issues[0]] }, { + includeImages: true, + loadDetails: async () => ({ description: "Important", attachments: [{ ...image, size: 3 * 1024 * 1024 }, { ...image, id: "15", filename: "other.png", size: 8 }] }), + loadImage: async () => null, + }) + assert.equal(payload.issues[0].description, "Important") + assert.deepEqual(skipped.map((item) => item.reason), ["size-limit", "unavailable"]) + assert.equal(payload.imageExport.included, 0) +}) + +test("image selection is globally bounded to 20 files", async () => { + const { payload, skipped } = await enrichWorklogAiPackage({ issues: [original.issues[0]] }, { + includeImages: true, + loadDetails: async () => ({ description: "Many photos", attachments: Array.from({ length: 23 }, (_, i) => ({ ...image, id: String(i), filename: "image-" + i + ".png" })) }), + loadImage: async () => encoded, + }) + assert.equal(payload.issues[0].images.length, 20) + assert.equal(skipped.length, 3) + assert.ok(skipped.every((item) => item.reason === "count-limit")) +}) + +test("failed Jira issue detail reads reject the export rather than shipping incomplete context", async () => { + await assert.rejects(() => enrichWorklogAiPackage(original, { includeImages: false, loadDetails: async () => { throw new Error("Jira 403") }, loadImage: async () => null }), /SLID-19\d description: Jira 403/) +}) + +test("zero issues never contact Jira", async () => { + const { payload } = await enrichWorklogAiPackage({ issues: [] }, { includeImages: true, loadDetails: async () => { throw new Error("should not run") }, loadImage: async () => { throw new Error("should not run") } }) + assert.deepEqual(payload.issues, []) +}) From 1d93c5c4a55bb46fdd3ec60d60a7760196e7c68f Mon Sep 17 00:00:00 2001 From: Hamed Ahmadi <107760095+hamedtkd@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:32:44 +0330 Subject: [PATCH 2/2] fix(worklog): safely append exported images under strict TS checks --- src/features/worklog/worklog-rich-export.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/worklog/worklog-rich-export.ts b/src/features/worklog/worklog-rich-export.ts index d3293a9..3a7bf74 100644 --- a/src/features/worklog/worklog-rich-export.ts +++ b/src/features/worklog/worklog-rich-export.ts @@ -73,8 +73,8 @@ export async function enrichWorklogAiPackage(base: T, opt included++ totalBytes += data.bytes const image: ExportedImage = { id: attachment.id, filename: attachment.filename, mimeType: data.mimeType, sizeBytes: data.bytes, dataUrl: dataUrl! } - issues[candidate.index].images ??= [] - issues[candidate.index].images.push(image) + const outputIssue = issues[candidate.index] + outputIssue.images = [...(outputIssue.images ?? []), image] } } options.onProgress?.({ phase: "images", completed: index + 1, total: candidates.length })