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:
committed by
gsxdsm
parent
5e3d57b1cc
commit
ba1604835b
5
.changeset/fn-5144-create-pr-routes.md
Normal file
5
.changeset/fn-5144-create-pr-routes.md
Normal 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.
|
||||
@@ -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") });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user