github-issue-tower-defence-management 1.143.5 → 1.144.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/bin/adapter/entry-points/cli/index.js +2 -0
  3. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  4. package/bin/adapter/entry-points/console/consoleItemUrlLookup.js +88 -0
  5. package/bin/adapter/entry-points/console/consoleItemUrlLookup.js.map +1 -0
  6. package/bin/adapter/entry-points/console/consoleOperationApi.js +44 -1
  7. package/bin/adapter/entry-points/console/consoleOperationApi.js.map +1 -1
  8. package/bin/adapter/entry-points/console/ui-dist/assets/index-y6LyqXlu.js +82 -0
  9. package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
  10. package/bin/adapter/entry-points/console/webServer.js +3 -0
  11. package/bin/adapter/entry-points/console/webServer.js.map +1 -1
  12. package/bin/adapter/repositories/LocalCommandIssueAttachmentRepository.js +75 -0
  13. package/bin/adapter/repositories/LocalCommandIssueAttachmentRepository.js.map +1 -0
  14. package/bin/domain/usecases/adapter-interfaces/IssueAttachmentRepository.js +3 -0
  15. package/bin/domain/usecases/adapter-interfaces/IssueAttachmentRepository.js.map +1 -0
  16. package/package.json +1 -1
  17. package/src/adapter/entry-points/cli/index.ts +4 -0
  18. package/src/adapter/entry-points/console/consoleItemUrlLookup.test.ts +63 -0
  19. package/src/adapter/entry-points/console/consoleItemUrlLookup.ts +66 -0
  20. package/src/adapter/entry-points/console/consoleOperationApi.test.ts +155 -0
  21. package/src/adapter/entry-points/console/consoleOperationApi.ts +56 -0
  22. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleCommentComposer.stories.tsx +17 -0
  23. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleCommentComposer.test.tsx +149 -1
  24. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleCommentComposer.tsx +107 -1
  25. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleOperations.ts +22 -0
  26. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleTabData.test.ts +41 -2
  27. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleTabData.ts +44 -28
  28. package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.ts +35 -0
  29. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.test.tsx +1 -0
  30. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.tsx +1 -0
  31. package/src/adapter/entry-points/console/ui-dist/assets/index-y6LyqXlu.js +82 -0
  32. package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
  33. package/src/adapter/entry-points/console/webServer.ts +6 -0
  34. package/src/adapter/repositories/LocalCommandIssueAttachmentRepository.test.ts +136 -0
  35. package/src/adapter/repositories/LocalCommandIssueAttachmentRepository.ts +96 -0
  36. package/src/domain/usecases/adapter-interfaces/IssueAttachmentRepository.ts +9 -0
  37. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  38. package/types/adapter/entry-points/console/consoleItemUrlLookup.d.ts +2 -0
  39. package/types/adapter/entry-points/console/consoleItemUrlLookup.d.ts.map +1 -0
  40. package/types/adapter/entry-points/console/consoleOperationApi.d.ts +3 -0
  41. package/types/adapter/entry-points/console/consoleOperationApi.d.ts.map +1 -1
  42. package/types/adapter/entry-points/console/webServer.d.ts +2 -0
  43. package/types/adapter/entry-points/console/webServer.d.ts.map +1 -1
  44. package/types/adapter/repositories/LocalCommandIssueAttachmentRepository.d.ts +15 -0
  45. package/types/adapter/repositories/LocalCommandIssueAttachmentRepository.d.ts.map +1 -0
  46. package/types/domain/usecases/adapter-interfaces/IssueAttachmentRepository.d.ts +9 -0
  47. package/types/domain/usecases/adapter-interfaces/IssueAttachmentRepository.d.ts.map +1 -0
  48. package/bin/adapter/entry-points/console/ui-dist/assets/index-fTEmrrJ-.js +0 -81
  49. package/src/adapter/entry-points/console/ui-dist/assets/index-fTEmrrJ-.js +0 -81
@@ -1,6 +1,9 @@
1
1
  import { fireEvent, render, waitFor } from '@testing-library/react';
