logwork-cli 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import { stdin as defaultInput, stdout as defaultOutput } from 'node:process';
2
2
  import { resolve as resolvePath } from 'node:path';
3
- import { confirm, isCancel } from '@clack/prompts';
3
+ import { confirm, isCancel, select } from '@clack/prompts';
4
4
  import { getStoredAuthStatus, loginResourceOptimiser } from './auth.mjs';
5
5
  import { queryApplyLedger } from './apply-ledger.mjs';
6
6
  import { isAuthRequiredError } from './auth-errors.mjs';
@@ -49,8 +49,11 @@ import {
49
49
  import {
50
50
  enrichRoPreviewWithJiraProjects,
51
51
  listLogworkProjects,
52
+ resolveProjectSelection,
52
53
  upsertProjectMapping
53
54
  } from './project-mapping-workflow.mjs';
55
+ import { suggestProjectsForRepo } from './commit-workflow.mjs';
56
+ import { repoProjectFromOption, repoProjectSelectOptions } from './ui.mjs';
54
57
  import { queryLogwork } from './query-workflow.mjs';
55
58
  import { parseRoEditArgs } from './ro-edit-args.mjs';
56
59
  import {
@@ -366,26 +369,7 @@ export async function executeManualCommand(command, session, context) {
366
369
  }
367
370
 
368
371
  if (command.type === 'map') {
369
- const subject = [
370
- ...command.tickets,
371
- ...(command.repos || []).map((repo) => `repo ${resolvePath(cwd, repo)}`)
372
- ].join(', ');
373
- const ok = await prompts.confirm(`Map ${subject} to projectMemberId ${command.projectMemberId}?`);
374
- if (!ok) {
375
- print('Mapping cancelled.');
376
- return { exit: false };
377
- }
378
-
379
- const result = await runWithInlineAuth(() => workflows.upsertProjectMapping({
380
- projectMemberId: command.projectMemberId,
381
- tickets: command.tickets,
382
- keywords: [],
383
- ...(command.repos?.length ? { repos: command.repos } : {}),
384
- scope: 'user',
385
- confirm: true,
386
- cwd
387
- }), { workflows, print });
388
- print(result.summary);
372
+ await executeMapCommand(command, { cwd, workflows, prompts, print });
389
373
  return { exit: false };
390
374
  }
391
375
 
@@ -506,10 +490,115 @@ export function createManualPrompts() {
506
490
  throw new Error('Cancelled.');
507
491
  }
508
492
  return Boolean(answer);
493
+ },
494
+ // { message, options: [{ value, label, hint }], initialValue } -> value,
495
+ // or null when cancelled.
496
+ async select({ message, options, initialValue }) {
497
+ const answer = await select({ message, options, initialValue });
498
+ return isCancel(answer) ? null : answer;
509
499
  }
510
500
  };
511
501
  }
512
502
 
