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
|
## 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
|
## Screenshots
|
||||||
|
|
||||||
|
|||||||
@@ -28,14 +28,20 @@ function makeTmpDir(): string {
|
|||||||
return dir;
|
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> {
|
async function cleanupTmpDirsAsync(): Promise<void> {
|
||||||
const cleanup = Array.from(createdTmpDirs);
|
const cleanup = Array.from(createdTmpDirs);
|
||||||
await Promise.all(
|
await Promise.all(cleanup.map((dir) => removeTrackedTmpDir(dir)));
|
||||||
cleanup.map(async (dir) => {
|
|
||||||
await rm(dir, { recursive: true, force: true });
|
|
||||||
createdTmpDirs.delete(dir);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanupTmpDirsSync(): void {
|
function cleanupTmpDirsSync(): void {
|
||||||
@@ -1269,7 +1275,7 @@ describe("schema migrations", () => {
|
|||||||
let tmpDir: string;
|
let tmpDir: string;
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await rm(tmpDir, { recursive: true, force: true });
|
await removeTrackedTmpDir(tmpDir);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("migrates a v1 database by adding missing columns", () => {
|
it("migrates a v1 database by adding missing columns", () => {
|
||||||
@@ -2196,7 +2202,7 @@ describe("FTS5 full-text search", () => {
|
|||||||
} catch {
|
} catch {
|
||||||
// already closed
|
// already closed
|
||||||
}
|
}
|
||||||
await rm(tmpDir, { recursive: true, force: true });
|
await removeTrackedTmpDir(tmpDir);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates tasks_fts virtual table after init", () => {
|
it("creates tasks_fts virtual table after init", () => {
|
||||||
@@ -2409,7 +2415,7 @@ describe("Database FTS5 guard behavior", () => {
|
|||||||
expect(localDb.rebuildFts5Index()).toBe(false);
|
expect(localDb.rebuildFts5Index()).toBe(false);
|
||||||
} finally {
|
} finally {
|
||||||
localDb.close();
|
localDb.close();
|
||||||
await rm(tmpDir, { recursive: true, force: true });
|
await removeTrackedTmpDir(tmpDir);
|
||||||
if (prevEnv === undefined) {
|
if (prevEnv === undefined) {
|
||||||
delete process.env.FUSION_DISABLE_FTS5;
|
delete process.env.FUSION_DISABLE_FTS5;
|
||||||
} else {
|
} else {
|
||||||
@@ -2423,7 +2429,7 @@ describe("createDatabase factory", () => {
|
|||||||
let tmpDir: string;
|
let tmpDir: string;
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await rm(tmpDir, { recursive: true, force: true });
|
await removeTrackedTmpDir(tmpDir);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates a database instance without auto-init", () => {
|
it("creates a database instance without auto-init", () => {
|
||||||
|
|||||||
@@ -8,14 +8,16 @@ vi.mock("@fusion/core", async () => {
|
|||||||
...actual,
|
...actual,
|
||||||
isGhAvailable: vi.fn(),
|
isGhAvailable: vi.fn(),
|
||||||
isGhAuthenticated: vi.fn(),
|
isGhAuthenticated: vi.fn(),
|
||||||
|
runGhAsync: vi.fn(),
|
||||||
runGhJsonAsync: 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 mockIsGhAvailable = vi.mocked(isGhAvailable);
|
||||||
const mockIsGhAuthenticated = vi.mocked(isGhAuthenticated);
|
const mockIsGhAuthenticated = vi.mocked(isGhAuthenticated);
|
||||||
|
const mockRunGhAsync = vi.mocked(runGhAsync);
|
||||||
const mockRunGhJsonAsync = vi.mocked(runGhJsonAsync);
|
const mockRunGhJsonAsync = vi.mocked(runGhJsonAsync);
|
||||||
|
|
||||||
function task(): Task {
|
function task(): Task {
|
||||||
@@ -38,6 +40,7 @@ describe("tracking auth mode integration", () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockIsGhAvailable.mockReturnValue(true);
|
mockIsGhAvailable.mockReturnValue(true);
|
||||||
mockIsGhAuthenticated.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);
|
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,
|
taskStore: { linkGithubIssue: vi.fn(), recordActivity: vi.fn() } as any,
|
||||||
projectSettings: { githubAuthMode: "token", githubAuthToken: "token" } as any,
|
projectSettings: { githubAuthMode: "token", githubAuthToken: "token" } as any,
|
||||||
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
|
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
|
||||||
|
rootDir: "/tmp/test",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(fetchSpy).toHaveBeenCalled();
|
expect(fetchSpy).toHaveBeenCalled();
|
||||||
@@ -68,6 +72,7 @@ describe("tracking auth mode integration", () => {
|
|||||||
taskStore: { recordActivity } as any,
|
taskStore: { recordActivity } as any,
|
||||||
projectSettings: { githubAuthMode: "token", githubAuthToken: "" } as any,
|
projectSettings: { githubAuthMode: "token", githubAuthToken: "" } as any,
|
||||||
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
|
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
|
||||||
|
rootDir: "/tmp/test",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual({ created: false, reason: "auth_token_missing" });
|
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,
|
taskStore: { linkGithubIssue: vi.fn(), recordActivity: vi.fn() } as any,
|
||||||
projectSettings: { githubAuthMode: "gh-cli" } as any,
|
projectSettings: { githubAuthMode: "gh-cli" } as any,
|
||||||
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
|
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
|
||||||
|
rootDir: "/tmp/test",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockRunGhJsonAsync).toHaveBeenCalled();
|
expect(mockRunGhJsonAsync).toHaveBeenCalled();
|
||||||
@@ -96,6 +102,7 @@ describe("tracking auth mode integration", () => {
|
|||||||
taskStore: { recordActivity: vi.fn() } as any,
|
taskStore: { recordActivity: vi.fn() } as any,
|
||||||
projectSettings: { githubAuthMode: "gh-cli" } as any,
|
projectSettings: { githubAuthMode: "gh-cli" } as any,
|
||||||
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
|
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
|
||||||
|
rootDir: "/tmp/test",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual({ created: false, reason: "auth_gh_not_installed" });
|
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,
|
taskStore: { linkGithubIssue: vi.fn(), recordActivity: vi.fn() } as any,
|
||||||
projectSettings: {} as any,
|
projectSettings: {} as any,
|
||||||
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
|
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
|
||||||
|
rootDir: "/tmp/test",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockRunGhJsonAsync).toHaveBeenCalled();
|
expect(mockRunGhJsonAsync).toHaveBeenCalled();
|
||||||
|
|||||||
@@ -70,8 +70,20 @@ describe("formatTrackingComment", () => {
|
|||||||
expect(comment.startsWith("Fusion task: FN-1\n\n✅ Done")).toBe(true);
|
expect(comment.startsWith("Fusion task: FN-1\n\n✅ Done")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to untitled task", () => {
|
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: " " }, "done");
|
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");
|
expect(comment).toContain("Untitled task");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,15 @@ import type { Task } from "@fusion/core";
|
|||||||
|
|
||||||
const createIssueMock = vi.fn();
|
const createIssueMock = vi.fn();
|
||||||
const resolveAuthMock = 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", () => ({
|
vi.mock("../github.js", () => ({
|
||||||
GitHubClient: vi.fn().mockImplementation(() => ({
|
GitHubClient: vi.fn().mockImplementation(() => ({
|
||||||
@@ -14,7 +23,9 @@ vi.mock("../github-auth.js", () => ({
|
|||||||
resolveGithubTrackingAuth: (...args: unknown[]) => resolveAuthMock(...args),
|
resolveGithubTrackingAuth: (...args: unknown[]) => resolveAuthMock(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
import { AiServiceError, MIN_DESCRIPTION_LENGTH } from "@fusion/core";
|
||||||
import {
|
import {
|
||||||
|
deriveTitleFromDescription,
|
||||||
formatTrackingIssueBody,
|
formatTrackingIssueBody,
|
||||||
formatTrackingIssueTitle,
|
formatTrackingIssueTitle,
|
||||||
maybeCreateTrackingIssue,
|
maybeCreateTrackingIssue,
|
||||||
@@ -35,11 +46,70 @@ function buildTask(overrides: Partial<Task> = {}): Task {
|
|||||||
} as 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", () => {
|
describe("formatTrackingIssueTitle", () => {
|
||||||
it("formats a normal title", () => {
|
it("formats a normal title", () => {
|
||||||
expect(formatTrackingIssueTitle({ id: "FN-1", title: "Hello" })).toBe("[FN-1] Hello");
|
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", () => {
|
it("truncates very long titles while preserving id prefix", () => {
|
||||||
const longTitle = "x".repeat(400);
|
const longTitle = "x".repeat(400);
|
||||||
const formatted = formatTrackingIssueTitle({ id: "FN-123", title: longTitle });
|
const formatted = formatTrackingIssueTitle({ id: "FN-123", title: longTitle });
|
||||||
@@ -73,6 +143,9 @@ describe("formatTrackingIssueBody", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("maybeCreateTrackingIssue", () => {
|
describe("maybeCreateTrackingIssue", () => {
|
||||||
|
const rootDir = "/tmp/test";
|
||||||
|
const longDescription = `Derived fallback title. ${"a".repeat(MIN_DESCRIPTION_LENGTH)}`;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
resolveAuthMock.mockReturnValue({ ok: true, auth: { mode: "token", token: "tok" } });
|
resolveAuthMock.mockReturnValue({ ok: true, auth: { mode: "token", token: "tok" } });
|
||||||
@@ -83,6 +156,7 @@ describe("maybeCreateTrackingIssue", () => {
|
|||||||
htmlUrl: "https://github.com/o/r/issues/12",
|
htmlUrl: "https://github.com/o/r/issues/12",
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
});
|
});
|
||||||
|
summarizeTitleMock.mockResolvedValue(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns tracking_disabled when not enabled", async () => {
|
it("returns tracking_disabled when not enabled", async () => {
|
||||||
@@ -90,6 +164,7 @@ describe("maybeCreateTrackingIssue", () => {
|
|||||||
taskStore: {} as any,
|
taskStore: {} as any,
|
||||||
projectSettings: {},
|
projectSettings: {},
|
||||||
globalSettings: {},
|
globalSettings: {},
|
||||||
|
rootDir,
|
||||||
});
|
});
|
||||||
expect(result).toEqual({ created: false, reason: "tracking_disabled" });
|
expect(result).toEqual({ created: false, reason: "tracking_disabled" });
|
||||||
});
|
});
|
||||||
@@ -104,6 +179,7 @@ describe("maybeCreateTrackingIssue", () => {
|
|||||||
taskStore: {} as any,
|
taskStore: {} as any,
|
||||||
projectSettings: { githubTrackingDefaultRepo: "task/repo", githubAuthMode: "token", githubAuthToken: "tok" } as any,
|
projectSettings: { githubTrackingDefaultRepo: "task/repo", githubAuthMode: "token", githubAuthToken: "tok" } as any,
|
||||||
globalSettings: {},
|
globalSettings: {},
|
||||||
|
rootDir,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual({ created: false, reason: "issue_already_linked" });
|
expect(result).toEqual({ created: false, reason: "issue_already_linked" });
|
||||||
@@ -116,6 +192,7 @@ describe("maybeCreateTrackingIssue", () => {
|
|||||||
taskStore: { recordActivity } as any,
|
taskStore: { recordActivity } as any,
|
||||||
projectSettings: {},
|
projectSettings: {},
|
||||||
globalSettings: {},
|
globalSettings: {},
|
||||||
|
rootDir,
|
||||||
logger: { warn: vi.fn(), info: vi.fn() },
|
logger: { warn: vi.fn(), info: vi.fn() },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -132,6 +209,7 @@ describe("maybeCreateTrackingIssue", () => {
|
|||||||
taskStore: { linkGithubIssue, recordActivity } as any,
|
taskStore: { linkGithubIssue, recordActivity } as any,
|
||||||
projectSettings: {},
|
projectSettings: {},
|
||||||
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
|
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
|
||||||
|
rootDir,
|
||||||
logger: console,
|
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([
|
it.each([
|
||||||
["task override", { enabled: true, repoOverride: "task/repo" }, { githubTrackingDefaultRepo: "project/repo" }, { githubTrackingDefaultRepo: "global/repo" }, "task", "repo"],
|
["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"],
|
["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,
|
taskStore: { linkGithubIssue, recordActivity: vi.fn() } as any,
|
||||||
projectSettings: projectSettings as any,
|
projectSettings: projectSettings as any,
|
||||||
globalSettings: globalSettings as any,
|
globalSettings: globalSettings as any,
|
||||||
|
rootDir,
|
||||||
logger: console,
|
logger: console,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -165,23 +322,25 @@ describe("maybeCreateTrackingIssue", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("creates a tracking issue from explicit task override when defaults are unset", async () => {
|
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" } }), {
|
await maybeCreateTrackingIssue(buildTask({ githubTracking: { enabled: true, repoOverride: "task/repo" } }), {
|
||||||
taskStore: { linkGithubIssue, recordActivity: vi.fn() } as any,
|
taskStore: { linkGithubIssue, recordActivity: vi.fn() } as any,
|
||||||
projectSettings: {},
|
projectSettings: {},
|
||||||
globalSettings: {},
|
globalSettings: {},
|
||||||
logger: console,
|
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 () => {
|
it("skips creation when tracking is on but no repo is configured", async () => {
|
||||||
const result = await maybeCreateTrackingIssue(buildTask({ githubTracking: { enabled: true } }), {
|
const result = await maybeCreateTrackingIssue(buildTask({ githubTracking: { enabled: true } }), {
|
||||||
taskStore: { recordActivity: vi.fn() } as any,
|
taskStore: { recordActivity: vi.fn() } as any,
|
||||||
projectSettings: {},
|
projectSettings: {},
|
||||||
globalSettings: {},
|
globalSettings: {},
|
||||||
|
rootDir,
|
||||||
logger: { warn: vi.fn(), info: vi.fn() },
|
logger: { warn: vi.fn(), info: vi.fn() },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -202,6 +361,7 @@ describe("maybeCreateTrackingIssue", () => {
|
|||||||
taskStore: { recordActivity } as any,
|
taskStore: { recordActivity } as any,
|
||||||
projectSettings: {},
|
projectSettings: {},
|
||||||
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
|
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
|
||||||
|
rootDir,
|
||||||
logger: { warn: vi.fn(), info: vi.fn() },
|
logger: { warn: vi.fn(), info: vi.fn() },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -218,6 +378,7 @@ describe("maybeCreateTrackingIssue", () => {
|
|||||||
taskStore: { linkGithubIssue: vi.fn(), recordActivity: vi.fn() } as any,
|
taskStore: { linkGithubIssue: vi.fn(), recordActivity: vi.fn() } as any,
|
||||||
projectSettings: {},
|
projectSettings: {},
|
||||||
globalSettings,
|
globalSettings,
|
||||||
|
rootDir,
|
||||||
logger: { warn: vi.fn(), info: vi.fn() },
|
logger: { warn: vi.fn(), info: vi.fn() },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -232,6 +393,7 @@ describe("maybeCreateTrackingIssue", () => {
|
|||||||
taskStore: { recordActivity, linkGithubIssue: vi.fn() } as any,
|
taskStore: { recordActivity, linkGithubIssue: vi.fn() } as any,
|
||||||
projectSettings: {},
|
projectSettings: {},
|
||||||
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
|
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
|
||||||
|
rootDir,
|
||||||
logger: { warn: vi.fn(), info: vi.fn() },
|
logger: { warn: vi.fn(), info: vi.fn() },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { GlobalSettings, MergeDetails, ProjectSettings, Task, TaskStore } from "@fusion/core";
|
import type { GlobalSettings, MergeDetails, ProjectSettings, Task, TaskStore } from "@fusion/core";
|
||||||
|
import { deriveTitleFromDescription } from "./github-tracking.js";
|
||||||
import { GitHubClient } from "./github.js";
|
import { GitHubClient } from "./github.js";
|
||||||
import { resolveGithubTrackingAuth } from "./github-auth.js";
|
import { resolveGithubTrackingAuth } from "./github-auth.js";
|
||||||
|
|
||||||
@@ -44,6 +45,15 @@ function formatTitleSegment(title: string, maxLength: number): string {
|
|||||||
return truncateText(title, maxLength);
|
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(
|
function formatCommitLine(
|
||||||
mergeDetails: MergeDetails | undefined,
|
mergeDetails: MergeDetails | undefined,
|
||||||
linkContext: TrackingLinkContext | undefined,
|
linkContext: TrackingLinkContext | undefined,
|
||||||
@@ -83,11 +93,10 @@ function formatFilesLine(mergeDetails: MergeDetails | undefined): string | null
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildDoneComment(
|
function buildDoneComment(
|
||||||
task: Pick<Task, "id" | "title" | "branch" | "mergeDetails">,
|
task: Pick<Task, "id" | "title" | "description" | "branch" | "mergeDetails">,
|
||||||
linkContext?: TrackingLinkContext,
|
linkContext?: TrackingLinkContext,
|
||||||
options?: { includeCommitSubject?: boolean; includeFilesLine?: boolean },
|
options?: { includeCommitSubject?: boolean; includeFilesLine?: boolean },
|
||||||
): string {
|
): string {
|
||||||
const rawTitle = sanitizeInlineText(task.title ?? "") || "Untitled task";
|
|
||||||
const branch = sanitizeInlineText(task.branch ?? "");
|
const branch = sanitizeInlineText(task.branch ?? "");
|
||||||
const mergedAt = collapseWhitespace(task.mergeDetails?.mergedAt ?? "");
|
const mergedAt = collapseWhitespace(task.mergeDetails?.mergedAt ?? "");
|
||||||
const prNumber = task.mergeDetails?.prNumber;
|
const prNumber = task.mergeDetails?.prNumber;
|
||||||
@@ -122,7 +131,7 @@ function buildDoneComment(
|
|||||||
const suffix = "” is complete.";
|
const suffix = "” is complete.";
|
||||||
const extraLength = optionalLines.length === 0 ? 0 : `\n${optionalLines.join("\n")}`.length;
|
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 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}`;
|
const statusLine = `${stem}${title}${suffix}`;
|
||||||
|
|
||||||
return optionalLines.length === 0
|
return optionalLines.length === 0
|
||||||
@@ -131,7 +140,7 @@ function buildDoneComment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatTrackingComment(
|
export function formatTrackingComment(
|
||||||
task: Pick<Task, "id" | "title" | "branch" | "mergeDetails">,
|
task: Pick<Task, "id" | "title" | "description" | "branch" | "mergeDetails">,
|
||||||
transition: "in-progress" | "done",
|
transition: "in-progress" | "done",
|
||||||
linkContext?: TrackingLinkContext,
|
linkContext?: TrackingLinkContext,
|
||||||
): string {
|
): string {
|
||||||
@@ -153,11 +162,8 @@ export function formatTrackingComment(
|
|||||||
const stem = "🚧 In progress — work has started on “";
|
const stem = "🚧 In progress — work has started on “";
|
||||||
const suffix = "”.";
|
const suffix = "”.";
|
||||||
|
|
||||||
const rawTitle = collapseWhitespace(task.title ?? "") || "Untitled task";
|
|
||||||
const available = COMMENT_MAX_LENGTH - prefix.length - stem.length - suffix.length;
|
const available = COMMENT_MAX_LENGTH - prefix.length - stem.length - suffix.length;
|
||||||
const title = rawTitle.length <= available
|
const title = formatTitleSegment(resolveTrackingTitle(task, available), available);
|
||||||
? rawTitle
|
|
||||||
: `${rawTitle.slice(0, Math.max(0, available - 1)).trimEnd()}…`;
|
|
||||||
|
|
||||||
return `${prefix}${stem}${title}${suffix}`;
|
return `${prefix}${stem}${title}${suffix}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
|
AiServiceError,
|
||||||
|
MIN_DESCRIPTION_LENGTH,
|
||||||
resolveTaskGithubTracking,
|
resolveTaskGithubTracking,
|
||||||
|
summarizeTitle,
|
||||||
type GlobalSettings,
|
type GlobalSettings,
|
||||||
type ProjectSettings,
|
type ProjectSettings,
|
||||||
type Task,
|
type Task,
|
||||||
@@ -16,6 +19,65 @@ function collapseWhitespace(value: string): string {
|
|||||||
return value.replace(/\s+/g, " ").trim();
|
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 {
|
function firstNonEmptyParagraph(value: string | undefined): string | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const paragraph = value
|
const paragraph = value
|
||||||
@@ -39,17 +101,14 @@ function sanitizeSummaryText(value: string): string {
|
|||||||
return collapseWhitespace(withoutFusionUrls);
|
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 prefix = `[${task.id}] `;
|
||||||
const baseTitle = collapseWhitespace(task.title ?? "") || "Untitled task";
|
|
||||||
const maxTitleLength = Math.max(1, TRACKING_ISSUE_TITLE_LIMIT - prefix.length);
|
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}${truncateWithEllipsis(baseTitle, maxTitleLength)}`;
|
||||||
return `${prefix}${baseTitle}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const truncated = `${baseTitle.slice(0, Math.max(0, maxTitleLength - 1)).trimEnd()}…`;
|
|
||||||
return `${prefix}${truncated}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatTrackingIssueBody(task: {
|
export function formatTrackingIssueBody(task: {
|
||||||
@@ -76,6 +135,7 @@ export interface MaybeCreateTrackingIssueDeps {
|
|||||||
taskStore: TaskStore;
|
taskStore: TaskStore;
|
||||||
projectSettings: ProjectSettings;
|
projectSettings: ProjectSettings;
|
||||||
globalSettings: GlobalSettings;
|
globalSettings: GlobalSettings;
|
||||||
|
rootDir: string;
|
||||||
logger?: Pick<Console, "warn" | "info">;
|
logger?: Pick<Console, "warn" | "info">;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +150,34 @@ export type MaybeCreateTrackingIssueReason =
|
|||||||
| "auth_gh_not_authenticated"
|
| "auth_gh_not_authenticated"
|
||||||
| "auth_invalid_mode";
|
| "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(
|
export async function maybeCreateTrackingIssue(
|
||||||
task: Task,
|
task: Task,
|
||||||
deps: MaybeCreateTrackingIssueDeps,
|
deps: MaybeCreateTrackingIssueDeps,
|
||||||
@@ -122,6 +210,42 @@ export async function maybeCreateTrackingIssue(
|
|||||||
return { created: false, reason: "no_repo_configured" };
|
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({
|
const resolution = resolveGithubTrackingAuth({
|
||||||
projectSettings: deps.projectSettings,
|
projectSettings: deps.projectSettings,
|
||||||
globalSettings: deps.globalSettings,
|
globalSettings: deps.globalSettings,
|
||||||
|
|||||||
@@ -4803,6 +4803,7 @@ async function maybeCreateTaskTrackingIssue(taskStore: TaskStore, task: Task): P
|
|||||||
taskStore,
|
taskStore,
|
||||||
projectSettings,
|
projectSettings,
|
||||||
globalSettings,
|
globalSettings,
|
||||||
|
rootDir: taskStore.getRootDir(),
|
||||||
logger: console,
|
logger: console,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ async function maybeCreateTaskTrackingIssue(taskStore: TaskStore, task: import("
|
|||||||
taskStore,
|
taskStore,
|
||||||
projectSettings,
|
projectSettings,
|
||||||
globalSettings,
|
globalSettings,
|
||||||
|
rootDir: taskStore.getRootDir(),
|
||||||
logger: console,
|
logger: console,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ async function maybeCreateTaskTrackingIssue(taskStore: TaskStore, task: Task, op
|
|||||||
taskStore,
|
taskStore,
|
||||||
projectSettings: trackingProjectSettings,
|
projectSettings: trackingProjectSettings,
|
||||||
globalSettings,
|
globalSettings,
|
||||||
|
rootDir: taskStore.getRootDir(),
|
||||||
logger: console,
|
logger: console,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
Reference in New Issue
Block a user