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,446 @@
|
|
|
1
|
+
import { ApiError } from './api.mjs';
|
|
2
|
+
import {
|
|
3
|
+
getJiraIssueWorklogs,
|
|
4
|
+
searchJiraIssues
|
|
5
|
+
} from './jira-api.mjs';
|
|
6
|
+
import {
|
|
7
|
+
createJiraAuthRequiredError,
|
|
8
|
+
getStoredJiraSession
|
|
9
|
+
} from './jira-auth.mjs';
|
|
10
|
+
import {
|
|
11
|
+
normalizeQueryRange,
|
|
12
|
+
queryLogwork
|
|
13
|
+
} from './query-workflow.mjs';
|
|
14
|
+
|
|
15
|
+
export const RECONCILIATION_PERIODS = [
|
|
16
|
+
'today',
|
|
17
|
+
'yesterday',
|
|
18
|
+
'this_week',
|
|
19
|
+
'last_week',
|
|
20
|
+
'this_month',
|
|
21
|
+
'last_month'
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const ISSUE_KEY_REGEX = /\b[A-Z][A-Z0-9]+-\d+\b/g;
|
|
25
|
+
|
|
26
|
+
export async function reconcileLogwork({
|
|
27
|
+
period = 'this_week',
|
|
28
|
+
cwd = process.cwd(),
|
|
29
|
+
queryRo = queryLogwork,
|
|
30
|
+
queryJira = queryJiraWorklogs
|
|
31
|
+
} = {}) {
|
|
32
|
+
const normalizedPeriod = normalizeReconciliationPeriod(period);
|
|
33
|
+
const range = normalizeQueryRange({ period: normalizedPeriod });
|
|
34
|
+
const [ro, jira] = await Promise.all([
|
|
35
|
+
queryRo({
|
|
36
|
+
period: normalizedPeriod,
|
|
37
|
+
cwd,
|
|
38
|
+
includeEntries: true
|
|
39
|
+
}),
|
|
40
|
+
queryJira({ range })
|
|
41
|
+
]);
|
|
42
|
+
const days = buildReconciliationDays({ ro, jira });
|
|
43
|
+
const totals = buildReconciliationTotals({ ro, jira, days });
|
|
44
|
+
const suggestions = days.flatMap((day) => day.suggestions);
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
status: totals.mismatchDayCount ? 'mismatch' : 'matched',
|
|
48
|
+
period: normalizedPeriod,
|
|
49
|
+
range,
|
|
50
|
+
totals,
|
|
51
|
+
days,
|
|
52
|
+
suggestions,
|
|
53
|
+
summary: formatReconciliationSummary({
|
|
54
|
+
period: normalizedPeriod,
|
|
55
|
+
range,
|
|
56
|
+
totals,
|
|
57
|
+
days,
|
|
58
|
+
suggestions
|
|
59
|
+
})
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function queryJiraWorklogs({
|
|
64
|
+
range,
|
|
65
|
+
getSession = getStoredJiraSession,
|
|
66
|
+
searchIssues = searchJiraIssues,
|
|
67
|
+
fetchWorklogs
|
|
68
|
+
} = {}) {
|
|
69
|
+
assertRange(range);
|
|
70
|
+
const session = await getSession();
|
|
71
|
+
const currentUser = session.user;
|
|
72
|
+
if (!hasJiraUserIdentity(currentUser)) {
|
|
73
|
+
throw createJiraAuthRequiredError('Stored Jira session has no user identity. Run `logwork login jira` again.');
|
|
74
|
+
}
|
|
75
|
+
const issueSearcher = searchIssues;
|
|
76
|
+
const worklogFetcher = fetchWorklogs || ((issueKey) => getJiraIssueWorklogs(session.token, issueKey, {
|
|
77
|
+
baseUrl: session.baseUrl
|
|
78
|
+
}));
|
|
79
|
+
const jql = `worklogAuthor = currentUser() AND worklogDate >= "${range.from}" AND worklogDate < "${range.to}"`;
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const issues = await issueSearcher(session.token, {
|
|
83
|
+
jql,
|
|
84
|
+
fields: ['key', 'summary'],
|
|
85
|
+
pageSize: 100
|
|
86
|
+
}, {
|
|
87
|
+
baseUrl: session.baseUrl
|
|
88
|
+
});
|
|
89
|
+
const worklogGroups = await mapWithConcurrency(issues, 5, async (issue) => ({
|
|
90
|
+
issue,
|
|
91
|
+
worklogs: await worklogFetcher(issue.key)
|
|
92
|
+
}));
|
|
93
|
+
const entries = worklogGroups.flatMap(({ issue, worklogs }) => (
|
|
94
|
+
(worklogs || [])
|
|
95
|
+
.filter((worklog) => isCurrentUserWorklog(worklog, currentUser, range))
|
|
96
|
+
.map((worklog) => normalizeJiraReconciliationEntry(issue, worklog))
|
|
97
|
+
)).sort(compareEntries);
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
range,
|
|
101
|
+
issueCount: issues.length,
|
|
102
|
+
worklogCount: entries.length,
|
|
103
|
+
hours: roundHours(entries.reduce((sum, entry) => sum + entry.hours, 0)),
|
|
104
|
+
entries,
|
|
105
|
+
issues: issues.map((issue) => ({
|
|
106
|
+
key: issue.key,
|
|
107
|
+
summary: issue.summary
|
|
108
|
+
}))
|
|
109
|
+
};
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (error instanceof ApiError && error.status === 401) {
|
|
112
|
+
throw createJiraAuthRequiredError();
|
|
113
|
+
}
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function buildReconciliationDays({ ro = {}, jira = {} } = {}) {
|
|
119
|
+
const roEntries = Array.isArray(ro.entries) ? ro.entries.map(normalizeRoEntry) : [];
|
|
120
|
+
const jiraEntries = Array.isArray(jira.entries) ? jira.entries : [];
|
|
121
|
+
const roDays = new Map((ro.days || []).map((day) => [day.date, day]));
|
|
122
|
+
const dates = new Set([
|
|
123
|
+
...roDays.keys(),
|
|
124
|
+
...roEntries.map((entry) => entry.date),
|
|
125
|
+
...jiraEntries.map((entry) => entry.date)
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
return [...dates].sort().map((date) => {
|
|
129
|
+
const dayRoEntries = roEntries.filter((entry) => entry.date === date);
|
|
130
|
+
const dayJiraEntries = jiraEntries.filter((entry) => entry.date === date);
|
|
131
|
+
const detailedRoHours = roundHours(dayRoEntries.reduce((sum, entry) => sum + entry.hours, 0));
|
|
132
|
+
const roHours = roundHours(roDays.get(date)?.loggedHours ?? detailedRoHours);
|
|
133
|
+
const jiraHours = roundHours(dayJiraEntries.reduce((sum, entry) => sum + entry.hours, 0));
|
|
134
|
+
const bookedHours = roundHours(roDays.get(date)?.bookedHours || 0);
|
|
135
|
+
const unverifiedRoHours = roundHours(Math.max(0, roHours - detailedRoHours));
|
|
136
|
+
const issueComparisons = buildIssueComparisons(dayRoEntries, dayJiraEntries);
|
|
137
|
+
const suggestions = buildCorrectionSuggestions(date, issueComparisons);
|
|
138
|
+
const unmatchedRoEntries = dayRoEntries.filter((entry) => entry.issueKeys.length !== 1);
|
|
139
|
+
const differenceHours = roundHours(jiraHours - roHours);
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
date,
|
|
143
|
+
bookedHours,
|
|
144
|
+
roHours,
|
|
145
|
+
jiraHours,
|
|
146
|
+
differenceHours,
|
|
147
|
+
missingOn: differenceHours < 0 ? 'jira' : differenceHours > 0 ? 'ro' : null,
|
|
148
|
+
remainingBookedHours: roundHours(Math.max(0, bookedHours - roHours)),
|
|
149
|
+
status: reconciliationDayStatus({
|
|
150
|
+
bookedHours,
|
|
151
|
+
roHours,
|
|
152
|
+
jiraHours,
|
|
153
|
+
issueComparisons,
|
|
154
|
+
unmatchedRoEntries,
|
|
155
|
+
unverifiedRoHours
|
|
156
|
+
}),
|
|
157
|
+
roEntries: dayRoEntries,
|
|
158
|
+
jiraEntries: dayJiraEntries,
|
|
159
|
+
issueComparisons,
|
|
160
|
+
unmatchedRoEntries,
|
|
161
|
+
unverifiedRoHours,
|
|
162
|
+
suggestions
|
|
163
|
+
};
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function formatReconciliationSummary({
|
|
168
|
+
period,
|
|
169
|
+
range,
|
|
170
|
+
totals,
|
|
171
|
+
days,
|
|
172
|
+
suggestions = []
|
|
173
|
+
} = {}) {
|
|
174
|
+
const lines = [
|
|
175
|
+
`RO / Jira reconciliation: ${formatPeriod(period)} (${range.from} to ${range.to}).`,
|
|
176
|
+
'',
|
|
177
|
+
'Date Booked RO Jira Diff Status'
|
|
178
|
+
];
|
|
179
|
+
if (!days.length) {
|
|
180
|
+
lines.push('No Resource Optimiser or Jira worklogs found.');
|
|
181
|
+
}
|
|
182
|
+
for (const day of days) {
|
|
183
|
+
lines.push([
|
|
184
|
+
day.date,
|
|
185
|
+
`${formatHours(day.bookedHours)}h`.padStart(7),
|
|
186
|
+
`${formatHours(day.roHours)}h`.padStart(7),
|
|
187
|
+
`${formatHours(day.jiraHours)}h`.padStart(7),
|
|
188
|
+
formatSignedHours(day.differenceHours).padStart(7),
|
|
189
|
+
formatDayStatus(day.status)
|
|
190
|
+
].join(' '));
|
|
191
|
+
}
|
|
192
|
+
lines.push('');
|
|
193
|
+
lines.push(`Total: booked ${formatHours(totals.bookedHours)}h, RO ${formatHours(totals.roHours)}h, Jira ${formatHours(totals.jiraHours)}h, difference ${formatSignedHours(totals.differenceHours)}.`);
|
|
194
|
+
lines.push(`Days: matched ${totals.matchedDayCount}, mismatched ${totals.mismatchDayCount}, incomplete ${totals.incompleteDayCount}, unverified ${totals.unverifiedDayCount}.`);
|
|
195
|
+
|
|
196
|
+
if (suggestions.length) {
|
|
197
|
+
lines.push('');
|
|
198
|
+
lines.push('High-confidence correction suggestions:');
|
|
199
|
+
for (const suggestion of suggestions) {
|
|
200
|
+
lines.push(`- ${suggestion.date}: ${suggestion.target.toUpperCase()} +${formatHours(suggestion.hours)}h ${suggestion.issueKey} - ${suggestion.taskName}`);
|
|
201
|
+
}
|
|
202
|
+
lines.push('Create a normal target-specific preview before applying any suggestion.');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const unverifiedCount = days.reduce((sum, day) => sum + day.unmatchedRoEntries.length, 0);
|
|
206
|
+
const missingDetailHours = roundHours(days.reduce((sum, day) => sum + day.unverifiedRoHours, 0));
|
|
207
|
+
if (unverifiedCount || missingDetailHours) {
|
|
208
|
+
lines.push('');
|
|
209
|
+
if (unverifiedCount) {
|
|
210
|
+
lines.push(`${unverifiedCount} RO entr${unverifiedCount === 1 ? 'y has' : 'ies have'} no single Jira issue key, so task-level reconciliation is unverified.`);
|
|
211
|
+
}
|
|
212
|
+
if (missingDetailHours) {
|
|
213
|
+
lines.push(`${formatHours(missingDetailHours)}h of RO work has no task detail returned, so task-level reconciliation is unverified.`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return lines.join('\n');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function normalizeReconciliationPeriod(value = 'this_week') {
|
|
220
|
+
const normalized = String(value || 'this_week').trim().toLowerCase().replace(/-/g, '_');
|
|
221
|
+
const aliases = {
|
|
222
|
+
week: 'this_week',
|
|
223
|
+
month: 'this_month'
|
|
224
|
+
};
|
|
225
|
+
const period = aliases[normalized] || normalized;
|
|
226
|
+
if (!RECONCILIATION_PERIODS.includes(period)) {
|
|
227
|
+
throw new Error('Reconciliation period must be today, yesterday, this-week, last-week, this-month, or last-month.');
|
|
228
|
+
}
|
|
229
|
+
return period;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function buildReconciliationTotals({ ro, jira, days }) {
|
|
233
|
+
const bookedHours = roundHours(ro?.totals?.bookedHours ?? days.reduce((sum, day) => sum + day.bookedHours, 0));
|
|
234
|
+
const roHours = roundHours(ro?.totals?.loggedHours ?? days.reduce((sum, day) => sum + day.roHours, 0));
|
|
235
|
+
const jiraHours = roundHours(jira?.hours ?? days.reduce((sum, day) => sum + day.jiraHours, 0));
|
|
236
|
+
return {
|
|
237
|
+
bookedHours,
|
|
238
|
+
roHours,
|
|
239
|
+
jiraHours,
|
|
240
|
+
differenceHours: roundHours(jiraHours - roHours),
|
|
241
|
+
dayCount: days.length,
|
|
242
|
+
matchedDayCount: days.filter((day) => day.status.startsWith('matched')).length,
|
|
243
|
+
mismatchDayCount: days.filter((day) => ['missing_jira', 'missing_ro', 'entry_mismatch'].includes(day.status)).length,
|
|
244
|
+
incompleteDayCount: days.filter((day) => day.remainingBookedHours > 0).length,
|
|
245
|
+
unverifiedDayCount: days.filter((day) => day.status === 'matched_unverified').length,
|
|
246
|
+
suggestionCount: days.reduce((sum, day) => sum + day.suggestions.length, 0)
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function buildIssueComparisons(roEntries, jiraEntries) {
|
|
251
|
+
const issueKeys = new Set([
|
|
252
|
+
...roEntries.flatMap((entry) => entry.issueKeys.length === 1 ? entry.issueKeys : []),
|
|
253
|
+
...jiraEntries.map((entry) => entry.issueKey).filter(Boolean)
|
|
254
|
+
]);
|
|
255
|
+
return [...issueKeys].sort().map((issueKey) => {
|
|
256
|
+
const matchingRo = roEntries.filter((entry) => entry.issueKeys.length === 1 && entry.issueKeys[0] === issueKey);
|
|
257
|
+
const matchingJira = jiraEntries.filter((entry) => entry.issueKey === issueKey);
|
|
258
|
+
const roHours = roundHours(matchingRo.reduce((sum, entry) => sum + entry.hours, 0));
|
|
259
|
+
const jiraHours = roundHours(matchingJira.reduce((sum, entry) => sum + entry.hours, 0));
|
|
260
|
+
return {
|
|
261
|
+
issueKey,
|
|
262
|
+
roHours,
|
|
263
|
+
jiraHours,
|
|
264
|
+
differenceHours: roundHours(jiraHours - roHours),
|
|
265
|
+
roEntries: matchingRo,
|
|
266
|
+
jiraEntries: matchingJira
|
|
267
|
+
};
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function buildCorrectionSuggestions(date, comparisons) {
|
|
272
|
+
return comparisons.flatMap((comparison) => {
|
|
273
|
+
if (comparison.differenceHours === 0) {
|
|
274
|
+
return [];
|
|
275
|
+
}
|
|
276
|
+
const target = comparison.differenceHours < 0 ? 'jira' : 'ro';
|
|
277
|
+
const sourceEntries = target === 'jira' ? comparison.roEntries : comparison.jiraEntries;
|
|
278
|
+
const taskName = preferredTaskName(sourceEntries, comparison.issueKey);
|
|
279
|
+
return [{
|
|
280
|
+
date,
|
|
281
|
+
target,
|
|
282
|
+
issueKey: comparison.issueKey,
|
|
283
|
+
hours: roundHours(Math.abs(comparison.differenceHours)),
|
|
284
|
+
taskName,
|
|
285
|
+
confidence: 'high',
|
|
286
|
+
requiresProjectResolution: target === 'ro'
|
|
287
|
+
}];
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function preferredTaskName(entries, issueKey) {
|
|
292
|
+
const entry = [...entries].sort((left, right) => right.hours - left.hours)[0];
|
|
293
|
+
const taskName = String(entry?.taskName || entry?.issueSummary || '').trim();
|
|
294
|
+
return taskName || issueKey;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function reconciliationDayStatus({
|
|
298
|
+
bookedHours,
|
|
299
|
+
roHours,
|
|
300
|
+
jiraHours,
|
|
301
|
+
issueComparisons,
|
|
302
|
+
unmatchedRoEntries,
|
|
303
|
+
unverifiedRoHours
|
|
304
|
+
}) {
|
|
305
|
+
const difference = roundHours(jiraHours - roHours);
|
|
306
|
+
if (difference < 0) {
|
|
307
|
+
return 'missing_jira';
|
|
308
|
+
}
|
|
309
|
+
if (difference > 0) {
|
|
310
|
+
return 'missing_ro';
|
|
311
|
+
}
|
|
312
|
+
if (issueComparisons.some((comparison) => comparison.differenceHours !== 0)) {
|
|
313
|
+
return 'entry_mismatch';
|
|
314
|
+
}
|
|
315
|
+
if ((unmatchedRoEntries.length || unverifiedRoHours > 0) && (roHours > 0 || jiraHours > 0)) {
|
|
316
|
+
return 'matched_unverified';
|
|
317
|
+
}
|
|
318
|
+
if (bookedHours > roHours) {
|
|
319
|
+
return 'matched_incomplete';
|
|
320
|
+
}
|
|
321
|
+
return 'matched';
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function normalizeRoEntry(entry = {}) {
|
|
325
|
+
const taskName = String(entry.taskName || '');
|
|
326
|
+
return {
|
|
327
|
+
id: entry.id,
|
|
328
|
+
date: String(entry.date || ''),
|
|
329
|
+
projectMemberId: entry.projectMemberId,
|
|
330
|
+
projectId: entry.projectId,
|
|
331
|
+
projectName: String(entry.projectName || ''),
|
|
332
|
+
hours: roundHours(entry.hours),
|
|
333
|
+
taskName,
|
|
334
|
+
issueKeys: extractIssueKeys(taskName)
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function normalizeJiraReconciliationEntry(issue, worklog) {
|
|
339
|
+
return {
|
|
340
|
+
id: worklog.id,
|
|
341
|
+
date: normalizeDateOnly(worklog.started),
|
|
342
|
+
issueKey: issue.key,
|
|
343
|
+
issueSummary: issue.summary || '',
|
|
344
|
+
hours: roundHours(Number(worklog.timeSpentSeconds || 0) / 3600),
|
|
345
|
+
timeSpentSeconds: Number(worklog.timeSpentSeconds || 0),
|
|
346
|
+
taskName: stripJiraWorklogMarkers(worklog.comment) || issue.summary || issue.key,
|
|
347
|
+
comment: String(worklog.comment || ''),
|
|
348
|
+
started: String(worklog.started || '')
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function isCurrentUserWorklog(worklog, currentUser, range) {
|
|
353
|
+
const date = normalizeDateOnly(worklog.started);
|
|
354
|
+
return date >= range.from && date < range.to && sameJiraUser(worklog.author, currentUser);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function sameJiraUser(left, right) {
|
|
358
|
+
for (const field of ['key', 'name', 'emailAddress']) {
|
|
359
|
+
const leftValue = normalizeIdentity(left?.[field]);
|
|
360
|
+
const rightValue = normalizeIdentity(right?.[field]);
|
|
361
|
+
if (leftValue && rightValue && leftValue === rightValue) {
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
const leftDisplay = normalizeIdentity(left?.displayName);
|
|
366
|
+
const rightDisplay = normalizeIdentity(right?.displayName);
|
|
367
|
+
return Boolean(leftDisplay && rightDisplay && leftDisplay === rightDisplay);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function hasJiraUserIdentity(user) {
|
|
371
|
+
return ['key', 'name', 'emailAddress', 'displayName'].some((field) => normalizeIdentity(user?.[field]));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function extractIssueKeys(value) {
|
|
375
|
+
return [...new Set(String(value || '').toUpperCase().match(ISSUE_KEY_REGEX) || [])];
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function stripJiraWorklogMarkers(value) {
|
|
379
|
+
return String(value || '')
|
|
380
|
+
.replace(/\s*#lh:[a-f0-9]{6,16}\s*/gi, ' ')
|
|
381
|
+
.replace(/\s*\[logwork-helper:[^\]]+\]\s*/gi, ' ')
|
|
382
|
+
.replace(/\s+/g, ' ')
|
|
383
|
+
.trim();
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function mapWithConcurrency(items, concurrency, mapper) {
|
|
387
|
+
const results = new Array(items.length);
|
|
388
|
+
let nextIndex = 0;
|
|
389
|
+
async function worker() {
|
|
390
|
+
while (nextIndex < items.length) {
|
|
391
|
+
const index = nextIndex;
|
|
392
|
+
nextIndex += 1;
|
|
393
|
+
results[index] = await mapper(items[index], index);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
|
|
397
|
+
return results;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function assertRange(range) {
|
|
401
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(range?.from || '')) ||
|
|
402
|
+
!/^\d{4}-\d{2}-\d{2}$/.test(String(range?.to || '')) ||
|
|
403
|
+
range.to <= range.from) {
|
|
404
|
+
throw new Error('Jira reconciliation requires a valid exclusive date range.');
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function normalizeDateOnly(value) {
|
|
409
|
+
return String(value || '').match(/\d{4}-\d{2}-\d{2}/)?.[0] || '';
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function normalizeIdentity(value) {
|
|
413
|
+
return String(value || '').trim().toLowerCase();
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function compareEntries(left, right) {
|
|
417
|
+
return left.date.localeCompare(right.date) || String(left.issueKey || '').localeCompare(String(right.issueKey || ''));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function formatDayStatus(status) {
|
|
421
|
+
return ({
|
|
422
|
+
matched: 'Matched',
|
|
423
|
+
matched_incomplete: 'Matched, incomplete',
|
|
424
|
+
matched_unverified: 'Matched, unverified tasks',
|
|
425
|
+
missing_jira: 'Missing Jira',
|
|
426
|
+
missing_ro: 'Missing RO',
|
|
427
|
+
entry_mismatch: 'Task mismatch'
|
|
428
|
+
})[status] || status;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function formatPeriod(period) {
|
|
432
|
+
return String(period || '').replace(/_/g, ' ');
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function formatSignedHours(value) {
|
|
436
|
+
const number = roundHours(value);
|
|
437
|
+
return `${number > 0 ? '+' : ''}${formatHours(number)}h`;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function formatHours(value) {
|
|
441
|
+
return Number(value || 0).toFixed(2).replace(/\.00$/, '').replace(/(\.\d)0$/, '$1');
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function roundHours(value) {
|
|
445
|
+
return Number(Number(value || 0).toFixed(2));
|
|
446
|
+
}
|