github-issue-tower-defence-management 1.101.8 β†’ 1.103.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 (48) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +1 -0
  3. package/bin/adapter/entry-points/console/consoleOperationApi.js +37 -1
  4. package/bin/adapter/entry-points/console/consoleOperationApi.js.map +1 -1
  5. package/bin/adapter/entry-points/console/consoleServer.js +2 -0
  6. package/bin/adapter/entry-points/console/consoleServer.js.map +1 -1
  7. package/bin/adapter/entry-points/console/ui-dist/assets/index-CO-f_j4f.css +1 -0
  8. package/bin/adapter/entry-points/console/ui-dist/assets/index-CP3-MdUU.js +101 -0
  9. package/bin/adapter/entry-points/console/ui-dist/index.html +2 -2
  10. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +71 -0
  11. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  12. package/jest.config.js +15 -1
  13. package/package.json +3 -1
  14. package/src/adapter/entry-points/console/consoleOperationApi.test.ts +96 -0
  15. package/src/adapter/entry-points/console/consoleOperationApi.ts +55 -1
  16. package/src/adapter/entry-points/console/consoleServer.ts +3 -0
  17. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +1 -0
  18. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleChangedFileList.tsx +25 -4
  19. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleFileDiff.stories.tsx +9 -0
  20. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleFileDiff.test.tsx +88 -1
  21. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleFileDiff.tsx +197 -12
  22. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleItemDetail.tsx +4 -0
  23. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleOperations.test.ts +23 -0
  24. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleOperations.ts +33 -0
  25. package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.test.ts +50 -1
  26. package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.ts +27 -0
  27. package/src/adapter/entry-points/console/ui/src/features/console/lib/markdown.test.ts +10 -0
  28. package/src/adapter/entry-points/console/ui/src/features/console/lib/markdown.ts +9 -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 +11 -0
  31. package/src/adapter/entry-points/console/ui/src/index.css +104 -0
  32. package/src/adapter/entry-points/console/ui-dist/assets/index-CO-f_j4f.css +1 -0
  33. package/src/adapter/entry-points/console/ui-dist/assets/index-CP3-MdUU.js +101 -0
  34. package/src/adapter/entry-points/console/ui-dist/index.html +2 -2
  35. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +106 -0
  36. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +108 -0
  37. package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +9 -0
  38. package/types/adapter/entry-points/console/consoleOperationApi.d.ts +1 -0
  39. package/types/adapter/entry-points/console/consoleOperationApi.d.ts.map +1 -1
  40. package/types/adapter/entry-points/console/consoleServer.d.ts.map +1 -1
  41. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +4 -1
  42. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  43. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +2 -0
  44. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
  45. package/bin/adapter/entry-points/console/ui-dist/assets/index-ES6SLB1Y.css +0 -1
  46. package/bin/adapter/entry-points/console/ui-dist/assets/index-_g2CqG11.js +0 -101
  47. package/src/adapter/entry-points/console/ui-dist/assets/index-ES6SLB1Y.css +0 -1
  48. package/src/adapter/entry-points/console/ui-dist/assets/index-_g2CqG11.js +0 -101
@@ -1,4 +1,4 @@
1
- import { render } from '@testing-library/react';
1
+ import { fireEvent, render, waitFor } from '@testing-library/react';
2
2
  import { consoleChangedFilesFixture } from '../../testing/fixtures';
3
3
  import { ConsoleFileDiff } from './ConsoleFileDiff';
4
4
 
@@ -21,4 +21,91 @@ describe('ConsoleFileDiff', () => {
21
21
  const { getByText } = render(<ConsoleFileDiff patch={null} />);
22
22
  expect(getByText('(no diff / binary or too large)')).toBeInTheDocument();
23
23
  });
