github-issue-tower-defence-management 1.143.6 → 1.144.1
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/cli/index.js +2 -0
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/console/consoleItemUrlLookup.js +88 -0
- package/bin/adapter/entry-points/console/consoleItemUrlLookup.js.map +1 -0
- package/bin/adapter/entry-points/console/consoleOperationApi.js +44 -1
- package/bin/adapter/entry-points/console/consoleOperationApi.js.map +1 -1
- package/bin/adapter/entry-points/console/ui-dist/assets/index-BYoG8oQb.js +82 -0
- package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
- package/bin/adapter/entry-points/console/webServer.js +3 -0
- package/bin/adapter/entry-points/console/webServer.js.map +1 -1
- package/bin/adapter/repositories/LocalCommandIssueAttachmentRepository.js +75 -0
- package/bin/adapter/repositories/LocalCommandIssueAttachmentRepository.js.map +1 -0
- package/bin/domain/usecases/adapter-interfaces/IssueAttachmentRepository.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/IssueAttachmentRepository.js.map +1 -0
- package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js +26 -3
- package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/entry-points/cli/index.ts +4 -0
- package/src/adapter/entry-points/console/consoleItemUrlLookup.test.ts +63 -0
- package/src/adapter/entry-points/console/consoleItemUrlLookup.ts +66 -0
- package/src/adapter/entry-points/console/consoleOperationApi.test.ts +155 -0
- package/src/adapter/entry-points/console/consoleOperationApi.ts +56 -0
- package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +3 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleCommentComposer.stories.tsx +17 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleCommentComposer.test.tsx +149 -1
- package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleCommentComposer.tsx +107 -1
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleDetailPrefetch.test.ts +1 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleItemDetailData.test.ts +2 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleOperations.ts +22 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleTabData.test.ts +39 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleTabData.ts +14 -1
- package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.ts +35 -0
- package/src/adapter/entry-points/console/ui/src/features/console/logic/grouping.test.ts +1 -0
- package/src/adapter/entry-points/console/ui/src/features/console/logic/overlay.test.ts +1 -0
- package/src/adapter/entry-points/console/ui/src/features/console/logic/types.ts +1 -0
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.test.tsx +43 -1
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.tsx +8 -2
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +7 -0
- package/src/adapter/entry-points/console/ui/src/features/console/testing/fixtures.ts +5 -0
- package/src/adapter/entry-points/console/ui-dist/assets/index-BYoG8oQb.js +82 -0
- package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
- package/src/adapter/entry-points/console/webServer.ts +6 -0
- package/src/adapter/repositories/LocalCommandIssueAttachmentRepository.test.ts +136 -0
- package/src/adapter/repositories/LocalCommandIssueAttachmentRepository.ts +96 -0
- package/src/domain/usecases/adapter-interfaces/IssueAttachmentRepository.ts +9 -0
- package/src/domain/usecases/console/GenerateConsoleListsUseCase.test.ts +62 -0
- package/src/domain/usecases/console/GenerateConsoleListsUseCase.ts +45 -3
- package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
- package/types/adapter/entry-points/console/consoleItemUrlLookup.d.ts +2 -0
- package/types/adapter/entry-points/console/consoleItemUrlLookup.d.ts.map +1 -0
- package/types/adapter/entry-points/console/consoleOperationApi.d.ts +3 -0
- package/types/adapter/entry-points/console/consoleOperationApi.d.ts.map +1 -1
- package/types/adapter/entry-points/console/webServer.d.ts +2 -0
- package/types/adapter/entry-points/console/webServer.d.ts.map +1 -1
- package/types/adapter/repositories/LocalCommandIssueAttachmentRepository.d.ts +15 -0
- package/types/adapter/repositories/LocalCommandIssueAttachmentRepository.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/IssueAttachmentRepository.d.ts +9 -0
- package/types/domain/usecases/adapter-interfaces/IssueAttachmentRepository.d.ts.map +1 -0
- package/types/domain/usecases/console/GenerateConsoleListsUseCase.d.ts +2 -0
- package/types/domain/usecases/console/GenerateConsoleListsUseCase.d.ts.map +1 -1
- package/bin/adapter/entry-points/console/ui-dist/assets/index-DDQKZmBM.js +0 -81
- package/src/adapter/entry-points/console/ui-dist/assets/index-DDQKZmBM.js +0 -81
|
@@ -2,9 +2,11 @@ import {
|
|
|
2
2
|
IssueRepository,
|
|
3
3
|
PullRequestReviewCommentSide,
|
|
4
4
|
} from '../../../domain/usecases/adapter-interfaces/IssueRepository';
|
|
5
|
+
import { IssueAttachmentRepository } from '../../../domain/usecases/adapter-interfaces/IssueAttachmentRepository';
|
|
5
6
|
import { Project } from '../../../domain/entities/Project';
|
|
6
7
|
import { Issue } from '../../../domain/entities/Issue';
|
|
7
8
|
import { recordDoneProjectItemIdAcrossTabs } from './consoleDoneStore';
|
|
9
|
+
import { findConsoleItemUrl } from './consoleItemUrlLookup';
|
|
8
10
|
|
|
9
11
|
export const AWAITING_WORKSPACE_STATUS_NAME = 'awaiting workspace';
|
|
10
12
|
export const IN_TMUX_BY_HUMAN_STATUS_NAME = 'in tmux by human';
|
|
@@ -25,6 +27,7 @@ export type ConsoleOperationContext = {
|
|
|
25
27
|
resolveProject: ConsoleProjectResolver;
|
|
26
28
|
isPjcodeConfigured: ConsolePjcodeValidator;
|
|
27
29
|
consoleDataOutputDir: string | null;
|
|
30
|
+
issueAttachmentRepository: IssueAttachmentRepository | null;
|
|
28
31
|
};
|
|
29
32
|
|
|
30
33
|
export type ConsoleOperationResponse = {
|
|
@@ -53,6 +56,11 @@ const isNonEmptyString = (value: unknown): value is string =>
|
|
|
53
56
|
const isPullRequestUrl = (url: string): boolean =>
|
|
54
57
|
/github\.com\/[^/]+\/[^/]+\/pull\/\d+/.test(url);
|
|
55
58
|
|
|
59
|
+
const isValidIssueOrPullRequestUrl = (url: string): boolean =>
|
|
60
|
+
/^https:\/\/github\.com\/[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+\/(issues|pull)\/\d+$/.test(
|
|
61
|
+
url,
|
|
62
|
+
);
|
|
63
|
+
|
|
56
64
|
const isReviewCommentSide = (
|
|
57
65
|
value: unknown,
|
|
58
66
|
): value is PullRequestReviewCommentSide =>
|
|
@@ -399,6 +407,54 @@ export const handleComment = async (
|
|
|
399
407
|
};
|
|
400
408
|
};
|
|
401
409
|
|
|
410
|
+
export const handleAttachmentUpload = async (
|
|
411
|
+
context: ConsoleOperationContext,
|
|
412
|
+
body: Record<string, unknown>,
|
|
413
|
+
): Promise<ConsoleOperationResponse> => {
|
|
414
|
+
const url = body.url;
|
|
415
|
+
const fileName = body.fileName;
|
|
416
|
+
const contentBase64 = body.contentBase64;
|
|
417
|
+
if (!isNonEmptyString(url)) {
|
|
418
|
+
return badRequest('url is required');
|
|
419
|
+
}
|
|
420
|
+
if (!isValidIssueOrPullRequestUrl(url)) {
|
|
421
|
+
return badRequest('url must be a github issue or pull request url');
|
|
422
|
+
}
|
|
423
|
+
if (!isNonEmptyString(fileName)) {
|
|
424
|
+
return badRequest('fileName is required');
|
|
425
|
+
}
|
|
426
|
+
if (!isNonEmptyString(contentBase64)) {
|
|
427
|
+
return badRequest('contentBase64 is required');
|
|
428
|
+
}
|
|
429
|
+
const pjcodeResult = resolveConfiguredPjcode(context, body);
|
|
430
|
+
if (typeof pjcodeResult !== 'string') {
|
|
431
|
+
return pjcodeResult;
|
|
432
|
+
}
|
|
433
|
+
if (context.consoleDataOutputDir === null) {
|
|
434
|
+
return badGateway('console data output dir is not configured');
|
|
435
|
+
}
|
|
436
|
+
const knownUrl = findConsoleItemUrl(
|
|
437
|
+
context.consoleDataOutputDir,
|
|
438
|
+
pjcodeResult,
|
|
439
|
+
url,
|
|
440
|
+
);
|
|
441
|
+
if (knownUrl === null) {
|
|
442
|
+
return badRequest('url is not a console item of this project');
|
|
443
|
+
}
|
|
444
|
+
if (context.issueAttachmentRepository === null) {
|
|
445
|
+
return badGateway('attachment upload is not configured');
|
|
446
|
+
}
|
|
447
|
+
const markdown = await context.issueAttachmentRepository.uploadAttachment({
|
|
448
|
+
issueOrPullRequestUrl: knownUrl,
|
|
449
|
+
fileName,
|
|
450
|
+
content: Buffer.from(contentBase64, 'base64'),
|
|
451
|
+
});
|
|
452
|
+
return {
|
|
453
|
+
statusCode: 200,
|
|
454
|
+
body: { ok: true, markdown },
|
|
455
|
+
};
|
|
456
|
+
};
|
|
457
|
+
|
|
402
458
|
export const handleReviewComment = async (
|
|
403
459
|
context: ConsoleOperationContext,
|
|
404
460
|
body: Record<string, unknown>,
|
|
@@ -53,6 +53,7 @@ type ConsoleFixtureListItem = {
|
|
|
53
53
|
dependedIssueUrls: string[];
|
|
54
54
|
labels: string[];
|
|
55
55
|
createdAt: string;
|
|
56
|
+
relatedOpenPullRequestUrls: string[];
|
|
56
57
|
};
|
|
57
58
|
|
|
58
59
|
type ConsoleFixtureFieldOption = {
|
|
@@ -144,6 +145,7 @@ const issueItem = (
|
|
|
144
145
|
projectItemId: `PVTI_lADOABCD1234zg${projectItemSuffix}`,
|
|
145
146
|
itemId: `PVTI_lADOABCD1234zg${projectItemSuffix}`,
|
|
146
147
|
isPr: false,
|
|
148
|
+
relatedOpenPullRequestUrls: [],
|
|
147
149
|
story,
|
|
148
150
|
status: null,
|
|
149
151
|
nextActionDate: null,
|
|
@@ -168,6 +170,7 @@ const pullRequestItem = (
|
|
|
168
170
|
projectItemId: `PVTI_lADOABCD1234zg${projectItemSuffix}`,
|
|
169
171
|
itemId: `PVTI_lADOABCD1234zg${projectItemSuffix}`,
|
|
170
172
|
isPr: true,
|
|
173
|
+
relatedOpenPullRequestUrls: [],
|
|
171
174
|
story,
|
|
172
175
|
status: null,
|
|
173
176
|
nextActionDate: null,
|
|
@@ -28,3 +28,20 @@ export const IssueComposerOpen: Story = {
|
|
|
28
28
|
export const PullRequestComposerCollapsed: Story = {
|
|
29
29
|
args: { isPr: true },
|
|
30
30
|
};
|
|
31
|
+
|
|
32
|
+
export const IssueComposerWithFileUpload: Story = {
|
|
33
|
+
args: {
|
|
34
|
+
isPr: false,
|
|
35
|
+
onUploadFile: async (file: File) =>
|
|
36
|
+
``,
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const IssueComposerUploadFailure: Story = {
|
|
41
|
+
args: {
|
|
42
|
+
isPr: false,
|
|
43
|
+
onUploadFile: async () => {
|
|
44
|
+
throw new Error('No GitHub web session is available');
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
};
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { fireEvent, render, waitFor } from '@testing-library/react';
|
|
2
2
|
import type { ConsoleComment } from '../../logic/types';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
appendAttachmentMarkdown,
|
|
5
|
+
ConsoleCommentComposer,
|
|
6
|
+
} from './ConsoleCommentComposer';
|
|
4
7
|
|
|
5
8
|
jest.mock('../../lib/mermaidLoader', () => ({
|
|
6
9
|
renderMermaidToSvg: jest.fn(async () => '<svg></svg>'),
|
|
@@ -78,4 +81,149 @@ describe('ConsoleCommentComposer', () => {
|
|
|
78
81
|
const alert = await findByRole('alert');
|
|
79
82
|
expect(alert.textContent).toContain('HTTP 500');
|
|
80
83
|
});
|
|
84
|
+
|
|
85
|
+
it('does not render the attach control when no upload handler is given', () => {
|
|
86
|
+
const { queryByText } = render(
|
|
87
|
+
<ConsoleCommentComposer
|
|
88
|
+
isPr={false}
|
|
89
|
+
now={now}
|
|
90
|
+
onSubmit={async (body) => ({
|
|
91
|
+
author: 'HiromiShikata',
|
|
92
|
+
body,
|
|
93
|
+
createdAt: '2026-06-19T11:58:00.000Z',
|
|
94
|
+
})}
|
|
95
|
+
/>,
|
|
96
|
+
);
|
|
97
|
+
expect(queryByText('📎 Attach files')).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('uploads a selected file and inserts the returned markdown into the draft', async () => {
|
|
101
|
+
const onUploadFile = jest.fn(
|
|
102
|
+
async () => '',
|
|
103
|
+
);
|
|
104
|
+
const { getByPlaceholderText, getByLabelText } = render(
|
|
105
|
+
<ConsoleCommentComposer
|
|
106
|
+
isPr={false}
|
|
107
|
+
now={now}
|
|
108
|
+
onSubmit={async (body) => ({
|
|
109
|
+
author: 'HiromiShikata',
|
|
110
|
+
body,
|
|
111
|
+
createdAt: '2026-06-19T11:58:00.000Z',
|
|
112
|
+
})}
|
|
113
|
+
onUploadFile={onUploadFile}
|
|
114
|
+
/>,
|
|
115
|
+
);
|
|
116
|
+
const textarea = getByPlaceholderText(
|
|
117
|
+
'Leave a comment…',
|
|
118
|
+
) as HTMLTextAreaElement;
|
|
119
|
+
fireEvent.change(textarea, { target: { value: 'See the screen:' } });
|
|
120
|
+
const file = new File(['binary'], 'shot.png', { type: 'image/png' });
|
|
121
|
+
fireEvent.change(getByLabelText('Attach files'), {
|
|
122
|
+
target: { files: [file] },
|
|
123
|
+
});
|
|
124
|
+
await waitFor(() => {
|
|
125
|
+
expect(textarea.value).toBe(
|
|
126
|
+
'See the screen:\n\n',
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
expect(onUploadFile).toHaveBeenCalledWith(file);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('uploads a pasted file', async () => {
|
|
133
|
+
const onUploadFile = jest.fn(
|
|
134
|
+
async () => '',
|
|
135
|
+
);
|
|
136
|
+
const { getByPlaceholderText } = render(
|
|
137
|
+
<ConsoleCommentComposer
|
|
138
|
+
isPr={false}
|
|
139
|
+
now={now}
|
|
140
|
+
onSubmit={async (body) => ({
|
|
141
|
+
author: 'HiromiShikata',
|
|
142
|
+
body,
|
|
143
|
+
createdAt: '2026-06-19T11:58:00.000Z',
|
|
144
|
+
})}
|
|
145
|
+
onUploadFile={onUploadFile}
|
|
146
|
+
/>,
|
|
147
|
+
);
|
|
148
|
+
const textarea = getByPlaceholderText(
|
|
149
|
+
'Leave a comment…',
|
|
150
|
+
) as HTMLTextAreaElement;
|
|
151
|
+
const file = new File(['binary'], 'pasted.png', { type: 'image/png' });
|
|
152
|
+
fireEvent.paste(textarea, { clipboardData: { files: [file] } });
|
|
153
|
+
await waitFor(() => {
|
|
154
|
+
expect(textarea.value).toBe(
|
|
155
|
+
'\n',
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
expect(onUploadFile).toHaveBeenCalledWith(file);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('uploads a dropped file', async () => {
|
|
162
|
+
const onUploadFile = jest.fn(
|
|
163
|
+
async () => '',
|
|
164
|
+
);
|
|
165
|
+
const { getByPlaceholderText } = render(
|
|
166
|
+
<ConsoleCommentComposer
|
|
167
|
+
isPr={false}
|
|
168
|
+
now={now}
|
|
169
|
+
onSubmit={async (body) => ({
|
|
170
|
+
author: 'HiromiShikata',
|
|
171
|
+
body,
|
|
172
|
+
createdAt: '2026-06-19T11:58:00.000Z',
|
|
173
|
+
})}
|
|
174
|
+
onUploadFile={onUploadFile}
|
|
175
|
+
/>,
|
|
176
|
+
);
|
|
177
|
+
const textarea = getByPlaceholderText(
|
|
178
|
+
'Leave a comment…',
|
|
179
|
+
) as HTMLTextAreaElement;
|
|
180
|
+
const file = new File(['binary'], 'dropped.png', { type: 'image/png' });
|
|
181
|
+
fireEvent.drop(textarea, { dataTransfer: { files: [file] } });
|
|
182
|
+
await waitFor(() => {
|
|
183
|
+
expect(textarea.value).toBe(
|
|
184
|
+
'\n',
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
expect(onUploadFile).toHaveBeenCalledWith(file);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('shows the upload failure reason when the upload rejects', async () => {
|
|
191
|
+
const { getByLabelText, findByRole } = render(
|
|
192
|
+
<ConsoleCommentComposer
|
|
193
|
+
isPr={false}
|
|
194
|
+
now={now}
|
|
195
|
+
onSubmit={async (body) => ({
|
|
196
|
+
author: 'HiromiShikata',
|
|
197
|
+
body,
|
|
198
|
+
createdAt: '2026-06-19T11:58:00.000Z',
|
|
199
|
+
})}
|
|
200
|
+
onUploadFile={async () => {
|
|
201
|
+
throw new Error('No GitHub web session is available');
|
|
202
|
+
}}
|
|
203
|
+
/>,
|
|
204
|
+
);
|
|
205
|
+
fireEvent.change(getByLabelText('Attach files'), {
|
|
206
|
+
target: { files: [new File(['x'], 'shot.png', { type: 'image/png' })] },
|
|
207
|
+
});
|
|
208
|
+
const alert = await findByRole('alert');
|
|
209
|
+
expect(alert.textContent).toContain('No GitHub web session is available');
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe('appendAttachmentMarkdown', () => {
|
|
214
|
+
it('returns the markdown alone when the draft is empty', () => {
|
|
215
|
+
expect(appendAttachmentMarkdown('', '')).toBe('\n');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('adds a separating newline when the draft does not end with one', () => {
|
|
219
|
+
expect(appendAttachmentMarkdown('hello', '')).toBe(
|
|
220
|
+
'hello\n\n',
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('does not add a second newline when the draft already ends with one', () => {
|
|
225
|
+
expect(appendAttachmentMarkdown('hello\n', '')).toBe(
|
|
226
|
+
'hello\n\n',
|
|
227
|
+
);
|
|
228
|
+
});
|
|
81
229
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState } from 'react';
|
|
1
|
+
import { useRef, useState } from 'react';
|
|
2
2
|
import {
|
|
3
3
|
formatFullTimestamp,
|
|
4
4
|
formatRelativeTime,
|
|
@@ -10,6 +10,7 @@ export type ConsoleCommentComposerProps = {
|
|
|
10
10
|
isPr: boolean;
|
|
11
11
|
now: number;
|
|
12
12
|
onSubmit: (body: string) => Promise<ConsoleComment>;
|
|
13
|
+
onUploadFile?: (file: File) => Promise<string>;
|
|
13
14
|
};
|
|
14
15
|
|
|
15
16
|
type ComposerStatus =
|
|
@@ -17,15 +18,38 @@ type ComposerStatus =
|
|
|
17
18
|
| { kind: 'posting' }
|
|
18
19
|
| { kind: 'error'; message: string };
|
|
19
20
|
|
|
21
|
+
type UploadStatus =
|
|
22
|
+
| { kind: 'idle' }
|
|
23
|
+
| { kind: 'uploading'; fileName: string }
|
|
24
|
+
| { kind: 'error'; message: string };
|
|
25
|
+
|
|
26
|
+
export const appendAttachmentMarkdown = (
|
|
27
|
+
draft: string,
|
|
28
|
+
markdown: string,
|
|
29
|
+
): string => {
|
|
30
|
+
if (draft.length === 0) {
|
|
31
|
+
return `${markdown}\n`;
|
|
32
|
+
}
|
|
33
|
+
if (draft.endsWith('\n')) {
|
|
34
|
+
return `${draft}${markdown}\n`;
|
|
35
|
+
}
|
|
36
|
+
return `${draft}\n${markdown}\n`;
|
|
37
|
+
};
|
|
38
|
+
|
|
20
39
|
export const ConsoleCommentComposer = ({
|
|
21
40
|
isPr,
|
|
22
41
|
now,
|
|
23
42
|
onSubmit,
|
|
43
|
+
onUploadFile,
|
|
24
44
|
}: ConsoleCommentComposerProps) => {
|
|
25
45
|
const [open, setOpen] = useState<boolean>(!isPr);
|
|
26
46
|
const [draft, setDraft] = useState<string>('');
|
|
27
47
|
const [status, setStatus] = useState<ComposerStatus>({ kind: 'idle' });
|
|
48
|
+
const [uploadStatus, setUploadStatus] = useState<UploadStatus>({
|
|
49
|
+
kind: 'idle',
|
|
50
|
+
});
|
|
28
51
|
const [posted, setPosted] = useState<ConsoleComment[]>([]);
|
|
52
|
+
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
29
53
|
|
|
30
54
|
const submit = async (): Promise<void> => {
|
|
31
55
|
const body = draft.trim();
|
|
@@ -46,6 +70,27 @@ export const ConsoleCommentComposer = ({
|
|
|
46
70
|
}
|
|
47
71
|
};
|
|
48
72
|
|
|
73
|
+
const uploadFiles = async (files: File[]): Promise<void> => {
|
|
74
|
+
if (onUploadFile === undefined || files.length === 0) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
for (const file of files) {
|
|
78
|
+
setUploadStatus({ kind: 'uploading', fileName: file.name });
|
|
79
|
+
try {
|
|
80
|
+
const markdown = await onUploadFile(file);
|
|
81
|
+
setDraft((previous) => appendAttachmentMarkdown(previous, markdown));
|
|
82
|
+
setUploadStatus({ kind: 'idle' });
|
|
83
|
+
} catch (error) {
|
|
84
|
+
setUploadStatus({
|
|
85
|
+
kind: 'error',
|
|
86
|
+
message:
|
|
87
|
+
error instanceof Error ? error.message : 'failed to upload file',
|
|
88
|
+
});
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
49
94
|
return (
|
|
50
95
|
<div className="console-composer">
|
|
51
96
|
<button
|
|
@@ -87,7 +132,68 @@ export const ConsoleCommentComposer = ({
|
|
|
87
132
|
placeholder="Leave a comment…"
|
|
88
133
|
value={draft}
|
|
89
134
|
onChange={(event) => setDraft(event.target.value)}
|
|
135
|
+
onDragOver={(event) => {
|
|
136
|
+
if (onUploadFile === undefined) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
event.preventDefault();
|
|
140
|
+
}}
|
|
141
|
+
onDrop={(event) => {
|
|
142
|
+
if (onUploadFile === undefined) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
event.preventDefault();
|
|
146
|
+
void uploadFiles(Array.from(event.dataTransfer.files));
|
|
147
|
+
}}
|
|
148
|
+
onPaste={(event) => {
|
|
149
|
+
if (onUploadFile === undefined) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const files = Array.from(event.clipboardData.files);
|
|
153
|
+
if (files.length === 0) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
event.preventDefault();
|
|
157
|
+
void uploadFiles(files);
|
|
158
|
+
}}
|
|
90
159
|
/>
|
|
160
|
+
{onUploadFile !== undefined && (
|
|
161
|
+
<div className="console-composer-attach">
|
|
162
|
+
<input
|
|
163
|
+
ref={fileInputRef}
|
|
164
|
+
type="file"
|
|
165
|
+
multiple
|
|
166
|
+
className="console-composer-file-input"
|
|
167
|
+
aria-label="Attach files"
|
|
168
|
+
onChange={(event) => {
|
|
169
|
+
const files = Array.from(event.target.files ?? []);
|
|
170
|
+
event.target.value = '';
|
|
171
|
+
void uploadFiles(files);
|
|
172
|
+
}}
|
|
173
|
+
/>
|
|
174
|
+
<button
|
|
175
|
+
type="button"
|
|
176
|
+
className="console-composer-attach-button"
|
|
177
|
+
disabled={uploadStatus.kind === 'uploading'}
|
|
178
|
+
onClick={() => fileInputRef.current?.click()}
|
|
179
|
+
>
|
|
180
|
+
📎 Attach files
|
|
181
|
+
</button>
|
|
182
|
+
{uploadStatus.kind === 'uploading' && (
|
|
183
|
+
<span className="console-composer-status">
|
|
184
|
+
Uploading {uploadStatus.fileName}…
|
|
185
|
+
</span>
|
|
186
|
+
)}
|
|
187
|
+
{uploadStatus.kind === 'error' && (
|
|
188
|
+
<span
|
|
189
|
+
role="alert"
|
|
190
|
+
className="console-composer-status console-composer-error"
|
|
191
|
+
>
|
|
192
|
+
Upload failed: {uploadStatus.message}
|
|
193
|
+
</span>
|
|
194
|
+
)}
|
|
195
|
+
</div>
|
|
196
|
+
)}
|
|
91
197
|
<div className="console-composer-row">
|
|
92
198
|
{status.kind === 'posting' && (
|
|
93
199
|
<span className="console-composer-status">Posting…</span>
|
|
@@ -21,6 +21,7 @@ const prItem: ConsoleListItem = {
|
|
|
21
21
|
projectItemId: 'PVTI_1',
|
|
22
22
|
itemId: 'PVTI_1',
|
|
23
23
|
isPr: true,
|
|
24
|
+
relatedOpenPullRequestUrls: [],
|
|
24
25
|
story: 'Story',
|
|
25
26
|
status: null,
|
|
26
27
|
nextActionDate: null,
|
|
@@ -33,6 +34,7 @@ const prItem: ConsoleListItem = {
|
|
|
33
34
|
const issueItem: ConsoleListItem = {
|
|
34
35
|
...prItem,
|
|
35
36
|
isPr: false,
|
|
37
|
+
relatedOpenPullRequestUrls: [],
|
|
36
38
|
url: 'https://github.com/o/r/issues/2',
|
|
37
39
|
number: 2,
|
|
38
40
|
};
|
package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleOperations.ts
CHANGED
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
type ConsoleReviewCommentSide,
|
|
5
5
|
type ConsoleReviewRequest,
|
|
6
6
|
type ConsoleTriageRequest,
|
|
7
|
+
encodeAttachmentContent,
|
|
8
|
+
postConsoleAttachment,
|
|
7
9
|
postConsoleComment,
|
|
8
10
|
postConsoleOperation,
|
|
9
11
|
postConsoleReviewComment,
|
|
@@ -59,6 +61,7 @@ export type ConsoleOperationsApi = {
|
|
|
59
61
|
action: ConsoleCloseAction,
|
|
60
62
|
) => Promise<void>;
|
|
61
63
|
addComment: (item: ConsoleListItem, body: string) => Promise<ConsoleComment>;
|
|
64
|
+
uploadAttachment: (item: ConsoleListItem, file: File) => Promise<string>;
|
|
62
65
|
addInlineReviewComment: (
|
|
63
66
|
prUrl: string,
|
|
64
67
|
path: string,
|
|
@@ -286,6 +289,24 @@ export const useConsoleOperations = (
|
|
|
286
289
|
[pjcode, invalidateItemContent],
|
|
287
290
|
);
|
|
288
291
|
|
|
292
|
+
const uploadAttachment = useCallback(
|
|
293
|
+
async (item: ConsoleListItem, file: File) => {
|
|
294
|
+
if (pjcode === null) {
|
|
295
|
+
throw new Error('no project in the URL path');
|
|
296
|
+
}
|
|
297
|
+
const contentBase64 = encodeAttachmentContent(
|
|
298
|
+
new Uint8Array(await file.arrayBuffer()),
|
|
299
|
+
);
|
|
300
|
+
return postConsoleAttachment({
|
|
301
|
+
pjcode,
|
|
302
|
+
url: item.url,
|
|
303
|
+
fileName: file.name,
|
|
304
|
+
contentBase64,
|
|
305
|
+
});
|
|
306
|
+
},
|
|
307
|
+
[pjcode],
|
|
308
|
+
);
|
|
309
|
+
|
|
289
310
|
const addInlineReviewComment = useCallback(
|
|
290
311
|
async (
|
|
291
312
|
prUrl: string,
|
|
@@ -317,6 +338,7 @@ export const useConsoleOperations = (
|
|
|
317
338
|
setInTmuxByHuman,
|
|
318
339
|
closeIssue,
|
|
319
340
|
addComment,
|
|
341
|
+
uploadAttachment,
|
|
320
342
|
addInlineReviewComment,
|
|
321
343
|
};
|
|
322
344
|
};
|
package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleTabData.test.ts
CHANGED
|
@@ -41,6 +41,45 @@ describe('useConsoleTabData', () => {
|
|
|
41
41
|
);
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
+
it('normalizes relatedOpenPullRequestUrls for a snapshot written before the field existed', async () => {
|
|
45
|
+
const fetchMock = jest.fn(async (url: string) => ({
|
|
46
|
+
ok: true,
|
|
47
|
+
status: 200,
|
|
48
|
+
json: async () => ({
|
|
49
|
+
pjcode: 'umino',
|
|
50
|
+
generatedAt: '2026-06-19T00:00:00.000Z',
|
|
51
|
+
statusOptions: [],
|
|
52
|
+
storyColors: {},
|
|
53
|
+
items: url.includes('/prs/')
|
|
54
|
+
? [
|
|
55
|
+
{ number: 1, itemId: 'PVTI_1', projectItemId: 'PVTI_1' },
|
|
56
|
+
{
|
|
57
|
+
number: 2,
|
|
58
|
+
itemId: 'PVTI_2',
|
|
59
|
+
projectItemId: 'PVTI_2',
|
|
60
|
+
relatedOpenPullRequestUrls: [
|
|
61
|
+
'https://github.com/demo/repo/pull/7',
|
|
62
|
+
9,
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
]
|
|
66
|
+
: [],
|
|
67
|
+
}),
|
|
68
|
+
}));
|
|
69
|
+
global.fetch = fetchMock as unknown as typeof fetch;
|
|
70
|
+
|
|
71
|
+
const { result } = renderHook(() => useConsoleTabData('umino'));
|
|
72
|
+
await waitFor(() => {
|
|
73
|
+
expect(result.current.isLoading).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
expect(
|
|
76
|
+
result.current.snapshots.prs?.items[0].relatedOpenPullRequestUrls,
|
|
77
|
+
).toEqual([]);
|
|
78
|
+
expect(
|
|
79
|
+
result.current.snapshots.prs?.items[1].relatedOpenPullRequestUrls,
|
|
80
|
+
).toEqual(['https://github.com/demo/repo/pull/7']);
|
|
81
|
+
});
|
|
82
|
+
|
|
44
83
|
it('drops an item that disappeared from the snapshot after the refresh interval', async () => {
|
|
45
84
|
jest.useFakeTimers();
|
|
46
85
|
let closed = false;
|
|
@@ -24,11 +24,24 @@ export type ConsoleTabDataState = {
|
|
|
24
24
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
25
25
|
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
26
26
|
|
|
27
|
+
const parseStringArray = (value: unknown): string[] =>
|
|
28
|
+
Array.isArray(value)
|
|
29
|
+
? value.filter((entry): entry is string => typeof entry === 'string')
|
|
30
|
+
: [];
|
|
31
|
+
|
|
27
32
|
const parseItems = (payload: unknown): ConsoleListItem[] => {
|
|
28
33
|
if (!isRecord(payload) || !Array.isArray(payload.items)) {
|
|
29
34
|
return [];
|
|
30
35
|
}
|
|
31
|
-
return payload.items.filter(isRecord)
|
|
36
|
+
return payload.items.filter(isRecord).map(
|
|
37
|
+
(item) =>
|
|
38
|
+
({
|
|
39
|
+
...item,
|
|
40
|
+
relatedOpenPullRequestUrls: parseStringArray(
|
|
41
|
+
item.relatedOpenPullRequestUrls,
|
|
42
|
+
),
|
|
43
|
+
}) as unknown as ConsoleListItem,
|
|
44
|
+
);
|
|
32
45
|
};
|
|
33
46
|
|
|
34
47
|
const parseOptions = (value: unknown): ConsoleFieldOption[] => {
|
|
@@ -288,6 +288,41 @@ export const postConsoleComment = async (
|
|
|
288
288
|
return parsePostedComment(await response.json());
|
|
289
289
|
};
|
|
290
290
|
|
|
291
|
+
export const ATTACHMENT_UPLOAD_OPERATION_PATH = '/api/upload';
|
|
292
|
+
|
|
293
|
+
export type ConsoleAttachmentUploadRequest = {
|
|
294
|
+
pjcode: string;
|
|
295
|
+
url: string;
|
|
296
|
+
fileName: string;
|
|
297
|
+
contentBase64: string;
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
export const encodeAttachmentContent = (bytes: Uint8Array): string => {
|
|
301
|
+
let binary = '';
|
|
302
|
+
for (const byte of bytes) {
|
|
303
|
+
binary += String.fromCharCode(byte);
|
|
304
|
+
}
|
|
305
|
+
return btoa(binary);
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
export const postConsoleAttachment = async (
|
|
309
|
+
request: ConsoleAttachmentUploadRequest,
|
|
310
|
+
): Promise<string> => {
|
|
311
|
+
const response = await fetch(ATTACHMENT_UPLOAD_OPERATION_PATH, {
|
|
312
|
+
method: 'POST',
|
|
313
|
+
headers: { 'Content-Type': 'application/json' },
|
|
314
|
+
body: JSON.stringify(request),
|
|
315
|
+
});
|
|
316
|
+
if (!response.ok) {
|
|
317
|
+
throw new Error(await readOperationErrorReason(response));
|
|
318
|
+
}
|
|
319
|
+
const payload: unknown = await response.json();
|
|
320
|
+
if (!isRecord(payload) || typeof payload.markdown !== 'string') {
|
|
321
|
+
throw new Error('markdown was not returned');
|
|
322
|
+
}
|
|
323
|
+
return payload.markdown;
|
|
324
|
+
};
|
|
325
|
+
|
|
291
326
|
export const REVIEW_COMMENT_OPERATION_PATH = '/api/reviewcomment';
|
|
292
327
|
|
|
293
328
|
export const postConsoleReviewComment = async (
|