feat(FN-3513): add eval domain store, plugin dashboard view registry, and G

This merge adds three major features: an eval domain (`eval-store.ts`, `eval-types.ts`) with persistence schema for evaluation data; a plugin dashboard view registry with navigation integration for third-party dashboard extensions; and GitHub source metadata traceability that locks and enforces issu

Fusion-Task-Id: FN-3513
This commit is contained in:
Fusion
2026-05-05 12:20:28 -07:00
committed by gsxdsm
parent d7880c66b0
commit 30f6381ec0
11 changed files with 125 additions and 61 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Preserve complete GitHub source metadata for imported issues across CLI and extension import paths, and improve commit reference generation by falling back to `externalIssueId` when `issueNumber` is missing.

View File

@@ -290,6 +290,8 @@ Archive entries preserve key metadata needed for restoration, including:
Import issues:
- GitHub-imported tasks retain typed source issue metadata (`sourceIssue.provider/repository/externalIssueId/issueNumber/url`), which executor and merger flows use to include `Ref: owner/repo#N` in commit bodies.
```bash
fn task import owner/repo --labels bug --limit 20
fn task import owner/repo --interactive

View File

@@ -1185,6 +1185,10 @@ describe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)("fn pi extension", () => {
issueNumber: 1,
url: "https://github.com/acme/demo/issues/1",
});
expect(issueOneTask?.source?.sourceMetadata).toEqual({
issueUrl: "https://github.com/acme/demo/issues/1",
issueNumber: 1,
});
});
it("fn_task_browse_github_issues lists issues via gh api", async () => {

View File

@@ -170,7 +170,7 @@ describe("runTaskShow", () => {
[{ sourceType: "task_refine", sourceParentTaskId: "FN-2904" }, "Source: Refinement of FN-2904"],
[{ sourceType: "task_duplicate", sourceParentTaskId: "FN-2905" }, "Source: Duplicate of FN-2905"],
[
{ sourceType: "github_import", sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/42" } },
{ sourceType: "github_import", sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/42", issueNumber: 42 } },
"Source: GitHub Import (https://github.com/owner/repo/issues/42)",
],
[
@@ -1115,7 +1115,7 @@ describe("runTaskImportGitHubInteractive", () => {
issueNumber: 1,
url: "https://github.com/owner/repo/issues/1",
},
source: { sourceType: "github_import", sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/1" } },
source: { sourceType: "github_import", sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/1", issueNumber: 1 } },
});
expect(mockCreateTask).toHaveBeenCalledWith({
title: "Third Issue",
@@ -1129,7 +1129,7 @@ describe("runTaskImportGitHubInteractive", () => {
issueNumber: 3,
url: "https://github.com/owner/repo/issues/3",
},
source: { sourceType: "github_import", sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/3" } },
source: { sourceType: "github_import", sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/3", issueNumber: 3 } },
});
});
@@ -1187,7 +1187,7 @@ describe("runTaskImportGitHubInteractive", () => {
issueNumber: 2,
url: "https://github.com/owner/repo/issues/2",
},
source: { sourceType: "github_import", sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/2" } },
source: { sourceType: "github_import", sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/2", issueNumber: 2 } },
});
const skipLine = logSpy.mock.calls.find(
@@ -1441,7 +1441,7 @@ describe("runTaskImportFromGitHub", () => {
issueNumber: 1,
url: "https://github.com/owner/repo/issues/1",
},
source: { sourceType: "github_import", sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/1" } },
source: { sourceType: "github_import", sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/1", issueNumber: 1 } },
});
const successLine = logSpy.mock.calls.find(
@@ -1524,7 +1524,7 @@ describe("runTaskImportFromGitHub", () => {
issueNumber: 1,
url: "https://github.com/owner/repo/issues/1",
},
source: { sourceType: "github_import", sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/1" } },
source: { sourceType: "github_import", sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/1", issueNumber: 1 } },
});
});
@@ -1546,7 +1546,7 @@ describe("runTaskImportFromGitHub", () => {
issueNumber: 1,
url: "https://github.com/owner/repo/issues/1",
},
source: { sourceType: "github_import", sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/1" } },
source: { sourceType: "github_import", sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/1", issueNumber: 1 } },
});
});
});

View File

@@ -984,21 +984,16 @@ export async function runTaskImportGitHubInteractive(
const description = `${body}\n\nSource: ${issue.html_url}`;
// Create the task
const source = buildGitHubIssueSource(owner, repo, issue);
const task = await store.createTask({
title: title || undefined,
description,
column: "triage",
dependencies: [],
sourceIssue: {
provider: "github",
repository: `${owner}/${repo}`,
externalIssueId: String(issue.number),
issueNumber: issue.number,
url: issue.html_url,
},
sourceIssue: source.sourceIssue,
source: {
sourceType: "github_import",
sourceMetadata: { issueUrl: issue.html_url },
sourceMetadata: source.sourceMetadata,
},
});
@@ -1067,6 +1062,19 @@ export interface TaskImportOptions {
labels?: string[];
}
function buildGitHubIssueSource(owner: string, repo: string, issue: { number: number; html_url: string }) {
return {
sourceIssue: {
provider: "github" as const,
repository: `${owner}/${repo}`,
externalIssueId: String(issue.number),
issueNumber: issue.number,
url: issue.html_url,
},
sourceMetadata: { issueUrl: issue.html_url, issueNumber: issue.number },
};
}
export async function runTaskImportFromGitHub(
ownerRepo: string,
options: TaskImportOptions = {},
@@ -1131,21 +1139,16 @@ export async function runTaskImportFromGitHub(
const description = `${body}\n\nSource: ${issue.html_url}`;
// Create the task
const source = buildGitHubIssueSource(owner, repo, issue);
const task = await store.createTask({
title: title || undefined,
description,
column: "triage",
dependencies: [],
sourceIssue: {
provider: "github",
repository: `${owner}/${repo}`,
externalIssueId: String(issue.number),
issueNumber: issue.number,
url: issue.html_url,
},
sourceIssue: source.sourceIssue,
source: {
sourceType: "github_import",
sourceMetadata: { issueUrl: issue.html_url },
sourceMetadata: source.sourceMetadata,
},
});

View File

@@ -251,6 +251,19 @@ async function fetchGitHubIssuesViaGh(
}
}
function buildGitHubIssueSource(owner: string, repo: string, issue: { number: number; html_url: string }) {
return {
sourceIssue: {
provider: "github" as const,
repository: `${owner}/${repo}`,
externalIssueId: String(issue.number),
issueNumber: issue.number,
url: issue.html_url,
},
sourceMetadata: { issueUrl: issue.html_url, issueNumber: issue.number },
};
}
async function fetchGitHubIssueViaGh(
owner: string,
repo: string,
@@ -988,21 +1001,16 @@ export default function kbExtension(pi: ExtensionAPI) {
const body = issue.body?.trim() || "(no description)";
const description = `${body}\n\nSource: ${sourceUrl}`;
const source = buildGitHubIssueSource(owner, repo, issue);
const task = await store.createTask({
title: title || undefined,
description,
column: "triage",
dependencies: [],
sourceIssue: {
provider: "github",
repository: `${owner}/${repo}`,
externalIssueId: String(issue.number),
issueNumber: issue.number,
url: issue.html_url,
},
sourceIssue: source.sourceIssue,
source: {
sourceType: "github_import",
sourceMetadata: { issueUrl: issue.html_url },
sourceMetadata: source.sourceMetadata,
},
});
@@ -1085,21 +1093,16 @@ export default function kbExtension(pi: ExtensionAPI) {
const body = issue.body?.trim() || "(no description)";
const description = `${body}\n\nSource: ${sourceUrl}`;
const source = buildGitHubIssueSource(owner, repo, issue);
const task = await store.createTask({
title: title || undefined,
description,
column: "triage",
dependencies: [],
sourceIssue: {
provider: "github",
repository: `${owner}/${repo}`,
externalIssueId: String(issue.number),
issueNumber: issue.number,
url: issue.html_url,
},
sourceIssue: source.sourceIssue,
source: {
sourceType: "github_import",
sourceMetadata: { issueUrl: issue.html_url },
sourceMetadata: source.sourceMetadata,
},
});

View File

@@ -935,6 +935,19 @@ export function createBatchImportRateLimiter(): (req: Request, res: Response, ne
};
}
function buildGitHubIssueSource(owner: string, repo: string, issue: { number: number; html_url: string }) {
return {
sourceIssue: {
provider: "github" as const,
repository: `${owner}/${repo}`,
externalIssueId: String(issue.number),
issueNumber: issue.number,
url: issue.html_url,
},
sourceMetadata: { issueUrl: issue.html_url, issueNumber: issue.number },
};
}
export function getDefaultGitHubRepo(store: TaskStore): { owner: string; repo: string } | null {
const envRepo = process.env.GITHUB_REPOSITORY;
if (envRepo) {
@@ -2082,21 +2095,16 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
const body = issue.body?.trim() || "(no description)";
const description = `${body}\n\nSource: ${sourceUrl}`;
const source = buildGitHubIssueSource(owner, repo, issue);
const task = await scopedStore.createTask({
title: title || undefined,
description,
column: "triage",
dependencies: [],
sourceIssue: {
provider: "github",
repository: `${owner}/${repo}`,
externalIssueId: String(issue.number),
issueNumber: issue.number,
url: issue.html_url,
},
sourceIssue: source.sourceIssue,
source: {
sourceType: "github_import",
sourceMetadata: { issueUrl: issue.html_url, issueNumber: issue.number },
sourceMetadata: source.sourceMetadata,
},
});
@@ -2222,21 +2230,16 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
const description = `${body}\n\nSource: ${sourceUrl}`;
try {
const source = buildGitHubIssueSource(owner, repo, issue);
const task = await scopedStore.createTask({
title: title || undefined,
description,
column: "triage",
dependencies: [],
sourceIssue: {
provider: "github",
repository: `${owner}/${repo}`,
externalIssueId: String(issue.number),
issueNumber: issue.number,
url: issue.html_url,
},
sourceIssue: source.sourceIssue,
source: {
sourceType: "github_import",
sourceMetadata: { issueUrl: issue.html_url, issueNumber: issue.number },
sourceMetadata: source.sourceMetadata,
},
});

View File

@@ -2505,6 +2505,19 @@ describe("buildExecutionPrompt", () => {
expect(result).toContain('git commit -m "feat(FN-001): complete Step N — description" -m "Ref: runfusion/fusion#2915"');
});
it("falls back to externalIssueId for commit source issue reference when issueNumber is missing", () => {
const task = createMockTaskDetail({
sourceIssue: {
provider: "github",
repository: "runfusion/fusion",
externalIssueId: "2915",
},
} as any);
const result = buildExecutionPrompt(task, "/home/user/project");
expect(result).toContain('git commit -m "feat(FN-001): complete Step N — description" -m "Ref: runfusion/fusion#2915"');
});
it("omits source issue reference from commit instruction when sourceIssue is missing", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task, "/home/user/project");

View File

@@ -5582,6 +5582,15 @@ describe("buildSourceIssueRef", () => {
})).toBe("runfusion/fusion#123");
});
it("falls back to externalIssueId when issueNumber is missing", async () => {
const { buildSourceIssueRef } = await import("../merger.js");
expect(buildSourceIssueRef({
provider: "github",
repository: "runfusion/fusion",
externalIssueId: "321",
} as any)).toBe("runfusion/fusion#321");
});
it("returns empty string for non-GitHub providers", async () => {
const { buildSourceIssueRef } = await import("../merger.js");
expect(buildSourceIssueRef({
@@ -5596,6 +5605,11 @@ describe("buildSourceIssueRef", () => {
const { buildSourceIssueRef } = await import("../merger.js");
expect(buildSourceIssueRef(undefined)).toBe("");
expect(buildSourceIssueRef(null)).toBe("");
expect(buildSourceIssueRef({
provider: "github",
repository: "runfusion/fusion",
externalIssueId: "not-a-number",
} as any)).toBe("");
});
});

View File

@@ -6890,6 +6890,21 @@ function scopePromptToWorktree(prompt: string, rootDir?: string, worktreePath?:
.replaceAll(`${worktreePath}/.fusion/`, `${rootDir}/.fusion/`);
}
function buildSourceIssueRef(sourceIssue: TaskDetail["sourceIssue"]): string {
if (!sourceIssue || sourceIssue.provider !== "github" || !sourceIssue.repository) {
return "";
}
const issueNumber = sourceIssue.issueNumber
?? Number.parseInt(sourceIssue.externalIssueId ?? "", 10);
if (!Number.isInteger(issueNumber) || issueNumber < 1) {
return "";
}
return `${sourceIssue.repository}#${issueNumber}`;
}
export function buildExecutionPrompt(task: TaskDetail, rootDir?: string, settings?: Settings, worktreePath?: string): string {
const prompt = scopePromptToWorktree(task.prompt, rootDir, worktreePath);
const reviewMatch = prompt.match(/##\s*Review Level[:\s]*(\d)/);
@@ -6900,9 +6915,7 @@ export function buildExecutionPrompt(task: TaskDetail, rootDir?: string, setting
? ` --author="${settings?.commitAuthorName || "Fusion"} <${settings?.commitAuthorEmail || "noreply@runfusion.ai"}>"`
: "";
const sourceIssueRef = task.sourceIssue?.provider === "github" && task.sourceIssue.repository && task.sourceIssue.issueNumber
? `${task.sourceIssue.repository}#${task.sourceIssue.issueNumber}`
: "";
const sourceIssueRef = buildSourceIssueRef(task.sourceIssue);
// Build step progress for resume
const hasProgress = task.steps.length > 0 && task.steps.some((s) => s.status !== "pending");

View File

@@ -1770,9 +1770,13 @@ function getCommitAuthorArg(settings: {
}
export function buildSourceIssueRef(sourceIssue?: TaskSourceIssue | null): string {
if (!sourceIssue || sourceIssue.provider !== "github") return "";
if (!sourceIssue.repository || !sourceIssue.issueNumber) return "";
return `${sourceIssue.repository}#${sourceIssue.issueNumber}`;
if (!sourceIssue || sourceIssue.provider !== "github" || !sourceIssue.repository) return "";
const issueNumber = sourceIssue.issueNumber
?? Number.parseInt(sourceIssue.externalIssueId ?? "", 10);
if (!Number.isInteger(issueNumber) || issueNumber < 1) return "";
return `${sourceIssue.repository}#${issueNumber}`;
}
/**