#!/usr/bin/env bun import { describe, test, expect, beforeEach, afterEach, spyOn, type Mock, } from "bun:test"; import { tmpdir } from "os"; import { join } from "path"; import { readFileSync, unlinkSync, writeFileSync } from "fs"; import { createInitialComment } from "../src/github/operations/comments/create-initial"; import { _resetPlatformCache } from "../src/platform/detector"; import { createMockContext } from "./mockContext"; import type { Octokit } from "@octokit/rest"; // Note: create-initial.ts defines its own CLAUDE_APP_BOT_ID = 209825114 (line 23), // which differs from src/github/constants.ts CLAUDE_APP_BOT_ID = 41898282. // Tests use 209825114 to match the actual code in create-initial.ts. const LOCAL_CLAUDE_APP_BOT_ID = 209825114; function createMockOctokit(overrides: Record = {}) { // createInitialComment receives an Octokit instance and accesses octokit.rest.issues.* return { rest: { issues: { listComments: overrides.listComments ?? (async () => ({ data: [] })), createComment: overrides.createComment ?? (async () => ({ data: { id: 100 } })), updateComment: overrides.updateComment ?? (async () => ({ data: { id: 200 } })), }, pulls: { createReplyForReviewComment: overrides.createReplyForReviewComment ?? (async () => ({ data: { id: 300 } })), }, }, } as unknown as Octokit; } describe("createInitialComment", () => { let savedPlatform: string | undefined; let savedServerUrl: string | undefined; let savedRunNumber: string | undefined; let savedRunId: string | undefined; let savedGithubOutput: string | undefined; let tempOutputFile: string; let consoleLogSpy: Mock<(...args: any[]) => void>; let consoleErrorSpy: Mock<(...args: any[]) => void>; beforeEach(() => { _resetPlatformCache(); savedPlatform = process.env.PLATFORM; savedServerUrl = process.env.GITHUB_SERVER_URL; savedRunNumber = process.env.GITHUB_RUN_NUMBER; savedRunId = process.env.GITHUB_RUN_ID; savedGithubOutput = process.env.GITHUB_OUTPUT; // Create a real temp file for GITHUB_OUTPUT tempOutputFile = join( tmpdir(), `test-gh-output-${Date.now()}-${Math.random().toString(36).slice(2)}`, ); writeFileSync(tempOutputFile, ""); process.env.GITHUB_OUTPUT = tempOutputFile; // Default to GitHub platform delete process.env.PLATFORM; process.env.GITHUB_SERVER_URL = "https://github.com"; process.env.GITHUB_RUN_ID = "9876543"; consoleLogSpy = spyOn(console, "log").mockImplementation(() => {}); consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { if (savedPlatform !== undefined) process.env.PLATFORM = savedPlatform; else delete process.env.PLATFORM; if (savedServerUrl !== undefined) process.env.GITHUB_SERVER_URL = savedServerUrl; else delete process.env.GITHUB_SERVER_URL; if (savedRunNumber !== undefined) process.env.GITHUB_RUN_NUMBER = savedRunNumber; else delete process.env.GITHUB_RUN_NUMBER; if (savedRunId !== undefined) process.env.GITHUB_RUN_ID = savedRunId; else delete process.env.GITHUB_RUN_ID; if (savedGithubOutput !== undefined) process.env.GITHUB_OUTPUT = savedGithubOutput; else delete process.env.GITHUB_OUTPUT; try { unlinkSync(tempOutputFile); } catch {} consoleLogSpy.mockRestore(); consoleErrorSpy.mockRestore(); _resetPlatformCache(); }); describe("GitHub platform — basic comment creation", () => { test("creates issue comment for issue_comment event", async () => { const createComment = async () => ({ data: { id: 42 } }); const createCommentSpy = spyOn({ createComment }, "createComment"); const mockOctokit = createMockOctokit({ createComment: createCommentSpy, }); const context = createMockContext({ entityNumber: 10 }); const result = await createInitialComment(mockOctokit, context); expect(result.id).toBe(42); expect(createCommentSpy).toHaveBeenCalled(); const callArgs = (createCommentSpy.mock.calls as any[][])[0]![0]; expect(callArgs.owner).toBe("test-owner"); expect(callArgs.repo).toBe("test-repo"); expect(callArgs.issue_number).toBe(10); // GitHub body uses CDN spinner image expect(callArgs.body).toContain("user-attachments/assets"); expect(callArgs.body).toContain("Claude Code is working"); }); test("writes comment ID to GITHUB_OUTPUT file", async () => { const mockOctokit = createMockOctokit({ createComment: async () => ({ data: { id: 777 } }), }); const context = createMockContext(); await createInitialComment(mockOctokit, context); const output = readFileSync(tempOutputFile, "utf-8"); expect(output).toContain("claude_comment_id=777"); }); test("uses GitHub CDN spinner and actions/runs URL in body", async () => { let capturedBody = ""; const mockOctokit = createMockOctokit({ createComment: async (args: any) => { capturedBody = args.body; return { data: { id: 1 } }; }, }); const context = createMockContext({ runId: "12345" }); await createInitialComment(mockOctokit, context); // GitHub spinner is a CDN image expect(capturedBody).toContain("user-attachments/assets"); expect(capturedBody).not.toContain("⏳"); // GitHub URL uses GITHUB_SERVER_URL + actions/runs/ expect(capturedBody).toContain( "https://github.com/test-owner/test-repo/actions/runs/12345", ); }); }); describe("GitHub platform — sticky comment on PR", () => { test("creates new comment when no existing comment matches", async () => { const createComment = async () => ({ data: { id: 50 } }); const updateComment = async () => ({ data: { id: 99 } }); const createSpy = spyOn({ createComment }, "createComment"); const updateSpy = spyOn({ updateComment }, "updateComment"); const mockOctokit = createMockOctokit({ listComments: async () => ({ data: [] }), createComment: createSpy, updateComment: updateSpy, }); const context = createMockContext({ eventName: "pull_request", isPR: true, entityNumber: 5, inputs: { useStickyComment: true }, payload: { action: "opened", number: 5, pull_request: { number: 5 }, } as any, }); const result = await createInitialComment(mockOctokit, context); expect(result.id).toBe(50); expect(createSpy).toHaveBeenCalled(); expect(updateSpy).not.toHaveBeenCalled(); }); test("updates existing comment matched by CLAUDE_APP_BOT_ID", async () => { const updateComment = async () => ({ data: { id: 200 } }); const updateSpy = spyOn({ updateComment }, "updateComment"); const createComment = async () => ({ data: { id: 100 } }); const createSpy = spyOn({ createComment }, "createComment"); const mockOctokit = createMockOctokit({ listComments: async () => ({ data: [ { id: 200, user: { id: LOCAL_CLAUDE_APP_BOT_ID, type: "User", login: "someone", }, body: "old body", }, ], }), createComment: createSpy, updateComment: updateSpy, }); const context = createMockContext({ eventName: "pull_request", isPR: true, entityNumber: 5, inputs: { useStickyComment: true }, payload: { action: "opened", number: 5, pull_request: { number: 5 }, } as any, }); const result = await createInitialComment(mockOctokit, context); expect(result.id).toBe(200); expect(updateSpy).toHaveBeenCalled(); expect(createSpy).not.toHaveBeenCalled(); }); test("updates existing comment matched by Bot type with 'claude' in login", async () => { const updateComment = async () => ({ data: { id: 201 } }); const updateSpy = spyOn({ updateComment }, "updateComment"); const mockOctokit = createMockOctokit({ listComments: async () => ({ data: [ { id: 201, user: { id: 999, type: "Bot", login: "claude[bot]" }, body: "old", }, ], }), updateComment: updateSpy, }); const context = createMockContext({ eventName: "pull_request", isPR: true, inputs: { useStickyComment: true }, payload: { action: "opened", number: 1, pull_request: { number: 1 }, } as any, }); const result = await createInitialComment(mockOctokit, context); expect(result.id).toBe(201); expect(updateSpy).toHaveBeenCalled(); }); test("updates existing comment matched by exact body content", async () => { // To test body matching, we need the mock comment's body to exactly equal // the generated initialBody. Import the GitHub comment builder to construct // the matching body with the same runId as the mock context. const { createCommentBody, createJobRunLink } = await import( "../src/github/operations/comments/common" ); const expectedBody = createCommentBody( createJobRunLink("test-owner", "test-repo", "1234567890"), ); const updateComment = async () => ({ data: { id: 202 } }); const updateSpy = spyOn({ updateComment }, "updateComment"); const mockOctokit = createMockOctokit({ listComments: async () => ({ data: [ { id: 202, user: { id: 1, type: "User", login: "random-user" }, body: expectedBody, }, ], }), updateComment: updateSpy, }); const context = createMockContext({ eventName: "pull_request", isPR: true, inputs: { useStickyComment: true }, payload: { action: "opened", number: 1, pull_request: { number: 1 }, } as any, }); const result = await createInitialComment(mockOctokit, context); expect(result.id).toBe(202); expect(updateSpy).toHaveBeenCalled(); }); }); describe("GitHub platform — PR review comment reply", () => { test("creates reply via createReplyForReviewComment when payload has comment.id", async () => { const createReply = async () => ({ data: { id: 301 } }); const replySpy = spyOn({ createReply }, "createReply"); const mockOctokit = createMockOctokit({ createReplyForReviewComment: replySpy, }); const context = createMockContext({ eventName: "pull_request_review_comment", isPR: true, entityNumber: 10, payload: { action: "created", comment: { id: 555 }, pull_request: { number: 10 }, } as any, }); const result = await createInitialComment(mockOctokit, context); expect(result.id).toBe(301); expect(replySpy).toHaveBeenCalled(); const callArgs = (replySpy.mock.calls as any[][])[0]![0]; expect(callArgs.comment_id).toBe(555); expect(callArgs.pull_number).toBe(10); }); test("falls back to createComment when payload lacks comment.id", async () => { const createComment = async () => ({ data: { id: 102 } }); const createSpy = spyOn({ createComment }, "createComment"); const createReply = async () => ({ data: { id: 301 } }); const replySpy = spyOn({ createReply }, "createReply"); const mockOctokit = createMockOctokit({ createComment: createSpy, createReplyForReviewComment: replySpy, }); const context = createMockContext({ eventName: "pull_request_review_comment", isPR: true, entityNumber: 10, // No comment.id in payload (Forgejo behavior) payload: { action: "created", pull_request: { number: 10 }, } as any, }); const result = await createInitialComment(mockOctokit, context); expect(result.id).toBe(102); expect(createSpy).toHaveBeenCalled(); expect(replySpy).not.toHaveBeenCalled(); }); }); describe("Forgejo platform", () => { beforeEach(() => { _resetPlatformCache(); process.env.PLATFORM = "forgejo"; process.env.GITHUB_SERVER_URL = "https://git.example.com"; process.env.GITHUB_RUN_NUMBER = "42"; }); test("uses Unicode spinner and Forgejo URL format in body", async () => { let capturedBody = ""; const mockOctokit = createMockOctokit({ createComment: async (args: any) => { capturedBody = args.body; return { data: { id: 1 } }; }, }); const context = createMockContext(); await createInitialComment(mockOctokit, context); // Forgejo uses Unicode hourglass, not CDN image expect(capturedBody).toContain("⏳"); expect(capturedBody).not.toContain("user-attachments/assets"); // Forgejo URL uses GITHUB_RUN_NUMBER expect(capturedBody).toContain( "https://git.example.com/test-owner/test-repo/actions/runs/42", ); }); test("sticky comment matches by botName input on Forgejo", async () => { const updateComment = async () => ({ data: { id: 203 } }); const updateSpy = spyOn({ updateComment }, "updateComment"); const mockOctokit = createMockOctokit({ listComments: async () => ({ data: [ { id: 203, user: { id: 999, type: "User", login: "claude" }, body: "some old body", }, ], }), updateComment: updateSpy, }); const context = createMockContext({ eventName: "pull_request", isPR: true, inputs: { useStickyComment: true, botName: "claude" }, payload: { action: "opened", number: 1, pull_request: { number: 1 }, } as any, }); const result = await createInitialComment(mockOctokit, context); expect(result.id).toBe(203); expect(updateSpy).toHaveBeenCalled(); }); test("review comment event falls back to createComment (no comment.id in Forgejo payload)", async () => { const createComment = async () => ({ data: { id: 103 } }); const createSpy = spyOn({ createComment }, "createComment"); const mockOctokit = createMockOctokit({ createComment: createSpy, }); const context = createMockContext({ eventName: "pull_request_review_comment", isPR: true, entityNumber: 7, payload: { action: "created", pull_request: { number: 7 }, // Forgejo payload lacks top-level comment object } as any, }); const result = await createInitialComment(mockOctokit, context); expect(result.id).toBe(103); expect(createSpy).toHaveBeenCalled(); }); }); describe("error handling", () => { test("falls back to issues.createComment when primary path throws", async () => { let fallbackCalled = false; const mockOctokit = createMockOctokit({ listComments: async () => { throw new Error("API error"); }, createComment: async () => { fallbackCalled = true; return { data: { id: 999 } }; }, }); const context = createMockContext({ eventName: "pull_request", isPR: true, inputs: { useStickyComment: true }, payload: { action: "opened", number: 1, pull_request: { number: 1 }, } as any, }); const result = await createInitialComment(mockOctokit, context); expect(result.id).toBe(999); expect(fallbackCalled).toBe(true); expect(consoleErrorSpy).toHaveBeenCalled(); }); test("throws when both primary and fallback fail", async () => { const mockOctokit = createMockOctokit({ listComments: async () => { throw new Error("Primary error"); }, createComment: async () => { throw new Error("Fallback error"); }, }); const context = createMockContext({ eventName: "pull_request", isPR: true, inputs: { useStickyComment: true }, payload: { action: "opened", number: 1, pull_request: { number: 1 }, } as any, }); await expect(createInitialComment(mockOctokit, context)).rejects.toThrow( "Fallback error", ); }); test("writes comment ID from fallback to GITHUB_OUTPUT", async () => { const mockOctokit = createMockOctokit({ listComments: async () => { throw new Error("API error"); }, createComment: async () => ({ data: { id: 888 } }), }); const context = createMockContext({ eventName: "pull_request", isPR: true, inputs: { useStickyComment: true }, payload: { action: "opened", number: 1, pull_request: { number: 1 }, } as any, }); await createInitialComment(mockOctokit, context); const output = readFileSync(tempOutputFile, "utf-8"); expect(output).toContain("claude_comment_id=888"); }); }); });