Files
fusion/packages/dashboard/src/github-tracking.ts
Fusion 991776b80c feat(FN-4271): create tracking issue for github-imported tasks
Adds a tracking issue mechanism for GitHub-imported tasks, creating an internal Fusion task to track the import lifecycle. The TaskCard component gains visibility into tracking status, backed by new tests in both TaskCard and GitHub tracking modules.

Fusion-Task-Id: FN-4271
2026-05-13 06:29:34 -07:00

331 lines
10 KiB
TypeScript

import {
AiServiceError,
MIN_DESCRIPTION_LENGTH,
resolveTaskGithubTracking,
summarizeTitle,
type GlobalSettings,
type ProjectSettings,
type Task,
type TaskStore,
} from "@fusion/core";
import type { CreatedIssue } from "./github.js";
import { GitHubClient } from "./github.js";
import { resolveGithubTrackingAuth } from "./github-auth.js";
const TRACKING_ISSUE_TITLE_LIMIT = 240;
const TRACKING_ISSUE_BODY_SUMMARY_LIMIT = 500;
function collapseWhitespace(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
function truncateWithEllipsis(value: string, maxLength: number): string {
if (value.length <= maxLength) {
return value;
}
return `${value.slice(0, Math.max(0, maxLength - 1)).trimEnd()}`;
}
export function deriveTitleFromDescription(description: string | undefined, maxLength: number): string | null {
if (!description || !description.trim()) {
return null;
}
const lines = description.split(/\r?\n/);
const cleanedLines: string[] = [];
let inCodeFence = false;
for (const line of lines) {
if (/^\s*```/.test(line)) {
inCodeFence = !inCodeFence;
continue;
}
if (inCodeFence) {
continue;
}
let cleaned = line.trim();
while (cleaned) {
const next = cleaned
.replace(/^>\s*/, "")
.replace(/^#{1,6}\s+/, "")
.replace(/^(?:[-*+]\s+|\d+\.\s+)/, "");
if (next === cleaned) {
break;
}
cleaned = next.trimStart();
}
cleanedLines.push(cleaned);
}
const firstLine = cleanedLines.find((line) => line.trim().length > 0);
if (!firstLine) {
return null;
}
const terminatorMatch = /[.!?](?=\s|$)/.exec(firstLine);
const candidate = terminatorMatch
? firstLine.slice(0, terminatorMatch.index + 1)
: firstLine;
const collapsed = collapseWhitespace(candidate);
if (!collapsed) {
return null;
}
return truncateWithEllipsis(collapsed, maxLength);
}
function firstNonEmptyParagraph(value: string | undefined): string | null {
if (!value) return null;
const paragraph = value
.split(/\n\s*\n/g)
.map((part) => part.trim())
.find((part) => part.length > 0);
return paragraph && paragraph.length > 0 ? paragraph : null;
}
function sanitizeSummaryText(value: string): string {
const cleaned = value
.split(/\r?\n/)
.filter((line) => !/^```/.test(line.trim()))
.map((line) => line.replace(/^\s*#{1,6}\s+/, "").replace(/^\s*(?:[-*+]\s+|\d+\.\s+)/, ""))
.join(" ");
const withoutFusionUrls = cleaned
.replace(/https?:\/\/localhost(?::\d+)?\/[^\s)]*/gi, " ")
.replace(/https?:\/\/[^\s)]*\/tasks\/FN-\d+[^\s)]*/gi, " ");
return collapseWhitespace(withoutFusionUrls);
}
export function formatTrackingIssueTitle(task: Pick<Task, "id" | "title" | "description">): string {
const prefix = `[${task.id}] `;
const maxTitleLength = Math.max(1, TRACKING_ISSUE_TITLE_LIMIT - prefix.length);
const baseTitle = collapseWhitespace(task.title ?? "")
|| deriveTitleFromDescription(task.description, maxTitleLength)
|| "Untitled task";
return `${prefix}${truncateWithEllipsis(baseTitle, maxTitleLength)}`;
}
export function formatTrackingIssueBody(task: {
id: string;
title?: string;
description?: string;
summary?: string;
prompt?: string;
}): string {
const source = firstNonEmptyParagraph(task.description)
?? firstNonEmptyParagraph(task.prompt)
?? task.summary?.trim()
?? "No summary available.";
const sanitized = sanitizeSummaryText(source) || "No summary available.";
const summary = sanitized.length > TRACKING_ISSUE_BODY_SUMMARY_LIMIT
? `${sanitized.slice(0, TRACKING_ISSUE_BODY_SUMMARY_LIMIT - 1).trimEnd()}`
: sanitized;
return `Fusion task: ${task.id}\n\n${summary}`;
}
export interface MaybeCreateTrackingIssueDeps {
taskStore: TaskStore;
projectSettings: ProjectSettings;
globalSettings: GlobalSettings;
rootDir: string;
logger?: Pick<Console, "warn" | "info">;
}
export type MaybeCreateTrackingIssueReason =
| "tracking_disabled"
| "issue_already_linked"
| "no_repo_configured"
| "no_title_available"
| "github_error"
| "auth_token_missing"
| "auth_gh_not_installed"
| "auth_gh_not_authenticated"
| "auth_invalid_mode";
function resolveTrackingTitleSummarizerModel(
projectSettings: ProjectSettings,
globalSettings: GlobalSettings,
): { provider?: string; modelId?: string } {
const candidates = [
{
provider: projectSettings.titleSummarizerProvider,
modelId: projectSettings.titleSummarizerModelId,
},
{
provider: globalSettings.titleSummarizerGlobalProvider,
modelId: globalSettings.titleSummarizerGlobalModelId,
},
{
provider: projectSettings.titleSummarizerFallbackProvider,
modelId: projectSettings.titleSummarizerFallbackModelId,
},
];
for (const candidate of candidates) {
if (candidate.provider && candidate.modelId) {
return candidate;
}
}
return {};
}
export async function maybeCreateTrackingIssue(
task: Task,
deps: MaybeCreateTrackingIssueDeps,
): Promise<{ created: false; reason: MaybeCreateTrackingIssueReason } | { created: true; issue: CreatedIssue }> {
const tracking = task.githubTracking;
const resolvedTracking = resolveTaskGithubTracking(task, deps.projectSettings, deps.globalSettings);
if (!resolvedTracking.enabled) {
return { created: false, reason: "tracking_disabled" };
}
if (tracking?.issue) {
return { created: false, reason: "issue_already_linked" };
}
const repo = resolvedTracking.repo;
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 titleMissing = collapseWhitespace(task.title ?? "").length === 0;
const resolvedSummarizer = resolveTrackingTitleSummarizerModel(deps.projectSettings, deps.globalSettings);
const canSummarizeTitle = titleMissing
&& typeof task.description === "string"
&& task.description.length >= MIN_DESCRIPTION_LENGTH
&& Boolean(resolvedSummarizer.provider && resolvedSummarizer.modelId);
if (canSummarizeTitle) {
try {
const generatedTitle = await summarizeTitle(
task.description,
deps.rootDir,
resolvedSummarizer.provider,
resolvedSummarizer.modelId,
);
if (generatedTitle) {
const updatedTask = await deps.taskStore.updateTask(task.id, { title: generatedTitle });
task.title = updatedTask.title;
await deps.taskStore.recordActivity({
type: "task:updated",
taskId: task.id,
taskTitle: updatedTask.title,
details: "Generated task title for GitHub tracking issue",
metadata: { type: "github-tracking-title-summarized" },
});
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const prefix = error instanceof AiServiceError
? "AI title summarizer failed"
: "Title summarizer failed";
deps.logger?.warn?.(`[github-tracking] ${task.id}: ${prefix}: ${message}`);
}
}
const effectiveTitle = collapseWhitespace(task.title ?? "")
|| deriveTitleFromDescription(task.description, TRACKING_ISSUE_TITLE_LIMIT - `[${task.id}] `.length);
if (!effectiveTitle) {
deps.logger?.info?.(`[github-tracking] ${task.id}: deferred — no usable title; waiting for title or summarizer`);
await deps.taskStore.recordActivity({
type: "task:updated",
taskId: task.id,
taskTitle: task.title,
details: "GitHub tracking issue not created: task has no title yet",
metadata: { type: "github-tracking-no-title" },
});
return { created: false, reason: "no_title_available" };
}
const resolution = resolveGithubTrackingAuth({
projectSettings: deps.projectSettings,
globalSettings: deps.globalSettings,
});
if (!resolution.ok) {
deps.logger?.warn?.(`[github-tracking] ${task.id}: auth unavailable (${resolution.reason}): ${resolution.message}`);
await deps.taskStore.recordActivity({
type: "task:updated",
taskId: task.id,
taskTitle: task.title,
details: `GitHub tracking issue not created: ${resolution.message}`,
metadata: {
type: "github-issue-skipped",
reason: resolution.reason,
message: resolution.message,
},
});
return { created: false, reason: `auth_${resolution.reason}` };
}
const githubClient = resolution.auth.mode === "token"
? new GitHubClient({ token: resolution.auth.token, forceMode: "token" })
: new GitHubClient({ forceMode: "gh-cli" });
const title = formatTrackingIssueTitle(task);
const body = formatTrackingIssueBody(task);
try {
const issue = await 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}`);
await deps.taskStore.recordActivity({
type: "task:updated",
taskId: task.id,
taskTitle: task.title,
details: `GitHub tracking issue not created: ${message}`,
metadata: {
type: "github-issue-failed",
reason: "github_error",
message,
},
});
return { created: false, reason: "github_error" };
}
}