feat(FN-4942): complete Step 2 — route tracking creation via hook

Fusion-Task-Id: FN-4942
Fusion-Task-Lineage: 20865705-e5ec-4261-932c-4d47c2d8dc12
This commit is contained in:
Fusion (runfusion.ai)
2026-05-17 17:07:43 -07:00
committed by gsxdsm
parent fc98f46196
commit 8ef7fe2b18
5 changed files with 49 additions and 22 deletions

View File

@@ -182,6 +182,25 @@ describe("registerGithubTrackingHook", () => {
spy.mockRestore();
});
it("passes registered hook githubToken fallback when settings token is missing", async () => {
registerGithubTrackingHook({ githubToken: "hook-token" });
await store.updateSettings({
githubTrackingDefaultRepo: "o/r",
githubAuthMode: "token",
});
await store.createTask({
description: "hook token fallback",
title: "Hook token fallback",
githubTracking: { enabled: true },
});
await vi.waitFor(() => {
expect(mockCreateIssue).toHaveBeenCalledTimes(1);
});
});
it("prefers settings token over request token", async () => {
await store.updateSettings({ githubAuthToken: "settings-token" });
const task = await store.createTask({
@@ -401,6 +420,31 @@ describe("registerGithubTrackingHook", () => {
expect(mockCreateIssue).not.toHaveBeenCalled();
});
it("creates exactly one issue per planning-style createTask invocation", async () => {
registerGithubTrackingHook();
await store.updateSettings({
githubTrackingEnabledByDefault: true,
githubTrackingDefaultRepo: "owner/repo",
githubAuthMode: "token",
githubAuthToken: "tok",
});
await store.createTask({
title: "Planning single task",
description: "planning summary output",
source: { sourceType: "api" },
});
await store.createTask({
title: "Planning subtask A",
description: "planning subtask output",
source: { sourceType: "api", sourceMetadata: { planningSessionId: "sess-1" } },
});
expect(mockCreateIssue).toHaveBeenCalledTimes(2);
});
it("creates issue during createTask await when summarization is disabled", async () => {
registerGithubTrackingHook();

View File

@@ -39,13 +39,13 @@ export async function createTrackingIssueForTask(
* Idempotent: calling this twice replaces the previous hook (no chaining).
*/
export function registerGithubTrackingHook(
options?: { logger?: Pick<Console, "warn" | "info"> },
options?: { githubToken?: string; logger?: Pick<Console, "warn" | "info"> },
): void {
const logger = options?.logger ?? console;
setTaskCreatedHook(async (task: Task, store: TaskStore) => {
try {
await createTrackingIssueForTask(store, task, { logger });
await createTrackingIssueForTask(store, task, { githubToken: options?.githubToken, logger });
} catch (error) {
// Best-effort: never propagate out of the hook.
const message = error instanceof Error ? error.message : String(error);

View File

@@ -11,7 +11,6 @@ import { writeSSEEvent, type SessionBufferedEvent } from "../sse-buffer.js";
import type { AiSessionStore } from "../ai-session-store.js";
import type { ApiRoutesContext } from "./types.js";
import { derivePerTaskBranch, resolveBranchAssignmentContext, resolveBranchSelection } from "./branch-selection.js";
import { createTrackingIssueForTask } from "../github-tracking-hook.js";
interface PlanningSubtaskRouteDeps {
store: TaskStore;
@@ -25,15 +24,6 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
const { router, getProjectContext, planningLogger, rethrowAsApiError } = ctx;
const { aiSessionStore, checkSessionLock, parseLastEventId, replayBufferedSSE } = deps;
const dispatchTrackingIssueCreation = (scopedStore: TaskStore, task: Awaited<ReturnType<TaskStore["createTask"]>>): void => {
void createTrackingIssueForTask(scopedStore, task, { logger: planningLogger }).catch((error: unknown) => {
planningLogger.warn("Background planning tracking-issue creation failed", {
taskId: task.id,
error: error instanceof Error ? error.message : String(error),
});
});
};
// ── Planning Mode Routes ──────────────────────────────────────────────────
// UTILITY PATH: Planning and subtask session routes are on a separate control-plane lane.
// They must NOT be gated on task-lane saturation (maxConcurrent, semaphore, queue depth).
@@ -1087,7 +1077,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
source: { sourceType: "api" },
branch: resolvedBranch,
baseBranch: resolvedBaseBranch,
}, { invokeTaskCreatedHook: false });
});
// Update task with suggested size if provided
if (summary.suggestedSize) {
@@ -1105,7 +1095,6 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
}
res.status(201).json(task);
dispatchTrackingIssueCreation(scopedStore, task);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
@@ -1281,7 +1270,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
branch: taskBranch,
baseBranch: resolvedBaseBranch,
branchContext: planningBranchContext,
}, { invokeTaskCreatedHook: false });
});
tempIdToTaskId.set(item.id, task.id);
createdTasks.push(task);
@@ -1309,9 +1298,6 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
cleanupSession(planningSessionId);
res.status(201).json({ tasks: createdTasks });
for (const task of createdTasks) {
dispatchTrackingIssueCreation(scopedStore, task);
}
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;

View File

@@ -458,7 +458,6 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
createInput,
{ onSummarize, settings: { autoSummarizeTitles: settings.autoSummarizeTitles } },
);
await createTrackingIssueForTask(scopedStore, task, { githubToken: options?.githubToken });
if (acknowledgedDuplicateIds.length > 0) {
try {
@@ -732,7 +731,6 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
try {
const { store: scopedStore } = await getProjectContext(req);
const newTask = await scopedStore.duplicateTask(req.params.id);
await createTrackingIssueForTask(scopedStore, newTask, { githubToken: options?.githubToken });
res.status(201).json(newTask);
} catch (err: unknown) {
if (err instanceof ApiError) {
@@ -759,7 +757,6 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
}
const refinedTask = await scopedStore.refineTask(req.params.id, trimmedFeedback);
await createTrackingIssueForTask(scopedStore, refinedTask, { githubToken: options?.githubToken });
await scopedStore.logEntry(req.params.id, "Refinement requested", trimmedFeedback);
res.status(201).json(refinedTask);
} catch (err: unknown) {

View File

@@ -537,7 +537,7 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
// (HTTP routes, CLI, pi extension, mission triage, etc.) triggers
// GitHub tracking issue creation when enabled.
try {
registerGithubTrackingHook();
registerGithubTrackingHook({ githubToken: options?.githubToken });
} catch (error) {
// Some unit tests mock @fusion/core with narrow export surfaces. Keep
// server bootstrap resilient when hook registration is unavailable.