difit 3.1.18 → 4.0.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 (100) hide show
  1. package/README.ja.md +44 -16
  2. package/README.ko.md +44 -16
  3. package/README.md +44 -16
  4. package/README.zh.md +44 -16
  5. package/dist/cli/github.d.ts +65 -0
  6. package/dist/cli/github.js +296 -0
  7. package/dist/cli/github.test.d.ts +1 -0
  8. package/dist/cli/github.test.js +341 -0
  9. package/dist/cli/index.js +42 -1
  10. package/dist/cli/index.test.js +330 -4
  11. package/dist/cli/utils.d.ts +2 -8
  12. package/dist/cli/utils.js +4 -43
  13. package/dist/cli/utils.test.js +50 -67
  14. package/dist/client/assets/{_basePickBy-DyiQWUmK.js → _basePickBy-ChXFkTMC.js} +1 -1
  15. package/dist/client/assets/{_baseUniq-DivSZEOF.js → _baseUniq-Mj_sFFQW.js} +1 -1
  16. package/dist/client/assets/{arc-c0kacVOL.js → arc-BMA6S9F1.js} +1 -1
  17. package/dist/client/assets/{architectureDiagram-2XIMDMQ5-ubymLNEe.js → architectureDiagram-2XIMDMQ5-0uiM_v5K.js} +1 -1
  18. package/dist/client/assets/{blockDiagram-WCTKOSBZ-F9D8w4_S.js → blockDiagram-WCTKOSBZ-CM7ZLL6F.js} +1 -1
  19. package/dist/client/assets/{c4Diagram-IC4MRINW-JE9Kx4yQ.js → c4Diagram-IC4MRINW-DKtCnVwn.js} +1 -1
  20. package/dist/client/assets/channel-D057yzDp.js +1 -0
  21. package/dist/client/assets/{chunk-4BX2VUAB-CYOCoDMc.js → chunk-4BX2VUAB-Wsl8DxEB.js} +1 -1
  22. package/dist/client/assets/{chunk-55IACEB6-PRBuiJg9.js → chunk-55IACEB6-CHm9X5i7.js} +1 -1
  23. package/dist/client/assets/{chunk-FMBD7UC4-C0eJ7JsI.js → chunk-FMBD7UC4-BSa8SHgd.js} +1 -1
  24. package/dist/client/assets/{chunk-JSJVCQXG-QZotPSqo.js → chunk-JSJVCQXG-Cpk76oJ3.js} +1 -1
  25. package/dist/client/assets/{chunk-KX2RTZJC-B8du3tt8.js → chunk-KX2RTZJC-D8YvfZVu.js} +1 -1
  26. package/dist/client/assets/{chunk-NQ4KR5QH-B10ldi5m.js → chunk-NQ4KR5QH-BogviJOv.js} +1 -1
  27. package/dist/client/assets/{chunk-QZHKN3VN-CpwW9rUQ.js → chunk-QZHKN3VN-DwLJYu26.js} +1 -1
  28. package/dist/client/assets/{chunk-WL4C6EOR-DwKPHpbL.js → chunk-WL4C6EOR-BFDpGxW2.js} +1 -1
  29. package/dist/client/assets/classDiagram-VBA2DB6C---D4iOts.js +1 -0
  30. package/dist/client/assets/classDiagram-v2-RAHNMMFH---D4iOts.js +1 -0
  31. package/dist/client/assets/clone-xSR3otEf.js +1 -0
  32. package/dist/client/assets/{cose-bilkent-S5V4N54A-p76yal75.js → cose-bilkent-S5V4N54A-oEosZ_5y.js} +1 -1
  33. package/dist/client/assets/{dagre-KLK3FWXG-CdDyed3V.js → dagre-KLK3FWXG-gFld4u1H.js} +1 -1
  34. package/dist/client/assets/{diagram-E7M64L7V-BaC8dXuW.js → diagram-E7M64L7V-gJq3kSrf.js} +1 -1
  35. package/dist/client/assets/{diagram-IFDJBPK2-BGf8xwJI.js → diagram-IFDJBPK2-BsUm_q22.js} +1 -1
  36. package/dist/client/assets/{diagram-P4PSJMXO-D3j16gBZ.js → diagram-P4PSJMXO-juB-sfcR.js} +1 -1
  37. package/dist/client/assets/{erDiagram-INFDFZHY-DFpDdocf.js → erDiagram-INFDFZHY-Dn77qXAt.js} +1 -1
  38. package/dist/client/assets/{flowDiagram-PKNHOUZH-Cz4mb4IF.js → flowDiagram-PKNHOUZH-DtmvDYdN.js} +1 -1
  39. package/dist/client/assets/{ganttDiagram-A5KZAMGK-CNzY9ua5.js → ganttDiagram-A5KZAMGK-BlDaKLbQ.js} +1 -1
  40. package/dist/client/assets/{gitGraphDiagram-K3NZZRJ6-DCSxL8EQ.js → gitGraphDiagram-K3NZZRJ6-DeAAeuMS.js} +1 -1
  41. package/dist/client/assets/{graph-BC2BV1-T.js → graph-NX9gBP47.js} +1 -1
  42. package/dist/client/assets/index-VxkpzDXr.css +1 -0
  43. package/dist/client/assets/index-kJdw4DY-.js +98 -0
  44. package/dist/client/assets/{infoDiagram-LFFYTUFH-BKSspZbH.js → infoDiagram-LFFYTUFH-CAaX023c.js} +1 -1
  45. package/dist/client/assets/{ishikawaDiagram-PHBUUO56-DZ2IRYwc.js → ishikawaDiagram-PHBUUO56-CmiTQStv.js} +1 -1
  46. package/dist/client/assets/{journeyDiagram-4ABVD52K-BrjXAkii.js → journeyDiagram-4ABVD52K-B0SHC7mz.js} +1 -1
  47. package/dist/client/assets/{kanban-definition-K7BYSVSG-B1mfOekw.js → kanban-definition-K7BYSVSG-IfRdhzz7.js} +1 -1
  48. package/dist/client/assets/{layout-CWTG02uT.js → layout-l3OdNQhJ.js} +1 -1
  49. package/dist/client/assets/{linear-CGgOKp1d.js → linear-CQ0hx5Qs.js} +1 -1
  50. package/dist/client/assets/{mermaid.core-DTPtVBG7.js → mermaid.core-DqlPTabt.js} +4 -4
  51. package/dist/client/assets/{mindmap-definition-YRQLILUH-DByVRPFT.js → mindmap-definition-YRQLILUH-DIgSmG_f.js} +1 -1
  52. package/dist/client/assets/{pieDiagram-SKSYHLDU-DEgvAxAy.js → pieDiagram-SKSYHLDU-FzM5qoIB.js} +1 -1
  53. package/dist/client/assets/{prism-csharp-DqTrHqwJ.js → prism-csharp-DCfUUOUs.js} +1 -1
  54. package/dist/client/assets/{prism-elixir-DEJaM00V.js → prism-elixir-riuOL1mm.js} +1 -1
  55. package/dist/client/assets/{prism-hcl-HvJ0aPiH.js → prism-hcl-CizuX1s4.js} +1 -1
  56. package/dist/client/assets/{prism-java-DDUFERTh.js → prism-java-DYCKrDUh.js} +1 -1
  57. package/dist/client/assets/{prism-perl-CNA3SNC9.js → prism-perl-BJwBYR3Y.js} +1 -1
  58. package/dist/client/assets/{prism-php-hBQuhE2A.js → prism-php-BMhFuA7y.js} +1 -1
  59. package/dist/client/assets/{prism-ruby-BKap8imy.js → prism-ruby-Bcu0cDEh.js} +1 -1
  60. package/dist/client/assets/{prism-solidity-DHc7LZHq.js → prism-solidity-DDDs3w-w.js} +1 -1
  61. package/dist/client/assets/{quadrantDiagram-337W2JSQ-DTtikTvc.js → quadrantDiagram-337W2JSQ-BBrApyD7.js} +1 -1
  62. package/dist/client/assets/{requirementDiagram-Z7DCOOCP-B34R-xD0.js → requirementDiagram-Z7DCOOCP-CLXiwUaA.js} +1 -1
  63. package/dist/client/assets/{sankeyDiagram-WA2Y5GQK-Dts1ZXRC.js → sankeyDiagram-WA2Y5GQK-9Y3Ly5qe.js} +1 -1
  64. package/dist/client/assets/{sequenceDiagram-2WXFIKYE-DzM3WhEY.js → sequenceDiagram-2WXFIKYE-DEpX1BA5.js} +1 -1
  65. package/dist/client/assets/{stateDiagram-RAJIS63D-B2dF8YnK.js → stateDiagram-RAJIS63D-Ck3ullwA.js} +1 -1
  66. package/dist/client/assets/stateDiagram-v2-FVOUBMTO-X6UiDsar.js +1 -0
  67. package/dist/client/assets/{timeline-definition-YZTLITO2-BO4OtcEm.js → timeline-definition-YZTLITO2-CMezf3XV.js} +1 -1
  68. package/dist/client/assets/{treemap-KZPCXAKY-DaXnvVRH.js → treemap-KZPCXAKY-DqrcV0gQ.js} +1 -1
  69. package/dist/client/assets/{vennDiagram-LZ73GAT5-AIMhd8Js.js → vennDiagram-LZ73GAT5-eQg945Fz.js} +1 -1
  70. package/dist/client/assets/{xychartDiagram-JWTSCODW-Ch6W1f7P.js → xychartDiagram-JWTSCODW-_hqdXeX1.js} +1 -1
  71. package/dist/client/index.html +2 -2
  72. package/dist/server/generated-file-check.js +113 -58
  73. package/dist/server/generated-file-check.test.js +2 -0
  74. package/dist/server/git-diff-tui.d.ts +1 -1
  75. package/dist/server/git-diff-tui.js +7 -5
  76. package/dist/server/git-diff-tui.test.d.ts +1 -0
  77. package/dist/server/git-diff-tui.test.js +60 -0
  78. package/dist/server/git-diff.d.ts +4 -1
  79. package/dist/server/git-diff.js +73 -9
  80. package/dist/server/git-diff.test.js +46 -0
  81. package/dist/server/server.d.ts +3 -0
  82. package/dist/server/server.js +111 -37
  83. package/dist/server/server.test.js +152 -0
  84. package/dist/tui/App.d.ts +1 -0
  85. package/dist/tui/App.js +2 -2
  86. package/dist/types/diff.d.ts +74 -14
  87. package/dist/utils/commentFormatting.d.ts +4 -2
  88. package/dist/utils/commentFormatting.js +57 -19
  89. package/dist/utils/commentImports.d.ts +9 -0
  90. package/dist/utils/commentImports.js +264 -0
  91. package/dist/utils/commentImports.test.d.ts +1 -0
  92. package/dist/utils/commentImports.test.js +197 -0
  93. package/package.json +1 -1
  94. package/dist/client/assets/channel-Ca4c0q8d.js +0 -1
  95. package/dist/client/assets/classDiagram-VBA2DB6C-CJLw9sK7.js +0 -1
  96. package/dist/client/assets/classDiagram-v2-RAHNMMFH-CJLw9sK7.js +0 -1
  97. package/dist/client/assets/clone-D0mDLEir.js +0 -1
  98. package/dist/client/assets/index-DHt9OwVU.css +0 -1
  99. package/dist/client/assets/index-mE8CA51x.js +0 -95
  100. package/dist/client/assets/stateDiagram-v2-FVOUBMTO-ReD0hBzH.js +0 -1
