feat(FN-3870): wire GitHub tracking issue creation

Implements GitHub tracking issue creation (FN-3870) for the dashboard, wiring new routes for planning subtasks and task workflows, adding a dedicated `github-tracking` module with issue creation and auth fallback logic, and updating the main `github.ts` with enhanced capabilities — backed by compreh

Fusion-Task-Id: FN-3870
This commit is contained in:
Fusion
2026-05-09 23:16:29 -07:00
committed by gsxdsm
parent 4ccef83c2c
commit 6cab8f985e
11 changed files with 467 additions and 6 deletions

View File

@@ -0,0 +1,137 @@
import { describe, expect, it, vi } from "vitest";
import type { Task } from "@fusion/core";
import { maybeCreateTrackingIssue } from "../github-tracking.js";
function buildTask(overrides: Partial<Task> = {}): Task {
return {
id: "FN-1",
description: "desc",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
} as Task;
}
describe("maybeCreateTrackingIssue", () => {
it("returns tracking_disabled when not enabled", async () => {
const result = await maybeCreateTrackingIssue(buildTask({ githubTracking: { enabled: false } }), {
taskStore: {} as any,
githubClient: {} as any,
projectSettings: {},
globalSettings: {},
});
expect(result).toEqual({ created: false, reason: "tracking_disabled" });
});
it("returns issue_already_linked when issue already exists", async () => {
const result = await maybeCreateTrackingIssue(buildTask({
githubTracking: {
enabled: true,
issue: { owner: "o", repo: "r", number: 1, url: "https://github.com/o/r/issues/1", createdAt: "2026-01-01T00:00:00.000Z" },
},
}), {
taskStore: {} as any,
githubClient: {} as any,
projectSettings: {},
globalSettings: {},
});
expect(result).toEqual({ created: false, reason: "issue_already_linked" });
});
it("returns github_import_source for imported tasks", async () => {
const result = await maybeCreateTrackingIssue(buildTask({
githubTracking: { enabled: true },
sourceType: "github_import",
}), {
taskStore: {} as any,
githubClient: {} as any,
projectSettings: {},
globalSettings: {},
});
expect(result).toEqual({ created: false, reason: "github_import_source" });
});
it("prefers task repo override over project/global defaults", async () => {
const createIssue = vi.fn().mockResolvedValue({
owner: "task-owner",
repo: "task-repo",
number: 11,
htmlUrl: "https://github.com/task-owner/task-repo/issues/11",
createdAt: "2026-01-01T00:00:00.000Z",
});
await maybeCreateTrackingIssue(buildTask({
title: "Test",
githubTracking: { enabled: true, repoOverride: "task-owner/task-repo" },
}), {
taskStore: { linkGithubIssue: vi.fn(), recordActivity: vi.fn() } as any,
githubClient: { createIssue } as any,
projectSettings: { githubTrackingDefaultRepo: "project-owner/project-repo" } as any,
globalSettings: { githubTrackingDefaultRepo: "global-owner/global-repo" } as any,
});
expect(createIssue).toHaveBeenCalledWith(expect.objectContaining({ owner: "task-owner", repo: "task-repo" }));
});
it("creates issue, links metadata, and records activity", async () => {
const createIssue = vi.fn().mockResolvedValue({
owner: "o",
repo: "r",
number: 12,
htmlUrl: "https://github.com/o/r/issues/12",
createdAt: "2026-01-01T00:00:00.000Z",
});
const linkGithubIssue = vi.fn();
const recordActivity = vi.fn();
const result = await maybeCreateTrackingIssue(buildTask({ title: "Test", githubTracking: { enabled: true } }), {
taskStore: { linkGithubIssue, recordActivity } as any,
githubClient: { createIssue } as any,
projectSettings: {},
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
logger: console,
});
expect(result.created).toBe(true);
expect(createIssue).toHaveBeenCalledTimes(1);
expect(linkGithubIssue).toHaveBeenCalledWith("FN-1", expect.objectContaining({ owner: "o", repo: "r", number: 12 }));
expect(recordActivity).toHaveBeenCalledWith(expect.objectContaining({
metadata: expect.objectContaining({ type: "github-issue-created", repo: "o/r", number: 12 }),
}));
});
it("returns no_repo_configured and records activity", async () => {
const recordActivity = vi.fn();
const warn = vi.fn();
const result = await maybeCreateTrackingIssue(buildTask({ githubTracking: { enabled: true } }), {
taskStore: { recordActivity } as any,
githubClient: {} as any,
projectSettings: {},
globalSettings: {},
logger: { warn, info: vi.fn() },
});
expect(result).toEqual({ created: false, reason: "no_repo_configured" });
expect(recordActivity).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalled();
});
it("swallows github errors", async () => {
const warn = vi.fn();
const result = await maybeCreateTrackingIssue(buildTask({ githubTracking: { enabled: true } }), {
taskStore: { recordActivity: vi.fn() } as any,
githubClient: { createIssue: vi.fn().mockRejectedValue(new Error("boom")) } as any,
projectSettings: { githubTrackingDefaultRepo: "o/r" } as any,
globalSettings: {},
logger: { warn, info: vi.fn() },
});
expect(result).toEqual({ created: false, reason: "github_error" });
expect(warn).toHaveBeenCalled();
});
});

