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:
5
.changeset/fn-3513-github-source-metadata.md
Normal file
5
.changeset/fn-3513-github-source-metadata.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user