diff --git a/prds/1-forgejo-platform-support.md b/prds/1-forgejo-platform-support.md index 81245ae..afa5270 100644 --- a/prds/1-forgejo-platform-support.md +++ b/prds/1-forgejo-platform-support.md @@ -243,7 +243,7 @@ The upstream repo has 13 workflows in `.github/workflows/`. Strategy: ### Phase 2 - Core Data Fetching (the hard part) -- [ ] **M4a: Forgejo REST data fetching — PR/Issue basics** — Implement REST-based fetching for PR details, issue details, changed files, and commits. Produce output matching the `FetchDataResult` type that existing code expects. +- [x] **M4a: Forgejo REST data fetching — PR/Issue basics** — Implement REST-based fetching for PR details, issue details, changed files, and commits. Produce output matching the `FetchDataResult` type that existing code expects. - Files: `src/forgejo/data/fetcher.ts` (NEW) - Depends on: M1, M2 - Success: Can fetch PR/issue title, body, author, state, files, commits from Forgejo REST API. diff --git a/src/forgejo/data/fetcher.ts b/src/forgejo/data/fetcher.ts new file mode 100644 index 0000000..ec922be --- /dev/null +++ b/src/forgejo/data/fetcher.ts @@ -0,0 +1,246 @@ +/** + * Forgejo REST-based data fetching. + * + * Replaces GitHub's GraphQL queries (PR_QUERY, ISSUE_QUERY, USER_QUERY) + * with Octokit REST calls that work on Forgejo's /api/v1 endpoint. + * + * M4a scope: PR/issue details, files, commits, user display name. + * M4b scope: Comments, reviews, inline comments, image processing. + */ + +import { execFileSync } from "child_process"; +import type { Octokits } from "../../github/api/client"; +import type { + GitHubComment, + GitHubFile, + GitHubIssue, + GitHubPullRequest, + GitHubReview, +} from "../../github/types"; +import type { + FetchDataResult, + GitHubFileWithSHA, +} from "../../github/data/fetcher"; + +type FetchForgejoDataParams = { + octokits: Octokits; + repository: string; + prNumber: string; + isPR: boolean; + triggerUsername?: string; + triggerTime?: string; + originalTitle?: string; + originalBody?: string | null; + // Accepted for API compatibility — used by M4b + includeCommentsByActor?: string; + excludeCommentsByActor?: string; +}; + +/** + * Maps Forgejo/GitHub REST file status strings to GraphQL-style uppercase enums. + * REST returns: "added", "removed", "modified", "renamed", "copied", "changed", "unchanged" + * GraphQL uses: "ADDED", "DELETED", "MODIFIED", "RENAMED", "COPIED" + */ +function mapFileStatus(status: string): string { + const statusMap: Record = { + added: "ADDED", + removed: "DELETED", + modified: "MODIFIED", + renamed: "RENAMED", + copied: "COPIED", + changed: "MODIFIED", + unchanged: "MODIFIED", + }; + return statusMap[status] || status.toUpperCase(); +} + +/** + * Fetch user display name via REST (replaces GraphQL USER_QUERY). + */ +async function fetchUserDisplayNameRest( + octokits: Octokits, + login: string, +): Promise { + try { + const { data } = await octokits.rest.users.getByUsername({ + username: login, + }); + return data.name || null; + } catch (error) { + console.warn(`Failed to fetch user display name for ${login}:`, error); + return null; + } +} + +/** + * Fetch PR or issue data from Forgejo using REST API calls. + * + * For PRs, fetches details + files + commits in parallel via Promise.all(). + * Produces output matching FetchDataResult so downstream code + * (formatter, prompt builder, branch setup) works unchanged. + * + * Comments and reviews are empty in M4a — M4b will populate them. + */ +export async function fetchForgejoData({ + octokits, + repository, + prNumber, + isPR, + triggerUsername, + originalTitle, + originalBody, +}: FetchForgejoDataParams): Promise { + const [owner, repo] = repository.split("/"); + if (!owner || !repo) { + throw new Error("Invalid repository format. Expected 'owner/repo'."); + } + + const number = parseInt(prNumber); + let contextData: GitHubPullRequest | GitHubIssue; + let changedFiles: GitHubFile[] = []; + let changedFilesWithSHA: GitHubFileWithSHA[] = []; + + if (isPR) { + // Fetch PR details, files, and commits in parallel + const [prResponse, filesResponse, commitsResponse] = await Promise.all([ + octokits.rest.pulls.get({ owner, repo, pull_number: number }), + octokits.rest.pulls.listFiles({ + owner, + repo, + pull_number: number, + per_page: 100, + }), + octokits.rest.pulls.listCommits({ + owner, + repo, + pull_number: number, + per_page: 100, + }), + ]); + + const pr = prResponse.data; + const files = filesResponse.data; + const commits = commitsResponse.data; + + // Map REST file objects to GitHubFile type + changedFiles = files.map((f) => ({ + path: f.filename, + additions: f.additions, + deletions: f.deletions, + changeType: mapFileStatus(f.status), + })); + + // Map REST response to GitHubPullRequest type + contextData = { + title: pr.title, + body: pr.body ?? "", + author: { login: pr.user?.login ?? "unknown" }, + baseRefName: pr.base.ref, + headRefName: pr.head.ref, + headRefOid: pr.head.sha, + createdAt: pr.created_at, + updatedAt: pr.updated_at, + // REST doesn't expose lastEditedAt — TOCTOU filtering falls back to updatedAt + lastEditedAt: undefined, + additions: pr.additions, + deletions: pr.deletions, + state: pr.state, + labels: { + nodes: (pr.labels ?? []).map((l) => ({ + name: typeof l === "string" ? l : (l.name ?? ""), + })), + }, + commits: { + totalCount: commits.length, + nodes: commits.map((c) => ({ + commit: { + oid: c.sha, + message: c.commit.message, + author: { + name: c.commit.author?.name ?? "unknown", + email: c.commit.author?.email ?? "", + }, + }, + })), + }, + files: { nodes: changedFiles }, + comments: { nodes: [] as GitHubComment[] }, // M4b + reviews: { nodes: [] as GitHubReview[] }, // M4b + }; + + console.log(`Successfully fetched PR #${prNumber} data`); + } else { + // Fetch issue details + const { data: issue } = await octokits.rest.issues.get({ + owner, + repo, + issue_number: number, + }); + + // Map REST response to GitHubIssue type + contextData = { + title: issue.title, + body: issue.body ?? "", + author: { login: issue.user?.login ?? "unknown" }, + createdAt: issue.created_at, + updatedAt: issue.updated_at, + lastEditedAt: undefined, + state: issue.state ?? "open", + labels: { + nodes: (issue.labels ?? []).map((l) => ({ + name: typeof l === "string" ? l : (l.name ?? ""), + })), + }, + comments: { nodes: [] as GitHubComment[] }, // M4b + }; + + console.log(`Successfully fetched issue #${prNumber} data`); + } + + // Compute SHAs for changed files (same logic as GitHub fetcher) + if (isPR && changedFiles.length > 0) { + changedFilesWithSHA = changedFiles.map((file) => { + if (file.changeType === "DELETED") { + return { ...file, sha: "deleted" }; + } + + try { + const sha = execFileSync("git", ["hash-object", file.path], { + encoding: "utf-8", + }).trim(); + return { ...file, sha }; + } catch { + console.warn(`Failed to compute SHA for ${file.path}`); + return { ...file, sha: "unknown" }; + } + }); + } + + // TOCTOU protection: use original body/title from webhook payload if provided + if (originalBody !== undefined) { + contextData.body = originalBody ?? ""; + } + + if (originalTitle !== undefined) { + contextData.title = originalTitle; + } + + // Fetch trigger user display name via REST (replaces GraphQL USER_QUERY) + let triggerDisplayName: string | null | undefined; + if (triggerUsername) { + triggerDisplayName = await fetchUserDisplayNameRest( + octokits, + triggerUsername, + ); + } + + return { + contextData, + comments: [], // M4b will populate + changedFiles, + changedFilesWithSHA, + reviewData: isPR ? { nodes: [] } : null, // M4b will populate + imageUrlMap: new Map(), // M4b will populate + triggerDisplayName, + }; +} diff --git a/test/forgejo-data-fetcher.test.ts b/test/forgejo-data-fetcher.test.ts new file mode 100644 index 0000000..ca1b69b --- /dev/null +++ b/test/forgejo-data-fetcher.test.ts @@ -0,0 +1,726 @@ +import { describe, expect, it, jest } from "bun:test"; +import { fetchForgejoData } from "../src/forgejo/data/fetcher"; +import type { Octokits } from "../src/github/api/client"; + +// Helper to build a mock Octokits with REST methods +function createMockOctokits({ + prData, + filesData, + commitsData, + issueData, + userData, +}: { + prData?: Record; + filesData?: Record[]; + commitsData?: Record[]; + issueData?: Record; + userData?: Record; +} = {}) { + return { + rest: { + pulls: { + get: jest.fn().mockResolvedValue({ data: prData ?? {} }), + listFiles: jest.fn().mockResolvedValue({ data: filesData ?? [] }), + listCommits: jest.fn().mockResolvedValue({ data: commitsData ?? [] }), + }, + issues: { + get: jest.fn().mockResolvedValue({ data: issueData ?? {} }), + }, + 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, and commits in parallel", async () => { + const mockOctokits = createMockOctokits({ + prData: mockPrRestResponse, + filesData: mockFilesRestResponse, + commitsData: mockCommitsRestResponse, + }); + + await fetchForgejoData({ + octokits: mockOctokits, + repository: "test-owner/test-repo", + prNumber: "42", + isPR: true, + }); + + // Verify all three REST calls were made + 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, + }); + }); + + 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", 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, + }); + + // 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(); + }); + + 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("M4a placeholders (comments/reviews/images)", () => { + it("should return empty comments array", async () => { + const mockOctokits = createMockOctokits({ + prData: mockPrRestResponse, + filesData: [], + commitsData: [], + }); + + const result = await fetchForgejoData({ + octokits: mockOctokits, + repository: "test-owner/test-repo", + prNumber: "42", + isPR: true, + }); + + expect(result.comments).toEqual([]); + }); + + it("should return empty reviewData nodes for PRs", async () => { + const mockOctokits = createMockOctokits({ + prData: mockPrRestResponse, + filesData: [], + commitsData: [], + }); + + const result = await fetchForgejoData({ + octokits: mockOctokits, + repository: "test-owner/test-repo", + prNumber: "42", + isPR: true, + }); + + expect(result.reviewData).toEqual({ nodes: [] }); + }); + + it("should return empty imageUrlMap", 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("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(""); + }); + }); +});