github-issue-tower-defence-management 1.101.7 → 1.102.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/CHANGELOG.md +14 -0
- package/bin/adapter/entry-points/console/consoleServer.js +32 -10
- package/bin/adapter/entry-points/console/consoleServer.js.map +1 -1
- package/bin/adapter/entry-points/console/ui-dist/assets/index-CFGeqgfE.js +101 -0
- package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
- package/jest.config.js +15 -1
- package/package.json +3 -1
- package/src/adapter/entry-points/console/consoleServer.test.ts +43 -0
- package/src/adapter/entry-points/console/consoleServer.ts +38 -11
- package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleErrorToast.stories.tsx +23 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleUndoToast.test.tsx +44 -1
- package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleUndoToast.tsx +25 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleActionQueue.test.ts +84 -1
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleActionQueue.ts +31 -6
- package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.test.ts +37 -10
- package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.ts +19 -1
- package/src/adapter/entry-points/console/ui/src/features/console/lib/markdown.test.ts +10 -0
- package/src/adapter/entry-points/console/ui/src/features/console/lib/markdown.ts +9 -0
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.tsx +7 -19
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +10 -1
- package/src/adapter/entry-points/console/ui-dist/assets/index-CFGeqgfE.js +101 -0
- package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
- package/types/adapter/entry-points/console/consoleServer.d.ts.map +1 -1
- package/bin/adapter/entry-points/console/ui-dist/assets/index--7aFZNg9.js +0 -101
- package/src/adapter/entry-points/console/ui-dist/assets/index--7aFZNg9.js +0 -101
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>TDPM Console</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-CFGeqgfE.js"></script>
|
|
8
8
|
<link rel="stylesheet" crossorigin href="/assets/index-ES6SLB1Y.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
package/jest.config.js
CHANGED
|
@@ -57,8 +57,22 @@ module.exports = {
|
|
|
57
57
|
},
|
|
58
58
|
},
|
|
59
59
|
],
|
|
60
|
+
'^.+\\.js$': [
|
|
61
|
+
'ts-jest',
|
|
62
|
+
{
|
|
63
|
+
tsconfig: {
|
|
64
|
+
allowJs: true,
|
|
65
|
+
esModuleInterop: true,
|
|
66
|
+
module: 'CommonJS',
|
|
67
|
+
moduleResolution: 'Node',
|
|
68
|
+
verbatimModuleSyntax: false,
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
],
|
|
60
72
|
},
|
|
61
|
-
transformIgnorePatterns: [
|
|
73
|
+
transformIgnorePatterns: [
|
|
74
|
+
'/node_modules/(?!(marked|marked-emoji|gemoji)/)',
|
|
75
|
+
],
|
|
62
76
|
moduleNameMapper: {
|
|
63
77
|
'\\.(css|less|scss)$': '<rootDir>/jest.styleMock.js',
|
|
64
78
|
'^@/(.*)$': '<rootDir>/src/$1',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "github-issue-tower-defence-management",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.102.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "bin/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -101,9 +101,11 @@
|
|
|
101
101
|
"commander": "^14.0.0",
|
|
102
102
|
"dompurify": "3.4.11",
|
|
103
103
|
"dotenv": "^17.0.0",
|
|
104
|
+
"gemoji": "8.1.0",
|
|
104
105
|
"googleapis": "^171.4.0",
|
|
105
106
|
"ky": "^2.0.0",
|
|
106
107
|
"marked": "12.0.2",
|
|
108
|
+
"marked-emoji": "2.0.3",
|
|
107
109
|
"typia": "^12.0.0",
|
|
108
110
|
"yaml": "^2.6.0"
|
|
109
111
|
}
|
|
@@ -652,6 +652,49 @@ describe('consoleServer new routes integration', () => {
|
|
|
652
652
|
}
|
|
653
653
|
});
|
|
654
654
|
|
|
655
|
+
it('returns 502 with the underlying error message when an operation rejects', async () => {
|
|
656
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'console-server-'));
|
|
657
|
+
const issueRepository = mock<IssueRepository>();
|
|
658
|
+
issueRepository.get.mockResolvedValue({
|
|
659
|
+
...mock<Issue>(),
|
|
660
|
+
itemId: 'PVTI_loaded',
|
|
661
|
+
});
|
|
662
|
+
issueRepository.approvePullRequest.mockRejectedValue(
|
|
663
|
+
new Error('Failed to approve PR https://github.com/o/r/pull/1: HTTP 422'),
|
|
664
|
+
);
|
|
665
|
+
const server = await startConsoleServer({
|
|
666
|
+
accessToken: testToken,
|
|
667
|
+
uiDistDir: path.join(tmpDir, 'ui-dist'),
|
|
668
|
+
consoleDataOutputDir: null,
|
|
669
|
+
inTmuxDataDir: null,
|
|
670
|
+
dashboardDir: null,
|
|
671
|
+
issueRepository,
|
|
672
|
+
resolveProject: async (pjcode) =>
|
|
673
|
+
pjcode === 'umino' ? { pjcode, project: buildProject() } : null,
|
|
674
|
+
port: 0,
|
|
675
|
+
});
|
|
676
|
+
try {
|
|
677
|
+
const response = await request(
|
|
678
|
+
server,
|
|
679
|
+
'POST',
|
|
680
|
+
`/api/review?k=${testToken}`,
|
|
681
|
+
{
|
|
682
|
+
pjcode: 'umino',
|
|
683
|
+
action: 'approve',
|
|
684
|
+
prUrl: 'https://github.com/o/r/pull/1',
|
|
685
|
+
projectItemId: 'PVTI_op',
|
|
686
|
+
},
|
|
687
|
+
);
|
|
688
|
+
expect(response.statusCode).toBe(502);
|
|
689
|
+
expect(JSON.parse(response.body)).toEqual({
|
|
690
|
+
error: 'Failed to approve PR https://github.com/o/r/pull/1: HTTP 422',
|
|
691
|
+
});
|
|
692
|
+
} finally {
|
|
693
|
+
await closeServer(server);
|
|
694
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
|
|
655
698
|
it('rejects an operation api with a malformed json body', async () => {
|
|
656
699
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'console-server-'));
|
|
657
700
|
const issueRepository = mock<IssueRepository>();
|
|
@@ -372,6 +372,32 @@ const handleReadApi = async (
|
|
|
372
372
|
}
|
|
373
373
|
};
|
|
374
374
|
|
|
375
|
+
const operationErrorMessage = (error: unknown): string => {
|
|
376
|
+
if (error instanceof Error && error.message.length > 0) {
|
|
377
|
+
return error.message;
|
|
378
|
+
}
|
|
379
|
+
return String(error);
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
const dispatchOperation = (
|
|
383
|
+
context: ConsoleOperationContext,
|
|
384
|
+
requestPath: string,
|
|
385
|
+
body: Record<string, unknown>,
|
|
386
|
+
): Promise<{ statusCode: number; body: unknown }> | null => {
|
|
387
|
+
switch (requestPath) {
|
|
388
|
+
case '/api/review':
|
|
389
|
+
return handleReview(context, body);
|
|
390
|
+
case '/api/triage':
|
|
391
|
+
return handleTriage(context, body);
|
|
392
|
+
case '/api/intmux':
|
|
393
|
+
return handleIntmux(context, body);
|
|
394
|
+
case '/api/comment':
|
|
395
|
+
return handleComment(context, body);
|
|
396
|
+
default:
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
|
|
375
401
|
const handleOperationApi = async (
|
|
376
402
|
options: ConsoleServerOptions,
|
|
377
403
|
requestPath: string,
|
|
@@ -387,17 +413,18 @@ const handleOperationApi = async (
|
|
|
387
413
|
resolveProject,
|
|
388
414
|
consoleDataOutputDir: options.consoleDataOutputDir,
|
|
389
415
|
};
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
416
|
+
const dispatched = dispatchOperation(context, requestPath, body);
|
|
417
|
+
if (dispatched === null) {
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
try {
|
|
421
|
+
return await dispatched;
|
|
422
|
+
} catch (error) {
|
|
423
|
+
console.error('console operation failed', error);
|
|
424
|
+
return {
|
|
425
|
+
statusCode: 502,
|
|
426
|
+
body: { error: operationErrorMessage(error) },
|
|
427
|
+
};
|
|
401
428
|
}
|
|
402
429
|
};
|
|
403
430
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { ConsoleErrorToast } from './ConsoleUndoToast';
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof ConsoleErrorToast> = {
|
|
5
|
+
title: 'Console/ConsoleErrorToast',
|
|
6
|
+
component: ConsoleErrorToast,
|
|
7
|
+
args: {
|
|
8
|
+
message: '操作に失敗しました: HTTP 422 Review cannot be requested',
|
|
9
|
+
onDismiss: () => {},
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export default meta;
|
|
14
|
+
|
|
15
|
+
type Story = StoryObj<typeof ConsoleErrorToast>;
|
|
16
|
+
|
|
17
|
+
export const ReviewRejectedByGitHub: Story = {};
|
|
18
|
+
|
|
19
|
+
export const NetworkFailure: Story = {
|
|
20
|
+
args: {
|
|
21
|
+
message: '操作に失敗しました: network down',
|
|
22
|
+
},
|
|
23
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { fireEvent, render } from '@testing-library/react';
|
|
2
|
-
import { ConsoleUndoToast } from './ConsoleUndoToast';
|
|
2
|
+
import { ConsoleErrorToast, ConsoleUndoToast } from './ConsoleUndoToast';
|
|
3
3
|
|
|
4
4
|
describe('ConsoleUndoToast', () => {
|
|
5
5
|
const baseProps = {
|
|
@@ -52,3 +52,46 @@ describe('ConsoleUndoToast', () => {
|
|
|
52
52
|
expect(onUndo).toHaveBeenCalledTimes(1);
|
|
53
53
|
});
|
|
54
54
|
});
|
|
55
|
+
|
|
56
|
+
describe('ConsoleErrorToast', () => {
|
|
57
|
+
it('shows the failure message with the error modifier class and alert role', () => {
|
|
58
|
+
const { getByText, container, getByRole } = render(
|
|
59
|
+
<ConsoleErrorToast
|
|
60
|
+
message="操作に失敗しました: HTTP 422"
|
|
61
|
+
onDismiss={() => {}}
|
|
62
|
+
/>,
|
|
63
|
+
);
|
|
64
|
+
expect(getByText('操作に失敗しました: HTTP 422')).toBeInTheDocument();
|
|
65
|
+
expect(
|
|
66
|
+
container.querySelector('.console-undo-toast-error'),
|
|
67
|
+
).toBeInTheDocument();
|
|
68
|
+
expect(getByRole('alert')).toBeInTheDocument();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('does not render a countdown or progress bar', () => {
|
|
72
|
+
const { container } = render(
|
|
73
|
+
<ConsoleErrorToast
|
|
74
|
+
message="操作に失敗しました: boom"
|
|
75
|
+
onDismiss={() => {}}
|
|
76
|
+
/>,
|
|
77
|
+
);
|
|
78
|
+
expect(
|
|
79
|
+
container.querySelector('.console-undo-toast-countdown'),
|
|
80
|
+
).not.toBeInTheDocument();
|
|
81
|
+
expect(
|
|
82
|
+
container.querySelector('.console-undo-toast-bar'),
|
|
83
|
+
).not.toBeInTheDocument();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('invokes onDismiss when the Dismiss control is clicked', () => {
|
|
87
|
+
const onDismiss = jest.fn();
|
|
88
|
+
const { getByText } = render(
|
|
89
|
+
<ConsoleErrorToast
|
|
90
|
+
message="操作に失敗しました: boom"
|
|
91
|
+
onDismiss={onDismiss}
|
|
92
|
+
/>,
|
|
93
|
+
);
|
|
94
|
+
fireEvent.click(getByText('Dismiss'));
|
|
95
|
+
expect(onDismiss).toHaveBeenCalledTimes(1);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -34,3 +34,28 @@ export const ConsoleUndoToast = ({
|
|
|
34
34
|
/>
|
|
35
35
|
</div>
|
|
36
36
|
);
|
|
37
|
+
|
|
38
|
+
export type ConsoleErrorToastProps = {
|
|
39
|
+
message: string;
|
|
40
|
+
onDismiss: () => void;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const ConsoleErrorToast = ({
|
|
44
|
+
message,
|
|
45
|
+
onDismiss,
|
|
46
|
+
}: ConsoleErrorToastProps) => (
|
|
47
|
+
<div
|
|
48
|
+
className="console-undo-toast console-undo-toast-error"
|
|
49
|
+
role="alert"
|
|
50
|
+
aria-live="assertive"
|
|
51
|
+
>
|
|
52
|
+
<span className="console-undo-toast-message">{message}</span>
|
|
53
|
+
<button
|
|
54
|
+
type="button"
|
|
55
|
+
className="console-undo-toast-undo"
|
|
56
|
+
onClick={onDismiss}
|
|
57
|
+
>
|
|
58
|
+
Dismiss
|
|
59
|
+
</button>
|
|
60
|
+
</div>
|
|
61
|
+
);
|
package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleActionQueue.test.ts
CHANGED
|
@@ -8,11 +8,14 @@ const makeAction = (
|
|
|
8
8
|
) => ({
|
|
9
9
|
message: 'Approved — PR #851',
|
|
10
10
|
color: 'green' as const,
|
|
11
|
-
commit: jest.fn(),
|
|
11
|
+
commit: jest.fn<Promise<void>, []>().mockResolvedValue(undefined),
|
|
12
12
|
advance: jest.fn(),
|
|
13
13
|
...overrides,
|
|
14
14
|
});
|
|
15
15
|
|
|
16
|
+
const flushMicrotasks = (): Promise<void> =>
|
|
17
|
+
Promise.resolve().then(() => undefined);
|
|
18
|
+
|
|
16
19
|
describe('useConsoleActionQueue', () => {
|
|
17
20
|
beforeEach(() => {
|
|
18
21
|
jest.useFakeTimers();
|
|
@@ -114,4 +117,84 @@ describe('useConsoleActionQueue', () => {
|
|
|
114
117
|
expect(first.commit).toHaveBeenCalledTimes(1);
|
|
115
118
|
expect(second.commit).toHaveBeenCalledTimes(1);
|
|
116
119
|
});
|
|
120
|
+
|
|
121
|
+
it('surfaces the failure reason when the timer commit rejects', async () => {
|
|
122
|
+
const { result } = renderHook(() => useConsoleActionQueue());
|
|
123
|
+
const action = makeAction({
|
|
124
|
+
commit: jest
|
|
125
|
+
.fn<Promise<void>, []>()
|
|
126
|
+
.mockRejectedValue(new Error('HTTP 422 review cannot be requested')),
|
|
127
|
+
});
|
|
128
|
+
act(() => {
|
|
129
|
+
result.current.enqueue(action);
|
|
130
|
+
});
|
|
131
|
+
expect(result.current.error).toBeNull();
|
|
132
|
+
await act(async () => {
|
|
133
|
+
jest.advanceTimersByTime(5000);
|
|
134
|
+
await flushMicrotasks();
|
|
135
|
+
});
|
|
136
|
+
expect(action.commit).toHaveBeenCalledTimes(1);
|
|
137
|
+
expect(result.current.error).toEqual({
|
|
138
|
+
message: 'Approved — PR #851',
|
|
139
|
+
reason: 'HTTP 422 review cannot be requested',
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('surfaces the failure reason when the previous action commit rejects on enqueue', async () => {
|
|
144
|
+
const { result } = renderHook(() => useConsoleActionQueue());
|
|
145
|
+
const first = makeAction({
|
|
146
|
+
commit: jest
|
|
147
|
+
.fn<Promise<void>, []>()
|
|
148
|
+
.mockRejectedValue(new Error('network down')),
|
|
149
|
+
});
|
|
150
|
+
const second = makeAction({
|
|
151
|
+
message: 'Rejected — PR #853',
|
|
152
|
+
color: 'amber',
|
|
153
|
+
});
|
|
154
|
+
act(() => {
|
|
155
|
+
result.current.enqueue(first);
|
|
156
|
+
});
|
|
157
|
+
await act(async () => {
|
|
158
|
+
jest.advanceTimersByTime(1000);
|
|
159
|
+
result.current.enqueue(second);
|
|
160
|
+
await flushMicrotasks();
|
|
161
|
+
});
|
|
162
|
+
expect(first.commit).toHaveBeenCalledTimes(1);
|
|
163
|
+
expect(result.current.error).toEqual({
|
|
164
|
+
message: 'Approved — PR #851',
|
|
165
|
+
reason: 'network down',
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('clears the surfaced error when dismissError is called', async () => {
|
|
170
|
+
const { result } = renderHook(() => useConsoleActionQueue());
|
|
171
|
+
const action = makeAction({
|
|
172
|
+
commit: jest.fn<Promise<void>, []>().mockRejectedValue(new Error('boom')),
|
|
173
|
+
});
|
|
174
|
+
act(() => {
|
|
175
|
+
result.current.enqueue(action);
|
|
176
|
+
});
|
|
177
|
+
await act(async () => {
|
|
178
|
+
jest.advanceTimersByTime(5000);
|
|
179
|
+
await flushMicrotasks();
|
|
180
|
+
});
|
|
181
|
+
expect(result.current.error).not.toBeNull();
|
|
182
|
+
act(() => {
|
|
183
|
+
result.current.dismissError();
|
|
184
|
+
});
|
|
185
|
+
expect(result.current.error).toBeNull();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('does not surface an error when the commit resolves', async () => {
|
|
189
|
+
const { result } = renderHook(() => useConsoleActionQueue());
|
|
190
|
+
const action = makeAction();
|
|
191
|
+
act(() => {
|
|
192
|
+
result.current.enqueue(action);
|
|
193
|
+
});
|
|
194
|
+
await act(async () => {
|
|
195
|
+
jest.advanceTimersByTime(5000);
|
|
196
|
+
await flushMicrotasks();
|
|
197
|
+
});
|
|
198
|
+
expect(result.current.error).toBeNull();
|
|
199
|
+
});
|
|
117
200
|
});
|
package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleActionQueue.ts
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
export type ConsoleQueuedAction = {
|
|
8
8
|
message: string;
|
|
9
9
|
color: ConsoleToastColor;
|
|
10
|
-
commit: () => void
|
|
10
|
+
commit: () => Promise<void>;
|
|
11
11
|
advance: () => void;
|
|
12
12
|
};
|
|
13
13
|
|
|
@@ -18,6 +18,18 @@ export type ConsolePendingActionView = {
|
|
|
18
18
|
progress: number;
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
+
export type ConsoleActionError = {
|
|
22
|
+
message: string;
|
|
23
|
+
reason: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const errorReason = (error: unknown): string => {
|
|
27
|
+
if (error instanceof Error && error.message.length > 0) {
|
|
28
|
+
return error.message;
|
|
29
|
+
}
|
|
30
|
+
return String(error);
|
|
31
|
+
};
|
|
32
|
+
|
|
21
33
|
const COUNTDOWN_TICK_MS = 100;
|
|
22
34
|
|
|
23
35
|
const computeRemainingSeconds = (elapsedMs: number): number =>
|
|
@@ -28,12 +40,15 @@ const computeProgress = (elapsedMs: number): number =>
|
|
|
28
40
|
|
|
29
41
|
export type ConsoleActionQueue = {
|
|
30
42
|
pending: ConsolePendingActionView | null;
|
|
43
|
+
error: ConsoleActionError | null;
|
|
31
44
|
enqueue: (action: ConsoleQueuedAction) => void;
|
|
32
45
|
undo: () => void;
|
|
46
|
+
dismissError: () => void;
|
|
33
47
|
};
|
|
34
48
|
|
|
35
49
|
export const useConsoleActionQueue = (): ConsoleActionQueue => {
|
|
36
50
|
const [pending, setPending] = useState<ConsolePendingActionView | null>(null);
|
|
51
|
+
const [error, setError] = useState<ConsoleActionError | null>(null);
|
|
37
52
|
const actionRef = useRef<ConsoleQueuedAction | null>(null);
|
|
38
53
|
const startRef = useRef<number>(0);
|
|
39
54
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
@@ -46,6 +61,12 @@ export const useConsoleActionQueue = (): ConsoleActionQueue => {
|
|
|
46
61
|
}
|
|
47
62
|
}, []);
|
|
48
63
|
|
|
64
|
+
const runCommit = useCallback((action: ConsoleQueuedAction): void => {
|
|
65
|
+
action.commit().catch((cause: unknown) => {
|
|
66
|
+
setError({ message: action.message, reason: errorReason(cause) });
|
|
67
|
+
});
|
|
68
|
+
}, []);
|
|
69
|
+
|
|
49
70
|
const commitPending = useCallback((): void => {
|
|
50
71
|
const action = actionRef.current;
|
|
51
72
|
clearTimer();
|
|
@@ -53,9 +74,9 @@ export const useConsoleActionQueue = (): ConsoleActionQueue => {
|
|
|
53
74
|
setPending(null);
|
|
54
75
|
if (action !== null && !committedRef.current) {
|
|
55
76
|
committedRef.current = true;
|
|
56
|
-
action
|
|
77
|
+
runCommit(action);
|
|
57
78
|
}
|
|
58
|
-
}, [clearTimer]);
|
|
79
|
+
}, [clearTimer, runCommit]);
|
|
59
80
|
|
|
60
81
|
const undo = useCallback((): void => {
|
|
61
82
|
clearTimer();
|
|
@@ -64,13 +85,17 @@ export const useConsoleActionQueue = (): ConsoleActionQueue => {
|
|
|
64
85
|
setPending(null);
|
|
65
86
|
}, [clearTimer]);
|
|
66
87
|
|
|
88
|
+
const dismissError = useCallback((): void => {
|
|
89
|
+
setError(null);
|
|
90
|
+
}, []);
|
|
91
|
+
|
|
67
92
|
const enqueue = useCallback(
|
|
68
93
|
(action: ConsoleQueuedAction): void => {
|
|
69
94
|
if (actionRef.current !== null && !committedRef.current) {
|
|
70
95
|
const previous = actionRef.current;
|
|
71
96
|
clearTimer();
|
|
72
97
|
committedRef.current = true;
|
|
73
|
-
previous
|
|
98
|
+
runCommit(previous);
|
|
74
99
|
}
|
|
75
100
|
committedRef.current = false;
|
|
76
101
|
actionRef.current = action;
|
|
@@ -96,10 +121,10 @@ export const useConsoleActionQueue = (): ConsoleActionQueue => {
|
|
|
96
121
|
});
|
|
97
122
|
}, COUNTDOWN_TICK_MS);
|
|
98
123
|
},
|
|
99
|
-
[clearTimer, commitPending],
|
|
124
|
+
[clearTimer, commitPending, runCommit],
|
|
100
125
|
);
|
|
101
126
|
|
|
102
127
|
useEffect(() => clearTimer, [clearTimer]);
|
|
103
128
|
|
|
104
|
-
return { pending, enqueue, undo };
|
|
129
|
+
return { pending, error, enqueue, undo, dismissError };
|
|
105
130
|
};
|
|
@@ -8,6 +8,17 @@ const mockFetchOnce = (body: unknown, ok = true): jest.Mock => {
|
|
|
8
8
|
ok,
|
|
9
9
|
status: ok ? 200 : 500,
|
|
10
10
|
json: async () => body,
|
|
11
|
+
text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
|
|
12
|
+
});
|
|
13
|
+
global.fetch = fetchMock as unknown as typeof fetch;
|
|
14
|
+
return fetchMock;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const mockFetchFailureOnce = (status: number, rawBody: string): jest.Mock => {
|
|
18
|
+
const fetchMock = jest.fn().mockResolvedValue({
|
|
19
|
+
ok: false,
|
|
20
|
+
status,
|
|
21
|
+
text: async () => rawBody,
|
|
11
22
|
});
|
|
12
23
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
13
24
|
return fetchMock;
|
|
@@ -169,15 +180,31 @@ describe('postConsoleOperation', () => {
|
|
|
169
180
|
expect(init).toMatchObject({ method: 'POST' });
|
|
170
181
|
});
|
|
171
182
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
183
|
+
const failingReview = (): Promise<void> =>
|
|
184
|
+
postConsoleOperation(appendToken, '/api/review', {
|
|
185
|
+
pjcode: 'umino',
|
|
186
|
+
action: 'approve',
|
|
187
|
+
prUrl: 'https://github.com/o/r/pull/1',
|
|
188
|
+
projectItemId: 'PVTI_1',
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('throws the error reason from a JSON error body', async () => {
|
|
192
|
+
mockFetchFailureOnce(
|
|
193
|
+
502,
|
|
194
|
+
JSON.stringify({ error: 'Failed to approve PR: HTTP 422' }),
|
|
195
|
+
);
|
|
196
|
+
await expect(failingReview()).rejects.toThrow(
|
|
197
|
+
'Failed to approve PR: HTTP 422',
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('throws the raw body when the error body is not JSON', async () => {
|
|
202
|
+
mockFetchFailureOnce(500, 'Internal Server Error');
|
|
203
|
+
await expect(failingReview()).rejects.toThrow('Internal Server Error');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('falls back to the status code when the error body is empty', async () => {
|
|
207
|
+
mockFetchFailureOnce(500, '');
|
|
208
|
+
await expect(failingReview()).rejects.toThrow('HTTP 500');
|
|
182
209
|
});
|
|
183
210
|
});
|
|
@@ -171,6 +171,24 @@ export const createConsoleApiClient = (
|
|
|
171
171
|
parseState(await requestJson(appendToken, '/api/issuetitle', url)),
|
|
172
172
|
});
|
|
173
173
|
|
|
174
|
+
const readOperationErrorReason = async (
|
|
175
|
+
response: Response,
|
|
176
|
+
): Promise<string> => {
|
|
177
|
+
const raw = await response.text().catch(() => '');
|
|
178
|
+
if (raw.length === 0) {
|
|
179
|
+
return `HTTP ${response.status}`;
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
const parsed: unknown = JSON.parse(raw);
|
|
183
|
+
if (isRecord(parsed) && typeof parsed.error === 'string') {
|
|
184
|
+
return parsed.error;
|
|
185
|
+
}
|
|
186
|
+
} catch {
|
|
187
|
+
return raw;
|
|
188
|
+
}
|
|
189
|
+
return raw;
|
|
190
|
+
};
|
|
191
|
+
|
|
174
192
|
export const postConsoleOperation = async (
|
|
175
193
|
appendToken: AppendToken,
|
|
176
194
|
apiPath: string,
|
|
@@ -182,7 +200,7 @@ export const postConsoleOperation = async (
|
|
|
182
200
|
body: JSON.stringify(body),
|
|
183
201
|
});
|
|
184
202
|
if (!response.ok) {
|
|
185
|
-
throw new Error(
|
|
203
|
+
throw new Error(await readOperationErrorReason(response));
|
|
186
204
|
}
|
|
187
205
|
};
|
|
188
206
|
|
|
@@ -44,6 +44,16 @@ describe('renderMarkdownToSafeHtml', () => {
|
|
|
44
44
|
it('returns an empty string for blank input', () => {
|
|
45
45
|
expect(renderMarkdownToSafeHtml(' ')).toBe('');
|
|
46
46
|
});
|
|
47
|
+
|
|
48
|
+
it('renders GitHub emoji shortcodes as Unicode emoji glyphs', () => {
|
|
49
|
+
const html = renderMarkdownToSafeHtml(':magic_wand: :sparkles: :rocket:');
|
|
50
|
+
expect(html).toContain('🪄');
|
|
51
|
+
expect(html).toContain('✨');
|
|
52
|
+
expect(html).toContain('🚀');
|
|
53
|
+
expect(html).not.toContain(':magic_wand:');
|
|
54
|
+
expect(html).not.toContain(':sparkles:');
|
|
55
|
+
expect(html).not.toContain(':rocket:');
|
|
56
|
+
});
|
|
47
57
|
});
|
|
48
58
|
|
|
49
59
|
describe('splitMarkdownSegments', () => {
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import DOMPurify from 'dompurify';
|
|
2
|
+
import { nameToEmoji } from 'gemoji';
|
|
2
3
|
import { marked } from 'marked';
|
|
4
|
+
import { markedEmoji } from 'marked-emoji';
|
|
5
|
+
|
|
6
|
+
marked.use(
|
|
7
|
+
markedEmoji({
|
|
8
|
+
emojis: nameToEmoji,
|
|
9
|
+
renderer: (token) => token.emoji,
|
|
10
|
+
}),
|
|
11
|
+
);
|
|
3
12
|
|
|
4
13
|
export const renderMarkdownToSafeHtml = (source: string): string => {
|
|
5
14
|
const trimmed = source.trim();
|
|
@@ -22,7 +22,7 @@ import type {
|
|
|
22
22
|
export type ConsoleQueueActionInput = {
|
|
23
23
|
kind: ConsoleActionKind;
|
|
24
24
|
item: ConsoleListItem;
|
|
25
|
-
commit: () => void
|
|
25
|
+
commit: () => Promise<void>;
|
|
26
26
|
};
|
|
27
27
|
|
|
28
28
|
export type ConsoleItemDetailContainerProps = {
|
|
@@ -68,54 +68,42 @@ export const ConsoleItemDetailContainer = ({
|
|
|
68
68
|
onQueueAction({
|
|
69
69
|
kind: { type: 'review', action },
|
|
70
70
|
item,
|
|
71
|
-
commit: () =>
|
|
72
|
-
void operations.reviewPullRequest(item, prUrl, action);
|
|
73
|
-
},
|
|
71
|
+
commit: () => operations.reviewPullRequest(item, prUrl, action),
|
|
74
72
|
});
|
|
75
73
|
},
|
|
76
74
|
onSetNextActionDate: (action) => {
|
|
77
75
|
onQueueAction({
|
|
78
76
|
kind: { type: 'next_action_date', action },
|
|
79
77
|
item,
|
|
80
|
-
commit: () =>
|
|
81
|
-
void operations.setNextActionDate(item, action);
|
|
82
|
-
},
|
|
78
|
+
commit: () => operations.setNextActionDate(item, action),
|
|
83
79
|
});
|
|
84
80
|
},
|
|
85
81
|
onSetStory: (option: ConsoleFieldOption) => {
|
|
86
82
|
onQueueAction({
|
|
87
83
|
kind: { type: 'set_story', optionName: option.name },
|
|
88
84
|
item,
|
|
89
|
-
commit: () =>
|
|
90
|
-
void operations.setStory(item, option);
|
|
91
|
-
},
|
|
85
|
+
commit: () => operations.setStory(item, option),
|
|
92
86
|
});
|
|
93
87
|
},
|
|
94
88
|
onSetStatus: (option: ConsoleFieldOption) => {
|
|
95
89
|
onQueueAction({
|
|
96
90
|
kind: { type: 'set_status', optionName: option.name },
|
|
97
91
|
item,
|
|
98
|
-
commit: () =>
|
|
99
|
-
void operations.setStatus(item, option);
|
|
100
|
-
},
|
|
92
|
+
commit: () => operations.setStatus(item, option),
|
|
101
93
|
});
|
|
102
94
|
},
|
|
103
95
|
onSetInTmuxByHuman: (option: ConsoleFieldOption) => {
|
|
104
96
|
onQueueAction({
|
|
105
97
|
kind: { type: 'set_in_tmux_by_human', optionName: option.name },
|
|
106
98
|
item,
|
|
107
|
-
commit: () =>
|
|
108
|
-
void operations.setInTmuxByHuman(item, option);
|
|
109
|
-
},
|
|
99
|
+
commit: () => operations.setInTmuxByHuman(item, option),
|
|
110
100
|
});
|
|
111
101
|
},
|
|
112
102
|
onClose: (action) => {
|
|
113
103
|
onQueueAction({
|
|
114
104
|
kind: { type: 'close', action },
|
|
115
105
|
item,
|
|
116
|
-
commit: () =>
|
|
117
|
-
void operations.closeIssue(item, action);
|
|
118
|
-
},
|
|
106
|
+
commit: () => operations.closeIssue(item, action),
|
|
119
107
|
});
|
|
120
108
|
},
|
|
121
109
|
};
|