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);
|
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 { type NextFunction, type Request, type Response } from "express";
|
||||||
import { isAbsolute } from "node:path";
|
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 {
|
import type {
|
||||||
BatchStatusEntry,
|
BatchStatusEntry,
|
||||||
BatchStatusResponse,
|
BatchStatusResponse,
|
||||||
@@ -30,6 +31,7 @@ import { GitHubTrackingStateService } from "../github-tracking-state.js";
|
|||||||
import { GitHubTrackingReconciler } from "../github-tracking-reconciler.js";
|
import { GitHubTrackingReconciler } from "../github-tracking-reconciler.js";
|
||||||
import { githubRateLimiter } from "../github-poll.js";
|
import { githubRateLimiter } from "../github-poll.js";
|
||||||
import * as projectStoreResolver from "../project-store-resolver.js";
|
import * as projectStoreResolver from "../project-store-resolver.js";
|
||||||
|
import { generatePrMetadata } from "../pr-metadata-generator.js";
|
||||||
import {
|
import {
|
||||||
classifyWebhookEvent,
|
classifyWebhookEvent,
|
||||||
getGitHubAppConfig,
|
getGitHubAppConfig,
|
||||||
@@ -40,6 +42,12 @@ import {
|
|||||||
import type { ApiRoutesContext } from "./types.js";
|
import type { ApiRoutesContext } from "./types.js";
|
||||||
import { runGitCommand } from "./resolve-diff-base.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 {
|
function getCommandErrorMessage(error: unknown): string {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
const anyError = error as Error & { stdout?: string; stderr?: string };
|
const anyError = error as Error & { stdout?: string; stderr?: string };
|
||||||
@@ -162,6 +170,174 @@ const recentIssuesCache = new Map<string, { fetchedAt: number; items: Array<{
|
|||||||
updatedAt?: string;
|
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> {
|
export async function isGitRepo(cwd?: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await runGitCommand(["rev-parse", "--git-dir"], cwd, 5000);
|
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 /api/tasks/:id/pr/status
|
||||||
* Get cached PR status for a task. Triggers background refresh if stale (>5 min).
|
* Get cached PR status for a task. Triggers background refresh if stale (>5 min).
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const qualityAppTests = [
|
|||||||
const qualityApiTests = [
|
const qualityApiTests = [
|
||||||
// Critical HTTP/server behavior: auth, task/project/settings mutation,
|
// Critical HTTP/server behavior: auth, task/project/settings mutation,
|
||||||
// git/GitHub, agents, nodes, chat/files, realtime, and isolation guards.
|
// 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",
|
"src/routes/__tests__/{custom-provider-routes,custom-providers,register-docker-node-routes,stash-recovery-routes}.test.ts",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user