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,299 @@
|
|
|
1
|
+
import { isAuthRequiredError } from './auth-errors.mjs';
|
|
2
|
+
import { redactText } from './auth-redaction.mjs';
|
|
3
|
+
import { withFileLock } from './file-lock.mjs';
|
|
4
|
+
import { searchJiraIssues } from './jira-api.mjs';
|
|
5
|
+
import {
|
|
6
|
+
getStoredJiraSession,
|
|
7
|
+
isJiraAuthRequiredError
|
|
8
|
+
} from './jira-auth.mjs';
|
|
9
|
+
import { sendOsNotification } from './os-notification.mjs';
|
|
10
|
+
import { reminderStatePath } from './paths.mjs';
|
|
11
|
+
import { queryLogwork } from './query-workflow.mjs';
|
|
12
|
+
import { loadReminderConfig } from './reminder-config.mjs';
|
|
13
|
+
import {
|
|
14
|
+
loadReminderState,
|
|
15
|
+
saveReminderState
|
|
16
|
+
} from './reminder-state.mjs';
|
|
17
|
+
import { todayLocalDateISO } from './util.mjs';
|
|
18
|
+
|
|
19
|
+
export async function checkLogworkReminder({
|
|
20
|
+
target = 'both',
|
|
21
|
+
date = todayLocalDateISO(),
|
|
22
|
+
queryRo = queryResourceOptimiserDay,
|
|
23
|
+
queryJira = queryJiraWorklogDay
|
|
24
|
+
} = {}) {
|
|
25
|
+
const checks = {};
|
|
26
|
+
const jobs = [];
|
|
27
|
+
|
|
28
|
+
if (target === 'ro' || target === 'both') {
|
|
29
|
+
jobs.push(runReminderCheck('ro', () => queryRo({ date })).then((result) => {
|
|
30
|
+
checks.ro = result;
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
if (target === 'jira' || target === 'both') {
|
|
34
|
+
jobs.push(runReminderCheck('jira', () => queryJira({ date })).then((result) => {
|
|
35
|
+
checks.jira = result;
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
if (!jobs.length) {
|
|
39
|
+
throw new Error('Reminder target must be ro, jira, or both.');
|
|
40
|
+
}
|
|
41
|
+
await Promise.all(jobs);
|
|
42
|
+
|
|
43
|
+
const problemLines = buildProblemLines(checks);
|
|
44
|
+
const notify = problemLines.length > 0;
|
|
45
|
+
const notification = notify ? {
|
|
46
|
+
title: 'Logwork',
|
|
47
|
+
message: [
|
|
48
|
+
'Logwork hôm nay chưa hoàn tất',
|
|
49
|
+
...problemLines,
|
|
50
|
+
'',
|
|
51
|
+
'Mở Terminal và chạy: logwork'
|
|
52
|
+
].join('\n')
|
|
53
|
+
} : null;
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
date,
|
|
57
|
+
target,
|
|
58
|
+
status: notify ? 'notification_required' : 'complete',
|
|
59
|
+
notify,
|
|
60
|
+
checks,
|
|
61
|
+
notification,
|
|
62
|
+
summary: notify
|
|
63
|
+
? `Logwork reminder requires attention: ${problemLines.join(' | ')}`
|
|
64
|
+
: `Logwork reminder check is complete for ${date}; no notification is needed.`
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function queryResourceOptimiserDay({
|
|
69
|
+
date,
|
|
70
|
+
query = queryLogwork
|
|
71
|
+
} = {}) {
|
|
72
|
+
const result = await query({
|
|
73
|
+
date,
|
|
74
|
+
includeEntries: false
|
|
75
|
+
});
|
|
76
|
+
const bookedHours = roundHours(result?.totals?.bookedHours);
|
|
77
|
+
const loggedHours = roundHours(result?.totals?.loggedHours);
|
|
78
|
+
const remainingHours = roundHours(Math.max(0, bookedHours - loggedHours));
|
|
79
|
+
let status = 'complete';
|
|
80
|
+
if (bookedHours === 0 && loggedHours === 0) {
|
|
81
|
+
status = 'not_expected';
|
|
82
|
+
} else if (remainingHours > 0) {
|
|
83
|
+
status = 'incomplete';
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
target: 'ro',
|
|
87
|
+
status,
|
|
88
|
+
bookedHours,
|
|
89
|
+
loggedHours,
|
|
90
|
+
remainingHours
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function queryJiraWorklogDay({
|
|
95
|
+
date,
|
|
96
|
+
getSession = getStoredJiraSession,
|
|
97
|
+
searchIssues = searchJiraIssues
|
|
98
|
+
} = {}) {
|
|
99
|
+
assertDateOnly(date);
|
|
100
|
+
const session = await getSession();
|
|
101
|
+
const jql = `worklogAuthor = currentUser() AND worklogDate = "${date}"`;
|
|
102
|
+
const issues = await searchIssues(session.token, {
|
|
103
|
+
jql,
|
|
104
|
+
fields: ['key', 'summary'],
|
|
105
|
+
pageSize: 100
|
|
106
|
+
}, {
|
|
107
|
+
baseUrl: session.baseUrl
|
|
108
|
+
});
|
|
109
|
+
return {
|
|
110
|
+
target: 'jira',
|
|
111
|
+
status: issues.length ? 'complete' : 'incomplete',
|
|
112
|
+
issueCount: issues.length,
|
|
113
|
+
issues: issues.map((issue) => ({
|
|
114
|
+
key: issue.key,
|
|
115
|
+
summary: issue.summary
|
|
116
|
+
}))
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function runScheduledReminder({
|
|
121
|
+
loadConfig = loadReminderConfig,
|
|
122
|
+
loadState = loadReminderState,
|
|
123
|
+
saveState = saveReminderState,
|
|
124
|
+
check = checkLogworkReminder,
|
|
125
|
+
notify = sendOsNotification,
|
|
126
|
+
now = () => new Date(),
|
|
127
|
+
statePath = reminderStatePath(),
|
|
128
|
+
lockOptions = {}
|
|
129
|
+
} = {}) {
|
|
130
|
+
return withFileLock(statePath, async () => {
|
|
131
|
+
const config = await loadConfig();
|
|
132
|
+
const current = now();
|
|
133
|
+
const date = todayLocalDateISO(current);
|
|
134
|
+
const runAt = current.toISOString();
|
|
135
|
+
|
|
136
|
+
if (!config.enabled) {
|
|
137
|
+
return {
|
|
138
|
+
status: 'disabled',
|
|
139
|
+
notified: false,
|
|
140
|
+
summary: 'Logwork reminder is disabled.'
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
if (!config.days.includes(current.getDay())) {
|
|
144
|
+
const result = {
|
|
145
|
+
status: 'skipped_non_workday',
|
|
146
|
+
notified: false,
|
|
147
|
+
summary: 'Logwork reminder skipped because today is not a configured workday.'
|
|
148
|
+
};
|
|
149
|
+
await saveState(buildState(result, { date, runAt }));
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const previous = await loadState();
|
|
154
|
+
if (previous?.lastNotifiedDate === date) {
|
|
155
|
+
const result = {
|
|
156
|
+
status: 'already_notified',
|
|
157
|
+
notified: false,
|
|
158
|
+
summary: `Logwork reminder already notified on ${date}.`
|
|
159
|
+
};
|
|
160
|
+
await saveState(buildState(result, {
|
|
161
|
+
date,
|
|
162
|
+
runAt,
|
|
163
|
+
lastNotifiedDate: previous.lastNotifiedDate
|
|
164
|
+
}));
|
|
165
|
+
return result;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const checkResult = await check({
|
|
169
|
+
target: config.target,
|
|
170
|
+
date
|
|
171
|
+
});
|
|
172
|
+
if (!checkResult.notify) {
|
|
173
|
+
const result = {
|
|
174
|
+
...checkResult,
|
|
175
|
+
notified: false
|
|
176
|
+
};
|
|
177
|
+
await saveState(buildState(result, { date, runAt }));
|
|
178
|
+
return result;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
await notify(checkResult.notification);
|
|
183
|
+
} catch (error) {
|
|
184
|
+
const result = {
|
|
185
|
+
status: 'notification_failed',
|
|
186
|
+
notified: false,
|
|
187
|
+
summary: 'Logwork reminder was required but the OS notification failed.',
|
|
188
|
+
error: redactText(error.message)
|
|
189
|
+
};
|
|
190
|
+
await saveState(buildState(result, { date, runAt }));
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const result = {
|
|
195
|
+
...checkResult,
|
|
196
|
+
status: 'notified',
|
|
197
|
+
notified: true
|
|
198
|
+
};
|
|
199
|
+
await saveState(buildState(result, {
|
|
200
|
+
date,
|
|
201
|
+
runAt,
|
|
202
|
+
lastNotifiedDate: date
|
|
203
|
+
}));
|
|
204
|
+
return result;
|
|
205
|
+
}, lockOptions);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function sendTestReminderNotification({
|
|
209
|
+
target = 'both',
|
|
210
|
+
notify = sendOsNotification
|
|
211
|
+
} = {}) {
|
|
212
|
+
const lines = [];
|
|
213
|
+
if (target === 'ro' || target === 'both') {
|
|
214
|
+
lines.push('RO: 6/8h, còn thiếu 2h');
|
|
215
|
+
}
|
|
216
|
+
if (target === 'jira' || target === 'both') {
|
|
217
|
+
lines.push('Jira: chưa có worklog hôm nay');
|
|
218
|
+
}
|
|
219
|
+
if (!lines.length) {
|
|
220
|
+
throw new Error('Reminder target must be ro, jira, or both.');
|
|
221
|
+
}
|
|
222
|
+
const notification = {
|
|
223
|
+
title: 'Logwork',
|
|
224
|
+
message: [
|
|
225
|
+
'Đây là notification thử nghiệm',
|
|
226
|
+
...lines,
|
|
227
|
+
'',
|
|
228
|
+
'Mở Terminal và chạy: logwork'
|
|
229
|
+
].join('\n')
|
|
230
|
+
};
|
|
231
|
+
const result = await notify(notification);
|
|
232
|
+
return {
|
|
233
|
+
status: 'sent',
|
|
234
|
+
target,
|
|
235
|
+
notification,
|
|
236
|
+
platform: result?.platform || process.platform,
|
|
237
|
+
summary: `Sent a test logwork notification for ${target}.`
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function runReminderCheck(target, operation) {
|
|
242
|
+
try {
|
|
243
|
+
return await operation();
|
|
244
|
+
} catch (error) {
|
|
245
|
+
const authRequired = target === 'ro'
|
|
246
|
+
? isAuthRequiredError(error)
|
|
247
|
+
: isJiraAuthRequiredError(error);
|
|
248
|
+
return {
|
|
249
|
+
target,
|
|
250
|
+
status: authRequired ? 'auth_required' : 'unavailable',
|
|
251
|
+
error: authRequired ? '' : redactText(error.message).slice(0, 500)
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function buildProblemLines(checks) {
|
|
257
|
+
const lines = [];
|
|
258
|
+
if (checks.ro?.status === 'incomplete') {
|
|
259
|
+
lines.push(`RO: ${formatHours(checks.ro.loggedHours)}/${formatHours(checks.ro.bookedHours)}h, còn thiếu ${formatHours(checks.ro.remainingHours)}h`);
|
|
260
|
+
} else if (checks.ro?.status === 'auth_required') {
|
|
261
|
+
lines.push('RO: chưa đăng nhập, chạy logwork login');
|
|
262
|
+
} else if (checks.ro?.status === 'unavailable') {
|
|
263
|
+
lines.push('RO: không thể kiểm tra trạng thái');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (checks.jira?.status === 'incomplete') {
|
|
267
|
+
lines.push('Jira: chưa có worklog hôm nay');
|
|
268
|
+
} else if (checks.jira?.status === 'auth_required') {
|
|
269
|
+
lines.push('Jira: chưa đăng nhập, chạy logwork login jira');
|
|
270
|
+
} else if (checks.jira?.status === 'unavailable') {
|
|
271
|
+
lines.push('Jira: không thể kiểm tra trạng thái');
|
|
272
|
+
}
|
|
273
|
+
return lines;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function buildState(result, { date, runAt, lastNotifiedDate = '' }) {
|
|
277
|
+
return {
|
|
278
|
+
lastRunAt: runAt,
|
|
279
|
+
lastRunDate: date,
|
|
280
|
+
lastStatus: result.status,
|
|
281
|
+
lastNotifiedDate,
|
|
282
|
+
summary: result.summary,
|
|
283
|
+
error: result.error || ''
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function roundHours(value) {
|
|
288
|
+
return Number(Number(value || 0).toFixed(2));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function formatHours(value) {
|
|
292
|
+
return Number(value || 0).toFixed(2).replace(/\.00$/, '').replace(/(\.\d)0$/, '$1');
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function assertDateOnly(value) {
|
|
296
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(value || ''))) {
|
|
297
|
+
throw new Error('Reminder date must use YYYY-MM-DD format.');
|
|
298
|
+
}
|
|
299
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export function parseRoDeleteArgs(args = []) {
|
|
2
|
+
const options = {
|
|
3
|
+
logworkId: undefined,
|
|
4
|
+
yes: false,
|
|
5
|
+
json: false,
|
|
6
|
+
help: false
|
|
7
|
+
};
|
|
8
|
+
const positional = [];
|
|
9
|
+
|
|
10
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
11
|
+
const arg = args[index];
|
|
12
|
+
if (arg === '--help' || arg === '-h') {
|
|
13
|
+
options.help = true;
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
if (arg === '--yes' || arg === '-y') {
|
|
17
|
+
options.yes = true;
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (arg === '--json') {
|
|
21
|
+
options.json = true;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (String(arg).startsWith('-')) {
|
|
25
|
+
throw new Error(`Unknown delete option: ${arg}`);
|
|
26
|
+
}
|
|
27
|
+
positional.push(arg);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (options.help) {
|
|
31
|
+
return options;
|
|
32
|
+
}
|
|
33
|
+
if (positional.length !== 1) {
|
|
34
|
+
throw new Error('RO delete requires exactly one logwork id.');
|
|
35
|
+
}
|
|
36
|
+
options.logworkId = positional[0];
|
|
37
|
+
return options;
|
|
38
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { ApiError, deleteLogworkEntry } from './api.mjs';
|
|
2
|
+
import { createResourceOptimiserSession } from './query-workflow.mjs';
|
|
3
|
+
import { createF03LogworkReader } from './ro-entry-reader.mjs';
|
|
4
|
+
|
|
5
|
+
const DELETABLE_STATUSES = new Set(['submitted', 'approved']);
|
|
6
|
+
|
|
7
|
+
export async function previewRoLogworkDelete({
|
|
8
|
+
logworkId,
|
|
9
|
+
fetchLogwork
|
|
10
|
+
}) {
|
|
11
|
+
const reader = fetchLogwork || createF03LogworkReader();
|
|
12
|
+
const current = await reader(assertIdentifier(logworkId, 'logworkId'));
|
|
13
|
+
assertDeletableStatus(current);
|
|
14
|
+
|
|
15
|
+
const preview = {
|
|
16
|
+
previewId: `delete_${current.id}`,
|
|
17
|
+
status: 'ready',
|
|
18
|
+
logworkId: current.id,
|
|
19
|
+
original: current,
|
|
20
|
+
originalFingerprint: createLogworkRevisionFingerprint(current)
|
|
21
|
+
};
|
|
22
|
+
preview.summary = [
|
|
23
|
+
'RO logwork delete preview:',
|
|
24
|
+
`- Entry: ${current.id}`,
|
|
25
|
+
`- Date: ${current.date || '(unknown)'}`,
|
|
26
|
+
`- Project: ${current.projectName || current.projectId || '(unknown)'}`,
|
|
27
|
+
`- Task: ${current.taskName || '(no task name)'}`,
|
|
28
|
+
`- Hours: ${current.hours}`,
|
|
29
|
+
`- Status: ${current.status} (will be soft-deleted)`,
|
|
30
|
+
'Only submitted or approved entries can be deleted on Resource Optimiser.'
|
|
31
|
+
].join('\n');
|
|
32
|
+
return preview;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function applyRoLogworkDelete({
|
|
36
|
+
preview,
|
|
37
|
+
confirm,
|
|
38
|
+
fetchLogwork,
|
|
39
|
+
submitDelete
|
|
40
|
+
}) {
|
|
41
|
+
if (confirm !== true) {
|
|
42
|
+
throw new Error('Delete requires confirm: true.');
|
|
43
|
+
}
|
|
44
|
+
if (!preview || preview.status !== 'ready') {
|
|
45
|
+
throw new Error('A valid delete preview is required (delete_entry without confirm).');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const reader = fetchLogwork || createF03LogworkReader();
|
|
49
|
+
const writer = submitDelete || createDeleteWriter();
|
|
50
|
+
const current = await reader(preview.logworkId);
|
|
51
|
+
if (createLogworkRevisionFingerprint(current) !== preview.originalFingerprint) {
|
|
52
|
+
throw new Error('RO logwork changed after preview. Preview the delete again before applying.');
|
|
53
|
+
}
|
|
54
|
+
assertDeletableStatus(current);
|
|
55
|
+
|
|
56
|
+
const result = await writer(preview.logworkId);
|
|
57
|
+
if (result?.dryRun) {
|
|
58
|
+
return {
|
|
59
|
+
status: 'dry_run',
|
|
60
|
+
dryRun: true,
|
|
61
|
+
logworkId: preview.logworkId,
|
|
62
|
+
result,
|
|
63
|
+
summary: `Dry run: RO logwork entry ${preview.logworkId} was not deleted.`
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
status: 'deleted',
|
|
68
|
+
logworkId: preview.logworkId,
|
|
69
|
+
result,
|
|
70
|
+
summary: `Deleted RO logwork entry ${preview.logworkId}.`
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function createDeleteWriter() {
|
|
75
|
+
const session = createResourceOptimiserSession();
|
|
76
|
+
return async (logworkId) => {
|
|
77
|
+
const { token } = await session.get();
|
|
78
|
+
try {
|
|
79
|
+
return await deleteLogworkEntry(token, logworkId);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (!(error instanceof ApiError) || error.status !== 401) {
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
const refreshed = await session.get({ forceLogin: true });
|
|
85
|
+
return deleteLogworkEntry(refreshed.token, logworkId);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function assertDeletableStatus(logwork) {
|
|
91
|
+
const status = String(logwork?.status || '').toLowerCase();
|
|
92
|
+
if (status === 'rejected') {
|
|
93
|
+
throw new Error('Rejected entries cannot be deleted. Use resubmit_entry instead.');
|
|
94
|
+
}
|
|
95
|
+
if (!DELETABLE_STATUSES.has(status)) {
|
|
96
|
+
throw new Error(`Only submitted or approved RO logwork entries can be deleted (status: ${status || 'unknown'}).`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function assertIdentifier(value, label) {
|
|
101
|
+
const text = String(value ?? '').trim();
|
|
102
|
+
if (!text) {
|
|
103
|
+
throw new Error(`${label} is required.`);
|
|
104
|
+
}
|
|
105
|
+
return text;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function createLogworkRevisionFingerprint(logwork) {
|
|
109
|
+
return JSON.stringify({
|
|
110
|
+
id: logwork.id,
|
|
111
|
+
hours: logwork.hours,
|
|
112
|
+
status: logwork.status,
|
|
113
|
+
taskName: logwork.taskName
|
|
114
|
+
});
|
|
115
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
export function parseRoEditArgs(args = []) {
|
|
2
|
+
const options = {
|
|
3
|
+
logworkId: undefined,
|
|
4
|
+
hours: undefined,
|
|
5
|
+
taskName: undefined,
|
|
6
|
+
description: undefined,
|
|
7
|
+
typeOfWork: undefined,
|
|
8
|
+
yes: false,
|
|
9
|
+
json: false,
|
|
10
|
+
help: false
|
|
11
|
+
};
|
|
12
|
+
const positional = [];
|
|
13
|
+
|
|
14
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
15
|
+
const arg = args[index];
|
|
16
|
+
if (arg === '--help' || arg === '-h') {
|
|
17
|
+
options.help = true;
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (arg === '--yes' || arg === '-y') {
|
|
21
|
+
options.yes = true;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (arg === '--json') {
|
|
25
|
+
options.json = true;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (arg === '--hours' || arg === '--logtimes') {
|
|
29
|
+
const value = args[index + 1];
|
|
30
|
+
if (value === undefined) {
|
|
31
|
+
throw new Error(`${arg} requires a positive number.`);
|
|
32
|
+
}
|
|
33
|
+
options.hours = normalizeHours(value);
|
|
34
|
+
index += 1;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (arg.startsWith('--hours=') || arg.startsWith('--logtimes=')) {
|
|
38
|
+
options.hours = normalizeHours(arg.slice(arg.indexOf('=') + 1));
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (arg === '--task-name' || arg === '--task') {
|
|
42
|
+
const values = [];
|
|
43
|
+
while (args[index + 1] !== undefined && !String(args[index + 1]).startsWith('-')) {
|
|
44
|
+
values.push(args[index + 1]);
|
|
45
|
+
index += 1;
|
|
46
|
+
}
|
|
47
|
+
options.taskName = normalizeTaskName(values.join(' '), arg);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (arg.startsWith('--task-name=') || arg.startsWith('--task=')) {
|
|
51
|
+
options.taskName = normalizeTaskName(arg.slice(arg.indexOf('=') + 1), arg.split('=')[0]);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (arg === '--description' || arg === '--desc') {
|
|
55
|
+
const values = [];
|
|
56
|
+
while (args[index + 1] !== undefined && !String(args[index + 1]).startsWith('--')) {
|
|
57
|
+
values.push(args[index + 1]);
|
|
58
|
+
index += 1;
|
|
59
|
+
}
|
|
60
|
+
options.description = normalizeDescription(values.join(' '), arg);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (arg.startsWith('--description=') || arg.startsWith('--desc=')) {
|
|
64
|
+
options.description = normalizeDescription(arg.slice(arg.indexOf('=') + 1), arg.split('=')[0]);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (arg === '--type' || arg === '--type-of-work') {
|
|
68
|
+
options.typeOfWork = normalizeTypeOfWork(args[index + 1], arg);
|
|
69
|
+
index += 1;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (arg.startsWith('--type=') || arg.startsWith('--type-of-work=')) {
|
|
73
|
+
options.typeOfWork = normalizeTypeOfWork(arg.slice(arg.indexOf('=') + 1), arg.split('=')[0]);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (String(arg).startsWith('-')) {
|
|
77
|
+
throw new Error(`Unknown edit option: ${arg}`);
|
|
78
|
+
}
|
|
79
|
+
positional.push(arg);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (options.help) {
|
|
83
|
+
return options;
|
|
84
|
+
}
|
|
85
|
+
if (positional.length !== 1) {
|
|
86
|
+
throw new Error('RO edit requires exactly one logwork id.');
|
|
87
|
+
}
|
|
88
|
+
if (
|
|
89
|
+
options.hours === undefined &&
|
|
90
|
+
options.taskName === undefined &&
|
|
91
|
+
options.description === undefined &&
|
|
92
|
+
options.typeOfWork === undefined
|
|
93
|
+
) {
|
|
94
|
+
throw new Error('RO edit requires at least one of --hours, --task, --desc, --type.');
|
|
95
|
+
}
|
|
96
|
+
options.logworkId = positional[0];
|
|
97
|
+
return options;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function normalizeHours(value) {
|
|
101
|
+
const hours = Number(value);
|
|
102
|
+
if (!Number.isFinite(hours) || hours <= 0) {
|
|
103
|
+
throw new Error('--hours must be a positive number.');
|
|
104
|
+
}
|
|
105
|
+
const rounded = Number(hours.toFixed(2));
|
|
106
|
+
if (rounded <= 0) {
|
|
107
|
+
throw new Error('--hours must be at least 0.01 after rounding.');
|
|
108
|
+
}
|
|
109
|
+
return rounded;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function normalizeTaskName(value, option) {
|
|
113
|
+
const taskName = String(value || '').trim();
|
|
114
|
+
if (!taskName) {
|
|
115
|
+
throw new Error(`${option} requires a non-empty value.`);
|
|
116
|
+
}
|
|
117
|
+
if (taskName.length > 1_000) {
|
|
118
|
+
throw new Error('--task-name must not exceed 1000 characters.');
|
|
119
|
+
}
|
|
120
|
+
return taskName;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function normalizeDescription(value, option) {
|
|
124
|
+
const description = String(value || '').trim();
|
|
125
|
+
if (!description) {
|
|
126
|
+
throw new Error(`${option} requires a non-empty value.`);
|
|
127
|
+
}
|
|
128
|
+
if (description.length > 1_000) {
|
|
129
|
+
throw new Error('--description must not exceed 1000 characters.');
|
|
130
|
+
}
|
|
131
|
+
return description;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const TYPE_OF_WORK = ['create', 'correct', 'improve', 'other'];
|
|
135
|
+
|
|
136
|
+
function normalizeTypeOfWork(value, option) {
|
|
137
|
+
const typeOfWork = String(value ?? '').trim();
|
|
138
|
+
if (!TYPE_OF_WORK.includes(typeOfWork)) {
|
|
139
|
+
throw new Error(`${option} must be one of: ${TYPE_OF_WORK.join(', ')}.`);
|
|
140
|
+
}
|
|
141
|
+
return typeOfWork;
|
|
142
|
+
}
|