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
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Project memberships and per-project day logs from the legacy member-logtime API.
|
|
2
|
+
import { CONFIG } from './config.mjs';
|
|
3
|
+
import { mapLimit, toApiLogDate } from './util.mjs';
|
|
4
|
+
import { ApiError, apiFetch } from './ro-api-http.mjs';
|
|
5
|
+
import {
|
|
6
|
+
addDroppedRow,
|
|
7
|
+
attachNormalizationDiagnostics,
|
|
8
|
+
createNormalizationDiagnostics,
|
|
9
|
+
diagnosticProjectFields,
|
|
10
|
+
getNormalizationDiagnostics
|
|
11
|
+
} from './ro-api-diagnostics.mjs';
|
|
12
|
+
import {
|
|
13
|
+
hasProjectName,
|
|
14
|
+
normalizeDateOnly,
|
|
15
|
+
projectFallbackName,
|
|
16
|
+
readExplicitProjectMemberId,
|
|
17
|
+
readNumber,
|
|
18
|
+
readProjectId,
|
|
19
|
+
readProjectName,
|
|
20
|
+
unwrapRecords
|
|
21
|
+
} from './ro-api-fields.mjs';
|
|
22
|
+
|
|
23
|
+
export async function getProjects(token, userId) {
|
|
24
|
+
const qs = new URLSearchParams({
|
|
25
|
+
f_user_id: String(userId),
|
|
26
|
+
'f_project.stage_id': '!6'
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const data = await apiFetch(token, `${CONFIG.projectMembersPath}?${qs}`);
|
|
30
|
+
return unwrapRecords(data).map(normalizeProject).filter((project) => project.projectMemberId);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function getDayLogs(token, projectId, userId, localDateISO) {
|
|
34
|
+
const qs = new URLSearchParams({
|
|
35
|
+
f_project_id: String(projectId),
|
|
36
|
+
f_user_id: String(userId),
|
|
37
|
+
f_logdate: toApiLogDate(localDateISO)
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const data = await apiFetch(token, `${CONFIG.memberLogtimePath}?${qs}`);
|
|
42
|
+
const logs = unwrapRecords(data);
|
|
43
|
+
const entries = normalizeLogtimeEntries(logs, {
|
|
44
|
+
date: localDateISO,
|
|
45
|
+
projectId
|
|
46
|
+
});
|
|
47
|
+
const normalization = getNormalizationDiagnostics(entries);
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
projectId,
|
|
51
|
+
totalHours: entries.reduce((sum, log) => sum + Number(log.hours || 0), 0),
|
|
52
|
+
entries,
|
|
53
|
+
logs,
|
|
54
|
+
normalization,
|
|
55
|
+
ok: true
|
|
56
|
+
};
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (error instanceof ApiError && error.status === 401) {
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
projectId,
|
|
64
|
+
totalHours: null,
|
|
65
|
+
logs: [],
|
|
66
|
+
ok: false,
|
|
67
|
+
error: error.message
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function normalizeLogtimeEntries(logs, defaults = {}) {
|
|
73
|
+
const rows = unwrapRecords(logs);
|
|
74
|
+
const diagnostics = createNormalizationDiagnostics('logtime_entries');
|
|
75
|
+
diagnostics.rowsRead = rows.length;
|
|
76
|
+
const entries = [];
|
|
77
|
+
|
|
78
|
+
rows.forEach((log, index) => {
|
|
79
|
+
if (!log || typeof log !== 'object') {
|
|
80
|
+
addDroppedRow(diagnostics, 'invalid_log_entry', { index });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const context = { index, kind: 'logtime_entry' };
|
|
85
|
+
const date = normalizeDateOnly(log.logdate ?? log.log_date ?? log.date) || defaults.date;
|
|
86
|
+
const projectMemberId = readExplicitProjectMemberId(log) ?? defaults.projectMemberId;
|
|
87
|
+
const projectId = readProjectId(log) ?? defaults.projectId;
|
|
88
|
+
const hours = readNumber(log, ['logtimes', 'hours', 'time'], diagnostics, context);
|
|
89
|
+
|
|
90
|
+
if (!date) {
|
|
91
|
+
addDroppedRow(diagnostics, 'missing_date', { ...context, ...diagnosticProjectFields({ projectMemberId, projectId }) });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (projectMemberId === undefined && projectId === undefined) {
|
|
96
|
+
addDroppedRow(diagnostics, 'missing_project_identity', { ...context, date });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (hours <= 0) {
|
|
101
|
+
addDroppedRow(diagnostics, 'non_positive_hours', { ...context, date, ...diagnosticProjectFields({ projectMemberId, projectId }) });
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
diagnostics.rowsAccepted += 1;
|
|
106
|
+
entries.push({
|
|
107
|
+
id: log.id ?? log.logtime_id ?? log.logtimeId,
|
|
108
|
+
date,
|
|
109
|
+
projectMemberId,
|
|
110
|
+
projectId,
|
|
111
|
+
projectName: hasProjectName(log) ? readProjectName(log, readProjectId(log), diagnostics, context) : defaults.projectName,
|
|
112
|
+
hours,
|
|
113
|
+
taskName: log.task_name ?? log.taskName ?? log.name ?? log.title ?? ''
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return attachNormalizationDiagnostics(entries, diagnostics);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function getAllDayLogSummaries(token, projects, userId, localDateISO) {
|
|
121
|
+
return mapLimit(projects, CONFIG.dayLogConcurrency, (project) => (
|
|
122
|
+
getDayLogs(token, project.projectId, userId, localDateISO)
|
|
123
|
+
));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function normalizeProject(rec) {
|
|
127
|
+
const projectId = rec.project_id ?? rec.project?.id ?? rec.project?.project_id;
|
|
128
|
+
const projectMemberId = rec.id ?? rec.project_member_id;
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
projectMemberId,
|
|
132
|
+
projectId,
|
|
133
|
+
projectName: rec.project?.name ?? rec.project_name ?? rec.name ?? projectFallbackName(projectId ?? projectMemberId),
|
|
134
|
+
workloadPercent: rec.workload ?? rec.workload_percent ?? rec.assigned_percent ?? 0,
|
|
135
|
+
raw: rec
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Worklog task catalog: project tasks plus shared default tasks.
|
|
2
|
+
import { CONFIG } from './config.mjs';
|
|
3
|
+
import { apiFetch } from './ro-api-http.mjs';
|
|
4
|
+
import { unwrapRecords } from './ro-api-fields.mjs';
|
|
5
|
+
import {
|
|
6
|
+
mergeWorklogTaskLists,
|
|
7
|
+
normalizeWorklogTask
|
|
8
|
+
} from './worklog-task-resolver.mjs';
|
|
9
|
+
|
|
10
|
+
export async function getWorklogTasks(token, { projectId, search } = {}, options = {}) {
|
|
11
|
+
const path = `${CONFIG.logworkTasksPath}${buildLogworkTasksQuery({ projectId, search })}`;
|
|
12
|
+
const data = await apiFetch(token, path, options);
|
|
13
|
+
return unwrapRecords(data).map(normalizeWorklogTask);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function getDefaultWorklogTasks(token, { projectId, search } = {}, options = {}) {
|
|
17
|
+
const path = `${CONFIG.logworkDefaultTasksPath}${buildLogworkTasksQuery({ projectId, search })}`;
|
|
18
|
+
const data = await apiFetch(token, path, options);
|
|
19
|
+
return unwrapRecords(data).map(normalizeWorklogTask);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function fetchWorklogTasksForProject(token, projectId, options = {}) {
|
|
23
|
+
const cacheKey = Number(projectId);
|
|
24
|
+
const { cache, ...apiOptions } = options;
|
|
25
|
+
if (cache instanceof Map && cache.has(cacheKey)) {
|
|
26
|
+
return cache.get(cacheKey);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const [projectTasks, defaultTasks] = await Promise.all([
|
|
30
|
+
getWorklogTasks(token, { projectId: cacheKey }, apiOptions),
|
|
31
|
+
getDefaultWorklogTasks(token, { projectId: cacheKey }, apiOptions)
|
|
32
|
+
]);
|
|
33
|
+
const merged = mergeWorklogTaskLists(projectTasks, defaultTasks);
|
|
34
|
+
if (cache instanceof Map) {
|
|
35
|
+
cache.set(cacheKey, merged);
|
|
36
|
+
}
|
|
37
|
+
return merged;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function buildLogworkTasksQuery({ projectId, search }) {
|
|
41
|
+
const qs = new URLSearchParams();
|
|
42
|
+
if (projectId !== undefined && projectId !== null && projectId !== '') {
|
|
43
|
+
qs.set('project_id', String(projectId));
|
|
44
|
+
}
|
|
45
|
+
if (search) {
|
|
46
|
+
qs.set('search', String(search));
|
|
47
|
+
}
|
|
48
|
+
const query = qs.toString();
|
|
49
|
+
return query ? `?${query}` : '';
|
|
50
|
+
}
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
// Timesheet range reads: booked and logged hours per day and project.
|
|
2
|
+
import { CONFIG } from './config.mjs';
|
|
3
|
+
import { addLocalDays, toApiLogDate } from './util.mjs';
|
|
4
|
+
import { apiFetch } from './ro-api-http.mjs';
|
|
5
|
+
import {
|
|
6
|
+
addDroppedRow,
|
|
7
|
+
addWarning,
|
|
8
|
+
attachNormalizationDiagnostics,
|
|
9
|
+
createNormalizationDiagnostics,
|
|
10
|
+
diagnosticProjectFields,
|
|
11
|
+
getNormalizationDiagnostics
|
|
12
|
+
} from './ro-api-diagnostics.mjs';
|
|
13
|
+
import {
|
|
14
|
+
coerceHours,
|
|
15
|
+
hasProjectName,
|
|
16
|
+
isDateInRange,
|
|
17
|
+
normalizeDateOnly,
|
|
18
|
+
pickFirst,
|
|
19
|
+
readAssignedHours,
|
|
20
|
+
readDateLike,
|
|
21
|
+
readLoggedHours,
|
|
22
|
+
readNumber,
|
|
23
|
+
readProjectId,
|
|
24
|
+
readProjectMemberId,
|
|
25
|
+
readProjectName
|
|
26
|
+
} from './ro-api-fields.mjs';
|
|
27
|
+
|
|
28
|
+
export async function getTimesheetRange(token, userId, { from, to }) {
|
|
29
|
+
const { records } = await getTimesheetRangeResult(token, userId, { from, to });
|
|
30
|
+
return records;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function getTimesheetRangeResult(token, userId, { from, to }) {
|
|
34
|
+
const qs = new URLSearchParams({
|
|
35
|
+
f_user_id: String(userId),
|
|
36
|
+
f_from: toApiLogDate(from),
|
|
37
|
+
f_to: toApiLogDate(to),
|
|
38
|
+
f_timesheet_by_week: '0',
|
|
39
|
+
f_time_off: '1'
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const data = await apiFetch(token, `${CONFIG.timesheetPath}?${qs}`);
|
|
43
|
+
return normalizeTimesheetRangeResult(data, { from, to });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function normalizeTimesheetRange(data, { from, to } = {}) {
|
|
47
|
+
return normalizeTimesheetRangeResult(data, { from, to }).records;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function normalizeTimesheetRangeResult(data, { from, to } = {}) {
|
|
51
|
+
const diagnostics = createNormalizationDiagnostics('unknown');
|
|
52
|
+
const explicitRows = expandProjectTimesheetRows(data, { from, to }, diagnostics);
|
|
53
|
+
const rows = explicitRows.length ? explicitRows : findTimesheetRowsInRange(data, { from, to });
|
|
54
|
+
diagnostics.sourceShape = explicitRows.length ? 'project_timesheet' : 'range_scan';
|
|
55
|
+
if (!rows.length && hasObjectPayload(data)) {
|
|
56
|
+
addWarning(diagnostics, 'unknown_timesheet_shape');
|
|
57
|
+
}
|
|
58
|
+
const records = normalizeTimesheetRecords(rows, diagnostics);
|
|
59
|
+
return {
|
|
60
|
+
records,
|
|
61
|
+
normalization: getNormalizationDiagnostics(records)
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function getBookedProjectsForDate(token, userId, localDateISO) {
|
|
66
|
+
const { records } = await getTimesheetRangeResult(token, userId, {
|
|
67
|
+
from: localDateISO,
|
|
68
|
+
to: addLocalDays(localDateISO, 1)
|
|
69
|
+
});
|
|
70
|
+
return normalizeTimesheetProjects(records, localDateISO);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function getTodayTimesheetProjects(token, userId, localDateISO) {
|
|
74
|
+
return getBookedProjectsForDate(token, userId, localDateISO);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function normalizeTimesheetProjects(rows, localDateISO) {
|
|
78
|
+
const byMember = new Map();
|
|
79
|
+
|
|
80
|
+
for (const row of rows) {
|
|
81
|
+
const projectMemberId = readProjectMemberId(row);
|
|
82
|
+
const projectId = readProjectId(row);
|
|
83
|
+
|
|
84
|
+
if (!projectMemberId && !projectId) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const key = String(projectMemberId ?? projectId);
|
|
89
|
+
const projectName = readProjectName(row, projectId ?? projectMemberId);
|
|
90
|
+
const assignedHours = Number(row.bookedHours ?? readAssignedHours(row));
|
|
91
|
+
const loggedHours = Number(row.loggedHours ?? readLoggedHours(row));
|
|
92
|
+
const existing = byMember.get(key);
|
|
93
|
+
|
|
94
|
+
if (existing) {
|
|
95
|
+
existing.assignedHours += assignedHours;
|
|
96
|
+
existing.loggedHours += loggedHours;
|
|
97
|
+
existing.workloadPercent = hoursToAssignPercent(existing.assignedHours);
|
|
98
|
+
existing.raw.push(row);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
byMember.set(key, {
|
|
103
|
+
projectMemberId: projectMemberId ?? row.project_member_id ?? row.id,
|
|
104
|
+
projectId,
|
|
105
|
+
projectName,
|
|
106
|
+
assignedHours,
|
|
107
|
+
loggedHours,
|
|
108
|
+
workloadPercent: hoursToAssignPercent(assignedHours),
|
|
109
|
+
localDateISO,
|
|
110
|
+
raw: [row]
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return [...byMember.values()].filter((project) => project.assignedHours > 0);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function normalizeTimesheetRecords(rows, diagnostics = createNormalizationDiagnostics('range_scan')) {
|
|
118
|
+
const byDayProject = new Map();
|
|
119
|
+
diagnostics.rowsRead = rows.length;
|
|
120
|
+
|
|
121
|
+
rows.forEach((row, index) => {
|
|
122
|
+
const date = readDateLike(row);
|
|
123
|
+
const projectMemberId = readProjectMemberId(row);
|
|
124
|
+
const projectId = readProjectId(row);
|
|
125
|
+
const context = {
|
|
126
|
+
index,
|
|
127
|
+
kind: 'timesheet_row',
|
|
128
|
+
...diagnosticProjectFields({ projectMemberId, projectId, projectName: hasProjectName(row) ? readProjectName(row, projectId ?? projectMemberId) : undefined })
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
if (!date) {
|
|
132
|
+
addDroppedRow(diagnostics, 'missing_date', context);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (projectMemberId === undefined && projectId === undefined) {
|
|
137
|
+
addDroppedRow(diagnostics, 'missing_project_identity', { ...context, date });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const key = `${date}:${projectMemberId ?? projectId}`;
|
|
142
|
+
const existing = byDayProject.get(key);
|
|
143
|
+
const bookedHours = readTimesheetHours(row, ['bookedHours'], readAssignedHours, diagnostics, { ...context, date, metric: 'bookedHours' });
|
|
144
|
+
const loggedHours = readTimesheetHours(row, ['loggedHours'], readLoggedHours, diagnostics, { ...context, date, metric: 'loggedHours' });
|
|
145
|
+
const entries = readLogEntries(row, diagnostics, { ...context, date });
|
|
146
|
+
diagnostics.rowsAccepted += 1;
|
|
147
|
+
|
|
148
|
+
if (existing) {
|
|
149
|
+
existing.bookedHours += bookedHours;
|
|
150
|
+
existing.loggedHours += loggedHours;
|
|
151
|
+
existing.entries.push(...entries);
|
|
152
|
+
existing.raw.push(row);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
byDayProject.set(key, {
|
|
157
|
+
date,
|
|
158
|
+
projectMemberId,
|
|
159
|
+
projectId,
|
|
160
|
+
projectName: readProjectName(row, projectId ?? projectMemberId, diagnostics, { ...context, date }),
|
|
161
|
+
bookedHours,
|
|
162
|
+
loggedHours,
|
|
163
|
+
entries,
|
|
164
|
+
raw: [row]
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
return attachNormalizationDiagnostics([...byDayProject.values()].map((record) => ({
|
|
169
|
+
...record,
|
|
170
|
+
bookedHours: Number(record.bookedHours.toFixed(2)),
|
|
171
|
+
loggedHours: Number(record.loggedHours.toFixed(2))
|
|
172
|
+
})), diagnostics);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function expandProjectTimesheetRows(data, range, diagnostics = null) {
|
|
176
|
+
const assignments = [];
|
|
177
|
+
collectProjectAssignments(data, assignments, new Set());
|
|
178
|
+
const rows = [];
|
|
179
|
+
|
|
180
|
+
assignments.forEach((assignment, assignmentIndex) => {
|
|
181
|
+
assignment.timesheet.forEach((day, dayIndex) => {
|
|
182
|
+
const date = normalizeDateOnly(day.logdate ?? day.log_date ?? day.date);
|
|
183
|
+
if (!isDateInRange(date, range)) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const context = {
|
|
188
|
+
index: rows.length,
|
|
189
|
+
assignmentIndex,
|
|
190
|
+
dayIndex,
|
|
191
|
+
kind: 'project_timesheet_row',
|
|
192
|
+
date,
|
|
193
|
+
...diagnosticProjectFields({
|
|
194
|
+
projectMemberId: assignment.id,
|
|
195
|
+
projectId: assignment.project?.id,
|
|
196
|
+
projectName: assignment.project?.name
|
|
197
|
+
})
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
rows.push({
|
|
201
|
+
date,
|
|
202
|
+
projectMemberId: assignment.id,
|
|
203
|
+
project_member_id: assignment.id,
|
|
204
|
+
projectId: assignment.project?.id,
|
|
205
|
+
project_id: assignment.project?.id,
|
|
206
|
+
projectName: assignment.project?.name,
|
|
207
|
+
project_name: assignment.project?.name,
|
|
208
|
+
bookedHours: readAssignedHours(day, diagnostics, { ...context, metric: 'bookedHours' }),
|
|
209
|
+
loggedHours: readNumber(day, ['logtimes'], diagnostics, { ...context, metric: 'loggedHours' }),
|
|
210
|
+
overtime: readNumber(day, ['overtime'], diagnostics, { ...context, metric: 'overtime' }),
|
|
211
|
+
source: 'timesheet'
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
return rows;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function collectProjectAssignments(value, assignments, seen) {
|
|
220
|
+
if (!value || typeof value !== 'object') {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (seen.has(value)) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
seen.add(value);
|
|
228
|
+
|
|
229
|
+
if (Array.isArray(value)) {
|
|
230
|
+
for (const item of value) {
|
|
231
|
+
collectProjectAssignments(item, assignments, seen);
|
|
232
|
+
}
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (
|
|
237
|
+
value.project &&
|
|
238
|
+
typeof value.project === 'object' &&
|
|
239
|
+
Array.isArray(value.timesheet)
|
|
240
|
+
) {
|
|
241
|
+
assignments.push(value);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
for (const [key, child] of Object.entries(value)) {
|
|
246
|
+
if (key === 'overall') {
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
collectProjectAssignments(child, assignments, seen);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function findTimesheetRowsInRange(data, { from, to } = {}) {
|
|
254
|
+
const rows = [];
|
|
255
|
+
collectTimesheetRowsInRange(data, { from, to }, rows, new Set());
|
|
256
|
+
return rows;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function collectTimesheetRowsInRange(value, range, rows, seen) {
|
|
260
|
+
if (!value || typeof value !== 'object') {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (seen.has(value)) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
seen.add(value);
|
|
268
|
+
|
|
269
|
+
if (Array.isArray(value)) {
|
|
270
|
+
for (const item of value) {
|
|
271
|
+
collectTimesheetRowsInRange(item, range, rows, seen);
|
|
272
|
+
}
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const directDate = readDateLike(value);
|
|
277
|
+
if (isDateInRange(directDate, range) && looksLikeTimesheetRow(value)) {
|
|
278
|
+
rows.push(value);
|
|
279
|
+
} else if (!directDate && looksLikeTimesheetRow(value)) {
|
|
280
|
+
rows.push(value);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
for (const [key, child] of Object.entries(value)) {
|
|
284
|
+
if (Array.isArray(child)) {
|
|
285
|
+
const keyDate = normalizeDateOnly(key);
|
|
286
|
+
if (isDateInRange(keyDate, range)) {
|
|
287
|
+
for (const item of child) {
|
|
288
|
+
if (item && typeof item === 'object') {
|
|
289
|
+
rows.push({ ...item, date: readDateLike(item) || keyDate });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
for (const item of child) {
|
|
296
|
+
if (item && typeof item === 'object') {
|
|
297
|
+
const itemDate = readDateLike(item);
|
|
298
|
+
if (isDateInRange(itemDate, range) && looksLikeTimesheetRow(item)) {
|
|
299
|
+
rows.push(item);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
collectTimesheetRowsInRange(item, range, rows, seen);
|
|
305
|
+
}
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (child && typeof child === 'object') {
|
|
310
|
+
const keyDate = normalizeDateOnly(key);
|
|
311
|
+
if (isDateInRange(keyDate, range)) {
|
|
312
|
+
rows.push({ ...child, date: readDateLike(child) || keyDate });
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
collectTimesheetRowsInRange(child, range, rows, seen);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function looksLikeTimesheetRow(row) {
|
|
322
|
+
return readAssignedHours(row) > 0 || readLoggedHours(row) > 0 || Boolean(pickFirst(row, [
|
|
323
|
+
'project_member_id',
|
|
324
|
+
'projectMemberId',
|
|
325
|
+
'project_id',
|
|
326
|
+
'projectId'
|
|
327
|
+
]));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function readLogEntries(row, diagnostics = null, context = {}) {
|
|
331
|
+
const rawEntries = pickFirst(row, [
|
|
332
|
+
'logs',
|
|
333
|
+
'logtimes_data',
|
|
334
|
+
'logtimesData',
|
|
335
|
+
'log_times',
|
|
336
|
+
'logTimes',
|
|
337
|
+
'items',
|
|
338
|
+
'children'
|
|
339
|
+
]);
|
|
340
|
+
|
|
341
|
+
if (!Array.isArray(rawEntries)) {
|
|
342
|
+
return [];
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const entries = [];
|
|
346
|
+
rawEntries.forEach((entry, entryIndex) => {
|
|
347
|
+
if (!entry || typeof entry !== 'object') {
|
|
348
|
+
addDroppedRow(diagnostics, 'invalid_log_entry', {
|
|
349
|
+
...context,
|
|
350
|
+
entryIndex
|
|
351
|
+
});
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const entryContext = {
|
|
356
|
+
...context,
|
|
357
|
+
entryIndex,
|
|
358
|
+
kind: 'embedded_log_entry'
|
|
359
|
+
};
|
|
360
|
+
const date = normalizeDateOnly(entry.logdate ?? entry.log_date ?? entry.date ?? row.logdate ?? row.date);
|
|
361
|
+
const projectMemberId = readProjectMemberId(entry) ?? readProjectMemberId(row);
|
|
362
|
+
const projectId = readProjectId(entry) ?? readProjectId(row);
|
|
363
|
+
const hours = readNumber(entry, ['logtimes', 'hours', 'time'], diagnostics, { ...entryContext, metric: 'hours' });
|
|
364
|
+
|
|
365
|
+
if (!date) {
|
|
366
|
+
addDroppedRow(diagnostics, 'missing_date', { ...entryContext, ...diagnosticProjectFields({ projectMemberId, projectId }) });
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (projectMemberId === undefined && projectId === undefined) {
|
|
371
|
+
addDroppedRow(diagnostics, 'missing_project_identity', { ...entryContext, date });
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (hours <= 0) {
|
|
376
|
+
addDroppedRow(diagnostics, 'non_positive_hours', { ...entryContext, date, ...diagnosticProjectFields({ projectMemberId, projectId }) });
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
entries.push({
|
|
381
|
+
id: entry.id ?? entry.logtime_id ?? entry.logtimeId,
|
|
382
|
+
date,
|
|
383
|
+
projectMemberId,
|
|
384
|
+
projectId,
|
|
385
|
+
projectName: hasProjectName(entry) ? readProjectName(entry, readProjectId(entry), diagnostics, entryContext) : readProjectName(row, readProjectId(row) ?? readProjectMemberId(row), diagnostics, entryContext),
|
|
386
|
+
hours,
|
|
387
|
+
taskName: entry.task_name ?? entry.taskName ?? entry.name ?? entry.title ?? '',
|
|
388
|
+
raw: entry
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
return entries;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function readTimesheetHours(row, directPaths, fallbackReader, diagnostics, context) {
|
|
396
|
+
const directValue = pickFirst(row, directPaths);
|
|
397
|
+
if (directValue !== undefined) {
|
|
398
|
+
return coerceHours(directValue, diagnostics, {
|
|
399
|
+
...context,
|
|
400
|
+
field: directPaths[0]
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return fallbackReader(row, diagnostics, context);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function hoursToAssignPercent(hours) {
|
|
408
|
+
return Math.round((Number(hours || 0) / CONFIG.standardDayHours) * 100);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function hasObjectPayload(data) {
|
|
412
|
+
return Boolean(data && typeof data === 'object' && Object.keys(data).length > 0);
|
|
413
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { ApiError
|
|
1
|
+
import { ApiError } from './ro-api-http.mjs';
|
|
2
|
+
import { deleteLogworkEntry } from './ro-api-entries.mjs';
|
|
2
3
|
import { createResourceOptimiserSession } from './query-workflow.mjs';
|
|
3
4
|
import { assertOwnUnlockedRoEntry, createF03LogworkReader, createRoEntryRevisionFingerprint } from './ro-entry-reader.mjs';
|
|
4
5
|
|
package/lib/ro-entry-reader.mjs
CHANGED
|
@@ -1,26 +1,31 @@
|
|
|
1
1
|
import { ApiError, getLogtimeById, getLogworkDay, getProjects } from './api.mjs';
|
|
2
2
|
import { createResourceOptimiserSession } from './query-workflow.mjs';
|
|
3
3
|
|
|
4
|
+
const DEFAULT_READS = { getLogtimeById, getLogworkDay, getProjects };
|
|
5
|
+
|
|
4
6
|
// Reads one existing RO entry with the fields edit, resubmit and delete need. The legacy
|
|
5
7
|
// GET /member-logtime/:id gives the date, owner and Jira link; /logwork/day
|
|
6
8
|
// gives the F03 fields (status, worklog task, type of work, description); the
|
|
7
|
-
// project-member list maps project_member_id to project_id.
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
// project-member list maps project_member_id to project_id. Tests inject the
|
|
10
|
+
// session and the RO reads; callers use the defaults.
|
|
11
|
+
export function createF03LogworkReader({
|
|
12
|
+
session = createResourceOptimiserSession(),
|
|
13
|
+
reads = DEFAULT_READS
|
|
14
|
+
} = {}) {
|
|
10
15
|
let projectsPromise = null;
|
|
11
16
|
return async (logworkId) => withRoSessionRetry(session, async ({ token, userId }) => {
|
|
12
|
-
const legacy = await getLogtimeById(token, logworkId);
|
|
17
|
+
const legacy = await reads.getLogtimeById(token, logworkId);
|
|
13
18
|
if (!legacy.date) {
|
|
14
19
|
throw new Error(`Resource Optimiser did not return a date for logwork ${logworkId}.`);
|
|
15
20
|
}
|
|
16
|
-
const day = await getLogworkDay(token, { date: legacy.date });
|
|
21
|
+
const day = await reads.getLogworkDay(token, { date: legacy.date });
|
|
17
22
|
const f03 = (day.entries || []).find((entry) => String(entry.id) === String(legacy.id));
|
|
18
23
|
if (!f03) {
|
|
19
24
|
throw new Error(`RO logwork ${logworkId} was not found on ${legacy.date}; it may have been deleted.`);
|
|
20
25
|
}
|
|
21
26
|
// Cache only a successful load: a cached rejection (say a 401) would make
|
|
22
27
|
// the retry after a forced login fail the same way.
|
|
23
|
-
projectsPromise ||= getProjects(token, userId).catch((error) => {
|
|
28
|
+
projectsPromise ||= reads.getProjects(token, userId).catch((error) => {
|
|
24
29
|
projectsPromise = null;
|
|
25
30
|
throw error;
|
|
26
31
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { ApiError
|
|
1
|
+
import { ApiError } from './ro-api-http.mjs';
|
|
2
|
+
import { resubmitLogworkEntry } from './ro-api-entries.mjs';
|
|
2
3
|
import { createResourceOptimiserSession } from './query-workflow.mjs';
|
|
3
4
|
import { assertOwnUnlockedRoEntry, createF03LogworkReader, createRoEntryRevisionFingerprint } from './ro-entry-reader.mjs';
|
|
4
5
|
|
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
import
|
|
1
|
+
import * as clackPrompts from '@clack/prompts';
|
|
2
2
|
import { createAuthRequiredError } from './auth-errors.mjs';
|
|
3
3
|
import { createCredentialEmailStorage } from './credential-store.mjs';
|
|
4
4
|
|
|
5
5
|
export function createTerminalCredentialProvider({
|
|
6
6
|
emailStorage = createCredentialEmailStorage(),
|
|
7
7
|
stdin = process.stdin,
|
|
8
|
-
stdout = process.stdout
|
|
8
|
+
stdout = process.stdout,
|
|
9
|
+
prompts = clackPrompts
|
|
9
10
|
} = {}) {
|
|
11
|
+
const { password, select, text } = prompts;
|
|
12
|
+
const handleCancel = (value) => handleCancelWith(prompts, value);
|
|
10
13
|
return {
|
|
11
14
|
async requestCredentials() {
|
|
12
15
|
ensureInteractive(stdin, stdout);
|
|
@@ -111,8 +114,8 @@ function ensureInteractive(stdin, stdout) {
|
|
|
111
114
|
}
|
|
112
115
|
}
|
|
113
116
|
|
|
114
|
-
function
|
|
115
|
-
if (isCancel(value)) {
|
|
117
|
+
function handleCancelWith(prompts, value) {
|
|
118
|
+
if (prompts.isCancel(value)) {
|
|
116
119
|
throw new Error('User cancelled Resource Optimiser authentication.');
|
|
117
120
|
}
|
|
118
121
|
|