logwork-cli 0.0.0-stage → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +11 -0
- package/README.md +330 -2
- package/RELEASE.md +89 -0
- package/bin/logwork.mjs +104 -0
- package/commands/auth.mjs +227 -0
- package/commands/commits.mjs +82 -0
- package/commands/delete.mjs +69 -0
- package/commands/doctor.mjs +24 -0
- package/commands/edit.mjs +88 -0
- package/commands/history.mjs +62 -0
- package/commands/query.mjs +52 -0
- package/commands/quick.mjs +163 -0
- package/commands/reconcile.mjs +55 -0
- package/commands/release.mjs +77 -0
- package/commands/reminder.mjs +172 -0
- package/commands/setup.mjs +508 -0
- package/commands/skill.mjs +91 -0
- package/commands/tui.mjs +50 -0
- package/commands/update.mjs +161 -0
- package/docs/advanced.md +334 -0
- package/docs/mcp-setup.md +368 -0
- package/docs/operations-guide.md +587 -0
- package/docs/recipes.md +259 -0
- package/docs/security.md +151 -0
- package/examples/mcp/antigravity.mcp_config.json +8 -0
- package/examples/mcp/codex.config.toml +5 -0
- package/examples/mcp/copilot.mcp.json +9 -0
- package/lib/agent-skill.mjs +16 -0
- package/lib/api-auth.mjs +1259 -0
- package/lib/api.mjs +1422 -0
- package/lib/apply-ledger.mjs +227 -0
- package/lib/atomic-file.mjs +24 -0
- package/lib/auth-diagnostics.mjs +113 -0
- package/lib/auth-errors.mjs +22 -0
- package/lib/auth-redaction.mjs +78 -0
- package/lib/auth-terminal.mjs +97 -0
- package/lib/auth.mjs +323 -0
- package/lib/batch-parser.mjs +145 -0
- package/lib/batch-workflow.mjs +842 -0
- package/lib/commit-workflow.mjs +402 -0
- package/lib/config.mjs +191 -0
- package/lib/credential-store.mjs +130 -0
- package/lib/diagnostics.mjs +307 -0
- package/lib/entrypoint.mjs +14 -0
- package/lib/file-lock.mjs +44 -0
- package/lib/http.mjs +125 -0
- package/lib/jira-api.mjs +258 -0
- package/lib/jira-auth.mjs +203 -0
- package/lib/jira-workflow.mjs +523 -0
- package/lib/keychain.mjs +105 -0
- package/lib/legacy-migration.mjs +66 -0
- package/lib/list-navigation.mjs +19 -0
- package/lib/logwork-config.mjs +199 -0
- package/lib/logwork-f03-enrich.mjs +41 -0
- package/lib/macos.mjs +33 -0
- package/lib/manual-apply-state.mjs +35 -0
- package/lib/manual-commands.mjs +294 -0
- package/lib/manual-drafts.mjs +154 -0
- package/lib/manual-ink-app.mjs +1654 -0
- package/lib/manual-ink-auth.mjs +349 -0
- package/lib/manual-ink-ui.mjs +675 -0
- package/lib/manual-input.mjs +21 -0
- package/lib/manual-logwork-wizard.mjs +310 -0
- package/lib/manual-mcp.mjs +187 -0
- package/lib/manual-repl.mjs +1037 -0
- package/lib/manual-targets.mjs +78 -0
- package/lib/mcp-server.mjs +833 -0
- package/lib/os-notification.mjs +105 -0
- package/lib/package-info.mjs +17 -0
- package/lib/paths.mjs +68 -0
- package/lib/project-identity.mjs +66 -0
- package/lib/project-mapping-workflow.mjs +309 -0
- package/lib/project-resolver.mjs +373 -0
- package/lib/query-workflow.mjs +553 -0
- package/lib/reconciliation-workflow.mjs +446 -0
- package/lib/release-doctor.mjs +448 -0
- package/lib/reminder-config.mjs +71 -0
- package/lib/reminder-scheduler.mjs +301 -0
- package/lib/reminder-service.mjs +118 -0
- package/lib/reminder-state.mjs +38 -0
- package/lib/reminder-workflow.mjs +299 -0
- package/lib/ro-delete-args.mjs +38 -0
- package/lib/ro-delete-workflow.mjs +115 -0
- package/lib/ro-edit-args.mjs +142 -0
- package/lib/ro-edit-workflow.mjs +632 -0
- package/lib/ro-entry-reader.mjs +56 -0
- package/lib/ro-resubmit-workflow.mjs +134 -0
- package/lib/terminal-auth-prompts.mjs +120 -0
- package/lib/ui.mjs +133 -0
- package/lib/update-service.mjs +350 -0
- package/lib/util.mjs +66 -0
- package/lib/windows-credential-manager.mjs +260 -0
- package/lib/worklog-task-resolver.mjs +113 -0
- package/package.json +73 -4
- package/setup.sh +22 -0
- package/skills/logwork/SKILL.md +43 -0
|
@@ -0,0 +1,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
|
+
}
|