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
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-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
|
}) {
|
|
@@ -72,9 +75,153 @@ export async function previewLogworkBatch({
|
|
|
72
75
|
: enriched;
|
|
73
76
|
// typeOfWork also changes what gets written, so it is folded into the
|
|
74
77
|
// batchId the same way.
|
|
75
|
-
|
|
78
|
+
const typed = typeOfWork
|
|
76
79
|
? { ...output, typeOfWork, batchId: `${output.batchId}_${hash(`typeOfWork:${typeOfWork}`)}`, summary: `${output.summary}\n\nType of work for every entry: ${typeOfWork}` }
|
|
77
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() !== '';
|
|
78
225
|
}
|
|
79
226
|
|
|
80
227
|
export const LOGWORK_LINE_FORMAT = '+<hours> <worklog task> | <description>';
|
|
@@ -313,9 +460,11 @@ export async function applyLogworkBatch({
|
|
|
313
460
|
confirm,
|
|
314
461
|
projectOverrides = {},
|
|
315
462
|
allowUnbooked = false,
|
|
463
|
+
allowDuplicates = false,
|
|
316
464
|
cwd = process.cwd(),
|
|
317
465
|
fetchProjects,
|
|
318
466
|
fetchMembershipProjects,
|
|
467
|
+
fetchDayEntries,
|
|
319
468
|
submitEntry,
|
|
320
469
|
verifyLogwork,
|
|
321
470
|
recordLedger,
|
|
@@ -332,7 +481,8 @@ export async function applyLogworkBatch({
|
|
|
332
481
|
projectOverrides,
|
|
333
482
|
cwd,
|
|
334
483
|
fetchProjects,
|
|
335
|
-
fetchMembershipProjects
|
|
484
|
+
fetchMembershipProjects,
|
|
485
|
+
fetchDayEntries
|
|
336
486
|
});
|
|
337
487
|
|
|
338
488
|
if (preview.errors?.length) {
|
|
@@ -351,6 +501,11 @@ export async function applyLogworkBatch({
|
|
|
351
501
|
throw new Error(`Cannot apply ${unbookedEntries.length} unbooked entries without allowUnbooked: true. ${blocked}`);
|
|
352
502
|
}
|
|
353
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
|
+
|
|
354
509
|
const results = [];
|
|
355
510
|
const taskCache = new Map();
|
|
356
511
|
const entrySubmitter = submitEntry || createLogtimeSubmitter(taskCache);
|
|
@@ -474,6 +629,36 @@ function createBookedProjectsFetcher() {
|
|
|
474
629
|
};
|
|
475
630
|
}
|
|
476
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
|
+
|
|
477
662
|
function createMembershipProjectsFetcher() {
|
|
478
663
|
const session = createResourceOptimiserSession();
|
|
479
664
|
return async () => {
|
package/lib/commit-workflow.mjs
CHANGED
|
@@ -3,6 +3,9 @@ import { homedir } from 'node:os';
|
|
|
3
3
|
import { resolve } from 'node:path';
|
|
4
4
|
import { isAuthRequiredError } from './auth-errors.mjs';
|
|
5
5
|
import { CONFIG } from './config.mjs';
|
|
6
|
+
import { findRepoMapping, loadLocalConfig } from './logwork-config.mjs';
|
|
7
|
+
import { projectIdentityKey, sameProjectIdentity } from './project-identity.mjs';
|
|
8
|
+
import { resolveEntryProject } from './project-resolver.mjs';
|
|
6
9
|
import { normalizeQueryRange, queryLogwork } from './query-workflow.mjs';
|
|
7
10
|
import { addLocalDays, todayLocalDateISO } from './util.mjs';
|
|
8
11
|
|
|
@@ -10,8 +13,11 @@ import { addLocalDays, todayLocalDateISO } from './util.mjs';
|
|
|
10
13
|
// commits (all branches, this author, in the period)
|
|
11
14
|
// -> grouped per day by repo + ticket + kind of work
|
|
12
15
|
// -> each group gets a default RO worklog task and a description
|
|
13
|
-
// ->
|
|
14
|
-
//
|
|
16
|
+
// -> each group gets an RO project: a mapping whose repos list holds the
|
|
17
|
+
// repo, else the project resolver (tickets, keywords, booked projects)
|
|
18
|
+
// -> each project's remaining hours (RO booked - logged) are split across its
|
|
19
|
+
// groups by how much work each group holds; unresolved groups share the
|
|
20
|
+
// room of booked projects no group claimed (without RO: a standard day)
|
|
15
21
|
// -> a "+<hours> <worklog task> | <description>" block for log_work.
|
|
16
22
|
// Nothing is written anywhere; the agent shows the draft, the user edits and
|
|
17
23
|
// approves it, then log_work previews and applies it as usual.
|
|
@@ -115,8 +121,16 @@ export function parseGitLog(output = '') {
|
|
|
115
121
|
return commits;
|
|
116
122
|
}
|
|
117
123
|
|
|
124
|
+
export async function resolveGitRepoRoot(path, runGit = runGitCommand) {
|
|
125
|
+
const root = String(await runGit(path, ['rev-parse', '--show-toplevel'])).trim();
|
|
126
|
+
if (!root) {
|
|
127
|
+
throw new Error('git did not report a repository root');
|
|
128
|
+
}
|
|
129
|
+
return resolve(root);
|
|
130
|
+
}
|
|
131
|
+
|
|
118
132
|
export async function readRepoCommits({ repo, from, to, author, runGit = runGitCommand }) {
|
|
119
|
-
const root =
|
|
133
|
+
const root = await resolveGitRepoRoot(repo, runGit);
|
|
120
134
|
const authorFilter = (author || (await runGit(root, ['config', 'user.email']).catch(() => ''))).trim();
|
|
121
135
|
if (!authorFilter) {
|
|
122
136
|
throw new Error(`No git author for ${root}. Set git config user.email or pass author.`);
|
|
@@ -285,31 +299,111 @@ function isWeekend(localDateISO) {
|
|
|
285
299
|
}
|
|
286
300
|
|
|
287
301
|
// Remaining RO time per day: sum over projects of max(0, booked - logged), so
|
|
288
|
-
// over-logging one project does not hide a gap on another.
|
|
302
|
+
// over-logging one project does not hide a gap on another. projects keeps every
|
|
303
|
+
// booked project with its ids so commit groups can be allocated per project.
|
|
289
304
|
export function remainingHoursByDay(roResult) {
|
|
290
305
|
const byDay = new Map();
|
|
291
306
|
for (const day of roResult?.days || []) {
|
|
292
|
-
const projects = day.projects || []
|
|
307
|
+
const projects = (day.projects || []).map((project) => ({
|
|
308
|
+
...projectRef(project),
|
|
309
|
+
bookedHours: Number(project.bookedHours || 0),
|
|
310
|
+
loggedHours: Number(project.loggedHours || 0),
|
|
311
|
+
remainingHours: roundHours(Math.max(0, Number(project.bookedHours || 0) - Number(project.loggedHours || 0)))
|
|
312
|
+
}));
|
|
293
313
|
const remaining = projects.length
|
|
294
|
-
? projects.reduce((sum, project) => sum +
|
|
314
|
+
? projects.reduce((sum, project) => sum + project.remainingHours, 0)
|
|
295
315
|
: Math.max(0, Number(day.bookedHours || 0) - Number(day.loggedHours || 0));
|
|
296
316
|
byDay.set(day.date, {
|
|
297
317
|
bookedHours: Number(day.bookedHours || 0),
|
|
298
318
|
loggedHours: Number(day.loggedHours || 0),
|
|
299
|
-
remainingHours:
|
|
300
|
-
|
|
301
|
-
// the agent move lines to the projects that still have room.
|
|
319
|
+
remainingHours: roundHours(remaining),
|
|
320
|
+
projects,
|
|
302
321
|
remainingByProject: projects
|
|
303
|
-
.map((project) => ({
|
|
304
|
-
projectName: project.projectName,
|
|
305
|
-
remainingHours: Number(Math.max(0, Number(project.bookedHours || 0) - Number(project.loggedHours || 0)).toFixed(2))
|
|
306
|
-
}))
|
|
307
322
|
.filter((project) => project.remainingHours > 0)
|
|
323
|
+
.map((project) => ({ ...projectRef(project), remainingHours: project.remainingHours }))
|
|
308
324
|
});
|
|
309
325
|
}
|
|
310
326
|
return byDay;
|
|
311
327
|
}
|
|
312
328
|
|
|
329
|
+
// The project of one commit group: a mapping whose repos list holds the repo,
|
|
330
|
+
// else the shared resolver on the description and tickets against the day's
|
|
331
|
+
// booked projects and the ticket/keyword mappings, else null.
|
|
332
|
+
export function resolveGroupProject(group, { bookedProjects = [], config = {}, platform = process.platform } = {}) {
|
|
333
|
+
const mapping = findRepoMapping(config.projectMappings, group.repo, platform);
|
|
334
|
+
if (mapping) {
|
|
335
|
+
const booked = bookedProjects.find((project) => sameProjectIdentity(project, mapping));
|
|
336
|
+
return { ...projectRef(booked || mapping), source: 'repo_mapping' };
|
|
337
|
+
}
|
|
338
|
+
if (!bookedProjects.length) {
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
const resolution = resolveEntryProject({
|
|
342
|
+
entry: { id: group.repo, text: group.description, taskName: group.description, tickets: group.tickets },
|
|
343
|
+
projects: bookedProjects,
|
|
344
|
+
config
|
|
345
|
+
});
|
|
346
|
+
if (resolution.status !== 'resolved') {
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
return { ...projectRef(resolution.project), source: 'resolver', reason: resolution.reason };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Splits one day's RO room per project:
|
|
353
|
+
// - groups resolved to a project share that project's remaining hours;
|
|
354
|
+
// - unresolved groups share the remaining hours of booked projects that got
|
|
355
|
+
// no resolved group;
|
|
356
|
+
// - nothing ever exceeds a project's remaining hours.
|
|
357
|
+
export function allocateDayByProject(groups, projects) {
|
|
358
|
+
const hours = new Array(groups.length).fill(0);
|
|
359
|
+
const byProject = new Map();
|
|
360
|
+
const unresolved = [];
|
|
361
|
+
groups.forEach((group, index) => {
|
|
362
|
+
if (!group.project) {
|
|
363
|
+
unresolved.push(index);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const key = projectIdentityKey(group.project);
|
|
367
|
+
const entry = byProject.get(key) || { project: group.project, indexes: [] };
|
|
368
|
+
entry.indexes.push(index);
|
|
369
|
+
byProject.set(key, entry);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
const claimed = new Set();
|
|
373
|
+
const allocation = [];
|
|
374
|
+
for (const { project, indexes } of byProject.values()) {
|
|
375
|
+
const booked = projects.find((candidate) => sameProjectIdentity(candidate, project));
|
|
376
|
+
if (booked) {
|
|
377
|
+
claimed.add(booked);
|
|
378
|
+
}
|
|
379
|
+
const remainingHours = booked?.remainingHours ?? 0;
|
|
380
|
+
const split = allocateHours(indexes.map((index) => groups[index]), remainingHours);
|
|
381
|
+
indexes.forEach((index, position) => { hours[index] = split[position]; });
|
|
382
|
+
allocation.push({
|
|
383
|
+
...projectRef(project),
|
|
384
|
+
booked: Boolean(booked),
|
|
385
|
+
remainingHours,
|
|
386
|
+
allocatedHours: roundHours(split.reduce((sum, value) => sum + value, 0)),
|
|
387
|
+
groupCount: indexes.length
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const unclaimedHours = roundHours(projects
|
|
392
|
+
.filter((project) => !claimed.has(project))
|
|
393
|
+
.reduce((sum, project) => sum + project.remainingHours, 0));
|
|
394
|
+
if (unresolved.length) {
|
|
395
|
+
const split = allocateHours(unresolved.map((index) => groups[index]), unclaimedHours);
|
|
396
|
+
unresolved.forEach((index, position) => { hours[index] = split[position]; });
|
|
397
|
+
}
|
|
398
|
+
return {
|
|
399
|
+
hours,
|
|
400
|
+
allocation,
|
|
401
|
+
unresolved: unresolved.length
|
|
402
|
+
? { groupCount: unresolved.length, roomHours: unclaimedHours, allocatedHours: roundHours(unresolved.reduce((sum, index) => sum + hours[index], 0)) }
|
|
403
|
+
: null
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
313
407
|
export async function draftLogworkFromCommits({
|
|
314
408
|
period = 'today',
|
|
315
409
|
from,
|
|
@@ -319,7 +413,9 @@ export async function draftLogworkFromCommits({
|
|
|
319
413
|
useBookings = true,
|
|
320
414
|
cwd = process.cwd(),
|
|
321
415
|
runGit = runGitCommand,
|
|
322
|
-
queryRo = queryLogwork
|
|
416
|
+
queryRo = queryLogwork,
|
|
417
|
+
loadConfig = loadLocalConfig,
|
|
418
|
+
platform = process.platform
|
|
323
419
|
} = {}) {
|
|
324
420
|
const range = from || to ? normalizeQueryRange({ from, to }) : normalizeQueryRange({ period });
|
|
325
421
|
const repoPaths = [...new Set((repos?.length ? repos : [cwd]).map((repo) => resolve(cwd, expandHome(String(repo)))))];
|
|
@@ -360,14 +456,20 @@ export async function draftLogworkFromCommits({
|
|
|
360
456
|
}
|
|
361
457
|
}
|
|
362
458
|
|
|
459
|
+
const config = commits.length ? await loadConfig(cwd) : { projectMappings: [] };
|
|
460
|
+
|
|
363
461
|
const days = [];
|
|
364
462
|
for (let date = range.from; date < range.to; date = addLocalDays(date, 1)) {
|
|
365
463
|
const dayCommits = commits.filter((commit) => commit.date === date);
|
|
366
464
|
if (!dayCommits.length) {
|
|
367
465
|
continue;
|
|
368
466
|
}
|
|
369
|
-
const groups = groupCommits(dayCommits);
|
|
370
467
|
const booking = bookings?.get(date);
|
|
468
|
+
const bookedProjects = (booking?.projects || []).filter((project) => project.bookedHours > 0);
|
|
469
|
+
const groups = groupCommits(dayCommits).map((group) => ({
|
|
470
|
+
...group,
|
|
471
|
+
project: resolveGroupProject(group, { bookedProjects, config, platform })
|
|
472
|
+
}));
|
|
371
473
|
let targetHours;
|
|
372
474
|
let hoursSource;
|
|
373
475
|
if (booking) {
|
|
@@ -381,12 +483,19 @@ export async function draftLogworkFromCommits({
|
|
|
381
483
|
targetHours = isWeekend(date) ? 0 : (CONFIG.standardDayHours || 8);
|
|
382
484
|
hoursSource = isWeekend(date) ? 'weekend' : 'standard_day';
|
|
383
485
|
}
|
|
384
|
-
|
|
486
|
+
// Per-project split only when RO returned identifiable per-project rows
|
|
487
|
+
// for the day; otherwise (RO unavailable, no booking, day-level totals or
|
|
488
|
+
// nameless rows) the day's target is split across all groups as before.
|
|
489
|
+
const perProject = Boolean(booking?.projects?.length) &&
|
|
490
|
+
booking.projects.every((project) => projectIdentityKey(project) !== '');
|
|
491
|
+
const split = perProject ? allocateDayByProject(groups, booking.projects) : null;
|
|
492
|
+
const hours = split ? split.hours : allocateHours(groups, targetHours);
|
|
385
493
|
days.push({
|
|
386
494
|
date,
|
|
387
495
|
targetHours,
|
|
388
496
|
hoursSource,
|
|
389
497
|
...(booking ? { bookedHours: booking.bookedHours, loggedHours: booking.loggedHours, remainingByProject: booking.remainingByProject } : {}),
|
|
498
|
+
...(split ? { projectAllocation: split.allocation, unresolvedAllocation: split.unresolved } : {}),
|
|
390
499
|
groups: groups.map((group, index) => ({
|
|
391
500
|
hours: hours[index],
|
|
392
501
|
task: group.task,
|
|
@@ -394,6 +503,8 @@ export async function draftLogworkFromCommits({
|
|
|
394
503
|
description: group.description,
|
|
395
504
|
tickets: group.tickets,
|
|
396
505
|
repo: group.repoName,
|
|
506
|
+
repoPath: group.repo,
|
|
507
|
+
project: group.project,
|
|
397
508
|
commitCount: group.commitCount,
|
|
398
509
|
linesChanged: group.linesChanged,
|
|
399
510
|
commits: group.hashes
|
|
@@ -401,15 +512,32 @@ export async function draftLogworkFromCommits({
|
|
|
401
512
|
});
|
|
402
513
|
}
|
|
403
514
|
|
|
515
|
+
// log_work preview entry ids are "<date>-<NN>", NN counting entry lines
|
|
516
|
+
// through the whole text (lib/batch-parser.mjs), so they are numbered here in
|
|
517
|
+
// the same order the lines are written.
|
|
518
|
+
const projectOverrides = {};
|
|
519
|
+
let lineIndex = 0;
|
|
404
520
|
const draftText = days
|
|
405
521
|
.map((day) => {
|
|
406
|
-
const lines =
|
|
407
|
-
|
|
522
|
+
const lines = [];
|
|
523
|
+
for (const group of day.groups.filter((item) => item.hours > 0)) {
|
|
524
|
+
lineIndex += 1;
|
|
525
|
+
const entryId = `${day.date}-${String(lineIndex).padStart(2, '0')}`;
|
|
526
|
+
group.entryId = entryId;
|
|
527
|
+
if (hasValue(group.project?.projectMemberId)) {
|
|
528
|
+
projectOverrides[entryId] = group.project.projectMemberId;
|
|
529
|
+
}
|
|
530
|
+
lines.push(`+${group.hours} ${group.task} | ${group.description}`);
|
|
531
|
+
}
|
|
408
532
|
return lines.length ? [formatDayHeading(day.date), ...lines].join('\n') : '';
|
|
409
533
|
})
|
|
410
534
|
.filter(Boolean)
|
|
411
535
|
.join('\n\n');
|
|
412
536
|
|
|
537
|
+
const unmappedRepos = [...new Set(days.flatMap((day) => day.groups)
|
|
538
|
+
.filter((group) => !group.project)
|
|
539
|
+
.map((group) => group.repoPath))];
|
|
540
|
+
|
|
413
541
|
const result = {
|
|
414
542
|
status: !commits.length ? 'no_commits' : (draftText ? 'draft' : 'no_hours'),
|
|
415
543
|
range,
|
|
@@ -418,7 +546,9 @@ export async function draftLogworkFromCommits({
|
|
|
418
546
|
...(bookingsError ? { bookingsError } : {}),
|
|
419
547
|
commitCount: commits.length,
|
|
420
548
|
days,
|
|
421
|
-
draftText
|
|
549
|
+
draftText,
|
|
550
|
+
projectOverrides,
|
|
551
|
+
unmappedRepos
|
|
422
552
|
};
|
|
423
553
|
result.summary = formatCommitDraftSummary(result);
|
|
424
554
|
return result;
|
|
@@ -446,18 +576,55 @@ export function formatCommitDraftSummary(result) {
|
|
|
446
576
|
}[day.hoursSource];
|
|
447
577
|
lines.push('', `${formatDayHeading(day.date)} - ${source}`);
|
|
448
578
|
if (day.remainingByProject?.length) {
|
|
449
|
-
lines.push(` Room by project: ${day.remainingByProject.map((project) => `${project
|
|
579
|
+
lines.push(` Room by project: ${day.remainingByProject.map((project) => `${projectLabel(project)} ${project.remainingHours}h`).join(', ')}`);
|
|
580
|
+
}
|
|
581
|
+
for (const project of day.projectAllocation || []) {
|
|
582
|
+
if (project.allocatedHours === 0) {
|
|
583
|
+
lines.push(` No room on ${projectLabel(project)}${project.booked ? ` (remaining ${project.remainingHours}h)` : ' (not booked this day)'}: its ${project.groupCount} line(s) get 0h.`);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
if (day.unresolvedAllocation) {
|
|
587
|
+
lines.push(` ${day.unresolvedAllocation.groupCount} line(s) without a project share ${day.unresolvedAllocation.roomHours}h left on booked projects no other line claimed.`);
|
|
450
588
|
}
|
|
451
589
|
for (const group of day.groups) {
|
|
452
|
-
|
|
590
|
+
const project = group.project
|
|
591
|
+
? `${projectLabel(group.project)} (${group.project.source === 'repo_mapping' ? 'repo mapping' : group.project.reason || group.project.source})`
|
|
592
|
+
: 'project unresolved';
|
|
593
|
+
lines.push(` ${group.entryId ? `${group.entryId} ` : ''}+${group.hours} ${group.task} | ${group.description} [${group.repo}, ${group.commitCount} commit(s)] -> ${project}`);
|
|
453
594
|
}
|
|
454
595
|
}
|
|
455
596
|
if (result.draftText) {
|
|
456
597
|
lines.push('', 'Draft for log_work (review with the user first; worklog task names must exist in list_tasks for the project):', '', result.draftText);
|
|
598
|
+
if (Object.keys(result.projectOverrides || {}).length) {
|
|
599
|
+
lines.push('', `Pass projectOverrides to log_work with this draftText unchanged (renumbered or edited lines need new ids): ${JSON.stringify(result.projectOverrides)}`);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
if (result.unmappedRepos?.length) {
|
|
603
|
+
lines.push('', `No project for some lines from: ${result.unmappedRepos.join(', ')}. Ask the user which RO project each repo belongs to and offer map_project with repos so later drafts know it.`);
|
|
457
604
|
}
|
|
458
605
|
return lines.join('\n');
|
|
459
606
|
}
|
|
460
607
|
|
|
608
|
+
function projectRef(project = {}) {
|
|
609
|
+
return {
|
|
610
|
+
projectName: project.projectName || '',
|
|
611
|
+
...(hasValue(project.projectMemberId) ? { projectMemberId: project.projectMemberId } : {}),
|
|
612
|
+
...(hasValue(project.projectId) ? { projectId: project.projectId } : {})
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function projectLabel(project = {}) {
|
|
617
|
+
return project.projectName || (hasValue(project.projectMemberId) ? `project member ${project.projectMemberId}` : 'unnamed project');
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function hasValue(value) {
|
|
621
|
+
return value !== undefined && value !== null && String(value).trim() !== '';
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function roundHours(value) {
|
|
625
|
+
return Number(Number(value || 0).toFixed(2));
|
|
626
|
+
}
|
|
627
|
+
|
|
461
628
|
function expandHome(path) {
|
|
462
629
|
return path === '~' || /^~[\\/]/.test(path) ? `${homedir()}${path.slice(1)}` : path;
|
|
463
630
|
}
|
package/lib/config.mjs
CHANGED
|
@@ -45,6 +45,13 @@ const BASE_CONFIG = {
|
|
|
45
45
|
|
|
46
46
|
export const CONFIG = buildConfig(process.env);
|
|
47
47
|
|
|
48
|
+
// LOGWORK_DRY_RUN=1 turns every RO and Jira write into a returned request
|
|
49
|
+
// ({ dryRun: true, method, path, body }). Read at call time so tests and the
|
|
50
|
+
// MCP server can toggle it without reloading CONFIG. Reads still hit the API.
|
|
51
|
+
export function isDryRun(env = process.env) {
|
|
52
|
+
return env.LOGWORK_DRY_RUN === '1';
|
|
53
|
+
}
|
|
54
|
+
|
|
48
55
|
export function buildConfig(env = {}) {
|
|
49
56
|
const profile = readStringEnv(env, 'LOGWORK_PROFILE') || readStringEnv(env, 'LOGWORK_HELPER_PROFILE') || DEFAULT_PROFILE;
|
|
50
57
|
const config = {
|