feat(prd-1): implement M4a - Forgejo REST data fetching (PR/issue basics)
- Add src/forgejo/data/fetcher.ts: REST-based data fetcher replacing GraphQL queries with parallel Octokit REST calls (PR details + files + commits via Promise.all, issue details, file status mapping, SHA computation, TOCTOU title/body protection, user display name) - Add test/forgejo-data-fetcher.test.ts with 31 test cases covering field mapping, parallel fetch, null handling, and edge cases - Output matches FetchDataResult type for downstream compatibility - Comments/reviews left empty (M4b scope) Progress: 33% complete (M1, M2, M4a done) - next: M4b or M5 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
385acacc32
commit
6e104a33dc
3 files changed
+973
-1
No files matched your search
@@ -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<string, string> = {
|
||||
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<string | null> {
|
||||
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<FetchDataResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user