feat(FN-3868): add changeset for github tracking settings

Adds a changeset for the GitHub tracking settings feature (FN-3868), preparing it for the next release. The feature itself was implemented in earlier steps; this changeset marks it for publication to npm.

Fusion-Task-Id: FN-3868
This commit is contained in:
Fusion
2026-05-09 12:07:05 -07:00
committed by gsxdsm
parent ac0606daaf
commit 4b6a14974b
9 changed files with 244 additions and 3 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Add global and project settings for GitHub issue tracking: global default tracking repo, project-level default tracking repo, per-project tracking toggle for new tasks, GitHub auth mode (`gh-cli` | `token`), and optional stored personal access token. This is foundational settings work for FN-3868 → FN-3876; behavior wiring ships in downstream subtasks.

View File

@@ -59,6 +59,7 @@ In **Settings → Notifications**, use **Test message notification** to exercise
| `openrouterModelSync` | `boolean` | `true` | Sync OpenRouter model catalog into model pickers at startup. | | `openrouterModelSync` | `boolean` | `true` | Sync OpenRouter model catalog into model pickers at startup. |
| `opencodeGoModelSync` | `boolean` | `true` | Sync opencode-go model catalog at startup via `opencode models opencode --refresh`, normalizing discovered `opencode/...` IDs into the `opencode-go` provider surface used by `/api/models`. | | `opencodeGoModelSync` | `boolean` | `true` | Sync opencode-go model catalog at startup via `opencode models opencode --refresh`, normalizing discovered `opencode/...` IDs into the `opencode-go` provider surface used by `/api/models`. |
| `updateCheckEnabled` | `boolean` | `true` | When enabled, Fusion performs a daily npm registry check for new `@runfusion/fusion` versions and shows update notices in CLI/dashboard. | | `updateCheckEnabled` | `boolean` | `true` | When enabled, Fusion performs a daily npm registry check for new `@runfusion/fusion` versions and shows update notices in CLI/dashboard. |
| `githubTrackingDefaultRepo` | `string` | `undefined` | Global fallback issue-tracking repo (`owner/repo`) introduced in FN-3868 as groundwork for the FN-3868 → FN-3876 GitHub tracking epic; behavior wiring lands in downstream subtasks. |
| `autoReloadOnVersionChange` | `boolean` | `true` | When enabled (default), the dashboard automatically reloads when a new build version is detected via `/version.json` polling or service worker activation. Set to `false` to suppress automatic reloads — the user must manually refresh to pick up updates. | | `autoReloadOnVersionChange` | `boolean` | `true` | When enabled (default), the dashboard automatically reloads when a new build version is detected via `/version.json` polling or service worker activation. Set to `false` to suppress automatic reloads — the user must manually refresh to pick up updates. |
| `modelOnboardingComplete` | `boolean` | `undefined` | Whether AI onboarding has been completed or dismissed. | | `modelOnboardingComplete` | `boolean` | `undefined` | Whether AI onboarding has been completed or dismissed. |
| `executionGlobalProvider` | `string` | `undefined` | Global baseline provider for task execution. Project `executionProvider` overrides this. | | `executionGlobalProvider` | `string` | `undefined` | Global baseline provider for task execution. Project `executionProvider` overrides this. |
@@ -229,6 +230,10 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`
| `autoUpdatePrStatus` | `boolean` | `false` | Auto-refresh PR status badges. | | `autoUpdatePrStatus` | `boolean` | `false` | Auto-refresh PR status badges. |
| `githubCommentOnDone` | `boolean` | `false` | When enabled, tasks imported from GitHub issues post a completion comment to the source issue when the task moves to `done`. | | `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. | | `githubCommentTemplate` | `string` | `undefined` | Optional issue comment template used by `githubCommentOnDone`. Supports `{taskId}` and `{taskTitle}` placeholders. If unset, Fusion uses a default completion message. |
| `githubTrackingEnabledByDefault` | `boolean` | `false` | Project-level default for enabling issue tracking on new tasks. Added in FN-3868 as foundation for the FN-3868 → FN-3876 GitHub tracking epic; task/issue behavior ships in later subtasks. |
| `githubTrackingDefaultRepo` | `string` | `undefined` | Project default issue-tracking repo (`owner/repo`). Added in FN-3868 as foundation for the FN-3868 → FN-3876 epic; downstream subtasks implement runtime usage. |
| `githubAuthMode` | `"gh-cli" \| "token"` | `"gh-cli"` | Project GitHub auth strategy selector added in FN-3868 for the FN-3868 → FN-3876 tracking epic; auth wiring is deferred to later subtasks. |
| `githubAuthToken` | `string` | `undefined` | Optional project PAT used when `githubAuthMode` is `"token"`. Added in FN-3868 as data-layer groundwork; downstream subtasks consume it. |
| `autoCreatePr` | `boolean` | `false` | Auto-create PRs for completed tasks. | | `autoCreatePr` | `boolean` | `false` | Auto-create PRs for completed tasks. |
| `autoBackupEnabled` | `boolean` | `false` | Enable scheduled DB backups. | | `autoBackupEnabled` | `boolean` | `false` | Enable scheduled DB backups. |
| `autoBackupSchedule` | `string` | `"0 2 * * *"` | Backup cron schedule. | | `autoBackupSchedule` | `string` | `"0 2 * * *"` | Backup cron schedule. |

