github-issue-tower-defence-management 1.90.0 → 1.91.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 (155) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +14 -1
  3. package/bin/adapter/entry-points/cli/index.js +16 -12
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/entry-points/cli/projectConfig.js +2 -0
  6. package/bin/adapter/entry-points/cli/projectConfig.js.map +1 -1
  7. package/bin/adapter/entry-points/console/consoleOperationApi.js +54 -27
  8. package/bin/adapter/entry-points/console/consoleOperationApi.js.map +1 -1
  9. package/bin/adapter/entry-points/console/consoleProjectResolver.js +38 -0
  10. package/bin/adapter/entry-points/console/consoleProjectResolver.js.map +1 -0
  11. package/bin/adapter/entry-points/console/consoleServer.js +3 -4
  12. package/bin/adapter/entry-points/console/consoleServer.js.map +1 -1
  13. package/bin/adapter/entry-points/console/ui-dist/assets/index-BU6p3cGU.css +1 -0
  14. package/bin/adapter/entry-points/console/ui-dist/assets/index-BvuSQN9s.js +100 -0
  15. package/bin/adapter/entry-points/console/ui-dist/index.html +2 -2
  16. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +16 -0
  17. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  18. package/jest.config.js +57 -9
  19. package/package.json +17 -13
  20. package/src/adapter/entry-points/cli/index.test.ts +12 -2
  21. package/src/adapter/entry-points/cli/index.ts +30 -12
  22. package/src/adapter/entry-points/cli/projectConfig.ts +3 -0
  23. package/src/adapter/entry-points/console/consoleOperationApi.test.ts +129 -15
  24. package/src/adapter/entry-points/console/consoleOperationApi.ts +83 -28
  25. package/src/adapter/entry-points/console/consoleProjectResolver.test.ts +96 -0
  26. package/src/adapter/entry-points/console/consoleProjectResolver.ts +50 -0
  27. package/src/adapter/entry-points/console/consoleServer.test.ts +5 -4
  28. package/src/adapter/entry-points/console/consoleServer.ts +5 -7
  29. package/src/adapter/entry-points/console/ui/jest.setup.ts +1 -0
  30. package/src/adapter/entry-points/console/ui/jest.styleMock.js +1 -0
  31. package/src/adapter/entry-points/console/ui/src/features/console/colors.test.ts +34 -0
  32. package/src/adapter/entry-points/console/ui/src/features/console/colors.ts +73 -0
  33. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleChangedFileList.stories.tsx +28 -0
  34. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleChangedFileList.test.tsx +42 -0
  35. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleChangedFileList.tsx +55 -0
  36. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleCloseButtonGroup.stories.tsx +14 -0
  37. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleCloseButtonGroup.test.tsx +23 -0
  38. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleCloseButtonGroup.tsx +26 -0
  39. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleCommentList.stories.tsx +29 -0
  40. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleCommentList.test.tsx +55 -0
  41. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleCommentList.tsx +66 -0
  42. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleCommitList.stories.tsx +25 -0
  43. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleCommitList.test.tsx +53 -0
  44. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleCommitList.tsx +53 -0
  45. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleItemDetail.stories.tsx +79 -0
  46. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleItemDetail.test.tsx +81 -0
  47. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleItemDetail.tsx +226 -0
  48. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleItemIcon.stories.tsx +82 -0
  49. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleItemIcon.test.tsx +52 -0
  50. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleItemIcon.tsx +32 -0
  51. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleListItemRow.stories.tsx +25 -0
  52. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleListItemRow.test.tsx +43 -0
  53. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleListItemRow.tsx +34 -0
  54. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleListView.stories.tsx +22 -6
  55. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleListView.test.tsx +87 -0
  56. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleListView.tsx +36 -32
  57. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleMarkdownView.stories.tsx +27 -0
  58. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleMarkdownView.test.tsx +36 -0
  59. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleMarkdownView.tsx +50 -0
  60. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleMermaidDiagram.stories.tsx +22 -0
  61. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleMermaidDiagram.test.tsx +38 -0
  62. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleMermaidDiagram.tsx +65 -0
  63. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleNextActionDateGroup.stories.tsx +20 -0
  64. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleNextActionDateGroup.test.tsx +42 -0
  65. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleNextActionDateGroup.tsx +28 -0
  66. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleOperationBar.stories.tsx +55 -0
  67. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleOperationBar.test.tsx +85 -0
  68. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleOperationBar.tsx +55 -0
  69. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsolePanel.stories.tsx +26 -0
  70. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsolePanel.test.tsx +32 -0
  71. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsolePanel.tsx +36 -0
  72. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleProjectHeader.test.tsx +14 -0
  73. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsolePullRequestReviewGroup.stories.tsx +14 -0
  74. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsolePullRequestReviewGroup.test.tsx +33 -0
  75. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsolePullRequestReviewGroup.tsx +34 -0
  76. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsolePullRequestSection.stories.tsx +31 -0
  77. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsolePullRequestSection.test.tsx +40 -0
  78. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsolePullRequestSection.tsx +88 -0
  79. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleStatusButtonGroup.stories.tsx +17 -0
  80. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleStatusButtonGroup.test.tsx +49 -0
  81. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleStatusButtonGroup.tsx +63 -0
  82. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleStoryButtonGroup.stories.tsx +17 -0
  83. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleStoryButtonGroup.test.tsx +45 -0
  84. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleStoryButtonGroup.tsx +42 -0
  85. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleStoryGroupHeader.stories.tsx +27 -0
  86. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleStoryGroupHeader.test.tsx +24 -0
  87. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleStoryGroupHeader.tsx +28 -0
  88. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleTabBar.stories.tsx +41 -5
  89. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleTabBar.test.tsx +59 -0
  90. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleTabBar.tsx +28 -19
  91. package/src/adapter/entry-points/console/ui/src/features/console/fileStatus.test.ts +35 -0
  92. package/src/adapter/entry-points/console/ui/src/features/console/fileStatus.ts +21 -0
  93. package/src/adapter/entry-points/console/ui/src/features/console/fixtures.ts +206 -9
  94. package/src/adapter/entry-points/console/ui/src/features/console/grouping.test.ts +91 -0
  95. package/src/adapter/entry-points/console/ui/src/features/console/grouping.ts +79 -0
  96. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleCaches.test.ts +22 -0
  97. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleCaches.ts +42 -0
  98. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleItemDetailData.test.ts +126 -0
  99. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleItemDetailData.ts +167 -0
  100. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleOperations.test.ts +198 -0
  101. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleOperations.ts +243 -0
  102. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleOverlay.test.ts +40 -0
  103. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleOverlay.ts +71 -0
  104. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleResource.test.ts +41 -0
  105. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleResource.ts +57 -0
  106. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleTabData.test.ts +63 -0
  107. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleTabData.ts +129 -0
  108. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleToken.test.ts +41 -0
  109. package/src/adapter/entry-points/console/ui/src/features/console/itemIcons.test.ts +97 -0
  110. package/src/adapter/entry-points/console/ui/src/features/console/itemIcons.ts +95 -0
  111. package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.test.ts +155 -0
  112. package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.ts +187 -0
  113. package/src/adapter/entry-points/console/ui/src/features/console/lib/markdown.test.ts +76 -0
  114. package/src/adapter/entry-points/console/ui/src/features/console/lib/markdown.ts +73 -0
  115. package/src/adapter/entry-points/console/ui/src/features/console/lib/mermaidLoader.test.ts +27 -0
  116. package/src/adapter/entry-points/console/ui/src/features/console/lib/mermaidLoader.ts +71 -0
  117. package/src/adapter/entry-points/console/ui/src/features/console/lib/resourceCache.test.ts +56 -0
  118. package/src/adapter/entry-points/console/ui/src/features/console/lib/resourceCache.ts +51 -0
  119. package/src/adapter/entry-points/console/ui/src/features/console/operations.test.ts +37 -0
  120. package/src/adapter/entry-points/console/ui/src/features/console/operations.ts +35 -0
  121. package/src/adapter/entry-points/console/ui/src/features/console/overlay.test.ts +124 -0
  122. package/src/adapter/entry-points/console/ui/src/features/console/overlay.ts +101 -0
  123. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.test.tsx +79 -0
  124. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.tsx +109 -0
  125. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +74 -0
  126. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +133 -7
  127. package/src/adapter/entry-points/console/ui/src/features/console/relativeTime.test.ts +52 -0
  128. package/src/adapter/entry-points/console/ui/src/features/console/relativeTime.ts +51 -0
  129. package/src/adapter/entry-points/console/ui/src/features/console/types.ts +73 -1
  130. package/src/adapter/entry-points/console/ui/src/index.css +352 -2
  131. package/src/adapter/entry-points/console/ui/tsconfig.json +1 -0
  132. package/src/adapter/entry-points/console/ui/vite.config.ts +5 -0
  133. package/src/adapter/entry-points/console/ui-dist/assets/index-BU6p3cGU.css +1 -0
  134. package/src/adapter/entry-points/console/ui-dist/assets/index-BvuSQN9s.js +100 -0
  135. package/src/adapter/entry-points/console/ui-dist/index.html +2 -2
  136. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +25 -0
  137. package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +4 -0
  138. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  139. package/types/adapter/entry-points/cli/projectConfig.d.ts +1 -0
  140. package/types/adapter/entry-points/cli/projectConfig.d.ts.map +1 -1
  141. package/types/adapter/entry-points/console/consoleOperationApi.d.ts +6 -2
  142. package/types/adapter/entry-points/console/consoleOperationApi.d.ts.map +1 -1
  143. package/types/adapter/entry-points/console/consoleProjectResolver.d.ts +6 -0
  144. package/types/adapter/entry-points/console/consoleProjectResolver.d.ts.map +1 -0
  145. package/types/adapter/entry-points/console/consoleServer.d.ts +2 -3
  146. package/types/adapter/entry-points/console/consoleServer.d.ts.map +1 -1
  147. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +1 -0
  148. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  149. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +1 -0
  150. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
  151. package/bin/adapter/entry-points/console/ui-dist/assets/index-DDjYPXRT.js +0 -49
  152. package/bin/adapter/entry-points/console/ui-dist/assets/index-DHlBLm7d.css +0 -1
  153. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleList.ts +0 -78
  154. package/src/adapter/entry-points/console/ui-dist/assets/index-DDjYPXRT.js +0 -49
  155. package/src/adapter/entry-points/console/ui-dist/assets/index-DHlBLm7d.css +0 -1
