feat(FN-2317): merge fusion/fn-2317

This commit is contained in:
gsxdsm
2026-04-23 11:00:04 -07:00
parent 879f8a2e04
commit 807c1f6137
3 changed files with 97 additions and 17 deletions

View File

@@ -439,6 +439,11 @@ export type ProviderConnectionStatus = "connected" | "not-connected" | "skipped"
/** GitHub-specific status variants for richer connection feedback */
type GitHubConnectionStatus = "connected" | "failed" | "pending" | "skipped" | "not-connected";
interface GhCliStatus {
available: boolean;
authenticated: boolean;
}
/** Maximum number of poll cycles before timing out (150 × 2s = 5 minutes) */
const MAX_POLL_CYCLES = 150;
@@ -477,6 +482,7 @@ export function ModelOnboardingModal({
const [taskCreationError, setTaskCreationError] = useState<string | null>(null);
const [inlineCreatedTask, setInlineCreatedTask] = useState<Task | null>(null);
const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]);
const [ghCliStatus, setGhCliStatus] = useState<GhCliStatus | undefined>(undefined);
const [authLoading, setAuthLoading] = useState(true);
const [authActionInProgress, setAuthActionInProgress] = useState<string | null>(null);
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
@@ -583,8 +589,9 @@ export function ModelOnboardingModal({
// Load auth providers
const loadAuthStatus = useCallback(async () => {
try {
const { providers } = await fetchAuthStatus();
const { providers, ghCli } = await fetchAuthStatus();
setAuthProviders(providers);
setGhCliStatus(ghCli);
// Remove from skippedProviders when a provider becomes authenticated
setSkippedProviders((prev) => {
const updated = { ...prev };
@@ -609,10 +616,14 @@ export function ModelOnboardingModal({
aiSetupReturnRef.current = step !== "ai-setup";
}, [step, loadAuthStatus]);
// Check if GitHub provider is configured and currently authenticated
// OAuth status for the GitHub provider (used for OAuth-specific controls like Connect/Disconnect).
const githubProvider = authProviders.find((p) => p.id === "github");
const hasGithubProvider = !!githubProvider;
const isGithubAuthenticated = githubProvider?.authenticated ?? false;
const isGithubCliAuthenticated = ghCliStatus?.authenticated ?? false;
// Effective GitHub readiness (matches useSetupReadiness): OAuth OR authenticated gh CLI session.
const isGitHubReady = isGithubAuthenticated || isGithubCliAuthenticated;
const isGitHubReadyViaCli = !isGithubAuthenticated && isGithubCliAuthenticated;
// Get provider connection status for UI display
const getProviderStatus = useCallback((provider: AuthProvider): ProviderConnectionStatus => {
@@ -651,7 +662,7 @@ export function ModelOnboardingModal({
}
const getGitHubStatus = useCallback((): GitHubConnectionStatus => {
if (isGithubAuthenticated) {
if (isGitHubReady) {
return "connected";
}
@@ -667,7 +678,7 @@ export function ModelOnboardingModal({
}
return "not-connected";
}, [isGithubAuthenticated, loginOutcomes, isGithubSkipped]);
}, [isGitHubReady, loginOutcomes, isGithubSkipped]);
function GitHubStatusBadge({ status }: { status: GitHubConnectionStatus }) {
const config: Record<GitHubConnectionStatus, { text: string; className: string }> = {
@@ -883,8 +894,9 @@ export function ModelOnboardingModal({
}
try {
const { providers } = await fetchAuthStatus();
const { providers, ghCli } = await fetchAuthStatus();
setAuthProviders(providers);
setGhCliStatus(ghCli);
const provider = providers.find((p) => p.id === providerId);
if (provider?.authenticated) {
if (pollIntervalRef.current) {
@@ -1328,11 +1340,13 @@ export function ModelOnboardingModal({
});
}
if (isGithubAuthenticated) {
if (isGitHubReady) {
readinessItems.push({
label: "GitHub",
status: "connected",
detail: "Issues and PRs can be imported",
detail: isGitHubReadyViaCli
? "Connected via GitHub CLI — imports and PR tracking are available"
: "Issues and PRs can be imported",
});
} else if (!hasGithubProvider || isGithubSkipped) {
readinessItems.push({
@@ -1758,10 +1772,18 @@ export function ModelOnboardingModal({
{!hasGithubProvider ? (
<div className="model-onboarding-github-optional">
<GitPullRequest size={48} className="optional-icon" />
<p>
GitHub integration isn't set up yet. You can enable it later
in Settings → Authentication.
</p>
{isGitHubReadyViaCli ? (
<p>
GitHub CLI is already authenticated, so imports and PR tracking work now.
OAuth integration in Settings Authentication is optional and only controls
dashboard-managed connect/disconnect.
</p>
) : (
<p>
GitHub OAuth isn&apos;t connected yet. You can set it up in Settings Authentication,
or continue now and connect later.
</p>
)}
<button
className="btn btn-primary btn-sm"
onClick={() => setStep("first-task")}
@@ -1825,7 +1847,9 @@ export function ModelOnboardingModal({
{githubStatus === "connected" && (
<div className="onboarding-github-feedback onboarding-github-feedback--success">
GitHub is connected. You can import issues and track pull requests.
{isGitHubReadyViaCli
? "GitHub CLI is authenticated. Imports and pull request tracking are available. Connect OAuth in Settings → Authentication if you want dashboard-managed sign-in controls."
: "GitHub OAuth is connected. You can import issues and track pull requests."}
</div>
)}
@@ -1984,7 +2008,7 @@ export function ModelOnboardingModal({
</button>
<button
className={`onboarding-cta-card${!isGithubAuthenticated ? " onboarding-cta-card--disabled" : ""}`}
className={`onboarding-cta-card${!isGitHubReady ? " onboarding-cta-card--disabled" : ""}`}
data-testid="cta-github-import"
onClick={handleOpenGitHubImport}
disabled={saving}
@@ -1995,7 +2019,7 @@ export function ModelOnboardingModal({
<div className="cta-content">
<strong>Import from GitHub</strong>
<span>Turn GitHub issues into tasks you can track here</span>
{!isGithubAuthenticated && (
{!isGitHubReady && (
<small className="onboarding-cta-note">Requires GitHub connection</small>
)}
</div>

View File

@@ -942,12 +942,12 @@ describe("ModelOnboardingModal", () => {
});
describe("GitHub step", () => {
it("GitHub step shows fallback when no GitHub provider", async () => {
it("GitHub step shows OAuth setup fallback when neither OAuth nor gh CLI auth is available", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToGitHubStep();
expect(screen.getByText(/GitHub integration isn't set up yet/)).toBeTruthy();
expect(screen.getByText(/GitHub OAuth isn't connected yet/)).toBeTruthy();
expect(screen.getByText(/Settings → Authentication/)).toBeTruthy();
expect(screen.getByRole("button", { name: "Continue without GitHub →" })).toBeTruthy();
});
@@ -1002,6 +1002,21 @@ describe("ModelOnboardingModal", () => {
expect(screen.queryByTestId("onboarding-github-connect-cta")).toBeNull();
});
it("GitHub step explains gh CLI auth when OAuth provider is absent", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [],
ghCli: { available: true, authenticated: true },
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToGitHubStep();
expect(screen.getByText(/GitHub CLI is already authenticated/)).toBeTruthy();
expect(screen.getByText(/OAuth integration in Settings → Authentication is optional/)).toBeTruthy();
expect(screen.queryByTestId("onboarding-github-connect-cta")).toBeNull();
});
describe("GitHub connection status feedback", () => {
it("shows connected status with success feedback", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
@@ -1017,7 +1032,25 @@ describe("ModelOnboardingModal", () => {
const badge = screen.getByTestId("github-status-badge");
expect(badge).toHaveTextContent("✓ Connected");
expect(badge).toHaveClass("connected");
expect(screen.getByText("GitHub is connected. You can import issues and track pull requests.")).toBeTruthy();
expect(screen.getByText("GitHub OAuth is connected. You can import issues and track pull requests.")).toBeTruthy();
});
it("shows connected feedback when gh CLI is authenticated without GitHub OAuth", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "github", name: "GitHub", authenticated: false, type: "oauth" },
],
ghCli: { available: true, authenticated: true },
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
await navigateToGitHubStep();
const badge = screen.getByTestId("github-status-badge");
expect(badge).toHaveTextContent("✓ Connected");
expect(badge).toHaveClass("connected");
expect(screen.getByText(/GitHub CLI is authenticated/)).toBeTruthy();
});
it("shows not-connected status and keeps default helper text", async () => {

View File

@@ -727,6 +727,29 @@ describe("onboarding flow integration", () => {
expect(screen.getByText("Requires GitHub connection")).toBeInTheDocument();
});
it("skip warnings: gh CLI auth marks GitHub as ready in summary and hides connection-required note", async () => {
mockFetchAuthStatus.mockResolvedValue({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" },
],
ghCli: { available: true, authenticated: true },
});
const renderResult = renderModal();
await advanceThroughSteps(renderResult, ["next", "next"]);
await waitFor(() => {
expect(screen.getByText("Create Your First Task")).toBeInTheDocument();
});
const readinessSummary = screen.getByTestId("readiness-summary");
const githubItem = within(readinessSummary).getByText("GitHub").closest(".onboarding-readiness-item");
expect(githubItem).toHaveAttribute("data-status", "connected");
expect(screen.getByText("Connected via GitHub CLI — imports and PR tracking are available")).toBeInTheDocument();
expect(screen.queryByText("Requires GitHub connection")).toBeNull();
});
});
describe("task creation flow", () => {