View File

@@ -69,6 +69,23 @@ describe("settings key parity", () => {
expect(DEFAULT_PROJECT_SETTINGS.completionDocumentationMode).toBe("off"); expect(DEFAULT_PROJECT_SETTINGS.completionDocumentationMode).toBe("off");
}); });
it("keeps github tracking keys in expected scopes with documented defaults", () => {
expect(DEFAULT_PROJECT_SETTINGS.githubTrackingEnabledByDefault).toBe(false);
expect(DEFAULT_PROJECT_SETTINGS.githubTrackingDefaultRepo).toBeUndefined();
expect(DEFAULT_PROJECT_SETTINGS.githubAuthMode).toBe("gh-cli");
expect(DEFAULT_PROJECT_SETTINGS.githubAuthToken).toBeUndefined();
expect(DEFAULT_GLOBAL_SETTINGS.githubTrackingDefaultRepo).toBeUndefined();
expect(isProjectSettingsKey("githubTrackingEnabledByDefault")).toBe(true);
expect(isGlobalSettingsKey("githubTrackingEnabledByDefault")).toBe(false);
expect(isProjectSettingsKey("githubAuthMode")).toBe(true);
expect(isGlobalSettingsKey("githubAuthMode")).toBe(false);
expect(isProjectSettingsKey("githubAuthToken")).toBe(true);
expect(isGlobalSettingsKey("githubAuthToken")).toBe(false);
expect(isProjectSettingsKey("githubTrackingDefaultRepo")).toBe(true);
expect(isGlobalSettingsKey("githubTrackingDefaultRepo")).toBe(true);
});
it("keeps remoteAccess scoped to global settings only", () => { it("keeps remoteAccess scoped to global settings only", () => {
const globalKeys = GLOBAL_SETTINGS_KEYS as readonly string[]; const globalKeys = GLOBAL_SETTINGS_KEYS as readonly string[];
const projectKeys = PROJECT_SETTINGS_KEYS as readonly string[]; const projectKeys = PROJECT_SETTINGS_KEYS as readonly string[];
@@ -89,10 +106,10 @@ describe("settings key parity", () => {
expect((DEFAULT_PROJECT_SETTINGS as Record<string, unknown>).experimentalFeatures).toBeUndefined(); expect((DEFAULT_PROJECT_SETTINGS as Record<string, unknown>).experimentalFeatures).toBeUndefined();
}); });
it("No key appears in both GLOBAL_SETTINGS_KEYS and PROJECT_SETTINGS_KEYS", () => { it("only intentional shared keys appear in both global and project scopes", () => {
const projectKeySet = new Set(PROJECT_SETTINGS_KEYS as readonly string[]); const projectKeySet = new Set(PROJECT_SETTINGS_KEYS as readonly string[]);
const overlap = (GLOBAL_SETTINGS_KEYS as readonly string[]).filter((key) => projectKeySet.has(key)); const overlap = (GLOBAL_SETTINGS_KEYS as readonly string[]).filter((key) => projectKeySet.has(key));
expect(overlap).toEqual([]); expect(overlap).toEqual(["githubTrackingDefaultRepo"]);
}); });
}); });

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import {
validateGithubAuthMode,
validateGithubRepoSlug,
validateUnavailableNodePolicy,
} from "../settings-validation.js";
describe("settings-validation", () => {
describe("validateUnavailableNodePolicy", () => {
it("accepts supported policies", () => {
expect(validateUnavailableNodePolicy("block")).toBe("block");
expect(validateUnavailableNodePolicy("fallback-local")).toBe("fallback-local");
});
it("returns undefined for invalid values", () => {
expect(validateUnavailableNodePolicy("fallback")).toBeUndefined();
expect(validateUnavailableNodePolicy(123)).toBeUndefined();
expect(validateUnavailableNodePolicy(undefined)).toBeUndefined();
});
});
describe("validateGithubAuthMode", () => {
it("accepts supported auth modes", () => {
expect(validateGithubAuthMode("gh-cli")).toBe("gh-cli");
expect(validateGithubAuthMode("token")).toBe("token");
});
it("returns undefined for invalid values", () => {
expect(validateGithubAuthMode("oauth")).toBeUndefined();
expect(validateGithubAuthMode(123)).toBeUndefined();
expect(validateGithubAuthMode(undefined)).toBeUndefined();
});
});
describe("validateGithubRepoSlug", () => {
it("accepts valid owner/repo slugs", () => {
expect(validateGithubRepoSlug("owner/repo")).toBe("owner/repo");
expect(validateGithubRepoSlug("Owner.Name/repo_name-1")).toBe("Owner.Name/repo_name-1");
});
it("treats empty strings as unset", () => {
expect(validateGithubRepoSlug("")).toBeUndefined();
expect(validateGithubRepoSlug(" ")).toBeUndefined();
});
it("returns undefined for malformed slugs and invalid types", () => {
expect(validateGithubRepoSlug("owner")).toBeUndefined();
expect(validateGithubRepoSlug("owner/repo/extra")).toBeUndefined();
expect(validateGithubRepoSlug("owner repo/repo")).toBeUndefined();
expect(validateGithubRepoSlug(42)).toBeUndefined();
expect(validateGithubRepoSlug(undefined)).toBeUndefined();
});
});
});

