logwork-cli 1.1.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -25
- package/RELEASE.md +4 -2
- package/bin/logwork.mjs +2 -2
- package/commands/commits.mjs +128 -13
- package/commands/history.mjs +0 -4
- package/commands/mcp.mjs +266 -0
- package/commands/quick.mjs +38 -23
- package/commands/reminder.mjs +41 -2
- package/commands/setup.mjs +197 -42
- package/commands/tui.mjs +1 -0
- package/docs/advanced.md +18 -7
- package/docs/mcp-setup.md +120 -26
- package/docs/operations-guide.md +50 -2
- package/docs/recipes.md +40 -0
- package/docs/security.md +23 -2
- package/examples/mcp/antigravity.mcp_config.json +2 -2
- package/examples/mcp/codex.config.toml +2 -2
- package/examples/mcp/copilot.mcp.json +2 -2
- package/lib/api-auth-forms.mjs +393 -0
- package/lib/api-auth-http.mjs +184 -0
- package/lib/api-auth-support.mjs +101 -0
- package/lib/api-auth-tokens.mjs +129 -0
- package/lib/api-auth.mjs +39 -813
- package/lib/api.mjs +38 -1422
- package/lib/apply-ledger.mjs +1 -1
- package/lib/batch-workflow.mjs +187 -2
- package/lib/commit-workflow.mjs +345 -23
- package/lib/config.mjs +7 -0
- package/lib/diagnostics.mjs +116 -4
- package/lib/jira-api.mjs +25 -3
- package/lib/jira-workflow.mjs +36 -6
- package/lib/launcher.mjs +303 -0
- package/lib/logwork-config.mjs +64 -13
- package/lib/manual-apply-state.mjs +15 -0
- package/lib/manual-commands.mjs +2 -2
- package/lib/manual-ink-app.mjs +142 -1282
- package/lib/manual-ink-draft-actions.mjs +227 -0
- package/lib/manual-ink-interaction.mjs +318 -0
- package/lib/manual-ink-session.mjs +41 -0
- package/lib/manual-ink-task-editor.mjs +338 -0
- package/lib/manual-ink-ui.mjs +38 -0
- package/lib/manual-ink-wizard.mjs +520 -0
- package/lib/manual-mcp.mjs +78 -20
- package/lib/manual-repl.mjs +171 -22
- package/lib/mcp-install.mjs +930 -0
- package/lib/mcp-server.mjs +51 -20
- package/lib/node-runtime.mjs +155 -0
- package/lib/paths.mjs +7 -0
- package/lib/project-mapping-workflow.mjs +32 -27
- package/lib/query-workflow.mjs +19 -1
- package/lib/reconciliation-workflow.mjs +1 -1
- package/lib/reminder-config.mjs +38 -2
- package/lib/reminder-scheduler.mjs +85 -18
- package/lib/reminder-service.mjs +42 -3
- package/lib/reminder-workflow.mjs +118 -22
- package/lib/repo-project-suggestions.mjs +285 -0
- package/lib/ro-api-diagnostics.mjs +109 -0
- package/lib/ro-api-entries.mjs +359 -0
- package/lib/ro-api-fields.mjs +290 -0
- package/lib/ro-api-http.mjs +68 -0
- package/lib/ro-api-projects.mjs +137 -0
- package/lib/ro-api-tasks.mjs +50 -0
- package/lib/ro-api-timesheet.mjs +413 -0
- package/lib/ro-delete-workflow.mjs +2 -1
- package/lib/ro-entry-reader.mjs +11 -6
- package/lib/ro-resubmit-workflow.mjs +2 -1
- package/lib/terminal-auth-prompts.mjs +7 -4
- package/lib/ui.mjs +121 -56
- package/package.json +11 -4
- package/skills/logwork/SKILL.md +10 -4
|
@@ -3,6 +3,7 @@ import { promises as fs } from 'node:fs';
|
|
|
3
3
|
import { homedir, userInfo } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { atomicWriteFile } from './atomic-file.mjs';
|
|
6
|
+
import { resolveLaunchCommand, writeLauncher } from './launcher.mjs';
|
|
6
7
|
import { helperHome } from './paths.mjs';
|
|
7
8
|
import { normalizeReminderConfig } from './reminder-config.mjs';
|
|
8
9
|
|
|
@@ -12,18 +13,24 @@ export const WINDOWS_REMINDER_TASK_NAME = 'Logwork Reminder';
|
|
|
12
13
|
// point at the old runtime, so installing or removing the reminder clears them.
|
|
13
14
|
export const LEGACY_MACOS_REMINDER_LABEL = 'sg.vinova.logwork-helper.reminder';
|
|
14
15
|
export const LEGACY_WINDOWS_REMINDER_TASK_NAME = 'Logwork Helper Reminder';
|
|
15
|
-
// The scheduler runs
|
|
16
|
+
// The scheduler runs the launch command from lib/launcher.mjs with
|
|
17
|
+
// `reminder run --home <home>`: on macOS the <home>/bin/logwork-launch script
|
|
18
|
+
// (it finds Node itself), on Windows a stable node.exe plus
|
|
19
|
+
// <home>\bin\logwork.mjs.
|
|
16
20
|
export const REMINDER_RUNNER_RELATIVE_PATH = join('bin', 'logwork.mjs');
|
|
17
21
|
|
|
18
22
|
export async function installReminderScheduler(config, {
|
|
19
23
|
platform = process.platform,
|
|
20
24
|
nodePath = process.execPath,
|
|
21
25
|
home = helperHome(),
|
|
26
|
+
env = process.env,
|
|
27
|
+
launch = resolveLaunchCommand({ platform, home, execPath: nodePath, env }),
|
|
22
28
|
userHome = homedir(),
|
|
23
29
|
uid = userInfo().uid,
|
|
24
30
|
run = runCommand,
|
|
25
31
|
writeFile = atomicWriteFile,
|
|
26
|
-
accessRunner = fs.access
|
|
32
|
+
accessRunner = fs.access,
|
|
33
|
+
writeLauncherFn = writeLauncher
|
|
27
34
|
} = {}) {
|
|
28
35
|
const normalized = normalizeReminderConfig({ ...config, enabled: true });
|
|
29
36
|
const runnerPath = join(home, REMINDER_RUNNER_RELATIVE_PATH);
|
|
@@ -32,14 +39,17 @@ export async function installReminderScheduler(config, {
|
|
|
32
39
|
} catch {
|
|
33
40
|
throw new Error(`Reminder runtime is missing at ${runnerPath}. Run \`logwork setup --no-login\` and retry.`);
|
|
34
41
|
}
|
|
42
|
+
if (launch.kind === 'launcher') {
|
|
43
|
+
// `reminder enable` can run before `logwork setup` wrote the launcher.
|
|
44
|
+
await writeLauncherFn({ home, nodePath, platform });
|
|
45
|
+
}
|
|
35
46
|
await removeLegacyReminderScheduler({ platform, userHome, uid, run });
|
|
36
47
|
|
|
37
48
|
if (platform === 'darwin') {
|
|
38
49
|
const plistPath = macosReminderPlistPath(userHome);
|
|
39
50
|
await writeFile(plistPath, buildMacosReminderPlist({
|
|
40
51
|
config: normalized,
|
|
41
|
-
|
|
42
|
-
runnerPath,
|
|
52
|
+
launch,
|
|
43
53
|
home
|
|
44
54
|
}));
|
|
45
55
|
const domain = `gui/${uid}`;
|
|
@@ -58,8 +68,7 @@ export async function installReminderScheduler(config, {
|
|
|
58
68
|
if (platform === 'win32') {
|
|
59
69
|
const result = await run('schtasks.exe', buildWindowsReminderCreateArgs({
|
|
60
70
|
config: normalized,
|
|
61
|
-
|
|
62
|
-
runnerPath,
|
|
71
|
+
launch,
|
|
63
72
|
home
|
|
64
73
|
}));
|
|
65
74
|
assertCommandSuccess(result, 'schtasks /Create');
|
|
@@ -119,7 +128,8 @@ export async function getReminderSchedulerStatus({
|
|
|
119
128
|
userHome = homedir(),
|
|
120
129
|
uid = userInfo().uid,
|
|
121
130
|
run = runCommand,
|
|
122
|
-
accessFile = fs.access
|
|
131
|
+
accessFile = fs.access,
|
|
132
|
+
readFile = fs.readFile
|
|
123
133
|
} = {}) {
|
|
124
134
|
if (platform === 'darwin') {
|
|
125
135
|
const plistPath = macosReminderPlistPath(userHome);
|
|
@@ -130,7 +140,8 @@ export async function getReminderSchedulerStatus({
|
|
|
130
140
|
installed: fileExists && result.code === 0,
|
|
131
141
|
scheduler: 'launchd',
|
|
132
142
|
identifier: MACOS_REMINDER_LABEL,
|
|
133
|
-
path: plistPath
|
|
143
|
+
path: plistPath,
|
|
144
|
+
nodePath: fileExists ? await readReminderSchedulerNodePath({ platform, userHome, run, readFile }) : null
|
|
134
145
|
};
|
|
135
146
|
}
|
|
136
147
|
|
|
@@ -141,14 +152,63 @@ export async function getReminderSchedulerStatus({
|
|
|
141
152
|
installed: result.code === 0,
|
|
142
153
|
scheduler: 'Task Scheduler',
|
|
143
154
|
identifier: WINDOWS_REMINDER_TASK_NAME,
|
|
144
|
-
path: null
|
|
155
|
+
path: null,
|
|
156
|
+
nodePath: result.code === 0 ? await readReminderSchedulerNodePath({ platform, userHome, run, readFile }) : null
|
|
145
157
|
};
|
|
146
158
|
}
|
|
147
159
|
|
|
148
160
|
throw unsupportedReminderPlatform(platform);
|
|
149
161
|
}
|
|
150
162
|
|
|
151
|
-
|
|
163
|
+
// The command the installed schedule runs: the first ProgramArguments entry of
|
|
164
|
+
// the launchd plist (the launcher, or a raw node path from an older install),
|
|
165
|
+
// or the <Command> of the Windows task (node.exe). Returns null when no
|
|
166
|
+
// schedule is installed or it cannot be read. Read-only.
|
|
167
|
+
export async function readReminderSchedulerNodePath({
|
|
168
|
+
platform = process.platform,
|
|
169
|
+
userHome = homedir(),
|
|
170
|
+
run = runCommand,
|
|
171
|
+
readFile = fs.readFile
|
|
172
|
+
} = {}) {
|
|
173
|
+
try {
|
|
174
|
+
if (platform === 'darwin') {
|
|
175
|
+
const plist = await readFile(macosReminderPlistPath(userHome), 'utf8');
|
|
176
|
+
return parseMacosPlistNodePath(plist);
|
|
177
|
+
}
|
|
178
|
+
if (platform === 'win32') {
|
|
179
|
+
const result = await run('schtasks.exe', ['/Query', '/TN', WINDOWS_REMINDER_TASK_NAME, '/XML']);
|
|
180
|
+
return result.code === 0 ? parseWindowsTaskNodePath(result.stdout) : null;
|
|
181
|
+
}
|
|
182
|
+
} catch {
|
|
183
|
+
// Missing or unreadable schedule: nothing to compare.
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function parseMacosPlistNodePath(plist) {
|
|
189
|
+
const match = /<key>ProgramArguments<\/key>\s*<array>\s*<string>([^<]*)<\/string>/.exec(String(plist || ''));
|
|
190
|
+
return match ? unescapeXml(match[1]).trim() || null : null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function parseWindowsTaskNodePath(xml) {
|
|
194
|
+
// schtasks may write the XML as UTF-16; decoded as UTF-8 that leaves NULs.
|
|
195
|
+
const match = /<Command>([^<]*)<\/Command>/i.exec(String(xml || '').replaceAll('\0', ''));
|
|
196
|
+
if (!match) {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
// schtasks keeps the quotes /TR put around a path with spaces.
|
|
200
|
+
return unescapeXml(match[1]).trim().replace(/^"(.*)"$/, '$1') || null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// `launch` is { command, argsPrefix }. Older callers pass nodePath + runnerPath.
|
|
204
|
+
function scheduleProgram({ launch, nodePath, runnerPath, home }) {
|
|
205
|
+
const program = launch
|
|
206
|
+
? [launch.command, ...(launch.argsPrefix || [])]
|
|
207
|
+
: [nodePath, runnerPath];
|
|
208
|
+
return [...program, 'reminder', 'run', '--home', home];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function buildMacosReminderPlist({ config, launch, nodePath, runnerPath, home }) {
|
|
152
212
|
const normalized = normalizeReminderConfig({ ...config, enabled: true });
|
|
153
213
|
const [hour, minute] = normalized.time.split(':').map(Number);
|
|
154
214
|
const calendarEntries = normalized.days.map((weekday) => ` <dict>
|
|
@@ -156,6 +216,9 @@ export function buildMacosReminderPlist({ config, nodePath, runnerPath, home })
|
|
|
156
216
|
<key>Hour</key><integer>${hour}</integer>
|
|
157
217
|
<key>Minute</key><integer>${minute}</integer>
|
|
158
218
|
</dict>`).join('\n');
|
|
219
|
+
const programArguments = scheduleProgram({ launch, nodePath, runnerPath, home })
|
|
220
|
+
.map((part) => ` <string>${escapeXml(assertSafePath(part))}</string>`)
|
|
221
|
+
.join('\n');
|
|
159
222
|
|
|
160
223
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
161
224
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
@@ -164,12 +227,7 @@ export function buildMacosReminderPlist({ config, nodePath, runnerPath, home })
|
|
|
164
227
|
<key>Label</key><string>${escapeXml(MACOS_REMINDER_LABEL)}</string>
|
|
165
228
|
<key>ProgramArguments</key>
|
|
166
229
|
<array>
|
|
167
|
-
|
|
168
|
-
<string>${escapeXml(assertSafePath(runnerPath))}</string>
|
|
169
|
-
<string>reminder</string>
|
|
170
|
-
<string>run</string>
|
|
171
|
-
<string>--home</string>
|
|
172
|
-
<string>${escapeXml(assertSafePath(home))}</string>
|
|
230
|
+
${programArguments}
|
|
173
231
|
</array>
|
|
174
232
|
<key>WorkingDirectory</key><string>${escapeXml(assertSafePath(home))}</string>
|
|
175
233
|
<key>StartCalendarInterval</key>
|
|
@@ -183,9 +241,9 @@ ${calendarEntries}
|
|
|
183
241
|
`;
|
|
184
242
|
}
|
|
185
243
|
|
|
186
|
-
export function buildWindowsReminderCreateArgs({ config, nodePath, runnerPath, home }) {
|
|
244
|
+
export function buildWindowsReminderCreateArgs({ config, launch, nodePath, runnerPath, home }) {
|
|
187
245
|
const normalized = normalizeReminderConfig({ ...config, enabled: true });
|
|
188
|
-
const action =
|
|
246
|
+
const action = scheduleProgram({ launch, nodePath, runnerPath, home })
|
|
189
247
|
.map(quoteWindowsActionPart)
|
|
190
248
|
.join(' ');
|
|
191
249
|
const dayNames = normalized.days.map((day) => ({
|
|
@@ -296,6 +354,15 @@ function escapeXml(value) {
|
|
|
296
354
|
.replaceAll("'", ''');
|
|
297
355
|
}
|
|
298
356
|
|
|
357
|
+
function unescapeXml(value) {
|
|
358
|
+
return String(value)
|
|
359
|
+
.replaceAll('<', '<')
|
|
360
|
+
.replaceAll('>', '>')
|
|
361
|
+
.replaceAll('"', '"')
|
|
362
|
+
.replaceAll(''', "'")
|
|
363
|
+
.replaceAll('&', '&');
|
|
364
|
+
}
|
|
365
|
+
|
|
299
366
|
function unsupportedReminderPlatform(platform) {
|
|
300
367
|
return new Error(`Reminder scheduling is unsupported on ${platform}. Supported platforms are macOS and Windows.`);
|
|
301
368
|
}
|
package/lib/reminder-service.mjs
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { checkLaunchCommand, formatLaunchCheckIssue } from './launcher.mjs';
|
|
2
|
+
import { formatNodePathIssue, inspectScheduledCommand } from './node-runtime.mjs';
|
|
1
3
|
import {
|
|
2
4
|
loadReminderConfig,
|
|
3
5
|
normalizeReminderConfig,
|
|
6
|
+
normalizeReminderRepos,
|
|
4
7
|
saveReminderConfig
|
|
5
8
|
} from './reminder-config.mjs';
|
|
6
9
|
import {
|
|
@@ -11,9 +14,12 @@ import {
|
|
|
11
14
|
import { loadReminderState } from './reminder-state.mjs';
|
|
12
15
|
import { sendTestReminderNotification } from './reminder-workflow.mjs';
|
|
13
16
|
|
|
17
|
+
// repos: undefined keeps the configured commit repos; an array (even empty)
|
|
18
|
+
// replaces them.
|
|
14
19
|
export async function enableLogworkReminder({
|
|
15
20
|
time,
|
|
16
21
|
target,
|
|
22
|
+
repos,
|
|
17
23
|
confirm,
|
|
18
24
|
loadConfig = loadReminderConfig,
|
|
19
25
|
saveConfig = saveReminderConfig,
|
|
@@ -26,7 +32,8 @@ export async function enableLogworkReminder({
|
|
|
26
32
|
...current,
|
|
27
33
|
enabled: true,
|
|
28
34
|
...(time ? { time } : {}),
|
|
29
|
-
...(target ? { target } : {})
|
|
35
|
+
...(target ? { target } : {}),
|
|
36
|
+
...(repos !== undefined ? { repos: normalizeReminderRepos(repos) } : {})
|
|
30
37
|
});
|
|
31
38
|
|
|
32
39
|
const scheduler = await installScheduler(config);
|
|
@@ -41,7 +48,12 @@ export async function enableLogworkReminder({
|
|
|
41
48
|
status: 'enabled',
|
|
42
49
|
config,
|
|
43
50
|
scheduler,
|
|
44
|
-
summary:
|
|
51
|
+
summary: [
|
|
52
|
+
`Enabled ${config.target} logwork reminder at ${config.time}, Monday-Friday, using ${scheduler.scheduler}.`,
|
|
53
|
+
config.repos.length
|
|
54
|
+
? `Commit summary repos: ${config.repos.join(', ')}.`
|
|
55
|
+
: 'No reminder repos set; add --repo <path> to include today\'s commits (repos on project mappings are also used).'
|
|
56
|
+
].join('\n')
|
|
45
57
|
};
|
|
46
58
|
}
|
|
47
59
|
|
|
@@ -69,7 +81,9 @@ export async function disableLogworkReminder({
|
|
|
69
81
|
export async function getLogworkReminderStatus({
|
|
70
82
|
loadConfig = loadReminderConfig,
|
|
71
83
|
loadState = loadReminderState,
|
|
72
|
-
schedulerStatus = getReminderSchedulerStatus
|
|
84
|
+
schedulerStatus = getReminderSchedulerStatus,
|
|
85
|
+
inspectNode = inspectScheduledCommand,
|
|
86
|
+
checkLaunch = checkLaunchCommand
|
|
73
87
|
} = {}) {
|
|
74
88
|
const [config, state, scheduler] = await Promise.all([
|
|
75
89
|
loadConfig(),
|
|
@@ -82,9 +96,32 @@ export async function getLogworkReminderStatus({
|
|
|
82
96
|
`Schedule: Monday-Friday at ${config.time}; target ${config.target}.`,
|
|
83
97
|
`OS scheduler: ${scheduler.scheduler} ${scheduler.installed ? 'installed' : 'not installed'}.`
|
|
84
98
|
];
|
|
99
|
+
if (config.repos?.length) {
|
|
100
|
+
lines.push(`Commit summary repos: ${config.repos.join(', ')}.`);
|
|
101
|
+
}
|
|
85
102
|
if (!consistent) {
|
|
86
103
|
lines.push('Warning: reminder config and OS scheduler are out of sync; run reminder enable or disable again.');
|
|
87
104
|
}
|
|
105
|
+
// The schedule stores an absolute command: the launcher on macOS (an older
|
|
106
|
+
// install: a raw node path), node.exe on Windows. nvm or Homebrew can remove
|
|
107
|
+
// a node while the schedule still looks installed.
|
|
108
|
+
const nodeCheck = scheduler.nodePath
|
|
109
|
+
? await inspectNode(scheduler.nodePath).catch(() => ({ status: 'unknown', recordedPath: scheduler.nodePath }))
|
|
110
|
+
: null;
|
|
111
|
+
const nodeIssue = nodeCheck ? formatNodePathIssue('reminder', nodeCheck) : '';
|
|
112
|
+
if (nodeIssue) {
|
|
113
|
+
lines.push(`Warning: ${nodeIssue}`);
|
|
114
|
+
}
|
|
115
|
+
let launchCheck = null;
|
|
116
|
+
if (nodeCheck?.status === 'ok' && nodeCheck.kind === 'launcher') {
|
|
117
|
+
launchCheck = await checkLaunch().catch(() => null);
|
|
118
|
+
const launchIssue = formatLaunchCheckIssue(launchCheck);
|
|
119
|
+
if (launchIssue) {
|
|
120
|
+
lines.push(`Warning: ${launchIssue}`);
|
|
121
|
+
} else if (launchCheck?.node) {
|
|
122
|
+
lines.push(`Launcher: runs Node ${launchCheck.node}.`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
88
125
|
if (state?.lastRunAt) {
|
|
89
126
|
lines.push(`Last run: ${state.lastRunAt}, status ${state.lastStatus || 'unknown'}.`);
|
|
90
127
|
}
|
|
@@ -94,6 +131,8 @@ export async function getLogworkReminderStatus({
|
|
|
94
131
|
scheduler,
|
|
95
132
|
state,
|
|
96
133
|
consistent,
|
|
134
|
+
...(nodeCheck ? { nodeCheck } : {}),
|
|
135
|
+
...(launchCheck ? { launchCheck } : {}),
|
|
97
136
|
summary: lines.join('\n')
|
|
98
137
|
};
|
|
99
138
|
}
|
|
@@ -1,26 +1,37 @@
|
|
|
1
1
|
import { isAuthRequiredError } from './auth-errors.mjs';
|
|
2
2
|
import { redactText } from './auth-redaction.mjs';
|
|
3
|
+
import { draftLogworkFromCommits } from './commit-workflow.mjs';
|
|
3
4
|
import { withFileLock } from './file-lock.mjs';
|
|
4
5
|
import { searchJiraIssues } from './jira-api.mjs';
|
|
5
6
|
import {
|
|
6
7
|
getStoredJiraSession,
|
|
7
8
|
isJiraAuthRequiredError
|
|
8
9
|
} from './jira-auth.mjs';
|
|
10
|
+
import { loadLocalConfig } from './logwork-config.mjs';
|
|
9
11
|
import { sendOsNotification } from './os-notification.mjs';
|
|
10
|
-
import { reminderStatePath } from './paths.mjs';
|
|
12
|
+
import { helperHome, reminderStatePath } from './paths.mjs';
|
|
11
13
|
import { queryLogwork } from './query-workflow.mjs';
|
|
12
|
-
import { loadReminderConfig } from './reminder-config.mjs';
|
|
14
|
+
import { loadReminderConfig, normalizeReminderRepos } from './reminder-config.mjs';
|
|
13
15
|
import {
|
|
14
16
|
loadReminderState,
|
|
15
17
|
saveReminderState
|
|
16
18
|
} from './reminder-state.mjs';
|
|
17
19
|
import { todayLocalDateISO } from './util.mjs';
|
|
18
20
|
|
|
21
|
+
const REMINDER_HEADER = 'Logwork hôm nay chưa hoàn tất';
|
|
22
|
+
const DEFAULT_ACTION_LINE = 'Mở Terminal và chạy: logwork';
|
|
23
|
+
const COMMITS_ACTION_LINE = 'Mở Terminal và chạy: logwork commits today (bản nháp từ commit)';
|
|
24
|
+
const COMMIT_SUMMARY_TIMEOUT_MS = 20_000;
|
|
25
|
+
|
|
26
|
+
// repos: git repos whose commits back up a missing-RO reminder. Without repos,
|
|
27
|
+
// or when reading them fails, the message is the plain RO/Jira one.
|
|
19
28
|
export async function checkLogworkReminder({
|
|
20
29
|
target = 'both',
|
|
21
30
|
date = todayLocalDateISO(),
|
|
31
|
+
repos = [],
|
|
22
32
|
queryRo = queryResourceOptimiserDay,
|
|
23
|
-
queryJira = queryJiraWorklogDay
|
|
33
|
+
queryJira = queryJiraWorklogDay,
|
|
34
|
+
summarizeCommits = summarizeTodayCommits
|
|
24
35
|
} = {}) {
|
|
25
36
|
const checks = {};
|
|
26
37
|
const jobs = [];
|
|
@@ -42,15 +53,16 @@ export async function checkLogworkReminder({
|
|
|
42
53
|
|
|
43
54
|
const problemLines = buildProblemLines(checks);
|
|
44
55
|
const notify = problemLines.length > 0;
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
56
|
+
let commits = null;
|
|
57
|
+
if (notify && checks.ro?.status === 'incomplete' && repos?.length) {
|
|
58
|
+
// Commit evidence is a bonus: any failure keeps the plain message.
|
|
59
|
+
try {
|
|
60
|
+
commits = await summarizeCommits({ date, repos });
|
|
61
|
+
} catch (error) {
|
|
62
|
+
commits = { status: 'unavailable', error: redactText(error.message || String(error)).slice(0, 300) };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const notification = notify ? buildReminderNotification({ problemLines, commits }) : null;
|
|
54
66
|
|
|
55
67
|
return {
|
|
56
68
|
date,
|
|
@@ -58,6 +70,7 @@ export async function checkLogworkReminder({
|
|
|
58
70
|
status: notify ? 'notification_required' : 'complete',
|
|
59
71
|
notify,
|
|
60
72
|
checks,
|
|
73
|
+
...(commits ? { commits } : {}),
|
|
61
74
|
notification,
|
|
62
75
|
summary: notify
|
|
63
76
|
? `Logwork reminder requires attention: ${problemLines.join(' | ')}`
|
|
@@ -117,8 +130,81 @@ export async function queryJiraWorklogDay({
|
|
|
117
130
|
};
|
|
118
131
|
}
|
|
119
132
|
|
|
133
|
+
// Counts today's commits in the given repos with the read-only commit draft
|
|
134
|
+
// (no RO query: the reminder already has the RO totals). Throws when no repo
|
|
135
|
+
// could be read, so the caller falls back to the plain message.
|
|
136
|
+
export async function summarizeTodayCommits({
|
|
137
|
+
date,
|
|
138
|
+
repos,
|
|
139
|
+
draft = draftLogworkFromCommits,
|
|
140
|
+
cwd = helperHome(),
|
|
141
|
+
timeoutMs = COMMIT_SUMMARY_TIMEOUT_MS
|
|
142
|
+
} = {}) {
|
|
143
|
+
const result = await withTimeout(
|
|
144
|
+
draft({ from: date, repos, useBookings: false, cwd }),
|
|
145
|
+
timeoutMs,
|
|
146
|
+
'Reading commits timed out.'
|
|
147
|
+
);
|
|
148
|
+
const repoResults = Array.isArray(result?.repos) ? result.repos : [];
|
|
149
|
+
const readable = repoResults.filter((repo) => !repo.error);
|
|
150
|
+
if (!readable.length) {
|
|
151
|
+
throw new Error('None of the reminder repos could be read.');
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
status: 'ok',
|
|
155
|
+
commitCount: Number(result.commitCount || 0),
|
|
156
|
+
repoCount: readable.filter((repo) => Number(repo.commitCount || 0) > 0).length,
|
|
157
|
+
readRepoCount: readable.length,
|
|
158
|
+
failedRepoCount: repoResults.length - readable.length
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Reminder repos plus any `repos` listed on project mappings in the local
|
|
163
|
+
// config. Mapping repos are optional and read defensively.
|
|
164
|
+
export async function collectReminderRepos({
|
|
165
|
+
config,
|
|
166
|
+
loadMappings = loadLocalConfig,
|
|
167
|
+
cwd = helperHome()
|
|
168
|
+
} = {}) {
|
|
169
|
+
const repos = [...(Array.isArray(config?.repos) ? config.repos : [])];
|
|
170
|
+
let mappings = [];
|
|
171
|
+
try {
|
|
172
|
+
mappings = (await loadMappings(cwd))?.projectMappings || [];
|
|
173
|
+
} catch {
|
|
174
|
+
mappings = [];
|
|
175
|
+
}
|
|
176
|
+
for (const mapping of Array.isArray(mappings) ? mappings : []) {
|
|
177
|
+
if (!Array.isArray(mapping?.repos)) {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
for (const repo of mapping.repos) {
|
|
181
|
+
try {
|
|
182
|
+
repos.push(...normalizeReminderRepos([repo], { cwd }));
|
|
183
|
+
} catch {
|
|
184
|
+
// Skip a malformed mapping path.
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return [...new Set(repos)];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function buildReminderNotification({ header = REMINDER_HEADER, problemLines = [], commits = null } = {}) {
|
|
192
|
+
const hasCommits = commits?.status === 'ok' && commits.commitCount > 0;
|
|
193
|
+
return {
|
|
194
|
+
title: 'Logwork',
|
|
195
|
+
message: [
|
|
196
|
+
header,
|
|
197
|
+
...problemLines,
|
|
198
|
+
...(hasCommits ? [`Hôm nay: ${commits.commitCount} commit trong ${commits.repoCount} repo`] : []),
|
|
199
|
+
'',
|
|
200
|
+
hasCommits ? COMMITS_ACTION_LINE : DEFAULT_ACTION_LINE
|
|
201
|
+
].join('\n')
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
120
205
|
export async function runScheduledReminder({
|
|
121
206
|
loadConfig = loadReminderConfig,
|
|
207
|
+
collectRepos = collectReminderRepos,
|
|
122
208
|
loadState = loadReminderState,
|
|
123
209
|
saveState = saveReminderState,
|
|
124
210
|
check = checkLogworkReminder,
|
|
@@ -165,9 +251,11 @@ export async function runScheduledReminder({
|
|
|
165
251
|
return result;
|
|
166
252
|
}
|
|
167
253
|
|
|
254
|
+
const repos = await collectRepos({ config }).catch(() => []);
|
|
168
255
|
const checkResult = await check({
|
|
169
256
|
target: config.target,
|
|
170
|
-
date
|
|
257
|
+
date,
|
|
258
|
+
repos
|
|
171
259
|
});
|
|
172
260
|
if (!checkResult.notify) {
|
|
173
261
|
const result = {
|
|
@@ -219,15 +307,13 @@ export async function sendTestReminderNotification({
|
|
|
219
307
|
if (!lines.length) {
|
|
220
308
|
throw new Error('Reminder target must be ro, jira, or both.');
|
|
221
309
|
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
].join('\n')
|
|
230
|
-
};
|
|
310
|
+
// Sample data in the real message format, including the commit line a
|
|
311
|
+
// reminder with repos shows when RO hours are missing.
|
|
312
|
+
const notification = buildReminderNotification({
|
|
313
|
+
header: 'Đây là notification thử nghiệm',
|
|
314
|
+
problemLines: lines,
|
|
315
|
+
commits: target === 'jira' ? null : { status: 'ok', commitCount: 5, repoCount: 2 }
|
|
316
|
+
});
|
|
231
317
|
const result = await notify(notification);
|
|
232
318
|
return {
|
|
233
319
|
status: 'sent',
|
|
@@ -292,6 +378,16 @@ function formatHours(value) {
|
|
|
292
378
|
return Number(value || 0).toFixed(2).replace(/\.00$/, '').replace(/(\.\d)0$/, '$1');
|
|
293
379
|
}
|
|
294
380
|
|
|
381
|
+
function withTimeout(promise, timeoutMs, message) {
|
|
382
|
+
let timer;
|
|
383
|
+
return Promise.race([
|
|
384
|
+
promise,
|
|
385
|
+
new Promise((_, reject) => {
|
|
386
|
+
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
|
387
|
+
})
|
|
388
|
+
]).finally(() => clearTimeout(timer));
|
|
389
|
+
}
|
|
390
|
+
|
|
295
391
|
function assertDateOnly(value) {
|
|
296
392
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(value || ''))) {
|
|
297
393
|
throw new Error('Reminder date must use YYYY-MM-DD format.');
|