@@ -0,0 +1,155 @@
1
+ import { createConsoleApiClient, postConsoleOperation } from './consoleApi';
2
+
3
+ const appendToken = (url: string): string =>
4
+ url.includes('?') ? `${url}&k=token` : `${url}?k=token`;
5
+
6
+ const mockFetchOnce = (body: unknown, ok = true): jest.Mock => {
7
+ const fetchMock = jest.fn().mockResolvedValue({
8
+ ok,
9
+ status: ok ? 200 : 500,
10
+ json: async () => body,
11
+ });
12
+ global.fetch = fetchMock as unknown as typeof fetch;
13
+ return fetchMock;
14
+ };
15
+
16
+ describe('createConsoleApiClient', () => {
17
+ it('reads the item body and appends the token to the url query', async () => {
18
+ const fetchMock = mockFetchOnce({ body: '# Title' });
19
+ const client = createConsoleApiClient(appendToken);
20
+ const body = await client.fetchItemBody('https://github.com/o/r/issues/1');
21
+ expect(body).toBe('# Title');
22
+ const requested = fetchMock.mock.calls[0][0] as string;
23
+ expect(requested).toContain('./api/itembody?url=');
24
+ expect(requested).toContain('&k=token');
25
+ });
26
+
27
+ it('parses comments', async () => {
28
+ mockFetchOnce({
29
+ comments: [
30
+ { author: 'a', body: 'hello', createdAt: '2026-06-19T00:00:00.000Z' },
31
+ ],
32
+ });
33
+ const client = createConsoleApiClient(appendToken);
34
+ const comments = await client.fetchComments(
35
+ 'https://github.com/o/r/issues/1',
36
+ );
37
+ expect(comments).toEqual([
38
+ { author: 'a', body: 'hello', createdAt: '2026-06-19T00:00:00.000Z' },
39
+ ]);
40
+ });
41
+
42
+ it('parses changed files supporting path and filename keys', async () => {
43
+ mockFetchOnce({
44
+ files: [
45
+ { path: 'a.ts', additions: 3, deletions: 1, status: 'modified' },
46
+ { filename: 'b.ts', additions: 9, deletions: 0, status: 'added' },
47
+ ],
48
+ });
49
+ const client = createConsoleApiClient(appendToken);
50
+ const files = await client.fetchPrFiles('https://github.com/o/r/pull/1');
51
+ expect(files.map((file) => file.path)).toEqual(['a.ts', 'b.ts']);
52
+ });
53
+
54
+ it('returns no files when the response files array is null', async () => {
55
+ mockFetchOnce({ files: null });
56
+ const client = createConsoleApiClient(appendToken);
57
+ expect(await client.fetchPrFiles('https://github.com/o/r/pull/1')).toEqual(
58
+ [],
59
+ );
60
+ });
61
+
62
+ it('parses the issue state', async () => {
63
+ mockFetchOnce({ state: 'closed', merged: true, isPullRequest: true });
64
+ const client = createConsoleApiClient(appendToken);
65
+ expect(
66
+ await client.fetchIssueState('https://github.com/o/r/pull/1'),
67
+ ).toEqual({ state: 'closed', merged: true, isPullRequest: true });
68
+ });
69
+
70
+ it('parses pull request commits', async () => {
71
+ mockFetchOnce({
72
+ commits: [
73
+ {
74
+ sha: 'abc1234',
75
+ message: 'fix: thing',
76
+ author: 'dev',
77
+ authoredAt: '2026-06-19T00:00:00.000Z',
78
+ },
79
+ ],
80
+ });
81
+ const client = createConsoleApiClient(appendToken);
82
+ const commits = await client.fetchPrCommits(
83
+ 'https://github.com/o/r/pull/1',
84
+ );
85
+ expect(commits[0].sha).toBe('abc1234');
86
+ });
87
+
88
+ it('parses related pull requests with summaries', async () => {
89
+ mockFetchOnce({
90
+ relatedPullRequests: [
91
+ {
92
+ url: 'https://github.com/o/r/pull/9',
93
+ branchName: 'feat',
94
+ createdAt: '2026-06-19T00:00:00.000Z',
95
+ isDraft: false,
96
+ isConflicted: false,
97
+ isPassedAllCiJob: true,
98
+ isCiStateSuccess: true,
99
+ isResolvedAllReviewComments: true,
100
+ isBranchOutOfDate: false,
101
+ missingRequiredCheckNames: ['build'],
102
+ summary: {
103
+ title: 'Linked',
104
+ body: 'body',
105
+ additions: 10,
106
+ deletions: 2,
107
+ changedFiles: 3,
108
+ },
109
+ },
110
+ ],
111
+ });
112
+ const client = createConsoleApiClient(appendToken);
113
+ const related = await client.fetchRelatedPrs(
114
+ 'https://github.com/o/r/issues/1',
115
+ );
116
+ expect(related[0].url).toBe('https://github.com/o/r/pull/9');
117
+ expect(related[0].summary?.changedFiles).toBe(3);
118
+ expect(related[0].missingRequiredCheckNames).toEqual(['build']);
119
+ });
120
+
121
+ it('throws on a non-ok response', async () => {
122
+ mockFetchOnce({}, false);
123
+ const client = createConsoleApiClient(appendToken);
124
+ await expect(
125
+ client.fetchComments('https://github.com/o/r/issues/1'),
126
+ ).rejects.toThrow('HTTP 500');
127
+ });
128
+ });
129
+
130
+ describe('postConsoleOperation', () => {
131
+ it('posts a JSON body and appends the token', async () => {
132
+ const fetchMock = mockFetchOnce({ ok: true });
133
+ await postConsoleOperation(appendToken, '/api/review', {
134
+ pjcode: 'umino',
135
+ action: 'approve',
136
+ prUrl: 'https://github.com/o/r/pull/1',
137
+ projectItemId: 'PVTI_1',
138
+ });
139
+ const [url, init] = fetchMock.mock.calls[0];
140
+ expect(url).toBe('/api/review?k=token');
141
+ expect(init).toMatchObject({ method: 'POST' });
142
+ });
143
+
144
+ it('throws on a failed operation', async () => {
145
+ mockFetchOnce({}, false);
146
+ await expect(
147
+ postConsoleOperation(appendToken, '/api/review', {
148
+ pjcode: 'umino',
149
+ action: 'approve',
150
+ prUrl: 'https://github.com/o/r/pull/1',
151
+ projectItemId: 'PVTI_1',
152
+ }),
153
+ ).rejects.toThrow('HTTP 500');
154
+ });
155
+ });
@@ -0,0 +1,187 @@
1
+ import type {
2
+ ConsoleChangedFile,
3
+ ConsoleComment,
4
+ ConsoleCommit,
5
+ ConsoleIssueState,
6
+ ConsoleRelatedPullRequest,
7
+ } from '../types';
8
+
9
+ export type ConsoleApiClient = {
10
+ fetchItemBody: (url: string) => Promise<string>;
11
+ fetchComments: (url: string) => Promise<ConsoleComment[]>;
12
+ fetchPrFiles: (url: string) => Promise<ConsoleChangedFile[]>;
13
+ fetchPrCommits: (url: string) => Promise<ConsoleCommit[]>;
14
+ fetchRelatedPrs: (url: string) => Promise<ConsoleRelatedPullRequest[]>;
15
+ fetchIssueState: (url: string) => Promise<ConsoleIssueState>;
16
+ };
17
+
18
+ export type ConsoleReviewRequest = {
19
+ pjcode: string;
20
+ action: string;
21
+ prUrl: string;
22
+ projectItemId: string;
23
+ commentBody?: string;
24
+ changedFilePath?: string;
25
+ };
26
+
27
+ export type ConsoleTriageRequest = {
28
+ pjcode: string;
29
+ action: string;
30
+ issueUrl: string;
31
+ projectItemId: string;
32
+ statusName?: string;
33
+ storyOptionId?: string;
34
+ commentBody?: string;
35
+ };
36
+
37
+ export type ConsoleIntmuxRequest = {
38
+ pjcode: string;
39
+ action: 'set_intmux';
40
+ issueUrl: string;
41
+ projectItemId: string;
42
+ };
43
+
44
+ type AppendToken = (url: string) => string;
45
+
46
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
47
+ value !== null && typeof value === 'object' && !Array.isArray(value);
48
+
49
+ const getString = (value: unknown): string =>
50
+ typeof value === 'string' ? value : '';
51
+
52
+ const getNumber = (value: unknown): number =>
53
+ typeof value === 'number' ? value : 0;
54
+
55
+ const getBoolean = (value: unknown): boolean => value === true;
56
+
57
+ const requestJson = async (
58
+ appendToken: AppendToken,
59
+ apiPath: string,
60
+ resourceUrl: string,
61
+ ): Promise<unknown> => {
62
+ const target = appendToken(
63
+ `${apiPath}?url=${encodeURIComponent(resourceUrl)}`,
64
+ );
65
+ const response = await fetch(target);
66
+ if (!response.ok) {
67
+ throw new Error(`HTTP ${response.status}`);
68
+ }
69
+ return response.json();
70
+ };
71
+
72
+ const parseComments = (payload: unknown): ConsoleComment[] => {
73
+ if (!isRecord(payload) || !Array.isArray(payload.comments)) {
74
+ return [];
75
+ }
76
+ return payload.comments.filter(isRecord).map((comment) => ({
77
+ author: getString(comment.author),
78
+ body: getString(comment.body),
79
+ createdAt: getString(comment.createdAt),
80
+ }));
81
+ };
82
+
83
+ const parseFiles = (payload: unknown): ConsoleChangedFile[] => {
84
+ if (!isRecord(payload) || !Array.isArray(payload.files)) {
85
+ return [];
86
+ }
87
+ return payload.files.filter(isRecord).map((file) => ({
88
+ path: getString(file.path) || getString(file.filename),
89
+ additions: getNumber(file.additions),
90
+ deletions: getNumber(file.deletions),
91
+ status: getString(file.status),
92
+ patch: typeof file.patch === 'string' ? file.patch : null,
93
+ }));
94
+ };
95
+
96
+ const parseCommits = (payload: unknown): ConsoleCommit[] => {
97
+ if (!isRecord(payload) || !Array.isArray(payload.commits)) {
98
+ return [];
99
+ }
100
+ return payload.commits.filter(isRecord).map((commit) => ({
101
+ sha: getString(commit.sha),
102
+ message: getString(commit.message),
103
+ author: getString(commit.author),
104
+ authoredAt: getString(commit.authoredAt),
105
+ }));
106
+ };
107
+
108
+ const parseSummary = (value: unknown): ConsoleRelatedPullRequest['summary'] => {
109
+ if (!isRecord(value)) {
110
+ return null;
111
+ }
112
+ return {
113
+ title: getString(value.title),
114
+ body: getString(value.body),
115
+ additions: getNumber(value.additions),
116
+ deletions: getNumber(value.deletions),
117
+ changedFiles: getNumber(value.changedFiles),
118
+ };
119
+ };
120
+
121
+ const parseRelatedPrs = (payload: unknown): ConsoleRelatedPullRequest[] => {
122
+ if (!isRecord(payload) || !Array.isArray(payload.relatedPullRequests)) {
123
+ return [];
124
+ }
125
+ return payload.relatedPullRequests.filter(isRecord).map((pr) => ({
126
+ url: getString(pr.url),
127
+ branchName: typeof pr.branchName === 'string' ? pr.branchName : null,
128
+ createdAt: getString(pr.createdAt),
129
+ isDraft: getBoolean(pr.isDraft),
130
+ isConflicted: getBoolean(pr.isConflicted),
131
+ isPassedAllCiJob: getBoolean(pr.isPassedAllCiJob),
132
+ isCiStateSuccess: getBoolean(pr.isCiStateSuccess),
133
+ isResolvedAllReviewComments: getBoolean(pr.isResolvedAllReviewComments),
134
+ isBranchOutOfDate: getBoolean(pr.isBranchOutOfDate),
135
+ missingRequiredCheckNames: Array.isArray(pr.missingRequiredCheckNames)
136
+ ? pr.missingRequiredCheckNames.filter(
137
+ (name): name is string => typeof name === 'string',
138
+ )
139
+ : [],
140
+ summary: parseSummary(pr.summary),
141
+ }));
142
+ };
143
+
144
+ const parseState = (payload: unknown): ConsoleIssueState => {
145
+ if (!isRecord(payload)) {
146
+ return { state: 'open', merged: false, isPullRequest: false };
147
+ }
148
+ return {
149
+ state: getString(payload.state) || 'open',
150
+ merged: getBoolean(payload.merged),
151
+ isPullRequest: getBoolean(payload.isPullRequest),
152
+ };
153
+ };
154
+
155
+ export const createConsoleApiClient = (
156
+ appendToken: AppendToken,
157
+ ): ConsoleApiClient => ({
158
+ fetchItemBody: async (url) => {
159
+ const payload = await requestJson(appendToken, './api/itembody', url);
160
+ return isRecord(payload) ? getString(payload.body) : '';
161
+ },
162
+ fetchComments: async (url) =>
163
+ parseComments(await requestJson(appendToken, './api/comments', url)),
164
+ fetchPrFiles: async (url) =>
165
+ parseFiles(await requestJson(appendToken, './api/prfiles', url)),
166
+ fetchPrCommits: async (url) =>
167
+ parseCommits(await requestJson(appendToken, './api/prcommits', url)),
168
+ fetchRelatedPrs: async (url) =>
169
+ parseRelatedPrs(await requestJson(appendToken, './api/relatedprs', url)),
170
+ fetchIssueState: async (url) =>
171
+ parseState(await requestJson(appendToken, './api/issuetitle', url)),
172
+ });
173
+
174
+ export const postConsoleOperation = async (
175
+ appendToken: AppendToken,
176
+ apiPath: string,
177
+ body: ConsoleReviewRequest | ConsoleTriageRequest | ConsoleIntmuxRequest,
178
+ ): Promise<void> => {
179
+ const response = await fetch(appendToken(apiPath), {
180
+ method: 'POST',
181
+ headers: { 'Content-Type': 'application/json' },
182
+ body: JSON.stringify(body),
183
+ });
184
+ if (!response.ok) {
185
+ throw new Error(`HTTP ${response.status}`);
186
+ }
187
+ };
@@ -0,0 +1,76 @@
1
+ import {
2
+ type ConsoleMarkdownSegment,
3
+ hasMermaidFence,
4
+ renderMarkdownToSafeHtml,
5
+ splitMarkdownSegments,
6
+ } from './markdown';
7
+
8
+ const stripKey = (segment: ConsoleMarkdownSegment): Record<string, unknown> => {
9
+ if (segment.kind === 'mermaid') {
10
+ return { kind: segment.kind, code: segment.code };
11
+ }
12
+ return { kind: segment.kind, source: segment.source };
13
+ };
14
+
15
+ describe('renderMarkdownToSafeHtml', () => {
16
+ it('renders headings and lists', () => {
17
+ const html = renderMarkdownToSafeHtml('# Title\n\n- one\n- two');
18
+ expect(html).toContain('<h1');
19
+ expect(html).toContain('<li>one</li>');
20
+ });
21
+
22
+ it('strips script tags via DOMPurify', () => {
23
+ const html = renderMarkdownToSafeHtml(
24
+ 'before <script>alert(1)</script> after',
25
+ );
26
+ expect(html).not.toContain('<script>');
27
+ expect(html).not.toContain('alert(1)');
28
+ });
29
+
30
+ it('strips event-handler attributes', () => {
31
+ const html = renderMarkdownToSafeHtml('<img src="x" onerror="alert(1)">');
32
+ expect(html).not.toContain('onerror');
33
+ });
34
+
35
+ it('returns an empty string for blank input', () => {
36
+ expect(renderMarkdownToSafeHtml(' ')).toBe('');
37
+ });
38
+ });
39
+
40
+ describe('splitMarkdownSegments', () => {
41
+ it('separates a mermaid fence from surrounding markdown', () => {
42
+ const segments = splitMarkdownSegments(
43
+ 'intro\n\n```mermaid\ngraph TD; A-->B;\n```\n\noutro',
44
+ );
45
+ expect(segments.map(stripKey)).toEqual([
46
+ { kind: 'markdown', source: 'intro\n' },
47
+ { kind: 'mermaid', code: 'graph TD; A-->B;' },
48
+ { kind: 'markdown', source: '\noutro' },
49
+ ]);
50
+ expect(new Set(segments.map((segment) => segment.key)).size).toBe(3);
51
+ });
52
+
53
+ it('keeps plain markdown as a single segment', () => {
54
+ const segments = splitMarkdownSegments('just text');
55
+ expect(segments.map(stripKey)).toEqual([
56
+ { kind: 'markdown', source: 'just text' },
57
+ ]);
58
+ });
59
+
60
+ it('keeps an unterminated mermaid fence as markdown', () => {
61
+ const segments = splitMarkdownSegments('```mermaid\ngraph TD; A-->B;');
62
+ expect(segments.map(stripKey)).toEqual([
63
+ { kind: 'markdown', source: '```mermaid\ngraph TD; A-->B;' },
64
+ ]);
65
+ });
66
+ });
67
+
68
+ describe('hasMermaidFence', () => {
69
+ it('detects a mermaid fence', () => {
70
+ expect(hasMermaidFence('```mermaid\ngraph TD; A-->B;\n```')).toBe(true);
71
+ });
72
+
73
+ it('returns false without a mermaid fence', () => {
74
+ expect(hasMermaidFence('```ts\nconst a = 1;\n```')).toBe(false);
75
+ });
76
+ });
@@ -0,0 +1,73 @@
1
+ import DOMPurify from 'dompurify';
2
+ import { marked } from 'marked';
3
+
4
+ export const renderMarkdownToSafeHtml = (source: string): string => {
5
+ const trimmed = source.trim();
6
+ if (trimmed === '') {
7
+ return '';
8
+ }
9
+ marked.setOptions({ gfm: true, breaks: true });
10
+ const parsed = marked.parse(source, { async: false });
11
+ const rawHtml = typeof parsed === 'string' ? parsed : '';
12
+ return DOMPurify.sanitize(rawHtml);
13
+ };
14
+
15
+ export type ConsoleMarkdownSegment =
16
+ | { kind: 'markdown'; key: string; source: string }
17
+ | { kind: 'mermaid'; key: string; code: string };
18
+
19
+ const MERMAID_FENCE = /^```mermaid[^\n]*\n([\s\S]*?)\n```$/;
20
+
21
+ export const splitMarkdownSegments = (
22
+ source: string,
23
+ ): ConsoleMarkdownSegment[] => {
24
+ const lines = source.split('\n');
25
+ const segments: ConsoleMarkdownSegment[] = [];
26
+ let markdownBuffer: string[] = [];
27
+ let mermaidBuffer: string[] | null = null;
28
+ let sequence = 0;
29
+
30
+ const flushMarkdown = (): void => {
31
+ if (markdownBuffer.length > 0) {
32
+ segments.push({
33
+ kind: 'markdown',
34
+ key: `markdown:${sequence}`,
35
+ source: markdownBuffer.join('\n'),
36
+ });
37
+ sequence += 1;
38
+ markdownBuffer = [];
39
+ }
40
+ };
41
+
42
+ for (const line of lines) {
43
+ if (mermaidBuffer === null && /^```mermaid\s*$/.test(line.trim())) {
44
+ flushMarkdown();
45
+ mermaidBuffer = [];
46
+ continue;
47
+ }
48
+ if (mermaidBuffer !== null) {
49
+ if (line.trim() === '```') {
50
+ segments.push({
51
+ kind: 'mermaid',
52
+ key: `mermaid:${sequence}`,
53
+ code: mermaidBuffer.join('\n'),
54
+ });
55
+ sequence += 1;
56
+ mermaidBuffer = null;
57
+ continue;
58
+ }
59
+ mermaidBuffer.push(line);
60
+ continue;
61
+ }
62
+ markdownBuffer.push(line);
63
+ }
64
+
65
+ if (mermaidBuffer !== null) {
66
+ markdownBuffer.push('```mermaid', ...mermaidBuffer);
67
+ }
68
+ flushMarkdown();
69
+ return segments;
70
+ };
71
+
72
+ export const hasMermaidFence = (source: string): boolean =>
73
+ /```mermaid\s*\n/.test(source) || MERMAID_FENCE.test(source.trim());
@@ -0,0 +1,27 @@
1
+ import { renderMermaidToSvg } from './mermaidLoader';
2
+
3
+ const initialize = jest.fn();
4
+ const render = jest.fn(async () => ({
5
+ svg: '<svg><script>alert(1)</script><g></g></svg>',
6
+ }));
7
+
8
+ describe('renderMermaidToSvg', () => {
9
+ beforeEach(() => {
10
+ initialize.mockClear();
11
+ render.mockClear();
12
+ (window as unknown as { mermaid: unknown }).mermaid = {
13
+ initialize,
14
+ render,
15
+ };
16
+ });
17
+
18
+ it('initializes mermaid once and returns sanitized svg without scripts', async () => {
19
+ const first = await renderMermaidToSvg('graph TD; A-->B;');
20
+ const second = await renderMermaidToSvg('graph TD; C-->D;');
21
+ expect(initialize).toHaveBeenCalledTimes(1);
22
+ expect(render).toHaveBeenCalledTimes(2);
23
+ expect(first).not.toContain('<script>');
24
+ expect(first).toContain('<svg');
25
+ expect(second).not.toContain('alert(1)');
26
+ });
27
+ });
@@ -0,0 +1,71 @@
1
+ import DOMPurify from 'dompurify';
2
+
3
+ type MermaidModule = {
4
+ initialize: (config: Record<string, unknown>) => void;
5
+ render: (id: string, code: string) => Promise<{ svg: string }>;
6
+ };
7
+
8
+ declare global {
9
+ interface Window {
10
+ mermaid?: MermaidModule;
11
+ }
12
+ }
13
+
14
+ export const MERMAID_SCRIPT_URL =
15
+ 'https://cdn.jsdelivr.net/npm/mermaid@10.9.6/dist/mermaid.min.js';
16
+
17
+ let mermaidPromise: Promise<MermaidModule> | null = null;
18
+ let renderSequence = 0;
19
+
20
+ const initializeMermaid = (mermaid: MermaidModule): MermaidModule => {
21
+ mermaid.initialize({
22
+ startOnLoad: false,
23
+ securityLevel: 'strict',
24
+ theme: 'dark',
25
+ themeVariables: {
26
+ background: '#0d1117',
27
+ primaryColor: '#161b22',
28
+ primaryTextColor: '#e6edf3',
29
+ primaryBorderColor: '#30363d',
30
+ lineColor: '#8b949e',
31
+ fontSize: '14px',
32
+ },
33
+ });
34
+ return mermaid;
35
+ };
36
+
37
+ const loadMermaid = (): Promise<MermaidModule> => {
38
+ if (mermaidPromise !== null) {
39
+ return mermaidPromise;
40
+ }
41
+ if (window.mermaid !== undefined) {
42
+ mermaidPromise = Promise.resolve(initializeMermaid(window.mermaid));
43
+ return mermaidPromise;
44
+ }
45
+ mermaidPromise = new Promise<MermaidModule>((resolve, reject) => {
46
+ const script = document.createElement('script');
47
+ script.src = MERMAID_SCRIPT_URL;
48
+ script.async = true;
49
+ script.onload = () => {
50
+ if (window.mermaid === undefined) {
51
+ reject(new Error('mermaid failed to load'));
52
+ return;
53
+ }
54
+ resolve(initializeMermaid(window.mermaid));
55
+ };
56
+ script.onerror = () => reject(new Error('mermaid script failed to load'));
57
+ document.head.appendChild(script);
58
+ });
59
+ return mermaidPromise;
60
+ };
61
+
62
+ export const renderMermaidToSvg = async (code: string): Promise<string> => {
63
+ const mermaid = await loadMermaid();
64
+ renderSequence += 1;
65
+ const id = `console-mermaid-${renderSequence}`;
66
+ const { svg } = await mermaid.render(id, code);
67
+ return DOMPurify.sanitize(svg, {
68
+ USE_PROFILES: { svg: true, svgFilters: true },
69
+ ADD_TAGS: ['foreignObject'],
70
+ });
71
+ };
@@ -0,0 +1,56 @@
1
+ import { ResourceCache, runWithConcurrency } from './resourceCache';
2
+
3
+ describe('ResourceCache', () => {
4
+ it('fetches once and caches the result', async () => {
5
+ const fetcher = jest.fn(async (url: string) => `value:${url}`);
6
+ const cache = new ResourceCache(fetcher);
7
+ expect(await cache.load('k', 'u')).toBe('value:u');
8
+ expect(await cache.load('k', 'u')).toBe('value:u');
9
+ expect(fetcher).toHaveBeenCalledTimes(1);
10
+ expect(cache.peek('k')).toBe('value:u');
11
+ });
12
+
13
+ it('de-duplicates concurrent in-flight requests', async () => {
14
+ let resolveFetch: (value: string) => void = () => {};
15
+ const fetcher = jest.fn(
16
+ () =>
17
+ new Promise<string>((resolve) => {
18
+ resolveFetch = resolve;
19
+ }),
20
+ );
21
+ const cache = new ResourceCache(fetcher);
22
+ const first = cache.load('k', 'u');
23
+ const second = cache.load('k', 'u');
24
+ resolveFetch('shared');
25
+ expect(await first).toBe('shared');
26
+ expect(await second).toBe('shared');
27
+ expect(fetcher).toHaveBeenCalledTimes(1);
28
+ });
29
+
30
+ it('allows a retry after a failed fetch', async () => {
31
+ const fetcher = jest
32
+ .fn<Promise<string>, [string]>()
33
+ .mockRejectedValueOnce(new Error('boom'))
34
+ .mockResolvedValueOnce('ok');
35
+ const cache = new ResourceCache(fetcher);
36
+ await expect(cache.load('k', 'u')).rejects.toThrow('boom');
37
+ expect(await cache.load('k', 'u')).toBe('ok');
38
+ expect(fetcher).toHaveBeenCalledTimes(2);
39
+ });
40
+ });
41
+
42
+ describe('runWithConcurrency', () => {
43
+ it('never exceeds the concurrency limit', async () => {
44
+ let active = 0;
45
+ let maxActive = 0;
46
+ const makeTask = () => async () => {
47
+ active += 1;
48
+ maxActive = Math.max(maxActive, active);
49
+ await new Promise((resolve) => setTimeout(resolve, 5));
50
+ active -= 1;
51
+ };
52
+ const tasks = Array.from({ length: 6 }, makeTask);
53
+ await runWithConcurrency(tasks, 2);
54
+ expect(maxActive).toBeLessThanOrEqual(2);
55
+ });
56
+ });
@@ -0,0 +1,51 @@
1
+ export type ResourceFetcher<T> = (url: string) => Promise<T>;
2
+
3
+ export class ResourceCache<T> {
4
+ private readonly cache = new Map<string, T>();
5
+ private readonly inFlight = new Map<string, Promise<T>>();
6
+
7
+ constructor(private readonly fetcher: ResourceFetcher<T>) {}
8
+
9
+ peek = (key: string): T | undefined => this.cache.get(key);
10
+
11
+ has = (key: string): boolean => this.cache.has(key);
12
+
13
+ load = (key: string, url: string): Promise<T> => {
14
+ const cached = this.cache.get(key);
15
+ if (cached !== undefined) {
16
+ return Promise.resolve(cached);
17
+ }
18
+ const pending = this.inFlight.get(key);
19
+ if (pending !== undefined) {
20
+ return pending;
21
+ }
22
+ const promise = this.fetcher(url)
23
+ .then((value) => {
24
+ this.cache.set(key, value);
25
+ return value;
26
+ })
27
+ .finally(() => {
28
+ this.inFlight.delete(key);
29
+ });
30
+ this.inFlight.set(key, promise);
31
+ return promise;
32
+ };
33
+ }
34
+
35
+ export const runWithConcurrency = async (
36
+ tasks: (() => Promise<unknown>)[],
37
+ limit: number,
38
+ ): Promise<void> => {
39
+ let index = 0;
40
+ const workers = Array.from(
41
+ { length: Math.min(limit, tasks.length) },
42
+ async () => {
43
+ while (index < tasks.length) {
44
+ const current = tasks[index];
45
+ index += 1;
46
+ await current();
47
+ }
48
+ },
49
+ );
50
+ await Promise.all(workers);
51
+ };