logwork-cli 1.1.1 → 1.3.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 (70) hide show
  1. package/README.md +53 -25
  2. package/RELEASE.md +4 -2
  3. package/bin/logwork.mjs +2 -2
  4. package/commands/commits.mjs +128 -13
  5. package/commands/history.mjs +0 -4
  6. package/commands/mcp.mjs +266 -0
  7. package/commands/quick.mjs +38 -23
  8. package/commands/reminder.mjs +41 -2
  9. package/commands/setup.mjs +197 -42
  10. package/commands/tui.mjs +1 -0
  11. package/docs/advanced.md +18 -7
  12. package/docs/mcp-setup.md +120 -26
  13. package/docs/operations-guide.md +50 -2
  14. package/docs/recipes.md +40 -0
  15. package/docs/security.md +23 -2
  16. package/examples/mcp/antigravity.mcp_config.json +2 -2
  17. package/examples/mcp/codex.config.toml +2 -2
  18. package/examples/mcp/copilot.mcp.json +2 -2
  19. package/lib/api-auth-forms.mjs +393 -0
  20. package/lib/api-auth-http.mjs +184 -0
  21. package/lib/api-auth-support.mjs +101 -0
  22. package/lib/api-auth-tokens.mjs +129 -0
  23. package/lib/api-auth.mjs +39 -813
  24. package/lib/api.mjs +38 -1422
  25. package/lib/apply-ledger.mjs +1 -1
  26. package/lib/batch-workflow.mjs +187 -2
  27. package/lib/commit-workflow.mjs +345 -23
  28. package/lib/config.mjs +7 -0
  29. package/lib/diagnostics.mjs +116 -4
  30. package/lib/jira-api.mjs +25 -3
  31. package/lib/jira-workflow.mjs +36 -6
  32. package/lib/launcher.mjs +303 -0
  33. package/lib/logwork-config.mjs +64 -13
  34. package/lib/manual-apply-state.mjs +15 -0
  35. package/lib/manual-commands.mjs +2 -2
  36. package/lib/manual-ink-app.mjs +142 -1282
  37. package/lib/manual-ink-draft-actions.mjs +227 -0
  38. package/lib/manual-ink-interaction.mjs +318 -0
  39. package/lib/manual-ink-session.mjs +41 -0
  40. package/lib/manual-ink-task-editor.mjs +338 -0
  41. package/lib/manual-ink-ui.mjs +38 -0
  42. package/lib/manual-ink-wizard.mjs +520 -0
  43. package/lib/manual-mcp.mjs +78 -20
  44. package/lib/manual-repl.mjs +171 -22
  45. package/lib/mcp-install.mjs +930 -0
  46. package/lib/mcp-server.mjs +51 -20
  47. package/lib/node-runtime.mjs +155 -0
  48. package/lib/paths.mjs +7 -0
  49. package/lib/project-mapping-workflow.mjs +32 -27
  50. package/lib/query-workflow.mjs +19 -1
  51. package/lib/reconciliation-workflow.mjs +1 -1
  52. package/lib/reminder-config.mjs +38 -2
  53. package/lib/reminder-scheduler.mjs +85 -18
  54. package/lib/reminder-service.mjs +42 -3
  55. package/lib/reminder-workflow.mjs +118 -22
  56. package/lib/repo-project-suggestions.mjs +285 -0
  57. package/lib/ro-api-diagnostics.mjs +109 -0
  58. package/lib/ro-api-entries.mjs +359 -0
  59. package/lib/ro-api-fields.mjs +290 -0
  60. package/lib/ro-api-http.mjs +68 -0
  61. package/lib/ro-api-projects.mjs +137 -0
  62. package/lib/ro-api-tasks.mjs +50 -0
  63. package/lib/ro-api-timesheet.mjs +413 -0
  64. package/lib/ro-delete-workflow.mjs +2 -1
  65. package/lib/ro-entry-reader.mjs +11 -6
  66. package/lib/ro-resubmit-workflow.mjs +2 -1
  67. package/lib/terminal-auth-prompts.mjs +7 -4
  68. package/lib/ui.mjs +121 -56
  69. package/package.json +11 -4
  70. package/skills/logwork/SKILL.md +10 -4
