feat(FN-4760): complete Step 2 — add PR checks endpoint and client

Fusion-Task-Id: FN-4760
Fusion-Task-Lineage: 71db4a53-ee59-49f0-a1f7-232954b86660
This commit is contained in:
Fusion (runfusion.ai)
2026-05-17 08:32:50 -07:00
committed by gsxdsm
parent ef43957d72
commit 49daa3046d
3 changed files with 221 additions and 0 deletions

View File

@@ -2198,6 +2198,9 @@ export interface PrCheckStatus {
name: string;
required: boolean;
state: string;
detailsUrl?: string;
startedAt?: string;
completedAt?: string;
}
export interface PrStatusResponse {
@@ -2215,6 +2218,12 @@ export interface PrRefreshResponse {
automationStatus?: string | null;
}
export interface PrChecksResponse {
checks: PrCheckStatus[];
rollup: "success" | "pending" | "failure" | "unknown";
lastCheckedAt: string;
}
export interface PrMetadataResponse {
title: string;
body: string;
@@ -2314,6 +2323,11 @@ export function refreshPrStatus(id: string, projectId?: string): Promise<PrRefre
});
}
/** Fetch all PR checks for a task */
export function fetchPrChecks(id: string, projectId?: string): Promise<PrChecksResponse> {
return api<PrChecksResponse>(withProjectId(`/tasks/${id}/pr/checks`, projectId));
}
// --- Issue Management API ---
/** Re-export GitHub badge-related types for convenience */

View File

