feat(FN-4148): derive GitHub tracking titles from task descriptions

Adds GitHub tracking title derivation so tasks can display meaningful titles when PR/issue tracking data is incomplete, falling back to the task description. The core implementation lives in `github-tracking.ts` with expanded test coverage across the tracking suite, and the feature is wired into pla

Fusion-Task-Id: FN-4148

Fusion-Task-Lineage: d57c6279-1b4f-4ade-9c3f-e20ac391ea45
This commit is contained in:
Fusion
2026-05-12 10:37:51 -07:00
committed by gsxdsm
parent bc5fdd28a8
commit 08b4af3757
11 changed files with 365 additions and 39 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
GitHub tracking now derives an issue title and transition-comment title from the task description (and, when configured, the AI title summarizer) instead of emitting "Untitled task" when the Fusion task has no title.

View File

@@ -567,7 +567,7 @@ Users can apply presets at task creation; manual model selection can override th
## AI Title Summarization
When `autoSummarizeTitles` is enabled and a task has a long untitled description, Fusion can auto-generate a concise title. This applies to tasks created from the dashboard/API as well as tasks created by agents and tooling flows (`fn_task_create`, delegated tasks, and triage-created child tasks).
When `autoSummarizeTitles` is enabled and a task has a long untitled description, Fusion can auto-generate a concise title. This applies to tasks created from the dashboard/API as well as tasks created by agents and tooling flows (`fn_task_create`, delegated tasks, and triage-created child tasks). GitHub tracking also opportunistically uses the title-summarizer lane for untitled tasks before falling back to a deterministic description-derived title.
## Screenshots

View File

