Add in-app preflight remediation so Create PR can push task branches without leaving Fusion. - add a dashboard API route and client helper to push the task branch to origin and recompute PR preflight state - update the Create Pull Request modal, styles, and tests to surface push-branch remediation alongside AI conflict resolution - document the flow and add a published CLI changeset plus server coverage for the new push-branch endpoint Files changed: .changeset/fn-5950-pr-push-branch.md | 5 + docs/dashboard-guide.md | 2 +- packages/dashboard/app/api/legacy.ts | 19 +++ packages/dashboard/app/components/PrCreateModal.css | 4 +- packages/dashboard/app/components/PrCreateModal.tsx | 48 +++++- packages/dashboard/app/components/__tests__/PrCreateModal.test.tsx | 44 ++++++ packages/dashboard/src/__tests__/register-git-github.pr-push-branch.test.ts | 176 +++++++++++++++++++++ packages/dashboard/src/routes/register-git-github.ts | 68 ++++++++ 8 files changed, 362 insertions(+), 4 deletions(-) Fusion-Task-Id: FN-5950 Fusion-Task-Lineage: 5a6c5c6a-8f99-44c0-8f54-f0a6ff537ed0
5567 lines
194 KiB
TypeScript
5567 lines
194 KiB
TypeScript
import { type NextFunction, type Request, type Response } from "express";
|
|
import { isAbsolute, resolve, relative } from "node:path";
|
|
import { realpathSync } from "node:fs";
|
|
import { exec as execCb, spawn } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
import type {
|
|
BatchStatusEntry,
|
|
BatchStatusResponse,
|
|
BatchStatusResult,
|
|
DirectMergeCommitStrategy,
|
|
IssueInfo,
|
|
PrInfo,
|
|
RunAuditEvent,
|
|
RunAuditEventInput,
|
|
Settings,
|
|
StructuredGhError,
|
|
Task,
|
|
TaskStore,
|
|
} from "@fusion/core";
|
|
import { classifyGhError, getCurrentRepo, isGhAuthenticated } from "@fusion/core";
|
|
import {
|
|
dropAutostashHandle,
|
|
generateSyntheticRunId,
|
|
getConflictedFiles,
|
|
resolveIntegrationRemote,
|
|
restoreUnrelatedRootDirChanges,
|
|
stashUnrelatedRootDirChanges,
|
|
tryFastForwardFromOrigin,
|
|
type MergerOptions,
|
|
} from "@fusion/engine";
|
|
import {
|
|
ApiError,
|
|
badRequest,
|
|
conflict,
|
|
internalError,
|
|
notFound,
|
|
rateLimited,
|
|
unauthorized,
|
|
} from "../api-error.js";
|
|
import { GitHubClient, type PrReviewSnapshot, parseBadgeUrl } from "../github.js";
|
|
import { GitHubIssueCommentService } from "../github-issue-comment.js";
|
|
import { GitHubTrackingCommentService } from "../github-tracking-comments.js";
|
|
import { GitHubTrackingStateService } from "../github-tracking-state.js";
|
|
import { GitHubTrackingReconciler, RECONCILE_SCAN_LIMIT } from "../github-tracking-reconciler.js";
|
|
import { GitHubSourceIssueCloseService } from "../github-source-issue-close.js";
|
|
import { githubRateLimiter } from "../github-poll.js";
|
|
import * as projectStoreResolver from "../project-store-resolver.js";
|
|
import { generatePrMetadata } from "../pr-metadata-generator.js";
|
|
import { resolvePrConflicts } from "../pr-conflict-resolver.js";
|
|
import {
|
|
classifyWebhookEvent,
|
|
getGitHubAppConfig,
|
|
hasIssueBadgeFieldsChanged,
|
|
hasPrBadgeFieldsChanged,
|
|
verifyWebhookSignature,
|
|
} from "../github-webhooks.js";
|
|
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._/-]+$/;
|
|
export const GITHUB_TRACKING_RECONCILE_INTERVAL_MS = 15 * 60 * 1000;
|
|
|
|
function getCommandErrorMessage(error: unknown): string {
|
|
if (error instanceof Error) {
|
|
const anyError = error as Error & { stdout?: string; stderr?: string };
|
|
return [anyError.stderr, anyError.stdout, anyError.message].filter(Boolean).join("\n").trim() || anyError.message;
|
|
}
|
|
return String(error);
|
|
}
|
|
|
|
function mapStructuredGhErrorToStatus(code: StructuredGhError["code"]): number {
|
|
switch (code) {
|
|
case "not-authenticated":
|
|
return 401;
|
|
case "permission":
|
|
return 403;
|
|
case "rate-limited":
|
|
return 429;
|
|
case "not-found":
|
|
return 404;
|
|
case "validation":
|
|
case "merge-conflict":
|
|
return 422;
|
|
default:
|
|
return 502;
|
|
}
|
|
}
|
|
|
|
function toPrApiError(err: unknown, fallbackMessage: string): ApiError {
|
|
const githubError = classifyGhError(err);
|
|
return new ApiError(mapStructuredGhErrorToStatus(githubError.code), githubError.message || fallbackMessage, {
|
|
githubError,
|
|
...(typeof githubError.retryAfterMs === "number" ? { retryAfterMs: githubError.retryAfterMs } : {}),
|
|
});
|
|
}
|
|
|
|
export { runGitCommand };
|
|
|
|
/** Git remote info returned by the remotes endpoint */
|
|
export interface GitRemote {
|
|
name: string;
|
|
owner: string;
|
|
repo: string;
|
|
url: string;
|
|
}
|
|
|
|
export function parseGitHubUrl(url: string): { owner: string; repo: string } | null {
|
|
const httpsMatch = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/i);
|
|
if (httpsMatch) {
|
|
return { owner: httpsMatch[1], repo: httpsMatch[2] };
|
|
}
|
|
|
|
const sshMatch = url.match(/^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/i);
|
|
if (sshMatch) {
|
|
return { owner: sshMatch[1], repo: sshMatch[2] };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function parseGitHubBadgeUrl(url: string | undefined): { owner: string; repo: string } | null {
|
|
if (!url) return null;
|
|
|
|
try {
|
|
const parsed = new URL(url);
|
|
if (parsed.hostname !== "github.com") return null;
|
|
const parts = parsed.pathname.split("/").filter(Boolean);
|
|
if (parts.length < 4) return null;
|
|
const [owner, repo, resourceType] = parts;
|
|
if ((resourceType !== "issues" && resourceType !== "pull") || !owner || !repo) {
|
|
return null;
|
|
}
|
|
return { owner, repo };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function getGitHubRemotes(cwd?: string): Promise<GitRemote[]> {
|
|
try {
|
|
const output = await runGitCommand(["remote", "-v"], cwd, 5000);
|
|
|
|
const remotes: GitRemote[] = [];
|
|
const seen = new Set<string>();
|
|
|
|
for (const line of output.split("\n")) {
|
|
const match = line.match(/^(\S+)\s+(\S+)\s+\((fetch|push)\)$/);
|
|
if (!match) continue;
|
|
|
|
const [, name, url] = match;
|
|
const key = `${name}-${url}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
|
|
const parsed = parseGitHubUrl(url);
|
|
if (parsed) {
|
|
remotes.push({
|
|
name,
|
|
owner: parsed.owner,
|
|
repo: parsed.repo,
|
|
url,
|
|
});
|
|
}
|
|
}
|
|
|
|
return remotes;
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
const RECENT_ISSUES_CACHE_TTL_MS = 60_000;
|
|
|
|
// Intentionally module-scoped and TTL-only. We do not proactively invalidate on remote
|
|
// changes because the 60s window is short and keeps per-keystroke chat lookups cheap.
|
|
const recentIssuesCache = new Map<string, { fetchedAt: number; items: Array<{
|
|
number: number;
|
|
title: string;
|
|
state: "open" | "closed";
|
|
htmlUrl: string;
|
|
repository: 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);
|
|
}
|
|
|
|
interface PrPreflightResponse {
|
|
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" }>;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
async function computePrPreflight(task: Task, repoRoot: string, requestedBase?: string): Promise<PrPreflightResponse> {
|
|
const defaultBaseBranch = requestedBase?.trim()
|
|
? ensureSafeGitRef(requestedBase, "base branch")
|
|
: await resolveDefaultPrBaseBranch(task, repoRoot);
|
|
const head = `fusion/${task.id.toLowerCase()}`;
|
|
const safeHead = ensureSafeGitRef(head, "head branch");
|
|
const response: PrPreflightResponse = {
|
|
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);
|
|
return response;
|
|
}
|
|
|
|
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);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function getGitStatus(cwd?: string): Promise<{
|
|
branch: string;
|
|
commit: string;
|
|
isDirty: boolean;
|
|
ahead: number;
|
|
behind: number;
|
|
} | null> {
|
|
try {
|
|
const branchOutput = await runGitCommand(["branch", "--show-current"], cwd, 5000);
|
|
const branch = branchOutput.trim() || "HEAD detached";
|
|
|
|
const commit = (await runGitCommand(["rev-parse", "--short", "HEAD"], cwd, 5000)).trim();
|
|
|
|
const statusOutput = (await runGitCommand(["status", "--porcelain"], cwd, 5000)).trim();
|
|
const isDirty = statusOutput.length > 0;
|
|
|
|
let ahead = 0;
|
|
let behind = 0;
|
|
try {
|
|
const revListOutput = (await runGitCommand(["rev-list", "--left-right", "--count", "HEAD...@{u}"], cwd, 5000)).trim();
|
|
const match = revListOutput.match(/(\d+)\s+(\d+)/);
|
|
if (match) {
|
|
ahead = parseInt(match[1], 10);
|
|
behind = parseInt(match[2], 10);
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
return { branch, commit, isDirty, ahead, behind };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export interface ExtendedGitStatus {
|
|
headSha?: string;
|
|
integrationBranch?: string;
|
|
integrationBranchSource?: "settings" | "origin-head" | "fallback";
|
|
isOnIntegrationBranch?: boolean;
|
|
/** True when `git branch --show-current` failed (timeout, permission, etc.)
|
|
* — distinct from the legitimate detached-HEAD case where the command
|
|
* succeeds with empty stdout. UI should surface "branch detection
|
|
* unavailable" rather than silently hiding the wrong-branch warning. */
|
|
currentBranchDetectionFailed?: boolean;
|
|
integrationTipSha?: string | null;
|
|
/** Where `integrationTipSha` was resolved from. `"local"` = the branch
|
|
* exists locally; `"remote-only"` = the branch only exists as
|
|
* `refs/remotes/origin/<branch>` and was used as a fallback; `"missing"` =
|
|
* neither ref exists, so the integration tip is null. */
|
|
integrationTipSource?: "local" | "remote-only" | "missing";
|
|
originIntegrationTipSha?: string | null;
|
|
/** HEAD vs the **local** integration tip. Undefined when the branch
|
|
* exists only as a remote-tracking ref. */
|
|
aheadOfIntegration?: number;
|
|
behindIntegration?: number;
|
|
/** HEAD vs `origin/<integrationBranch>`. Defined whenever the remote
|
|
* tracking ref exists, regardless of whether the local ref does. Useful
|
|
* in remote-only mode (and as an unambiguous comparison in any mode). */
|
|
aheadOfIntegrationRemote?: number;
|
|
behindIntegrationRemote?: number;
|
|
/** Local integration tip vs `origin/<integrationBranch>`. Defined only
|
|
* when both refs exist. */
|
|
aheadOfOriginIntegration?: number;
|
|
behindOriginIntegration?: number;
|
|
dirtyDetails?: {
|
|
staged: number;
|
|
modified: number;
|
|
untracked: number;
|
|
conflicted: number;
|
|
sample: string[];
|
|
};
|
|
indexStaleVsHead?: boolean;
|
|
stashCount?: number;
|
|
recentMergeAdvances?: Array<{
|
|
taskId: string;
|
|
fromSha: string | null;
|
|
toSha: string;
|
|
advancedAt: string;
|
|
autoSyncOutcome?: string;
|
|
needsAction: boolean;
|
|
resolution: "reachable" | "orphaned" | "subsumed" | "superseded" | "pending";
|
|
}>;
|
|
}
|
|
|
|
async function resolveIntegrationBranchForStatus(
|
|
cwd: string,
|
|
settings: { integrationBranch?: unknown; baseBranch?: unknown } | null | undefined,
|
|
): Promise<{ branch: string; source: "settings" | "origin-head" | "fallback" }> {
|
|
const explicit = typeof settings?.integrationBranch === "string" ? settings.integrationBranch.trim() : "";
|
|
if (explicit.length > 0) return { branch: explicit, source: "settings" };
|
|
const legacyBase = typeof settings?.baseBranch === "string" ? (settings.baseBranch as string).trim() : "";
|
|
if (legacyBase.length > 0) return { branch: legacyBase, source: "settings" };
|
|
try {
|
|
const ref = (await runGitCommand(["symbolic-ref", "refs/remotes/origin/HEAD"], cwd, 5_000)).trim();
|
|
const m = /^refs\/remotes\/origin\/(.+)$/.exec(ref);
|
|
if (m) return { branch: m[1], source: "origin-head" };
|
|
} catch {
|
|
// fall through
|
|
}
|
|
return { branch: "main", source: "fallback" };
|
|
}
|
|
|
|
async function revParse(cwd: string, ref: string): Promise<string | null> {
|
|
try {
|
|
const out = (await runGitCommand(["rev-parse", "--verify", ref], cwd, 5_000)).trim();
|
|
return out.length > 0 ? out : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function aheadBehind(cwd: string, leftRef: string, rightRef: string): Promise<{ ahead: number; behind: number } | null> {
|
|
try {
|
|
const out = (await runGitCommand(["rev-list", "--left-right", "--count", `${leftRef}...${rightRef}`], cwd, 5_000)).trim();
|
|
const m = out.match(/(\d+)\s+(\d+)/);
|
|
if (!m) return null;
|
|
return { ahead: parseInt(m[1], 10), behind: parseInt(m[2], 10) };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function computeDirtyDetails(cwd: string): Promise<ExtendedGitStatus["dirtyDetails"]> {
|
|
try {
|
|
const out = await runGitCommand(["-c", "core.quotePath=false", "status", "--porcelain=v1", "--untracked-files=all"], cwd, 10_000);
|
|
let staged = 0, modified = 0, untracked = 0, conflicted = 0;
|
|
const sample: string[] = [];
|
|
for (const line of out.split("\n")) {
|
|
if (!line) continue;
|
|
const x = line[0] ?? " ";
|
|
const y = line[1] ?? " ";
|
|
const path = line.slice(3);
|
|
if (sample.length < 12) sample.push(`${x}${y} ${path}`);
|
|
if (x === "?" && y === "?") { untracked += 1; continue; }
|
|
if (x === "U" || y === "U" || (x === "A" && y === "A") || (x === "D" && y === "D")) { conflicted += 1; continue; }
|
|
if (x !== " " && x !== "?") staged += 1;
|
|
if (y !== " " && y !== "?") modified += 1;
|
|
}
|
|
return { staged, modified, untracked, conflicted, sample };
|
|
} catch {
|
|
return { staged: 0, modified: 0, untracked: 0, conflicted: 0, sample: [] };
|
|
}
|
|
}
|
|
|
|
async function isIndexStale(
|
|
cwd: string,
|
|
integrationBranch: string,
|
|
isOnIntegrationBranch: boolean | undefined,
|
|
): Promise<boolean | undefined> {
|
|
// The FN-INDEX-DESYNC scenario: the merger advanced refs/heads/<integration>
|
|
// locally so HEAD points at the new tip, but the index still reflects an
|
|
// *earlier* tip. Detect by walking `refs/heads/<integration>` reflog and
|
|
// checking whether the index exactly matches any of the recent prior tips
|
|
// (with HEAD descending from that prior tip). Walking the reflog (not just
|
|
// `@{1}`) catches multi-hop misses: if the merger advanced A→B→C without
|
|
// the rootDir worktree being synced in between, the index still holds A's
|
|
// tree while `@{1}` is now B; comparing only against B would miss this.
|
|
//
|
|
// Only fires when the worktree is actually on the integration branch.
|
|
// A feature-branch worktree whose HEAD happens to equal `<integration>@{1}`
|
|
// (e.g. user just `git switch -c hotfix main@{N}`) is a perfectly healthy
|
|
// state, not a stale-index situation.
|
|
if (isOnIntegrationBranch !== true) return false;
|
|
try {
|
|
const headSha = await revParse(cwd, "HEAD");
|
|
if (!headSha) return false;
|
|
// Walk up to 16 reflog entries. The merger's typical burst is a handful
|
|
// of advances; 16 is a comfortable ceiling that still bounds the work.
|
|
const REFLOG_DEPTH = 16;
|
|
for (let i = 1; i <= REFLOG_DEPTH; i++) {
|
|
const prevTip = await revParse(cwd, `refs/heads/${integrationBranch}@{${i}}`);
|
|
if (!prevTip) return false; // reflog exhausted (or pruned)
|
|
if (prevTip === headSha) continue; // not actually a prior state
|
|
// HEAD must descend from this prior tip — otherwise the operator
|
|
// rolled back the branch and the "stale" framing doesn't apply.
|
|
let isDescendant = false;
|
|
try {
|
|
await runGitCommand(["merge-base", "--is-ancestor", prevTip, "HEAD"], cwd, 5_000);
|
|
isDescendant = true;
|
|
} catch {
|
|
isDescendant = false;
|
|
}
|
|
if (!isDescendant) continue;
|
|
const diffOut = (await runGitCommand(["diff-index", "--cached", "--name-only", prevTip], cwd, 5_000)).trim();
|
|
if (diffOut.length === 0) return true; // index exactly matches this prior tip → stale
|
|
}
|
|
return false;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
async function computeStashCount(cwd: string): Promise<number | undefined> {
|
|
try {
|
|
const out = (await runGitCommand(["stash", "list", "--format=%H"], cwd, 5_000)).trim();
|
|
if (out.length === 0) return 0;
|
|
return out.split("\n").filter((l) => l.length > 0).length;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/** Canonicalize a filesystem path for cross-process equality checks. The
|
|
* merger emits audit events with `worktreePath` run through `realpath` (via
|
|
* `canonicalizePath` in worktree-pool.ts); the route is called with the
|
|
* store's raw `rootDir`. On macOS the two routinely differ through
|
|
* `/private` symlinks. Resolving both ends through `realpathSync` (with a
|
|
* graceful fallback if the path no longer exists) gives a stable key. */
|
|
function canonicalForCompare(p: string): string {
|
|
try {
|
|
return realpathSync(p);
|
|
} catch {
|
|
return p;
|
|
}
|
|
}
|
|
|
|
async function getPatchFingerprint(cwd: string, sha: string): Promise<string | null> {
|
|
try {
|
|
const out = await runGitCommand(["show", sha, "--pretty=format:", "--patch", "--no-color"], cwd, 5_000);
|
|
const normalized = out
|
|
.split("\n")
|
|
.filter((line) => !line.startsWith("index ") && !line.startsWith("@@ "))
|
|
.join("\n")
|
|
.trim();
|
|
return normalized || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function collectRecentMergeAdvances(
|
|
scopedStore: TaskStore & {
|
|
getRunAuditEvents?: (filters: {
|
|
taskId?: string;
|
|
domain?: "database" | "git" | "filesystem" | "sandbox";
|
|
mutationType?: string;
|
|
limit?: number;
|
|
}) => RunAuditEvent[];
|
|
},
|
|
worktreePath: string,
|
|
headSha: string | undefined,
|
|
localIntegrationTipSha: string | undefined,
|
|
): Promise<ExtendedGitStatus["recentMergeAdvances"]> {
|
|
if (typeof scopedStore.getRunAuditEvents !== "function") return [];
|
|
const advances = scopedStore.getRunAuditEvents({
|
|
domain: "git",
|
|
mutationType: "merge:integration-ref-advance",
|
|
limit: 10,
|
|
});
|
|
const wantPath = canonicalForCompare(worktreePath);
|
|
const autoSyncByAdvance = new Map<string, string>();
|
|
const autoSyncByTaskFallback = new Map<string, string>();
|
|
const pairKey = (tid: string, toSha: string) => `${tid}:${toSha}`;
|
|
for (const ev of scopedStore.getRunAuditEvents({
|
|
domain: "git",
|
|
mutationType: "merge:auto-sync",
|
|
limit: 200,
|
|
})) {
|
|
const md = ev.metadata as { worktreePath?: unknown; outcome?: unknown; taskId?: unknown; newSha?: unknown } | undefined;
|
|
if (!md || typeof md !== "object") continue;
|
|
if (typeof md.outcome !== "string") continue;
|
|
const tid = typeof md.taskId === "string" ? md.taskId : (typeof ev.taskId === "string" ? ev.taskId : "");
|
|
if (!tid) continue;
|
|
const hasPath = typeof md.worktreePath === "string";
|
|
const hasNewSha = typeof md.newSha === "string";
|
|
if (hasPath && hasNewSha) {
|
|
if (canonicalForCompare(md.worktreePath as string) !== wantPath) continue;
|
|
const key = pairKey(tid, md.newSha as string);
|
|
if (!autoSyncByAdvance.has(key)) autoSyncByAdvance.set(key, md.outcome);
|
|
} else if (!hasPath && !hasNewSha) {
|
|
if (!autoSyncByTaskFallback.has(tid)) autoSyncByTaskFallback.set(tid, md.outcome);
|
|
}
|
|
}
|
|
const successOutcomes = new Set(["clean-sync", "synced-with-edits-restored"]);
|
|
const out: NonNullable<ExtendedGitStatus["recentMergeAdvances"]> = [];
|
|
const headPatchIds = new Set<string>();
|
|
let headPatchIdsLoaded = false;
|
|
for (const ev of advances) {
|
|
const md = ev.metadata as { fromSha?: unknown; toSha?: unknown; succeeded?: unknown } | undefined;
|
|
if (!md || typeof md !== "object") continue;
|
|
if (typeof md.toSha !== "string") continue;
|
|
if (md.succeeded === false) continue;
|
|
const tid = typeof ev.taskId === "string" ? ev.taskId : "";
|
|
if (!tid) continue;
|
|
const autoSyncOutcome = autoSyncByAdvance.get(pairKey(tid, md.toSha)) ?? autoSyncByTaskFallback.get(tid);
|
|
|
|
let resolution: "reachable" | "orphaned" | "subsumed" | "superseded" | "pending" = "pending";
|
|
let toShaExists = true;
|
|
if (headSha && headSha === md.toSha) {
|
|
resolution = "reachable";
|
|
} else if (headSha) {
|
|
try {
|
|
await runGitCommand(["cat-file", "-e", `${md.toSha}^{commit}`], worktreePath, 5_000);
|
|
} catch {
|
|
toShaExists = false;
|
|
resolution = "orphaned";
|
|
}
|
|
|
|
if (toShaExists && resolution === "pending") {
|
|
try {
|
|
await runGitCommand(["merge-base", "--is-ancestor", md.toSha, headSha], worktreePath, 5_000);
|
|
resolution = "reachable";
|
|
} catch {
|
|
// continue
|
|
}
|
|
}
|
|
|
|
if (toShaExists && resolution === "pending") {
|
|
const targetPatchId = await getPatchFingerprint(worktreePath, md.toSha);
|
|
if (targetPatchId) {
|
|
if (!headPatchIdsLoaded) {
|
|
headPatchIdsLoaded = true;
|
|
try {
|
|
const commitsOut = (await runGitCommand(["log", "-n", "50", "--pretty=%H", headSha], worktreePath, 5_000)).trim();
|
|
const commits = commitsOut ? commitsOut.split("\n").filter(Boolean) : [];
|
|
for (const commitSha of commits) {
|
|
const patchId = await getPatchFingerprint(worktreePath, commitSha);
|
|
if (patchId) headPatchIds.add(patchId);
|
|
}
|
|
} catch {
|
|
// degrade conservatively
|
|
}
|
|
}
|
|
if (headPatchIds.has(targetPatchId)) {
|
|
resolution = "subsumed";
|
|
}
|
|
}
|
|
}
|
|
|
|
// When HEAD is already aligned with the local integration tip, resetting
|
|
// to that tip cannot make an unreachable advance SHA become reachable.
|
|
// Treat this as handled (superseded by rewrite), not actionable pending.
|
|
if (toShaExists && resolution === "pending" && localIntegrationTipSha && headSha === localIntegrationTipSha) {
|
|
resolution = "superseded";
|
|
}
|
|
}
|
|
|
|
const needsAction = resolution === "pending"
|
|
&& (autoSyncOutcome === undefined || !successOutcomes.has(autoSyncOutcome));
|
|
|
|
out.push({
|
|
taskId: tid,
|
|
fromSha: typeof md.fromSha === "string" ? md.fromSha : null,
|
|
toSha: md.toSha,
|
|
advancedAt: ev.timestamp,
|
|
autoSyncOutcome,
|
|
needsAction,
|
|
resolution,
|
|
});
|
|
if (out.length >= 5) break;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export async function computeExtendedGitStatus(rootDir: string, scopedStore: TaskStore): Promise<ExtendedGitStatus> {
|
|
const settings = await scopedStore.getSettings().catch(() => null);
|
|
const { branch: integrationBranch, source: integrationBranchSource } = await resolveIntegrationBranchForStatus(
|
|
rootDir,
|
|
settings as { integrationBranch?: unknown; baseBranch?: unknown } | null,
|
|
);
|
|
// Distinguish three states:
|
|
// - command succeeded with branch name → "on <name>"
|
|
// - command succeeded with empty stdout → detached HEAD (legitimate)
|
|
// - command threw → unknown (transient git failure, .git/index.lock
|
|
// contention, etc.)
|
|
// The middle two collapse to `isOnIntegrationBranch: undefined` so the
|
|
// UI suppresses the misleading "(not on <branch>)" sub-text in BOTH
|
|
// cases. We tag the failure case separately so the UI can surface a
|
|
// "branch detection unavailable" hint rather than silently rendering
|
|
// nothing — masking a genuine wrong-branch state because of a
|
|
// transient git error would mislead the operator just as much as the
|
|
// detached-HEAD case the comment originally claimed to fix.
|
|
let currentBranch: string | null = null;
|
|
let currentBranchDetectionFailed = false;
|
|
try {
|
|
currentBranch = (await runGitCommand(["branch", "--show-current"], rootDir, 5_000)).trim();
|
|
} catch {
|
|
currentBranchDetectionFailed = true;
|
|
}
|
|
const isOnIntegrationBranch =
|
|
currentBranchDetectionFailed || currentBranch === null || currentBranch.length === 0
|
|
? undefined
|
|
: currentBranch === integrationBranch;
|
|
const headSha = (await revParse(rootDir, "HEAD")) ?? undefined;
|
|
// Prefer the local head; fall back to the remote-tracking ref so projects
|
|
// whose `integrationBranch` setting names a branch that exists only on
|
|
// origin (e.g. `release/v2` the operator has never `git switch`-ed
|
|
// locally) still get a meaningful tip + ahead/behind comparison instead of
|
|
// a silently-empty integration card.
|
|
const localIntegrationTip = await revParse(rootDir, `refs/heads/${integrationBranch}`);
|
|
const originIntegrationTipSha = await revParse(rootDir, `refs/remotes/origin/${integrationBranch}`);
|
|
const integrationTipSha = localIntegrationTip ?? originIntegrationTipSha ?? null;
|
|
const integrationTipSource: ExtendedGitStatus["integrationTipSource"] =
|
|
localIntegrationTip ? "local" : originIntegrationTipSha ? "remote-only" : "missing";
|
|
|
|
// `aheadOfIntegration` / `behindIntegration` is HEAD vs the **local**
|
|
// integration tip — undefined when the branch exists only as a
|
|
// remote-tracking ref. `aheadOfIntegrationRemote` / `behindIntegrationRemote`
|
|
// is HEAD vs `origin/<branch>` — defined whenever the remote tracking ref
|
|
// exists, regardless of local. Keeping the two distances under distinct
|
|
// names removes the silent semantics shift the prior single-field flavor
|
|
// produced in remote-only mode.
|
|
let aheadOfIntegration: number | undefined;
|
|
let behindIntegration: number | undefined;
|
|
if (localIntegrationTip && headSha) {
|
|
const ab = await aheadBehind(rootDir, "HEAD", localIntegrationTip);
|
|
if (ab) { aheadOfIntegration = ab.ahead; behindIntegration = ab.behind; }
|
|
}
|
|
let aheadOfIntegrationRemote: number | undefined;
|
|
let behindIntegrationRemote: number | undefined;
|
|
if (originIntegrationTipSha && headSha) {
|
|
const ab = await aheadBehind(rootDir, "HEAD", originIntegrationTipSha);
|
|
if (ab) { aheadOfIntegrationRemote = ab.ahead; behindIntegrationRemote = ab.behind; }
|
|
}
|
|
let aheadOfOriginIntegration: number | undefined;
|
|
let behindOriginIntegration: number | undefined;
|
|
if (originIntegrationTipSha && localIntegrationTip) {
|
|
const ab = await aheadBehind(rootDir, localIntegrationTip, originIntegrationTipSha);
|
|
if (ab) { aheadOfOriginIntegration = ab.ahead; behindOriginIntegration = ab.behind; }
|
|
}
|
|
|
|
const [dirtyDetails, indexStaleVsHead, stashCount, recentMergeAdvances] = await Promise.all([
|
|
computeDirtyDetails(rootDir),
|
|
isIndexStale(rootDir, integrationBranch, isOnIntegrationBranch),
|
|
computeStashCount(rootDir),
|
|
collectRecentMergeAdvances(
|
|
scopedStore as TaskStore & {
|
|
getRunAuditEvents?: (filters: { taskId?: string; domain?: "database" | "git" | "filesystem" | "sandbox"; mutationType?: string; limit?: number }) => RunAuditEvent[];
|
|
},
|
|
rootDir,
|
|
headSha,
|
|
localIntegrationTip ?? undefined,
|
|
),
|
|
]);
|
|
|
|
return {
|
|
headSha,
|
|
integrationBranch,
|
|
integrationBranchSource,
|
|
isOnIntegrationBranch,
|
|
currentBranchDetectionFailed: currentBranchDetectionFailed || undefined,
|
|
integrationTipSha,
|
|
integrationTipSource,
|
|
originIntegrationTipSha,
|
|
aheadOfIntegrationRemote,
|
|
behindIntegrationRemote,
|
|
aheadOfIntegration,
|
|
behindIntegration,
|
|
aheadOfOriginIntegration,
|
|
behindOriginIntegration,
|
|
dirtyDetails,
|
|
indexStaleVsHead,
|
|
stashCount,
|
|
recentMergeAdvances,
|
|
};
|
|
}
|
|
|
|
export interface GitCommit {
|
|
hash: string;
|
|
shortHash: string;
|
|
message: string;
|
|
body?: string;
|
|
author: string;
|
|
date: string;
|
|
parents: string[];
|
|
}
|
|
|
|
function parseGitCommitsFromLogOutput(output: string): GitCommit[] {
|
|
const commits: GitCommit[] = [];
|
|
|
|
for (const record of output.split("\0")) {
|
|
if (!record) continue;
|
|
|
|
const parts = record.split("\x1f");
|
|
if (parts.length < 7) continue;
|
|
|
|
const [hash, shortHash, message, fullMessage, author, date, parentsStr] = parts;
|
|
const trimmedFullMessage = fullMessage.trimEnd();
|
|
const subjectLine = message || "";
|
|
let body = trimmedFullMessage;
|
|
|
|
if (subjectLine && body.startsWith(subjectLine)) {
|
|
body = body.slice(subjectLine.length);
|
|
body = body.replace(/^\n+/, "");
|
|
}
|
|
|
|
body = body.trim();
|
|
const parents = parentsStr ? parentsStr.split(" ").filter(Boolean) : [];
|
|
|
|
commits.push({
|
|
hash,
|
|
shortHash,
|
|
message: subjectLine,
|
|
body: body || undefined,
|
|
author: author || "",
|
|
date: date || "",
|
|
parents,
|
|
});
|
|
}
|
|
|
|
return commits;
|
|
}
|
|
|
|
export async function getGitCommits(limit = 20, cwd?: string): Promise<GitCommit[]> {
|
|
try {
|
|
const format = "%H%x1f%h%x1f%s%x1f%B%x1f%an%x1f%aI%x1f%P";
|
|
const output = await runGitCommand(["log", "-z", `--max-count=${limit}`, `--pretty=format:${format}`], cwd, 10000);
|
|
return parseGitCommitsFromLogOutput(output);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export function isValidGitRef(ref: string): boolean {
|
|
if (!ref || ref.length === 0) return false;
|
|
if (ref.startsWith("-")) return false;
|
|
if (/[;<>&|`$(){}[\]\r\n]/.test(ref)) return false;
|
|
if (/\s/.test(ref)) return false;
|
|
if (!/^[a-zA-Z0-9/_.@-]+$/.test(ref)) return false;
|
|
if (ref.includes("..")) return false;
|
|
if (ref.includes("~")) return false;
|
|
if (ref.includes("^")) return false;
|
|
if (ref.includes(":")) return false;
|
|
if (ref.startsWith("--")) return false;
|
|
return true;
|
|
}
|
|
|
|
export async function getGitCommitsForBranch(branch: string, limit = 10, cwd?: string): Promise<GitCommit[]> {
|
|
try {
|
|
const format = "%H%x1f%h%x1f%s%x1f%B%x1f%an%x1f%aI%x1f%P";
|
|
const output = await runGitCommand(["log", "-z", `--max-count=${limit}`, `--pretty=format:${format}`, branch], cwd, 10000);
|
|
return parseGitCommitsFromLogOutput(output);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export async function getAheadCommits(cwd?: string): Promise<GitCommit[]> {
|
|
try {
|
|
try {
|
|
await runGitCommand(["rev-parse", "--abbrev-ref", "@{u}"], cwd, 10000);
|
|
} catch {
|
|
return [];
|
|
}
|
|
|
|
const format = "%H%x1f%h%x1f%s%x1f%B%x1f%an%x1f%aI%x1f%P";
|
|
const output = await runGitCommand(["log", "-z", "@{u}..HEAD", `--pretty=format:${format}`], cwd, 10000);
|
|
return parseGitCommitsFromLogOutput(output);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export async function getRemoteCommits(remoteRef: string, limit = 10, cwd?: string): Promise<GitCommit[]> {
|
|
try {
|
|
if (!isValidGitRef(remoteRef)) {
|
|
throw new Error("Invalid remote ref");
|
|
}
|
|
|
|
try {
|
|
await runGitCommand(["rev-parse", "--verify", remoteRef], cwd, 5000);
|
|
} catch {
|
|
return [];
|
|
}
|
|
|
|
const format = "%H%x1f%h%x1f%s%x1f%B%x1f%an%x1f%aI%x1f%P";
|
|
const safeLimit = Math.min(Math.max(1, limit), 50);
|
|
const output = await runGitCommand(["log", "-z", `--max-count=${safeLimit}`, `--pretty=format:${format}`, remoteRef], cwd, 10000);
|
|
return parseGitCommitsFromLogOutput(output);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export async function getCommitDiff(hash: string, cwd?: string): Promise<{ stat: string; patch: string } | null> {
|
|
try {
|
|
await runGitCommand(["cat-file", "-t", hash], cwd, 5000);
|
|
const stat = (await runGitCommand(["show", "--stat", "--format=", hash], cwd, 10000)).trim();
|
|
const patch = await runGitCommand(["show", "--format=", hash], cwd, 10000);
|
|
return { stat, patch };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export interface GitBranch {
|
|
name: string;
|
|
isCurrent: boolean;
|
|
remote?: string;
|
|
lastCommitDate?: string;
|
|
}
|
|
|
|
export async function getGitBranches(cwd?: string): Promise<GitBranch[]> {
|
|
try {
|
|
let currentBranch = "";
|
|
try {
|
|
currentBranch = (await runGitCommand(["branch", "--show-current"], cwd, 5000)).trim();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
const format = "%(refname:short)|%(upstream:short)|%(committerdate:iso8601)|%(HEAD)";
|
|
const output = (await runGitCommand(["for-each-ref", `--format=${format}`, "refs/heads/"], cwd, 10000)).trim();
|
|
|
|
const branches: GitBranch[] = [];
|
|
for (const line of output.split("\n")) {
|
|
const parts = line.split("|");
|
|
if (parts.length < 4) continue;
|
|
|
|
const [name, remote, lastCommitDate, headMarker] = parts;
|
|
const isCurrent = headMarker === "*" || name === currentBranch;
|
|
|
|
branches.push({
|
|
name,
|
|
isCurrent,
|
|
remote: remote || undefined,
|
|
lastCommitDate: lastCommitDate || undefined,
|
|
});
|
|
}
|
|
|
|
return branches;
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export interface GitWorktree {
|
|
path: string;
|
|
branch?: string;
|
|
isMain: boolean;
|
|
isBare: boolean;
|
|
taskId?: string;
|
|
}
|
|
|
|
export async function getGitWorktrees(tasks: { id: string; worktree?: string }[] = [], cwd?: string): Promise<GitWorktree[]> {
|
|
try {
|
|
const output = await runGitCommand(["worktree", "list", "--porcelain"], cwd, 10000);
|
|
|
|
const worktrees: GitWorktree[] = [];
|
|
let currentWorktree: Partial<GitWorktree> = {};
|
|
|
|
for (const line of output.split("\n")) {
|
|
if (line.startsWith("worktree ")) {
|
|
if (currentWorktree.path) {
|
|
const task = tasks.find((t) => t.worktree && currentWorktree.path === t.worktree);
|
|
worktrees.push({
|
|
path: currentWorktree.path,
|
|
branch: currentWorktree.branch,
|
|
isMain: currentWorktree.isMain || false,
|
|
isBare: currentWorktree.isBare || false,
|
|
taskId: task?.id,
|
|
});
|
|
}
|
|
currentWorktree = { path: line.slice(9).trim() };
|
|
} else if (line.startsWith("branch ")) {
|
|
currentWorktree.branch = line.slice(8).trim().replace(/^refs\/heads\//, "");
|
|
} else if (line === "bare") {
|
|
currentWorktree.isBare = true;
|
|
} else if (line === "main") {
|
|
currentWorktree.isMain = true;
|
|
} else if (line === "" && currentWorktree.path) {
|
|
const task = tasks.find((t) => t.worktree && currentWorktree.path === t.worktree);
|
|
worktrees.push({
|
|
path: currentWorktree.path,
|
|
branch: currentWorktree.branch,
|
|
isMain: currentWorktree.isMain || false,
|
|
isBare: currentWorktree.isBare || false,
|
|
taskId: task?.id,
|
|
});
|
|
currentWorktree = {};
|
|
}
|
|
}
|
|
|
|
if (currentWorktree.path) {
|
|
const task = tasks.find((t) => t.worktree && currentWorktree.path === t.worktree);
|
|
worktrees.push({
|
|
path: currentWorktree.path,
|
|
branch: currentWorktree.branch,
|
|
isMain: currentWorktree.isMain || false,
|
|
isBare: currentWorktree.isBare || false,
|
|
taskId: task?.id,
|
|
});
|
|
}
|
|
|
|
return worktrees;
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export function isValidBranchName(name: string): boolean {
|
|
if (!name || name.length === 0) return false;
|
|
if (name.startsWith("-")) return false;
|
|
if (/[;<>&|`$(){}[\]\r\n]/.test(name)) return false;
|
|
if (/\s/.test(name)) return false;
|
|
if (name.includes("..")) return false;
|
|
if (name.includes("~")) return false;
|
|
if (name.includes("^")) return false;
|
|
if (name.includes(":")) return false;
|
|
const reserved = ["HEAD", "FETCH_HEAD", "ORIG_HEAD", "MERGE_HEAD", "CHERRY_PICK_HEAD"];
|
|
if (reserved.includes(name)) return false;
|
|
return true;
|
|
}
|
|
|
|
export async function createGitBranch(name: string, base?: string, cwd?: string): Promise<string> {
|
|
if (!isValidBranchName(name)) {
|
|
throw new Error("Invalid branch name");
|
|
}
|
|
if (base && !isValidBranchName(base)) {
|
|
throw new Error("Invalid base branch name");
|
|
}
|
|
const args = base ? ["checkout", "-b", name, base] : ["checkout", "-b", name];
|
|
await runGitCommand(args, cwd, 10000);
|
|
return name;
|
|
}
|
|
|
|
export async function checkoutGitBranch(name: string, cwd?: string): Promise<void> {
|
|
if (!isValidBranchName(name)) {
|
|
throw new Error("Invalid branch name");
|
|
}
|
|
try {
|
|
await runGitCommand(["diff-index", "--quiet", "HEAD", "--"], cwd, 5000);
|
|
} catch {
|
|
const diff = (await runGitCommand(["diff", "--name-only"], cwd, 5000)).trim();
|
|
if (diff) {
|
|
throw new Error("Uncommitted changes would be lost. Commit or stash changes first.");
|
|
}
|
|
}
|
|
await runGitCommand(["checkout", name], cwd, 10000);
|
|
}
|
|
|
|
export async function deleteGitBranch(name: string, force = false, cwd?: string): Promise<void> {
|
|
if (!isValidBranchName(name)) {
|
|
throw new Error("Invalid branch name");
|
|
}
|
|
const flag = force ? "-D" : "-d";
|
|
await runGitCommand(["branch", flag, name], cwd, 10000);
|
|
}
|
|
|
|
export interface GitFetchResult {
|
|
fetched: boolean;
|
|
message: string;
|
|
}
|
|
|
|
export async function fetchGitRemote(remote = "origin", cwd?: string): Promise<GitFetchResult> {
|
|
if (!isValidBranchName(remote)) {
|
|
throw new Error("Invalid remote name");
|
|
}
|
|
try {
|
|
const output = await runGitCommand(["fetch", remote], cwd, 30000);
|
|
return { fetched: true, message: output.trim() || "Fetch completed" };
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
const message = getCommandErrorMessage(err);
|
|
if (message.includes("Could not resolve host") || message.includes("Connection refused")) {
|
|
throw new Error("Failed to connect to remote");
|
|
}
|
|
return { fetched: false, message: message || "No updates" };
|
|
}
|
|
}
|
|
|
|
export interface GitPullResult {
|
|
success: boolean;
|
|
message: string;
|
|
conflict?: boolean;
|
|
autostashed?: boolean;
|
|
stashReapplied?: boolean;
|
|
stashConflict?: boolean;
|
|
}
|
|
|
|
interface PullAutostashHandle {
|
|
sha: string;
|
|
label: string;
|
|
}
|
|
|
|
function isGitConflictMessage(message: string): boolean {
|
|
return message.includes("CONFLICT") || message.includes("Merge conflict") || message.includes("could not apply");
|
|
}
|
|
|
|
async function hasLocalChangesForPull(cwd?: string): Promise<boolean> {
|
|
const output = await runGitCommand(["status", "--porcelain=v1", "--untracked-files=all"], cwd, 10_000);
|
|
return output.trim().length > 0;
|
|
}
|
|
|
|
async function findStashRefBySha(sha: string, cwd?: string): Promise<string | null> {
|
|
const output = await runGitCommand(["stash", "list", '--format=%H|%gd'], cwd, 5_000);
|
|
for (const line of output.split("\n")) {
|
|
const [entrySha, ref] = line.trim().split("|");
|
|
if (entrySha === sha && ref) {
|
|
return ref;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function dropStashBySha(sha: string, cwd?: string): Promise<void> {
|
|
const ref = await findStashRefBySha(sha, cwd);
|
|
if (!ref) return;
|
|
await runGitCommand(["stash", "drop", ref], cwd, 10_000);
|
|
}
|
|
|
|
function isPathWithin(parent: string, candidate: string): boolean {
|
|
const rel = relative(parent, candidate);
|
|
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
}
|
|
|
|
async function listRegisteredWorktreePaths(rootDir: string): Promise<string[]> {
|
|
const output = await runGitCommand(["worktree", "list", "--porcelain"], rootDir, 10_000);
|
|
const paths: string[] = [];
|
|
for (const line of output.split("\n")) {
|
|
if (!line.startsWith("worktree ")) continue;
|
|
const worktreePath = line.slice("worktree ".length).trim();
|
|
if (!worktreePath) continue;
|
|
paths.push(resolve(worktreePath));
|
|
}
|
|
return paths;
|
|
}
|
|
|
|
async function assertWorktreePathSafe(
|
|
scopedStore: Pick<TaskStore, "getRootDir">,
|
|
worktreePath: string,
|
|
cache: Map<string, string[]>,
|
|
): Promise<string> {
|
|
if (typeof worktreePath !== "string" || worktreePath.trim().length === 0) {
|
|
throw badRequest("worktreePath is required");
|
|
}
|
|
|
|
if (!isAbsolute(worktreePath)) {
|
|
throw badRequest("worktreePath must be an absolute path");
|
|
}
|
|
const rootDir = resolve(scopedStore.getRootDir());
|
|
const resolved = resolve(worktreePath);
|
|
if (resolved !== worktreePath) {
|
|
throw badRequest("worktreePath must be normalized");
|
|
}
|
|
if (isPathWithin(rootDir, resolved)) {
|
|
return resolved;
|
|
}
|
|
|
|
let allowlisted = cache.get(rootDir);
|
|
if (!allowlisted) {
|
|
allowlisted = await listRegisteredWorktreePaths(rootDir);
|
|
cache.set(rootDir, allowlisted);
|
|
}
|
|
|
|
if (allowlisted.some((allowed) => isPathWithin(allowed, resolved))) {
|
|
return resolved;
|
|
}
|
|
|
|
throw badRequest("worktreePath outside project");
|
|
}
|
|
|
|
type DashboardGitMutationType = "stash:push" | "stash:pop" | "pull:fast-forward" | "stash:pop-conflict";
|
|
|
|
function assertRelativeFileSafe(worktreePath: string, file: string): string {
|
|
if (typeof file !== "string" || file.trim().length === 0) {
|
|
throw badRequest("file is required");
|
|
}
|
|
if (file.split("/").includes("..") || file.split("\\").includes("..")) {
|
|
throw badRequest("file outside worktree");
|
|
}
|
|
const normalized = resolve(worktreePath, file);
|
|
if (!isPathWithin(worktreePath, normalized)) {
|
|
throw badRequest("file outside worktree");
|
|
}
|
|
return file;
|
|
}
|
|
|
|
function buildDashboardGitAuditEvent(input: {
|
|
taskId?: string;
|
|
mutationType: DashboardGitMutationType;
|
|
target: string;
|
|
metadata?: Record<string, unknown>;
|
|
}): RunAuditEventInput {
|
|
return {
|
|
taskId: input.taskId,
|
|
agentId: "dashboard-api",
|
|
runId: `dashboard-git-${Date.now()}`,
|
|
domain: "git",
|
|
mutationType: input.mutationType,
|
|
target: input.target,
|
|
metadata: input.metadata,
|
|
};
|
|
}
|
|
|
|
async function createPullAutostash(cwd?: string): Promise<PullAutostashHandle | null> {
|
|
if (!(await hasLocalChangesForPull(cwd))) {
|
|
return null;
|
|
}
|
|
const label = `fusion-dashboard-pull-autostash:${Date.now()}`;
|
|
const output = await runGitCommand(["stash", "push", "-u", "-m", label], cwd, 15_000);
|
|
if (output.includes("No local changes to save")) {
|
|
return null;
|
|
}
|
|
const sha = (await runGitCommand(["rev-parse", "stash@{0}"], cwd, 5_000)).trim();
|
|
if (!sha) {
|
|
throw new Error("Pull autostash failed: could not resolve created stash");
|
|
}
|
|
return { sha, label };
|
|
}
|
|
|
|
async function reapplyPullAutostash(
|
|
handle: PullAutostashHandle,
|
|
cwd?: string,
|
|
): Promise<{ applied: boolean; conflict: boolean; message?: string }> {
|
|
try {
|
|
await runGitCommand(["stash", "apply", handle.sha], cwd, 20_000);
|
|
} catch (err: unknown) {
|
|
const message = getCommandErrorMessage(err);
|
|
if (isGitConflictMessage(message) || message.includes("Command failed: git stash apply")) {
|
|
return {
|
|
applied: false,
|
|
conflict: true,
|
|
message:
|
|
`Pulled latest changes, but reapplying your local edits conflicted. ` +
|
|
`Your work was preserved in stash ${handle.sha.slice(0, 7)} (${handle.label}). ` +
|
|
`Resolve the conflicts in the working tree or reapply later from the Stashes view.`,
|
|
};
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
await dropStashBySha(handle.sha, cwd).catch(() => undefined);
|
|
return { applied: true, conflict: false };
|
|
}
|
|
|
|
export interface PullGitBranchOptions {
|
|
rebase?: boolean;
|
|
integration?: {
|
|
worktreePath: string;
|
|
integrationBranch: string;
|
|
taskId?: string;
|
|
integrationRemote?: string;
|
|
store: TaskStore;
|
|
settings: Settings;
|
|
runId: string;
|
|
/**
|
|
* When true, skip the `tryFastForwardFromOrigin` step entirely. Use this
|
|
* for "the merger advanced local `refs/heads/<branch>` and my worktree is
|
|
* stale relative to it" recovery — there's no need to fetch or merge from
|
|
* origin, just hard-reset the worktree to the local ref. Avoids silently
|
|
* pulling in unrelated remote work the operator didn't ask for.
|
|
*/
|
|
skipOriginFetch?: boolean;
|
|
};
|
|
}
|
|
|
|
export type IntegrationPullResult =
|
|
| { kind: "pull-clean"; message: string; fromSha: string; toSha: string }
|
|
| { kind: "pull-restored"; message: string; fromSha: string; toSha: string; autostash: { status: "restored" | "ai-resolved" } }
|
|
| { kind: "stash-conflict"; message: string; fromSha: string; toSha: string; stashSha: string; stashLabel: string; conflictedFiles: string[]; autostashOutcome: "conflict-needs-manual" | "failed" };
|
|
|
|
function emitDashboardGitAuditEvent(
|
|
store: TaskStore,
|
|
input: {
|
|
taskId?: string;
|
|
runId: string;
|
|
mutationType: DashboardGitMutationType;
|
|
target: string;
|
|
metadata?: Record<string, unknown>;
|
|
},
|
|
): void {
|
|
Promise.resolve(store.recordRunAuditEvent?.({
|
|
taskId: input.taskId,
|
|
agentId: "dashboard-api",
|
|
runId: input.runId,
|
|
domain: "git",
|
|
mutationType: input.mutationType,
|
|
target: input.target,
|
|
metadata: input.metadata,
|
|
})).catch(() => undefined);
|
|
}
|
|
|
|
export async function pullGitBranch(cwd?: string, options?: PullGitBranchOptions): Promise<GitPullResult | IntegrationPullResult> {
|
|
const integration = options?.integration;
|
|
if (integration) {
|
|
const taskId = integration.taskId ?? "dashboard-pull";
|
|
const rootDir = integration.worktreePath;
|
|
if (!(await isGitRepo(rootDir))) {
|
|
throw badRequest("Not a git repository");
|
|
}
|
|
|
|
const currentBranch = (await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], rootDir, 5_000)).trim();
|
|
if (currentBranch !== integration.integrationBranch) {
|
|
throw new ApiError(409, "Worktree is not on integration branch", { reason: "branch-mismatch", currentBranch });
|
|
}
|
|
|
|
const fromSha = (await runGitCommand(["rev-parse", "HEAD"], rootDir, 5_000)).trim();
|
|
const stashHandle = await stashUnrelatedRootDirChanges(rootDir, taskId);
|
|
if (stashHandle) {
|
|
emitDashboardGitAuditEvent(integration.store, {
|
|
taskId: integration.taskId,
|
|
runId: integration.runId,
|
|
mutationType: "stash:push",
|
|
target: rootDir,
|
|
metadata: {
|
|
taskId: integration.taskId,
|
|
worktreePath: rootDir,
|
|
stashSha: stashHandle.sha,
|
|
stashLabel: stashHandle.label,
|
|
untrackedIncluded: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
const pullStart = performance.now();
|
|
if (!integration.skipOriginFetch) {
|
|
await tryFastForwardFromOrigin(rootDir, taskId, integration.integrationBranch, integration.integrationRemote ?? "origin");
|
|
}
|
|
|
|
// Sync working tree + index to the local integration tip. The merger
|
|
// advances `refs/heads/<integrationBranch>` via `git update-ref` without
|
|
// touching any worktree. When HEAD here is symbolic to that branch
|
|
// (the normal case in the user's project-root checkout), HEAD already
|
|
// resolves to the new sha — but the working files and index don't
|
|
// follow until something forces it. `tryFastForwardFromOrigin` only
|
|
// updates the worktree when origin is ahead of local; when the local
|
|
// tip is ahead of origin (the post-merge, pre-push state), it returns
|
|
// a no-op and the user sees "Pull completed" with no visible change.
|
|
// Reset against the branch ref explicitly so the worktree advances to
|
|
// the local tip regardless of whether the origin FF ran. The autostash
|
|
// above protects user edits, so --hard is safe here.
|
|
const localIntegrationTip = (await runGitCommand(
|
|
["rev-parse", "--verify", `refs/heads/${integration.integrationBranch}`],
|
|
rootDir,
|
|
5_000,
|
|
)).trim();
|
|
if (localIntegrationTip) {
|
|
await runGitCommand(["reset", "--hard", localIntegrationTip], rootDir, 10_000)
|
|
.catch((err) => {
|
|
// Log-and-continue: a failed worktree sync still leaves the ref
|
|
// advanced, so downstream stash-pop and audit emission proceed.
|
|
// The user's worktree just stays at its prior sha, matching today's
|
|
// behavior. Logged loudly so the failure is visible.
|
|
console.warn(
|
|
`[integration-pull] taskId=${taskId} worktree sync to ${localIntegrationTip.slice(0, 8)} failed (continuing): ${err instanceof Error ? err.message : String(err)}`,
|
|
);
|
|
});
|
|
}
|
|
|
|
const durationMs = Math.round(performance.now() - pullStart);
|
|
const toSha = (await runGitCommand(["rev-parse", "HEAD"], rootDir, 5_000)).trim();
|
|
|
|
emitDashboardGitAuditEvent(integration.store, {
|
|
taskId: integration.taskId,
|
|
runId: integration.runId,
|
|
mutationType: "pull:fast-forward",
|
|
target: rootDir,
|
|
metadata: {
|
|
taskId: integration.taskId,
|
|
worktreePath: rootDir,
|
|
integrationBranch: integration.integrationBranch,
|
|
remote: integration.integrationRemote ?? "origin",
|
|
fromSha,
|
|
toSha,
|
|
durationMs,
|
|
succeeded: true,
|
|
...(toSha === fromSha ? { behind: 0 } : {}),
|
|
},
|
|
});
|
|
|
|
if (!stashHandle) {
|
|
console.info(`[integration-pull] taskId=${taskId} worktree=${rootDir.split("/").pop() ?? rootDir} kind=pull-clean from=${fromSha.slice(0, 7)} to=${toSha.slice(0, 7)}`);
|
|
return { kind: "pull-clean", message: "Pull completed", fromSha, toSha };
|
|
}
|
|
|
|
const mergerOptions = {
|
|
taskId,
|
|
rootDir,
|
|
branch: integration.integrationBranch,
|
|
integrationBranch: integration.integrationBranch,
|
|
mergeMode: "squash",
|
|
} as MergerOptions;
|
|
const outcome = await restoreUnrelatedRootDirChanges(rootDir, taskId, stashHandle, {
|
|
store: integration.store,
|
|
options: mergerOptions,
|
|
settings: integration.settings,
|
|
});
|
|
|
|
if (outcome.status === "restored" || outcome.status === "ai-resolved") {
|
|
emitDashboardGitAuditEvent(integration.store, {
|
|
taskId: integration.taskId,
|
|
runId: integration.runId,
|
|
mutationType: "stash:pop",
|
|
target: rootDir,
|
|
metadata: {
|
|
taskId: integration.taskId,
|
|
worktreePath: rootDir,
|
|
stashSha: stashHandle.sha,
|
|
stashLabel: stashHandle.label,
|
|
autostashOutcome: outcome.status,
|
|
},
|
|
});
|
|
console.info(`[integration-pull] taskId=${taskId} worktree=${rootDir.split("/").pop() ?? rootDir} kind=pull-restored from=${fromSha.slice(0, 7)} to=${toSha.slice(0, 7)}`);
|
|
return { kind: "pull-restored", message: "Pulled latest changes and restored local edits.", fromSha, toSha, autostash: { status: outcome.status } };
|
|
}
|
|
|
|
if (outcome.status === "conflict-needs-manual" || outcome.status === "failed") {
|
|
const conflictedFiles = await getConflictedFiles(rootDir);
|
|
emitDashboardGitAuditEvent(integration.store, {
|
|
taskId: integration.taskId,
|
|
runId: integration.runId,
|
|
mutationType: "stash:pop-conflict",
|
|
target: rootDir,
|
|
metadata: {
|
|
taskId: integration.taskId,
|
|
worktreePath: rootDir,
|
|
stashSha: stashHandle.sha,
|
|
stashLabel: stashHandle.label,
|
|
conflictedFiles,
|
|
autostashOutcome: outcome.status,
|
|
},
|
|
});
|
|
console.info(`[integration-pull] taskId=${taskId} worktree=${rootDir.split("/").pop() ?? rootDir} kind=stash-conflict from=${fromSha.slice(0, 7)} to=${toSha.slice(0, 7)}`);
|
|
return {
|
|
kind: "stash-conflict",
|
|
message: "Pulled latest changes, but restoring local edits needs manual resolution.",
|
|
fromSha,
|
|
toSha,
|
|
stashSha: stashHandle.sha,
|
|
stashLabel: stashHandle.label,
|
|
conflictedFiles,
|
|
autostashOutcome: outcome.status,
|
|
};
|
|
}
|
|
|
|
await dropAutostashHandle(rootDir, taskId, stashHandle, {
|
|
keepIfLive: false,
|
|
store: integration.store,
|
|
context: "integration-pull",
|
|
}).catch(() => undefined);
|
|
console.info(`[integration-pull] taskId=${taskId} worktree=${rootDir.split("/").pop() ?? rootDir} kind=pull-clean from=${fromSha.slice(0, 7)} to=${toSha.slice(0, 7)}`);
|
|
return { kind: "pull-clean", message: "Pull completed", fromSha, toSha };
|
|
}
|
|
|
|
const rebase = options?.rebase === true;
|
|
const autostash = await createPullAutostash(cwd);
|
|
try {
|
|
const output = await runGitCommand(rebase ? ["pull", "--rebase"] : ["pull", "--ff-only"], cwd, 30_000);
|
|
const message = output.trim() || (rebase ? "Pull completed (rebase)" : "Pull completed");
|
|
|
|
if (!autostash) {
|
|
return { success: true, message };
|
|
}
|
|
|
|
const reapply = await reapplyPullAutostash(autostash, cwd);
|
|
if (reapply.conflict) {
|
|
return {
|
|
success: false,
|
|
conflict: true,
|
|
message: reapply.message ?? "Pulled latest changes, but reapplying local edits conflicted.",
|
|
autostashed: true,
|
|
stashConflict: true,
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
message: `${message}\n\nRestored your local changes from an automatic pre-pull stash.`,
|
|
autostashed: true,
|
|
stashReapplied: true,
|
|
};
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
const message = getCommandErrorMessage(err);
|
|
if (isGitConflictMessage(message)) {
|
|
const preservedMessage = autostash
|
|
? `Merge conflict detected during pull. Your local edits were preserved in stash ${autostash.sha.slice(0, 7)} (${autostash.label}). Resolve the pull conflict first, then reapply from the Stashes view.`
|
|
: "Merge conflict detected. Resolve manually.";
|
|
return { success: false, message: preservedMessage, conflict: true, autostashed: Boolean(autostash) };
|
|
}
|
|
if (autostash) {
|
|
const restored = await reapplyPullAutostash(autostash, cwd).catch(() => null);
|
|
if (restored?.applied) {
|
|
throw new Error(`${message || "Pull failed"}\n\nYour local changes were restored from the automatic pre-pull stash.`);
|
|
}
|
|
if (restored?.conflict) {
|
|
throw new Error(`${message || "Pull failed"}\n\n${restored.message}`);
|
|
}
|
|
}
|
|
throw new Error(message || "Pull failed");
|
|
}
|
|
}
|
|
|
|
export interface GitPushResult {
|
|
success: boolean;
|
|
message: string;
|
|
}
|
|
|
|
export async function pushGitBranch(cwd?: string): Promise<GitPushResult> {
|
|
try {
|
|
const output = await runGitCommand(["push"], cwd, 30000);
|
|
return { success: true, message: output.trim() || "Push completed" };
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
const message = getCommandErrorMessage(err);
|
|
if (message.includes("rejected") || message.includes("non-fast-forward")) {
|
|
throw new Error("Push rejected. Pull latest changes first.");
|
|
}
|
|
if (message.includes("Could not resolve host") || message.includes("Connection refused")) {
|
|
throw new Error("Failed to connect to remote");
|
|
}
|
|
throw new Error(message || "Push failed");
|
|
}
|
|
}
|
|
|
|
export interface GitRemoteDetailed {
|
|
name: string;
|
|
fetchUrl: string;
|
|
pushUrl: string;
|
|
}
|
|
|
|
export function isValidGitUrl(url: string): boolean {
|
|
if (!url || typeof url !== "string") return false;
|
|
if (/[;<>&|`$(){}[\]\r\n]/.test(url)) return false;
|
|
if (url.startsWith("-")) return false;
|
|
if (/^https?:\/\/.+/.test(url)) return true;
|
|
if (/^git@[^:]+:.+/.test(url)) return true;
|
|
if (/^file:\/\/.+/.test(url)) return true;
|
|
if (/^ssh:\/\/.+/.test(url)) return true;
|
|
return false;
|
|
}
|
|
|
|
export async function listGitRemotes(cwd?: string): Promise<GitRemoteDetailed[]> {
|
|
try {
|
|
const output = await runGitCommand(["remote", "-v"], cwd, 5000);
|
|
|
|
const remotes = new Map<string, { fetchUrl: string; pushUrl: string }>();
|
|
|
|
for (const line of output.split("\n")) {
|
|
const match = line.match(/^(\S+)\s+(\S+)\s+\((fetch|push)\)$/);
|
|
if (!match) continue;
|
|
|
|
const [, name, url, type] = match;
|
|
|
|
if (!remotes.has(name)) {
|
|
remotes.set(name, { fetchUrl: "", pushUrl: "" });
|
|
}
|
|
|
|
const remote = remotes.get(name)!;
|
|
if (type === "fetch") {
|
|
remote.fetchUrl = url;
|
|
} else {
|
|
remote.pushUrl = url;
|
|
}
|
|
}
|
|
|
|
return Array.from(remotes.entries()).map(([name, urls]) => ({
|
|
name,
|
|
fetchUrl: urls.fetchUrl,
|
|
pushUrl: urls.pushUrl,
|
|
}));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export async function addGitRemote(name: string, url: string, cwd?: string): Promise<void> {
|
|
if (!isValidBranchName(name)) {
|
|
throw new Error("Invalid remote name");
|
|
}
|
|
if (!isValidGitUrl(url)) {
|
|
throw new Error("Invalid git URL format");
|
|
}
|
|
try {
|
|
await runGitCommand(["remote", "add", name, url], cwd, 10000);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
const message = getCommandErrorMessage(err);
|
|
if (message.includes("already exists")) {
|
|
throw new Error(`Remote '${name}' already exists`);
|
|
}
|
|
throw new Error(message || "Failed to add remote");
|
|
}
|
|
}
|
|
|
|
export async function removeGitRemote(name: string, cwd?: string): Promise<void> {
|
|
if (!isValidBranchName(name)) {
|
|
throw new Error("Invalid remote name");
|
|
}
|
|
try {
|
|
await runGitCommand(["remote", "remove", name], cwd, 10000);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
const message = getCommandErrorMessage(err);
|
|
if (message.includes("No such remote")) {
|
|
throw new Error(`Remote '${name}' does not exist`);
|
|
}
|
|
throw new Error(message || "Failed to remove remote");
|
|
}
|
|
}
|
|
|
|
export async function renameGitRemote(oldName: string, newName: string, cwd?: string): Promise<void> {
|
|
if (!isValidBranchName(oldName)) {
|
|
throw new Error("Invalid remote name");
|
|
}
|
|
if (!isValidBranchName(newName)) {
|
|
throw new Error("Invalid new remote name");
|
|
}
|
|
try {
|
|
await runGitCommand(["remote", "rename", oldName, newName], cwd, 10000);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
const message = getCommandErrorMessage(err);
|
|
if (message.includes("No such remote")) {
|
|
throw new Error(`Remote '${oldName}' does not exist`);
|
|
}
|
|
if (message.includes("already exists")) {
|
|
throw new Error(`Remote '${newName}' already exists`);
|
|
}
|
|
throw new Error(message || "Failed to rename remote");
|
|
}
|
|
}
|
|
|
|
export async function setGitRemoteUrl(name: string, url: string, cwd?: string): Promise<void> {
|
|
if (!isValidBranchName(name)) {
|
|
throw new Error("Invalid remote name");
|
|
}
|
|
if (!isValidGitUrl(url)) {
|
|
throw new Error("Invalid git URL format");
|
|
}
|
|
try {
|
|
await runGitCommand(["remote", "set-url", name, url], cwd, 10000);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
const message = getCommandErrorMessage(err);
|
|
if (message.includes("No such remote")) {
|
|
throw new Error(`Remote '${name}' does not exist`);
|
|
}
|
|
throw new Error(message || "Failed to update remote URL");
|
|
}
|
|
}
|
|
|
|
export interface GitStash {
|
|
index: number;
|
|
message: string;
|
|
date: string;
|
|
branch: string;
|
|
}
|
|
|
|
export interface GitFileChange {
|
|
file: string;
|
|
status: "added" | "modified" | "deleted" | "renamed" | "copied" | "untracked";
|
|
staged: boolean;
|
|
oldFile?: string;
|
|
}
|
|
|
|
export async function getGitStashList(cwd?: string): Promise<GitStash[]> {
|
|
try {
|
|
const output = (await runGitCommand(["stash", "list", '--format="%gd|%gs|%ai"'], cwd, 5000)).trim();
|
|
if (!output) return [];
|
|
|
|
const stashes: GitStash[] = [];
|
|
for (const line of output.split("\n")) {
|
|
const parts = line.split("|");
|
|
if (parts.length < 3) continue;
|
|
const [ref, message, date] = parts;
|
|
const indexMatch = ref.match(/stash@\{(\d+)\}/);
|
|
const index = indexMatch ? parseInt(indexMatch[1], 10) : stashes.length;
|
|
const branchMatch = message.match(/(?:WIP on|On) ([^:]+):/);
|
|
const branch = branchMatch ? branchMatch[1] : "";
|
|
stashes.push({ index, message, date, branch });
|
|
}
|
|
return stashes;
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export async function createGitStash(message?: string, cwd?: string): Promise<string> {
|
|
let output: string;
|
|
if (message) {
|
|
const sanitized = message.replace(/[`$\\!"]/g, "").trim();
|
|
if (!sanitized) {
|
|
throw new Error("Invalid stash message");
|
|
}
|
|
output = (await runGitCommand(["stash", "push", "-m", sanitized], cwd, 10000)).trim();
|
|
} else {
|
|
output = (await runGitCommand(["stash", "push"], cwd, 10000)).trim();
|
|
}
|
|
if (output.includes("No local changes to save")) {
|
|
throw new Error("No local changes to stash");
|
|
}
|
|
return output || "Stash created";
|
|
}
|
|
|
|
export async function applyGitStash(index: number, drop = false, cwd?: string): Promise<string> {
|
|
if (index < 0 || !Number.isInteger(index)) throw new Error("Invalid stash index");
|
|
const args = drop ? ["stash", "pop", `stash@{${index}}`] : ["stash", "apply", `stash@{${index}}`];
|
|
const output = (await runGitCommand(args, cwd, 10000)).trim();
|
|
return output || (drop ? "Stash popped" : "Stash applied");
|
|
}
|
|
|
|
export async function dropGitStash(index: number, cwd?: string): Promise<string> {
|
|
if (index < 0 || !Number.isInteger(index)) throw new Error("Invalid stash index");
|
|
const output = (await runGitCommand(["stash", "drop", `stash@{${index}}`], cwd, 10000)).trim();
|
|
return output || "Stash dropped";
|
|
}
|
|
|
|
export async function getGitStashDiff(index: number, cwd?: string): Promise<{ stat: string; patch: string } | null> {
|
|
if (index < 0 || !Number.isInteger(index)) {
|
|
throw new Error("Invalid stash index");
|
|
}
|
|
|
|
const stashRef = `stash@{${index}}`;
|
|
try {
|
|
await runGitCommand(["rev-parse", "--verify", stashRef], cwd, 5000);
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
const stat = (await runGitCommand(["stash", "show", "--stat", stashRef], cwd, 10000)).trim();
|
|
const patch = await runGitCommand(["stash", "show", "-p", stashRef], cwd, 10000);
|
|
return { stat, patch };
|
|
}
|
|
|
|
export async function getGitFileChanges(cwd?: string): Promise<GitFileChange[]> {
|
|
try {
|
|
const output = await runGitCommand(["status", "--porcelain=v1"], cwd, 5000);
|
|
if (!output.trim()) return [];
|
|
|
|
const changes: GitFileChange[] = [];
|
|
for (const line of output.split("\n")) {
|
|
// Preserve leading status spaces from porcelain output. Trimming the
|
|
// whole command output corrupts the first unstaged entry (`" M foo"` →
|
|
// `"M foo"`), which misclassifies it as staged and truncates the path.
|
|
const normalizedLine = line.replace(/\r$/, "");
|
|
if (normalizedLine.length < 3) continue;
|
|
const indexStatus = normalizedLine[0];
|
|
const workTreeStatus = normalizedLine[1];
|
|
const filePath = normalizedLine.slice(3).trim();
|
|
|
|
const mapStatus = (code: string): GitFileChange["status"] => {
|
|
switch (code) {
|
|
case "A": return "added";
|
|
case "M": return "modified";
|
|
case "D": return "deleted";
|
|
case "R": return "renamed";
|
|
case "C": return "copied";
|
|
case "?": return "untracked";
|
|
default: return "modified";
|
|
}
|
|
};
|
|
|
|
let file = filePath;
|
|
let oldFile: string | undefined;
|
|
if (filePath.includes(" -> ")) {
|
|
const [old, newF] = filePath.split(" -> ");
|
|
oldFile = old.trim();
|
|
file = newF.trim();
|
|
}
|
|
|
|
if (indexStatus !== " " && indexStatus !== "?") {
|
|
changes.push({ file, status: mapStatus(indexStatus), staged: true, oldFile });
|
|
}
|
|
|
|
if (workTreeStatus !== " ") {
|
|
changes.push({
|
|
file,
|
|
status: workTreeStatus === "?" ? "untracked" : mapStatus(workTreeStatus),
|
|
staged: false,
|
|
oldFile,
|
|
});
|
|
}
|
|
}
|
|
return changes;
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export async function getGitWorkingDiff(cwd?: string): Promise<{ stat: string; patch: string }> {
|
|
try {
|
|
const stat = (await runGitCommand(["diff", "--stat"], cwd, 10000)).trim();
|
|
const patch = await runGitCommand(["diff"], cwd, 10000);
|
|
return { stat, patch };
|
|
} catch {
|
|
return { stat: "", patch: "" };
|
|
}
|
|
}
|
|
|
|
export function isValidGitFilePath(filePath: string): boolean {
|
|
if (!filePath || !filePath.trim()) return false;
|
|
if (filePath.startsWith("-")) return false;
|
|
if (isAbsolute(filePath)) return false;
|
|
if (filePath.includes("\0")) return false;
|
|
if (filePath.includes("..")) return false;
|
|
if (/[;&|`$(){}[\]\r\n]/.test(filePath)) return false;
|
|
return true;
|
|
}
|
|
|
|
// `git diff --no-index` exits 1 when files differ — that's the success case
|
|
// for synthetic untracked-file diffs, not an error. Use spawn directly so we
|
|
// can accept exit code 1 with stdout, independent of how callers (or test
|
|
// mocks) wrap execFile / promisify.
|
|
async function runNoIndexDiff(args: string[], cwd?: string): Promise<string> {
|
|
return await new Promise<string>((resolve, reject) => {
|
|
const child = spawn("git", args, { cwd, timeout: 10_000 });
|
|
let stdout = "";
|
|
let stderr = "";
|
|
child.stdout?.on("data", (chunk) => {
|
|
stdout += chunk.toString();
|
|
});
|
|
child.stderr?.on("data", (chunk) => {
|
|
stderr += chunk.toString();
|
|
});
|
|
child.on("error", reject);
|
|
child.on("close", (code) => {
|
|
if (code === 0 || code === 1) {
|
|
resolve(stdout);
|
|
} else {
|
|
reject(new Error(`git ${args.join(" ")} exited ${code}: ${stderr}`));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function getGitFileDiff(filePath: string, staged: boolean, cwd?: string): Promise<{ stat: string; patch: string }> {
|
|
if (!isValidGitFilePath(filePath)) {
|
|
throw new Error(`Invalid file path: ${filePath}`);
|
|
}
|
|
|
|
if (staged) {
|
|
const stat = (await runGitCommand(["diff", "--cached", "--stat", "--", filePath], cwd, 10000)).trim();
|
|
const patch = await runGitCommand(["diff", "--cached", "--", filePath], cwd, 10000);
|
|
return { stat, patch };
|
|
}
|
|
|
|
const untracked = (await runGitCommand(["ls-files", "--others", "--exclude-standard", "--", filePath], cwd, 5000)).trim();
|
|
if (untracked === filePath) {
|
|
const stat = (await runNoIndexDiff(["diff", "--no-index", "--stat", "/dev/null", filePath], cwd)).trim();
|
|
const patch = await runNoIndexDiff(["diff", "--no-index", "/dev/null", filePath], cwd);
|
|
return { stat, patch };
|
|
}
|
|
|
|
const stat = (await runGitCommand(["diff", "--stat", "--", filePath], cwd, 10000)).trim();
|
|
const patch = await runGitCommand(["diff", "--", filePath], cwd, 10000);
|
|
return { stat, patch };
|
|
}
|
|
|
|
export async function stageGitFiles(files: string[], cwd?: string): Promise<string[]> {
|
|
if (!files.length) throw new Error("No files specified");
|
|
for (const f of files) {
|
|
if (!isValidGitFilePath(f)) {
|
|
throw new Error(`Invalid file path: ${f}`);
|
|
}
|
|
}
|
|
await runGitCommand(["add", ...files], cwd, 10000);
|
|
return files;
|
|
}
|
|
|
|
export async function unstageGitFiles(files: string[], cwd?: string): Promise<string[]> {
|
|
if (!files.length) throw new Error("No files specified");
|
|
for (const f of files) {
|
|
if (!isValidGitFilePath(f)) {
|
|
throw new Error(`Invalid file path: ${f}`);
|
|
}
|
|
}
|
|
await runGitCommand(["reset", "HEAD", "--", ...files], cwd, 10000);
|
|
return files;
|
|
}
|
|
|
|
export async function createGitCommit(message: string, cwd?: string): Promise<{ hash: string; message: string }> {
|
|
if (!message || !message.trim()) throw new Error("Commit message is required");
|
|
const staged = (await runGitCommand(["diff", "--cached", "--name-only"], cwd, 5000)).trim();
|
|
if (!staged) throw new Error("No staged changes to commit");
|
|
await runGitCommand(["commit", "-m", message.trim()], cwd, 10000);
|
|
const hash = (await runGitCommand(["rev-parse", "--short", "HEAD"], cwd, 5000)).trim();
|
|
return { hash, message: message.trim() };
|
|
}
|
|
|
|
export async function discardGitChanges(files: string[], cwd?: string): Promise<string[]> {
|
|
if (!files.length) throw new Error("No files specified");
|
|
for (const f of files) {
|
|
if (!isValidGitFilePath(f)) {
|
|
throw new Error(`Invalid file path: ${f}`);
|
|
}
|
|
}
|
|
const statusOutput = (await runGitCommand(["status", "--porcelain=v1"], cwd, 5000)).trim();
|
|
const untracked = new Set<string>();
|
|
for (const line of statusOutput.split("\n")) {
|
|
if (line.startsWith("??")) {
|
|
untracked.add(line.slice(3).trim());
|
|
}
|
|
}
|
|
const trackedFiles = files.filter((f) => !untracked.has(f));
|
|
const untrackedFiles = files.filter((f) => untracked.has(f));
|
|
|
|
if (trackedFiles.length) {
|
|
await runGitCommand(["checkout", "--", ...trackedFiles], cwd, 10000);
|
|
}
|
|
if (untrackedFiles.length) {
|
|
await runGitCommand(["clean", "-f", "--", ...untrackedFiles], cwd, 10000);
|
|
}
|
|
return files;
|
|
}
|
|
|
|
const batchImportWindowMs = 10_000;
|
|
const batchImportInstances: Map<string, number>[] = [];
|
|
let batchImportCleanupInterval: ReturnType<typeof setInterval> | undefined;
|
|
|
|
export function __resetBatchImportRateLimiter(): void {
|
|
for (const clients of batchImportInstances) {
|
|
clients.clear();
|
|
}
|
|
batchImportInstances.length = 0;
|
|
if (batchImportCleanupInterval) {
|
|
clearInterval(batchImportCleanupInterval);
|
|
batchImportCleanupInterval = undefined;
|
|
}
|
|
}
|
|
|
|
export function createBatchImportRateLimiter(): (req: Request, res: Response, next: NextFunction) => void {
|
|
const clients = new Map<string, number>();
|
|
batchImportInstances.push(clients);
|
|
|
|
if (!batchImportCleanupInterval) {
|
|
batchImportCleanupInterval = setInterval(() => {
|
|
const now = Date.now();
|
|
for (const instanceClients of batchImportInstances) {
|
|
for (const [ip, resetTime] of instanceClients) {
|
|
if (now >= resetTime) {
|
|
instanceClients.delete(ip);
|
|
}
|
|
}
|
|
}
|
|
}, batchImportWindowMs);
|
|
batchImportCleanupInterval.unref?.();
|
|
}
|
|
|
|
return (req: Request, res: Response, next: NextFunction): void => {
|
|
const ip = req.ip ?? req.socket.remoteAddress ?? "unknown";
|
|
const now = Date.now();
|
|
|
|
const resetTime = clients.get(ip);
|
|
if (resetTime && now < resetTime) {
|
|
const retryAfter = Math.ceil((resetTime - now) / 1000);
|
|
res.setHeader("Retry-After", String(retryAfter));
|
|
throw rateLimited("Batch import rate limit exceeded. Try again in a few seconds.");
|
|
}
|
|
|
|
clients.set(ip, now + batchImportWindowMs);
|
|
next();
|
|
};
|
|
}
|
|
|
|
function buildGitHubIssueSource(owner: string, repo: string, issue: { number: number; html_url: string }) {
|
|
return {
|
|
sourceIssue: {
|
|
provider: "github" as const,
|
|
repository: `${owner}/${repo}`,
|
|
externalIssueId: String(issue.number),
|
|
issueNumber: issue.number,
|
|
url: issue.html_url,
|
|
},
|
|
sourceMetadata: { issueUrl: issue.html_url, issueNumber: issue.number },
|
|
};
|
|
}
|
|
|
|
export function getDefaultGitHubRepo(store: TaskStore): { owner: string; repo: string } | null {
|
|
const envRepo = process.env.GITHUB_REPOSITORY;
|
|
if (envRepo) {
|
|
const [owner, repo] = envRepo.split("/");
|
|
if (owner && repo) {
|
|
return { owner, repo };
|
|
}
|
|
}
|
|
|
|
const rootDir = typeof store.getRootDir === "function" ? store.getRootDir() : process.cwd();
|
|
return getCurrentRepo(rootDir);
|
|
}
|
|
|
|
export function isBatchStatusStale(info: { lastCheckedAt?: string } | undefined, updatedAt?: string): boolean {
|
|
const lastChecked = info?.lastCheckedAt ?? updatedAt;
|
|
if (!lastChecked) return true;
|
|
return Date.now() - new Date(lastChecked).getTime() > 5 * 60 * 1000;
|
|
}
|
|
|
|
export function ensureBatchStatusEntry(results: BatchStatusResult, taskId: string): BatchStatusEntry {
|
|
results[taskId] ??= { stale: true };
|
|
return results[taskId];
|
|
}
|
|
|
|
export function appendBatchStatusError(results: BatchStatusResult, taskId: string, message: string): void {
|
|
const entry = ensureBatchStatusEntry(results, taskId);
|
|
entry.error = entry.error ? `${entry.error}; ${message}` : message;
|
|
entry.stale = true;
|
|
}
|
|
|
|
async function syncPrReviewsToTask(store: TaskStore, task: Task, snapshot: PrReviewSnapshot): Promise<void> {
|
|
for (const item of snapshot.items) {
|
|
const isReviewItem = item.id.startsWith("gh-review-");
|
|
const source = isReviewItem ? "github-review" : "github-review-comment";
|
|
const externalId = String(item.githubCommentId ?? item.id);
|
|
const reviewState = item.state === "APPROVED" || item.state === "CHANGES_REQUESTED" || item.state === "COMMENTED"
|
|
? item.state
|
|
: undefined;
|
|
const header = isReviewItem
|
|
? `**Review by @${item.author.login} — ${item.state ?? "COMMENTED"}**`
|
|
: `**Inline comment by @${item.author.login}**`;
|
|
const body = `${header}\n\n${item.body}`;
|
|
|
|
await store.addComment(task.id, body, `github:${item.author.login}`, {
|
|
skipRefinement: true,
|
|
source,
|
|
externalId,
|
|
reviewState,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function applyChangesRequestedTransition(
|
|
store: TaskStore,
|
|
task: Task,
|
|
snapshot: PrReviewSnapshot,
|
|
prInfo: PrInfo,
|
|
): Promise<void> {
|
|
if (snapshot.decision !== "CHANGES_REQUESTED") return;
|
|
if (task.column !== "in-review") return;
|
|
if (task.prInfo?.lastReviewDecision === "CHANGES_REQUESTED") return;
|
|
|
|
const reviewItems = snapshot.items.filter((item) => item.id.startsWith("gh-review-") && item.state === "CHANGES_REQUESTED");
|
|
const commentItems = snapshot.items.filter((item) => item.id.startsWith("gh-comment-")).slice(-5);
|
|
const latestReview = reviewItems.at(-1);
|
|
const feedbackBody = [
|
|
`Reviewer requested changes for PR #${prInfo.number}.`,
|
|
latestReview ? `\nLatest review by @${latestReview.author.login}:\n${latestReview.body}` : "",
|
|
commentItems.length > 0
|
|
? `\nRecent inline comments:\n${commentItems.map((item) => `- @${item.author.login}: ${item.body}`).join("\n")}`
|
|
: "",
|
|
].join("\n").trim();
|
|
|
|
await store.upsertTaskDocument(task.id, {
|
|
key: "review-feedback",
|
|
content: feedbackBody || "Reviewer requested changes.",
|
|
author: "system",
|
|
});
|
|
await store.moveTask(task.id, "todo", {
|
|
preserveProgress: true,
|
|
preserveWorktree: true,
|
|
moveSource: "engine",
|
|
});
|
|
|
|
if ("recordRunAuditEvent" in store && typeof store.recordRunAuditEvent === "function") {
|
|
const auditInput: RunAuditEventInput = {
|
|
taskId: task.id,
|
|
agentId: "dashboard-api",
|
|
runId: `dashboard-pr-refresh-${task.id}`,
|
|
domain: "database",
|
|
mutationType: "pr:changes-requested-auto-move",
|
|
target: task.id,
|
|
metadata: {
|
|
reviewDecision: snapshot.decision,
|
|
reviewCount: reviewItems.length,
|
|
commentCount: commentItems.length,
|
|
},
|
|
};
|
|
store.recordRunAuditEvent(auditInput);
|
|
}
|
|
}
|
|
|
|
export function resolvePrMergeMethod(
|
|
settings: Pick<import("@fusion/core").Settings, "directMergeCommitStrategy"> | null | undefined,
|
|
prInfo: Pick<PrInfo, "autoMergeStrategy"> | null | undefined,
|
|
explicit?: "merge" | "squash" | "rebase",
|
|
): "merge" | "squash" | "rebase" {
|
|
if (explicit) return explicit;
|
|
if (prInfo?.autoMergeStrategy) return prInfo.autoMergeStrategy;
|
|
switch (settings?.directMergeCommitStrategy) {
|
|
case "always-rebase":
|
|
return "rebase";
|
|
case "always-squash":
|
|
return "squash";
|
|
case "auto":
|
|
default:
|
|
return "squash";
|
|
}
|
|
}
|
|
|
|
async function mergeTaskPr(
|
|
scopedStore: TaskStore,
|
|
task: Task,
|
|
token: string | undefined,
|
|
explicitMethod?: "merge" | "squash" | "rebase",
|
|
runIdPrefix = "pr-merge",
|
|
): Promise<PrInfo> {
|
|
if (!task.prInfo?.number) {
|
|
throw badRequest("Task has no associated PR number");
|
|
}
|
|
if (task.prInfo.status !== "open") {
|
|
throw badRequest(`PR is ${task.prInfo.status}`);
|
|
}
|
|
|
|
const badgeParsed = parseBadgeUrl(task.prInfo.url);
|
|
const repo = badgeParsed ?? getCurrentRepo(scopedStore.getRootDir());
|
|
if (!repo) {
|
|
throw badRequest("Could not determine GitHub repository");
|
|
}
|
|
|
|
const settings = await scopedStore.getSettings();
|
|
const method = resolvePrMergeMethod(settings, task.prInfo, explicitMethod);
|
|
const client = new GitHubClient(token);
|
|
|
|
try {
|
|
const mergedPrInfo = await client.mergePr({ owner: repo.owner, repo: repo.repo, number: task.prInfo.number, method });
|
|
const updated = {
|
|
...task.prInfo,
|
|
...mergedPrInfo,
|
|
autoMergeOnGreen: task.prInfo.autoMergeOnGreen,
|
|
autoMergeStrategy: task.prInfo.autoMergeStrategy,
|
|
lastMergeError: undefined,
|
|
lastMergeErrorAt: undefined,
|
|
draft: mergedPrInfo.draft ?? mergedPrInfo.isDraft,
|
|
} satisfies PrInfo;
|
|
await scopedStore.updatePrInfo(task.id, updated);
|
|
await scopedStore.applyPrMergedTransition(task.id, {
|
|
agentId: "dashboard",
|
|
runId: `${runIdPrefix}-${task.id}-${Date.now()}`,
|
|
});
|
|
return updated;
|
|
} catch (error) {
|
|
const message = getCommandErrorMessage(error) || "Failed to merge pull request";
|
|
await scopedStore.updatePrInfo(task.id, {
|
|
...task.prInfo,
|
|
lastMergeError: message,
|
|
lastMergeErrorAt: new Date().toISOString(),
|
|
});
|
|
const err = new ApiError(502, "Failed to merge pull request", {
|
|
code: "pr_merge_failed",
|
|
retryable: true,
|
|
error: message,
|
|
});
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
function getTaskPrList(task: Pick<Task, "prInfo" | "prInfos">): PrInfo[] {
|
|
return task.prInfos ?? (task.prInfo ? [task.prInfo] : []);
|
|
}
|
|
|
|
export async function refreshPrInBackground(
|
|
store: TaskStore,
|
|
taskId: string,
|
|
currentPrInfos: PrInfo[],
|
|
token?: string,
|
|
options?: {
|
|
onConflictDetected?: (taskId: string) => Promise<void>;
|
|
repoRoot?: string;
|
|
directMergeCommitStrategy?: DirectMergeCommitStrategy;
|
|
},
|
|
): Promise<void> {
|
|
try {
|
|
const initialPrInfo = currentPrInfos[0];
|
|
if (!initialPrInfo) return;
|
|
let owner: string;
|
|
let repo: string;
|
|
|
|
const badgeParsed = parseBadgeUrl(initialPrInfo.url);
|
|
if (badgeParsed) {
|
|
owner = badgeParsed.owner;
|
|
repo = badgeParsed.repo;
|
|
} else {
|
|
const envRepo = process.env.GITHUB_REPOSITORY;
|
|
if (envRepo) {
|
|
const [o, r] = envRepo.split("/");
|
|
owner = o;
|
|
repo = r;
|
|
} else {
|
|
const gitRepo = getCurrentRepo(store.getRootDir());
|
|
if (!gitRepo) return;
|
|
owner = gitRepo.owner;
|
|
repo = gitRepo.repo;
|
|
}
|
|
}
|
|
|
|
const repoKey = `${owner}/${repo}`;
|
|
if (!githubRateLimiter.canMakeRequest(repoKey)) {
|
|
return;
|
|
}
|
|
|
|
const client = new GitHubClient(token);
|
|
const task = await store.getTask(taskId);
|
|
const taskPrs = task ? getTaskPrList(task) : currentPrInfos;
|
|
|
|
for (const currentPrInfo of taskPrs) {
|
|
const reviewSnapshot = await client.getPrReviewSnapshot(owner, repo, currentPrInfo.number);
|
|
const mergeStatus = await client.getPrMergeStatus(owner, repo, currentPrInfo.number);
|
|
const prior = getTaskPrList(task).find((entry) => entry.number === currentPrInfo.number) ?? currentPrInfo;
|
|
let conflictDiagnostics = mergeStatus.prInfo.conflictDiagnostics;
|
|
if (mergeStatus.prInfo.mergeable === "conflicting" && mergeStatus.prInfo.headBranch && mergeStatus.prInfo.baseBranch) {
|
|
try {
|
|
conflictDiagnostics = await client.getPrConflictDiagnostics(owner, repo, currentPrInfo.number, {
|
|
baseBranch: mergeStatus.prInfo.baseBranch,
|
|
headBranch: mergeStatus.prInfo.headBranch,
|
|
repoRoot: options?.repoRoot,
|
|
directMergeCommitStrategy: options?.directMergeCommitStrategy,
|
|
});
|
|
} catch (err) {
|
|
console.error("[pr-conflict-diagnostics]", err);
|
|
}
|
|
} else {
|
|
conflictDiagnostics = undefined;
|
|
}
|
|
|
|
const prInfo = {
|
|
...prior,
|
|
...mergeStatus.prInfo,
|
|
mergeable: mergeStatus.prInfo.mergeable,
|
|
conflictDiagnostics,
|
|
autoMergeOnGreen: prior?.autoMergeOnGreen,
|
|
autoMergeStrategy: prior?.autoMergeStrategy,
|
|
lastMergeError: prior?.lastMergeError,
|
|
lastMergeErrorAt: prior?.lastMergeErrorAt,
|
|
draft: mergeStatus.prInfo.draft ?? mergeStatus.prInfo.isDraft,
|
|
lastCheckedAt: new Date().toISOString(),
|
|
lastReviewDecision: reviewSnapshot.decision,
|
|
} satisfies PrInfo;
|
|
|
|
await store.updatePrInfoByNumber(taskId, currentPrInfo.number, prInfo);
|
|
await syncPrReviewsToTask(store, task, reviewSnapshot);
|
|
await applyChangesRequestedTransition(store, task, reviewSnapshot, prInfo);
|
|
|
|
if (prInfo.mergeable === "conflicting" && task?.branch && task?.worktree && options?.onConflictDetected) {
|
|
await options.onConflictDetected(taskId);
|
|
}
|
|
|
|
if (prInfo.status === "merged") {
|
|
await store.applyPrMergedTransition(taskId, {
|
|
agentId: "dashboard",
|
|
runId: `pr-refresh-${taskId}-${Date.now()}`,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const lastMergeErrorAt = prior?.lastMergeErrorAt ? Date.parse(prior.lastMergeErrorAt) : Number.NaN;
|
|
const recentlyFailed = Number.isFinite(lastMergeErrorAt) && Date.now() - lastMergeErrorAt < 5 * 60 * 1000;
|
|
if (prior?.autoMergeOnGreen && mergeStatus.mergeReady && !recentlyFailed) {
|
|
await mergeTaskPr(store, task, token, undefined, "pr-refresh");
|
|
}
|
|
}
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
}
|
|
|
|
export async function refreshIssueInBackground(
|
|
store: TaskStore,
|
|
taskId: string,
|
|
currentIssueInfo: IssueInfo,
|
|
token?: string,
|
|
): Promise<void> {
|
|
try {
|
|
let owner: string;
|
|
let repo: string;
|
|
|
|
const badgeParsed = parseBadgeUrl(currentIssueInfo.url);
|
|
if (badgeParsed) {
|
|
owner = badgeParsed.owner;
|
|
repo = badgeParsed.repo;
|
|
} else {
|
|
const envRepo = process.env.GITHUB_REPOSITORY;
|
|
if (envRepo) {
|
|
const [o, r] = envRepo.split("/");
|
|
owner = o;
|
|
repo = r;
|
|
} else {
|
|
const gitRepo = getCurrentRepo(store.getRootDir());
|
|
if (!gitRepo) return;
|
|
owner = gitRepo.owner;
|
|
repo = gitRepo.repo;
|
|
}
|
|
}
|
|
|
|
const repoKey = `${owner}/${repo}`;
|
|
if (!githubRateLimiter.canMakeRequest(repoKey)) {
|
|
return;
|
|
}
|
|
|
|
const client = new GitHubClient(token);
|
|
const issueInfo = await client.getIssueStatus(owner, repo, currentIssueInfo.number);
|
|
if (!issueInfo) {
|
|
return;
|
|
}
|
|
|
|
await store.updateIssueInfo(taskId, {
|
|
...issueInfo,
|
|
lastCheckedAt: new Date().toISOString(),
|
|
});
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
}
|
|
|
|
export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
|
const { router, getProjectContext, rethrowAsApiError, store } = ctx;
|
|
const githubToken = ctx.options?.githubToken ?? process.env.GITHUB_TOKEN;
|
|
if (typeof (store as Partial<{ on: unknown; off: unknown }>).on === "function" &&
|
|
typeof (store as Partial<{ off: unknown }>).off === "function") {
|
|
const githubIssueCommentService = new GitHubIssueCommentService(
|
|
store,
|
|
() => ctx.options?.githubToken ?? process.env.GITHUB_TOKEN,
|
|
);
|
|
githubIssueCommentService.start();
|
|
ctx.registerDispose(() => githubIssueCommentService.stop());
|
|
|
|
const githubTrackingCommentService = new GitHubTrackingCommentService(store);
|
|
githubTrackingCommentService.start();
|
|
ctx.registerDispose(() => githubTrackingCommentService.stop());
|
|
|
|
const githubSourceIssueCloseService = new GitHubSourceIssueCloseService(store);
|
|
githubSourceIssueCloseService.start();
|
|
ctx.registerDispose(() => githubSourceIssueCloseService.stop());
|
|
|
|
const githubTrackingStateService = new GitHubTrackingStateService(store);
|
|
const githubTrackingReconciler = new GitHubTrackingReconciler();
|
|
const reconcileScheduledStores = new WeakSet<TaskStore>();
|
|
const reconcileSweepOffsetByStore = new WeakMap<TaskStore, number>();
|
|
const reconcileSweepInFlightByStore = new WeakMap<TaskStore, boolean>();
|
|
githubTrackingStateService.start();
|
|
|
|
const runReconcileSweep = async (projectStore: TaskStore, options?: { startup?: boolean }) => {
|
|
if (typeof (projectStore as Partial<TaskStore>).listTasks !== "function"
|
|
|| typeof (projectStore as Partial<TaskStore>).listTasksForGithubTrackingReconcile !== "function"
|
|
|| typeof (projectStore as Partial<TaskStore>).getSettings !== "function"
|
|
|| typeof (projectStore as Partial<TaskStore>).logEntry !== "function") {
|
|
return;
|
|
}
|
|
|
|
if (reconcileSweepInFlightByStore.get(projectStore) === true) {
|
|
return;
|
|
}
|
|
reconcileSweepInFlightByStore.set(projectStore, true);
|
|
|
|
try {
|
|
const offset = options?.startup ? 0 : reconcileSweepOffsetByStore.get(projectStore) ?? 0;
|
|
const deletedArchivedResult = await githubTrackingReconciler.reconcileDeletedAndArchived(projectStore, {
|
|
offset,
|
|
limit: RECONCILE_SCAN_LIMIT,
|
|
});
|
|
|
|
if (deletedArchivedResult.hasMore) {
|
|
reconcileSweepOffsetByStore.set(projectStore, offset + RECONCILE_SCAN_LIMIT);
|
|
} else {
|
|
reconcileSweepOffsetByStore.set(projectStore, 0);
|
|
}
|
|
|
|
await githubTrackingReconciler.reconcile(projectStore);
|
|
await githubTrackingReconciler.reconcileSourceIssues(projectStore);
|
|
} catch {
|
|
// best-effort sweep
|
|
} finally {
|
|
reconcileSweepInFlightByStore.set(projectStore, false);
|
|
}
|
|
};
|
|
|
|
const attachedStateStores = new Set<TaskStore>();
|
|
const attachStateStore = (projectStore: TaskStore) => {
|
|
if (attachedStateStores.has(projectStore)) {
|
|
return;
|
|
}
|
|
attachedStateStores.add(projectStore);
|
|
githubTrackingStateService.attach(projectStore);
|
|
githubSourceIssueCloseService.attach(projectStore);
|
|
|
|
if (!reconcileScheduledStores.has(projectStore)) {
|
|
reconcileScheduledStores.add(projectStore);
|
|
setImmediate(() => {
|
|
void runReconcileSweep(projectStore, { startup: true });
|
|
});
|
|
}
|
|
};
|
|
|
|
attachStateStore(store);
|
|
|
|
const listProjectStores = (): Array<{ store: TaskStore }> => {
|
|
try {
|
|
const value = Reflect.get(projectStoreResolver as object, "listRegisteredProjectStores");
|
|
if (typeof value !== "function") {
|
|
return [];
|
|
}
|
|
const listed = value();
|
|
return Array.isArray(listed) ? listed : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
};
|
|
|
|
const subscribeProjectStoreRegistered = (handler: (projectId: string, projectStore: TaskStore) => void): (() => void) => {
|
|
try {
|
|
const value = Reflect.get(projectStoreResolver as object, "onProjectStoreRegistered");
|
|
if (typeof value !== "function") {
|
|
return () => {};
|
|
}
|
|
return value(handler) as () => void;
|
|
} catch {
|
|
return () => {};
|
|
}
|
|
};
|
|
|
|
for (const { store: projectStore } of listProjectStores()) {
|
|
attachStateStore(projectStore);
|
|
}
|
|
|
|
const unsubscribeProjectStoreRegistration = subscribeProjectStoreRegistered((_projectId, projectStore) => {
|
|
attachStateStore(projectStore);
|
|
});
|
|
|
|
const periodicReconcileInterval = setInterval(() => {
|
|
for (const projectStore of attachedStateStores) {
|
|
void runReconcileSweep(projectStore);
|
|
}
|
|
}, GITHUB_TRACKING_RECONCILE_INTERVAL_MS);
|
|
|
|
ctx.registerDispose(() => {
|
|
clearInterval(periodicReconcileInterval);
|
|
unsubscribeProjectStoreRegistration();
|
|
for (const projectStore of attachedStateStores) {
|
|
githubTrackingStateService.detach(projectStore);
|
|
githubSourceIssueCloseService.detach(projectStore);
|
|
}
|
|
githubTrackingStateService.stop();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* GET /api/git/remotes
|
|
* Returns GitHub remotes from the current git repository.
|
|
* Response: Array of GitRemote objects [{ name: string, owner: string, repo: string, url: string }]
|
|
*/
|
|
router.get("/git/remotes", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const rootDir = scopedStore.getRootDir();
|
|
const remotes = await getGitHubRemotes(rootDir);
|
|
res.json(remotes);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/git/remotes/detailed
|
|
* Returns all git remotes with their fetch and push URLs.
|
|
* Response: Array of GitRemoteDetailed objects [{ name: string, fetchUrl: string, pushUrl: string }]
|
|
*/
|
|
router.get("/git/remotes/detailed", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const rootDir = scopedStore.getRootDir();
|
|
if (!(await isGitRepo(rootDir))) {
|
|
throw badRequest("Not a git repository");
|
|
}
|
|
const remotes = await listGitRemotes(rootDir);
|
|
res.json(remotes);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/git/remotes
|
|
* Add a new git remote.
|
|
* Body: { name: string, url: string }
|
|
*/
|
|
router.post("/git/remotes", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const rootDir = scopedStore.getRootDir();
|
|
const { name, url } = req.body;
|
|
if (!name || typeof name !== "string") {
|
|
throw badRequest("name is required");
|
|
}
|
|
if (!url || typeof url !== "string") {
|
|
throw badRequest("url is required");
|
|
}
|
|
if (!isValidBranchName(name)) {
|
|
throw badRequest("Invalid remote name");
|
|
}
|
|
if (!isValidGitUrl(url)) {
|
|
throw badRequest("Invalid git URL format");
|
|
}
|
|
if (!(await isGitRepo(rootDir))) {
|
|
throw badRequest("Not a git repository");
|
|
}
|
|
await addGitRemote(name, url, rootDir);
|
|
res.status(201).json({ name, added: true });
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
if ((err instanceof Error ? err.message : String(err)).includes("Invalid remote name")) {
|
|
throw badRequest(err instanceof Error ? err.message : String(err));
|
|
} else if ((err instanceof Error ? err.message : String(err)).includes("Invalid git URL")) {
|
|
throw badRequest(err instanceof Error ? err.message : String(err));
|
|
} else if ((err instanceof Error ? err.message : String(err)).includes("already exists")) {
|
|
throw conflict(err instanceof Error ? err.message : String(err));
|
|
} else {
|
|
rethrowAsApiError(err);
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* DELETE /api/git/remotes/:name
|
|
* Remove a git remote.
|
|
*/
|
|
router.delete("/git/remotes/:name", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const rootDir = scopedStore.getRootDir();
|
|
if (!(await isGitRepo(rootDir))) {
|
|
throw badRequest("Not a git repository");
|
|
}
|
|
const { name } = req.params;
|
|
await removeGitRemote(name, rootDir);
|
|
res.json({ name, removed: true });
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
if ((err instanceof Error ? err.message : String(err)).includes("Invalid remote name")) {
|
|
throw badRequest(err instanceof Error ? err.message : String(err));
|
|
} else if ((err instanceof Error ? err.message : String(err)).includes("does not exist")) {
|
|
throw notFound(err instanceof Error ? err.message : String(err));
|
|
} else {
|
|
rethrowAsApiError(err);
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* PATCH /api/git/remotes/:name
|
|
* Rename a git remote.
|
|
* Body: { newName: string }
|
|
*/
|
|
router.patch("/git/remotes/:name", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const rootDir = scopedStore.getRootDir();
|
|
if (!(await isGitRepo(rootDir))) {
|
|
throw badRequest("Not a git repository");
|
|
}
|
|
const { name } = req.params;
|
|
const { newName } = req.body;
|
|
if (!newName || typeof newName !== "string") {
|
|
throw badRequest("newName is required");
|
|
}
|
|
await renameGitRemote(name, newName, rootDir);
|
|
res.json({ oldName: name, newName, renamed: true });
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
if ((err instanceof Error ? err.message : String(err)).includes("Invalid")) {
|
|
throw badRequest(err instanceof Error ? err.message : String(err));
|
|
} else if ((err instanceof Error ? err.message : String(err)).includes("does not exist")) {
|
|
throw notFound(err instanceof Error ? err.message : String(err));
|
|
} else if ((err instanceof Error ? err.message : String(err)).includes("already exists")) {
|
|
throw conflict(err instanceof Error ? err.message : String(err));
|
|
} else {
|
|
rethrowAsApiError(err);
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* PUT /api/git/remotes/:name/url
|
|
* Update the URL for a git remote.
|
|
* Body: { url: string }
|
|
*/
|
|
router.put("/git/remotes/:name/url", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const rootDir = scopedStore.getRootDir();
|
|
if (!(await isGitRepo(rootDir))) {
|
|
throw badRequest("Not a git repository");
|
|
}
|
|
const { name } = req.params;
|
|
const { url } = req.body;
|
|
if (!url || typeof url !== "string") {
|
|
throw badRequest("url is required");
|
|
}
|
|
await setGitRemoteUrl(name, url, rootDir);
|
|
res.json({ name, url, updated: true });
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
if ((err instanceof Error ? err.message : String(err)).includes("Invalid")) {
|
|
throw badRequest(err instanceof Error ? err.message : String(err));
|
|
} else if ((err instanceof Error ? err.message : String(err)).includes("does not exist")) {
|
|
throw notFound(err instanceof Error ? err.message : String(err));
|
|
} else {
|
|
rethrowAsApiError(err);
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/git/status[?extended=1]
|
|
* Returns current git status: branch, commit hash, dirty state, ahead/behind counts.
|
|
* When `extended=1` is set, also returns integration-branch resolution, ahead/
|
|
* behind vs both local and origin integration tip, dirty breakdown, stash count,
|
|
* index-stale detection (the FN-INDEX-DESYNC scenario the auto-sync hook
|
|
* fixes), and the most-recent merger ref-advance audit events for this
|
|
* worktree (so operators can see what needs to be pulled even if the
|
|
* Merge Advance Notice banner was dismissed).
|
|
*/
|
|
router.get("/git/status", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const rootDir = scopedStore.getRootDir();
|
|
if (!(await isGitRepo(rootDir))) {
|
|
throw badRequest("Not a git repository");
|
|
}
|
|
const status = await getGitStatus(rootDir);
|
|
if (!status) {
|
|
throw internalError("Failed to get git status");
|
|
}
|
|
if (req.query.extended !== "1" && req.query.extended !== "true") {
|
|
res.json(status);
|
|
return;
|
|
}
|
|
// Compute extended status best-effort: if any unhandled git or store
|
|
// failure escapes the helpers (timeout on `branch --show-current`,
|
|
// missing reflog, store layer throws), degrade to the basic shape
|
|
// rather than returning HTTP 500. The basic path swallows the same
|
|
// failures via getGitStatus's broad try/catch — surface parity matters
|
|
// because the dashboard always passes ?extended=1 and would otherwise
|
|
// render an error toast where the legacy path would render a degraded
|
|
// but usable panel.
|
|
try {
|
|
const extended = await computeExtendedGitStatus(rootDir, scopedStore);
|
|
res.json({ ...status, ...extended });
|
|
} catch (extErr: unknown) {
|
|
const message = extErr instanceof Error ? extErr.message : String(extErr);
|
|
console.warn(`[git-status] extended computation failed; returning basic status: ${message}`);
|
|
res.json(status);
|
|
}
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/git/commits
|
|
* Returns recent commits (default 20, configurable via ?limit=).
|
|
* Response: Array of GitCommit objects
|
|
*/
|
|
router.get("/git/commits", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const rootDir = scopedStore.getRootDir();
|
|
if (!(await isGitRepo(rootDir))) {
|
|
throw badRequest("Not a git repository");
|
|
}
|
|
const limit = Math.min(parseInt(req.query.limit as string, 10) || 20, 100);
|
|
const commits = await getGitCommits(limit, rootDir);
|
|
res.json(commits);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/git/commits/:hash/diff
|
|
* Returns diff for a specific commit (stat + patch).
|
|
* Response: { stat: string, patch: string }
|
|
*/
|
|
router.get("/git/commits/:hash/diff", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const rootDir = scopedStore.getRootDir();
|
|
if (!(await isGitRepo(rootDir))) {
|
|
throw badRequest("Not a git repository");
|
|
}
|
|
const { hash } = req.params;
|
|
// Validate hash format (only hex characters, 7-40 chars)
|
|
if (!/^[a-f0-9]{7,40}$/i.test(hash)) {
|
|
throw badRequest("Invalid commit hash format");
|
|
}
|
|
const diff = await getCommitDiff(hash, rootDir);
|
|
if (!diff) {
|
|
throw notFound("Commit not found");
|
|
}
|
|
res.json(diff);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/git/commits/ahead
|
|
* Returns local commits ahead of the upstream tracking branch (commits that would be pushed).
|
|
* Response: Array of GitCommit objects (empty when no upstream is configured)
|
|
*/
|
|
router.get("/git/commits/ahead", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const rootDir = scopedStore.getRootDir();
|
|
if (!(await isGitRepo(rootDir))) {
|
|
throw badRequest("Not a git repository");
|
|
}
|
|
const commits = await getAheadCommits(rootDir);
|
|
res.json(commits);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/git/remotes/:name/commits
|
|
* Returns recent commits for a specific remote tracking ref.
|
|
* Query: ?ref=branchName (defaults to HEAD of the remote's default branch)
|
|
* Query: ?limit=N (defaults to 10, max 50)
|
|
* Response: Array of GitCommit objects
|
|
*/
|
|
router.get("/git/remotes/:name/commits", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const rootDir = scopedStore.getRootDir();
|
|
if (!(await isGitRepo(rootDir))) {
|
|
throw badRequest("Not a git repository");
|
|
}
|
|
|
|
const { name } = req.params;
|
|
if (!isValidBranchName(name)) {
|
|
throw badRequest("Invalid remote name");
|
|
}
|
|
|
|
const ref = req.query.ref as string | undefined;
|
|
const limit = Math.min(parseInt(req.query.limit as string, 10) || 10, 50);
|
|
|
|
// Build the full remote ref: if ref is given, use "remote/ref", otherwise use "remote/HEAD"
|
|
let remoteRef: string;
|
|
if (ref) {
|
|
if (!isValidGitRef(ref)) {
|
|
throw badRequest("Invalid ref name");
|
|
}
|
|
// Strip any leading "refs/" or remote prefix the user might accidentally include
|
|
const cleanRef = ref.replace(/^refs\/(heads\/)?/, "");
|
|
// If the ref already starts with the remote name, use it as-is
|
|
if (cleanRef.startsWith(`${name}/`)) {
|
|
remoteRef = cleanRef;
|
|
} else {
|
|
remoteRef = `${name}/${cleanRef}`;
|
|
}
|
|
} else {
|
|
// Default: try remote/HEAD symbolic ref, fall back to remote/main, remote/master
|
|
try {
|
|
const headRef = (await runGitCommand(["symbolic-ref", `refs/remotes/${name}/HEAD`], rootDir, 5000)).trim();
|
|
// symbolic-ref returns full ref like refs/remotes/origin/main
|
|
remoteRef = headRef.replace(/^refs\/remotes\//, "");
|
|
} catch {
|
|
// Try common defaults
|
|
try {
|
|
await runGitCommand(["rev-parse", "--verify", `${name}/main`], rootDir, 5000);
|
|
remoteRef = `${name}/main`;
|
|
} catch {
|
|
try {
|
|
await runGitCommand(["rev-parse", "--verify", `${name}/master`], rootDir, 5000);
|
|
remoteRef = `${name}/master`;
|
|
} catch {
|
|
// Remote exists but no common branch found
|
|
res.json([]);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const commits = await getRemoteCommits(remoteRef, limit, rootDir);
|
|
res.json(commits);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/git/branches
|
|
* Returns all local branches with current indicator, remote tracking info, and last commit date.
|
|
* Response: Array of GitBranch objects
|
|
*/
|
|
router.get("/git/branches", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const rootDir = scopedStore.getRootDir();
|
|
if (!(await isGitRepo(rootDir))) {
|
|
throw badRequest("Not a git repository");
|
|
}
|
|
const branches = await getGitBranches(rootDir);
|
|
res.json(branches);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/git/branches/:name/commits
|
|
* Returns recent commits for a specific branch.
|
|
* Query params: limit (default 10, max 100)
|
|
* Response: Array of GitCommit objects
|
|
*/
|
|
router.get("/git/branches/:name/commits", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const rootDir = scopedStore.getRootDir();
|
|
if (!(await isGitRepo(rootDir))) {
|
|
throw badRequest("Not a git repository");
|
|
}
|
|
const { name } = req.params;
|
|
if (!isValidGitRef(name)) {
|
|
throw badRequest("Invalid branch name");
|
|
}
|
|
const limit = Math.min(Math.max(parseInt(String(req.query.limit)) || 10, 1), 100);
|
|
const commits = await getGitCommitsForBranch(name, limit, rootDir);
|
|
res.json(commits);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/git/worktrees
|
|
* Returns all worktrees with path, branch, isMain, and associated task ID.
|
|
* Response: Array of GitWorktree objects
|
|
*/
|
|
router.get("/git/worktrees", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const rootDir = scopedStore.getRootDir();
|
|
if (!(await isGitRepo(rootDir))) {
|
|
throw badRequest("Not a git repository");
|
|
}
|
|
// Get tasks to correlate with worktrees
|
|
const tasks = await scopedStore.listTasks({ slim: true, includeArchived: false });
|
|
const worktrees = await getGitWorktrees(tasks, rootDir);
|
|
res.json(worktrees);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
// ── Git Action Routes ─────────────────────────────────────────────
|
|
|
|
/**
|
|
* POST /api/git/branches
|
|
* Create a new branch from current HEAD or specified base.
|
|
* Body: { name: string, base?: string }
|
|
*/
|
|
router.post("/git/branches", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const rootDir = scopedStore.getRootDir();
|
|
if (!(await isGitRepo(rootDir))) {
|
|
throw badRequest("Not a git repository");
|
|
}
|
|
const { name, base } = req.body;
|
|
if (!name || typeof name !== "string") {
|
|
throw badRequest("name is required");
|
|
}
|
|
const branchName = await createGitBranch(name, base, rootDir);
|
|
res.status(201).json({ name: branchName, created: true });
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
if ((err instanceof Error ? err.message : String(err)).includes("Invalid branch name")) {
|
|
throw badRequest(err instanceof Error ? err.message : String(err));
|
|
} else if ((err instanceof Error ? err.message : String(err)).includes("already exists")) {
|
|
throw conflict(err instanceof Error ? err.message : String(err));
|
|
} else {
|
|
rethrowAsApiError(err);
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/git/branches/:name/checkout
|
|
* Checkout an existing branch.
|
|
*/
|
|
router.post("/git/branches/:name/checkout", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const rootDir = scopedStore.getRootDir();
|
|
if (!(await isGitRepo(rootDir))) {
|
|
throw badRequest("Not a git repository");
|
|
}
|
|
const { name } = req.params;
|
|
await checkoutGitBranch(name, rootDir);
|
|
res.json({ checkedOut: name });
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
if ((err instanceof Error ? err.message : String(err)).includes("Invalid branch name")) {
|
|
throw badRequest(err instanceof Error ? err.message : String(err));
|
|
} else if ((err instanceof Error ? err.message : String(err)).includes("Uncommitted changes")) {
|
|
throw conflict(err instanceof Error ? err.message : String(err));
|
|
} else {
|
|
rethrowAsApiError(err);
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* DELETE /api/git/branches/:name
|
|
* Delete a branch.
|
|
* Query: ?force=true to force delete (even with unmerged commits)
|
|
*/
|
|
router.delete("/git/branches/:name", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const rootDir = scopedStore.getRootDir();
|
|
if (!(await isGitRepo(rootDir))) {
|
|
throw badRequest("Not a git repository");
|
|
}
|
|
const { name } = req.params;
|
|
const force = req.query.force === "true";
|
|
await deleteGitBranch(name, force, rootDir);
|
|
res.json({ deleted: name });
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
if ((err instanceof Error ? err.message : String(err)).includes("Invalid branch name")) {
|
|
throw badRequest(err instanceof Error ? err.message : String(err));
|
|
} else if ((err instanceof Error ? err.message : String(err)).includes("Cannot delete branch") || (err instanceof Error ? err.message : String(err)).includes("is currently checked out")) {
|
|
throw conflict(err instanceof Error ? err.message : String(err));
|
|
} else if ((err instanceof Error ? err.message : String(err)).includes("not fully merged")) {
|
|
throw conflict("Branch has unmerged commits. Use force=true to delete anyway.");
|
|
} else {
|
|
rethrowAsApiError(err);
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/git/fetch
|
|
* Fetch from origin or specified remote.
|
|
* Body: { remote?: string }
|
|
*/
|
|
router.post("/git/fetch", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const rootDir = scopedStore.getRootDir();
|
|
if (!(await isGitRepo(rootDir))) {
|
|
throw badRequest("Not a git repository");
|
|
}
|
|
const { remote } = req.body;
|
|
const result = await fetchGitRemote(remote || "origin", rootDir);
|
|
res.json(result);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
if ((err instanceof Error ? err.message : String(err)).includes("Invalid remote name")) {
|
|
throw badRequest(err instanceof Error ? err.message : String(err));
|
|
} else if ((err instanceof Error ? err.message : String(err)).includes("Failed to connect")) {
|
|
throw new ApiError(503, err instanceof Error ? err.message : String(err));
|
|
} else {
|
|
rethrowAsApiError(err);
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/git/pull
|
|
* Pull current branch, or integration worktree when provided.
|
|
*/
|
|
router.post("/git/pull", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const rootDir = scopedStore.getRootDir();
|
|
if (!(await isGitRepo(rootDir))) {
|
|
throw badRequest("Not a git repository");
|
|
}
|
|
const requestCache = new Map<string, string[]>();
|
|
const { rebase, worktreePath, integrationBranch, taskId, skipOriginFetch } = req.body ?? {};
|
|
if (rebase !== undefined && typeof rebase !== "boolean") {
|
|
throw badRequest("rebase must be a boolean");
|
|
}
|
|
if (taskId !== undefined && typeof taskId !== "string") {
|
|
throw badRequest("taskId must be a string");
|
|
}
|
|
if (skipOriginFetch !== undefined && typeof skipOriginFetch !== "boolean") {
|
|
throw badRequest("skipOriginFetch must be a boolean");
|
|
}
|
|
|
|
if (worktreePath !== undefined) {
|
|
if (rebase === true) {
|
|
throw badRequest("rebase not supported with worktreePath");
|
|
}
|
|
const safeWorktreePath = await assertWorktreePathSafe(scopedStore, worktreePath, requestCache);
|
|
if (typeof integrationBranch !== "string" || integrationBranch.trim().length === 0) {
|
|
throw badRequest("integrationBranch required when worktreePath set");
|
|
}
|
|
const settings = await scopedStore.getSettings();
|
|
const integrationRemote = await resolveIntegrationRemote({
|
|
settings,
|
|
rootDir: safeWorktreePath,
|
|
integrationBranch,
|
|
}).catch(() => "origin");
|
|
const runId = generateSyntheticRunId("dashboard-pull", taskId ?? "dashboard-pull");
|
|
const result = await pullGitBranch(safeWorktreePath, {
|
|
rebase: false,
|
|
integration: {
|
|
worktreePath: safeWorktreePath,
|
|
integrationBranch,
|
|
taskId,
|
|
integrationRemote,
|
|
store: scopedStore,
|
|
settings,
|
|
runId,
|
|
skipOriginFetch: skipOriginFetch === true,
|
|
},
|
|
});
|
|
res.json(result);
|
|
return;
|
|
}
|
|
|
|
const result = await pullGitBranch(rootDir, { rebase: rebase === true });
|
|
if ("conflict" in result && result.conflict) {
|
|
throw new ApiError(409, result.message ?? "Merge conflict detected. Resolve manually.", {
|
|
...result,
|
|
});
|
|
}
|
|
res.json(result);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
router.post("/git/stash-resolve", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const { worktreePath, file, choice } = req.body ?? {};
|
|
if (choice !== "ours" && choice !== "theirs") {
|
|
throw badRequest("choice must be ours or theirs");
|
|
}
|
|
|
|
const requestCache = new Map<string, string[]>();
|
|
const safeWorktreePath = await assertWorktreePathSafe(scopedStore, worktreePath, requestCache);
|
|
const safeFile = assertRelativeFileSafe(safeWorktreePath, file);
|
|
const conflictedFiles = await getConflictedFiles(safeWorktreePath);
|
|
if (!conflictedFiles.includes(safeFile)) {
|
|
throw badRequest("file is not conflicted");
|
|
}
|
|
|
|
await runGitCommand(["checkout", choice === "ours" ? "--ours" : "--theirs", "--", safeFile], safeWorktreePath, 10_000);
|
|
await runGitCommand(["add", "--", safeFile], safeWorktreePath, 10_000);
|
|
const remainingConflicts = await getConflictedFiles(safeWorktreePath);
|
|
res.json({ resolvedFile: safeFile, choice, remainingConflicts });
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
router.post("/git/stash-drop", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const { worktreePath, stashSha, taskId } = req.body ?? {};
|
|
if (typeof stashSha !== "string" || stashSha.trim().length === 0) {
|
|
throw badRequest("stashSha is required");
|
|
}
|
|
if (taskId !== undefined && typeof taskId !== "string") {
|
|
throw badRequest("taskId must be a string");
|
|
}
|
|
|
|
const requestCache = new Map<string, string[]>();
|
|
const safeWorktreePath = await assertWorktreePathSafe(scopedStore, worktreePath, requestCache);
|
|
const remainingConflicts = await getConflictedFiles(safeWorktreePath);
|
|
if (remainingConflicts.length > 0) {
|
|
throw new ApiError(409, "Resolve conflicts before dropping stash", { remainingConflicts });
|
|
}
|
|
|
|
const ref = await findStashRefBySha(stashSha, safeWorktreePath);
|
|
if (!ref) {
|
|
res.json({ dropped: false });
|
|
return;
|
|
}
|
|
|
|
await runGitCommand(["stash", "drop", ref], safeWorktreePath, 10_000);
|
|
Promise.resolve(scopedStore.recordRunAuditEvent?.(buildDashboardGitAuditEvent({
|
|
taskId,
|
|
mutationType: "stash:pop",
|
|
target: safeWorktreePath,
|
|
metadata: {
|
|
taskId,
|
|
worktreePath: safeWorktreePath,
|
|
stashSha,
|
|
manualResolution: true,
|
|
},
|
|
}))).catch(() => undefined);
|
|
|
|
res.json({ dropped: true });
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
router.post("/git/stash-apply", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const { worktreePath, stashSha, taskId } = req.body ?? {};
|
|
if (typeof stashSha !== "string" || stashSha.trim().length === 0) {
|
|
throw badRequest("stashSha is required");
|
|
}
|
|
if (taskId !== undefined && typeof taskId !== "string") {
|
|
throw badRequest("taskId must be a string");
|
|
}
|
|
|
|
const requestCache = new Map<string, string[]>();
|
|
const safeWorktreePath = await assertWorktreePathSafe(scopedStore, worktreePath, requestCache);
|
|
const ref = await findStashRefBySha(stashSha, safeWorktreePath);
|
|
if (!ref) {
|
|
res.json({ applied: false, conflict: false, conflictedFiles: [] });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await runGitCommand(["stash", "apply", ref], safeWorktreePath, 20_000);
|
|
res.json({ applied: true, conflict: false, conflictedFiles: [] });
|
|
} catch (err: unknown) {
|
|
const message = getCommandErrorMessage(err);
|
|
const conflictedFiles = await getConflictedFiles(safeWorktreePath);
|
|
if (isGitConflictMessage(message)) {
|
|
Promise.resolve(scopedStore.recordRunAuditEvent?.(buildDashboardGitAuditEvent({
|
|
taskId,
|
|
mutationType: "stash:pop-conflict",
|
|
target: safeWorktreePath,
|
|
metadata: {
|
|
taskId,
|
|
worktreePath: safeWorktreePath,
|
|
stashSha,
|
|
stashLabel: ref,
|
|
conflictedFiles,
|
|
autostashOutcome: "conflict-needs-manual",
|
|
},
|
|
}))).catch(() => undefined);
|
|
res.json({ applied: true, conflict: true, conflictedFiles });
|
|
return;
|
|
}
|
|
throw err;
|
|
}
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/git/push
|
|
* Push the current branch.
|
|
*/
|
|
router.post("/git/push", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const rootDir = scopedStore.getRootDir();
|
|
if (!(await isGitRepo(rootDir))) {
|
|
throw badRequest("Not a git repository");
|
|
}
|
|
const result = await pushGitBranch(rootDir);
|
|
res.json(result);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
if ((err instanceof Error ? err.message : String(err)).includes("rejected") || (err instanceof Error ? err.message : String(err)).includes("Pull latest")) {
|
|
throw conflict(err instanceof Error ? err.message : String(err));
|
|
} else if ((err instanceof Error ? err.message : String(err)).includes("Failed to connect")) {
|
|
throw new ApiError(503, err instanceof Error ? err.message : String(err));
|
|
} else {
|
|
rethrowAsApiError(err);
|
|
}
|
|
}
|
|
});
|
|
|
|
// ── Git Stash, Stage, Commit Routes ────────────────────────────────
|
|
|
|
/**
|
|
* GET /api/git/stashes
|
|
* Returns list of stash entries.
|
|
*/
|
|
router.get("/git/stashes", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const rootDir = scopedStore.getRootDir();
|
|
if (!(await isGitRepo(rootDir))) {
|
|
throw badRequest("Not a git repository");
|
|
}
|
|
const stashes = await getGitStashList(rootDir);
|
|
res.json(stashes);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/git/stashes
|
|
* Create a new stash.
|
|
* Body: { message?: string }
|
|
*/
|
|
router.post("/git/stashes", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const rootDir = scopedStore.getRootDir();
|
|
if (!(await isGitRepo(rootDir))) {
|
|
throw badRequest("Not a git repository");
|
|
}
|
|
const { message } = req.body;
|
|
const result = await createGitStash(message, rootDir);
|
|
res.status(201).json({ message: result });
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
if ((err instanceof Error ? err.message : String(err)).includes("No local changes")) {
|
|
throw badRequest(err instanceof Error ? err.message : String(err));
|
|
} else {
|
|
rethrowAsApiError(err);
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/git/stashes/:index/apply
|
|
* Apply a stash entry.
|
|
* Body: { drop?: boolean }
|
|
*/
|
|
router.post("/git/stashes/:index/apply", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const rootDir = scopedStore.getRootDir();
|
|
if (!(await isGitRepo(rootDir))) {
|
|
throw badRequest("Not a git repository");
|
|
}
|
|
const index = parseInt(req.params.index, 10);
|
|
if (isNaN(index) || index < 0) {
|
|
throw badRequest("Invalid stash index");
|
|
}
|
|
const { drop } = req.body;
|
|
const result = await applyGitStash(index, drop === true, rootDir);
|
|
res.json({ message: result });
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/git/stashes/:index/diff
|
|
* Returns stash diff (stat + patch) for a stash entry.
|
|
*/
|
|
router.get("/git/stashes/:index/diff", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const rootDir = scopedStore.getRootDir();
|
|
if (!(await isGitRepo(rootDir))) {
|
|
throw badRequest("Not a git repository");
|
|
}
|
|
|
|
const index = parseInt(req.params.index, 10);
|
|
if (isNaN(index) || index < 0) {
|
|
throw badRequest("Invalid stash index");
|
|
}
|
|
|
|
const diff = await getGitStashDiff(index, rootDir);
|
|
if (!diff) {
|
|
throw notFound("Stash not found");
|
|
}
|
|
|
|
res.json(diff);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* DELETE /api/git/stashes/:index
|
|
* Drop a stash entry.
|
|
*/
|
|
router.delete("/git/stashes/:index", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const rootDir = scopedStore.getRootDir();
|
|
if (!(await isGitRepo(rootDir))) {
|
|
throw badRequest("Not a git repository");
|
|
}
|
|
const index = parseInt(req.params.index, 10);
|
|
if (isNaN(index) || index < 0) {
|
|
throw badRequest("Invalid stash index");
|
|
}
|
|
const result = await dropGitStash(index, rootDir);
|
|
res.json({ message: result });
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/git/diff
|
|
* Returns working directory diff (unstaged changes).
|
|
*/
|
|
router.get("/git/diff", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const rootDir = scopedStore.getRootDir();
|
|
if (!(await isGitRepo(rootDir))) {
|
|
throw badRequest("Not a git repository");
|
|
}
|
|
const diff = await getGitWorkingDiff(rootDir);
|
|
res.json(diff);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/git/diff/file
|
|
* Returns staged or unstaged diff for a specific file.
|
|
* Query: path=<file-path>&staged=true|false
|
|
*/
|
|
router.get("/git/diff/file", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const rootDir = scopedStore.getRootDir();
|
|
if (!(await isGitRepo(rootDir))) {
|
|
throw badRequest("Not a git repository");
|
|
}
|
|
|
|
const rawPath = req.query.path;
|
|
const rawStaged = req.query.staged;
|
|
|
|
if (typeof rawPath !== "string" || !rawPath.trim()) {
|
|
throw badRequest("path query parameter is required");
|
|
}
|
|
if (rawStaged !== "true" && rawStaged !== "false") {
|
|
throw badRequest("staged query parameter must be 'true' or 'false'");
|
|
}
|
|
if (!isValidGitFilePath(rawPath)) {
|
|
throw badRequest(`Invalid file path: ${rawPath}`);
|
|
}
|
|
|
|
const diff = await getGitFileDiff(rawPath, rawStaged === "true", rootDir);
|
|
res.json(diff);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/git/changes
|
|
* Returns file changes (staged and unstaged).
|
|
*/
|
|
router.get("/git/changes", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const rootDir = scopedStore.getRootDir();
|
|
if (!(await isGitRepo(rootDir))) {
|
|
throw badRequest("Not a git repository");
|
|
}
|
|
const changes = await getGitFileChanges(rootDir);
|
|
res.json(changes);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/git/stage
|
|
* Stage specific files.
|
|
* Body: { files: string[] }
|
|
*/
|
|
router.post("/git/stage", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const rootDir = scopedStore.getRootDir();
|
|
if (!(await isGitRepo(rootDir))) {
|
|
throw badRequest("Not a git repository");
|
|
}
|
|
const { files } = req.body;
|
|
if (!Array.isArray(files) || files.length === 0) {
|
|
throw badRequest("files array is required");
|
|
}
|
|
const staged = await stageGitFiles(files, rootDir);
|
|
res.json({ staged });
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/git/unstage
|
|
* Unstage specific files.
|
|
* Body: { files: string[] }
|
|
*/
|
|
router.post("/git/unstage", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const rootDir = scopedStore.getRootDir();
|
|
if (!(await isGitRepo(rootDir))) {
|
|
throw badRequest("Not a git repository");
|
|
}
|
|
const { files } = req.body;
|
|
if (!Array.isArray(files) || files.length === 0) {
|
|
throw badRequest("files array is required");
|
|
}
|
|
const unstaged = await unstageGitFiles(files, rootDir);
|
|
res.json({ unstaged });
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/git/commit
|
|
* Create a commit with staged changes.
|
|
* Body: { message: string }
|
|
*/
|
|
router.post("/git/commit", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const rootDir = scopedStore.getRootDir();
|
|
if (!(await isGitRepo(rootDir))) {
|
|
throw badRequest("Not a git repository");
|
|
}
|
|
const { message } = req.body;
|
|
if (!message || typeof message !== "string" || !message.trim()) {
|
|
throw badRequest("Commit message is required");
|
|
}
|
|
const result = await createGitCommit(message, rootDir);
|
|
res.status(201).json(result);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
if ((err instanceof Error ? err.message : String(err)).includes("No staged changes")) {
|
|
throw badRequest(err instanceof Error ? err.message : String(err));
|
|
} else {
|
|
rethrowAsApiError(err);
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/git/discard
|
|
* Discard working directory changes for specific files.
|
|
* Body: { files: string[] }
|
|
*/
|
|
router.post("/git/discard", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const rootDir = scopedStore.getRootDir();
|
|
if (!(await isGitRepo(rootDir))) {
|
|
throw badRequest("Not a git repository");
|
|
}
|
|
const { files } = req.body;
|
|
if (!Array.isArray(files) || files.length === 0) {
|
|
throw badRequest("files array is required");
|
|
}
|
|
const discarded = await discardGitChanges(files, rootDir);
|
|
res.json({ discarded });
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
// ── GitHub Import Routes ──────────────────────────────────────────
|
|
|
|
/**
|
|
* GET /api/github/issues/recent
|
|
* Returns recent issues for the first GitHub remote (prefer origin when present).
|
|
*/
|
|
router.get("/github/issues/recent", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const rootDir = scopedStore.getRootDir();
|
|
const remotes = await getGitHubRemotes(rootDir);
|
|
const remote = remotes.find((item) => item.name === "origin") ?? remotes[0];
|
|
|
|
if (!remote || !isGhAuthenticated()) {
|
|
res.json([]);
|
|
return;
|
|
}
|
|
|
|
const rawLimit = Number.parseInt(String(req.query.limit ?? "20"), 10);
|
|
const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.min(rawLimit, 100)) : 20;
|
|
const q = typeof req.query.q === "string" ? req.query.q.trim().toLowerCase() : "";
|
|
|
|
const cacheKey = `${remote.owner}/${remote.repo}`;
|
|
const now = Date.now();
|
|
const cached = recentIssuesCache.get(cacheKey);
|
|
|
|
let items = cached?.items;
|
|
if (!cached || now - cached.fetchedAt > RECENT_ISSUES_CACHE_TTL_MS) {
|
|
const client = new GitHubClient(githubToken);
|
|
try {
|
|
const issues = await client.listIssues(remote.owner, remote.repo, { limit: 100, state: "all" });
|
|
items = issues
|
|
.filter((issue) => issue.html_url.includes("/issues/"))
|
|
.map((issue) => ({
|
|
number: issue.number,
|
|
title: issue.title,
|
|
state: issue.state ?? "open",
|
|
htmlUrl: issue.html_url,
|
|
repository: cacheKey,
|
|
updatedAt: issue.updatedAt,
|
|
}));
|
|
recentIssuesCache.set(cacheKey, { fetchedAt: now, items });
|
|
} catch {
|
|
res.json([]);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const filtered = (items ?? []).filter((issue) => {
|
|
if (!q) return true;
|
|
return String(issue.number).startsWith(q) || issue.title.toLowerCase().includes(q);
|
|
});
|
|
|
|
res.json(filtered.slice(0, limit));
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/github/issues/fetch
|
|
* Fetch open issues from a GitHub repository.
|
|
* Body: { owner: string, repo: string, limit?: number, labels?: string[] }
|
|
* Returns: Array of GitHubIssue objects (filtered, no PRs)
|
|
*/
|
|
router.post("/github/issues/fetch", async (req, res) => {
|
|
try {
|
|
const { owner, repo, limit = 30, labels } = req.body;
|
|
|
|
if (!owner || typeof owner !== "string") {
|
|
throw badRequest("owner is required");
|
|
}
|
|
if (!repo || typeof repo !== "string") {
|
|
throw badRequest("repo is required");
|
|
}
|
|
|
|
// Check gh authentication
|
|
if (!isGhAuthenticated()) {
|
|
throw unauthorized("Not authenticated with GitHub. Run `gh auth login`.");
|
|
}
|
|
|
|
const client = new GitHubClient();
|
|
|
|
try {
|
|
const issues = await client.listIssues(owner, repo, { limit, labels });
|
|
res.json(issues);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
// Handle specific error cases from gh CLI
|
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
|
|
if (errorMessage.includes("not found") || errorMessage.includes("404")) {
|
|
throw notFound(`Repository not found: ${owner}/${repo}`);
|
|
}
|
|
if (errorMessage.includes("authentication") || errorMessage.includes("401") || errorMessage.includes("403")) {
|
|
throw unauthorized("Not authenticated with GitHub. Run `gh auth login`.");
|
|
}
|
|
|
|
throw new ApiError(502, `GitHub CLI error: ${errorMessage}`);
|
|
}
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/github/issues/import
|
|
* Import a specific GitHub issue as a fn task.
|
|
* Body: { owner: string, repo: string, issueNumber: number }
|
|
* Returns: Created Task object
|
|
*/
|
|
router.post("/github/issues/import", async (req, res) => {
|
|
try {
|
|
const { owner, repo, issueNumber } = req.body;
|
|
|
|
if (!owner || typeof owner !== "string") {
|
|
throw badRequest("owner is required");
|
|
}
|
|
if (!repo || typeof repo !== "string") {
|
|
throw badRequest("repo is required");
|
|
}
|
|
if (!issueNumber || typeof issueNumber !== "number" || issueNumber < 1) {
|
|
throw badRequest("issueNumber is required and must be a positive number");
|
|
}
|
|
|
|
// Check gh authentication
|
|
if (!isGhAuthenticated()) {
|
|
throw unauthorized("Not authenticated with GitHub. Run `gh auth login`.");
|
|
}
|
|
|
|
const client = new GitHubClient();
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
|
|
let issue: {
|
|
number: number;
|
|
title: string;
|
|
body: string | null;
|
|
html_url: string;
|
|
state: "open" | "closed";
|
|
} | null;
|
|
|
|
try {
|
|
issue = await client.getIssue(owner, repo, issueNumber);
|
|
|
|
// getIssue returns null when the issue doesn't exist OR when it's a PR
|
|
// We return a 400 error indicating it might be a PR (consistent with old behavior)
|
|
if (issue === null) {
|
|
throw badRequest(`#${issueNumber} is a pull request, not an issue`);
|
|
}
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
|
|
if (errorMessage.includes("not found") || errorMessage.includes("404")) {
|
|
throw notFound(`Issue #${issueNumber} not found in ${owner}/${repo}`);
|
|
}
|
|
if (errorMessage.includes("authentication") || errorMessage.includes("401") || errorMessage.includes("403")) {
|
|
throw unauthorized("Not authenticated with GitHub. Run `gh auth login`.");
|
|
}
|
|
|
|
throw new ApiError(502, `GitHub CLI error: ${errorMessage}`);
|
|
}
|
|
|
|
// Check if already imported
|
|
const existingTasks = await scopedStore.listTasks({ slim: true, includeArchived: false });
|
|
const sourceUrl = issue.html_url;
|
|
for (const existingTask of existingTasks) {
|
|
if (existingTask.description.includes(sourceUrl)) {
|
|
throw new ApiError(409, `Issue #${issueNumber} already imported as ${existingTask.id}`, {
|
|
existingTaskId: existingTask.id,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Create the task
|
|
const title = issue.title.slice(0, 200);
|
|
const body = issue.body?.trim() || "(no description)";
|
|
const description = `${body}\n\nSource: ${sourceUrl}`;
|
|
|
|
const source = buildGitHubIssueSource(owner, repo, issue);
|
|
const task = await scopedStore.createTask({
|
|
title: title || undefined,
|
|
description,
|
|
column: "triage",
|
|
dependencies: [],
|
|
sourceIssue: source.sourceIssue,
|
|
source: {
|
|
sourceType: "github_import",
|
|
sourceMetadata: source.sourceMetadata,
|
|
},
|
|
});
|
|
|
|
// Log the import action
|
|
await scopedStore.logEntry(task.id, "Imported from GitHub", sourceUrl);
|
|
|
|
res.status(201).json(task);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/github/issues/batch-import
|
|
* Import multiple GitHub issues as fn tasks with throttling.
|
|
* Body: { owner: string, repo: string, issueNumbers: number[], delayMs?: number }
|
|
* Returns: { results: BatchImportResult[] }
|
|
*/
|
|
// Batch import rate limiter: max 1 request per 10 seconds per IP
|
|
const batchImportRateLimiter = createBatchImportRateLimiter();
|
|
|
|
router.post("/github/issues/batch-import", batchImportRateLimiter, async (req, res) => {
|
|
try {
|
|
const { owner, repo, issueNumbers, delayMs } = req.body;
|
|
|
|
// Validate owner
|
|
if (!owner || typeof owner !== "string") {
|
|
throw badRequest("owner is required");
|
|
}
|
|
|
|
// Validate repo
|
|
if (!repo || typeof repo !== "string") {
|
|
throw badRequest("repo is required");
|
|
}
|
|
|
|
// Validate issueNumbers
|
|
if (!Array.isArray(issueNumbers)) {
|
|
throw badRequest("issueNumbers is required and must be an array");
|
|
}
|
|
|
|
if (issueNumbers.length === 0) {
|
|
throw badRequest("issueNumbers must contain at least 1 issue number");
|
|
}
|
|
|
|
if (issueNumbers.length > 50) {
|
|
throw badRequest("issueNumbers cannot contain more than 50 issue numbers");
|
|
}
|
|
|
|
if (!issueNumbers.every((n) => typeof n === "number" && n > 0 && Number.isInteger(n))) {
|
|
throw badRequest("issueNumbers must contain only positive integers");
|
|
}
|
|
|
|
const token = process.env.GITHUB_TOKEN;
|
|
const githubClient = new GitHubClient(token);
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
|
|
// Get existing tasks to check for duplicates
|
|
const existingTasks = await scopedStore.listTasks({ slim: true, includeArchived: false });
|
|
|
|
// Process issues sequentially with throttling
|
|
const results: Array<{
|
|
issueNumber: number;
|
|
success: boolean;
|
|
taskId?: string;
|
|
error?: string;
|
|
skipped?: boolean;
|
|
retryAfter?: number;
|
|
}> = [];
|
|
|
|
for (const issueNumber of issueNumbers) {
|
|
const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${issueNumber}`;
|
|
|
|
// Use throttled fetch to avoid rate limits
|
|
const fetchResult = await githubClient.fetchThrottled<{
|
|
number: number;
|
|
title: string;
|
|
body: string | null;
|
|
html_url: string;
|
|
pull_request?: unknown;
|
|
}>(url, {}, { delayMs: delayMs ?? 1000, maxRetries: 3 });
|
|
|
|
if (!fetchResult.success) {
|
|
results.push({
|
|
issueNumber,
|
|
success: false,
|
|
error: fetchResult.error ?? "Failed to fetch issue",
|
|
retryAfter: fetchResult.retryAfter,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const issue = fetchResult.data!;
|
|
|
|
// Check if it's a pull request
|
|
if (issue.pull_request) {
|
|
results.push({
|
|
issueNumber,
|
|
success: false,
|
|
error: "This is a pull request, not an issue",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// Check if already imported
|
|
const sourceUrl = issue.html_url;
|
|
const existingTask = existingTasks.find((t) => t.description.includes(sourceUrl));
|
|
if (existingTask) {
|
|
results.push({
|
|
issueNumber,
|
|
success: true,
|
|
skipped: true,
|
|
taskId: existingTask.id,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// Create the task
|
|
const title = issue.title.slice(0, 200);
|
|
const body = issue.body?.trim() || "(no description)";
|
|
const description = `${body}\n\nSource: ${sourceUrl}`;
|
|
|
|
try {
|
|
const source = buildGitHubIssueSource(owner, repo, issue);
|
|
const task = await scopedStore.createTask({
|
|
title: title || undefined,
|
|
description,
|
|
column: "triage",
|
|
dependencies: [],
|
|
sourceIssue: source.sourceIssue,
|
|
source: {
|
|
sourceType: "github_import",
|
|
sourceMetadata: source.sourceMetadata,
|
|
},
|
|
});
|
|
|
|
// Log the import action
|
|
await scopedStore.logEntry(task.id, "Imported from GitHub", sourceUrl);
|
|
|
|
results.push({
|
|
issueNumber,
|
|
success: true,
|
|
taskId: task.id,
|
|
});
|
|
|
|
// Add to existingTasks to avoid duplicate imports within the same batch
|
|
existingTasks.push({ ...task, description });
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
results.push({
|
|
issueNumber,
|
|
success: false,
|
|
error: (err instanceof Error ? err.message : String(err)) || "Failed to create task",
|
|
});
|
|
}
|
|
}
|
|
|
|
res.json({ results });
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/github/pulls/fetch
|
|
* Fetch open pull requests from a GitHub repository.
|
|
* Body: { owner: string, repo: string, limit?: number }
|
|
* Returns: Array of GitHubPull objects
|
|
*/
|
|
router.post("/github/pulls/fetch", async (req, res) => {
|
|
try {
|
|
const { owner, repo, limit = 30 } = req.body;
|
|
|
|
if (!owner || typeof owner !== "string") {
|
|
throw badRequest("owner is required");
|
|
}
|
|
if (!repo || typeof repo !== "string") {
|
|
throw badRequest("repo is required");
|
|
}
|
|
|
|
// Check gh authentication
|
|
if (!isGhAuthenticated()) {
|
|
throw unauthorized("Not authenticated with GitHub. Run `gh auth login`.");
|
|
}
|
|
|
|
const client = new GitHubClient();
|
|
|
|
try {
|
|
const pulls = await client.listPullRequests(owner, repo, { limit });
|
|
res.json(pulls);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
// Handle specific error cases from gh CLI
|
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
|
|
if (errorMessage.includes("not found") || errorMessage.includes("404")) {
|
|
throw notFound(`Repository not found: ${owner}/${repo}`);
|
|
}
|
|
if (errorMessage.includes("authentication") || errorMessage.includes("401") || errorMessage.includes("403")) {
|
|
throw unauthorized("Not authenticated with GitHub. Run `gh auth login`.");
|
|
}
|
|
|
|
throw new ApiError(502, `GitHub CLI error: ${errorMessage}`);
|
|
}
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/github/pulls/import
|
|
* Import a specific GitHub pull request as a fn review task.
|
|
* Body: { owner: string, repo: string, prNumber: number }
|
|
* Returns: Created Task object
|
|
*/
|
|
router.post("/github/pulls/import", async (req, res) => {
|
|
try {
|
|
const { owner, repo, prNumber } = req.body;
|
|
|
|
if (!owner || typeof owner !== "string") {
|
|
throw badRequest("owner is required");
|
|
}
|
|
if (!repo || typeof repo !== "string") {
|
|
throw badRequest("repo is required");
|
|
}
|
|
if (!prNumber || typeof prNumber !== "number" || prNumber < 1) {
|
|
throw badRequest("prNumber is required and must be a positive number");
|
|
}
|
|
|
|
// Check gh authentication
|
|
if (!isGhAuthenticated()) {
|
|
throw unauthorized("Not authenticated with GitHub. Run `gh auth login`.");
|
|
}
|
|
|
|
const client = new GitHubClient();
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
|
|
let pr: {
|
|
number: number;
|
|
title: string;
|
|
body: string | null;
|
|
html_url: string;
|
|
headBranch: string;
|
|
baseBranch: string;
|
|
state: "open" | "closed" | "merged";
|
|
} | null;
|
|
|
|
try {
|
|
pr = await client.getPullRequest(owner, repo, prNumber);
|
|
|
|
if (pr === null) {
|
|
throw notFound(`PR #${prNumber} not found in ${owner}/${repo}`);
|
|
}
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
|
|
if (errorMessage.includes("not found") || errorMessage.includes("404")) {
|
|
throw notFound(`PR #${prNumber} not found in ${owner}/${repo}`);
|
|
}
|
|
if (errorMessage.includes("authentication") || errorMessage.includes("401") || errorMessage.includes("403")) {
|
|
throw unauthorized("Not authenticated with GitHub. Run `gh auth login`.");
|
|
}
|
|
|
|
throw new ApiError(502, `GitHub CLI error: ${errorMessage}`);
|
|
}
|
|
|
|
// Check if already imported
|
|
const existingTasks = await scopedStore.listTasks({ slim: true, includeArchived: false });
|
|
const sourceUrl = pr.html_url;
|
|
for (const existingTask of existingTasks) {
|
|
if (existingTask.description.includes(sourceUrl)) {
|
|
throw new ApiError(409, `PR #${prNumber} already imported as ${existingTask.id}`, {
|
|
existingTaskId: existingTask.id,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Create the task with "Review PR:" prefix
|
|
const title = `Review PR #${pr.number}: ${pr.title.slice(0, 180)}`;
|
|
const body = pr.body?.trim() || "(no description)";
|
|
const description = `Review and address any issues in this pull request.\n\nPR: ${sourceUrl}\nBranch: ${pr.headBranch} → ${pr.baseBranch}\n\n${body}`;
|
|
|
|
const task = await scopedStore.createTask({
|
|
title: title || undefined,
|
|
description,
|
|
column: "triage",
|
|
dependencies: [],
|
|
source: {
|
|
sourceType: "github_import",
|
|
sourceMetadata: { prUrl: sourceUrl, prNumber },
|
|
},
|
|
});
|
|
|
|
// Log the import action
|
|
await scopedStore.logEntry(task.id, "Imported PR from GitHub", sourceUrl);
|
|
|
|
res.status(201).json(task);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
|
|
/**
|
|
* POST /api/github/webhooks
|
|
* GitHub App webhook endpoint for badge updates.
|
|
* Accepts signed webhook deliveries for pull_request, issues, and issue_comment events.
|
|
* Verifies X-Hub-Signature-256, fetches canonical badge state, and updates matching tasks.
|
|
*
|
|
* Responses:
|
|
* - 200: Valid ping event
|
|
* - 202: Valid but unsupported/irrelevant event
|
|
* - 401: Missing required webhook auth headers
|
|
* - 403: Signature mismatch/tampering detected
|
|
* - 503: GitHub App configuration missing or incomplete
|
|
* - 500: Installation token refresh failed
|
|
*/
|
|
router.post("/github/webhooks", async (req, res) => {
|
|
const config = getGitHubAppConfig();
|
|
if (!config) {
|
|
throw new ApiError(503, "GitHub App not configured");
|
|
}
|
|
|
|
// Get raw body (Buffer from express.raw() middleware)
|
|
const rawBody = req.body as Buffer;
|
|
if (!Buffer.isBuffer(rawBody)) {
|
|
throw badRequest("Invalid request body");
|
|
}
|
|
|
|
// Verify signature
|
|
const signatureHeader = req.headers["x-hub-signature-256"] as string | undefined;
|
|
const verification = verifyWebhookSignature(rawBody, signatureHeader, config.webhookSecret);
|
|
if (!verification.valid) {
|
|
throw new ApiError(403, verification.error ?? "Invalid signature");
|
|
}
|
|
|
|
// Parse payload after verification
|
|
let payload: unknown;
|
|
try {
|
|
payload = JSON.parse(rawBody.toString("utf-8"));
|
|
} catch {
|
|
throw badRequest("Invalid JSON payload");
|
|
}
|
|
|
|
// Classify event
|
|
const eventType = req.headers["x-github-event"] as string | undefined;
|
|
const classification = classifyWebhookEvent(eventType, payload);
|
|
|
|
// Handle ping
|
|
if (eventType === "ping") {
|
|
res.status(200).json({ message: "Pong" });
|
|
return;
|
|
}
|
|
|
|
// Unsupported event
|
|
if (!classification.supported) {
|
|
res.status(202).json({ message: "Event type not supported" });
|
|
return;
|
|
}
|
|
|
|
// Not relevant for badge updates (e.g., issue_comment on regular issue)
|
|
if (!classification.relevant) {
|
|
res.status(202).json({ message: "Event not relevant for badges" });
|
|
return;
|
|
}
|
|
|
|
// Missing required data
|
|
if (!classification.owner || !classification.repo || classification.number === undefined || !classification.installationId) {
|
|
throw badRequest("Missing repository or installation data");
|
|
}
|
|
|
|
// Fetch installation token
|
|
const installationToken = await GitHubClient.fetchInstallationToken(
|
|
classification.installationId,
|
|
config.appId,
|
|
config.privateKey,
|
|
);
|
|
if (!installationToken) {
|
|
throw internalError("Failed to fetch installation token");
|
|
}
|
|
|
|
// Fetch canonical badge state
|
|
let badgeData: Omit<PrInfo, "lastCheckedAt"> | Omit<import("@fusion/core").IssueInfo, "lastCheckedAt"> | null = null;
|
|
if (classification.resourceType === "pr") {
|
|
badgeData = await GitHubClient.fetchPrWithInstallationToken(
|
|
classification.owner,
|
|
classification.repo,
|
|
classification.number,
|
|
installationToken,
|
|
);
|
|
} else {
|
|
badgeData = await GitHubClient.fetchIssueWithInstallationToken(
|
|
classification.owner,
|
|
classification.repo,
|
|
classification.number,
|
|
installationToken,
|
|
);
|
|
}
|
|
|
|
if (!badgeData) {
|
|
res.status(202).json({ message: "Badge resource not found or inaccessible" });
|
|
return;
|
|
}
|
|
|
|
// Find all matching tasks by badge URL (use project-scoped store if projectId is provided)
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const tasks = await scopedStore.listTasks({ slim: true, includeArchived: false });
|
|
const matchingTasks: Array<{ id: string; resourceType: "pr" | "issue"; current: unknown }> = [];
|
|
|
|
for (const task of tasks) {
|
|
if (classification.resourceType === "pr" && task.prInfo) {
|
|
const parsed = parseBadgeUrl(task.prInfo.url);
|
|
if (parsed &&
|
|
parsed.owner.toLowerCase() === classification.owner!.toLowerCase() &&
|
|
parsed.repo.toLowerCase() === classification.repo!.toLowerCase() &&
|
|
parsed.number === classification.number) {
|
|
matchingTasks.push({ id: task.id, resourceType: "pr", current: task.prInfo });
|
|
}
|
|
} else if (classification.resourceType === "issue" && task.issueInfo) {
|
|
const parsed = parseBadgeUrl(task.issueInfo.url);
|
|
if (parsed &&
|
|
parsed.owner.toLowerCase() === classification.owner!.toLowerCase() &&
|
|
parsed.repo.toLowerCase() === classification.repo!.toLowerCase() &&
|
|
parsed.number === classification.number) {
|
|
matchingTasks.push({ id: task.id, resourceType: "issue", current: task.issueInfo });
|
|
}
|
|
}
|
|
}
|
|
|
|
if (matchingTasks.length === 0) {
|
|
res.status(202).json({ message: "No tasks linked to this resource" });
|
|
return;
|
|
}
|
|
|
|
// Update matching tasks
|
|
const checkedAt = new Date().toISOString();
|
|
let badgeFieldsChanged = false;
|
|
|
|
for (const match of matchingTasks) {
|
|
if (match.resourceType === "pr") {
|
|
const current = match.current as PrInfo;
|
|
const next = { ...(badgeData as Omit<PrInfo, "lastCheckedAt">), lastCheckedAt: checkedAt };
|
|
const changed = hasPrBadgeFieldsChanged(current, badgeData as Omit<PrInfo, "lastCheckedAt">);
|
|
if (changed || current.lastCheckedAt !== checkedAt) {
|
|
await scopedStore.updatePrInfo(match.id, next);
|
|
if (changed) badgeFieldsChanged = true;
|
|
}
|
|
} else {
|
|
const current = match.current as import("@fusion/core").IssueInfo;
|
|
const next = { ...(badgeData as Omit<import("@fusion/core").IssueInfo, "lastCheckedAt">), lastCheckedAt: checkedAt };
|
|
const changed = hasIssueBadgeFieldsChanged(current, badgeData as Omit<import("@fusion/core").IssueInfo, "lastCheckedAt">);
|
|
if (changed || current.lastCheckedAt !== checkedAt) {
|
|
await scopedStore.updateIssueInfo(match.id, next);
|
|
if (changed) badgeFieldsChanged = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
res.status(200).json({
|
|
updated: matchingTasks.length,
|
|
tasks: matchingTasks.map(m => m.id),
|
|
badgeFieldsChanged,
|
|
});
|
|
});
|
|
|
|
/**
|
|
* POST /api/github/batch/status
|
|
* Refresh issue/PR badge status for up to 100 tasks in grouped GitHub requests.
|
|
* Body: { taskIds: string[] }
|
|
*/
|
|
router.post("/github/batch/status", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const { taskIds } = (req.body ?? {}) as import("@fusion/core").BatchStatusRequest;
|
|
if (!Array.isArray(taskIds)) {
|
|
throw badRequest("taskIds must be an array");
|
|
}
|
|
if (taskIds.some((taskId) => typeof taskId !== "string" || taskId.trim().length === 0)) {
|
|
throw badRequest("taskIds must contain non-empty strings");
|
|
}
|
|
if (taskIds.length > 100) {
|
|
throw badRequest("taskIds must contain at most 100 items");
|
|
}
|
|
if (taskIds.length === 0) {
|
|
res.json({ results: {} } satisfies BatchStatusResponse);
|
|
return;
|
|
}
|
|
|
|
const fallbackRepo = getDefaultGitHubRepo(scopedStore);
|
|
const results: BatchStatusResult = {};
|
|
const issueGroups = new Map<string, { owner: string; repo: string; numbers: Set<number>; taskIds: Set<string> }>();
|
|
const prGroups = new Map<string, { owner: string; repo: string; numbers: Set<number>; taskIds: Set<string> }>();
|
|
const tasksById = new Map<string, Awaited<ReturnType<TaskStore["getTask"]>>>();
|
|
|
|
for (const taskId of taskIds) {
|
|
try {
|
|
const task = await scopedStore.getTask(taskId);
|
|
tasksById.set(taskId, task);
|
|
|
|
const entry = ensureBatchStatusEntry(results, taskId);
|
|
if (task.issueInfo) entry.issueInfo = task.issueInfo;
|
|
if (task.prInfo) entry.prInfo = task.prInfo;
|
|
entry.stale = Boolean(
|
|
(task.issueInfo && isBatchStatusStale(task.issueInfo, task.updatedAt))
|
|
|| (task.prInfo && isBatchStatusStale(task.prInfo, task.updatedAt)),
|
|
);
|
|
|
|
if (!task.issueInfo && !task.prInfo) {
|
|
appendBatchStatusError(results, taskId, "Task has no GitHub badge metadata");
|
|
continue;
|
|
}
|
|
|
|
if (task.issueInfo) {
|
|
const issueRepo = parseGitHubBadgeUrl(task.issueInfo.url) ?? fallbackRepo;
|
|
if (!issueRepo) {
|
|
appendBatchStatusError(results, taskId, "Could not determine GitHub repository for issue badge");
|
|
} else {
|
|
const repoKey = `${issueRepo.owner}/${issueRepo.repo}`;
|
|
const group = issueGroups.get(repoKey) ?? {
|
|
owner: issueRepo.owner,
|
|
repo: issueRepo.repo,
|
|
numbers: new Set<number>(),
|
|
taskIds: new Set<string>(),
|
|
};
|
|
group.numbers.add(task.issueInfo.number);
|
|
group.taskIds.add(taskId);
|
|
issueGroups.set(repoKey, group);
|
|
}
|
|
}
|
|
|
|
if (task.prInfo) {
|
|
const prRepo = parseGitHubBadgeUrl(task.prInfo.url) ?? fallbackRepo;
|
|
if (!prRepo) {
|
|
appendBatchStatusError(results, taskId, "Could not determine GitHub repository for PR badge");
|
|
} else {
|
|
const repoKey = `${prRepo.owner}/${prRepo.repo}`;
|
|
const group = prGroups.get(repoKey) ?? {
|
|
owner: prRepo.owner,
|
|
repo: prRepo.repo,
|
|
numbers: new Set<number>(),
|
|
taskIds: new Set<string>(),
|
|
};
|
|
group.numbers.add(task.prInfo.number);
|
|
group.taskIds.add(taskId);
|
|
prGroups.set(repoKey, group);
|
|
}
|
|
}
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
appendBatchStatusError(results, taskId, `Task ${taskId} not found`);
|
|
} else {
|
|
appendBatchStatusError(results, taskId, err instanceof Error ? err.message : String(err) || `Failed to load task ${taskId}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const client = new GitHubClient(githubToken);
|
|
const applyIssueGroup = async (group: { owner: string; repo: string; numbers: Set<number>; taskIds: Set<string> }) => {
|
|
const repoKey = `${group.owner}/${group.repo}`;
|
|
if (!githubRateLimiter.canMakeRequest(repoKey)) {
|
|
const resetTime = githubRateLimiter.getResetTime(repoKey);
|
|
const retryAfter = resetTime
|
|
? Math.max(0, Math.ceil((resetTime.getTime() - Date.now()) / 1000))
|
|
: undefined;
|
|
throw new ApiError(429, "GitHub API rate limit exceeded for this repository", {
|
|
retryAfter,
|
|
resetAt: resetTime?.toISOString(),
|
|
});
|
|
}
|
|
|
|
try {
|
|
const issueStatuses = await client.getBatchIssueStatus(group.owner, group.repo, [...group.numbers]);
|
|
const refreshedAt = new Date().toISOString();
|
|
|
|
for (const taskId of group.taskIds) {
|
|
const task = tasksById.get(taskId);
|
|
if (!task?.issueInfo) continue;
|
|
const issueInfo = issueStatuses.get(task.issueInfo.number);
|
|
if (!issueInfo) {
|
|
appendBatchStatusError(results, taskId, `Issue #${task.issueInfo.number} not found in ${group.owner}/${group.repo}`);
|
|
continue;
|
|
}
|
|
|
|
const updatedIssueInfo: IssueInfo = {
|
|
...issueInfo,
|
|
lastCheckedAt: refreshedAt,
|
|
};
|
|
await scopedStore.updateIssueInfo(taskId, updatedIssueInfo);
|
|
const entry = ensureBatchStatusEntry(results, taskId);
|
|
entry.issueInfo = updatedIssueInfo;
|
|
entry.stale = entry.prInfo ? isBatchStatusStale(entry.prInfo, task.updatedAt) : false;
|
|
}
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
for (const taskId of group.taskIds) {
|
|
appendBatchStatusError(results, taskId, (err instanceof Error ? err.message : String(err)) || `Failed to refresh issue badges for ${repoKey}`);
|
|
}
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
const applyPrGroup = async (group: { owner: string; repo: string; numbers: Set<number>; taskIds: Set<string> }) => {
|
|
const repoKey = `${group.owner}/${group.repo}`;
|
|
if (!githubRateLimiter.canMakeRequest(repoKey)) {
|
|
const resetTime = githubRateLimiter.getResetTime(repoKey);
|
|
const retryAfter = resetTime
|
|
? Math.max(0, Math.ceil((resetTime.getTime() - Date.now()) / 1000))
|
|
: undefined;
|
|
throw new ApiError(429, "GitHub API rate limit exceeded for this repository", {
|
|
retryAfter,
|
|
resetAt: resetTime?.toISOString(),
|
|
});
|
|
}
|
|
|
|
try {
|
|
const prStatuses = await client.getBatchPrStatus(group.owner, group.repo, [...group.numbers]);
|
|
const refreshedAt = new Date().toISOString();
|
|
|
|
for (const taskId of group.taskIds) {
|
|
const task = tasksById.get(taskId);
|
|
if (!task?.prInfo) continue;
|
|
const prInfo = prStatuses.get(task.prInfo.number);
|
|
if (!prInfo) {
|
|
appendBatchStatusError(results, taskId, `PR #${task.prInfo.number} not found in ${group.owner}/${group.repo}`);
|
|
continue;
|
|
}
|
|
|
|
const updatedPrInfo: PrInfo = {
|
|
...prInfo,
|
|
lastCheckedAt: refreshedAt,
|
|
};
|
|
await scopedStore.updatePrInfo(taskId, updatedPrInfo);
|
|
const entry = ensureBatchStatusEntry(results, taskId);
|
|
entry.prInfo = updatedPrInfo;
|
|
entry.stale = entry.issueInfo ? isBatchStatusStale(entry.issueInfo, task.updatedAt) : false;
|
|
}
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
for (const taskId of group.taskIds) {
|
|
appendBatchStatusError(results, taskId, (err instanceof Error ? err.message : String(err)) || `Failed to refresh PR badges for ${repoKey}`);
|
|
}
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
for (const group of issueGroups.values()) {
|
|
const shouldContinue = await applyIssueGroup(group);
|
|
if (!shouldContinue) return;
|
|
}
|
|
for (const group of prGroups.values()) {
|
|
const shouldContinue = await applyPrGroup(group);
|
|
if (!shouldContinue) return;
|
|
}
|
|
|
|
for (const taskId of taskIds) {
|
|
ensureBatchStatusEntry(results, taskId);
|
|
}
|
|
|
|
res.json({ results } satisfies BatchStatusResponse);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err, "Failed to batch refresh GitHub status");
|
|
}
|
|
});
|
|
|
|
|
|
// ── PR Management Routes ─────────────────────────────────────────
|
|
|
|
/**
|
|
* POST /api/tasks/:id/pr/create
|
|
* Create a GitHub PR for an in-review task.
|
|
* Body: { title: string, body?: string, base?: string }
|
|
* Returns: Created PrInfo
|
|
*/
|
|
router.post("/tasks/:id/pr/create", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const { title, body, base } = req.body;
|
|
|
|
if (!title || typeof title !== "string") {
|
|
throw badRequest("title is required and must be a string");
|
|
}
|
|
|
|
// Get task and validate
|
|
const task = await scopedStore.getTask(req.params.id);
|
|
if (task.column !== "in-review") {
|
|
throw badRequest("Task must be in 'in-review' column to create a PR");
|
|
}
|
|
|
|
const existingPrs = getTaskPrList(task);
|
|
|
|
// Determine branch name from task
|
|
const branchName = `fusion/${task.id.toLowerCase()}`;
|
|
|
|
// Get owner/repo from git remote or GITHUB_REPOSITORY env
|
|
let owner: string;
|
|
let repo: string;
|
|
|
|
const envRepo = process.env.GITHUB_REPOSITORY;
|
|
if (envRepo) {
|
|
const [o, r] = envRepo.split("/");
|
|
owner = o;
|
|
repo = r;
|
|
} else {
|
|
const gitRepo = getCurrentRepo(scopedStore.getRootDir());
|
|
if (!gitRepo) {
|
|
throw badRequest("Could not determine GitHub repository. Set GITHUB_REPOSITORY env var or configure git remote.");
|
|
}
|
|
owner = gitRepo.owner;
|
|
repo = gitRepo.repo;
|
|
}
|
|
|
|
// Check rate limit
|
|
const repoKey = `${owner}/${repo}`;
|
|
if (!githubRateLimiter.canMakeRequest(repoKey)) {
|
|
const resetTime = githubRateLimiter.getResetTime(repoKey);
|
|
const retryAfter = resetTime
|
|
? Math.max(0, Math.ceil((resetTime.getTime() - Date.now()) / 1000))
|
|
: undefined;
|
|
throw new ApiError(429, "GitHub API rate limit exceeded for this repository", {
|
|
retryAfter,
|
|
resetAt: resetTime?.toISOString(),
|
|
});
|
|
}
|
|
|
|
const client = new GitHubClient();
|
|
const existingPr = await client.findPrForBranch({ head: branchName, state: "all", owner, repo });
|
|
|
|
let prInfo: PrInfo;
|
|
if (existingPr) {
|
|
prInfo = existingPr;
|
|
} else {
|
|
await runGitCommand(["push", "-u", "origin", branchName], scopedStore.getRootDir(), 60_000);
|
|
prInfo = await client.createPr({
|
|
owner,
|
|
repo,
|
|
title,
|
|
body,
|
|
head: branchName,
|
|
base,
|
|
});
|
|
}
|
|
|
|
// Store PR info
|
|
if (existingPrs.length > 0) {
|
|
await scopedStore.addPrInfo(task.id, prInfo);
|
|
} else {
|
|
await scopedStore.updatePrInfo(task.id, prInfo);
|
|
}
|
|
await scopedStore.logEntry(task.id, existingPr ? "Linked existing PR" : "Created PR", `PR #${prInfo.number}: ${prInfo.url}`);
|
|
|
|
res.status(201).json(prInfo);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
throw notFound(`Task ${req.params.id} not found`);
|
|
} else if ((err instanceof Error ? err.message : String(err)).includes("already exists")) {
|
|
throw conflict(err instanceof Error ? err.message : String(err));
|
|
} else if ((err instanceof Error ? err.message : String(err)).includes("No commits between")) {
|
|
throw badRequest("Branch has no commits. Push changes before creating PR.");
|
|
} else {
|
|
throw toPrApiError(err, "Failed to create PR");
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/tasks/:id/pr/push-branch
|
|
* Push the task branch to origin and return refreshed preflight state.
|
|
*/
|
|
router.post("/tasks/:id/pr/push-branch", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const task = await scopedStore.getTask(req.params.id);
|
|
if (task.column !== "in-review") {
|
|
throw badRequest("Task must be in 'in-review' column to push PR branch");
|
|
}
|
|
|
|
if (req.body?.base !== undefined && typeof req.body.base !== "string") {
|
|
throw badRequest("base must be a string when provided");
|
|
}
|
|
|
|
const repoRoot = scopedStore.getRootDir();
|
|
const requestedBase = typeof req.body?.base === "string" ? req.body.base.trim() : "";
|
|
const defaultBaseBranch = requestedBase || await resolveDefaultPrBaseBranch(task, repoRoot);
|
|
const baseBranch = ensureSafeGitRef(defaultBaseBranch, "base branch");
|
|
const head = ensureSafeGitRef(`fusion/${task.id.toLowerCase()}`, "head branch");
|
|
const headRef = `refs/heads/${head}`;
|
|
const baseRef = await resolvePrBaseRef(repoRoot, baseBranch).catch(() => baseBranch);
|
|
|
|
try {
|
|
await runGitCommand(["rev-parse", "--verify", headRef], repoRoot, 10_000);
|
|
} catch {
|
|
throw badRequest(`Branch ${head} does not exist locally. Commit changes before creating a PR.`);
|
|
}
|
|
|
|
let commitCount = 0;
|
|
try {
|
|
const commitCountOutput = await runGitCommand(["rev-list", "--count", `${baseRef}..${head}`], repoRoot, 10_000);
|
|
commitCount = Number.parseInt(commitCountOutput, 10);
|
|
} catch {
|
|
throw badRequest(`Branch ${head} does not exist locally. Commit changes before creating a PR.`);
|
|
}
|
|
|
|
if (!Number.isFinite(commitCount) || commitCount <= 0) {
|
|
throw badRequest("Branch has no commits. Push changes before creating PR.");
|
|
}
|
|
|
|
await runGitCommand(["push", "-u", "origin", head], repoRoot, 60_000);
|
|
await scopedStore.logEntry(task.id, "Pushed PR branch", head);
|
|
|
|
const preflight = await computePrPreflight(task, repoRoot, baseBranch);
|
|
res.json({
|
|
result: {
|
|
pushed: true,
|
|
head,
|
|
message: `Pushed ${head} to origin.`,
|
|
},
|
|
preflight,
|
|
});
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
throw notFound(`Task ${req.params.id} not found`);
|
|
}
|
|
if ((err instanceof Error ? err.message : String(err)).includes("already exists")) {
|
|
throw conflict(err instanceof Error ? err.message : String(err));
|
|
}
|
|
throw toPrApiError(err, "Failed to push PR branch");
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/tasks/:id/pr/resolve-conflicts
|
|
* Resolve Create-PR merge conflicts on the task branch, push the branch,
|
|
* and return refreshed preflight state.
|
|
*/
|
|
router.post("/tasks/:id/pr/resolve-conflicts", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const task = await scopedStore.getTask(req.params.id);
|
|
if (task.column !== "in-review") {
|
|
throw badRequest("Task must be in 'in-review' column to resolve PR conflicts");
|
|
}
|
|
|
|
if (req.body?.base !== undefined && typeof req.body.base !== "string") {
|
|
throw badRequest("base must be a string when provided");
|
|
}
|
|
|
|
const repoRoot = scopedStore.getRootDir();
|
|
const envRepo = process.env.GITHUB_REPOSITORY?.trim();
|
|
const repoInfo = envRepo
|
|
? (() => {
|
|
const [owner = "", repo = ""] = envRepo.split("/");
|
|
return owner && repo ? { owner, repo } : null;
|
|
})()
|
|
: getCurrentRepo(repoRoot);
|
|
if (!repoInfo) {
|
|
throw badRequest("Could not determine GitHub repository. Set GITHUB_REPOSITORY env var or configure git remote.");
|
|
}
|
|
|
|
const requestedBase = typeof req.body?.base === "string" ? req.body.base.trim() : "";
|
|
const defaultBaseBranch = requestedBase || await resolveDefaultPrBaseBranch(task, repoRoot);
|
|
const baseBranch = ensureSafeGitRef(defaultBaseBranch, "base branch");
|
|
const head = ensureSafeGitRef(`fusion/${task.id.toLowerCase()}`, "head branch");
|
|
const baseRef = await resolvePrBaseRef(repoRoot, baseBranch).catch(() => baseBranch);
|
|
|
|
const result = await resolvePrConflicts({
|
|
taskId: task.id,
|
|
baseRef,
|
|
rootDir: repoRoot,
|
|
store: scopedStore,
|
|
settings: await scopedStore.getSettings(),
|
|
});
|
|
|
|
if (!result.resolved) {
|
|
throw conflict(result.message, {
|
|
code: "conflict-resolution-failed",
|
|
retryable: true,
|
|
unresolvedFiles: result.conflictedFiles,
|
|
head,
|
|
base: baseBranch,
|
|
});
|
|
}
|
|
|
|
await scopedStore.logEntry(task.id, "AI resolved PR conflicts", `${head} against ${baseRef} in ${repoInfo.owner}/${repoInfo.repo}`);
|
|
if (result.pushed) {
|
|
await scopedStore.logEntry(task.id, "Pushed branch after PR conflict resolution", head);
|
|
}
|
|
|
|
const preflight = await computePrPreflight(task, repoRoot, baseBranch);
|
|
res.json({ result, preflight });
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
throw notFound(`Task ${req.params.id} not found`);
|
|
}
|
|
throw toPrApiError(err, "Failed to resolve PR conflicts");
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 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() : "";
|
|
res.json(await computePrPreflight(task, repoRoot, requestedBase));
|
|
} 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).
|
|
* Uses only persisted badge timestamps (no in-memory poller state).
|
|
*/
|
|
router.get("/tasks/:id/pr/status", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const task = await scopedStore.getTask(req.params.id);
|
|
|
|
const prList = getTaskPrList(task);
|
|
if (prList.length === 0) {
|
|
throw notFound("Task has no associated PR");
|
|
}
|
|
|
|
const primaryPr = prList[0];
|
|
|
|
// Check if data is stale (>5 minutes since last check)
|
|
const fiveMinutesMs = 5 * 60 * 1000;
|
|
const lastChecked = primaryPr.lastCheckedAt || task.updatedAt;
|
|
const lastCheckedTime = new Date(lastChecked).getTime();
|
|
const isStale = Date.now() - lastCheckedTime > fiveMinutesMs;
|
|
|
|
// Return cached data immediately
|
|
res.json({
|
|
prInfo: primaryPr,
|
|
prInfos: prList,
|
|
stale: isStale,
|
|
automationStatus: task.status ?? null,
|
|
});
|
|
|
|
// Trigger background refresh if stale (don't await, let it run)
|
|
if (isStale) {
|
|
const settings = await scopedStore.getSettings();
|
|
refreshPrInBackground(scopedStore, task.id, prList, githubToken, {
|
|
repoRoot: scopedStore.getRootDir(),
|
|
directMergeCommitStrategy: settings.directMergeCommitStrategy,
|
|
});
|
|
}
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
throw notFound(`Task ${req.params.id} not found`);
|
|
} else {
|
|
rethrowAsApiError(err);
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/tasks/:id/pr/refresh
|
|
* Force refresh PR status from GitHub API.
|
|
* Returns: Updated PrInfo
|
|
*/
|
|
router.post("/tasks/:id/pr/refresh", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore, engine } = await getProjectContext(req);
|
|
const task = await scopedStore.getTask(req.params.id);
|
|
const prList = getTaskPrList(task);
|
|
|
|
if (prList.length === 0) {
|
|
throw notFound("Task has no associated PR");
|
|
}
|
|
|
|
let owner: string;
|
|
let repo: string;
|
|
const badgeParsed = parseBadgeUrl(prList[0].url);
|
|
if (badgeParsed) {
|
|
owner = badgeParsed.owner;
|
|
repo = badgeParsed.repo;
|
|
} else {
|
|
const envRepo = process.env.GITHUB_REPOSITORY;
|
|
if (envRepo) {
|
|
const [o, r] = envRepo.split("/");
|
|
owner = o;
|
|
repo = r;
|
|
} else {
|
|
const gitRepo = getCurrentRepo(scopedStore.getRootDir());
|
|
if (!gitRepo) throw badRequest("Could not determine GitHub repository");
|
|
owner = gitRepo.owner;
|
|
repo = gitRepo.repo;
|
|
}
|
|
}
|
|
|
|
const repoKey = `${owner}/${repo}`;
|
|
if (!githubRateLimiter.canMakeRequest(repoKey)) {
|
|
const resetTime = githubRateLimiter.getResetTime(repoKey);
|
|
const retryAfter = resetTime ? Math.max(0, Math.ceil((resetTime.getTime() - Date.now()) / 1000)) : undefined;
|
|
throw new ApiError(429, "GitHub API rate limit exceeded for this repository", {
|
|
retryAfter,
|
|
resetAt: resetTime?.toISOString(),
|
|
});
|
|
}
|
|
|
|
const settings = await scopedStore.getSettings();
|
|
const client = new GitHubClient();
|
|
const refreshedEntries: Array<{
|
|
prInfo: PrInfo;
|
|
conflictDiagnostics?: PrInfo["conflictDiagnostics"];
|
|
mergeReady: boolean;
|
|
mergeable?: PrInfo["mergeable"];
|
|
blockingReasons: string[];
|
|
reviewDecision: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null;
|
|
checks: Array<{ name: string; required: boolean; state: string; detailsUrl?: string; startedAt?: string; completedAt?: string }>;
|
|
automationStatus?: string | null;
|
|
conflictReclaimQueued?: boolean;
|
|
}> = [];
|
|
const batchSize = 4;
|
|
for (let i = 0; i < prList.length; i += batchSize) {
|
|
const batch = prList.slice(i, i + batchSize);
|
|
const results = await Promise.all(batch.map(async (priorPr) => {
|
|
const reviewSnapshot = await client.getPrReviewSnapshot(owner, repo, priorPr.number);
|
|
const mergeStatus = await client.getPrMergeStatus(owner, repo, priorPr.number);
|
|
let conflictDiagnostics = mergeStatus.prInfo.conflictDiagnostics;
|
|
if (mergeStatus.prInfo.mergeable === "conflicting" && mergeStatus.prInfo.headBranch && mergeStatus.prInfo.baseBranch) {
|
|
try {
|
|
conflictDiagnostics = await client.getPrConflictDiagnostics(owner, repo, priorPr.number, {
|
|
baseBranch: mergeStatus.prInfo.baseBranch,
|
|
headBranch: mergeStatus.prInfo.headBranch,
|
|
repoRoot: scopedStore.getRootDir(),
|
|
directMergeCommitStrategy: settings.directMergeCommitStrategy,
|
|
});
|
|
} catch (err) {
|
|
console.error("[pr-conflict-diagnostics]", err);
|
|
}
|
|
} else {
|
|
conflictDiagnostics = undefined;
|
|
}
|
|
|
|
const prInfo: PrInfo = {
|
|
...priorPr,
|
|
...mergeStatus.prInfo,
|
|
mergeable: mergeStatus.prInfo.mergeable,
|
|
conflictDiagnostics,
|
|
autoMergeOnGreen: priorPr.autoMergeOnGreen,
|
|
autoMergeStrategy: priorPr.autoMergeStrategy,
|
|
lastMergeError: priorPr.lastMergeError,
|
|
lastMergeErrorAt: priorPr.lastMergeErrorAt,
|
|
draft: mergeStatus.prInfo.draft ?? mergeStatus.prInfo.isDraft,
|
|
lastCheckedAt: new Date().toISOString(),
|
|
lastReviewDecision: reviewSnapshot.decision,
|
|
};
|
|
|
|
await scopedStore.updatePrInfoByNumber(task.id, priorPr.number, prInfo);
|
|
await syncPrReviewsToTask(scopedStore, task, reviewSnapshot);
|
|
await applyChangesRequestedTransition(scopedStore, task, reviewSnapshot, prInfo);
|
|
|
|
return {
|
|
prInfo,
|
|
conflictDiagnostics: prInfo.conflictDiagnostics,
|
|
mergeReady: mergeStatus.mergeReady,
|
|
mergeable: prInfo.mergeable,
|
|
blockingReasons: mergeStatus.blockingReasons,
|
|
reviewDecision: reviewSnapshot.decision,
|
|
checks: mergeStatus.checks,
|
|
automationStatus: task.status ?? null,
|
|
conflictReclaimQueued: false,
|
|
};
|
|
}));
|
|
refreshedEntries.push(...results);
|
|
}
|
|
|
|
const anyConflict = refreshedEntries.some((entry) => entry.prInfo.mergeable === "conflicting");
|
|
let conflictReclaimQueued = false;
|
|
if (anyConflict && task.branch && task.worktree) {
|
|
const selfHealingManager =
|
|
(engine as { getSelfHealingManager?: () => { reclaimPrConflictForTask: (taskId: string) => Promise<unknown> } } | undefined)?.getSelfHealingManager?.() ??
|
|
(engine as { getRuntime?: () => { getSelfHealingManager?: () => { reclaimPrConflictForTask: (taskId: string) => Promise<unknown> } } } | undefined)
|
|
?.getRuntime?.()
|
|
?.getSelfHealingManager?.();
|
|
if (selfHealingManager) {
|
|
await selfHealingManager.reclaimPrConflictForTask(task.id);
|
|
conflictReclaimQueued = true;
|
|
}
|
|
}
|
|
|
|
const refreshedTask = await scopedStore.getTask(task.id);
|
|
const latestPrs = getTaskPrList(refreshedTask);
|
|
const primaryPr = refreshedTask.prInfo ?? latestPrs[0] ?? refreshedEntries[0]?.prInfo;
|
|
const primaryEntry = refreshedEntries.find((entry) => entry.prInfo.number === primaryPr?.number) ?? refreshedEntries[0];
|
|
if (!primaryEntry) {
|
|
throw internalError("No refreshed PR entries were produced");
|
|
}
|
|
|
|
res.json({
|
|
...primaryEntry,
|
|
prInfo: primaryEntry.prInfo,
|
|
prInfos: latestPrs,
|
|
primary: primaryEntry,
|
|
all: refreshedEntries,
|
|
automationStatus: refreshedTask.status ?? task.status ?? null,
|
|
conflictReclaimQueued,
|
|
});
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) throw err;
|
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
throw notFound(`Task ${req.params.id} not found`);
|
|
}
|
|
throw toPrApiError(err, "Failed to refresh PR status");
|
|
}
|
|
});
|
|
|
|
router.post("/tasks/:id/pr/:number/unlink", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const prNumber = Number.parseInt(req.params.number ?? "", 10);
|
|
if (!Number.isInteger(prNumber) || prNumber <= 0) {
|
|
throw badRequest("PR number must be a positive integer");
|
|
}
|
|
|
|
const task = await scopedStore.getTask(req.params.id);
|
|
const prList = getTaskPrList(task);
|
|
if (!prList.some((pr) => pr.number === prNumber)) {
|
|
throw notFound(`Task ${req.params.id} has no linked PR #${prNumber}`);
|
|
}
|
|
|
|
const updatedTask = await scopedStore.removePrInfoByNumber(task.id, prNumber);
|
|
if (!updatedTask) {
|
|
throw notFound(`Task ${req.params.id} not found`);
|
|
}
|
|
|
|
res.json({ task: updatedTask, prInfos: getTaskPrList(updatedTask) });
|
|
} 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 unlink pull request");
|
|
}
|
|
});
|
|
|
|
router.post("/tasks/:id/pr/reclaim-conflict", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore, engine } = await getProjectContext(req);
|
|
const task = await scopedStore.getTask(req.params.id);
|
|
if (!task.prInfo) {
|
|
throw notFound("Task has no associated PR");
|
|
}
|
|
if (!task.branch || !task.worktree) {
|
|
throw conflict("Task has no branch/worktree to reclaim");
|
|
}
|
|
|
|
const selfHealingManager =
|
|
(engine as { getSelfHealingManager?: () => { reclaimPrConflictForTask: (taskId: string) => Promise<unknown> } } | undefined)?.getSelfHealingManager?.() ??
|
|
(engine as { getRuntime?: () => { getSelfHealingManager?: () => { reclaimPrConflictForTask: (taskId: string) => Promise<unknown> } } } | undefined)
|
|
?.getRuntime?.()
|
|
?.getSelfHealingManager?.();
|
|
if (!selfHealingManager) {
|
|
return res.json({ queued: false, reason: "engine-unavailable" });
|
|
}
|
|
|
|
await selfHealingManager.reclaimPrConflictForTask(task.id);
|
|
res.json({ queued: true });
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) throw err;
|
|
rethrowAsApiError(err, "Failed to queue PR conflict reclaim");
|
|
}
|
|
});
|
|
|
|
router.post("/tasks/:id/pr/merge", async (req, res) => {
|
|
try {
|
|
const method = req.body?.method;
|
|
if (method && !["merge", "squash", "rebase"].includes(method)) {
|
|
throw badRequest("Invalid merge method");
|
|
}
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const task = await scopedStore.getTask(req.params.id);
|
|
const prList = getTaskPrList(task);
|
|
const requestedPr = Number.parseInt(String(req.query.pr ?? ""), 10);
|
|
const targetPr = Number.isInteger(requestedPr) && requestedPr > 0
|
|
? prList.find((pr) => pr.number === requestedPr)
|
|
: (task.prInfo ?? prList[0]);
|
|
if (!targetPr?.number) {
|
|
throw notFound("Task has no associated PR");
|
|
}
|
|
if (targetPr.status === "merged") {
|
|
await scopedStore.applyPrMergedTransition(task.id, {
|
|
agentId: "dashboard",
|
|
runId: `pr-merge-${task.id}-${Date.now()}`,
|
|
});
|
|
return res.json({ prInfo: targetPr, alreadyMerged: true });
|
|
}
|
|
const taskForPr = task.prInfo?.number === targetPr.number ? task : { ...task, prInfo: targetPr };
|
|
const prInfo = await mergeTaskPr(scopedStore, taskForPr, githubToken, method);
|
|
res.json({ prInfo });
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
throw toPrApiError(err, "Failed to merge PR");
|
|
}
|
|
});
|
|
|
|
router.post("/tasks/:id/pr/auto-merge", async (req, res) => {
|
|
try {
|
|
const { enabled, strategy } = req.body ?? {};
|
|
if (typeof enabled !== "boolean") {
|
|
throw badRequest("enabled must be a boolean");
|
|
}
|
|
if (strategy && !["merge", "squash", "rebase"].includes(strategy)) {
|
|
throw badRequest("Invalid auto-merge strategy");
|
|
}
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const task = await scopedStore.getTask(req.params.id);
|
|
const prList = getTaskPrList(task);
|
|
const requestedPr = Number.parseInt(String(req.query.pr ?? ""), 10);
|
|
const targetPr = Number.isInteger(requestedPr) && requestedPr > 0
|
|
? prList.find((pr) => pr.number === requestedPr)
|
|
: (task.prInfo ?? prList[0]);
|
|
if (!targetPr) {
|
|
throw notFound("Task has no associated PR");
|
|
}
|
|
const prInfo: PrInfo = {
|
|
...targetPr,
|
|
autoMergeOnGreen: enabled,
|
|
autoMergeStrategy: strategy ?? targetPr.autoMergeStrategy,
|
|
lastMergeError: undefined,
|
|
lastMergeErrorAt: undefined,
|
|
};
|
|
await scopedStore.updatePrInfoByNumber(task.id, prInfo.number, prInfo);
|
|
res.json({ prInfo });
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) throw err;
|
|
rethrowAsApiError(err, "Failed to set PR auto-merge");
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/tasks/:id/pr/reviews
|
|
* Fetch PR review snapshot and merged Fusion comment thread view.
|
|
*/
|
|
router.get("/tasks/:id/pr/reviews", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const task = await scopedStore.getTask(req.params.id);
|
|
const prList = getTaskPrList(task);
|
|
|
|
if (prList.length === 0) {
|
|
throw notFound("Task has no associated PR");
|
|
}
|
|
|
|
const requestedPr = Number.parseInt(String(req.query.pr ?? ""), 10);
|
|
const primaryPr = Number.isInteger(requestedPr) && requestedPr > 0
|
|
? prList.find((pr) => pr.number === requestedPr) ?? prList[0]
|
|
: prList[0];
|
|
let owner: string;
|
|
let repo: string;
|
|
const badgeParsed = parseBadgeUrl(primaryPr.url);
|
|
if (badgeParsed) {
|
|
owner = badgeParsed.owner;
|
|
repo = badgeParsed.repo;
|
|
} else {
|
|
const envRepo = process.env.GITHUB_REPOSITORY;
|
|
if (envRepo) {
|
|
const [o, r] = envRepo.split("/");
|
|
owner = o;
|
|
repo = r;
|
|
} else {
|
|
const gitRepo = getCurrentRepo(scopedStore.getRootDir());
|
|
if (!gitRepo) {
|
|
throw badRequest("Could not determine GitHub repository");
|
|
}
|
|
owner = gitRepo.owner;
|
|
repo = gitRepo.repo;
|
|
}
|
|
}
|
|
|
|
const repoKey = `${owner}/${repo}`;
|
|
if (!githubRateLimiter.canMakeRequest(repoKey)) {
|
|
const resetTime = githubRateLimiter.getResetTime(repoKey);
|
|
const retryAfter = resetTime
|
|
? Math.max(0, Math.ceil((resetTime.getTime() - Date.now()) / 1000))
|
|
: undefined;
|
|
throw new ApiError(429, "GitHub API rate limit exceeded for this repository", {
|
|
retryAfter,
|
|
resetAt: resetTime?.toISOString(),
|
|
});
|
|
}
|
|
|
|
const client = new GitHubClient();
|
|
const snapshot = await client.getPrReviewSnapshot(owner, repo, primaryPr.number);
|
|
const fusionThread = (task.comments ?? []).filter((comment) =>
|
|
comment.source === "github-review" || comment.source === "github-review-comment"
|
|
);
|
|
|
|
res.json({
|
|
snapshot,
|
|
comments: fusionThread,
|
|
prInfos: prList,
|
|
});
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
throw notFound(`Task ${req.params.id} not found`);
|
|
}
|
|
throw toPrApiError(err, "Failed to fetch PR reviews");
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/tasks/:id/pr/checks
|
|
* Fetch all PR checks (required + optional) and rollup derived from required checks.
|
|
*/
|
|
router.get("/tasks/:id/pr/checks", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const task = await scopedStore.getTask(req.params.id);
|
|
const prList = getTaskPrList(task);
|
|
|
|
if (prList.length === 0) {
|
|
throw notFound("Task has no associated PR");
|
|
}
|
|
|
|
const requestedPr = Number.parseInt(String(req.query.pr ?? ""), 10);
|
|
const primaryPr = Number.isInteger(requestedPr) && requestedPr > 0
|
|
? prList.find((pr) => pr.number === requestedPr) ?? prList[0]
|
|
: prList[0];
|
|
let owner: string;
|
|
let repo: string;
|
|
const badgeParsed = parseBadgeUrl(primaryPr.url);
|
|
if (badgeParsed) {
|
|
owner = badgeParsed.owner;
|
|
repo = badgeParsed.repo;
|
|
} else {
|
|
const envRepo = process.env.GITHUB_REPOSITORY;
|
|
if (envRepo) {
|
|
const [o, r] = envRepo.split("/");
|
|
owner = o;
|
|
repo = r;
|
|
} else {
|
|
const gitRepo = getCurrentRepo(scopedStore.getRootDir());
|
|
if (!gitRepo) {
|
|
throw badRequest("Could not determine GitHub repository");
|
|
}
|
|
owner = gitRepo.owner;
|
|
repo = gitRepo.repo;
|
|
}
|
|
}
|
|
|
|
const repoKey = `${owner}/${repo}`;
|
|
if (!githubRateLimiter.canMakeRequest(repoKey)) {
|
|
const resetTime = githubRateLimiter.getResetTime(repoKey);
|
|
const retryAfter = resetTime
|
|
? Math.max(0, Math.ceil((resetTime.getTime() - Date.now()) / 1000))
|
|
: undefined;
|
|
throw new ApiError(429, "GitHub API rate limit exceeded for this repository", {
|
|
retryAfter,
|
|
resetAt: resetTime?.toISOString(),
|
|
});
|
|
}
|
|
|
|
const client = new GitHubClient();
|
|
const checksResult = await client.getAllPrChecks(owner, repo, primaryPr.number);
|
|
|
|
res.json({
|
|
checks: checksResult.checks,
|
|
rollup: checksResult.rollupRequired,
|
|
lastCheckedAt: new Date().toISOString(),
|
|
prInfos: prList,
|
|
});
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
throw notFound(`Task ${req.params.id} not found`);
|
|
} else {
|
|
throw toPrApiError(err, "Failed to fetch PR checks");
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/tasks/:id/issue/status
|
|
* Get cached issue status for a task. Triggers background refresh if stale (>5 min).
|
|
* Uses only persisted badge timestamps (no in-memory poller state).
|
|
*/
|
|
router.get("/tasks/:id/issue/status", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const task = await scopedStore.getTask(req.params.id);
|
|
|
|
if (!task.issueInfo) {
|
|
throw notFound("Task has no associated issue");
|
|
}
|
|
|
|
const fiveMinutesMs = 5 * 60 * 1000;
|
|
const lastChecked = task.issueInfo.lastCheckedAt || task.updatedAt;
|
|
const lastCheckedTime = new Date(lastChecked).getTime();
|
|
const isStale = Date.now() - lastCheckedTime > fiveMinutesMs;
|
|
|
|
res.json({
|
|
issueInfo: task.issueInfo,
|
|
stale: isStale,
|
|
});
|
|
|
|
if (isStale) {
|
|
refreshIssueInBackground(scopedStore, task.id, task.issueInfo, githubToken);
|
|
}
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
throw notFound(`Task ${req.params.id} not found`);
|
|
} else {
|
|
rethrowAsApiError(err);
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/tasks/:id/issue/refresh
|
|
* Force refresh issue status from GitHub API.
|
|
* Returns: Updated IssueInfo
|
|
*/
|
|
router.post("/tasks/:id/issue/refresh", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const task = await scopedStore.getTask(req.params.id);
|
|
|
|
if (!task.issueInfo) {
|
|
throw notFound("Task has no associated issue");
|
|
}
|
|
|
|
let owner: string;
|
|
let repo: string;
|
|
|
|
// Get owner/repo from badge URL first, then fall back to env/git
|
|
const badgeParsed = parseBadgeUrl(task.issueInfo.url);
|
|
if (badgeParsed) {
|
|
owner = badgeParsed.owner;
|
|
repo = badgeParsed.repo;
|
|
} else {
|
|
const envRepo = process.env.GITHUB_REPOSITORY;
|
|
if (envRepo) {
|
|
const [o, r] = envRepo.split("/");
|
|
owner = o;
|
|
repo = r;
|
|
} else {
|
|
const gitRepo = getCurrentRepo(scopedStore.getRootDir());
|
|
if (!gitRepo) {
|
|
throw badRequest("Could not determine GitHub repository");
|
|
}
|
|
owner = gitRepo.owner;
|
|
repo = gitRepo.repo;
|
|
}
|
|
}
|
|
|
|
const repoKey = `${owner}/${repo}`;
|
|
if (!githubRateLimiter.canMakeRequest(repoKey)) {
|
|
const resetTime = githubRateLimiter.getResetTime(repoKey);
|
|
const retryAfter = resetTime
|
|
? Math.max(0, Math.ceil((resetTime.getTime() - Date.now()) / 1000))
|
|
: undefined;
|
|
throw new ApiError(429, "GitHub API rate limit exceeded for this repository", {
|
|
retryAfter,
|
|
resetAt: resetTime?.toISOString(),
|
|
});
|
|
}
|
|
|
|
const client = new GitHubClient(githubToken);
|
|
const issueInfo = await client.getIssueStatus(owner, repo, task.issueInfo.number);
|
|
|
|
if (!issueInfo) {
|
|
throw notFound(`Issue #${task.issueInfo.number} not found in ${owner}/${repo}`);
|
|
}
|
|
|
|
const updatedIssueInfo = {
|
|
...issueInfo,
|
|
lastCheckedAt: new Date().toISOString(),
|
|
};
|
|
|
|
await scopedStore.updateIssueInfo(task.id, updatedIssueInfo);
|
|
res.json(updatedIssueInfo);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
throw notFound(`Task ${req.params.id} not found`);
|
|
} else if ((err instanceof Error ? err.message : String(err)).includes("not found")) {
|
|
throw notFound(err instanceof Error ? err.message : String(err));
|
|
} else {
|
|
rethrowAsApiError(err);
|
|
}
|
|
}
|
|
});
|
|
|
|
|
|
}
|