Files
fusion/packages/dashboard/src/github-tracking.ts
Fusion (runfusion.ai) caec731cc6 fix(FN-4725): make maybeCreateTrackingIssue idempotent via store re-read
Fusion-Task-Id: FN-4725
Fusion-Task-Lineage: 91058db5-9a5b-4506-89f4-8abf89b28862
2026-05-16 07:41:47 -07:00

349 lines
11 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 inlineTracking = task.githubTracking;
const resolvedTracking = resolveTaskGithubTracking(task, deps.projectSettings, deps.globalSettings);
if (!resolvedTracking.enabled) {
return { created: false, reason: "tracking_disabled" };
}
if (inlineTracking?.issue) {
return { created: false, reason: "issue_already_linked" };
}
let latestTask = task;
if (typeof deps.taskStore.getTask === "function") {
try {
const loadedTask = await deps.taskStore.getTask(task.id);
if (loadedTask) {
latestTask = loadedTask;
}
} catch {
// Best-effort refresh only; continue with provided task if lookup fails.
}
}
const tracking = latestTask.githubTracking ?? inlineTracking;
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: latestTask.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(latestTask.title ?? "").length === 0;
const resolvedSummarizer = resolveTrackingTitleSummarizerModel(deps.projectSettings, deps.globalSettings);
const canSummarizeTitle = titleMissing
&& typeof latestTask.description === "string"
&& latestTask.description.length >= MIN_DESCRIPTION_LENGTH
&& Boolean(resolvedSummarizer.provider && resolvedSummarizer.modelId);
if (canSummarizeTitle) {
try {
const generatedTitle = await summarizeTitle(
latestTask.description,
deps.rootDir,
resolvedSummarizer.provider,
resolvedSummarizer.modelId,
);
if (generatedTitle) {
const updatedTask = await deps.taskStore.updateTask(task.id, { title: generatedTitle });
task.title = updatedTask.title;
latestTask = updatedTask;
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(latestTask.title ?? "")
|| deriveTitleFromDescription(latestTask.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: latestTask.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: latestTask.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(latestTask);
const body = formatTrackingIssueBody(latestTask);
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: latestTask.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: latestTask.title,
details: `GitHub tracking issue not created: ${message}`,
metadata: {
type: "github-issue-failed",
reason: "github_error",
message,
},
});
return { created: false, reason: "github_error" };
}
}