@@ -28,14 +28,20 @@ function makeTmpDir(): string {
return dir;
}
async function removeTrackedTmpDir(dir: string | undefined): Promise<void> {
if (!dir) return;
try {
await rm(dir, { recursive: true, force: true });
} catch {
rmSync(dir, { recursive: true, force: true });
} finally {
createdTmpDirs.delete(dir);
}
}
async function cleanupTmpDirsAsync(): Promise<void> {
const cleanup = Array.from(createdTmpDirs);
await Promise.all(
cleanup.map(async (dir) => {
await rm(dir, { recursive: true, force: true });
createdTmpDirs.delete(dir);
}),
);
await Promise.all(cleanup.map((dir) => removeTrackedTmpDir(dir)));
}
function cleanupTmpDirsSync(): void {
@@ -1269,7 +1275,7 @@ describe("schema migrations", () => {
let tmpDir: string;
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
await removeTrackedTmpDir(tmpDir);
});
it("migrates a v1 database by adding missing columns", () => {
@@ -2196,7 +2202,7 @@ describe("FTS5 full-text search", () => {
} catch {
// already closed
}
await rm(tmpDir, { recursive: true, force: true });
await removeTrackedTmpDir(tmpDir);
});
it("creates tasks_fts virtual table after init", () => {
@@ -2409,7 +2415,7 @@ describe("Database FTS5 guard behavior", () => {
expect(localDb.rebuildFts5Index()).toBe(false);
} finally {
localDb.close();
await rm(tmpDir, { recursive: true, force: true });
await removeTrackedTmpDir(tmpDir);
if (prevEnv === undefined) {
delete process.env.FUSION_DISABLE_FTS5;
} else {
@@ -2423,7 +2429,7 @@ describe("createDatabase factory", () => {
let tmpDir: string;
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
await removeTrackedTmpDir(tmpDir);
});
it("creates a database instance without auto-init", () => {

View File

@@ -8,14 +8,16 @@ vi.mock("@fusion/core", async () => {
...actual,
isGhAvailable: vi.fn(),
isGhAuthenticated: vi.fn(),
runGhAsync: vi.fn(),
runGhJsonAsync: vi.fn(),
};
});
import { isGhAuthenticated, isGhAvailable, runGhJsonAsync } from "@fusion/core";
import { isGhAuthenticated, isGhAvailable, runGhAsync, runGhJsonAsync } from "@fusion/core";
const mockIsGhAvailable = vi.mocked(isGhAvailable);
const mockIsGhAuthenticated = vi.mocked(isGhAuthenticated);
const mockRunGhAsync = vi.mocked(runGhAsync);
const mockRunGhJsonAsync = vi.mocked(runGhJsonAsync);
function task(): Task {
@@ -38,6 +40,7 @@ describe("tracking auth mode integration", () => {
vi.clearAllMocks();
mockIsGhAvailable.mockReturnValue(true);
mockIsGhAuthenticated.mockReturnValue(true);
mockRunGhAsync.mockResolvedValue("https://github.com/o/r/issues/5");
mockRunGhJsonAsync.mockResolvedValue({ url: "https://github.com/o/r/issues/5", number: 5, createdAt: "2026-01-01T00:00:00.000Z" } as any);
});
@@ -52,6 +55,7 @@ describe("tracking auth mode integration", () => {
taskStore: { linkGithubIssue: vi.fn(), recordActivity: vi.fn() } as any,
projectSettings: { githubAuthMode: "token", githubAuthToken: "token" } as any,
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
rootDir: "/tmp/test",
});
expect(fetchSpy).toHaveBeenCalled();
@@ -68,6 +72,7 @@ describe("tracking auth mode integration", () => {
taskStore: { recordActivity } as any,
projectSettings: { githubAuthMode: "token", githubAuthToken: "" } as any,
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
rootDir: "/tmp/test",
});
expect(result).toEqual({ created: false, reason: "auth_token_missing" });
@@ -84,6 +89,7 @@ describe("tracking auth mode integration", () => {
taskStore: { linkGithubIssue: vi.fn(), recordActivity: vi.fn() } as any,
projectSettings: { githubAuthMode: "gh-cli" } as any,
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
rootDir: "/tmp/test",
});
expect(mockRunGhJsonAsync).toHaveBeenCalled();
@@ -96,6 +102,7 @@ describe("tracking auth mode integration", () => {
taskStore: { recordActivity: vi.fn() } as any,
projectSettings: { githubAuthMode: "gh-cli" } as any,
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
rootDir: "/tmp/test",
});
expect(result).toEqual({ created: false, reason: "auth_gh_not_installed" });
@@ -106,6 +113,7 @@ describe("tracking auth mode integration", () => {
taskStore: { linkGithubIssue: vi.fn(), recordActivity: vi.fn() } as any,
projectSettings: {} as any,
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
rootDir: "/tmp/test",
});
expect(mockRunGhJsonAsync).toHaveBeenCalled();

View File

@@ -70,8 +70,20 @@ describe("formatTrackingComment", () => {
expect(comment.startsWith("Fusion task: FN-1\n\n✅ Done")).toBe(true);
});
it("falls back to untitled task", () => {
const comment = formatTrackingComment({ id: "FN-1", title: " " }, "done");
it.each(["in-progress", "done"] as const)("derives the title from description for %s comments when title is empty", (transition) => {
const comment = formatTrackingComment({ id: "FN-1", title: "", description: "Ship GitHub tracking fallback" }, transition);
expect(comment).toContain("Ship GitHub tracking fallback");
expect(comment).not.toContain("Untitled task");
});
it.each(["in-progress", "done"] as const)("derives the title from description for %s comments when title is whitespace", (transition) => {
const comment = formatTrackingComment({ id: "FN-1", title: " ", description: "Use description instead" }, transition);
expect(comment).toContain("Use description instead");
expect(comment).not.toContain("Untitled task");
});
it.each(["in-progress", "done"] as const)("falls back to untitled task for %s comments only when title and description are empty", (transition) => {
const comment = formatTrackingComment({ id: "FN-1", title: " ", description: "\n\n " }, transition);
expect(comment).toContain("Untitled task");
});

View File

@@ -3,6 +3,15 @@ import type { Task } from "@fusion/core";
const createIssueMock = vi.fn();
const resolveAuthMock = vi.fn();
const summarizeTitleMock = vi.fn();
vi.mock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
return {
...actual,
summarizeTitle: (...args: unknown[]) => summarizeTitleMock(...args),
};
});
vi.mock("../github.js", () => ({
GitHubClient: vi.fn().mockImplementation(() => ({
@@ -14,7 +23,9 @@ vi.mock("../github-auth.js", () => ({
resolveGithubTrackingAuth: (...args: unknown[]) => resolveAuthMock(...args),
}));
import { AiServiceError, MIN_DESCRIPTION_LENGTH } from "@fusion/core";
import {
deriveTitleFromDescription,
formatTrackingIssueBody,
formatTrackingIssueTitle,
maybeCreateTrackingIssue,
@@ -35,11 +46,70 @@ function buildTask(overrides: Partial<Task> = {}): Task {
} as Task;
}
describe("deriveTitleFromDescription", () => {
it("returns null for empty input", () => {
expect(deriveTitleFromDescription(undefined, 40)).toBeNull();
expect(deriveTitleFromDescription(" \n\n ", 40)).toBeNull();
});
it("uses the first non-empty line for a single-line description", () => {
expect(deriveTitleFromDescription("Build the GitHub tracking issue title", 80)).toBe(
"Build the GitHub tracking issue title",
);
});
it("uses the first non-empty line from a later paragraph without joining lines", () => {
expect(deriveTitleFromDescription("\n\nFirst paragraph title\nMore detail here\n\nSecond paragraph", 80)).toBe(
"First paragraph title",
);
});
it("strips leading heading, list, and quote markers", () => {
expect(deriveTitleFromDescription("> ## - Ship GitHub tracking fallback\nFollow-up detail", 80)).toBe(
"Ship GitHub tracking fallback",
);
});
it("skips fenced code blocks at the top", () => {
expect(deriveTitleFromDescription("```ts\nconst title = 'ignore me';\n```\nReal title line", 80)).toBe(
"Real title line",
);
});
it("truncates long derived titles with an ellipsis", () => {
expect(deriveTitleFromDescription("abcdefghijk", 8)).toBe("abcdefg…");
});
it.each([
["Sentence one. Sentence two", "Sentence one."],
["Ship it! Then celebrate", "Ship it!"],
["Question first? Answer later", "Question first?"],
])("truncates at the first sentence terminator for %s", (input, expected) => {
expect(deriveTitleFromDescription(input, 80)).toBe(expected);
});
});
describe("formatTrackingIssueTitle", () => {
it("formats a normal title", () => {
expect(formatTrackingIssueTitle({ id: "FN-1", title: "Hello" })).toBe("[FN-1] Hello");
});
it("derives the title from description when the title is empty", () => {
expect(formatTrackingIssueTitle({ id: "FN-1", title: "", description: "Ship GitHub tracking fallback" })).toBe(
"[FN-1] Ship GitHub tracking fallback",
);
});
it("derives the title from description when the title is whitespace only", () => {
expect(formatTrackingIssueTitle({ id: "FN-1", title: " ", description: "Use description instead" })).toBe(
"[FN-1] Use description instead",
);
});
it("falls back to untitled task only when title and description are both empty", () => {
expect(formatTrackingIssueTitle({ id: "FN-1", title: " ", description: "\n\n " })).toBe("[FN-1] Untitled task");
});
it("truncates very long titles while preserving id prefix", () => {
const longTitle = "x".repeat(400);
const formatted = formatTrackingIssueTitle({ id: "FN-123", title: longTitle });
@@ -73,6 +143,9 @@ describe("formatTrackingIssueBody", () => {
});
describe("maybeCreateTrackingIssue", () => {
const rootDir = "/tmp/test";
const longDescription = `Derived fallback title. ${"a".repeat(MIN_DESCRIPTION_LENGTH)}`;
beforeEach(() => {
vi.clearAllMocks();
resolveAuthMock.mockReturnValue({ ok: true, auth: { mode: "token", token: "tok" } });
@@ -83,6 +156,7 @@ describe("maybeCreateTrackingIssue", () => {
htmlUrl: "https://github.com/o/r/issues/12",
createdAt: "2026-01-01T00:00:00.000Z",
});
summarizeTitleMock.mockResolvedValue(null);
});
it("returns tracking_disabled when not enabled", async () => {
@@ -90,6 +164,7 @@ describe("maybeCreateTrackingIssue", () => {
taskStore: {} as any,
projectSettings: {},
globalSettings: {},
rootDir,
});
expect(result).toEqual({ created: false, reason: "tracking_disabled" });
});
@@ -104,6 +179,7 @@ describe("maybeCreateTrackingIssue", () => {
taskStore: {} as any,
projectSettings: { githubTrackingDefaultRepo: "task/repo", githubAuthMode: "token", githubAuthToken: "tok" } as any,
globalSettings: {},
rootDir,
});
expect(result).toEqual({ created: false, reason: "issue_already_linked" });
@@ -116,6 +192,7 @@ describe("maybeCreateTrackingIssue", () => {
taskStore: { recordActivity } as any,
projectSettings: {},
globalSettings: {},
rootDir,
logger: { warn: vi.fn(), info: vi.fn() },
});
@@ -132,6 +209,7 @@ describe("maybeCreateTrackingIssue", () => {
taskStore: { linkGithubIssue, recordActivity } as any,
projectSettings: {},
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
rootDir,
logger: console,
});
@@ -147,6 +225,84 @@ describe("maybeCreateTrackingIssue", () => {
}));
});
it("uses the AI summarizer when the title is missing and a summarizer model is configured", async () => {
const linkGithubIssue = vi.fn();
const recordActivity = vi.fn();
const updateTask = vi.fn().mockImplementation(async (_id, updates) => buildTask({ title: updates.title, description: longDescription }));
summarizeTitleMock.mockResolvedValue("AI generated title");
await maybeCreateTrackingIssue(buildTask({ title: " ", description: longDescription, githubTracking: { enabled: true } }), {
taskStore: { linkGithubIssue, recordActivity, updateTask } as any,
projectSettings: { titleSummarizerProvider: "anthropic", titleSummarizerModelId: "claude" } as any,
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
rootDir,
logger: { warn: vi.fn(), info: vi.fn() },
});
expect(summarizeTitleMock).toHaveBeenCalledWith(longDescription, rootDir, "anthropic", "claude");
expect(updateTask).toHaveBeenCalledWith("FN-1", { title: "AI generated title" });
expect(createIssueMock).toHaveBeenCalledWith(expect.objectContaining({ title: "[FN-1] AI generated title" }));
expect(recordActivity).toHaveBeenCalledWith(expect.objectContaining({ metadata: { type: "github-tracking-title-summarized" } }));
});
it("falls back to a derived description title when the summarizer throws", async () => {
const logger = { warn: vi.fn(), info: vi.fn() };
const updateTask = vi.fn();
summarizeTitleMock.mockRejectedValue(new AiServiceError("model unavailable"));
const result = await maybeCreateTrackingIssue(buildTask({ title: "", description: longDescription, githubTracking: { enabled: true } }), {
taskStore: { linkGithubIssue: vi.fn(), recordActivity: vi.fn(), updateTask } as any,
projectSettings: { titleSummarizerProvider: "anthropic", titleSummarizerModelId: "claude" } as any,
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
rootDir,
logger,
});
expect(result.created).toBe(true);
expect(updateTask).not.toHaveBeenCalled();
expect(createIssueMock).toHaveBeenCalledWith(expect.objectContaining({ title: "[FN-1] Derived fallback title." }));
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("AI title summarizer failed"));
});
it("does not invoke the summarizer when the description is too short", async () => {
await maybeCreateTrackingIssue(buildTask({ title: "", description: "Short title fallback", githubTracking: { enabled: true } }), {
taskStore: { linkGithubIssue: vi.fn(), recordActivity: vi.fn(), updateTask: vi.fn() } as any,
projectSettings: { titleSummarizerProvider: "anthropic", titleSummarizerModelId: "claude" } as any,
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
rootDir,
logger: { warn: vi.fn(), info: vi.fn() },
});
expect(summarizeTitleMock).not.toHaveBeenCalled();
expect(createIssueMock).toHaveBeenCalledWith(expect.objectContaining({ title: "[FN-1] Short title fallback" }));
});
it("does not invoke the summarizer when no summarizer model is configured", async () => {
await maybeCreateTrackingIssue(buildTask({ title: "", description: longDescription, githubTracking: { enabled: true } }), {
taskStore: { linkGithubIssue: vi.fn(), recordActivity: vi.fn(), updateTask: vi.fn() } as any,
projectSettings: {} as any,
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
rootDir,
logger: { warn: vi.fn(), info: vi.fn() },
});
expect(summarizeTitleMock).not.toHaveBeenCalled();
expect(createIssueMock).toHaveBeenCalledWith(expect.objectContaining({ title: "[FN-1] Derived fallback title." }));
});
it("does not invoke the summarizer when a non-empty title is already present", async () => {
await maybeCreateTrackingIssue(buildTask({ title: "Keep existing title", description: longDescription, githubTracking: { enabled: true } }), {
taskStore: { linkGithubIssue: vi.fn(), recordActivity: vi.fn(), updateTask: vi.fn() } as any,
projectSettings: { titleSummarizerProvider: "anthropic", titleSummarizerModelId: "claude" } as any,
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
rootDir,
logger: { warn: vi.fn(), info: vi.fn() },
});
expect(summarizeTitleMock).not.toHaveBeenCalled();
expect(createIssueMock).toHaveBeenCalledWith(expect.objectContaining({ title: "[FN-1] Keep existing title" }));
});
it.each([
["task override", { enabled: true, repoOverride: "task/repo" }, { githubTrackingDefaultRepo: "project/repo" }, { githubTrackingDefaultRepo: "global/repo" }, "task", "repo"],
["project default", { enabled: true }, { githubTrackingDefaultRepo: "project/repo" }, { githubTrackingDefaultRepo: "global/repo" }, "project", "repo"],
@@ -158,6 +314,7 @@ describe("maybeCreateTrackingIssue", () => {
taskStore: { linkGithubIssue, recordActivity: vi.fn() } as any,
projectSettings: projectSettings as any,
globalSettings: globalSettings as any,
rootDir,
logger: console,
});
@@ -165,23 +322,25 @@ describe("maybeCreateTrackingIssue", () => {
});
it("creates a tracking issue from explicit task override when defaults are unset", async () => {
const linkGithubIssue = vi.fn();
const linkGithubIssue = vi.fn();
await maybeCreateTrackingIssue(buildTask({ githubTracking: { enabled: true, repoOverride: "task/repo" } }), {
taskStore: { linkGithubIssue, recordActivity: vi.fn() } as any,
projectSettings: {},
globalSettings: {},
logger: console,
});
await maybeCreateTrackingIssue(buildTask({ githubTracking: { enabled: true, repoOverride: "task/repo" } }), {
taskStore: { linkGithubIssue, recordActivity: vi.fn() } as any,
projectSettings: {},
globalSettings: {},
rootDir,
logger: console,
});
expect(createIssueMock).toHaveBeenCalledWith(expect.objectContaining({ owner: "task", repo: "repo" }));
});
expect(createIssueMock).toHaveBeenCalledWith(expect.objectContaining({ owner: "task", repo: "repo" }));
});
it("skips creation when tracking is on but no repo is configured", async () => {
const result = await maybeCreateTrackingIssue(buildTask({ githubTracking: { enabled: true } }), {
taskStore: { recordActivity: vi.fn() } as any,
projectSettings: {},
globalSettings: {},
rootDir,
logger: { warn: vi.fn(), info: vi.fn() },
});
@@ -202,6 +361,7 @@ describe("maybeCreateTrackingIssue", () => {
taskStore: { recordActivity } as any,
projectSettings: {},
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
rootDir,
logger: { warn: vi.fn(), info: vi.fn() },
});
@@ -218,6 +378,7 @@ describe("maybeCreateTrackingIssue", () => {
taskStore: { linkGithubIssue: vi.fn(), recordActivity: vi.fn() } as any,
projectSettings: {},
globalSettings,
rootDir,
logger: { warn: vi.fn(), info: vi.fn() },
});
@@ -232,6 +393,7 @@ describe("maybeCreateTrackingIssue", () => {
taskStore: { recordActivity, linkGithubIssue: vi.fn() } as any,
projectSettings: {},
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
rootDir,
logger: { warn: vi.fn(), info: vi.fn() },
});

View File

@@ -1,4 +1,5 @@
import type { GlobalSettings, MergeDetails, ProjectSettings, Task, TaskStore } from "@fusion/core";
import { deriveTitleFromDescription } from "./github-tracking.js";
import { GitHubClient } from "./github.js";
import { resolveGithubTrackingAuth } from "./github-auth.js";
@@ -44,6 +45,15 @@ function formatTitleSegment(title: string, maxLength: number): string {
return truncateText(title, maxLength);
}
function resolveTrackingTitle(
task: Pick<Task, "title" | "description">,
maxLength: number,
): string {
return sanitizeInlineText(task.title ?? "")
|| deriveTitleFromDescription(task.description, maxLength)
|| "Untitled task";
}
function formatCommitLine(
mergeDetails: MergeDetails | undefined,
linkContext: TrackingLinkContext | undefined,
@@ -83,11 +93,10 @@ function formatFilesLine(mergeDetails: MergeDetails | undefined): string | null
}
function buildDoneComment(
task: Pick<Task, "id" | "title" | "branch" | "mergeDetails">,
task: Pick<Task, "id" | "title" | "description" | "branch" | "mergeDetails">,
linkContext?: TrackingLinkContext,
options?: { includeCommitSubject?: boolean; includeFilesLine?: boolean },
): string {
const rawTitle = sanitizeInlineText(task.title ?? "") || "Untitled task";
const branch = sanitizeInlineText(task.branch ?? "");
const mergedAt = collapseWhitespace(task.mergeDetails?.mergedAt ?? "");
const prNumber = task.mergeDetails?.prNumber;
@@ -122,7 +131,7 @@ function buildDoneComment(
const suffix = "” is complete.";
const extraLength = optionalLines.length === 0 ? 0 : `\n${optionalLines.join("\n")}`.length;
const available = DONE_COMMENT_MAX_LENGTH - prefix.length - stem.length - suffix.length - extraLength;
const title = formatTitleSegment(rawTitle, available);
const title = formatTitleSegment(resolveTrackingTitle(task, available), available);
const statusLine = `${stem}${title}${suffix}`;
return optionalLines.length === 0
@@ -131,7 +140,7 @@ function buildDoneComment(
}
export function formatTrackingComment(
task: Pick<Task, "id" | "title" | "branch" | "mergeDetails">,
task: Pick<Task, "id" | "title" | "description" | "branch" | "mergeDetails">,
transition: "in-progress" | "done",
linkContext?: TrackingLinkContext,
): string {
@@ -153,11 +162,8 @@ export function formatTrackingComment(
const stem = "🚧 In progress — work has started on “";
const suffix = "”.";
const rawTitle = collapseWhitespace(task.title ?? "") || "Untitled task";
const available = COMMENT_MAX_LENGTH - prefix.length - stem.length - suffix.length;
const title = rawTitle.length <= available
? rawTitle
: `${rawTitle.slice(0, Math.max(0, available - 1)).trimEnd()}`;
const title = formatTitleSegment(resolveTrackingTitle(task, available), available);
return `${prefix}${stem}${title}${suffix}`;
}

View File

@@ -1,5 +1,8 @@
import {
AiServiceError,
MIN_DESCRIPTION_LENGTH,
resolveTaskGithubTracking,
summarizeTitle,
type GlobalSettings,
type ProjectSettings,
type Task,
@@ -16,6 +19,65 @@ 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
@@ -39,17 +101,14 @@ function sanitizeSummaryText(value: string): string {
return collapseWhitespace(withoutFusionUrls);
}
export function formatTrackingIssueTitle(task: Pick<Task, "id" | "title">): string {
export function formatTrackingIssueTitle(task: Pick<Task, "id" | "title" | "description">): string {
const prefix = `[${task.id}] `;
const baseTitle = collapseWhitespace(task.title ?? "") || "Untitled task";
const maxTitleLength = Math.max(1, TRACKING_ISSUE_TITLE_LIMIT - prefix.length);
const baseTitle = collapseWhitespace(task.title ?? "")
|| deriveTitleFromDescription(task.description, maxTitleLength)
|| "Untitled task";
if (baseTitle.length <= maxTitleLength) {
return `${prefix}${baseTitle}`;
}
const truncated = `${baseTitle.slice(0, Math.max(0, maxTitleLength - 1)).trimEnd()}`;
return `${prefix}${truncated}`;
return `${prefix}${truncateWithEllipsis(baseTitle, maxTitleLength)}`;
}
export function formatTrackingIssueBody(task: {
@@ -76,6 +135,7 @@ export interface MaybeCreateTrackingIssueDeps {
taskStore: TaskStore;
projectSettings: ProjectSettings;
globalSettings: GlobalSettings;
rootDir: string;
logger?: Pick<Console, "warn" | "info">;
}
@@ -90,6 +150,34 @@ export type MaybeCreateTrackingIssueReason =
| "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,
@@ -122,6 +210,42 @@ export async function maybeCreateTrackingIssue(
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 resolution = resolveGithubTrackingAuth({
projectSettings: deps.projectSettings,
globalSettings: deps.globalSettings,

View File

@@ -4803,6 +4803,7 @@ async function maybeCreateTaskTrackingIssue(taskStore: TaskStore, task: Task): P
taskStore,
projectSettings,
globalSettings,
rootDir: taskStore.getRootDir(),
logger: console,
});
} catch {

View File

@@ -21,6 +21,7 @@ async function maybeCreateTaskTrackingIssue(taskStore: TaskStore, task: import("
taskStore,
projectSettings,
globalSettings,
rootDir: taskStore.getRootDir(),
logger: console,
});
} catch {

View File

@@ -108,6 +108,7 @@ async function maybeCreateTaskTrackingIssue(taskStore: TaskStore, task: Task, op
taskStore,
projectSettings: trackingProjectSettings,
globalSettings,
rootDir: taskStore.getRootDir(),
logger: console,
});
} catch {