2
2
  import type { ConsoleComment } from '../../logic/types';
3
- import { ConsoleCommentComposer } from './ConsoleCommentComposer';
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 () => '![shot](https://github.com/user-attachments/assets/abc)',
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![shot](https://github.com/user-attachments/assets/abc)\n',
127
+ );
128
+ });
129
+ expect(onUploadFile).toHaveBeenCalledWith(file);
130
+ });
131
+
132
+ it('uploads a pasted file', async () => {
133
+ const onUploadFile = jest.fn(
134
+ async () => '![pasted](https://github.com/user-attachments/assets/def)',
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
+ '![pasted](https://github.com/user-attachments/assets/def)\n',
156
+ );
157
+ });
158
+ expect(onUploadFile).toHaveBeenCalledWith(file);
159
+ });
160
+
161
+ it('uploads a dropped file', async () => {
162
+ const onUploadFile = jest.fn(
163
+ async () => '![dropped](https://github.com/user-attachments/assets/ghi)',
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
+ '![dropped](https://github.com/user-attachments/assets/ghi)\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('', '![a](b)')).toBe('![a](b)\n');
216
+ });
217
+
218
+ it('adds a separating newline when the draft does not end with one', () => {
219
+ expect(appendAttachmentMarkdown('hello', '![a](b)')).toBe(
220
+ 'hello\n![a](b)\n',
221
+ );
222
+ });
223
+
224
+ it('does not add a second newline when the draft already ends with one', () => {
225
+ expect(appendAttachmentMarkdown('hello\n', '![a](b)')).toBe(
226
+ 'hello\n![a](b)\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>
@@ -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
  };
@@ -1,6 +1,9 @@
1
- import { renderHook, waitFor } from '@testing-library/react';
1
+ import { act, renderHook, waitFor } from '@testing-library/react';
2
2
  import { CONSOLE_TABS } from '../logic/types';
3
- import { useConsoleTabData } from './useConsoleTabData';
3
+ import {
4
+ CONSOLE_TAB_REFRESH_INTERVAL_MS,
5
+ useConsoleTabData,
6
+ } from './useConsoleTabData';
4
7
 
5
8
  describe('useConsoleTabData', () => {
6
9
  beforeEach(() => {
@@ -38,6 +41,42 @@ describe('useConsoleTabData', () => {
38
41
  );
39
42
  });
40
43
 
44
+ it('drops an item that disappeared from the snapshot after the refresh interval', async () => {
45
+ jest.useFakeTimers();
46
+ let closed = false;
47
+ const fetchMock = jest.fn(async (url: string) => ({
48
+ ok: true,
49
+ status: 200,
50
+ json: async () => ({
51
+ pjcode: 'umino',
52
+ generatedAt: '2026-06-19T00:00:00.000Z',
53
+ statusOptions: [],
54
+ storyColors: {},
55
+ items:
56
+ url.includes('/prs/') && !closed
57
+ ? [{ number: 1, itemId: 'PVTI_1', projectItemId: 'PVTI_1' }]
58
+ : [],
59
+ }),
60
+ }));
61
+ global.fetch = fetchMock as unknown as typeof fetch;
62
+
63
+ const { result } = renderHook(() => useConsoleTabData('umino'));
64
+ await waitFor(() => {
65
+ expect(result.current.snapshots.prs?.items.length).toBe(1);
66
+ });
67
+
68
+ closed = true;
69
+ await act(async () => {
70
+ jest.advanceTimersByTime(CONSOLE_TAB_REFRESH_INTERVAL_MS);
71
+ });
72
+
73
+ await waitFor(() => {
74
+ expect(result.current.snapshots.prs?.items.length).toBe(0);
75
+ });
76
+ expect(fetchMock.mock.calls.length).toBe(CONSOLE_TABS.length * 2);
77
+ jest.useRealTimers();
78
+ });
79
+
41
80
  it('surfaces an error when a tab fetch fails', async () => {
42
81
  const fetchMock = jest.fn(async () => ({
43
82
  ok: false,
@@ -66,6 +66,28 @@ const emptySnapshots = (): Record<
66
66
  const buildListUrl = (pjcode: string, tab: ConsoleTabName): string =>
67
67
  `/projects/${pjcode}/${tab}/list.json`;
68
68
 
69
+ export const CONSOLE_TAB_REFRESH_INTERVAL_MS = 60000;
70
+
71
+ const fetchSnapshots = async (
72
+ pjcode: string,
73
+ ): Promise<Record<ConsoleTabName, ConsoleTabSnapshot | null>> => {
74
+ const entries = await Promise.all(
75
+ CONSOLE_TABS.map(async (tab) => {
76
+ const response = await fetch(buildListUrl(pjcode, tab.name));
77
+ if (!response.ok) {
78
+ throw new Error(`HTTP ${response.status}`);
79
+ }
80
+ const payload: unknown = await response.json();
81
+ return [tab.name, parseSnapshot(payload)] as const;
82
+ }),
83
+ );
84
+ const next = emptySnapshots();
85
+ for (const [name, snapshot] of entries) {
86
+ next[name] = snapshot;
87
+ }
88
+ return next;
89
+ };
90
+
69
91
  export const useConsoleTabData = (
70
92
  pjcode: string | null,
71
93
  ): ConsoleTabDataState => {
@@ -88,37 +110,31 @@ export const useConsoleTabData = (
88
110
  };
89
111
  }
90
112
 
91
- Promise.all(
92
- CONSOLE_TABS.map(async (tab) => {
93
- const response = await fetch(buildListUrl(pjcode, tab.name));
94
- if (!response.ok) {
95
- throw new Error(`HTTP ${response.status}`);
96
- }
97
- const payload: unknown = await response.json();
98
- return [tab.name, parseSnapshot(payload)] as const;
99
- }),
100
- )
101
- .then((entries) => {
102
- if (cancelled) {
103
- return;
104
- }
105
- const next = emptySnapshots();
106
- for (const [name, snapshot] of entries) {
107
- next[name] = snapshot;
108
- }
109
- setSnapshots(next);
110
- setIsLoading(false);
111
- })
112
- .catch((cause: unknown) => {
113
- if (cancelled) {
114
- return;
115
- }
116
- setError(cause instanceof Error ? cause.message : String(cause));
117
- setIsLoading(false);
118
- });
113
+ const load = (): void => {
114
+ fetchSnapshots(pjcode)
115
+ .then((next) => {
116
+ if (cancelled) {
117
+ return;
118
+ }
119
+ setSnapshots(next);
120
+ setError(null);
121
+ setIsLoading(false);
122
+ })
123
+ .catch((cause: unknown) => {
124
+ if (cancelled) {
125
+ return;
126
+ }
127
+ setError(cause instanceof Error ? cause.message : String(cause));
128
+ setIsLoading(false);
129
+ });
130
+ };
131
+
132
+ load();
133
+ const timer = setInterval(load, CONSOLE_TAB_REFRESH_INTERVAL_MS);
119
134
 
120
135
  return () => {
121
136
  cancelled = true;
137
+ clearInterval(timer);
122
138
  };
123
139
  }, [pjcode]);
124
140
 
@@ -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 (
@@ -75,6 +75,7 @@ const buildOperations = (): ConsoleOperationsApi => ({
75
75
  body: 'comment body',
76
76
  createdAt: '2026-06-19T11:58:00.000Z',
77
77
  })),
78
+ uploadAttachment: jest.fn(async () => ''),
78
79
  addInlineReviewComment: jest.fn(async () => {}),
79
80
  });
80
81
 
@@ -173,6 +173,7 @@ export const ConsoleItemDetailContainer = ({
173
173
  isPr={item.isPr}
174
174
  now={now}
175
175
  onSubmit={(body) => operations.addComment(item, body)}
176
+ onUploadFile={(file) => operations.uploadAttachment(item, file)}
176
177
  />
177
178
  }
178
179
  operationBar={