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

@@ -69,6 +69,23 @@ describe("settings key parity", () => {
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", () => {
const globalKeys = GLOBAL_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();
});
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 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",
autoReloadOnVersionChange: true,
showGitHubStarButton: true,
githubTrackingDefaultRepo: undefined,
modelOnboardingComplete: undefined,
useClaudeCli: undefined,
useDroidCli: undefined,
@@ -229,6 +230,10 @@ export const DEFAULT_PROJECT_SETTINGS = {
autoUpdatePrStatus: false,
githubCommentOnDone: false,
githubCommentTemplate: undefined,
githubTrackingEnabledByDefault: false,
githubTrackingDefaultRepo: undefined,
githubAuthMode: "gh-cli",
githubAuthToken: undefined,
autoBackupEnabled: false,
autoBackupSchedule: "0 2 * * *",
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 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.
@@ -18,3 +20,29 @@ export function validateUnavailableNodePolicy(value: unknown): UnavailableNodePo
? (value as UnavailableNodePolicy)
: 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
* user has clicked it (tracked client-side in localStorage). */
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`
* route uses this to decide whether to consult npm or return a cached
* result.
@@ -1849,6 +1852,9 @@ export interface RemoteAccessProjectSettings {
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`.
*
@@ -2187,6 +2193,18 @@ export interface ProjectSettings {
/** Optional template used for GitHub issue comments posted on task completion.
* Supports `{taskId}` and `{taskTitle}` placeholders. */
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. */
autoBackupEnabled?: boolean;
/** Cron expression for backup schedule. Default: "0 2 * * *" (daily at 2 AM). */

View File

@@ -1600,6 +1600,8 @@ export function SettingsModal({
...form,
worktreeInitCommand: form.worktreeInitCommand?.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),
experimentalFeatures: normalizeExperimentalFeaturesForSave(form.experimentalFeatures),
};
@@ -1988,6 +1990,20 @@ export function SettingsModal({
it hidden even before clicking.
</small>
</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 />
<div className="form-group">
<label htmlFor="persistAgentToolOutput" className="checkbox-label">
@@ -3563,6 +3579,61 @@ export function SettingsModal({
</details>
</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">
<label htmlFor="includeTaskIdInCommit" className="checkbox-label">
<input

View File

@@ -530,6 +530,15 @@ describe("SettingsModal", () => {
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", () => {
@@ -1855,6 +1864,35 @@ describe("SettingsModal", () => {
expect(payload.pushAfterMerge).toBe(true);
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", () => {