logwork-cli 1.1.0 → 1.1.1
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 +12 -2
- package/commands/setup.mjs +107 -3
- package/docs/mcp-setup.md +1 -1
- package/docs/operations-guide.md +2 -0
- package/lib/batch-parser.mjs +23 -0
- package/lib/batch-workflow.mjs +15 -1
- package/lib/commit-workflow.mjs +98 -17
- package/lib/keychain.mjs +19 -12
- package/lib/mcp-server.mjs +15 -4
- package/lib/npm-command.mjs +29 -0
- package/lib/paths.mjs +4 -1
- package/lib/release-doctor.mjs +4 -2
- package/lib/ro-delete-workflow.mjs +3 -7
- package/lib/ro-edit-workflow.mjs +66 -8
- package/lib/ro-entry-reader.mjs +39 -1
- package/lib/ro-resubmit-workflow.mjs +33 -20
- package/lib/update-service.mjs +5 -2
- package/package.json +1 -1
- package/skills/logwork/SKILL.md +2 -1
package/README.md
CHANGED
|
@@ -30,16 +30,26 @@ logwork setup
|
|
|
30
30
|
|
|
31
31
|
## Upgrading from logwork-helper
|
|
32
32
|
|
|
33
|
-
The package was renamed from `logwork-helper` to `logwork-cli`, and the `logwork-helper` command no longer exists.
|
|
33
|
+
The package was renamed from `logwork-helper` to `logwork-cli`, and the `logwork-helper` command no longer exists. Both packages install a `logwork` command, so remove the old one first (otherwise npm stops with `EEXIST`):
|
|
34
34
|
|
|
35
35
|
```bash
|
|
36
|
+
npm uninstall -g logwork-helper
|
|
36
37
|
npm install -g logwork-cli
|
|
37
38
|
logwork setup
|
|
38
39
|
```
|
|
39
40
|
|
|
40
41
|
Most of the move is automatic:
|
|
41
42
|
|
|
42
|
-
- **Old global package:** `logwork setup` removes the global `logwork-helper` package
|
|
43
|
+
- **Old global package:** if it is still installed, `logwork setup` removes the global `logwork-helper` package before linking. You can also run `npx logwork-cli@latest setup` without uninstalling first.
|
|
44
|
+
- **`EEXIST ... bin/logwork` even after uninstalling:** the old package was linked from `~/.logwork-helper`. If that folder was deleted first, `npm uninstall` cannot remove its links. Remove the broken links, then install again:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
P="$(npm prefix -g)"
|
|
48
|
+
rm -f "$P/bin/logwork" "$P"/bin/logwork-helper* "$P/lib/node_modules/logwork-helper"
|
|
49
|
+
npm install -g logwork-cli && logwork setup
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`npx logwork-cli@latest setup` also removes those broken links (macOS).
|
|
43
53
|
- **Local state:** on the first run, settings, project mappings, drafts, apply history and the reminder config are copied from `~/.logwork-helper` to `~/.logwork-cli`. The old directory is left in place.
|
|
44
54
|
- **Login sessions:** your RO and Jira sessions are copied from the old Keychain / Credential Manager entry (`logwork-helper`) to the new one (`logwork-cli`), so you do not need to log in again.
|
|
45
55
|
- **Repo mappings:** a repo that has `.logwork-helper.json` keeps using it until a `.logwork.json` exists.
|
package/commands/setup.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { delimiter, dirname, join, relative, resolve } from 'node:path';
|
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { redactText } from '../lib/auth-redaction.mjs';
|
|
6
6
|
import { credentialStoreLabel } from '../lib/credential-store.mjs';
|
|
7
|
+
import { npmInvocation } from '../lib/npm-command.mjs';
|
|
7
8
|
import { helperHome } from '../lib/paths.mjs';
|
|
8
9
|
import {
|
|
9
10
|
MCP_SERVER_NAME,
|
|
@@ -59,7 +60,7 @@ export async function installUserRuntime({
|
|
|
59
60
|
sourceDir,
|
|
60
61
|
targetDir,
|
|
61
62
|
mkdirFn = fs.mkdir,
|
|
62
|
-
copyFn =
|
|
63
|
+
copyFn = copyRuntimeFiles,
|
|
63
64
|
cleanupFn = sanitizeRuntimeMetadata,
|
|
64
65
|
installFn = installDependencies,
|
|
65
66
|
linkFn = linkGlobalBinaries,
|
|
@@ -103,6 +104,40 @@ async function refreshReminderSchedule(targetDir) {
|
|
|
103
104
|
}
|
|
104
105
|
}
|
|
105
106
|
|
|
107
|
+
// Copies only what the npm package ships (package.json "files", plus the
|
|
108
|
+
// manifest and lockfile). The target is also the state directory, so a git
|
|
109
|
+
// checkout's .env files, tests, archives or a repo-scope .logwork.json must
|
|
110
|
+
// never land there.
|
|
111
|
+
export async function copyRuntimeFiles(sourceDir, targetDir) {
|
|
112
|
+
const manifest = JSON.parse(await fs.readFile(join(sourceDir, 'package.json'), 'utf8'));
|
|
113
|
+
const listed = Array.isArray(manifest.files) ? manifest.files : [];
|
|
114
|
+
const names = ['package.json', 'package-lock.json', 'npm-shrinkwrap.json', ...listed.map((item) => String(item).replace(/[\\/]+$/, ''))];
|
|
115
|
+
for (const name of [...new Set(names)]) {
|
|
116
|
+
const sourcePath = join(sourceDir, name);
|
|
117
|
+
let stat;
|
|
118
|
+
try {
|
|
119
|
+
stat = await fs.stat(sourcePath);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (error.code === 'ENOENT') {
|
|
122
|
+
// A lockfile from an earlier install that no longer matches this
|
|
123
|
+
// package.json would make `npm ci` fail; let npm install write a new one.
|
|
124
|
+
if (name === 'package-lock.json') {
|
|
125
|
+
await fs.rm(join(targetDir, name), { force: true });
|
|
126
|
+
}
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
if (stat.isDirectory()) {
|
|
132
|
+
await fs.mkdir(join(targetDir, name), { recursive: true });
|
|
133
|
+
await copyDirectory(sourcePath, targetDir, sourceDir);
|
|
134
|
+
} else {
|
|
135
|
+
await fs.mkdir(dirname(join(targetDir, name)), { recursive: true });
|
|
136
|
+
await fs.copyFile(sourcePath, join(targetDir, name));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
106
141
|
async function copyDirectory(sourceDir, targetDir, rootDir) {
|
|
107
142
|
const entries = await fs.readdir(sourceDir, { withFileTypes: true });
|
|
108
143
|
|
|
@@ -139,7 +174,8 @@ function installDependencies(targetDir) {
|
|
|
139
174
|
|
|
140
175
|
function runCommand(command, args, options = {}) {
|
|
141
176
|
return new Promise((resolveValue, reject) => {
|
|
142
|
-
const
|
|
177
|
+
const invocation = command === 'npm' ? npmInvocation(args) : { command, args, shell: false };
|
|
178
|
+
const child = spawn(invocation.command, invocation.args, { ...options, shell: invocation.shell });
|
|
143
179
|
|
|
144
180
|
child.on('error', reject);
|
|
145
181
|
child.on('close', (code) => {
|
|
@@ -243,9 +279,73 @@ function isStaleSelfDependency(value) {
|
|
|
243
279
|
return typeof value === 'string' && /^file:.*logwork-(helper|cli).*\.tgz$/i.test(value);
|
|
244
280
|
}
|
|
245
281
|
|
|
282
|
+
const LEGACY_GLOBAL_LINKS = [
|
|
283
|
+
'bin/logwork',
|
|
284
|
+
'bin/logwork-helper',
|
|
285
|
+
'bin/logwork-helper-install',
|
|
286
|
+
'bin/logwork-helper-manual',
|
|
287
|
+
'bin/logwork-helper-mcp',
|
|
288
|
+
'bin/logwork-helper-setup-user',
|
|
289
|
+
'lib/node_modules/logwork-helper'
|
|
290
|
+
];
|
|
291
|
+
|
|
292
|
+
// The old package was installed with `npm link` from ~/.logwork-helper. When
|
|
293
|
+
// that folder is deleted first, its global links dangle: `npm rm -g` cannot
|
|
294
|
+
// read the package any more and leaves bin/logwork behind, which then blocks
|
|
295
|
+
// `npm install -g logwork-cli` and `npm link` with EEXIST. Remove only links
|
|
296
|
+
// that point into a logwork-helper path that no longer exists.
|
|
297
|
+
export async function removeDanglingLegacyLinks(globalPrefix, {
|
|
298
|
+
platform = process.platform,
|
|
299
|
+
lstatFn = fs.lstat,
|
|
300
|
+
statFn = fs.stat,
|
|
301
|
+
readlinkFn = fs.readlink,
|
|
302
|
+
unlinkFn = fs.unlink
|
|
303
|
+
} = {}) {
|
|
304
|
+
const removed = [];
|
|
305
|
+
if (!globalPrefix || platform === 'win32') {
|
|
306
|
+
return removed;
|
|
307
|
+
}
|
|
308
|
+
for (const relativePath of LEGACY_GLOBAL_LINKS) {
|
|
309
|
+
const path = join(globalPrefix, relativePath);
|
|
310
|
+
try {
|
|
311
|
+
if (!(await lstatFn(path)).isSymbolicLink()) {
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (!/logwork-helper/.test(await readlinkFn(path))) {
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
try {
|
|
318
|
+
await statFn(path);
|
|
319
|
+
continue;
|
|
320
|
+
} catch {
|
|
321
|
+
// Dangling: the target is gone.
|
|
322
|
+
}
|
|
323
|
+
await unlinkFn(path);
|
|
324
|
+
removed.push(path);
|
|
325
|
+
} catch {
|
|
326
|
+
// Missing or unreadable: nothing to clean.
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return removed;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function readNpmGlobalPrefix(cwd) {
|
|
333
|
+
return new Promise((resolveValue) => {
|
|
334
|
+
const invocation = npmInvocation(['prefix', '-g']);
|
|
335
|
+
const child = spawn(invocation.command, invocation.args, { cwd, shell: invocation.shell, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
336
|
+
let stdout = '';
|
|
337
|
+
child.stdout.setEncoding('utf8');
|
|
338
|
+
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
339
|
+
child.on('error', () => resolveValue(''));
|
|
340
|
+
child.on('close', (code) => resolveValue(code === 0 ? stdout.trim() : ''));
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
246
344
|
export async function linkGlobalBinaries(targetDir, {
|
|
247
345
|
runner = runCommand,
|
|
248
|
-
commandFinder = findCommandOnPath
|
|
346
|
+
commandFinder = findCommandOnPath,
|
|
347
|
+
prefixReader = readNpmGlobalPrefix,
|
|
348
|
+
legacyLinkCleaner = removeDanglingLegacyLinks
|
|
249
349
|
} = {}) {
|
|
250
350
|
// Best effort: drop the pre-rename global package so its `logwork` bin does
|
|
251
351
|
// not block linking this one. A no-op when it is not installed.
|
|
@@ -253,6 +353,10 @@ export async function linkGlobalBinaries(targetDir, {
|
|
|
253
353
|
cwd: targetDir,
|
|
254
354
|
stdio: 'ignore'
|
|
255
355
|
}).catch(() => {});
|
|
356
|
+
await Promise.resolve()
|
|
357
|
+
.then(() => prefixReader(targetDir))
|
|
358
|
+
.then((prefix) => legacyLinkCleaner(prefix))
|
|
359
|
+
.catch(() => []);
|
|
256
360
|
|
|
257
361
|
try {
|
|
258
362
|
await runner('npm', ['link'], {
|
package/docs/mcp-setup.md
CHANGED
|
@@ -107,7 +107,7 @@ Previews expire after 1 hour. They are also lost when the server restarts.
|
|
|
107
107
|
|
|
108
108
|
### Tool Name Changes From logwork-helper
|
|
109
109
|
|
|
110
|
-
| logwork-helper (
|
|
110
|
+
| logwork-helper (0.2.x and earlier) | logwork-cli |
|
|
111
111
|
|---|---|
|
|
112
112
|
| `preview_logwork_batch`, `apply_logwork_batch` | `log_work` |
|
|
113
113
|
| `query_logwork` | `get_logwork` |
|
package/docs/operations-guide.md
CHANGED
|
@@ -19,6 +19,8 @@ npm install -g logwork-cli
|
|
|
19
19
|
logwork setup
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
+
Upgrading from `logwork-helper`: run `npm uninstall -g logwork-helper` first. Both packages provide a `logwork` command, and npm refuses to overwrite it (`EEXIST`). If `~/.logwork-helper` was already deleted, the uninstall leaves broken links behind; remove them with `P="$(npm prefix -g)"; rm -f "$P/bin/logwork" "$P"/bin/logwork-helper* "$P/lib/node_modules/logwork-helper"`, or run `npx logwork-cli@latest setup`, which removes them.
|
|
23
|
+
|
|
22
24
|
Authenticate each target from Terminal:
|
|
23
25
|
|
|
24
26
|
```bash
|
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
|
@@ -70,7 +70,11 @@ export async function previewLogworkBatch({
|
|
|
70
70
|
defaultWorklogTask: normalizedDefaultTask
|
|
71
71
|
}
|
|
72
72
|
: enriched;
|
|
73
|
-
|
|
73
|
+
// typeOfWork also changes what gets written, so it is folded into the
|
|
74
|
+
// batchId the same way.
|
|
75
|
+
return typeOfWork
|
|
76
|
+
? { ...output, typeOfWork, batchId: `${output.batchId}_${hash(`typeOfWork:${typeOfWork}`)}`, summary: `${output.summary}\n\nType of work for every entry: ${typeOfWork}` }
|
|
77
|
+
: output;
|
|
74
78
|
}
|
|
75
79
|
|
|
76
80
|
export const LOGWORK_LINE_FORMAT = '+<hours> <worklog task> | <description>';
|
|
@@ -394,6 +398,16 @@ export async function applyLogworkBatch({
|
|
|
394
398
|
ok: false,
|
|
395
399
|
error: error.message
|
|
396
400
|
});
|
|
401
|
+
// Earlier entries are already in RO. Say so plainly: re-applying the same
|
|
402
|
+
// text would log them twice.
|
|
403
|
+
const written = results.filter((result) => result.ok);
|
|
404
|
+
const notTried = preview.entries.slice(results.length).map((item) => item.id);
|
|
405
|
+
error.message = `Entry ${entry.id} (+${entry.hours}h ${entry.taskName}) failed: ${error.message}` +
|
|
406
|
+
(written.length
|
|
407
|
+
? ` 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.`
|
|
408
|
+
: ' Nothing was written.') +
|
|
409
|
+
(notTried.length ? ` Not attempted: ${notTried.join(', ')}.` : '');
|
|
410
|
+
error.partialResults = results;
|
|
397
411
|
error.applyLedger = await recordRoApplyLedger({
|
|
398
412
|
writer: ledgerWriter,
|
|
399
413
|
preview,
|
package/lib/commit-workflow.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
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';
|
|
4
6
|
import { normalizeQueryRange, queryLogwork } from './query-workflow.mjs';
|
|
5
7
|
import { addLocalDays, todayLocalDateISO } from './util.mjs';
|
|
@@ -15,6 +17,10 @@ import { addLocalDays, todayLocalDateISO } from './util.mjs';
|
|
|
15
17
|
// approves it, then log_work previews and applies it as usual.
|
|
16
18
|
|
|
17
19
|
const TICKET_RE = /\b[A-Z][A-Z0-9]+-\d+\b/g;
|
|
20
|
+
// Prefixes that look like ticket keys but are standards or versions
|
|
21
|
+
// ("UTF-8", "ISO-8601", "SHA-256", "CVE-2024-1234").
|
|
22
|
+
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']);
|
|
23
|
+
const MAX_TICKETS_IN_DESCRIPTION = 4;
|
|
18
24
|
const CONVENTIONAL_RE = /^(\w+)(?:\([^)]*\))?!?:\s*/;
|
|
19
25
|
const FIELD = '\x1f';
|
|
20
26
|
const RECORD = '\x1e';
|
|
@@ -111,36 +117,77 @@ export function parseGitLog(output = '') {
|
|
|
111
117
|
|
|
112
118
|
export async function readRepoCommits({ repo, from, to, author, runGit = runGitCommand }) {
|
|
113
119
|
const root = (await runGit(repo, ['rev-parse', '--show-toplevel'])).trim();
|
|
114
|
-
const authorFilter = author || (await runGit(root, ['config', 'user.email']).catch(() => '')).trim();
|
|
120
|
+
const authorFilter = (author || (await runGit(root, ['config', 'user.email']).catch(() => ''))).trim();
|
|
115
121
|
if (!authorFilter) {
|
|
116
122
|
throw new Error(`No git author for ${root}. Set git config user.email or pass author.`);
|
|
117
123
|
}
|
|
118
|
-
|
|
124
|
+
// The period is matched on the author date in JS below. Git's --since and
|
|
125
|
+
// --until use the committer date (so rebased or amended commits would fall
|
|
126
|
+
// out of their real day) and --since stops the walk at the first older
|
|
127
|
+
// commit (clock skew). --since-as-filter (git 2.37+) only filters, and a day
|
|
128
|
+
// of slack covers time zones; older git falls back to a 31-day window.
|
|
129
|
+
const sinceDate = addLocalDays(from, -1);
|
|
130
|
+
const baseArgs = [
|
|
119
131
|
'log',
|
|
120
|
-
// Every local and remote branch (work often sits on feature branches)
|
|
121
|
-
// not refs/stash, whose "WIP on"/
|
|
132
|
+
// Every local and remote branch (work often sits on feature branches) and
|
|
133
|
+
// HEAD (a detached HEAD mid-rebase), but not refs/stash, whose "WIP on" /
|
|
134
|
+
// "index on" commits are not work items.
|
|
122
135
|
'--branches',
|
|
123
136
|
'--remotes',
|
|
137
|
+
'HEAD',
|
|
124
138
|
'--no-merges',
|
|
125
|
-
|
|
126
|
-
|
|
139
|
+
// Narrow by author in git (fixed string, so "." or "+" in an email is not
|
|
140
|
+
// a regex); the exact match happens in JS.
|
|
127
141
|
`--author=${authorFilter}`,
|
|
142
|
+
'--fixed-strings',
|
|
128
143
|
'--regexp-ignore-case',
|
|
129
144
|
`--format=${RECORD}%H${FIELD}%at${FIELD}%an${FIELD}%ae${FIELD}%s${FIELD}%b${FIELD}`,
|
|
130
145
|
'--numstat'
|
|
131
|
-
]
|
|
146
|
+
];
|
|
147
|
+
let output;
|
|
148
|
+
try {
|
|
149
|
+
output = await runGit(root, [...baseArgs, `--since-as-filter=${sinceDate}T00:00:00`]);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (!/since-as-filter|unrecognized argument|unknown option/i.test(error.message)) {
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
output = await runGit(root, [...baseArgs, `--since=${addLocalDays(from, -31)}T00:00:00`]);
|
|
155
|
+
}
|
|
132
156
|
const seen = new Set();
|
|
133
157
|
const commits = parseGitLog(output)
|
|
158
|
+
.filter((commit) => matchesAuthor(commit, authorFilter))
|
|
134
159
|
.filter((commit) => !seen.has(commit.hash) && seen.add(commit.hash))
|
|
135
160
|
.map((commit) => ({ ...commit, repo: root, date: todayLocalDateISO(new Date(commit.timestamp * 1000)) }))
|
|
136
161
|
.filter((commit) => commit.date >= from && commit.date < to);
|
|
137
162
|
return { root, author: authorFilter, commits };
|
|
138
163
|
}
|
|
139
164
|
|
|
165
|
+
// Exact author match: an email filter must equal the author email, a name
|
|
166
|
+
// filter must equal the author name (both case-insensitive). git --author is
|
|
167
|
+
// a substring match, so "me@x.com" alone would also match "jime@x.com".
|
|
168
|
+
export function matchesAuthor(commit, authorFilter) {
|
|
169
|
+
const filter = String(authorFilter || '').trim().toLowerCase();
|
|
170
|
+
if (!filter) {
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
const email = String(commit.authorEmail || '').toLowerCase();
|
|
174
|
+
const name = String(commit.authorName || '').toLowerCase();
|
|
175
|
+
const bracketed = filter.match(/<([^>]+)>/);
|
|
176
|
+
if (bracketed) {
|
|
177
|
+
return email === bracketed[1].trim();
|
|
178
|
+
}
|
|
179
|
+
return filter.includes('@') ? email === filter : name === filter;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function extractTickets(text = '') {
|
|
183
|
+
return [...new Set((String(text).match(TICKET_RE) || [])
|
|
184
|
+
.filter((key) => !NOT_TICKET_PREFIXES.has(key.slice(0, key.lastIndexOf('-')))))];
|
|
185
|
+
}
|
|
186
|
+
|
|
140
187
|
export function groupCommits(commits = []) {
|
|
141
188
|
const groups = new Map();
|
|
142
189
|
for (const commit of [...commits].sort((a, b) => a.timestamp - b.timestamp)) {
|
|
143
|
-
const tickets =
|
|
190
|
+
const tickets = extractTickets(`${commit.subject}\n${commit.body}`);
|
|
144
191
|
const kind = classifyCommit(commit.subject, commit.body);
|
|
145
192
|
const key = [commit.repo, tickets[0] || '', kind].join('|');
|
|
146
193
|
const group = groups.get(key) || {
|
|
@@ -172,9 +219,14 @@ export function groupCommits(commits = []) {
|
|
|
172
219
|
}
|
|
173
220
|
|
|
174
221
|
export function buildDescription(group) {
|
|
175
|
-
const
|
|
176
|
-
const
|
|
177
|
-
const
|
|
222
|
+
const shown = group.tickets.slice(0, MAX_TICKETS_IN_DESCRIPTION);
|
|
223
|
+
const more = group.tickets.length - shown.length;
|
|
224
|
+
const ticketSuffix = shown.length ? ` (${shown.join(', ')}${more > 0 ? ` +${more} more` : ''})` : '';
|
|
225
|
+
const room = Math.max(40, MAX_DESCRIPTION_LENGTH - ticketSuffix.length);
|
|
226
|
+
const ticketSet = new Set(group.tickets);
|
|
227
|
+
const subjects = group.subjects
|
|
228
|
+
.map((subject) => subject.replace(TICKET_RE, (key) => (ticketSet.has(key) ? '' : key)).replace(/\s*[[(]\s*[)\]]\s*/g, ' ').replace(/\s+/g, ' ').trim())
|
|
229
|
+
.filter(Boolean);
|
|
178
230
|
let text = '';
|
|
179
231
|
for (const subject of subjects) {
|
|
180
232
|
const next = text ? `${text}; ${subject}` : subject;
|
|
@@ -244,7 +296,15 @@ export function remainingHoursByDay(roResult) {
|
|
|
244
296
|
byDay.set(day.date, {
|
|
245
297
|
bookedHours: Number(day.bookedHours || 0),
|
|
246
298
|
loggedHours: Number(day.loggedHours || 0),
|
|
247
|
-
remainingHours: Number(remaining.toFixed(2))
|
|
299
|
+
remainingHours: Number(remaining.toFixed(2)),
|
|
300
|
+
// The draft does not know which project a commit belongs to; this lets
|
|
301
|
+
// the agent move lines to the projects that still have room.
|
|
302
|
+
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
|
+
.filter((project) => project.remainingHours > 0)
|
|
248
308
|
});
|
|
249
309
|
}
|
|
250
310
|
return byDay;
|
|
@@ -262,15 +322,24 @@ export async function draftLogworkFromCommits({
|
|
|
262
322
|
queryRo = queryLogwork
|
|
263
323
|
} = {}) {
|
|
264
324
|
const range = from || to ? normalizeQueryRange({ from, to }) : normalizeQueryRange({ period });
|
|
265
|
-
const repoPaths = [...new Set((repos?.length ? repos : [cwd]).map((repo) => resolve(cwd, String(repo))))];
|
|
325
|
+
const repoPaths = [...new Set((repos?.length ? repos : [cwd]).map((repo) => resolve(cwd, expandHome(String(repo)))))];
|
|
266
326
|
|
|
267
327
|
const repoResults = [];
|
|
268
328
|
const commits = [];
|
|
329
|
+
const seenRoots = new Set();
|
|
330
|
+
// A commit counts once even when two paths reach it (a subfolder of the same
|
|
331
|
+
// repo, or a git worktree of it).
|
|
332
|
+
const seenHashes = new Set();
|
|
269
333
|
for (const repo of repoPaths) {
|
|
270
334
|
try {
|
|
271
335
|
const result = await readRepoCommits({ repo, from: range.from, to: range.to, author, runGit });
|
|
272
|
-
|
|
273
|
-
|
|
336
|
+
if (seenRoots.has(result.root)) {
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
seenRoots.add(result.root);
|
|
340
|
+
const fresh = result.commits.filter((commit) => !seenHashes.has(commit.hash) && seenHashes.add(commit.hash));
|
|
341
|
+
repoResults.push({ path: result.root, author: result.author, commitCount: fresh.length });
|
|
342
|
+
commits.push(...fresh);
|
|
274
343
|
} catch (error) {
|
|
275
344
|
repoResults.push({ path: repo, error: error.message, commitCount: 0 });
|
|
276
345
|
}
|
|
@@ -282,6 +351,11 @@ export async function draftLogworkFromCommits({
|
|
|
282
351
|
try {
|
|
283
352
|
bookings = remainingHoursByDay(await queryRo({ from: range.from, to: range.to, includeEntries: false, cwd }));
|
|
284
353
|
} catch (error) {
|
|
354
|
+
// A missing or expired RO login must reach the agent as authRequired;
|
|
355
|
+
// falling back to an 8h day would hide hours that are already logged.
|
|
356
|
+
if (isAuthRequiredError(error)) {
|
|
357
|
+
throw error;
|
|
358
|
+
}
|
|
285
359
|
bookingsError = error.message;
|
|
286
360
|
}
|
|
287
361
|
}
|
|
@@ -312,7 +386,7 @@ export async function draftLogworkFromCommits({
|
|
|
312
386
|
date,
|
|
313
387
|
targetHours,
|
|
314
388
|
hoursSource,
|
|
315
|
-
...(booking ? { bookedHours: booking.bookedHours, loggedHours: booking.loggedHours } : {}),
|
|
389
|
+
...(booking ? { bookedHours: booking.bookedHours, loggedHours: booking.loggedHours, remainingByProject: booking.remainingByProject } : {}),
|
|
316
390
|
groups: groups.map((group, index) => ({
|
|
317
391
|
hours: hours[index],
|
|
318
392
|
task: group.task,
|
|
@@ -337,7 +411,7 @@ export async function draftLogworkFromCommits({
|
|
|
337
411
|
.join('\n\n');
|
|
338
412
|
|
|
339
413
|
const result = {
|
|
340
|
-
status: commits.length ? 'draft' : '
|
|
414
|
+
status: !commits.length ? 'no_commits' : (draftText ? 'draft' : 'no_hours'),
|
|
341
415
|
range,
|
|
342
416
|
repos: repoResults,
|
|
343
417
|
bookings: useBookings ? (bookings ? 'ro' : 'unavailable') : 'skipped',
|
|
@@ -371,6 +445,9 @@ export function formatCommitDraftSummary(result) {
|
|
|
371
445
|
standard_day: `standard day ${day.targetHours}h`
|
|
372
446
|
}[day.hoursSource];
|
|
373
447
|
lines.push('', `${formatDayHeading(day.date)} - ${source}`);
|
|
448
|
+
if (day.remainingByProject?.length) {
|
|
449
|
+
lines.push(` Room by project: ${day.remainingByProject.map((project) => `${project.projectName} ${project.remainingHours}h`).join(', ')}`);
|
|
450
|
+
}
|
|
374
451
|
for (const group of day.groups) {
|
|
375
452
|
lines.push(` +${group.hours} ${group.task} | ${group.description} [${group.repo}, ${group.commitCount} commit(s)]`);
|
|
376
453
|
}
|
|
@@ -381,6 +458,10 @@ export function formatCommitDraftSummary(result) {
|
|
|
381
458
|
return lines.join('\n');
|
|
382
459
|
}
|
|
383
460
|
|
|
461
|
+
function expandHome(path) {
|
|
462
|
+
return path === '~' || /^~[\\/]/.test(path) ? `${homedir()}${path.slice(1)}` : path;
|
|
463
|
+
}
|
|
464
|
+
|
|
384
465
|
function runGitCommand(cwd, args) {
|
|
385
466
|
return new Promise((resolveValue, reject) => {
|
|
386
467
|
const child = spawn('git', ['-C', cwd, ...args], { stdio: ['ignore', 'pipe', 'pipe'] });
|
package/lib/keychain.mjs
CHANGED
|
@@ -41,16 +41,9 @@ export function createKeychainValueStorage({
|
|
|
41
41
|
},
|
|
42
42
|
|
|
43
43
|
async set(token) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
'-s',
|
|
48
|
-
service,
|
|
49
|
-
'-a',
|
|
50
|
-
account,
|
|
51
|
-
'-w',
|
|
52
|
-
token
|
|
53
|
-
]);
|
|
44
|
+
// The value goes in on stdin (security's interactive mode, as hex), never
|
|
45
|
+
// in argv where `ps` could show it.
|
|
46
|
+
await run(['-i'], { input: formatAddPasswordCommand({ service, account, value: token }) });
|
|
54
47
|
},
|
|
55
48
|
|
|
56
49
|
async delete() {
|
|
@@ -67,11 +60,25 @@ export function createKeychainValueStorage({
|
|
|
67
60
|
};
|
|
68
61
|
}
|
|
69
62
|
|
|
70
|
-
function
|
|
63
|
+
export function formatAddPasswordCommand({ service, account, value }) {
|
|
64
|
+
for (const [label, text] of [['service', service], ['account', account]]) {
|
|
65
|
+
if (!/^[A-Za-z0-9._@+-]+$/.test(String(text))) {
|
|
66
|
+
throw new Error(`Unsupported Keychain ${label} name: ${text}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const hex = Buffer.from(String(value), 'utf8').toString('hex');
|
|
70
|
+
return `add-generic-password -U -s "${service}" -a "${account}" -X ${hex}\n`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function runSecurity(args, { input } = {}) {
|
|
71
74
|
return new Promise((resolve, reject) => {
|
|
72
75
|
const child = spawn('/usr/bin/security', args, {
|
|
73
|
-
stdio: ['ignore', 'pipe', 'pipe']
|
|
76
|
+
stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe']
|
|
74
77
|
});
|
|
78
|
+
if (input !== undefined) {
|
|
79
|
+
child.stdin.on('error', () => {});
|
|
80
|
+
child.stdin.end(input);
|
|
81
|
+
}
|
|
75
82
|
|
|
76
83
|
let stdout = '';
|
|
77
84
|
let stderr = '';
|
package/lib/mcp-server.mjs
CHANGED
|
@@ -162,6 +162,10 @@ server.registerTool('log_work', {
|
|
|
162
162
|
prunePreviewCache(previews);
|
|
163
163
|
if (confirm === true) {
|
|
164
164
|
assertApplyOnly('log_work', { text, projectOverrides, defaultWorklogTask, typeOfWork });
|
|
165
|
+
// Check allowUnbooked before the preview is consumed, so the agent can
|
|
166
|
+
// retry the same batchId with allowUnbooked: true.
|
|
167
|
+
const pending = resolveApprovedBatch({ cache: previews, batchId });
|
|
168
|
+
assertUnbookedAllowed(pending, allowUnbooked);
|
|
165
169
|
const approvedBatch = consumeApprovedBatch({ cache: previews, batchId });
|
|
166
170
|
return formatToolResponse(await applyLogworkBatch({ batch: approvedBatch, confirm, allowUnbooked }));
|
|
167
171
|
}
|
|
@@ -203,7 +207,7 @@ server.registerTool('resubmit_entry', {
|
|
|
203
207
|
annotations: WRITE,
|
|
204
208
|
inputSchema: {
|
|
205
209
|
logworkId: LOGWORK_ID_SCHEMA,
|
|
206
|
-
hours: z.number().positive().optional().describe('Preview call only. New hours; defaults to the rejected entry hours.'),
|
|
210
|
+
hours: z.number().positive().max(24).optional().describe('Preview call only. New hours; defaults to the rejected entry hours.'),
|
|
207
211
|
typeOfWork: TYPE_OF_WORK_SCHEMA.optional().describe('Preview call only. New type of work; defaults to the rejected entry value.'),
|
|
208
212
|
noteToPm: z.string().max(500).optional().describe('Preview call only. Note to the project manager explaining the fix.'),
|
|
209
213
|
description: z.string().max(1_000).optional().describe('Preview call only. New description; defaults to the rejected entry description.'),
|
|
@@ -536,7 +540,7 @@ export function resolveApprovedBatch({
|
|
|
536
540
|
|
|
537
541
|
const cached = getCachedPreviewRecord(cache, batchId, now);
|
|
538
542
|
if (!cached) {
|
|
539
|
-
throw new Error('No cached log_work preview for this batchId (expired after 1 hour, already
|
|
543
|
+
throw new Error('No cached log_work preview for this batchId (expired after 1 hour, already used by an earlier confirm call, or the server restarted; an earlier confirm may have failed without writing, so check get_logwork before assuming it was applied). Preview again and apply using the returned batchId.');
|
|
540
544
|
}
|
|
541
545
|
|
|
542
546
|
if (batch && createPreviewFingerprint(batch) !== cached.fingerprint) {
|
|
@@ -546,6 +550,13 @@ export function resolveApprovedBatch({
|
|
|
546
550
|
return clonePreview(cached.preview);
|
|
547
551
|
}
|
|
548
552
|
|
|
553
|
+
function assertUnbookedAllowed(batch, allowUnbooked) {
|
|
554
|
+
const unbooked = (batch?.entries || []).filter((entry) => entry.requiresAllowUnbooked);
|
|
555
|
+
if (unbooked.length && allowUnbooked !== true) {
|
|
556
|
+
throw new Error(`Nothing was written. ${unbooked.length} entr${unbooked.length === 1 ? 'y has' : 'ies have'} no booking on that date (${unbooked.map((entry) => entry.id).join(', ')}). Ask the user, then call log_work again with the same batchId, confirm: true and allowUnbooked: true.`);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
549
560
|
export function consumeApprovedBatch({
|
|
550
561
|
cache,
|
|
551
562
|
batchId,
|
|
@@ -578,7 +589,7 @@ export function resolveApprovedJiraBatch({
|
|
|
578
589
|
|
|
579
590
|
const cached = getCachedPreviewRecord(cache, batchId, now);
|
|
580
591
|
if (!cached) {
|
|
581
|
-
throw new Error('No cached log_jira_work preview for this batchId (expired after 1 hour, already
|
|
592
|
+
throw new Error('No cached log_jira_work preview for this batchId (expired after 1 hour, already used by an earlier confirm call, or the server restarted; an earlier confirm may have failed without writing, so check get_logwork before assuming it was applied). Preview again and apply using the returned batchId.');
|
|
582
593
|
}
|
|
583
594
|
|
|
584
595
|
if (batch && createJiraPreviewFingerprint(batch) !== cached.fingerprint) {
|
|
@@ -620,7 +631,7 @@ export function resolveApprovedRoEditPreview({
|
|
|
620
631
|
|
|
621
632
|
const cached = getCachedPreviewRecord(cache, previewId, now);
|
|
622
633
|
if (!cached) {
|
|
623
|
-
throw new Error(`No cached ${toolName} preview for this previewId (expired after 1 hour, already
|
|
634
|
+
throw new Error(`No cached ${toolName} preview for this previewId (expired after 1 hour, already used by an earlier confirm call, or the server restarted; an earlier confirm may have failed without writing, so check get_logwork before assuming it was applied). Preview again before applying.`);
|
|
624
635
|
}
|
|
625
636
|
if (preview && createRoEditPreviewFingerprint(preview) !== cached.fingerprint) {
|
|
626
637
|
throw new Error(`Approved ${toolName} preview changed after preview. Preview again before applying.`);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
// How to start npm as a child process on every platform.
|
|
5
|
+
// On Windows, npm is npm.cmd: spawn('npm') fails with ENOENT, and since the
|
|
6
|
+
// Node fix for CVE-2024-27980 spawn('npm.cmd') without a shell fails with
|
|
7
|
+
// EINVAL. Running npm's own npm-cli.js with the current node avoids the shell;
|
|
8
|
+
// when it cannot be found, fall back to npm.cmd through cmd.exe.
|
|
9
|
+
export function npmInvocation(args, {
|
|
10
|
+
platform = process.platform,
|
|
11
|
+
execPath = process.execPath,
|
|
12
|
+
env = process.env,
|
|
13
|
+
exists = existsSync
|
|
14
|
+
} = {}) {
|
|
15
|
+
if (platform !== 'win32') {
|
|
16
|
+
return { command: 'npm', args, shell: false };
|
|
17
|
+
}
|
|
18
|
+
const candidates = [
|
|
19
|
+
env.npm_execpath && /npm-cli\.js$/i.test(env.npm_execpath) ? env.npm_execpath : null,
|
|
20
|
+
join(dirname(execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js')
|
|
21
|
+
].filter(Boolean);
|
|
22
|
+
const cli = candidates.find((path) => exists(path));
|
|
23
|
+
if (cli) {
|
|
24
|
+
return { command: execPath, args: [cli, ...args], shell: false };
|
|
25
|
+
}
|
|
26
|
+
// cmd.exe joins the arguments unquoted; the arguments used here have no
|
|
27
|
+
// spaces or shell metacharacters.
|
|
28
|
+
return { command: 'npm.cmd', args, shell: true };
|
|
29
|
+
}
|
package/lib/paths.mjs
CHANGED
|
@@ -52,7 +52,10 @@ export function reminderStatePath() {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
export function legacyUserConfigPath() {
|
|
55
|
-
|
|
55
|
+
// A custom home (tests, separate profiles) keeps its state isolated from
|
|
56
|
+
// the real ~/.logwork-helper.json.
|
|
57
|
+
const custom = customHelperHome();
|
|
58
|
+
return custom ? resolve(custom, LEGACY_CONFIG_FILE_NAME) : resolve(homedir(), LEGACY_CONFIG_FILE_NAME);
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
// Project-scope mappings live in the repo the user works in. A repo that still
|
package/lib/release-doctor.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { readdir, readFile } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import { npmInvocation } from './npm-command.mjs';
|
|
4
5
|
|
|
5
6
|
const PACKAGE_NAME = 'logwork-cli';
|
|
6
7
|
const PACK_CACHE_PATH = '/private/tmp/logwork-cli-npm-cache';
|
|
@@ -113,10 +114,11 @@ export async function runCommandDefault(command, args = [], {
|
|
|
113
114
|
...process.env,
|
|
114
115
|
...(gitSafe ? { GIT_CONFIG_GLOBAL: '/dev/null' } : {})
|
|
115
116
|
};
|
|
116
|
-
const
|
|
117
|
+
const invocation = command === 'npm' ? npmInvocation(args) : { command, args, shell: false };
|
|
118
|
+
const child = spawn(invocation.command, invocation.args, {
|
|
117
119
|
cwd,
|
|
118
120
|
env,
|
|
119
|
-
shell:
|
|
121
|
+
shell: invocation.shell,
|
|
120
122
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
121
123
|
});
|
|
122
124
|
let stdout = '';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ApiError, deleteLogworkEntry } from './api.mjs';
|
|
2
2
|
import { createResourceOptimiserSession } from './query-workflow.mjs';
|
|
3
|
-
import { createF03LogworkReader } from './ro-entry-reader.mjs';
|
|
3
|
+
import { assertOwnUnlockedRoEntry, createF03LogworkReader, createRoEntryRevisionFingerprint } from './ro-entry-reader.mjs';
|
|
4
4
|
|
|
5
5
|
const DELETABLE_STATUSES = new Set(['submitted', 'approved']);
|
|
6
6
|
|
|
@@ -11,6 +11,7 @@ export async function previewRoLogworkDelete({
|
|
|
11
11
|
const reader = fetchLogwork || createF03LogworkReader();
|
|
12
12
|
const current = await reader(assertIdentifier(logworkId, 'logworkId'));
|
|
13
13
|
assertDeletableStatus(current);
|
|
14
|
+
assertOwnUnlockedRoEntry(current, 'delete');
|
|
14
15
|
|
|
15
16
|
const preview = {
|
|
16
17
|
previewId: `delete_${current.id}`,
|
|
@@ -106,10 +107,5 @@ function assertIdentifier(value, label) {
|
|
|
106
107
|
}
|
|
107
108
|
|
|
108
109
|
function createLogworkRevisionFingerprint(logwork) {
|
|
109
|
-
return
|
|
110
|
-
id: logwork.id,
|
|
111
|
-
hours: logwork.hours,
|
|
112
|
-
status: logwork.status,
|
|
113
|
-
taskName: logwork.taskName
|
|
114
|
-
});
|
|
110
|
+
return createRoEntryRevisionFingerprint(logwork);
|
|
115
111
|
}
|
package/lib/ro-edit-workflow.mjs
CHANGED
|
@@ -100,7 +100,7 @@ export async function applyRoLogworkEdit({
|
|
|
100
100
|
const reader = fetchLogwork || createF03LogworkReader();
|
|
101
101
|
const creator = submitCreate || createEntryWriter();
|
|
102
102
|
const deleter = submitDelete || createDeleteWriter();
|
|
103
|
-
const dayReader = fetchDayEntries || createDayEntriesReader();
|
|
103
|
+
const dayReader = fetchDayEntries || (injected ? null : createDayEntriesReader());
|
|
104
104
|
const ledgerWriter = recordLedger === undefined
|
|
105
105
|
? (injected ? null : recordApplyResult)
|
|
106
106
|
: recordLedger;
|
|
@@ -121,15 +121,27 @@ export async function applyRoLogworkEdit({
|
|
|
121
121
|
needsProjectId: preview.updated.needsProjectId
|
|
122
122
|
};
|
|
123
123
|
|
|
124
|
+
// Entry ids already on the target day, so a replacement found later by its
|
|
125
|
+
// values is never confused with an older identical entry.
|
|
126
|
+
const existingIds = await readDayEntryIds(dayReader, preview.updated.date);
|
|
127
|
+
|
|
124
128
|
let createResult;
|
|
125
129
|
try {
|
|
126
130
|
createResult = await creator(createRequest);
|
|
127
131
|
} catch (error) {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
132
|
+
// Writes are not retried, so a timeout is ambiguous: RO may have saved the
|
|
133
|
+
// entry. Look for it before reporting that nothing changed.
|
|
134
|
+
const savedId = await findCreatedEntryId({ dayReader, preview, existingIds });
|
|
135
|
+
if (savedId === null) {
|
|
136
|
+
error.message = existingIds
|
|
137
|
+
? `Could not create the replacement RO entry; the original entry ${preview.logworkId} is unchanged. ${error.message}`
|
|
138
|
+
: `Could not create the replacement RO entry (${error.message}). The original entry ${preview.logworkId} is unchanged, but check ${preview.updated.date} (get_logwork or \`logwork query\`) in case RO saved a copy.`;
|
|
139
|
+
error.applyLedger = await recordEditLedger({
|
|
140
|
+
writer: ledgerWriter, preview, status: 'failed', entryStatus: 'failed', error: error.message, cwd
|
|
141
|
+
});
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
createResult = { entry: { id: savedId } };
|
|
133
145
|
}
|
|
134
146
|
|
|
135
147
|
if (createResult?.dryRun) {
|
|
@@ -156,7 +168,7 @@ export async function applyRoLogworkEdit({
|
|
|
156
168
|
|
|
157
169
|
let newLogworkId = createResult?.entry?.id ?? null;
|
|
158
170
|
if (newLogworkId === null || newLogworkId === undefined) {
|
|
159
|
-
newLogworkId = await findCreatedEntryId({ dayReader, preview });
|
|
171
|
+
newLogworkId = await findCreatedEntryId({ dayReader, preview, existingIds });
|
|
160
172
|
}
|
|
161
173
|
if (newLogworkId === null || newLogworkId === undefined) {
|
|
162
174
|
const error = new Error(
|
|
@@ -172,6 +184,25 @@ export async function applyRoLogworkEdit({
|
|
|
172
184
|
try {
|
|
173
185
|
await deleter(preview.logworkId);
|
|
174
186
|
} catch (error) {
|
|
187
|
+
// A failed DELETE may still have deleted the original (timeout). Roll the
|
|
188
|
+
// replacement back only when the original is confirmed to still exist;
|
|
189
|
+
// otherwise rolling back could leave neither entry.
|
|
190
|
+
const originalState = await originalEntryState({ dayReader, preview });
|
|
191
|
+
if (originalState === 'gone') {
|
|
192
|
+
return finishReplace({ reader, preview, newLogworkId, ledgerWriter, cwd });
|
|
193
|
+
}
|
|
194
|
+
if (originalState === 'unknown') {
|
|
195
|
+
const message = `Deleting the original RO entry ${preview.logworkId} failed (${error.message}) and its state could not be read. ` +
|
|
196
|
+
`The replacement entry ${newLogworkId} was kept. Check ${preview.original?.date || preview.updated.date} (get_logwork or \`logwork query\`); if both entries exist, delete ${preview.logworkId}.`;
|
|
197
|
+
const wrapped = new Error(message);
|
|
198
|
+
wrapped.cause = error;
|
|
199
|
+
wrapped.newLogworkId = newLogworkId;
|
|
200
|
+
wrapped.rolledBack = false;
|
|
201
|
+
wrapped.applyLedger = await recordEditLedger({
|
|
202
|
+
writer: ledgerWriter, preview, status: 'partial', entryStatus: 'failed', error: message, newLogworkId, cwd
|
|
203
|
+
});
|
|
204
|
+
throw wrapped;
|
|
205
|
+
}
|
|
175
206
|
const rollback = await rollbackCreatedEntry({ deleter, newLogworkId });
|
|
176
207
|
const message = rollback.ok
|
|
177
208
|
? `Could not delete the original RO entry ${preview.logworkId} (${error.message}). The replacement entry ${newLogworkId} was rolled back; nothing changed.`
|
|
@@ -193,6 +224,10 @@ export async function applyRoLogworkEdit({
|
|
|
193
224
|
throw wrapped;
|
|
194
225
|
}
|
|
195
226
|
|
|
227
|
+
return finishReplace({ reader, preview, newLogworkId, ledgerWriter, cwd });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function finishReplace({ reader, preview, newLogworkId, ledgerWriter, cwd }) {
|
|
196
231
|
let verified;
|
|
197
232
|
try {
|
|
198
233
|
verified = sanitizeLogwork(await reader(newLogworkId));
|
|
@@ -483,11 +518,34 @@ function changeFingerprint(change) {
|
|
|
483
518
|
};
|
|
484
519
|
}
|
|
485
520
|
|
|
486
|
-
async function
|
|
521
|
+
async function readDayEntryIds(dayReader, date) {
|
|
522
|
+
if (!dayReader) {
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
try {
|
|
526
|
+
return new Set((await dayReader(date) || []).map((entry) => String(entry.id)));
|
|
527
|
+
} catch {
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
async function originalEntryState({ dayReader, preview }) {
|
|
533
|
+
const ids = await readDayEntryIds(dayReader, preview.original?.date || preview.updated.date);
|
|
534
|
+
if (!ids) {
|
|
535
|
+
return 'unknown';
|
|
536
|
+
}
|
|
537
|
+
return ids.has(String(preview.logworkId)) ? 'present' : 'gone';
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
async function findCreatedEntryId({ dayReader, preview, existingIds = null }) {
|
|
541
|
+
if (!dayReader) {
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
487
544
|
try {
|
|
488
545
|
const entries = await dayReader(preview.updated.date);
|
|
489
546
|
const candidates = (entries || []).filter((entry) => (
|
|
490
547
|
String(entry.id) !== String(preview.logworkId) &&
|
|
548
|
+
(!existingIds || !existingIds.has(String(entry.id))) &&
|
|
491
549
|
String(entry.worklogTaskId) === String(preview.updated.worklogTaskId) &&
|
|
492
550
|
Number(entry.hours) === Number(preview.updated.hours) &&
|
|
493
551
|
(entry.description || '') === (preview.updated.description || '') &&
|
package/lib/ro-entry-reader.mjs
CHANGED
|
@@ -18,7 +18,12 @@ export function createF03LogworkReader() {
|
|
|
18
18
|
if (!f03) {
|
|
19
19
|
throw new Error(`RO logwork ${logworkId} was not found on ${legacy.date}; it may have been deleted.`);
|
|
20
20
|
}
|
|
21
|
-
|
|
21
|
+
// Cache only a successful load: a cached rejection (say a 401) would make
|
|
22
|
+
// the retry after a forced login fail the same way.
|
|
23
|
+
projectsPromise ||= getProjects(token, userId).catch((error) => {
|
|
24
|
+
projectsPromise = null;
|
|
25
|
+
throw error;
|
|
26
|
+
});
|
|
22
27
|
const projects = await projectsPromise;
|
|
23
28
|
const projectMemberId = f03.projectMemberId ?? legacy.projectMemberId;
|
|
24
29
|
const project = projects.find((item) => String(item.projectMemberId) === String(projectMemberId));
|
|
@@ -54,3 +59,36 @@ function withoutEmpty(record = {}) {
|
|
|
54
59
|
value !== undefined && value !== null && value !== ''
|
|
55
60
|
)));
|
|
56
61
|
}
|
|
62
|
+
|
|
63
|
+
// Guards shared by delete and resubmit (edit has its own, stricter set): the
|
|
64
|
+
// entry must be the user's own, not synced from Jira, and on an unlocked day.
|
|
65
|
+
export function assertOwnUnlockedRoEntry(logwork, action) {
|
|
66
|
+
if (
|
|
67
|
+
logwork?.currentUserId !== undefined &&
|
|
68
|
+
logwork?.createdBy !== undefined &&
|
|
69
|
+
String(logwork.currentUserId) !== String(logwork.createdBy)
|
|
70
|
+
) {
|
|
71
|
+
throw new Error(`Cannot ${action} another user's RO logwork.`);
|
|
72
|
+
}
|
|
73
|
+
const jiraId = logwork?.jiraLogworkId;
|
|
74
|
+
if ((jiraId !== null && jiraId !== undefined && String(jiraId).trim() !== '') || String(logwork?.source || '').toLowerCase() === 'jira') {
|
|
75
|
+
throw new Error(`This RO logwork was created by Jira; ${action} it in Jira so both systems stay in step.`);
|
|
76
|
+
}
|
|
77
|
+
if (logwork?.dayLocked) {
|
|
78
|
+
throw new Error(`Logwork for ${logwork.date || 'this day'} is locked on Resource Optimiser; cannot ${action} it.`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// What a preview was approved against; apply refuses if any of it changed.
|
|
83
|
+
export function createRoEntryRevisionFingerprint(logwork) {
|
|
84
|
+
return JSON.stringify({
|
|
85
|
+
id: logwork.id,
|
|
86
|
+
date: logwork.date ?? null,
|
|
87
|
+
hours: logwork.hours,
|
|
88
|
+
status: logwork.status,
|
|
89
|
+
taskName: logwork.taskName,
|
|
90
|
+
worklogTaskId: logwork.worklogTaskId ?? null,
|
|
91
|
+
typeOfWork: logwork.typeOfWork ?? null,
|
|
92
|
+
description: logwork.description ?? null
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ApiError, resubmitLogworkEntry } from './api.mjs';
|
|
2
2
|
import { createResourceOptimiserSession } from './query-workflow.mjs';
|
|
3
|
-
import { createF03LogworkReader } from './ro-entry-reader.mjs';
|
|
3
|
+
import { assertOwnUnlockedRoEntry, createF03LogworkReader, createRoEntryRevisionFingerprint } from './ro-entry-reader.mjs';
|
|
4
4
|
|
|
5
5
|
export async function previewRoLogworkResubmit({
|
|
6
6
|
logworkId,
|
|
@@ -15,31 +15,40 @@ export async function previewRoLogworkResubmit({
|
|
|
15
15
|
if (String(current.status || '').toLowerCase() !== 'rejected') {
|
|
16
16
|
throw new Error('Only rejected RO logwork entries can be resubmitted through this flow.');
|
|
17
17
|
}
|
|
18
|
+
assertOwnUnlockedRoEntry(current, 'resubmit');
|
|
18
19
|
|
|
19
20
|
const effortHours = hours === undefined ? current.hours : Number(hours);
|
|
20
|
-
if (!Number.isFinite(effortHours) || effortHours <= 0) {
|
|
21
|
-
throw new Error('hours must be a positive number.');
|
|
21
|
+
if (!Number.isFinite(effortHours) || effortHours <= 0 || effortHours > 24) {
|
|
22
|
+
throw new Error('hours must be a positive number up to 24.');
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
const
|
|
25
|
+
const updated = {
|
|
26
|
+
hours: effortHours,
|
|
27
|
+
typeOfWork: typeOfWork || current.typeOfWork || 'other',
|
|
28
|
+
noteToPm: noteToPm ?? current.noteToPm ?? '',
|
|
29
|
+
description: description ?? current.description ?? ''
|
|
30
|
+
};
|
|
31
|
+
const originalFingerprint = createLogworkRevisionFingerprint(current);
|
|
32
|
+
// Every field that will be sent is part of the id, so two different
|
|
33
|
+
// previews of the same entry never share (and overwrite) one cache slot.
|
|
34
|
+
const previewId = `resubmit_${current.id}_${hash(JSON.stringify({ updated, originalFingerprint }))}`;
|
|
25
35
|
const preview = {
|
|
26
36
|
previewId,
|
|
27
37
|
status: 'ready',
|
|
28
38
|
logworkId: current.id,
|
|
29
39
|
original: current,
|
|
30
|
-
updated
|
|
31
|
-
|
|
32
|
-
typeOfWork: typeOfWork || current.typeOfWork || 'other',
|
|
33
|
-
noteToPm: noteToPm ?? current.noteToPm ?? '',
|
|
34
|
-
description: description ?? current.description ?? ''
|
|
35
|
-
},
|
|
36
|
-
originalFingerprint: createLogworkRevisionFingerprint(current)
|
|
40
|
+
updated,
|
|
41
|
+
originalFingerprint
|
|
37
42
|
};
|
|
43
|
+
const changeLine = (label, from, to) => (String(from ?? '') === String(to ?? '') ? `- ${label}: ${to || '(empty)'}` : `- ${label}: ${from || '(empty)'} -> ${to || '(empty)'}`);
|
|
38
44
|
preview.summary = [
|
|
39
45
|
'RO logwork resubmit preview:',
|
|
40
|
-
`- Entry: ${current.id}`,
|
|
41
|
-
|
|
42
|
-
|
|
46
|
+
`- Entry: ${current.id} (${current.date || 'unknown date'}, ${current.taskName || 'unknown task'})`,
|
|
47
|
+
changeLine('Hours', current.hours, effortHours),
|
|
48
|
+
changeLine('Type of work', current.typeOfWork, updated.typeOfWork),
|
|
49
|
+
changeLine('Description', current.description, updated.description),
|
|
50
|
+
`- Note to PM: ${updated.noteToPm || '(none)'}`,
|
|
51
|
+
'- Status: rejected -> resubmitted'
|
|
43
52
|
].join('\n');
|
|
44
53
|
return preview;
|
|
45
54
|
}
|
|
@@ -125,10 +134,14 @@ function assertIdentifier(value, label) {
|
|
|
125
134
|
}
|
|
126
135
|
|
|
127
136
|
function createLogworkRevisionFingerprint(logwork) {
|
|
128
|
-
return
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
137
|
+
return createRoEntryRevisionFingerprint(logwork);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function hash(value) {
|
|
141
|
+
let hashValue = 2166136261;
|
|
142
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
143
|
+
hashValue ^= value.charCodeAt(index);
|
|
144
|
+
hashValue = Math.imul(hashValue, 16777619);
|
|
145
|
+
}
|
|
146
|
+
return (hashValue >>> 0).toString(36);
|
|
134
147
|
}
|
package/lib/update-service.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { atomicWriteFile } from './atomic-file.mjs';
|
|
|
5
5
|
import { redactText } from './auth-redaction.mjs';
|
|
6
6
|
import { fetchWithPolicy, redactedExcerpt } from './http.mjs';
|
|
7
7
|
import { readPackageInfo } from './package-info.mjs';
|
|
8
|
+
import { npmInvocation } from './npm-command.mjs';
|
|
8
9
|
import { helperHome, updateStatePath } from './paths.mjs';
|
|
9
10
|
|
|
10
11
|
export const UPDATE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
@@ -187,7 +188,7 @@ export async function installUpdate({
|
|
|
187
188
|
};
|
|
188
189
|
}
|
|
189
190
|
|
|
190
|
-
const executable =
|
|
191
|
+
const executable = 'npm';
|
|
191
192
|
const args = [
|
|
192
193
|
'exec',
|
|
193
194
|
'--yes',
|
|
@@ -311,7 +312,9 @@ function requireSemver(value, label) {
|
|
|
311
312
|
|
|
312
313
|
function runUpdateCommand(executable, args, { stdio = 'pipe' } = {}) {
|
|
313
314
|
return new Promise((resolveValue, reject) => {
|
|
314
|
-
const
|
|
315
|
+
const invocation = executable === 'npm' ? npmInvocation(args) : { command: executable, args, shell: false };
|
|
316
|
+
const child = spawn(invocation.command, invocation.args, {
|
|
317
|
+
shell: invocation.shell,
|
|
315
318
|
cwd: helperHome(),
|
|
316
319
|
env: process.env,
|
|
317
320
|
stdio: stdio === 'inherit' ? 'inherit' : ['ignore', 'pipe', 'pipe']
|
package/package.json
CHANGED
package/skills/logwork/SKILL.md
CHANGED
|
@@ -14,7 +14,8 @@ Goal: every logged line is backed by real work (the user's commits or what the u
|
|
|
14
14
|
3. **Check the worklog tasks.** For each project involved, call `list_tasks(projectId)`. Keep the suggested task when it exists in that catalog; otherwise pick the catalog task whose description matches the work.
|
|
15
15
|
4. **Write the descriptions.** One line per piece of work: what was done and the outcome, with ticket keys, in English, under about 200 characters, no `|`. Rewrite vague commit subjects ("wip", "fix", "update"); use `get_jira_issue` to read the ticket when the subject alone is unclear.
|
|
16
16
|
5. **Set the hours.**
|
|
17
|
-
- Stay within the remaining booked hours per day and project unless the user says otherwise.
|
|
17
|
+
- Stay within the remaining booked hours per day and project unless the user says otherwise. The draft splits the day's total; use each day's `remainingByProject` to move hours to the project the work belongs to.
|
|
18
|
+
- If `draft_from_commits` returns `authRequired`, call `login` first; do not guess hours without RO.
|
|
18
19
|
- Use 0.5h steps.
|
|
19
20
|
- Do not log weekends or days without a booking unless the user asks.
|
|
20
21
|
- Work without commits (meetings, reviews, support) only comes from the user; ask, then log it as `Other Activities` or `Code Review & Refactoring`.
|