logwork-cli 1.1.1 → 1.2.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.
- package/README.md +23 -7
- package/RELEASE.md +1 -0
- package/commands/commits.mjs +57 -6
- package/commands/history.mjs +0 -4
- package/commands/quick.mjs +38 -23
- package/commands/reminder.mjs +41 -2
- package/commands/setup.mjs +50 -16
- package/commands/tui.mjs +1 -0
- package/docs/advanced.md +12 -2
- package/docs/mcp-setup.md +56 -14
- package/docs/operations-guide.md +35 -2
- package/docs/recipes.md +27 -0
- package/docs/security.md +3 -1
- package/examples/mcp/antigravity.mcp_config.json +1 -1
- package/examples/mcp/codex.config.toml +1 -1
- package/examples/mcp/copilot.mcp.json +1 -1
- package/lib/api-auth-forms.mjs +393 -0
- package/lib/api-auth-http.mjs +184 -0
- package/lib/api-auth-support.mjs +101 -0
- package/lib/api-auth-tokens.mjs +129 -0
- package/lib/api-auth.mjs +39 -813
- package/lib/api.mjs +38 -1422
- package/lib/apply-ledger.mjs +1 -1
- package/lib/batch-workflow.mjs +187 -2
- package/lib/commit-workflow.mjs +188 -21
- package/lib/config.mjs +7 -0
- package/lib/diagnostics.mjs +53 -4
- package/lib/jira-api.mjs +25 -3
- package/lib/jira-workflow.mjs +36 -6
- package/lib/logwork-config.mjs +64 -13
- package/lib/manual-apply-state.mjs +15 -0
- package/lib/manual-commands.mjs +2 -2
- package/lib/manual-ink-app.mjs +108 -1281
- package/lib/manual-ink-draft-actions.mjs +227 -0
- package/lib/manual-ink-interaction.mjs +306 -0
- package/lib/manual-ink-session.mjs +41 -0
- package/lib/manual-ink-task-editor.mjs +338 -0
- package/lib/manual-ink-wizard.mjs +520 -0
- package/lib/manual-mcp.mjs +27 -15
- package/lib/manual-repl.mjs +39 -3
- package/lib/mcp-server.mjs +51 -20
- package/lib/node-runtime.mjs +115 -0
- package/lib/paths.mjs +7 -0
- package/lib/project-mapping-workflow.mjs +30 -7
- package/lib/query-workflow.mjs +0 -1
- package/lib/reconciliation-workflow.mjs +1 -1
- package/lib/reminder-config.mjs +38 -2
- package/lib/reminder-scheduler.mjs +54 -3
- package/lib/reminder-service.mjs +28 -3
- package/lib/reminder-workflow.mjs +118 -22
- package/lib/ro-api-diagnostics.mjs +109 -0
- package/lib/ro-api-entries.mjs +359 -0
- package/lib/ro-api-fields.mjs +290 -0
- package/lib/ro-api-http.mjs +68 -0
- package/lib/ro-api-projects.mjs +137 -0
- package/lib/ro-api-tasks.mjs +50 -0
- package/lib/ro-api-timesheet.mjs +413 -0
- package/lib/ro-delete-workflow.mjs +2 -1
- package/lib/ro-entry-reader.mjs +11 -6
- package/lib/ro-resubmit-workflow.mjs +2 -1
- package/lib/terminal-auth-prompts.mjs +7 -4
- package/lib/ui.mjs +80 -59
- package/package.json +11 -4
- package/skills/logwork/SKILL.md +5 -4
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
// Logwork entries: reads (legacy GET, /logwork/day, weekly, rejected) and writes
|
|
2
|
+
// (create, resubmit, delete). Writes honor LOGWORK_DRY_RUN.
|
|
3
|
+
import { CONFIG, isDryRun } from './config.mjs';
|
|
4
|
+
import { resolveWorklogTask } from './worklog-task-resolver.mjs';
|
|
5
|
+
import { ApiError, apiFetch } from './ro-api-http.mjs';
|
|
6
|
+
import { fetchWorklogTasksForProject } from './ro-api-tasks.mjs';
|
|
7
|
+
import {
|
|
8
|
+
assertLogtimeId,
|
|
9
|
+
extractLogtimeRecord,
|
|
10
|
+
normalizeDateOnly,
|
|
11
|
+
preserveNumericId,
|
|
12
|
+
unwrapRecords
|
|
13
|
+
} from './ro-api-fields.mjs';
|
|
14
|
+
|
|
15
|
+
const LOGWORK_TYPE_OF_WORK = new Set(['create', 'correct', 'improve', 'other']);
|
|
16
|
+
|
|
17
|
+
export async function getLogtimeById(token, logtimeId, options = {}) {
|
|
18
|
+
const path = `${CONFIG.memberLogtimePath}/${encodeURIComponent(assertLogtimeId(logtimeId, 'logtimeId'))}`;
|
|
19
|
+
const data = await apiFetch(token, path, options);
|
|
20
|
+
const record = extractLogtimeRecord(data);
|
|
21
|
+
if (!record) {
|
|
22
|
+
throw new ApiError(`Resource Optimiser did not return logwork ${logtimeId}.`, {
|
|
23
|
+
path
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
return normalizeEditableLogtime(record);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function createLogworkEntry(token, {
|
|
30
|
+
worklogTaskId,
|
|
31
|
+
projectId,
|
|
32
|
+
effortHours,
|
|
33
|
+
localDateISO,
|
|
34
|
+
typeOfWork = CONFIG.defaultTypeOfWork,
|
|
35
|
+
description,
|
|
36
|
+
needsProjectId = false
|
|
37
|
+
}, options = {}) {
|
|
38
|
+
const normalizedTaskId = assertLogtimeId(worklogTaskId, 'worklogTaskId');
|
|
39
|
+
const normalizedProjectId = assertPositiveProjectId(projectId);
|
|
40
|
+
const normalizedHours = Number(effortHours);
|
|
41
|
+
const normalizedTypeOfWork = String(typeOfWork || CONFIG.defaultTypeOfWork).trim();
|
|
42
|
+
if (!Number.isFinite(normalizedHours) || normalizedHours <= 0) {
|
|
43
|
+
throw new Error('effortHours must be a positive number.');
|
|
44
|
+
}
|
|
45
|
+
if (!LOGWORK_TYPE_OF_WORK.has(normalizedTypeOfWork)) {
|
|
46
|
+
throw new Error(`typeOfWork must be one of: ${[...LOGWORK_TYPE_OF_WORK].join(', ')}.`);
|
|
47
|
+
}
|
|
48
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(localDateISO || ''))) {
|
|
49
|
+
throw new Error('localDateISO must use YYYY-MM-DD format.');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const path = CONFIG.logworkEntriesPath;
|
|
53
|
+
const body = {
|
|
54
|
+
worklog_task_id: preserveNumericId(normalizedTaskId),
|
|
55
|
+
effort_hours: normalizedHours,
|
|
56
|
+
logdate: localDateISO,
|
|
57
|
+
type_of_work: normalizedTypeOfWork
|
|
58
|
+
};
|
|
59
|
+
if (description !== undefined && description !== null && String(description).trim()) {
|
|
60
|
+
body.description = String(description).trim();
|
|
61
|
+
}
|
|
62
|
+
if (needsProjectId) {
|
|
63
|
+
body.project_id = preserveNumericId(normalizedProjectId);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (isDryRun()) {
|
|
67
|
+
return {
|
|
68
|
+
dryRun: true,
|
|
69
|
+
method: 'POST',
|
|
70
|
+
path,
|
|
71
|
+
body
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const response = await apiFetch(token, path, {
|
|
77
|
+
...options,
|
|
78
|
+
method: 'POST',
|
|
79
|
+
body: JSON.stringify(body)
|
|
80
|
+
});
|
|
81
|
+
const record = extractLogtimeRecord(response);
|
|
82
|
+
return {
|
|
83
|
+
dryRun: false,
|
|
84
|
+
entry: record ? normalizeEditableLogtime(record) : null,
|
|
85
|
+
response
|
|
86
|
+
};
|
|
87
|
+
} catch (error) {
|
|
88
|
+
throw mapLogworkEntryApiError(error);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function getLogworkDay(token, { date, projectId } = {}, options = {}) {
|
|
93
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(date || ''))) {
|
|
94
|
+
throw new Error('date must use YYYY-MM-DD format.');
|
|
95
|
+
}
|
|
96
|
+
const qs = new URLSearchParams({ date });
|
|
97
|
+
if (projectId !== undefined && projectId !== null && projectId !== '') {
|
|
98
|
+
qs.set('project_id', String(projectId));
|
|
99
|
+
}
|
|
100
|
+
const data = await apiFetch(token, `${CONFIG.logworkDayPath}?${qs}`, options);
|
|
101
|
+
return normalizeLogworkDayPayload(unwrapLogworkObject(data));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function getLogworkWeekly(token, { weekStart } = {}, options = {}) {
|
|
105
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(weekStart || ''))) {
|
|
106
|
+
throw new Error('weekStart must use YYYY-MM-DD format.');
|
|
107
|
+
}
|
|
108
|
+
const qs = new URLSearchParams({ week_start: weekStart });
|
|
109
|
+
const data = await apiFetch(token, `${CONFIG.logworkWeeklyPath}?${qs}`, options);
|
|
110
|
+
return normalizeLogworkWeeklyPayload(unwrapLogworkObject(data));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function getLogworkRejected(token, options = {}) {
|
|
114
|
+
const data = await apiFetch(token, CONFIG.logworkRejectedPath, options);
|
|
115
|
+
return unwrapRecords(data).map(normalizeLogworkF03Entry);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function resubmitLogworkEntry(token, entryId, {
|
|
119
|
+
effortHours,
|
|
120
|
+
typeOfWork = CONFIG.defaultTypeOfWork,
|
|
121
|
+
noteToPm,
|
|
122
|
+
description
|
|
123
|
+
}, options = {}) {
|
|
124
|
+
const id = assertLogtimeId(entryId, 'entryId');
|
|
125
|
+
const normalizedHours = Number(effortHours);
|
|
126
|
+
const normalizedTypeOfWork = String(typeOfWork || CONFIG.defaultTypeOfWork).trim();
|
|
127
|
+
if (!Number.isFinite(normalizedHours) || normalizedHours <= 0) {
|
|
128
|
+
throw new Error('effortHours must be a positive number.');
|
|
129
|
+
}
|
|
130
|
+
if (!LOGWORK_TYPE_OF_WORK.has(normalizedTypeOfWork)) {
|
|
131
|
+
throw new Error(`typeOfWork must be one of: ${[...LOGWORK_TYPE_OF_WORK].join(', ')}.`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const path = `${CONFIG.logworkEntriesPath}/${encodeURIComponent(String(id))}/resubmit`;
|
|
135
|
+
const body = {
|
|
136
|
+
effort_hours: normalizedHours,
|
|
137
|
+
type_of_work: normalizedTypeOfWork
|
|
138
|
+
};
|
|
139
|
+
if (noteToPm !== undefined && String(noteToPm).trim()) {
|
|
140
|
+
body.note_to_pm = String(noteToPm).trim();
|
|
141
|
+
}
|
|
142
|
+
if (description !== undefined && String(description).trim()) {
|
|
143
|
+
body.description = String(description).trim();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (isDryRun()) {
|
|
147
|
+
return { dryRun: true, method: 'PATCH', path, body };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const response = await apiFetch(token, path, {
|
|
152
|
+
...options,
|
|
153
|
+
method: 'PATCH',
|
|
154
|
+
body: JSON.stringify(body)
|
|
155
|
+
});
|
|
156
|
+
const record = extractLogtimeRecord(response);
|
|
157
|
+
return {
|
|
158
|
+
dryRun: false,
|
|
159
|
+
entry: record ? normalizeLogworkF03Entry(record) : null,
|
|
160
|
+
response
|
|
161
|
+
};
|
|
162
|
+
} catch (error) {
|
|
163
|
+
throw mapLogworkEntryApiError(error);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function deleteLogworkEntry(token, entryId, options = {}) {
|
|
168
|
+
const id = assertLogtimeId(entryId, 'entryId');
|
|
169
|
+
const path = `${CONFIG.logworkEntriesPath}/${encodeURIComponent(String(id))}`;
|
|
170
|
+
|
|
171
|
+
if (isDryRun()) {
|
|
172
|
+
return { dryRun: true, method: 'DELETE', path };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
const response = await apiFetch(token, path, {
|
|
177
|
+
...options,
|
|
178
|
+
method: 'DELETE'
|
|
179
|
+
});
|
|
180
|
+
return { dryRun: false, response };
|
|
181
|
+
} catch (error) {
|
|
182
|
+
throw mapLogworkEntryApiError(error);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function normalizeLogworkF03Entry(record = {}) {
|
|
187
|
+
const base = normalizeEditableLogtime(record);
|
|
188
|
+
return {
|
|
189
|
+
...base,
|
|
190
|
+
typeOfWork: record.type_of_work ?? record.typeOfWork ?? '',
|
|
191
|
+
worklogTaskId: record.worklog_task_id ?? record.worklogTaskId ?? null,
|
|
192
|
+
description: record.description ?? '',
|
|
193
|
+
rejectionComment: record.rejection_comment ?? record.rejectionComment ?? '',
|
|
194
|
+
noteToPm: record.note_to_pm ?? record.noteToPm ?? ''
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function normalizeLogworkDayPayload(payload = {}) {
|
|
199
|
+
const entries = Array.isArray(payload.entries)
|
|
200
|
+
? payload.entries.map(normalizeLogworkF03Entry)
|
|
201
|
+
: [];
|
|
202
|
+
return {
|
|
203
|
+
date: normalizeDateOnly(payload.date) || '',
|
|
204
|
+
entries,
|
|
205
|
+
isLocked: Boolean(payload.isLocked ?? payload.is_locked),
|
|
206
|
+
isProjectClosed: Boolean(payload.isProjectClosed ?? payload.is_project_closed),
|
|
207
|
+
leaveRequests: Array.isArray(payload.leaveRequests) ? payload.leaveRequests : []
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function normalizeLogworkWeeklyPayload(payload = {}) {
|
|
212
|
+
return {
|
|
213
|
+
weekStart: normalizeDateOnly(payload.weekStart ?? payload.week_start) || '',
|
|
214
|
+
isLocked: Boolean(payload.isLocked ?? payload.is_locked),
|
|
215
|
+
projects: Array.isArray(payload.projects) ? payload.projects : [],
|
|
216
|
+
missingDays: Array.isArray(payload.missingDays) ? payload.missingDays : [],
|
|
217
|
+
weeklyTotals: payload.weeklyTotals ?? payload.weekly_totals ?? null
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function unwrapLogworkObject(data) {
|
|
222
|
+
if (data?.data && typeof data.data === 'object' && !Array.isArray(data.data)) {
|
|
223
|
+
return data.data;
|
|
224
|
+
}
|
|
225
|
+
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
|
226
|
+
return data;
|
|
227
|
+
}
|
|
228
|
+
return {};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export async function addLogtime(token, entry, options = {}) {
|
|
232
|
+
const {
|
|
233
|
+
projectMemberId: _projectMemberId,
|
|
234
|
+
projectId,
|
|
235
|
+
logtimes,
|
|
236
|
+
taskName,
|
|
237
|
+
localDateISO,
|
|
238
|
+
typeOfWork,
|
|
239
|
+
description,
|
|
240
|
+
worklogTaskId,
|
|
241
|
+
worklogNeedsProjectId,
|
|
242
|
+
taskCache
|
|
243
|
+
} = entry || {};
|
|
244
|
+
|
|
245
|
+
const normalizedHours = Number(logtimes);
|
|
246
|
+
const normalizedTaskName = String(taskName ?? '').trim();
|
|
247
|
+
if (!Number.isFinite(normalizedHours) || normalizedHours <= 0) {
|
|
248
|
+
throw new Error('logtimes must be a positive number.');
|
|
249
|
+
}
|
|
250
|
+
if (!normalizedTaskName) {
|
|
251
|
+
throw new Error('taskName must not be empty.');
|
|
252
|
+
}
|
|
253
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(localDateISO || ''))) {
|
|
254
|
+
throw new Error('localDateISO must use YYYY-MM-DD format.');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const normalizedProjectId = assertPositiveProjectId(projectId);
|
|
258
|
+
let resolvedTaskId = worklogTaskId;
|
|
259
|
+
let needsProjectId = worklogNeedsProjectId;
|
|
260
|
+
|
|
261
|
+
if (resolvedTaskId === undefined || resolvedTaskId === null) {
|
|
262
|
+
const tasks = await fetchWorklogTasksForProject(token, normalizedProjectId, {
|
|
263
|
+
cache: taskCache,
|
|
264
|
+
...options
|
|
265
|
+
});
|
|
266
|
+
const resolution = resolveWorklogTask({
|
|
267
|
+
taskName: normalizedTaskName,
|
|
268
|
+
projectId: normalizedProjectId,
|
|
269
|
+
tasks
|
|
270
|
+
});
|
|
271
|
+
resolvedTaskId = resolution.worklogTaskId;
|
|
272
|
+
needsProjectId = resolution.needsProjectId;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return createLogworkEntry(token, {
|
|
276
|
+
worklogTaskId: resolvedTaskId,
|
|
277
|
+
projectId: normalizedProjectId,
|
|
278
|
+
effortHours: normalizedHours,
|
|
279
|
+
localDateISO,
|
|
280
|
+
typeOfWork,
|
|
281
|
+
description,
|
|
282
|
+
needsProjectId: Boolean(needsProjectId)
|
|
283
|
+
}, options);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function assertPositiveProjectId(projectId, label = 'projectId') {
|
|
287
|
+
const normalized = Number(projectId);
|
|
288
|
+
if (!Number.isFinite(normalized) || normalized <= 0) {
|
|
289
|
+
throw new Error(`${label} must be a positive number.`);
|
|
290
|
+
}
|
|
291
|
+
return normalized;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function mapLogworkEntryApiError(error) {
|
|
295
|
+
if (!(error instanceof ApiError)) {
|
|
296
|
+
return error;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const bodyText = String(error.body || '');
|
|
300
|
+
const knownMessages = [
|
|
301
|
+
['TASK_OR_PROJECT_NOT_FOUND', 'Worklog task or project was not found. Check project_id and worklog_task_id.'],
|
|
302
|
+
['HOLIDAY_BLOCKED', 'Logwork is blocked on this date (holiday).'],
|
|
303
|
+
['NOT_MEMBER_OF_PROJECT', 'You are not a member of this project.'],
|
|
304
|
+
['INVALID_EFFORT', 'Effort hours must be greater than 0.'],
|
|
305
|
+
['EFFORT_EXCEEDS_24', 'Effort cannot exceed 24 hours per entry.'],
|
|
306
|
+
['ENTRY_NOT_FOUND', 'Logwork entry was not found or is already deleted.'],
|
|
307
|
+
['FORBIDDEN', 'You can only delete or change logwork entries you created.']
|
|
308
|
+
];
|
|
309
|
+
|
|
310
|
+
for (const [code, message] of knownMessages) {
|
|
311
|
+
if (bodyText.includes(code)) {
|
|
312
|
+
return new ApiError(message, {
|
|
313
|
+
status: error.status,
|
|
314
|
+
path: error.path,
|
|
315
|
+
body: error.body
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (bodyText.includes('Can only delete unprocessed entries')) {
|
|
321
|
+
return new ApiError('Only submitted or approved entries can be deleted. Rejected entries must be resubmitted or edited.', {
|
|
322
|
+
status: error.status,
|
|
323
|
+
path: error.path,
|
|
324
|
+
body: error.body
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (bodyText.includes('LOCKED') || bodyText.includes('locked')) {
|
|
329
|
+
return new ApiError('This log date is locked and cannot be edited.', {
|
|
330
|
+
status: error.status,
|
|
331
|
+
path: error.path,
|
|
332
|
+
body: error.body
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return error;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function normalizeEditableLogtime(record = {}) {
|
|
340
|
+
const hours = Number(record.logtimes ?? record.hours ?? record.effort_hours ?? record.time);
|
|
341
|
+
return {
|
|
342
|
+
id: record.id ?? record.logtime_id ?? record.logtimeId,
|
|
343
|
+
projectMemberId: record.project_member_id ?? record.projectMemberId,
|
|
344
|
+
projectId: record.project_id ?? record.projectId ?? record.project?.id,
|
|
345
|
+
projectName: record.project_name ?? record.projectName ?? record.project?.name ?? '',
|
|
346
|
+
date: normalizeDateOnly(record.logdate ?? record.log_date ?? record.date),
|
|
347
|
+
hours: Number.isFinite(hours) ? hours : 0,
|
|
348
|
+
taskName: record.task_name ?? record.taskName ?? record.name ?? record.title ?? '',
|
|
349
|
+
createdBy: record.created_by ?? record.createdBy,
|
|
350
|
+
updatedBy: record.updated_by ?? record.updatedBy,
|
|
351
|
+
updatedDate: record.updated_date ?? record.updatedDate ?? '',
|
|
352
|
+
jiraLogworkId: record.jira_logwork_id ?? record.jiraLogworkId ?? null,
|
|
353
|
+
referrerUrl: record.referrer_url ?? record.referrerUrl ?? null,
|
|
354
|
+
source: record.source ?? '',
|
|
355
|
+
status: record.status ?? '',
|
|
356
|
+
isLocked: Boolean(record.is_locked ?? record.isLocked),
|
|
357
|
+
raw: record
|
|
358
|
+
};
|
|
359
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// Readers for loosely shaped Resource Optimiser responses. Each reader tries a list
|
|
2
|
+
// of candidate field paths; extend those lists rather than hard-coding one shape.
|
|
3
|
+
import { addWarning, diagnosticProjectFields } from './ro-api-diagnostics.mjs';
|
|
4
|
+
|
|
5
|
+
export function readProjectMemberId(row) {
|
|
6
|
+
const explicit = readExplicitProjectMemberId(row);
|
|
7
|
+
|
|
8
|
+
if (explicit !== undefined) {
|
|
9
|
+
return explicit;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (looksLikeProjectMemberRow(row)) {
|
|
13
|
+
return row.id;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function readExplicitProjectMemberId(row) {
|
|
20
|
+
return pickFirst(row, [
|
|
21
|
+
'project_member_id',
|
|
22
|
+
'projectMemberId',
|
|
23
|
+
'project_member.id',
|
|
24
|
+
'projectMember.id'
|
|
25
|
+
]);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function readProjectId(row) {
|
|
29
|
+
return pickFirst(row, [
|
|
30
|
+
'project_id',
|
|
31
|
+
'projectId',
|
|
32
|
+
'project.id',
|
|
33
|
+
'project.project_id',
|
|
34
|
+
'projectMember.project_id',
|
|
35
|
+
'project_member.project_id'
|
|
36
|
+
]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function readProjectName(row, fallbackId, diagnostics = null, context = {}) {
|
|
40
|
+
const projectName = pickFirst(row, [
|
|
41
|
+
'project.name',
|
|
42
|
+
'project_name',
|
|
43
|
+
'projectName',
|
|
44
|
+
'name',
|
|
45
|
+
'projectMember.project.name',
|
|
46
|
+
'project_member.project.name'
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
if (projectName !== undefined) {
|
|
50
|
+
return projectName;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (fallbackId !== undefined && fallbackId !== null && String(fallbackId).trim() !== '') {
|
|
54
|
+
addWarning(diagnostics, 'fallback_project_name', {
|
|
55
|
+
...context,
|
|
56
|
+
...diagnosticProjectFields({ projectMemberId: readProjectMemberId(row), projectId: readProjectId(row) })
|
|
57
|
+
});
|
|
58
|
+
return projectFallbackName(fallbackId);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
addWarning(diagnostics, 'fallback_project_name', context);
|
|
62
|
+
return 'Unknown project';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function projectFallbackName(fallbackId) {
|
|
66
|
+
return fallbackId !== undefined && fallbackId !== null && String(fallbackId).trim() !== ''
|
|
67
|
+
? `Project ${fallbackId}`
|
|
68
|
+
: 'Unknown project';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function hasProjectName(row) {
|
|
72
|
+
return pickFirst(row, [
|
|
73
|
+
'project.name',
|
|
74
|
+
'project_name',
|
|
75
|
+
'projectName',
|
|
76
|
+
'name',
|
|
77
|
+
'projectMember.project.name',
|
|
78
|
+
'project_member.project.name'
|
|
79
|
+
]) !== undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function looksLikeProjectMemberRow(row) {
|
|
83
|
+
return row && typeof row === 'object' && row.id !== undefined && (
|
|
84
|
+
row.project_id !== undefined ||
|
|
85
|
+
row.projectId !== undefined ||
|
|
86
|
+
row.project !== undefined ||
|
|
87
|
+
row.project_name !== undefined ||
|
|
88
|
+
row.projectName !== undefined ||
|
|
89
|
+
row.assign_hours !== undefined ||
|
|
90
|
+
row.assigned_hours !== undefined ||
|
|
91
|
+
row.booked_hours !== undefined
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function readAssignedHours(row, diagnostics = null, context = {}) {
|
|
96
|
+
return readNumber(row, [
|
|
97
|
+
'assign_hours',
|
|
98
|
+
'assigned_hours',
|
|
99
|
+
'assignedHours',
|
|
100
|
+
'booking_hours',
|
|
101
|
+
'booked_hours',
|
|
102
|
+
'bookedHours',
|
|
103
|
+
'workload_hours',
|
|
104
|
+
'workloadHours',
|
|
105
|
+
'assign_percent',
|
|
106
|
+
'assignPercent',
|
|
107
|
+
'hours_per_day',
|
|
108
|
+
'hoursPerDay',
|
|
109
|
+
'timesheet',
|
|
110
|
+
'logtime',
|
|
111
|
+
'logtimes_plan',
|
|
112
|
+
'planned_hours',
|
|
113
|
+
'plannedHours',
|
|
114
|
+
'effort',
|
|
115
|
+
'man_day'
|
|
116
|
+
], diagnostics, context);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function readLoggedHours(row, diagnostics = null, context = {}) {
|
|
120
|
+
return readNumber(row, [
|
|
121
|
+
'logged_hours',
|
|
122
|
+
'loggedHours',
|
|
123
|
+
'actual_hours',
|
|
124
|
+
'actualHours',
|
|
125
|
+
'logtimes',
|
|
126
|
+
'log_times',
|
|
127
|
+
'logTimes',
|
|
128
|
+
'logged',
|
|
129
|
+
'total_logtimes',
|
|
130
|
+
'totalLoggedHours',
|
|
131
|
+
'total_logged_hours',
|
|
132
|
+
'spent_hours',
|
|
133
|
+
'spentHours'
|
|
134
|
+
], diagnostics, context);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function readNumber(row, paths, diagnostics = null, context = {}) {
|
|
138
|
+
for (const path of paths) {
|
|
139
|
+
const value = pickFirst(row, [path]);
|
|
140
|
+
if (value !== undefined) {
|
|
141
|
+
const number = Number(value);
|
|
142
|
+
if (Number.isFinite(number) && number > 0) {
|
|
143
|
+
return number;
|
|
144
|
+
}
|
|
145
|
+
if (Number.isFinite(number) && number === 0) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
addWarning(diagnostics, 'invalid_hours', {
|
|
149
|
+
...context,
|
|
150
|
+
field: path
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function coerceHours(value, diagnostics = null, context = {}) {
|
|
159
|
+
const number = Number(value);
|
|
160
|
+
if (Number.isFinite(number) && number >= 0) {
|
|
161
|
+
return number;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
addWarning(diagnostics, 'invalid_hours', context);
|
|
165
|
+
return 0;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function readDateLike(row) {
|
|
169
|
+
const value = pickFirst(row, [
|
|
170
|
+
'logdate',
|
|
171
|
+
'log_date',
|
|
172
|
+
'date',
|
|
173
|
+
'day',
|
|
174
|
+
'workdate',
|
|
175
|
+
'work_date',
|
|
176
|
+
'timesheet_date'
|
|
177
|
+
]);
|
|
178
|
+
|
|
179
|
+
return normalizeDateOnly(value);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function normalizeDateOnly(value) {
|
|
183
|
+
if (!value) {
|
|
184
|
+
return '';
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const text = String(value);
|
|
188
|
+
const match = text.match(/\d{4}-\d{2}-\d{2}/);
|
|
189
|
+
return match ? match[0] : '';
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function isDateInRange(localDateISO, { from, to } = {}) {
|
|
193
|
+
if (!localDateISO) {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (from && localDateISO < from) {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (to && localDateISO >= to) {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function pickFirst(row, paths) {
|
|
209
|
+
for (const path of paths) {
|
|
210
|
+
const value = getPath(row, path);
|
|
211
|
+
if (value !== undefined && value !== null && value !== '') {
|
|
212
|
+
return value;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function getPath(row, path) {
|
|
220
|
+
return path.split('.').reduce((current, part) => {
|
|
221
|
+
if (current === undefined || current === null) {
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return current[part];
|
|
226
|
+
}, row);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function unwrapRecords(data) {
|
|
230
|
+
if (Array.isArray(data)) {
|
|
231
|
+
return data;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (Array.isArray(data?.data)) {
|
|
235
|
+
return data.data;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (Array.isArray(data?.data?.data)) {
|
|
239
|
+
return data.data.data;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (Array.isArray(data?.records)) {
|
|
243
|
+
return data.records;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (Array.isArray(data?.items)) {
|
|
247
|
+
return data.items;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return [];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function extractLogtimeRecord(data, collectionKey) {
|
|
254
|
+
const candidates = collectionKey
|
|
255
|
+
? [data?.data?.[collectionKey], data?.[collectionKey]]
|
|
256
|
+
: [data?.data?.data, data?.data, data?.record, data?.item, data];
|
|
257
|
+
|
|
258
|
+
for (const candidate of candidates) {
|
|
259
|
+
if (Array.isArray(candidate)) {
|
|
260
|
+
const record = candidate.find((item) => item && typeof item === 'object');
|
|
261
|
+
if (record) {
|
|
262
|
+
return record;
|
|
263
|
+
}
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (candidate && typeof candidate === 'object' && (
|
|
267
|
+
candidate.id !== undefined ||
|
|
268
|
+
candidate.logtime_id !== undefined ||
|
|
269
|
+
candidate.logtimeId !== undefined
|
|
270
|
+
)) {
|
|
271
|
+
return candidate;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function assertLogtimeId(value, name) {
|
|
279
|
+
const normalized = String(value ?? '').trim();
|
|
280
|
+
if (!normalized || !/^[A-Za-z0-9_-]+$/.test(normalized)) {
|
|
281
|
+
throw new Error(`${name} must be a valid Resource Optimiser identifier.`);
|
|
282
|
+
}
|
|
283
|
+
return normalized;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function preserveNumericId(value) {
|
|
287
|
+
return /^\d+$/.test(value) && Number.isSafeInteger(Number(value))
|
|
288
|
+
? Number(value)
|
|
289
|
+
: value;
|
|
290
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Resource Optimiser HTTP plumbing: the authenticated JSON fetch and its error type.
|
|
2
|
+
import { CONFIG } from './config.mjs';
|
|
3
|
+
import { fetchWithPolicy, redactedExcerpt } from './http.mjs';
|
|
4
|
+
import { safeJsonParse } from './util.mjs';
|
|
5
|
+
|
|
6
|
+
export class ApiError extends Error {
|
|
7
|
+
constructor(message, { status, path, body } = {}) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = 'ApiError';
|
|
10
|
+
this.status = status;
|
|
11
|
+
this.path = path;
|
|
12
|
+
this.body = body;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function apiFetch(token, path, options = {}) {
|
|
17
|
+
const {
|
|
18
|
+
fetchImpl,
|
|
19
|
+
retries,
|
|
20
|
+
retryDelayMs = CONFIG.httpRetryDelayMs,
|
|
21
|
+
timeoutMs = CONFIG.httpTimeoutMs,
|
|
22
|
+
cache: _worklogTaskCache,
|
|
23
|
+
taskCache: _taskCacheAlias,
|
|
24
|
+
...requestOptions
|
|
25
|
+
} = options;
|
|
26
|
+
const method = requestOptions.method || 'GET';
|
|
27
|
+
const response = await fetchWithPolicy(`${CONFIG.apiBase}${path}`, {
|
|
28
|
+
...requestOptions,
|
|
29
|
+
fetchImpl,
|
|
30
|
+
timeoutMs,
|
|
31
|
+
retryDelayMs,
|
|
32
|
+
retries: retries ?? CONFIG.httpReadRetries,
|
|
33
|
+
idempotent: isIdempotentMethod(method),
|
|
34
|
+
method,
|
|
35
|
+
headers: {
|
|
36
|
+
Authorization: `Bearer ${token}`,
|
|
37
|
+
Accept: 'application/json',
|
|
38
|
+
'Content-Type': 'application/json',
|
|
39
|
+
...requestOptions.headers
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const text = await response.text();
|
|
44
|
+
const data = text ? safeJsonParse(text, text) : null;
|
|
45
|
+
const safeBody = redactedExcerpt(text);
|
|
46
|
+
|
|
47
|
+
if (response.status === 401) {
|
|
48
|
+
throw new ApiError('API request returned 401 Unauthorized.', {
|
|
49
|
+
status: response.status,
|
|
50
|
+
path,
|
|
51
|
+
body: safeBody
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!response.ok) {
|
|
56
|
+
throw new ApiError(`API request failed: ${response.status} ${response.statusText} for ${path}. Body: ${safeBody}`, {
|
|
57
|
+
status: response.status,
|
|
58
|
+
path,
|
|
59
|
+
body: safeBody
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return data;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isIdempotentMethod(method) {
|
|
67
|
+
return ['GET', 'HEAD', 'OPTIONS'].includes(String(method || 'GET').toUpperCase());
|
|
68
|
+
}
|