feat(FN-5144): add create PR metadata and preflight routes

- Add dashboard routes to generate PR title/body metadata for task branches
- Add PR preflight diagnostics for base branch resolution, remote branch checks, commit summaries, changed files, conflicts, and gh auth state
- Cover the new Create PR route contracts and metadata preflight behavior with dashboard tests
- Add a changeset for the published @runfusion/fusion package

Fusion-Task-Id: FN-5144
This commit is contained in:
Fusion (runfusion.ai)
2026-05-19 10:25:32 -07:00
committed by gsxdsm
parent 5e3d57b1cc
commit ba1604835b
5 changed files with 714 additions and 2 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Dashboard: implement missing /api/tasks/:id/pr/generate-metadata, /pr/preflight, and /pr/options routes so the Create PR dialog populates AI title/body, preflight checks, and base-branch/reviewer/label dropdowns.

View File

@@ -93,4 +93,39 @@ describe("PR routes contract", () => {
expect(response.status).not.toBe(409);
});
it("returns structured 404 for PR options when task is missing", async () => {
const missingStore = createStore(createTask());
missingStore.getTask = vi.fn().mockRejectedValue(Object.assign(new Error("missing"), { code: "ENOENT" }));
const app = createServer(missingStore);
const response = await performGet(app, "/api/tasks/FN-404/pr/options");
expect(response.status).toBe(404);
expect(response.body).toMatchObject({ error: expect.stringContaining("Task FN-404 not found") });
});
it("returns structured 404 for PR preflight when task is missing", async () => {
const missingStore = createStore(createTask());
missingStore.getTask = vi.fn().mockRejectedValue(Object.assign(new Error("missing"), { code: "ENOENT" }));
const app = createServer(missingStore);
const response = await performGet(app, "/api/tasks/FN-404/pr/preflight");
expect(response.status).toBe(404);
expect(response.body).toMatchObject({ error: expect.stringContaining("Task FN-404 not found") });
});
it("returns structured 404 for PR metadata generation when task is missing", async () => {
const missingStore = createStore(createTask());
missingStore.getTask = vi.fn().mockRejectedValue(Object.assign(new Error("missing"), { code: "ENOENT" }));
const app = createServer(missingStore);
const response = await performRequest(app, "POST", "/api/tasks/FN-404/pr/generate-metadata", "{}", {
"content-type": "application/json",
});
expect(response.status).toBe(404);
expect(response.body).toMatchObject({ error: expect.stringContaining("Task FN-404 not found") });
});
});

View File

