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/commit-workflow.mjs
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
2
3
|
import { resolve } from 'node:path';
|
|
4
|
+
import { isAuthRequiredError } from './auth-errors.mjs';
|
|
3
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';
|
|
4
9
|
import { normalizeQueryRange, queryLogwork } from './query-workflow.mjs';
|
|
5
10
|
import { addLocalDays, todayLocalDateISO } from './util.mjs';
|
|
6
11
|
|
|
@@ -8,13 +13,20 @@ import { addLocalDays, todayLocalDateISO } from './util.mjs';
|
|
|
8
13
|
// commits (all branches, this author, in the period)
|
|
9
14
|
// -> grouped per day by repo + ticket + kind of work
|
|
10
15
|
// -> each group gets a default RO worklog task and a description
|
|
11
|
-
// ->
|
|
12
|
-
//
|
|
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)
|
|
13
21
|
// -> a "+<hours> <worklog task> | <description>" block for log_work.
|
|
14
22
|
// Nothing is written anywhere; the agent shows the draft, the user edits and
|
|
15
23
|
// approves it, then log_work previews and applies it as usual.
|
|
16
24
|
|
|
17
25
|
const TICKET_RE = /\b[A-Z][A-Z0-9]+-\d+\b/g;
|
|
26
|
+
// Prefixes that look like ticket keys but are standards or versions
|
|
27
|
+
// ("UTF-8", "ISO-8601", "SHA-256", "CVE-2024-1234").
|
|
28
|
+
const NOT_TICKET_PREFIXES = new Set(['UTF', 'ISO', 'SHA', 'RFC', 'HTTP', 'HTTPS', 'ES', 'ECMA', 'TLS', 'SSL', 'MD', 'AES', 'RSA', 'UTC', 'GMT', 'CVE', 'CWE', 'IPV', 'WCAG', 'PEP', 'JSR', 'ISBN', 'COVID', 'MP', 'H', 'X', 'IE', 'WIN', 'NODE', 'PHP', 'IOS']);
|
|
29
|
+
const MAX_TICKETS_IN_DESCRIPTION = 4;
|
|
18
30
|
const CONVENTIONAL_RE = /^(\w+)(?:\([^)]*\))?!?:\s*/;
|
|
19
31
|
const FIELD = '\x1f';
|
|
20
32
|
const RECORD = '\x1e';
|
|
@@ -109,38 +121,87 @@ export function parseGitLog(output = '') {
|
|
|
109
121
|
return commits;
|
|
110
122
|
}
|
|
111
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
|
+
|
|
112
132
|
export async function readRepoCommits({ repo, from, to, author, runGit = runGitCommand }) {
|
|
113
|
-
const root =
|
|
114
|
-
const authorFilter = author || (await runGit(root, ['config', 'user.email']).catch(() => '')).trim();
|
|
133
|
+
const root = await resolveGitRepoRoot(repo, runGit);
|
|
134
|
+
const authorFilter = (author || (await runGit(root, ['config', 'user.email']).catch(() => ''))).trim();
|
|
115
135
|
if (!authorFilter) {
|
|
116
136
|
throw new Error(`No git author for ${root}. Set git config user.email or pass author.`);
|
|
117
137
|
}
|
|
118
|
-
|
|
138
|
+
// The period is matched on the author date in JS below. Git's --since and
|
|
139
|
+
// --until use the committer date (so rebased or amended commits would fall
|
|
140
|
+
// out of their real day) and --since stops the walk at the first older
|
|
141
|
+
// commit (clock skew). --since-as-filter (git 2.37+) only filters, and a day
|
|
142
|
+
// of slack covers time zones; older git falls back to a 31-day window.
|
|
143
|
+
const sinceDate = addLocalDays(from, -1);
|
|
144
|
+
const baseArgs = [
|
|
119
145
|
'log',
|
|
120
|
-
// Every local and remote branch (work often sits on feature branches)
|
|
121
|
-
// not refs/stash, whose "WIP on"/
|
|
146
|
+
// Every local and remote branch (work often sits on feature branches) and
|
|
147
|
+
// HEAD (a detached HEAD mid-rebase), but not refs/stash, whose "WIP on" /
|
|
148
|
+
// "index on" commits are not work items.
|
|
122
149
|
'--branches',
|
|
123
150
|
'--remotes',
|
|
151
|
+
'HEAD',
|
|
124
152
|
'--no-merges',
|
|
125
|
-
|
|
126
|
-
|
|
153
|
+
// Narrow by author in git (fixed string, so "." or "+" in an email is not
|
|
154
|
+
// a regex); the exact match happens in JS.
|
|
127
155
|
`--author=${authorFilter}`,
|
|
156
|
+
'--fixed-strings',
|
|
128
157
|
'--regexp-ignore-case',
|
|
129
158
|
`--format=${RECORD}%H${FIELD}%at${FIELD}%an${FIELD}%ae${FIELD}%s${FIELD}%b${FIELD}`,
|
|
130
159
|
'--numstat'
|
|
131
|
-
]
|
|
160
|
+
];
|
|
161
|
+
let output;
|
|
162
|
+
try {
|
|
163
|
+
output = await runGit(root, [...baseArgs, `--since-as-filter=${sinceDate}T00:00:00`]);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (!/since-as-filter|unrecognized argument|unknown option/i.test(error.message)) {
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
output = await runGit(root, [...baseArgs, `--since=${addLocalDays(from, -31)}T00:00:00`]);
|
|
169
|
+
}
|
|
132
170
|
const seen = new Set();
|
|
133
171
|
const commits = parseGitLog(output)
|
|
172
|
+
.filter((commit) => matchesAuthor(commit, authorFilter))
|
|
134
173
|
.filter((commit) => !seen.has(commit.hash) && seen.add(commit.hash))
|
|
135
174
|
.map((commit) => ({ ...commit, repo: root, date: todayLocalDateISO(new Date(commit.timestamp * 1000)) }))
|
|
136
175
|
.filter((commit) => commit.date >= from && commit.date < to);
|
|
137
176
|
return { root, author: authorFilter, commits };
|
|
138
177
|
}
|
|
139
178
|
|
|
179
|
+
// Exact author match: an email filter must equal the author email, a name
|
|
180
|
+
// filter must equal the author name (both case-insensitive). git --author is
|
|
181
|
+
// a substring match, so "me@x.com" alone would also match "jime@x.com".
|
|
182
|
+
export function matchesAuthor(commit, authorFilter) {
|
|
183
|
+
const filter = String(authorFilter || '').trim().toLowerCase();
|
|
184
|
+
if (!filter) {
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
const email = String(commit.authorEmail || '').toLowerCase();
|
|
188
|
+
const name = String(commit.authorName || '').toLowerCase();
|
|
189
|
+
const bracketed = filter.match(/<([^>]+)>/);
|
|
190
|
+
if (bracketed) {
|
|
191
|
+
return email === bracketed[1].trim();
|
|
192
|
+
}
|
|
193
|
+
return filter.includes('@') ? email === filter : name === filter;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function extractTickets(text = '') {
|
|
197
|
+
return [...new Set((String(text).match(TICKET_RE) || [])
|
|
198
|
+
.filter((key) => !NOT_TICKET_PREFIXES.has(key.slice(0, key.lastIndexOf('-')))))];
|
|
199
|
+
}
|
|
200
|
+
|
|
140
201
|
export function groupCommits(commits = []) {
|
|
141
202
|
const groups = new Map();
|
|
142
203
|
for (const commit of [...commits].sort((a, b) => a.timestamp - b.timestamp)) {
|
|
143
|
-
const tickets =
|
|
204
|
+
const tickets = extractTickets(`${commit.subject}\n${commit.body}`);
|
|
144
205
|
const kind = classifyCommit(commit.subject, commit.body);
|
|
145
206
|
const key = [commit.repo, tickets[0] || '', kind].join('|');
|
|
146
207
|
const group = groups.get(key) || {
|
|
@@ -172,9 +233,14 @@ export function groupCommits(commits = []) {
|
|
|
172
233
|
}
|
|
173
234
|
|
|
174
235
|
export function buildDescription(group) {
|
|
175
|
-
const
|
|
176
|
-
const
|
|
177
|
-
const
|
|
236
|
+
const shown = group.tickets.slice(0, MAX_TICKETS_IN_DESCRIPTION);
|
|
237
|
+
const more = group.tickets.length - shown.length;
|
|
238
|
+
const ticketSuffix = shown.length ? ` (${shown.join(', ')}${more > 0 ? ` +${more} more` : ''})` : '';
|
|
239
|
+
const room = Math.max(40, MAX_DESCRIPTION_LENGTH - ticketSuffix.length);
|
|
240
|
+
const ticketSet = new Set(group.tickets);
|
|
241
|
+
const subjects = group.subjects
|
|
242
|
+
.map((subject) => subject.replace(TICKET_RE, (key) => (ticketSet.has(key) ? '' : key)).replace(/\s*[[(]\s*[)\]]\s*/g, ' ').replace(/\s+/g, ' ').trim())
|
|
243
|
+
.filter(Boolean);
|
|
178
244
|
let text = '';
|
|
179
245
|
for (const subject of subjects) {
|
|
180
246
|
const next = text ? `${text}; ${subject}` : subject;
|
|
@@ -233,23 +299,111 @@ function isWeekend(localDateISO) {
|
|
|
233
299
|
}
|
|
234
300
|
|
|
235
301
|
// Remaining RO time per day: sum over projects of max(0, booked - logged), so
|
|
236
|
-
// 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.
|
|
237
304
|
export function remainingHoursByDay(roResult) {
|
|
238
305
|
const byDay = new Map();
|
|
239
306
|
for (const day of roResult?.days || []) {
|
|
240
|
-
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
|
+
}));
|
|
241
313
|
const remaining = projects.length
|
|
242
|
-
? projects.reduce((sum, project) => sum +
|
|
314
|
+
? projects.reduce((sum, project) => sum + project.remainingHours, 0)
|
|
243
315
|
: Math.max(0, Number(day.bookedHours || 0) - Number(day.loggedHours || 0));
|
|
244
316
|
byDay.set(day.date, {
|
|
245
317
|
bookedHours: Number(day.bookedHours || 0),
|
|
246
318
|
loggedHours: Number(day.loggedHours || 0),
|
|
247
|
-
remainingHours:
|
|
319
|
+
remainingHours: roundHours(remaining),
|
|
320
|
+
projects,
|
|
321
|
+
remainingByProject: projects
|
|
322
|
+
.filter((project) => project.remainingHours > 0)
|
|
323
|
+
.map((project) => ({ ...projectRef(project), remainingHours: project.remainingHours }))
|
|
248
324
|
});
|
|
249
325
|
}
|
|
250
326
|
return byDay;
|
|
251
327
|
}
|
|
252
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
|
+
|
|
253
407
|
export async function draftLogworkFromCommits({
|
|
254
408
|
period = 'today',
|
|
255
409
|
from,
|
|
@@ -259,18 +413,29 @@ export async function draftLogworkFromCommits({
|
|
|
259
413
|
useBookings = true,
|
|
260
414
|
cwd = process.cwd(),
|
|
261
415
|
runGit = runGitCommand,
|
|
262
|
-
queryRo = queryLogwork
|
|
416
|
+
queryRo = queryLogwork,
|
|
417
|
+
loadConfig = loadLocalConfig,
|
|
418
|
+
platform = process.platform
|
|
263
419
|
} = {}) {
|
|
264
420
|
const range = from || to ? normalizeQueryRange({ from, to }) : normalizeQueryRange({ period });
|
|
265
|
-
const repoPaths = [...new Set((repos?.length ? repos : [cwd]).map((repo) => resolve(cwd, String(repo))))];
|
|
421
|
+
const repoPaths = [...new Set((repos?.length ? repos : [cwd]).map((repo) => resolve(cwd, expandHome(String(repo)))))];
|
|
266
422
|
|
|
267
423
|
const repoResults = [];
|
|
268
424
|
const commits = [];
|
|
425
|
+
const seenRoots = new Set();
|
|
426
|
+
// A commit counts once even when two paths reach it (a subfolder of the same
|
|
427
|
+
// repo, or a git worktree of it).
|
|
428
|
+
const seenHashes = new Set();
|
|
269
429
|
for (const repo of repoPaths) {
|
|
270
430
|
try {
|
|
271
431
|
const result = await readRepoCommits({ repo, from: range.from, to: range.to, author, runGit });
|
|
272
|
-
|
|
273
|
-
|
|
432
|
+
if (seenRoots.has(result.root)) {
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
seenRoots.add(result.root);
|
|
436
|
+
const fresh = result.commits.filter((commit) => !seenHashes.has(commit.hash) && seenHashes.add(commit.hash));
|
|
437
|
+
repoResults.push({ path: result.root, author: result.author, commitCount: fresh.length });
|
|
438
|
+
commits.push(...fresh);
|
|
274
439
|
} catch (error) {
|
|
275
440
|
repoResults.push({ path: repo, error: error.message, commitCount: 0 });
|
|
276
441
|
}
|
|
@@ -282,18 +447,29 @@ export async function draftLogworkFromCommits({
|
|
|
282
447
|
try {
|
|
283
448
|
bookings = remainingHoursByDay(await queryRo({ from: range.from, to: range.to, includeEntries: false, cwd }));
|
|
284
449
|
} catch (error) {
|
|
450
|
+
// A missing or expired RO login must reach the agent as authRequired;
|
|
451
|
+
// falling back to an 8h day would hide hours that are already logged.
|
|
452
|
+
if (isAuthRequiredError(error)) {
|
|
453
|
+
throw error;
|
|
454
|
+
}
|
|
285
455
|
bookingsError = error.message;
|
|
286
456
|
}
|
|
287
457
|
}
|
|
288
458
|
|
|
459
|
+
const config = commits.length ? await loadConfig(cwd) : { projectMappings: [] };
|
|
460
|
+
|
|
289
461
|
const days = [];
|
|
290
462
|
for (let date = range.from; date < range.to; date = addLocalDays(date, 1)) {
|
|
291
463
|
const dayCommits = commits.filter((commit) => commit.date === date);
|
|
292
464
|
if (!dayCommits.length) {
|
|
293
465
|
continue;
|
|
294
466
|
}
|
|
295
|
-
const groups = groupCommits(dayCommits);
|
|
296
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
|
+
}));
|
|
297
473
|
let targetHours;
|
|
298
474
|
let hoursSource;
|
|
299
475
|
if (booking) {
|
|
@@ -307,12 +483,19 @@ export async function draftLogworkFromCommits({
|
|
|
307
483
|
targetHours = isWeekend(date) ? 0 : (CONFIG.standardDayHours || 8);
|
|
308
484
|
hoursSource = isWeekend(date) ? 'weekend' : 'standard_day';
|
|
309
485
|
}
|
|
310
|
-
|
|
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);
|
|
311
493
|
days.push({
|
|
312
494
|
date,
|
|
313
495
|
targetHours,
|
|
314
496
|
hoursSource,
|
|
315
|
-
...(booking ? { bookedHours: booking.bookedHours, loggedHours: booking.loggedHours } : {}),
|
|
497
|
+
...(booking ? { bookedHours: booking.bookedHours, loggedHours: booking.loggedHours, remainingByProject: booking.remainingByProject } : {}),
|
|
498
|
+
...(split ? { projectAllocation: split.allocation, unresolvedAllocation: split.unresolved } : {}),
|
|
316
499
|
groups: groups.map((group, index) => ({
|
|
317
500
|
hours: hours[index],
|
|
318
501
|
task: group.task,
|
|
@@ -320,6 +503,8 @@ export async function draftLogworkFromCommits({
|
|
|
320
503
|
description: group.description,
|
|
321
504
|
tickets: group.tickets,
|
|
322
505
|
repo: group.repoName,
|
|
506
|
+
repoPath: group.repo,
|
|
507
|
+
project: group.project,
|
|
323
508
|
commitCount: group.commitCount,
|
|
324
509
|
linesChanged: group.linesChanged,
|
|
325
510
|
commits: group.hashes
|
|
@@ -327,24 +512,43 @@ export async function draftLogworkFromCommits({
|
|
|
327
512
|
});
|
|
328
513
|
}
|
|
329
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;
|
|
330
520
|
const draftText = days
|
|
331
521
|
.map((day) => {
|
|
332
|
-
const lines =
|
|
333
|
-
|
|
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
|
+
}
|
|
334
532
|
return lines.length ? [formatDayHeading(day.date), ...lines].join('\n') : '';
|
|
335
533
|
})
|
|
336
534
|
.filter(Boolean)
|
|
337
535
|
.join('\n\n');
|
|
338
536
|
|
|
537
|
+
const unmappedRepos = [...new Set(days.flatMap((day) => day.groups)
|
|
538
|
+
.filter((group) => !group.project)
|
|
539
|
+
.map((group) => group.repoPath))];
|
|
540
|
+
|
|
339
541
|
const result = {
|
|
340
|
-
status: commits.length ? 'draft' : '
|
|
542
|
+
status: !commits.length ? 'no_commits' : (draftText ? 'draft' : 'no_hours'),
|
|
341
543
|
range,
|
|
342
544
|
repos: repoResults,
|
|
343
545
|
bookings: useBookings ? (bookings ? 'ro' : 'unavailable') : 'skipped',
|
|
344
546
|
...(bookingsError ? { bookingsError } : {}),
|
|
345
547
|
commitCount: commits.length,
|
|
346
548
|
days,
|
|
347
|
-
draftText
|
|
549
|
+
draftText,
|
|
550
|
+
projectOverrides,
|
|
551
|
+
unmappedRepos
|
|
348
552
|
};
|
|
349
553
|
result.summary = formatCommitDraftSummary(result);
|
|
350
554
|
return result;
|
|
@@ -371,16 +575,60 @@ export function formatCommitDraftSummary(result) {
|
|
|
371
575
|
standard_day: `standard day ${day.targetHours}h`
|
|
372
576
|
}[day.hoursSource];
|
|
373
577
|
lines.push('', `${formatDayHeading(day.date)} - ${source}`);
|
|
578
|
+
if (day.remainingByProject?.length) {
|
|
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.`);
|
|
588
|
+
}
|
|
374
589
|
for (const group of day.groups) {
|
|
375
|
-
|
|
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}`);
|
|
376
594
|
}
|
|
377
595
|
}
|
|
378
596
|
if (result.draftText) {
|
|
379
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.`);
|
|
380
604
|
}
|
|
381
605
|
return lines.join('\n');
|
|
382
606
|
}
|
|
383
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
|
+
|
|
628
|
+
function expandHome(path) {
|
|
629
|
+
return path === '~' || /^~[\\/]/.test(path) ? `${homedir()}${path.slice(1)}` : path;
|
|
630
|
+
}
|
|
631
|
+
|
|
384
632
|
function runGitCommand(cwd, args) {
|
|
385
633
|
return new Promise((resolveValue, reject) => {
|
|
386
634
|
const child = spawn('git', ['-C', cwd, ...args], { stdio: ['ignore', 'pipe', 'pipe'] });
|
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 = {
|
package/lib/diagnostics.mjs
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
1
|
import { constants as fsConstants } from 'node:fs';
|
|
3
2
|
import { access, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
4
3
|
import { EOL, homedir, platform, release, type } from 'node:os';
|
|
@@ -11,7 +10,9 @@ import { getStoredAuthStatus } from './auth.mjs';
|
|
|
11
10
|
import { redactText } from './auth-redaction.mjs';
|
|
12
11
|
import { authDebugLogPath, sanitizeDiagnosticValue } from './auth-diagnostics.mjs';
|
|
13
12
|
import { configPath, loadLocalConfig } from './logwork-config.mjs';
|
|
14
|
-
import {
|
|
13
|
+
import { formatNodePathIssue, inspectNodePath, loadRuntimeInfo } from './node-runtime.mjs';
|
|
14
|
+
import { helperHome, manualDraftsPath, runtimeInfoPath, userConfigPath } from './paths.mjs';
|
|
15
|
+
import { readReminderSchedulerNodePath } from './reminder-scheduler.mjs';
|
|
15
16
|
|
|
16
17
|
const DIAGNOSTICS_DIR_NAME = 'diagnostics';
|
|
17
18
|
const MAX_AUTH_DEBUG_CHARS = 20_000;
|
|
@@ -35,6 +36,7 @@ export async function generateDiagnosticsReport({
|
|
|
35
36
|
commandFinder = findCommandOnPath,
|
|
36
37
|
authStatusFn = getStoredAuthStatus,
|
|
37
38
|
mcpSmokeFn = runMcpSmoke,
|
|
39
|
+
nodeRuntimeFn = collectNodeRuntimeCheck,
|
|
38
40
|
readFileFn = readFile,
|
|
39
41
|
statFn = stat,
|
|
40
42
|
writeFileFn = writeFile,
|
|
@@ -47,6 +49,8 @@ export async function generateDiagnosticsReport({
|
|
|
47
49
|
const fileChecks = await collectFileChecks({ statFn, cwd });
|
|
48
50
|
const configSnapshot = await safeCollect('config snapshot', () => collectConfigSnapshot({ cwd, includeMappingDetails }));
|
|
49
51
|
const mcpSmoke = await safeCollect('mcp smoke', () => mcpSmokeFn({ cwd }));
|
|
52
|
+
const nodeRuntime = await safeCollect('node runtime', () => nodeRuntimeFn());
|
|
53
|
+
const nodeIssues = Array.isArray(nodeRuntime?.issues) ? nodeRuntime.issues : [];
|
|
50
54
|
const authDebug = await readSanitizedAuthDebug({ readFileFn });
|
|
51
55
|
|
|
52
56
|
sections.push(formatSection('logwork-cli Diagnostics', [
|
|
@@ -64,6 +68,17 @@ export async function generateDiagnosticsReport({
|
|
|
64
68
|
`home: ${homedir()}`
|
|
65
69
|
]));
|
|
66
70
|
|
|
71
|
+
sections.push(formatSection('Node Path', nodeRuntime?.ok === false
|
|
72
|
+
? formatObjectLines(nodeRuntime)
|
|
73
|
+
: [
|
|
74
|
+
`runtimeFile: ${nodeRuntime.runtimeFile}`,
|
|
75
|
+
`recordedNode: ${nodeRuntime.recordedNode || 'none (run logwork setup)'}${nodeRuntime.recordedVersion ? ` (${nodeRuntime.recordedVersion})` : ''}`,
|
|
76
|
+
`recordedNodeStatus: ${nodeRuntime.recordedStatus}`,
|
|
77
|
+
`reminderNode: ${nodeRuntime.reminderNode || 'none (no reminder schedule)'}`,
|
|
78
|
+
`reminderNodeStatus: ${nodeRuntime.reminderStatus}`,
|
|
79
|
+
...(nodeIssues.length ? nodeIssues.map((issue) => `warning: ${issue}`) : ['warnings: none'])
|
|
80
|
+
]));
|
|
81
|
+
|
|
67
82
|
sections.push(formatSection('Commands', commandChecks.map((entry) => `${entry.command}: ${entry.available ? 'found' : 'missing'}${entry.path ? ` (${entry.path})` : ''}`)));
|
|
68
83
|
sections.push(formatSection('Files', fileChecks.map((entry) => formatFileCheck(entry))));
|
|
69
84
|
sections.push(formatSection('Config Snapshot', formatObjectLines(configSnapshot)));
|
|
@@ -78,7 +93,41 @@ export async function generateDiagnosticsReport({
|
|
|
78
93
|
return {
|
|
79
94
|
path: outputPath,
|
|
80
95
|
report,
|
|
81
|
-
|
|
96
|
+
nodeIssues: nodeIssues.map((issue) => sanitizeReport(issue)),
|
|
97
|
+
summary: [
|
|
98
|
+
...nodeIssues.map((issue) => sanitizeReport(`Warning: ${issue}`)),
|
|
99
|
+
`Diagnostics report written to ${outputPath}`
|
|
100
|
+
].join(EOL)
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Compares the node recorded by `logwork setup` (runtime.json) and the node in
|
|
105
|
+
// the installed reminder schedule with the node running now. Read-only.
|
|
106
|
+
export async function collectNodeRuntimeCheck({
|
|
107
|
+
loadRuntime = loadRuntimeInfo,
|
|
108
|
+
readSchedulerNodePath = readReminderSchedulerNodePath,
|
|
109
|
+
inspect = inspectNodePath
|
|
110
|
+
} = {}) {
|
|
111
|
+
const runtime = await loadRuntime().catch(() => null);
|
|
112
|
+
const reminderNode = await readSchedulerNodePath().catch(() => null);
|
|
113
|
+
const recorded = runtime?.nodePath
|
|
114
|
+
? await inspect(runtime.nodePath)
|
|
115
|
+
: { status: 'unknown' };
|
|
116
|
+
const reminder = reminderNode
|
|
117
|
+
? await inspect(reminderNode)
|
|
118
|
+
: { status: 'unknown' };
|
|
119
|
+
const issues = [
|
|
120
|
+
formatNodePathIssue('runtime', recorded),
|
|
121
|
+
formatNodePathIssue('reminder', reminder)
|
|
122
|
+
].filter(Boolean);
|
|
123
|
+
return {
|
|
124
|
+
runtimeFile: runtimeInfoPath(),
|
|
125
|
+
recordedNode: runtime?.nodePath || '',
|
|
126
|
+
recordedVersion: runtime?.nodeVersion || '',
|
|
127
|
+
recordedStatus: recorded.status,
|
|
128
|
+
reminderNode: reminderNode || '',
|
|
129
|
+
reminderStatus: reminder.status,
|
|
130
|
+
issues
|
|
82
131
|
};
|
|
83
132
|
}
|
|
84
133
|
|
|
@@ -202,7 +251,7 @@ async function safeCollect(label, fn) {
|
|
|
202
251
|
}
|
|
203
252
|
}
|
|
204
253
|
|
|
205
|
-
export async function runMcpSmoke({
|
|
254
|
+
export async function runMcpSmoke({ timeoutMs = MCP_SMOKE_TIMEOUT_MS } = {}) {
|
|
206
255
|
const client = new Client({
|
|
207
256
|
name: 'logwork-diagnostics',
|
|
208
257
|
version: '0.1.0'
|
package/lib/jira-api.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CONFIG } from './config.mjs';
|
|
1
|
+
import { CONFIG, isDryRun } from './config.mjs';
|
|
2
2
|
import { ApiError } from './api.mjs';
|
|
3
3
|
import { fetchWithPolicy, redactedExcerpt } from './http.mjs';
|
|
4
4
|
import { safeJsonParse } from './util.mjs';
|
|
@@ -15,6 +15,11 @@ export async function jiraApiFetch(token, path, {
|
|
|
15
15
|
} = {}) {
|
|
16
16
|
assertJiraToken(token);
|
|
17
17
|
const url = buildJiraUrl(baseUrl, path);
|
|
18
|
+
// Every Jira write goes through here, so this one gate covers worklog create
|
|
19
|
+
// and any write added later. The result never carries the token or headers.
|
|
20
|
+
if (!isIdempotentMethod(method) && isDryRun()) {
|
|
21
|
+
return createJiraDryRunRequest(method, path, body);
|
|
22
|
+
}
|
|
18
23
|
const response = await fetchWithPolicy(url.toString(), {
|
|
19
24
|
fetchImpl,
|
|
20
25
|
timeoutMs,
|
|
@@ -123,12 +128,29 @@ export async function addJiraIssueWorklog(token, issueKey, worklog, options = {}
|
|
|
123
128
|
assertIssueKey(issueKey);
|
|
124
129
|
const qs = new URLSearchParams({ adjustEstimate: 'leave' });
|
|
125
130
|
const path = `/rest/api/2/issue/${encodeURIComponent(issueKey)}/worklog?${qs}`;
|
|
126
|
-
|
|
131
|
+
const data = await jiraApiFetch(token, path, {
|
|
127
132
|
...options,
|
|
128
133
|
method: 'POST',
|
|
129
134
|
retries: 0,
|
|
130
135
|
body: worklog
|
|
131
|
-
})
|
|
136
|
+
});
|
|
137
|
+
return isJiraDryRunResult(data) ? data : normalizeJiraWorklog(data);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function isJiraDryRunResult(value) {
|
|
141
|
+
return value?.dryRun === true;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function createJiraDryRunRequest(method, path, body) {
|
|
145
|
+
const request = {
|
|
146
|
+
dryRun: true,
|
|
147
|
+
method: String(method).toUpperCase(),
|
|
148
|
+
path: String(path)
|
|
149
|
+
};
|
|
150
|
+
if (body !== undefined) {
|
|
151
|
+
request.body = structuredClone(body);
|
|
152
|
+
}
|
|
153
|
+
return request;
|
|
132
154
|
}
|
|
133
155
|
|
|
134
156
|
export function normalizeJiraIssue(issue = {}) {
|