main
1/**
2 * Tests for GitHub extension
3 *
4 * Run with: bun test github.test.ts
5 */
6
7import { describe, expect, test } from "bun:test";
8import {
9 parsePRList,
10 parsePRItem,
11 parseIssueList,
12 parseIssueItem,
13 parseChecks,
14 parseRunList,
15 parseReviews,
16 parseReviewComments,
17 parseReviewSummaries,
18 parseReleaseList,
19 parseRepo,
20 truncate,
21 formatDate,
22 formatRelativeDate,
23 getPRStateIcon,
24 getCheckIcon,
25 getRunStatusIcon,
26 getReviewDecisionText,
27 buildPRCreateConfirmation,
28 buildPRMergeConfirmation,
29 buildReviewConfirmation,
30 buildIssueCreateConfirmation,
31 buildCommentConfirmation,
32 buildLineCommentConfirmation,
33 buildReviewWithCommentsConfirmation,
34 buildReviewEditConfirmation,
35 buildReviewCommentEditConfirmation,
36 buildReviewCommentDeleteConfirmation,
37 buildSubIssueConfirmation,
38 isAuthError,
39 isNotFoundError,
40 isRepoError,
41 getErrorMessage,
42 extractPRNumber,
43 extractIssueNumber,
44 extractPRUrl,
45 extractIssueUrl,
46 approvalGate,
47 buildModifyResult,
48 buildRejectResult,
49 normalizePRNumbers,
50 prepareGithubArguments,
51} from "./utils";
52import { handlePRComment, handlePRReview } from "./actions/pr";
53import type { GhDetails } from "./types";
54
55// ============================================================================
56// Parsing Tests
57// ============================================================================
58
59describe("PR Parsing", () => {
60 test("parsePRList parses JSON array", () => {
61 const json = JSON.stringify([
62 {
63 number: 123,
64 title: "feat: add feature",
65 state: "OPEN",
66 author: { login: "alice" },
67 headRefName: "feat/feature",
68 baseRefName: "main",
69 url: "https://github.com/org/repo/pull/123",
70 isDraft: false,
71 labels: [{ name: "enhancement" }],
72 reviewDecision: "APPROVED",
73 additions: 50,
74 deletions: 10,
75 changedFiles: 3,
76 createdAt: "2025-01-01T00:00:00Z",
77 updatedAt: "2025-01-02T00:00:00Z",
78 },
79 ]);
80
81 const prs = parsePRList(json);
82 expect(prs.length).toBe(1);
83 expect(prs[0].number).toBe(123);
84 expect(prs[0].title).toBe("feat: add feature");
85 expect(prs[0].author).toBe("alice");
86 expect(prs[0].branch).toBe("feat/feature");
87 expect(prs[0].base).toBe("main");
88 expect(prs[0].isDraft).toBe(false);
89 expect(prs[0].labels).toEqual(["enhancement"]);
90 expect(prs[0].reviewDecision).toBe("APPROVED");
91 expect(prs[0].additions).toBe(50);
92 expect(prs[0].deletions).toBe(10);
93 expect(prs[0].changedFiles).toBe(3);
94 });
95
96 test("parsePRList handles empty array", () => {
97 expect(parsePRList("[]")).toEqual([]);
98 });
99
100 test("parsePRList handles invalid JSON", () => {
101 expect(parsePRList("not json")).toEqual([]);
102 });
103
104 test("parsePRItem handles missing fields gracefully", () => {
105 const pr = parsePRItem({});
106 expect(pr.number).toBe(0);
107 expect(pr.title).toBe("");
108 expect(pr.author).toBe("");
109 expect(pr.isDraft).toBe(false);
110 expect(pr.labels).toEqual([]);
111 expect(pr.additions).toBe(0);
112 });
113
114 test("parsePRList handles draft PRs", () => {
115 const json = JSON.stringify([
116 {
117 number: 456,
118 title: "WIP: draft PR",
119 state: "OPEN",
120 author: { login: "bob" },
121 isDraft: true,
122 headRefName: "wip",
123 baseRefName: "main",
124 },
125 ]);
126
127 const prs = parsePRList(json);
128 expect(prs[0].isDraft).toBe(true);
129 });
130
131 test("parsePRList handles merged PRs", () => {
132 const json = JSON.stringify([
133 {
134 number: 789,
135 title: "Merged PR",
136 state: "MERGED",
137 author: { login: "carol" },
138 headRefName: "merged-branch",
139 baseRefName: "main",
140 },
141 ]);
142
143 const prs = parsePRList(json);
144 expect(prs[0].state).toBe("MERGED");
145 });
146});
147
148describe("Issue Parsing", () => {
149 test("parseIssueList parses JSON array", () => {
150 const json = JSON.stringify([
151 {
152 number: 42,
153 title: "Bug: login broken",
154 state: "OPEN",
155 author: { login: "alice" },
156 url: "https://github.com/org/repo/issues/42",
157 labels: [{ name: "bug" }, { name: "priority:high" }],
158 assignees: [{ login: "bob" }],
159 createdAt: "2025-01-01T00:00:00Z",
160 updatedAt: "2025-01-02T00:00:00Z",
161 body: "Login is broken on mobile",
162 comments: { totalCount: 5 },
163 },
164 ]);
165
166 const issues = parseIssueList(json);
167 expect(issues.length).toBe(1);
168 expect(issues[0].number).toBe(42);
169 expect(issues[0].title).toBe("Bug: login broken");
170 expect(issues[0].state).toBe("OPEN");
171 expect(issues[0].labels).toEqual(["bug", "priority:high"]);
172 expect(issues[0].assignees).toEqual(["bob"]);
173 expect(issues[0].comments).toBe(5);
174 });
175
176 test("parseIssueList handles empty array", () => {
177 expect(parseIssueList("[]")).toEqual([]);
178 });
179
180 test("parseIssueList handles invalid JSON", () => {
181 expect(parseIssueList("invalid")).toEqual([]);
182 });
183
184 test("parseIssueItem handles missing fields", () => {
185 const issue = parseIssueItem({});
186 expect(issue.number).toBe(0);
187 expect(issue.title).toBe("");
188 expect(issue.labels).toEqual([]);
189 expect(issue.assignees).toEqual([]);
190 expect(issue.comments).toBe(0);
191 });
192
193 test("parseIssueItem handles numeric comments", () => {
194 const issue = parseIssueItem({ comments: 10 });
195 expect(issue.comments).toBe(10);
196 });
197});
198
199describe("Checks Parsing", () => {
200 test("parseChecks parses statusCheckRollup object", () => {
201 const json = JSON.stringify({
202 statusCheckRollup: [
203 {
204 name: "CI Tests",
205 status: "COMPLETED",
206 conclusion: "SUCCESS",
207 startedAt: "2025-01-01T00:00:00Z",
208 completedAt: "2025-01-01T00:05:00Z",
209 detailsUrl: "https://github.com/org/repo/actions/runs/123",
210 },
211 {
212 name: "Lint",
213 status: "COMPLETED",
214 conclusion: "FAILURE",
215 startedAt: "2025-01-01T00:00:00Z",
216 completedAt: "2025-01-01T00:02:00Z",
217 },
218 {
219 name: "E2E",
220 status: "IN_PROGRESS",
221 conclusion: "",
222 },
223 ],
224 });
225
226 const checks = parseChecks(json);
227 expect(checks.length).toBe(3);
228 expect(checks[0].name).toBe("CI Tests");
229 expect(checks[0].conclusion).toBe("SUCCESS");
230 expect(checks[1].name).toBe("Lint");
231 expect(checks[1].conclusion).toBe("FAILURE");
232 expect(checks[2].name).toBe("E2E");
233 expect(checks[2].status).toBe("IN_PROGRESS");
234 });
235
236 test("parseChecks handles plain array", () => {
237 const json = JSON.stringify([
238 { name: "test", status: "COMPLETED", conclusion: "SUCCESS" },
239 ]);
240
241 const checks = parseChecks(json);
242 expect(checks.length).toBe(1);
243 expect(checks[0].name).toBe("test");
244 });
245
246 test("parseChecks handles context field (status checks)", () => {
247 const json = JSON.stringify({
248 statusCheckRollup: [
249 { context: "ci/jenkins", status: "COMPLETED", conclusion: "SUCCESS", targetUrl: "https://ci.example.com" },
250 ],
251 });
252
253 const checks = parseChecks(json);
254 expect(checks[0].name).toBe("ci/jenkins");
255 expect(checks[0].detailsUrl).toBe("https://ci.example.com");
256 });
257
258 test("parseChecks handles empty", () => {
259 expect(parseChecks("{}")).toEqual([]);
260 expect(parseChecks("invalid")).toEqual([]);
261 });
262});
263
264describe("Run Parsing", () => {
265 test("parseRunList parses JSON array", () => {
266 const json = JSON.stringify([
267 {
268 databaseId: 12345,
269 name: "CI",
270 displayTitle: "feat: add feature",
271 status: "completed",
272 conclusion: "success",
273 headBranch: "main",
274 event: "push",
275 url: "https://github.com/org/repo/actions/runs/12345",
276 createdAt: "2025-01-01T00:00:00Z",
277 updatedAt: "2025-01-01T00:05:00Z",
278 },
279 ]);
280
281 const runs = parseRunList(json);
282 expect(runs.length).toBe(1);
283 expect(runs[0].databaseId).toBe(12345);
284 expect(runs[0].name).toBe("CI");
285 expect(runs[0].conclusion).toBe("success");
286 expect(runs[0].headBranch).toBe("main");
287 });
288
289 test("parseRunList handles empty", () => {
290 expect(parseRunList("[]")).toEqual([]);
291 expect(parseRunList("invalid")).toEqual([]);
292 });
293});
294
295describe("Review Parsing", () => {
296 test("parseReviews parses reviews object", () => {
297 const json = JSON.stringify({
298 reviews: [
299 {
300 author: { login: "alice" },
301 state: "APPROVED",
302 body: "LGTM",
303 submittedAt: "2025-01-01T00:00:00Z",
304 },
305 {
306 author: { login: "bob" },
307 state: "CHANGES_REQUESTED",
308 body: "Please fix the error handling",
309 submittedAt: "2025-01-01T01:00:00Z",
310 },
311 ],
312 });
313
314 const reviews = parseReviews(json);
315 expect(reviews.length).toBe(2);
316 expect(reviews[0].author).toBe("alice");
317 expect(reviews[0].state).toBe("APPROVED");
318 expect(reviews[1].author).toBe("bob");
319 expect(reviews[1].state).toBe("CHANGES_REQUESTED");
320 });
321
322 test("parseReviews handles empty", () => {
323 expect(parseReviews("{}")).toEqual([]);
324 });
325});
326
327describe("Review Summary Parsing", () => {
328 test("parseReviewSummaries parses API response", () => {
329 const json = JSON.stringify([
330 {
331 id: 123456,
332 user: { login: "alice" },
333 state: "APPROVED",
334 body: "LGTM",
335 submitted_at: "2025-01-01T00:00:00Z",
336 html_url: "https://github.com/owner/repo/pull/1#pullrequestreview-123456",
337 commit_id: "abc123",
338 },
339 {
340 id: 789012,
341 user: { login: "bob" },
342 state: "CHANGES_REQUESTED",
343 body: "Fix this",
344 submitted_at: "2025-01-02T00:00:00Z",
345 html_url: "https://github.com/owner/repo/pull/1#pullrequestreview-789012",
346 commit_id: "def456",
347 },
348 ]);
349
350 const summaries = parseReviewSummaries(json);
351 expect(summaries.length).toBe(2);
352 expect(summaries[0].id).toBe(123456);
353 expect(summaries[0].author).toBe("alice");
354 expect(summaries[0].state).toBe("APPROVED");
355 expect(summaries[0].htmlUrl).toContain("pullrequestreview");
356 expect(summaries[1].id).toBe(789012);
357 expect(summaries[1].state).toBe("CHANGES_REQUESTED");
358 });
359
360 test("parseReviewSummaries handles empty", () => {
361 expect(parseReviewSummaries("[]")).toEqual([]);
362 expect(parseReviewSummaries("{}")).toEqual([]);
363 });
364});
365
366describe("Review Comment Parsing (with IDs)", () => {
367 test("parseReviewComments parses API response with IDs", () => {
368 const json = JSON.stringify([
369 {
370 id: 2788725648,
371 user: { login: "vdemeester" },
372 body: "The function only checks...",
373 path: "pkg/pod/status.go",
374 line: 779,
375 created_at: "2026-02-10T15:41:54Z",
376 updated_at: "2026-02-10T15:41:54Z",
377 html_url: "https://github.com/tektoncd/pipeline/pull/9368#discussion_r2788725648",
378 },
379 ]);
380
381 const comments = parseReviewComments(json);
382 expect(comments.length).toBe(1);
383 expect(comments[0].id).toBe(2788725648);
384 expect(comments[0].author).toBe("vdemeester");
385 expect(comments[0].path).toBe("pkg/pod/status.go");
386 expect(comments[0].line).toBe(779);
387 expect(comments[0].htmlUrl).toContain("discussion_r");
388 });
389
390 test("parseReviewComments handles in_reply_to_id", () => {
391 const json = JSON.stringify([
392 {
393 id: 100,
394 user: { login: "alice" },
395 body: "reply",
396 path: "main.go",
397 line: 10,
398 created_at: "2025-01-01T00:00:00Z",
399 in_reply_to_id: 99,
400 },
401 ]);
402
403 const comments = parseReviewComments(json);
404 expect(comments[0].inReplyToId).toBe(99);
405 });
406
407 test("parseReviewComments handles empty", () => {
408 expect(parseReviewComments("[]")).toEqual([]);
409 });
410});
411
412describe("Release Parsing", () => {
413 test("parseReleaseList parses JSON", () => {
414 const json = JSON.stringify([
415 {
416 tagName: "v1.0.0",
417 name: "Release 1.0.0",
418 isDraft: false,
419 isPrerelease: false,
420 publishedAt: "2025-01-01T00:00:00Z",
421 url: "https://github.com/org/repo/releases/tag/v1.0.0",
422 },
423 ]);
424
425 const releases = parseReleaseList(json);
426 expect(releases.length).toBe(1);
427 expect(releases[0].tagName).toBe("v1.0.0");
428 expect(releases[0].name).toBe("Release 1.0.0");
429 expect(releases[0].isDraft).toBe(false);
430 });
431
432 test("parseReleaseList handles empty", () => {
433 expect(parseReleaseList("[]")).toEqual([]);
434 expect(parseReleaseList("invalid")).toEqual([]);
435 });
436});
437
438describe("Repo Parsing", () => {
439 test("parseRepo parses repo JSON", () => {
440 const json = JSON.stringify({
441 nameWithOwner: "org/repo",
442 description: "A test repo",
443 defaultBranchRef: { name: "main" },
444 visibility: "PUBLIC",
445 url: "https://github.com/org/repo",
446 stargazerCount: 100,
447 forkCount: 20,
448 isArchived: false,
449 });
450
451 const repo = parseRepo(json);
452 expect(repo).not.toBeNull();
453 expect(repo!.nameWithOwner).toBe("org/repo");
454 expect(repo!.description).toBe("A test repo");
455 expect(repo!.defaultBranch).toBe("main");
456 expect(repo!.visibility).toBe("PUBLIC");
457 expect(repo!.stargazerCount).toBe(100);
458 expect(repo!.isArchived).toBe(false);
459 });
460
461 test("parseRepo handles defaultBranch string", () => {
462 const json = JSON.stringify({ defaultBranch: "develop" });
463 const repo = parseRepo(json);
464 expect(repo!.defaultBranch).toBe("develop");
465 });
466
467 test("parseRepo handles invalid JSON", () => {
468 expect(parseRepo("invalid")).toBeNull();
469 });
470});
471
472// ============================================================================
473// Formatting Tests
474// ============================================================================
475
476describe("Formatting", () => {
477 test("truncate shortens long text", () => {
478 expect(truncate("This is a very long text", 15)).toBe("This is a ve...");
479 expect(truncate("This is a very long text", 15).length).toBe(15);
480 });
481
482 test("truncate preserves short text", () => {
483 expect(truncate("Short", 20)).toBe("Short");
484 });
485
486 test("truncate handles exact length", () => {
487 expect(truncate("Exactly 10", 10)).toBe("Exactly 10");
488 });
489
490 test("formatDate formats ISO date", () => {
491 const result = formatDate("2025-06-15T10:30:00Z");
492 expect(result).toBeTruthy();
493 expect(result).not.toBe("");
494 });
495
496 test("formatDate handles empty", () => {
497 expect(formatDate("")).toBe("");
498 });
499
500 test("formatRelativeDate returns relative time", () => {
501 const now = new Date();
502 const fiveMinAgo = new Date(now.getTime() - 5 * 60000).toISOString();
503 expect(formatRelativeDate(fiveMinAgo)).toBe("5m ago");
504
505 const twoHoursAgo = new Date(now.getTime() - 2 * 3600000).toISOString();
506 expect(formatRelativeDate(twoHoursAgo)).toBe("2h ago");
507
508 const threeDaysAgo = new Date(now.getTime() - 3 * 86400000).toISOString();
509 expect(formatRelativeDate(threeDaysAgo)).toBe("3d ago");
510 });
511
512 test("formatRelativeDate handles just now", () => {
513 const now = new Date().toISOString();
514 const result = formatRelativeDate(now);
515 expect(result === "just now" || result === "1m ago").toBe(true);
516 });
517
518 test("formatRelativeDate handles empty", () => {
519 expect(formatRelativeDate("")).toBe("");
520 });
521});
522
523// ============================================================================
524// Icon/Status Tests
525// ============================================================================
526
527describe("Status Icons", () => {
528 test("getPRStateIcon returns correct icons", () => {
529 expect(getPRStateIcon({ state: "MERGED" } as any)).toBe("⏣");
530 expect(getPRStateIcon({ state: "CLOSED" } as any)).toBe("✗");
531 expect(getPRStateIcon({ state: "OPEN", isDraft: true } as any)).toBe("◌");
532 expect(getPRStateIcon({ state: "OPEN", isDraft: false } as any)).toBe("●");
533 });
534
535 test("getCheckIcon returns correct icons", () => {
536 expect(getCheckIcon({ conclusion: "SUCCESS" } as any)).toBe("✓");
537 expect(getCheckIcon({ conclusion: "FAILURE" } as any)).toBe("✗");
538 expect(getCheckIcon({ conclusion: "CANCELLED" } as any)).toBe("⊘");
539 expect(getCheckIcon({ conclusion: "SKIPPED" } as any)).toBe("⊘");
540 expect(getCheckIcon({ status: "IN_PROGRESS", conclusion: "" } as any)).toBe("⏳");
541 expect(getCheckIcon({ status: "QUEUED", conclusion: "" } as any)).toBe("⏳");
542 });
543
544 test("getRunStatusIcon returns correct icons", () => {
545 expect(getRunStatusIcon({ conclusion: "success" } as any)).toBe("✓");
546 expect(getRunStatusIcon({ conclusion: "failure" } as any)).toBe("✗");
547 expect(getRunStatusIcon({ conclusion: "cancelled" } as any)).toBe("⊘");
548 expect(getRunStatusIcon({ status: "in_progress", conclusion: "" } as any)).toBe("⏳");
549 });
550
551 test("getReviewDecisionText returns human-readable text", () => {
552 expect(getReviewDecisionText("APPROVED")).toBe("✓ Approved");
553 expect(getReviewDecisionText("CHANGES_REQUESTED")).toBe("✗ Changes requested");
554 expect(getReviewDecisionText("REVIEW_REQUIRED")).toBe("⏳ Review required");
555 expect(getReviewDecisionText("")).toBe("No reviews");
556 });
557});
558
559// ============================================================================
560// Confirmation Builder Tests
561// ============================================================================
562
563describe("Confirmation Builders", () => {
564 test("buildPRCreateConfirmation includes all fields", () => {
565 const msg = buildPRCreateConfirmation({
566 title: "feat: add feature",
567 body: "Description",
568 base: "main",
569 draft: true,
570 labels: ["enhancement"],
571 reviewers: ["alice"],
572 });
573
574 expect(msg).toContain("feat: add feature");
575 expect(msg).toContain("Base: main");
576 expect(msg).toContain("Body: Description");
577 expect(msg).toContain("Draft: yes");
578 expect(msg).toContain("Labels: enhancement");
579 expect(msg).toContain("Reviewers: alice");
580 expect(msg).toContain("create a new pull request");
581 });
582
583 test("buildPRCreateConfirmation handles minimal", () => {
584 const msg = buildPRCreateConfirmation({ title: "fix: bug" });
585 expect(msg).toContain("fix: bug");
586 expect(msg).not.toContain("Base:");
587 expect(msg).not.toContain("Draft:");
588 });
589
590 test("buildPRCreateConfirmation truncates long body", () => {
591 const msg = buildPRCreateConfirmation({
592 title: "test",
593 body: "a".repeat(300),
594 });
595 expect(msg).toContain("...");
596 });
597
598 test("buildPRMergeConfirmation includes method", () => {
599 const msg = buildPRMergeConfirmation({ number: 123, method: "squash", deleteBranch: true });
600 expect(msg).toContain("#123");
601 expect(msg).toContain("squash");
602 expect(msg).toContain("Delete branch: yes");
603 });
604
605 test("buildReviewConfirmation includes action", () => {
606 const msg = buildReviewConfirmation({ numbers: [456], reviewAction: "approve", body: "LGTM" });
607 expect(msg).toContain("#456");
608 expect(msg).toContain("approve");
609 expect(msg).toContain("LGTM");
610 });
611
612 test("buildReviewConfirmation shows no comment when body is absent", () => {
613 const msg = buildReviewConfirmation({ numbers: [789], reviewAction: "approve" });
614 expect(msg).toContain("#789");
615 expect(msg).toContain("approve");
616 expect(msg).toContain("(none)");
617 });
618
619 test("buildIssueCreateConfirmation includes all fields", () => {
620 const msg = buildIssueCreateConfirmation({
621 title: "Bug report",
622 body: "Steps to reproduce",
623 labels: ["bug"],
624 assignees: ["alice"],
625 });
626
627 expect(msg).toContain("Bug report");
628 expect(msg).toContain("Steps to reproduce");
629 expect(msg).toContain("bug");
630 expect(msg).toContain("alice");
631 });
632
633 test("buildCommentConfirmation includes preview", () => {
634 const msg = buildCommentConfirmation("PR", 123, "Great work!");
635 expect(msg).toContain("PRs: #123");
636 expect(msg).toContain("Great work!");
637 expect(msg).toContain("public comment");
638 });
639
640 test("buildCommentConfirmation truncates long comment", () => {
641 const msg = buildCommentConfirmation("Issue", 42, "a".repeat(300));
642 expect(msg).toContain("...");
643 });
644
645 test("buildLineCommentConfirmation includes file and line", () => {
646 const msg = buildLineCommentConfirmation(123, "src/main.ts", 42, "This needs fixing");
647 expect(msg).toContain("#123");
648 expect(msg).toContain("src/main.ts");
649 expect(msg).toContain("line 42");
650 expect(msg).toContain("This needs fixing");
651 expect(msg).toContain("inline comment");
652 });
653
654 test("buildLineCommentConfirmation shows range for multi-line", () => {
655 const msg = buildLineCommentConfirmation(123, "src/main.ts", 50, "Bad range", 42);
656 expect(msg).toContain("lines 42-50");
657 });
658
659 test("buildLineCommentConfirmation truncates long body", () => {
660 const msg = buildLineCommentConfirmation(1, "f.ts", 1, "a".repeat(300));
661 expect(msg).toContain("...");
662 });
663
664 test("buildReviewWithCommentsConfirmation includes all fields", () => {
665 const msg = buildReviewWithCommentsConfirmation(456, "request-changes", "Please fix", 3);
666 expect(msg).toContain("#456");
667 expect(msg).toContain("request-changes");
668 expect(msg).toContain("3");
669 expect(msg).toContain("Please fix");
670 expect(msg).toContain("inline comments");
671 });
672
673 test("buildReviewWithCommentsConfirmation works without body", () => {
674 const msg = buildReviewWithCommentsConfirmation(789, "approve", undefined, 1);
675 expect(msg).toContain("#789");
676 expect(msg).toContain("approve");
677 expect(msg).toContain("1");
678 expect(msg).not.toContain("Review body:");
679 });
680
681 test("buildReviewEditConfirmation includes all fields", () => {
682 const msg = buildReviewEditConfirmation(42, 123456, "Updated review body");
683 expect(msg).toContain("#42");
684 expect(msg).toContain("123456");
685 expect(msg).toContain("Updated review body");
686 expect(msg).toContain("update the review body");
687 });
688
689 test("buildReviewCommentEditConfirmation includes fields", () => {
690 const msg = buildReviewCommentEditConfirmation(999, "New comment text");
691 expect(msg).toContain("999");
692 expect(msg).toContain("New comment text");
693 expect(msg).toContain("update the inline review comment");
694 });
695
696 test("buildReviewCommentDeleteConfirmation includes warning", () => {
697 const msg = buildReviewCommentDeleteConfirmation(888);
698 expect(msg).toContain("888");
699 expect(msg).toContain("permanently delete");
700 });
701
702 test("buildSubIssueConfirmation add includes parent and child", () => {
703 const msg = buildSubIssueConfirmation("add", 100, 200);
704 expect(msg).toContain("Add");
705 expect(msg).toContain("#100");
706 expect(msg).toContain("#200");
707 expect(msg).toContain("child of the parent");
708 });
709
710 test("buildSubIssueConfirmation remove includes parent and child", () => {
711 const msg = buildSubIssueConfirmation("remove", 100, 200);
712 expect(msg).toContain("Remove");
713 expect(msg).toContain("#100");
714 expect(msg).toContain("#200");
715 expect(msg).toContain("remove the parent-child");
716 });
717});
718
719// ============================================================================
720// Error Handling Tests
721// ============================================================================
722
723describe("Error Handling", () => {
724 test("isAuthError detects auth failures", () => {
725 expect(isAuthError("authentication required")).toBe(true);
726 expect(isAuthError("unauthorized")).toBe(true);
727 expect(isAuthError("not logged in to any github hosts")).toBe(true);
728 expect(isAuthError("try: gh auth login")).toBe(true);
729 expect(isAuthError("network timeout")).toBe(false);
730 });
731
732 test("isNotFoundError detects not found", () => {
733 expect(isNotFoundError("not found")).toBe(true);
734 expect(isNotFoundError("could not resolve to a repository")).toBe(true);
735 expect(isNotFoundError("authentication failed")).toBe(false);
736 });
737
738 test("isRepoError detects repo errors", () => {
739 expect(isRepoError("not a git repository")).toBe(true);
740 expect(isRepoError("no git remotes found")).toBe(true);
741 expect(isRepoError("authentication failed")).toBe(false);
742 });
743
744 test("getErrorMessage returns helpful messages", () => {
745 expect(getErrorMessage("not logged in", "list")).toContain("gh auth login");
746 expect(getErrorMessage("not a git repository", "view")).toContain("Not in a GitHub repository");
747 expect(getErrorMessage("not found", "view")).toContain("not found");
748 expect(getErrorMessage("something else", "create")).toBe("something else");
749 });
750});
751
752// ============================================================================
753// Extraction Tests
754// ============================================================================
755
756describe("URL/Number Extraction", () => {
757 test("extractPRNumber from URL", () => {
758 expect(extractPRNumber("https://github.com/org/repo/pull/123")).toBe(123);
759 });
760
761 test("extractPRNumber from hash format", () => {
762 expect(extractPRNumber("Created PR #456")).toBe(456);
763 });
764
765 test("extractPRNumber returns null for no match", () => {
766 expect(extractPRNumber("no number here")).toBeNull();
767 });
768
769 test("extractIssueNumber from URL", () => {
770 expect(extractIssueNumber("https://github.com/org/repo/issues/42")).toBe(42);
771 });
772
773 test("extractIssueNumber from hash format", () => {
774 expect(extractIssueNumber("Created issue #99")).toBe(99);
775 });
776
777 test("extractIssueNumber returns null for no match", () => {
778 expect(extractIssueNumber("no number here")).toBeNull();
779 });
780
781 test("extractPRUrl extracts GitHub PR URL", () => {
782 const url = extractPRUrl("Created https://github.com/org/repo/pull/123 successfully");
783 expect(url).toBe("https://github.com/org/repo/pull/123");
784 });
785
786 test("extractPRUrl returns null for no match", () => {
787 expect(extractPRUrl("no url here")).toBeNull();
788 });
789
790 test("extractIssueUrl extracts GitHub issue URL", () => {
791 const url = extractIssueUrl("Created https://github.com/org/repo/issues/42 successfully");
792 expect(url).toBe("https://github.com/org/repo/issues/42");
793 });
794
795 test("extractIssueUrl returns null for no match", () => {
796 expect(extractIssueUrl("no url here")).toBeNull();
797 });
798});
799
800// ============================================================================
801// Auto-detection Pattern Tests
802// ============================================================================
803
804describe("Auto-detection Patterns", () => {
805 test("GitHub PR URL pattern matches", () => {
806 const pattern = /^https:\/\/github\.com\/[^\/]+\/[^\/]+\/pull\/(\d+)\/?$/;
807
808 expect("https://github.com/org/repo/pull/123".match(pattern)?.[1]).toBe("123");
809 expect("https://github.com/org/repo/pull/123/".match(pattern)?.[1]).toBe("123");
810 expect("https://github.com/my-org/my-repo/pull/456".match(pattern)?.[1]).toBe("456");
811 });
812
813 test("GitHub PR URL pattern doesn't match non-PRs", () => {
814 const pattern = /^https:\/\/github\.com\/[^\/]+\/[^\/]+\/pull\/(\d+)\/?$/;
815
816 expect("https://github.com/org/repo/issues/123".match(pattern)).toBeNull();
817 expect("https://github.com/org/repo/pull/".match(pattern)).toBeNull();
818 expect("https://github.com/org/repo".match(pattern)).toBeNull();
819 expect("not a url".match(pattern)).toBeNull();
820 });
821
822 test("GitHub issue URL pattern matches", () => {
823 const pattern = /^https:\/\/github\.com\/[^\/]+\/[^\/]+\/issues\/(\d+)\/?$/;
824
825 expect("https://github.com/org/repo/issues/42".match(pattern)?.[1]).toBe("42");
826 expect("https://github.com/org/repo/issues/42/".match(pattern)?.[1]).toBe("42");
827 });
828
829 test("GitHub issue URL pattern doesn't match non-issues", () => {
830 const pattern = /^https:\/\/github\.com\/[^\/]+\/[^\/]+\/issues\/(\d+)\/?$/;
831
832 expect("https://github.com/org/repo/pull/123".match(pattern)).toBeNull();
833 expect("https://github.com/org/repo/issues/".match(pattern)).toBeNull();
834 });
835});
836
837// ============================================================================
838// Batch PR Write Tests
839// ============================================================================
840
841describe("Batch PR arguments", () => {
842 test("normalizePRNumbers accepts one or many PRs and preserves order", () => {
843 expect(normalizePRNumbers({ numbers: [123] })).toEqual([123]);
844 expect(normalizePRNumbers({ numbers: [123, 456, 123, 789] })).toEqual([123, 456, 789]);
845 });
846
847 test("normalizePRNumbers rejects missing, empty, and invalid lists", () => {
848 expect(normalizePRNumbers({})).toEqual([]);
849 expect(normalizePRNumbers({ numbers: [] })).toEqual([]);
850 expect(normalizePRNumbers({ numbers: [123, 0, -1, 1.5] })).toEqual([]);
851 });
852
853 test("prepareGithubArguments converts legacy singular PR writes", () => {
854 expect(prepareGithubArguments({ action: "pr-review", number: 123 })).toEqual({
855 action: "pr-review",
856 numbers: [123],
857 });
858 expect(prepareGithubArguments({ action: "pr-comment", number: 456, body: "LGTM" })).toEqual({
859 action: "pr-comment",
860 body: "LGTM",
861 numbers: [456],
862 });
863 });
864
865 test("prepareGithubArguments leaves other actions and explicit numbers unchanged", () => {
866 const review = { action: "pr-review", numbers: [123, 456] };
867 expect(prepareGithubArguments(review)).toBe(review);
868 const view = { action: "pr-view", number: 123 };
869 expect(prepareGithubArguments(view)).toBe(view);
870 });
871});
872
873describe("Batch PR writes", () => {
874 function batchContext(selectReturn = "✓ Accept") {
875 let selectCalls = 0;
876 let selectPrompt = "";
877 const ctx = {
878 hasUI: true,
879 cwd: "/repo",
880 ui: {
881 select: async (prompt: string) => {
882 selectCalls++;
883 selectPrompt = prompt;
884 return selectReturn;
885 },
886 notify: () => {},
887 },
888 sessionManager: { getBranch: () => [] },
889 } as any;
890 return { ctx, selectCalls: () => selectCalls, selectPrompt: () => selectPrompt };
891 }
892
893 function batchPi(failNumber?: number) {
894 const writes: string[][] = [];
895 const pi = {
896 exec: async (command: string, args: string[]) => {
897 if (command === "git") return { code: 1, stdout: "", stderr: "" };
898 if (args[0] === "pr" && args[1] === "view") {
899 return { code: 0, stdout: `PR ${args[2]}`, stderr: "" };
900 }
901 writes.push(args);
902 if (Number(args[2]) === failNumber) return { code: 1, stdout: "", stderr: "failed" };
903 return { code: 0, stdout: "ok", stderr: "" };
904 },
905 } as any;
906 return { pi, writes };
907 }
908
909 test("pr-review approves multiple PRs with one prompt and sequential writes", async () => {
910 const { ctx, selectCalls, selectPrompt } = batchContext();
911 const { pi, writes } = batchPi();
912 const result = await handlePRReview(
913 pi,
914 { numbers: [123, 456], reviewAction: "approve", body: "LGTM" },
915 undefined,
916 undefined,
917 ctx,
918 );
919
920 expect(selectCalls()).toBe(1);
921 expect(selectPrompt()).toContain("#123 PR 123");
922 expect(selectPrompt()).toContain("#456 PR 456");
923 expect(writes).toEqual([
924 ["pr", "review", "123", "--approve", "--body", "LGTM"],
925 ["pr", "review", "456", "--approve", "--body", "LGTM"],
926 ]);
927 expect(result.details.prNumbers).toEqual([123, 456]);
928 expect(result.details.succeeded).toEqual([123, 456]);
929 });
930
931 test("pr-comment continues after an individual failure", async () => {
932 const { ctx, selectCalls } = batchContext();
933 const { pi, writes } = batchPi(456);
934 const result = await handlePRComment(
935 pi,
936 { numbers: [123, 456, 789], body: "Shared body" },
937 undefined,
938 undefined,
939 ctx,
940 );
941
942 expect(selectCalls()).toBe(1);
943 expect(writes.map((args) => Number(args[2]))).toEqual([123, 456, 789]);
944 expect(result.details.succeeded).toEqual([123, 789]);
945 expect(result.details.failed).toEqual([{ number: 456, error: "failed" }]);
946 });
947});
948
949// ============================================================================
950// Approval Gate Tests
951// ============================================================================
952
953describe("Approval Gate", () => {
954 function mockCtx(selectReturn: string | undefined) {
955 return {
956 ui: {
957 select: async (_title: string, _options: string[]) => selectReturn,
958 notify: (_msg: string, _level: string) => {},
959 },
960 } as any;
961 }
962
963 test("approvalGate returns accepted when user selects Accept", async () => {
964 const result = await approvalGate(mockCtx("✓ Accept"), "Test?", "Description");
965 expect(result.outcome).toBe("accepted");
966 });
967
968 test("approvalGate returns modify when user selects Modify", async () => {
969 const result = await approvalGate(mockCtx("✎ Modify"), "Test?", "Description");
970 expect(result.outcome).toBe("modify");
971 });
972
973 test("approvalGate returns rejected when user selects Reject", async () => {
974 const result = await approvalGate(mockCtx("✗ Reject"), "Test?", "Description");
975 expect(result.outcome).toBe("rejected");
976 });
977
978 test("approvalGate returns rejected when user presses Escape (undefined)", async () => {
979 const result = await approvalGate(mockCtx(undefined), "Test?", "Description");
980 expect(result.outcome).toBe("rejected");
981 });
982
983 test("approvalGate passes title with description to select", async () => {
984 let capturedTitle = "";
985 let capturedOptions: string[] = [];
986 const ctx = {
987 ui: {
988 select: async (title: string, options: string[]) => {
989 capturedTitle = title;
990 capturedOptions = options;
991 return "✓ Accept";
992 },
993 notify: () => {},
994 },
995 } as any;
996
997 await approvalGate(ctx, "Create PR?", "Title: fix stuff\nBase: main");
998 expect(capturedTitle).toContain("Create PR?");
999 expect(capturedTitle).toContain("Title: fix stuff");
1000 expect(capturedTitle).toContain("Base: main");
1001 expect(capturedOptions).toEqual(["✓ Accept", "✎ Modify", "✗ Reject"]);
1002 });
1003});
1004
1005describe("buildModifyResult", () => {
1006 test("includes modify message telling LLM to ask user", () => {
1007 const result = buildModifyResult("PR creation", { action: "pr-create" });
1008 const text = result.content[0].text;
1009 expect(text).toContain("modify");
1010 expect(text).toContain("Ask the user");
1011 expect(text).toContain("retry");
1012 });
1013
1014 test("sets modifyRequested in details", () => {
1015 const result = buildModifyResult("PR creation", { action: "pr-create", prNumber: 123 });
1016 const details = result.details as GhDetails;
1017 expect(details.modifyRequested).toBe(true);
1018 expect(details.cancelled).toBeUndefined();
1019 expect(details.action).toBe("pr-create");
1020 expect(details.prNumber).toBe(123);
1021 });
1022
1023 test("does not set isError", () => {
1024 const result = buildModifyResult("comment", { action: "pr-comment" });
1025 expect(result.isError).toBeUndefined();
1026 });
1027});
1028
1029describe("buildRejectResult", () => {
1030 test("includes reject message telling LLM NOT to retry", () => {
1031 const result = buildRejectResult("PR creation", { action: "pr-create" });
1032 const text = result.content[0].text;
1033 expect(text).toContain("rejected");
1034 expect(text).toContain("Do NOT retry");
1035 });
1036
1037 test("sets cancelled in details", () => {
1038 const result = buildRejectResult("PR creation", { action: "pr-create", prNumber: 42 });
1039 const details = result.details as GhDetails;
1040 expect(details.cancelled).toBe(true);
1041 expect(details.modifyRequested).toBeUndefined();
1042 expect(details.action).toBe("pr-create");
1043 expect(details.prNumber).toBe(42);
1044 });
1045
1046 test("does not set isError", () => {
1047 const result = buildRejectResult("merge", { action: "pr-merge" });
1048 expect(result.isError).toBeUndefined();
1049 });
1050});