@@ -0,0 +1,283 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as fusionCore from "@fusion/core";
import type { Task, TaskStore } from "@fusion/core";
const { mockGeneratePrMetadata } = vi.hoisted(() => ({
mockGeneratePrMetadata: vi.fn(),
}));
vi.mock("../pr-metadata-generator.js", () => ({
generatePrMetadata: mockGeneratePrMetadata,
}));
import { prRouteCommandRunner } from "../routes/register-git-github.js";
import { createServer } from "../server.js";
import { get as performGet, request as performRequest } from "../test-request.js";
function createTask(overrides: Partial<Task> = {}): Task {
return {
id: "FN-001",
title: "Task",
description: "desc",
column: "in-review",
status: "in-review",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
prInfo: {
url: "https://github.com/owner/repo/pull/1",
number: 1,
status: "open",
title: "PR",
headBranch: "fusion/fn-001",
baseBranch: "main",
commentCount: 0,
},
comments: [],
...overrides,
} as Task;
}
function createStore(taskOrError: Task | Error): TaskStore {
const getTask = taskOrError instanceof Error
? vi.fn().mockRejectedValue(taskOrError)
: vi.fn().mockResolvedValue(taskOrError);
return {
getTask,
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({ directMergeCommitStrategy: "auto" }),
updateSettings: vi.fn(),
logEntry: vi.fn().mockResolvedValue(undefined),
getAgentLogs: vi.fn().mockResolvedValue([]),
addSteeringComment: vi.fn(),
updatePrInfo: vi.fn().mockResolvedValue(undefined),
updatePrInfoByNumber: vi.fn().mockResolvedValue(undefined),
addPrInfo: vi.fn().mockResolvedValue(undefined),
removePrInfoByNumber: vi.fn().mockResolvedValue(undefined),
updateIssueInfo: vi.fn().mockResolvedValue(undefined),
getRootDir: vi.fn().mockReturnValue("/tmp/project"),
getFusionDir: vi.fn().mockReturnValue("/tmp/project/.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([]) }),
on: vi.fn(),
off: vi.fn(),
} as unknown as TaskStore;
}
type TryRunResult = Awaited<ReturnType<typeof prRouteCommandRunner.tryRun>>;
const runQueue: Array<{ ok: true; value: string } | { ok: false; error: Error }> = [];
const tryRunQueue: TryRunResult[] = [];
function queueRunSuccess(value = "") {
runQueue.push({ ok: true, value });
}
function queueRunFailure(message: string, code = 1) {
runQueue.push({ ok: false, error: Object.assign(new Error(message), { code, stderr: message, stdout: "" }) });
}
function queueTryRunSuccess(value = "") {
tryRunQueue.push({ ok: true, stdout: value });
}
function queueTryRunFailure(code: number, stderr = "failed", stdout = "") {
tryRunQueue.push({ ok: false, error: Object.assign(new Error(stderr), { code, stderr, stdout }), code, stderr, stdout });
}
describe("PR metadata/preflight/options routes", () => {
const originalRepoEnv = process.env.GITHUB_REPOSITORY;
beforeEach(() => {
vi.clearAllMocks();
runQueue.length = 0;
tryRunQueue.length = 0;
vi.spyOn(prRouteCommandRunner, "run").mockImplementation(async () => {
const next = runQueue.shift();
if (!next) throw new Error("Unexpected run command");
if (next.ok) return next.value;
throw next.error;
});
vi.spyOn(prRouteCommandRunner, "tryRun").mockImplementation(async () => {
const next = tryRunQueue.shift();
if (!next) throw new Error("Unexpected tryRun command");
return next;
});
process.env.GITHUB_REPOSITORY = "owner/repo";
vi.spyOn(fusionCore, "getCurrentRepo").mockReturnValue({ owner: "owner", repo: "repo" });
vi.spyOn(fusionCore, "isGhAuthenticated").mockReturnValue(true);
mockGeneratePrMetadata.mockResolvedValue({
title: "Generated title",
body: "Generated body",
templateUsed: true,
});
});
afterEach(() => {
vi.restoreAllMocks();
if (originalRepoEnv === undefined) {
delete process.env.GITHUB_REPOSITORY;
} else {
process.env.GITHUB_REPOSITORY = originalRepoEnv;
}
});
it("POST /pr/generate-metadata returns generated metadata", async () => {
const app = createServer(createStore(createTask()));
const response = await performRequest(app, "POST", "/api/tasks/FN-001/pr/generate-metadata", "{}", { "content-type": "application/json" });
expect(response.status).toBe(200);
expect(response.body).toEqual({ title: "Generated title", body: "Generated body", templateUsed: true });
expect(mockGeneratePrMetadata).toHaveBeenCalledWith(expect.objectContaining({ task: expect.objectContaining({ id: "FN-001" }), repoRoot: "/tmp/project" }));
});
it("POST /pr/generate-metadata returns 404 for missing task", async () => {
const missing = Object.assign(new Error("missing"), { code: "ENOENT" });
const app = createServer(createStore(missing));
const response = await performRequest(app, "POST", "/api/tasks/FN-404/pr/generate-metadata", "{}", { "content-type": "application/json" });
expect(response.status).toBe(404);
expect(response.body.error).toContain("Task FN-404 not found");
});
it("POST /pr/generate-metadata wraps generator failures", async () => {
mockGeneratePrMetadata.mockRejectedValueOnce(new Error("generator exploded"));
const app = createServer(createStore(createTask()));
const response = await performRequest(app, "POST", "/api/tasks/FN-001/pr/generate-metadata", "{}", { "content-type": "application/json" });
expect(response.status).toBe(500);
expect(response.body.error).toContain("generator exploded");
});
it("GET /pr/preflight returns clean branch diagnostics", async () => {
queueTryRunSuccess("deadbeef\n");
queueTryRunSuccess("refs/heads/fusion/fn-001\n");
queueRunSuccess("2\n");
queueRunSuccess("");
queueRunSuccess("abc123\tAdd feature\tDev\ndef456\tFix tests\tDev\n");
queueRunSuccess("5\t1\tsrc/a.ts\n1\t1\told.ts => new.ts\n");
queueRunSuccess("M\tsrc/a.ts\nR100\told.ts\tnew.ts\n");
const app = createServer(createStore(createTask()));
const response = await performGet(app, "/api/tasks/FN-001/pr/preflight");
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
branchOnRemote: true,
commitsPresent: true,
conflictsWithBase: false,
ghAuthOk: true,
defaultBaseBranch: "main",
head: "fusion/fn-001",
commits: [
{ sha: "abc123", subject: "Add feature", author: "Dev" },
{ sha: "def456", subject: "Fix tests", author: "Dev" },
],
});
expect(response.body.changedFiles).toEqual([
{ path: "src/a.ts", additions: 5, deletions: 1, status: "modified" },
{ path: "new.ts", additions: 1, deletions: 1, status: "renamed" },
]);
});
it("GET /pr/preflight degrades safely when branch is missing, auth fails, conflicts exist, and diff output is malformed", async () => {
vi.spyOn(fusionCore, "isGhAuthenticated").mockReturnValue(false);
queueTryRunSuccess("deadbeef\n");
queueTryRunFailure(2, "missing remote branch");
queueRunSuccess("0\n");
queueRunSuccess("conflicted-file.ts\n");
queueRunSuccess("");
queueRunSuccess("not-a-numstat-line\n");
queueRunSuccess("M\n\n");
const app = createServer(createStore(createTask()));
const response = await performGet(app, "/api/tasks/FN-001/pr/preflight");
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
branchOnRemote: false,
commitsPresent: false,
conflictsWithBase: true,
ghAuthOk: false,
commits: [],
changedFiles: [],
});
});
it("GET /pr/preflight returns 404 for missing task", async () => {
const missing = Object.assign(new Error("missing"), { code: "ENOENT" });
const app = createServer(createStore(missing));
const response = await performGet(app, "/api/tasks/FN-404/pr/preflight");
expect(response.status).toBe(404);
expect(response.body.error).toContain("Task FN-404 not found");
});
it("GET /pr/options returns branches, collaborators, and labels", async () => {
queueRunSuccess("main\nrelease\n");
queueRunSuccess("origin/HEAD\norigin/main\norigin/develop\n");
queueRunSuccess('{"login":"alice","name":"Alice"}\n{"login":"bob","name":"bob"}\n');
queueRunSuccess('{"name":"bug","color":"ff0000"}\n{"name":"feature","color":"00ff00"}\n');
const app = createServer(createStore(createTask()));
const response = await performGet(app, "/api/tasks/FN-001/pr/options");
expect(response.status).toBe(200);
expect(response.body).toEqual({
baseBranches: ["main", "release", "develop"],
reviewers: [{ login: "alice", name: "Alice" }, { login: "bob", name: "bob" }],
assignees: [{ login: "alice", name: "Alice" }, { login: "bob", name: "bob" }],
labels: [{ name: "bug", color: "ff0000" }, { name: "feature", color: "00ff00" }],
});
});
it("GET /pr/options returns degraded but shaped responses when gh calls fail", async () => {
queueRunFailure("gh branches failed");
queueRunSuccess("origin/HEAD\norigin/main\norigin/release\n");
queueRunFailure("gh collaborators failed");
queueRunFailure("gh labels failed");
const app = createServer(createStore(createTask()));
const response = await performGet(app, "/api/tasks/FN-001/pr/options");
expect(response.status).toBe(200);
expect(response.body).toEqual({
baseBranches: ["main", "release"],
reviewers: [],
assignees: [],
labels: [],
});
});
it("GET /pr/options returns 400 when repository cannot be resolved", async () => {
delete process.env.GITHUB_REPOSITORY;
vi.spyOn(fusionCore, "getCurrentRepo").mockReturnValue(null);
const app = createServer(createStore(createTask()));
const response = await performGet(app, "/api/tasks/FN-001/pr/options");
expect(response.status).toBe(400);
expect(response.body.error).toContain("Could not determine GitHub repository");
});
it("GET /pr/options returns 404 for missing task", async () => {
const missing = Object.assign(new Error("missing"), { code: "ENOENT" });
const app = createServer(createStore(missing));
const response = await performGet(app, "/api/tasks/FN-404/pr/options");
expect(response.status).toBe(404);
expect(response.body.error).toContain("Task FN-404 not found");
});
});

