feat(prd-1): implement M4b - Forgejo REST comment/review fetching
- Add 3 REST calls to Promise.all for PRs (listComments, listReviews, listReviewComments) and 1 for issues (listComments) - Add mapping helpers: mapRestComments, mapRestReviewComments, mapRestReviews (groups inline comments by pull_request_review_id) - Import and apply TOCTOU filtering (filterCommentsToTriggerTime, filterReviewsToTriggerTime) and actor filtering from GitHub fetcher - Add image processing pipeline via downloadCommentImages (no-op on Forgejo due to URL pattern mismatch, architecturally correct) - Deep-clone reviews before filtering to preserve contextData immutability - Update test suite: 51 tests covering mapping helpers, full fetch flow, TOCTOU filtering, actor filtering, and contextData immutability - Updated PRD checkboxes for M4b completion Progress: 44% complete (4/9 milestones) - next: M5 or M3 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
6e104a33dc
commit
4b0737c54c
4 files changed
+1001
-100
No files matched your search
@@ -1,18 +1,36 @@
|
||||
import { describe, expect, it, jest } from "bun:test";
|
||||
import { fetchForgejoData } from "../src/forgejo/data/fetcher";
|
||||
import { describe, expect, it, jest, mock } 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";
|
||||
|
||||
// Mock image downloader — returns empty map (no-op on Forgejo)
|
||||
mock.module("../src/github/utils/image-downloader", () => ({
|
||||
downloadCommentImages: jest.fn().mockResolvedValue(new Map()),
|
||||
}));
|
||||
|
||||
// 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>[];
|
||||
reviewCommentsData?: Record<string, unknown>[];
|
||||
issueData?: Record<string, unknown>;
|
||||
userData?: Record<string, unknown>;
|
||||
} = {}) {
|
||||
@@ -22,9 +40,14 @@ function createMockOctokits({
|
||||
get: jest.fn().mockResolvedValue({ data: prData ?? {} }),
|
||||
listFiles: jest.fn().mockResolvedValue({ data: filesData ?? [] }),
|
||||
listCommits: jest.fn().mockResolvedValue({ data: commitsData ?? [] }),
|
||||
listReviews: jest.fn().mockResolvedValue({ data: reviewsData ?? [] }),
|
||||
listReviewComments: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ data: reviewCommentsData ?? [] }),
|
||||
},
|
||||
issues: {
|
||||
get: jest.fn().mockResolvedValue({ data: issueData ?? {} }),
|
||||
listComments: jest.fn().mockResolvedValue({ data: commentsData ?? [] }),
|
||||
},
|
||||
users: {
|
||||
getByUsername: jest
|
||||
@@ -91,7 +114,7 @@ const mockIssueRestResponse = {
|
||||
|
||||
describe("fetchForgejoData", () => {
|
||||
describe("PR data fetching", () => {
|
||||
it("should fetch PR details, files, and commits in parallel", async () => {
|
||||
it("should fetch PR details, files, commits, comments, reviews, and inline comments in parallel", async () => {
|
||||
const mockOctokits = createMockOctokits({
|
||||
prData: mockPrRestResponse,
|
||||
filesData: mockFilesRestResponse,
|
||||
@@ -105,7 +128,7 @@ describe("fetchForgejoData", () => {
|
||||
isPR: true,
|
||||
});
|
||||
|
||||
// Verify all three REST calls were made
|
||||
// Verify all six REST calls were made
|
||||
expect(mockOctokits.rest.pulls.get).toHaveBeenCalledWith({
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
@@ -123,6 +146,24 @@ describe("fetchForgejoData", () => {
|
||||
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,
|
||||
});
|
||||
expect(mockOctokits.rest.pulls.listReviewComments).toHaveBeenCalledWith({
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
pull_number: 42,
|
||||
per_page: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("should map PR fields to GitHubPullRequest type", async () => {
|
||||
@@ -210,13 +251,9 @@ describe("fetchForgejoData", () => {
|
||||
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.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",
|
||||
);
|
||||
expect(pr.commits.nodes[0].commit.author.email).toBe("test@example.com");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -346,7 +383,7 @@ describe("fetchForgejoData", () => {
|
||||
});
|
||||
|
||||
describe("issue data fetching", () => {
|
||||
it("should fetch issue details", async () => {
|
||||
it("should fetch issue details and comments in parallel", async () => {
|
||||
const mockOctokits = createMockOctokits({
|
||||
issueData: mockIssueRestResponse,
|
||||
});
|
||||
@@ -363,11 +400,19 @@ describe("fetchForgejoData", () => {
|
||||
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.listReviewComments).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should map issue fields to GitHubIssue type", async () => {
|
||||
@@ -564,12 +609,129 @@ describe("fetchForgejoData", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("M4a placeholders (comments/reviews/images)", () => {
|
||||
it("should return empty comments array", async () => {
|
||||
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({
|
||||
@@ -580,13 +742,47 @@ describe("fetchForgejoData", () => {
|
||||
});
|
||||
|
||||
expect(result.comments).toEqual([]);
|
||||
expect(result.reviewData).toEqual({ nodes: [] });
|
||||
});
|
||||
|
||||
it("should return empty reviewData nodes for PRs", async () => {
|
||||
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({
|
||||
@@ -594,12 +790,145 @@ describe("fetchForgejoData", () => {
|
||||
repository: "test-owner/test-repo",
|
||||
prNumber: "42",
|
||||
isPR: true,
|
||||
triggerTime: "2024-01-15T10:00:00Z",
|
||||
});
|
||||
|
||||
expect(result.reviewData).toEqual({ nodes: [] });
|
||||
// Only the comment before trigger time should be included
|
||||
expect(result.comments).toHaveLength(1);
|
||||
expect(result.comments[0]!.body).toBe("Before trigger");
|
||||
});
|
||||
|
||||
it("should return empty imageUrlMap", async () => {
|
||||
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,
|
||||
});
|
||||
@@ -615,6 +944,49 @@ describe("fetchForgejoData", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -724,3 +1096,250 @@ describe("fetchForgejoData", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user