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.
Files changed (63) 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-BYoG8oQb.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/bin/domain/usecases/console/GenerateConsoleListsUseCase.js +26 -3
  17. package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js.map +1 -1
  18. package/package.json +1 -1
  19. package/src/adapter/entry-points/cli/index.ts +4 -0
  20. package/src/adapter/entry-points/console/consoleItemUrlLookup.test.ts +63 -0
  21. package/src/adapter/entry-points/console/consoleItemUrlLookup.ts +66 -0
  22. package/src/adapter/entry-points/console/consoleOperationApi.test.ts +155 -0
  23. package/src/adapter/entry-points/console/consoleOperationApi.ts +56 -0
  24. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +3 -0
  25. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleCommentComposer.stories.tsx +17 -0
  26. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleCommentComposer.test.tsx +149 -1
  27. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleCommentComposer.tsx +107 -1
  28. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleDetailPrefetch.test.ts +1 -0
  29. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleItemDetailData.test.ts +2 -0
  30. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleOperations.ts +22 -0
  31. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleTabData.test.ts +39 -0
  32. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleTabData.ts +14 -1
  33. package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.ts +35 -0
  34. package/src/adapter/entry-points/console/ui/src/features/console/logic/grouping.test.ts +1 -0
  35. package/src/adapter/entry-points/console/ui/src/features/console/logic/overlay.test.ts +1 -0
  36. package/src/adapter/entry-points/console/ui/src/features/console/logic/types.ts +1 -0
  37. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.test.tsx +43 -1
  38. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.tsx +8 -2
  39. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +7 -0
  40. package/src/adapter/entry-points/console/ui/src/features/console/testing/fixtures.ts +5 -0
  41. package/src/adapter/entry-points/console/ui-dist/assets/index-BYoG8oQb.js +82 -0
  42. package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
  43. package/src/adapter/entry-points/console/webServer.ts +6 -0
  44. package/src/adapter/repositories/LocalCommandIssueAttachmentRepository.test.ts +136 -0
  45. package/src/adapter/repositories/LocalCommandIssueAttachmentRepository.ts +96 -0
  46. package/src/domain/usecases/adapter-interfaces/IssueAttachmentRepository.ts +9 -0
  47. package/src/domain/usecases/console/GenerateConsoleListsUseCase.test.ts +62 -0
  48. package/src/domain/usecases/console/GenerateConsoleListsUseCase.ts +45 -3
  49. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  50. package/types/adapter/entry-points/console/consoleItemUrlLookup.d.ts +2 -0
  51. package/types/adapter/entry-points/console/consoleItemUrlLookup.d.ts.map +1 -0
  52. package/types/adapter/entry-points/console/consoleOperationApi.d.ts +3 -0
  53. package/types/adapter/entry-points/console/consoleOperationApi.d.ts.map +1 -1
  54. package/types/adapter/entry-points/console/webServer.d.ts +2 -0
  55. package/types/adapter/entry-points/console/webServer.d.ts.map +1 -1
  56. package/types/adapter/repositories/LocalCommandIssueAttachmentRepository.d.ts +15 -0
  57. package/types/adapter/repositories/LocalCommandIssueAttachmentRepository.d.ts.map +1 -0
  58. package/types/domain/usecases/adapter-interfaces/IssueAttachmentRepository.d.ts +9 -0
  59. package/types/domain/usecases/adapter-interfaces/IssueAttachmentRepository.d.ts.map +1 -0
  60. package/types/domain/usecases/console/GenerateConsoleListsUseCase.d.ts +2 -0
  61. package/types/domain/usecases/console/GenerateConsoleListsUseCase.d.ts.map +1 -1
  62. package/bin/adapter/entry-points/console/ui-dist/assets/index-DDQKZmBM.js +0 -81
  63. 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
+ `![${file.name}](https://github.com/user-attachments/assets/9f0b1c3d-4e5f-4a6b-8c7d-1e2f3a4b5c6d)`,
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 { 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>
@@ -16,6 +16,7 @@ const makeItem = (
16
16
  projectItemId: `PVTI_${number}`,
17
17
  itemId: `PVTI_${number}`,
18
18
  isPr: false,
19
+ relatedOpenPullRequestUrls: [],
19
20
  story: 'Story',
20
21
  status: null,
21
22
  nextActionDate: null,
@@ -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
  };
@@ -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
  };
@@ -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) as unknown as ConsoleListItem[];
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 (
@@ -17,6 +17,7 @@ const item = (
17
17
  projectItemId: `PVTI_${overrides.number}`,
18
18
  itemId: `PVTI_${overrides.number}`,
19
19
  isPr: false,
20
+ relatedOpenPullRequestUrls: [],
20
21
  status: null,
21
22
  nextActionDate: null,
22
23
  nextActionHour: null,
@@ -18,6 +18,7 @@ const item = (number: number): ConsoleListItem => ({
18
18
  projectItemId: `PVTI_${number}`,
19
19
  itemId: `PVTI_${number}`,
20
20
  isPr: false,
21
+ relatedOpenPullRequestUrls: [],
21
22
  story: 'Story',
22
23
  status: null,
23
24
  nextActionDate: null,
@@ -24,6 +24,7 @@ export type ConsoleListItem = {
24
24
  dependedIssueUrls: string[];
25
25
  labels: string[];
26
26
  createdAt: string;
27
+ relatedOpenPullRequestUrls: string[];
27
28
  };
28
29
 
29
30
  export type ConsoleFieldOption = {