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
+295
-23
@@ -4,8 +4,8 @@
|
||||
* 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.
|
||||
* M4a: PR/issue details, files, commits, user display name.
|
||||
* M4b: Comments, reviews, inline comments, TOCTOU filtering, actor filtering, image processing.
|
||||
*/
|
||||
|
||||
import { execFileSync } from "child_process";
|
||||
@@ -16,13 +16,22 @@ import type {
|
||||
GitHubIssue,
|
||||
GitHubPullRequest,
|
||||
GitHubReview,
|
||||
GitHubReviewComment,
|
||||
} from "../../github/types";
|
||||
import type {
|
||||
FetchDataResult,
|
||||
GitHubFileWithSHA,
|
||||
} from "../../github/data/fetcher";
|
||||
import {
|
||||
filterCommentsToTriggerTime,
|
||||
filterReviewsToTriggerTime,
|
||||
filterCommentsByActor,
|
||||
isBodySafeToUse,
|
||||
} from "../../github/data/fetcher";
|
||||
import type { CommentWithImages } from "../../github/utils/image-downloader";
|
||||
import { downloadCommentImages } from "../../github/utils/image-downloader";
|
||||
|
||||
type FetchForgejoDataParams = {
|
||||
export type FetchForgejoDataParams = {
|
||||
octokits: Octokits;
|
||||
repository: string;
|
||||
prNumber: string;
|
||||
@@ -31,7 +40,6 @@ type FetchForgejoDataParams = {
|
||||
triggerTime?: string;
|
||||
originalTitle?: string;
|
||||
originalBody?: string | null;
|
||||
// Accepted for API compatibility — used by M4b
|
||||
includeCommentsByActor?: string;
|
||||
excludeCommentsByActor?: string;
|
||||
};
|
||||
@@ -41,7 +49,7 @@ type FetchForgejoDataParams = {
|
||||
* REST returns: "added", "removed", "modified", "renamed", "copied", "changed", "unchanged"
|
||||
* GraphQL uses: "ADDED", "DELETED", "MODIFIED", "RENAMED", "COPIED"
|
||||
*/
|
||||
function mapFileStatus(status: string): string {
|
||||
export function mapFileStatus(status: string): string {
|
||||
const statusMap: Record<string, string> = {
|
||||
added: "ADDED",
|
||||
removed: "DELETED",
|
||||
@@ -54,6 +62,103 @@ function mapFileStatus(status: string): string {
|
||||
return statusMap[status] || status.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps REST issue/PR comment objects to GitHubComment type.
|
||||
*/
|
||||
export function mapRestComments(
|
||||
comments: Array<{
|
||||
id: number;
|
||||
body?: string | null;
|
||||
user?: { login?: string } | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}>,
|
||||
): GitHubComment[] {
|
||||
return comments.map((c) => ({
|
||||
id: c.id.toString(),
|
||||
databaseId: c.id.toString(),
|
||||
body: c.body ?? "",
|
||||
author: { login: c.user?.login ?? "unknown" },
|
||||
createdAt: c.created_at,
|
||||
updatedAt: c.updated_at,
|
||||
lastEditedAt: undefined, // Forgejo REST doesn't expose lastEditedAt
|
||||
isMinimized: false, // Forgejo REST doesn't have minimized comments
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps REST inline review comment objects to GitHubReviewComment type,
|
||||
* preserving the review ID for grouping.
|
||||
*/
|
||||
export function mapRestReviewComments(
|
||||
comments: Array<{
|
||||
id: number;
|
||||
body?: string | null;
|
||||
user?: { login?: string } | null;
|
||||
path: string;
|
||||
line?: number | null;
|
||||
original_line?: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
pull_request_review_id?: number | null;
|
||||
}>,
|
||||
): (GitHubReviewComment & { reviewId: number | null })[] {
|
||||
return comments.map((c) => ({
|
||||
id: c.id.toString(),
|
||||
databaseId: c.id.toString(),
|
||||
body: c.body ?? "",
|
||||
author: { login: c.user?.login ?? "unknown" },
|
||||
createdAt: c.created_at,
|
||||
updatedAt: c.updated_at,
|
||||
lastEditedAt: undefined,
|
||||
isMinimized: false,
|
||||
path: c.path,
|
||||
line: c.line ?? c.original_line ?? null,
|
||||
reviewId: c.pull_request_review_id ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps REST review objects to GitHubReview type, grouping inline comments
|
||||
* by their pull_request_review_id.
|
||||
*/
|
||||
export function mapRestReviews(
|
||||
reviews: Array<{
|
||||
id: number;
|
||||
body?: string | null;
|
||||
user?: { login?: string } | null;
|
||||
state: string;
|
||||
submitted_at?: string;
|
||||
}>,
|
||||
inlineComments: (GitHubReviewComment & { reviewId: number | null })[],
|
||||
): GitHubReview[] {
|
||||
// Group inline comments by review ID
|
||||
const commentsByReview = new Map<number, GitHubReviewComment[]>();
|
||||
for (const comment of inlineComments) {
|
||||
if (comment.reviewId != null) {
|
||||
const existing = commentsByReview.get(comment.reviewId) ?? [];
|
||||
// Strip reviewId before storing (it's not part of GitHubReviewComment)
|
||||
const { reviewId: _, ...reviewComment } = comment;
|
||||
existing.push(reviewComment);
|
||||
commentsByReview.set(comment.reviewId, existing);
|
||||
}
|
||||
}
|
||||
|
||||
return reviews.map((r) => ({
|
||||
id: r.id.toString(),
|
||||
databaseId: r.id.toString(),
|
||||
author: { login: r.user?.login ?? "unknown" },
|
||||
body: r.body ?? "",
|
||||
state: r.state.toUpperCase(), // Normalize to uppercase for consistency
|
||||
submittedAt: r.submitted_at ?? new Date().toISOString(),
|
||||
updatedAt: r.submitted_at, // Forgejo has no separate updatedAt for reviews
|
||||
lastEditedAt: undefined,
|
||||
comments: {
|
||||
nodes: commentsByReview.get(r.id) ?? [],
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user display name via REST (replaces GraphQL USER_QUERY).
|
||||
*/
|
||||
@@ -75,11 +180,10 @@ async function fetchUserDisplayNameRest(
|
||||
/**
|
||||
* 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
|
||||
* For PRs, fetches details + files + commits + comments + reviews + inline
|
||||
* comments in parallel via Promise.all(). For issues, fetches details + comments
|
||||
* in parallel. 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,
|
||||
@@ -87,8 +191,11 @@ export async function fetchForgejoData({
|
||||
prNumber,
|
||||
isPR,
|
||||
triggerUsername,
|
||||
triggerTime,
|
||||
originalTitle,
|
||||
originalBody,
|
||||
includeCommentsByActor,
|
||||
excludeCommentsByActor,
|
||||
}: FetchForgejoDataParams): Promise<FetchDataResult> {
|
||||
const [owner, repo] = repository.split("/");
|
||||
if (!owner || !repo) {
|
||||
@@ -99,10 +206,19 @@ export async function fetchForgejoData({
|
||||
let contextData: GitHubPullRequest | GitHubIssue;
|
||||
let changedFiles: GitHubFile[] = [];
|
||||
let changedFilesWithSHA: GitHubFileWithSHA[] = [];
|
||||
let rawComments: GitHubComment[] = [];
|
||||
let rawReviews: GitHubReview[] = [];
|
||||
|
||||
if (isPR) {
|
||||
// Fetch PR details, files, and commits in parallel
|
||||
const [prResponse, filesResponse, commitsResponse] = await Promise.all([
|
||||
// Fetch PR details, files, commits, comments, reviews, and inline comments in parallel
|
||||
const [
|
||||
prResponse,
|
||||
filesResponse,
|
||||
commitsResponse,
|
||||
commentsResponse,
|
||||
reviewsResponse,
|
||||
reviewCommentsResponse,
|
||||
] = await Promise.all([
|
||||
octokits.rest.pulls.get({ owner, repo, pull_number: number }),
|
||||
octokits.rest.pulls.listFiles({
|
||||
owner,
|
||||
@@ -116,12 +232,37 @@ export async function fetchForgejoData({
|
||||
pull_number: number,
|
||||
per_page: 100,
|
||||
}),
|
||||
octokits.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: number,
|
||||
per_page: 100,
|
||||
}),
|
||||
octokits.rest.pulls.listReviews({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: number,
|
||||
per_page: 100,
|
||||
}),
|
||||
octokits.rest.pulls.listReviewComments({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: number,
|
||||
per_page: 100,
|
||||
}),
|
||||
]);
|
||||
|
||||
const pr = prResponse.data;
|
||||
const files = filesResponse.data;
|
||||
const commits = commitsResponse.data;
|
||||
|
||||
// Map REST responses to internal types
|
||||
rawComments = mapRestComments(commentsResponse.data);
|
||||
const mappedInlineComments = mapRestReviewComments(
|
||||
reviewCommentsResponse.data,
|
||||
);
|
||||
rawReviews = mapRestReviews(reviewsResponse.data, mappedInlineComments);
|
||||
|
||||
// Map REST file objects to GitHubFile type
|
||||
changedFiles = files.map((f) => ({
|
||||
path: f.filename,
|
||||
@@ -164,18 +305,29 @@ export async function fetchForgejoData({
|
||||
})),
|
||||
},
|
||||
files: { nodes: changedFiles },
|
||||
comments: { nodes: [] as GitHubComment[] }, // M4b
|
||||
reviews: { nodes: [] as GitHubReview[] }, // M4b
|
||||
comments: { nodes: rawComments },
|
||||
reviews: { nodes: rawReviews },
|
||||
};
|
||||
|
||||
console.log(`Successfully fetched PR #${prNumber} data`);
|
||||
} else {
|
||||
// Fetch issue details
|
||||
const { data: issue } = await octokits.rest.issues.get({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: number,
|
||||
});
|
||||
// Fetch issue details and comments in parallel
|
||||
const [issueResponse, commentsResponse] = await Promise.all([
|
||||
octokits.rest.issues.get({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: number,
|
||||
}),
|
||||
octokits.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: number,
|
||||
per_page: 100,
|
||||
}),
|
||||
]);
|
||||
|
||||
const issue = issueResponse.data;
|
||||
rawComments = mapRestComments(commentsResponse.data);
|
||||
|
||||
// Map REST response to GitHubIssue type
|
||||
contextData = {
|
||||
@@ -191,7 +343,7 @@ export async function fetchForgejoData({
|
||||
name: typeof l === "string" ? l : (l.name ?? ""),
|
||||
})),
|
||||
},
|
||||
comments: { nodes: [] as GitHubComment[] }, // M4b
|
||||
comments: { nodes: rawComments },
|
||||
};
|
||||
|
||||
console.log(`Successfully fetched issue #${prNumber} data`);
|
||||
@@ -225,6 +377,126 @@ export async function fetchForgejoData({
|
||||
contextData.title = originalTitle;
|
||||
}
|
||||
|
||||
// Apply TOCTOU + actor filtering to issue-level comments
|
||||
const comments = filterCommentsByActor(
|
||||
filterCommentsToTriggerTime(rawComments, triggerTime),
|
||||
includeCommentsByActor,
|
||||
excludeCommentsByActor,
|
||||
);
|
||||
|
||||
// Build reviewData with filtering (PRs only)
|
||||
let reviewData: { nodes: GitHubReview[] } | null = null;
|
||||
if (isPR) {
|
||||
// Deep-clone reviews so filtering doesn't mutate contextData
|
||||
let filteredReviews = rawReviews.map((r) => ({
|
||||
...r,
|
||||
comments: { nodes: [...r.comments.nodes] },
|
||||
}));
|
||||
|
||||
// Actor-filter reviews by review author
|
||||
filteredReviews = filterCommentsByActor(
|
||||
filteredReviews,
|
||||
includeCommentsByActor,
|
||||
excludeCommentsByActor,
|
||||
);
|
||||
|
||||
// Actor-filter inline comments within each review
|
||||
for (const review of filteredReviews) {
|
||||
review.comments.nodes = filterCommentsByActor(
|
||||
review.comments.nodes,
|
||||
includeCommentsByActor,
|
||||
excludeCommentsByActor,
|
||||
);
|
||||
}
|
||||
|
||||
reviewData = { nodes: filteredReviews };
|
||||
}
|
||||
|
||||
// --- Image processing ---
|
||||
// Prepare issue-level comments for image URL extraction
|
||||
const issueComments: CommentWithImages[] = comments
|
||||
.filter((c) => c.body && !c.isMinimized)
|
||||
.map((c) => ({
|
||||
type: "issue_comment" as const,
|
||||
id: c.databaseId,
|
||||
body: c.body,
|
||||
}));
|
||||
|
||||
// Filter review bodies to trigger time for image processing
|
||||
const filteredReviewBodies = reviewData?.nodes
|
||||
? filterReviewsToTriggerTime(reviewData.nodes, triggerTime).filter(
|
||||
(r) => r.body,
|
||||
)
|
||||
: [];
|
||||
|
||||
const reviewBodies: CommentWithImages[] = filteredReviewBodies.map((r) => ({
|
||||
type: "review_body" as const,
|
||||
id: r.databaseId,
|
||||
pullNumber: prNumber,
|
||||
body: r.body,
|
||||
}));
|
||||
|
||||
// Filter inline review comments to trigger time for image processing
|
||||
const allReviewComments =
|
||||
reviewData?.nodes?.flatMap((r) => r.comments?.nodes ?? []) ?? [];
|
||||
const filteredReviewComments = filterCommentsToTriggerTime(
|
||||
allReviewComments,
|
||||
triggerTime,
|
||||
);
|
||||
|
||||
const reviewCommentImages: CommentWithImages[] = filteredReviewComments
|
||||
.filter((c) => c.body && !c.isMinimized)
|
||||
.map((c) => ({
|
||||
type: "review_comment" as const,
|
||||
id: c.databaseId,
|
||||
body: c.body,
|
||||
}));
|
||||
|
||||
// Process main body for images (with TOCTOU check)
|
||||
let mainBody: CommentWithImages[] = [];
|
||||
if (contextData.body) {
|
||||
if (
|
||||
originalBody !== undefined ||
|
||||
isBodySafeToUse(contextData, triggerTime)
|
||||
) {
|
||||
mainBody = [
|
||||
isPR
|
||||
? {
|
||||
type: "pr_body" as const,
|
||||
pullNumber: prNumber,
|
||||
body: contextData.body,
|
||||
}
|
||||
: {
|
||||
type: "issue_body" as const,
|
||||
issueNumber: prNumber,
|
||||
body: contextData.body,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
console.warn(
|
||||
`Security: ${isPR ? "PR" : "Issue"} #${prNumber} body was edited after the trigger event. ` +
|
||||
`Excluding body content to prevent potential injection attacks.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const allCommentsForImages = [
|
||||
...mainBody,
|
||||
...issueComments,
|
||||
...reviewBodies,
|
||||
...reviewCommentImages,
|
||||
];
|
||||
|
||||
// Download images — uses Octokit REST. On Forgejo the GitHub-specific URL
|
||||
// patterns won't match, so this is effectively a no-op for Forgejo-hosted
|
||||
// images. Safe to call regardless.
|
||||
const imageUrlMap = await downloadCommentImages(
|
||||
octokits,
|
||||
owner,
|
||||
repo,
|
||||
allCommentsForImages,
|
||||
);
|
||||
|
||||
// Fetch trigger user display name via REST (replaces GraphQL USER_QUERY)
|
||||
let triggerDisplayName: string | null | undefined;
|
||||
if (triggerUsername) {
|
||||
@@ -236,11 +508,11 @@ export async function fetchForgejoData({
|
||||
|
||||
return {
|
||||
contextData,
|
||||
comments: [], // M4b will populate
|
||||
comments,
|
||||
changedFiles,
|
||||
changedFilesWithSHA,
|
||||
reviewData: isPR ? { nodes: [] } : null, // M4b will populate
|
||||
imageUrlMap: new Map(), // M4b will populate
|
||||
reviewData,
|
||||
imageUrlMap,
|
||||
triggerDisplayName,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user