mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 05:16:50 +01:00
feat(skills): select and validate tracked GitHub branches (#16288)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Skills can track a branch in an authorized GitHub repository. > - Sources previously offered only a default-branch repair action. > - People need to select a listed branch or enter a branch that GitHub has not listed. > - The server must validate the selected ref with the caller's authorized connection. > - This pull request connects the approved branch picker to production APIs. > - Installed skills remain usable while the source changes its tracked ref. ## Linked Issues or Issue Description **Subsystem affected** Skills UI, shared API contracts, GitHub source services, and agent API discovery. **Problem or motivation** The default-branch repair action does not let people choose another branch. The settings service also validates the old ref when a caller supplies a new ref other than HEAD. A missing tracked branch can prevent connection repair even when the selected account has repository access. **Proposed solution** Load a bounded branch list with the authorized connection. Mark the default branch. Search listed branches or accept a typed ref. Validate the selected ref before saving. Keep the chosen value after a load error, invalid ref, or concurrent update. Save the selected connection and branch together during connection repair. **Alternatives considered** A default-branch button does not support a named branch. A text field alone does not help people find the repository default or available branches. **Roadmap alignment** This improves the existing Skills source manager. It does not add a new skill import system. **Additional context** Refs #16066 and #16256. This is the production implementation of the approved branch-selection Storybook journeys. ## What Changed - Add an authorized branch-list endpoint and shared response/query types. Register it in agent API discovery. - Validate the requested ref, including the returned commit SHA. Preserve source leases, revision checks, project ownership, skill identities, selections, and installed versions. - Add the searchable branch picker to Sources and connection repair. Allow typed refs during branch discovery and after discovery fails. - Keep save errors in the dialog. Retain the selected branch through a concurrent source update. - Use the production components and API client in Storybook. Add keyboard, touch, loading, retry, invalid-ref, connection-repair, and concurrent-update browser journeys. - Update product documentation and the feature/access maps. ## Verification - Red/green: two new database regressions fail against the previous service and pass against the implementation. - Linux database/service suite: 85 tests passed. This includes source visibility, human/agent authorization, selected connections, immutable versions, and invalid refs. - Rebased branch and review fixes: 94 focused UI, route, branch-validation, and fixture tests passed. Two conflict-message regressions and the stale-retry fixture regression fail before the fix and pass after it. - Storybook build passed. All 26 focused browser journeys passed. - Repository typecheck and build passed. Final UI typecheck passed after the review fixes. - The full local Vitest run was interrupted when the checkout and its dependencies disappeared. It reported missing working-directory/Postgres files and worker startup errors. Its two timed-out tests pass after the checkout was restored. The complete CI suite passed on the final head, `cf5d4c8f0a`: [CI run](https://github.com/paperclipai/paperclip/actions/runs/38571068332). One unchanged workspace timestamp test required a single rerun. Its release-minus-one-millisecond value can equal the original confirmation time. The rerun passed. - Live acceptance passed on implementation head `5dd935bf14` in an isolated macOS arm64 instance with Node 24.21.0 and an authorized private GitHub repository. The UI loaded the actual default branch, saved branch and selected connection together, and preserved the installed skill/version IDs after reload. An invalid typed ref retained the draft and showed the provider error. A valid typed HEAD ref then saved successfully. The owned runtime and database were stopped after the test. Focused unit, Linux database, and browser suites passed again after the error-handling review fixes. - Greptile reviewed final head `cf5d4c8f0a` at 5/5. Both review threads are resolved; the fresh review has no remaining findings. - Token gates and map/catalog checks passed. - Review entry points: `Skills / Project repository skills / 03 Branch selection components` and `04 Branch selection journeys`. Run `pnpm storybook` to inspect them. - Browser command: `pnpm exec playwright test --config tests/storybook-visual/playwright.config.ts tests/storybook-visual/skill-source-branch-selection.spec.ts tests/storybook-visual/project-repository-skills.spec.ts`. - Manual check: open an independent GitHub source, choose a new branch, select the default or type a ref, save, and reload. Try an invalid ref and confirm the selection remains in the dialog. For a project-bound source, change its ref in project settings. ## Risks - GitHub discovery is bounded to 100 listed branches. The default branch is always included. Typed refs remain available. - A source can change while the dialog is open. The API rejects a stale revision and the UI keeps the draft for retry. - A ref change schedules the existing refresh pipeline. Installed versions remain available until a successful refresh imports new content. - No database migration or credential storage change is required. Existing HEAD requests remain supported. ## Model Used OpenAI Codex, GPT-6 family. The serving model's exact identifier and context-window size are not exposed in this session. Used reasoning, repository tools, code execution, and browser tools. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
d4892d4761
commit
df4ee06e2f
29 files changed
+722
-42
No files matched your search
@@ -1,11 +1,13 @@
|
||||
import type { ProjectRepositoryOptions, ProjectSkillRepository, SkillSourceSettingsRequest, SkillSourceFilePreview, SkillSourcePreviewRequest, SkillSource, SkillSourceCreateRequest, SkillSourceDiscovery, SkillSourceDiscoveryRequest, SkillSourceRefreshResult, SkillSourceSelectionRequest, SkillSourceScanUpdate, SkillSourceDiscoveryEvent, SkillSourceDisconnectRequest, SkillSourceDisconnectResult } from '@paperclipai/shared';
|
||||
import { api, ApiError, requestResponse } from './client';
|
||||
import type { SkillSourceBranches, SkillSourceBranchesQuery } from '@paperclipai/shared';
|
||||
const base = (companyId: string) => `/companies/${companyId}/skill-sources`;
|
||||
export const skillSourcesApi = {
|
||||
list: (companyId: string) => api.get<SkillSource[]>(base(companyId)),
|
||||
projectRepositories: (companyId: string) => api.get<ProjectSkillRepository[]>(`${base(companyId)}/project-repositories`),
|
||||
adoptProject: (companyId: string, workspaceId: string) => api.post<{ bindingId: string }>(`${base(companyId)}/adopt-project`, { workspaceId }),
|
||||
settings: (companyId: string, id: string, input: SkillSourceSettingsRequest) => api.patch<SkillSource>(`${base(companyId)}/${id}/settings`, input),
|
||||
branches: (companyId: string, id: string, input: SkillSourceBranchesQuery = {}, signal?: AbortSignal) => api.get<SkillSourceBranches>(`${base(companyId)}/${id}/branches${input.connectionId ? `?connectionId=${encodeURIComponent(input.connectionId)}` : ''}`, { signal }),
|
||||
repositories: (companyId: string) => api.get<ProjectRepositoryOptions>(`${base(companyId)}/repositories`),
|
||||
discover: (companyId: string, input: SkillSourceDiscoveryRequest) => api.post<SkillSourceDiscovery>(`${base(companyId)}/discover`, input),
|
||||
discoverStream: async (companyId: string, input: SkillSourceDiscoveryRequest, onProgress: (event: SkillSourceScanUpdate) => void, signal?: AbortSignal): Promise<SkillSourceDiscovery> => {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { GitBranch, LoaderCircle } from 'lucide-react';
|
||||
import { SearchableSelect, type SearchableSelectGroup, type SearchableSelectOption } from '@/components/SearchableSelect';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export interface RepositoryBranch {
|
||||
name: string;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
interface BranchOption extends SearchableSelectOption {
|
||||
isDefault?: boolean;
|
||||
entered?: boolean;
|
||||
}
|
||||
|
||||
export function RepositoryBranchSelect({ value, branches, loading, error, disabled, onChange, onRetry }: {
|
||||
value: string;
|
||||
branches: readonly RepositoryBranch[];
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
disabled?: boolean;
|
||||
onChange: (branch: string) => void;
|
||||
onRetry?: () => void;
|
||||
}) {
|
||||
const uniqueBranches = [...new Map(branches.map(branch => [branch.name, branch])).values()];
|
||||
const options: BranchOption[] = uniqueBranches.map(branch => ({ key: branch.name, value: branch.name, label: branch.name, searchText: branch.isDefault ? 'default branch' : undefined, isDefault: branch.isDefault }));
|
||||
if (value && !options.some(option => option.value === value)) options.push({ key: value, value, label: value });
|
||||
const groups: SearchableSelectGroup<string, BranchOption>[] = [
|
||||
{ id: 'default', label: 'Default branch', options: options.filter(option => option.isDefault) },
|
||||
{ id: 'branches', label: 'Branches', options: options.filter(option => !option.isDefault) },
|
||||
];
|
||||
return <div className="flex min-w-0 flex-col gap-2">
|
||||
<SearchableSelect<string, BranchOption>
|
||||
value={value} groups={groups} disabled={disabled} triggerAriaLabel="Branch" mobileTitle="Choose a branch"
|
||||
placeholder="Choose a branch" searchPlaceholder="Search or enter a branch name…"
|
||||
emptyMessage={loading ? 'Branches are loading. You can still enter a name.' : 'No matching branches. Enter a branch name to use it.'}
|
||||
onValueChange={branch => onChange(branch)}
|
||||
deriveGroups={(query, current) => {
|
||||
const name = query.trim();
|
||||
return name && !options.some(option => option.value === name)
|
||||
? [...current, { id: 'entered', options: [{ key: `entered:${name}`, value: name, label: name, entered: true }] }]
|
||||
: current;
|
||||
}}
|
||||
renderOption={option => <>
|
||||
<GitBranch className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate" title={option.value}>{option.entered ? `Use “${option.value}”` : option.label}</span>
|
||||
{option.isDefault && <span className="text-xs text-muted-foreground">Default</span>}
|
||||
</>}
|
||||
/>
|
||||
{loading && <p role="status" className="flex items-center gap-2 text-xs text-muted-foreground"><LoaderCircle className="size-3.5 animate-spin motion-reduce:animate-none" />Loading branches…</p>}
|
||||
{error && <div className="flex flex-wrap items-center gap-2"><p role="alert" className="text-sm text-destructive">{error} You can still enter a branch name.</p>
|
||||
{onRetry && <Button type="button" size="sm" variant="ghost" disabled={loading || disabled} onClick={onRetry}>Try again</Button>}
|
||||
</div>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { SkillSource } from '@paperclipai/shared';
|
||||
import { skillSourcesApi } from '@/api/skillSources';
|
||||
import { ApiError } from '@/api/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
import { SkillSourceBranchRepairDialog } from './SkillSourceBranchRepairDialog';
|
||||
|
||||
export function SkillSourceBranchRepair({ companyId, source, connectionId, onSaved, onClose, onReturnFocus }: {
|
||||
companyId: string; source: SkillSource; connectionId?: string; onSaved: () => Promise<void>;
|
||||
onClose: () => void; onReturnFocus?: () => void;
|
||||
}) {
|
||||
const client = useQueryClient();
|
||||
const branches = useQuery({
|
||||
queryKey: ['skill-source-branches', companyId, source.id, connectionId ?? source.connectionId],
|
||||
queryFn: ({ signal }) => skillSourcesApi.branches(companyId, source.id, connectionId ? { connectionId } : {}, signal),
|
||||
retry: false, refetchOnMount: 'always',
|
||||
});
|
||||
async function save(branch: string) {
|
||||
try {
|
||||
await skillSourcesApi.settings(companyId, source.id, { revision: source.revision, trackingRef: branch, ...(connectionId ? { connectionId } : {}) });
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 409 && (error.body as { code?: string } | null)?.code === 'skill_source_settings_changed') {
|
||||
await client.invalidateQueries({ queryKey: queryKeys.skillSources.all(companyId) });
|
||||
throw new Error('This source is refreshing or changed. Your branch is still selected. Try saving again.');
|
||||
}
|
||||
if (error instanceof ApiError && (error.body as { code?: string } | null)?.code === 'skill_source_ref_unavailable') {
|
||||
throw new Error('GitHub could not find that branch or revision. Check the name or choose another branch.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
await onSaved();
|
||||
}
|
||||
return <SkillSourceBranchRepairDialog repository={source.fullName} currentBranch={source.trackingRef}
|
||||
branches={branches.data?.branches ?? []} loading={branches.isFetching} loadError={branches.error?.message}
|
||||
onRetry={() => void branches.refetch()} onSave={save} onClose={onClose} onReturnFocus={onReturnFocus} />;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { LoaderCircle } from 'lucide-react';
|
||||
import { RepositoryBranchSelect, type RepositoryBranch } from '@/components/RepositoryBranchSelect';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
|
||||
/** Presentational branch repair. The caller owns branch discovery and saving. */
|
||||
export function SkillSourceBranchRepairDialog({ repository, currentBranch, branches, loading, loadError, onRetry, onSave, onClose, onReturnFocus }: {
|
||||
repository: string;
|
||||
currentBranch: string;
|
||||
branches: readonly RepositoryBranch[];
|
||||
loading?: boolean;
|
||||
loadError?: string | null;
|
||||
onRetry?: () => void;
|
||||
onSave: (branch: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
onReturnFocus?: () => void;
|
||||
}) {
|
||||
const [branch, setBranch] = useState('');
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const title = useRef<HTMLHeadingElement>(null);
|
||||
async function save() {
|
||||
setPending(true); setError(null);
|
||||
try { await onSave(branch); onClose(); }
|
||||
catch (problem) { setError(problem instanceof Error ? problem.message : 'Could not change the branch. Try again.'); }
|
||||
finally { setPending(false); }
|
||||
}
|
||||
return <Dialog open onOpenChange={open => { if (!open && !pending) onClose(); }}>
|
||||
<DialogContent showCloseButton={!pending} onOpenAutoFocus={event => { event.preventDefault(); title.current?.focus(); }}
|
||||
onCloseAutoFocus={event => { if (onReturnFocus) { event.preventDefault(); onReturnFocus(); } }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle ref={title} tabIndex={-1} className="outline-none">Choose a new branch</DialogTitle>
|
||||
<DialogDescription className="break-words">{repository} · Currently tracking {currentBranch === 'HEAD' ? 'the default branch' : currentBranch}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="flex min-w-0 flex-col gap-4" onSubmit={event => { event.preventDefault(); if (branch && !pending) void save(); }}>
|
||||
<RepositoryBranchSelect value={branch} branches={branches} loading={loading} error={loadError} disabled={pending}
|
||||
onChange={value => { setBranch(value); setError(null); }} onRetry={onRetry} />
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Button type="button" variant="ghost" disabled={pending} onClick={onClose}>Cancel</Button>
|
||||
<Button type="submit" disabled={!branch || pending}>{pending && <LoaderCircle className="size-4 animate-spin motion-reduce:animate-none" />}{pending ? 'Changing branch…' : 'Change branch'}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
@@ -8,18 +8,19 @@ import { Link } from '@/lib/router';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { RadioCardGroup } from '@/components/ui/radio-card';
|
||||
import { SkillSourceBranchRepair } from './SkillSourceBranchRepair';
|
||||
|
||||
export function SkillSourceConnectionRepair({ companyId, source, onClose, onSaved, onReturnFocus }: {
|
||||
companyId: string; source: SkillSource; onClose: () => void; onSaved: () => Promise<void>;
|
||||
onReturnFocus?: () => void;
|
||||
}) {
|
||||
const [connectionId, setConnectionId] = useState(source.connectionId ?? '');
|
||||
const [chooseBranch, setChooseBranch] = useState(false);
|
||||
const client = useQueryClient();
|
||||
const query = useQuery({ queryKey: queryKeys.skillSources.repositories(companyId), queryFn: () => skillSourcesApi.repositories(companyId), refetchOnMount: 'always', retry: false });
|
||||
const save = useMutation({ mutationFn: (useDefaultBranch: boolean) => skillSourcesApi.settings(companyId, source.id, { revision: source.revision, connectionId,
|
||||
...(useDefaultBranch ? { trackingRef: 'HEAD' as const } : {}) }),
|
||||
const save = useMutation({ mutationFn: () => skillSourcesApi.settings(companyId, source.id, { revision: source.revision, connectionId }),
|
||||
onError: async error => {
|
||||
if (error instanceof ApiError && error.status === 409) {
|
||||
if (error instanceof ApiError && error.status === 409 && (error.body as { code?: string } | null)?.code === 'skill_source_settings_changed') {
|
||||
await client.invalidateQueries({ queryKey: queryKeys.skillSources.all(companyId) });
|
||||
}
|
||||
},
|
||||
@@ -27,6 +28,8 @@ export function SkillSourceConnectionRepair({ companyId, source, onClose, onSave
|
||||
const connections = query.data?.connections ?? [];
|
||||
const branchMissing = save.error instanceof ApiError && (save.error.body as { code?: string } | null)?.code === 'skill_source_ref_unavailable';
|
||||
const canSave = connections.some(connection => connection.id === connectionId);
|
||||
if (chooseBranch) return <SkillSourceBranchRepair companyId={companyId} source={source} connectionId={connectionId}
|
||||
onClose={() => setChooseBranch(false)} onReturnFocus={onReturnFocus} onSaved={async () => { await onSaved(); onClose(); }} />;
|
||||
return <Dialog open onOpenChange={open => { if (!open && !save.isPending) onClose(); }}>
|
||||
<DialogContent className="flex max-h-(--sz-calc-18) flex-col overflow-hidden sm:max-w-md"
|
||||
onCloseAutoFocus={event => { if (onReturnFocus) { event.preventDefault(); onReturnFocus(); } }}>
|
||||
@@ -41,13 +44,13 @@ export function SkillSourceConnectionRepair({ companyId, source, onClose, onSave
|
||||
options={connections.map(connection => ({ value: connection.id, title: connection.name }))} />
|
||||
{query.isSuccess && connections.length === 0 && <p className="text-sm text-muted-foreground">Connect GitHub to continue.</p>}
|
||||
<Button asChild variant="link" className="justify-start p-0"><Link to="/apps">Manage GitHub connections</Link></Button>
|
||||
{save.error && <p role="alert" className="text-sm text-destructive">{save.error instanceof ApiError && save.error.status === 409
|
||||
{save.error && <p role="alert" className="text-sm text-destructive">{save.error instanceof ApiError && save.error.status === 409 && (save.error.body as { code?: string } | null)?.code === 'skill_source_settings_changed'
|
||||
? 'This source was updated. Your connection is still selected. Save again to apply it.' : save.error.message}</p>}
|
||||
{branchMissing && source.accessScope !== 'projects' && !source.projects?.some(project => project.active) && <Button variant="outline" disabled={!canSave || save.isPending} onClick={() => save.mutate(true)}>Use default branch</Button>}
|
||||
{branchMissing && source.accessScope !== 'projects' && !source.projects?.some(project => project.active) && <Button variant="outline" disabled={!canSave || save.isPending} onClick={() => setChooseBranch(true)}>Choose a new branch</Button>}
|
||||
</div>
|
||||
<footer className="flex shrink-0 items-center justify-between gap-3 border-t border-border pt-4">
|
||||
<Button variant="ghost" disabled={save.isPending} onClick={onClose}>Cancel</Button>
|
||||
<Button disabled={!canSave || save.isPending} onClick={() => save.mutate(false)}>{save.isPending ? 'Saving…' : 'Save connection'}</Button>
|
||||
<Button disabled={!canSave || save.isPending} onClick={() => save.mutate()}>{save.isPending ? 'Saving…' : 'Save connection'}</Button>
|
||||
</footer>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
|
||||
@@ -16,7 +16,7 @@ vi.mock('@/lib/router', () => ({
|
||||
useNavigate: () => context.navigate,
|
||||
useParams: () => ({ sourceId: context.sourceId }),
|
||||
}));
|
||||
vi.mock('@/api/skillSources', () => ({ skillSourcesApi: { list: vi.fn(), projectRepositories: vi.fn(), adoptProject: vi.fn(), settings: vi.fn(), repositories: vi.fn(), discoverStream: vi.fn(), create: vi.fn(), select: vi.fn(), refresh: vi.fn(), disconnect: vi.fn() } }));
|
||||
vi.mock('@/api/skillSources', () => ({ skillSourcesApi: { list: vi.fn(), projectRepositories: vi.fn(), adoptProject: vi.fn(), settings: vi.fn(), branches: vi.fn(), repositories: vi.fn(), discoverStream: vi.fn(), create: vi.fn(), select: vi.fn(), refresh: vi.fn(), disconnect: vi.fn() } }));
|
||||
|
||||
const repos = {
|
||||
connections: [{ id: 'personal', name: 'Personal' }, { id: 'shared', name: 'Engineering' }],
|
||||
@@ -52,6 +52,7 @@ beforeEach(() => {
|
||||
vi.mocked(skillSourcesApi.list).mockResolvedValue([]);
|
||||
vi.mocked(skillSourcesApi.projectRepositories).mockResolvedValue([]);
|
||||
vi.mocked(skillSourcesApi.repositories).mockResolvedValue(structuredClone(repos));
|
||||
vi.mocked(skillSourcesApi.branches).mockResolvedValue({ defaultBranch: 'master', branches: [{ name: 'master', isDefault: true }], truncated: false });
|
||||
vi.mocked(skillSourcesApi.discoverStream).mockResolvedValue(discovery);
|
||||
context.sourceId = 'new';
|
||||
sessionStorage.clear();
|
||||
@@ -90,11 +91,32 @@ describe('GitHub skill source import', () => {
|
||||
context.sourceId = '';
|
||||
const source = { ...sourceFixture(), trackingRef: 'main', lastError: 'The tracked GitHub branch or revision is unavailable. Use the repository’s default branch or update its configured ref.' };
|
||||
vi.mocked(skillSourcesApi.list).mockResolvedValue([{ ...source, repairAction: 'branch' }]);
|
||||
vi.mocked(skillSourcesApi.settings).mockResolvedValue({ ...source, trackingRef: 'HEAD', lastError: null });
|
||||
vi.mocked(skillSourcesApi.settings).mockResolvedValue({ ...source, trackingRef: 'master', lastError: null });
|
||||
await mount();
|
||||
expect(button('Choose connection')).toBeUndefined();
|
||||
await act(async () => button('Use default branch').click()); await flush();
|
||||
expect(skillSourcesApi.settings).toHaveBeenCalledWith('company-1', source.id, { revision: source.revision, trackingRef: 'HEAD' });
|
||||
await act(async () => button('Choose a new branch').click()); await flush();
|
||||
await act(async () => document.querySelector<HTMLButtonElement>('button[aria-label="Branch"]')!.click()); await flush();
|
||||
await act(async () => (document.querySelector('[cmdk-item][data-value="master"]') as HTMLElement).click()); await flush();
|
||||
await act(async () => button('Change branch').click()); await flush();
|
||||
expect(skillSourcesApi.branches).toHaveBeenCalledWith('company-1', source.id, {}, expect.any(AbortSignal));
|
||||
expect(skillSourcesApi.settings).toHaveBeenCalledWith('company-1', source.id, { revision: source.revision, trackingRef: 'master' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
'This repository and branch are already in Sources. Use that source; installed skills were kept.',
|
||||
'The repository at this URL has changed identity. Add it as a new source.',
|
||||
])('preserves actionable branch conflict advice: %s', async message => {
|
||||
context.sourceId = '';
|
||||
const source = { ...sourceFixture(), trackingRef: 'main', lastError: 'Unavailable branch.', repairAction: 'branch' as const };
|
||||
vi.mocked(skillSourcesApi.list).mockResolvedValue([source]);
|
||||
vi.mocked(skillSourcesApi.settings).mockRejectedValue(new ApiError(message, 409, {}));
|
||||
await mount();
|
||||
await act(async () => button('Choose a new branch').click()); await flush();
|
||||
await act(async () => document.querySelector<HTMLButtonElement>('button[aria-label="Branch"]')!.click()); await flush();
|
||||
await act(async () => (document.querySelector('[cmdk-item][data-value="master"]') as HTMLElement).click()); await flush();
|
||||
await act(async () => button('Change branch').click()); await flush();
|
||||
expect(document.querySelector('[role="dialog"] [role="alert"]')?.textContent).toBe(message);
|
||||
expect(document.querySelector('button[aria-label="Branch"]')?.textContent).toContain('master');
|
||||
});
|
||||
|
||||
it('keeps pending project links inside the installed source instead of duplicating its repository', async () => {
|
||||
@@ -129,13 +151,17 @@ describe('GitHub skill source import', () => {
|
||||
const source = { ...sourceFixture(), connectionId: 'personal', lastError: 'Choose a GitHub connection to enable automatic updates.', repairAction: 'connection' as const };
|
||||
vi.mocked(skillSourcesApi.list).mockResolvedValue([source]);
|
||||
vi.mocked(skillSourcesApi.settings).mockRejectedValueOnce(new ApiError('The tracked GitHub branch or revision is unavailable.', 422, { code: 'skill_source_ref_unavailable' }))
|
||||
.mockResolvedValue({ ...source, trackingRef: 'HEAD', lastError: null });
|
||||
.mockResolvedValue({ ...source, trackingRef: 'master', lastError: null });
|
||||
await mount();
|
||||
await act(async () => button('Choose connection').click()); await flush();
|
||||
await act(async () => button('Save connection').click()); await flush();
|
||||
expect(skillSourcesApi.settings).toHaveBeenLastCalledWith('company-1', source.id, { revision: source.revision, connectionId: 'personal' });
|
||||
await act(async () => button('Use default branch').click()); await flush();
|
||||
expect(skillSourcesApi.settings).toHaveBeenLastCalledWith('company-1', source.id, { revision: source.revision, connectionId: 'personal', trackingRef: 'HEAD' });
|
||||
await act(async () => button('Choose a new branch').click()); await flush();
|
||||
await act(async () => document.querySelector<HTMLButtonElement>('button[aria-label="Branch"]')!.click()); await flush();
|
||||
await act(async () => (document.querySelector('[cmdk-item][data-value="master"]') as HTMLElement).click()); await flush();
|
||||
await act(async () => button('Change branch').click()); await flush();
|
||||
expect(skillSourcesApi.branches).toHaveBeenLastCalledWith('company-1', source.id, { connectionId: 'personal' }, expect.any(AbortSignal));
|
||||
expect(skillSourcesApi.settings).toHaveBeenLastCalledWith('company-1', source.id, { revision: source.revision, connectionId: 'personal', trackingRef: 'master' });
|
||||
expect(document.querySelector('[role="dialog"]')).toBeNull();
|
||||
});
|
||||
it('shows measured Git download progress before skill discovery and retains the automatically chosen connection', async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ProjectSkillRepositoryRow } from "@/components/skill-studio/ProjectSkillRepositoryRow";
|
||||
import { SkillSourceConnectionRepair } from "@/components/skill-studio/SkillSourceConnectionRepair";
|
||||
import { SkillSourceBranchRepair } from "@/components/skill-studio/SkillSourceBranchRepair";
|
||||
import { SkillSourceSyncControls } from "@/components/skill-studio/SkillSourceSyncControls";
|
||||
import { GithubIcon } from "@/components/icons/github-icon";
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
@@ -38,11 +39,13 @@ export function SkillSources() {
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const [results, setResults] = useState<Record<string, { message: string; failedAt: number; operation: 'refresh' | 'settings' } | null>>({});
|
||||
const [repairSourceId, setRepairSourceId] = useState<string | null>(null);
|
||||
const [branchSourceId, setBranchSourceId] = useState<string | null>(null);
|
||||
const [disconnectSource, setDisconnectSource] = useState<SkillSource | null>(null);
|
||||
const companyId = selectedCompanyId ?? '';
|
||||
useEffect(() => { setBreadcrumbs([{ label: 'Skills', href: '/skills' }, { label: 'Sources' }]); }, [setBreadcrumbs]);
|
||||
const query = useQuery({ queryKey: sourceKey(companyId), queryFn: () => skillSourcesApi.list(companyId), enabled: Boolean(companyId), refetchInterval: 5000 });
|
||||
const repairSource = query.data?.find(source => source.id === repairSourceId);
|
||||
const branchSource = query.data?.find(source => source.id === branchSourceId);
|
||||
const projectRepositories = useQuery({ queryKey: queryKeys.skillSources.projects(companyId), queryFn: () => skillSourcesApi.projectRepositories(companyId), enabled: Boolean(companyId), refetchInterval: 5000 });
|
||||
async function invalidate() {
|
||||
await Promise.all([client.invalidateQueries({ queryKey: queryKeys.skillSources.projects(companyId) }), client.invalidateQueries({ queryKey: sourceKey(companyId) }), client.invalidateQueries({ queryKey: queryKeys.companySkills.list(companyId) })]);
|
||||
@@ -56,9 +59,6 @@ export function SkillSources() {
|
||||
const settings = useMutation({ mutationFn: ({ source, enabled }: { source: SkillSource; enabled: boolean }) => skillSourcesApi.settings(companyId, source.id, { revision: source.revision, automaticUpdates: enabled }),
|
||||
onSuccess: async (_result, variables) => { setResults(prev => ({ ...prev, [variables.source.id]: null })); await invalidate(); },
|
||||
onError: (error, variables) => setResults(prev => ({ ...prev, [variables.source.id]: { message: error.message, failedAt: Date.now(), operation: 'settings' } })) });
|
||||
const repairBranch = useMutation({ mutationFn: (source: SkillSource) => skillSourcesApi.settings(companyId, source.id, { revision: source.revision, trackingRef: 'HEAD' }),
|
||||
onSuccess: async (_result, source) => { setResults(prev => ({ ...prev, [source.id]: null })); await invalidate(); sourceActionButtons.current[source.id]?.focus(); },
|
||||
onError: (error, source) => { setResults(prev => ({ ...prev, [source.id]: { message: error.message, failedAt: Date.now(), operation: 'settings' } })); void invalidate(); } });
|
||||
const availableProjects = groupProjectSkillRepositories(Array.isArray(projectRepositories.data) ? projectRepositories.data.filter(repo => !repo.sourceId) : []);
|
||||
const groupSource = (group: typeof availableProjects[number]) => query.data?.find(source => source.repositoryUrl.toLowerCase() === group.repository.repositoryUrl.toLowerCase() && source.trackingRef === group.repository.trackingRef);
|
||||
const uninstalledProjects = availableProjects.filter(group => !groupSource(group));
|
||||
@@ -131,6 +131,7 @@ export function SkillSources() {
|
||||
onCheckedChange={enabled => settings.mutate({ source, enabled })}>Automatic updates</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuItem onSelect={() => navigate(`/skills/sources/${source.id}`)}>Select skills</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setRepairSourceId(source.id)}>Choose connection</DropdownMenuItem>
|
||||
{source.accessScope !== 'projects' && !source.projects?.some(project => project.active) && <DropdownMenuItem onSelect={() => setBranchSourceId(source.id)}>Choose a new branch</DropdownMenuItem>}
|
||||
<DropdownMenuItem variant="destructive" disabled={disconnect.isPending} onSelect={() => { disconnect.reset(); reloadRemoval.reset(); setDisconnectSource(source); }}>{source.enabled ? 'Disconnect source' : 'Remove source'}</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -157,12 +158,13 @@ export function SkillSources() {
|
||||
{source.lastError && (source.repairAction === 'connection' || (!source.repairAction && !source.connectionId)) && <Button variant="outline" size="sm" className="self-start" onClick={() => setRepairSourceId(source.id)}>Choose connection</Button>}
|
||||
{source.lastError && source.repairAction === 'branch' && (source.accessScope === 'projects' || source.projects?.some(project => project.active)
|
||||
? <div className="flex flex-wrap gap-3">{source.projects?.filter(project => project.active).map(project => <Link key={project.id} to={`/projects/${project.id}`} className="text-sm underline">Update {project.name} repository</Link>)}</div>
|
||||
: <Button variant="outline" size="sm" className="self-start" disabled={repairBranch.isPending} onClick={() => repairBranch.mutate(source)}>
|
||||
{repairBranch.isPending && repairBranch.variables.id === source.id ? 'Updating branch…' : 'Use default branch'}</Button>)}
|
||||
: <Button variant="outline" size="sm" className="self-start" onClick={() => setBranchSourceId(source.id)}>Choose a new branch</Button>)}
|
||||
{source.connectionId && <Link to={`/apps/${source.connectionId}/permissions`} className="text-xs text-muted-foreground hover:text-foreground">Manage GitHub connection</Link>}
|
||||
</section>;
|
||||
})}
|
||||
</div>
|
||||
{branchSource && <SkillSourceBranchRepair companyId={companyId} source={branchSource} onClose={() => setBranchSourceId(null)}
|
||||
onReturnFocus={() => sourceActionButtons.current[branchSource.id]?.focus()} onSaved={async () => { setResults(prev => ({ ...prev, [branchSource.id]: null })); await invalidate(); }} />}
|
||||
{repairSource && <SkillSourceConnectionRepair companyId={companyId} source={repairSource} onClose={() => setRepairSourceId(null)} onReturnFocus={() => sourceActionButtons.current[repairSource.id]?.focus()} onSaved={async () => {
|
||||
setResults(prev => ({ ...prev, [repairSource.id]: null })); await invalidate();
|
||||
}} />}
|
||||
|
||||
Reference in new issue
Block a user