logwork-cli 1.1.1 → 1.2.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 +23 -7
- package/RELEASE.md +1 -0
- package/commands/commits.mjs +57 -6
- package/commands/history.mjs +0 -4
- package/commands/quick.mjs +38 -23
- package/commands/reminder.mjs +41 -2
- package/commands/setup.mjs +50 -16
- package/commands/tui.mjs +1 -0
- package/docs/advanced.md +12 -2
- package/docs/mcp-setup.md +56 -14
- package/docs/operations-guide.md +35 -2
- package/docs/recipes.md +27 -0
- package/docs/security.md +3 -1
- package/examples/mcp/antigravity.mcp_config.json +1 -1
- package/examples/mcp/codex.config.toml +1 -1
- package/examples/mcp/copilot.mcp.json +1 -1
- 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 +188 -21
- package/lib/config.mjs +7 -0
- package/lib/diagnostics.mjs +53 -4
- package/lib/jira-api.mjs +25 -3
- package/lib/jira-workflow.mjs +36 -6
- 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 +108 -1281
- package/lib/manual-ink-draft-actions.mjs +227 -0
- package/lib/manual-ink-interaction.mjs +306 -0
- package/lib/manual-ink-session.mjs +41 -0
- package/lib/manual-ink-task-editor.mjs +338 -0
- package/lib/manual-ink-wizard.mjs +520 -0
- package/lib/manual-mcp.mjs +27 -15
- package/lib/manual-repl.mjs +39 -3
- package/lib/mcp-server.mjs +51 -20
- package/lib/node-runtime.mjs +115 -0
- package/lib/paths.mjs +7 -0
- package/lib/project-mapping-workflow.mjs +30 -7
- package/lib/query-workflow.mjs +0 -1
- package/lib/reconciliation-workflow.mjs +1 -1
- package/lib/reminder-config.mjs +38 -2
- package/lib/reminder-scheduler.mjs +54 -3
- package/lib/reminder-service.mjs +28 -3
- package/lib/reminder-workflow.mjs +118 -22
- 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 +80 -59
- package/package.json +11 -4
- package/skills/logwork/SKILL.md +5 -4
package/lib/diagnostics.mjs
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
1
|
import { constants as fsConstants } from 'node:fs';
|
|
3
2
|
import { access, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
4
3
|
import { EOL, homedir, platform, release, type } from 'node:os';
|
|
@@ -11,7 +10,9 @@ import { getStoredAuthStatus } from './auth.mjs';
|
|
|
11
10
|
import { redactText } from './auth-redaction.mjs';
|
|
12
11
|
import { authDebugLogPath, sanitizeDiagnosticValue } from './auth-diagnostics.mjs';
|
|
13
12
|
import { configPath, loadLocalConfig } from './logwork-config.mjs';
|
|
14
|
-
import {
|
|
13
|
+
import { formatNodePathIssue, inspectNodePath, loadRuntimeInfo } from './node-runtime.mjs';
|
|
14
|
+
import { helperHome, manualDraftsPath, runtimeInfoPath, userConfigPath } from './paths.mjs';
|
|
15
|
+
import { readReminderSchedulerNodePath } from './reminder-scheduler.mjs';
|
|
15
16
|
|
|
16
17
|
const DIAGNOSTICS_DIR_NAME = 'diagnostics';
|
|
17
18
|
const MAX_AUTH_DEBUG_CHARS = 20_000;
|
|
@@ -35,6 +36,7 @@ export async function generateDiagnosticsReport({
|
|
|
35
36
|
commandFinder = findCommandOnPath,
|
|
36
37
|
authStatusFn = getStoredAuthStatus,
|
|
37
38
|
mcpSmokeFn = runMcpSmoke,
|
|
39
|
+
nodeRuntimeFn = collectNodeRuntimeCheck,
|
|
38
40
|
readFileFn = readFile,
|
|
39
41
|
statFn = stat,
|
|
40
42
|
writeFileFn = writeFile,
|
|
@@ -47,6 +49,8 @@ export async function generateDiagnosticsReport({
|
|
|
47
49
|
const fileChecks = await collectFileChecks({ statFn, cwd });
|
|
48
50
|
const configSnapshot = await safeCollect('config snapshot', () => collectConfigSnapshot({ cwd, includeMappingDetails }));
|
|
49
51
|
const mcpSmoke = await safeCollect('mcp smoke', () => mcpSmokeFn({ cwd }));
|
|
52
|
+
const nodeRuntime = await safeCollect('node runtime', () => nodeRuntimeFn());
|
|
53
|
+
const nodeIssues = Array.isArray(nodeRuntime?.issues) ? nodeRuntime.issues : [];
|
|
50
54
|
const authDebug = await readSanitizedAuthDebug({ readFileFn });
|
|
51
55
|
|
|
52
56
|
sections.push(formatSection('logwork-cli Diagnostics', [
|
|
@@ -64,6 +68,17 @@ export async function generateDiagnosticsReport({
|
|
|
64
68
|
`home: ${homedir()}`
|
|
65
69
|
]));
|
|
66
70
|
|
|
71
|
+
sections.push(formatSection('Node Path', nodeRuntime?.ok === false
|
|
72
|
+
? formatObjectLines(nodeRuntime)
|
|
73
|
+
: [
|
|
74
|
+
`runtimeFile: ${nodeRuntime.runtimeFile}`,
|
|
75
|
+
`recordedNode: ${nodeRuntime.recordedNode || 'none (run logwork setup)'}${nodeRuntime.recordedVersion ? ` (${nodeRuntime.recordedVersion})` : ''}`,
|
|
76
|
+
`recordedNodeStatus: ${nodeRuntime.recordedStatus}`,
|
|
77
|
+
`reminderNode: ${nodeRuntime.reminderNode || 'none (no reminder schedule)'}`,
|
|
78
|
+
`reminderNodeStatus: ${nodeRuntime.reminderStatus}`,
|
|
79
|
+
...(nodeIssues.length ? nodeIssues.map((issue) => `warning: ${issue}`) : ['warnings: none'])
|
|
80
|
+
]));
|
|
81
|
+
|
|
67
82
|
sections.push(formatSection('Commands', commandChecks.map((entry) => `${entry.command}: ${entry.available ? 'found' : 'missing'}${entry.path ? ` (${entry.path})` : ''}`)));
|
|
68
83
|
sections.push(formatSection('Files', fileChecks.map((entry) => formatFileCheck(entry))));
|
|
69
84
|
sections.push(formatSection('Config Snapshot', formatObjectLines(configSnapshot)));
|
|
@@ -78,7 +93,41 @@ export async function generateDiagnosticsReport({
|
|
|
78
93
|
return {
|
|
79
94
|
path: outputPath,
|
|
80
95
|
report,
|
|
81
|
-
|
|
96
|
+
nodeIssues: nodeIssues.map((issue) => sanitizeReport(issue)),
|
|
97
|
+
summary: [
|
|
98
|
+
...nodeIssues.map((issue) => sanitizeReport(`Warning: ${issue}`)),
|
|
99
|
+
`Diagnostics report written to ${outputPath}`
|
|
100
|
+
].join(EOL)
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Compares the node recorded by `logwork setup` (runtime.json) and the node in
|
|
105
|
+
// the installed reminder schedule with the node running now. Read-only.
|
|
106
|
+
export async function collectNodeRuntimeCheck({
|
|
107
|
+
loadRuntime = loadRuntimeInfo,
|
|
108
|
+
readSchedulerNodePath = readReminderSchedulerNodePath,
|
|
109
|
+
inspect = inspectNodePath
|
|
110
|
+
} = {}) {
|
|
111
|
+
const runtime = await loadRuntime().catch(() => null);
|
|
112
|
+
const reminderNode = await readSchedulerNodePath().catch(() => null);
|
|
113
|
+
const recorded = runtime?.nodePath
|
|
114
|
+
? await inspect(runtime.nodePath)
|
|
115
|
+
: { status: 'unknown' };
|
|
116
|
+
const reminder = reminderNode
|
|
117
|
+
? await inspect(reminderNode)
|
|
118
|
+
: { status: 'unknown' };
|
|
119
|
+
const issues = [
|
|
120
|
+
formatNodePathIssue('runtime', recorded),
|
|
121
|
+
formatNodePathIssue('reminder', reminder)
|
|
122
|
+
].filter(Boolean);
|
|
123
|
+
return {
|
|
124
|
+
runtimeFile: runtimeInfoPath(),
|
|
125
|
+
recordedNode: runtime?.nodePath || '',
|
|
126
|
+
recordedVersion: runtime?.nodeVersion || '',
|
|
127
|
+
recordedStatus: recorded.status,
|
|
128
|
+
reminderNode: reminderNode || '',
|
|
129
|
+
reminderStatus: reminder.status,
|
|
130
|
+
issues
|
|
82
131
|
};
|
|
83
132
|
}
|
|
84
133
|
|
|
@@ -202,7 +251,7 @@ async function safeCollect(label, fn) {
|
|
|
202
251
|
}
|
|
203
252
|
}
|
|
204
253
|
|
|
205
|
-
export async function runMcpSmoke({
|
|
254
|
+
export async function runMcpSmoke({ timeoutMs = MCP_SMOKE_TIMEOUT_MS } = {}) {
|
|
206
255
|
const client = new Client({
|
|
207
256
|
name: 'logwork-diagnostics',
|
|
208
257
|
version: '0.1.0'
|
package/lib/jira-api.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CONFIG } from './config.mjs';
|
|
1
|
+
import { CONFIG, isDryRun } from './config.mjs';
|
|
2
2
|
import { ApiError } from './api.mjs';
|
|
3
3
|
import { fetchWithPolicy, redactedExcerpt } from './http.mjs';
|
|
4
4
|
import { safeJsonParse } from './util.mjs';
|
|
@@ -15,6 +15,11 @@ export async function jiraApiFetch(token, path, {
|
|
|
15
15
|
} = {}) {
|
|
16
16
|
assertJiraToken(token);
|
|
17
17
|
const url = buildJiraUrl(baseUrl, path);
|
|
18
|
+
// Every Jira write goes through here, so this one gate covers worklog create
|
|
19
|
+
// and any write added later. The result never carries the token or headers.
|
|
20
|
+
if (!isIdempotentMethod(method) && isDryRun()) {
|
|
21
|
+
return createJiraDryRunRequest(method, path, body);
|
|
22
|
+
}
|
|
18
23
|
const response = await fetchWithPolicy(url.toString(), {
|
|
19
24
|
fetchImpl,
|
|
20
25
|
timeoutMs,
|
|
@@ -123,12 +128,29 @@ export async function addJiraIssueWorklog(token, issueKey, worklog, options = {}
|
|
|
123
128
|
assertIssueKey(issueKey);
|
|
124
129
|
const qs = new URLSearchParams({ adjustEstimate: 'leave' });
|
|
125
130
|
const path = `/rest/api/2/issue/${encodeURIComponent(issueKey)}/worklog?${qs}`;
|
|
126
|
-
|
|
131
|
+
const data = await jiraApiFetch(token, path, {
|
|
127
132
|
...options,
|
|
128
133
|
method: 'POST',
|
|
129
134
|
retries: 0,
|
|
130
135
|
body: worklog
|
|
131
|
-
})
|
|
136
|
+
});
|
|
137
|
+
return isJiraDryRunResult(data) ? data : normalizeJiraWorklog(data);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function isJiraDryRunResult(value) {
|
|
141
|
+
return value?.dryRun === true;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function createJiraDryRunRequest(method, path, body) {
|
|
145
|
+
const request = {
|
|
146
|
+
dryRun: true,
|
|
147
|
+
method: String(method).toUpperCase(),
|
|
148
|
+
path: String(path)
|
|
149
|
+
};
|
|
150
|
+
if (body !== undefined) {
|
|
151
|
+
request.body = structuredClone(body);
|
|
152
|
+
}
|
|
153
|
+
return request;
|
|
132
154
|
}
|
|
133
155
|
|
|
134
156
|
export function normalizeJiraIssue(issue = {}) {
|
package/lib/jira-workflow.mjs
CHANGED
|
@@ -2,11 +2,12 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { CONFIG } from './config.mjs';
|
|
3
3
|
import { recordApplyResult, recordApplyResultSafely } from './apply-ledger.mjs';
|
|
4
4
|
import { parseWeeklyLogText } from './batch-parser.mjs';
|
|
5
|
-
import { ApiError } from './api.mjs';
|
|
5
|
+
import { ApiError } from './ro-api-http.mjs';
|
|
6
6
|
import {
|
|
7
7
|
addJiraIssueWorklog,
|
|
8
8
|
getJiraIssue,
|
|
9
|
-
getJiraIssueWorklogs
|
|
9
|
+
getJiraIssueWorklogs,
|
|
10
|
+
isJiraDryRunResult
|
|
10
11
|
} from './jira-api.mjs';
|
|
11
12
|
import {
|
|
12
13
|
createJiraAuthRequiredError,
|
|
@@ -148,6 +149,25 @@ export async function applyJiraWorklogBatch({
|
|
|
148
149
|
const payload = entry.worklog || buildJiraWorklogPayload(entry);
|
|
149
150
|
try {
|
|
150
151
|
const worklog = await submitter(entry.issueKey, payload);
|
|
152
|
+
if (isJiraDryRunResult(worklog)) {
|
|
153
|
+
// LOGWORK_DRY_RUN=1: Jira was not called. Keep only the request so the
|
|
154
|
+
// caller can see what apply would send.
|
|
155
|
+
results.push({
|
|
156
|
+
entryId: entry.id,
|
|
157
|
+
issueKey: entry.issueKey,
|
|
158
|
+
date: entry.date,
|
|
159
|
+
hours: entry.hours,
|
|
160
|
+
taskName: entry.taskName,
|
|
161
|
+
status: 'dry_run',
|
|
162
|
+
dryRun: true,
|
|
163
|
+
request: {
|
|
164
|
+
method: worklog.method,
|
|
165
|
+
path: worklog.path,
|
|
166
|
+
body: worklog.body
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
151
171
|
results.push({
|
|
152
172
|
entryId: entry.id,
|
|
153
173
|
issueKey: entry.issueKey,
|
|
@@ -173,9 +193,13 @@ export async function applyJiraWorklogBatch({
|
|
|
173
193
|
}
|
|
174
194
|
|
|
175
195
|
const failed = results.some((result) => result.status === 'failed');
|
|
196
|
+
const dryRun = results.some((result) => result.dryRun);
|
|
176
197
|
const output = {
|
|
177
198
|
batchId: batch.batchId,
|
|
178
|
-
status: failed
|
|
199
|
+
status: failed
|
|
200
|
+
? (results.some((result) => result.status === 'submitted') ? 'partial_failed' : 'failed')
|
|
201
|
+
: (dryRun ? 'dry_run' : 'submitted'),
|
|
202
|
+
dryRun,
|
|
179
203
|
results,
|
|
180
204
|
summary: formatJiraApplySummary(results)
|
|
181
205
|
};
|
|
@@ -395,9 +419,15 @@ function formatJiraApplyBlockedSummary(blockedEntries) {
|
|
|
395
419
|
function formatJiraApplySummary(results) {
|
|
396
420
|
const submitted = results.filter((result) => result.status === 'submitted');
|
|
397
421
|
const failed = results.filter((result) => result.status === 'failed');
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
|
|
422
|
+
const dryRun = results.filter((result) => result.dryRun);
|
|
423
|
+
const lines = dryRun.length
|
|
424
|
+
? [
|
|
425
|
+
'DRY RUN (LOGWORK_DRY_RUN=1): Jira worklog requests generated; nothing was written to Jira.',
|
|
426
|
+
`Jira worklog dry run: ${dryRun.length} request${dryRun.length === 1 ? '' : 's'}, submitted ${submitted.length}, failed ${failed.length}.`
|
|
427
|
+
]
|
|
428
|
+
: [
|
|
429
|
+
`Jira worklog apply: submitted ${submitted.length}, failed ${failed.length}.`
|
|
430
|
+
];
|
|
401
431
|
for (const result of results) {
|
|
402
432
|
lines.push(`- ${result.date}: ${result.issueKey} +${formatHours(result.hours)}h -> ${result.status}${result.error ? ` (${result.error})` : ''}`);
|
|
403
433
|
}
|
package/lib/logwork-config.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
2
4
|
import {
|
|
3
5
|
legacyUserConfigPath,
|
|
4
6
|
projectConfigPath,
|
|
@@ -29,6 +31,7 @@ export async function upsertProjectMappingConfig({
|
|
|
29
31
|
project,
|
|
30
32
|
tickets = [],
|
|
31
33
|
keywords = [],
|
|
34
|
+
repos = [],
|
|
32
35
|
lockOptions = {}
|
|
33
36
|
}) {
|
|
34
37
|
const path = scope === 'project' ? projectConfigPath(cwd) : userConfigPath();
|
|
@@ -39,25 +42,19 @@ export async function upsertProjectMappingConfig({
|
|
|
39
42
|
normalizeConfig(await readRawConfigPath(legacyUserConfigPath())),
|
|
40
43
|
normalizeConfig(await readRawConfigPath(path))
|
|
41
44
|
);
|
|
42
|
-
const mapping = {
|
|
45
|
+
const mapping = withRepos({
|
|
43
46
|
projectName: project.projectName,
|
|
44
47
|
projectMemberId: project.projectMemberId,
|
|
45
48
|
tickets: normalizeTickets(tickets),
|
|
46
49
|
keywords: normalizeKeywords(keywords)
|
|
47
|
-
};
|
|
50
|
+
}, normalizeRepos(repos));
|
|
48
51
|
const existingIndex = config.projectMappings.findIndex((candidate) => (
|
|
49
52
|
sameProjectIdentity(candidate, mapping)
|
|
50
53
|
));
|
|
51
54
|
|
|
52
55
|
const projectMappings = [...config.projectMappings];
|
|
53
56
|
if (existingIndex >= 0) {
|
|
54
|
-
|
|
55
|
-
projectMappings[existingIndex] = {
|
|
56
|
-
projectName: mapping.projectName || existing.projectName,
|
|
57
|
-
projectMemberId: mapping.projectMemberId ?? existing.projectMemberId,
|
|
58
|
-
tickets: uniqueStrings([...existing.tickets, ...mapping.tickets], { uppercase: true }),
|
|
59
|
-
keywords: uniqueStrings([...existing.keywords, ...mapping.keywords])
|
|
60
|
-
};
|
|
57
|
+
projectMappings[existingIndex] = mergeMapping(projectMappings[existingIndex], mapping);
|
|
61
58
|
} else {
|
|
62
59
|
projectMappings.push(mapping);
|
|
63
60
|
}
|
|
@@ -133,21 +130,75 @@ function mappingKey(mapping) {
|
|
|
133
130
|
}
|
|
134
131
|
|
|
135
132
|
function normalizeMapping(mapping) {
|
|
136
|
-
return {
|
|
133
|
+
return withRepos({
|
|
137
134
|
projectName: stringOrEmpty(mapping.projectName),
|
|
138
135
|
projectMemberId: optionalId(mapping.projectMemberId),
|
|
139
136
|
tickets: normalizeTickets(mapping.tickets),
|
|
140
137
|
keywords: normalizeKeywords(mapping.keywords)
|
|
141
|
-
};
|
|
138
|
+
}, normalizeRepos(mapping.repos));
|
|
142
139
|
}
|
|
143
140
|
|
|
144
141
|
function mergeMapping(existing, next) {
|
|
145
|
-
return {
|
|
142
|
+
return withRepos({
|
|
146
143
|
projectName: next.projectName || existing.projectName,
|
|
147
144
|
projectMemberId: next.projectMemberId ?? existing.projectMemberId,
|
|
148
145
|
tickets: uniqueStrings([...existing.tickets, ...next.tickets], { uppercase: true }),
|
|
149
146
|
keywords: uniqueStrings([...existing.keywords, ...next.keywords])
|
|
150
|
-
};
|
|
147
|
+
}, normalizeRepos([...(existing.repos || []), ...(next.repos || [])]));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// repos is optional: a mapping without repos keeps its old shape on disk.
|
|
151
|
+
function withRepos(mapping, repos) {
|
|
152
|
+
return repos.length ? { ...mapping, repos } : mapping;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Git repo roots are stored resolved and without trailing separators. Windows
|
|
156
|
+
// paths compare case-insensitively (see repoPathKey).
|
|
157
|
+
export function normalizeRepoPath(value, platform = process.platform) {
|
|
158
|
+
const text = typeof value === 'string' ? value.trim() : '';
|
|
159
|
+
if (!text) {
|
|
160
|
+
return '';
|
|
161
|
+
}
|
|
162
|
+
const pathApi = platform === 'win32' ? path.win32 : path.posix;
|
|
163
|
+
const expanded = text === '~' || /^~[\\/]/.test(text) ? `${homedir()}${text.slice(1)}` : text;
|
|
164
|
+
const resolved = pathApi.resolve(expanded);
|
|
165
|
+
const root = pathApi.parse(resolved).root;
|
|
166
|
+
let trimmed = resolved;
|
|
167
|
+
while (trimmed.length > root.length && /[\\/]$/.test(trimmed)) {
|
|
168
|
+
trimmed = trimmed.slice(0, -1);
|
|
169
|
+
}
|
|
170
|
+
return trimmed;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function repoPathKey(value, platform = process.platform) {
|
|
174
|
+
const normalized = normalizeRepoPath(value, platform);
|
|
175
|
+
return platform === 'win32' ? normalized.toLowerCase() : normalized;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function normalizeRepos(value, platform = process.platform) {
|
|
179
|
+
const seen = new Set();
|
|
180
|
+
const results = [];
|
|
181
|
+
for (const item of stringArray(value)) {
|
|
182
|
+
const normalized = normalizeRepoPath(item, platform);
|
|
183
|
+
const key = repoPathKey(normalized, platform);
|
|
184
|
+
if (!normalized || seen.has(key)) {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
seen.add(key);
|
|
188
|
+
results.push(normalized);
|
|
189
|
+
}
|
|
190
|
+
return results;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// The mapping whose repos list contains this git repo root, if any.
|
|
194
|
+
export function findRepoMapping(mappings, repoRoot, platform = process.platform) {
|
|
195
|
+
const key = repoPathKey(repoRoot, platform);
|
|
196
|
+
if (!key) {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
return (mappings || []).find((mapping) => (
|
|
200
|
+
(mapping.repos || []).some((repo) => repoPathKey(repo, platform) === key)
|
|
201
|
+
)) || null;
|
|
151
202
|
}
|
|
152
203
|
|
|
153
204
|
function stringOrEmpty(value) {
|
|
@@ -30,6 +30,21 @@ export function manualUnbookedEntryCount(preview) {
|
|
|
30
30
|
return preview?.unbookedEntries?.length || 0;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
export function buildManualDuplicateConfirmation(preview) {
|
|
34
|
+
const duplicates = (preview?.entries || []).filter((entry) => entry.possibleDuplicate);
|
|
35
|
+
const list = duplicates.map((entry) => {
|
|
36
|
+
const duplicate = entry.possibleDuplicate;
|
|
37
|
+
return duplicate.source === 'batch'
|
|
38
|
+
? `${entry.id} repeats ${duplicate.entryId}`
|
|
39
|
+
: `${entry.id} matches RO logwork ${duplicate.logworkId ?? '(no id)'}`;
|
|
40
|
+
}).join(', ');
|
|
41
|
+
return `This preview contains ${duplicates.length} possible duplicate entries (${list}). Submit them anyway?`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function manualDuplicateEntryCount(preview) {
|
|
45
|
+
return (preview?.entries || []).filter((entry) => entry.possibleDuplicate).length;
|
|
46
|
+
}
|
|
47
|
+
|
|
33
48
|
export function totalPreviewHours(preview) {
|
|
34
49
|
return (preview?.entries || []).reduce((sum, entry) => sum + Number(entry.hours || 0), 0);
|
|
35
50
|
}
|
package/lib/manual-commands.mjs
CHANGED
|
@@ -74,8 +74,8 @@ export const MANUAL_COMMANDS = [
|
|
|
74
74
|
},
|
|
75
75
|
{
|
|
76
76
|
name: '/map',
|
|
77
|
-
description: 'Map ticket prefix to projectMemberId',
|
|
78
|
-
usage: '/map SCB 5234',
|
|
77
|
+
description: 'Map ticket prefix or git repo to projectMemberId',
|
|
78
|
+
usage: '/map SCB 5234 | /map --repo [path] 5234',
|
|
79
79
|
aliases: []
|
|
80
80
|
}
|
|
81
81
|
];
|