logwork-cli 1.1.0 → 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 +35 -9
- 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 +157 -19
- package/commands/tui.mjs +1 -0
- package/docs/advanced.md +12 -2
- package/docs/mcp-setup.md +57 -15
- package/docs/operations-guide.md +37 -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-parser.mjs +23 -0
- package/lib/batch-workflow.mjs +201 -2
- package/lib/commit-workflow.mjs +278 -30
- 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/keychain.mjs +19 -12
- 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 +63 -21
- package/lib/node-runtime.mjs +115 -0
- package/lib/npm-command.mjs +29 -0
- package/lib/paths.mjs +11 -1
- package/lib/project-mapping-workflow.mjs +30 -7
- package/lib/query-workflow.mjs +0 -1
- package/lib/reconciliation-workflow.mjs +1 -1
- package/lib/release-doctor.mjs +4 -2
- 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 +5 -8
- package/lib/ro-edit-workflow.mjs +66 -8
- package/lib/ro-entry-reader.mjs +49 -6
- package/lib/ro-resubmit-workflow.mjs +35 -21
- package/lib/terminal-auth-prompts.mjs +7 -4
- package/lib/ui.mjs +80 -59
- package/lib/update-service.mjs +5 -2
- package/package.json +11 -4
- package/skills/logwork/SKILL.md +6 -4
package/lib/apply-ledger.mjs
CHANGED
|
@@ -211,7 +211,7 @@ function createRecordId(date, random) {
|
|
|
211
211
|
|
|
212
212
|
function cleanText(value, maxLength) {
|
|
213
213
|
const text = String(value || '')
|
|
214
|
-
.replace(/Bearer\s+[A-Za-z0-9._
|
|
214
|
+
.replace(/Bearer\s+[A-Za-z0-9._~+/-]+/gi, 'Bearer <redacted>')
|
|
215
215
|
.replace(/(token|password|secret|otp)\s*[=:]\s*[^\s,;]+/gi, '$1=<redacted>')
|
|
216
216
|
.trim();
|
|
217
217
|
return text ? text.slice(0, maxLength) : '';
|
package/lib/batch-parser.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
const DAY_HEADING_RE = /^(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday),\s+(\d{2})\s+([A-Za-z]{3})\s+(\d{4})$/;
|
|
2
2
|
const ENTRY_RE = /^\+(\d+(?:\.\d+)?)\s+(.+)$/;
|
|
3
3
|
const TICKET_RE = /\b[A-Z][A-Z0-9]+-\d+\b/g;
|
|
4
|
+
const MAX_DAY_HOURS = 24;
|
|
5
|
+
const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
4
6
|
|
|
5
7
|
const MONTHS = new Map([
|
|
6
8
|
['jan', 1],
|
|
@@ -67,6 +69,18 @@ export function parseWeeklyLogText(text) {
|
|
|
67
69
|
});
|
|
68
70
|
continue;
|
|
69
71
|
}
|
|
72
|
+
// RO rejects more than 24h; catching it here keeps a batch from failing
|
|
73
|
+
// halfway after earlier entries were written.
|
|
74
|
+
const dayTotal = entries.filter((item) => item.date === currentDate).reduce((sum, item) => sum + item.hours, 0) + hours;
|
|
75
|
+
if (hours > MAX_DAY_HOURS || dayTotal > MAX_DAY_HOURS) {
|
|
76
|
+
errors.push({
|
|
77
|
+
line: lineNumber,
|
|
78
|
+
message: hours > MAX_DAY_HOURS
|
|
79
|
+
? `Hours ${entry[1]} exceed ${MAX_DAY_HOURS}h for one entry.`
|
|
80
|
+
: `Entries for ${currentDate} add up to ${dayTotal}h, more than ${MAX_DAY_HOURS}h in one day.`
|
|
81
|
+
});
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
70
84
|
|
|
71
85
|
const text = entry[2].trim();
|
|
72
86
|
const { taskName, description, taskExplicit } = splitTaskAndDescription(text);
|
|
@@ -137,6 +151,15 @@ function parseHeadingDate(match, lineNumber, errors) {
|
|
|
137
151
|
return null;
|
|
138
152
|
}
|
|
139
153
|
|
|
154
|
+
const weekday = WEEKDAYS[date.getUTCDay()];
|
|
155
|
+
if (weekday !== match[1]) {
|
|
156
|
+
errors.push({
|
|
157
|
+
line: lineNumber,
|
|
158
|
+
message: `Date heading says ${match[1]} but ${match[2]} ${match[3]} ${match[4]} is a ${weekday}: ${match[0]}`
|
|
159
|
+
});
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
140
163
|
return [
|
|
141
164
|
String(year).padStart(4, '0'),
|
|
142
165
|
String(month).padStart(2, '0'),
|
package/lib/batch-workflow.mjs
CHANGED
|
@@ -3,8 +3,10 @@ import {
|
|
|
3
3
|
ApiError,
|
|
4
4
|
fetchWorklogTasksForProject,
|
|
5
5
|
getBookedProjectsForDate,
|
|
6
|
+
getLogworkDay,
|
|
6
7
|
getProjects
|
|
7
8
|
} from './api.mjs';
|
|
9
|
+
import { isAuthRequiredError } from './auth-errors.mjs';
|
|
8
10
|
import {
|
|
9
11
|
listAvailableTaskNames,
|
|
10
12
|
listSuggestedTaskNames,
|
|
@@ -24,6 +26,7 @@ export async function previewLogworkBatch({
|
|
|
24
26
|
fetchProjects,
|
|
25
27
|
fetchMembershipProjects,
|
|
26
28
|
fetchWorklogTasks,
|
|
29
|
+
fetchDayEntries,
|
|
27
30
|
typeOfWork,
|
|
28
31
|
defaultWorklogTask
|
|
29
32
|
}) {
|
|
@@ -70,7 +73,155 @@ export async function previewLogworkBatch({
|
|
|
70
73
|
defaultWorklogTask: normalizedDefaultTask
|
|
71
74
|
}
|
|
72
75
|
: enriched;
|
|
73
|
-
|
|
76
|
+
// typeOfWork also changes what gets written, so it is folded into the
|
|
77
|
+
// batchId the same way.
|
|
78
|
+
const typed = typeOfWork
|
|
79
|
+
? { ...output, typeOfWork, batchId: `${output.batchId}_${hash(`typeOfWork:${typeOfWork}`)}`, summary: `${output.summary}\n\nType of work for every entry: ${typeOfWork}` }
|
|
80
|
+
: output;
|
|
81
|
+
return attachDuplicateWarnings(typed, {
|
|
82
|
+
fetchDayEntries: fetchDayEntries || createDayEntriesFetcher()
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Duplicate guard: an entry is a possible duplicate when RO already has an
|
|
87
|
+
// entry on the same date with the same worklog task (id, else name), the same
|
|
88
|
+
// hours and the same description (trimmed, whitespace collapsed, any case), or
|
|
89
|
+
// when an earlier line of this batch is identical. Apply refuses those entries
|
|
90
|
+
// unless allowDuplicates: true. A failed day read (other than auth) skips the
|
|
91
|
+
// check for that date and says so; it never blocks the preview.
|
|
92
|
+
export async function attachDuplicateWarnings(preview, { fetchDayEntries } = {}) {
|
|
93
|
+
const entries = preview.entries || [];
|
|
94
|
+
if (!entries.length || preview.errors?.length) {
|
|
95
|
+
return preview;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const dates = [...new Set(entries.map((entry) => entry.date).filter(Boolean))].sort();
|
|
99
|
+
const existingByDate = new Map();
|
|
100
|
+
const skipped = [];
|
|
101
|
+
for (const date of dates) {
|
|
102
|
+
try {
|
|
103
|
+
existingByDate.set(date, await fetchDayEntries(date));
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (isAuthRequiredError(error) || (error instanceof ApiError && error.status === 401)) {
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
skipped.push({ date, error: String(error?.message || error) });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const seen = [];
|
|
113
|
+
const flagged = entries.map((entry) => {
|
|
114
|
+
const { possibleDuplicate: _previous, ...base } = entry;
|
|
115
|
+
const existing = (existingByDate.get(entry.date) || [])
|
|
116
|
+
.filter((candidate) => !candidate?.raw?.deleted_date && !candidate?.deletedDate)
|
|
117
|
+
.find((candidate) => isSameLogwork(entry, candidate));
|
|
118
|
+
const earlier = seen.find((candidate) => isSameLogwork(entry, candidate));
|
|
119
|
+
seen.push(entry);
|
|
120
|
+
if (existing) {
|
|
121
|
+
return {
|
|
122
|
+
...base,
|
|
123
|
+
possibleDuplicate: {
|
|
124
|
+
source: 'ro',
|
|
125
|
+
logworkId: existing.id ?? null,
|
|
126
|
+
date: existing.date || entry.date,
|
|
127
|
+
hours: Number(existing.hours),
|
|
128
|
+
taskName: existingTaskName(existing),
|
|
129
|
+
worklogTaskId: existing.worklogTaskId ?? null,
|
|
130
|
+
description: existing.description || '',
|
|
131
|
+
status: existing.status || '',
|
|
132
|
+
projectName: existing.projectName || ''
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (earlier) {
|
|
137
|
+
return {
|
|
138
|
+
...base,
|
|
139
|
+
possibleDuplicate: {
|
|
140
|
+
source: 'batch',
|
|
141
|
+
logworkId: null,
|
|
142
|
+
entryId: earlier.id,
|
|
143
|
+
date: earlier.date,
|
|
144
|
+
hours: earlier.hours,
|
|
145
|
+
taskName: earlier.taskName,
|
|
146
|
+
description: earlier.description || ''
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return base;
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const duplicateEntries = flagged.filter((entry) => entry.possibleDuplicate);
|
|
154
|
+
const duplicateCheck = {
|
|
155
|
+
status: skipped.length ? (skipped.length === dates.length ? 'skipped' : 'partial') : 'checked',
|
|
156
|
+
checkedDates: dates.filter((date) => existingByDate.has(date)),
|
|
157
|
+
...(skipped.length ? { skippedDates: skipped } : {})
|
|
158
|
+
};
|
|
159
|
+
const summaryLines = [];
|
|
160
|
+
if (duplicateEntries.length) {
|
|
161
|
+
summaryLines.push(`Possible duplicates: ${duplicateEntries.length}. Apply requires allowDuplicates: true.`);
|
|
162
|
+
summaryLines.push(...duplicateEntries.map(formatDuplicateWarning));
|
|
163
|
+
}
|
|
164
|
+
for (const item of skipped) {
|
|
165
|
+
summaryLines.push(`Note: duplicate check skipped for ${item.date} (could not read RO entries: ${item.error}).`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
...preview,
|
|
170
|
+
entries: flagged,
|
|
171
|
+
unresolvedEntries: flagged.filter((entry) => entry.status === 'unresolved'),
|
|
172
|
+
unbookedEntries: flagged.filter((entry) => entry.status === 'resolved_unbooked'),
|
|
173
|
+
duplicateEntries,
|
|
174
|
+
duplicateCheck,
|
|
175
|
+
approvalSummary: {
|
|
176
|
+
...preview.approvalSummary,
|
|
177
|
+
possibleDuplicates: duplicateEntries.length,
|
|
178
|
+
requiresAllowDuplicates: duplicateEntries.length > 0
|
|
179
|
+
},
|
|
180
|
+
summary: summaryLines.length ? `${preview.summary}\n${summaryLines.join('\n')}` : preview.summary
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function formatDuplicateWarning(entry) {
|
|
185
|
+
const duplicate = entry.possibleDuplicate;
|
|
186
|
+
const line = `${entry.id}: +${entry.hours}h ${formatTaskAndDescription(entry)}`;
|
|
187
|
+
if (duplicate.source === 'batch') {
|
|
188
|
+
return `- WARNING possible duplicate ${line} repeats line ${duplicate.entryId} of this batch.`;
|
|
189
|
+
}
|
|
190
|
+
const status = duplicate.status ? `, ${duplicate.status}` : '';
|
|
191
|
+
const project = duplicate.projectName ? ` on ${duplicate.projectName}` : '';
|
|
192
|
+
return `- WARNING possible duplicate ${line} matches existing RO logwork ${duplicate.logworkId ?? '(no id)'}${project}${status}.`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function isSameLogwork(entry, other) {
|
|
196
|
+
if (!other || (other.date && entry.date && other.date !== entry.date)) {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
if (Math.abs(Number(entry.hours) - Number(other.hours)) > 1e-9) {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
if (normalizeDuplicateText(entry.description) !== normalizeDuplicateText(other.description)) {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
const entryTaskId = entry.worklogTaskId;
|
|
206
|
+
const otherTaskId = other.worklogTaskId;
|
|
207
|
+
if (hasId(entryTaskId) && hasId(otherTaskId)) {
|
|
208
|
+
return String(entryTaskId) === String(otherTaskId);
|
|
209
|
+
}
|
|
210
|
+
const entryTask = normalizeDuplicateText(entry.worklogTaskName || entry.taskName);
|
|
211
|
+
return Boolean(entryTask) && entryTask === normalizeDuplicateText(existingTaskName(other));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function existingTaskName(entry) {
|
|
215
|
+
const raw = entry?.raw || {};
|
|
216
|
+
return entry?.worklogTaskName || raw.worklog_task_name || raw.worklog_task?.name || raw.worklogTask?.name || entry?.taskName || '';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function normalizeDuplicateText(value) {
|
|
220
|
+
return String(value ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function hasId(value) {
|
|
224
|
+
return value !== undefined && value !== null && String(value).trim() !== '';
|
|
74
225
|
}
|
|
75
226
|
|
|
76
227
|
export const LOGWORK_LINE_FORMAT = '+<hours> <worklog task> | <description>';
|
|
@@ -309,9 +460,11 @@ export async function applyLogworkBatch({
|
|
|
309
460
|
confirm,
|
|
310
461
|
projectOverrides = {},
|
|
311
462
|
allowUnbooked = false,
|
|
463
|
+
allowDuplicates = false,
|
|
312
464
|
cwd = process.cwd(),
|
|
313
465
|
fetchProjects,
|
|
314
466
|
fetchMembershipProjects,
|
|
467
|
+
fetchDayEntries,
|
|
315
468
|
submitEntry,
|
|
316
469
|
verifyLogwork,
|
|
317
470
|
recordLedger,
|
|
@@ -328,7 +481,8 @@ export async function applyLogworkBatch({
|
|
|
328
481
|
projectOverrides,
|
|
329
482
|
cwd,
|
|
330
483
|
fetchProjects,
|
|
331
|
-
fetchMembershipProjects
|
|
484
|
+
fetchMembershipProjects,
|
|
485
|
+
fetchDayEntries
|
|
332
486
|
});
|
|
333
487
|
|
|
334
488
|
if (preview.errors?.length) {
|
|
@@ -347,6 +501,11 @@ export async function applyLogworkBatch({
|
|
|
347
501
|
throw new Error(`Cannot apply ${unbookedEntries.length} unbooked entries without allowUnbooked: true. ${blocked}`);
|
|
348
502
|
}
|
|
349
503
|
|
|
504
|
+
const duplicateEntries = preview.entries.filter((entry) => entry.possibleDuplicate);
|
|
505
|
+
if (duplicateEntries.length && allowDuplicates !== true) {
|
|
506
|
+
throw new Error(`Nothing was written. ${formatDuplicateBlock(duplicateEntries)} Apply requires allowDuplicates: true.`);
|
|
507
|
+
}
|
|
508
|
+
|
|
350
509
|
const results = [];
|
|
351
510
|
const taskCache = new Map();
|
|
352
511
|
const entrySubmitter = submitEntry || createLogtimeSubmitter(taskCache);
|
|
@@ -394,6 +553,16 @@ export async function applyLogworkBatch({
|
|
|
394
553
|
ok: false,
|
|
395
554
|
error: error.message
|
|
396
555
|
});
|
|
556
|
+
// Earlier entries are already in RO. Say so plainly: re-applying the same
|
|
557
|
+
// text would log them twice.
|
|
558
|
+
const written = results.filter((result) => result.ok);
|
|
559
|
+
const notTried = preview.entries.slice(results.length).map((item) => item.id);
|
|
560
|
+
error.message = `Entry ${entry.id} (+${entry.hours}h ${entry.taskName}) failed: ${error.message}` +
|
|
561
|
+
(written.length
|
|
562
|
+
? ` Already written to RO: ${written.map((result) => `${result.entryId} (+${result.hours}h ${result.taskName})`).join(', ')}. Do not apply this batch again; preview only the entries that were not written.`
|
|
563
|
+
: ' Nothing was written.') +
|
|
564
|
+
(notTried.length ? ` Not attempted: ${notTried.join(', ')}.` : '');
|
|
565
|
+
error.partialResults = results;
|
|
397
566
|
error.applyLedger = await recordRoApplyLedger({
|
|
398
567
|
writer: ledgerWriter,
|
|
399
568
|
preview,
|
|
@@ -460,6 +629,36 @@ function createBookedProjectsFetcher() {
|
|
|
460
629
|
};
|
|
461
630
|
}
|
|
462
631
|
|
|
632
|
+
export function formatDuplicateBlock(entries) {
|
|
633
|
+
const list = entries.map((entry) => {
|
|
634
|
+
const duplicate = entry.possibleDuplicate || {};
|
|
635
|
+
const target = duplicate.source === 'batch'
|
|
636
|
+
? `line ${duplicate.entryId}`
|
|
637
|
+
: `RO logwork ${duplicate.logworkId ?? '(no id)'}`;
|
|
638
|
+
return `${entry.id} (+${entry.hours}h ${entry.taskName}) matches ${target}`;
|
|
639
|
+
}).join('; ');
|
|
640
|
+
return `${entries.length} entr${entries.length === 1 ? 'y looks' : 'ies look'} already logged: ${list}.`;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Existing RO entries for one date, from GET /logwork/day, with one forced
|
|
644
|
+
// re-login on a 401.
|
|
645
|
+
function createDayEntriesFetcher() {
|
|
646
|
+
const session = createResourceOptimiserSession();
|
|
647
|
+
return async (localDateISO) => {
|
|
648
|
+
const { token } = await session.get();
|
|
649
|
+
try {
|
|
650
|
+
return (await getLogworkDay(token, { date: localDateISO })).entries || [];
|
|
651
|
+
} catch (error) {
|
|
652
|
+
if (!(error instanceof ApiError) || error.status !== 401) {
|
|
653
|
+
throw error;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const refreshed = await session.get({ forceLogin: true });
|
|
657
|
+
return (await getLogworkDay(refreshed.token, { date: localDateISO })).entries || [];
|
|
658
|
+
}
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
|
|
463
662
|
function createMembershipProjectsFetcher() {
|
|
464
663
|
const session = createResourceOptimiserSession();
|
|
465
664
|
return async () => {
|