github-issue-tower-defence-management 1.118.0 → 1.119.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 (52) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +5 -3
  3. package/bin/adapter/entry-points/console/ui-dist/assets/{index-BMe1bEF4.css → index-D0RwhyOM.css} +1 -1
  4. package/bin/adapter/entry-points/console/ui-dist/assets/index-syKbd_lW.js +103 -0
  5. package/bin/adapter/entry-points/console/ui-dist/index.html +2 -2
  6. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +14 -2
  7. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  8. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js +7 -2
  9. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js.map +1 -1
  10. package/bin/adapter/repositories/FileSystemSilentSessionHubTaskStatusCacheRepository.js +128 -0
  11. package/bin/adapter/repositories/FileSystemSilentSessionHubTaskStatusCacheRepository.js.map +1 -0
  12. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +61 -16
  13. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
  14. package/bin/domain/usecases/adapter-interfaces/SilentSessionHubTaskStatusCacheRepository.js +3 -0
  15. package/bin/domain/usecases/adapter-interfaces/SilentSessionHubTaskStatusCacheRepository.js.map +1 -0
  16. package/package.json +1 -1
  17. package/src/adapter/entry-points/console/ui/e2e/consoleScenario.spec.ts +1 -1
  18. package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleMarkdownContent.tsx +8 -2
  19. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleChangedFileList.test.tsx +21 -8
  20. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleChangedFileList.tsx +63 -6
  21. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleCommentList.tsx +4 -0
  22. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleItemDetail.tsx +4 -0
  23. package/src/adapter/entry-points/console/ui/src/features/console/lib/markdown.test.ts +37 -0
  24. package/src/adapter/entry-points/console/ui/src/features/console/lib/markdown.ts +69 -5
  25. package/src/adapter/entry-points/console/ui/src/features/console/logic/fileTree.test.ts +95 -0
  26. package/src/adapter/entry-points/console/ui/src/features/console/logic/fileTree.ts +93 -0
  27. package/src/adapter/entry-points/console/ui/src/features/console/logic/references.test.ts +23 -0
  28. package/src/adapter/entry-points/console/ui/src/features/console/logic/references.ts +20 -0
  29. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.test.tsx +6 -2
  30. package/src/adapter/entry-points/console/ui/src/index.css +25 -0
  31. package/src/adapter/entry-points/console/ui-dist/assets/{index-BMe1bEF4.css → index-D0RwhyOM.css} +1 -1
  32. package/src/adapter/entry-points/console/ui-dist/assets/index-syKbd_lW.js +103 -0
  33. package/src/adapter/entry-points/console/ui-dist/index.html +2 -2
  34. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +11 -0
  35. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +6 -0
  36. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts +13 -0
  37. package/src/adapter/repositories/FileSystemSilentSessionHubTaskStatusCacheRepository.test.ts +251 -0
  38. package/src/adapter/repositories/FileSystemSilentSessionHubTaskStatusCacheRepository.ts +121 -0
  39. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +218 -9
  40. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +105 -19
  41. package/src/domain/usecases/adapter-interfaces/SilentSessionHubTaskStatusCacheRepository.ts +20 -0
  42. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  43. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts +3 -0
  44. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts.map +1 -1
  45. package/types/adapter/repositories/FileSystemSilentSessionHubTaskStatusCacheRepository.d.ts +20 -0
  46. package/types/adapter/repositories/FileSystemSilentSessionHubTaskStatusCacheRepository.d.ts.map +1 -0
  47. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts +7 -1
  48. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
  49. package/types/domain/usecases/adapter-interfaces/SilentSessionHubTaskStatusCacheRepository.d.ts +19 -0
  50. package/types/domain/usecases/adapter-interfaces/SilentSessionHubTaskStatusCacheRepository.d.ts.map +1 -0
  51. package/bin/adapter/entry-points/console/ui-dist/assets/index-DWiG7btO.js +0 -103
  52. package/src/adapter/entry-points/console/ui-dist/assets/index-DWiG7btO.js +0 -103