View File

@@ -1,6 +1,7 @@
import { type NextFunction, type Request, type Response } from "express";
import { isAbsolute } from "node:path";
import { spawn } from "node:child_process";
import { exec as execCb, spawn } from "node:child_process";
import { promisify } from "node:util";
import type {
BatchStatusEntry,
BatchStatusResponse,
@@ -30,6 +31,7 @@ import { GitHubTrackingStateService } from "../github-tracking-state.js";
import { GitHubTrackingReconciler } from "../github-tracking-reconciler.js";
import { githubRateLimiter } from "../github-poll.js";
import * as projectStoreResolver from "../project-store-resolver.js";
import { generatePrMetadata } from "../pr-metadata-generator.js";
import {
classifyWebhookEvent,
getGitHubAppConfig,
@@ -40,6 +42,12 @@ import {
import type { ApiRoutesContext } from "./types.js";
import { runGitCommand } from "./resolve-diff-base.js";
const execAsync = promisify(execCb);
const PR_ROUTE_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
const PR_PREFLIGHT_TIMEOUT_MS = 15_000;
const PR_OPTIONS_TIMEOUT_MS = 10_000;
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._/-]+$/;
function getCommandErrorMessage(error: unknown): string {
if (error instanceof Error) {
const anyError = error as Error & { stdout?: string; stderr?: string };
@@ -162,6 +170,174 @@ const recentIssuesCache = new Map<string, { fetchedAt: number; items: Array<{
updatedAt?: string;
}> }>();
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
function ensureSafeGitRef(value: string, fieldName = "branch"): string {
const trimmed = value.trim();
if (!trimmed || !SAFE_GIT_REF_PATTERN.test(trimmed)) {
throw badRequest(`Invalid ${fieldName}`);
}
return trimmed;
}
function getExecErrorCode(error: unknown): number | undefined {
const code = (error as { code?: unknown } | undefined)?.code;
return typeof code === "number" ? code : undefined;
}
async function runPrShellCommand(command: string, cwd: string, timeoutMs: number): Promise<string> {
const { stdout } = await execAsync(command, {
cwd,
timeout: timeoutMs,
maxBuffer: PR_ROUTE_MAX_BUFFER_BYTES,
});
return stdout.trim();
}
async function tryRunPrShellCommand(command: string, cwd: string, timeoutMs: number): Promise<
| { ok: true; stdout: string }
| { ok: false; error: unknown; code?: number; stdout: string; stderr: string }
> {
try {
const stdout = await runPrShellCommand(command, cwd, timeoutMs);
return { ok: true, stdout };
} catch (error) {
return {
ok: false,
error,
code: getExecErrorCode(error),
stdout: ((error as { stdout?: string } | undefined)?.stdout ?? "").trim(),
stderr: ((error as { stderr?: string } | undefined)?.stderr ?? "").trim(),
};
}
}
export const prRouteCommandRunner = {
run: runPrShellCommand,
tryRun: tryRunPrShellCommand,
};
async function resolvePrBaseRef(repoRoot: string, baseBranch: string): Promise<string> {
const safeBase = ensureSafeGitRef(baseBranch, "base branch");
const localCheck = await prRouteCommandRunner.tryRun(
`git rev-parse --verify ${shellQuote(safeBase)}`,
repoRoot,
PR_PREFLIGHT_TIMEOUT_MS,
);
if (localCheck.ok) {
return safeBase;
}
await prRouteCommandRunner.tryRun(
`git fetch origin ${shellQuote(safeBase)} --no-tags`,
repoRoot,
PR_PREFLIGHT_TIMEOUT_MS,
);
const remoteRef = `origin/${safeBase}`;
const remoteCheck = await prRouteCommandRunner.tryRun(
`git rev-parse --verify ${shellQuote(remoteRef)}`,
repoRoot,
PR_PREFLIGHT_TIMEOUT_MS,
);
return remoteCheck.ok ? remoteRef : safeBase;
}
async function resolveDefaultPrBaseBranch(task: Task, repoRoot: string): Promise<string> {
const taskBaseBranch = task.prInfo?.baseBranch?.trim();
if (taskBaseBranch) {
return taskBaseBranch;
}
try {
const stdout = await prRouteCommandRunner.run(
"gh repo view --json defaultBranchRef -q .defaultBranchRef.name",
repoRoot,
PR_OPTIONS_TIMEOUT_MS,
);
if (stdout) {
return stdout;
}
} catch {
// fall through to main
}
return "main";
}
function parsePreflightCommits(output: string): Array<{ sha: string; subject: string; author: string }> {
return output
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [sha = "", subject = "", author = ""] = line.split("\t");
return { sha, subject, author };
})
.filter((entry) => entry.sha && entry.subject)
.slice(0, 50);
}
function parsePreflightChangedFiles(numstatOutput: string, nameStatusOutput: string): Array<{
path: string;
additions: number;
deletions: number;
status: "added" | "modified" | "deleted" | "renamed";
}> {
const numstatLines = numstatOutput.split(/\r?\n/).filter(Boolean);
const nameStatusLines = nameStatusOutput.split(/\r?\n/).filter(Boolean);
const results: Array<{ path: string; additions: number; deletions: number; status: "added" | "modified" | "deleted" | "renamed" }> = [];
for (let index = 0; index < nameStatusLines.length && results.length < 200; index += 1) {
const nameParts = nameStatusLines[index]?.split("\t").filter(Boolean) ?? [];
if (nameParts.length === 0) {
continue;
}
const statusToken = nameParts[0] ?? "M";
const numstatParts = numstatLines[index]?.split("\t") ?? [];
const additions = Number.parseInt(numstatParts[0] ?? "0", 10);
const deletions = Number.parseInt(numstatParts[1] ?? "0", 10);
const fallbackPath = numstatParts[2] ?? "";
const path = statusToken.startsWith("R") ? (nameParts[2] ?? fallbackPath) : (nameParts[1] ?? fallbackPath);
if (!path) {
continue;
}
results.push({
path,
additions: Number.isFinite(additions) ? additions : 0,
deletions: Number.isFinite(deletions) ? deletions : 0,
status: statusToken === "A"
? "added"
: statusToken === "D"
? "deleted"
: statusToken.startsWith("R")
? "renamed"
: "modified",
});
}
return results;
}
function parseGhJsonLines<T>(output: string): T[] {
return output
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.flatMap((line) => {
try {
return [JSON.parse(line) as T];
} catch {
return [];
}
});
}
export async function isGitRepo(cwd?: string): Promise<boolean> {
try {
await runGitCommand(["rev-parse", "--git-dir"], cwd, 5000);
@@ -3364,6 +3540,219 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
}
});
/**
* POST /api/tasks/:id/pr/generate-metadata
* Generate AI PR title/body metadata for the Create PR dialog.
* Returns: { title, body, templateUsed }
*/
router.post("/tasks/:id/pr/generate-metadata", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const task = await scopedStore.getTask(req.params.id);
const settings = await scopedStore.getSettings();
const metadata = await generatePrMetadata({
task,
repoRoot: scopedStore.getRootDir(),
settings,
});
res.json(metadata);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
throw notFound(`Task ${req.params.id} not found`);
}
rethrowAsApiError(err, "Failed to generate PR metadata");
}
});
/**
* GET /api/tasks/:id/pr/preflight
* Collect branch, commit, diff, conflict, and auth diagnostics for Create PR.
*/
router.get("/tasks/:id/pr/preflight", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const task = await scopedStore.getTask(req.params.id);
const repoRoot = scopedStore.getRootDir();
const requestedBase = typeof req.query.base === "string" ? req.query.base.trim() : "";
const defaultBaseBranch = requestedBase
? ensureSafeGitRef(requestedBase, "base branch")
: await resolveDefaultPrBaseBranch(task, repoRoot);
const head = `fusion/${task.id.toLowerCase()}`;
const safeHead = ensureSafeGitRef(head, "head branch");
const response: {
branchOnRemote: boolean;
commitsPresent: boolean;
conflictsWithBase: boolean;
ghAuthOk: boolean;
defaultBaseBranch: string;
head: string;
commits: Array<{ sha: string; subject: string; author: string }>;
changedFiles: Array<{ path: string; additions: number; deletions: number; status: "added" | "modified" | "deleted" | "renamed" }>;
} = {
branchOnRemote: false,
commitsPresent: false,
conflictsWithBase: false,
ghAuthOk: isGhAuthenticated(),
defaultBaseBranch,
head,
commits: [],
changedFiles: [],
};
const baseRef = await resolvePrBaseRef(repoRoot, defaultBaseBranch).catch(() => defaultBaseBranch);
const remoteBranchCheck = await prRouteCommandRunner.tryRun(
`git ls-remote --exit-code --heads origin ${shellQuote(safeHead)}`,
repoRoot,
PR_PREFLIGHT_TIMEOUT_MS,
);
if (remoteBranchCheck.ok) {
response.branchOnRemote = true;
} else if (remoteBranchCheck.code !== 2) {
response.branchOnRemote = false;
}
const commitCountOutput = await prRouteCommandRunner.run(
`git rev-list --count ${shellQuote(baseRef)}..${shellQuote(safeHead)}`,
repoRoot,
PR_PREFLIGHT_TIMEOUT_MS,
).catch(() => "0");
response.commitsPresent = Number.parseInt(commitCountOutput, 10) > 0;
const mergeTreeOutput = await prRouteCommandRunner.run(
`git merge-tree --write-tree --name-only ${shellQuote(baseRef)} ${shellQuote(safeHead)}`,
repoRoot,
PR_PREFLIGHT_TIMEOUT_MS,
).catch(() => "");
response.conflictsWithBase = mergeTreeOutput.trim().length > 0;
const [commitLogOutput, numstatOutput, nameStatusOutput] = await Promise.all([
prRouteCommandRunner.run(
`git log --no-merges ${shellQuote(baseRef)}..${shellQuote(safeHead)} --format=%H%x09%s%x09%an`,
repoRoot,
PR_PREFLIGHT_TIMEOUT_MS,
).catch(() => ""),
prRouteCommandRunner.run(
`git diff --numstat ${shellQuote(baseRef)}..${shellQuote(safeHead)}`,
repoRoot,
PR_PREFLIGHT_TIMEOUT_MS,
).catch(() => ""),
prRouteCommandRunner.run(
`git diff --name-status ${shellQuote(baseRef)}..${shellQuote(safeHead)}`,
repoRoot,
PR_PREFLIGHT_TIMEOUT_MS,
).catch(() => ""),
]);
response.commits = parsePreflightCommits(commitLogOutput);
response.changedFiles = parsePreflightChangedFiles(numstatOutput, nameStatusOutput);
res.json(response);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
throw notFound(`Task ${req.params.id} not found`);
}
rethrowAsApiError(err, "Failed to load PR preflight");
}
});
/**
* GET /api/tasks/:id/pr/options
* Load base branches, reviewers, assignees, and labels for Create PR.
*/
router.get("/tasks/:id/pr/options", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const task = await scopedStore.getTask(req.params.id);
const repoRoot = scopedStore.getRootDir();
const envRepo = process.env.GITHUB_REPOSITORY;
const gitRepo = getCurrentRepo(repoRoot);
const [owner, repo] = envRepo?.split("/") ?? [gitRepo?.owner, gitRepo?.repo];
if (!owner || !repo) {
throw badRequest("Could not determine GitHub repository. Set GITHUB_REPOSITORY env var or configure git remote.");
}
const repoKey = `${owner}/${repo}`;
const ghRequestsAllowed = githubRateLimiter.canMakeRequest(repoKey);
const defaultBaseBranch = await resolveDefaultPrBaseBranch(task, repoRoot);
const [ghBranchesResult, gitBranchesResult, collaboratorsResult, labelsResult] = await Promise.allSettled([
ghRequestsAllowed
? prRouteCommandRunner.run(
`gh api repos/${owner}/${repo}/branches --paginate -q '.[].name'`,
repoRoot,
PR_OPTIONS_TIMEOUT_MS,
)
: Promise.reject(new Error("GitHub API rate limited")),
prRouteCommandRunner.run(
"git for-each-ref refs/remotes/origin --format=%(refname:short)",
repoRoot,
PR_OPTIONS_TIMEOUT_MS,
),
ghRequestsAllowed
? prRouteCommandRunner.run(
`gh api repos/${owner}/${repo}/collaborators --paginate -q '.[] | {login, name: (.name // .login)}'`,
repoRoot,
PR_OPTIONS_TIMEOUT_MS,
)
: Promise.reject(new Error("GitHub API rate limited")),
ghRequestsAllowed
? prRouteCommandRunner.run(
`gh api repos/${owner}/${repo}/labels --paginate -q '.[] | {name, color}'`,
repoRoot,
PR_OPTIONS_TIMEOUT_MS,
)
: Promise.reject(new Error("GitHub API rate limited")),
]);
const baseBranchSet = new Set<string>([defaultBaseBranch]);
if (ghBranchesResult.status === "fulfilled") {
for (const branch of ghBranchesResult.value.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean).slice(0, 100)) {
baseBranchSet.add(branch);
}
}
if (gitBranchesResult.status === "fulfilled") {
for (const branch of gitBranchesResult.value.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean)) {
if (branch === "origin/HEAD") {
continue;
}
baseBranchSet.add(branch.replace(/^origin\//, ""));
if (baseBranchSet.size >= 100) {
break;
}
}
}
const reviewers = collaboratorsResult.status === "fulfilled"
? parseGhJsonLines<{ login: string; name?: string }>(collaboratorsResult.value).slice(0, 50)
: [];
const labels = labelsResult.status === "fulfilled"
? parseGhJsonLines<{ name: string; color: string }>(labelsResult.value).slice(0, 50)
: [];
res.json({
baseBranches: Array.from(baseBranchSet),
reviewers,
assignees: reviewers,
labels,
});
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
throw notFound(`Task ${req.params.id} not found`);
}
rethrowAsApiError(err, "Failed to load PR options");
}
});
/**
* GET /api/tasks/:id/pr/status
* Get cached PR status for a task. Triggers background refresh if stale (>5 min).

View File

@@ -22,7 +22,7 @@ const qualityAppTests = [
const qualityApiTests = [
// Critical HTTP/server behavior: auth, task/project/settings mutation,
// git/GitHub, agents, nodes, chat/files, realtime, and isolation guards.
"src/__tests__/{api-error,auth-middleware,auth-middleware-integration,chat-attachment-routes,chat-routes,file-service,github,github-webhooks,initialize,planning-flow-diagnostics-guardrail,pr-routes-auto-merge,pr-routes.contract,project-routes,project-store-resolver,remote-access-routes,remote-auth,routes-agent-budget,routes-agent-keys,routes-agent-permissions,routes-agent-ratings,routes-agent-runs,routes-agent-soul-memory,routes-agents,routes-automation,routes-git,routes-github,routes-nodes,routes-nodes-sync-contract,routes-secrets-sync,routes-settings,routes-task-commit-associations,routes-tasks,routes-tasks-deterministic-dedup,routes-tasks-duplicate-check,server,server-static-assets,server-webhook,server.events,setup-routes,sse,sse-buffer,test-isolation-guard,update-check-route,websocket,recover-branch-binding-route}.test.ts",
"src/__tests__/{api-error,auth-middleware,auth-middleware-integration,chat-attachment-routes,chat-routes,file-service,github,github-webhooks,initialize,planning-flow-diagnostics-guardrail,pr-routes-auto-merge,pr-routes.contract,project-routes,project-store-resolver,register-git-github.pr-options-preflight-metadata,remote-access-routes,remote-auth,routes-agent-budget,routes-agent-keys,routes-agent-permissions,routes-agent-ratings,routes-agent-runs,routes-agent-soul-memory,routes-agents,routes-automation,routes-git,routes-github,routes-nodes,routes-nodes-sync-contract,routes-secrets-sync,routes-settings,routes-task-commit-associations,routes-tasks,routes-tasks-deterministic-dedup,routes-tasks-duplicate-check,server,server-static-assets,server-webhook,server.events,setup-routes,sse,sse-buffer,test-isolation-guard,update-check-route,websocket,recover-branch-binding-route}.test.ts",
"src/routes/__tests__/{custom-provider-routes,custom-providers,register-docker-node-routes,stash-recovery-routes}.test.ts",
];