atl-fetch 1.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 (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +113 -0
  3. package/dist/cli/cli.d.ts +61 -0
  4. package/dist/cli/cli.js +131 -0
  5. package/dist/cli/index.d.ts +5 -0
  6. package/dist/cli/index.js +4 -0
  7. package/dist/index.d.ts +8 -0
  8. package/dist/index.js +13 -0
  9. package/dist/ports/file/file-port.d.ts +89 -0
  10. package/dist/ports/file/file-port.js +155 -0
  11. package/dist/ports/file/index.d.ts +1 -0
  12. package/dist/ports/file/index.js +1 -0
  13. package/dist/ports/http/http-port.d.ts +107 -0
  14. package/dist/ports/http/http-port.js +238 -0
  15. package/dist/ports/http/index.d.ts +1 -0
  16. package/dist/ports/http/index.js +1 -0
  17. package/dist/services/auth/auth-service.d.ts +79 -0
  18. package/dist/services/auth/auth-service.js +158 -0
  19. package/dist/services/auth/index.d.ts +1 -0
  20. package/dist/services/auth/index.js +1 -0
  21. package/dist/services/confluence/confluence-service.d.ts +152 -0
  22. package/dist/services/confluence/confluence-service.js +510 -0
  23. package/dist/services/confluence/index.d.ts +1 -0
  24. package/dist/services/confluence/index.js +1 -0
  25. package/dist/services/diff/diff-service.d.ts +84 -0
  26. package/dist/services/diff/diff-service.js +881 -0
  27. package/dist/services/diff/index.d.ts +1 -0
  28. package/dist/services/diff/index.js +1 -0
  29. package/dist/services/fetch/fetch-service.d.ts +112 -0
  30. package/dist/services/fetch/fetch-service.js +302 -0
  31. package/dist/services/fetch/index.d.ts +1 -0
  32. package/dist/services/fetch/index.js +1 -0
  33. package/dist/services/jira/index.d.ts +1 -0
  34. package/dist/services/jira/index.js +1 -0
  35. package/dist/services/jira/jira-service.d.ts +100 -0
  36. package/dist/services/jira/jira-service.js +354 -0
  37. package/dist/services/output/index.d.ts +4 -0
  38. package/dist/services/output/index.js +4 -0
  39. package/dist/services/output/output-service.d.ts +67 -0
  40. package/dist/services/output/output-service.js +228 -0
  41. package/dist/services/storage/index.d.ts +6 -0
  42. package/dist/services/storage/index.js +6 -0
  43. package/dist/services/storage/storage-service.d.ts +77 -0
  44. package/dist/services/storage/storage-service.js +738 -0
  45. package/dist/services/text-converter/index.d.ts +1 -0
  46. package/dist/services/text-converter/index.js +1 -0
  47. package/dist/services/text-converter/text-converter.d.ts +35 -0
  48. package/dist/services/text-converter/text-converter.js +681 -0
  49. package/dist/services/url-parser/index.d.ts +1 -0
  50. package/dist/services/url-parser/index.js +1 -0
  51. package/dist/services/url-parser/url-parser.d.ts +43 -0
  52. package/dist/services/url-parser/url-parser.js +283 -0
  53. package/dist/types/auth.d.ts +25 -0
  54. package/dist/types/auth.js +1 -0
  55. package/dist/types/confluence.d.ts +68 -0
  56. package/dist/types/confluence.js +1 -0
  57. package/dist/types/diff.d.ts +77 -0
  58. package/dist/types/diff.js +7 -0
  59. package/dist/types/fetch.d.ts +65 -0
  60. package/dist/types/fetch.js +1 -0
  61. package/dist/types/file.d.ts +22 -0
  62. package/dist/types/file.js +1 -0
  63. package/dist/types/http.d.ts +45 -0
  64. package/dist/types/http.js +1 -0
  65. package/dist/types/jira.d.ts +90 -0
  66. package/dist/types/jira.js +1 -0
  67. package/dist/types/output.d.ts +55 -0
  68. package/dist/types/output.js +7 -0
  69. package/dist/types/result.d.ts +104 -0
  70. package/dist/types/result.js +119 -0
  71. package/dist/types/storage.d.ts +209 -0
  72. package/dist/types/storage.js +6 -0
  73. package/dist/types/url-parser.d.ts +46 -0
  74. package/dist/types/url-parser.js +1 -0
  75. package/package.json +106 -0
@@ -0,0 +1,881 @@
1
+ import { structuredPatch } from 'diff';
2
+ /** ANSI カラーコード */
3
+ const ANSI_COLORS = {
4
+ green: '\x1b[32m',
5
+ red: '\x1b[31m',
6
+ reset: '\x1b[0m',
7
+ };
8
+ /**
9
+ * 差分ハンクから差分行を抽出する
10
+ *
11
+ * diff ライブラリの StructuredPatchHunk から DiffLine 配列に変換する。
12
+ * 行のプレフィックス(+, -, 空白)から行の種別を判定する。
13
+ *
14
+ * @param hunk - diff ライブラリの StructuredPatchHunk
15
+ * @returns 差分行の配列
16
+ */
17
+ function extractDiffLines(hunk) {
18
+ return hunk.lines.map((line) => {
19
+ const prefix = line.charAt(0);
20
+ let type;
21
+ let content;
22
+ switch (prefix) {
23
+ case '+':
24
+ type = 'add';
25
+ content = line.substring(1);
26
+ break;
27
+ case '-':
28
+ type = 'remove';
29
+ content = line.substring(1);
30
+ break;
31
+ default:
32
+ type = 'context';
33
+ content = prefix === ' ' ? line.substring(1) : line;
34
+ break;
35
+ }
36
+ return { content, type };
37
+ });
38
+ }
39
+ /**
40
+ * diff ライブラリの StructuredPatchHunk を DiffHunk に変換する
41
+ *
42
+ * @param hunk - diff ライブラリの StructuredPatchHunk
43
+ * @returns DiffHunk
44
+ */
45
+ function convertToDiffHunk(hunk) {
46
+ return {
47
+ lines: extractDiffLines(hunk),
48
+ newLines: hunk.newLines,
49
+ newStart: hunk.newStart,
50
+ oldLines: hunk.oldLines,
51
+ oldStart: hunk.oldStart,
52
+ };
53
+ }
54
+ /**
55
+ * ハンクから差分統計を計算する
56
+ *
57
+ * @param hunks - DiffHunk の配列
58
+ * @returns DiffStats
59
+ */
60
+ function calculateStats(hunks) {
61
+ let additions = 0;
62
+ let deletions = 0;
63
+ for (const hunk of hunks) {
64
+ for (const line of hunk.lines) {
65
+ if (line.type === 'add') {
66
+ additions++;
67
+ }
68
+ else if (line.type === 'remove') {
69
+ deletions++;
70
+ }
71
+ }
72
+ }
73
+ return {
74
+ additions,
75
+ changes: hunks.length,
76
+ deletions,
77
+ };
78
+ }
79
+ /**
80
+ * ハンクを unified diff 形式の文字列にフォーマットする
81
+ *
82
+ * @param hunks - DiffHunk の配列
83
+ * @returns unified diff 形式の文字列
84
+ */
85
+ function formatUnifiedDiff(hunks) {
86
+ if (hunks.length === 0) {
87
+ return '';
88
+ }
89
+ const lines = [];
90
+ for (const hunk of hunks) {
91
+ // ハンクヘッダー
92
+ lines.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`);
93
+ // 差分行
94
+ for (const line of hunk.lines) {
95
+ switch (line.type) {
96
+ case 'add':
97
+ lines.push(`+${line.content}`);
98
+ break;
99
+ case 'remove':
100
+ lines.push(`-${line.content}`);
101
+ break;
102
+ case 'context':
103
+ lines.push(` ${line.content}`);
104
+ break;
105
+ }
106
+ }
107
+ }
108
+ return lines.join('\n');
109
+ }
110
+ /**
111
+ * 2つのテキスト間の差分を計算する
112
+ *
113
+ * unified diff 形式で差分を出力し、差分統計を計算する。
114
+ * diff ライブラリを使用して行単位の差分を検出する。
115
+ *
116
+ * @param oldText - 変更前のテキスト
117
+ * @param newText - 変更後のテキスト
118
+ * @param options - 差分オプション
119
+ * @returns 差分結果
120
+ *
121
+ * @example
122
+ * ```typescript
123
+ * // 基本的な使用例
124
+ * const result = diffText('Hello', 'Goodbye', { colorEnabled: false });
125
+ * console.log(result.formatted);
126
+ * // @@ -1,1 +1,1 @@
127
+ * // -Hello
128
+ * // +Goodbye
129
+ * ```
130
+ *
131
+ * @example
132
+ * ```typescript
133
+ * // 差分統計の確認
134
+ * const result = diffText('A\nB\nC', 'A\nX\nC', { colorEnabled: false });
135
+ * console.log(result.stats.additions); // 1
136
+ * console.log(result.stats.deletions); // 1
137
+ * ```
138
+ */
139
+ export function diffText(oldText, newText, options) {
140
+ const contextLines = options.contextLines ?? 3;
141
+ // diff ライブラリで差分を計算
142
+ const patch = structuredPatch('old', 'new', oldText, newText, undefined, undefined, {
143
+ context: contextLines,
144
+ });
145
+ // ハンクを変換
146
+ const hunks = patch.hunks.map(convertToDiffHunk);
147
+ // 統計を計算
148
+ const stats = calculateStats(hunks);
149
+ // フォーマット
150
+ const formatted = formatUnifiedDiff(hunks);
151
+ return {
152
+ formatted,
153
+ hunks,
154
+ stats,
155
+ };
156
+ }
157
+ /**
158
+ * null 値を「(なし)」に変換する
159
+ *
160
+ * @param value - 値
161
+ * @returns 値または「(なし)」
162
+ */
163
+ function formatNullableValue(value) {
164
+ return value ?? '(なし)';
165
+ }
166
+ /**
167
+ * 削除行(変更前の値)をフォーマットする
168
+ *
169
+ * @param field - フィールド名
170
+ * @param value - 値
171
+ * @param colorEnabled - カラー出力を有効にするか
172
+ * @returns フォーマットされた文字列
173
+ */
174
+ function formatRemoveLine(field, value, colorEnabled) {
175
+ const formattedValue = formatNullableValue(value);
176
+ const line = `- ${field}: ${formattedValue}`;
177
+ if (colorEnabled) {
178
+ return `${ANSI_COLORS.red}${line}${ANSI_COLORS.reset}`;
179
+ }
180
+ return line;
181
+ }
182
+ /**
183
+ * 追加行(変更後の値)をフォーマットする
184
+ *
185
+ * @param field - フィールド名
186
+ * @param value - 値
187
+ * @param colorEnabled - カラー出力を有効にするか
188
+ * @returns フォーマットされた文字列
189
+ */
190
+ function formatAddLine(field, value, colorEnabled) {
191
+ const formattedValue = formatNullableValue(value);
192
+ const line = `+ ${field}: ${formattedValue}`;
193
+ if (colorEnabled) {
194
+ return `${ANSI_COLORS.green}${line}${ANSI_COLORS.reset}`;
195
+ }
196
+ return line;
197
+ }
198
+ /**
199
+ * Jira changelog を差分形式でフォーマットする
200
+ *
201
+ * 各変更のフィールド名、変更前の値、変更後の値を表示する。
202
+ * カラー出力が有効な場合、追加行を緑、削除行を赤でハイライトする。
203
+ *
204
+ * @param changelog - Jira の変更履歴
205
+ * @param options - 差分オプション
206
+ * @returns フォーマットされた差分文字列
207
+ *
208
+ * @example
209
+ * ```typescript
210
+ * const changelog = [{
211
+ * id: '12345',
212
+ * author: 'John Doe',
213
+ * created: '2024-01-15T10:30:00.000+0900',
214
+ * items: [{ field: 'status', fromString: 'Open', toString: 'In Progress' }]
215
+ * }];
216
+ * const result = formatJiraChangelog(changelog, { colorEnabled: false });
217
+ * // Changelog #12345 by John Doe at 2024-01-15T10:30:00.000+0900
218
+ * // [status]
219
+ * // - status: Open
220
+ * // + status: In Progress
221
+ * ```
222
+ */
223
+ export function formatJiraChangelog(changelog, options) {
224
+ if (changelog.length === 0) {
225
+ return '';
226
+ }
227
+ const lines = [];
228
+ for (const entry of changelog) {
229
+ // ヘッダー行
230
+ lines.push(`Changelog #${entry.id} by ${entry.author} at ${entry.created}`);
231
+ // 変更アイテム
232
+ for (const item of entry.items) {
233
+ lines.push(` [${item.field}]`);
234
+ lines.push(` ${formatRemoveLine(item.field, item.fromString, options.colorEnabled)}`);
235
+ lines.push(` ${formatAddLine(item.field, item.toString, options.colorEnabled)}`);
236
+ }
237
+ lines.push(''); // 空行で区切る
238
+ }
239
+ // 末尾の空行を削除
240
+ while (lines.length > 0 && lines[lines.length - 1] === '') {
241
+ lines.pop();
242
+ }
243
+ return lines.join('\n');
244
+ }
245
+ /**
246
+ * 差分行をカラー付きでフォーマットする
247
+ *
248
+ * @param line - 差分行(プレフィックス付き)
249
+ * @param type - 行の種別
250
+ * @param colorEnabled - カラー出力を有効にするか
251
+ * @returns フォーマットされた文字列
252
+ */
253
+ function formatDiffLineWithColor(line, type, colorEnabled) {
254
+ if (!colorEnabled) {
255
+ return line;
256
+ }
257
+ switch (type) {
258
+ case 'add':
259
+ return `${ANSI_COLORS.green}${line}${ANSI_COLORS.reset}`;
260
+ case 'remove':
261
+ return `${ANSI_COLORS.red}${line}${ANSI_COLORS.reset}`;
262
+ default:
263
+ return line;
264
+ }
265
+ }
266
+ /**
267
+ * DiffLine をプレフィックス付きの行文字列に変換する
268
+ *
269
+ * @param line - 差分行
270
+ * @returns プレフィックス付きの行文字列
271
+ */
272
+ function diffLineToString(line) {
273
+ switch (line.type) {
274
+ case 'add':
275
+ return `+${line.content}`;
276
+ case 'remove':
277
+ return `-${line.content}`;
278
+ case 'context':
279
+ return ` ${line.content}`;
280
+ }
281
+ }
282
+ /**
283
+ * 差分ハンクをカラー付きの文字列配列に変換する
284
+ *
285
+ * @param hunks - 差分ハンクの配列
286
+ * @param colorEnabled - カラー出力を有効にするか
287
+ * @returns フォーマットされた行の配列
288
+ */
289
+ function formatHunksWithColor(hunks, colorEnabled) {
290
+ const lines = [];
291
+ for (const hunk of hunks) {
292
+ lines.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`);
293
+ for (const line of hunk.lines) {
294
+ const lineStr = diffLineToString(line);
295
+ lines.push(formatDiffLineWithColor(lineStr, line.type, colorEnabled));
296
+ }
297
+ }
298
+ return lines;
299
+ }
300
+ /**
301
+ * Confluence バージョンヘッダーを生成する
302
+ *
303
+ * @param oldVersion - 変更前のバージョン
304
+ * @param newVersion - 変更後のバージョン
305
+ * @param stats - 差分統計(null の場合は統計なし)
306
+ * @returns ヘッダー行
307
+ */
308
+ function buildVersionHeader(oldVersion, newVersion, stats) {
309
+ const baseHeader = `v${oldVersion.number} → v${newVersion.number} by ${newVersion.by}`;
310
+ if (stats === null) {
311
+ return baseHeader;
312
+ }
313
+ return `${baseHeader} (+${stats.additions}, -${stats.deletions})`;
314
+ }
315
+ /**
316
+ * Confluence バージョン間の差分をフォーマットする
317
+ *
318
+ * 2つの Confluence バージョン間の本文差分を計算し、unified diff 形式で出力する。
319
+ * ヘッダーにはバージョン番号、作成者、差分統計が含まれる。
320
+ * カラー出力が有効な場合、追加行を緑、削除行を赤でハイライトする。
321
+ *
322
+ * @param oldVersion - 変更前のバージョン
323
+ * @param newVersion - 変更後のバージョン
324
+ * @param options - 差分オプション
325
+ * @returns フォーマットされた差分文字列
326
+ *
327
+ * @example
328
+ * ```typescript
329
+ * const oldVersion = { number: 1, by: 'John', when: '2024-01-15', message: null, body: 'Hello' };
330
+ * const newVersion = { number: 2, by: 'Jane', when: '2024-01-16', message: 'Updated', body: 'Hello, World!' };
331
+ * const result = formatConfluenceVersionDiff(oldVersion, newVersion, { colorEnabled: false });
332
+ * // v1 → v2 by Jane (+1, -1)
333
+ * // Updated
334
+ * // @@ -1,1 +1,1 @@
335
+ * // -Hello
336
+ * // +Hello, World!
337
+ * ```
338
+ */
339
+ export function formatConfluenceVersionDiff(oldVersion, newVersion, options) {
340
+ // 本文を取得(undefined の場合は空文字列として扱う)
341
+ const oldBody = oldVersion.body ?? '';
342
+ const newBody = newVersion.body ?? '';
343
+ // 差分を計算
344
+ const diffResult = diffText(oldBody, newBody, options);
345
+ // 差分がない場合
346
+ if (diffResult.hunks.length === 0) {
347
+ const lines = [buildVersionHeader(oldVersion, newVersion, null)];
348
+ if (newVersion.message !== null) {
349
+ lines.push(newVersion.message);
350
+ }
351
+ lines.push('(変更なし)');
352
+ return lines.join('\n');
353
+ }
354
+ // 出力を構築
355
+ const lines = [];
356
+ // ヘッダー
357
+ lines.push(buildVersionHeader(oldVersion, newVersion, diffResult.stats));
358
+ lines.push(` by ${oldVersion.by} → ${newVersion.by}`);
359
+ // バージョンメッセージ
360
+ if (newVersion.message !== null) {
361
+ lines.push(newVersion.message);
362
+ }
363
+ lines.push(''); // 空行
364
+ // 差分内容(カラー対応)
365
+ lines.push(...formatHunksWithColor(diffResult.hunks, options.colorEnabled));
366
+ return lines.join('\n');
367
+ }
368
+ /**
369
+ * In-source testing for private functions
370
+ *
371
+ * vitest の in-source testing 機能を使用して、
372
+ * プライベート関数のテストを実行する。
373
+ */
374
+ if (import.meta.vitest) {
375
+ const { describe, it, expect } = import.meta.vitest;
376
+ /**
377
+ * extractDiffLines のテスト
378
+ *
379
+ * Note: extractDiffLines は StructuredPatchHunk を取る(lines は string[])
380
+ */
381
+ describe('extractDiffLines (private)', () => {
382
+ it('Given 追加行、When 抽出すると、Then 追加タイプの DiffLine を返す', () => {
383
+ // Given: 追加行のハンク(StructuredPatchHunk 形式)
384
+ const hunk = {
385
+ lines: ['+added line'],
386
+ newLines: 1,
387
+ newStart: 1,
388
+ oldLines: 0,
389
+ oldStart: 1,
390
+ };
391
+ // When: 行を抽出する
392
+ const lines = extractDiffLines(hunk);
393
+ // Then: 追加タイプの DiffLine を返す
394
+ expect(lines).toHaveLength(1);
395
+ expect(lines[0]).toEqual({ content: 'added line', type: 'add' });
396
+ });
397
+ it('Given 削除行、When 抽出すると、Then 削除タイプの DiffLine を返す', () => {
398
+ // Given: 削除行のハンク
399
+ const hunk = {
400
+ lines: ['-removed line'],
401
+ newLines: 0,
402
+ newStart: 1,
403
+ oldLines: 1,
404
+ oldStart: 1,
405
+ };
406
+ // When: 行を抽出する
407
+ const lines = extractDiffLines(hunk);
408
+ // Then: 削除タイプの DiffLine を返す
409
+ expect(lines).toHaveLength(1);
410
+ expect(lines[0]).toEqual({ content: 'removed line', type: 'remove' });
411
+ });
412
+ it('Given コンテキスト行(スペース始まり)、When 抽出すると、Then コンテキストタイプを返す', () => {
413
+ // Given: コンテキスト行のハンク
414
+ const hunk = {
415
+ lines: [' context line'],
416
+ newLines: 1,
417
+ newStart: 1,
418
+ oldLines: 1,
419
+ oldStart: 1,
420
+ };
421
+ // When: 行を抽出する
422
+ const lines = extractDiffLines(hunk);
423
+ // Then: コンテキストタイプの DiffLine を返す
424
+ expect(lines).toHaveLength(1);
425
+ expect(lines[0]).toEqual({ content: 'context line', type: 'context' });
426
+ });
427
+ it('Given 空行、When 抽出すると、Then 空のコンテキスト行を返す', () => {
428
+ // Given: 空行のハンク
429
+ const hunk = {
430
+ lines: [''],
431
+ newLines: 1,
432
+ newStart: 1,
433
+ oldLines: 1,
434
+ oldStart: 1,
435
+ };
436
+ // When: 行を抽出する
437
+ const lines = extractDiffLines(hunk);
438
+ // Then: 空のコンテキスト行を返す
439
+ expect(lines).toHaveLength(1);
440
+ expect(lines[0]).toEqual({ content: '', type: 'context' });
441
+ });
442
+ it('Given 未知のプレフィックス、When 抽出すると、Then コンテキストタイプで行全体を保持', () => {
443
+ // Given: 未知のプレフィックスを持つハンク
444
+ const hunk = {
445
+ lines: ['?unknown prefix'],
446
+ newLines: 1,
447
+ newStart: 1,
448
+ oldLines: 1,
449
+ oldStart: 1,
450
+ };
451
+ // When: 行を抽出する
452
+ const lines = extractDiffLines(hunk);
453
+ // Then: コンテキストタイプとして処理され、行全体が保持される(default ケース)
454
+ // Note: prefix === ' ' でない場合、line.substring(1) ではなく line 全体が content になる
455
+ expect(lines).toHaveLength(1);
456
+ expect(lines[0]).toEqual({ content: '?unknown prefix', type: 'context' });
457
+ });
458
+ it('Given 複数行、When 抽出すると、Then 全ての行が正しく抽出される', () => {
459
+ // Given: 複数行のハンク
460
+ const hunk = {
461
+ lines: [' context', '-old', '+new'],
462
+ newLines: 3,
463
+ newStart: 1,
464
+ oldLines: 2,
465
+ oldStart: 1,
466
+ };
467
+ // When: 行を抽出する
468
+ const lines = extractDiffLines(hunk);
469
+ // Then: 全ての行が正しく抽出される
470
+ expect(lines).toHaveLength(3);
471
+ expect(lines[0]).toEqual({ content: 'context', type: 'context' });
472
+ expect(lines[1]).toEqual({ content: 'old', type: 'remove' });
473
+ expect(lines[2]).toEqual({ content: 'new', type: 'add' });
474
+ });
475
+ });
476
+ /**
477
+ * calculateStats のテスト
478
+ *
479
+ * Note: calculateStats は DiffHunk[] を取る(各 hunk は lines: DiffLine[] を持つ)
480
+ */
481
+ describe('calculateStats (private)', () => {
482
+ it('Given 追加と削除がある DiffHunk、When 統計を計算すると、Then 正しい統計を返す', () => {
483
+ // Given: 追加と削除がある DiffHunk 配列
484
+ const hunks = [
485
+ {
486
+ lines: [
487
+ { content: 'line1', type: 'add' },
488
+ { content: 'line2', type: 'add' },
489
+ { content: 'line3', type: 'remove' },
490
+ { content: 'line4', type: 'context' },
491
+ ],
492
+ newLines: 3,
493
+ newStart: 1,
494
+ oldLines: 2,
495
+ oldStart: 1,
496
+ },
497
+ ];
498
+ // When: 統計を計算する
499
+ const stats = calculateStats(hunks);
500
+ // Then: 正しい統計を返す
501
+ expect(stats.additions).toBe(2);
502
+ expect(stats.deletions).toBe(1);
503
+ expect(stats.changes).toBe(1); // ハンク数
504
+ });
505
+ it('Given 空の DiffHunk 配列、When 統計を計算すると、Then ゼロの統計を返す', () => {
506
+ // Given: 空の DiffHunk 配列
507
+ const hunks = [];
508
+ // When: 統計を計算する
509
+ const stats = calculateStats(hunks);
510
+ // Then: ゼロの統計を返す
511
+ expect(stats.additions).toBe(0);
512
+ expect(stats.deletions).toBe(0);
513
+ expect(stats.changes).toBe(0);
514
+ });
515
+ it('Given コンテキスト行のみの DiffHunk、When 統計を計算すると、Then ゼロの統計を返す', () => {
516
+ // Given: コンテキスト行のみ
517
+ const hunks = [
518
+ {
519
+ lines: [
520
+ { content: 'line1', type: 'context' },
521
+ { content: 'line2', type: 'context' },
522
+ ],
523
+ newLines: 2,
524
+ newStart: 1,
525
+ oldLines: 2,
526
+ oldStart: 1,
527
+ },
528
+ ];
529
+ // When: 統計を計算する
530
+ const stats = calculateStats(hunks);
531
+ // Then: ゼロの統計を返す(コンテキスト行はカウントされない)
532
+ expect(stats.additions).toBe(0);
533
+ expect(stats.deletions).toBe(0);
534
+ expect(stats.changes).toBe(1); // ハンク数は1
535
+ });
536
+ it('Given 複数のハンク、When 統計を計算すると、Then 全ハンクの合計を返す', () => {
537
+ // Given: 複数のハンク
538
+ const hunks = [
539
+ {
540
+ lines: [{ content: 'add1', type: 'add' }],
541
+ newLines: 1,
542
+ newStart: 1,
543
+ oldLines: 1,
544
+ oldStart: 1,
545
+ },
546
+ {
547
+ lines: [
548
+ { content: 'del1', type: 'remove' },
549
+ { content: 'del2', type: 'remove' },
550
+ ],
551
+ newLines: 1,
552
+ newStart: 10,
553
+ oldLines: 1,
554
+ oldStart: 10,
555
+ },
556
+ ];
557
+ // When: 統計を計算する
558
+ const stats = calculateStats(hunks);
559
+ // Then: 全ハンクの合計を返す
560
+ expect(stats.additions).toBe(1);
561
+ expect(stats.deletions).toBe(2);
562
+ expect(stats.changes).toBe(2); // ハンク数
563
+ });
564
+ });
565
+ /**
566
+ * formatNullableValue のテスト
567
+ */
568
+ describe('formatNullableValue (private)', () => {
569
+ it('Given null 値、When フォーマットすると、Then "(なし)" を返す', () => {
570
+ // Given: null 値
571
+ const value = null;
572
+ // When: フォーマットする
573
+ const result = formatNullableValue(value);
574
+ // Then: "(なし)" を返す
575
+ expect(result).toBe('(なし)');
576
+ });
577
+ it('Given 文字列値、When フォーマットすると、Then その値を返す', () => {
578
+ // Given: 文字列値
579
+ const value = 'test value';
580
+ // When: フォーマットする
581
+ const result = formatNullableValue(value);
582
+ // Then: その値を返す
583
+ expect(result).toBe('test value');
584
+ });
585
+ it('Given 空文字列、When フォーマットすると、Then 空文字列を返す', () => {
586
+ // Given: 空文字列
587
+ const value = '';
588
+ // When: フォーマットする
589
+ const result = formatNullableValue(value);
590
+ // Then: 空文字列を返す
591
+ expect(result).toBe('');
592
+ });
593
+ });
594
+ /**
595
+ * diffLineToString のテスト
596
+ */
597
+ describe('diffLineToString (private)', () => {
598
+ it('Given 追加行、When 文字列に変換すると、Then "+" プレフィックスが付く', () => {
599
+ // Given: 追加行
600
+ const line = { content: 'new content', type: 'add' };
601
+ // When: 文字列に変換する
602
+ const result = diffLineToString(line);
603
+ // Then: "+" プレフィックスが付く
604
+ expect(result).toBe('+new content');
605
+ });
606
+ it('Given 削除行、When 文字列に変換すると、Then "-" プレフィックスが付く', () => {
607
+ // Given: 削除行
608
+ const line = { content: 'old content', type: 'remove' };
609
+ // When: 文字列に変換する
610
+ const result = diffLineToString(line);
611
+ // Then: "-" プレフィックスが付く
612
+ expect(result).toBe('-old content');
613
+ });
614
+ it('Given コンテキスト行、When 文字列に変換すると、Then " " プレフィックスが付く', () => {
615
+ // Given: コンテキスト行
616
+ const line = { content: 'unchanged', type: 'context' };
617
+ // When: 文字列に変換する
618
+ const result = diffLineToString(line);
619
+ // Then: " " プレフィックスが付く
620
+ expect(result).toBe(' unchanged');
621
+ });
622
+ });
623
+ /**
624
+ * formatDiffLineWithColor のテスト
625
+ *
626
+ * Note: formatDiffLineWithColor は (line: string, type: DiffLineType, colorEnabled: boolean) を取る
627
+ */
628
+ describe('formatDiffLineWithColor (private)', () => {
629
+ it('Given 追加行とカラー有効、When フォーマットすると、Then 緑色の ANSI コードが付く', () => {
630
+ // Given: 追加行とカラー有効
631
+ const lineStr = '+new';
632
+ // When: カラー付きでフォーマットする
633
+ const result = formatDiffLineWithColor(lineStr, 'add', true);
634
+ // Then: 緑色の ANSI コードが付く
635
+ expect(result).toBe('\x1b[32m+new\x1b[0m');
636
+ });
637
+ it('Given 削除行とカラー有効、When フォーマットすると、Then 赤色の ANSI コードが付く', () => {
638
+ // Given: 削除行とカラー有効
639
+ const lineStr = '-old';
640
+ // When: カラー付きでフォーマットする
641
+ const result = formatDiffLineWithColor(lineStr, 'remove', true);
642
+ // Then: 赤色の ANSI コードが付く
643
+ expect(result).toBe('\x1b[31m-old\x1b[0m');
644
+ });
645
+ it('Given コンテキスト行とカラー有効、When フォーマットすると、Then ANSI コードなし', () => {
646
+ // Given: コンテキスト行とカラー有効
647
+ const lineStr = ' same';
648
+ // When: カラー付きでフォーマットする
649
+ const result = formatDiffLineWithColor(lineStr, 'context', true);
650
+ // Then: ANSI コードなし
651
+ expect(result).toBe(' same');
652
+ });
653
+ it('Given 追加行とカラー無効、When フォーマットすると、Then ANSI コードなし', () => {
654
+ // Given: 追加行とカラー無効
655
+ const lineStr = '+new';
656
+ // When: カラーなしでフォーマットする
657
+ const result = formatDiffLineWithColor(lineStr, 'add', false);
658
+ // Then: ANSI コードなし
659
+ expect(result).toBe('+new');
660
+ });
661
+ });
662
+ /**
663
+ * formatRemoveLine / formatAddLine のテスト
664
+ *
665
+ * Note: これらの関数は (field, value, colorEnabled) の3つのパラメータを取る
666
+ */
667
+ describe('formatRemoveLine / formatAddLine (private)', () => {
668
+ it('Given フィールドと値とカラー有効、When formatRemoveLine すると、Then 赤色で "- field: value" 形式', () => {
669
+ // Given: フィールドと値とカラー有効
670
+ const field = 'status';
671
+ const value = 'Open';
672
+ // When: フォーマットする
673
+ const result = formatRemoveLine(field, value, true);
674
+ // Then: 赤色で "- field: value" 形式
675
+ expect(result).toBe('\x1b[31m- status: Open\x1b[0m');
676
+ });
677
+ it('Given フィールドと値とカラー無効、When formatRemoveLine すると、Then "- field: value" 形式のみ', () => {
678
+ // Given: フィールドと値とカラー無効
679
+ const field = 'status';
680
+ const value = 'Open';
681
+ // When: フォーマットする
682
+ const result = formatRemoveLine(field, value, false);
683
+ // Then: "- field: value" 形式のみ
684
+ expect(result).toBe('- status: Open');
685
+ });
686
+ it('Given フィールドと値とカラー有効、When formatAddLine すると、Then 緑色で "+ field: value" 形式', () => {
687
+ // Given: フィールドと値とカラー有効
688
+ const field = 'status';
689
+ const value = 'Closed';
690
+ // When: フォーマットする
691
+ const result = formatAddLine(field, value, true);
692
+ // Then: 緑色で "+ field: value" 形式
693
+ expect(result).toBe('\x1b[32m+ status: Closed\x1b[0m');
694
+ });
695
+ it('Given フィールドと値とカラー無効、When formatAddLine すると、Then "+ field: value" 形式のみ', () => {
696
+ // Given: フィールドと値とカラー無効
697
+ const field = 'status';
698
+ const value = 'Closed';
699
+ // When: フォーマットする
700
+ const result = formatAddLine(field, value, false);
701
+ // Then: "+ field: value" 形式のみ
702
+ expect(result).toBe('+ status: Closed');
703
+ });
704
+ it('Given null 値、When formatRemoveLine すると、Then "(なし)" が表示される', () => {
705
+ // Given: null 値
706
+ const field = 'assignee';
707
+ const value = null;
708
+ // When: フォーマットする
709
+ const result = formatRemoveLine(field, value, false);
710
+ // Then: "(なし)" が表示される
711
+ expect(result).toBe('- assignee: (なし)');
712
+ });
713
+ it('Given null 値、When formatAddLine すると、Then "(なし)" が表示される', () => {
714
+ // Given: null 値
715
+ const field = 'assignee';
716
+ const value = null;
717
+ // When: フォーマットする
718
+ const result = formatAddLine(field, value, false);
719
+ // Then: "(なし)" が表示される
720
+ expect(result).toBe('+ assignee: (なし)');
721
+ });
722
+ });
723
+ /**
724
+ * buildVersionHeader のテスト
725
+ *
726
+ * Note: 出力フォーマットは `v{old.number} → v{new.number} by {new.by}` 形式
727
+ */
728
+ describe('buildVersionHeader (private)', () => {
729
+ it('Given stats が null、When ヘッダーを構築すると、Then 統計なしのヘッダーを返す', () => {
730
+ // Given: stats が null
731
+ const oldVersion = {
732
+ body: 'old body',
733
+ by: 'user1',
734
+ message: null,
735
+ number: 1,
736
+ when: '2024-01-01T00:00:00.000Z',
737
+ };
738
+ const newVersion = {
739
+ body: 'new body',
740
+ by: 'user2',
741
+ message: 'Updated',
742
+ number: 2,
743
+ when: '2024-01-02T00:00:00.000Z',
744
+ };
745
+ // When: ヘッダーを構築する
746
+ const result = buildVersionHeader(oldVersion, newVersion, null);
747
+ // Then: 統計なしのヘッダーを返す(v1 → v2 by user2 形式)
748
+ expect(result).toBe('v1 → v2 by user2');
749
+ });
750
+ it('Given stats がある、When ヘッダーを構築すると、Then 統計付きのヘッダーを返す', () => {
751
+ // Given: stats がある
752
+ const oldVersion = {
753
+ body: 'old body',
754
+ by: 'user1',
755
+ message: null,
756
+ number: 1,
757
+ when: '2024-01-01T00:00:00.000Z',
758
+ };
759
+ const newVersion = {
760
+ body: 'new body',
761
+ by: 'user2',
762
+ message: 'Updated',
763
+ number: 2,
764
+ when: '2024-01-02T00:00:00.000Z',
765
+ };
766
+ const stats = { additions: 5, changes: 1, deletions: 3 };
767
+ // When: ヘッダーを構築する
768
+ const result = buildVersionHeader(oldVersion, newVersion, stats);
769
+ // Then: 統計付きのヘッダーを返す(v1 → v2 by user2 (+5, -3) 形式)
770
+ expect(result).toBe('v1 → v2 by user2 (+5, -3)');
771
+ });
772
+ it('Given 同じユーザーによる複数バージョン、When ヘッダーを構築すると、Then 新バージョンの by を使用', () => {
773
+ // Given: 同じユーザーによる複数バージョン
774
+ const oldVersion = {
775
+ body: 'old body',
776
+ by: 'John Doe',
777
+ message: null,
778
+ number: 5,
779
+ when: '2024-01-01T00:00:00.000Z',
780
+ };
781
+ const newVersion = {
782
+ body: 'new body',
783
+ by: 'Jane Smith',
784
+ message: null,
785
+ number: 6,
786
+ when: '2024-01-02T00:00:00.000Z',
787
+ };
788
+ // When: ヘッダーを構築する
789
+ const result = buildVersionHeader(oldVersion, newVersion, null);
790
+ // Then: 新バージョンの by を使用
791
+ expect(result).toBe('v5 → v6 by Jane Smith');
792
+ });
793
+ });
794
+ /**
795
+ * formatUnifiedDiff のテスト
796
+ *
797
+ * Note: formatUnifiedDiff は hunks: readonly DiffHunk[] を取り、
798
+ * hunk.lines は DiffLine[] である(文字列ではない)
799
+ */
800
+ describe('formatUnifiedDiff (private)', () => {
801
+ it('Given DiffHunk 配列、When フォーマットすると、Then unified diff 形式の文字列を返す', () => {
802
+ // Given: DiffHunk 配列(lines は DiffLine オブジェクト)
803
+ const hunks = [
804
+ {
805
+ lines: [
806
+ { content: 'old', type: 'remove' },
807
+ { content: 'new', type: 'add' },
808
+ ],
809
+ newLines: 1,
810
+ newStart: 1,
811
+ oldLines: 1,
812
+ oldStart: 1,
813
+ },
814
+ ];
815
+ // When: フォーマットする
816
+ const formatted = formatUnifiedDiff(hunks);
817
+ // Then: unified diff 形式の文字列を返す
818
+ expect(formatted).toContain('@@ -1,1 +1,1 @@');
819
+ expect(formatted).toContain('-old');
820
+ expect(formatted).toContain('+new');
821
+ });
822
+ it('Given 空のハンク配列、When フォーマットすると、Then 空文字列を返す', () => {
823
+ // Given: 空のハンク配列
824
+ const hunks = [];
825
+ // When: フォーマットする
826
+ const formatted = formatUnifiedDiff(hunks);
827
+ // Then: 空文字列を返す
828
+ expect(formatted).toBe('');
829
+ });
830
+ it('Given コンテキスト行を含むハンク、When フォーマットすると、Then 正しくフォーマットされる', () => {
831
+ // Given: コンテキスト行を含むハンク
832
+ const hunks = [
833
+ {
834
+ lines: [
835
+ { content: 'unchanged1', type: 'context' },
836
+ { content: 'old', type: 'remove' },
837
+ { content: 'new', type: 'add' },
838
+ { content: 'unchanged2', type: 'context' },
839
+ ],
840
+ newLines: 3,
841
+ newStart: 1,
842
+ oldLines: 3,
843
+ oldStart: 1,
844
+ },
845
+ ];
846
+ // When: フォーマットする
847
+ const formatted = formatUnifiedDiff(hunks);
848
+ // Then: 正しくフォーマットされる
849
+ expect(formatted).toContain(' unchanged1');
850
+ expect(formatted).toContain('-old');
851
+ expect(formatted).toContain('+new');
852
+ expect(formatted).toContain(' unchanged2');
853
+ });
854
+ it('Given 複数のハンク、When フォーマットすると、Then 全てのハンクが含まれる', () => {
855
+ // Given: 複数のハンク
856
+ const hunks = [
857
+ {
858
+ lines: [{ content: 'first', type: 'remove' }],
859
+ newLines: 1,
860
+ newStart: 1,
861
+ oldLines: 1,
862
+ oldStart: 1,
863
+ },
864
+ {
865
+ lines: [{ content: 'second', type: 'add' }],
866
+ newLines: 1,
867
+ newStart: 10,
868
+ oldLines: 1,
869
+ oldStart: 10,
870
+ },
871
+ ];
872
+ // When: フォーマットする
873
+ const formatted = formatUnifiedDiff(hunks);
874
+ // Then: 全てのハンクが含まれる
875
+ expect(formatted).toContain('@@ -1,1 +1,1 @@');
876
+ expect(formatted).toContain('@@ -10,1 +10,1 @@');
877
+ expect(formatted).toContain('-first');
878
+ expect(formatted).toContain('+second');
879
+ });
880
+ });
881
+ }