@@ -5,6 +5,7 @@ import {
5
5
  rewriteGitHubImageSources,
6
6
  } from '../../lib/imageProxy';
7
7
  import {
8
+ type ConsoleRepoContext,
8
9
  renderMarkdownToSafeHtml,
9
10
  splitMarkdownSegments,
10
11
  } from '../../lib/markdown';
@@ -20,12 +21,14 @@ export type ConsoleMarkdownViewProps = {
20
21
  body: string;
21
22
  buildImageProxyUrl?: ImageProxyUrlBuilder;
22
23
  renderReferenceLink?: ConsoleReferenceLinkRenderer;
24
+ repoContext?: ConsoleRepoContext;
23
25
  };
24
26
 
25
27
  type ConsoleMarkdownHtmlBlockProps = {
26
28
  source: string;
27
29
  buildImageProxyUrl?: ImageProxyUrlBuilder;
28
30
  renderReferenceLink?: ConsoleReferenceLinkRenderer;
31
+ repoContext?: ConsoleRepoContext;
29
32
  };
30
33
 
31
34
  type ReferenceMount = {
@@ -56,14 +59,15 @@ const ConsoleMarkdownHtmlBlock = ({
56
59
  source,
57
60
  buildImageProxyUrl,
58
61
  renderReferenceLink,
62
+ repoContext,
59
63
  }: ConsoleMarkdownHtmlBlockProps) => {
60
64
  const html = useMemo(() => {
61
- const safeHtml = renderMarkdownToSafeHtml(source);
65
+ const safeHtml = renderMarkdownToSafeHtml(source, repoContext);
62
66
  if (buildImageProxyUrl === undefined) {
63
67
  return safeHtml;
64
68
  }
65
69
  return rewriteGitHubImageSources(safeHtml, buildImageProxyUrl);
66
- }, [source, buildImageProxyUrl]);
70
+ }, [source, buildImageProxyUrl, repoContext]);
67
71
  const containerRef = useRef<HTMLDivElement>(null);
68
72
  const [referenceMounts, setReferenceMounts] = useState<ReferenceMount[]>([]);
69
73
 
@@ -98,6 +102,7 @@ export const ConsoleMarkdownContent = ({
98
102
  body,
99
103
  buildImageProxyUrl,
100
104
  renderReferenceLink,
105
+ repoContext,
101
106
  }: ConsoleMarkdownViewProps) => {
102
107
  const segments = useMemo(() => splitMarkdownSegments(body), [body]);
103
108
 
@@ -116,6 +121,7 @@ export const ConsoleMarkdownContent = ({
116
121
  source={segment.source}
117
122
  buildImageProxyUrl={buildImageProxyUrl}
118
123
  renderReferenceLink={renderReferenceLink}
124
+ repoContext={repoContext}
119
125
  />
120
126
  ),
121
127
  )}
@@ -3,7 +3,7 @@ import { consoleChangedFilesFixture } from '../../testing/fixtures';
3
3
  import { ConsoleChangedFileList } from './ConsoleChangedFileList';
4
4
 
