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