github-issue-tower-defence-management 1.89.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.
- package/.github/CODEOWNERS +1 -2
- package/CHANGELOG.md +14 -0
- package/README.md +15 -2
- package/bin/adapter/entry-points/cli/index.js +16 -12
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/cli/projectConfig.js +2 -0
- package/bin/adapter/entry-points/cli/projectConfig.js.map +1 -1
- package/bin/adapter/entry-points/console/consoleOperationApi.js +54 -27
- package/bin/adapter/entry-points/console/consoleOperationApi.js.map +1 -1
- package/bin/adapter/entry-points/console/consoleProjectResolver.js +38 -0
- package/bin/adapter/entry-points/console/consoleProjectResolver.js.map +1 -0
- package/bin/adapter/entry-points/console/consoleServer.js +43 -17
- package/bin/adapter/entry-points/console/consoleServer.js.map +1 -1
- package/bin/adapter/entry-points/console/ui-dist/assets/index-BU6p3cGU.css +1 -0
- package/bin/adapter/entry-points/console/ui-dist/assets/index-BvuSQN9s.js +100 -0
- package/bin/adapter/entry-points/console/ui-dist/index.html +2 -2
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +16 -0
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
- package/jest.config.js +57 -9
- package/package.json +17 -13
- package/src/adapter/entry-points/cli/index.test.ts +12 -2
- package/src/adapter/entry-points/cli/index.ts +30 -12
- package/src/adapter/entry-points/cli/projectConfig.ts +3 -0
- package/src/adapter/entry-points/console/consoleOperationApi.test.ts +129 -15
- package/src/adapter/entry-points/console/consoleOperationApi.ts +83 -28
- package/src/adapter/entry-points/console/consoleProjectResolver.test.ts +96 -0
- package/src/adapter/entry-points/console/consoleProjectResolver.ts +50 -0
- package/src/adapter/entry-points/console/consoleServer.test.ts +86 -4
- package/src/adapter/entry-points/console/consoleServer.ts +53 -23
- package/src/adapter/entry-points/console/ui/jest.setup.ts +1 -0
- package/src/adapter/entry-points/console/ui/jest.styleMock.js +1 -0
- package/src/adapter/entry-points/console/ui/src/features/console/colors.test.ts +34 -0
- package/src/adapter/entry-points/console/ui/src/features/console/colors.ts +73 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleChangedFileList.stories.tsx +28 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleChangedFileList.test.tsx +42 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleChangedFileList.tsx +55 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleCloseButtonGroup.stories.tsx +14 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleCloseButtonGroup.test.tsx +23 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleCloseButtonGroup.tsx +26 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleCommentList.stories.tsx +29 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleCommentList.test.tsx +55 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleCommentList.tsx +66 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleCommitList.stories.tsx +25 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleCommitList.test.tsx +53 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleCommitList.tsx +53 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleItemDetail.stories.tsx +79 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleItemDetail.test.tsx +81 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleItemDetail.tsx +226 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleItemIcon.stories.tsx +82 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleItemIcon.test.tsx +52 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleItemIcon.tsx +32 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleListItemRow.stories.tsx +25 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleListItemRow.test.tsx +43 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleListItemRow.tsx +34 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleListView.stories.tsx +22 -6
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleListView.test.tsx +87 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleListView.tsx +36 -32
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleMarkdownView.stories.tsx +27 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleMarkdownView.test.tsx +36 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleMarkdownView.tsx +50 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleMermaidDiagram.stories.tsx +22 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleMermaidDiagram.test.tsx +38 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleMermaidDiagram.tsx +65 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleNextActionDateGroup.stories.tsx +20 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleNextActionDateGroup.test.tsx +42 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleNextActionDateGroup.tsx +28 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleOperationBar.stories.tsx +55 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleOperationBar.test.tsx +85 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleOperationBar.tsx +55 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsolePanel.stories.tsx +26 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsolePanel.test.tsx +32 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsolePanel.tsx +36 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleProjectHeader.stories.tsx +29 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleProjectHeader.test.tsx +14 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleProjectHeader.tsx +14 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsolePullRequestReviewGroup.stories.tsx +14 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsolePullRequestReviewGroup.test.tsx +33 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsolePullRequestReviewGroup.tsx +34 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsolePullRequestSection.stories.tsx +31 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsolePullRequestSection.test.tsx +40 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsolePullRequestSection.tsx +88 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleStatusButtonGroup.stories.tsx +17 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleStatusButtonGroup.test.tsx +49 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleStatusButtonGroup.tsx +63 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleStoryButtonGroup.stories.tsx +17 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleStoryButtonGroup.test.tsx +45 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleStoryButtonGroup.tsx +42 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleStoryGroupHeader.stories.tsx +27 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleStoryGroupHeader.test.tsx +24 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleStoryGroupHeader.tsx +28 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleTabBar.stories.tsx +41 -5
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleTabBar.test.tsx +59 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleTabBar.tsx +28 -19
- package/src/adapter/entry-points/console/ui/src/features/console/fileStatus.test.ts +35 -0
- package/src/adapter/entry-points/console/ui/src/features/console/fileStatus.ts +21 -0
- package/src/adapter/entry-points/console/ui/src/features/console/fixtures.ts +206 -9
- package/src/adapter/entry-points/console/ui/src/features/console/grouping.test.ts +91 -0
- package/src/adapter/entry-points/console/ui/src/features/console/grouping.ts +79 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleCaches.test.ts +22 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleCaches.ts +42 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleItemDetailData.test.ts +126 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleItemDetailData.ts +167 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleOperations.test.ts +198 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleOperations.ts +243 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleOverlay.test.ts +40 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleOverlay.ts +71 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsolePjcode.test.ts +24 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsolePjcode.ts +17 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleResource.test.ts +41 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleResource.ts +57 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleTabData.test.ts +63 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleTabData.ts +129 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleToken.test.ts +41 -0
- package/src/adapter/entry-points/console/ui/src/features/console/itemIcons.test.ts +97 -0
- package/src/adapter/entry-points/console/ui/src/features/console/itemIcons.ts +95 -0
- package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.test.ts +155 -0
- package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.ts +187 -0
- package/src/adapter/entry-points/console/ui/src/features/console/lib/markdown.test.ts +76 -0
- package/src/adapter/entry-points/console/ui/src/features/console/lib/markdown.ts +73 -0
- package/src/adapter/entry-points/console/ui/src/features/console/lib/mermaidLoader.test.ts +27 -0
- package/src/adapter/entry-points/console/ui/src/features/console/lib/mermaidLoader.ts +71 -0
- package/src/adapter/entry-points/console/ui/src/features/console/lib/resourceCache.test.ts +56 -0
- package/src/adapter/entry-points/console/ui/src/features/console/lib/resourceCache.ts +51 -0
- package/src/adapter/entry-points/console/ui/src/features/console/operations.test.ts +37 -0
- package/src/adapter/entry-points/console/ui/src/features/console/operations.ts +35 -0
- package/src/adapter/entry-points/console/ui/src/features/console/overlay.test.ts +124 -0
- package/src/adapter/entry-points/console/ui/src/features/console/overlay.ts +101 -0
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.test.tsx +79 -0
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.tsx +109 -0
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +74 -0
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +137 -7
- package/src/adapter/entry-points/console/ui/src/features/console/relativeTime.test.ts +52 -0
- package/src/adapter/entry-points/console/ui/src/features/console/relativeTime.ts +51 -0
- package/src/adapter/entry-points/console/ui/src/features/console/types.ts +73 -1
- package/src/adapter/entry-points/console/ui/src/index.css +352 -2
- package/src/adapter/entry-points/console/ui/tsconfig.json +3 -1
- package/src/adapter/entry-points/console/ui/vite.config.ts +6 -1
- package/src/adapter/entry-points/console/ui-dist/assets/index-BU6p3cGU.css +1 -0
- package/src/adapter/entry-points/console/ui-dist/assets/index-BvuSQN9s.js +100 -0
- package/src/adapter/entry-points/console/ui-dist/index.html +2 -2
- package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +25 -0
- package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +4 -0
- package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
- package/types/adapter/entry-points/cli/projectConfig.d.ts +1 -0
- package/types/adapter/entry-points/cli/projectConfig.d.ts.map +1 -1
- package/types/adapter/entry-points/console/consoleOperationApi.d.ts +6 -2
- package/types/adapter/entry-points/console/consoleOperationApi.d.ts.map +1 -1
- package/types/adapter/entry-points/console/consoleProjectResolver.d.ts +6 -0
- package/types/adapter/entry-points/console/consoleProjectResolver.d.ts.map +1 -0
- package/types/adapter/entry-points/console/consoleServer.d.ts +3 -3
- package/types/adapter/entry-points/console/consoleServer.d.ts.map +1 -1
- package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +1 -0
- package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
- package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +1 -0
- package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
- package/bin/adapter/entry-points/console/ui-dist/assets/index-DFxrSRH4.css +0 -1
- package/bin/adapter/entry-points/console/ui-dist/assets/index-DcOZ02ON.js +0 -49
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleList.ts +0 -65
- package/src/adapter/entry-points/console/ui-dist/assets/index-DFxrSRH4.css +0 -1
- package/src/adapter/entry-points/console/ui-dist/assets/index-DcOZ02ON.js +0 -49
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import {
|
|
2
|
+
IN_TMUX_BY_HUMAN_NAME,
|
|
3
|
+
isTodoByHumanTab,
|
|
4
|
+
STATUS_BUTTON_NAMES,
|
|
5
|
+
TOTALLY_WRONG_COMMENT_BODY,
|
|
6
|
+
UNNECESSARY_COMMENT_BODY,
|
|
7
|
+
} from './operations';
|
|
8
|
+
|
|
9
|
+
describe('operation constants', () => {
|
|
10
|
+
it('defines the totally wrong comment body', () => {
|
|
11
|
+
expect(TOTALLY_WRONG_COMMENT_BODY).toBe('totally wrong');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('defines the unnecessary comment body', () => {
|
|
15
|
+
expect(UNNECESSARY_COMMENT_BODY).toBe('This pull request is unnecessary.');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('lists the status buttons left to right', () => {
|
|
19
|
+
expect(STATUS_BUTTON_NAMES).toEqual([
|
|
20
|
+
'In Tmux by agent',
|
|
21
|
+
'In Tmux by human',
|
|
22
|
+
'Todo by human',
|
|
23
|
+
'Awaiting Workspace',
|
|
24
|
+
]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('names the in-tmux-by-human status', () => {
|
|
28
|
+
expect(IN_TMUX_BY_HUMAN_NAME).toBe('In Tmux by human');
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('isTodoByHumanTab', () => {
|
|
33
|
+
it('is true only for the todo-by-human tab', () => {
|
|
34
|
+
expect(isTodoByHumanTab('todo-by-human')).toBe(true);
|
|
35
|
+
expect(isTodoByHumanTab('prs')).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ConsoleFieldOption, ConsoleTabName } from './types';
|
|
2
|
+
|
|
3
|
+
export const TOTALLY_WRONG_COMMENT_BODY = 'totally wrong';
|
|
4
|
+
export const UNNECESSARY_COMMENT_BODY = 'This pull request is unnecessary.';
|
|
5
|
+
|
|
6
|
+
export type ConsoleReviewAction =
|
|
7
|
+
| 'approve'
|
|
8
|
+
| 'request_changes'
|
|
9
|
+
| 'unnecessary'
|
|
10
|
+
| 'totally_wrong';
|
|
11
|
+
|
|
12
|
+
export type ConsoleNextActionDateAction = 'snooze_1day' | 'snooze_1week';
|
|
13
|
+
|
|
14
|
+
export type ConsoleCloseAction = 'close' | 'close_not_planned';
|
|
15
|
+
|
|
16
|
+
export type ConsoleOperationHandlers = {
|
|
17
|
+
onReview: (action: ConsoleReviewAction) => void;
|
|
18
|
+
onSetNextActionDate: (action: ConsoleNextActionDateAction) => void;
|
|
19
|
+
onSetStory: (option: ConsoleFieldOption) => void;
|
|
20
|
+
onSetStatus: (option: ConsoleFieldOption) => void;
|
|
21
|
+
onSetInTmuxByHuman: (option: ConsoleFieldOption) => void;
|
|
22
|
+
onClose: (action: ConsoleCloseAction) => void;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const STATUS_BUTTON_NAMES: string[] = [
|
|
26
|
+
'In Tmux by agent',
|
|
27
|
+
'In Tmux by human',
|
|
28
|
+
'Todo by human',
|
|
29
|
+
'Awaiting Workspace',
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export const IN_TMUX_BY_HUMAN_NAME = 'In Tmux by human';
|
|
33
|
+
|
|
34
|
+
export const isTodoByHumanTab = (tab: ConsoleTabName): boolean =>
|
|
35
|
+
tab === 'todo-by-human';
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import {
|
|
2
|
+
countPendingItems,
|
|
3
|
+
filterPendingItems,
|
|
4
|
+
getOverlayEntry,
|
|
5
|
+
isOverlayEntryActedForMode,
|
|
6
|
+
isOverlayEntryExpiredForMode,
|
|
7
|
+
overlayKeyForItem,
|
|
8
|
+
overlayStorageKey,
|
|
9
|
+
parseGeneratedAtMs,
|
|
10
|
+
writeOverlayEntry,
|
|
11
|
+
} from './overlay';
|
|
12
|
+
import type { ConsoleListItem, ConsoleOverlay } from './types';
|
|
13
|
+
|
|
14
|
+
const item = (number: number): ConsoleListItem => ({
|
|
15
|
+
number,
|
|
16
|
+
title: `Item ${number}`,
|
|
17
|
+
url: `https://github.com/o/r/issues/${number}`,
|
|
18
|
+
repo: 'o/r',
|
|
19
|
+
nameWithOwner: 'o/r',
|
|
20
|
+
projectItemId: `PVTI_${number}`,
|
|
21
|
+
itemId: `PVTI_${number}`,
|
|
22
|
+
isPr: false,
|
|
23
|
+
story: 'Story',
|
|
24
|
+
labels: [],
|
|
25
|
+
createdAt: '2026-06-10T00:00:00.000Z',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('overlay helpers', () => {
|
|
29
|
+
it('builds the per-project storage key', () => {
|
|
30
|
+
expect(overlayStorageKey('umino')).toBe('pv_overlay_umino');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('uses the projectItemId as the overlay key when present', () => {
|
|
34
|
+
expect(overlayKeyForItem(item(5))).toBe('PVTI_5');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('parses a generatedAt timestamp', () => {
|
|
38
|
+
expect(parseGeneratedAtMs('2026-06-10T00:00:00.000Z')).toBe(
|
|
39
|
+
Date.parse('2026-06-10T00:00:00.000Z'),
|
|
40
|
+
);
|
|
41
|
+
expect(parseGeneratedAtMs('not-a-date')).toBe(0);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('mode-aware expiry', () => {
|
|
46
|
+
it('expires a same-mode entry written before the snapshot', () => {
|
|
47
|
+
expect(
|
|
48
|
+
isOverlayEntryExpiredForMode(
|
|
49
|
+
{ ts: 100, mode: 'prs', done: true },
|
|
50
|
+
200,
|
|
51
|
+
'prs',
|
|
52
|
+
),
|
|
53
|
+
).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('does not expire an entry written in a different mode', () => {
|
|
57
|
+
expect(
|
|
58
|
+
isOverlayEntryExpiredForMode(
|
|
59
|
+
{ ts: 100, mode: 'triage', done: true },
|
|
60
|
+
200,
|
|
61
|
+
'prs',
|
|
62
|
+
),
|
|
63
|
+
).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('does not expire an entry written after the snapshot', () => {
|
|
67
|
+
expect(
|
|
68
|
+
isOverlayEntryExpiredForMode(
|
|
69
|
+
{ ts: 300, mode: 'prs', done: true },
|
|
70
|
+
200,
|
|
71
|
+
'prs',
|
|
72
|
+
),
|
|
73
|
+
).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('counts driven to zero do not revive on tab switch', () => {
|
|
78
|
+
it('keeps a done item subtracted in its own mode', () => {
|
|
79
|
+
const overlay: ConsoleOverlay = {
|
|
80
|
+
PVTI_1: { ts: 500, mode: 'prs', done: true },
|
|
81
|
+
};
|
|
82
|
+
const generatedAtMs = 400;
|
|
83
|
+
expect(countPendingItems([item(1)], overlay, generatedAtMs, 'prs')).toBe(0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('treats a done entry from another mode as still acted', () => {
|
|
87
|
+
const overlay: ConsoleOverlay = {
|
|
88
|
+
PVTI_1: { ts: 100, mode: 'triage', done: true },
|
|
89
|
+
};
|
|
90
|
+
expect(isOverlayEntryActedForMode(overlay.PVTI_1, 999, 'prs')).toBe(true);
|
|
91
|
+
expect(countPendingItems([item(1)], overlay, 999, 'prs')).toBe(0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('revives the count only when a newer same-mode snapshot supersedes the entry', () => {
|
|
95
|
+
const overlay: ConsoleOverlay = {
|
|
96
|
+
PVTI_1: { ts: 100, mode: 'prs', done: true },
|
|
97
|
+
};
|
|
98
|
+
expect(countPendingItems([item(1)], overlay, 200, 'prs')).toBe(1);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('filterPendingItems', () => {
|
|
103
|
+
it('drops acted items and keeps the rest', () => {
|
|
104
|
+
const overlay: ConsoleOverlay = {
|
|
105
|
+
PVTI_1: { ts: 500, mode: 'prs', done: true },
|
|
106
|
+
};
|
|
107
|
+
const result = filterPendingItems([item(1), item(2)], overlay, 400, 'prs');
|
|
108
|
+
expect(result.map((entry) => entry.number)).toEqual([2]);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('getOverlayEntry and writeOverlayEntry', () => {
|
|
113
|
+
it('returns null for an expired entry', () => {
|
|
114
|
+
const overlay: ConsoleOverlay = {
|
|
115
|
+
PVTI_1: { ts: 100, mode: 'prs', done: true },
|
|
116
|
+
};
|
|
117
|
+
expect(getOverlayEntry(overlay, item(1), 200, 'prs')).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('stamps the timestamp and mode on write', () => {
|
|
121
|
+
const next = writeOverlayEntry({}, 'PVTI_1', { done: true }, 'prs', 1234);
|
|
122
|
+
expect(next.PVTI_1).toEqual({ done: true, ts: 1234, mode: 'prs' });
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ConsoleListItem,
|
|
3
|
+
ConsoleOverlay,
|
|
4
|
+
ConsoleOverlayEntry,
|
|
5
|
+
ConsoleTabName,
|
|
6
|
+
} from './types';
|
|
7
|
+
|
|
8
|
+
export const overlayStorageKey = (pjcode: string): string =>
|
|
9
|
+
`pv_overlay_${pjcode}`;
|
|
10
|
+
|
|
11
|
+
export const overlayKeyForItem = (item: ConsoleListItem): string =>
|
|
12
|
+
item.projectItemId !== '' ? item.projectItemId : item.itemId;
|
|
13
|
+
|
|
14
|
+
export const parseGeneratedAtMs = (generatedAt: string): number => {
|
|
15
|
+
const parsed = Date.parse(generatedAt);
|
|
16
|
+
return Number.isNaN(parsed) ? 0 : parsed;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const isOverlayEntryExpiredForMode = (
|
|
20
|
+
entry: ConsoleOverlayEntry,
|
|
21
|
+
generatedAtMs: number,
|
|
22
|
+
mode: ConsoleTabName,
|
|
23
|
+
): boolean =>
|
|
24
|
+
entry.ts > 0 &&
|
|
25
|
+
generatedAtMs > 0 &&
|
|
26
|
+
entry.mode === mode &&
|
|
27
|
+
entry.ts < generatedAtMs;
|
|
28
|
+
|
|
29
|
+
export const getOverlayEntry = (
|
|
30
|
+
overlay: ConsoleOverlay,
|
|
31
|
+
item: ConsoleListItem,
|
|
32
|
+
generatedAtMs: number,
|
|
33
|
+
mode: ConsoleTabName,
|
|
34
|
+
): ConsoleOverlayEntry | null => {
|
|
35
|
+
const entry = overlay[overlayKeyForItem(item)];
|
|
36
|
+
if (entry === undefined) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
if (isOverlayEntryExpiredForMode(entry, generatedAtMs, mode)) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
return entry;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const isOverlayEntryActedForMode = (
|
|
46
|
+
entry: ConsoleOverlayEntry | undefined,
|
|
47
|
+
generatedAtMs: number,
|
|
48
|
+
mode: ConsoleTabName,
|
|
49
|
+
): boolean => {
|
|
50
|
+
if (entry === undefined || entry.done !== true) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
return !isOverlayEntryExpiredForMode(entry, generatedAtMs, mode);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const countPendingItems = (
|
|
57
|
+
items: ConsoleListItem[],
|
|
58
|
+
overlay: ConsoleOverlay,
|
|
59
|
+
generatedAtMs: number,
|
|
60
|
+
mode: ConsoleTabName,
|
|
61
|
+
): number =>
|
|
62
|
+
items.filter(
|
|
63
|
+
(item) =>
|
|
64
|
+
!isOverlayEntryActedForMode(
|
|
65
|
+
overlay[overlayKeyForItem(item)],
|
|
66
|
+
generatedAtMs,
|
|
67
|
+
mode,
|
|
68
|
+
),
|
|
69
|
+
).length;
|
|
70
|
+
|
|
71
|
+
export const filterPendingItems = (
|
|
72
|
+
items: ConsoleListItem[],
|
|
73
|
+
overlay: ConsoleOverlay,
|
|
74
|
+
generatedAtMs: number,
|
|
75
|
+
mode: ConsoleTabName,
|
|
76
|
+
): ConsoleListItem[] =>
|
|
77
|
+
items.filter(
|
|
78
|
+
(item) =>
|
|
79
|
+
!isOverlayEntryActedForMode(
|
|
80
|
+
overlay[overlayKeyForItem(item)],
|
|
81
|
+
generatedAtMs,
|
|
82
|
+
mode,
|
|
83
|
+
),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
export const writeOverlayEntry = (
|
|
87
|
+
overlay: ConsoleOverlay,
|
|
88
|
+
key: string,
|
|
89
|
+
patch: Partial<Omit<ConsoleOverlayEntry, 'ts' | 'mode'>>,
|
|
90
|
+
mode: ConsoleTabName,
|
|
91
|
+
now: number,
|
|
92
|
+
): ConsoleOverlay => {
|
|
93
|
+
const existing = overlay[key];
|
|
94
|
+
const next: ConsoleOverlayEntry = {
|
|
95
|
+
...(existing ?? {}),
|
|
96
|
+
...patch,
|
|
97
|
+
ts: now,
|
|
98
|
+
mode,
|
|
99
|
+
};
|
|
100
|
+
return { ...overlay, [key]: next };
|
|
101
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { fireEvent, render, waitFor } from '@testing-library/react';
|
|
2
|
+
import {
|
|
3
|
+
consoleListItemsFixture,
|
|
4
|
+
consoleStatusOptionsFixture,
|
|
5
|
+
consoleStoryColorsFixture,
|
|
6
|
+
consoleStoryOptionsFixture,
|
|
7
|
+
} from '../fixtures';
|
|
8
|
+
import type { ConsoleCaches } from '../hooks/useConsoleCaches';
|
|
9
|
+
import type { ConsoleOperationsApi } from '../hooks/useConsoleOperations';
|
|
10
|
+
import { ResourceCache } from '../lib/resourceCache';
|
|
11
|
+
import { ConsoleItemDetailContainer } from './ConsoleItemDetailContainer';
|
|
12
|
+
|
|
13
|
+
jest.mock('../lib/mermaidLoader', () => ({
|
|
14
|
+
renderMermaidToSvg: jest.fn(async () => '<svg></svg>'),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
const prItem = consoleListItemsFixture[0];
|
|
18
|
+
|
|
19
|
+
const buildCaches = (): ConsoleCaches => {
|
|
20
|
+
const client = {
|
|
21
|
+
fetchItemBody: async () => '# body',
|
|
22
|
+
fetchComments: async () => [],
|
|
23
|
+
fetchPrFiles: async () => [],
|
|
24
|
+
fetchPrCommits: async () => [],
|
|
25
|
+
fetchRelatedPrs: async () => [],
|
|
26
|
+
fetchIssueState: async () => ({
|
|
27
|
+
state: 'open',
|
|
28
|
+
merged: false,
|
|
29
|
+
isPullRequest: true,
|
|
30
|
+
}),
|
|
31
|
+
};
|
|
32
|
+
return {
|
|
33
|
+
client,
|
|
34
|
+
body: new ResourceCache(client.fetchItemBody),
|
|
35
|
+
comments: new ResourceCache(client.fetchComments),
|
|
36
|
+
files: new ResourceCache(client.fetchPrFiles),
|
|
37
|
+
commits: new ResourceCache(client.fetchPrCommits),
|
|
38
|
+
relatedPrs: new ResourceCache(client.fetchRelatedPrs),
|
|
39
|
+
state: new ResourceCache(client.fetchIssueState),
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const buildOperations = (): ConsoleOperationsApi => ({
|
|
44
|
+
reviewPullRequest: jest.fn(async () => {}),
|
|
45
|
+
setNextActionDate: jest.fn(async () => {}),
|
|
46
|
+
setStory: jest.fn(async () => {}),
|
|
47
|
+
setStatus: jest.fn(async () => {}),
|
|
48
|
+
setInTmuxByHuman: jest.fn(async () => {}),
|
|
49
|
+
closeIssue: jest.fn(async () => {}),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('ConsoleItemDetailContainer', () => {
|
|
53
|
+
it('wires the review action to the operations api for a PR item', async () => {
|
|
54
|
+
const operations = buildOperations();
|
|
55
|
+
const { getByText } = render(
|
|
56
|
+
<ConsoleItemDetailContainer
|
|
57
|
+
tab="prs"
|
|
58
|
+
item={prItem}
|
|
59
|
+
caches={buildCaches()}
|
|
60
|
+
operations={operations}
|
|
61
|
+
statusOptions={consoleStatusOptionsFixture}
|
|
62
|
+
storyOptions={consoleStoryOptionsFixture}
|
|
63
|
+
storyColors={consoleStoryColorsFixture}
|
|
64
|
+
storyName="TDPM Console port"
|
|
65
|
+
overlayStatus={null}
|
|
66
|
+
now={Date.parse('2026-06-19T12:00:00.000Z')}
|
|
67
|
+
/>,
|
|
68
|
+
);
|
|
69
|
+
await waitFor(() => {
|
|
70
|
+
expect(getByText('Approve')).toBeInTheDocument();
|
|
71
|
+
});
|
|
72
|
+
fireEvent.click(getByText('Approve'));
|
|
73
|
+
expect(operations.reviewPullRequest).toHaveBeenCalledWith(
|
|
74
|
+
prItem,
|
|
75
|
+
prItem.url,
|
|
76
|
+
'approve',
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
});
|