fix(git-sources): return 200 for stacks without a Git source (#1294)

The dashboard probes GET /api/stacks/<name>/git-source for every stack to
decide whether to show a Git badge. For a stack with no Git source attached
the endpoint answered 404, so a fleet of unlinked stacks painted a red 404
per stack in the browser console.

Return 200 { linked: false } when the stack exists but has no Git source,
and reserve 404 for the genuine "stack does not exist" case (mirroring the
existence guard the PUT handler already uses). The two consumers that read
this endpoint now treat the { linked: false } sentinel as unlinked rather
than as a configured source.
This commit is contained in:
Anso
2026-06-02 22:02:49 -04:00
committed by GitHub
parent c68f0b494c
commit ae0b9d166c
6 changed files with 213 additions and 22 deletions
@@ -0,0 +1,94 @@
/**
* Covers the panel's load path for the unlinked-stack contract: when the
* backend answers 200 { linked: false } (an existing stack with no Git source
* attached), the form must land in the empty/unlinked state rather than
* treating the sentinel as a configured source.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
vi.mock('@/context/DeployFeedbackContext', () => ({
useDeployFeedback: () => ({ runWithLog: vi.fn() }),
}));
vi.mock('@/components/ui/toast-store', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
loading: vi.fn(() => 'toast-id'),
dismiss: vi.fn(),
},
}));
import { apiFetch } from '@/lib/api';
import { GitSourcePanel } from './GitSourcePanel';
function jsonRes(body: unknown, ok = true, status = 200) {
return { ok, status, json: async () => body, text: async () => '' } as unknown as Response;
}
const LINKED_SOURCE = {
id: 1,
stack_name: 'web',
repo_url: 'https://github.com/org/repo.git',
branch: 'main',
compose_path: 'compose.yaml',
sync_env: false,
env_path: null,
auth_type: 'none' as const,
has_token: false,
auto_apply_on_webhook: false,
auto_deploy_on_apply: false,
last_applied_commit_sha: null,
pending_commit_sha: null,
pending_fetched_at: null,
created_at: 0,
updated_at: 0,
};
function panel() {
return (
<GitSourcePanel
open
onOpenChange={vi.fn()}
stackName="web"
canEdit
isDarkMode={false}
/>
);
}
beforeEach(() => {
vi.mocked(apiFetch).mockReset();
});
describe('GitSourcePanel load', () => {
it('treats a 200 { linked: false } response as the empty/unlinked state', async () => {
vi.mocked(apiFetch).mockResolvedValue(jsonRes({ linked: false }));
render(panel());
// Save (not Update) and no Pull now / Remove affordances means the panel
// did not mistake the { linked: false } sentinel for a configured source.
await screen.findByRole('button', { name: /^save$/i });
expect(screen.queryByRole('button', { name: /update/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /pull now/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /remove/i })).not.toBeInTheDocument();
expect(screen.getByLabelText(/repository url/i)).toHaveValue('');
});
it('renders the configured source when one is attached', async () => {
vi.mocked(apiFetch).mockResolvedValue(jsonRes(LINKED_SOURCE));
render(panel());
// A real source flips the primary action to Update and exposes Pull now / Remove.
await screen.findByRole('button', { name: /update/i });
expect(screen.getByRole('button', { name: /pull now/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /remove/i })).toBeInTheDocument();
await waitFor(() =>
expect(screen.getByLabelText(/repository url/i)).toHaveValue('https://github.com/org/repo.git'),
);
});
});
@@ -76,29 +76,38 @@ export function GitSourcePanel({
const { runWithLog } = useDeployFeedback();
const applyMode = deriveApplyMode(source, applyModeOverride);
const resetToUnlinked = useCallback(() => {
setSource(null);
setRepoUrl('');
setBranch('main');
setComposePath('compose.yaml');
setSyncEnv(false);
setAuthType('none');
setToken('');
setApplyModeOverride(null);
}, []);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/git-source`);
if (res.ok) {
const data: GitSource = await res.json();
setSource(data);
setRepoUrl(data.repo_url);
setBranch(data.branch);
setComposePath(data.compose_path);
setSyncEnv(data.sync_env);
setAuthType(data.auth_type);
setToken('');
setApplyModeOverride(null);
const data: GitSource | { linked: false } = await res.json();
// An existing stack with no Git source attached answers 200 { linked: false }.
if ('linked' in data) {
resetToUnlinked();
} else {
setSource(data);
setRepoUrl(data.repo_url);
setBranch(data.branch);
setComposePath(data.compose_path);
setSyncEnv(data.sync_env);
setAuthType(data.auth_type);
setToken('');
setApplyModeOverride(null);
}
} else if (res.status === 404) {
setSource(null);
setRepoUrl('');
setBranch('main');
setComposePath('compose.yaml');
setSyncEnv(false);
setAuthType('none');
setToken('');
setApplyModeOverride(null);
resetToUnlinked();
} else if (res.status === 403) {
setSource(null);
toast.error('You do not have permission to view this stack\'s Git source.');
@@ -111,7 +120,7 @@ export function GitSourcePanel({
} finally {
setLoading(false);
}
}, [stackName]);
}, [stackName, resetToUnlinked]);
useEffect(() => {
if (open) {