@@ -0,0 +1,296 @@
1
+ import { execFileSync } from 'child_process';
2
+ const PR_REVIEW_THREADS_GRAPHQL_QUERY = `
3
+ query($owner: String!, $repo: String!, $number: Int!, $endCursor: String) {
4
+ repository(owner: $owner, name: $repo) {
5
+ pullRequest(number: $number) {
6
+ reviewThreads(first: 100, after: $endCursor) {
7
+ nodes {
8
+ id
9
+ isResolved
10
+ isOutdated
11
+ subjectType
12
+ path
13
+ diffSide
14
+ startDiffSide
15
+ line
16
+ startLine
17
+ originalLine
18
+ originalStartLine
19
+ comments(first: 100) {
20
+ nodes {
21
+ id
22
+ body
23
+ createdAt
24
+ updatedAt
25
+ author {
26
+ login
27
+ }
28
+ }
29
+ }
30
+ }
31
+ pageInfo {
32
+ hasNextPage
33
+ endCursor
34
+ }
35
+ }
36
+ }
37
+ }
38
+ }
39
+ `;
40
+ function isPositiveInteger(value) {
41
+ return typeof value === 'number' && Number.isInteger(value) && value > 0;
42
+ }
43
+ function formatGhCommandError(error) {
44
+ const stderr = error.stderr;
45
+ const stderrText = typeof stderr === 'string'
46
+ ? stderr.trim()
47
+ : Buffer.isBuffer(stderr)
48
+ ? stderr.toString('utf8').trim()
49
+ : '';
50
+ const message = stderrText || (error instanceof Error ? error.message : 'Unknown error while running gh');
51
+ return new Error(`${message}\nTry: gh auth login`);
52
+ }
53
+ function warnPrCommentImport(threadId, message) {
54
+ const threadLabel = threadId ? ` ${threadId}` : '';
55
+ console.warn(`Warning: Skipping PR review thread${threadLabel}: ${message}`);
56
+ }
57
+ function createSingleLinePosition(side, line) {
58
+ return { side, line };
59
+ }
60
+ function createMultiLinePosition(side, start, end) {
61
+ const line = { start, end };
62
+ return { side, line };
63
+ }
64
+ function createRightSidePosition(thread) {
65
+ if (!isPositiveInteger(thread.line)) {
66
+ warnPrCommentImport(thread.id ?? undefined, 'RIGHT thread is missing line.');
67
+ return null;
68
+ }
69
+ const startLine = thread.startLine !== undefined && thread.startLine !== null ? thread.startLine : null;
70
+ const isSingleLineWithRedundantStart = startLine !== null && startLine === thread.line && thread.startDiffSide == null;
71
+ const hasMultiLineMetadata = thread.startDiffSide !== undefined && thread.startDiffSide !== null
72
+ ? true
73
+ : startLine !== null && !isSingleLineWithRedundantStart;
74
+ if (!hasMultiLineMetadata) {
75
+ return createSingleLinePosition('new', thread.line);
76
+ }
77
+ if (thread.startDiffSide !== 'RIGHT') {
78
+ warnPrCommentImport(thread.id ?? undefined, 'RIGHT thread has mismatched startDiffSide.');
79
+ return null;
80
+ }
81
+ if (!isPositiveInteger(startLine) || startLine > thread.line) {
82
+ warnPrCommentImport(thread.id ?? undefined, 'RIGHT thread has an invalid multi-line range.');
83
+ return null;
84
+ }
85
+ return createMultiLinePosition('new', startLine, thread.line);
86
+ }
87
+ function createLeftSidePosition(thread) {
88
+ if (!isPositiveInteger(thread.originalLine)) {
89
+ warnPrCommentImport(thread.id ?? undefined, 'LEFT thread is missing originalLine.');
90
+ return null;
91
+ }
92
+ const originalStartLine = thread.originalStartLine !== undefined && thread.originalStartLine !== null
93
+ ? thread.originalStartLine
94
+ : null;
95
+ const isSingleLineWithRedundantStart = originalStartLine !== null &&
96
+ originalStartLine === thread.originalLine &&
97
+ thread.startDiffSide == null;
98
+ const hasMultiLineMetadata = thread.startDiffSide !== undefined && thread.startDiffSide !== null
99
+ ? true
100
+ : originalStartLine !== null && !isSingleLineWithRedundantStart;
101
+ if (!hasMultiLineMetadata) {
102
+ return createSingleLinePosition('old', thread.originalLine);
103
+ }
104
+ if (thread.startDiffSide !== 'LEFT') {
105
+ warnPrCommentImport(thread.id ?? undefined, 'LEFT thread has mismatched startDiffSide.');
106
+ return null;
107
+ }
108
+ if (!isPositiveInteger(originalStartLine) || originalStartLine > thread.originalLine) {
109
+ warnPrCommentImport(thread.id ?? undefined, 'LEFT thread has an invalid multi-line range.');
110
+ return null;
111
+ }
112
+ return createMultiLinePosition('old', originalStartLine, thread.originalLine);
113
+ }
114
+ function getThreadPosition(thread) {
115
+ if (thread.diffSide === 'RIGHT') {
116
+ return createRightSidePosition(thread);
117
+ }
118
+ if (thread.diffSide === 'LEFT') {
119
+ return createLeftSidePosition(thread);
120
+ }
121
+ warnPrCommentImport(thread.id ?? undefined, 'Unsupported diffSide.');
122
+ return null;
123
+ }
124
+ function toSortedComments(thread) {
125
+ return [...(thread.comments?.nodes ?? [])].sort((left, right) => (left.createdAt ?? '').localeCompare(right.createdAt ?? ''));
126
+ }
127
+ function toCommentImportsForThread(thread) {
128
+ if (thread.isResolved || thread.isOutdated || thread.subjectType !== 'LINE') {
129
+ return [];
130
+ }
131
+ if (typeof thread.path !== 'string' || thread.path.trim().length === 0) {
132
+ warnPrCommentImport(thread.id ?? undefined, 'Thread is missing path.');
133
+ return [];
134
+ }
135
+ const filePath = thread.path;
136
+ const position = getThreadPosition(thread);
137
+ if (!position) {
138
+ return [];
139
+ }
140
+ const comments = toSortedComments(thread);
141
+ if (comments.length === 0) {
142
+ warnPrCommentImport(thread.id ?? undefined, 'Thread has no comments.');
143
+ return [];
144
+ }
145
+ const rootComment = comments[0];
146
+ if (!rootComment ||
147
+ typeof rootComment.id !== 'string' ||
148
+ typeof rootComment.body !== 'string' ||
149
+ typeof rootComment.createdAt !== 'string' ||
150
+ typeof rootComment.updatedAt !== 'string') {
151
+ warnPrCommentImport(thread.id ?? undefined, 'Thread has a comment with missing fields.');
152
+ return [];
153
+ }
154
+ const commentImports = [
155
+ {
156
+ type: 'thread',
157
+ id: rootComment.id,
158
+ filePath,
159
+ position,
160
+ body: rootComment.body,
161
+ author: rootComment.author?.login ?? undefined,
162
+ createdAt: rootComment.createdAt,
163
+ updatedAt: rootComment.updatedAt,
164
+ },
165
+ ];
166
+ for (const comment of comments.slice(1)) {
167
+ if (typeof comment.id !== 'string' ||
168
+ typeof comment.body !== 'string' ||
169
+ typeof comment.createdAt !== 'string' ||
170
+ typeof comment.updatedAt !== 'string') {
171
+ warnPrCommentImport(thread.id ?? undefined, 'Thread has a comment with missing fields.');
172
+ continue;
173
+ }
174
+ commentImports.push({
175
+ type: 'reply',
176
+ id: comment.id,
177
+ filePath,
178
+ position,
179
+ body: comment.body,
180
+ author: comment.author?.login ?? undefined,
181
+ createdAt: comment.createdAt,
182
+ updatedAt: comment.updatedAt,
183
+ });
184
+ }
185
+ return commentImports;
186
+ }
187
+ export function parseGitHubPrUrl(url) {
188
+ try {
189
+ const urlObj = new URL(url);
190
+ const pathParts = urlObj.pathname.split('/').filter(Boolean);
191
+ if (pathParts.length < 4 || pathParts[2] !== 'pull') {
192
+ return null;
193
+ }
194
+ const owner = pathParts[0];
195
+ const repo = pathParts[1];
196
+ const pullNumber = parseInt(pathParts[3], 10);
197
+ if (isNaN(pullNumber)) {
198
+ return null;
199
+ }
200
+ return { owner, repo, pullNumber, hostname: urlObj.hostname };
201
+ }
202
+ catch {
203
+ return null;
204
+ }
205
+ }
206
+ export function getPrPatch(prArg) {
207
+ try {
208
+ const patch = execFileSync('gh', ['pr', 'diff', prArg], {
209
+ encoding: 'utf8',
210
+ stdio: ['ignore', 'pipe', 'pipe'],
211
+ });
212
+ if (!patch.trim()) {
213
+ throw new Error('No diff content returned from gh pr diff');
214
+ }
215
+ return patch;
216
+ }
217
+ catch (error) {
218
+ throw formatGhCommandError(error);
219
+ }
220
+ }
221
+ export function parsePrCommentImportsResponse(response) {
222
+ const errors = response.errors?.map((error) => error.message).filter((message) => message) ?? [];
223
+ if (errors.length > 0) {
224
+ throw new Error(errors.join('\n'));
225
+ }
226
+ const reviewThreads = response.data?.repository?.pullRequest?.reviewThreads;
227
+ if (!reviewThreads) {
228
+ return {
229
+ commentImports: [],
230
+ pageInfo: {
231
+ hasNextPage: false,
232
+ endCursor: null,
233
+ },
234
+ };
235
+ }
236
+ return {
237
+ commentImports: (reviewThreads.nodes ?? []).flatMap((thread) => toCommentImportsForThread(thread)),
238
+ pageInfo: {
239
+ hasNextPage: reviewThreads.pageInfo?.hasNextPage === true,
240
+ endCursor: reviewThreads.pageInfo?.endCursor ?? null,
241
+ },
242
+ };
243
+ }
244
+ export function getPrCommentImports(prArg) {
245
+ const pullRequestInfo = parseGitHubPrUrl(prArg);
246
+ if (!pullRequestInfo) {
247
+ throw new Error('Invalid GitHub PR URL');
248
+ }
249
+ const { owner, repo, pullNumber, hostname } = pullRequestInfo;
250
+ const commentImports = [];
251
+ let endCursor = null;
252
+ while (true) {
253
+ try {
254
+ const args = [
255
+ 'api',
256
+ 'graphql',
257
+ '--hostname',
258
+ hostname,
259
+ '-f',
260
+ `query=${PR_REVIEW_THREADS_GRAPHQL_QUERY}`,
261
+ '-F',
262
+ `owner=${owner}`,
263
+ '-F',
264
+ `repo=${repo}`,
265
+ '-F',
266
+ `number=${pullNumber}`,
267
+ ];
268
+ if (endCursor) {
269
+ args.push('-F', `endCursor=${endCursor}`);
270
+ }
271
+ const stdout = execFileSync('gh', args, {
272
+ encoding: 'utf8',
273
+ stdio: ['ignore', 'pipe', 'pipe'],
274
+ });
275
+ const parsed = JSON.parse(stdout);
276
+ const page = parsePrCommentImportsResponse(parsed);
277
+ commentImports.push(...page.commentImports);
278
+ if (!page.pageInfo.hasNextPage) {
279
+ return Promise.resolve(commentImports);
280
+ }
281
+ if (!page.pageInfo.endCursor) {
282
+ throw new Error('GitHub GraphQL response indicated more pages without endCursor.');
283
+ }
284
+ endCursor = page.pageInfo.endCursor;
285
+ }
286
+ catch (error) {
287
+ if (error instanceof SyntaxError) {
288
+ throw new Error('Invalid JSON returned from gh api graphql');
289
+ }
290
+ if (error instanceof Error && !('stderr' in error)) {
291
+ throw error;
292
+ }
293
+ throw formatGhCommandError(error);
294
+ }
295
+ }
296
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,341 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { parseGitHubPrUrl, parsePrCommentImportsResponse } from './github';
3
+ function createReviewComment(overrides = {}) {
4
+ return {
5
+ id: 'COMMENT_1',
6
+ body: 'Imported comment',
7
+ createdAt: '2026-03-25T09:00:00Z',
8
+ updatedAt: '2026-03-25T09:05:00Z',
9
+ author: { login: 'octocat' },
10
+ ...overrides,
11
+ };
12
+ }
13
+ function createReviewThread(overrides = {}) {
14
+ return {
15
+ id: 'THREAD_1',
16
+ isResolved: false,
17
+ isOutdated: false,
18
+ subjectType: 'LINE',
19
+ path: 'src/example.ts',
20
+ diffSide: 'RIGHT',
21
+ startDiffSide: null,
22
+ line: 12,
23
+ startLine: null,
24
+ originalLine: 11,
25
+ originalStartLine: null,
26
+ comments: {
27
+ nodes: [createReviewComment()],
28
+ },
29
+ ...overrides,
30
+ };
31
+ }
32
+ afterEach(() => {
33
+ vi.restoreAllMocks();
34
+ });
35
+ describe('CLI GitHub utils', () => {
36
+ describe('parsePrCommentImportsResponse', () => {
37
+ it('imports unresolved inline threads, sorts comments, and skips non-importable threads', () => {
38
+ const response = {
39
+ data: {
40
+ repository: {
41
+ pullRequest: {
42
+ reviewThreads: {
43
+ nodes: [
44
+ createReviewThread({
45
+ id: 'THREAD_UNRESOLVED',
46
+ diffSide: 'RIGHT',
47
+ line: 14,
48
+ comments: {
49
+ nodes: [
50
+ createReviewComment({
51
+ id: 'COMMENT_REPLY',
52
+ body: 'Second comment',
53
+ createdAt: '2026-03-25T09:10:00Z',
54
+ updatedAt: '2026-03-25T09:12:00Z',
55
+ author: { login: 'reviewer-2' },
56
+ }),
57
+ createReviewComment({
58
+ id: 'COMMENT_ROOT',
59
+ body: 'First comment',
60
+ createdAt: '2026-03-25T09:00:00Z',
61
+ updatedAt: '2026-03-25T09:05:00Z',
62
+ author: { login: 'reviewer-1' },
63
+ }),
64
+ ],
65
+ },
66
+ }),
67
+ createReviewThread({
68
+ id: 'THREAD_RESOLVED',
69
+ isResolved: true,
70
+ }),
71
+ createReviewThread({
72
+ id: 'THREAD_OUTDATED',
73
+ isOutdated: true,
74
+ }),
75
+ createReviewThread({
76
+ id: 'THREAD_FILE',
77
+ subjectType: 'FILE',
78
+ }),
79
+ ],
80
+ pageInfo: {
81
+ hasNextPage: true,
82
+ endCursor: 'CURSOR_1',
83
+ },
84
+ },
85
+ },
86
+ },
87
+ },
88
+ };
89
+ const result = parsePrCommentImportsResponse(response);
90
+ expect(result).toEqual({
91
+ commentImports: [
92
+ {
93
+ type: 'thread',
94
+ id: 'COMMENT_ROOT',
95
+ filePath: 'src/example.ts',
96
+ position: { side: 'new', line: 14 },
97
+ body: 'First comment',
98
+ author: 'reviewer-1',
99
+ createdAt: '2026-03-25T09:00:00Z',
100
+ updatedAt: '2026-03-25T09:05:00Z',
101
+ },
102
+ {
103
+ type: 'reply',
104
+ id: 'COMMENT_REPLY',
105
+ filePath: 'src/example.ts',
106
+ position: { side: 'new', line: 14 },
107
+ body: 'Second comment',
108
+ author: 'reviewer-2',
109
+ createdAt: '2026-03-25T09:10:00Z',
110
+ updatedAt: '2026-03-25T09:12:00Z',
111
+ },
112
+ ],
113
+ pageInfo: {
114
+ hasNextPage: true,
115
+ endCursor: 'CURSOR_1',
116
+ },
117
+ });
118
+ });
119
+ it('maps RIGHT and LEFT threads to new and old diff positions for single and multi-line comments', () => {
120
+ const response = {
121
+ data: {
122
+ repository: {
123
+ pullRequest: {
124
+ reviewThreads: {
125
+ nodes: [
126
+ createReviewThread({
127
+ id: 'THREAD_RIGHT_SINGLE',
128
+ diffSide: 'RIGHT',
129
+ line: 20,
130
+ startLine: 20,
131
+ startDiffSide: null,
132
+ comments: {
133
+ nodes: [createReviewComment({ id: 'COMMENT_RIGHT_SINGLE' })],
134
+ },
135
+ }),
136
+ createReviewThread({
137
+ id: 'THREAD_RIGHT_MULTI',
138
+ diffSide: 'RIGHT',
139
+ line: 24,
140
+ startLine: 21,
141
+ startDiffSide: 'RIGHT',
142
+ comments: {
143
+ nodes: [createReviewComment({ id: 'COMMENT_RIGHT_MULTI' })],
144
+ },
145
+ }),
146
+ createReviewThread({
147
+ id: 'THREAD_LEFT_SINGLE',
148
+ diffSide: 'LEFT',
149
+ line: null,
150
+ originalLine: 8,
151
+ originalStartLine: null,
152
+ startDiffSide: null,
153
+ comments: {
154
+ nodes: [createReviewComment({ id: 'COMMENT_LEFT_SINGLE' })],
155
+ },
156
+ }),
157
+ createReviewThread({
158
+ id: 'THREAD_LEFT_MULTI',
159
+ diffSide: 'LEFT',
160
+ line: null,
161
+ originalLine: 11,
162
+ originalStartLine: 7,
163
+ startDiffSide: 'LEFT',
164
+ comments: {
165
+ nodes: [createReviewComment({ id: 'COMMENT_LEFT_MULTI' })],
166
+ },
167
+ }),
168
+ ],
169
+ pageInfo: {
170
+ hasNextPage: false,
171
+ endCursor: null,
172
+ },
173
+ },
174
+ },
175
+ },
176
+ },
177
+ };
178
+ const result = parsePrCommentImportsResponse(response);
179
+ expect(result.commentImports).toEqual([
180
+ {
181
+ type: 'thread',
182
+ id: 'COMMENT_RIGHT_SINGLE',
183
+ filePath: 'src/example.ts',
184
+ position: { side: 'new', line: 20 },
185
+ body: 'Imported comment',
186
+ author: 'octocat',
187
+ createdAt: '2026-03-25T09:00:00Z',
188
+ updatedAt: '2026-03-25T09:05:00Z',
189
+ },
190
+ {
191
+ type: 'thread',
192
+ id: 'COMMENT_RIGHT_MULTI',
193
+ filePath: 'src/example.ts',
194
+ position: { side: 'new', line: { start: 21, end: 24 } },
195
+ body: 'Imported comment',
196
+ author: 'octocat',
197
+ createdAt: '2026-03-25T09:00:00Z',
198
+ updatedAt: '2026-03-25T09:05:00Z',
199
+ },
200
+ {
201
+ type: 'thread',
202
+ id: 'COMMENT_LEFT_SINGLE',
203
+ filePath: 'src/example.ts',
204
+ position: { side: 'old', line: 8 },
205
+ body: 'Imported comment',
206
+ author: 'octocat',
207
+ createdAt: '2026-03-25T09:00:00Z',
208
+ updatedAt: '2026-03-25T09:05:00Z',
209
+ },
210
+ {
211
+ type: 'thread',
212
+ id: 'COMMENT_LEFT_MULTI',
213
+ filePath: 'src/example.ts',
214
+ position: { side: 'old', line: { start: 7, end: 11 } },
215
+ body: 'Imported comment',
216
+ author: 'octocat',
217
+ createdAt: '2026-03-25T09:00:00Z',
218
+ updatedAt: '2026-03-25T09:05:00Z',
219
+ },
220
+ ]);
221
+ });
222
+ it('warns and skips threads with invalid line mapping', () => {
223
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
224
+ const response = {
225
+ data: {
226
+ repository: {
227
+ pullRequest: {
228
+ reviewThreads: {
229
+ nodes: [
230
+ createReviewThread({
231
+ id: 'THREAD_RIGHT_MISSING_LINE',
232
+ diffSide: 'RIGHT',
233
+ line: null,
234
+ }),
235
+ createReviewThread({
236
+ id: 'THREAD_LEFT_MISSING_ORIGINAL',
237
+ diffSide: 'LEFT',
238
+ line: null,
239
+ originalLine: null,
240
+ }),
241
+ createReviewThread({
242
+ id: 'THREAD_RIGHT_BAD_RANGE',
243
+ diffSide: 'RIGHT',
244
+ line: 9,
245
+ startLine: 12,
246
+ startDiffSide: 'RIGHT',
247
+ }),
248
+ createReviewThread({
249
+ id: 'THREAD_LEFT_BAD_SIDE',
250
+ diffSide: 'LEFT',
251
+ line: null,
252
+ originalLine: 9,
253
+ originalStartLine: 7,
254
+ startDiffSide: 'RIGHT',
255
+ }),
256
+ ],
257
+ pageInfo: {
258
+ hasNextPage: false,
259
+ endCursor: null,
260
+ },
261
+ },
262
+ },
263
+ },
264
+ },
265
+ };
266
+ const result = parsePrCommentImportsResponse(response);
267
+ expect(result.commentImports).toEqual([]);
268
+ expect(warnSpy).toHaveBeenCalledTimes(4);
269
+ expect(warnSpy).toHaveBeenNthCalledWith(1, 'Warning: Skipping PR review thread THREAD_RIGHT_MISSING_LINE: RIGHT thread is missing line.');
270
+ expect(warnSpy).toHaveBeenNthCalledWith(2, 'Warning: Skipping PR review thread THREAD_LEFT_MISSING_ORIGINAL: LEFT thread is missing originalLine.');
271
+ expect(warnSpy).toHaveBeenNthCalledWith(3, 'Warning: Skipping PR review thread THREAD_RIGHT_BAD_RANGE: RIGHT thread has an invalid multi-line range.');
272
+ expect(warnSpy).toHaveBeenNthCalledWith(4, 'Warning: Skipping PR review thread THREAD_LEFT_BAD_SIDE: LEFT thread has mismatched startDiffSide.');
273
+ });
274
+ });
275
+ describe('parseGitHubPrUrl', () => {
276
+ it('should parse valid GitHub PR URLs', () => {
277
+ const result = parseGitHubPrUrl('https://github.com/owner/repo/pull/123');
278
+ expect(result).toEqual({
279
+ owner: 'owner',
280
+ repo: 'repo',
281
+ pullNumber: 123,
282
+ hostname: 'github.com',
283
+ });
284
+ });
285
+ it('should parse GitHub PR URLs with additional path segments', () => {
286
+ const result = parseGitHubPrUrl('https://github.com/owner/repo/pull/456/files');
287
+ expect(result).toEqual({
288
+ owner: 'owner',
289
+ repo: 'repo',
290
+ pullNumber: 456,
291
+ hostname: 'github.com',
292
+ });
293
+ });
294
+ it('should parse GitHub PR URLs with query parameters', () => {
295
+ const result = parseGitHubPrUrl('https://github.com/owner/repo/pull/789?tab=files');
296
+ expect(result).toEqual({
297
+ owner: 'owner',
298
+ repo: 'repo',
299
+ pullNumber: 789,
300
+ hostname: 'github.com',
301
+ });
302
+ });
303
+ it('should handle URLs with hyphens and underscores in owner/repo names', () => {
304
+ const result = parseGitHubPrUrl('https://github.com/owner-name/repo_name/pull/123');
305
+ expect(result).toEqual({
306
+ owner: 'owner-name',
307
+ repo: 'repo_name',
308
+ pullNumber: 123,
309
+ hostname: 'github.com',
310
+ });
311
+ });
312
+ it('should parse GitHub Enterprise PR URLs', () => {
313
+ const result1 = parseGitHubPrUrl('https://github.enterprise.com/owner/repo/pull/123');
314
+ expect(result1).toEqual({
315
+ owner: 'owner',
316
+ repo: 'repo',
317
+ pullNumber: 123,
318
+ hostname: 'github.enterprise.com',
319
+ });
320
+ const result2 = parseGitHubPrUrl('https://git.company.io/team/project/pull/456');
321
+ expect(result2).toEqual({
322
+ owner: 'team',
323
+ repo: 'project',
324
+ pullNumber: 456,
325
+ hostname: 'git.company.io',
326
+ });
327
+ });
328
+ it('should return null for invalid URLs', () => {
329
+ expect(parseGitHubPrUrl('not-a-url')).toBe(null);
330
+ expect(parseGitHubPrUrl('https://github.com/owner/repo/issues/123')).toBe(null);
331
+ expect(parseGitHubPrUrl('https://github.com/owner/repo')).toBe(null);
332
+ expect(parseGitHubPrUrl('https://github.com/owner/repo/pull/abc')).toBe(null);
333
+ });
334
+ it('should handle malformed URLs gracefully', () => {
335
+ expect(parseGitHubPrUrl('')).toBe(null);
336
+ expect(parseGitHubPrUrl('https://github.com')).toBe(null);
337
+ expect(parseGitHubPrUrl('https://github.com/owner')).toBe(null);
338
+ expect(parseGitHubPrUrl('https://github.com/owner/repo/pull')).toBe(null);
339
+ });
340
+ });
341
+ });