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:
5
.changeset/FN-3871-github-issue-title-body.md
Normal file
5
.changeset/FN-3871-github-issue-title-body.md
Normal 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.
|
||||
@@ -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 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
|
||||
- Each active task runs in isolated worktree under `.worktrees/*`
|
||||
- Executor creates branches like `fusion/{task-id}` (`executor.ts`)
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
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 {
|
||||
return {
|
||||
@@ -17,6 +21,82 @@ function buildTask(overrides: Partial<Task> = {}): 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", () => {
|
||||
it("returns tracking_disabled when not enabled", async () => {
|
||||
const result = await maybeCreateTrackingIssue(buildTask({ githubTracking: { enabled: false } }), {
|
||||
@@ -89,7 +169,7 @@ describe("maybeCreateTrackingIssue", () => {
|
||||
const linkGithubIssue = 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,
|
||||
githubClient: { createIssue } as any,
|
||||
projectSettings: {},
|
||||
@@ -99,6 +179,12 @@ describe("maybeCreateTrackingIssue", () => {
|
||||
|
||||
expect(result.created).toBe(true);
|
||||
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(recordActivity).toHaveBeenCalledWith(expect.objectContaining({
|
||||
metadata: expect.objectContaining({ type: "github-issue-created", repo: "o/r", number: 12 }),
|
||||
|
||||
@@ -2,6 +2,69 @@ import type { GlobalSettings, ProjectSettings, Task, TaskStore } from "@fusion/c
|
||||
import type { CreatedIssue } 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 {
|
||||
taskStore: TaskStore;
|
||||
githubClient: GitHubClient;
|
||||
@@ -52,8 +115,8 @@ export async function maybeCreateTrackingIssue(
|
||||
return { created: false, reason: "no_repo_configured" };
|
||||
}
|
||||
|
||||
const title = `[${task.id}] ${task.title ?? task.description.slice(0, 80)}`;
|
||||
const body = `Tracking issue for ${task.id}.\n\n_Summary placeholder — populated by FN-3871._`;
|
||||
const title = formatTrackingIssueTitle(task);
|
||||
const body = formatTrackingIssueBody(task);
|
||||
|
||||
try {
|
||||
const issue = await deps.githubClient.createIssue({ owner: repo.owner, repo: repo.repo, title, body });
|
||||
|
||||
Reference in New Issue
Block a user