View File

@@ -65,6 +65,63 @@ describe("GitHubClient", () => {
});
});
describe("createIssue", () => {
it("uses gh path when authenticated", async () => {
mockRunGhJsonAsync.mockResolvedValue({ url: "https://github.com/o/r/issues/8", number: 8, createdAt: "2026-01-02T00:00:00Z" } as any);
const issue = await client.createIssue({ owner: "o", repo: "r", title: "t", body: "b", labels: ["bug"] });
expect(mockRunGhJsonAsync).toHaveBeenCalled();
expect(issue.number).toBe(8);
});
it("falls back to API when gh path fails and token is configured", async () => {
const clientWithToken = new GitHubClient("ghp_token");
mockRunGhJsonAsync.mockRejectedValue(new Error("gh failed"));
const fetchSpy = vi.spyOn(global, "fetch" as any).mockResolvedValue({
ok: true,
status: 201,
json: async () => ({ number: 7, html_url: "https://github.com/o/r/issues/7", created_at: "2026-01-01T00:00:00Z" }),
} as any);
const issue = await clientWithToken.createIssue({ owner: "o", repo: "r", title: "t", body: "b" });
expect(issue).toEqual({ owner: "o", repo: "r", number: 7, htmlUrl: "https://github.com/o/r/issues/7", createdAt: "2026-01-01T00:00:00Z" });
expect(mockRunGhJsonAsync).toHaveBeenCalled();
fetchSpy.mockRestore();
});
it("uses API path when gh auth is unavailable and token is configured", async () => {
mockIsGhAvailable.mockReturnValue(false);
const clientWithToken = new GitHubClient("ghp_token");
const fetchSpy = vi.spyOn(global, "fetch" as any).mockResolvedValue({
ok: true,
status: 201,
json: async () => ({ number: 9, html_url: "https://github.com/o/r/issues/9", created_at: "2026-01-03T00:00:00Z" }),
} as any);
const issue = await clientWithToken.createIssue({ owner: "o", repo: "r", title: "t", body: "b" });
expect(issue.number).toBe(9);
fetchSpy.mockRestore();
});
it("throws when gh auth unavailable and no token provided", async () => {
mockIsGhAvailable.mockReturnValue(false);
await expect(client.createIssue({ owner: "o", repo: "r", title: "t", body: "b" })).rejects.toThrow("GitHub CLI (gh) is not available");
});
it("surfaces 422 API failures with cause", async () => {
mockIsGhAvailable.mockReturnValue(false);
const clientWithToken = new GitHubClient("ghp_token");
vi.spyOn(global, "fetch" as any).mockResolvedValue({ ok: false, status: 422, statusText: "Unprocessable", json: async () => ({ message: "Validation Failed" }) } as any);
await expect(clientWithToken.createIssue({ owner: "o", repo: "r", title: "t", body: "b" })).rejects.toThrow("Failed to create GitHub issue");
});
it("surfaces 404 API failures", async () => {
mockIsGhAvailable.mockReturnValue(false);
const clientWithToken = new GitHubClient("ghp_token");
vi.spyOn(global, "fetch" as any).mockResolvedValue({ ok: false, status: 404, statusText: "Not Found", json: async () => ({ message: "Not Found" }) } as any);
await expect(clientWithToken.createIssue({ owner: "o", repo: "r", title: "t", body: "b" })).rejects.toThrow("Failed to create GitHub issue");
});
});
describe("createPr", () => {
const mockPrParams: CreatePrParams = {
owner: "test-owner",

View File

@@ -0,0 +1,88 @@
import type { GlobalSettings, ProjectSettings, Task, TaskStore } from "@fusion/core";
import type { CreatedIssue } from "./github.js";
import type { GitHubClient } from "./github.js";
export interface MaybeCreateTrackingIssueDeps {
taskStore: TaskStore;
githubClient: GitHubClient;
projectSettings: ProjectSettings;
globalSettings: GlobalSettings;
logger?: Pick<Console, "warn" | "info">;
}
function parseRepo(value: string | undefined): { owner: string; repo: string } | null {
if (!value) return null;
const trimmed = value.trim();
const [owner, repo, ...rest] = trimmed.split("/");
if (!owner || !repo || rest.length > 0) return null;
return { owner, repo };
}
export async function maybeCreateTrackingIssue(
task: Task,
deps: MaybeCreateTrackingIssueDeps,
): Promise<{ created: false; reason: string } | { created: true; issue: CreatedIssue }> {
const tracking = task.githubTracking;
if (tracking?.enabled !== true) {
return { created: false, reason: "tracking_disabled" };
}
if (tracking.issue) {
return { created: false, reason: "issue_already_linked" };
}
if (task.sourceType === "github_import") {
return { created: false, reason: "github_import_source" };
}
const repo =
parseRepo(tracking.repoOverride) ??
parseRepo(deps.projectSettings.githubTrackingDefaultRepo) ??
parseRepo(deps.globalSettings.githubTrackingDefaultRepo);
if (!repo) {
deps.logger?.warn?.(`[github-tracking] No repo configured for ${task.id}`);
await deps.taskStore.recordActivity({
type: "task:updated",
taskId: task.id,
taskTitle: task.title,
details: "GitHub tracking issue not created: no repository configured",
metadata: { type: "github-tracking-no-repo" },
});
return { created: false, reason: "no_repo_configured" };
}
const title = `[${task.id}] ${task.title ?? task.description.slice(0, 80)}`;
const body = `Tracking issue for ${task.id}.\n\n_Summary placeholder — populated by FN-3871._`;
try {
const issue = await deps.githubClient.createIssue({ owner: repo.owner, repo: repo.repo, title, body });
await deps.taskStore.linkGithubIssue(task.id, {
owner: repo.owner,
repo: repo.repo,
number: issue.number,
url: issue.htmlUrl,
createdAt: issue.createdAt,
});
await deps.taskStore.recordActivity({
type: "task:updated",
taskId: task.id,
taskTitle: task.title,
details: `Linked tracking issue ${repo.owner}/${repo.repo}#${issue.number}`,
metadata: {
type: "github-issue-created",
repo: `${repo.owner}/${repo.repo}`,
number: issue.number,
htmlUrl: issue.htmlUrl,
},
});
return { created: true, issue };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
deps.logger?.warn?.(`[github-tracking] Failed to create issue for ${task.id} in ${repo.owner}/${repo.repo}: ${message}`);
return { created: false, reason: "github_error" };
}
}

View File

@@ -45,6 +45,22 @@ export interface CreatePrParams {
base?: string;
}
export interface CreateIssueParams {
owner: string;
repo: string;
title: string;
body: string;
labels?: string[];
}
export interface CreatedIssue {
owner: string;
repo: string;
number: number;
htmlUrl: string;
createdAt: string;
}
export interface PrComment {
id: number;
body: string;
@@ -403,7 +419,7 @@ export class GitHubClient {
throw new Error(getGhErrorMessage(err));
}
}
// Fall back to REST API
if (this.token) {
return this.createPrWithApi(params);
@@ -411,6 +427,85 @@ export class GitHubClient {
throw new Error("GitHub CLI (gh) is not available or not authenticated, and no GITHUB_TOKEN provided. Run 'gh auth login' or set GITHUB_TOKEN.");
}
async createIssue(params: CreateIssueParams): Promise<CreatedIssue> {
if (this.hasGhAuth()) {
try {
return await this.createIssueWithGh(params);
} catch (error) {
if (this.token) {
try {
return await this.createIssueWithApi(params);
} catch (apiError) {
throw new Error(`Failed to create GitHub issue in ${params.owner}/${params.repo}`, { cause: apiError });
}
}
throw new Error(`Failed to create GitHub issue in ${params.owner}/${params.repo}`, { cause: error });
}
}
if (this.token) {
try {
return await this.createIssueWithApi(params);
} catch (error) {
throw new Error(`Failed to create GitHub issue in ${params.owner}/${params.repo}`, { cause: error });
}
}
throw new Error("GitHub CLI (gh) is not available or not authenticated, and no GITHUB_TOKEN provided. Run 'gh auth login' or set GITHUB_TOKEN.");
}
private async createIssueWithGh(params: CreateIssueParams): Promise<CreatedIssue> {
const issue = await runGhJsonAsync<{ url: string; number: number; createdAt: string }>([
"issue",
"create",
"--repo",
`${params.owner}/${params.repo}`,
"--title",
params.title,
"--body",
params.body,
...(params.labels && params.labels.length > 0 ? ["--label", params.labels.join(",")] : []),
"--json",
"url,number,createdAt",
]);
return {
owner: params.owner,
repo: params.repo,
number: issue.number,
htmlUrl: issue.url,
createdAt: issue.createdAt,
};
}
private async createIssueWithApi(params: CreateIssueParams): Promise<CreatedIssue> {
const url = `${this.baseUrl}/repos/${encodeURIComponent(params.owner)}/${encodeURIComponent(params.repo)}/issues`;
const result = await this.fetchThrottled<{
number: number;
html_url: string;
created_at: string;
}>(url, {
method: "POST",
body: JSON.stringify({
title: params.title,
body: params.body,
labels: params.labels,
}),
});
if (!result.success || !result.data) {
throw new Error(result.error ?? "GitHub API error");
}
return {
owner: params.owner,
repo: params.repo,
number: result.data.number,
htmlUrl: result.data.html_url,
createdAt: result.data.created_at,
};
}
private createPrWithGh(params: CreatePrParams): PrInfo {
const { owner: paramOwner, repo: paramRepo, title, body, head, base } = params;
const { owner, repo } = this.resolveRepo(paramOwner, paramRepo);

View File

@@ -11,7 +11,8 @@ export {
type RuntimeLogSink,
} from "./runtime-logger.js";
export { createSkillsAdapter, getProjectSettingsPath, type SkillsAdapter, type DiscoveredSkill, type CatalogEntry, type CatalogFetchResult, type ToggleSkillResult, type UpstreamError, type UpstreamErrorCode, type SkillContent, type SkillFileEntry } from "./skills-adapter.js";
export { GitHubClient, isPrMergeReady, type PrMergeStatus, type PrCheckStatus, type ReviewDecision, type MergePrParams, type FindPrParams } from "./github.js";
export { GitHubClient, isPrMergeReady, type PrMergeStatus, type PrCheckStatus, type ReviewDecision, type MergePrParams, type FindPrParams, type CreateIssueParams, type CreatedIssue } from "./github.js";
export { maybeCreateTrackingIssue, type MaybeCreateTrackingIssueDeps } from "./github-tracking.js";
export { rateLimit, RATE_LIMITS, type RateLimitOptions } from "./rate-limit.js";
export { GitHubPollingService, type GitHubPollingServiceOptions, type TaskWatchInput, type WatchedBadgeType } from "./github-poll.js";
export { GitHubIssueCommentService, DEFAULT_COMMENT_TEMPLATE } from "./github-issue-comment.js";

View File

@@ -34,6 +34,8 @@ import {
} from "@fusion/core";
import type { ServerOptions } from "./server.js";
import { verifyWebhookSignature } from "./github-webhooks.js";
import { GitHubClient } from "./github.js";
import { maybeCreateTrackingIssue } from "./github-tracking.js";
import { AiSessionStore, SESSION_CLEANUP_DEFAULT_MAX_AGE_MS } from "./ai-session-store.js";
import { getSession as getPlanningSession, cleanupSession as cleanupPlanningSession } from "./planning.js";
import { getSubtaskSession, cleanupSubtaskSession } from "./subtask-breakdown.js";
@@ -4793,6 +4795,27 @@ async function executeAiPromptStep(
}
}
async function maybeCreateTaskTrackingIssue(taskStore: TaskStore, task: Task): Promise<void> {
const projectSettings = await taskStore.getSettings();
const globalSettings = (await taskStore.getGlobalSettingsStore?.()?.getSettings?.()) ?? {};
const authMode = projectSettings.githubAuthMode;
const token = authMode === "token"
? projectSettings.githubAuthToken
: undefined;
try {
await maybeCreateTrackingIssue(task, {
taskStore,
githubClient: new GitHubClient(token),
projectSettings,
globalSettings,
logger: console,
});
} catch {
// best-effort only
}
}
async function executeCreateTaskStep(
step: import("@fusion/core").AutomationStep,
startedAt: string,
@@ -4823,6 +4846,7 @@ async function executeCreateTaskStep(
sourceMetadata: { stepId: step.id },
},
});
await maybeCreateTaskTrackingIssue(taskStore, task);
return {
stepId: step.id,

View File

@@ -7,11 +7,34 @@ import {
type TaskStore,
} from "@fusion/core";
import { ApiError, badRequest, notFound, rateLimited } from "../api-error.js";
import { GitHubClient } from "../github.js";
import { maybeCreateTrackingIssue } from "../github-tracking.js";
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";
async function maybeCreateTaskTrackingIssue(taskStore: TaskStore, task: import("@fusion/core").Task): Promise<void> {
const projectSettings = await taskStore.getSettings();
const globalSettings = (await taskStore.getGlobalSettingsStore?.()?.getSettings?.()) ?? {};
const authMode = projectSettings.githubAuthMode;
const token = authMode === "token"
? projectSettings.githubAuthToken
: undefined;
try {
await maybeCreateTrackingIssue(task, {
taskStore,
githubClient: new GitHubClient(token),
projectSettings,
globalSettings,
logger: console,
});
} catch {
// best-effort only
}
}
interface PlanningSubtaskRouteDeps {
store: TaskStore;
aiSessionStore?: AiSessionStore;
@@ -244,6 +267,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
baseBranch: resolvedBaseBranch,
branchContext: planningBranchContext,
});
await maybeCreateTaskTrackingIssue(scopedStore, task);
tempIdToTaskId.set(item.tempId, task.id);
createdTasks.push(task);
@@ -1076,6 +1100,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
branch: resolvedBranch,
baseBranch: resolvedBaseBranch,
});
await maybeCreateTaskTrackingIssue(scopedStore, task);
// Update task with suggested size if provided
if (summary.suggestedSize) {
@@ -1240,6 +1265,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
baseBranch: resolvedBaseBranch,
branchContext: planningBranchContext,
});
await maybeCreateTaskTrackingIssue(scopedStore, task);
tempIdToTaskId.set(item.id, task.id);
createdTasks.push(task);

