logwork-cli 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/LICENSE +11 -0
  2. package/README.md +330 -2
  3. package/RELEASE.md +89 -0
  4. package/bin/logwork.mjs +104 -0
  5. package/commands/auth.mjs +227 -0
  6. package/commands/commits.mjs +82 -0
  7. package/commands/delete.mjs +69 -0
  8. package/commands/doctor.mjs +24 -0
  9. package/commands/edit.mjs +88 -0
  10. package/commands/history.mjs +62 -0
  11. package/commands/query.mjs +52 -0
  12. package/commands/quick.mjs +163 -0
  13. package/commands/reconcile.mjs +55 -0
  14. package/commands/release.mjs +77 -0
  15. package/commands/reminder.mjs +172 -0
  16. package/commands/setup.mjs +508 -0
  17. package/commands/skill.mjs +91 -0
  18. package/commands/tui.mjs +50 -0
  19. package/commands/update.mjs +161 -0
  20. package/docs/advanced.md +334 -0
  21. package/docs/mcp-setup.md +368 -0
  22. package/docs/operations-guide.md +587 -0
  23. package/docs/recipes.md +259 -0
  24. package/docs/security.md +151 -0
  25. package/examples/mcp/antigravity.mcp_config.json +8 -0
  26. package/examples/mcp/codex.config.toml +5 -0
  27. package/examples/mcp/copilot.mcp.json +9 -0
  28. package/lib/agent-skill.mjs +16 -0
  29. package/lib/api-auth.mjs +1259 -0
  30. package/lib/api.mjs +1422 -0
  31. package/lib/apply-ledger.mjs +227 -0
  32. package/lib/atomic-file.mjs +24 -0
  33. package/lib/auth-diagnostics.mjs +113 -0
  34. package/lib/auth-errors.mjs +22 -0
  35. package/lib/auth-redaction.mjs +78 -0
  36. package/lib/auth-terminal.mjs +97 -0
  37. package/lib/auth.mjs +323 -0
  38. package/lib/batch-parser.mjs +145 -0
  39. package/lib/batch-workflow.mjs +842 -0
  40. package/lib/commit-workflow.mjs +402 -0
  41. package/lib/config.mjs +191 -0
  42. package/lib/credential-store.mjs +130 -0
  43. package/lib/diagnostics.mjs +307 -0
  44. package/lib/entrypoint.mjs +14 -0
  45. package/lib/file-lock.mjs +44 -0
  46. package/lib/http.mjs +125 -0
  47. package/lib/jira-api.mjs +258 -0
  48. package/lib/jira-auth.mjs +203 -0
  49. package/lib/jira-workflow.mjs +523 -0
  50. package/lib/keychain.mjs +105 -0
  51. package/lib/legacy-migration.mjs +66 -0
  52. package/lib/list-navigation.mjs +19 -0
  53. package/lib/logwork-config.mjs +199 -0
  54. package/lib/logwork-f03-enrich.mjs +41 -0
  55. package/lib/macos.mjs +33 -0
  56. package/lib/manual-apply-state.mjs +35 -0
  57. package/lib/manual-commands.mjs +294 -0
  58. package/lib/manual-drafts.mjs +154 -0
  59. package/lib/manual-ink-app.mjs +1654 -0
  60. package/lib/manual-ink-auth.mjs +349 -0
  61. package/lib/manual-ink-ui.mjs +675 -0
  62. package/lib/manual-input.mjs +21 -0
  63. package/lib/manual-logwork-wizard.mjs +310 -0
  64. package/lib/manual-mcp.mjs +187 -0
  65. package/lib/manual-repl.mjs +1037 -0
  66. package/lib/manual-targets.mjs +78 -0
  67. package/lib/mcp-server.mjs +833 -0
  68. package/lib/os-notification.mjs +105 -0
  69. package/lib/package-info.mjs +17 -0
  70. package/lib/paths.mjs +68 -0
  71. package/lib/project-identity.mjs +66 -0
  72. package/lib/project-mapping-workflow.mjs +309 -0
  73. package/lib/project-resolver.mjs +373 -0
  74. package/lib/query-workflow.mjs +553 -0
  75. package/lib/reconciliation-workflow.mjs +446 -0
  76. package/lib/release-doctor.mjs +448 -0
  77. package/lib/reminder-config.mjs +71 -0
  78. package/lib/reminder-scheduler.mjs +301 -0
  79. package/lib/reminder-service.mjs +118 -0
  80. package/lib/reminder-state.mjs +38 -0
  81. package/lib/reminder-workflow.mjs +299 -0
  82. package/lib/ro-delete-args.mjs +38 -0
  83. package/lib/ro-delete-workflow.mjs +115 -0
  84. package/lib/ro-edit-args.mjs +142 -0
  85. package/lib/ro-edit-workflow.mjs +632 -0
  86. package/lib/ro-entry-reader.mjs +56 -0
  87. package/lib/ro-resubmit-workflow.mjs +134 -0
  88. package/lib/terminal-auth-prompts.mjs +120 -0
  89. package/lib/ui.mjs +133 -0
  90. package/lib/update-service.mjs +350 -0
  91. package/lib/util.mjs +66 -0
  92. package/lib/windows-credential-manager.mjs +260 -0
  93. package/lib/worklog-task-resolver.mjs +113 -0
  94. package/package.json +73 -4
  95. package/setup.sh +22 -0
  96. package/skills/logwork/SKILL.md +43 -0