24
+
25
+ it('does not render any comment affordance without an onAddInlineComment handler', () => {
26
+ const { container } = render(
27
+ <ConsoleFileDiff
28
+ patch={consoleChangedFilesFixture[0].patch}
29
+ path="src/adapter/entry-points/console/consoleServer.ts"
30
+ />,
31
+ );
32
+ expect(
33
+ container.querySelectorAll('.console-diff-comment-button').length,
34
+ ).toBe(0);
35
+ });
36
+
37
+ it('anchors an added line comment to its new line on the RIGHT side', async () => {
38
+ const onAddInlineComment = jest.fn().mockResolvedValue(undefined);
39
+ const { getByLabelText, getByPlaceholderText, getByText } = render(
40
+ <ConsoleFileDiff
41
+ patch={consoleChangedFilesFixture[0].patch}
42
+ path="src/adapter/entry-points/console/consoleServer.ts"
43
+ onAddInlineComment={onAddInlineComment}
44
+ />,
45
+ );
46
+ fireEvent.click(getByLabelText('Comment on line 57 (RIGHT)'));
47
+ fireEvent.change(
48
+ getByPlaceholderText('Leave a review comment on this line…'),
49
+ { target: { value: 'Why npm ci here?' } },
50
+ );
51
+ fireEvent.click(getByText('Comment'));
52
+ await waitFor(() =>
53
+ expect(onAddInlineComment).toHaveBeenCalledWith(
54
+ 'src/adapter/entry-points/console/consoleServer.ts',
55
+ 57,
56
+ 'RIGHT',
57
+ 'Why npm ci here?',
58
+ ),
59
+ );
60
+ });
61
+
62
+ it('anchors a removed line comment to its old line on the LEFT side', async () => {
63
+ const onAddInlineComment = jest.fn().mockResolvedValue(undefined);
64
+ const { getByLabelText, getByPlaceholderText, getByText } = render(
65
+ <ConsoleFileDiff
66
+ patch={consoleChangedFilesFixture[0].patch}
67
+ path="src/adapter/entry-points/console/consoleServer.ts"
68
+ onAddInlineComment={onAddInlineComment}
69
+ />,
70
+ );
71
+ fireEvent.click(getByLabelText('Comment on line 57 (LEFT)'));
72
+ fireEvent.change(
73
+ getByPlaceholderText('Leave a review comment on this line…'),
74
+ { target: { value: 'Keep npm install?' } },
75
+ );
76
+ fireEvent.click(getByText('Comment'));
77
+ await waitFor(() =>
78
+ expect(onAddInlineComment).toHaveBeenCalledWith(
79
+ 'src/adapter/entry-points/console/consoleServer.ts',
80
+ 57,
81
+ 'LEFT',
82
+ 'Keep npm install?',
83
+ ),
84
+ );
85
+ });
86
+
87
+ it('shows the surfaced error when posting an inline comment fails', async () => {
88
+ const onAddInlineComment = jest
89
+ .fn()
90
+ .mockRejectedValue(new Error('line must be part of the diff'));
91
+ const { getByLabelText, getByPlaceholderText, getByText, getByRole } =
92
+ render(
93
+ <ConsoleFileDiff
94
+ patch={consoleChangedFilesFixture[0].patch}
95
+ path="src/adapter/entry-points/console/consoleServer.ts"
96
+ onAddInlineComment={onAddInlineComment}
97
+ />,
98
+ );
99
+ fireEvent.click(getByLabelText('Comment on line 58 (RIGHT)'));
100
+ fireEvent.change(
101
+ getByPlaceholderText('Leave a review comment on this line…'),
102
+ { target: { value: 'A comment that will fail.' } },
103
+ );
104
+ fireEvent.click(getByText('Comment'));
105
+ await waitFor(() =>
106
+ expect(getByRole('alert')).toHaveTextContent(
107
+ 'line must be part of the diff',
108
+ ),
109
+ );
110
+ });
24
111
  });