@@ -0,0 +1,138 @@
// @vitest-environment node
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { Task, TaskStore } from "@fusion/core";
import { createServer } from "../server.js";
import { get as performGet } from "../test-request.js";
import { GitHubClient } from "../github.js";
import { githubRateLimiter } from "../github-poll.js";
function createMockTask(overrides: Partial<Task> = {}): Task {
return {
id: "KB-001",
title: "Task",
status: "todo",
description: "desc",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
prInfo: {
url: "https://github.com/owner/repo/pull/1",
number: 1,
status: "open",
title: "PR",
headBranch: "feature",
baseBranch: "main",
commentCount: 0,
},
...overrides,
} as Task;
}
function createMockStore(task: Task): TaskStore {
return {
getTask: vi.fn().mockResolvedValue(task),
listTasks: vi.fn().mockResolvedValue([]),
createTask: vi.fn(),
moveTask: vi.fn(),
updateTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
getSettings: vi.fn().mockResolvedValue({}),
updateSettings: vi.fn(),
logEntry: vi.fn().mockResolvedValue(undefined),
getAgentLogs: vi.fn().mockResolvedValue([]),
addSteeringComment: vi.fn(),
updatePrInfo: vi.fn().mockResolvedValue(undefined),
updateIssueInfo: vi.fn().mockResolvedValue(undefined),
getRootDir: vi.fn().mockReturnValue("/fake/root"),
getFusionDir: vi.fn().mockReturnValue("/fake/root/.fusion"),
getDatabase: vi.fn().mockReturnValue({
exec: vi.fn(),
prepare: vi.fn().mockReturnValue({ run: vi.fn().mockReturnValue({ changes: 0 }), get: vi.fn(), all: vi.fn().mockReturnValue([]) }),
}),
getMissionStore: vi.fn().mockReturnValue({
listMissions: vi.fn().mockReturnValue([]),
createMission: vi.fn(),
getMissionWithHierarchy: vi.fn(),
updateMission: vi.fn(),
getMission: vi.fn(),
deleteMission: vi.fn(),
listMilestonesByMission: vi.fn().mockReturnValue([]),
createMilestone: vi.fn(),
updateMilestone: vi.fn(),
getMilestone: vi.fn(),
deleteMilestone: vi.fn(),
listTasksByMilestone: vi.fn().mockReturnValue([]),
createMissionTask: vi.fn(),
updateMissionTask: vi.fn(),
getMissionTask: vi.fn(),
deleteMissionTask: vi.fn(),
}),
on: vi.fn(),
off: vi.fn(),
} as unknown as TaskStore;
}
describe("GET /api/tasks/:id/pr/checks", () => {
beforeEach(() => {
vi.spyOn(githubRateLimiter, "canMakeRequest").mockReturnValue(true);
vi.spyOn(githubRateLimiter, "getResetTime").mockReturnValue(null);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("returns checks payload", async () => {
vi.spyOn(GitHubClient.prototype, "getAllPrChecks").mockResolvedValue({
checks: [{ name: "ci", required: true, state: "success", detailsUrl: "https://example.com" }],
rollupRequired: "success",
});
const app = createServer(createMockStore(createMockTask()));
const response = await performGet(app, "/api/tasks/KB-001/pr/checks");
expect(response.status).toBe(200);
expect(response.body.rollup).toBe("success");
expect(response.body.checks).toHaveLength(1);
expect(response.body.lastCheckedAt).toEqual(expect.any(String));
});
it("returns 404 when task has no PR", async () => {
const app = createServer(createMockStore(createMockTask({ prInfo: undefined })));
const response = await performGet(app, "/api/tasks/KB-001/pr/checks");
expect(response.status).toBe(404);
expect(response.body.error).toContain("no associated PR");
});
it("returns 429 when rate limited", async () => {
vi.spyOn(githubRateLimiter, "canMakeRequest").mockReturnValue(false);
vi.spyOn(githubRateLimiter, "getResetTime").mockReturnValue(new Date(Date.now() + 30_000));
const app = createServer(createMockStore(createMockTask()));
const response = await performGet(app, "/api/tasks/KB-001/pr/checks");
expect(response.status).toBe(429);
expect(response.body.error).toContain("rate limit");
expect(response.body.details.retryAfter).toEqual(expect.any(Number));
});
it("returns required-only rollup from mixed checks", async () => {
vi.spyOn(GitHubClient.prototype, "getAllPrChecks").mockResolvedValue({
checks: [
{ name: "required", required: true, state: "pending" },
{ name: "optional", required: false, state: "failure" },
],
rollupRequired: "pending",
});
const app = createServer(createMockStore(createMockTask()));
const response = await performGet(app, "/api/tasks/KB-001/pr/checks");
expect(response.status).toBe(200);
expect(response.body.rollup).toBe("pending");
});
});

View File

@@ -3242,6 +3242,75 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
}
});
/**
* GET /api/tasks/:id/pr/checks
* Fetch all PR checks (required + optional) and rollup derived from required checks.
*/
router.get("/tasks/:id/pr/checks", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const task = await scopedStore.getTask(req.params.id);
if (!task.prInfo) {
throw notFound("Task has no associated PR");
}
let owner: string;
let repo: string;
const badgeParsed = parseBadgeUrl(task.prInfo.url);
if (badgeParsed) {
owner = badgeParsed.owner;
repo = badgeParsed.repo;
} else {
const envRepo = process.env.GITHUB_REPOSITORY;
if (envRepo) {
const [o, r] = envRepo.split("/");
owner = o;
repo = r;
} else {
const gitRepo = getCurrentRepo(scopedStore.getRootDir());
if (!gitRepo) {
throw badRequest("Could not determine GitHub repository");
}
owner = gitRepo.owner;
repo = gitRepo.repo;
}
}
const repoKey = `${owner}/${repo}`;
if (!githubRateLimiter.canMakeRequest(repoKey)) {
const resetTime = githubRateLimiter.getResetTime(repoKey);
const retryAfter = resetTime
? Math.max(0, Math.ceil((resetTime.getTime() - Date.now()) / 1000))
: undefined;
throw new ApiError(429, "GitHub API rate limit exceeded for this repository", {
retryAfter,
resetAt: resetTime?.toISOString(),
});
}
const client = new GitHubClient();
const checksResult = await client.getAllPrChecks(owner, repo, task.prInfo.number);
res.json({
checks: checksResult.checks,
rollup: checksResult.rollupRequired,
lastCheckedAt: new Date().toISOString(),
});
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
throw notFound(`Task ${req.params.id} not found`);
} else if ((err instanceof Error ? err.message : String(err)).includes("not found")) {
throw notFound(err instanceof Error ? err.message : String(err));
} else {
rethrowAsApiError(err);
}
}
});
/**
* GET /api/tasks/:id/issue/status
* Get cached issue status for a task. Triggers background refresh if stale (>5 min).