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

@@ -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).