feat(FN-2560): merge fusion/fn-2560

This commit is contained in:
gsxdsm
2026-04-26 09:41:06 -07:00
parent ba023cb17d
commit 704a4a7e8d
4 changed files with 3126 additions and 3351 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -37,6 +37,9 @@ The context provides core cross-cutting plumbing:
- `register-chat-routes.ts` — chat session/list/mutation/stream routes
- `register-messaging-scripts.ts` — scripts API and mailbox/message routes
- `register-git-github.ts` — git/GitHub workflows and related helpers
- Git plumbing routes: `/git/remotes*`, `/git/status`, `/git/commits*`, `/git/branches*`, `/git/worktrees`, `/git/fetch|pull|push`, `/git/stashes*`, `/git/diff*`, `/git/changes`, `/git/stage|unstage|commit|discard`
- GitHub import/integration routes: `/github/issues/*`, `/github/pulls/*`, `/github/webhooks`, `/github/batch/status` (includes shared batch-import rate limiter state + reset export)
- Task-scoped GitHub routes: `/tasks/:id/pr/*` and `/tasks/:id/issue/*` status/refresh/create flows
- `register-model-routes.ts``/models` endpoint, favorites projection, and `useClaudeCli` filtering for `pi-claude-cli` entries
- `register-auth-routes.ts` — auth/provider domain (`/auth/status`, `/auth/login`, `/auth/logout`, `/auth/api-key`, `/auth/claude-cli`, `/providers/claude-cli/status`)
- `register-usage-routes.ts``/usage` endpoint with `fetchAllProviderUsage(options?.authStorage)` integration

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,9 @@
import { createReadStream } from "node:fs";
import { access } from "node:fs/promises";
import type { TaskStore, Task, TaskDetail, Column } from "@fusion/core";
import { COLUMNS, VALID_TRANSITIONS, getCurrentRepo } from "@fusion/core";
import { GitHubClient, parseBadgeUrl } from "../github.js";
import { githubRateLimiter } from "../github-poll.js";
import { COLUMNS, VALID_TRANSITIONS } from "@fusion/core";
import { listFiles, readFile, writeFile, scanMarkdownFiles, FileServiceError } from "../file-service.js";
import { ApiError, badRequest, conflict, notFound } from "../api-error.js";
import { ApiError, badRequest, notFound } from "../api-error.js";
import type { ApiRoutesContext } from "./types.js";
interface TaskWorkflowRouteDeps {
@@ -20,9 +18,6 @@ interface TaskWorkflowRouteDeps {
sessionFilesCache: Map<string, { files: string[]; expiresAt: number }>;
fileDiffsCache: Map<string, { files: Array<{ path: string; status: "added" | "modified" | "deleted" | "renamed"; diff: string; oldPath?: string }>; expiresAt: number }>;
triggerCommentWakeForAssignedAgent: (scopedStore: TaskStore, task: Task, wake: { triggeringCommentType: "steering" | "task" | "pr"; triggeringCommentIds?: string[]; triggerDetail: string }) => Promise<void>;
refreshPrInBackground: (scopedStore: TaskStore, taskId: string, prInfo: import("@fusion/core").PrInfo, githubToken?: string) => void;
refreshIssueInBackground: (scopedStore: TaskStore, taskId: string, issueInfo: import("@fusion/core").IssueInfo, githubToken?: string) => void;
githubToken?: string;
}
export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWorkflowRouteDeps): void {
@@ -39,9 +34,6 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
sessionFilesCache,
fileDiffsCache,
triggerCommentWakeForAssignedAgent,
refreshPrInBackground,
refreshIssueInBackground,
githubToken,
} = deps;
const TASK_DETAIL_ACTIVITY_LOG_LIMIT = taskDetailActivityLogLimit;
@@ -1745,345 +1737,6 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
}
});
// ── 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");
}
if (task.prInfo) {
throw conflict(`Task already has PR #${task.prInfo.number}: ${task.prInfo.url}`);
}
// 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(),
});
}
// Create the PR
const client = new GitHubClient();
const prInfo = await client.createPr({
owner,
repo,
title,
body,
head: branchName,
base,
});
// Store PR info
await scopedStore.updatePrInfo(task.id, prInfo);
await scopedStore.logEntry(task.id, "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 {
rethrowAsApiError(err, "Failed to create PR");
}
}
});
/**
* 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);
if (!task.prInfo) {
throw notFound("Task has no associated PR");
}
// Check if data is stale (>5 minutes since last check)
const fiveMinutesMs = 5 * 60 * 1000;
const lastChecked = task.prInfo.lastCheckedAt || task.updatedAt;
const lastCheckedTime = new Date(lastChecked).getTime();
const isStale = Date.now() - lastCheckedTime > fiveMinutesMs;
// Return cached data immediately
res.json({
prInfo: task.prInfo,
stale: isStale,
automationStatus: task.status ?? null,
});
// Trigger background refresh if stale (don't await, let it run)
if (isStale) {
refreshPrInBackground(scopedStore, task.id, task.prInfo, 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/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 } = await getProjectContext(req);
const task = await scopedStore.getTask(req.params.id);
if (!task.prInfo) {
throw notFound("Task has no associated PR");
}
// Get owner/repo from badge URL first, then fall back to env/git
let owner: string;
let repo: string;
const badgeParsed = parseBadgeUrl(task.prInfo.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;
}
}
// 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(),
});
}
// Fetch fresh PR status + merge readiness
const client = new GitHubClient();
const mergeStatus = await client.getPrMergeStatus(owner, repo, task.prInfo.number);
const prInfo = {
...mergeStatus.prInfo,
lastCheckedAt: new Date().toISOString(),
};
// Update stored PR info
await scopedStore.updatePrInfo(task.id, prInfo);
res.json({
prInfo,
mergeReady: mergeStatus.mergeReady,
blockingReasons: mergeStatus.blockingReasons,
reviewDecision: mergeStatus.reviewDecision,
checks: mergeStatus.checks,
automationStatus: task.status ?? null,
});
} 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);
}
}
});
/**
* 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);
}
}
});
// ── File API Routes ───────────────────────────────────────────────
/**