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.
Files changed (91) hide show
  1. package/LICENSE +11 -0
  2. package/README.md +287 -2
  3. package/RELEASE.md +89 -0
  4. package/bin/logwork.mjs +101 -0
  5. package/commands/auth.mjs +227 -0
  6. package/commands/delete.mjs +69 -0
  7. package/commands/doctor.mjs +24 -0
  8. package/commands/edit.mjs +88 -0
  9. package/commands/history.mjs +62 -0
  10. package/commands/query.mjs +52 -0
  11. package/commands/quick.mjs +163 -0
  12. package/commands/reconcile.mjs +55 -0
  13. package/commands/release.mjs +77 -0
  14. package/commands/reminder.mjs +172 -0
  15. package/commands/setup.mjs +504 -0
  16. package/commands/tui.mjs +50 -0
  17. package/commands/update.mjs +161 -0
  18. package/docs/advanced.md +334 -0
  19. package/docs/mcp-setup.md +347 -0
  20. package/docs/operations-guide.md +587 -0
  21. package/docs/recipes.md +259 -0
  22. package/docs/security.md +151 -0
  23. package/examples/mcp/antigravity.mcp_config.json +8 -0
  24. package/examples/mcp/codex.config.toml +5 -0
  25. package/examples/mcp/copilot.mcp.json +9 -0
  26. package/lib/api-auth.mjs +1259 -0
  27. package/lib/api.mjs +1422 -0
  28. package/lib/apply-ledger.mjs +227 -0
  29. package/lib/atomic-file.mjs +24 -0
  30. package/lib/auth-diagnostics.mjs +113 -0
  31. package/lib/auth-errors.mjs +22 -0
  32. package/lib/auth-redaction.mjs +78 -0
  33. package/lib/auth-terminal.mjs +97 -0
  34. package/lib/auth.mjs +323 -0
  35. package/lib/batch-parser.mjs +145 -0
  36. package/lib/batch-workflow.mjs +842 -0
  37. package/lib/config.mjs +191 -0
  38. package/lib/credential-store.mjs +130 -0
  39. package/lib/diagnostics.mjs +307 -0
  40. package/lib/entrypoint.mjs +14 -0
  41. package/lib/file-lock.mjs +44 -0
  42. package/lib/http.mjs +125 -0
  43. package/lib/jira-api.mjs +258 -0
  44. package/lib/jira-auth.mjs +203 -0
  45. package/lib/jira-workflow.mjs +523 -0
  46. package/lib/keychain.mjs +105 -0
  47. package/lib/legacy-migration.mjs +66 -0
  48. package/lib/list-navigation.mjs +19 -0
  49. package/lib/logwork-config.mjs +199 -0
  50. package/lib/logwork-f03-enrich.mjs +41 -0
  51. package/lib/macos.mjs +33 -0
  52. package/lib/manual-apply-state.mjs +35 -0
  53. package/lib/manual-commands.mjs +294 -0
  54. package/lib/manual-drafts.mjs +154 -0
  55. package/lib/manual-ink-app.mjs +1654 -0
  56. package/lib/manual-ink-auth.mjs +349 -0
  57. package/lib/manual-ink-ui.mjs +675 -0
  58. package/lib/manual-input.mjs +21 -0
  59. package/lib/manual-logwork-wizard.mjs +310 -0
  60. package/lib/manual-mcp.mjs +187 -0
  61. package/lib/manual-repl.mjs +1037 -0
  62. package/lib/manual-targets.mjs +78 -0
  63. package/lib/mcp-server.mjs +793 -0
  64. package/lib/os-notification.mjs +105 -0
  65. package/lib/package-info.mjs +17 -0
  66. package/lib/paths.mjs +68 -0
  67. package/lib/project-identity.mjs +66 -0
  68. package/lib/project-mapping-workflow.mjs +309 -0
  69. package/lib/project-resolver.mjs +373 -0
  70. package/lib/query-workflow.mjs +553 -0
  71. package/lib/reconciliation-workflow.mjs +446 -0
  72. package/lib/release-doctor.mjs +448 -0
  73. package/lib/reminder-config.mjs +71 -0
  74. package/lib/reminder-scheduler.mjs +301 -0
  75. package/lib/reminder-service.mjs +118 -0
  76. package/lib/reminder-state.mjs +38 -0
  77. package/lib/reminder-workflow.mjs +299 -0
  78. package/lib/ro-delete-args.mjs +38 -0
  79. package/lib/ro-delete-workflow.mjs +115 -0
  80. package/lib/ro-edit-args.mjs +142 -0
  81. package/lib/ro-edit-workflow.mjs +632 -0
  82. package/lib/ro-entry-reader.mjs +56 -0
  83. package/lib/ro-resubmit-workflow.mjs +134 -0
  84. package/lib/terminal-auth-prompts.mjs +120 -0
  85. package/lib/ui.mjs +133 -0
  86. package/lib/update-service.mjs +350 -0
  87. package/lib/util.mjs +66 -0
  88. package/lib/windows-credential-manager.mjs +260 -0
  89. package/lib/worklog-task-resolver.mjs +113 -0
  90. package/package.json +72 -4
  91. package/setup.sh +22 -0