View File

@@ -14,6 +14,7 @@ import {
getCurrentRepo,
} from "@fusion/core";
import { GitHubClient } from "../github.js";
import { maybeCreateTrackingIssue } from "../github-tracking.js";
import { parseGitHubBadgeUrl } from "./register-git-github.js";
import { planTaskWorktreePath } from "@fusion/engine";
import { ApiError, badRequest, conflict, notFound } from "../api-error.js";
@@ -96,6 +97,27 @@ async function buildDirectTaskReviewData(task: Task, store: TaskStore): Promise<
};
}
async function maybeCreateTaskTrackingIssue(taskStore: TaskStore, task: Task, optionsToken?: string): Promise<void> {
const projectSettings = await taskStore.getSettings();
const globalSettings = (await taskStore.getGlobalSettingsStore?.()?.getSettings?.()) ?? {};
const authMode = projectSettings.githubAuthMode;
const token = authMode === "token"
? projectSettings.githubAuthToken ?? optionsToken
: optionsToken;
try {
await maybeCreateTrackingIssue(task, {
taskStore,
githubClient: new GitHubClient(token),
projectSettings,
globalSettings,
logger: console,
});
} catch {
// best-effort only
}
}
interface TaskWorkflowRouteDeps {
runtimeLogger: { error: (message: string, data?: Record<string, unknown>) => void; warn: (message: string, data?: Record<string, unknown>) => void };
upload: { single: (name: string) => unknown };
@@ -309,6 +331,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
createInput,
{ onSummarize, settings: { autoSummarizeTitles: settings.autoSummarizeTitles } },
);
await maybeCreateTaskTrackingIssue(scopedStore, task, options?.githubToken);
res.status(201).json(task);
return;
}
@@ -343,6 +366,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
createdTask = await scopedStore.createTaskWithReservedId(createInput, {
taskId: reservation.taskId,
});
await maybeCreateTaskTrackingIssue(scopedStore, createdTask, options?.githubToken);
const replicatedPayload = buildMeshReplicatedTaskCreatePayload({
taskId: createdTask.id,