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:
5
.changeset/fn-4148-derive-github-tracking-title.md
Normal file
5
.changeset/fn-4148-derive-github-tracking-title.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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() },
|
||||
});
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4803,6 +4803,7 @@ async function maybeCreateTaskTrackingIssue(taskStore: TaskStore, task: Task): P
|
||||
taskStore,
|
||||
projectSettings,
|
||||
globalSettings,
|
||||
rootDir: taskStore.getRootDir(),
|
||||
logger: console,
|
||||
});
|
||||
} catch {
|
||||
|
||||
@@ -21,6 +21,7 @@ async function maybeCreateTaskTrackingIssue(taskStore: TaskStore, task: import("
|
||||
taskStore,
|
||||
projectSettings,
|
||||
globalSettings,
|
||||
rootDir: taskStore.getRootDir(),
|
||||
logger: console,
|
||||
});
|
||||
} catch {
|
||||
|
||||
@@ -108,6 +108,7 @@ async function maybeCreateTaskTrackingIssue(taskStore: TaskStore, task: Task, op
|
||||
taskStore,
|
||||
projectSettings: trackingProjectSettings,
|
||||
globalSettings,
|
||||
rootDir: taskStore.getRootDir(),
|
||||
logger: console,
|
||||
});
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user