@@ -1,29 +1,214 @@
1
- import { parseUnifiedDiff } from '../../logic/diff';
1
+ import { Fragment, useState } from 'react';
2
+ import type { ConsoleReviewCommentSide } from '../../lib/consoleApi';
3
+ import { type ConsoleDiffLine, parseUnifiedDiff } from '../../logic/diff';
4
+
5
+ export type ConsoleInlineCommentTarget = {
6
+ path: string;
7
+ line: number;
8
+ side: ConsoleReviewCommentSide;
9
+ };
10
+
11
+ export type ConsoleAddInlineComment = (
12
+ path: string,
13
+ line: number,
14
+ side: ConsoleReviewCommentSide,
15
+ body: string,
16
+ ) => Promise<void>;
2
17
 
3
18
  export type ConsoleFileDiffProps = {
4
19
  patch: string | null;
20
+ path?: string;
21
+ onAddInlineComment?: ConsoleAddInlineComment;
22
+ };
23
+
24
+ type ComposerStatus =
25
+ | { kind: 'idle' }
26
+ | { kind: 'posting' }
27
+ | { kind: 'error'; message: string }
28
+ | { kind: 'posted' };
29
+
30
+ const commentTargetForRow = (
31
+ row: ConsoleDiffLine,
32
+ ): { line: number; side: ConsoleReviewCommentSide } | null => {
33
+ if (row.kind === 'add' || row.kind === 'ctx') {
34
+ if (row.newLineNumber === null) {
35
+ return null;
36
+ }
37
+ return { line: row.newLineNumber, side: 'RIGHT' };
38
+ }
39
+ if (row.kind === 'del') {
40
+ if (row.oldLineNumber === null) {
41
+ return null;
42
+ }
43
+ return { line: row.oldLineNumber, side: 'LEFT' };
44
+ }
45
+ return null;
5
46
  };
6
47
 
