Files
claude-code-action/test/forgejo-data-fetcher.test.ts
T
Vlad MocanuandClaude Opus 4.6 2f44e40942 fix: remove all image-downloader mocking from forgejo-data-fetcher test
Both mock.module and spyOn cause cross-file test pollution in Bun's
single-process runner — mock.module poisons the module registry permanently,
and spyOn doesn't correctly restore ESM live bindings across files on Linux.

The real downloadCommentImages is safe to call because test comment bodies
contain no GitHub image URLs, so the function finds zero images and returns
an empty Map naturally. The only side effect is mkdir("/tmp/github-images")
which is harmless on CI runners.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 10:45:09 +02:00

1367 lines
39 KiB
TypeScript

import { describe, expect, it, jest } from "bun:test";
import {
fetchForgejoData,
mapFileStatus,
mapRestComments,
mapRestReviewComments,
mapRestReviews,
} from "../src/forgejo/data/fetcher";
import type { Octokits } from "../src/github/api/client";
import type { GitHubReviewComment } from "../src/github/types";
// NOTE: We intentionally do NOT mock downloadCommentImages here.
//
// Previous attempts to mock it (mock.module and spyOn) both caused
// cross-file test pollution in Bun's single-process runner:
// - mock.module() permanently replaces the module in Bun's registry,
// poisoning image-downloader.test.ts when it runs afterward.
// - spyOn() on ESM module namespace exports doesn't correctly restore
// live bindings across test files on Linux Bun.
//
// The real downloadCommentImages is safe to call here because:
// 1. Test comment bodies contain no GitHub image URLs
// (github.com/user-attachments/assets/...), so the function
// finds zero images and returns an empty Map.
// 2. The only side effect is mkdir("/tmp/github-images") which is
// harmless on CI runners.
// This avoids all module-level mocking issues entirely.
// Helper to build a mock Octokits with REST methods
function createMockOctokits({
prData,
filesData,
commitsData,
commentsData,
reviewsData,
reviewCommentsData,
issueData,
userData,
}: {
prData?: Record<string, unknown>;
filesData?: Record<string, unknown>[];
commitsData?: Record<string, unknown>[];
commentsData?: Record<string, unknown>[];
reviewsData?: (Record<string, unknown> & { id?: number })[];
reviewCommentsData?: (Record<string, unknown> & {
pull_request_review_id?: number | null;
})[];
issueData?: Record<string, unknown>;
userData?: Record<string, unknown>;
} = {}) {
// Build per-review mock: listCommentsForReview returns comments matching
// the requested review_id (Forgejo doesn't have a flat review-comments endpoint).
const allReviewComments = reviewCommentsData ?? [];
const listCommentsForReviewMock = jest
.fn()
.mockImplementation((params: { review_id: number }) => {
const matching = allReviewComments.filter(
(c) => c.pull_request_review_id === params.review_id,
);
return Promise.resolve({ data: matching });
});
return {
rest: {
pulls: {
get: jest.fn().mockResolvedValue({ data: prData ?? {} }),
listFiles: jest.fn().mockResolvedValue({ data: filesData ?? [] }),
listCommits: jest.fn().mockResolvedValue({ data: commitsData ?? [] }),
listReviews: jest.fn().mockResolvedValue({ data: reviewsData ?? [] }),
listCommentsForReview: listCommentsForReviewMock,
},
issues: {
get: jest.fn().mockResolvedValue({ data: issueData ?? {} }),
listComments: jest.fn().mockResolvedValue({ data: commentsData ?? [] }),
},
users: {
getByUsername: jest
.fn()
.mockResolvedValue({ data: userData ?? { name: null } }),
},
},
graphql: jest.fn(),
} as unknown as Octokits;
}
// Minimal PR REST response matching Forgejo/GitHub REST shape
const mockPrRestResponse = {
title: "Fix memory leak in user service",
body: "This PR fixes a critical memory leak.",
user: { login: "test-author" },
base: { ref: "main" },
head: { ref: "fix/memory-leak", sha: "abc123def456" },
created_at: "2024-01-15T10:00:00Z",
updated_at: "2024-01-15T12:00:00Z",
additions: 42,
deletions: 15,
state: "open",
labels: [{ name: "bug" }, { name: "priority:high" }],
};
const mockFilesRestResponse = [
{ filename: "src/user.ts", additions: 30, deletions: 10, status: "modified" },
{ filename: "src/new-file.ts", additions: 12, deletions: 0, status: "added" },
{
filename: "src/old-file.ts",
additions: 0,
deletions: 5,
status: "removed",
},
];
const mockCommitsRestResponse = [
{
sha: "commit1sha",
commit: {
message: "fix: patch memory leak",
author: { name: "Test Author", email: "test@example.com" },
},
},
{
sha: "commit2sha",
commit: {
message: "test: add regression test",
author: { name: "Test Author", email: "test@example.com" },
},
},
];
const mockIssueRestResponse = {
title: "Bug: memory leak in user service",
body: "Steps to reproduce...",
user: { login: "reporter" },
created_at: "2024-01-10T08:00:00Z",
updated_at: "2024-01-14T09:00:00Z",
state: "open",
labels: [{ name: "bug" }],
};
describe("fetchForgejoData", () => {
describe("PR data fetching", () => {
it("should fetch PR details, files, commits, comments, and reviews in parallel then per-review comments", async () => {
const mockOctokits = createMockOctokits({
prData: mockPrRestResponse,
filesData: mockFilesRestResponse,
commitsData: mockCommitsRestResponse,
});
await fetchForgejoData({
octokits: mockOctokits,
repository: "test-owner/test-repo",
prNumber: "42",
isPR: true,
});
// Verify phase 1 REST calls (parallel)
expect(mockOctokits.rest.pulls.get).toHaveBeenCalledWith({
owner: "test-owner",
repo: "test-repo",
pull_number: 42,
});
expect(mockOctokits.rest.pulls.listFiles).toHaveBeenCalledWith({
owner: "test-owner",
repo: "test-repo",
pull_number: 42,
per_page: 100,
});
expect(mockOctokits.rest.pulls.listCommits).toHaveBeenCalledWith({
owner: "test-owner",
repo: "test-repo",
pull_number: 42,
per_page: 100,
});
expect(mockOctokits.rest.issues.listComments).toHaveBeenCalledWith({
owner: "test-owner",
repo: "test-repo",
issue_number: 42,
per_page: 100,
});
expect(mockOctokits.rest.pulls.listReviews).toHaveBeenCalledWith({
owner: "test-owner",
repo: "test-repo",
pull_number: 42,
per_page: 100,
});
// Phase 2: per-review comment fetching (no reviews in this test → no calls)
});
it("should map PR fields to GitHubPullRequest type", async () => {
const mockOctokits = createMockOctokits({
prData: mockPrRestResponse,
filesData: mockFilesRestResponse,
commitsData: mockCommitsRestResponse,
});
const result = await fetchForgejoData({
octokits: mockOctokits,
repository: "test-owner/test-repo",
prNumber: "42",
isPR: true,
});
const pr = result.contextData as any;
expect(pr.title).toBe("Fix memory leak in user service");
expect(pr.body).toBe("This PR fixes a critical memory leak.");
expect(pr.author.login).toBe("test-author");
expect(pr.baseRefName).toBe("main");
expect(pr.headRefName).toBe("fix/memory-leak");
expect(pr.headRefOid).toBe("abc123def456");
expect(pr.createdAt).toBe("2024-01-15T10:00:00Z");
expect(pr.updatedAt).toBe("2024-01-15T12:00:00Z");
expect(pr.lastEditedAt).toBeUndefined();
expect(pr.additions).toBe(42);
expect(pr.deletions).toBe(15);
expect(pr.state).toBe("open");
});
it("should map PR labels correctly", async () => {
const mockOctokits = createMockOctokits({
prData: mockPrRestResponse,
filesData: [],
commitsData: [],
});
const result = await fetchForgejoData({
octokits: mockOctokits,
repository: "test-owner/test-repo",
prNumber: "42",
isPR: true,
});
const pr = result.contextData as any;
expect(pr.labels.nodes).toEqual([
{ name: "bug" },
{ name: "priority:high" },
]);
});
it("should handle string labels", async () => {
const mockOctokits = createMockOctokits({
prData: { ...mockPrRestResponse, labels: ["bug", "fix"] },
filesData: [],
commitsData: [],
});
const result = await fetchForgejoData({
octokits: mockOctokits,
repository: "test-owner/test-repo",
prNumber: "42",
isPR: true,
});
const pr = result.contextData as any;
expect(pr.labels.nodes).toEqual([{ name: "bug" }, { name: "fix" }]);
});
it("should map commits to GitHubCommit structure", async () => {
const mockOctokits = createMockOctokits({
prData: mockPrRestResponse,
filesData: [],
commitsData: mockCommitsRestResponse,
});
const result = await fetchForgejoData({
octokits: mockOctokits,
repository: "test-owner/test-repo",
prNumber: "42",
isPR: true,
});
const pr = result.contextData as any;
expect(pr.commits.totalCount).toBe(2);
expect(pr.commits.nodes[0].commit.oid).toBe("commit1sha");
expect(pr.commits.nodes[0].commit.message).toBe("fix: patch memory leak");
expect(pr.commits.nodes[0].commit.author.name).toBe("Test Author");
expect(pr.commits.nodes[0].commit.author.email).toBe("test@example.com");
});
});
describe("file status mapping", () => {
it("should map REST file statuses to GraphQL-style enums", async () => {
const files = [
{ filename: "a.ts", additions: 1, deletions: 0, status: "added" },
{ filename: "b.ts", additions: 0, deletions: 1, status: "removed" },
{ filename: "c.ts", additions: 1, deletions: 1, status: "modified" },
{ filename: "d.ts", additions: 1, deletions: 0, status: "renamed" },
{ filename: "e.ts", additions: 0, deletions: 0, status: "copied" },
{ filename: "f.ts", additions: 1, deletions: 0, status: "changed" },
];
const mockOctokits = createMockOctokits({
prData: mockPrRestResponse,
filesData: files,
commitsData: [],
});
const result = await fetchForgejoData({
octokits: mockOctokits,
repository: "test-owner/test-repo",
prNumber: "42",
isPR: true,
});
expect(result.changedFiles.map((f) => f.changeType)).toEqual([
"ADDED",
"DELETED",
"MODIFIED",
"RENAMED",
"COPIED",
"MODIFIED",
]);
});
it("should uppercase unknown file statuses", async () => {
const mockOctokits = createMockOctokits({
prData: mockPrRestResponse,
filesData: [
{
filename: "a.ts",
additions: 0,
deletions: 0,
status: "unknown_status",
},
],
commitsData: [],
});
const result = await fetchForgejoData({
octokits: mockOctokits,
repository: "test-owner/test-repo",
prNumber: "42",
isPR: true,
});
expect(result.changedFiles[0]!.changeType).toBe("UNKNOWN_STATUS");
});
});
describe("SHA computation", () => {
it("should mark deleted files with sha 'deleted'", async () => {
const mockOctokits = createMockOctokits({
prData: mockPrRestResponse,
filesData: [
{
filename: "deleted.ts",
additions: 0,
deletions: 10,
status: "removed",
},
],
commitsData: [],
});
const result = await fetchForgejoData({
octokits: mockOctokits,
repository: "test-owner/test-repo",
prNumber: "42",
isPR: true,
});
expect(result.changedFilesWithSHA[0]!.sha).toBe("deleted");
});
it("should use 'unknown' for files that fail SHA computation", async () => {
const mockOctokits = createMockOctokits({
prData: mockPrRestResponse,
filesData: [
{
filename: "nonexistent-file.ts",
additions: 1,
deletions: 0,
status: "added",
},
],
commitsData: [],
});
const result = await fetchForgejoData({
octokits: mockOctokits,
repository: "test-owner/test-repo",
prNumber: "42",
isPR: true,
});
// File doesn't exist on disk, so SHA computation fails gracefully
expect(result.changedFilesWithSHA[0]!.sha).toBe("unknown");
});
it("should not compute SHAs for issue (no changed files)", async () => {
const mockOctokits = createMockOctokits({
issueData: mockIssueRestResponse,
});
const result = await fetchForgejoData({
octokits: mockOctokits,
repository: "test-owner/test-repo",
prNumber: "10",
isPR: false,
});
expect(result.changedFilesWithSHA).toEqual([]);
});
});
describe("issue data fetching", () => {
it("should fetch issue details and comments in parallel", async () => {
const mockOctokits = createMockOctokits({
issueData: mockIssueRestResponse,
});
await fetchForgejoData({
octokits: mockOctokits,
repository: "test-owner/test-repo",
prNumber: "10",
isPR: false,
});
expect(mockOctokits.rest.issues.get).toHaveBeenCalledWith({
owner: "test-owner",
repo: "test-repo",
issue_number: 10,
});
expect(mockOctokits.rest.issues.listComments).toHaveBeenCalledWith({
owner: "test-owner",
repo: "test-repo",
issue_number: 10,
per_page: 100,
});
// Should NOT call PR endpoints
expect(mockOctokits.rest.pulls.get).not.toHaveBeenCalled();
expect(mockOctokits.rest.pulls.listFiles).not.toHaveBeenCalled();
expect(mockOctokits.rest.pulls.listCommits).not.toHaveBeenCalled();
expect(mockOctokits.rest.pulls.listReviews).not.toHaveBeenCalled();
expect(
mockOctokits.rest.pulls.listCommentsForReview,
).not.toHaveBeenCalled();
});
it("should map issue fields to GitHubIssue type", async () => {
const mockOctokits = createMockOctokits({
issueData: mockIssueRestResponse,
});
const result = await fetchForgejoData({
octokits: mockOctokits,
repository: "test-owner/test-repo",
prNumber: "10",
isPR: false,
});
const issue = result.contextData as any;
expect(issue.title).toBe("Bug: memory leak in user service");
expect(issue.body).toBe("Steps to reproduce...");
expect(issue.author.login).toBe("reporter");
expect(issue.createdAt).toBe("2024-01-10T08:00:00Z");
expect(issue.updatedAt).toBe("2024-01-14T09:00:00Z");
expect(issue.lastEditedAt).toBeUndefined();
expect(issue.state).toBe("open");
expect(issue.labels.nodes).toEqual([{ name: "bug" }]);
});
it("should return empty changedFiles for issues", async () => {
const mockOctokits = createMockOctokits({
issueData: mockIssueRestResponse,
});
const result = await fetchForgejoData({
octokits: mockOctokits,
repository: "test-owner/test-repo",
prNumber: "10",
isPR: false,
});
expect(result.changedFiles).toEqual([]);
expect(result.changedFilesWithSHA).toEqual([]);
});
it("should return null reviewData for issues", async () => {
const mockOctokits = createMockOctokits({
issueData: mockIssueRestResponse,
});
const result = await fetchForgejoData({
octokits: mockOctokits,
repository: "test-owner/test-repo",
prNumber: "10",
isPR: false,
});
expect(result.reviewData).toBeNull();
});
});
describe("TOCTOU protection", () => {
it("should override body with originalBody when provided", async () => {
const mockOctokits = createMockOctokits({
issueData: mockIssueRestResponse,
});
const result = await fetchForgejoData({
octokits: mockOctokits,
repository: "test-owner/test-repo",
prNumber: "10",
isPR: false,
originalBody: "Original body from webhook",
});
expect(result.contextData.body).toBe("Original body from webhook");
});
it("should override body with empty string when originalBody is null", async () => {
const mockOctokits = createMockOctokits({
issueData: mockIssueRestResponse,
});
const result = await fetchForgejoData({
octokits: mockOctokits,
repository: "test-owner/test-repo",
prNumber: "10",
isPR: false,
originalBody: null,
});
expect(result.contextData.body).toBe("");
});
it("should override title with originalTitle when provided", async () => {
const mockOctokits = createMockOctokits({
issueData: mockIssueRestResponse,
});
const result = await fetchForgejoData({
octokits: mockOctokits,
repository: "test-owner/test-repo",
prNumber: "10",
isPR: false,
originalTitle: "Original title from webhook",
});
expect(result.contextData.title).toBe("Original title from webhook");
});
it("should keep fetched body when originalBody is not provided", async () => {
const mockOctokits = createMockOctokits({
issueData: mockIssueRestResponse,
});
const result = await fetchForgejoData({
octokits: mockOctokits,
repository: "test-owner/test-repo",
prNumber: "10",
isPR: false,
});
expect(result.contextData.body).toBe("Steps to reproduce...");
});
});
describe("user display name", () => {
it("should fetch display name via REST when triggerUsername provided", async () => {
const mockOctokits = createMockOctokits({
issueData: mockIssueRestResponse,
userData: { name: "Test User Display Name" },
});
const result = await fetchForgejoData({
octokits: mockOctokits,
repository: "test-owner/test-repo",
prNumber: "10",
isPR: false,
triggerUsername: "testuser",
});
expect(mockOctokits.rest.users.getByUsername).toHaveBeenCalledWith({
username: "testuser",
});
expect(result.triggerDisplayName).toBe("Test User Display Name");
});
it("should return null when user has no display name", async () => {
const mockOctokits = createMockOctokits({
issueData: mockIssueRestResponse,
userData: { name: null },
});
const result = await fetchForgejoData({
octokits: mockOctokits,
repository: "test-owner/test-repo",
prNumber: "10",
isPR: false,
triggerUsername: "testuser",
});
expect(result.triggerDisplayName).toBeNull();
});
it("should return null on user fetch failure", async () => {
const mockOctokits = createMockOctokits({
issueData: mockIssueRestResponse,
});
(mockOctokits.rest.users.getByUsername as any).mockRejectedValue(
new Error("User not found"),
);
const result = await fetchForgejoData({
octokits: mockOctokits,
repository: "test-owner/test-repo",
prNumber: "10",
isPR: false,
triggerUsername: "nonexistent",
});
expect(result.triggerDisplayName).toBeNull();
});
it("should not fetch display name when triggerUsername is not provided", async () => {
const mockOctokits = createMockOctokits({
issueData: mockIssueRestResponse,
});
const result = await fetchForgejoData({
octokits: mockOctokits,
repository: "test-owner/test-repo",
prNumber: "10",
isPR: false,
});
expect(mockOctokits.rest.users.getByUsername).not.toHaveBeenCalled();
expect(result.triggerDisplayName).toBeUndefined();
});
});
describe("comments and reviews (M4b)", () => {
const mockCommentsData = [
{
id: 100,
body: "First comment",
user: { login: "commenter1" },
created_at: "2024-01-15T09:00:00Z",
updated_at: "2024-01-15T09:00:00Z",
},
{
id: 101,
body: "Second comment",
user: { login: "commenter2" },
created_at: "2024-01-15T09:30:00Z",
updated_at: "2024-01-15T09:30:00Z",
},
];
const mockReviewsData = [
{
id: 200,
body: "LGTM",
user: { login: "reviewer" },
state: "APPROVED",
submitted_at: "2024-01-15T10:00:00Z",
},
];
const mockReviewCommentsData = [
{
id: 300,
body: "Fix this line",
user: { login: "reviewer" },
path: "src/main.ts",
line: 42,
original_line: 42,
created_at: "2024-01-15T10:00:00Z",
updated_at: "2024-01-15T10:00:00Z",
pull_request_review_id: 200,
},
];
it("should fetch and return comments for PRs", async () => {
const mockOctokits = createMockOctokits({
prData: mockPrRestResponse,
filesData: [],
commitsData: [],
commentsData: mockCommentsData,
});
const result = await fetchForgejoData({
octokits: mockOctokits,
repository: "test-owner/test-repo",
prNumber: "42",
isPR: true,
});
expect(result.comments).toHaveLength(2);
expect(result.comments[0]!.id).toBe("100");
expect(result.comments[0]!.body).toBe("First comment");
expect(result.comments[0]!.author.login).toBe("commenter1");
expect(result.comments[1]!.body).toBe("Second comment");
});
it("should fetch and return reviews with grouped inline comments", async () => {
const mockOctokits = createMockOctokits({
prData: mockPrRestResponse,
filesData: [],
commitsData: [],
reviewsData: mockReviewsData,
reviewCommentsData: mockReviewCommentsData,
});
const result = await fetchForgejoData({
octokits: mockOctokits,
repository: "test-owner/test-repo",
prNumber: "42",
isPR: true,
});
expect(result.reviewData).not.toBeNull();
expect(result.reviewData!.nodes).toHaveLength(1);
expect(result.reviewData!.nodes[0]!.body).toBe("LGTM");
expect(result.reviewData!.nodes[0]!.state).toBe("APPROVED");
expect(result.reviewData!.nodes[0]!.comments.nodes).toHaveLength(1);
expect(result.reviewData!.nodes[0]!.comments.nodes[0]!.body).toBe(
"Fix this line",
);
expect(result.reviewData!.nodes[0]!.comments.nodes[0]!.path).toBe(
"src/main.ts",
);
});
it("should populate contextData.comments.nodes and contextData.reviews.nodes", async () => {
const mockOctokits = createMockOctokits({
prData: mockPrRestResponse,
filesData: [],
commitsData: [],
commentsData: mockCommentsData,
reviewsData: mockReviewsData,
reviewCommentsData: mockReviewCommentsData,
});
const result = await fetchForgejoData({
octokits: mockOctokits,
repository: "test-owner/test-repo",
prNumber: "42",
isPR: true,
});
const pr = result.contextData as any;
expect(pr.comments.nodes).toHaveLength(2);
expect(pr.reviews.nodes).toHaveLength(1);
});
it("should return empty comments/reviewData when no data", async () => {
const mockOctokits = createMockOctokits({
prData: mockPrRestResponse,
filesData: [],
commitsData: [],
commentsData: [],
reviewsData: [],
reviewCommentsData: [],
});
const result = await fetchForgejoData({
octokits: mockOctokits,
repository: "test-owner/test-repo",
prNumber: "42",
isPR: true,
});
expect(result.comments).toEqual([]);
expect(result.reviewData).toEqual({ nodes: [] });
});
it("should fetch and return comments for issues", async () => {
const mockOctokits = createMockOctokits({
issueData: mockIssueRestResponse,
commentsData: mockCommentsData,
});
const result = await fetchForgejoData({
octokits: mockOctokits,
repository: "test-owner/test-repo",
prNumber: "10",
isPR: false,
});
expect(result.comments).toHaveLength(2);
expect(result.comments[0]!.body).toBe("First comment");
});
it("should apply TOCTOU filtering to comments", async () => {
const mockOctokits = createMockOctokits({
prData: mockPrRestResponse,
filesData: [],
commitsData: [],
commentsData: [
{
id: 1,
body: "Before trigger",
user: { login: "alice" },
created_at: "2024-01-15T09:00:00Z",
updated_at: "2024-01-15T09:00:00Z",
},
{
id: 2,
body: "After trigger",
user: { login: "bob" },
created_at: "2024-01-15T11:00:00Z",
updated_at: "2024-01-15T11:00:00Z",
},
],
});
const result = await fetchForgejoData({
octokits: mockOctokits,
repository: "test-owner/test-repo",
prNumber: "42",
isPR: true,
triggerTime: "2024-01-15T10:00:00Z",
});
// Only the comment before trigger time should be included
expect(result.comments).toHaveLength(1);
expect(result.comments[0]!.body).toBe("Before trigger");
});
it("should apply actor filtering to comments", async () => {
const mockOctokits = createMockOctokits({
prData: mockPrRestResponse,
filesData: [],
commitsData: [],
commentsData: [
{
id: 1,
body: "Human comment",
user: { login: "alice" },
created_at: "2024-01-15T09:00:00Z",
updated_at: "2024-01-15T09:00:00Z",
},
{
id: 2,
body: "Bot comment",
user: { login: "dependabot[bot]" },
created_at: "2024-01-15T09:00:00Z",
updated_at: "2024-01-15T09:00:00Z",
},
],
});
const result = await fetchForgejoData({
octokits: mockOctokits,
repository: "test-owner/test-repo",
prNumber: "42",
isPR: true,
excludeCommentsByActor: "*[bot]",
});
expect(result.comments).toHaveLength(1);
expect(result.comments[0]!.author.login).toBe("alice");
});
it("should actor-filter reviews without mutating contextData", async () => {
const mockOctokits = createMockOctokits({
prData: mockPrRestResponse,
filesData: [],
commitsData: [],
reviewsData: [
{
id: 200,
body: "Bot review",
user: { login: "bot[bot]" },
state: "COMMENTED",
submitted_at: "2024-01-15T10:00:00Z",
},
{
id: 201,
body: "Human review",
user: { login: "alice" },
state: "APPROVED",
submitted_at: "2024-01-15T10:30:00Z",
},
],
reviewCommentsData: [],
});
const result = await fetchForgejoData({
octokits: mockOctokits,
repository: "test-owner/test-repo",
prNumber: "42",
isPR: true,
excludeCommentsByActor: "*[bot]",
});
// contextData should still have both reviews (unfiltered)
const pr = result.contextData as any;
expect(pr.reviews.nodes).toHaveLength(2);
// reviewData should only have the human review (filtered)
expect(result.reviewData!.nodes).toHaveLength(1);
expect(result.reviewData!.nodes[0]!.author.login).toBe("alice");
});
it("should actor-filter inline comments within reviews", async () => {
const mockOctokits = createMockOctokits({
prData: mockPrRestResponse,
filesData: [],
commitsData: [],
reviewsData: [
{
id: 200,
body: "Review",
user: { login: "alice" },
state: "COMMENTED",
submitted_at: "2024-01-15T10:00:00Z",
},
],
reviewCommentsData: [
{
id: 300,
body: "Human inline",
user: { login: "alice" },
path: "file.ts",
line: 10,
original_line: 10,
created_at: "2024-01-15T10:00:00Z",
updated_at: "2024-01-15T10:00:00Z",
pull_request_review_id: 200,
},
{
id: 301,
body: "Bot inline",
user: { login: "bot[bot]" },
path: "file.ts",
line: 20,
original_line: 20,
created_at: "2024-01-15T10:00:00Z",
updated_at: "2024-01-15T10:00:00Z",
pull_request_review_id: 200,
},
],
});
const result = await fetchForgejoData({
octokits: mockOctokits,
repository: "test-owner/test-repo",
prNumber: "42",
isPR: true,
excludeCommentsByActor: "*[bot]",
});
expect(result.reviewData!.nodes[0]!.comments.nodes).toHaveLength(1);
expect(result.reviewData!.nodes[0]!.comments.nodes[0]!.author.login).toBe(
"alice",
);
});
it("should return imageUrlMap (empty on Forgejo due to URL pattern mismatch)", async () => {
const mockOctokits = createMockOctokits({
issueData: mockIssueRestResponse,
});
const result = await fetchForgejoData({
octokits: mockOctokits,
repository: "test-owner/test-repo",
prNumber: "10",
isPR: false,
});
expect(result.imageUrlMap.size).toBe(0);
});
});
describe("TOCTOU filtering for issue comments", () => {
it("should exclude comments edited after trigger time", async () => {
const mockOctokits = createMockOctokits({
issueData: mockIssueRestResponse,
commentsData: [
{
id: 1,
body: "Early comment",
user: { login: "alice" },
created_at: "2024-01-15T08:00:00Z",
updated_at: "2024-01-15T08:00:00Z",
},
{
id: 2,
body: "Comment edited after trigger",
user: { login: "bob" },
created_at: "2024-01-15T08:30:00Z",
updated_at: "2024-01-15T11:00:00Z",
},
{
id: 3,
body: "Late comment",
user: { login: "charlie" },
created_at: "2024-01-15T11:00:00Z",
updated_at: "2024-01-15T11:00:00Z",
},
],
});
const result = await fetchForgejoData({
octokits: mockOctokits,
repository: "test-owner/test-repo",
prNumber: "5",
isPR: false,
triggerTime: "2024-01-15T10:00:00Z",
});
// Only the early, unedited comment should be included
expect(result.comments).toHaveLength(1);
expect(result.comments[0]!.body).toBe("Early comment");
});
});
describe("error handling", () => {
it("should throw on invalid repository format", async () => {
const mockOctokits = createMockOctokits();
expect(
fetchForgejoData({
octokits: mockOctokits,
repository: "invalid-format",
prNumber: "1",
isPR: false,
}),
).rejects.toThrow("Invalid repository format");
});
it("should handle null PR body gracefully", async () => {
const mockOctokits = createMockOctokits({
prData: { ...mockPrRestResponse, body: null },
filesData: [],
commitsData: [],
});
const result = await fetchForgejoData({
octokits: mockOctokits,
repository: "test-owner/test-repo",
prNumber: "42",
isPR: true,
});
expect(result.contextData.body).toBe("");
});
it("should handle null issue body gracefully", async () => {
const mockOctokits = createMockOctokits({
issueData: { ...mockIssueRestResponse, body: null },
});
const result = await fetchForgejoData({
octokits: mockOctokits,
repository: "test-owner/test-repo",
prNumber: "10",
isPR: false,
});
expect(result.contextData.body).toBe("");
});
it("should handle null PR user gracefully", async () => {
const mockOctokits = createMockOctokits({
prData: { ...mockPrRestResponse, user: null },
filesData: [],
commitsData: [],
});
const result = await fetchForgejoData({
octokits: mockOctokits,
repository: "test-owner/test-repo",
prNumber: "42",
isPR: true,
});
expect(result.contextData.author.login).toBe("unknown");
});
it("should handle missing labels gracefully", async () => {
const mockOctokits = createMockOctokits({
prData: { ...mockPrRestResponse, labels: undefined },
filesData: [],
commitsData: [],
});
const result = await fetchForgejoData({
octokits: mockOctokits,
repository: "test-owner/test-repo",
prNumber: "42",
isPR: true,
});
const pr = result.contextData as any;
expect(pr.labels.nodes).toEqual([]);
});
it("should handle missing commit author gracefully", async () => {
const mockOctokits = createMockOctokits({
prData: mockPrRestResponse,
filesData: [],
commitsData: [
{
sha: "abc",
commit: {
message: "test",
author: null,
},
},
],
});
const result = await fetchForgejoData({
octokits: mockOctokits,
repository: "test-owner/test-repo",
prNumber: "42",
isPR: true,
});
const pr = result.contextData as any;
expect(pr.commits.nodes[0].commit.author.name).toBe("unknown");
expect(pr.commits.nodes[0].commit.author.email).toBe("");
});
});
});
describe("mapFileStatus", () => {
it("maps REST file statuses to GraphQL-style enums", () => {
expect(mapFileStatus("added")).toBe("ADDED");
expect(mapFileStatus("removed")).toBe("DELETED");
expect(mapFileStatus("modified")).toBe("MODIFIED");
expect(mapFileStatus("renamed")).toBe("RENAMED");
expect(mapFileStatus("copied")).toBe("COPIED");
expect(mapFileStatus("changed")).toBe("MODIFIED");
expect(mapFileStatus("unchanged")).toBe("MODIFIED");
});
it("uppercases unknown statuses", () => {
expect(mapFileStatus("something_else")).toBe("SOMETHING_ELSE");
});
});
describe("mapRestComments", () => {
it("maps REST comment objects to GitHubComment type", () => {
const result = mapRestComments([
{
id: 42,
body: "Hello",
user: { login: "alice" },
created_at: "2024-01-15T10:00:00Z",
updated_at: "2024-01-15T11:00:00Z",
},
]);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
id: "42",
databaseId: "42",
body: "Hello",
author: { login: "alice" },
createdAt: "2024-01-15T10:00:00Z",
updatedAt: "2024-01-15T11:00:00Z",
lastEditedAt: undefined,
isMinimized: false,
});
});
it("handles null body and user", () => {
const result = mapRestComments([
{
id: 1,
body: null,
user: null,
created_at: "2024-01-15T10:00:00Z",
updated_at: "2024-01-15T10:00:00Z",
},
]);
expect(result[0]!.body).toBe("");
expect(result[0]!.author.login).toBe("unknown");
});
it("handles empty array", () => {
expect(mapRestComments([])).toEqual([]);
});
});
describe("mapRestReviewComments", () => {
it("maps inline review comments with reviewId", () => {
const result = mapRestReviewComments([
{
id: 100,
body: "Fix this",
user: { login: "bob" },
path: "src/main.ts",
line: 42,
original_line: 40,
created_at: "2024-01-15T10:00:00Z",
updated_at: "2024-01-15T10:00:00Z",
pull_request_review_id: 200,
},
]);
expect(result).toHaveLength(1);
expect(result[0]!.path).toBe("src/main.ts");
expect(result[0]!.line).toBe(42);
expect(result[0]!.reviewId).toBe(200);
});
it("falls back to original_line when line is null", () => {
const result = mapRestReviewComments([
{
id: 101,
body: "comment",
user: { login: "bob" },
path: "file.ts",
line: null,
original_line: 10,
created_at: "2024-01-15T10:00:00Z",
updated_at: "2024-01-15T10:00:00Z",
},
]);
expect(result[0]!.line).toBe(10);
});
it("returns null line when both line and original_line are null", () => {
const result = mapRestReviewComments([
{
id: 102,
body: "comment",
user: { login: "bob" },
path: "file.ts",
line: null,
original_line: null,
created_at: "2024-01-15T10:00:00Z",
updated_at: "2024-01-15T10:00:00Z",
},
]);
expect(result[0]!.line).toBeNull();
});
});
describe("mapRestReviews", () => {
it("groups inline comments by review ID", () => {
const inlineComments: (GitHubReviewComment & {
reviewId: number | null;
})[] = [
{
id: "100",
databaseId: "100",
body: "Comment for review 200",
author: { login: "alice" },
createdAt: "2024-01-15T10:00:00Z",
updatedAt: "2024-01-15T10:00:00Z",
path: "file.ts",
line: 10,
reviewId: 200,
},
{
id: "101",
databaseId: "101",
body: "Another for review 200",
author: { login: "alice" },
createdAt: "2024-01-15T10:00:00Z",
updatedAt: "2024-01-15T10:00:00Z",
path: "file.ts",
line: 20,
reviewId: 200,
},
];
const result = mapRestReviews(
[
{
id: 200,
body: "Review",
user: { login: "alice" },
state: "COMMENTED",
submitted_at: "2024-01-15T10:00:00Z",
},
],
inlineComments,
);
expect(result[0]!.comments.nodes).toHaveLength(2);
});
it("uppercases review state", () => {
const result = mapRestReviews(
[
{
id: 200,
body: "",
user: { login: "alice" },
state: "changes_requested",
submitted_at: "2024-01-15T10:00:00Z",
},
],
[],
);
expect(result[0]!.state).toBe("CHANGES_REQUESTED");
});
it("excludes orphan inline comments (null reviewId)", () => {
const inlineComments: (GitHubReviewComment & {
reviewId: number | null;
})[] = [
{
id: "100",
databaseId: "100",
body: "orphan",
author: { login: "alice" },
createdAt: "2024-01-15T10:00:00Z",
updatedAt: "2024-01-15T10:00:00Z",
path: "file.ts",
line: 1,
reviewId: null,
},
];
const result = mapRestReviews(
[
{
id: 200,
body: "",
user: { login: "alice" },
state: "COMMENTED",
submitted_at: "2024-01-15T10:00:00Z",
},
],
inlineComments,
);
expect(result[0]!.comments.nodes).toEqual([]);
});
it("strips reviewId from stored comments", () => {
const inlineComments: (GitHubReviewComment & {
reviewId: number | null;
})[] = [
{
id: "100",
databaseId: "100",
body: "inline",
author: { login: "alice" },
createdAt: "2024-01-15T10:00:00Z",
updatedAt: "2024-01-15T10:00:00Z",
path: "file.ts",
line: 5,
reviewId: 200,
},
];
const result = mapRestReviews(
[
{
id: 200,
body: "",
user: { login: "alice" },
state: "COMMENTED",
submitted_at: "2024-01-15T10:00:00Z",
},
],
inlineComments,
);
expect((result[0]!.comments.nodes[0] as any).reviewId).toBeUndefined();
});
});