@@ -0,0 +1,105 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { runOsascript } from './macos.mjs';
3
+
4
+ export async function sendOsNotification({
5
+ title = 'Logwork',
6
+ message,
7
+ platform = process.platform,
8
+ macNotifier = sendMacosNotification,
9
+ windowsNotifier = sendWindowsNotification
10
+ } = {}) {
11
+ const notification = normalizeNotification({ title, message });
12
+ if (platform === 'darwin') {
13
+ await macNotifier(notification);
14
+ } else if (platform === 'win32') {
15
+ await windowsNotifier(notification);
16
+ } else {
17
+ throw new Error(`OS notifications are unsupported on ${platform}. Supported platforms are macOS and Windows.`);
18
+ }
19
+
20
+ return {
21
+ status: 'sent',
22
+ platform,
23
+ title: notification.title,
24
+ message: notification.message
25
+ };
26
+ }
27
+
28
+ export async function sendMacosNotification({ title, message }, {
29
+ run = runOsascript
30
+ } = {}) {
31
+ const script = String.raw`
32
+ on run argv
33
+ display notification (item 2 of argv) with title (item 1 of argv)
34
+ end run`;
35
+ await run(script, [title, message]);
36
+ }
37
+
38
+ export async function sendWindowsNotification({ title, message }, {
39
+ run = runPowerShellToast
40
+ } = {}) {
41
+ await run({ title, message });
42
+ }
43
+
44
+ export function runPowerShellToast(notification, {
45
+ command = 'powershell.exe',
46
+ spawnFn = spawn
47
+ } = {}) {
48
+ return new Promise((resolveValue, reject) => {
49
+ const child = spawnFn(command, [
50
+ '-NoProfile',
51
+ '-NonInteractive',
52
+ '-ExecutionPolicy', 'Bypass',
53
+ '-Command', WINDOWS_TOAST_SCRIPT
54
+ ], {
55
+ stdio: ['pipe', 'pipe', 'pipe'],
56
+ windowsHide: true
57
+ });
58
+ let stdout = '';
59
+ let stderr = '';
60
+ child.stdout.setEncoding('utf8');
61
+ child.stderr.setEncoding('utf8');
62
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
63
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
64
+ child.on('error', reject);
65
+ child.on('close', (code) => {
66
+ if (code !== 0) {
67
+ reject(new Error(`Windows notification failed with exit code ${code}: ${stderr.trim() || 'no stderr'}`));
68
+ return;
69
+ }
70
+ resolveValue(stdout.trim());
71
+ });
72
+ child.stdin.end(JSON.stringify(notification));
73
+ });
74
+ }
75
+
76
+ function normalizeNotification({ title, message }) {
77
+ const normalizedTitle = String(title || '').replace(/[\r\n]+/g, ' ').trim().slice(0, 100);
78
+ const normalizedMessage = String(message || '').trim().slice(0, 1_000);
79
+ if (!normalizedTitle || !normalizedMessage) {
80
+ throw new Error('Notification title and message are required.');
81
+ }
82
+ return {
83
+ title: normalizedTitle,
84
+ message: normalizedMessage
85
+ };
86
+ }
87
+
88
+ const WINDOWS_TOAST_SCRIPT = String.raw`
89
+ $ErrorActionPreference = 'Stop'
90
+ [Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false)
91
+ [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
92
+
93
+ $request = [Console]::In.ReadToEnd() | ConvertFrom-Json
94
+ [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
95
+ [Windows.UI.Notifications.ToastNotification, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
96
+
97
+ $template = [Windows.UI.Notifications.ToastTemplateType]::ToastText02
98
+ $toastXml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent($template)
99
+ $textNodes = $toastXml.GetElementsByTagName('text')
100
+ $textNodes.Item(0).AppendChild($toastXml.CreateTextNode([string]$request.title)) | Out-Null
101
+ $textNodes.Item(1).AppendChild($toastXml.CreateTextNode([string]$request.message)) | Out-Null
102
+ $toast = [Windows.UI.Notifications.ToastNotification]::new($toastXml)
103
+ [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('PowerShell').Show($toast)
104
+ @{ ok = $true } | ConvertTo-Json -Compress
105
+ `;
@@ -0,0 +1,17 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
6
+
7
+ export function readPackageInfo({ packagePath = join(PACKAGE_ROOT, 'package.json'), readFile = readFileSync } = {}) {
8
+ const packageJson = JSON.parse(readFile(packagePath, 'utf8'));
9
+ return {
10
+ name: packageJson.name,
11
+ version: packageJson.version
12
+ };
13
+ }
14
+
15
+ export function readPackageVersion(options = {}) {
16
+ return readPackageInfo(options).version;
17
+ }
package/lib/paths.mjs ADDED
@@ -0,0 +1,68 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { resolve } from 'node:path';
4
+
5
+ export const HELPER_HOME_DIRNAME = '.logwork-cli';
6
+ export const CONFIG_FILE_NAME = '.logwork.json';
7
+ export const MANUAL_DRAFTS_FILE_NAME = 'manual-drafts.json';
8
+ export const APPLY_LEDGER_FILE_NAME = 'apply-ledger.json';
9
+ export const UPDATE_STATE_FILE_NAME = 'update-state.json';
10
+ export const REMINDER_CONFIG_FILE_NAME = 'reminder.json';
11
+ export const REMINDER_STATE_FILE_NAME = 'reminder-state.json';
12
+
13
+ // Names used before the package was renamed from logwork-helper to logwork-cli.
14
+ // lib/legacy-migration.mjs copies state from these locations on first run.
15
+ export const LEGACY_HELPER_HOME_DIRNAME = '.logwork-helper';
16
+ export const LEGACY_CONFIG_FILE_NAME = '.logwork-helper.json';
17
+
18
+ export function helperHome() {
19
+ return resolve(customHelperHome() || resolve(homedir(), HELPER_HOME_DIRNAME));
20
+ }
21
+
22
+ export function customHelperHome(env = process.env) {
23
+ return env.LOGWORK_HOME || env.LOGWORK_HELPER_HOME || '';
24
+ }
25
+
26
+ export function legacyHelperHome() {
27
+ return resolve(homedir(), LEGACY_HELPER_HOME_DIRNAME);
28
+ }
29
+
30
+ export function userConfigPath() {
31
+ return resolve(helperHome(), CONFIG_FILE_NAME);
32
+ }
33
+
34
+ export function manualDraftsPath() {
35
+ return resolve(helperHome(), MANUAL_DRAFTS_FILE_NAME);
36
+ }
37
+
38
+ export function applyLedgerPath() {
39
+ return resolve(helperHome(), APPLY_LEDGER_FILE_NAME);
40
+ }
41
+
42
+ export function updateStatePath() {
43
+ return resolve(helperHome(), UPDATE_STATE_FILE_NAME);
44
+ }
45
+
46
+ export function reminderConfigPath() {
47
+ return resolve(helperHome(), REMINDER_CONFIG_FILE_NAME);
48
+ }
49
+
50
+ export function reminderStatePath() {
51
+ return resolve(helperHome(), REMINDER_STATE_FILE_NAME);
52
+ }
53
+
54
+ export function legacyUserConfigPath() {
55
+ return resolve(homedir(), LEGACY_CONFIG_FILE_NAME);
56
+ }
57
+
58
+ // Project-scope mappings live in the repo the user works in. A repo that still
59
+ // has the old .logwork-helper.json (and no .logwork.json) keeps using it, so
60
+ // committed mappings keep working without a rename.
61
+ export function projectConfigPath(cwd = process.cwd(), { exists = existsSync } = {}) {
62
+ const current = resolve(cwd, CONFIG_FILE_NAME);
63
+ const legacy = resolve(cwd, LEGACY_CONFIG_FILE_NAME);
64
+ if (!exists(current) && exists(legacy)) {
65
+ return legacy;
66
+ }
67
+ return current;
68
+ }
@@ -0,0 +1,66 @@
1
+ export function normalizeProjectName(value) {
2
+ return String(value || '').toLowerCase().replace(/\s+/g, ' ').trim();
3
+ }
4
+
5
+ export function projectIdentityKey(project = {}) {
6
+ return String(
7
+ firstPresent(project.projectMemberId, project.projectId) ??
8
+ normalizeProjectName(project.projectName)
9
+ );
10
+ }
11
+
12
+ export function sameProjectIdentity(left = {}, right = {}) {
13
+ if (hasValue(left.projectMemberId) && hasValue(right.projectMemberId)) {
14
+ return String(left.projectMemberId) === String(right.projectMemberId);
15
+ }
16
+ if (hasValue(left.projectId) && hasValue(right.projectId)) {
17
+ return String(left.projectId) === String(right.projectId);
18
+ }
19
+
20
+ const leftName = normalizeProjectName(left.projectName);
21
+ const rightName = normalizeProjectName(right.projectName);
22
+ return Boolean(leftName && rightName && leftName === rightName);
23
+ }
24
+
25
+ export function projectMatchesFilter(project = {}, filterValue) {
26
+ const filter = String(filterValue || '').trim();
27
+ if (!filter) {
28
+ return false;
29
+ }
30
+
31
+ const normalizedFilter = normalizeProjectName(filter);
32
+ return (hasValue(project.projectMemberId) && String(project.projectMemberId) === filter) ||
33
+ (hasValue(project.projectId) && String(project.projectId) === filter) ||
34
+ normalizeProjectName(project.projectName).includes(normalizedFilter);
35
+ }
36
+
37
+ export function projectMatchesMapping(project = {}, mapping = {}, {
38
+ allowNameContains = true,
39
+ fallbackToName = false
40
+ } = {}) {
41
+ if (hasValue(mapping.projectMemberId)) {
42
+ const memberMatches = hasValue(project.projectMemberId) &&
43
+ String(mapping.projectMemberId) === String(project.projectMemberId);
44
+ if (memberMatches || !fallbackToName) {
45
+ return memberMatches;
46
+ }
47
+ }
48
+
49
+ const projectName = normalizeProjectName(project.projectName);
50
+ const mappingName = normalizeProjectName(mapping.projectName);
51
+ if (!projectName || !mappingName) {
52
+ return false;
53
+ }
54
+
55
+ return allowNameContains
56
+ ? projectName.includes(mappingName)
57
+ : projectName === mappingName;
58
+ }
59
+
60
+ function firstPresent(...values) {
61
+ return values.find(hasValue);
62
+ }
63
+
64
+ function hasValue(value) {
65
+ return value !== undefined && value !== null && String(value).trim() !== '';
66
+ }
@@ -0,0 +1,309 @@
1
+ import { ApiError, getProjects } from './api.mjs';
2
+ import {
3
+ configPath,
4
+ loadLocalConfig,
5
+ upsertProjectMappingConfig
6
+ } from './logwork-config.mjs';
7
+ import { createResourceOptimiserSession } from './query-workflow.mjs';
8
+
9
+ export async function listLogworkProjects({
10
+ cwd = process.cwd(),
11
+ fetchProjects
12
+ } = {}) {
13
+ const projects = sanitizeProjects(await (fetchProjects || createMembershipProjectsFetcher())());
14
+ const config = await loadLocalConfig(cwd);
15
+
16
+ return {
17
+ projects,
18
+ mappings: config.projectMappings,
19
+ configSources: {
20
+ user: configPath(cwd, 'user'),
21
+ project: configPath(cwd, 'project')
22
+ },
23
+ summary: [
24
+ `Found ${projects.length} Resource Optimiser project memberships.`,
25
+ config.projectMappings.length
26
+ ? `Current mappings: ${config.projectMappings.length}.`
27
+ : 'No project mappings configured yet.'
28
+ ].join('\n')
29
+ };
30
+ }
31
+
32
+ export async function upsertProjectMapping({
33
+ projectMemberId,
34
+ projectName,
35
+ tickets = [],
36
+ keywords = [],
37
+ scope = 'user',
38
+ confirm,
39
+ cwd = process.cwd(),
40
+ fetchProjects
41
+ } = {}) {
42
+ if (confirm !== true) {
43
+ throw new Error('map_project requires confirm: true.');
44
+ }
45
+
46
+ if (!Array.isArray(tickets) || !tickets.length) {
47
+ throw new Error('map_project requires at least one ticket prefix.');
48
+ }
49
+
50
+ const projects = sanitizeProjects(await (fetchProjects || createMembershipProjectsFetcher())());
51
+ const resolution = resolveProjectSelection(projects, { projectMemberId, projectName });
52
+ if (!resolution.project) {
53
+ const suffix = resolution.candidates?.length
54
+ ? ` Candidates: ${resolution.candidates.map((project) => `${project.projectMemberId}:${project.projectName}`).join(', ')}`
55
+ : '';
56
+ throw new Error(`${resolution.reason}.${suffix}`);
57
+ }
58
+
59
+ const result = await upsertProjectMappingConfig({
60
+ cwd,
61
+ scope,
62
+ project: resolution.project,
63
+ tickets,
64
+ keywords
65
+ });
66
+
67
+ return {
68
+ status: result.created ? 'created' : 'updated',
69
+ scope: result.scope,
70
+ configPath: result.path,
71
+ mapping: result.mapping,
72
+ projects,
73
+ summary: [
74
+ `${result.created ? 'Created' : 'Updated'} project mapping for ${result.mapping.projectName}.`,
75
+ `Tickets: ${result.mapping.tickets.join(', ')}.`,
76
+ result.mapping.keywords.length ? `Keywords: ${result.mapping.keywords.join(', ')}.` : 'Keywords: none.'
77
+ ].join('\n')
78
+ };
79
+ }
80
+
81
+ export function buildSetupSuggestions(entries, membershipProjects = []) {
82
+ const candidates = sanitizeProjects(membershipProjects);
83
+
84
+ return entries
85
+ .filter((entry) => entry.status === 'unresolved')
86
+ .map((entry) => {
87
+ const ticketPrefixes = ticketPrefixesFromEntry(entry);
88
+ if (!ticketPrefixes.length) {
89
+ return null;
90
+ }
91
+
92
+ const candidateProjects = rankCandidatesByTicketPrefix(
93
+ sanitizeProjects(entry.candidates?.length ? entry.candidates : candidates),
94
+ ticketPrefixes
95
+ );
96
+ if (!candidateProjects.length) {
97
+ return null;
98
+ }
99
+
100
+ return {
101
+ entryId: entry.id,
102
+ date: entry.date,
103
+ hours: entry.hours,
104
+ taskName: entry.taskName,
105
+ tickets: entry.tickets || [],
106
+ ticketPrefixes,
107
+ candidateProjects: candidateProjects.map((project) => ({
108
+ ...project,
109
+ toolArguments: {
110
+ projectMemberId: project.projectMemberId,
111
+ tickets: ticketPrefixes,
112
+ keywords: [],
113
+ confirm: true
114
+ }
115
+ }))
116
+ };
117
+ })
118
+ .filter(Boolean);
119
+ }
120
+
121
+ export function enrichRoPreviewWithJiraProjects(roPreview, jiraPreview) {
122
+ if (!roPreview || !jiraPreview) {
123
+ return roPreview;
124
+ }
125
+
126
+ const setupByEntry = new Map((roPreview.setupSuggestions || []).map((suggestion) => [suggestion.entryId, suggestion]));
127
+ const suggestions = (jiraPreview.entries || [])
128
+ .map((jiraEntry) => buildJiraMappingSuggestion(jiraEntry, setupByEntry.get(jiraEntry.id)))
129
+ .filter(Boolean);
130
+
131
+ if (!suggestions.length) {
132
+ return roPreview;
133
+ }
134
+
135
+ return {
136
+ ...roPreview,
137
+ jiraMappingSuggestions: suggestions,
138
+ summary: [
139
+ roPreview.summary,
140
+ '',
141
+ 'Jira-assisted RO mapping suggestions:',
142
+ ...suggestions.map(formatJiraMappingSuggestion)
143
+ ].join('\n')
144
+ };
145
+ }
146
+
147
+ function createMembershipProjectsFetcher() {
148
+ const session = createResourceOptimiserSession();
149
+ return async () => {
150
+ const { token, userId } = await session.get();
151
+ try {
152
+ return await getProjects(token, userId);
153
+ } catch (error) {
154
+ if (!(error instanceof ApiError) || error.status !== 401) {
155
+ throw error;
156
+ }
157
+
158
+ const refreshed = await session.get({ forceLogin: true });
159
+ return getProjects(refreshed.token, refreshed.userId);
160
+ }
161
+ };
162
+ }
163
+
164
+ function resolveProjectSelection(projects, { projectMemberId, projectName }) {
165
+ if (projectMemberId !== undefined && projectMemberId !== null && String(projectMemberId).trim() !== '') {
166
+ const project = projects.find((candidate) => String(candidate.projectMemberId) === String(projectMemberId));
167
+ return project
168
+ ? { project }
169
+ : { project: null, reason: `Project member ${projectMemberId} was not found in your memberships`, candidates: projects };
170
+ }
171
+
172
+ if (!projectName || !String(projectName).trim()) {
173
+ return {
174
+ project: null,
175
+ reason: 'Pass projectMemberId or projectName to choose a mapping project',
176
+ candidates: projects
177
+ };
178
+ }
179
+
180
+ const normalizedName = normalize(projectName);
181
+ const exactMatches = projects.filter((project) => normalize(project.projectName) === normalizedName);
182
+ if (exactMatches.length === 1) {
183
+ return { project: exactMatches[0] };
184
+ }
185
+
186
+ const fuzzyMatches = projects.filter((project) => normalize(project.projectName).includes(normalizedName));
187
+ if (fuzzyMatches.length === 1) {
188
+ return { project: fuzzyMatches[0] };
189
+ }
190
+
191
+ if (exactMatches.length > 1 || fuzzyMatches.length > 1) {
192
+ return {
193
+ project: null,
194
+ reason: `Project name "${projectName}" is ambiguous`,
195
+ candidates: exactMatches.length > 1 ? exactMatches : fuzzyMatches
196
+ };
197
+ }
198
+
199
+ return {
200
+ project: null,
201
+ reason: `Project name "${projectName}" was not found in your memberships`,
202
+ candidates: projects
203
+ };
204
+ }
205
+
206
+ function ticketPrefixesFromEntry(entry) {
207
+ return [...new Set((entry.tickets || [])
208
+ .map((ticket) => String(ticket).toUpperCase().match(/^([A-Z][A-Z0-9]+)-\d+/)?.[1])
209
+ .filter(Boolean))];
210
+ }
211
+
212
+ function buildJiraMappingSuggestion(jiraEntry, setupSuggestion) {
213
+ if (!setupSuggestion || !jiraEntry?.issue?.project) {
214
+ return null;
215
+ }
216
+
217
+ const jiraProject = {
218
+ key: String(jiraEntry.issue.project.key || '').trim(),
219
+ name: String(jiraEntry.issue.project.name || '').trim()
220
+ };
221
+ const ranked = (setupSuggestion.candidateProjects || [])
222
+ .map((project) => ({
223
+ ...project,
224
+ ...scoreJiraProjectCandidate(project, jiraProject)
225
+ }))
226
+ .sort((left, right) => right.confidence - left.confidence || String(left.projectName).localeCompare(String(right.projectName)));
227
+ const recommendation = ranked[0]?.confidence > 0 ? ranked[0] : null;
228
+
229
+ return {
230
+ entryId: jiraEntry.id,
231
+ issueKey: jiraEntry.issueKey,
232
+ jiraProject,
233
+ ticketPrefixes: setupSuggestion.ticketPrefixes,
234
+ recommendation,
235
+ candidates: ranked
236
+ };
237
+ }
238
+
239
+ function scoreJiraProjectCandidate(project, jiraProject) {
240
+ const projectName = normalize(project.projectName);
241
+ const jiraName = normalize(jiraProject.name);
242
+ const jiraKey = normalize(jiraProject.key);
243
+
244
+ if (jiraName && projectName === jiraName) {
245
+ return { confidence: 0.99, reason: 'jira_project_name_exact' };
246
+ }
247
+ if (jiraName && (projectName.includes(jiraName) || jiraName.includes(projectName))) {
248
+ return { confidence: 0.94, reason: 'jira_project_name_contains' };
249
+ }
250
+ if (jiraKey && projectName.split(/[^a-z0-9]+/).includes(jiraKey)) {
251
+ return { confidence: 0.9, reason: 'jira_project_key_match' };
252
+ }
253
+
254
+ const jiraTokens = meaningfulTokens(jiraName);
255
+ const projectTokens = new Set(meaningfulTokens(projectName));
256
+ const overlap = jiraTokens.filter((token) => projectTokens.has(token));
257
+ if (overlap.length) {
258
+ return {
259
+ confidence: Number(Math.min(0.85, 0.6 + (overlap.length / Math.max(jiraTokens.length, 1)) * 0.25).toFixed(2)),
260
+ reason: 'jira_project_name_token_match'
261
+ };
262
+ }
263
+
264
+ return { confidence: 0, reason: 'jira_project_no_match' };
265
+ }
266
+
267
+ function rankCandidatesByTicketPrefix(projects, ticketPrefixes) {
268
+ const prefixes = ticketPrefixes.map(normalize);
269
+ return projects
270
+ .map((project) => {
271
+ const tokens = normalize(project.projectName).split(/[^a-z0-9]+/);
272
+ const prefixMatch = prefixes.some((prefix) => tokens.includes(prefix));
273
+ return {
274
+ ...project,
275
+ suggestedByTicket: prefixMatch
276
+ };
277
+ })
278
+ .sort((left, right) => Number(right.suggestedByTicket) - Number(left.suggestedByTicket));
279
+ }
280
+
281
+ function meaningfulTokens(value) {
282
+ return normalize(value)
283
+ .split(/[^a-z0-9]+/)
284
+ .filter((token) => token.length >= 3 && !['project', 'team', 'the', 'and'].includes(token));
285
+ }
286
+
287
+ function formatJiraMappingSuggestion(suggestion) {
288
+ const jiraProject = [suggestion.jiraProject.key, suggestion.jiraProject.name].filter(Boolean).join(' / ') || 'unknown Jira project';
289
+ if (!suggestion.recommendation) {
290
+ return `- ${suggestion.issueKey}: ${jiraProject} has no confident RO project match. Choose a candidate manually.`;
291
+ }
292
+
293
+ const recommendation = suggestion.recommendation;
294
+ return `- ${suggestion.issueKey}: ${jiraProject} -> ${recommendation.projectName} (${recommendation.projectMemberId}), ${recommendation.reason}, confidence ${recommendation.confidence}. Confirm with /map ${suggestion.ticketPrefixes.join(',')} ${recommendation.projectMemberId}.`;
295
+ }
296
+
297
+ function sanitizeProjects(projects) {
298
+ return (Array.isArray(projects) ? projects : [])
299
+ .filter((project) => project && project.projectMemberId !== undefined && project.projectMemberId !== null)
300
+ .map((project) => ({
301
+ projectMemberId: project.projectMemberId,
302
+ projectId: project.projectId,
303
+ projectName: project.projectName
304
+ }));
305
+ }
306
+
307
+ function normalize(value) {
308
+ return String(value || '').toLowerCase().replace(/\s+/g, ' ').trim();
309
+ }