logwork-cli 0.0.0-stage → 1.1.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 +330 -2
- package/RELEASE.md +89 -0
- package/bin/logwork.mjs +104 -0
- package/commands/auth.mjs +227 -0
- package/commands/commits.mjs +82 -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 +508 -0
- package/commands/skill.mjs +91 -0
- package/commands/tui.mjs +50 -0
- package/commands/update.mjs +161 -0
- package/docs/advanced.md +334 -0
- package/docs/mcp-setup.md +368 -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/agent-skill.mjs +16 -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/commit-workflow.mjs +402 -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 +833 -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 +73 -4
- package/setup.sh +22 -0
- package/skills/logwork/SKILL.md +43 -0
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { CONFIG } from './config.mjs';
|
|
4
|
+
import { normalizeQueryRange, queryLogwork } from './query-workflow.mjs';
|
|
5
|
+
import { addLocalDays, todayLocalDateISO } from './util.mjs';
|
|
6
|
+
|
|
7
|
+
// Builds a logwork draft from the user's own git commits:
|
|
8
|
+
// commits (all branches, this author, in the period)
|
|
9
|
+
// -> grouped per day by repo + ticket + kind of work
|
|
10
|
+
// -> each group gets a default RO worklog task and a description
|
|
11
|
+
// -> the day's hours (RO booked - logged, or a standard day) are split across
|
|
12
|
+
// groups by how much work each group holds
|
|
13
|
+
// -> a "+<hours> <worklog task> | <description>" block for log_work.
|
|
14
|
+
// Nothing is written anywhere; the agent shows the draft, the user edits and
|
|
15
|
+
// approves it, then log_work previews and applies it as usual.
|
|
16
|
+
|
|
17
|
+
const TICKET_RE = /\b[A-Z][A-Z0-9]+-\d+\b/g;
|
|
18
|
+
const CONVENTIONAL_RE = /^(\w+)(?:\([^)]*\))?!?:\s*/;
|
|
19
|
+
const FIELD = '\x1f';
|
|
20
|
+
const RECORD = '\x1e';
|
|
21
|
+
const MAX_DESCRIPTION_LENGTH = 240;
|
|
22
|
+
|
|
23
|
+
// Default RO catalog task per kind of work. These are the shared default task
|
|
24
|
+
// names; a project with its own catalog may need a different name (list_tasks).
|
|
25
|
+
export const WORK_KINDS = Object.freeze({
|
|
26
|
+
feature: { task: 'Feature Development', label: 'feature' },
|
|
27
|
+
fix: { task: 'Bug Fix', label: 'fix' },
|
|
28
|
+
refactor: { task: 'Code Review & Refactoring', label: 'refactor/review' },
|
|
29
|
+
test: { task: 'Testing Activities', label: 'test' },
|
|
30
|
+
release: { task: 'Deployment & Release', label: 'build/deploy' },
|
|
31
|
+
design: { task: 'Requirements & Design', label: 'docs/design' },
|
|
32
|
+
other: { task: 'Other Activities', label: 'other' }
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const CONVENTIONAL_KINDS = new Map([
|
|
36
|
+
['feat', 'feature'], ['feature', 'feature'],
|
|
37
|
+
['fix', 'fix'], ['bugfix', 'fix'], ['hotfix', 'fix'], ['revert', 'fix'],
|
|
38
|
+
['refactor', 'refactor'], ['perf', 'refactor'], ['style', 'refactor'], ['review', 'refactor'],
|
|
39
|
+
['test', 'test'], ['tests', 'test'], ['e2e', 'test'],
|
|
40
|
+
['ci', 'release'], ['build', 'release'], ['release', 'release'], ['deploy', 'release'],
|
|
41
|
+
['docs', 'design'], ['doc', 'design'], ['design', 'design'], ['spec', 'design'],
|
|
42
|
+
['chore', 'other']
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
// Keyword fallback for non-conventional subjects, strongest signal first.
|
|
46
|
+
const KEYWORD_KINDS = [
|
|
47
|
+
[/\b(fix|fixes|fixed|bug|hotfix|crash|regression)\b/i, 'fix'],
|
|
48
|
+
[/\b(tests?|e2e|unit test|coverage|qa)\b/i, 'test'],
|
|
49
|
+
[/\b(deploy|deployment|release|bump|pipeline|docker|helm|k8s|kubernetes)\b/i, 'release'],
|
|
50
|
+
[/\b(refactor|cleanup|clean up|rename|review|lint|format)\b/i, 'refactor'],
|
|
51
|
+
[/\b(docs?|readme|design|spec|requirements?|diagram)\b/i, 'design']
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
export function classifyCommit(subject = '', body = '') {
|
|
55
|
+
const conventional = CONVENTIONAL_RE.exec(subject);
|
|
56
|
+
if (conventional) {
|
|
57
|
+
const kind = CONVENTIONAL_KINDS.get(conventional[1].toLowerCase());
|
|
58
|
+
if (kind) {
|
|
59
|
+
return kind;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
for (const text of [subject, body]) {
|
|
63
|
+
for (const [pattern, kind] of KEYWORD_KINDS) {
|
|
64
|
+
if (pattern.test(text)) {
|
|
65
|
+
return kind;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return 'feature';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function cleanSubject(subject = '') {
|
|
73
|
+
return String(subject).replace(CONVENTIONAL_RE, '').trim();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function parseGitLog(output = '') {
|
|
77
|
+
const commits = [];
|
|
78
|
+
for (const chunk of String(output).split(RECORD)) {
|
|
79
|
+
if (!chunk.trim()) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const [hash, timestamp, authorName, authorEmail, subject, body = '', stats = ''] = chunk.split(FIELD);
|
|
83
|
+
if (!hash || !timestamp) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
let additions = 0;
|
|
87
|
+
let deletions = 0;
|
|
88
|
+
let files = 0;
|
|
89
|
+
for (const line of stats.split(/\r?\n/)) {
|
|
90
|
+
const match = /^(\d+|-)\t(\d+|-)\t/.exec(line.trim());
|
|
91
|
+
if (match) {
|
|
92
|
+
files += 1;
|
|
93
|
+
additions += match[1] === '-' ? 0 : Number(match[1]);
|
|
94
|
+
deletions += match[2] === '-' ? 0 : Number(match[2]);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
commits.push({
|
|
98
|
+
hash: hash.trim(),
|
|
99
|
+
timestamp: Number(timestamp),
|
|
100
|
+
authorName: authorName.trim(),
|
|
101
|
+
authorEmail: authorEmail.trim(),
|
|
102
|
+
subject: subject.trim(),
|
|
103
|
+
body: body.trim(),
|
|
104
|
+
files,
|
|
105
|
+
additions,
|
|
106
|
+
deletions
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return commits;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function readRepoCommits({ repo, from, to, author, runGit = runGitCommand }) {
|
|
113
|
+
const root = (await runGit(repo, ['rev-parse', '--show-toplevel'])).trim();
|
|
114
|
+
const authorFilter = author || (await runGit(root, ['config', 'user.email']).catch(() => '')).trim();
|
|
115
|
+
if (!authorFilter) {
|
|
116
|
+
throw new Error(`No git author for ${root}. Set git config user.email or pass author.`);
|
|
117
|
+
}
|
|
118
|
+
const output = await runGit(root, [
|
|
119
|
+
'log',
|
|
120
|
+
// Every local and remote branch (work often sits on feature branches), but
|
|
121
|
+
// not refs/stash, whose "WIP on"/"index on" commits are not work items.
|
|
122
|
+
'--branches',
|
|
123
|
+
'--remotes',
|
|
124
|
+
'--no-merges',
|
|
125
|
+
`--since=${from}T00:00:00`,
|
|
126
|
+
`--until=${to}T00:00:00`,
|
|
127
|
+
`--author=${authorFilter}`,
|
|
128
|
+
'--regexp-ignore-case',
|
|
129
|
+
`--format=${RECORD}%H${FIELD}%at${FIELD}%an${FIELD}%ae${FIELD}%s${FIELD}%b${FIELD}`,
|
|
130
|
+
'--numstat'
|
|
131
|
+
]);
|
|
132
|
+
const seen = new Set();
|
|
133
|
+
const commits = parseGitLog(output)
|
|
134
|
+
.filter((commit) => !seen.has(commit.hash) && seen.add(commit.hash))
|
|
135
|
+
.map((commit) => ({ ...commit, repo: root, date: todayLocalDateISO(new Date(commit.timestamp * 1000)) }))
|
|
136
|
+
.filter((commit) => commit.date >= from && commit.date < to);
|
|
137
|
+
return { root, author: authorFilter, commits };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function groupCommits(commits = []) {
|
|
141
|
+
const groups = new Map();
|
|
142
|
+
for (const commit of [...commits].sort((a, b) => a.timestamp - b.timestamp)) {
|
|
143
|
+
const tickets = [...new Set(`${commit.subject}\n${commit.body}`.match(TICKET_RE) || [])];
|
|
144
|
+
const kind = classifyCommit(commit.subject, commit.body);
|
|
145
|
+
const key = [commit.repo, tickets[0] || '', kind].join('|');
|
|
146
|
+
const group = groups.get(key) || {
|
|
147
|
+
repo: commit.repo,
|
|
148
|
+
repoName: commit.repo.split(/[\\/]/).filter(Boolean).pop() || commit.repo,
|
|
149
|
+
tickets: [],
|
|
150
|
+
kind,
|
|
151
|
+
task: WORK_KINDS[kind].task,
|
|
152
|
+
subjects: [],
|
|
153
|
+
commitCount: 0,
|
|
154
|
+
linesChanged: 0,
|
|
155
|
+
hashes: []
|
|
156
|
+
};
|
|
157
|
+
group.tickets = [...new Set([...group.tickets, ...tickets])];
|
|
158
|
+
const subject = cleanSubject(commit.subject);
|
|
159
|
+
if (subject && !group.subjects.includes(subject)) {
|
|
160
|
+
group.subjects.push(subject);
|
|
161
|
+
}
|
|
162
|
+
group.commitCount += 1;
|
|
163
|
+
group.linesChanged += commit.additions + commit.deletions;
|
|
164
|
+
group.hashes.push(commit.hash.slice(0, 8));
|
|
165
|
+
groups.set(key, group);
|
|
166
|
+
}
|
|
167
|
+
return [...groups.values()].map((group) => ({
|
|
168
|
+
...group,
|
|
169
|
+
description: buildDescription(group),
|
|
170
|
+
weight: group.commitCount + Math.min(3, group.linesChanged / 200)
|
|
171
|
+
}));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function buildDescription(group) {
|
|
175
|
+
const ticketSuffix = group.tickets.length ? ` (${group.tickets.join(', ')})` : '';
|
|
176
|
+
const room = MAX_DESCRIPTION_LENGTH - ticketSuffix.length;
|
|
177
|
+
const subjects = group.subjects.map((subject) => subject.replace(TICKET_RE, '').replace(/\s*[[(]\s*[)\]]\s*/g, ' ').replace(/\s+/g, ' ').trim()).filter(Boolean);
|
|
178
|
+
let text = '';
|
|
179
|
+
for (const subject of subjects) {
|
|
180
|
+
const next = text ? `${text}; ${subject}` : subject;
|
|
181
|
+
if (next.length > room) {
|
|
182
|
+
text = text || subject.slice(0, Math.max(0, room - 1)).trim();
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
text = next;
|
|
186
|
+
}
|
|
187
|
+
return `${text || group.repoName}${ticketSuffix}`.replace(/\|/g, '/');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Splits targetHours across groups by weight in hourStep increments. Every
|
|
191
|
+
// group that gets time gets at least one step; when the day has fewer steps than
|
|
192
|
+
// groups, the heaviest groups win and the rest get 0 (still listed).
|
|
193
|
+
export function allocateHours(groups, targetHours, step = CONFIG.hourStep || 0.5) {
|
|
194
|
+
const totalSteps = Math.max(0, Math.floor((Number(targetHours) || 0) / step + 1e-9));
|
|
195
|
+
const ordered = groups.map((group, index) => ({ index, weight: group.weight || 1 }))
|
|
196
|
+
.sort((a, b) => b.weight - a.weight);
|
|
197
|
+
const steps = new Array(groups.length).fill(0);
|
|
198
|
+
if (!totalSteps || !groups.length) {
|
|
199
|
+
return steps.map(() => 0);
|
|
200
|
+
}
|
|
201
|
+
const funded = ordered.slice(0, Math.min(totalSteps, ordered.length));
|
|
202
|
+
for (const item of funded) {
|
|
203
|
+
steps[item.index] = 1;
|
|
204
|
+
}
|
|
205
|
+
let remaining = totalSteps - funded.length;
|
|
206
|
+
const totalWeight = funded.reduce((sum, item) => sum + item.weight, 0);
|
|
207
|
+
const shares = funded.map((item) => ({ ...item, exact: (item.weight / totalWeight) * remaining }));
|
|
208
|
+
for (const share of shares) {
|
|
209
|
+
const whole = Math.floor(share.exact);
|
|
210
|
+
steps[share.index] += whole;
|
|
211
|
+
remaining -= whole;
|
|
212
|
+
}
|
|
213
|
+
shares.sort((a, b) => (b.exact - Math.floor(b.exact)) - (a.exact - Math.floor(a.exact)));
|
|
214
|
+
for (let i = 0; remaining > 0; i = (i + 1) % shares.length) {
|
|
215
|
+
steps[shares[i].index] += 1;
|
|
216
|
+
remaining -= 1;
|
|
217
|
+
}
|
|
218
|
+
return steps.map((count) => Number((count * step).toFixed(2)));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function formatDayHeading(localDateISO) {
|
|
222
|
+
const [year, month, day] = localDateISO.split('-').map(Number);
|
|
223
|
+
const date = new Date(Date.UTC(year, month - 1, day));
|
|
224
|
+
const weekday = date.toLocaleDateString('en-US', { weekday: 'long', timeZone: 'UTC' });
|
|
225
|
+
const monthName = date.toLocaleDateString('en-US', { month: 'short', timeZone: 'UTC' });
|
|
226
|
+
return `${weekday}, ${String(day).padStart(2, '0')} ${monthName} ${year}`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function isWeekend(localDateISO) {
|
|
230
|
+
const [year, month, day] = localDateISO.split('-').map(Number);
|
|
231
|
+
const weekday = new Date(Date.UTC(year, month - 1, day)).getUTCDay();
|
|
232
|
+
return weekday === 0 || weekday === 6;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Remaining RO time per day: sum over projects of max(0, booked - logged), so
|
|
236
|
+
// over-logging one project does not hide a gap on another.
|
|
237
|
+
export function remainingHoursByDay(roResult) {
|
|
238
|
+
const byDay = new Map();
|
|
239
|
+
for (const day of roResult?.days || []) {
|
|
240
|
+
const projects = day.projects || [];
|
|
241
|
+
const remaining = projects.length
|
|
242
|
+
? projects.reduce((sum, project) => sum + Math.max(0, Number(project.bookedHours || 0) - Number(project.loggedHours || 0)), 0)
|
|
243
|
+
: Math.max(0, Number(day.bookedHours || 0) - Number(day.loggedHours || 0));
|
|
244
|
+
byDay.set(day.date, {
|
|
245
|
+
bookedHours: Number(day.bookedHours || 0),
|
|
246
|
+
loggedHours: Number(day.loggedHours || 0),
|
|
247
|
+
remainingHours: Number(remaining.toFixed(2))
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
return byDay;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export async function draftLogworkFromCommits({
|
|
254
|
+
period = 'today',
|
|
255
|
+
from,
|
|
256
|
+
to,
|
|
257
|
+
repos,
|
|
258
|
+
author,
|
|
259
|
+
useBookings = true,
|
|
260
|
+
cwd = process.cwd(),
|
|
261
|
+
runGit = runGitCommand,
|
|
262
|
+
queryRo = queryLogwork
|
|
263
|
+
} = {}) {
|
|
264
|
+
const range = from || to ? normalizeQueryRange({ from, to }) : normalizeQueryRange({ period });
|
|
265
|
+
const repoPaths = [...new Set((repos?.length ? repos : [cwd]).map((repo) => resolve(cwd, String(repo))))];
|
|
266
|
+
|
|
267
|
+
const repoResults = [];
|
|
268
|
+
const commits = [];
|
|
269
|
+
for (const repo of repoPaths) {
|
|
270
|
+
try {
|
|
271
|
+
const result = await readRepoCommits({ repo, from: range.from, to: range.to, author, runGit });
|
|
272
|
+
repoResults.push({ path: result.root, author: result.author, commitCount: result.commits.length });
|
|
273
|
+
commits.push(...result.commits);
|
|
274
|
+
} catch (error) {
|
|
275
|
+
repoResults.push({ path: repo, error: error.message, commitCount: 0 });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
let bookings = null;
|
|
280
|
+
let bookingsError = '';
|
|
281
|
+
if (useBookings) {
|
|
282
|
+
try {
|
|
283
|
+
bookings = remainingHoursByDay(await queryRo({ from: range.from, to: range.to, includeEntries: false, cwd }));
|
|
284
|
+
} catch (error) {
|
|
285
|
+
bookingsError = error.message;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const days = [];
|
|
290
|
+
for (let date = range.from; date < range.to; date = addLocalDays(date, 1)) {
|
|
291
|
+
const dayCommits = commits.filter((commit) => commit.date === date);
|
|
292
|
+
if (!dayCommits.length) {
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
const groups = groupCommits(dayCommits);
|
|
296
|
+
const booking = bookings?.get(date);
|
|
297
|
+
let targetHours;
|
|
298
|
+
let hoursSource;
|
|
299
|
+
if (booking) {
|
|
300
|
+
targetHours = booking.remainingHours;
|
|
301
|
+
hoursSource = booking.bookedHours ? 'ro_remaining' : 'ro_no_booking';
|
|
302
|
+
} else if (bookings) {
|
|
303
|
+
// RO answered but has no row for this day: nothing is booked.
|
|
304
|
+
targetHours = 0;
|
|
305
|
+
hoursSource = 'ro_no_booking';
|
|
306
|
+
} else {
|
|
307
|
+
targetHours = isWeekend(date) ? 0 : (CONFIG.standardDayHours || 8);
|
|
308
|
+
hoursSource = isWeekend(date) ? 'weekend' : 'standard_day';
|
|
309
|
+
}
|
|
310
|
+
const hours = allocateHours(groups, targetHours);
|
|
311
|
+
days.push({
|
|
312
|
+
date,
|
|
313
|
+
targetHours,
|
|
314
|
+
hoursSource,
|
|
315
|
+
...(booking ? { bookedHours: booking.bookedHours, loggedHours: booking.loggedHours } : {}),
|
|
316
|
+
groups: groups.map((group, index) => ({
|
|
317
|
+
hours: hours[index],
|
|
318
|
+
task: group.task,
|
|
319
|
+
kind: group.kind,
|
|
320
|
+
description: group.description,
|
|
321
|
+
tickets: group.tickets,
|
|
322
|
+
repo: group.repoName,
|
|
323
|
+
commitCount: group.commitCount,
|
|
324
|
+
linesChanged: group.linesChanged,
|
|
325
|
+
commits: group.hashes
|
|
326
|
+
}))
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const draftText = days
|
|
331
|
+
.map((day) => {
|
|
332
|
+
const lines = day.groups.filter((group) => group.hours > 0)
|
|
333
|
+
.map((group) => `+${group.hours} ${group.task} | ${group.description}`);
|
|
334
|
+
return lines.length ? [formatDayHeading(day.date), ...lines].join('\n') : '';
|
|
335
|
+
})
|
|
336
|
+
.filter(Boolean)
|
|
337
|
+
.join('\n\n');
|
|
338
|
+
|
|
339
|
+
const result = {
|
|
340
|
+
status: commits.length ? 'draft' : 'no_commits',
|
|
341
|
+
range,
|
|
342
|
+
repos: repoResults,
|
|
343
|
+
bookings: useBookings ? (bookings ? 'ro' : 'unavailable') : 'skipped',
|
|
344
|
+
...(bookingsError ? { bookingsError } : {}),
|
|
345
|
+
commitCount: commits.length,
|
|
346
|
+
days,
|
|
347
|
+
draftText
|
|
348
|
+
};
|
|
349
|
+
result.summary = formatCommitDraftSummary(result);
|
|
350
|
+
return result;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function formatCommitDraftSummary(result) {
|
|
354
|
+
const lines = [];
|
|
355
|
+
if (!result.commitCount) {
|
|
356
|
+
lines.push(`No commits found from ${result.range.from} to ${addLocalDays(result.range.to, -1)}.`);
|
|
357
|
+
} else {
|
|
358
|
+
lines.push(`${result.commitCount} commit(s) from ${result.range.from} to ${addLocalDays(result.range.to, -1)}.`);
|
|
359
|
+
}
|
|
360
|
+
for (const repo of result.repos) {
|
|
361
|
+
lines.push(repo.error ? `- ${repo.path}: ${repo.error}` : `- ${repo.path} (${repo.author}): ${repo.commitCount} commit(s)`);
|
|
362
|
+
}
|
|
363
|
+
if (result.bookings === 'unavailable') {
|
|
364
|
+
lines.push(`RO bookings unavailable (${result.bookingsError || 'unknown error'}); hours assume a ${CONFIG.standardDayHours || 8}h weekday.`);
|
|
365
|
+
}
|
|
366
|
+
for (const day of result.days) {
|
|
367
|
+
const source = {
|
|
368
|
+
ro_remaining: `RO remaining ${day.targetHours}h (booked ${day.bookedHours}h, logged ${day.loggedHours}h)`,
|
|
369
|
+
ro_no_booking: 'no RO booking: 0h suggested',
|
|
370
|
+
weekend: 'weekend: 0h suggested',
|
|
371
|
+
standard_day: `standard day ${day.targetHours}h`
|
|
372
|
+
}[day.hoursSource];
|
|
373
|
+
lines.push('', `${formatDayHeading(day.date)} - ${source}`);
|
|
374
|
+
for (const group of day.groups) {
|
|
375
|
+
lines.push(` +${group.hours} ${group.task} | ${group.description} [${group.repo}, ${group.commitCount} commit(s)]`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (result.draftText) {
|
|
379
|
+
lines.push('', 'Draft for log_work (review with the user first; worklog task names must exist in list_tasks for the project):', '', result.draftText);
|
|
380
|
+
}
|
|
381
|
+
return lines.join('\n');
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function runGitCommand(cwd, args) {
|
|
385
|
+
return new Promise((resolveValue, reject) => {
|
|
386
|
+
const child = spawn('git', ['-C', cwd, ...args], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
387
|
+
let stdout = '';
|
|
388
|
+
let stderr = '';
|
|
389
|
+
child.stdout.setEncoding('utf8');
|
|
390
|
+
child.stderr.setEncoding('utf8');
|
|
391
|
+
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
392
|
+
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
393
|
+
child.on('error', reject);
|
|
394
|
+
child.on('close', (code) => {
|
|
395
|
+
if (code === 0) {
|
|
396
|
+
resolveValue(stdout);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
reject(new Error(stderr.trim().split('\n')[0] || `git ${args[0]} failed with exit code ${code}`));
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
}
|
package/lib/config.mjs
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
const DEFAULT_PROFILE = 'vinova';
|
|
2
|
+
|
|
3
|
+
const BASE_CONFIG = {
|
|
4
|
+
profile: DEFAULT_PROFILE,
|
|
5
|
+
apiBase: 'https://api.resourceoptimiser.com/api/v1',
|
|
6
|
+
loginUrl: 'https://app.resourceoptimiser.com/vinova',
|
|
7
|
+
tokenKey: 'vinova_access_token',
|
|
8
|
+
jiraBaseUrl: 'https://jira-vnv.vinova.sg',
|
|
9
|
+
jiraStartedTime: '09:00',
|
|
10
|
+
timezone: 'Asia/Ho_Chi_Minh',
|
|
11
|
+
|
|
12
|
+
projectMembersPath: '/project-members/by-user',
|
|
13
|
+
memberLogtimePath: '/member-logtime',
|
|
14
|
+
logworkEntriesPath: '/logwork/entries',
|
|
15
|
+
logworkTasksPath: '/logwork/tasks',
|
|
16
|
+
logworkDefaultTasksPath: '/logwork/tasks/defaults',
|
|
17
|
+
logworkDayPath: '/logwork/day',
|
|
18
|
+
logworkWeeklyPath: '/logwork/weekly',
|
|
19
|
+
logworkRejectedPath: '/logwork/rejected',
|
|
20
|
+
defaultTypeOfWork: 'other',
|
|
21
|
+
timesheetPath: '/member-logtime/timesheet',
|
|
22
|
+
signinKeycloakPath: '/auth/signinKeyCloak',
|
|
23
|
+
refreshTokenPath: '/auth/refresh-token',
|
|
24
|
+
keycloakAuthUrl: 'https://keycloak.vinova.sg/auth/realms/resource/protocol/openid-connect/auth',
|
|
25
|
+
keycloakTokenUrl: 'https://keycloak.vinova.sg/auth/realms/resource/protocol/openid-connect/token',
|
|
26
|
+
keycloakClientId: 'localhost',
|
|
27
|
+
keycloakRedirectUri: 'https://app.resourceoptimiser.com/vinova/check-login',
|
|
28
|
+
keycloakScope: 'openid',
|
|
29
|
+
keycloakResponseMode: 'fragment',
|
|
30
|
+
keycloakResponseType: 'code',
|
|
31
|
+
|
|
32
|
+
pollMs: 500,
|
|
33
|
+
loginPollMs: 2000,
|
|
34
|
+
loginTimeoutMs: 5 * 60 * 1000,
|
|
35
|
+
maxApiAuthSteps: 12,
|
|
36
|
+
httpTimeoutMs: 30 * 1000,
|
|
37
|
+
httpReadRetries: 2,
|
|
38
|
+
httpRetryDelayMs: 250,
|
|
39
|
+
|
|
40
|
+
defaultHours: 1,
|
|
41
|
+
standardDayHours: 8,
|
|
42
|
+
hourStep: 0.5,
|
|
43
|
+
dayLogConcurrency: 5
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const CONFIG = buildConfig(process.env);
|
|
47
|
+
|
|
48
|
+
export function buildConfig(env = {}) {
|
|
49
|
+
const profile = readStringEnv(env, 'LOGWORK_PROFILE') || readStringEnv(env, 'LOGWORK_HELPER_PROFILE') || DEFAULT_PROFILE;
|
|
50
|
+
const config = {
|
|
51
|
+
...BASE_CONFIG,
|
|
52
|
+
profile,
|
|
53
|
+
apiBase: readUrlEnv(env, 'LOGWORK_API_BASE') || BASE_CONFIG.apiBase,
|
|
54
|
+
loginUrl: readUrlEnv(env, 'LOGWORK_LOGIN_URL') || BASE_CONFIG.loginUrl,
|
|
55
|
+
tokenKey: readStringEnv(env, 'LOGWORK_TOKEN_KEY') || BASE_CONFIG.tokenKey,
|
|
56
|
+
jiraBaseUrl: readUrlEnv(env, 'LOGWORK_JIRA_BASE_URL') || BASE_CONFIG.jiraBaseUrl,
|
|
57
|
+
jiraStartedTime: readTimeEnv(env, 'LOGWORK_JIRA_STARTED_TIME') || BASE_CONFIG.jiraStartedTime,
|
|
58
|
+
timezone: readTimeZoneEnv(env, 'LOGWORK_TIMEZONE') || BASE_CONFIG.timezone,
|
|
59
|
+
keycloakAuthUrl: readUrlEnv(env, 'LOGWORK_KEYCLOAK_AUTH_URL') || BASE_CONFIG.keycloakAuthUrl,
|
|
60
|
+
keycloakTokenUrl: readUrlEnv(env, 'LOGWORK_KEYCLOAK_TOKEN_URL') || BASE_CONFIG.keycloakTokenUrl,
|
|
61
|
+
keycloakClientId: readStringEnv(env, 'LOGWORK_KEYCLOAK_CLIENT_ID') || BASE_CONFIG.keycloakClientId,
|
|
62
|
+
keycloakRedirectUri: readUrlEnv(env, 'LOGWORK_KEYCLOAK_REDIRECT_URI') || BASE_CONFIG.keycloakRedirectUri,
|
|
63
|
+
keycloakScope: readStringEnv(env, 'LOGWORK_KEYCLOAK_SCOPE') || BASE_CONFIG.keycloakScope,
|
|
64
|
+
keycloakResponseMode: readStringEnv(env, 'LOGWORK_KEYCLOAK_RESPONSE_MODE') || BASE_CONFIG.keycloakResponseMode,
|
|
65
|
+
keycloakResponseType: readStringEnv(env, 'LOGWORK_KEYCLOAK_RESPONSE_TYPE') || BASE_CONFIG.keycloakResponseType,
|
|
66
|
+
httpTimeoutMs: readPositiveIntegerEnv(env, 'LOGWORK_HTTP_TIMEOUT_MS') || BASE_CONFIG.httpTimeoutMs,
|
|
67
|
+
httpReadRetries: readNonNegativeIntegerEnv(env, 'LOGWORK_HTTP_READ_RETRIES') ?? BASE_CONFIG.httpReadRetries,
|
|
68
|
+
httpRetryDelayMs: readNonNegativeIntegerEnv(env, 'LOGWORK_HTTP_RETRY_DELAY_MS') ?? BASE_CONFIG.httpRetryDelayMs,
|
|
69
|
+
dayLogConcurrency: readPositiveIntegerEnv(env, 'LOGWORK_DAY_LOG_CONCURRENCY') || BASE_CONFIG.dayLogConcurrency
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const configuredHosts = readCsvEnv(env, 'LOGWORK_ALLOWED_SAFARI_HOSTS');
|
|
73
|
+
config.allowedSafariHosts = configuredHosts || [hostFromUrl(config.loginUrl, 'LOGWORK_LOGIN_URL')];
|
|
74
|
+
config.keycloakHost = hostFromUrl(config.keycloakAuthUrl, 'LOGWORK_KEYCLOAK_AUTH_URL');
|
|
75
|
+
|
|
76
|
+
return Object.freeze({
|
|
77
|
+
...config,
|
|
78
|
+
allowedSafariHosts: Object.freeze([...config.allowedSafariHosts])
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function readStringEnv(env, key) {
|
|
83
|
+
const value = env[key];
|
|
84
|
+
if (value === undefined || value === null || String(value).trim() === '') {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
return String(value).trim();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function readUrlEnv(env, key) {
|
|
91
|
+
const value = readStringEnv(env, key);
|
|
92
|
+
if (!value) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
validateUrl(value, key);
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function readCsvEnv(env, key) {
|
|
100
|
+
const value = readStringEnv(env, key);
|
|
101
|
+
if (!value) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
const entries = value.split(',')
|
|
105
|
+
.map((entry) => entry.trim())
|
|
106
|
+
.filter(Boolean);
|
|
107
|
+
if (entries.length === 0) {
|
|
108
|
+
throw new Error(`${key} must include at least one value.`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return [...new Set(entries)];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function readPositiveIntegerEnv(env, key) {
|
|
115
|
+
const value = readStringEnv(env, key);
|
|
116
|
+
if (!value) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
const number = Number(value);
|
|
120
|
+
if (!Number.isInteger(number) || number <= 0) {
|
|
121
|
+
throw new Error(`${key} must be a positive integer.`);
|
|
122
|
+
}
|
|
123
|
+
return number;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function readNonNegativeIntegerEnv(env, key) {
|
|
127
|
+
const value = readStringEnv(env, key);
|
|
128
|
+
if (!value) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
const number = Number(value);
|
|
132
|
+
if (!Number.isInteger(number) || number < 0) {
|
|
133
|
+
throw new Error(`${key} must be a non-negative integer.`);
|
|
134
|
+
}
|
|
135
|
+
return number;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function readTimeEnv(env, key) {
|
|
139
|
+
const value = readStringEnv(env, key);
|
|
140
|
+
if (!value) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(value)) {
|
|
144
|
+
throw new Error(`${key} must use 24-hour HH:mm format.`);
|
|
145
|
+
}
|
|
146
|
+
return value;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function readTimeZoneEnv(env, key) {
|
|
150
|
+
const value = readStringEnv(env, key);
|
|
151
|
+
if (!value) {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
new Intl.DateTimeFormat('en-US', { timeZone: value }).format();
|
|
156
|
+
} catch {
|
|
157
|
+
throw new Error(`${key} must be a valid IANA timezone.`);
|
|
158
|
+
}
|
|
159
|
+
return value;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function hostFromUrl(value, label) {
|
|
163
|
+
return validateUrl(value, label).host;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function validateUrl(value, label) {
|
|
167
|
+
let url;
|
|
168
|
+
try {
|
|
169
|
+
url = new URL(value);
|
|
170
|
+
} catch {
|
|
171
|
+
throw new Error(`${label} must be a valid http(s) URL.`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
175
|
+
throw new Error(`${label} must be a valid http(s) URL.`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (url.protocol === 'http:' && !isLocalHttpHost(url.hostname)) {
|
|
179
|
+
throw new Error(`${label} must use HTTPS unless it points to localhost or loopback.`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return url;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function isLocalHttpHost(hostname) {
|
|
186
|
+
const host = String(hostname || '').replace(/^\[|\]$/g, '').toLowerCase();
|
|
187
|
+
return host === 'localhost' ||
|
|
188
|
+
host === '127.0.0.1' ||
|
|
189
|
+
host === '::1' ||
|
|
190
|
+
host.startsWith('127.');
|
|
191
|
+
}
|