feat(FN-2560): merge fusion/fn-2560
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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 ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user