5
5
  describe('ConsoleChangedFileList', () => {
6
- it('renders each file path, status badge and additions/deletions', () => {
6
+ it('renders each file basename, status badge and additions/deletions', () => {
7
7
  const { getByText, getAllByText } = render(
8
8
  <ConsoleChangedFileList
9
9
  files={consoleChangedFilesFixture}
@@ -11,14 +11,29 @@ describe('ConsoleChangedFileList', () => {
11
11
  error={null}
12
12
  />,
13
13
  );
14
- expect(
15
- getByText('src/adapter/entry-points/console/consoleServer.ts'),
16
- ).toBeInTheDocument();
14
+ expect(getByText('consoleServer.ts')).toBeInTheDocument();
17
15
  expect(getByText('+312')).toBeInTheDocument();
18
16
  expect(getAllByText('A').length).toBe(2);
19
17
  expect(getByText('M')).toBeInTheDocument();
20
18
  });
21
19
 
20
+ it('nests files under their directory nodes as a tree', () => {
21
+ const { container, getByText } = render(
22
+ <ConsoleChangedFileList
23
+ files={consoleChangedFilesFixture}
24
+ isLoading={false}
25
+ error={null}
26
+ />,
27
+ );
28
+ expect(getByText('adapter')).toBeInTheDocument();
29
+ expect(getByText('console')).toBeInTheDocument();
30
+ expect(getByText('package.json')).toBeInTheDocument();
31
+ const directoryNames = [
32
+ ...container.querySelectorAll('.console-file-tree-dir-name'),
33
+ ].map((node) => node.textContent);
34
+ expect(directoryNames).toContain('📁src');
35
+ });
36
+
22
37
  it('keeps the diff hidden until the file row is clicked', () => {
23
38
  const { container, getByText } = render(
24
39
  <ConsoleChangedFileList
@@ -28,9 +43,7 @@ describe('ConsoleChangedFileList', () => {
28
43
  />,
29
44
  );
30
45
  expect(container.querySelector('.console-file-diff')).toBeNull();
31
- fireEvent.click(
32
- getByText('src/adapter/entry-points/console/consoleServer.ts'),
33
- );
46
+ fireEvent.click(getByText('consoleServer.ts'));
34
47
  expect(container.querySelector('.console-file-diff')).toBeInTheDocument();
35
48
  const codeText = [...container.querySelectorAll('.console-diff-code')].map(
36
49
  (cell) => cell.textContent,
@@ -46,7 +59,7 @@ describe('ConsoleChangedFileList', () => {
46
59
  error={null}
47
60
  />,
48
61
  );
49
- const path = getByText('src/adapter/entry-points/console/consoleServer.ts');
62
+ const path = getByText('consoleServer.ts');
50
63
  fireEvent.click(path);
51
64
  expect(container.querySelector('.console-file-diff')).toBeInTheDocument();
52
65
  fireEvent.click(path);
@@ -1,5 +1,9 @@
1
1
  import { useState } from 'react';
2
2
  import { fileStatusBadge } from '../../logic/fileStatus';
3
+ import {
4
+ buildConsoleFileTree,
5
+ type ConsoleFileTreeNode,
6
+ } from '../../logic/fileTree';
3
7
  import type { ConsoleChangedFile } from '../../logic/types';
4
8
  import {
5
9
  type ConsoleAddInlineComment,
@@ -13,11 +17,19 @@ export type ConsoleChangedFileListProps = {
13
17
  onAddInlineComment?: ConsoleAddInlineComment;
14
18
  };
15
19
 
20
+ const indentStyle = (depth: number): { paddingLeft: string } => ({
21
+ paddingLeft: `${depth * 16}px`,
22
+ });
23
+
16
24
  const ConsoleChangedFileRow = ({
17
25
  file,
26
+ depth,
27
+ name,
18
28
  onAddInlineComment,
19
29
  }: {
20
30
  file: ConsoleChangedFile;
31
+ depth: number;
32
+ name: string;
21
33
  onAddInlineComment?: ConsoleAddInlineComment;
22
34
  }) => {
23
35
  const [expanded, setExpanded] = useState<boolean>(false);
@@ -27,6 +39,7 @@ const ConsoleChangedFileRow = ({
27
39
  <button
28
40
  type="button"
29
41
  className="console-file-row"
42
+ style={indentStyle(depth)}
30
43
  aria-expanded={expanded}
31
44
  onClick={() => setExpanded((value) => !value)}
32
45
  >
@@ -37,7 +50,7 @@ const ConsoleChangedFileRow = ({
37
50
  >
38
51
  {badge.label}
39
52
  </span>
40
- <span className="console-file-path">{file.path}</span>
53
+ <span className="console-file-path">{name}</span>
41
54
  <span className="console-file-stat console-file-add">
42
55
  +{file.additions}
43
56
  </span>
@@ -56,6 +69,47 @@ const ConsoleChangedFileRow = ({
56
69
  );
57
70
  };
58
71
 
72
+ const ConsoleChangedFileTreeNode = ({
73
+ node,
74
+ depth,
75
+ onAddInlineComment,
76
+ }: {
77
+ node: ConsoleFileTreeNode;
78
+ depth: number;
79
+ onAddInlineComment?: ConsoleAddInlineComment;
80
+ }) => {
81
+ if (node.kind === 'file') {
82
+ return (
83
+ <ConsoleChangedFileRow
84
+ file={node.file}
85
+ depth={depth}
86
+ name={node.name}
87
+ onAddInlineComment={onAddInlineComment}
88
+ />
89
+ );
90
+ }
91
+ return (
92
+ <li className="console-file-tree-dir">
93
+ <div className="console-file-tree-dir-name" style={indentStyle(depth)}>
94
+ <span className="console-file-tree-dir-icon" aria-hidden="true">
95
+ 📁
96
+ </span>
97
+ {node.name}
98
+ </div>
99
+ <ul className="console-file-tree-children">
100
+ {node.children.map((child) => (
101
+ <ConsoleChangedFileTreeNode
102
+ key={child.path}
103
+ node={child}
104
+ depth={depth + 1}
105
+ onAddInlineComment={onAddInlineComment}
106
+ />
107
+ ))}
108
+ </ul>
109
+ </li>
110
+ );
111
+ };
112
+
59
113
  export const ConsoleChangedFileList = ({
60
114
  files,
61
115
  isLoading,
@@ -78,12 +132,15 @@ export const ConsoleChangedFileList = ({
78
132
  return <p className="console-files-empty">No changed files.</p>;
79
133
  }
80
134
 
135
+ const tree = buildConsoleFileTree(files);
136
+
81
137
  return (
82
- <ul className="console-files">
83
- {files.map((file) => (
84
- <ConsoleChangedFileRow
85
- key={file.path}
86
- file={file}
138
+ <ul className="console-files console-file-tree">
139
+ {tree.map((node) => (
140
+ <ConsoleChangedFileTreeNode
141
+ key={node.path}
142
+ node={node}
143
+ depth={0}
87
144
  onAddInlineComment={onAddInlineComment}
88
145
  />
89
146
  ))}
@@ -1,5 +1,6 @@
1
1
  import { useState } from 'react';
2
2
  import type { ImageProxyUrlBuilder } from '../../lib/imageProxy';
3
+ import type { ConsoleRepoContext } from '../../logic/references';
3
4
  import { formatRelativeTime } from '../../logic/relativeTime';
4
5
  import type { ConsoleComment } from '../../logic/types';
5
6
  import type { ConsoleReferenceLinkRenderer } from '../content/ConsoleMarkdownContent';
@@ -12,6 +13,7 @@ export type ConsoleCommentListProps = {
12
13
  now: number;
13
14
  buildImageProxyUrl?: ImageProxyUrlBuilder;
14
15
  renderReferenceLink?: ConsoleReferenceLinkRenderer;
16
+ repoContext?: ConsoleRepoContext;
15
17
  };
16
18
 
17
19
  export const ConsoleCommentList = ({
@@ -21,6 +23,7 @@ export const ConsoleCommentList = ({
21
23
  now,
22
24
  buildImageProxyUrl,
23
25
  renderReferenceLink,
26
+ repoContext,
24
27
  }: ConsoleCommentListProps) => {
25
28
  const [showAll, setShowAll] = useState<boolean>(false);
26
29
 
@@ -68,6 +71,7 @@ export const ConsoleCommentList = ({
68
71
  body={comment.body}
69
72
  buildImageProxyUrl={buildImageProxyUrl}
70
73
  renderReferenceLink={renderReferenceLink}
74
+ repoContext={repoContext}
71
75
  />
72
76
  </article>
73
77
  ))}
@@ -1,6 +1,7 @@
1
1
  import type { ReactNode } from 'react';
2
2
  import type { ImageProxyUrlBuilder } from '../../lib/imageProxy';
3
3
  import { colorFromEnum } from '../../logic/colors';
4
+ import { parseNameWithOwner } from '../../logic/references';
4
5
  import {
5
6
  formatFullTimestamp,
6
7
  formatRelativeTime,
@@ -97,6 +98,7 @@ export const ConsoleItemDetail = ({
97
98
  const merged = state?.merged ?? false;
98
99
  const closedStateLabel =
99
100
  !item.isPr && resolvedState === 'closed' ? 'Closed' : null;
101
+ const repoContext = parseNameWithOwner(item.nameWithOwner) ?? undefined;
100
102
  const storyPalette = colorFromEnum(storyColorEnum);
101
103
  const statusPalette = overlayStatus
102
104
  ? colorFromEnum(overlayStatus.color)
@@ -224,6 +226,7 @@ export const ConsoleItemDetail = ({
224
226
  body={body}
225
227
  buildImageProxyUrl={buildImageProxyUrl}
226
228
  renderReferenceLink={renderReferenceLink}
229
+ repoContext={repoContext}
227
230
  />
228
231
  )}
229
232
  </ConsolePanel>
@@ -251,6 +254,7 @@ export const ConsoleItemDetail = ({
251
254
  now={now}
252
255
  buildImageProxyUrl={buildImageProxyUrl}
253
256
  renderReferenceLink={renderReferenceLink}
257
+ repoContext={repoContext}
254
258
  />
255
259
  </ConsolePanel>
256
260
 
@@ -54,6 +54,43 @@ describe('renderMarkdownToSafeHtml', () => {
54
54
  expect(html).not.toContain(':sparkles:');
55
55
  expect(html).not.toContain(':rocket:');
56
56
  });
57
+
58
+ it('links a bare #N reference to the provided repo issues path', () => {
59
+ const html = renderMarkdownToSafeHtml('close #127', {
60
+ owner: 'HiromiShikata',
61
+ repo: 'npm-cli-github-issue-tower-defence-management',
62
+ });
63
+ expect(html).toContain(
64
+ '<a href="https://github.com/HiromiShikata/npm-cli-github-issue-tower-defence-management/issues/127">#127</a>',
65
+ );
66
+ });
67
+
68
+ it('keeps a bare #N reference as plain text when no repo context is given', () => {
69
+ const html = renderMarkdownToSafeHtml('close #127');
70
+ expect(html).toContain('#127');
71
+ expect(html).not.toContain('<a ');
72
+ });
73
+
74
+ it('keeps a full GitHub URL as a rendered link', () => {
75
+ const html = renderMarkdownToSafeHtml(
76
+ 'see https://github.com/HiromiShikata/npm-cli-github-issue-tower-defence-management/issues/42',
77
+ {
78
+ owner: 'HiromiShikata',
79
+ repo: 'npm-cli-github-issue-tower-defence-management',
80
+ },
81
+ );
82
+ expect(html).toContain(
83
+ 'href="https://github.com/HiromiShikata/npm-cli-github-issue-tower-defence-management/issues/42"',
84
+ );
85
+ });
86
+
87
+ it('preserves the href attribute on issue reference anchors after sanitization', () => {
88
+ const html = renderMarkdownToSafeHtml('resolves #9', {
89
+ owner: 'owner',
90
+ repo: 'repo',
91
+ });
92
+ expect(html).toContain('href="https://github.com/owner/repo/issues/9"');
93
+ });
57
94
  });
58
95
 
59
96
  describe('splitMarkdownSegments', () => {
@@ -1,7 +1,28 @@
1
1
  import DOMPurify from 'dompurify';
2
2
  import { nameToEmoji } from 'gemoji';
3
- import { marked } from 'marked';
3
+ import { marked, type Tokens } from 'marked';
4
4
  import { markedEmoji } from 'marked-emoji';
5
+ import type { ConsoleRepoContext } from '../logic/references';
6
+
7
+ export type { ConsoleRepoContext } from '../logic/references';
8
+
9
+ type IssueReferenceToken = Tokens.Generic & {
10
+ type: 'consoleIssueReference';
11
+ raw: string;
12
+ numberText: string;
13
+ };
14
+
15
+ const ISSUE_REFERENCE_PATTERN = /^#(\d+)\b/;
16
+
17
+ let activeRepoContext: ConsoleRepoContext | null = null;
18
+
19
+ const issueReferenceHref = (numberText: string): string | null => {
20
+ if (activeRepoContext === null) {
21
+ return null;
22
+ }
23
+ const { owner, repo } = activeRepoContext;
24
+ return `https://github.com/${owner}/${repo}/issues/${numberText}`;
25
+ };
5
26
 
6
27
  marked.use(
7
28
  markedEmoji({
@@ -10,15 +31,58 @@ marked.use(
10
31
  }),
11
32
  );
12
33
 
13
- export const renderMarkdownToSafeHtml = (source: string): string => {
34
+ marked.use({
35
+ extensions: [
36
+ {
37
+ name: 'consoleIssueReference',
38
+ level: 'inline',
39
+ start(src: string) {
40
+ const index = src.indexOf('#');
41
+ return index === -1 ? undefined : index;
42
+ },
43
+ tokenizer(src: string): IssueReferenceToken | undefined {
44
+ if (activeRepoContext === null) {
45
+ return undefined;
46
+ }
47
+ const match = ISSUE_REFERENCE_PATTERN.exec(src);
48
+ if (match === null) {
49
+ return undefined;
50
+ }
51
+ return {
52
+ type: 'consoleIssueReference',
53
+ raw: match[0],
54
+ numberText: match[1],
55
+ };
56
+ },
57
+ renderer(token: Tokens.Generic): string {
58
+ const reference = token as IssueReferenceToken;
59
+ const href = issueReferenceHref(reference.numberText);
60
+ if (href === null) {
61
+ return reference.raw;
62
+ }
63
+ return `<a href="${href}">#${reference.numberText}</a>`;
64
+ },
65
+ },
66
+ ],
67
+ });
68
+
69
+ export const renderMarkdownToSafeHtml = (
70
+ source: string,
71
+ repoContext?: ConsoleRepoContext,
72
+ ): string => {
14
73
  const trimmed = source.trim();
15
74
  if (trimmed === '') {
16
75
  return '';
17
76
  }
77
+ activeRepoContext = repoContext ?? null;
18
78
  marked.setOptions({ gfm: true, breaks: true });
19
- const parsed = marked.parse(source, { async: false });
20
- const rawHtml = typeof parsed === 'string' ? parsed : '';
21
- return DOMPurify.sanitize(rawHtml);
79
+ try {
80
+ const parsed = marked.parse(source, { async: false });
81
+ const rawHtml = typeof parsed === 'string' ? parsed : '';
82
+ return DOMPurify.sanitize(rawHtml);
83
+ } finally {
84
+ activeRepoContext = null;
85
+ }
22
86
  };
23
87
 
24
88
  export type ConsoleMarkdownSegment =
@@ -0,0 +1,95 @@
1
+ import { buildConsoleFileTree } from './fileTree';
2
+ import type { ConsoleChangedFile } from './types';
3
+
4
+ const changedFile = (path: string): ConsoleChangedFile => ({
5
+ path,
6
+ additions: 1,
7
+ deletions: 0,
8
+ status: 'modified',
9
+ patch: null,
10
+ });
11
+
12
+ describe('buildConsoleFileTree', () => {
13
+ it('nests files under their directory segments', () => {
14
+ const tree = buildConsoleFileTree([
15
+ changedFile('src/features/console/markdown.ts'),
16
+ ]);
17
+ expect(tree).toEqual([
18
+ {
19
+ kind: 'directory',
20
+ name: 'src',
21
+ path: 'src',
22
+ children: [
23
+ {
24
+ kind: 'directory',
25
+ name: 'features',
26
+ path: 'src/features',
27
+ children: [
28
+ {
29
+ kind: 'directory',
30
+ name: 'console',
31
+ path: 'src/features/console',
32
+ children: [
33
+ {
34
+ kind: 'file',
35
+ name: 'markdown.ts',
36
+ path: 'src/features/console/markdown.ts',
37
+ file: changedFile('src/features/console/markdown.ts'),
38
+ },
39
+ ],
40
+ },
41
+ ],
42
+ },
43
+ ],
44
+ },
45
+ ]);
46
+ });
47
+
48
+ it('groups multiple files that share a directory under a single directory node', () => {
49
+ const tree = buildConsoleFileTree([
50
+ changedFile('src/one.ts'),
51
+ changedFile('src/two.ts'),
52
+ ]);
53
+ expect(tree).toHaveLength(1);
54
+ const [directory] = tree;
55
+ expect(directory.kind).toBe('directory');
56
+ if (directory.kind !== 'directory') {
57
+ throw new Error('expected a directory node');
58
+ }
59
+ expect(directory.children.map((child) => child.name)).toEqual([
60
+ 'one.ts',
61
+ 'two.ts',
62
+ ]);
63
+ });
64
+
65
+ it('sorts directories before files and both alphabetically', () => {
66
+ const tree = buildConsoleFileTree([
67
+ changedFile('zeta.ts'),
68
+ changedFile('alpha.ts'),
69
+ changedFile('lib/util.ts'),
70
+ changedFile('components/view.ts'),
71
+ ]);
72
+ expect(tree.map((node) => node.name)).toEqual([
73
+ 'components',
74
+ 'lib',
75
+ 'alpha.ts',
76
+ 'zeta.ts',
77
+ ]);
78
+ });
79
+
80
+ it('places a root-level file as a leaf at the top level', () => {
81
+ const tree = buildConsoleFileTree([changedFile('package.json')]);
82
+ expect(tree).toEqual([
83
+ {
84
+ kind: 'file',
85
+ name: 'package.json',
86
+ path: 'package.json',
87
+ file: changedFile('package.json'),
88
+ },
89
+ ]);
90
+ });
91
+
92
+ it('returns an empty tree for no files', () => {
93
+ expect(buildConsoleFileTree([])).toEqual([]);
94
+ });
95
+ });
@@ -0,0 +1,93 @@
1
+ import type { ConsoleChangedFile } from './types';
2
+
3
+ export type ConsoleFileTreeFileNode = {
4
+ kind: 'file';
5
+ name: string;
6
+ path: string;
7
+ file: ConsoleChangedFile;
8
+ };
9
+
10
+ export type ConsoleFileTreeDirectoryNode = {
11
+ kind: 'directory';
12
+ name: string;
13
+ path: string;
14
+ children: ConsoleFileTreeNode[];
15
+ };
16
+
17
+ export type ConsoleFileTreeNode =
18
+ | ConsoleFileTreeDirectoryNode
19
+ | ConsoleFileTreeFileNode;
20
+
21
+ type DirectoryBuilder = {
22
+ directories: Map<string, DirectoryBuilder>;
23
+ files: ConsoleChangedFile[];
24
+ };
25
+
26
+ const createDirectoryBuilder = (): DirectoryBuilder => ({
27
+ directories: new Map(),
28
+ files: [],
29
+ });
30
+
31
+ const splitPathSegments = (path: string): string[] =>
32
+ path.split('/').filter((segment) => segment !== '');
33
+
34
+ const compareByName = (
35
+ left: { name: string },
36
+ right: { name: string },
37
+ ): number => left.name.localeCompare(right.name);
38
+
39
+ const buildNodes = (
40
+ builder: DirectoryBuilder,
41
+ parentPath: string,
42
+ ): ConsoleFileTreeNode[] => {
43
+ const directoryNodes: ConsoleFileTreeDirectoryNode[] = [];
44
+ builder.directories.forEach((childBuilder, name) => {
45
+ const path = parentPath === '' ? name : `${parentPath}/${name}`;
46
+ directoryNodes.push({
47
+ kind: 'directory',
48
+ name,
49
+ path,
50
+ children: buildNodes(childBuilder, path),
51
+ });
52
+ });
53
+ directoryNodes.sort(compareByName);
54
+
55
+ const fileNodes: ConsoleFileTreeFileNode[] = builder.files.map((file) => ({
56
+ kind: 'file',
57
+ name: splitPathSegments(file.path).at(-1) ?? file.path,
58
+ path: file.path,
59
+ file,
60
+ }));
61
+ fileNodes.sort(compareByName);
62
+
63
+ return [...directoryNodes, ...fileNodes];
64
+ };
65
+
66
+ export const buildConsoleFileTree = (
67
+ files: ConsoleChangedFile[],
68
+ ): ConsoleFileTreeNode[] => {
69
+ const root = createDirectoryBuilder();
70
+
71
+ for (const file of files) {
72
+ const segments = splitPathSegments(file.path);
73
+ if (segments.length === 0) {
74
+ root.files.push(file);
75
+ continue;
76
+ }
77
+ const directorySegments = segments.slice(0, -1);
78
+ let cursor = root;
79
+ for (const segment of directorySegments) {
80
+ const existing = cursor.directories.get(segment);
81
+ if (existing === undefined) {
82
+ const created = createDirectoryBuilder();
83
+ cursor.directories.set(segment, created);
84
+ cursor = created;
85
+ continue;
86
+ }
87
+ cursor = existing;
88
+ }
89
+ cursor.files.push(file);
90
+ }
91
+
92
+ return buildNodes(root, '');
93
+ };
@@ -1,8 +1,31 @@
1
1
  import {
2
2
  parseGitHubReferenceUrl,
3
+ parseNameWithOwner,
3
4
  referenceStateToIconInput,
4
5
  } from './references';
5
6
 
7
+ describe('parseNameWithOwner', () => {
8
+ it('splits owner and repo on the first slash', () => {
9
+ expect(parseNameWithOwner('HiromiShikata/repo-name')).toEqual({
10
+ owner: 'HiromiShikata',
11
+ repo: 'repo-name',
12
+ });
13
+ });
14
+
15
+ it('returns null when there is no slash', () => {
16
+ expect(parseNameWithOwner('repo-name')).toBeNull();
17
+ });
18
+
19
+ it('returns null when owner or repo is empty', () => {
20
+ expect(parseNameWithOwner('/repo')).toBeNull();
21
+ expect(parseNameWithOwner('owner/')).toBeNull();
22
+ });
23
+
24
+ it('returns null when the value contains extra path segments', () => {
25
+ expect(parseNameWithOwner('owner/repo/extra')).toBeNull();
26
+ });
27
+ });
28
+
6
29
  describe('parseGitHubReferenceUrl', () => {
7
30
  it('parses an issue url', () => {
8
31
  expect(
@@ -33,6 +33,26 @@ export const parseGitHubReferenceUrl = (
33
33
  };
34
34
  };
35
35
 
36
+ export type ConsoleRepoContext = {
37
+ owner: string;
38
+ repo: string;
39
+ };
40
+
41
+ export const parseNameWithOwner = (
42
+ nameWithOwner: string,
43
+ ): ConsoleRepoContext | null => {
44
+ const separatorIndex = nameWithOwner.indexOf('/');
45
+ if (separatorIndex <= 0) {
46
+ return null;
47
+ }
48
+ const owner = nameWithOwner.slice(0, separatorIndex);
49
+ const repo = nameWithOwner.slice(separatorIndex + 1);
50
+ if (owner === '' || repo === '' || repo.includes('/')) {
51
+ return null;
52
+ }
53
+ return { owner, repo };
54
+ };
55
+
36
56
  export const referenceStateToIconInput = (
37
57
  state: ConsoleIssueState,
38
58
  ): ConsoleItemIconInput => ({