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
@@ -243,7 +243,7 @@ The upstream repo has 13 workflows in `.github/workflows/`. Strategy:
|
|||||||
|
|
||||||
### Phase 2 - Core Data Fetching (the hard part)
|
### 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)
|
- Files: `src/forgejo/data/fetcher.ts` (NEW)
|
||||||
- Depends on: M1, M2
|
- Depends on: M1, M2
|
||||||
- Success: Can fetch PR/issue title, body, author, state, files, commits from Forgejo REST API.
|
- Success: Can fetch PR/issue title, body, author, state, files, commits from Forgejo REST API.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<string, unknown>;
|
||||||
|
filesData?: Record<string, unknown>[];
|
||||||
|
commitsData?: Record<string, unknown>[];
|
||||||
|
issueData?: Record<string, unknown>;
|
||||||
|
userData?: Record<string, unknown>;
|
||||||
|
} = {}) {
|
||||||
|
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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user