diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 0b079d9902ad..15be20b2a206 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -18,6 +18,7 @@ import { getNewProjectGitHubRepository, getNewProjectGitHubTarget, getNewProjectPathPreview, + githubIncompleteOwnerRepositoryMessage, normalizePastedCloneUrl, resolveAddProjectPath, sortAddProjectProviderSources, @@ -755,6 +756,11 @@ export function AddProjectRepositoryScreen(props: { const lookupRepository = useCallback(async () => { if (!environment || repositoryInput.trim().length === 0 || isSubmitting) return; + const incompleteOwnerMessage = githubIncompleteOwnerRepositoryMessage(source, repositoryInput); + if (incompleteOwnerMessage) { + setError(incompleteOwnerMessage); + return; + } setError(null); setIsSubmitting(true); const provider = addProjectRemoteSourceProvider(source); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index f7b78b5ae676..e88c2c766750 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -13,6 +13,7 @@ import { getNewProjectGitHubRepository, getNewProjectGitHubTarget, getNewProjectPathPreview, + githubIncompleteOwnerRepositoryMessage, normalizePastedCloneUrl, } from "@t3tools/client-runtime/operations/projects"; import { connectionStatusText } from "@t3tools/client-runtime/connection"; @@ -2561,6 +2562,21 @@ function OpenCommandPaletteDialog(props: { return; } + const incompleteOwnerMessage = githubIncompleteOwnerRepositoryMessage( + addProjectCloneFlow.source, + rawRepository, + ); + if (incompleteOwnerMessage) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Repository name incomplete", + description: incompleteOwnerMessage, + }), + ); + return; + } + const provider = remoteProjectSourceProvider(addProjectCloneFlow.source); if (!provider) { const destinationPath = getCloneDestinationPath( diff --git a/packages/client-runtime/src/operations/projects.test.ts b/packages/client-runtime/src/operations/projects.test.ts index 9343e9842056..b46a3fee2eb1 100644 --- a/packages/client-runtime/src/operations/projects.test.ts +++ b/packages/client-runtime/src/operations/projects.test.ts @@ -17,6 +17,7 @@ import { getCloneDestinationPath, getCloneDirectoryName, getDefaultCloneUrl, + githubIncompleteOwnerRepositoryMessage, normalizePastedCloneUrl, resolveAddProjectPath, sortAddProjectProviderSources, @@ -24,6 +25,27 @@ import { import type { EnvironmentProject } from "../state/models.ts"; describe("add project shared logic", () => { + it("asks for a repository name when GitHub input is only an owner path", () => { + expect(githubIncompleteOwnerRepositoryMessage("github", "pingdotgg/")).toBe( + "Enter the full owner/repository name.", + ); + expect(githubIncompleteOwnerRepositoryMessage("github", " pingdotgg// ")).toBe( + "Enter the full owner/repository name.", + ); + + for (const input of [ + "pingdotgg/t3code", + "pingdotgg", + "https://github.com/pingdotgg/t3code.git", + "git@github.com:pingdotgg/t3code.git", + ]) { + expect(githubIncompleteOwnerRepositoryMessage("github", input)).toBeNull(); + } + expect(githubIncompleteOwnerRepositoryMessage("gitlab", "group/")).toBeNull(); + expect(githubIncompleteOwnerRepositoryMessage("url", "owner/")).toBeNull(); + expect(githubIncompleteOwnerRepositoryMessage("forgejo", "owner/")).toBeNull(); + }); + it("only allows project creation in connected environments", () => { expect(canCreateProjectInEnvironment("connected")).toBe(true); expect(canCreateProjectInEnvironment("available")).toBe(false); diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts index 8dd937d3a68d..b2533e340b6f 100644 --- a/packages/client-runtime/src/operations/projects.ts +++ b/packages/client-runtime/src/operations/projects.ts @@ -129,6 +129,25 @@ export function addProjectRemoteSourceProvider( const GITHUB_REPOSITORY_SHORTHAND = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]+(?:\.git)?$/; +/** Owner present, repository name missing: `owner/` or `owner//`. */ +const INCOMPLETE_GITHUB_OWNER_PATH = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/+$/; + +const INCOMPLETE_GITHUB_OWNER_REPOSITORY_MESSAGE = "Enter the full owner/repository name."; + +/** + * Message to show when a GitHub add-project field is only `owner/`. + * Lookup would send that to GitHub and come back as a generic failure. + * Returns null for complete names, bare repo names, URLs, and other sources. + */ +export function githubIncompleteOwnerRepositoryMessage( + source: AddProjectRemoteSource, + input: string, +): string | null { + if (source !== "github") return null; + if (!INCOMPLETE_GITHUB_OWNER_PATH.test(input.trim())) return null; + return INCOMPLETE_GITHUB_OWNER_REPOSITORY_MESSAGE; +} + /** Treat the common owner/repository shorthand as a public GitHub HTTPS URL. */ export function normalizePastedCloneUrl(input: string): string { const trimmed = input.trim();