@@ -0,0 +1,137 @@
1
+ // Project memberships and per-project day logs from the legacy member-logtime API.
2
+ import { CONFIG } from './config.mjs';
3
+ import { mapLimit, toApiLogDate } from './util.mjs';
4
+ import { ApiError, apiFetch } from './ro-api-http.mjs';
5
+ import {
6
+ addDroppedRow,
7
+ attachNormalizationDiagnostics,
8
+ createNormalizationDiagnostics,
9
+ diagnosticProjectFields,
10
+ getNormalizationDiagnostics
11
+ } from './ro-api-diagnostics.mjs';
12
+ import {
13
+ hasProjectName,
14
+ normalizeDateOnly,
15
+ projectFallbackName,
16
+ readExplicitProjectMemberId,
17
+ readNumber,
18
+ readProjectId,
19
+ readProjectName,
20
+ unwrapRecords
21
+ } from './ro-api-fields.mjs';
22
+
23
+ export async function getProjects(token, userId) {
24
+ const qs = new URLSearchParams({
25
+ f_user_id: String(userId),
26
+ 'f_project.stage_id': '!6'
27
+ });
28
+
29
+ const data = await apiFetch(token, `${CONFIG.projectMembersPath}?${qs}`);
30
+ return unwrapRecords(data).map(normalizeProject).filter((project) => project.projectMemberId);
31
+ }
32
+
33
+ export async function getDayLogs(token, projectId, userId, localDateISO) {
34
+ const qs = new URLSearchParams({
35
+ f_project_id: String(projectId),
36
+ f_user_id: String(userId),
37
+ f_logdate: toApiLogDate(localDateISO)
38
+ });
39
+
40
+ try {
41
+ const data = await apiFetch(token, `${CONFIG.memberLogtimePath}?${qs}`);
42
+ const logs = unwrapRecords(data);
43
+ const entries = normalizeLogtimeEntries(logs, {
44
+ date: localDateISO,
45
+ projectId
46
+ });
47
+ const normalization = getNormalizationDiagnostics(entries);
48
+
49
+ return {
50
+ projectId,
51
+ totalHours: entries.reduce((sum, log) => sum + Number(log.hours || 0), 0),
52
+ entries,
53
+ logs,
54
+ normalization,
55
+ ok: true
56
+ };
57
+ } catch (error) {
58
+ if (error instanceof ApiError && error.status === 401) {
59
+ throw error;
60
+ }
61
+
62
+ return {
63
+ projectId,
64
+ totalHours: null,
65
+ logs: [],
66
+ ok: false,
67
+ error: error.message
68
+ };
69
+ }
70
+ }
71
+
72
+ export function normalizeLogtimeEntries(logs, defaults = {}) {
73
+ const rows = unwrapRecords(logs);
74
+ const diagnostics = createNormalizationDiagnostics('logtime_entries');
75
+ diagnostics.rowsRead = rows.length;
76
+ const entries = [];
77
+
78
+ rows.forEach((log, index) => {
79
+ if (!log || typeof log !== 'object') {
80
+ addDroppedRow(diagnostics, 'invalid_log_entry', { index });
81
+ return;
82
+ }
83
+
84
+ const context = { index, kind: 'logtime_entry' };
85
+ const date = normalizeDateOnly(log.logdate ?? log.log_date ?? log.date) || defaults.date;
86
+ const projectMemberId = readExplicitProjectMemberId(log) ?? defaults.projectMemberId;
87
+ const projectId = readProjectId(log) ?? defaults.projectId;
88
+ const hours = readNumber(log, ['logtimes', 'hours', 'time'], diagnostics, context);
89
+
90
+ if (!date) {
91
+ addDroppedRow(diagnostics, 'missing_date', { ...context, ...diagnosticProjectFields({ projectMemberId, projectId }) });
92
+ return;
93
+ }
94
+
95
+ if (projectMemberId === undefined && projectId === undefined) {
96
+ addDroppedRow(diagnostics, 'missing_project_identity', { ...context, date });
97
+ return;
98
+ }
99
+
100
+ if (hours <= 0) {
101
+ addDroppedRow(diagnostics, 'non_positive_hours', { ...context, date, ...diagnosticProjectFields({ projectMemberId, projectId }) });
102
+ return;
103
+ }
104
+
105
+ diagnostics.rowsAccepted += 1;
106
+ entries.push({
107
+ id: log.id ?? log.logtime_id ?? log.logtimeId,
108
+ date,
109
+ projectMemberId,
110
+ projectId,
111
+ projectName: hasProjectName(log) ? readProjectName(log, readProjectId(log), diagnostics, context) : defaults.projectName,
112
+ hours,
113
+ taskName: log.task_name ?? log.taskName ?? log.name ?? log.title ?? ''
114
+ });
115
+ });
116
+
117
+ return attachNormalizationDiagnostics(entries, diagnostics);
118
+ }
119
+
120
+ export async function getAllDayLogSummaries(token, projects, userId, localDateISO) {
121
+ return mapLimit(projects, CONFIG.dayLogConcurrency, (project) => (
122
+ getDayLogs(token, project.projectId, userId, localDateISO)
123
+ ));
124
+ }
125
+
126
+ function normalizeProject(rec) {
127
+ const projectId = rec.project_id ?? rec.project?.id ?? rec.project?.project_id;
128
+ const projectMemberId = rec.id ?? rec.project_member_id;
129
+
130
+ return {
131
+ projectMemberId,
132
+ projectId,
133
+ projectName: rec.project?.name ?? rec.project_name ?? rec.name ?? projectFallbackName(projectId ?? projectMemberId),
134
+ workloadPercent: rec.workload ?? rec.workload_percent ?? rec.assigned_percent ?? 0,
135
+ raw: rec
136
+ };
137
+ }
@@ -0,0 +1,50 @@
1
+ // Worklog task catalog: project tasks plus shared default tasks.
2
+ import { CONFIG } from './config.mjs';
3
+ import { apiFetch } from './ro-api-http.mjs';
4
+ import { unwrapRecords } from './ro-api-fields.mjs';
5
+ import {
6
+ mergeWorklogTaskLists,
7
+ normalizeWorklogTask
8
+ } from './worklog-task-resolver.mjs';
9
+
10
+ export async function getWorklogTasks(token, { projectId, search } = {}, options = {}) {
11
+ const path = `${CONFIG.logworkTasksPath}${buildLogworkTasksQuery({ projectId, search })}`;
12
+ const data = await apiFetch(token, path, options);
13
+ return unwrapRecords(data).map(normalizeWorklogTask);
14
+ }
15
+
16
+ export async function getDefaultWorklogTasks(token, { projectId, search } = {}, options = {}) {
17
+ const path = `${CONFIG.logworkDefaultTasksPath}${buildLogworkTasksQuery({ projectId, search })}`;
18
+ const data = await apiFetch(token, path, options);
19
+ return unwrapRecords(data).map(normalizeWorklogTask);
20
+ }
21
+
22
+ export async function fetchWorklogTasksForProject(token, projectId, options = {}) {
23
+ const cacheKey = Number(projectId);
24
+ const { cache, ...apiOptions } = options;
25
+ if (cache instanceof Map && cache.has(cacheKey)) {
26
+ return cache.get(cacheKey);
27
+ }
28
+
29
+ const [projectTasks, defaultTasks] = await Promise.all([
30
+ getWorklogTasks(token, { projectId: cacheKey }, apiOptions),
31
+ getDefaultWorklogTasks(token, { projectId: cacheKey }, apiOptions)
32
+ ]);
33
+ const merged = mergeWorklogTaskLists(projectTasks, defaultTasks);
34
+ if (cache instanceof Map) {
35
+ cache.set(cacheKey, merged);
36
+ }
37
+ return merged;
38
+ }
39
+
40
+ function buildLogworkTasksQuery({ projectId, search }) {
41
+ const qs = new URLSearchParams();
42
+ if (projectId !== undefined && projectId !== null && projectId !== '') {
43
+ qs.set('project_id', String(projectId));
44
+ }
45
+ if (search) {
46
+ qs.set('search', String(search));
47
+ }
48
+ const query = qs.toString();
49
+ return query ? `?${query}` : '';
50
+ }
@@ -0,0 +1,413 @@
1
+ // Timesheet range reads: booked and logged hours per day and project.
2
+ import { CONFIG } from './config.mjs';
3
+ import { addLocalDays, toApiLogDate } from './util.mjs';
4
+ import { apiFetch } from './ro-api-http.mjs';
5
+ import {
6
+ addDroppedRow,
7
+ addWarning,
8
+ attachNormalizationDiagnostics,
9
+ createNormalizationDiagnostics,
10
+ diagnosticProjectFields,
11
+ getNormalizationDiagnostics
12
+ } from './ro-api-diagnostics.mjs';
13
+ import {
14
+ coerceHours,
15
+ hasProjectName,
16
+ isDateInRange,
17
+ normalizeDateOnly,
18
+ pickFirst,
19
+ readAssignedHours,
20
+ readDateLike,
21
+ readLoggedHours,
22
+ readNumber,
23
+ readProjectId,
24
+ readProjectMemberId,
25
+ readProjectName
26
+ } from './ro-api-fields.mjs';
27
+
28
+ export async function getTimesheetRange(token, userId, { from, to }) {
29
+ const { records } = await getTimesheetRangeResult(token, userId, { from, to });
30
+ return records;
31
+ }
32
+
33
+ export async function getTimesheetRangeResult(token, userId, { from, to }) {
34
+ const qs = new URLSearchParams({
35
+ f_user_id: String(userId),
36
+ f_from: toApiLogDate(from),
37
+ f_to: toApiLogDate(to),
38
+ f_timesheet_by_week: '0',
39
+ f_time_off: '1'
40
+ });
41
+
42
+ const data = await apiFetch(token, `${CONFIG.timesheetPath}?${qs}`);
43
+ return normalizeTimesheetRangeResult(data, { from, to });
44
+ }
45
+
46
+ export function normalizeTimesheetRange(data, { from, to } = {}) {
47
+ return normalizeTimesheetRangeResult(data, { from, to }).records;
48
+ }
49
+
50
+ export function normalizeTimesheetRangeResult(data, { from, to } = {}) {
51
+ const diagnostics = createNormalizationDiagnostics('unknown');
52
+ const explicitRows = expandProjectTimesheetRows(data, { from, to }, diagnostics);
53
+ const rows = explicitRows.length ? explicitRows : findTimesheetRowsInRange(data, { from, to });
54
+ diagnostics.sourceShape = explicitRows.length ? 'project_timesheet' : 'range_scan';
55
+ if (!rows.length && hasObjectPayload(data)) {
56
+ addWarning(diagnostics, 'unknown_timesheet_shape');
57
+ }
58
+ const records = normalizeTimesheetRecords(rows, diagnostics);
59
+ return {
60
+ records,
61
+ normalization: getNormalizationDiagnostics(records)
62
+ };
63
+ }
64
+
65
+ export async function getBookedProjectsForDate(token, userId, localDateISO) {
66
+ const { records } = await getTimesheetRangeResult(token, userId, {
67
+ from: localDateISO,
68
+ to: addLocalDays(localDateISO, 1)
69
+ });
70
+ return normalizeTimesheetProjects(records, localDateISO);
71
+ }
72
+
73
+ export async function getTodayTimesheetProjects(token, userId, localDateISO) {
74
+ return getBookedProjectsForDate(token, userId, localDateISO);
75
+ }
76
+
77
+ function normalizeTimesheetProjects(rows, localDateISO) {
78
+ const byMember = new Map();
79
+
80
+ for (const row of rows) {
81
+ const projectMemberId = readProjectMemberId(row);
82
+ const projectId = readProjectId(row);
83
+
84
+ if (!projectMemberId && !projectId) {
85
+ continue;
86
+ }
87
+
88
+ const key = String(projectMemberId ?? projectId);
89
+ const projectName = readProjectName(row, projectId ?? projectMemberId);
90
+ const assignedHours = Number(row.bookedHours ?? readAssignedHours(row));
91
+ const loggedHours = Number(row.loggedHours ?? readLoggedHours(row));
92
+ const existing = byMember.get(key);
93
+
94
+ if (existing) {
95
+ existing.assignedHours += assignedHours;
96
+ existing.loggedHours += loggedHours;
97
+ existing.workloadPercent = hoursToAssignPercent(existing.assignedHours);
98
+ existing.raw.push(row);
99
+ continue;
100
+ }
101
+
102
+ byMember.set(key, {
103
+ projectMemberId: projectMemberId ?? row.project_member_id ?? row.id,
104
+ projectId,
105
+ projectName,
106
+ assignedHours,
107
+ loggedHours,
108
+ workloadPercent: hoursToAssignPercent(assignedHours),
109
+ localDateISO,
110
+ raw: [row]
111
+ });
112
+ }
113
+
114
+ return [...byMember.values()].filter((project) => project.assignedHours > 0);
115
+ }
116
+
117
+ function normalizeTimesheetRecords(rows, diagnostics = createNormalizationDiagnostics('range_scan')) {
118
+ const byDayProject = new Map();
119
+ diagnostics.rowsRead = rows.length;
120
+
121
+ rows.forEach((row, index) => {
122
+ const date = readDateLike(row);
123
+ const projectMemberId = readProjectMemberId(row);
124
+ const projectId = readProjectId(row);
125
+ const context = {
126
+ index,
127
+ kind: 'timesheet_row',
128
+ ...diagnosticProjectFields({ projectMemberId, projectId, projectName: hasProjectName(row) ? readProjectName(row, projectId ?? projectMemberId) : undefined })
129
+ };
130
+
131
+ if (!date) {
132
+ addDroppedRow(diagnostics, 'missing_date', context);
133
+ return;
134
+ }
135
+
136
+ if (projectMemberId === undefined && projectId === undefined) {
137
+ addDroppedRow(diagnostics, 'missing_project_identity', { ...context, date });
138
+ return;
139
+ }
140
+
141
+ const key = `${date}:${projectMemberId ?? projectId}`;
142
+ const existing = byDayProject.get(key);
143
+ const bookedHours = readTimesheetHours(row, ['bookedHours'], readAssignedHours, diagnostics, { ...context, date, metric: 'bookedHours' });
144
+ const loggedHours = readTimesheetHours(row, ['loggedHours'], readLoggedHours, diagnostics, { ...context, date, metric: 'loggedHours' });
145
+ const entries = readLogEntries(row, diagnostics, { ...context, date });
146
+ diagnostics.rowsAccepted += 1;
147
+
148
+ if (existing) {
149
+ existing.bookedHours += bookedHours;
150
+ existing.loggedHours += loggedHours;
151
+ existing.entries.push(...entries);
152
+ existing.raw.push(row);
153
+ return;
154
+ }
155
+
156
+ byDayProject.set(key, {
157
+ date,
158
+ projectMemberId,
159
+ projectId,
160
+ projectName: readProjectName(row, projectId ?? projectMemberId, diagnostics, { ...context, date }),
161
+ bookedHours,
162
+ loggedHours,
163
+ entries,
164
+ raw: [row]
165
+ });
166
+ });
167
+
168
+ return attachNormalizationDiagnostics([...byDayProject.values()].map((record) => ({
169
+ ...record,
170
+ bookedHours: Number(record.bookedHours.toFixed(2)),
171
+ loggedHours: Number(record.loggedHours.toFixed(2))
172
+ })), diagnostics);
173
+ }
174
+
175
+ function expandProjectTimesheetRows(data, range, diagnostics = null) {
176
+ const assignments = [];
177
+ collectProjectAssignments(data, assignments, new Set());
178
+ const rows = [];
179
+
180
+ assignments.forEach((assignment, assignmentIndex) => {
181
+ assignment.timesheet.forEach((day, dayIndex) => {
182
+ const date = normalizeDateOnly(day.logdate ?? day.log_date ?? day.date);
183
+ if (!isDateInRange(date, range)) {
184
+ return;
185
+ }
186
+
187
+ const context = {
188
+ index: rows.length,
189
+ assignmentIndex,
190
+ dayIndex,
191
+ kind: 'project_timesheet_row',
192
+ date,
193
+ ...diagnosticProjectFields({
194
+ projectMemberId: assignment.id,
195
+ projectId: assignment.project?.id,
196
+ projectName: assignment.project?.name
197
+ })
198
+ };
199
+
200
+ rows.push({
201
+ date,
202
+ projectMemberId: assignment.id,
203
+ project_member_id: assignment.id,
204
+ projectId: assignment.project?.id,
205
+ project_id: assignment.project?.id,
206
+ projectName: assignment.project?.name,
207
+ project_name: assignment.project?.name,
208
+ bookedHours: readAssignedHours(day, diagnostics, { ...context, metric: 'bookedHours' }),
209
+ loggedHours: readNumber(day, ['logtimes'], diagnostics, { ...context, metric: 'loggedHours' }),
210
+ overtime: readNumber(day, ['overtime'], diagnostics, { ...context, metric: 'overtime' }),
211
+ source: 'timesheet'
212
+ });
213
+ });
214
+ });
215
+
216
+ return rows;
217
+ }
218
+
219
+ function collectProjectAssignments(value, assignments, seen) {
220
+ if (!value || typeof value !== 'object') {
221
+ return;
222
+ }
223
+
224
+ if (seen.has(value)) {
225
+ return;
226
+ }
227
+ seen.add(value);
228
+
229
+ if (Array.isArray(value)) {
230
+ for (const item of value) {
231
+ collectProjectAssignments(item, assignments, seen);
232
+ }
233
+ return;
234
+ }
235
+
236
+ if (
237
+ value.project &&
238
+ typeof value.project === 'object' &&
239
+ Array.isArray(value.timesheet)
240
+ ) {
241
+ assignments.push(value);
242
+ return;
243
+ }
244
+
245
+ for (const [key, child] of Object.entries(value)) {
246
+ if (key === 'overall') {
247
+ continue;
248
+ }
249
+ collectProjectAssignments(child, assignments, seen);
250
+ }
251
+ }
252
+
253
+ function findTimesheetRowsInRange(data, { from, to } = {}) {
254
+ const rows = [];
255
+ collectTimesheetRowsInRange(data, { from, to }, rows, new Set());
256
+ return rows;
257
+ }
258
+
259
+ function collectTimesheetRowsInRange(value, range, rows, seen) {
260
+ if (!value || typeof value !== 'object') {
261
+ return;
262
+ }
263
+
264
+ if (seen.has(value)) {
265
+ return;
266
+ }
267
+ seen.add(value);
268
+
269
+ if (Array.isArray(value)) {
270
+ for (const item of value) {
271
+ collectTimesheetRowsInRange(item, range, rows, seen);
272
+ }
273
+ return;
274
+ }
275
+
276
+ const directDate = readDateLike(value);
277
+ if (isDateInRange(directDate, range) && looksLikeTimesheetRow(value)) {
278
+ rows.push(value);
279
+ } else if (!directDate && looksLikeTimesheetRow(value)) {
280
+ rows.push(value);
281
+ }
282
+
283
+ for (const [key, child] of Object.entries(value)) {
284
+ if (Array.isArray(child)) {
285
+ const keyDate = normalizeDateOnly(key);
286
+ if (isDateInRange(keyDate, range)) {
287
+ for (const item of child) {
288
+ if (item && typeof item === 'object') {
289
+ rows.push({ ...item, date: readDateLike(item) || keyDate });
290
+ }
291
+ }
292
+ continue;
293
+ }
294
+
295
+ for (const item of child) {
296
+ if (item && typeof item === 'object') {
297
+ const itemDate = readDateLike(item);
298
+ if (isDateInRange(itemDate, range) && looksLikeTimesheetRow(item)) {
299
+ rows.push(item);
300
+ continue;
301
+ }
302
+ }
303
+
304
+ collectTimesheetRowsInRange(item, range, rows, seen);
305
+ }
306
+ continue;
307
+ }
308
+
309
+ if (child && typeof child === 'object') {
310
+ const keyDate = normalizeDateOnly(key);
311
+ if (isDateInRange(keyDate, range)) {
312
+ rows.push({ ...child, date: readDateLike(child) || keyDate });
313
+ continue;
314
+ }
315
+
316
+ collectTimesheetRowsInRange(child, range, rows, seen);
317
+ }
318
+ }
319
+ }
320
+
321
+ function looksLikeTimesheetRow(row) {
322
+ return readAssignedHours(row) > 0 || readLoggedHours(row) > 0 || Boolean(pickFirst(row, [
323
+ 'project_member_id',
324
+ 'projectMemberId',
325
+ 'project_id',
326
+ 'projectId'
327
+ ]));
328
+ }
329
+
330
+ function readLogEntries(row, diagnostics = null, context = {}) {
331
+ const rawEntries = pickFirst(row, [
332
+ 'logs',
333
+ 'logtimes_data',
334
+ 'logtimesData',
335
+ 'log_times',
336
+ 'logTimes',
337
+ 'items',
338
+ 'children'
339
+ ]);
340
+
341
+ if (!Array.isArray(rawEntries)) {
342
+ return [];
343
+ }
344
+
345
+ const entries = [];
346
+ rawEntries.forEach((entry, entryIndex) => {
347
+ if (!entry || typeof entry !== 'object') {
348
+ addDroppedRow(diagnostics, 'invalid_log_entry', {
349
+ ...context,
350
+ entryIndex
351
+ });
352
+ return;
353
+ }
354
+
355
+ const entryContext = {
356
+ ...context,
357
+ entryIndex,
358
+ kind: 'embedded_log_entry'
359
+ };
360
+ const date = normalizeDateOnly(entry.logdate ?? entry.log_date ?? entry.date ?? row.logdate ?? row.date);
361
+ const projectMemberId = readProjectMemberId(entry) ?? readProjectMemberId(row);
362
+ const projectId = readProjectId(entry) ?? readProjectId(row);
363
+ const hours = readNumber(entry, ['logtimes', 'hours', 'time'], diagnostics, { ...entryContext, metric: 'hours' });
364
+
365
+ if (!date) {
366
+ addDroppedRow(diagnostics, 'missing_date', { ...entryContext, ...diagnosticProjectFields({ projectMemberId, projectId }) });
367
+ return;
368
+ }
369
+
370
+ if (projectMemberId === undefined && projectId === undefined) {
371
+ addDroppedRow(diagnostics, 'missing_project_identity', { ...entryContext, date });
372
+ return;
373
+ }
374
+
375
+ if (hours <= 0) {
376
+ addDroppedRow(diagnostics, 'non_positive_hours', { ...entryContext, date, ...diagnosticProjectFields({ projectMemberId, projectId }) });
377
+ return;
378
+ }
379
+
380
+ entries.push({
381
+ id: entry.id ?? entry.logtime_id ?? entry.logtimeId,
382
+ date,
383
+ projectMemberId,
384
+ projectId,
385
+ projectName: hasProjectName(entry) ? readProjectName(entry, readProjectId(entry), diagnostics, entryContext) : readProjectName(row, readProjectId(row) ?? readProjectMemberId(row), diagnostics, entryContext),
386
+ hours,
387
+ taskName: entry.task_name ?? entry.taskName ?? entry.name ?? entry.title ?? '',
388
+ raw: entry
389
+ });
390
+ });
391
+
392
+ return entries;
393
+ }
394
+
395
+ function readTimesheetHours(row, directPaths, fallbackReader, diagnostics, context) {
396
+ const directValue = pickFirst(row, directPaths);
397
+ if (directValue !== undefined) {
398
+ return coerceHours(directValue, diagnostics, {
399
+ ...context,
400
+ field: directPaths[0]
401
+ });
402
+ }
403
+
404
+ return fallbackReader(row, diagnostics, context);
405
+ }
406
+
407
+ function hoursToAssignPercent(hours) {
408
+ return Math.round((Number(hours || 0) / CONFIG.standardDayHours) * 100);
409
+ }
410
+
411
+ function hasObjectPayload(data) {
412
+ return Boolean(data && typeof data === 'object' && Object.keys(data).length > 0);
413
+ }
@@ -1,4 +1,5 @@
1
- import { ApiError, deleteLogworkEntry } from './api.mjs';
1
+ import { ApiError } from './ro-api-http.mjs';
2
+ import { deleteLogworkEntry } from './ro-api-entries.mjs';
2
3
  import { createResourceOptimiserSession } from './query-workflow.mjs';
