Forgejo REST API returns state as lowercase ("closed"/"open") but
branch.ts compares against GraphQL-style uppercase ("CLOSED"/"MERGED").
This caused @claude on closed/merged PRs to take the open-PR checkout
path, failing with "fatal: invalid reference: refs/pull/N/head".
Normalize state to uppercase in the Forgejo data fetcher, matching the
existing pattern used for review state (line 153).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1367 lines
39 KiB
TypeScript
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();
|
|
});
|
|
});
|