fix: ignore stale GitHub Skill Sync previews

This commit is contained in:
Patrick Erichsen
2026-07-23 14:28:56 -07:00
parent f8af9e7a0a
commit b1a8dfcbf3
3 changed files with 110 additions and 5 deletions
@@ -6,7 +6,7 @@ import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
export type GitHubSkillSyncPublisherOption = {
type GitHubSkillSyncPublisherOption = {
publisher: {
_id: Id<"publishers">;
handle: string;
@@ -27,7 +27,7 @@ export type GitHubSkillSyncRepository = {
unavailableReason: "disabled" | null;
};
export type GitHubSkillSyncPreviewItem = {
type GitHubSkillSyncPreviewItem = {
slug: string;
displayName: string;
path: string;
@@ -297,6 +297,7 @@ function classificationLabel(classification: GitHubSkillSyncPreviewItem["classif
case "ownership-conflict":
return "Ownership conflict";
}
return assertNever(classification);
}
function classificationTone(classification: GitHubSkillSyncPreviewItem["classification"]) {
@@ -309,6 +310,11 @@ function classificationTone(classification: GitHubSkillSyncPreviewItem["classifi
case "ownership-conflict":
return "text-status-error-fg";
}
return assertNever(classification);
}
function assertNever(value: never): never {
throw new Error(`Unsupported GitHub Skill Sync classification: ${String(value)}`);
}
function previewReasonLabel(reason: string) {
+88 -1
View File
@@ -1,5 +1,5 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { getFunctionName } from "convex/server";
import type { FunctionReturnType } from "convex/server";
import type { ReactNode } from "react";
@@ -801,6 +801,93 @@ describe("Settings", () => {
).toBe(true);
});
it("ignores a preview response after the selected repository changes", async () => {
const listRepositories = vi.fn().mockResolvedValue({
publisher: { _id: "publisher_patrick", handle: "patrick", kind: "user" },
page: 1,
perPage: 100,
hasMore: false,
repositories: [
{
repositoryId: "1",
repo: "patrick-erichsen/skills",
ownerId: "123",
ownerLogin: "patrick-erichsen",
defaultBranch: "main",
archived: false,
disabled: false,
fork: false,
pushedAt: "2026-07-23T12:00:00Z",
selectable: true,
unavailableReason: null,
},
],
});
let resolvePreview: ((value: unknown) => void) | undefined;
const previewRepository = vi.fn(
() =>
new Promise((resolve) => {
resolvePreview = resolve;
}),
);
useActionMock.mockImplementation((action) => {
const actionName = getFunctionName(action);
if (actionName === "githubSkillSyncSettings:listRepositories") return listRepositories;
if (actionName === "githubSkillSyncSettings:previewRepository") return previewRepository;
return vi.fn();
});
mockSignedInSettings({
search: { view: "githubSources" },
memberships: [personalMembership],
});
render(<Settings />);
await waitFor(() => expect(listRepositories).toHaveBeenCalled());
fireEvent.click(screen.getByRole("button", { name: "Preview repository" }));
await waitFor(() => expect(previewRepository).toHaveBeenCalled());
fireEvent.change(screen.getByLabelText("Repository URL"), {
target: { value: "patrick-erichsen/other-skills" },
});
await act(async () => {
resolvePreview?.({
publisher: { _id: "publisher_patrick", handle: "patrick", kind: "user" },
repository: {
requestedRepo: "patrick-erichsen/skills",
repositoryId: "1",
repo: "patrick-erichsen/skills",
redirected: false,
defaultBranch: "main",
commit: "a".repeat(40),
},
summary: {
total: 1,
newDestinations: 1,
replacements: 0,
unavailable: 0,
conflicts: 0,
},
items: [
{
slug: "html",
displayName: "HTML",
path: "skills/html",
contentHash: "hash-html",
classification: "new-destination",
eligible: true,
destination: null,
},
],
});
});
expect(screen.getByLabelText<HTMLInputElement>("Repository URL").value).toBe(
"patrick-erichsen/other-skills",
);
expect(screen.queryByRole("heading", { name: "Repository preview" })).toBeNull();
});
it("shows synced repos as separate cards and lets owners delete a source", async () => {
const deleteSource = vi.fn().mockResolvedValue({ ok: true, deletedSkills: 0 });
useMutationMock.mockImplementation((mutation) =>
+14 -2
View File
@@ -36,6 +36,7 @@ import {
type FormEvent,
type ReactNode,
useEffect,
useRef,
useState,
} from "react";
import { toast } from "sonner";
@@ -331,6 +332,7 @@ export function Settings() {
const [isLoadingGitHubRepositories, setIsLoadingGitHubRepositories] = useState(false);
const [githubSyncPreview, setGitHubSyncPreview] = useState<GitHubSkillSyncPreview | null>(null);
const [isPreviewingGitHubSource, setIsPreviewingGitHubSource] = useState(false);
const githubSyncPreviewRequestId = useRef(0);
const [deletingSourceId, setDeletingSourceId] = useState<Id<"githubSkillSources"> | null>(null);
const [sourceToDelete, setSourceToDelete] = useState<GitHubSkillSource | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
@@ -472,7 +474,7 @@ export function Settings() {
) {
setGitHubRepositories([]);
setGitHubRepositoriesError(null);
return;
return undefined;
}
let cancelled = false;
setIsLoadingGitHubRepositories(true);
@@ -788,6 +790,8 @@ export function Settings() {
if (!selectedSourcePublisher) return;
const repo = parseGitHubRepoInput(githubRepo);
if (!repo) return;
const requestId = githubSyncPreviewRequestId.current + 1;
githubSyncPreviewRequestId.current = requestId;
setIsPreviewingGitHubSource(true);
setGitHubSyncPreview(null);
try {
@@ -795,12 +799,16 @@ export function Settings() {
publisherId: selectedSourcePublisher.publisher._id,
repo,
});
if (githubSyncPreviewRequestId.current !== requestId) return;
setGithubRepo(result.repository.repo);
setGitHubSyncPreview(result as GitHubSkillSyncPreview);
} catch (error) {
if (githubSyncPreviewRequestId.current !== requestId) return;
toast.error(getUserFacingConvexError(error, "GitHub repository could not be previewed."));
} finally {
setIsPreviewingGitHubSource(false);
if (githubSyncPreviewRequestId.current === requestId) {
setIsPreviewingGitHubSource(false);
}
}
}
@@ -1706,17 +1714,21 @@ export function Settings() {
publisherOptions={githubSourcePublishers}
selectedPublisherId={selectedSourcePublisher.publisher._id}
onPublisherChange={(publisherId) => {
githubSyncPreviewRequestId.current += 1;
setSelectedSourcePublisherId(publisherId);
setGithubRepo("");
setGitHubSyncPreview(null);
setIsPreviewingGitHubSource(false);
}}
repositories={githubRepositories}
repositoriesError={githubRepositoriesError}
isLoadingRepositories={isLoadingGitHubRepositories}
githubRepo={githubRepo}
onGithubRepoChange={(repo) => {
githubSyncPreviewRequestId.current += 1;
setGithubRepo(repo);
setGitHubSyncPreview(null);
setIsPreviewingGitHubSource(false);
}}
onPreview={onPreviewGitHubSource}
isPreviewing={isPreviewingGitHubSource}