3
4
  import { assertOwnUnlockedRoEntry, createF03LogworkReader, createRoEntryRevisionFingerprint } from './ro-entry-reader.mjs';
4
5
 
@@ -1,26 +1,31 @@
1
1
  import { ApiError, getLogtimeById, getLogworkDay, getProjects } from './api.mjs';
2
2
  import { createResourceOptimiserSession } from './query-workflow.mjs';
3
3
 
4
+ const DEFAULT_READS = { getLogtimeById, getLogworkDay, getProjects };
5
+
4
6
  // Reads one existing RO entry with the fields edit, resubmit and delete need. The legacy
5
7
  // GET /member-logtime/:id gives the date, owner and Jira link; /logwork/day
6
8
  // 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();
9
+ // project-member list maps project_member_id to project_id. Tests inject the
10
+ // session and the RO reads; callers use the defaults.
11
+ export function createF03LogworkReader({
12
+ session = createResourceOptimiserSession(),
13
+ reads = DEFAULT_READS
14
+ } = {}) {
10
15
  let projectsPromise = null;
11
16
  return async (logworkId) => withRoSessionRetry(session, async ({ token, userId }) => {
12
- const legacy = await getLogtimeById(token, logworkId);
17
+ const legacy = await reads.getLogtimeById(token, logworkId);
13
18
  if (!legacy.date) {
14
19
  throw new Error(`Resource Optimiser did not return a date for logwork ${logworkId}.`);
15
20
  }
16
- const day = await getLogworkDay(token, { date: legacy.date });
21
+ const day = await reads.getLogworkDay(token, { date: legacy.date });
17
22
  const f03 = (day.entries || []).find((entry) => String(entry.id) === String(legacy.id));
18
23
  if (!f03) {
19
24
  throw new Error(`RO logwork ${logworkId} was not found on ${legacy.date}; it may have been deleted.`);
20
25
  }
21
26
  // Cache only a successful load: a cached rejection (say a 401) would make
22
27
  // the retry after a forced login fail the same way.
23
- projectsPromise ||= getProjects(token, userId).catch((error) => {
28
+ projectsPromise ||= reads.getProjects(token, userId).catch((error) => {
24
29
  projectsPromise = null;
25
30
  throw error;
26
31
  });
@@ -1,4 +1,5 @@
1
- import { ApiError, resubmitLogworkEntry } from './api.mjs';
1
+ import { ApiError } from './ro-api-http.mjs';
2
+ import { resubmitLogworkEntry } from './ro-api-entries.mjs';
2
3
  import { createResourceOptimiserSession } from './query-workflow.mjs';
3
4
  import { assertOwnUnlockedRoEntry, createF03LogworkReader, createRoEntryRevisionFingerprint } from './ro-entry-reader.mjs';
4
5
 
@@ -1,12 +1,15 @@
1
- import { isCancel, password, select, text } from '@clack/prompts';
1
+ import * as clackPrompts from '@clack/prompts';
2
2
  import { createAuthRequiredError } from './auth-errors.mjs';
3
3
  import { createCredentialEmailStorage } from './credential-store.mjs';
4
4
 
5
5
  export function createTerminalCredentialProvider({
6
6
  emailStorage = createCredentialEmailStorage(),
7
7
  stdin = process.stdin,
8
- stdout = process.stdout
8
+ stdout = process.stdout,
9
+ prompts = clackPrompts
9
10
  } = {}) {
11
+ const { password, select, text } = prompts;
12
+ const handleCancel = (value) => handleCancelWith(prompts, value);
10
13
  return {
11
14
  async requestCredentials() {
12
15
  ensureInteractive(stdin, stdout);
@@ -111,8 +114,8 @@ function ensureInteractive(stdin, stdout) {
111
114
  }
112
115
  }
113
116
 
114
- function handleCancel(value) {
115
- if (isCancel(value)) {
117
+ function handleCancelWith(prompts, value) {
118
+ if (prompts.isCancel(value)) {
116
119
  throw new Error('User cancelled Resource Optimiser authentication.');
117
120
  }
118
121