logwork-cli 0.0.0-stage → 1.0.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/LICENSE +11 -0
- package/README.md +287 -2
- package/RELEASE.md +89 -0
- package/bin/logwork.mjs +101 -0
- package/commands/auth.mjs +227 -0
- package/commands/delete.mjs +69 -0
- package/commands/doctor.mjs +24 -0
- package/commands/edit.mjs +88 -0
- package/commands/history.mjs +62 -0
- package/commands/query.mjs +52 -0
- package/commands/quick.mjs +163 -0
- package/commands/reconcile.mjs +55 -0
- package/commands/release.mjs +77 -0
- package/commands/reminder.mjs +172 -0
- package/commands/setup.mjs +504 -0
- package/commands/tui.mjs +50 -0
- package/commands/update.mjs +161 -0
- package/docs/advanced.md +334 -0
- package/docs/mcp-setup.md +347 -0
- package/docs/operations-guide.md +587 -0
- package/docs/recipes.md +259 -0
- package/docs/security.md +151 -0
- package/examples/mcp/antigravity.mcp_config.json +8 -0
- package/examples/mcp/codex.config.toml +5 -0
- package/examples/mcp/copilot.mcp.json +9 -0
- package/lib/api-auth.mjs +1259 -0
- package/lib/api.mjs +1422 -0
- package/lib/apply-ledger.mjs +227 -0
- package/lib/atomic-file.mjs +24 -0
- package/lib/auth-diagnostics.mjs +113 -0
- package/lib/auth-errors.mjs +22 -0
- package/lib/auth-redaction.mjs +78 -0
- package/lib/auth-terminal.mjs +97 -0
- package/lib/auth.mjs +323 -0
- package/lib/batch-parser.mjs +145 -0
- package/lib/batch-workflow.mjs +842 -0
- package/lib/config.mjs +191 -0
- package/lib/credential-store.mjs +130 -0
- package/lib/diagnostics.mjs +307 -0
- package/lib/entrypoint.mjs +14 -0
- package/lib/file-lock.mjs +44 -0
- package/lib/http.mjs +125 -0
- package/lib/jira-api.mjs +258 -0
- package/lib/jira-auth.mjs +203 -0
- package/lib/jira-workflow.mjs +523 -0
- package/lib/keychain.mjs +105 -0
- package/lib/legacy-migration.mjs +66 -0
- package/lib/list-navigation.mjs +19 -0
- package/lib/logwork-config.mjs +199 -0
- package/lib/logwork-f03-enrich.mjs +41 -0
- package/lib/macos.mjs +33 -0
- package/lib/manual-apply-state.mjs +35 -0
- package/lib/manual-commands.mjs +294 -0
- package/lib/manual-drafts.mjs +154 -0
- package/lib/manual-ink-app.mjs +1654 -0
- package/lib/manual-ink-auth.mjs +349 -0
- package/lib/manual-ink-ui.mjs +675 -0
- package/lib/manual-input.mjs +21 -0
- package/lib/manual-logwork-wizard.mjs +310 -0
- package/lib/manual-mcp.mjs +187 -0
- package/lib/manual-repl.mjs +1037 -0
- package/lib/manual-targets.mjs +78 -0
- package/lib/mcp-server.mjs +793 -0
- package/lib/os-notification.mjs +105 -0
- package/lib/package-info.mjs +17 -0
- package/lib/paths.mjs +68 -0
- package/lib/project-identity.mjs +66 -0
- package/lib/project-mapping-workflow.mjs +309 -0
- package/lib/project-resolver.mjs +373 -0
- package/lib/query-workflow.mjs +553 -0
- package/lib/reconciliation-workflow.mjs +446 -0
- package/lib/release-doctor.mjs +448 -0
- package/lib/reminder-config.mjs +71 -0
- package/lib/reminder-scheduler.mjs +301 -0
- package/lib/reminder-service.mjs +118 -0
- package/lib/reminder-state.mjs +38 -0
- package/lib/reminder-workflow.mjs +299 -0
- package/lib/ro-delete-args.mjs +38 -0
- package/lib/ro-delete-workflow.mjs +115 -0
- package/lib/ro-edit-args.mjs +142 -0
- package/lib/ro-edit-workflow.mjs +632 -0
- package/lib/ro-entry-reader.mjs +56 -0
- package/lib/ro-resubmit-workflow.mjs +134 -0
- package/lib/terminal-auth-prompts.mjs +120 -0
- package/lib/ui.mjs +133 -0
- package/lib/update-service.mjs +350 -0
- package/lib/util.mjs +66 -0
- package/lib/windows-credential-manager.mjs +260 -0
- package/lib/worklog-task-resolver.mjs +113 -0
- package/package.json +72 -4
- package/setup.sh +22 -0
|
@@ -0,0 +1,842 @@
|
|
|
1
|
+
import {
|
|
2
|
+
addLogtime,
|
|
3
|
+
ApiError,
|
|
4
|
+
fetchWorklogTasksForProject,
|
|
5
|
+
getBookedProjectsForDate,
|
|
6
|
+
getProjects
|
|
7
|
+
} from './api.mjs';
|
|
8
|
+
import {
|
|
9
|
+
listAvailableTaskNames,
|
|
10
|
+
listSuggestedTaskNames,
|
|
11
|
+
resolveWorklogTask
|
|
12
|
+
} from './worklog-task-resolver.mjs';
|
|
13
|
+
import { recordApplyResult, recordApplyResultSafely } from './apply-ledger.mjs';
|
|
14
|
+
import { parseWeeklyLogText } from './batch-parser.mjs';
|
|
15
|
+
import { loadLocalConfig } from './logwork-config.mjs';
|
|
16
|
+
import { buildSetupSuggestions } from './project-mapping-workflow.mjs';
|
|
17
|
+
import { resolveEntryProject } from './project-resolver.mjs';
|
|
18
|
+
import { createResourceOptimiserSession, queryLogwork } from './query-workflow.mjs';
|
|
19
|
+
|
|
20
|
+
export async function previewLogworkBatch({
|
|
21
|
+
text,
|
|
22
|
+
projectOverrides = {},
|
|
23
|
+
cwd = process.cwd(),
|
|
24
|
+
fetchProjects,
|
|
25
|
+
fetchMembershipProjects,
|
|
26
|
+
fetchWorklogTasks,
|
|
27
|
+
typeOfWork,
|
|
28
|
+
defaultWorklogTask
|
|
29
|
+
}) {
|
|
30
|
+
const config = await loadLocalConfig(cwd);
|
|
31
|
+
const parsed = parseWeeklyLogText(text);
|
|
32
|
+
const dates = [...new Set(parsed.entries.map((entry) => entry.date))];
|
|
33
|
+
const projectsByDate = new Map();
|
|
34
|
+
const projectFetcher = fetchProjects || createBookedProjectsFetcher();
|
|
35
|
+
const membershipFetcher = fetchMembershipProjects || createMembershipProjectsFetcher();
|
|
36
|
+
const worklogTaskFetcher = fetchWorklogTasks || createWorklogTasksFetcher();
|
|
37
|
+
|
|
38
|
+
for (const date of dates) {
|
|
39
|
+
projectsByDate.set(date, await projectFetcher(date));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const bookedPreview = buildLogworkBatchPreview({
|
|
43
|
+
parsed,
|
|
44
|
+
projectsByDate,
|
|
45
|
+
projectOverrides,
|
|
46
|
+
config
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const preview = !bookedPreview.unresolvedEntries.length
|
|
50
|
+
? bookedPreview
|
|
51
|
+
: buildLogworkBatchPreview({
|
|
52
|
+
parsed,
|
|
53
|
+
projectsByDate,
|
|
54
|
+
membershipProjects: await membershipFetcher(),
|
|
55
|
+
projectOverrides,
|
|
56
|
+
config
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const normalizedDefaultTask = String(defaultWorklogTask ?? '').trim();
|
|
60
|
+
const enriched = await attachWorklogTaskTargets(preview, {
|
|
61
|
+
fetchWorklogTasks: worklogTaskFetcher,
|
|
62
|
+
defaultWorklogTask: normalizedDefaultTask
|
|
63
|
+
});
|
|
64
|
+
const output = normalizedDefaultTask
|
|
65
|
+
? {
|
|
66
|
+
...enriched,
|
|
67
|
+
// The default task changes what gets written, so approval must not reuse
|
|
68
|
+
// a batchId previewed with a different default.
|
|
69
|
+
batchId: `${enriched.batchId}_${hash(`defaultWorklogTask:${normalizedDefaultTask}`)}`,
|
|
70
|
+
defaultWorklogTask: normalizedDefaultTask
|
|
71
|
+
}
|
|
72
|
+
: enriched;
|
|
73
|
+
return typeOfWork ? { ...output, typeOfWork } : output;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const LOGWORK_LINE_FORMAT = '+<hours> <worklog task> | <description>';
|
|
77
|
+
|
|
78
|
+
export async function listWorklogTasks({ projectId, fetchWorklogTasks } = {}) {
|
|
79
|
+
const normalizedProjectId = Number(projectId);
|
|
80
|
+
if (!Number.isFinite(normalizedProjectId) || normalizedProjectId <= 0) {
|
|
81
|
+
throw new Error('projectId must be a positive Resource Optimiser project id (see list_projects).');
|
|
82
|
+
}
|
|
83
|
+
const fetcher = fetchWorklogTasks || createWorklogTasksFetcher();
|
|
84
|
+
const tasks = (await fetcher(normalizedProjectId)).map((task) => ({
|
|
85
|
+
id: task.id,
|
|
86
|
+
name: String(task.name || '').trim(),
|
|
87
|
+
typeOfWork: task.typeOfWork ?? task.type_of_work ?? null,
|
|
88
|
+
description: String(task.description || '').trim(),
|
|
89
|
+
projectSpecific: task.projectId !== null && task.projectId !== undefined,
|
|
90
|
+
isDefault: Boolean(task.isDefault ?? task.is_default)
|
|
91
|
+
})).filter((task) => task.name);
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
projectId: normalizedProjectId,
|
|
95
|
+
tasks,
|
|
96
|
+
lineFormat: LOGWORK_LINE_FORMAT,
|
|
97
|
+
summary: [
|
|
98
|
+
`Found ${tasks.length} worklog task${tasks.length === 1 ? '' : 's'} for project ${normalizedProjectId}.`,
|
|
99
|
+
...tasks.map((task) => `- #${task.id} ${task.name}${task.typeOfWork ? ` (${task.typeOfWork})` : ''}${task.description ? `: ${task.description}` : ''}`),
|
|
100
|
+
`Write logwork lines as "${LOGWORK_LINE_FORMAT}", for example "+2 Feature Development | Implement maintenance mode UI (SCB-213)". Pick the task whose description best matches the work.`
|
|
101
|
+
].join('\n')
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Resolves which Resource Optimiser worklog task (catalog item) and which free-text
|
|
106
|
+
// description each entry writes:
|
|
107
|
+
// - "+2 Code Review & Refactoring | Review PR #12 (SCB-1)": explicit task + description.
|
|
108
|
+
// - "+2 Code Review": whole text matches a catalog task, so no description.
|
|
109
|
+
// - "+2 Review PR #12 (SCB-1)": no catalog match, so the text becomes the
|
|
110
|
+
// description and defaultWorklogTask supplies the task; without a default the
|
|
111
|
+
// entry stays unresolved with reason worklog_task_required.
|
|
112
|
+
export async function attachWorklogTaskTargets(preview, { fetchWorklogTasks, defaultWorklogTask = '' } = {}) {
|
|
113
|
+
if (!fetchWorklogTasks) {
|
|
114
|
+
return preview;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const taskCache = new Map();
|
|
118
|
+
const entries = [];
|
|
119
|
+
|
|
120
|
+
for (const entry of preview.entries || []) {
|
|
121
|
+
if (entry.status !== 'resolved' && entry.status !== 'resolved_unbooked') {
|
|
122
|
+
entries.push(entry);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const projectId = entry.matchedProject?.projectId;
|
|
127
|
+
let tasks = [];
|
|
128
|
+
try {
|
|
129
|
+
tasks = await fetchWorklogTasks(projectId, taskCache);
|
|
130
|
+
const { resolution, description, reason } = resolveEntryWorklogTask({
|
|
131
|
+
entry,
|
|
132
|
+
projectId,
|
|
133
|
+
tasks,
|
|
134
|
+
defaultWorklogTask
|
|
135
|
+
});
|
|
136
|
+
entries.push({
|
|
137
|
+
...entry,
|
|
138
|
+
taskName: resolution.taskName || entry.taskName,
|
|
139
|
+
description,
|
|
140
|
+
descriptionMissing: !description,
|
|
141
|
+
worklogTaskId: resolution.worklogTaskId,
|
|
142
|
+
worklogTaskName: resolution.taskName || entry.taskName,
|
|
143
|
+
worklogNeedsProjectId: resolution.needsProjectId,
|
|
144
|
+
...(resolution.typeOfWork ? { typeOfWork: resolution.typeOfWork } : {}),
|
|
145
|
+
taskResolution: {
|
|
146
|
+
status: 'resolved',
|
|
147
|
+
reason,
|
|
148
|
+
worklogTaskId: resolution.worklogTaskId,
|
|
149
|
+
worklogTaskName: resolution.taskName || entry.taskName,
|
|
150
|
+
...(resolution.typeOfWork ? { typeOfWork: resolution.typeOfWork } : {})
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
} catch (error) {
|
|
154
|
+
const classified = classifyWorklogTaskError(error);
|
|
155
|
+
const suggestedTaskNames = classified.reason === 'task_not_found'
|
|
156
|
+
? listSuggestedTaskNames(tasks, entry.taskName)
|
|
157
|
+
: [];
|
|
158
|
+
const availableTaskNames = classified.reason === 'task_lookup_failed'
|
|
159
|
+
? []
|
|
160
|
+
: listAvailableTaskNames(tasks);
|
|
161
|
+
entries.push({
|
|
162
|
+
...entry,
|
|
163
|
+
status: 'unresolved',
|
|
164
|
+
reason: classified.reason,
|
|
165
|
+
confidence: 0,
|
|
166
|
+
worklogTaskId: undefined,
|
|
167
|
+
worklogNeedsProjectId: undefined,
|
|
168
|
+
suggestedTaskNames,
|
|
169
|
+
availableTaskNames,
|
|
170
|
+
taskResolution: {
|
|
171
|
+
status: 'unresolved',
|
|
172
|
+
reason: classified.reason,
|
|
173
|
+
error: classified.message,
|
|
174
|
+
suggestedTaskNames,
|
|
175
|
+
availableTaskNames
|
|
176
|
+
},
|
|
177
|
+
resolution: {
|
|
178
|
+
...(entry.resolution || {}),
|
|
179
|
+
status: 'unresolved',
|
|
180
|
+
reason: classified.reason,
|
|
181
|
+
confidence: 0,
|
|
182
|
+
autoResolved: false,
|
|
183
|
+
requiresUserChoice: true,
|
|
184
|
+
requiresAllowUnbooked: false,
|
|
185
|
+
candidates: entry.resolution?.candidates || []
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const unresolvedEntries = entries.filter((entry) => entry.status === 'unresolved');
|
|
192
|
+
const unbookedEntries = entries.filter((entry) => entry.status === 'resolved_unbooked');
|
|
193
|
+
const approvalSummary = buildApprovalSummary(entries, preview.errors || []);
|
|
194
|
+
const setupSuggestions = preview.setupSuggestions || [];
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
...preview,
|
|
198
|
+
entries,
|
|
199
|
+
unresolvedEntries,
|
|
200
|
+
unbookedEntries,
|
|
201
|
+
approvalSummary,
|
|
202
|
+
status: getBatchStatus(preview.errors || [], unresolvedEntries, unbookedEntries),
|
|
203
|
+
summary: formatPreviewSummary(entries, preview.errors || [], setupSuggestions, approvalSummary)
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function resolveEntryWorklogTask({ entry, projectId, tasks, defaultWorklogTask }) {
|
|
208
|
+
if (entry.taskExplicit) {
|
|
209
|
+
return {
|
|
210
|
+
resolution: resolveWorklogTask({ taskName: entry.taskName, projectId, tasks }),
|
|
211
|
+
description: entry.description || '',
|
|
212
|
+
reason: 'worklog_task_match'
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
return {
|
|
218
|
+
resolution: resolveWorklogTask({ taskName: entry.taskName, projectId, tasks }),
|
|
219
|
+
description: '',
|
|
220
|
+
reason: 'worklog_task_match'
|
|
221
|
+
};
|
|
222
|
+
} catch (error) {
|
|
223
|
+
if (!String(error?.message || '').startsWith('No worklog task named')) {
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const text = entry.text ?? entry.taskName;
|
|
229
|
+
if (!defaultWorklogTask) {
|
|
230
|
+
throw new WorklogTaskRequiredError(
|
|
231
|
+
`"${text}" is not a Resource Optimiser worklog task for project ${projectId}. ` +
|
|
232
|
+
'Write the line as "+<hours> <worklog task> | <description>" or pass defaultWorklogTask.'
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
resolution: resolveWorklogTask({ taskName: defaultWorklogTask, projectId, tasks }),
|
|
237
|
+
description: text,
|
|
238
|
+
reason: 'default_worklog_task'
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
class WorklogTaskRequiredError extends Error {
|
|
243
|
+
constructor(message) {
|
|
244
|
+
super(message);
|
|
245
|
+
this.code = 'worklog_task_required';
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function buildLogworkBatchPreview({
|
|
250
|
+
parsed,
|
|
251
|
+
projectsByDate,
|
|
252
|
+
membershipProjects = [],
|
|
253
|
+
projectOverrides = {},
|
|
254
|
+
config = {}
|
|
255
|
+
}) {
|
|
256
|
+
const batchId = createBatchId(parsed.entries, projectOverrides);
|
|
257
|
+
const entries = parsed.entries.map((entry) => {
|
|
258
|
+
const projects = projectsByDate instanceof Map
|
|
259
|
+
? projectsByDate.get(entry.date) || []
|
|
260
|
+
: projectsByDate?.[entry.date] || [];
|
|
261
|
+
const resolution = resolveEntryProject({
|
|
262
|
+
entry,
|
|
263
|
+
projects,
|
|
264
|
+
membershipProjects,
|
|
265
|
+
projectOverrides,
|
|
266
|
+
config
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const matchedProject = resolution.status === 'resolved' || resolution.status === 'resolved_unbooked' ? {
|
|
270
|
+
projectMemberId: resolution.project.projectMemberId,
|
|
271
|
+
projectId: resolution.project.projectId,
|
|
272
|
+
projectName: resolution.project.projectName
|
|
273
|
+
} : null;
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
...entry,
|
|
277
|
+
status: resolution.status,
|
|
278
|
+
reason: resolution.reason,
|
|
279
|
+
confidence: resolution.confidence,
|
|
280
|
+
matchedProject,
|
|
281
|
+
booked: Boolean(resolution.booked),
|
|
282
|
+
requiresAllowUnbooked: Boolean(resolution.requiresAllowUnbooked),
|
|
283
|
+
candidates: resolution.candidates,
|
|
284
|
+
resolution: buildResolutionMetadata(resolution)
|
|
285
|
+
};
|
|
286
|
+
});
|
|
287
|
+
const unresolvedEntries = entries.filter((entry) => entry.status === 'unresolved');
|
|
288
|
+
const unbookedEntries = entries.filter((entry) => entry.status === 'resolved_unbooked');
|
|
289
|
+
const setupSuggestions = buildSetupSuggestions(unresolvedEntries, membershipProjects);
|
|
290
|
+
const approvalSummary = buildApprovalSummary(entries, parsed.errors);
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
batchId,
|
|
294
|
+
status: getBatchStatus(parsed.errors, unresolvedEntries, unbookedEntries),
|
|
295
|
+
errors: parsed.errors,
|
|
296
|
+
entries,
|
|
297
|
+
unresolvedEntries,
|
|
298
|
+
unbookedEntries,
|
|
299
|
+
setupSuggestions,
|
|
300
|
+
totalsByDate: totalsBy(entries, (entry) => entry.date),
|
|
301
|
+
totalsByProject: totalsBy(entries.filter((entry) => entry.matchedProject), (entry) => entry.matchedProject.projectName),
|
|
302
|
+
approvalSummary,
|
|
303
|
+
summary: formatPreviewSummary(entries, parsed.errors, setupSuggestions, approvalSummary)
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export async function applyLogworkBatch({
|
|
308
|
+
batch,
|
|
309
|
+
confirm,
|
|
310
|
+
projectOverrides = {},
|
|
311
|
+
allowUnbooked = false,
|
|
312
|
+
cwd = process.cwd(),
|
|
313
|
+
fetchProjects,
|
|
314
|
+
fetchMembershipProjects,
|
|
315
|
+
submitEntry,
|
|
316
|
+
verifyLogwork,
|
|
317
|
+
recordLedger,
|
|
318
|
+
ledgerContext = {}
|
|
319
|
+
}) {
|
|
320
|
+
if (confirm !== true) {
|
|
321
|
+
throw new Error('Apply requires confirm: true.');
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const preview = batch?.entries
|
|
325
|
+
? applyOverridesToBatch(batch, projectOverrides)
|
|
326
|
+
: await previewLogworkBatch({
|
|
327
|
+
text: batch?.text,
|
|
328
|
+
projectOverrides,
|
|
329
|
+
cwd,
|
|
330
|
+
fetchProjects,
|
|
331
|
+
fetchMembershipProjects
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
if (preview.errors?.length) {
|
|
335
|
+
throw new Error(`Cannot apply batch with parse errors: ${preview.errors.map((error) => error.message).join('; ')}`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (preview.unresolvedEntries?.length) {
|
|
339
|
+
throw new Error(`Cannot apply batch with ${preview.unresolvedEntries.length} unresolved entries.`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const unbookedEntries = preview.entries.filter((entry) => entry.requiresAllowUnbooked);
|
|
343
|
+
if (unbookedEntries.length && allowUnbooked !== true) {
|
|
344
|
+
const blocked = unbookedEntries
|
|
345
|
+
.map((entry) => `${entry.id}: +${entry.hours}h ${entry.taskName} -> ${entry.matchedProject?.projectName || 'unknown project'}`)
|
|
346
|
+
.join('; ');
|
|
347
|
+
throw new Error(`Cannot apply ${unbookedEntries.length} unbooked entries without allowUnbooked: true. ${blocked}`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const results = [];
|
|
351
|
+
const taskCache = new Map();
|
|
352
|
+
const entrySubmitter = submitEntry || createLogtimeSubmitter(taskCache);
|
|
353
|
+
const ledgerWriter = recordLedger === undefined
|
|
354
|
+
? (!submitEntry && !verifyLogwork ? recordApplyResult : null)
|
|
355
|
+
: recordLedger;
|
|
356
|
+
for (const entry of preview.entries) {
|
|
357
|
+
try {
|
|
358
|
+
const entryTypeOfWork = preview.typeOfWork || entry.typeOfWork;
|
|
359
|
+
const result = await entrySubmitter({
|
|
360
|
+
projectMemberId: entry.matchedProject.projectMemberId,
|
|
361
|
+
projectId: entry.matchedProject.projectId,
|
|
362
|
+
logtimes: entry.hours,
|
|
363
|
+
taskName: entry.taskName,
|
|
364
|
+
...(entry.description ? { description: entry.description } : {}),
|
|
365
|
+
localDateISO: entry.date,
|
|
366
|
+
worklogTaskId: entry.worklogTaskId,
|
|
367
|
+
worklogNeedsProjectId: entry.worklogNeedsProjectId,
|
|
368
|
+
...(entryTypeOfWork ? { typeOfWork: entryTypeOfWork } : {}),
|
|
369
|
+
taskCache
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
results.push({
|
|
373
|
+
entryId: entry.id,
|
|
374
|
+
date: entry.date,
|
|
375
|
+
hours: entry.hours,
|
|
376
|
+
taskName: entry.taskName,
|
|
377
|
+
description: entry.description || '',
|
|
378
|
+
projectName: entry.matchedProject.projectName,
|
|
379
|
+
status: 'approved',
|
|
380
|
+
dryRun: Boolean(result?.dryRun),
|
|
381
|
+
ok: true,
|
|
382
|
+
result
|
|
383
|
+
});
|
|
384
|
+
} catch (error) {
|
|
385
|
+
results.push({
|
|
386
|
+
entryId: entry.id,
|
|
387
|
+
date: entry.date,
|
|
388
|
+
hours: entry.hours,
|
|
389
|
+
taskName: entry.taskName,
|
|
390
|
+
description: entry.description || '',
|
|
391
|
+
projectName: entry.matchedProject.projectName,
|
|
392
|
+
status: 'failed',
|
|
393
|
+
dryRun: false,
|
|
394
|
+
ok: false,
|
|
395
|
+
error: error.message
|
|
396
|
+
});
|
|
397
|
+
error.applyLedger = await recordRoApplyLedger({
|
|
398
|
+
writer: ledgerWriter,
|
|
399
|
+
preview,
|
|
400
|
+
results,
|
|
401
|
+
status: results.some((result) => result.status === 'approved') ? 'partial_failed' : 'failed',
|
|
402
|
+
cwd,
|
|
403
|
+
ledgerContext
|
|
404
|
+
});
|
|
405
|
+
throw error;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const output = {
|
|
410
|
+
batchId: preview.batchId,
|
|
411
|
+
status: 'submitted',
|
|
412
|
+
dryRun: results.some((result) => result.dryRun),
|
|
413
|
+
results,
|
|
414
|
+
submissionSummary: buildSubmissionSummary(results),
|
|
415
|
+
verification: await buildApplyVerification({
|
|
416
|
+
preview,
|
|
417
|
+
results,
|
|
418
|
+
cwd,
|
|
419
|
+
verifyLogwork
|
|
420
|
+
}),
|
|
421
|
+
summary: formatApplySummary(results)
|
|
422
|
+
};
|
|
423
|
+
output.ledger = await recordRoApplyLedger({
|
|
424
|
+
writer: ledgerWriter,
|
|
425
|
+
preview,
|
|
426
|
+
results,
|
|
427
|
+
status: output.status,
|
|
428
|
+
cwd,
|
|
429
|
+
ledgerContext
|
|
430
|
+
});
|
|
431
|
+
return output;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export function formatToolResponse(payload) {
|
|
435
|
+
return {
|
|
436
|
+
content: [
|
|
437
|
+
{
|
|
438
|
+
type: 'text',
|
|
439
|
+
text: `${payload.summary}\n\n${JSON.stringify(payload, null, 2)}`
|
|
440
|
+
}
|
|
441
|
+
],
|
|
442
|
+
structuredContent: payload
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function createBookedProjectsFetcher() {
|
|
447
|
+
const session = createResourceOptimiserSession();
|
|
448
|
+
return async (localDateISO) => {
|
|
449
|
+
const { token, userId } = await session.get();
|
|
450
|
+
try {
|
|
451
|
+
return await getBookedProjectsForDate(token, userId, localDateISO);
|
|
452
|
+
} catch (error) {
|
|
453
|
+
if (!(error instanceof ApiError) || error.status !== 401) {
|
|
454
|
+
throw error;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const refreshed = await session.get({ forceLogin: true });
|
|
458
|
+
return getBookedProjectsForDate(refreshed.token, refreshed.userId, localDateISO);
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function createMembershipProjectsFetcher() {
|
|
464
|
+
const session = createResourceOptimiserSession();
|
|
465
|
+
return async () => {
|
|
466
|
+
const { token, userId } = await session.get();
|
|
467
|
+
try {
|
|
468
|
+
return await getProjects(token, userId);
|
|
469
|
+
} catch (error) {
|
|
470
|
+
if (!(error instanceof ApiError) || error.status !== 401) {
|
|
471
|
+
throw error;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const refreshed = await session.get({ forceLogin: true });
|
|
475
|
+
return getProjects(refreshed.token, refreshed.userId);
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function createLogtimeSubmitter(taskCache = new Map()) {
|
|
481
|
+
const session = createResourceOptimiserSession();
|
|
482
|
+
return async (entry) => {
|
|
483
|
+
const payload = {
|
|
484
|
+
...entry,
|
|
485
|
+
taskCache: entry.taskCache || taskCache
|
|
486
|
+
};
|
|
487
|
+
const { token } = await session.get();
|
|
488
|
+
try {
|
|
489
|
+
return await addLogtime(token, payload);
|
|
490
|
+
} catch (error) {
|
|
491
|
+
if (!(error instanceof ApiError) || error.status !== 401) {
|
|
492
|
+
throw error;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const refreshed = await session.get({ forceLogin: true });
|
|
496
|
+
return addLogtime(refreshed.token, payload);
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function createWorklogTasksFetcher() {
|
|
502
|
+
const session = createResourceOptimiserSession();
|
|
503
|
+
const cache = new Map();
|
|
504
|
+
return async (projectId, taskCache = cache) => {
|
|
505
|
+
const { token } = await session.get();
|
|
506
|
+
try {
|
|
507
|
+
return await fetchWorklogTasksForProject(token, projectId, { cache: taskCache });
|
|
508
|
+
} catch (error) {
|
|
509
|
+
if (!(error instanceof ApiError) || error.status !== 401) {
|
|
510
|
+
throw error;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const refreshed = await session.get({ forceLogin: true });
|
|
514
|
+
return fetchWorklogTasksForProject(refreshed.token, projectId, { cache: taskCache });
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function applyOverridesToBatch(batch, projectOverrides) {
|
|
520
|
+
if (!projectOverrides || !Object.keys(projectOverrides).length) {
|
|
521
|
+
return batch;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const entries = (batch.entries || []).map((entry) => {
|
|
525
|
+
if (!Object.hasOwn(projectOverrides, entry.id)) {
|
|
526
|
+
return entry;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const candidates = (entry.candidates || []).map((candidate) => ({
|
|
530
|
+
projectMemberId: candidate.projectMemberId,
|
|
531
|
+
projectId: candidate.projectId,
|
|
532
|
+
projectName: candidate.projectName,
|
|
533
|
+
booked: candidate.booked
|
|
534
|
+
}));
|
|
535
|
+
if (entry.matchedProject) {
|
|
536
|
+
candidates.push({
|
|
537
|
+
...entry.matchedProject,
|
|
538
|
+
booked: Boolean(entry.booked)
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const preview = buildLogworkBatchPreview({
|
|
543
|
+
parsed: {
|
|
544
|
+
entries: [entry],
|
|
545
|
+
errors: []
|
|
546
|
+
},
|
|
547
|
+
projectsByDate: new Map([[entry.date, candidates.filter((candidate) => candidate.booked)]]),
|
|
548
|
+
membershipProjects: candidates,
|
|
549
|
+
projectOverrides,
|
|
550
|
+
config: { projectMappings: [] }
|
|
551
|
+
});
|
|
552
|
+
return preview.entries[0];
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
const unresolvedEntries = entries.filter((entry) => entry.status === 'unresolved');
|
|
556
|
+
const unbookedEntries = entries.filter((entry) => entry.status === 'resolved_unbooked');
|
|
557
|
+
const setupSuggestions = buildSetupSuggestions(unresolvedEntries, entries.flatMap((entry) => entry.candidates || []));
|
|
558
|
+
const approvalSummary = buildApprovalSummary(entries, batch.errors || []);
|
|
559
|
+
return {
|
|
560
|
+
...batch,
|
|
561
|
+
status: getBatchStatus(batch.errors || [], unresolvedEntries, unbookedEntries),
|
|
562
|
+
entries,
|
|
563
|
+
unresolvedEntries,
|
|
564
|
+
unbookedEntries,
|
|
565
|
+
setupSuggestions,
|
|
566
|
+
totalsByDate: totalsBy(entries, (entry) => entry.date),
|
|
567
|
+
totalsByProject: totalsBy(entries.filter((entry) => entry.matchedProject), (entry) => entry.matchedProject.projectName),
|
|
568
|
+
approvalSummary,
|
|
569
|
+
summary: formatPreviewSummary(entries, batch.errors || [], setupSuggestions, approvalSummary)
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async function buildApplyVerification({ preview, results, cwd, verifyLogwork }) {
|
|
574
|
+
if (results.some((result) => result.dryRun)) {
|
|
575
|
+
return null;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const dates = [...new Set(preview.entries.map((entry) => entry.date))].sort();
|
|
579
|
+
if (!dates.length) {
|
|
580
|
+
return null;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const from = dates[0];
|
|
584
|
+
const to = addOneDay(dates[dates.length - 1]);
|
|
585
|
+
const verifier = verifyLogwork || ((args) => queryLogwork({
|
|
586
|
+
...args,
|
|
587
|
+
cwd,
|
|
588
|
+
includeEntries: false
|
|
589
|
+
}));
|
|
590
|
+
|
|
591
|
+
return verifier({ from, to, includeEntries: false });
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function addOneDay(localDateISO) {
|
|
595
|
+
const [year, month, day] = localDateISO.split('-').map(Number);
|
|
596
|
+
const date = new Date(year, month - 1, day + 1);
|
|
597
|
+
return [
|
|
598
|
+
String(date.getFullYear()).padStart(4, '0'),
|
|
599
|
+
String(date.getMonth() + 1).padStart(2, '0'),
|
|
600
|
+
String(date.getDate()).padStart(2, '0')
|
|
601
|
+
].join('-');
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function createBatchId(entries, projectOverrides) {
|
|
605
|
+
const source = JSON.stringify({
|
|
606
|
+
entries: entries.map((entry) => [entry.id, entry.date, entry.hours, entry.text ?? entry.taskName]),
|
|
607
|
+
projectOverrides
|
|
608
|
+
});
|
|
609
|
+
return `batch_${hash(source)}`;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function getBatchStatus(errors, unresolvedEntries, unbookedEntries) {
|
|
613
|
+
if (errors.length) {
|
|
614
|
+
return 'invalid';
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (unresolvedEntries.length) {
|
|
618
|
+
return 'unresolved';
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if (unbookedEntries.length) {
|
|
622
|
+
return 'ready_with_unbooked';
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return 'ready';
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function hash(value) {
|
|
629
|
+
let hashValue = 2166136261;
|
|
630
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
631
|
+
hashValue ^= value.charCodeAt(index);
|
|
632
|
+
hashValue = Math.imul(hashValue, 16777619);
|
|
633
|
+
}
|
|
634
|
+
return (hashValue >>> 0).toString(16);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function totalsBy(entries, keyFn) {
|
|
638
|
+
return entries.reduce((totals, entry) => {
|
|
639
|
+
const key = keyFn(entry);
|
|
640
|
+
totals[key] = Number((Number(totals[key] || 0) + Number(entry.hours || 0)).toFixed(2));
|
|
641
|
+
return totals;
|
|
642
|
+
}, {});
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function buildApprovalSummary(entries, errors = []) {
|
|
646
|
+
const unresolvedEntries = entries.filter((entry) => entry.status === 'unresolved');
|
|
647
|
+
const unbookedEntries = entries.filter((entry) => entry.status === 'resolved_unbooked');
|
|
648
|
+
const dates = [...new Set(entries.map((entry) => entry.date).filter(Boolean))].sort();
|
|
649
|
+
const projectMap = new Map();
|
|
650
|
+
|
|
651
|
+
for (const entry of entries) {
|
|
652
|
+
const key = entry.matchedProject?.projectMemberId
|
|
653
|
+
? `member:${entry.matchedProject.projectMemberId}`
|
|
654
|
+
: entry.matchedProject?.projectName
|
|
655
|
+
? `name:${entry.matchedProject.projectName}`
|
|
656
|
+
: 'unresolved';
|
|
657
|
+
const existing = projectMap.get(key) || {
|
|
658
|
+
projectMemberId: entry.matchedProject?.projectMemberId ?? null,
|
|
659
|
+
projectId: entry.matchedProject?.projectId ?? null,
|
|
660
|
+
projectName: entry.matchedProject?.projectName || 'UNRESOLVED',
|
|
661
|
+
hours: 0,
|
|
662
|
+
entries: 0,
|
|
663
|
+
unbookedEntries: 0,
|
|
664
|
+
unresolvedEntries: 0
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
existing.hours = Number((existing.hours + Number(entry.hours || 0)).toFixed(2));
|
|
668
|
+
existing.entries += 1;
|
|
669
|
+
if (entry.status === 'resolved_unbooked') {
|
|
670
|
+
existing.unbookedEntries += 1;
|
|
671
|
+
}
|
|
672
|
+
if (entry.status === 'unresolved') {
|
|
673
|
+
existing.unresolvedEntries += 1;
|
|
674
|
+
}
|
|
675
|
+
projectMap.set(key, existing);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
return {
|
|
679
|
+
entryCount: entries.length,
|
|
680
|
+
totalHours: Number(entries.reduce((total, entry) => total + Number(entry.hours || 0), 0).toFixed(2)),
|
|
681
|
+
dateRange: dates.length ? { from: dates[0], to: dates[dates.length - 1] } : null,
|
|
682
|
+
projectCount: [...projectMap.values()].filter((project) => project.projectName !== 'UNRESOLVED').length,
|
|
683
|
+
projects: [...projectMap.values()],
|
|
684
|
+
unresolvedEntries: unresolvedEntries.length,
|
|
685
|
+
unbookedEntries: unbookedEntries.length,
|
|
686
|
+
requiresUserChoice: unresolvedEntries.length > 0,
|
|
687
|
+
requiresAllowUnbooked: unbookedEntries.length > 0,
|
|
688
|
+
confirmRequired: true,
|
|
689
|
+
canApply: !errors.length && unresolvedEntries.length === 0
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function buildResolutionMetadata(resolution) {
|
|
694
|
+
const autoResolved = resolution.status === 'resolved' || resolution.status === 'resolved_unbooked';
|
|
695
|
+
return {
|
|
696
|
+
status: resolution.status,
|
|
697
|
+
reason: resolution.reason,
|
|
698
|
+
confidence: resolution.confidence,
|
|
699
|
+
booked: Boolean(resolution.booked),
|
|
700
|
+
autoResolved,
|
|
701
|
+
requiresUserChoice: resolution.status === 'unresolved',
|
|
702
|
+
requiresAllowUnbooked: Boolean(resolution.requiresAllowUnbooked),
|
|
703
|
+
candidates: resolution.candidates || []
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function formatPreviewSummary(entries, errors, setupSuggestions = [], approvalSummary = buildApprovalSummary(entries, errors)) {
|
|
708
|
+
if (errors.length) {
|
|
709
|
+
return [
|
|
710
|
+
'Logwork preview has parse errors:',
|
|
711
|
+
...errors.map((error) => `- line ${error.line}: ${error.message}`)
|
|
712
|
+
].join('\n');
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const lines = ['Logwork preview:'];
|
|
716
|
+
for (const entry of entries) {
|
|
717
|
+
const project = entry.matchedProject?.projectName || 'UNRESOLVED';
|
|
718
|
+
const prefix = entry.status === 'resolved_unbooked' ? 'UNBOOKED: ' : '';
|
|
719
|
+
const hint = formatResolutionHint(entry);
|
|
720
|
+
lines.push(`- ${entry.date}: ${prefix}+${entry.hours}h ${project} - ${formatTaskAndDescription(entry)}${hint ? ` (${hint})` : ''}`);
|
|
721
|
+
}
|
|
722
|
+
lines.push(...formatApprovalSummaryLines(approvalSummary));
|
|
723
|
+
const unresolved = entries.filter((entry) => entry.status === 'unresolved').length;
|
|
724
|
+
const unbooked = entries.filter((entry) => entry.status === 'resolved_unbooked').length;
|
|
725
|
+
if (unresolved) {
|
|
726
|
+
lines.push(`Unresolved entries: ${unresolved}`);
|
|
727
|
+
const taskFailures = entries.filter((entry) => entry.reason === 'task_not_found').length;
|
|
728
|
+
if (taskFailures) {
|
|
729
|
+
lines.push('Some entries need a worklog task name that exactly matches Resource Optimiser.');
|
|
730
|
+
}
|
|
731
|
+
const taskRequired = entries.filter((entry) => entry.reason === 'worklog_task_required');
|
|
732
|
+
if (taskRequired.length) {
|
|
733
|
+
const names = taskRequired[0].availableTaskNames || [];
|
|
734
|
+
lines.push('Some entries need a worklog task. Use "+<hours> <worklog task> | <description>" or pass defaultWorklogTask, then preview again.');
|
|
735
|
+
if (names.length) {
|
|
736
|
+
lines.push(`Available worklog tasks: ${names.join(', ')}.`);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
if (setupSuggestions.length) {
|
|
740
|
+
lines.push('Choose a project and call map_project, then preview again.');
|
|
741
|
+
}
|
|
742
|
+
} else if (unbooked) {
|
|
743
|
+
lines.push(`Unbooked entries: ${unbooked}. Apply requires allowUnbooked: true.`);
|
|
744
|
+
} else {
|
|
745
|
+
lines.push('Ready to apply.');
|
|
746
|
+
}
|
|
747
|
+
const missingDescriptions = entries.filter((entry) => entry.status !== 'unresolved' && entry.descriptionMissing).length;
|
|
748
|
+
if (missingDescriptions) {
|
|
749
|
+
lines.push(`Warning: ${missingDescriptions} entr${missingDescriptions === 1 ? 'y has' : 'ies have'} no description. Use "+<hours> <worklog task> | <description>" to add one.`);
|
|
750
|
+
}
|
|
751
|
+
return lines.join('\n');
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function formatTaskAndDescription(entry) {
|
|
755
|
+
const description = entry.description || '';
|
|
756
|
+
return description ? `${entry.taskName} | ${description}` : entry.taskName;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function formatApprovalSummaryLines(approvalSummary) {
|
|
760
|
+
if (!approvalSummary.entryCount) {
|
|
761
|
+
return [];
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const dateText = approvalSummary.dateRange
|
|
765
|
+
? `${approvalSummary.dateRange.from}${approvalSummary.dateRange.to === approvalSummary.dateRange.from ? '' : ` to ${approvalSummary.dateRange.to}`}`
|
|
766
|
+
: 'unknown dates';
|
|
767
|
+
const projects = approvalSummary.projects
|
|
768
|
+
.map((project) => `${project.projectName}: ${formatHours(project.hours)}h`)
|
|
769
|
+
.join('; ');
|
|
770
|
+
|
|
771
|
+
return [
|
|
772
|
+
`Approval checklist: ${approvalSummary.entryCount} entr${approvalSummary.entryCount === 1 ? 'y' : 'ies'}, ${formatHours(approvalSummary.totalHours)}h total, ${dateText}.`,
|
|
773
|
+
`Projects: ${projects || 'none'}.`
|
|
774
|
+
];
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function formatResolutionHint(entry) {
|
|
778
|
+
const reason = entry.reason || entry.resolution?.reason;
|
|
779
|
+
if (!reason) {
|
|
780
|
+
return '';
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const confidence = Number(entry.confidence ?? entry.resolution?.confidence);
|
|
784
|
+
const confidenceText = Number.isFinite(confidence)
|
|
785
|
+
? `, confidence ${formatConfidence(confidence)}`
|
|
786
|
+
: '';
|
|
787
|
+
const prefix = entry.status === 'unresolved' ? 'needs user choice' : 'via';
|
|
788
|
+
return `${prefix} ${reason}${confidenceText}`;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function formatConfidence(confidence) {
|
|
792
|
+
return Number(confidence).toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function formatApplySummary(results) {
|
|
796
|
+
const submissionSummary = buildSubmissionSummary(results);
|
|
797
|
+
return [
|
|
798
|
+
results.some((result) => result.dryRun) ? 'DRY RUN: logwork payloads generated.' : 'Logwork submitted:',
|
|
799
|
+
`Submitted ${submissionSummary.entryCount} entr${submissionSummary.entryCount === 1 ? 'y' : 'ies'}, ${formatHours(submissionSummary.totalHours)}h total.`,
|
|
800
|
+
...results.map((result) => `- ${result.date}: +${result.hours}h ${result.projectName} - ${formatTaskAndDescription(result)}`)
|
|
801
|
+
].join('\n');
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function buildSubmissionSummary(results) {
|
|
805
|
+
return {
|
|
806
|
+
entryCount: results.length,
|
|
807
|
+
totalHours: Number(results.reduce((total, result) => total + Number(result.hours || 0), 0).toFixed(2)),
|
|
808
|
+
dryRun: results.some((result) => result.dryRun),
|
|
809
|
+
dates: [...new Set(results.map((result) => result.date).filter(Boolean))].sort(),
|
|
810
|
+
projects: Object.entries(totalsBy(results, (result) => result.projectName || 'Unknown project'))
|
|
811
|
+
.map(([projectName, hours]) => ({ projectName, hours }))
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function recordRoApplyLedger({ writer, preview, results, status, cwd, ledgerContext }) {
|
|
816
|
+
return recordApplyResultSafely(writer, {
|
|
817
|
+
target: 'ro',
|
|
818
|
+
flowTarget: ledgerContext.flowTarget || 'ro',
|
|
819
|
+
batchId: preview.batchId,
|
|
820
|
+
status,
|
|
821
|
+
cwd,
|
|
822
|
+
entries: results
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function formatHours(hours) {
|
|
827
|
+
return Number(hours).toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function classifyWorklogTaskError(error) {
|
|
831
|
+
const message = String(error?.message || error || 'Task lookup failed.');
|
|
832
|
+
if (error?.code === 'worklog_task_required') {
|
|
833
|
+
return { reason: 'worklog_task_required', message };
|
|
834
|
+
}
|
|
835
|
+
if (
|
|
836
|
+
message.startsWith('No worklog task named')
|
|
837
|
+
|| message.startsWith('Ambiguous worklog task')
|
|
838
|
+
) {
|
|
839
|
+
return { reason: 'task_not_found', message };
|
|
840
|
+
}
|
|
841
|
+
return { reason: 'task_lookup_failed', message };
|
|
842
|
+
}
|