View File

@@ -52,6 +52,7 @@ export const DEFAULT_GLOBAL_SETTINGS = {
updateCheckFrequency: "daily", updateCheckFrequency: "daily",
autoReloadOnVersionChange: true, autoReloadOnVersionChange: true,
showGitHubStarButton: true, showGitHubStarButton: true,
githubTrackingDefaultRepo: undefined,
modelOnboardingComplete: undefined, modelOnboardingComplete: undefined,
useClaudeCli: undefined, useClaudeCli: undefined,
useDroidCli: undefined, useDroidCli: undefined,
@@ -229,6 +230,10 @@ export const DEFAULT_PROJECT_SETTINGS = {
autoUpdatePrStatus: false, autoUpdatePrStatus: false,
githubCommentOnDone: false, githubCommentOnDone: false,
githubCommentTemplate: undefined, githubCommentTemplate: undefined,
githubTrackingEnabledByDefault: false,
githubTrackingDefaultRepo: undefined,
githubAuthMode: "gh-cli",
githubAuthToken: undefined,
autoBackupEnabled: false, autoBackupEnabled: false,
autoBackupSchedule: "0 2 * * *", autoBackupSchedule: "0 2 * * *",
autoBackupRetention: 7, autoBackupRetention: 7,

View File

@@ -1,6 +1,8 @@
import type { UnavailableNodePolicy } from "./types.js"; import type { GithubAuthMode, UnavailableNodePolicy } from "./types.js";
const UNAVAILABLE_NODE_POLICIES: readonly UnavailableNodePolicy[] = ["block", "fallback-local"] as const; const UNAVAILABLE_NODE_POLICIES: readonly UnavailableNodePolicy[] = ["block", "fallback-local"] as const;
const GITHUB_AUTH_MODES: readonly GithubAuthMode[] = ["gh-cli", "token"] as const;
const GITHUB_REPO_SLUG_PATTERN = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
/** /**
* Validates a project unavailable-node routing policy value. * Validates a project unavailable-node routing policy value.
@@ -18,3 +20,29 @@ export function validateUnavailableNodePolicy(value: unknown): UnavailableNodePo
? (value as UnavailableNodePolicy) ? (value as UnavailableNodePolicy)
: undefined; : undefined;
} }
/** Returns a validated GitHub auth mode for project settings, otherwise undefined. */
export function validateGithubAuthMode(value: unknown): GithubAuthMode | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value !== "string") {
return undefined;
}
return (GITHUB_AUTH_MODES as readonly string[]).includes(value) ? (value as GithubAuthMode) : undefined;
}
/** Returns a validated owner/repo GitHub slug, otherwise undefined. Empty string is treated as unset. */
export function validateGithubRepoSlug(value: unknown): string | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
if (trimmed.length === 0) {
return undefined;
}
return GITHUB_REPO_SLUG_PATTERN.test(trimmed) ? trimmed : undefined;
}

View File

