Files
claude-code-action/test/create-initial-comment.test.ts
Vlad MocanuandClaude Opus 4.6 bbfd630fc5 fix(prd-7): M4 test coverage review — 28 new tests for comment operations and branch setup
- test/create-initial-comment.test.ts: 15 tests covering GitHub/Forgejo
  platform branches, sticky comment matching, review comment fallback,
  error handling with GITHUB_OUTPUT verification
- test/update-tracking-comment.test.ts: 11 tests covering body construction,
  branch link logic (issue vs PR), comment type routing, Forgejo URLs
- test/setup-branch-forgejo.test.ts: 2 tests verifying Forgejo uses
  repos.getBranch() while GitHub uses git.getRef() for source branch SHA
- test/COVERAGE-NOTES.md: documents all coverage status and accepted gaps
  (tag mode orchestrator, entrypoint) with rationale
- Notable finding: CLAUDE_APP_BOT_ID discrepancy flagged for M5

Progress: 67% complete (4/6 milestones) — M5 code quality next

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 12:51:08 +02:00

528 lines
17 KiB
TypeScript

#!/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<string, any> = {}) {
// 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/<runId>
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");
});
});
});