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} 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});