@@ -1616,6 +1616,9 @@ export interface GlobalSettings {
* header. Defaults to true (visible). The button is also hidden once the * header. Defaults to true (visible). The button is also hidden once the
* user has clicked it (tracked client-side in localStorage). */ * user has clicked it (tracked client-side in localStorage). */
showGitHubStarButton?: boolean; showGitHubStarButton?: boolean;
/** Global fallback GitHub tracking repo in `owner/repo` format (FN-3868).
* Used when a project has no githubTrackingDefaultRepo. */
githubTrackingDefaultRepo?: string;
/** Cadence for automatic update checks. The dashboard's `/update-check` /** Cadence for automatic update checks. The dashboard's `/update-check`
* route uses this to decide whether to consult npm or return a cached * route uses this to decide whether to consult npm or return a cached
* result. * result.
@@ -1849,6 +1852,9 @@ export interface RemoteAccessProjectSettings {
lifecycle: RemoteAccessLifecycleConfig; lifecycle: RemoteAccessLifecycleConfig;
} }
/** GitHub authentication strategy used by project issue-tracking settings (FN-3868). */
export type GithubAuthMode = "gh-cli" | "token";
/** /**
* Project-level settings stored in `.fusion/config.json`. * Project-level settings stored in `.fusion/config.json`.
* *
@@ -2187,6 +2193,18 @@ export interface ProjectSettings {
/** Optional template used for GitHub issue comments posted on task completion. /** Optional template used for GitHub issue comments posted on task completion.
* Supports `{taskId}` and `{taskTitle}` placeholders. */ * Supports `{taskId}` and `{taskTitle}` placeholders. */
githubCommentTemplate?: string; githubCommentTemplate?: string;
/** When true, new tasks default GitHub tracking to enabled for this project (FN-3868).
* Default: false. */
githubTrackingEnabledByDefault?: boolean;
/** Project default GitHub tracking repo in `owner/repo` format (FN-3868).
* Falls back to global githubTrackingDefaultRepo when unset. */
githubTrackingDefaultRepo?: string;
/** GitHub auth strategy for issue-tracking API calls in this project (FN-3868).
* Default: "gh-cli". */
githubAuthMode?: GithubAuthMode;
/** Personal access token used when githubAuthMode is "token" (FN-3868).
* Stored as a plain settings string in this phase. */
githubAuthToken?: string;
/** When true, automatic database backups are enabled. Default: false. */ /** When true, automatic database backups are enabled. Default: false. */
autoBackupEnabled?: boolean; autoBackupEnabled?: boolean;
/** Cron expression for backup schedule. Default: "0 2 * * *" (daily at 2 AM). */ /** Cron expression for backup schedule. Default: "0 2 * * *" (daily at 2 AM). */

View File

@@ -1600,6 +1600,8 @@ export function SettingsModal({
...form, ...form,
worktreeInitCommand: form.worktreeInitCommand?.trim() || undefined, worktreeInitCommand: form.worktreeInitCommand?.trim() || undefined,
taskPrefix: form.taskPrefix?.trim() || undefined, taskPrefix: form.taskPrefix?.trim() || undefined,
githubTrackingDefaultRepo: form.githubTrackingDefaultRepo?.trim() || undefined,
githubAuthToken: form.githubAuthToken?.trim() || undefined,
overlapIgnorePaths: (form.overlapIgnorePaths ?? []).map((path) => path.trim()).filter((path) => path.length > 0), overlapIgnorePaths: (form.overlapIgnorePaths ?? []).map((path) => path.trim()).filter((path) => path.length > 0),
experimentalFeatures: normalizeExperimentalFeaturesForSave(form.experimentalFeatures), experimentalFeatures: normalizeExperimentalFeaturesForSave(form.experimentalFeatures),
}; };
@@ -1988,6 +1990,20 @@ export function SettingsModal({
it hidden even before clicking. it hidden even before clicking.
</small> </small>
</div> </div>
<div className="form-group">
<label htmlFor="globalGithubTrackingDefaultRepo">Global default tracking repo</label>
<input
id="globalGithubTrackingDefaultRepo"
type="text"
className="input"
placeholder="owner/repo"
value={form.githubTrackingDefaultRepo ?? ""}
onChange={(e) =>
setForm((f) => ({ ...f, githubTrackingDefaultRepo: e.target.value || undefined }))
}
/>
<small>Projects inherit this value when they do not set a project default tracking repo.</small>
</div>
<CliBinaryPanel /> <CliBinaryPanel />
<div className="form-group"> <div className="form-group">
<label htmlFor="persistAgentToolOutput" className="checkbox-label"> <label htmlFor="persistAgentToolOutput" className="checkbox-label">
@@ -3563,6 +3579,61 @@ export function SettingsModal({
</details> </details>
</div> </div>
)} )}
<h4 className="settings-section-heading settings-section-heading--spaced">GitHub Issue Tracking</h4>
<div className="form-group">
<label htmlFor="githubTrackingEnabledByDefault" className="checkbox-label">
<input
id="githubTrackingEnabledByDefault"
type="checkbox"
checked={form.githubTrackingEnabledByDefault ?? false}
onChange={(e) =>
setForm((f) => ({ ...f, githubTrackingEnabledByDefault: e.target.checked }))
}
/>
Default GitHub tracking ON for new tasks
</label>
</div>
<div className="form-group">
<label htmlFor="projectGithubTrackingDefaultRepo">Project default tracking repo</label>
<input
id="projectGithubTrackingDefaultRepo"
type="text"
className="input"
placeholder="owner/repo"
value={form.githubTrackingDefaultRepo ?? ""}
onChange={(e) =>
setForm((f) => ({ ...f, githubTrackingDefaultRepo: e.target.value || undefined }))
}
/>
</div>
<div className="form-group">
<label htmlFor="githubAuthMode">GitHub auth mode</label>
<select
id="githubAuthMode"
className="select"
value={form.githubAuthMode ?? "gh-cli"}
onChange={(e) =>
setForm((f) => ({ ...f, githubAuthMode: e.target.value as "gh-cli" | "token" }))
}
>
<option value="gh-cli">GitHub CLI (gh auth)</option>
<option value="token">Personal access token</option>
</select>
</div>
{(form.githubAuthMode ?? "gh-cli") === "token" && (
<div className="form-group">
<label htmlFor="githubAuthToken">GitHub personal access token</label>
<input
id="githubAuthToken"
type="password"
className="input"
value={form.githubAuthToken ?? ""}
onChange={(e) =>
setForm((f) => ({ ...f, githubAuthToken: e.target.value || undefined }))
}
/>
</div>
)}
<div className="form-group"> <div className="form-group">
<label htmlFor="includeTaskIdInCommit" className="checkbox-label"> <label htmlFor="includeTaskIdInCommit" className="checkbox-label">
<input <input

View File

@@ -530,6 +530,15 @@ describe("SettingsModal", () => {
expect(projectPayload.persistAgentToolOutput).toBeUndefined(); expect(projectPayload.persistAgentToolOutput).toBeUndefined();
} }
}); });
it("renders global default tracking repo control", async () => {
renderModal({ initialSection: "global-general" });
await waitForSettingsModalReady();
const input = screen.getByLabelText("Global default tracking repo") as HTMLInputElement;
expect(input.value).toBe("");
expect(screen.getByText(/Projects inherit this value when they do not set a project default tracking repo/i)).toBeInTheDocument();
});
}); });
describe("Project General", () => { describe("Project General", () => {
@@ -1855,6 +1864,35 @@ describe("SettingsModal", () => {
expect(payload.pushAfterMerge).toBe(true); expect(payload.pushAfterMerge).toBe(true);
expect(payload.pushRemote).toBe("upstream main"); expect(payload.pushRemote).toBe("upstream main");
}); });
it("renders and saves github issue tracking controls", async () => {
renderModal({ initialSection: "merge" });
await waitForSettingsModalReady();
expect(screen.getByRole("heading", { name: "GitHub Issue Tracking" })).toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: "Default GitHub tracking ON for new tasks" })).not.toBeChecked();
expect(screen.getByLabelText("Project default tracking repo")).toBeInTheDocument();
const authModeSelect = screen.getByLabelText("GitHub auth mode") as HTMLSelectElement;
expect(authModeSelect.value).toBe("gh-cli");
expect(screen.queryByLabelText("GitHub personal access token")).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("checkbox", { name: "Default GitHub tracking ON for new tasks" }));
await userEvent.type(screen.getByLabelText("Project default tracking repo"), "octo/repo");
await userEvent.selectOptions(authModeSelect, "token");
await userEvent.type(screen.getByLabelText("GitHub personal access token"), "ghp_test_token");
await userEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(mockUpdateSettings).toHaveBeenCalled();
});
const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
expect(payload.githubTrackingEnabledByDefault).toBe(true);
expect(payload.githubTrackingDefaultRepo).toBe("octo/repo");
expect(payload.githubAuthMode).toBe("token");
expect(payload.githubAuthToken).toBe("ghp_test_token");
});
}); });
describe("Experimental Features section", () => { describe("Experimental Features section", () => {