feat(FN-3871): add tracking issue title/body formatters

Added GitHub tracking issue title and body formatters to the dashboard (`packages/dashboard/src/github-tracking.ts`), with corresponding tests and documentation in the architecture docs. A changeset was included for this user-facing feature.

Fusion-Task-Id: FN-3871
This commit is contained in:
Fusion
2026-05-09 23:46:07 -07:00
committed by gsxdsm
parent 41c114add8
commit 56c232a322
4 changed files with 160 additions and 4 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
GitHub tracking issues now use the format `[FN-XXXX] Title` for the title and a short plaintext summary prefixed with `Fusion task: FN-XXXX` for the body. The full task prompt is never included and no hyperlink back to Fusion is added.

View File

@@ -1238,6 +1238,8 @@ Git dashboard routes are registered in `register-git-github.ts`.
When a task is created, Fusion only attempts GitHub issue creation if per-task tracking is explicitly enabled (`task.githubTracking.enabled === true`). The lifecycle then resolves the repo in priority order: task override (`repoOverride`) → project `githubTrackingDefaultRepo` → global `githubTrackingDefaultRepo`. If no repo resolves, task creation still succeeds and an activity entry records the skip reason. GitHub API/CLI failures are best-effort only (swallowed with warning), so task creation is never blocked by GitHub availability. When a task is created, Fusion only attempts GitHub issue creation if per-task tracking is explicitly enabled (`task.githubTracking.enabled === true`). The lifecycle then resolves the repo in priority order: task override (`repoOverride`) → project `githubTrackingDefaultRepo` → global `githubTrackingDefaultRepo`. If no repo resolves, task creation still succeeds and an activity entry records the skip reason. GitHub API/CLI failures are best-effort only (swallowed with warning), so task creation is never blocked by GitHub availability.
When Fusion does create a tracking issue, it formats the title as `[FN-XXXX] Task title` and sends a short plain-text body prefixed with `Fusion task: FN-XXXX`. The body is a bounded summary snippet (not full task prompt content), and Fusion does not include any hyperlink back to the local dashboard.
### Worktree model ### Worktree model
- Each active task runs in isolated worktree under `.worktrees/*` - Each active task runs in isolated worktree under `.worktrees/*`
- Executor creates branches like `fusion/{task-id}` (`executor.ts`) - Executor creates branches like `fusion/{task-id}` (`executor.ts`)

View File

