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