503
+ // /map with a project member id saves it as before. Otherwise the project is
504
+ // chosen by name (case-insensitive, then a unique substring) or, with no name
505
+ // or an ambiguous one, from a picker of member projects; for a repo the
506
+ // picker lists the suggested projects first.
507
+ async function executeMapCommand(command, { cwd, workflows, prompts, print }) {
508
+ const subject = [
509
+ ...command.tickets,
510
+ ...(command.repos || []).map((repo) => `repo ${resolvePath(cwd, repo)}`)
511
+ ].join(', ');
512
+ const save = (project) => runWithInlineAuth(() => workflows.upsertProjectMapping({
513
+ projectMemberId: project.projectMemberId,
514
+ ...(project.projectMemberId === undefined ? { projectName: project.projectName } : {}),
515
+ tickets: command.tickets,
516
+ keywords: [],
517
+ ...(command.repos?.length ? { repos: command.repos } : {}),
518
+ scope: 'user',
519
+ confirm: true,
520
+ cwd
521
+ }), { workflows, print });
522
+
523
+ if (command.projectMemberId !== undefined) {
524
+ const ok = await prompts.confirm(`Map ${subject} to projectMemberId ${command.projectMemberId}?`);
525
+ if (!ok) {
526
+ print('Mapping cancelled.');
527
+ return;
528
+ }
529
+ print((await save({ projectMemberId: command.projectMemberId })).summary);
530
+ return;
531
+ }
532
+
533
+ const { projects = [] } = await runWithInlineAuth(() => workflows.listLogworkProjects({ cwd }), { workflows, print });
534
+ if (!projects.length) {
535
+ throw new Error('No Resource Optimiser project memberships found to map to.');
536
+ }
537
+
538
+ let project = null;
539
+ let pickerProjects = projects;
540
+ let pickerNote = '';
541
+ if (command.projectQuery) {
542
+ const resolution = resolveProjectSelection(projects, { projectName: command.projectQuery });
543
+ project = resolution.project || null;
544
+ if (!project) {
545
+ pickerNote = `${resolution.reason}.`;
546
+ pickerProjects = resolution.candidates?.length ? resolution.candidates : projects;
547
+ }
548
+ }
549
+
550
+ if (project) {
551
+ const ok = await prompts.confirm(`Map ${subject} to ${project.projectName}?`);
552
+ if (!ok) {
553
+ print('Mapping cancelled.');
554
+ return;
555
+ }
556
+ } else {
557
+ let suggestions = [];
558
+ if (command.repos?.length && !command.projectQuery) {
559
+ const suggested = await runWithInlineAuth(() => workflows.suggestProjectsForRepo({
560
+ repo: command.repos[0],
561
+ projects,
562
+ cwd
563
+ }), { workflows, print }).catch((error) => {
564
+ if (isAuthRequiredError(error)) {
565
+ throw error;
566
+ }
567
+ pickerNote = `No suggestions: ${error.message}`;
568
+ return null;
569
+ });
570
+ suggestions = suggested?.suggestions || [];
571
+ }
572
+ if (pickerNote) {
573
+ print(pickerNote);
574
+ }
575
+ project = await pickMapProject({ subject, suggestions, projects: pickerProjects, prompts, print });
576
+ if (!project) {
577
+ print('Mapping cancelled.');
578
+ return;
579
+ }
580
+ }
581
+
582
+ print((await save(project)).summary);
583
+ }
584
+
585
+ async function pickMapProject({ subject, suggestions, projects, prompts, print }) {
586
+ const { items, options, initialValue } = repoProjectSelectOptions(suggestions, projects, { skipLabel: 'Cancel' });
587
+ if (typeof prompts.select === 'function') {
588
+ return repoProjectFromOption(items, await prompts.select({
589
+ message: `Which RO project should ${subject} map to?`,
590
+ options,
591
+ initialValue
592
+ }));
593
+ }
594
+ // Without a picker, list the projects; the user re-runs /map with a name.
595
+ print([
596
+ `Which RO project should ${subject} map to? Re-run /map with the project name:`,
597
+ ...items.map((item, index) => ` ${index + 1}. ${item.project.projectName}${item.suggestion ? ` (${item.suggestion.reasons.join('; ')})` : ''}`)
598
+ ].join('\n'));
599
+ return null;
600
+ }
601
+
513
602
  async function readPasteBlock({ readLine, print, target = RO_TARGET }) {
514
603
  if (typeof readLine !== 'function') {
515
604
  throw new Error('/logwork requires an interactive terminal.');
@@ -889,26 +978,21 @@ function parseTargetCommand(type, parts, {
889
978
  };
890
979
  }
891
980
 
892
- const MAP_USAGE = 'Usage: /map <TICKET_PREFIX[,PREFIX...]> <projectMemberId> | /map --repo [path] <projectMemberId>';
981
+ const MAP_USAGE = 'Usage: /map <TICKET_PREFIX[,PREFIX...]> [project name or id] | /map --repo [path] [project name or id]';
982
+ const PATH_LIKE = /^(?:\.{1,2}(?:[\\/]|$)|[\\/]|~(?:[\\/]|$)|[A-Za-z]:[\\/])/;
893
983
 
894
984
  function parseMapCommand(parts) {
895
985
  if (parts[0] === '--repo') {
896
- // The repo path defaults to the current folder; a path with spaces may be
897
- // quoted.
898
- const projectMemberId = parts.length > 1 ? parts[parts.length - 1] : '';
899
- const repo = parts.slice(1, -1).join(' ').replace(/^(["'])(.*)\1$/, '$2').trim() || '.';
900
- if (!projectMemberId || projectMemberId.startsWith('-')) {
901
- throw new Error(MAP_USAGE);
902
- }
986
+ const { repo, project } = splitRepoAndProject(parts.slice(1));
903
987
  return {
904
988
  type: 'map',
905
989
  tickets: [],
906
990
  repos: [repo],
907
- projectMemberId
991
+ ...mapProjectFields(project)
908
992
  };
909
993
  }
910
994
 
911
- if (parts.length < 2) {
995
+ if (!parts.length || parts[0].startsWith('-')) {
912
996
  throw new Error(MAP_USAGE);
913
997
  }
914
998
 
@@ -916,19 +1000,47 @@ function parseMapCommand(parts) {
916
1000
  .split(',')
917
1001
  .map((ticket) => ticket.trim().toUpperCase())
918
1002
  .filter(Boolean);
919
- const projectMemberId = parts[1];
920
1003
 
921
- if (!tickets.length || !projectMemberId) {
1004
+ if (!tickets.length) {
922
1005
  throw new Error(MAP_USAGE);
923
1006
  }
924
1007
 
925
1008
  return {
926
1009
  type: 'map',
927
1010
  tickets,
928
- projectMemberId
1011
+ ...mapProjectFields(parts.slice(1).join(' ').trim())
929
1012
  };
930
1013
  }
931
1014
 
1015
+ // "/map --repo [path] [project]": the path defaults to the current folder. A
1016
+ // path starts with ".", "/", "~" or a drive letter, or is quoted (for spaces);
1017
+ // anything else is the project. The old form "<path> <projectMemberId>" still
1018
+ // reads any path.
1019
+ function splitRepoAndProject(tokens) {
1020
+ const rest = tokens.join(' ').trim();
1021
+ const quoted = /^(["'])(.*?)\1(?:\s+(.*))?$/.exec(rest);
1022
+ if (quoted) {
1023
+ return { repo: quoted[2].trim() || '.', project: (quoted[3] || '').trim() };
1024
+ }
1025
+ if (tokens.length >= 2 && /^\d+$/.test(tokens.at(-1))) {
1026
+ return { repo: tokens.slice(0, -1).join(' '), project: tokens.at(-1) };
1027
+ }
1028
+ if (tokens.length && PATH_LIKE.test(tokens[0])) {
1029
+ return { repo: tokens[0], project: tokens.slice(1).join(' ').trim() };
1030
+ }
1031
+ return { repo: '.', project: rest };
1032
+ }
1033
+
1034
+ function mapProjectFields(project) {
1035
+ if (!project) {
1036
+ return {};
1037
+ }
1038
+ if (project.startsWith('-')) {
1039
+ throw new Error(MAP_USAGE);
1040
+ }
1041
+ return /^\d+$/.test(project) ? { projectMemberId: project } : { projectQuery: project };
1042
+ }
1043
+
932
1044
  export function formatProjects(result, weekly = null, projectFilter = undefined) {
933
1045
  const lines = [result.summary];
934
1046
  const projects = filterProjects(result.projects || [], projectFilter);
@@ -1064,6 +1176,7 @@ function defaultWorkflows() {
1064
1176
  applyJiraWorklogBatch,
1065
1177
  listLogworkProjects,
1066
1178
  upsertProjectMapping,
1179
+ suggestProjectsForRepo,
1067
1180
  loginResourceOptimiser,
1068
1181
  getStoredAuthStatus,
1069
1182
  getStoredJiraStatus,