7
- export const ConsoleFileDiff = ({ patch }: ConsoleFileDiffProps) => {
48
+ const rowKey = (row: ConsoleDiffLine): string =>
49
+ `${row.kind}:${row.oldLineNumber ?? 'x'}:${row.newLineNumber ?? 'x'}:${row.content}`;
50
+
51
+ type ConsoleInlineCommentComposerProps = {
52
+ target: ConsoleInlineCommentTarget;
53
+ onSubmit: ConsoleAddInlineComment;
54
+ onClose: () => void;
55
+ };
56
+
57
+ const ConsoleInlineCommentComposer = ({
58
+ target,
59
+ onSubmit,
60
+ onClose,
61
+ }: ConsoleInlineCommentComposerProps) => {
62
+ const [draft, setDraft] = useState<string>('');
63
+ const [status, setStatus] = useState<ComposerStatus>({ kind: 'idle' });
64
+
65
+ const submit = async (): Promise<void> => {
66
+ const body = draft.trim();
67
+ if (body.length === 0 || status.kind === 'posting') {
68
+ return;
69
+ }
70
+ setStatus({ kind: 'posting' });
71
+ try {
72
+ await onSubmit(target.path, target.line, target.side, body);
73
+ setDraft('');
74
+ setStatus({ kind: 'posted' });
75
+ } catch (error) {
76
+ setStatus({
77
+ kind: 'error',
78
+ message: error instanceof Error ? error.message : 'failed to post',
79
+ });
80
+ }
81
+ };
82
+
83
+ return (
84
+ <div className="console-diff-composer">
85
+ <div className="console-diff-composer-anchor">
86
+ commenting on line {target.line} ({target.side})
87
+ </div>
88
+ {status.kind === 'posted' ? (
89
+ <p className="console-diff-composer-posted">Comment posted.</p>
90
+ ) : (
91
+ <>
92
+ <textarea
93
+ className="console-diff-composer-input"
94
+ rows={3}
95
+ placeholder="Leave a review comment on this line…"
96
+ value={draft}
97
+ onChange={(event) => setDraft(event.target.value)}
98
+ />
99
+ <div className="console-diff-composer-controls">
100
+ <button
101
+ type="button"
102
+ className="console-diff-composer-submit"
103
+ disabled={status.kind === 'posting'}
104
+ onClick={() => {
105
+ void submit();
106
+ }}
107
+ >
108
+ Comment
109
+ </button>
110
+ <button
111
+ type="button"
112
+ className="console-diff-composer-cancel"
113
+ onClick={onClose}
114
+ >
115
+ Cancel
116
+ </button>
117
+ {status.kind === 'posting' && (
118
+ <span className="console-diff-composer-status">Posting…</span>
119
+ )}
120
+ {status.kind === 'error' && (
121
+ <span
122
+ role="alert"
123
+ className="console-diff-composer-status console-diff-composer-error"
124
+ >
125
+ Failed: {status.message}
126
+ </span>
127
+ )}
128
+ </div>
129
+ </>
130
+ )}
131
+ </div>
132
+ );
133
+ };
134
+
135
+ export const ConsoleFileDiff = ({
136
+ patch,
137
+ path,
138
+ onAddInlineComment,
139
+ }: ConsoleFileDiffProps) => {
140
+ const [activeTarget, setActiveTarget] =
141
+ useState<ConsoleInlineCommentTarget | null>(null);
142
+
8
143
  if (patch === null || patch === '') {
9
144
  return (
10
145
  <p className="console-file-diff-empty">(no diff / binary or too large)</p>
11
146
  );
12
147
  }
13
148
  const rows = parseUnifiedDiff(patch);
149
+ const canComment =
150
+ onAddInlineComment !== undefined && path !== undefined && path.length > 0;
151
+ const columnCount = canComment ? 4 : 3;
152
+
14
153
  return (
15
154
  <table className="console-file-diff">
16
155
  <tbody>
17
- {rows.map((row) => (
18
- <tr
19
- key={`${row.kind}:${row.oldLineNumber ?? 'x'}:${row.newLineNumber ?? 'x'}:${row.content}`}
20
- className={`console-diff-row console-diff-${row.kind}`}
21
- >
22
- <td className="console-diff-ln">{row.oldLineNumber ?? ''}</td>
23
- <td className="console-diff-ln">{row.newLineNumber ?? ''}</td>
24
- <td className="console-diff-code">{row.content}</td>
25
- </tr>
26
- ))}
156
+ {rows.map((row) => {
157
+ const key = rowKey(row);
158
+ const target = canComment ? commentTargetForRow(row) : null;
159
+ const isActive =
160
+ activeTarget !== null &&
161
+ target !== null &&
162
+ activeTarget.line === target.line &&
163
+ activeTarget.side === target.side;
164
+ return (
165
+ <Fragment key={key}>
166
+ <tr className={`console-diff-row console-diff-${row.kind}`}>
167
+ {canComment && (
168
+ <td className="console-diff-comment-cell">
169
+ {target !== null && path !== undefined && (
170
+ <button
171
+ type="button"
172
+ className="console-diff-comment-button"
173
+ aria-label={`Comment on line ${target.line} (${target.side})`}
174
+ aria-expanded={isActive}
175
+ onClick={() =>
176
+ setActiveTarget(
177
+ isActive
178
+ ? null
179
+ : {
180
+ path,
181
+ line: target.line,
182
+ side: target.side,
183
+ },
184
+ )
185
+ }
186
+ >
187
+ +
188
+ </button>
189
+ )}
190
+ </td>
191
+ )}
192
+ <td className="console-diff-ln">{row.oldLineNumber ?? ''}</td>
193
+ <td className="console-diff-ln">{row.newLineNumber ?? ''}</td>
194
+ <td className="console-diff-code">{row.content}</td>
195
+ </tr>
196
+ {isActive &&
197
+ onAddInlineComment !== undefined &&
198
+ activeTarget !== null && (
199
+ <tr className="console-diff-composer-row">
200
+ <td colSpan={columnCount}>
201
+ <ConsoleInlineCommentComposer
202
+ target={activeTarget}
203
+ onSubmit={onAddInlineComment}
204
+ onClose={() => setActiveTarget(null)}
205
+ />
206
+ </td>
207
+ </tr>
208
+ )}
209
+ </Fragment>
210
+ );
211
+ })}
27
212
  </tbody>
28
213
  </table>
29
214
  );
@@ -20,6 +20,7 @@ import { ConsolePanel } from '../layout/ConsolePanel';
20
20
  import { ConsoleChangedFileList } from './ConsoleChangedFileList';
21
21
  import { ConsoleCommentList } from './ConsoleCommentList';
22
22
  import { ConsoleCommitList } from './ConsoleCommitList';
23
+ import type { ConsoleAddInlineComment } from './ConsoleFileDiff';
23
24
  import { ConsoleItemIcon } from './ConsoleItemIcon';
24
25
  import { ConsolePullRequestDetail } from './ConsolePullRequestDetail';
25
26
 
@@ -56,6 +57,7 @@ export type ConsoleItemDetailProps = {
56
57
  commentComposer: ReactNode;
57
58
  operationBar: ReactNode;
58
59
  buildImageProxyUrl?: ImageProxyUrlBuilder;
60
+ onAddInlineComment?: ConsoleAddInlineComment;
59
61
  };
60
62
 
61
63
  export const ConsoleItemDetail = ({
@@ -81,6 +83,7 @@ export const ConsoleItemDetail = ({
81
83
  commentComposer,
82
84
  operationBar,
83
85
  buildImageProxyUrl,
86
+ onAddInlineComment,
84
87
  }: ConsoleItemDetailProps) => {
85
88
  const resolvedState = state?.state ?? 'open';
86
89
  const merged = state?.merged ?? false;
@@ -208,6 +211,7 @@ export const ConsoleItemDetail = ({
208
211
  files={files}
209
212
  isLoading={filesAreLoading}
210
213
  error={filesError}
214
+ onAddInlineComment={onAddInlineComment}
211
215
  />
212
216
  </ConsolePanel>
213
217
  )}
@@ -214,6 +214,29 @@ describe('useConsoleOperations', () => {
214
214
  });
215
215
  });
216
216
 
217
+ it('posts a line-anchored inline review comment to the reviewcomment endpoint', async () => {
218
+ const fetchMock = captureFetch();
219
+ const { result } = setup();
220
+ await act(async () => {
221
+ await result.current.operations.addInlineReviewComment(
222
+ prItem.url,
223
+ 'src/index.ts',
224
+ 42,
225
+ 'RIGHT',
226
+ 'Consider extracting this into a helper.',
227
+ );
228
+ });
229
+ expect(fetchMock.mock.calls[0][0]).toBe('/api/reviewcomment?k=token');
230
+ expect(lastBody(fetchMock)).toEqual({
231
+ pjcode: 'umino',
232
+ url: prItem.url,
233
+ path: 'src/index.ts',
234
+ line: 42,
235
+ side: 'RIGHT',
236
+ body: 'Consider extracting this into a helper.',
237
+ });
238
+ });
239
+
217
240
  it('rejects an operation and posts nothing when no pjcode is available', async () => {
218
241
  const fetchMock = captureFetch();
219
242
  localStorage.clear();
@@ -1,10 +1,12 @@
1
1
  import { useCallback } from 'react';
2
2
  import {
3
3
  type ConsoleIntmuxRequest,
4
+ type ConsoleReviewCommentSide,
4
5
  type ConsoleReviewRequest,
5
6
  type ConsoleTriageRequest,
6
7
  postConsoleComment,
7
8
  postConsoleOperation,
9
+ postConsoleReviewComment,
8
10
  } from '../lib/consoleApi';
9
11
  import {
10
12
  type ConsoleCloseAction,
@@ -54,6 +56,13 @@ export type ConsoleOperationsApi = {
54
56
  action: ConsoleCloseAction,
55
57
  ) => Promise<void>;
56
58
  addComment: (item: ConsoleListItem, body: string) => Promise<ConsoleComment>;
59
+ addInlineReviewComment: (
60
+ prUrl: string,
61
+ path: string,
62
+ line: number,
63
+ side: ConsoleReviewCommentSide,
64
+ body: string,
65
+ ) => Promise<void>;
57
66
  };
58
67
 
59
68
  const reviewRequest = (
@@ -247,6 +256,29 @@ export const useConsoleOperations = (
247
256
  [pjcode, appendToken],
248
257
  );
249
258
 
259
+ const addInlineReviewComment = useCallback(
260
+ async (
261
+ prUrl: string,
262
+ path: string,
263
+ line: number,
264
+ side: ConsoleReviewCommentSide,
265
+ body: string,
266
+ ) => {
267
+ if (pjcode === null) {
268
+ throw missingPjcodeError();
269
+ }
270
+ await postConsoleReviewComment(appendToken, {
271
+ pjcode,
272
+ url: prUrl,
273
+ path,
274
+ line,
275
+ side,
276
+ body,
277
+ });
278
+ },
279
+ [pjcode, appendToken],
280
+ );
281
+
250
282
  return {
251
283
  reviewPullRequest,
252
284
  setNextActionDate,
@@ -255,5 +287,6 @@ export const useConsoleOperations = (
255
287
  setInTmuxByHuman,
256
288
  closeIssue,
257
289
  addComment,
290
+ addInlineReviewComment,
258
291
  };
259
292
  };
@@ -1,4 +1,8 @@
1
- import { createConsoleApiClient, postConsoleOperation } from './consoleApi';
1
+ import {
2
+ createConsoleApiClient,
3
+ postConsoleOperation,
4
+ postConsoleReviewComment,
5
+ } from './consoleApi';
2
6
 
3
7
  const appendToken = (url: string): string =>
4
8
  url.includes('?') ? `${url}&k=token` : `${url}?k=token`;
@@ -208,3 +212,48 @@ describe('postConsoleOperation', () => {
208
212
  await expect(failingReview()).rejects.toThrow('HTTP 500');
209
213
  });
210
214
  });
215
+
216
+ describe('postConsoleReviewComment', () => {
217
+ it('posts the inline review comment body to the reviewcomment endpoint', async () => {
218
+ const fetchMock = mockFetchOnce({ ok: true });
219
+ await postConsoleReviewComment(appendToken, {
220
+ pjcode: 'umino',
221
+ url: 'https://github.com/o/r/pull/1',
222
+ path: 'src/index.ts',
223
+ line: 42,
224
+ side: 'RIGHT',
225
+ body: 'Consider extracting this into a helper.',
226
+ });
227
+ const [url, init] = fetchMock.mock.calls[0];
228
+ expect(url).toBe('/api/reviewcomment?k=token');
229
+ expect(init).toMatchObject({ method: 'POST' });
230
+ expect(JSON.parse((init as { body: string }).body)).toEqual({
231
+ pjcode: 'umino',
232
+ url: 'https://github.com/o/r/pull/1',
233
+ path: 'src/index.ts',
234
+ line: 42,
235
+ side: 'RIGHT',
236
+ body: 'Consider extracting this into a helper.',
237
+ });
238
+ });
239
+
240
+ it('throws the GitHub error reason surfaced by the server', async () => {
241
+ mockFetchFailureOnce(
242
+ 502,
243
+ JSON.stringify({
244
+ error:
245
+ 'Failed to create review comment on PR https://github.com/o/r/pull/1: line must be part of the diff',
246
+ }),
247
+ );
248
+ await expect(
249
+ postConsoleReviewComment(appendToken, {
250
+ pjcode: 'umino',
251
+ url: 'https://github.com/o/r/pull/1',
252
+ path: 'src/index.ts',
253
+ line: 42,
254
+ side: 'RIGHT',
255
+ body: 'Consider extracting this into a helper.',
256
+ }),
257
+ ).rejects.toThrow('line must be part of the diff');
258
+ });
259
+ });
@@ -41,6 +41,17 @@ export type ConsoleIntmuxRequest = {
41
41
  projectItemId: string;
42
42
  };
43
43
 
44
+ export type ConsoleReviewCommentSide = 'LEFT' | 'RIGHT';
45
+
46
+ export type ConsoleReviewCommentRequest = {
47
+ pjcode: string;
48
+ url: string;
49
+ path: string;
50
+ line: number;
51
+ side: ConsoleReviewCommentSide;
52
+ body: string;
53
+ };
54
+
44
55
  type AppendToken = (url: string) => string;
45
56
 
46
57
  const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -237,3 +248,19 @@ export const postConsoleComment = async (
237
248
  }
238
249
  return parsePostedComment(await response.json());
239
250
  };
251
+
252
+ export const REVIEW_COMMENT_OPERATION_PATH = '/api/reviewcomment';
253
+
254
+ export const postConsoleReviewComment = async (
255
+ appendToken: AppendToken,
256
+ request: ConsoleReviewCommentRequest,
257
+ ): Promise<void> => {
258
+ const response = await fetch(appendToken(REVIEW_COMMENT_OPERATION_PATH), {
259
+ method: 'POST',
260
+ headers: { 'Content-Type': 'application/json' },
261
+ body: JSON.stringify(request),
262
+ });
263
+ if (!response.ok) {
264
+ throw new Error(await readOperationErrorReason(response));
265
+ }
266
+ };
@@ -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();
@@ -52,6 +52,7 @@ const buildOperations = (): ConsoleOperationsApi => ({
52
52
  body: 'comment body',
53
53
  createdAt: '2026-06-19T11:58:00.000Z',
54
54
  })),
55
+ addInlineReviewComment: jest.fn(async () => {}),
55
56
  });