@@ -0,0 +1,632 @@
1
+ import {
2
+ ApiError,
3
+ createLogworkEntry,
4
+ deleteLogworkEntry,
5
+ fetchWorklogTasksForProject,
6
+ getLogworkDay
7
+ } from './api.mjs';
8
+ import { recordApplyResult, recordApplyResultSafely } from './apply-ledger.mjs';
9
+ import { createResourceOptimiserSession } from './query-workflow.mjs';
10
+ import { resolveWorklogTask } from './worklog-task-resolver.mjs';
11
+ import { createF03LogworkReader } from './ro-entry-reader.mjs';
12
+
13
+ // Resource Optimiser F03 has no staff endpoint for editing an entry
14
+ // (PATCH /logwork/pm/entries/:id/edit-approve is PM-only). An edit is
15
+ // therefore a replace: create the corrected entry with POST /logwork/entries,
16
+ // then delete the original with DELETE /logwork/entries/:id. Creating first
17
+ // means a failure never loses the original; if the delete fails, the new entry
18
+ // is rolled back so the day is left as it was.
19
+
20
+ const EDITABLE_STATUSES = new Set(['submitted', 'approved']);
21
+ const TYPE_OF_WORK = new Set(['create', 'correct', 'improve', 'other']);
22
+ const MAX_DESCRIPTION_LENGTH = 1_000;
23
+
24
+ export async function previewRoLogworkEdit({
25
+ logworkId,
26
+ hours,
27
+ taskName,
28
+ description,
29
+ typeOfWork,
30
+ fetchLogwork,
31
+ fetchWorklogTasks
32
+ }) {
33
+ const requested = normalizeRequestedChanges({ hours, taskName, description, typeOfWork });
34
+ const reader = fetchLogwork || createF03LogworkReader();
35
+ const current = await reader(assertIdentifier(logworkId, 'logworkId'));
36
+ assertEditableLogwork(current);
37
+
38
+ const original = sanitizeLogwork(current);
39
+ const taskReader = fetchWorklogTasks || createWorklogTaskReader();
40
+ const tasks = await taskReader(original.projectId);
41
+ const task = requested.taskName === undefined
42
+ ? resolveCurrentTask(original, tasks)
43
+ : resolveWorklogTask({ taskName: requested.taskName, projectId: original.projectId, tasks });
44
+ const taskChanged = String(task.worklogTaskId) !== String(original.worklogTaskId);
45
+
46
+ const updated = {
47
+ ...original,
48
+ id: null,
49
+ hours: requested.hours ?? original.hours,
50
+ worklogTaskId: task.worklogTaskId,
51
+ taskName: task.taskName,
52
+ needsProjectId: task.needsProjectId,
53
+ typeOfWork: requested.typeOfWork
54
+ ?? (taskChanged ? task.typeOfWork : undefined)
55
+ ?? (TYPE_OF_WORK.has(original.typeOfWork) ? original.typeOfWork : undefined)
56
+ ?? task.typeOfWork
57
+ ?? 'other',
58
+ description: requested.description ?? original.description,
59
+ status: '',
60
+ isLocked: false
61
+ };
62
+ const changes = buildChanges(original, updated);
63
+ if (!Object.values(changes).some(Boolean)) {
64
+ throw new Error('The requested RO logwork edit does not change hours, worklog task, type of work, or description.');
65
+ }
66
+
67
+ const originalFingerprint = createLogworkRevisionFingerprint(current);
68
+ const previewId = createPreviewId({ originalFingerprint, updated });
69
+ const preview = {
70
+ previewId,
71
+ status: 'ready',
72
+ strategy: 'replace',
73
+ logworkId: original.id,
74
+ original,
75
+ updated,
76
+ changes,
77
+ warnings: buildWarnings(original, updated),
78
+ originalFingerprint
79
+ };
80
+ preview.summary = formatRoEditPreviewSummary(preview);
81
+ return preview;
82
+ }
83
+
84
+ export async function applyRoLogworkEdit({
85
+ preview,
86
+ confirm,
87
+ cwd = process.cwd(),
88
+ fetchLogwork,
89
+ fetchDayEntries,
90
+ submitCreate,
91
+ submitDelete,
92
+ recordLedger
93
+ }) {
94
+ if (confirm !== true) {
95
+ throw new Error('Edit requires confirm: true.');
96
+ }
97
+ assertReadyPreview(preview);
98
+
99
+ const injected = Boolean(fetchLogwork || submitCreate || submitDelete);
100
+ const reader = fetchLogwork || createF03LogworkReader();
101
+ const creator = submitCreate || createEntryWriter();
102
+ const deleter = submitDelete || createDeleteWriter();
103
+ const dayReader = fetchDayEntries || createDayEntriesReader();
104
+ const ledgerWriter = recordLedger === undefined
105
+ ? (injected ? null : recordApplyResult)
106
+ : recordLedger;
107
+
108
+ const current = await reader(preview.logworkId);
109
+ assertEditableLogwork(current);
110
+ if (createLogworkRevisionFingerprint(current) !== preview.originalFingerprint) {
111
+ throw new Error('RO logwork changed after preview. Preview the edit again before applying.');
112
+ }
113
+
114
+ const createRequest = {
115
+ worklogTaskId: preview.updated.worklogTaskId,
116
+ projectId: preview.updated.projectId,
117
+ effortHours: preview.updated.hours,
118
+ localDateISO: preview.updated.date,
119
+ typeOfWork: preview.updated.typeOfWork,
120
+ description: preview.updated.description,
121
+ needsProjectId: preview.updated.needsProjectId
122
+ };
123
+
124
+ let createResult;
125
+ try {
126
+ createResult = await creator(createRequest);
127
+ } catch (error) {
128
+ error.message = `Could not create the replacement RO entry; the original entry ${preview.logworkId} is unchanged. ${error.message}`;
129
+ error.applyLedger = await recordEditLedger({
130
+ writer: ledgerWriter, preview, status: 'failed', entryStatus: 'failed', error: error.message, cwd
131
+ });
132
+ throw error;
133
+ }
134
+
135
+ if (createResult?.dryRun) {
136
+ const deleteResult = await deleter(preview.logworkId);
137
+ const output = {
138
+ previewId: preview.previewId,
139
+ status: 'dry_run',
140
+ dryRun: true,
141
+ strategy: 'replace',
142
+ changes: preview.changes,
143
+ original: preview.original,
144
+ updated: preview.updated,
145
+ requests: [
146
+ { method: createResult.method, path: createResult.path, body: createResult.body },
147
+ { method: deleteResult?.method || 'DELETE', path: deleteResult?.path || `/logwork/entries/${preview.logworkId}` }
148
+ ],
149
+ summary: `Dry run: RO logwork ${preview.logworkId} was not changed. Apply would create the corrected entry, then delete entry ${preview.logworkId}.`
150
+ };
151
+ output.ledger = await recordEditLedger({
152
+ writer: ledgerWriter, preview, status: 'dry_run', entryStatus: 'submitted', dryRun: true, cwd
153
+ });
154
+ return output;
155
+ }
156
+
157
+ let newLogworkId = createResult?.entry?.id ?? null;
158
+ if (newLogworkId === null || newLogworkId === undefined) {
159
+ newLogworkId = await findCreatedEntryId({ dayReader, preview });
160
+ }
161
+ if (newLogworkId === null || newLogworkId === undefined) {
162
+ const error = new Error(
163
+ `RO accepted the replacement entry but did not return its id, so the original entry ${preview.logworkId} was NOT deleted. ` +
164
+ `Check the entries for ${preview.updated.date} (get_logwork or \`logwork query\`) and delete the duplicate (delete_entry or \`logwork delete\`).`
165
+ );
166
+ error.applyLedger = await recordEditLedger({
167
+ writer: ledgerWriter, preview, status: 'partial', entryStatus: 'failed', error: error.message, cwd
168
+ });
169
+ throw error;
170
+ }
171
+
172
+ try {
173
+ await deleter(preview.logworkId);
174
+ } catch (error) {
175
+ const rollback = await rollbackCreatedEntry({ deleter, newLogworkId });
176
+ const message = rollback.ok
177
+ ? `Could not delete the original RO entry ${preview.logworkId} (${error.message}). The replacement entry ${newLogworkId} was rolled back; nothing changed.`
178
+ : `Could not delete the original RO entry ${preview.logworkId} (${error.message}), and rolling back the replacement entry ${newLogworkId} also failed (${rollback.error}). ` +
179
+ `Both entries now exist; delete one (delete_entry or \`logwork delete\`).`;
180
+ const wrapped = new Error(message);
181
+ wrapped.cause = error;
182
+ wrapped.newLogworkId = newLogworkId;
183
+ wrapped.rolledBack = rollback.ok;
184
+ wrapped.applyLedger = await recordEditLedger({
185
+ writer: ledgerWriter,
186
+ preview,
187
+ status: rollback.ok ? 'failed' : 'partial',
188
+ entryStatus: 'failed',
189
+ error: message,
190
+ newLogworkId,
191
+ cwd
192
+ });
193
+ throw wrapped;
194
+ }
195
+
196
+ let verified;
197
+ try {
198
+ verified = sanitizeLogwork(await reader(newLogworkId));
199
+ assertAppliedValues(verified, preview.updated);
200
+ } catch (error) {
201
+ const verificationError = new Error(
202
+ `RO replaced entry ${preview.logworkId} with entry ${newLogworkId}, but verification failed: ${error.message}`
203
+ );
204
+ verificationError.cause = error;
205
+ verificationError.newLogworkId = newLogworkId;
206
+ verificationError.applyLedger = await recordEditLedger({
207
+ writer: ledgerWriter,
208
+ preview,
209
+ status: 'verification_failed',
210
+ entryStatus: 'failed',
211
+ error: verificationError.message,
212
+ newLogworkId,
213
+ cwd
214
+ });
215
+ throw verificationError;
216
+ }
217
+
218
+ const output = {
219
+ previewId: preview.previewId,
220
+ status: 'replaced',
221
+ dryRun: false,
222
+ strategy: 'replace',
223
+ originalLogworkId: preview.logworkId,
224
+ newLogworkId,
225
+ changes: preview.changes,
226
+ original: preview.original,
227
+ updated: verified,
228
+ summary: formatRoEditApplySummary(preview, verified)
229
+ };
230
+ output.ledger = await recordEditLedger({
231
+ writer: ledgerWriter, preview, status: 'replaced', entryStatus: 'submitted', newLogworkId, cwd
232
+ });
233
+ return output;
234
+ }
235
+
236
+ export function createRoEditPreviewFingerprint(preview = {}) {
237
+ return JSON.stringify({
238
+ previewId: textOrNull(preview.previewId),
239
+ status: textOrNull(preview.status),
240
+ strategy: textOrNull(preview.strategy),
241
+ logworkId: idOrNull(preview.logworkId),
242
+ original: logworkFingerprint(preview.original),
243
+ updated: logworkFingerprint(preview.updated),
244
+ changes: {
245
+ hours: changeFingerprint(preview.changes?.hours),
246
+ taskName: changeFingerprint(preview.changes?.taskName),
247
+ typeOfWork: changeFingerprint(preview.changes?.typeOfWork),
248
+ description: changeFingerprint(preview.changes?.description)
249
+ },
250
+ originalFingerprint: textOrNull(preview.originalFingerprint)
251
+ });
252
+ }
253
+
254
+ export function formatRoEditPreviewSummary(preview) {
255
+ const { original, changes } = preview;
256
+ const lines = [
257
+ `RO logwork edit ${preview.previewId} is ready.`,
258
+ `- Logwork: ${preview.logworkId} (${original.status || 'unknown status'})`,
259
+ `- Project: ${original.projectName || original.projectId} (unchanged)`,
260
+ `- Date: ${original.date} (unchanged)`,
261
+ `- Hours: ${formatChange(changes.hours, original.hours)}`,
262
+ `- Worklog task: ${formatChange(changes.taskName, original.taskName)}`,
263
+ `- Type of work: ${formatChange(changes.typeOfWork, original.typeOfWork)}`,
264
+ `- Description: ${formatChange(changes.description, original.description || '(empty)', true)}`,
265
+ `Apply replaces the entry: it creates a new RO entry with these values, then deletes entry ${preview.logworkId}. ` +
266
+ 'Deleting needs no approval, and the new entry is approved immediately; it gets a new id.'
267
+ ];
268
+ for (const warning of preview.warnings || []) {
269
+ lines.push(`Warning: ${warning}`);
270
+ }
271
+ return lines.join('\n');
272
+ }
273
+
274
+ function normalizeRequestedChanges({ hours, taskName, description, typeOfWork }) {
275
+ if (hours === undefined && taskName === undefined && description === undefined && typeOfWork === undefined) {
276
+ throw new Error('Provide hours, taskName, description, and/or typeOfWork for the RO logwork edit.');
277
+ }
278
+
279
+ let normalizedHours;
280
+ if (hours !== undefined) {
281
+ normalizedHours = Number(hours);
282
+ if (!Number.isFinite(normalizedHours) || normalizedHours <= 0) {
283
+ throw new Error('hours must be a positive number.');
284
+ }
285
+ normalizedHours = Number(normalizedHours.toFixed(2));
286
+ if (normalizedHours <= 0) {
287
+ throw new Error('hours must be at least 0.01 after rounding.');
288
+ }
289
+ if (normalizedHours > 24) {
290
+ throw new Error('hours cannot exceed 24 per entry.');
291
+ }
292
+ }
293
+
294
+ let normalizedTaskName;
295
+ if (taskName !== undefined) {
296
+ normalizedTaskName = String(taskName).trim();
297
+ if (!normalizedTaskName) {
298
+ throw new Error('taskName must not be empty.');
299
+ }
300
+ if (normalizedTaskName.length > 1_000) {
301
+ throw new Error('taskName must not exceed 1000 characters.');
302
+ }
303
+ }
304
+
305
+ let normalizedDescription;
306
+ if (description !== undefined) {
307
+ normalizedDescription = String(description).trim();
308
+ if (!normalizedDescription) {
309
+ throw new Error('description must not be empty.');
310
+ }
311
+ if (normalizedDescription.length > MAX_DESCRIPTION_LENGTH) {
312
+ throw new Error(`description must not exceed ${MAX_DESCRIPTION_LENGTH} characters.`);
313
+ }
314
+ }
315
+
316
+ let normalizedTypeOfWork;
317
+ if (typeOfWork !== undefined) {
318
+ normalizedTypeOfWork = String(typeOfWork).trim();
319
+ if (!TYPE_OF_WORK.has(normalizedTypeOfWork)) {
320
+ throw new Error(`typeOfWork must be one of: ${[...TYPE_OF_WORK].join(', ')}.`);
321
+ }
322
+ }
323
+
324
+ return {
325
+ hours: normalizedHours,
326
+ taskName: normalizedTaskName,
327
+ description: normalizedDescription,
328
+ typeOfWork: normalizedTypeOfWork
329
+ };
330
+ }
331
+
332
+ function resolveCurrentTask(original, tasks) {
333
+ const match = tasks.find((task) => String(task?.id) === String(original.worklogTaskId));
334
+ if (!match) {
335
+ throw new Error(
336
+ `The current worklog task ${original.worklogTaskId ?? '(none)'} of RO logwork ${original.id} is not in the project's task catalog. ` +
337
+ 'Pass taskName with a task from list_tasks.'
338
+ );
339
+ }
340
+ return resolveWorklogTask({ taskName: `#${match.id}`, projectId: original.projectId, tasks });
341
+ }
342
+
343
+ function assertEditableLogwork(logwork) {
344
+ const entry = sanitizeLogwork(logwork);
345
+ if (!entry.id || !entry.date || entry.hours <= 0) {
346
+ throw new Error('Resource Optimiser returned an incomplete logwork entry; edit is blocked.');
347
+ }
348
+ if (!entry.projectId) {
349
+ throw new Error(`Could not find the project of RO logwork ${entry.id}; edit is blocked.`);
350
+ }
351
+ if (
352
+ logwork?.currentUserId !== undefined &&
353
+ logwork?.createdBy !== undefined &&
354
+ String(logwork.currentUserId) !== String(logwork.createdBy)
355
+ ) {
356
+ throw new Error('Editing another user\'s RO logwork is not supported.');
357
+ }
358
+ if (
359
+ logwork?.jiraLogworkId !== null &&
360
+ logwork?.jiraLogworkId !== undefined &&
361
+ String(logwork.jiraLogworkId).trim() !== ''
362
+ ) {
363
+ throw new Error('This RO logwork was created by Jira and must be edited in Jira.');
364
+ }
365
+ if (String(logwork?.source || '').toLowerCase() === 'jira') {
366
+ throw new Error('This RO logwork was created by Jira and must be edited in Jira.');
367
+ }
368
+ if (logwork?.dayLocked) {
369
+ throw new Error(`Logwork for ${entry.date} is locked on Resource Optimiser and cannot be edited.`);
370
+ }
371
+ const status = entry.status.toLowerCase();
372
+ if (status === 'rejected') {
373
+ throw new Error('Rejected entries cannot be edited by replace. Use resubmit_entry to fix hours, type of work, or description.');
374
+ }
375
+ if (!EDITABLE_STATUSES.has(status)) {
376
+ throw new Error(`Only submitted or approved RO logwork entries can be edited (status: ${status || 'unknown'}).`);
377
+ }
378
+ }
379
+
380
+ function assertReadyPreview(preview) {
381
+ if (!preview || preview.status !== 'ready' || !preview.previewId || !preview.originalFingerprint) {
382
+ throw new Error('A valid edit preview is required (edit_entry without confirm).');
383
+ }
384
+ if (!preview.updated || !preview.original || !preview.logworkId || !preview.updated.worklogTaskId) {
385
+ throw new Error('RO logwork edit preview is incomplete. Preview the edit again.');
386
+ }
387
+ }
388
+
389
+ function assertAppliedValues(actual, expected) {
390
+ const mismatches = [];
391
+ if (actual.date !== expected.date) mismatches.push('date');
392
+ if (Number(actual.hours) !== Number(expected.hours)) mismatches.push('hours');
393
+ if (String(actual.worklogTaskId) !== String(expected.worklogTaskId)) mismatches.push('worklog task');
394
+ if (String(actual.projectId) !== String(expected.projectId)) mismatches.push('project');
395
+ if ((actual.description || '') !== (expected.description || '')) mismatches.push('description');
396
+ if (actual.typeOfWork && actual.typeOfWork !== expected.typeOfWork) mismatches.push('type of work');
397
+ if (mismatches.length) {
398
+ throw new Error(`the new RO entry does not match the approved preview (${mismatches.join(', ')})`);
399
+ }
400
+ }
401
+
402
+ function buildWarnings(original, updated) {
403
+ const warnings = [];
404
+ if (!updated.description) {
405
+ warnings.push('The entry has no description. Pass description to add one.');
406
+ }
407
+ return warnings;
408
+ }
409
+
410
+ function sanitizeLogwork(logwork = {}) {
411
+ return {
412
+ id: logwork.id ?? null,
413
+ projectMemberId: logwork.projectMemberId ?? null,
414
+ projectId: logwork.projectId ?? null,
415
+ projectName: String(logwork.projectName || ''),
416
+ date: String(logwork.date || ''),
417
+ hours: Number(logwork.hours || 0),
418
+ worklogTaskId: logwork.worklogTaskId ?? null,
419
+ taskName: String(logwork.taskName || ''),
420
+ typeOfWork: String(logwork.typeOfWork || ''),
421
+ description: String(logwork.description || ''),
422
+ status: String(logwork.status || ''),
423
+ source: String(logwork.source || ''),
424
+ isLocked: Boolean(logwork.isLocked)
425
+ };
426
+ }
427
+
428
+ function buildChanges(original, updated) {
429
+ const diff = (from, to) => (from === to ? null : { from, to });
430
+ return {
431
+ hours: Number(original.hours) === Number(updated.hours)
432
+ ? null
433
+ : { from: original.hours, to: updated.hours },
434
+ taskName: String(original.worklogTaskId) === String(updated.worklogTaskId)
435
+ ? null
436
+ : { from: original.taskName, to: updated.taskName },
437
+ typeOfWork: diff(original.typeOfWork, updated.typeOfWork),
438
+ description: diff(original.description, updated.description)
439
+ };
440
+ }
441
+
442
+ function createLogworkRevisionFingerprint(logwork = {}) {
443
+ return JSON.stringify({
444
+ ...logworkFingerprint(sanitizeLogwork(logwork)),
445
+ updatedDate: textOrNull(logwork.updatedDate),
446
+ jiraLogworkId: idOrNull(logwork.jiraLogworkId),
447
+ dayLocked: Boolean(logwork.dayLocked)
448
+ });
449
+ }
450
+
451
+ function createPreviewId({ originalFingerprint, updated }) {
452
+ return `ro_edit_${hash(JSON.stringify({ originalFingerprint, updated: logworkFingerprint(updated) }))}`;
453
+ }
454
+
455
+ function logworkFingerprint(logwork) {
456
+ if (!logwork || typeof logwork !== 'object') {
457
+ return null;
458
+ }
459
+ return {
460
+ id: idOrNull(logwork.id),
461
+ projectMemberId: idOrNull(logwork.projectMemberId),
462
+ projectId: idOrNull(logwork.projectId),
463
+ projectName: textOrNull(logwork.projectName),
464
+ date: textOrNull(logwork.date),
465
+ hours: numberOrNull(logwork.hours),
466
+ worklogTaskId: idOrNull(logwork.worklogTaskId),
467
+ taskName: textOrNull(logwork.taskName),
468
+ needsProjectId: logwork.needsProjectId === undefined ? null : Boolean(logwork.needsProjectId),
469
+ typeOfWork: textOrNull(logwork.typeOfWork),
470
+ description: textOrNull(logwork.description),
471
+ status: textOrNull(logwork.status),
472
+ source: textOrNull(logwork.source)
473
+ };
474
+ }
475
+
476
+ function changeFingerprint(change) {
477
+ if (!change || typeof change !== 'object') {
478
+ return null;
479
+ }
480
+ return {
481
+ from: change.from ?? null,
482
+ to: change.to ?? null
483
+ };
484
+ }
485
+
486
+ async function findCreatedEntryId({ dayReader, preview }) {
487
+ try {
488
+ const entries = await dayReader(preview.updated.date);
489
+ const candidates = (entries || []).filter((entry) => (
490
+ String(entry.id) !== String(preview.logworkId) &&
491
+ String(entry.worklogTaskId) === String(preview.updated.worklogTaskId) &&
492
+ Number(entry.hours) === Number(preview.updated.hours) &&
493
+ (entry.description || '') === (preview.updated.description || '') &&
494
+ (!preview.updated.projectMemberId || String(entry.projectMemberId) === String(preview.updated.projectMemberId))
495
+ ));
496
+ if (!candidates.length) {
497
+ return null;
498
+ }
499
+ return candidates
500
+ .map((entry) => entry.id)
501
+ .sort((a, b) => Number(b) - Number(a))[0];
502
+ } catch {
503
+ return null;
504
+ }
505
+ }
506
+
507
+ async function rollbackCreatedEntry({ deleter, newLogworkId }) {
508
+ try {
509
+ await deleter(newLogworkId);
510
+ return { ok: true };
511
+ } catch (error) {
512
+ return { ok: false, error: error.message };
513
+ }
514
+ }
515
+
516
+ function createDayEntriesReader() {
517
+ const session = createResourceOptimiserSession();
518
+ return (date) => withRoSessionRetry(session, async ({ token }) => (await getLogworkDay(token, { date })).entries || []);
519
+ }
520
+
521
+ function createWorklogTaskReader() {
522
+ const session = createResourceOptimiserSession();
523
+ return (projectId) => withRoSessionRetry(session, ({ token }) => fetchWorklogTasksForProject(token, projectId));
524
+ }
525
+
526
+ function createEntryWriter() {
527
+ const session = createResourceOptimiserSession();
528
+ return (request) => withRoSessionRetry(session, ({ token }) => createLogworkEntry(token, request));
529
+ }
530
+
531
+ function createDeleteWriter() {
532
+ const session = createResourceOptimiserSession();
533
+ return (logworkId) => withRoSessionRetry(session, ({ token }) => deleteLogworkEntry(token, logworkId));
534
+ }
535
+
536
+ async function withRoSessionRetry(session, action) {
537
+ const current = await session.get();
538
+ try {
539
+ return await action(current);
540
+ } catch (error) {
541
+ if (!(error instanceof ApiError) || error.status !== 401) {
542
+ throw error;
543
+ }
544
+ return action(await session.get({ forceLogin: true }));
545
+ }
546
+ }
547
+
548
+ async function recordEditLedger({
549
+ writer,
550
+ preview,
551
+ status,
552
+ entryStatus,
553
+ error = '',
554
+ dryRun = false,
555
+ newLogworkId = null,
556
+ cwd
557
+ }) {
558
+ const replaced = newLogworkId === null ? '' : ` with ${newLogworkId}`;
559
+ return recordApplyResultSafely(writer, {
560
+ target: 'ro',
561
+ flowTarget: 'ro',
562
+ batchId: preview.previewId,
563
+ status,
564
+ cwd,
565
+ summary: `Replaced RO logwork ${preview.logworkId}${replaced}.`,
566
+ entries: [
567
+ {
568
+ entryId: newLogworkId ?? preview.logworkId,
569
+ date: preview.updated.date,
570
+ hours: preview.updated.hours,
571
+ taskName: preview.updated.taskName,
572
+ description: preview.updated.description,
573
+ projectName: preview.updated.projectName,
574
+ status: entryStatus,
575
+ error,
576
+ dryRun
577
+ }
578
+ ]
579
+ });
580
+ }
581
+
582
+ function formatRoEditApplySummary(preview, verified) {
583
+ const changed = [
584
+ preview.changes.hours ? `hours ${preview.changes.hours.from} -> ${preview.changes.hours.to}` : '',
585
+ preview.changes.taskName ? `task ${preview.changes.taskName.from} -> ${preview.changes.taskName.to}` : '',
586
+ preview.changes.typeOfWork ? `type ${preview.changes.typeOfWork.from || '(none)'} -> ${preview.changes.typeOfWork.to}` : '',
587
+ preview.changes.description ? 'description updated' : ''
588
+ ].filter(Boolean).join(', ');
589
+ return `Replaced RO logwork ${preview.logworkId} with new entry ${verified.id}: ${changed}. Project and date were unchanged.`;
590
+ }
591
+
592
+ function formatChange(change, current, quote = false) {
593
+ const show = (value) => (quote ? `"${value || ''}"` : `${value}`);
594
+ if (!change) {
595
+ return `${quote && current === '(empty)' ? current : show(current)} (unchanged)`;
596
+ }
597
+ return `${show(change.from)} -> ${show(change.to)}`;
598
+ }
599
+
600
+
601
+ function assertIdentifier(value, name) {
602
+ const normalized = String(value ?? '').trim();
603
+ if (!normalized || !/^[A-Za-z0-9_-]+$/.test(normalized)) {
604
+ throw new Error(`${name} must be a valid Resource Optimiser identifier.`);
605
+ }
606
+ return normalized;
607
+ }
608
+
609
+ function textOrNull(value) {
610
+ return value === undefined || value === null ? null : String(value);
611
+ }
612
+
613
+ function idOrNull(value) {
614
+ if (value === undefined || value === null || value === '') {
615
+ return null;
616
+ }
617
+ return typeof value === 'number' ? value : String(value);
618
+ }
619
+
620
+ function numberOrNull(value) {
621
+ const number = Number(value);
622
+ return Number.isFinite(number) ? number : null;
623
+ }
624
+
625
+ function hash(value) {
626
+ let hashValue = 2166136261;
627
+ for (let index = 0; index < value.length; index += 1) {
628
+ hashValue ^= value.charCodeAt(index);
629
+ hashValue = Math.imul(hashValue, 16777619);
630
+ }
631
+ return (hashValue >>> 0).toString(16);
632
+ }
@@ -0,0 +1,56 @@
1
+ import { ApiError, getLogtimeById, getLogworkDay, getProjects } from './api.mjs';
2
+ import { createResourceOptimiserSession } from './query-workflow.mjs';
3
+
4
+ // Reads one existing RO entry with the fields edit, resubmit and delete need. The legacy
5
+ // GET /member-logtime/:id gives the date, owner and Jira link; /logwork/day
6
+ // gives the F03 fields (status, worklog task, type of work, description); the
7
+ // project-member list maps project_member_id to project_id.
8
+ export function createF03LogworkReader() {
9
+ const session = createResourceOptimiserSession();
10
+ let projectsPromise = null;
11
+ return async (logworkId) => withRoSessionRetry(session, async ({ token, userId }) => {
12
+ const legacy = await getLogtimeById(token, logworkId);
13
+ if (!legacy.date) {
14
+ throw new Error(`Resource Optimiser did not return a date for logwork ${logworkId}.`);
15
+ }
16
+ const day = await getLogworkDay(token, { date: legacy.date });
17
+ const f03 = (day.entries || []).find((entry) => String(entry.id) === String(legacy.id));
18
+ if (!f03) {
19
+ throw new Error(`RO logwork ${logworkId} was not found on ${legacy.date}; it may have been deleted.`);
20
+ }
21
+ projectsPromise ||= getProjects(token, userId);
22
+ const projects = await projectsPromise;
23
+ const projectMemberId = f03.projectMemberId ?? legacy.projectMemberId;
24
+ const project = projects.find((item) => String(item.projectMemberId) === String(projectMemberId));
25
+ return {
26
+ ...legacy,
27
+ ...withoutEmpty(f03),
28
+ id: legacy.id,
29
+ date: legacy.date,
30
+ projectMemberId,
31
+ projectId: f03.projectId ?? legacy.projectId ?? project?.projectId ?? null,
32
+ projectName: f03.projectName || legacy.projectName || project?.projectName || '',
33
+ createdBy: legacy.createdBy ?? f03.createdBy,
34
+ dayLocked: Boolean(day.isLocked),
35
+ currentUserId: userId
36
+ };
37
+ });
38
+ }
39
+
40
+ async function withRoSessionRetry(session, action) {
41
+ const current = await session.get();
42
+ try {
43
+ return await action(current);
44
+ } catch (error) {
45
+ if (!(error instanceof ApiError) || error.status !== 401) {
46
+ throw error;
47
+ }
48
+ return action(await session.get({ forceLogin: true }));
49
+ }
50
+ }
51
+
52
+ function withoutEmpty(record = {}) {
53
+ return Object.fromEntries(Object.entries(record).filter(([, value]) => (
54
+ value !== undefined && value !== null && value !== ''
55
+ )));
56
+ }