logwork-cli 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +11 -0
- package/README.md +287 -2
- package/RELEASE.md +89 -0
- package/bin/logwork.mjs +101 -0
- package/commands/auth.mjs +227 -0
- package/commands/delete.mjs +69 -0
- package/commands/doctor.mjs +24 -0
- package/commands/edit.mjs +88 -0
- package/commands/history.mjs +62 -0
- package/commands/query.mjs +52 -0
- package/commands/quick.mjs +163 -0
- package/commands/reconcile.mjs +55 -0
- package/commands/release.mjs +77 -0
- package/commands/reminder.mjs +172 -0
- package/commands/setup.mjs +504 -0
- package/commands/tui.mjs +50 -0
- package/commands/update.mjs +161 -0
- package/docs/advanced.md +334 -0
- package/docs/mcp-setup.md +347 -0
- package/docs/operations-guide.md +587 -0
- package/docs/recipes.md +259 -0
- package/docs/security.md +151 -0
- package/examples/mcp/antigravity.mcp_config.json +8 -0
- package/examples/mcp/codex.config.toml +5 -0
- package/examples/mcp/copilot.mcp.json +9 -0
- package/lib/api-auth.mjs +1259 -0
- package/lib/api.mjs +1422 -0
- package/lib/apply-ledger.mjs +227 -0
- package/lib/atomic-file.mjs +24 -0
- package/lib/auth-diagnostics.mjs +113 -0
- package/lib/auth-errors.mjs +22 -0
- package/lib/auth-redaction.mjs +78 -0
- package/lib/auth-terminal.mjs +97 -0
- package/lib/auth.mjs +323 -0
- package/lib/batch-parser.mjs +145 -0
- package/lib/batch-workflow.mjs +842 -0
- package/lib/config.mjs +191 -0
- package/lib/credential-store.mjs +130 -0
- package/lib/diagnostics.mjs +307 -0
- package/lib/entrypoint.mjs +14 -0
- package/lib/file-lock.mjs +44 -0
- package/lib/http.mjs +125 -0
- package/lib/jira-api.mjs +258 -0
- package/lib/jira-auth.mjs +203 -0
- package/lib/jira-workflow.mjs +523 -0
- package/lib/keychain.mjs +105 -0
- package/lib/legacy-migration.mjs +66 -0
- package/lib/list-navigation.mjs +19 -0
- package/lib/logwork-config.mjs +199 -0
- package/lib/logwork-f03-enrich.mjs +41 -0
- package/lib/macos.mjs +33 -0
- package/lib/manual-apply-state.mjs +35 -0
- package/lib/manual-commands.mjs +294 -0
- package/lib/manual-drafts.mjs +154 -0
- package/lib/manual-ink-app.mjs +1654 -0
- package/lib/manual-ink-auth.mjs +349 -0
- package/lib/manual-ink-ui.mjs +675 -0
- package/lib/manual-input.mjs +21 -0
- package/lib/manual-logwork-wizard.mjs +310 -0
- package/lib/manual-mcp.mjs +187 -0
- package/lib/manual-repl.mjs +1037 -0
- package/lib/manual-targets.mjs +78 -0
- package/lib/mcp-server.mjs +793 -0
- package/lib/os-notification.mjs +105 -0
- package/lib/package-info.mjs +17 -0
- package/lib/paths.mjs +68 -0
- package/lib/project-identity.mjs +66 -0
- package/lib/project-mapping-workflow.mjs +309 -0
- package/lib/project-resolver.mjs +373 -0
- package/lib/query-workflow.mjs +553 -0
- package/lib/reconciliation-workflow.mjs +446 -0
- package/lib/release-doctor.mjs +448 -0
- package/lib/reminder-config.mjs +71 -0
- package/lib/reminder-scheduler.mjs +301 -0
- package/lib/reminder-service.mjs +118 -0
- package/lib/reminder-state.mjs +38 -0
- package/lib/reminder-workflow.mjs +299 -0
- package/lib/ro-delete-args.mjs +38 -0
- package/lib/ro-delete-workflow.mjs +115 -0
- package/lib/ro-edit-args.mjs +142 -0
- package/lib/ro-edit-workflow.mjs +632 -0
- package/lib/ro-entry-reader.mjs +56 -0
- package/lib/ro-resubmit-workflow.mjs +134 -0
- package/lib/terminal-auth-prompts.mjs +120 -0
- package/lib/ui.mjs +133 -0
- package/lib/update-service.mjs +350 -0
- package/lib/util.mjs +66 -0
- package/lib/windows-credential-manager.mjs +260 -0
- package/lib/worklog-task-resolver.mjs +113 -0
- package/package.json +72 -4
- package/setup.sh +22 -0
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { CONFIG } from './config.mjs';
|
|
3
|
+
import { recordApplyResult, recordApplyResultSafely } from './apply-ledger.mjs';
|
|
4
|
+
import { parseWeeklyLogText } from './batch-parser.mjs';
|
|
5
|
+
import { ApiError } from './api.mjs';
|
|
6
|
+
import {
|
|
7
|
+
addJiraIssueWorklog,
|
|
8
|
+
getJiraIssue,
|
|
9
|
+
getJiraIssueWorklogs
|
|
10
|
+
} from './jira-api.mjs';
|
|
11
|
+
import {
|
|
12
|
+
createJiraAuthRequiredError,
|
|
13
|
+
getStoredJiraSession,
|
|
14
|
+
isJiraAuthRequiredError
|
|
15
|
+
} from './jira-auth.mjs';
|
|
16
|
+
|
|
17
|
+
const JIRA_WORKLOG_MARKER_PREFIX = 'lh';
|
|
18
|
+
const LEGACY_JIRA_WORKLOG_MARKER_PREFIX = 'logwork-helper';
|
|
19
|
+
|
|
20
|
+
export async function previewJiraWorklogBatch({
|
|
21
|
+
text,
|
|
22
|
+
startedTime = CONFIG.jiraStartedTime,
|
|
23
|
+
timezone = CONFIG.timezone,
|
|
24
|
+
getSession = getStoredJiraSession,
|
|
25
|
+
fetchIssue,
|
|
26
|
+
fetchWorklogs
|
|
27
|
+
} = {}) {
|
|
28
|
+
const parsed = parseWeeklyLogText(text);
|
|
29
|
+
// "<RO worklog task> | <description>": Jira only needs the description as the
|
|
30
|
+
// worklog comment, so taskName carries the comment text for the Jira flow.
|
|
31
|
+
parsed.entries = parsed.entries.map(toJiraCommentEntry);
|
|
32
|
+
const batchId = createJiraBatchId(parsed.entries, { startedTime, timezone });
|
|
33
|
+
const session = await getSession();
|
|
34
|
+
const issueFetcher = fetchIssue || ((issueKey) => getJiraIssue(session.token, issueKey, { baseUrl: session.baseUrl }));
|
|
35
|
+
const worklogFetcher = fetchWorklogs || ((issueKey) => getJiraIssueWorklogs(session.token, issueKey, { baseUrl: session.baseUrl }));
|
|
36
|
+
const entries = [];
|
|
37
|
+
|
|
38
|
+
for (const entry of parsed.entries) {
|
|
39
|
+
entries.push(await previewJiraEntry({
|
|
40
|
+
entry,
|
|
41
|
+
batchId,
|
|
42
|
+
startedTime,
|
|
43
|
+
timezone,
|
|
44
|
+
fetchIssue: issueFetcher,
|
|
45
|
+
fetchWorklogs: worklogFetcher
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const status = parsed.errors.length || !entries.length || entries.some((entry) => entry.status !== 'ready')
|
|
50
|
+
? 'blocked'
|
|
51
|
+
: 'ready';
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
batchId,
|
|
55
|
+
status,
|
|
56
|
+
errors: parsed.errors,
|
|
57
|
+
entries,
|
|
58
|
+
totals: buildJiraTotals(entries),
|
|
59
|
+
settings: {
|
|
60
|
+
startedTime,
|
|
61
|
+
timezone
|
|
62
|
+
},
|
|
63
|
+
summary: formatJiraPreviewSummary({ status, errors: parsed.errors, entries })
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function applyJiraWorklogBatch({
|
|
68
|
+
batch,
|
|
69
|
+
confirm,
|
|
70
|
+
getSession = getStoredJiraSession,
|
|
71
|
+
fetchWorklogs,
|
|
72
|
+
submitWorklog,
|
|
73
|
+
recordLedger,
|
|
74
|
+
ledgerContext = {},
|
|
75
|
+
cwd = process.cwd()
|
|
76
|
+
} = {}) {
|
|
77
|
+
if (confirm !== true) {
|
|
78
|
+
throw new Error('Jira apply requires confirm: true.');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!batch?.batchId || !Array.isArray(batch.entries)) {
|
|
82
|
+
throw new Error('Jira apply requires a cached preview batch.');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const ledgerWriter = recordLedger === undefined
|
|
86
|
+
? (!fetchWorklogs && !submitWorklog ? recordApplyResult : null)
|
|
87
|
+
: recordLedger;
|
|
88
|
+
|
|
89
|
+
const readyEntries = batch.entries.filter((entry) => entry.status === 'ready');
|
|
90
|
+
const blockedEntries = batch.entries.filter((entry) => entry.status !== 'ready');
|
|
91
|
+
if (blockedEntries.length || readyEntries.length !== batch.entries.length || !readyEntries.length) {
|
|
92
|
+
const output = {
|
|
93
|
+
batchId: batch.batchId,
|
|
94
|
+
status: 'blocked',
|
|
95
|
+
results: [],
|
|
96
|
+
blockedEntries,
|
|
97
|
+
summary: formatJiraApplyBlockedSummary(blockedEntries)
|
|
98
|
+
};
|
|
99
|
+
output.ledger = await recordJiraApplyLedger({
|
|
100
|
+
writer: ledgerWriter,
|
|
101
|
+
batch,
|
|
102
|
+
status: output.status,
|
|
103
|
+
entries: blockedEntries,
|
|
104
|
+
ledgerContext,
|
|
105
|
+
cwd
|
|
106
|
+
});
|
|
107
|
+
return output;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const session = await getSession();
|
|
111
|
+
const worklogFetcher = fetchWorklogs || ((issueKey) => getJiraIssueWorklogs(session.token, issueKey, { baseUrl: session.baseUrl }));
|
|
112
|
+
const submitter = submitWorklog || ((issueKey, worklog) => addJiraIssueWorklog(session.token, issueKey, worklog, { baseUrl: session.baseUrl }));
|
|
113
|
+
|
|
114
|
+
const duplicateEntries = [];
|
|
115
|
+
for (const entry of readyEntries) {
|
|
116
|
+
const duplicate = findDuplicateWorklog(await worklogFetcher(entry.issueKey), entry);
|
|
117
|
+
if (duplicate) {
|
|
118
|
+
duplicateEntries.push({
|
|
119
|
+
...entry,
|
|
120
|
+
status: 'duplicate',
|
|
121
|
+
reason: 'duplicate_worklog',
|
|
122
|
+
duplicate
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (duplicateEntries.length) {
|
|
128
|
+
const output = {
|
|
129
|
+
batchId: batch.batchId,
|
|
130
|
+
status: 'blocked',
|
|
131
|
+
results: [],
|
|
132
|
+
blockedEntries: duplicateEntries,
|
|
133
|
+
summary: formatJiraApplyBlockedSummary(duplicateEntries)
|
|
134
|
+
};
|
|
135
|
+
output.ledger = await recordJiraApplyLedger({
|
|
136
|
+
writer: ledgerWriter,
|
|
137
|
+
batch,
|
|
138
|
+
status: output.status,
|
|
139
|
+
entries: duplicateEntries,
|
|
140
|
+
ledgerContext,
|
|
141
|
+
cwd
|
|
142
|
+
});
|
|
143
|
+
return output;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const results = [];
|
|
147
|
+
for (const entry of readyEntries) {
|
|
148
|
+
const payload = entry.worklog || buildJiraWorklogPayload(entry);
|
|
149
|
+
try {
|
|
150
|
+
const worklog = await submitter(entry.issueKey, payload);
|
|
151
|
+
results.push({
|
|
152
|
+
entryId: entry.id,
|
|
153
|
+
issueKey: entry.issueKey,
|
|
154
|
+
date: entry.date,
|
|
155
|
+
hours: entry.hours,
|
|
156
|
+
taskName: entry.taskName,
|
|
157
|
+
status: 'submitted',
|
|
158
|
+
worklogId: worklog?.id,
|
|
159
|
+
worklog
|
|
160
|
+
});
|
|
161
|
+
} catch (error) {
|
|
162
|
+
results.push({
|
|
163
|
+
entryId: entry.id,
|
|
164
|
+
issueKey: entry.issueKey,
|
|
165
|
+
date: entry.date,
|
|
166
|
+
hours: entry.hours,
|
|
167
|
+
taskName: entry.taskName,
|
|
168
|
+
status: 'failed',
|
|
169
|
+
error: error.message
|
|
170
|
+
});
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const failed = results.some((result) => result.status === 'failed');
|
|
176
|
+
const output = {
|
|
177
|
+
batchId: batch.batchId,
|
|
178
|
+
status: failed ? (results.some((result) => result.status === 'submitted') ? 'partial_failed' : 'failed') : 'submitted',
|
|
179
|
+
results,
|
|
180
|
+
summary: formatJiraApplySummary(results)
|
|
181
|
+
};
|
|
182
|
+
output.ledger = await recordJiraApplyLedger({
|
|
183
|
+
writer: ledgerWriter,
|
|
184
|
+
batch,
|
|
185
|
+
status: output.status,
|
|
186
|
+
entries: results,
|
|
187
|
+
ledgerContext,
|
|
188
|
+
cwd
|
|
189
|
+
});
|
|
190
|
+
return output;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function toJiraCommentEntry(entry) {
|
|
194
|
+
if (!entry.taskExplicit) {
|
|
195
|
+
return entry;
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
...entry,
|
|
199
|
+
roWorklogTask: entry.taskName,
|
|
200
|
+
taskName: entry.description || entry.taskName
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function buildJiraWorklogPayload(entry, {
|
|
205
|
+
startedTime = CONFIG.jiraStartedTime,
|
|
206
|
+
timezone = CONFIG.timezone
|
|
207
|
+
} = {}) {
|
|
208
|
+
return {
|
|
209
|
+
started: formatJiraStarted(entry.date, { startedTime, timezone }),
|
|
210
|
+
timeSpentSeconds: hoursToSeconds(entry.hours),
|
|
211
|
+
comment: createJiraWorklogComment(entry)
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function findDuplicateWorklog(worklogs = [], entry) {
|
|
216
|
+
const markers = createJiraWorklogMarkers(entry);
|
|
217
|
+
const expectedDate = String(entry.date || '');
|
|
218
|
+
const expectedSeconds = hoursToSeconds(entry.hours);
|
|
219
|
+
const expectedComment = normalizeDuplicateComment(entry.taskName);
|
|
220
|
+
|
|
221
|
+
return (worklogs || []).find((worklog) => {
|
|
222
|
+
const comment = String(worklog.comment || '');
|
|
223
|
+
if (markers.some((marker) => comment.includes(marker))) {
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return normalizeDateOnly(worklog.started) === expectedDate &&
|
|
228
|
+
Number(worklog.timeSpentSeconds || 0) === expectedSeconds &&
|
|
229
|
+
normalizeDuplicateComment(stripJiraWorklogMarkers(comment)) === expectedComment;
|
|
230
|
+
}) || null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function createJiraWorklogComment(entry) {
|
|
234
|
+
return `${entry.taskName}\n\n${createJiraWorklogMarker(entry)}`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function createJiraWorklogMarker(entry) {
|
|
238
|
+
const fingerprint = `${entry.batchId || ''}:${entry.id || ''}`;
|
|
239
|
+
const hash = createHash('sha256').update(fingerprint).digest('hex').slice(0, 8);
|
|
240
|
+
return `#${JIRA_WORKLOG_MARKER_PREFIX}:${hash}`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function formatJiraStarted(localDateISO, {
|
|
244
|
+
startedTime = CONFIG.jiraStartedTime,
|
|
245
|
+
timezone = CONFIG.timezone
|
|
246
|
+
} = {}) {
|
|
247
|
+
const date = parseLocalDate(localDateISO);
|
|
248
|
+
const time = parseStartedTime(startedTime);
|
|
249
|
+
const offsetMinutes = offsetForLocalDateTime({ ...date, ...time }, timezone);
|
|
250
|
+
return `${localDateISO}T${startedTime}:00.000${formatOffset(offsetMinutes)}`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function createJiraBatchId(entries = [], settings = {}) {
|
|
254
|
+
const fingerprint = JSON.stringify({
|
|
255
|
+
entries: entries.map((entry) => ({
|
|
256
|
+
id: entry.id,
|
|
257
|
+
date: entry.date,
|
|
258
|
+
hours: entry.hours,
|
|
259
|
+
taskName: entry.taskName,
|
|
260
|
+
tickets: entry.tickets || []
|
|
261
|
+
})),
|
|
262
|
+
startedTime: settings.startedTime || CONFIG.jiraStartedTime,
|
|
263
|
+
timezone: settings.timezone || CONFIG.timezone
|
|
264
|
+
});
|
|
265
|
+
return `jira_${createHash('sha256').update(fingerprint).digest('hex').slice(0, 16)}`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function previewJiraEntry({
|
|
269
|
+
entry,
|
|
270
|
+
batchId,
|
|
271
|
+
startedTime,
|
|
272
|
+
timezone,
|
|
273
|
+
fetchIssue,
|
|
274
|
+
fetchWorklogs
|
|
275
|
+
}) {
|
|
276
|
+
const baseEntry = {
|
|
277
|
+
...entry,
|
|
278
|
+
batchId,
|
|
279
|
+
issueKey: entry.tickets?.length === 1 ? entry.tickets[0] : null,
|
|
280
|
+
worklog: buildJiraWorklogPayload({ ...entry, batchId }, { startedTime, timezone })
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
if (!entry.tickets?.length) {
|
|
284
|
+
return {
|
|
285
|
+
...baseEntry,
|
|
286
|
+
status: 'unresolved',
|
|
287
|
+
reason: 'missing_issue_key'
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (entry.tickets.length > 1) {
|
|
292
|
+
return {
|
|
293
|
+
...baseEntry,
|
|
294
|
+
status: 'unresolved',
|
|
295
|
+
reason: 'multiple_issue_keys'
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
const issue = await fetchIssue(baseEntry.issueKey);
|
|
301
|
+
const duplicate = findDuplicateWorklog(await fetchWorklogs(baseEntry.issueKey), baseEntry);
|
|
302
|
+
if (duplicate) {
|
|
303
|
+
return {
|
|
304
|
+
...baseEntry,
|
|
305
|
+
issue,
|
|
306
|
+
status: 'duplicate',
|
|
307
|
+
reason: 'duplicate_worklog',
|
|
308
|
+
duplicate
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
...baseEntry,
|
|
314
|
+
issue,
|
|
315
|
+
status: 'ready',
|
|
316
|
+
reason: 'single_issue_key'
|
|
317
|
+
};
|
|
318
|
+
} catch (error) {
|
|
319
|
+
if (isJiraAuthRequiredError(error)) {
|
|
320
|
+
throw error;
|
|
321
|
+
}
|
|
322
|
+
if (error instanceof ApiError && error.status === 401) {
|
|
323
|
+
throw createJiraAuthRequiredError();
|
|
324
|
+
}
|
|
325
|
+
return {
|
|
326
|
+
...baseEntry,
|
|
327
|
+
status: 'unresolved',
|
|
328
|
+
reason: 'jira_lookup_failed',
|
|
329
|
+
error: error.message
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function buildJiraTotals(entries) {
|
|
335
|
+
const reasonCount = (reason) => entries.filter((entry) => entry.reason === reason).length;
|
|
336
|
+
return {
|
|
337
|
+
entryCount: entries.length,
|
|
338
|
+
readyCount: entries.filter((entry) => entry.status === 'ready').length,
|
|
339
|
+
duplicateCount: entries.filter((entry) => entry.status === 'duplicate').length,
|
|
340
|
+
missingIssueCount: reasonCount('missing_issue_key'),
|
|
341
|
+
multipleIssueCount: reasonCount('multiple_issue_keys'),
|
|
342
|
+
lookupFailedCount: reasonCount('jira_lookup_failed'),
|
|
343
|
+
blockedCount: entries.filter((entry) => entry.status !== 'ready').length,
|
|
344
|
+
hours: roundHours(entries.reduce((sum, entry) => sum + Number(entry.hours || 0), 0)),
|
|
345
|
+
readyHours: roundHours(entries.filter((entry) => entry.status === 'ready').reduce((sum, entry) => sum + Number(entry.hours || 0), 0))
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function formatJiraPreviewSummary({ status, errors, entries }) {
|
|
350
|
+
const totals = buildJiraTotals(entries);
|
|
351
|
+
const lines = [
|
|
352
|
+
`Jira worklog preview: ${status}. ${entries.length} entries, ${formatHours(entries.reduce((sum, entry) => sum + Number(entry.hours || 0), 0))}h total.`,
|
|
353
|
+
`Counts: ready ${totals.readyCount}, duplicate ${totals.duplicateCount}, missing ticket ${totals.missingIssueCount}, multiple tickets ${totals.multipleIssueCount}, lookup failed ${totals.lookupFailedCount}.`
|
|
354
|
+
];
|
|
355
|
+
|
|
356
|
+
if (errors.length) {
|
|
357
|
+
lines.push('Parse errors:');
|
|
358
|
+
for (const error of errors) {
|
|
359
|
+
lines.push(`- line ${error.line}: ${error.message}`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
for (const entry of entries) {
|
|
364
|
+
const issue = entry.issueKey || 'UNRESOLVED';
|
|
365
|
+
const suffix = entry.status === 'ready'
|
|
366
|
+
? ''
|
|
367
|
+
: ` (${entry.reason}${entry.error ? `: ${entry.error}` : ''})`;
|
|
368
|
+
lines.push(`- ${entry.date}: +${formatHours(entry.hours)}h ${issue} - ${entry.taskName} -> ${entry.status}${suffix}`);
|
|
369
|
+
if (entry.issue) {
|
|
370
|
+
lines.push(` Issue: ${entry.issue.summary || 'No summary'} [${entry.issue.status || 'unknown status'}]`);
|
|
371
|
+
}
|
|
372
|
+
if (entry.worklog) {
|
|
373
|
+
lines.push(` Started: ${entry.worklog.started}`);
|
|
374
|
+
lines.push(` Comment: ${String(entry.worklog.comment || '').replace(/\s*\n\s*/g, ' | ')}`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (status !== 'ready') {
|
|
379
|
+
lines.push('Resolve blocked entries and preview again before applying Jira worklogs.');
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return lines.join('\n');
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function formatJiraApplyBlockedSummary(blockedEntries) {
|
|
386
|
+
const lines = [
|
|
387
|
+
`Jira worklog apply blocked: ${blockedEntries.length || 0} entries are not ready.`
|
|
388
|
+
];
|
|
389
|
+
for (const entry of blockedEntries) {
|
|
390
|
+
lines.push(`- ${entry.date}: ${entry.issueKey || 'UNRESOLVED'} ${entry.taskName} (${entry.reason || entry.status})`);
|
|
391
|
+
}
|
|
392
|
+
return lines.join('\n');
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function formatJiraApplySummary(results) {
|
|
396
|
+
const submitted = results.filter((result) => result.status === 'submitted');
|
|
397
|
+
const failed = results.filter((result) => result.status === 'failed');
|
|
398
|
+
const lines = [
|
|
399
|
+
`Jira worklog apply: submitted ${submitted.length}, failed ${failed.length}.`
|
|
400
|
+
];
|
|
401
|
+
for (const result of results) {
|
|
402
|
+
lines.push(`- ${result.date}: ${result.issueKey} +${formatHours(result.hours)}h -> ${result.status}${result.error ? ` (${result.error})` : ''}`);
|
|
403
|
+
}
|
|
404
|
+
return lines.join('\n');
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function hoursToSeconds(hours) {
|
|
408
|
+
return Math.round(Number(hours || 0) * 3600);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function normalizeDuplicateComment(value) {
|
|
412
|
+
return String(value || '')
|
|
413
|
+
.replace(/\s+/g, ' ')
|
|
414
|
+
.trim()
|
|
415
|
+
.toLowerCase();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function stripJiraWorklogMarkers(value) {
|
|
419
|
+
return String(value || '')
|
|
420
|
+
.replace(/\s*#lh:[a-f0-9]{6,16}\s*/gi, ' ')
|
|
421
|
+
.replace(/\s*\[logwork-helper:[^\]]+\]\s*/g, ' ');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function createJiraWorklogMarkers(entry) {
|
|
425
|
+
return [
|
|
426
|
+
createJiraWorklogMarker(entry),
|
|
427
|
+
`${LEGACY_JIRA_WORKLOG_MARKER_PREFIX}:${entry.batchId}:${entry.id}`
|
|
428
|
+
];
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function parseLocalDate(value) {
|
|
432
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value || ''));
|
|
433
|
+
if (!match) {
|
|
434
|
+
throw new Error('Jira worklog date must use YYYY-MM-DD format.');
|
|
435
|
+
}
|
|
436
|
+
return {
|
|
437
|
+
year: Number(match[1]),
|
|
438
|
+
month: Number(match[2]),
|
|
439
|
+
day: Number(match[3])
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function parseStartedTime(value) {
|
|
444
|
+
const match = /^(\d{2}):(\d{2})$/.exec(String(value || ''));
|
|
445
|
+
const hour = Number(match?.[1]);
|
|
446
|
+
const minute = Number(match?.[2]);
|
|
447
|
+
if (!match || hour > 23 || minute > 59) {
|
|
448
|
+
throw new Error('Jira started time must use 24-hour HH:mm format.');
|
|
449
|
+
}
|
|
450
|
+
return { hour, minute };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function offsetForLocalDateTime(parts, timezone) {
|
|
454
|
+
const localAsUtc = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute);
|
|
455
|
+
let instant = localAsUtc;
|
|
456
|
+
let offset = timezoneOffsetAt(instant, timezone);
|
|
457
|
+
|
|
458
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
459
|
+
instant = localAsUtc - offset * 60_000;
|
|
460
|
+
offset = timezoneOffsetAt(instant, timezone);
|
|
461
|
+
}
|
|
462
|
+
return offset;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function timezoneOffsetAt(timestamp, timezone) {
|
|
466
|
+
let formatter;
|
|
467
|
+
try {
|
|
468
|
+
formatter = new Intl.DateTimeFormat('en-US', {
|
|
469
|
+
timeZone: timezone,
|
|
470
|
+
hourCycle: 'h23',
|
|
471
|
+
year: 'numeric',
|
|
472
|
+
month: '2-digit',
|
|
473
|
+
day: '2-digit',
|
|
474
|
+
hour: '2-digit',
|
|
475
|
+
minute: '2-digit'
|
|
476
|
+
});
|
|
477
|
+
} catch {
|
|
478
|
+
throw new Error(`Invalid Jira worklog timezone: ${timezone}.`);
|
|
479
|
+
}
|
|
480
|
+
const parts = Object.fromEntries(formatter.formatToParts(new Date(timestamp))
|
|
481
|
+
.filter((part) => part.type !== 'literal')
|
|
482
|
+
.map((part) => [part.type, Number(part.value)]));
|
|
483
|
+
const zonedAsUtc = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute);
|
|
484
|
+
return Math.round((zonedAsUtc - timestamp) / 60_000);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function formatOffset(minutes) {
|
|
488
|
+
const sign = minutes < 0 ? '-' : '+';
|
|
489
|
+
const absolute = Math.abs(minutes);
|
|
490
|
+
const hours = String(Math.floor(absolute / 60)).padStart(2, '0');
|
|
491
|
+
const remainder = String(absolute % 60).padStart(2, '0');
|
|
492
|
+
return `${sign}${hours}${remainder}`;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function recordJiraApplyLedger({ writer, batch, status, entries, ledgerContext, cwd }) {
|
|
496
|
+
const previewById = new Map((batch.entries || []).map((entry) => [entry.id, entry]));
|
|
497
|
+
return recordApplyResultSafely(writer, {
|
|
498
|
+
target: 'jira',
|
|
499
|
+
flowTarget: ledgerContext.flowTarget || 'jira',
|
|
500
|
+
batchId: batch.batchId,
|
|
501
|
+
status,
|
|
502
|
+
cwd,
|
|
503
|
+
entries: entries.map((entry) => ({
|
|
504
|
+
...previewById.get(entry.entryId || entry.id),
|
|
505
|
+
...entry,
|
|
506
|
+
entryId: entry.entryId || entry.id,
|
|
507
|
+
status: entry.status
|
|
508
|
+
}))
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function normalizeDateOnly(value) {
|
|
513
|
+
return String(value || '').match(/\d{4}-\d{2}-\d{2}/)?.[0] || '';
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function formatHours(value) {
|
|
517
|
+
const number = Number(value || 0);
|
|
518
|
+
return Number.isInteger(number) ? String(number) : String(Number(number.toFixed(2))).replace(/\.0$/, '');
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function roundHours(value) {
|
|
522
|
+
return Number(Number(value || 0).toFixed(2));
|
|
523
|
+
}
|
package/lib/keychain.mjs
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
export const KEYCHAIN_SERVICE = 'logwork-cli';
|
|
4
|
+
// Service name used before the rename; read once and copied to KEYCHAIN_SERVICE.
|
|
5
|
+
export const LEGACY_KEYCHAIN_SERVICE = 'logwork-helper';
|
|
6
|
+
export const KEYCHAIN_ACCOUNT = 'resourceoptimiser';
|
|
7
|
+
export const KEYCHAIN_EMAIL_ACCOUNT = 'resourceoptimiser-email';
|
|
8
|
+
|
|
9
|
+
export function createKeychainTokenStorage({
|
|
10
|
+
service = KEYCHAIN_SERVICE,
|
|
11
|
+
account = KEYCHAIN_ACCOUNT,
|
|
12
|
+
run = runSecurity
|
|
13
|
+
} = {}) {
|
|
14
|
+
return createKeychainValueStorage({ service, account, run });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createKeychainEmailStorage({
|
|
18
|
+
service = KEYCHAIN_SERVICE,
|
|
19
|
+
account = KEYCHAIN_EMAIL_ACCOUNT,
|
|
20
|
+
run = runSecurity
|
|
21
|
+
} = {}) {
|
|
22
|
+
return createKeychainValueStorage({ service, account, run });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createKeychainValueStorage({
|
|
26
|
+
service = KEYCHAIN_SERVICE,
|
|
27
|
+
account = KEYCHAIN_ACCOUNT,
|
|
28
|
+
run = runSecurity
|
|
29
|
+
} = {}) {
|
|
30
|
+
return {
|
|
31
|
+
async get() {
|
|
32
|
+
try {
|
|
33
|
+
const result = await run(['find-generic-password', '-s', service, '-a', account, '-w']);
|
|
34
|
+
return result.stdout.trim() || null;
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (isNotFoundError(error)) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
async set(token) {
|
|
44
|
+
await run([
|
|
45
|
+
'add-generic-password',
|
|
46
|
+
'-U',
|
|
47
|
+
'-s',
|
|
48
|
+
service,
|
|
49
|
+
'-a',
|
|
50
|
+
account,
|
|
51
|
+
'-w',
|
|
52
|
+
token
|
|
53
|
+
]);
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
async delete() {
|
|
57
|
+
try {
|
|
58
|
+
await run(['delete-generic-password', '-s', service, '-a', account]);
|
|
59
|
+
return true;
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (isNotFoundError(error)) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function runSecurity(args) {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const child = spawn('/usr/bin/security', args, {
|
|
73
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
let stdout = '';
|
|
77
|
+
let stderr = '';
|
|
78
|
+
|
|
79
|
+
child.stdout.setEncoding('utf8');
|
|
80
|
+
child.stderr.setEncoding('utf8');
|
|
81
|
+
child.stdout.on('data', (chunk) => {
|
|
82
|
+
stdout += chunk;
|
|
83
|
+
});
|
|
84
|
+
child.stderr.on('data', (chunk) => {
|
|
85
|
+
stderr += chunk;
|
|
86
|
+
});
|
|
87
|
+
child.on('error', reject);
|
|
88
|
+
child.on('close', (code) => {
|
|
89
|
+
if (code === 0) {
|
|
90
|
+
resolve({ stdout, stderr });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const error = new Error(`security ${args[0]} failed with exit code ${code}: ${stderr.trim() || 'no stderr'}`);
|
|
95
|
+
error.code = code;
|
|
96
|
+
error.stderr = stderr;
|
|
97
|
+
reject(error);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isNotFoundError(error) {
|
|
103
|
+
const text = `${error?.message || ''}\n${error?.stderr || ''}`.toLowerCase();
|
|
104
|
+
return error?.code === 44 || text.includes('could not be found') || text.includes('not found');
|
|
105
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
APPLY_LEDGER_FILE_NAME,
|
|
5
|
+
CONFIG_FILE_NAME,
|
|
6
|
+
LEGACY_CONFIG_FILE_NAME,
|
|
7
|
+
MANUAL_DRAFTS_FILE_NAME,
|
|
8
|
+
REMINDER_CONFIG_FILE_NAME,
|
|
9
|
+
REMINDER_STATE_FILE_NAME,
|
|
10
|
+
UPDATE_STATE_FILE_NAME,
|
|
11
|
+
customHelperHome,
|
|
12
|
+
helperHome,
|
|
13
|
+
legacyHelperHome
|
|
14
|
+
} from './paths.mjs';
|
|
15
|
+
|
|
16
|
+
export const MIGRATION_MARKER_FILE_NAME = '.migrated-from-logwork-helper';
|
|
17
|
+
|
|
18
|
+
// Local state files that move from ~/.logwork-helper to ~/.logwork-cli.
|
|
19
|
+
// The runtime code itself is not copied; `logwork setup` installs it fresh.
|
|
20
|
+
const STATE_FILES = [
|
|
21
|
+
[LEGACY_CONFIG_FILE_NAME, CONFIG_FILE_NAME],
|
|
22
|
+
[MANUAL_DRAFTS_FILE_NAME, MANUAL_DRAFTS_FILE_NAME],
|
|
23
|
+
[APPLY_LEDGER_FILE_NAME, APPLY_LEDGER_FILE_NAME],
|
|
24
|
+
[UPDATE_STATE_FILE_NAME, UPDATE_STATE_FILE_NAME],
|
|
25
|
+
[REMINDER_CONFIG_FILE_NAME, REMINDER_CONFIG_FILE_NAME],
|
|
26
|
+
[REMINDER_STATE_FILE_NAME, REMINDER_STATE_FILE_NAME]
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
// Copies state from the pre-rename home once. The old directory is left in
|
|
30
|
+
// place so an older install keeps working; existing new files are never
|
|
31
|
+
// overwritten. Skipped when LOGWORK_HOME (or the legacy LOGWORK_HELPER_HOME)
|
|
32
|
+
// points somewhere explicit.
|
|
33
|
+
export function migrateLegacyHome({
|
|
34
|
+
home = helperHome(),
|
|
35
|
+
legacyHome = legacyHelperHome(),
|
|
36
|
+
env = process.env,
|
|
37
|
+
fs = { copyFileSync, existsSync, mkdirSync, writeFileSync }
|
|
38
|
+
} = {}) {
|
|
39
|
+
if (customHelperHome(env)) {
|
|
40
|
+
return { migrated: false, reason: 'custom_home' };
|
|
41
|
+
}
|
|
42
|
+
const marker = join(home, MIGRATION_MARKER_FILE_NAME);
|
|
43
|
+
if (fs.existsSync(marker)) {
|
|
44
|
+
return { migrated: false, reason: 'already_migrated' };
|
|
45
|
+
}
|
|
46
|
+
if (!fs.existsSync(legacyHome)) {
|
|
47
|
+
return { migrated: false, reason: 'no_legacy_home' };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
fs.mkdirSync(home, { recursive: true });
|
|
52
|
+
const files = [];
|
|
53
|
+
for (const [from, to] of STATE_FILES) {
|
|
54
|
+
const source = join(legacyHome, from);
|
|
55
|
+
const target = join(home, to);
|
|
56
|
+
if (fs.existsSync(source) && !fs.existsSync(target)) {
|
|
57
|
+
fs.copyFileSync(source, target);
|
|
58
|
+
files.push(to);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
fs.writeFileSync(marker, `${JSON.stringify({ from: legacyHome, files, at: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 });
|
|
62
|
+
return { migrated: files.length > 0, from: legacyHome, to: home, files };
|
|
63
|
+
} catch (error) {
|
|
64
|
+
return { migrated: false, reason: 'error', error: error.message };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function previousIndex(selectedIndex, itemCount) {
|
|
2
|
+
return moveIndex(selectedIndex, itemCount, -1);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function nextIndex(selectedIndex, itemCount) {
|
|
6
|
+
return moveIndex(selectedIndex, itemCount, 1);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function moveIndex(selectedIndex, itemCount, delta) {
|
|
10
|
+
if (!Number.isFinite(itemCount) || itemCount <= 0) {
|
|
11
|
+
return 0;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const count = Math.trunc(itemCount);
|
|
15
|
+
const current = Number.isFinite(Number(selectedIndex))
|
|
16
|
+
? Math.trunc(Number(selectedIndex))
|
|
17
|
+
: 0;
|
|
18
|
+
return ((current + delta) % count + count) % count;
|
|
19
|
+
}
|