349 lines
11 KiB
TypeScript
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" };
|
|
}
|
|
}
|