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,5 @@
---
"@runfusion/fusion": patch
---
Create a tracking GitHub issue when a Fusion task is created with GitHub tracking enabled. Default is OFF; no GitHub calls are made when tracking is disabled.

View File

@@ -1234,6 +1234,10 @@ Git dashboard routes are registered in `register-git-github.ts`.
| POST | `/api/git/commit` | Create a commit from staged changes with a required message. |
| POST | `/api/git/discard` | Discard working-tree changes for specified files. |
### GitHub tracking lifecycle (task creation)
When a task is created, Fusion only attempts GitHub issue creation if per-task tracking is explicitly enabled (`task.githubTracking.enabled === true`). The lifecycle then resolves the repo in priority order: task override (`repoOverride`) → project `githubTrackingDefaultRepo` → global `githubTrackingDefaultRepo`. If no repo resolves, task creation still succeeds and an activity entry records the skip reason. GitHub API/CLI failures are best-effort only (swallowed with warning), so task creation is never blocked by GitHub availability.
### Worktree model
- Each active task runs in isolated worktree under `.worktrees/*`
- Executor creates branches like `fusion/{task-id}` (`executor.ts`)

View File

@@ -59,7 +59,7 @@ In **Settings → Notifications**, use **Test message notification** to exercise
| `openrouterModelSync` | `boolean` | `true` | Sync OpenRouter model catalog into model pickers at startup. |
| `opencodeGoModelSync` | `boolean` | `true` | Sync opencode-go model catalog at startup via `opencode models opencode --refresh`, normalizing discovered `opencode/...` IDs into the `opencode-go` provider surface used by `/api/models`. |
| `updateCheckEnabled` | `boolean` | `true` | When enabled, Fusion performs a daily npm registry check for new `@runfusion/fusion` versions and shows update notices in CLI/dashboard. |
| `githubTrackingDefaultRepo` | `string` | `undefined` | Global fallback issue-tracking repo (`owner/repo`) introduced in FN-3868 as groundwork for the FN-3868 → FN-3876 GitHub tracking epic; behavior wiring lands in downstream subtasks. |
| `githubTrackingDefaultRepo` | `string` | `undefined` | Global fallback issue-tracking repo (`owner/repo`) used when task-level tracking is enabled and no project/task override is set. |
| `autoReloadOnVersionChange` | `boolean` | `true` | When enabled (default), the dashboard automatically reloads when a new build version is detected via `/version.json` polling or service worker activation. Set to `false` to suppress automatic reloads — the user must manually refresh to pick up updates. |
| `modelOnboardingComplete` | `boolean` | `undefined` | Whether AI onboarding has been completed or dismissed. |
| `executionGlobalProvider` | `string` | `undefined` | Global baseline provider for task execution. Project `executionProvider` overrides this. |
@@ -231,9 +231,9 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`
| `autoUpdatePrStatus` | `boolean` | `false` | Auto-refresh PR status badges. |
| `githubCommentOnDone` | `boolean` | `false` | When enabled, tasks imported from GitHub issues post a completion comment to the source issue when the task moves to `done`. |
| `githubCommentTemplate` | `string` | `undefined` | Optional issue comment template used by `githubCommentOnDone`. Supports `{taskId}` and `{taskTitle}` placeholders. If unset, Fusion uses a default completion message. |
| `githubTrackingEnabledByDefault` | `boolean` | `false` | Project-level default for enabling issue tracking on new tasks. Added in FN-3868 as foundation for the FN-3868 → FN-3876 GitHub tracking epic; task/issue behavior ships in later subtasks. |
| `githubTrackingDefaultRepo` | `string` | `undefined` | Project default issue-tracking repo (`owner/repo`). Added in FN-3868 as foundation for the FN-3868 → FN-3876 epic; downstream subtasks implement runtime usage. |
| `githubAuthMode` | `"gh-cli" \| "token"` | `"gh-cli"` | Project GitHub auth strategy selector added in FN-3868 for the FN-3868 → FN-3876 tracking epic; auth wiring is deferred to later subtasks. |
| `githubTrackingEnabledByDefault` | `boolean` | `false` | Project-level default for enabling issue tracking on new tasks. Even when this is false, issue creation can still occur per task if tracking is explicitly enabled. |
| `githubTrackingDefaultRepo` | `string` | `undefined` | Project default issue-tracking repo (`owner/repo`) used before global fallback for tracked task creation. |
| `githubAuthMode` | `"gh-cli" \| "token"` | `"gh-cli"` | Project GitHub auth strategy used by tracking issue creation (`gh-cli` by default, `token` when configured). |
| `githubAuthToken` | `string` | `undefined` | Optional project PAT used when `githubAuthMode` is `"token"`. Added in FN-3868 as data-layer groundwork; downstream subtasks consume it. |
| `autoCreatePr` | `boolean` | `false` | Auto-create PRs for completed tasks. |
| `autoBackupEnabled` | `boolean` | `false` | Enable scheduled DB backups. |

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,