feat(FN-760): use source task labels for refinement titles
- Derive readable refinement titles from source task title or first line of description instead of raw task ID - Fall back to task ID only when both title and description are empty/whitespace - Collapse internal whitespace in description-derived labels for clean output - Update PROMPT.md heading to match the new readable refinement title - Add comprehensive tests for title precedence, whitespace handling, and edge cases - Document refinement naming behavior in README - Add changeset for @gsxdsm/fusion patch release
This commit is contained in:
5
.changeset/refinement-title-from-source-label.md
Normal file
5
.changeset/refinement-title-from-source-label.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@gsxdsm/fusion": patch
|
||||
---
|
||||
|
||||
Refinement tasks now use the source task's title (or description when untitled) instead of the raw task ID for readable names like "Refinement: Fix login bug" instead of "Refinement: FN-741".
|
||||
@@ -145,6 +145,8 @@ fn task pause FN-001 # Pause automation for task
|
||||
fn task unpause FN-001 # Resume automation for task
|
||||
```
|
||||
|
||||
**Refinement tasks** created with `fn task refine` inherit a readable label from the source task. The title follows the format `Refinement: {source label}` where the source label is the original task's title (if set), otherwise the first line of the original description. This keeps refinement task names meaningful even when the source task has no explicit title.
|
||||
|
||||
**GitHub Integration:**
|
||||
```bash
|
||||
fn task import owner/repo # Import all open issues
|
||||
|
||||
@@ -2910,7 +2910,8 @@ Task with acceptance criteria
|
||||
expect(refined.id).not.toBe(task.id);
|
||||
expect(refined.id).toMatch(/^FN-\d+$/);
|
||||
expect(refined.column).toBe("triage");
|
||||
expect(refined.title).toBe(`Refinement: ${task.id}`);
|
||||
// Untitled source: uses first line of description as readable label
|
||||
expect(refined.title).toBe("Refinement: Original task");
|
||||
});
|
||||
|
||||
it("creates refinement from in-review task", async () => {
|
||||
@@ -2922,7 +2923,8 @@ Task with acceptance criteria
|
||||
const refined = await store.refineTask(task.id, "Need improvements");
|
||||
|
||||
expect(refined.column).toBe("triage");
|
||||
expect(refined.title).toBe(`Refinement: ${task.id}`);
|
||||
// Untitled source: uses first line of description as readable label
|
||||
expect(refined.title).toBe("Refinement: Original task");
|
||||
});
|
||||
|
||||
it("throws error when refining task in triage", async () => {
|
||||
@@ -2979,8 +2981,8 @@ Task with acceptance criteria
|
||||
expect(refined.title).toBe("Refinement: My Feature");
|
||||
});
|
||||
|
||||
it("sets correct title format without original title (uses ID)", async () => {
|
||||
const task = await store.createTask({ description: "Original task" });
|
||||
it("sets correct title format without original title (uses description fallback)", async () => {
|
||||
const task = await store.createTask({ description: "Fix the login bug" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
@@ -2988,7 +2990,8 @@ Task with acceptance criteria
|
||||
|
||||
const refined = await store.refineTask(task.id, "Add more tests");
|
||||
|
||||
expect(refined.title).toBe(`Refinement: ${task.id}`);
|
||||
// Falls back to first line of description when no title
|
||||
expect(refined.title).toBe("Refinement: Fix the login bug");
|
||||
});
|
||||
|
||||
it("description includes feedback and refines reference", async () => {
|
||||
@@ -3120,11 +3123,102 @@ Task with acceptance criteria
|
||||
const refined = await store.refineTask(task.id, "Need improvements");
|
||||
|
||||
const detail = await store.getTask(refined.id);
|
||||
expect(detail.prompt).toContain(`Refinement: ${task.id}`);
|
||||
// Untitled source: uses first line of description
|
||||
expect(detail.prompt).toContain("Refinement: Original task");
|
||||
expect(detail.prompt).toContain("Need improvements");
|
||||
expect(detail.prompt).toContain(`Refines: ${task.id}`);
|
||||
});
|
||||
|
||||
it("uses first non-empty line of description when title is absent", async () => {
|
||||
const task = await store.createTask({
|
||||
description: "Use source task labels for refinement titles\n\nThis is a longer description.",
|
||||
});
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
|
||||
const refined = await store.refineTask(task.id, "Add more tests");
|
||||
|
||||
expect(refined.title).toBe("Refinement: Use source task labels for refinement titles");
|
||||
});
|
||||
|
||||
it("collapses internal whitespace in description fallback", async () => {
|
||||
const task = await store.createTask({
|
||||
description: "Fix the \t spacing issue in UI",
|
||||
});
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
|
||||
const refined = await store.refineTask(task.id, "More feedback");
|
||||
|
||||
expect(refined.title).toBe("Refinement: Fix the spacing issue in UI");
|
||||
});
|
||||
|
||||
it("skips leading blank lines in multi-line description", async () => {
|
||||
const task = await store.createTask({
|
||||
description: "\n \n \nFirst real line of description\nSecond line",
|
||||
});
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
|
||||
const refined = await store.refineTask(task.id, "Feedback");
|
||||
|
||||
expect(refined.title).toBe("Refinement: First real line of description");
|
||||
});
|
||||
|
||||
it("falls back to task ID when description has no non-empty lines", async () => {
|
||||
// Create a task with a valid description, then update to all-whitespace
|
||||
// (createTask rejects all-whitespace descriptions, but updates could produce this edge case)
|
||||
const task = await store.createTask({ description: "Valid description" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
await store.updateTask(task.id, { description: " \n \n\t\n" });
|
||||
|
||||
const refined = await store.refineTask(task.id, "Feedback");
|
||||
|
||||
expect(refined.title).toBe(`Refinement: ${task.id}`);
|
||||
});
|
||||
|
||||
it("PROMPT.md heading matches the refinement title", async () => {
|
||||
const task = await store.createTask({
|
||||
title: "My Feature",
|
||||
description: "Some description",
|
||||
});
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
|
||||
const refined = await store.refineTask(task.id, "Need improvements");
|
||||
const detail = await store.getTask(refined.id);
|
||||
|
||||
expect(refined.title).toBe("Refinement: My Feature");
|
||||
expect(detail.prompt).toMatch(/^# Refinement: My Feature\n/);
|
||||
});
|
||||
|
||||
it("PROMPT.md heading uses description fallback when untitled", async () => {
|
||||
const task = await store.createTask({
|
||||
description: "Fix the login bug on settings page",
|
||||
});
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
|
||||
const refined = await store.refineTask(task.id, "Need improvements");
|
||||
const detail = await store.getTask(refined.id);
|
||||
|
||||
expect(refined.title).toBe("Refinement: Fix the login bug on settings page");
|
||||
expect(detail.prompt).toMatch(/^# Refinement: Fix the login bug on settings page\n/);
|
||||
});
|
||||
|
||||
it("throws ENOENT when source task does not exist", async () => {
|
||||
await expect(store.refineTask("KB-999", "Feedback")).rejects.toThrow();
|
||||
});
|
||||
|
||||
@@ -793,10 +793,27 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
const newId = await this.allocateId();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Derive a readable source label for the refinement title.
|
||||
// Precedence: title → first non-empty line of description (collapsed) → task ID
|
||||
let sourceLabel: string;
|
||||
if (sourceTask.title?.trim()) {
|
||||
sourceLabel = sourceTask.title.trim();
|
||||
} else {
|
||||
const firstLine = sourceTask.description
|
||||
.split("\n")
|
||||
.map((line: string) => line.trim())
|
||||
.find((line: string) => line.length > 0);
|
||||
if (firstLine) {
|
||||
sourceLabel = firstLine.replace(/\s+/g, " ");
|
||||
} else {
|
||||
sourceLabel = sourceTask.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Create new refinement task
|
||||
const newTask: Task = {
|
||||
id: newId,
|
||||
title: `Refinement: ${sourceTask.title || sourceTask.id}`,
|
||||
title: `Refinement: ${sourceLabel}`,
|
||||
description: `${feedback.trim()}\n\nRefines: ${id}`,
|
||||
column: "triage",
|
||||
dependencies: [id], // Refinement depends on the original being complete
|
||||
|
||||
Reference in New Issue
Block a user