difit 3.1.18 → 4.0.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 (94) hide show
  1. package/README.ja.md +30 -3
  2. package/README.ko.md +30 -3
  3. package/README.md +30 -3
  4. package/README.zh.md +30 -3
  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 +26 -1
  10. package/dist/cli/index.test.js +206 -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-B9N-f0iT.js} +1 -1
  15. package/dist/client/assets/{_baseUniq-DivSZEOF.js → _baseUniq-tbL7nVvN.js} +1 -1
  16. package/dist/client/assets/{arc-c0kacVOL.js → arc-BOY-7mep.js} +1 -1
  17. package/dist/client/assets/{architectureDiagram-2XIMDMQ5-ubymLNEe.js → architectureDiagram-2XIMDMQ5-59AvHaSB.js} +1 -1
  18. package/dist/client/assets/{blockDiagram-WCTKOSBZ-F9D8w4_S.js → blockDiagram-WCTKOSBZ-DXIlumQk.js} +1 -1
  19. package/dist/client/assets/{c4Diagram-IC4MRINW-JE9Kx4yQ.js → c4Diagram-IC4MRINW-BbfZ0uRn.js} +1 -1
  20. package/dist/client/assets/channel-cZXsTJxA.js +1 -0
  21. package/dist/client/assets/{chunk-4BX2VUAB-CYOCoDMc.js → chunk-4BX2VUAB-l7rcB2IW.js} +1 -1
  22. package/dist/client/assets/{chunk-55IACEB6-PRBuiJg9.js → chunk-55IACEB6-CrZL3qv9.js} +1 -1
  23. package/dist/client/assets/{chunk-FMBD7UC4-C0eJ7JsI.js → chunk-FMBD7UC4-CrKv7ndg.js} +1 -1
  24. package/dist/client/assets/{chunk-JSJVCQXG-QZotPSqo.js → chunk-JSJVCQXG-DyBDhAEM.js} +1 -1
  25. package/dist/client/assets/{chunk-KX2RTZJC-B8du3tt8.js → chunk-KX2RTZJC-By5mkZmU.js} +1 -1
  26. package/dist/client/assets/{chunk-NQ4KR5QH-B10ldi5m.js → chunk-NQ4KR5QH-C30p9xRx.js} +1 -1
  27. package/dist/client/assets/{chunk-QZHKN3VN-CpwW9rUQ.js → chunk-QZHKN3VN-DVlhR2wU.js} +1 -1
  28. package/dist/client/assets/{chunk-WL4C6EOR-DwKPHpbL.js → chunk-WL4C6EOR-Cn7a6CO3.js} +1 -1
  29. package/dist/client/assets/classDiagram-VBA2DB6C-B_coIPEy.js +1 -0
  30. package/dist/client/assets/classDiagram-v2-RAHNMMFH-B_coIPEy.js +1 -0
  31. package/dist/client/assets/clone-BjaT2HOk.js +1 -0
  32. package/dist/client/assets/{cose-bilkent-S5V4N54A-p76yal75.js → cose-bilkent-S5V4N54A-LyauIk_9.js} +1 -1
  33. package/dist/client/assets/{dagre-KLK3FWXG-CdDyed3V.js → dagre-KLK3FWXG-DRWb2KE3.js} +1 -1
  34. package/dist/client/assets/{diagram-E7M64L7V-BaC8dXuW.js → diagram-E7M64L7V-ChT6mNWK.js} +1 -1
  35. package/dist/client/assets/{diagram-IFDJBPK2-BGf8xwJI.js → diagram-IFDJBPK2-CqbTduoP.js} +1 -1
  36. package/dist/client/assets/{diagram-P4PSJMXO-D3j16gBZ.js → diagram-P4PSJMXO-Bzv5Z3ri.js} +1 -1
  37. package/dist/client/assets/{erDiagram-INFDFZHY-DFpDdocf.js → erDiagram-INFDFZHY-CvXfUZ4L.js} +1 -1
  38. package/dist/client/assets/{flowDiagram-PKNHOUZH-Cz4mb4IF.js → flowDiagram-PKNHOUZH-CxmpNUKq.js} +1 -1
  39. package/dist/client/assets/{ganttDiagram-A5KZAMGK-CNzY9ua5.js → ganttDiagram-A5KZAMGK-9LpZCsg6.js} +1 -1
  40. package/dist/client/assets/{gitGraphDiagram-K3NZZRJ6-DCSxL8EQ.js → gitGraphDiagram-K3NZZRJ6-C6yZOrQJ.js} +1 -1
  41. package/dist/client/assets/{graph-BC2BV1-T.js → graph-bUZ7uHLW.js} +1 -1
  42. package/dist/client/assets/index-BLNN1bfE.js +98 -0
  43. package/dist/client/assets/index-VxkpzDXr.css +1 -0
  44. package/dist/client/assets/{infoDiagram-LFFYTUFH-BKSspZbH.js → infoDiagram-LFFYTUFH-Djdy3W21.js} +1 -1
  45. package/dist/client/assets/{ishikawaDiagram-PHBUUO56-DZ2IRYwc.js → ishikawaDiagram-PHBUUO56-oOdwCpeS.js} +1 -1
  46. package/dist/client/assets/{journeyDiagram-4ABVD52K-BrjXAkii.js → journeyDiagram-4ABVD52K-DTb_nGAw.js} +1 -1
  47. package/dist/client/assets/{kanban-definition-K7BYSVSG-B1mfOekw.js → kanban-definition-K7BYSVSG-CMtP7pHA.js} +1 -1
  48. package/dist/client/assets/{layout-CWTG02uT.js → layout-CXr5MatK.js} +1 -1
  49. package/dist/client/assets/{linear-CGgOKp1d.js → linear-pOMS9pjV.js} +1 -1
  50. package/dist/client/assets/{mermaid.core-DTPtVBG7.js → mermaid.core-DV5JJ1Ie.js} +4 -4
  51. package/dist/client/assets/{mindmap-definition-YRQLILUH-DByVRPFT.js → mindmap-definition-YRQLILUH-DN-sbonc.js} +1 -1
  52. package/dist/client/assets/{pieDiagram-SKSYHLDU-DEgvAxAy.js → pieDiagram-SKSYHLDU-tAHCkgh1.js} +1 -1
  53. package/dist/client/assets/{prism-csharp-DqTrHqwJ.js → prism-csharp-5CQ0RcEE.js} +1 -1
  54. package/dist/client/assets/{prism-elixir-DEJaM00V.js → prism-elixir-BSOTyVg2.js} +1 -1
  55. package/dist/client/assets/{prism-hcl-HvJ0aPiH.js → prism-hcl-BYvi1mtM.js} +1 -1
  56. package/dist/client/assets/{prism-java-DDUFERTh.js → prism-java-DMU2FM4X.js} +1 -1
  57. package/dist/client/assets/{prism-perl-CNA3SNC9.js → prism-perl-CpfvaEQk.js} +1 -1
  58. package/dist/client/assets/{prism-php-hBQuhE2A.js → prism-php-SC920LoD.js} +1 -1
  59. package/dist/client/assets/{prism-ruby-BKap8imy.js → prism-ruby-DZph-YiO.js} +1 -1
  60. package/dist/client/assets/{prism-solidity-DHc7LZHq.js → prism-solidity-qTLbmiAT.js} +1 -1
  61. package/dist/client/assets/{quadrantDiagram-337W2JSQ-DTtikTvc.js → quadrantDiagram-337W2JSQ-B0wODmgR.js} +1 -1
  62. package/dist/client/assets/{requirementDiagram-Z7DCOOCP-B34R-xD0.js → requirementDiagram-Z7DCOOCP-A3aeHC06.js} +1 -1
  63. package/dist/client/assets/{sankeyDiagram-WA2Y5GQK-Dts1ZXRC.js → sankeyDiagram-WA2Y5GQK-BWa6kZhG.js} +1 -1
  64. package/dist/client/assets/{sequenceDiagram-2WXFIKYE-DzM3WhEY.js → sequenceDiagram-2WXFIKYE-Cx_COX9G.js} +1 -1
  65. package/dist/client/assets/{stateDiagram-RAJIS63D-B2dF8YnK.js → stateDiagram-RAJIS63D-BXGnN6rZ.js} +1 -1
  66. package/dist/client/assets/stateDiagram-v2-FVOUBMTO-CMw3xNha.js +1 -0
  67. package/dist/client/assets/{timeline-definition-YZTLITO2-BO4OtcEm.js → timeline-definition-YZTLITO2-DbqaUm9k.js} +1 -1
  68. package/dist/client/assets/{treemap-KZPCXAKY-DaXnvVRH.js → treemap-KZPCXAKY-CfEujPCR.js} +1 -1
  69. package/dist/client/assets/{vennDiagram-LZ73GAT5-AIMhd8Js.js → vennDiagram-LZ73GAT5-CqJE8CAD.js} +1 -1
  70. package/dist/client/assets/{xychartDiagram-JWTSCODW-Ch6W1f7P.js → xychartDiagram-JWTSCODW-CfdDvzHC.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.d.ts +1 -0
  75. package/dist/server/git-diff.js +33 -6
  76. package/dist/server/git-diff.test.js +26 -0
  77. package/dist/server/server.d.ts +2 -0
  78. package/dist/server/server.js +107 -34
  79. package/dist/server/server.test.js +120 -0
  80. package/dist/types/diff.d.ts +73 -14
  81. package/dist/utils/commentFormatting.d.ts +4 -2
  82. package/dist/utils/commentFormatting.js +57 -19
  83. package/dist/utils/commentImports.d.ts +9 -0
  84. package/dist/utils/commentImports.js +264 -0
  85. package/dist/utils/commentImports.test.d.ts +1 -0
  86. package/dist/utils/commentImports.test.js +197 -0
  87. package/package.json +1 -1
  88. package/dist/client/assets/channel-Ca4c0q8d.js +0 -1
  89. package/dist/client/assets/classDiagram-VBA2DB6C-CJLw9sK7.js +0 -1
  90. package/dist/client/assets/classDiagram-v2-RAHNMMFH-CJLw9sK7.js +0 -1
  91. package/dist/client/assets/clone-D0mDLEir.js +0 -1
  92. package/dist/client/assets/index-DHt9OwVU.css +0 -1
  93. package/dist/client/assets/index-mE8CA51x.js +0 -95
  94. package/dist/client/assets/stateDiagram-v2-FVOUBMTO-ReD0hBzH.js +0 -1
@@ -59,6 +59,7 @@ vi.mock('./git-diff.js', () => {
59
59
  isEmpty: false,
60
60
  });
61
61
  getBlobContent = vi.fn().mockResolvedValue(Buffer.from('mock image data'));
62
+ getLineCount = vi.fn().mockResolvedValue(42);
62
63
  getGeneratedStatus = vi.fn().mockResolvedValue({
63
64
  isGenerated: true,
64
65
  source: 'content',
@@ -255,6 +256,74 @@ describe('Server Integration Tests', () => {
255
256
  expect(response.ok).toBe(true);
256
257
  expect(data).toHaveProperty('ignoreWhitespace', true);
257
258
  });
259
+ it('GET /api/diff returns comment import payload when configured', async () => {
260
+ const importedComments = [
261
+ {
262
+ type: 'thread',
263
+ filePath: 'test.js',
264
+ position: { side: 'new', line: 10 },
265
+ body: 'Imported comment',
266
+ },
267
+ ];
268
+ const importServer = await startServer({
269
+ targetCommitish: 'HEAD',
270
+ baseCommitish: 'HEAD^',
271
+ preferredPort: 9034,
272
+ commentImports: importedComments,
273
+ });
274
+ servers.push(importServer.server);
275
+ const response = await fetch(`http://localhost:${importServer.port}/api/diff`);
276
+ const data = (await response.json());
277
+ expect(response.ok).toBe(true);
278
+ expect(data.commentImports).toEqual(importedComments);
279
+ expect(data.commentImportId).toEqual(expect.any(String));
280
+ });
281
+ it('GET /api/diff returns clearComments together with comment import payload', async () => {
282
+ const importedComments = [
283
+ {
284
+ type: 'thread',
285
+ filePath: 'test.js',
286
+ position: { side: 'new', line: 10 },
287
+ body: 'Imported comment',
288
+ },
289
+ ];
290
+ const importServer = await startServer({
291
+ targetCommitish: 'HEAD',
292
+ baseCommitish: 'HEAD^',
293
+ preferredPort: 9037,
294
+ clearComments: true,
295
+ commentImports: importedComments,
296
+ });
297
+ servers.push(importServer.server);
298
+ const response = await fetch(`http://localhost:${importServer.port}/api/diff`);
299
+ const data = (await response.json());
300
+ expect(response.ok).toBe(true);
301
+ expect(data.clearComments).toBe(true);
302
+ expect(data.commentImports).toEqual(importedComments);
303
+ expect(data.commentImportId).toEqual(expect.any(String));
304
+ });
305
+ it('GET /api/diff omits comment import payload after revision changes', async () => {
306
+ const importedComments = [
307
+ {
308
+ type: 'thread',
309
+ filePath: 'test.js',
310
+ position: { side: 'new', line: 10 },
311
+ body: 'Imported comment',
312
+ },
313
+ ];
314
+ const importServer = await startServer({
315
+ targetCommitish: 'HEAD',
316
+ baseCommitish: 'HEAD^',
317
+ preferredPort: 9038,
318
+ commentImports: importedComments,
319
+ });
320
+ servers.push(importServer.server);
321
+ const response = await fetch(`http://localhost:${importServer.port}/api/diff?base=main&target=feature`);
322
+ const data = (await response.json());
323
+ expect(response.ok).toBe(true);
324
+ expect(data.commentImports).toBeUndefined();
325
+ expect(data.commentImportId).toBeUndefined();
326
+ });
258
327
  it('GET /api/generated-status/* returns generated status', async () => {
259
328
  const response = await fetch(`http://localhost:${port}/api/generated-status/src/query.ts?ref=HEAD`);
260
329
  const data = (await response.json());
@@ -329,6 +398,7 @@ describe('Server Integration Tests', () => {
329
398
  const response = await fetch(`http://localhost:${port}/api/comments-output`);
330
399
  const output = await response.text();
331
400
  expect(response.ok).toBe(true);
401
+ expect(response.headers.get('Content-Type')).toContain('text/plain');
332
402
  expect(output).toContain('Comments from review session');
333
403
  expect(output).toContain('test.js:L10');
334
404
  expect(output).toContain('First comment');
@@ -427,6 +497,17 @@ describe('Server Integration Tests', () => {
427
497
  // But the server should not crash
428
498
  expect([200, 404]).toContain(response.status);
429
499
  });
500
+ it('returns 404 for unknown paths in production mode', async () => {
501
+ process.env.NODE_ENV = 'production';
502
+ const result = await startServer({
503
+ targetCommitish: 'HEAD',
504
+ baseCommitish: 'HEAD^',
505
+ preferredPort: 9055,
506
+ });
507
+ servers.push(result.server);
508
+ const response = await fetch(`http://localhost:${result.port}/not-a-route`);
509
+ expect(response.status).toBe(404);
510
+ });
430
511
  });
431
512
  describe('Mode option handling', () => {
432
513
  it('accepts mode option in server configuration', async () => {
@@ -529,6 +610,39 @@ describe('Server Integration Tests', () => {
529
610
  expect(response.headers.get('Access-Control-Allow-Headers')).toBe('Origin, X-Requested-With, Content-Type, Accept');
530
611
  });
531
612
  });
613
+ describe('Line count API', () => {
614
+ let port;
615
+ beforeEach(async () => {
616
+ const result = await startServer({
617
+ targetCommitish: 'HEAD',
618
+ baseCommitish: 'HEAD^',
619
+ preferredPort: 9050,
620
+ });
621
+ servers.push(result.server);
622
+ port = result.port;
623
+ });
624
+ it('returns line counts for repository files', async () => {
625
+ const response = await fetch(`http://localhost:${port}/api/line-count/src%2Findex.ts?oldRef=HEAD~1&newRef=HEAD`);
626
+ const data = (await response.json());
627
+ expect(response.ok).toBe(true);
628
+ expect(data).toEqual({
629
+ oldLineCount: 42,
630
+ newLineCount: 42,
631
+ });
632
+ });
633
+ it('rejects paths outside repository', async () => {
634
+ const response = await fetch(`http://localhost:${port}/api/line-count/..%2Foutside.txt`);
635
+ const data = (await response.json());
636
+ expect(response.status).toBe(400);
637
+ expect(data).toHaveProperty('error', 'File path outside repository');
638
+ });
639
+ it('rejects oldPath values outside repository', async () => {
640
+ const response = await fetch(`http://localhost:${port}/api/line-count/src%2Findex.ts?oldRef=HEAD~1&oldPath=..%2Foutside.txt`);
641
+ const data = (await response.json());
642
+ expect(response.status).toBe(400);
643
+ expect(data).toHaveProperty('error', 'File path outside repository');
644
+ });
645
+ });
532
646
  describe('Blob API endpoints', () => {
533
647
  let port;
534
648
  beforeEach(async () => {
@@ -609,6 +723,12 @@ describe('Server Integration Tests', () => {
609
723
  expect(response.ok).toBe(true);
610
724
  }
611
725
  });
726
+ it('rejects paths outside repository', async () => {
727
+ const response = await fetch(`http://localhost:${port}/api/blob/..%2Foutside.txt?ref=HEAD`);
728
+ const data = (await response.json());
729
+ expect(response.status).toBe(400);
730
+ expect(data).toHaveProperty('error', 'File path outside repository');
731
+ });
612
732
  });
613
733
  describe('Keep-alive option', () => {
614
734
  it('accepts keepAlive option without error', async () => {
@@ -34,6 +34,18 @@ export interface ParsedDiff {
34
34
  export type DiffViewMode = 'split' | 'unified';
35
35
  export type LegacyDiffViewMode = 'side-by-side' | 'inline';
36
36
  export type DiffSide = 'old' | 'new';
37
+ export type DiffLineRange = number | {
38
+ start: number;
39
+ end: number;
40
+ };
41
+ export interface DiffCommentPosition {
42
+ side: DiffSide;
43
+ line: DiffLineRange;
44
+ }
45
+ export interface DiffCommentCodeSnapshot {
46
+ content: string;
47
+ language?: string;
48
+ }
37
49
  export interface DiffResponse {
38
50
  commit: string;
39
51
  files: DiffFile[];
@@ -47,6 +59,8 @@ export interface DiffResponse {
47
59
  requestedTargetCommitish?: string;
48
60
  clearComments?: boolean;
49
61
  repositoryId?: string;
62
+ commentImports?: CommentImport[];
63
+ commentImportId?: string;
50
64
  }
51
65
  export interface GeneratedStatusResponse {
52
66
  path: string;
@@ -69,38 +83,82 @@ export interface LineSelection {
69
83
  side: DiffSide;
70
84
  lineNumber: number;
71
85
  }
72
- export interface DiffComment {
86
+ export interface LegacyDiffComment {
73
87
  id: string;
74
88
  filePath: string;
75
89
  body: string;
76
90
  author?: string;
77
91
  createdAt: string;
78
92
  updatedAt: string;
79
- position: {
80
- side: DiffSide;
81
- line: number | {
82
- start: number;
83
- end: number;
84
- };
85
- };
86
- codeSnapshot?: {
87
- content: string;
88
- language?: string;
89
- };
93
+ position: DiffCommentPosition;
94
+ codeSnapshot?: DiffCommentCodeSnapshot;
95
+ }
96
+ export interface DiffCommentMessage {
97
+ id: string;
98
+ body: string;
99
+ author?: string;
100
+ createdAt: string;
101
+ updatedAt: string;
102
+ }
103
+ export interface DiffCommentThread {
104
+ id: string;
105
+ filePath: string;
106
+ createdAt: string;
107
+ updatedAt: string;
108
+ position: DiffCommentPosition;
109
+ codeSnapshot?: DiffCommentCodeSnapshot;
110
+ messages: DiffCommentMessage[];
111
+ }
112
+ interface CommentImportBase {
113
+ id?: string;
114
+ filePath: string;
115
+ position: DiffCommentPosition;
116
+ body: string;
117
+ author?: string;
118
+ createdAt?: string;
119
+ updatedAt?: string;
120
+ codeSnapshot?: DiffCommentCodeSnapshot;
90
121
  }
122
+ export interface ThreadCommentImport extends CommentImportBase {
123
+ type: 'thread';
124
+ }
125
+ export interface ReplyCommentImport extends CommentImportBase {
126
+ type: 'reply';
127
+ }
128
+ export type CommentImport = ThreadCommentImport | ReplyCommentImport;
91
129
  export interface ViewedFileRecord {
92
130
  filePath: string;
93
131
  viewedAt: string;
94
132
  diffContentHash: string;
95
133
  }
96
- export interface DiffContextStorage {
134
+ export interface LegacyDiffContextStorage {
97
135
  version: 1;
98
136
  baseCommitish: string;
99
137
  targetCommitish: string;
100
138
  createdAt: string;
101
139
  lastModifiedAt: string;
102
- comments: DiffComment[];
140
+ comments: LegacyDiffComment[];
141
+ viewedFiles: ViewedFileRecord[];
142
+ }
143
+ export interface DiffContextStorage {
144
+ version: 2;
145
+ baseCommitish: string;
146
+ targetCommitish: string;
147
+ createdAt: string;
148
+ lastModifiedAt: string;
149
+ threads: DiffCommentThread[];
103
150
  viewedFiles: ViewedFileRecord[];
151
+ appliedCommentImportIds: string[];
152
+ }
153
+ export interface CommentThread {
154
+ id: string;
155
+ file: string;
156
+ line: LineNumber;
157
+ side?: DiffSide;
158
+ createdAt: string;
159
+ updatedAt: string;
160
+ codeContent?: string;
161
+ messages: DiffCommentMessage[];
104
162
  }
105
163
  export interface RevisionOption {
106
164
  value: string;
@@ -140,3 +198,4 @@ export interface ExpandedRange {
140
198
  export interface ExpandedLine extends DiffLine {
141
199
  isExpanded?: boolean;
142
200
  }
201
+ export {};
@@ -1,4 +1,6 @@
1
- import type { Comment } from '../types/diff';
1
+ import type { Comment, CommentThread } from '../types/diff';
2
2
  export declare function formatCommentPrompt(file: string, line: number | number[], body: string, codeContent?: string): string;
3
3
  export declare function formatAllCommentsPrompt(comments: Comment[]): string;
4
- export declare function formatCommentsOutput(comments: Comment[]): string;
4
+ export declare function formatCommentThreadPrompt(thread: CommentThread): string;
5
+ export declare function formatAllCommentThreadsPrompt(threads: CommentThread[]): string;
6
+ export declare function formatCommentsOutput(input: Comment[] | CommentThread[]): string;
@@ -1,38 +1,56 @@
1
1
  import { hasSuggestionBlock, parseSuggestionBlocks } from './suggestionUtils.js';
2
- export function formatCommentPrompt(file, line, body, codeContent) {
3
- const lineInfo = typeof line === 'number' ? `L${line}` : Array.isArray(line) ? `L${line[0]}-L${line[1]}` : '';
4
- // Handle undefined or null file paths
5
- const filePath = file || '<unknown file>';
6
- // Check if body contains suggestion blocks
2
+ function getLineInfo(line) {
3
+ return typeof line === 'number' ? `L${line}` : `L${line[0]}-L${line[1]}`;
4
+ }
5
+ function formatCommentContent(body, codeContent) {
7
6
  if (hasSuggestionBlock(body)) {
8
7
  const suggestions = parseSuggestionBlocks(body);
9
8
  if (suggestions.length > 0) {
10
- let result = `${filePath}:${lineInfo}`;
11
- // Walk through body preserving text between suggestion blocks
9
+ let result = '';
12
10
  let lastIndex = 0;
13
11
  for (const suggestion of suggestions) {
14
- // Add text before this suggestion block
15
12
  const textBefore = body.slice(lastIndex, suggestion.startIndex).trim();
16
13
  if (textBefore) {
17
- result += `\n${textBefore}`;
14
+ result += `${result ? '\n' : ''}${textBefore}`;
18
15
  }
19
- // Add structured ORIGINAL/SUGGESTED format
20
16
  if (codeContent) {
21
- result += `\nORIGINAL:\n\`\`\`\n${codeContent}\n\`\`\``;
17
+ result += `${result ? '\n' : ''}ORIGINAL:\n\`\`\`\n${codeContent}\n\`\`\``;
22
18
  }
23
- result += `\nSUGGESTED:\n\`\`\`\n${suggestion.suggestedCode}\n\`\`\``;
19
+ result += `${result ? '\n' : ''}SUGGESTED:\n\`\`\`\n${suggestion.suggestedCode}\n\`\`\``;
24
20
  lastIndex = suggestion.endIndex;
25
21
  }
26
- // Add remaining text after the last suggestion block
27
22
  const textAfter = body.slice(lastIndex).trim();
28
23
  if (textAfter) {
29
- result += `\n${textAfter}`;
24
+ result += `${result ? '\n' : ''}${textAfter}`;
30
25
  }
31
26
  return result;
32
27
  }
33
28
  }
34
- // Regular comment without suggestion
35
- return `${filePath}:${lineInfo}\n${body}`;
29
+ return body;
30
+ }
31
+ function normalizeLegacyComment(comment) {
32
+ return {
33
+ id: comment.id,
34
+ file: comment.file,
35
+ line: comment.line,
36
+ side: comment.side,
37
+ createdAt: comment.timestamp,
38
+ updatedAt: comment.timestamp,
39
+ codeContent: comment.codeContent,
40
+ messages: [
41
+ {
42
+ id: comment.id,
43
+ body: comment.body,
44
+ author: comment.author,
45
+ createdAt: comment.timestamp,
46
+ updatedAt: comment.timestamp,
47
+ },
48
+ ],
49
+ };
50
+ }
51
+ export function formatCommentPrompt(file, line, body, codeContent) {
52
+ const filePath = file || '<unknown file>';
53
+ return `${filePath}:${getLineInfo(line)}\n${formatCommentContent(body, codeContent)}`;
36
54
  }
37
55
  export function formatAllCommentsPrompt(comments) {
38
56
  if (comments.length === 0)
@@ -40,13 +58,33 @@ export function formatAllCommentsPrompt(comments) {
40
58
  const prompts = comments.map((comment) => formatCommentPrompt(comment.file, comment.line, comment.body, comment.codeContent));
41
59
  return prompts.join('\n=====\n');
42
60
  }
43
- export function formatCommentsOutput(comments) {
44
- const allPrompts = formatAllCommentsPrompt(comments);
61
+ export function formatCommentThreadPrompt(thread) {
62
+ const sections = [`${thread.file || '<unknown file>'}:${getLineInfo(thread.line)}`];
63
+ thread.messages.forEach((message, index) => {
64
+ if (index === 0) {
65
+ sections.push(formatCommentContent(message.body, thread.codeContent));
66
+ return;
67
+ }
68
+ const replyIndex = index;
69
+ const authorLabel = message.author?.trim() || 'Unknown';
70
+ sections.push(`Reply ${replyIndex} (${authorLabel})`);
71
+ sections.push(formatCommentContent(message.body, thread.codeContent));
72
+ });
73
+ return sections.filter(Boolean).join('\n');
74
+ }
75
+ export function formatAllCommentThreadsPrompt(threads) {
76
+ if (threads.length === 0)
77
+ return '';
78
+ return threads.map((thread) => formatCommentThreadPrompt(thread)).join('\n=====\n');
79
+ }
80
+ export function formatCommentsOutput(input) {
81
+ const threads = input.map((item) => ('messages' in item ? item : normalizeLegacyComment(item)));
82
+ const allPrompts = formatAllCommentThreadsPrompt(threads);
45
83
  return [
46
84
  '\n📝 Comments from review session:',
47
85
  '='.repeat(50),
48
86
  allPrompts,
49
87
  '='.repeat(50),
50
- `Total comments: ${comments.length}\n`,
88
+ `Total comments: ${threads.length}\n`,
51
89
  ].join('\n');
52
90
  }
@@ -0,0 +1,9 @@
1
+ import type { CommentImport, DiffCommentThread } from '../types/diff.js';
2
+ interface MergeCommentImportsResult {
3
+ threads: DiffCommentThread[];
4
+ warnings: string[];
5
+ }
6
+ export declare function parseCommentImportValue(value: string): CommentImport[];
7
+ export declare function serializeCommentImports(commentImports: CommentImport[]): string;
8
+ export declare function mergeCommentImports(existingThreads: DiffCommentThread[], commentImports: CommentImport[]): MergeCommentImportsResult;
9
+ export {};
@@ -0,0 +1,264 @@
1
+ function isRecord(value) {
2
+ return typeof value === 'object' && value !== null;
3
+ }
4
+ function isValidIsoTimestamp(value) {
5
+ return Number.isFinite(Date.parse(value));
6
+ }
7
+ function normalizeTimestamp(value, fieldName) {
8
+ if (value === undefined) {
9
+ return undefined;
10
+ }
11
+ if (typeof value !== 'string' || value.trim().length === 0 || !isValidIsoTimestamp(value)) {
12
+ throw new Error(`Invalid comment import field: ${fieldName}`);
13
+ }
14
+ return value;
15
+ }
16
+ function normalizeLineRange(value) {
17
+ if (typeof value === 'number' && Number.isInteger(value) && value > 0) {
18
+ return value;
19
+ }
20
+ if (!isRecord(value)) {
21
+ throw new Error('Invalid comment import field: position.line');
22
+ }
23
+ const start = value.start;
24
+ const end = value.end;
25
+ if (typeof start !== 'number' ||
26
+ typeof end !== 'number' ||
27
+ !Number.isInteger(start) ||
28
+ !Number.isInteger(end) ||
29
+ start <= 0 ||
30
+ end <= 0 ||
31
+ start > end) {
32
+ throw new Error('Invalid comment import field: position.line');
33
+ }
34
+ return { start, end };
35
+ }
36
+ function normalizePosition(value) {
37
+ if (!isRecord(value)) {
38
+ throw new Error('Invalid comment import field: position');
39
+ }
40
+ if (value.side !== 'old' && value.side !== 'new') {
41
+ throw new Error('Invalid comment import field: position.side');
42
+ }
43
+ return {
44
+ side: value.side,
45
+ line: normalizeLineRange(value.line),
46
+ };
47
+ }
48
+ function normalizeCodeSnapshot(value) {
49
+ if (value === undefined) {
50
+ return undefined;
51
+ }
52
+ if (!isRecord(value) || typeof value.content !== 'string') {
53
+ throw new Error('Invalid comment import field: codeSnapshot');
54
+ }
55
+ if (value.language !== undefined && typeof value.language !== 'string') {
56
+ throw new Error('Invalid comment import field: codeSnapshot.language');
57
+ }
58
+ return {
59
+ content: value.content,
60
+ language: value.language,
61
+ };
62
+ }
63
+ function normalizeOptionalString(value, fieldName) {
64
+ if (value === undefined) {
65
+ return undefined;
66
+ }
67
+ if (typeof value !== 'string') {
68
+ throw new Error(`Invalid comment import field: ${fieldName}`);
69
+ }
70
+ return value;
71
+ }
72
+ function normalizeRequiredBody(value) {
73
+ if (typeof value !== 'string') {
74
+ throw new Error('Invalid comment import field: body');
75
+ }
76
+ const trimmed = value.trim();
77
+ if (trimmed.length === 0) {
78
+ throw new Error('Invalid comment import field: body');
79
+ }
80
+ return trimmed;
81
+ }
82
+ function normalizeCommentImportEntry(value) {
83
+ if (!isRecord(value)) {
84
+ throw new Error('Comment import must be an object');
85
+ }
86
+ if (value.type !== 'thread' && value.type !== 'reply') {
87
+ throw new Error('Invalid comment import field: type');
88
+ }
89
+ if (typeof value.filePath !== 'string' || value.filePath.trim().length === 0) {
90
+ throw new Error('Invalid comment import field: filePath');
91
+ }
92
+ const createdAt = normalizeTimestamp(value.createdAt, 'createdAt');
93
+ const updatedAt = normalizeTimestamp(value.updatedAt, 'updatedAt');
94
+ const normalized = {
95
+ type: value.type,
96
+ id: normalizeOptionalString(value.id, 'id'),
97
+ filePath: value.filePath,
98
+ position: normalizePosition(value.position),
99
+ body: normalizeRequiredBody(value.body),
100
+ author: normalizeOptionalString(value.author, 'author'),
101
+ createdAt,
102
+ updatedAt,
103
+ codeSnapshot: normalizeCodeSnapshot(value.codeSnapshot),
104
+ };
105
+ return normalized;
106
+ }
107
+ function normalizeCommentImports(input) {
108
+ if (Array.isArray(input)) {
109
+ return input.map((entry) => normalizeCommentImportEntry(entry));
110
+ }
111
+ return [normalizeCommentImportEntry(input)];
112
+ }
113
+ export function parseCommentImportValue(value) {
114
+ let parsed;
115
+ try {
116
+ parsed = JSON.parse(value);
117
+ }
118
+ catch {
119
+ throw new Error('Invalid --comment JSON');
120
+ }
121
+ return normalizeCommentImports(parsed);
122
+ }
123
+ function getPositionKey(position) {
124
+ if (typeof position.line === 'number') {
125
+ return `${position.side}:${position.line}`;
126
+ }
127
+ return `${position.side}:${position.line.start}-${position.line.end}`;
128
+ }
129
+ function positionsMatch(left, right) {
130
+ return getPositionKey(left) === getPositionKey(right);
131
+ }
132
+ function normalizeAuthor(author) {
133
+ return author?.trim() || '';
134
+ }
135
+ function messageMatchesImport(message, commentImport, includeId = true) {
136
+ if (includeId && commentImport.id && message.id === commentImport.id) {
137
+ return true;
138
+ }
139
+ if (normalizeAuthor(message.author) !== normalizeAuthor(commentImport.author)) {
140
+ return false;
141
+ }
142
+ if (message.body !== commentImport.body) {
143
+ return false;
144
+ }
145
+ if (commentImport.createdAt && message.createdAt !== commentImport.createdAt) {
146
+ return false;
147
+ }
148
+ return true;
149
+ }
150
+ function rootThreadMatchesImport(thread, commentImport) {
151
+ if (commentImport.id && thread.id === commentImport.id) {
152
+ return true;
153
+ }
154
+ if (thread.filePath !== commentImport.filePath ||
155
+ !positionsMatch(thread.position, commentImport.position)) {
156
+ return false;
157
+ }
158
+ const rootMessage = thread.messages[0];
159
+ if (!rootMessage) {
160
+ return false;
161
+ }
162
+ return messageMatchesImport(rootMessage, commentImport, true);
163
+ }
164
+ function toThreadTimestamp(commentImport, now) {
165
+ const createdAt = commentImport.createdAt ?? now;
166
+ const updatedAt = commentImport.updatedAt ?? createdAt;
167
+ return { createdAt, updatedAt };
168
+ }
169
+ function maxIsoTimestamp(left, right) {
170
+ return left.localeCompare(right) >= 0 ? left : right;
171
+ }
172
+ function sortByNewestThread(left, right) {
173
+ return right.updatedAt.localeCompare(left.updatedAt);
174
+ }
175
+ function createImportedThread(commentImport, now) {
176
+ const { createdAt, updatedAt } = toThreadTimestamp(commentImport, now);
177
+ const threadId = commentImport.id ?? crypto.randomUUID();
178
+ return {
179
+ id: threadId,
180
+ filePath: commentImport.filePath,
181
+ createdAt,
182
+ updatedAt,
183
+ position: commentImport.position,
184
+ codeSnapshot: commentImport.codeSnapshot,
185
+ messages: [
186
+ {
187
+ id: commentImport.id ?? threadId,
188
+ body: commentImport.body,
189
+ author: commentImport.author,
190
+ createdAt,
191
+ updatedAt,
192
+ },
193
+ ],
194
+ };
195
+ }
196
+ function createImportedReply(commentImport, now) {
197
+ const { createdAt, updatedAt } = toThreadTimestamp(commentImport, now);
198
+ return {
199
+ id: commentImport.id ?? crypto.randomUUID(),
200
+ body: commentImport.body,
201
+ author: commentImport.author,
202
+ createdAt,
203
+ updatedAt,
204
+ };
205
+ }
206
+ function serializeLineRange(line) {
207
+ if (typeof line === 'number') {
208
+ return line;
209
+ }
210
+ return { start: line.start, end: line.end };
211
+ }
212
+ export function serializeCommentImports(commentImports) {
213
+ return JSON.stringify(commentImports.map((commentImport) => ({
214
+ type: commentImport.type,
215
+ id: commentImport.id,
216
+ filePath: commentImport.filePath,
217
+ position: {
218
+ side: commentImport.position.side,
219
+ line: serializeLineRange(commentImport.position.line),
220
+ },
221
+ body: commentImport.body,
222
+ author: commentImport.author,
223
+ createdAt: commentImport.createdAt,
224
+ updatedAt: commentImport.updatedAt,
225
+ codeSnapshot: commentImport.codeSnapshot
226
+ ? {
227
+ content: commentImport.codeSnapshot.content,
228
+ language: commentImport.codeSnapshot.language,
229
+ }
230
+ : undefined,
231
+ })));
232
+ }
233
+ export function mergeCommentImports(existingThreads, commentImports) {
234
+ const threads = [...existingThreads];
235
+ const warnings = [];
236
+ for (const commentImport of commentImports) {
237
+ const now = new Date().toISOString();
238
+ if (commentImport.type === 'thread') {
239
+ const hasDuplicate = threads.some((thread) => rootThreadMatchesImport(thread, commentImport));
240
+ if (hasDuplicate) {
241
+ continue;
242
+ }
243
+ threads.push(createImportedThread(commentImport, now));
244
+ continue;
245
+ }
246
+ const targetThreads = threads
247
+ .filter((thread) => thread.filePath === commentImport.filePath &&
248
+ positionsMatch(thread.position, commentImport.position))
249
+ .sort(sortByNewestThread);
250
+ const targetThread = targetThreads[0];
251
+ if (!targetThread) {
252
+ warnings.push(`Skipped reply import for ${commentImport.filePath}:${getPositionKey(commentImport.position)} because no matching thread was found.`);
253
+ continue;
254
+ }
255
+ const hasDuplicateReply = targetThread.messages.some((message) => messageMatchesImport(message, commentImport, true));
256
+ if (hasDuplicateReply) {
257
+ continue;
258
+ }
259
+ const importedReply = createImportedReply(commentImport, now);
260
+ targetThread.messages = [...targetThread.messages, importedReply];
261
+ targetThread.updatedAt = maxIsoTimestamp(targetThread.updatedAt, importedReply.updatedAt);
262
+ }
263
+ return { threads, warnings };
264
+ }
@@ -0,0 +1 @@
1
+ export {};