FN-7367: link imported GitHub issues to tracking
Imported GitHub issues can now adopt their source issue as the tracking issue without changing ordinary task defaults. - Add the project-scoped githubLinkImportedIssuesToTracking setting with docs, Settings UI, and defaults. - Honor the import-only setting across dashboard, CLI, and extension GitHub issue import paths. - Cover saved settings and import tracking behavior with CLI, dashboard, and parity tests. - Add a minor changeset for the published CLI package. Files changed: .changeset/fn-7367-github-import-tracking.md | 7 +++ docs/settings-reference.md | 3 +- packages/cli/src/__tests__/extension.test.ts | 73 ++++++++++++++++++++++ .../task-command-github-import-tracking.test.ts | 16 +++++ packages/cli/src/commands/__tests__/task.test.ts | 28 +++++++++ packages/cli/src/commands/task.ts | 45 +++++++------ packages/cli/src/extension.ts | 45 +++++++------ .../core/src/__tests__/settings-parity.test.ts | 4 ++ packages/core/src/settings-schema.ts | 1 + packages/core/src/types.ts | 6 ++ .../app/__tests__/settings-save-split.test.ts | 19 ++++++ .../dashboard/app/components/SettingsModal.tsx | 6 ++ .../__tests__/SettingsModal.general.test.tsx | 59 +++++++++++++++++ .../__tests__/SettingsModal.test-harness.tsx | 1 + .../settings/sections/GeneralSection.tsx | 9 +++ .../dashboard/src/__tests__/routes-github.test.ts | 45 +++++++++++++ .../dashboard/src/routes/register-git-github.ts | 7 +++ 17 files changed, 325 insertions(+), 49 deletions(-) Fusion-Task-Id: FN-7367 Fusion-Task-Lineage: 43671cdf-7e0d-4b7a-9ba2-54c9a367dfec Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7367-github-import-tracking.md
Normal file
7
.changeset/fn-7367-github-import-tracking.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Add a project option to link imported GitHub issues to GitHub tracking.
|
||||
category: feature
|
||||
dev: GitHub issue import paths honor githubLinkImportedIssuesToTracking while ordinary task creation remains unchanged.
|
||||
@@ -581,7 +581,8 @@ Default notes:
|
||||
| `githubCommentOnDone` | `boolean` | `false` | When enabled, tasks imported from GitHub issues post a completion comment to the source issue when the task moves to `done`. |
|
||||
| `githubCommentTemplate` | `string` | `undefined` | Optional issue comment template used by `githubCommentOnDone`. Supports `{taskId}` and `{taskTitle}` placeholders. If unset, Fusion uses a default completion message. |
|
||||
| `githubCloseSourceIssueOnDone` | `boolean` | `false` | When enabled, source-imported GitHub issues are automatically closed with `state_reason: completed` when the Fusion task moves to `done`. A startup reconciliation sweep also closes missed open source issues on boot. |
|
||||
| `githubTrackingEnabledByDefault` | `boolean` | `false` | Project-level default for enabling issue tracking on new tasks. When this is false, the Quick Entry GitHub toggle is disabled until tracking is enabled in Settings. When this or the global new-task default enables tracking, imported GitHub issues are marked as tracked tasks and adopt the source issue instead of opening a duplicate tracking issue. |
|
||||
| `githubTrackingEnabledByDefault` | `boolean` | `false` | Project-level default for enabling issue tracking on ordinary new tasks. When this is false, the Quick Entry GitHub toggle is disabled until tracking is enabled in Settings. Imported GitHub issues still follow this default unless `githubLinkImportedIssuesToTracking` is enabled. |
|
||||
| `githubLinkImportedIssuesToTracking` | `boolean` | `false` | Project-scoped, import-only option. When enabled, GitHub issue imports from the dashboard, CLI, and extension tools persist `githubTracking: { enabled: true }` so Fusion adopts the imported source issue as the tracking issue without turning tracking on for ordinary new tasks. Duplicate/skipped imports do not create tasks or tracking metadata. |
|
||||
| `githubTrackingDefaultRepo` | `string` | `undefined` | Project default issue-tracking repo (`owner/repo`) used before global fallback for tracked task creation (precedence: task override → project default → global default). In Settings UI this is a detected-remote dropdown with a Custom fallback for manual entry. This key is dual-scope: project saves go through `PUT /api/settings` (Settings → General → GitHub Tracking) while global saves go through `PUT /api/settings/global` (Settings → Global General). |
|
||||
| `githubTrackingDedupEnabled` | `boolean` | `true` | When enabled, tracking issue creation searches open and closed repo issues for likely duplicates before opening a new issue (gh CLI search first, with REST search fallback). Set `false` to skip dedup and always create a new issue when tracking is enabled. Dashboard location: **Settings → Project → General → GitHub Tracking**. |
|
||||
| `githubAuthMode` | `"gh-cli" \| "token"` | `"gh-cli"` | Project GitHub auth strategy used by tracking lifecycle integration. `"gh-cli"` requires an installed/authenticated `gh` CLI. `"token"` requires a non-empty `githubAuthToken` (or `GITHUB_TOKEN` env fallback). Tracking lifecycle auth is strict per selected mode (no cross-fallback). |
|
||||
|
||||
@@ -2493,6 +2493,41 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega
|
||||
await verifyStore.close();
|
||||
});
|
||||
|
||||
it("fn_task_import_github marks imported issues as tracked when import linking is on and new-task defaults are off", async () => {
|
||||
const store = new TaskStore(tmpDir);
|
||||
await store.init();
|
||||
await store.updateSettings({
|
||||
githubTrackingEnabledByDefault: false,
|
||||
githubLinkImportedIssuesToTracking: true,
|
||||
});
|
||||
await store.close();
|
||||
|
||||
const tool = api.tools.get("fn_task_import_github")!;
|
||||
vi.mocked(runGhJsonAsync).mockResolvedValueOnce([
|
||||
{
|
||||
number: 9,
|
||||
title: "Import-linked issue",
|
||||
body: null,
|
||||
html_url: "https://github.com/acme/demo/issues/9",
|
||||
},
|
||||
] as never);
|
||||
|
||||
await tool.execute("gh-import-linked-bulk", { ownerRepo: "acme/demo" }, undefined, undefined, makeCtx(tmpDir));
|
||||
|
||||
const verifyStore = new TaskStore(tmpDir);
|
||||
await verifyStore.init();
|
||||
const tasks = await verifyStore.listTasks({ includeArchived: true });
|
||||
const imported = tasks.find((task) => task.sourceIssue?.issueNumber === 9);
|
||||
expect(imported?.description).toContain("(no description)");
|
||||
expect(imported?.githubTracking?.enabled).toBe(true);
|
||||
expect(imported?.sourceIssue).toEqual(expect.objectContaining({
|
||||
provider: "github",
|
||||
repository: "acme/demo",
|
||||
issueNumber: 9,
|
||||
}));
|
||||
await verifyStore.close();
|
||||
});
|
||||
|
||||
it("fn_task_import_github_issue leaves imported issues unforced when tracking defaults are off", async () => {
|
||||
const tool = api.tools.get("fn_task_import_github_issue")!;
|
||||
vi.mocked(runGhJsonAsync).mockResolvedValueOnce({
|
||||
@@ -2556,6 +2591,44 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega
|
||||
await verifyStore.close();
|
||||
});
|
||||
|
||||
it("fn_task_import_github_issue marks imported issues as tracked when import linking is on and new-task defaults are off", async () => {
|
||||
const store = new TaskStore(tmpDir);
|
||||
await store.init();
|
||||
await store.updateSettings({
|
||||
githubTrackingEnabledByDefault: false,
|
||||
githubLinkImportedIssuesToTracking: true,
|
||||
});
|
||||
await store.close();
|
||||
|
||||
const tool = api.tools.get("fn_task_import_github_issue")!;
|
||||
vi.mocked(runGhJsonAsync).mockResolvedValueOnce({
|
||||
number: 10,
|
||||
title: "Single import-linked issue",
|
||||
body: null,
|
||||
html_url: "https://github.com/acme/demo/issues/10",
|
||||
} as never);
|
||||
|
||||
const result = await tool.execute(
|
||||
"gh-import-linked-single",
|
||||
{ owner: "acme", repo: "demo", issueNumber: 10 },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
|
||||
const verifyStore = new TaskStore(tmpDir);
|
||||
await verifyStore.init();
|
||||
const imported = await verifyStore.getTask(result.details.taskId);
|
||||
expect(imported?.description).toContain("(no description)");
|
||||
expect(imported?.githubTracking?.enabled).toBe(true);
|
||||
expect(imported?.sourceIssue).toEqual(expect.objectContaining({
|
||||
provider: "github",
|
||||
repository: "acme/demo",
|
||||
issueNumber: 10,
|
||||
}));
|
||||
await verifyStore.close();
|
||||
});
|
||||
|
||||
it("fn_task_import_github skips issues already imported via sourceIssue even when description was edited", async () => {
|
||||
const store = new TaskStore(tmpDir);
|
||||
await store.init();
|
||||
|
||||
@@ -107,6 +107,22 @@ describe("fn task import GitHub tracking defaults", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it("sets githubTracking.enabled for fn task import when import linking is on and new-task defaults are off", async () => {
|
||||
const { createTask } = mockStore({
|
||||
projectSettings: {
|
||||
githubTrackingEnabledByDefault: false,
|
||||
githubLinkImportedIssuesToTracking: true,
|
||||
},
|
||||
});
|
||||
|
||||
await runTaskImportFromGitHub("owner/repo", { limit: 1 });
|
||||
|
||||
expect(createTask).toHaveBeenCalledWith(expect.objectContaining({
|
||||
githubTracking: { enabled: true },
|
||||
sourceIssue: expect.objectContaining({ provider: "github", repository: "owner/repo", issueNumber: 1 }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("does not force githubTracking for fn task import when tracking defaults are off", async () => {
|
||||
const { createTask } = mockStore();
|
||||
|
||||
|
||||
@@ -1639,6 +1639,34 @@ describe("runTaskImportGitHubInteractive", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it("marks interactive imports as tracked when import linking is on and new-task defaults are off", async () => {
|
||||
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
||||
init: vi.fn(),
|
||||
createTask: mockCreateTask,
|
||||
listTasks: mockListTasks,
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
githubTrackingEnabledByDefault: false,
|
||||
githubLinkImportedIssuesToTracking: true,
|
||||
}),
|
||||
getGlobalSettingsStore: vi.fn().mockReturnValue({ getSettings: vi.fn().mockResolvedValue({}) }),
|
||||
}));
|
||||
vi.mocked(runGhJsonAsync).mockResolvedValueOnce([
|
||||
mockIssue(1, "Import-linked Issue", null),
|
||||
] as never);
|
||||
vi.mocked(createInterface).mockReturnValueOnce({
|
||||
question: vi.fn().mockResolvedValueOnce("all"),
|
||||
close: vi.fn(),
|
||||
} as any);
|
||||
|
||||
await runTaskImportGitHubInteractive("owner/repo");
|
||||
|
||||
expect(mockCreateTask).toHaveBeenCalledWith(expect.objectContaining({
|
||||
description: "(no description)\n\nSource: https://github.com/owner/repo/issues/1",
|
||||
githubTracking: { enabled: true },
|
||||
sourceIssue: expect.objectContaining({ provider: "github", repository: "owner/repo", issueNumber: 1 }),
|
||||
}));
|
||||
});
|
||||
|
||||
it('imports all issues when "all" is selected', async () => {
|
||||
vi.mocked(runGhJsonAsync).mockResolvedValueOnce([
|
||||
mockIssue(1, "First Issue", "Description 1"),
|
||||
|
||||
@@ -1293,18 +1293,7 @@ export async function runTaskImportGitHubInteractive(
|
||||
|
||||
console.log();
|
||||
|
||||
const projectSettings = await store.getSettings();
|
||||
const globalSettings = await store.getGlobalSettingsStore().getSettings();
|
||||
const resolvedTracking = resolveTaskGithubTracking(
|
||||
{ githubTracking: undefined },
|
||||
projectSettings,
|
||||
globalSettings,
|
||||
);
|
||||
/*
|
||||
FNXC:GithubImportTracking 2026-06-26-00:00:
|
||||
CLI issue imports mark created tasks as tracking-enabled only when defaults resolve on. The task-created hook then links the GitHub source issue through source_issue_linked and avoids a duplicate tracking issue.
|
||||
*/
|
||||
const importedIssueGithubTracking = resolvedTracking.enabled ? { enabled: true as const } : undefined;
|
||||
const importedIssueGithubTracking = await resolveImportedIssueGithubTracking(store);
|
||||
|
||||
let created = 0;
|
||||
let skipped = 0;
|
||||
@@ -1408,6 +1397,25 @@ export interface TaskImportOptions {
|
||||
labels?: string[];
|
||||
}
|
||||
|
||||
async function resolveImportedIssueGithubTracking(store: TaskStore): Promise<{ enabled: true } | undefined> {
|
||||
const projectSettings = await store.getSettings();
|
||||
if (projectSettings.githubLinkImportedIssuesToTracking === true) {
|
||||
/*
|
||||
FNXC:GithubImportTracking 2026-07-01-00:00:
|
||||
The import-only linking setting deliberately bypasses ordinary new-task defaults for GitHub issue imports only. CLI import paths set githubTracking.enabled so the tracking hook adopts the sourceIssue instead of creating another issue.
|
||||
*/
|
||||
return { enabled: true };
|
||||
}
|
||||
|
||||
const globalSettings = await store.getGlobalSettingsStore().getSettings();
|
||||
const resolvedTracking = resolveTaskGithubTracking(
|
||||
{ githubTracking: undefined },
|
||||
projectSettings,
|
||||
globalSettings,
|
||||
);
|
||||
return resolvedTracking.enabled ? { enabled: true } : undefined;
|
||||
}
|
||||
|
||||
function buildGitHubIssueSource(owner: string, repo: string, issue: { number: number; html_url: string }) {
|
||||
return {
|
||||
sourceIssue: {
|
||||
@@ -1465,18 +1473,7 @@ export async function runTaskImportFromGitHub(
|
||||
return;
|
||||
}
|
||||
|
||||
const projectSettings = await store.getSettings();
|
||||
const globalSettings = await store.getGlobalSettingsStore().getSettings();
|
||||
const resolvedTracking = resolveTaskGithubTracking(
|
||||
{ githubTracking: undefined },
|
||||
projectSettings,
|
||||
globalSettings,
|
||||
);
|
||||
/*
|
||||
FNXC:GithubImportTracking 2026-06-26-00:00:
|
||||
Non-interactive fn task import uses the same tracking default resolution as the extension tools so imported source issues are adopted instead of duplicated when tracking is on.
|
||||
*/
|
||||
const importedIssueGithubTracking = resolvedTracking.enabled ? { enabled: true as const } : undefined;
|
||||
const importedIssueGithubTracking = await resolveImportedIssueGithubTracking(store);
|
||||
|
||||
let created = 0;
|
||||
let skipped = 0;
|
||||
|
||||
@@ -504,6 +504,25 @@ function buildGitHubIssueSource(owner: string, repo: string, issue: { number: nu
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveImportedIssueGithubTracking(store: TaskStore): Promise<{ enabled: true } | undefined> {
|
||||
const projectSettings = await store.getSettings();
|
||||
if (projectSettings.githubLinkImportedIssuesToTracking === true) {
|
||||
/*
|
||||
FNXC:GithubImportTracking 2026-07-01-00:00:
|
||||
Pi extension GitHub issue imports honor the import-only linking setting without enabling tracking for ordinary task tools. The created task keeps sourceIssue so the post-create hook links the imported issue instead of opening a duplicate tracking issue.
|
||||
*/
|
||||
return { enabled: true };
|
||||
}
|
||||
|
||||
const globalSettings = await store.getGlobalSettingsStore().getSettings();
|
||||
const resolvedTracking = resolveTaskGithubTracking(
|
||||
{ githubTracking: undefined },
|
||||
projectSettings,
|
||||
globalSettings,
|
||||
);
|
||||
return resolvedTracking.enabled ? { enabled: true } : undefined;
|
||||
}
|
||||
|
||||
function isIssueAlreadyImported(
|
||||
task: Pick<Task, "description" | "sourceIssue">,
|
||||
owner: string,
|
||||
@@ -1570,18 +1589,7 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
|
||||
const store = await getStore(ctx.cwd);
|
||||
const existingTasks = await store.listTasks({ slim: false });
|
||||
const projectSettings = await store.getSettings();
|
||||
const globalSettings = await store.getGlobalSettingsStore().getSettings();
|
||||
const resolvedTracking = resolveTaskGithubTracking(
|
||||
{ githubTracking: undefined },
|
||||
projectSettings,
|
||||
globalSettings,
|
||||
);
|
||||
/*
|
||||
FNXC:GithubImportTracking 2026-06-26-00:00:
|
||||
Imported GitHub issues must become tracked tasks when tracking/linking defaults resolve on. Setting only enabled lets the post-create hook adopt the imported source issue via source_issue_linked instead of opening a duplicate Fusion tracking issue.
|
||||
*/
|
||||
const importedIssueGithubTracking = resolvedTracking.enabled ? { enabled: true as const } : undefined;
|
||||
const importedIssueGithubTracking = await resolveImportedIssueGithubTracking(store);
|
||||
const createdTasks: Array<{ id: string; title: string }> = [];
|
||||
|
||||
for (const issue of issues) {
|
||||
@@ -1688,18 +1696,7 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
const body = issue.body?.trim() || "(no description)";
|
||||
const description = `${body}\n\nSource: ${sourceUrl}`;
|
||||
|
||||
const projectSettings = await store.getSettings();
|
||||
const globalSettings = await store.getGlobalSettingsStore().getSettings();
|
||||
const resolvedTracking = resolveTaskGithubTracking(
|
||||
{ githubTracking: undefined },
|
||||
projectSettings,
|
||||
globalSettings,
|
||||
);
|
||||
/*
|
||||
FNXC:GithubImportTracking 2026-06-26-00:00:
|
||||
Single-issue imports follow the same source-issue adoption contract as bulk imports: mark tracking enabled only when defaults resolve on, then let the post-create hook link the source issue without creating a duplicate.
|
||||
*/
|
||||
const importedIssueGithubTracking = resolvedTracking.enabled ? { enabled: true as const } : undefined;
|
||||
const importedIssueGithubTracking = await resolveImportedIssueGithubTracking(store);
|
||||
|
||||
const source = buildGitHubIssueSource(owner, repo, issue);
|
||||
const task = await store.createTask({
|
||||
|
||||
@@ -353,6 +353,7 @@ describe("settings key parity", () => {
|
||||
|
||||
it("keeps github tracking keys in expected scopes with documented defaults", () => {
|
||||
expect(DEFAULT_PROJECT_SETTINGS.githubTrackingEnabledByDefault).toBe(false);
|
||||
expect(DEFAULT_PROJECT_SETTINGS.githubLinkImportedIssuesToTracking).toBe(false);
|
||||
expect(DEFAULT_PROJECT_SETTINGS.githubTrackingDefaultRepo).toBeUndefined();
|
||||
expect(DEFAULT_PROJECT_SETTINGS.githubAuthMode).toBe("gh-cli");
|
||||
expect(DEFAULT_PROJECT_SETTINGS.githubAuthToken).toBeUndefined();
|
||||
@@ -360,6 +361,9 @@ describe("settings key parity", () => {
|
||||
|
||||
expect(isProjectSettingsKey("githubTrackingEnabledByDefault")).toBe(true);
|
||||
expect(isGlobalSettingsKey("githubTrackingEnabledByDefault")).toBe(false);
|
||||
expect(isProjectSettingsKey("githubLinkImportedIssuesToTracking")).toBe(true);
|
||||
expect(isGlobalSettingsKey("githubLinkImportedIssuesToTracking")).toBe(false);
|
||||
expect(isGlobalOnlySettingsKey("githubLinkImportedIssuesToTracking")).toBe(false);
|
||||
expect(isProjectSettingsKey("githubAuthMode")).toBe(true);
|
||||
expect(isGlobalSettingsKey("githubAuthMode")).toBe(false);
|
||||
expect(isProjectSettingsKey("githubAuthToken")).toBe(true);
|
||||
|
||||
@@ -484,6 +484,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
||||
githubCommentTemplate: undefined,
|
||||
githubCloseSourceIssueOnDone: false,
|
||||
githubTrackingEnabledByDefault: false,
|
||||
githubLinkImportedIssuesToTracking: false,
|
||||
githubTrackingDefaultRepo: undefined,
|
||||
githubTrackingDedupEnabled: true,
|
||||
githubAuthMode: "gh-cli",
|
||||
|
||||
@@ -4239,6 +4239,12 @@ export interface ProjectSettings {
|
||||
/** When true, new tasks default GitHub tracking to enabled for this project (FN-3868).
|
||||
* Default: false. */
|
||||
githubTrackingEnabledByDefault?: boolean;
|
||||
/**
|
||||
* FNXC:GithubImportTracking 2026-07-01-00:00:
|
||||
* This project-scoped switch is intentionally narrower than githubTrackingEnabledByDefault: it only forces imported GitHub issues to become GitHub-tracked tasks so the source issue is adopted, while ordinary new tasks keep their existing default behavior.
|
||||
* Default: false.
|
||||
*/
|
||||
githubLinkImportedIssuesToTracking?: boolean;
|
||||
/** Project default GitHub tracking repo in `owner/repo` format (FN-3868).
|
||||
* Falls back to global githubTrackingDefaultRepo when unset. */
|
||||
githubTrackingDefaultRepo?: string;
|
||||
|
||||
@@ -26,6 +26,8 @@ describe("scope anchors", () => {
|
||||
expect(isProjectSettingsKey("maxConcurrent")).toBe(true);
|
||||
expect(isProjectSettingsKey("integrationBranch")).toBe(true);
|
||||
expect(isProjectSettingsKey("enabledBuiltinWorkflowIds")).toBe(true);
|
||||
expect(isProjectSettingsKey("githubLinkImportedIssuesToTracking")).toBe(true);
|
||||
expect(isGlobalSettingsKey("githubLinkImportedIssuesToTracking")).toBe(false);
|
||||
});
|
||||
|
||||
it("every MODEL_LANE_KEYS entry is a project settings key", () => {
|
||||
@@ -214,6 +216,23 @@ describe("splitSettingsSave", () => {
|
||||
expect(projectPatch).toEqual({ maxConcurrent: 7 });
|
||||
});
|
||||
|
||||
it("routes imported GitHub issue linking only to project settings", () => {
|
||||
const initialScopedValues = {
|
||||
global: {},
|
||||
project: { githubLinkImportedIssuesToTracking: false },
|
||||
} as never;
|
||||
|
||||
const { globalPatch, projectPatch } = splitSettingsSave({
|
||||
payload: { githubLinkImportedIssuesToTracking: true },
|
||||
initialValues: null,
|
||||
initialScopedValues,
|
||||
activeSection: "general",
|
||||
});
|
||||
|
||||
expect(globalPatch).toEqual({});
|
||||
expect(projectPatch).toEqual({ githubLinkImportedIssuesToTracking: true });
|
||||
});
|
||||
|
||||
it("routes shared mcpServers only to the active MCP scope", () => {
|
||||
expect(isGlobalSettingsKey("mcpServers")).toBe(true);
|
||||
expect(isProjectSettingsKey("mcpServers")).toBe(true);
|
||||
|
||||
@@ -761,6 +761,7 @@ export function SettingsModal({
|
||||
webhookUrl: undefined,
|
||||
webhookFormat: "generic",
|
||||
webhookEvents: undefined,
|
||||
githubLinkImportedIssuesToTracking: false,
|
||||
prTitlePromptInstructions: "",
|
||||
prDescriptionPromptInstructions: "",
|
||||
});
|
||||
@@ -1007,6 +1008,11 @@ export function SettingsModal({
|
||||
The Settings form normalizes missing taskDetailChatFirst to false so new and upgraded projects show the Activity-first default until an operator explicitly opts into Chat-first.
|
||||
*/
|
||||
taskDetailChatFirst: s.taskDetailChatFirst === true,
|
||||
/*
|
||||
FNXC:GithubImportTracking 2026-07-01-00:00:
|
||||
Missing githubLinkImportedIssuesToTracking must render as unchecked and save as project-scoped false only after operator interaction; this keeps upgraded projects on legacy import behavior by default.
|
||||
*/
|
||||
githubLinkImportedIssuesToTracking: s.githubLinkImportedIssuesToTracking === true,
|
||||
mergeIntegrationWorktree: normalizeMergeIntegrationWorktreeMode(s.mergeIntegrationWorktree),
|
||||
mergeAdvanceAutoSync: normalizeMergeAdvanceAutoSyncMode(s.mergeAdvanceAutoSync),
|
||||
maxAutoMergeRetries: resolveMaxAutoMergeRetriesForSettingsForm(s),
|
||||
|
||||
@@ -966,6 +966,65 @@ describe("SettingsModal", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("renders and saves imported GitHub issue tracking linking as a project setting", async () => {
|
||||
renderModal({ initialSection: "general" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
const importLinkToggle = screen.getByLabelText(
|
||||
"Always link imported GitHub issues to GitHub tracking",
|
||||
) as HTMLInputElement;
|
||||
expect(importLinkToggle.id).toBe("githubLinkImportedIssuesToTracking");
|
||||
expect(importLinkToggle.checked).toBe(false);
|
||||
expect(screen.getByText(/does not turn GitHub tracking on for ordinary new tasks/i)).toBeInTheDocument();
|
||||
|
||||
await settingsModalUser.click(importLinkToggle);
|
||||
await settingsModalUser.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
|
||||
expect(payload.githubLinkImportedIssuesToTracking).toBe(true);
|
||||
if (mockUpdateGlobalSettings.mock.calls.length > 0) {
|
||||
const globalPayload = mockUpdateGlobalSettings.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
expect(globalPayload.githubLinkImportedIssuesToTracking).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("saves imported GitHub issue tracking linking as disabled", async () => {
|
||||
mockFetchSettings.mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
githubLinkImportedIssuesToTracking: true,
|
||||
});
|
||||
mockFetchSettingsByScope.mockResolvedValueOnce({
|
||||
global: defaultSettings,
|
||||
project: { githubLinkImportedIssuesToTracking: true },
|
||||
});
|
||||
|
||||
renderModal({ initialSection: "general" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
const importLinkToggle = screen.getByLabelText(
|
||||
"Always link imported GitHub issues to GitHub tracking",
|
||||
) as HTMLInputElement;
|
||||
expect(importLinkToggle.checked).toBe(true);
|
||||
|
||||
await settingsModalUser.click(importLinkToggle);
|
||||
await settingsModalUser.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
|
||||
expect(payload.githubLinkImportedIssuesToTracking).toBe(false);
|
||||
if (mockUpdateGlobalSettings.mock.calls.length > 0) {
|
||||
const globalPayload = mockUpdateGlobalSettings.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
expect(globalPayload.githubLinkImportedIssuesToTracking).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("saves GitHub tracking defaults as disabled and clears the repo when emptied", async () => {
|
||||
mockFetchSettings.mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
|
||||
@@ -124,6 +124,7 @@ export const defaultSettings = {
|
||||
webhookUrl: undefined,
|
||||
webhookFormat: undefined,
|
||||
webhookEvents: undefined,
|
||||
githubLinkImportedIssuesToTracking: false,
|
||||
};
|
||||
|
||||
export function renderModal(props: Partial<ComponentProps<typeof SettingsModal>> = {}) {
|
||||
|
||||
@@ -253,6 +253,15 @@ export function GeneralSection({ scopeBanner, form, setForm, projectId, addToast
|
||||
: ""}
|
||||
</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
{/*
|
||||
FNXC:GithubImportTracking 2026-07-01-00:00:
|
||||
This checkbox is project-scoped and import-specific: operators can link imported GitHub issues to GitHub tracking without turning tracking on for every new task.
|
||||
*/}
|
||||
<label htmlFor="githubLinkImportedIssuesToTracking" className="checkbox-label">
|
||||
<input id="githubLinkImportedIssuesToTracking" type="checkbox" checked={form.githubLinkImportedIssuesToTracking === true} onChange={(e) => setForm((f) => ({ ...f, githubLinkImportedIssuesToTracking: e.target.checked }))}/>{t("settings.general.alwaysLinkImportedGitHubIssuesToTracking", " Always link imported GitHub issues to GitHub tracking ")}</label>
|
||||
<small>{t("settings.general.whenEnabledImportedGitHubIssuesUseTheirSource", "When enabled, GitHub issue imports become tracked tasks that adopt the source issue. This does not turn GitHub tracking on for ordinary new tasks.")}</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="projectGithubTrackingDefaultRepoGeneral">{t("settings.general.projectDefaultTrackingRepo", "Project default tracking repo")}</label>
|
||||
<TrackingRepoSelect id="projectGithubTrackingDefaultRepoGeneral" ariaLabel="Project default tracking repo" value={form.githubTrackingDefaultRepo ?? ""} options={projectTrackingRepoOptions} loading={projectTrackingRepoLoading} error={projectTrackingRepoError ?? undefined} placeholder={t("settings.general.ownerRepo", "owner/repo")} onChange={(nextValue) => setForm((f) => ({ ...f, githubTrackingDefaultRepo: nextValue || undefined }))}/>
|
||||
|
||||
@@ -642,6 +642,25 @@ describe("POST /github/issues/import", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it("marks a single imported issue as tracked when import linking is on and new-task defaults are off", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
githubTrackingEnabledByDefault: false,
|
||||
githubLinkImportedIssuesToTracking: true,
|
||||
});
|
||||
getIssueSpy.mockResolvedValueOnce({ ...mockGitHubIssue, body: null });
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/import", JSON.stringify({ owner: "owner", repo: "repo", issueNumber: 1 }), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({
|
||||
description: "(no description)\n\nSource: https://github.com/owner/repo/issues/1",
|
||||
githubTracking: { enabled: true },
|
||||
sourceIssue: expect.objectContaining({ provider: "github", repository: "owner/repo", issueNumber: 1 }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("leaves a single imported issue unforced when tracking defaults are off", async () => {
|
||||
getIssueSpy.mockResolvedValueOnce(mockGitHubIssue);
|
||||
|
||||
@@ -918,6 +937,32 @@ describe("POST /github/issues/batch-import", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it("marks batch imported issues as tracked when import linking is on and new-task defaults are off", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
githubTrackingEnabledByDefault: false,
|
||||
githubLinkImportedIssuesToTracking: true,
|
||||
});
|
||||
vi.spyOn(GitHubClient.prototype, "fetchThrottled")
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: mockGitHubIssue(1, "Import-linked Batch Issue"),
|
||||
} as Awaited<ReturnType<GitHubClient["fetchThrottled"]>>);
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/github/issues/batch-import",
|
||||
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 1 }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({
|
||||
githubTracking: { enabled: true },
|
||||
sourceIssue: expect.objectContaining({ provider: "github", repository: "owner/repo", issueNumber: 1 }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("leaves batch imported issues unforced when tracking defaults are off", async () => {
|
||||
vi.spyOn(GitHubClient.prototype, "fetchThrottled")
|
||||
.mockResolvedValueOnce({
|
||||
|
||||
@@ -2085,6 +2085,13 @@ function isIssueAlreadyImported(
|
||||
|
||||
async function resolveImportedIssueGithubTracking(store: TaskStore): Promise<{ enabled: true } | undefined> {
|
||||
const projectSettings = await store.getSettings();
|
||||
if (projectSettings.githubLinkImportedIssuesToTracking === true) {
|
||||
/*
|
||||
FNXC:GithubImportTracking 2026-07-01-00:00:
|
||||
The imported-issue linking option is narrower than the general new-task default. Dashboard imports force githubTracking.enabled only for GitHub source issues so the post-create hook adopts sourceIssue instead of opening a separate Fusion tracking issue.
|
||||
*/
|
||||
return { enabled: true };
|
||||
}
|
||||
const globalSettings = await store.getGlobalSettingsStore().getSettings();
|
||||
const resolvedTracking = resolveTaskGithubTracking(
|
||||
{ githubTracking: undefined },
|
||||
|
||||
Reference in New Issue
Block a user