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; filesData?: Record[]; commitsData?: Record[]; commentsData?: Record[]; reviewsData?: (Record & { id?: number })[]; reviewCommentsData?: (Record & { pull_request_review_id?: number | null; })[]; issueData?: Record; userData?: Record; } = {}) { // 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(); }); });