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:
committed by
gsxdsm
parent
ef43957d72
commit
49daa3046d
@@ -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 */
|
||||
|
||||
138
packages/dashboard/src/__tests__/routes-pr-checks.test.ts
Normal file
138
packages/dashboard/src/__tests__/routes-pr-checks.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user