56
57
 
57
58
  describe('ConsoleItemDetailContainer', () => {
@@ -6,6 +6,7 @@ import type { ConsoleCaches } from '../hooks/useConsoleCaches';
6
6
  import { useConsoleItemDetailData } from '../hooks/useConsoleItemDetailData';
7
7
  import type { ConsoleOperationsApi } from '../hooks/useConsoleOperations';
8
8
  import { useConsoleToken } from '../hooks/useConsoleToken';
9
+ import type { ConsoleReviewCommentSide } from '../lib/consoleApi';
9
10
  import { buildImageProxyUrl } from '../lib/imageProxy';
10
11
  import type { ConsoleActionKind } from '../logic/actionToast';
11
12
  import { resolveStoryColorEnum } from '../logic/grouping';
@@ -59,6 +60,15 @@ export const ConsoleItemDetailContainer = ({
59
60
  [token],
60
61
  );
61
62
  const hasPullRequest = item.isPr || detail.relatedPullRequests.length > 0;
63
+ const addInlineComment = useCallback(
64
+ (
65
+ path: string,
66
+ line: number,
67
+ side: ConsoleReviewCommentSide,
68
+ body: string,
69
+ ) => operations.addInlineReviewComment(item.url, path, line, side, body),
70
+ [operations, item.url],
71
+ );
62
72
 
63
73
  const handlers: ConsoleOperationHandlers = {
64
74
  onReview: (action) => {
@@ -137,6 +147,7 @@ export const ConsoleItemDetailContainer = ({
137
147
  relatedPullRequests={detail.relatedPullRequests}
138
148
  now={now}
139
149
  buildImageProxyUrl={resolveImageProxyUrl}
150
+ onAddInlineComment={item.isPr ? addInlineComment : undefined}
140
151
  commentComposer={
141
152
  <ConsoleCommentComposer
142
153
  isPr={item.isPr}