@@ -1,6 +1,10 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import type { Task } from "@fusion/core"; import type { Task } from "@fusion/core";
import { maybeCreateTrackingIssue } from "../github-tracking.js"; import {
formatTrackingIssueBody,
formatTrackingIssueTitle,
maybeCreateTrackingIssue,
} from "../github-tracking.js";
function buildTask(overrides: Partial<Task> = {}): Task { function buildTask(overrides: Partial<Task> = {}): Task {
return { return {
@@ -17,6 +21,82 @@ function buildTask(overrides: Partial<Task> = {}): Task {
} as Task; } as Task;
} }
describe("formatTrackingIssueTitle", () => {
it("formats a normal title", () => {
expect(formatTrackingIssueTitle({ id: "FN-1", title: "Hello" })).toBe("[FN-1] Hello");
});
it("falls back for blank title", () => {
expect(formatTrackingIssueTitle({ id: "FN-1", title: " \n\t " })).toBe("[FN-1] Untitled task");
});
it("collapses multiline whitespace", () => {
expect(formatTrackingIssueTitle({ id: "FN-1", title: "Hello\n\tWorld" })).toBe("[FN-1] Hello World");
});
it("truncates very long titles while preserving id prefix", () => {
const longTitle = "x".repeat(400);
const formatted = formatTrackingIssueTitle({ id: "FN-123", title: longTitle });
expect(formatted.startsWith("[FN-123] ")).toBe(true);
expect(formatted.length).toBeLessThanOrEqual(240);
expect(formatted.endsWith("…")).toBe(true);
});
});
describe("formatTrackingIssueBody", () => {
it("prefers first description paragraph", () => {
expect(formatTrackingIssueBody({
id: "FN-X",
description: "Primary paragraph\n\nSecond paragraph",
prompt: "Prompt paragraph",
summary: "Summary paragraph",
})).toBe("Fusion task: FN-X\n\nPrimary paragraph");
});
it("uses prompt when description is empty", () => {
expect(formatTrackingIssueBody({ id: "FN-X", description: "", prompt: "Prompt paragraph", summary: "Summary" }))
.toBe("Fusion task: FN-X\n\nPrompt paragraph");
});
it("uses summary when description and prompt are unavailable", () => {
expect(formatTrackingIssueBody({ id: "FN-X", summary: "Summary paragraph" }))
.toBe("Fusion task: FN-X\n\nSummary paragraph");
});
it("falls back when prompt is undefined and sources are empty", () => {
expect(formatTrackingIssueBody({ id: "FN-X", description: " ", summary: " " }))
.toBe("Fusion task: FN-X\n\nNo summary available.");
});
it("strips markdown noise including headings, bullets, and code fences", () => {
const body = formatTrackingIssueBody({
id: "FN-X",
description: "# Heading\n- bullet\n1. numbered\n```ts\nconst x = 1;\n```\nfinal",
});
expect(body).toBe("Fusion task: FN-X\n\nHeading bullet numbered const x = 1; final");
});
it("truncates summary to 500 characters with ellipsis", () => {
const body = formatTrackingIssueBody({ id: "FN-X", description: "a".repeat(600) });
const summary = body.replace("Fusion task: FN-X\n\n", "");
expect(summary.length).toBe(500);
expect(summary.endsWith("…")).toBe(true);
});
it("removes fusion-style localhost task urls", () => {
const body = formatTrackingIssueBody({
id: "FN-1",
description: "See http://localhost:4040/tasks/FN-1 and continue",
});
expect(body).not.toContain("localhost");
expect(body).not.toMatch(/https?:\/\/[^\s]*\/tasks\/FN-/);
});
it("always starts with fusion task reference", () => {
expect(formatTrackingIssueBody({ id: "FN-99", description: "hello" }).startsWith("Fusion task: FN-99\n\n")).toBe(true);
});
});
describe("maybeCreateTrackingIssue", () => { describe("maybeCreateTrackingIssue", () => {
it("returns tracking_disabled when not enabled", async () => { it("returns tracking_disabled when not enabled", async () => {
const result = await maybeCreateTrackingIssue(buildTask({ githubTracking: { enabled: false } }), { const result = await maybeCreateTrackingIssue(buildTask({ githubTracking: { enabled: false } }), {
@@ -89,7 +169,7 @@ describe("maybeCreateTrackingIssue", () => {
const linkGithubIssue = vi.fn(); const linkGithubIssue = vi.fn();
const recordActivity = vi.fn(); const recordActivity = vi.fn();
const result = await maybeCreateTrackingIssue(buildTask({ title: "Test", githubTracking: { enabled: true } }), { const result = await maybeCreateTrackingIssue(buildTask({ title: "Test", description: "Short body", githubTracking: { enabled: true } }), {
taskStore: { linkGithubIssue, recordActivity } as any, taskStore: { linkGithubIssue, recordActivity } as any,
githubClient: { createIssue } as any, githubClient: { createIssue } as any,
projectSettings: {}, projectSettings: {},
@@ -99,6 +179,12 @@ describe("maybeCreateTrackingIssue", () => {
expect(result.created).toBe(true); expect(result.created).toBe(true);
expect(createIssue).toHaveBeenCalledTimes(1); expect(createIssue).toHaveBeenCalledTimes(1);
expect(createIssue).toHaveBeenCalledWith(expect.objectContaining({
title: "[FN-1] Test",
body: expect.stringMatching(/^Fusion task: FN-1\n\n/),
}));
const calledBody = createIssue.mock.calls[0][0]?.body as string;
expect(calledBody.length).toBeLessThanOrEqual("Fusion task: FN-1\n\n".length + 500);
expect(linkGithubIssue).toHaveBeenCalledWith("FN-1", expect.objectContaining({ owner: "o", repo: "r", number: 12 })); expect(linkGithubIssue).toHaveBeenCalledWith("FN-1", expect.objectContaining({ owner: "o", repo: "r", number: 12 }));
expect(recordActivity).toHaveBeenCalledWith(expect.objectContaining({ expect(recordActivity).toHaveBeenCalledWith(expect.objectContaining({
metadata: expect.objectContaining({ type: "github-issue-created", repo: "o/r", number: 12 }), metadata: expect.objectContaining({ type: "github-issue-created", repo: "o/r", number: 12 }),

View File

@@ -2,6 +2,69 @@ import type { GlobalSettings, ProjectSettings, Task, TaskStore } from "@fusion/c
import type { CreatedIssue } from "./github.js"; import type { CreatedIssue } from "./github.js";
import type { GitHubClient } from "./github.js"; import type { GitHubClient } from "./github.js";
const TRACKING_ISSUE_TITLE_LIMIT = 240;
const TRACKING_ISSUE_BODY_SUMMARY_LIMIT = 500;
function collapseWhitespace(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
function firstNonEmptyParagraph(value: string | undefined): string | null {
if (!value) return null;
const paragraph = value
.split(/\n\s*\n/g)
.map((part) => part.trim())
.find((part) => part.length > 0);
return paragraph && paragraph.length > 0 ? paragraph : null;
}
function sanitizeSummaryText(value: string): string {
const cleaned = value
.split(/\r?\n/)
.filter((line) => !/^```/.test(line.trim()))
.map((line) => line.replace(/^\s*#{1,6}\s+/, "").replace(/^\s*(?:[-*+]\s+|\d+\.\s+)/, ""))
.join(" ");
const withoutFusionUrls = cleaned
.replace(/https?:\/\/localhost(?::\d+)?\/[^\s)]*/gi, " ")
.replace(/https?:\/\/[^\s)]*\/tasks\/FN-\d+[^\s)]*/gi, " ");
return collapseWhitespace(withoutFusionUrls);
}
export function formatTrackingIssueTitle(task: Pick<Task, "id" | "title">): string {
const prefix = `[${task.id}] `;
const baseTitle = collapseWhitespace(task.title ?? "") || "Untitled task";
const maxTitleLength = Math.max(1, TRACKING_ISSUE_TITLE_LIMIT - prefix.length);
if (baseTitle.length <= maxTitleLength) {
return `${prefix}${baseTitle}`;
}
const truncated = `${baseTitle.slice(0, Math.max(0, maxTitleLength - 1)).trimEnd()}`;
return `${prefix}${truncated}`;
}
export function formatTrackingIssueBody(task: {
id: string;
title?: string;
description?: string;
summary?: string;
prompt?: string;
}): string {
const source = firstNonEmptyParagraph(task.description)
?? firstNonEmptyParagraph(task.prompt)
?? task.summary?.trim()
?? "No summary available.";
const sanitized = sanitizeSummaryText(source) || "No summary available.";
const summary = sanitized.length > TRACKING_ISSUE_BODY_SUMMARY_LIMIT
? `${sanitized.slice(0, TRACKING_ISSUE_BODY_SUMMARY_LIMIT - 1).trimEnd()}`
: sanitized;
return `Fusion task: ${task.id}\n\n${summary}`;
}
export interface MaybeCreateTrackingIssueDeps { export interface MaybeCreateTrackingIssueDeps {
taskStore: TaskStore; taskStore: TaskStore;
githubClient: GitHubClient; githubClient: GitHubClient;
@@ -52,8 +115,8 @@ export async function maybeCreateTrackingIssue(
return { created: false, reason: "no_repo_configured" }; return { created: false, reason: "no_repo_configured" };
} }
const title = `[${task.id}] ${task.title ?? task.description.slice(0, 80)}`; const title = formatTrackingIssueTitle(task);
const body = `Tracking issue for ${task.id}.\n\n_Summary placeholder — populated by FN-3871._`; const body = formatTrackingIssueBody(task);
try { try {
const issue = await deps.githubClient.createIssue({ owner: repo.owner, repo: repo.repo, title, body }); const issue = await deps.githubClient.createIssue({ owner: repo.owner, repo: repo.repo, title, body });