[cli] MAJOR: Connect a Git provider repository in vc link (#8290)

#8100 added a new `vc git` command, which allows users to connect a Git provider repository, enabling them to set up a full Git workflow for their Vercel projects without having to leave the CLI.

This PR takes this functionality a step further by including it as part of the `vc link` flow. This way, users can set up a Vercel project and add a Git provider repository all in one step.

This PR is blocked by a PR to `front` which adds an option to the Git Settings page for a Project to re-enable the prompt if the user opted out (in review).

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [x] The code changed/added as part of this PR has been covered with tests
- [x] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
This commit is contained in:
Matthew Stanciu
2022-08-11 15:49:36 -07:00
committed by GitHub
parent 0fcf172a10
commit c98c9996bf
20 changed files with 878 additions and 87 deletions

View File

@@ -335,6 +335,7 @@ export interface ProjectSettings {
directoryListing?: boolean;
gitForkProtection?: boolean;
commandForIgnoringBuildStep?: string | null;
skipGitConnectDuringLink?: boolean;
}
export interface BuilderV2 {

View File

@@ -12,7 +12,7 @@ import {
connectGitProvider,
disconnectGitProvider,
formatProvider,
ParsedRepoUrl,
RepoInfo,
parseRepoUrl,
printRemoteUrls,
} from '../../util/git/connect-git-provider';
@@ -35,7 +35,7 @@ interface ConnectArgParams {
org: Org;
project: Project;
confirm: boolean;
repoInfo: ParsedRepoUrl;
repoInfo: RepoInfo;
}
interface ConnectGitArgParams extends ConnectArgParams {
@@ -45,7 +45,7 @@ interface ConnectGitArgParams extends ConnectArgParams {
interface PromptConnectArgParams {
client: Client;
yes: boolean;
repoInfo: ParsedRepoUrl;
repoInfo: RepoInfo;
remoteUrls: Dictionary<string>;
}
@@ -155,8 +155,8 @@ export default async function connect(
output.log(`Connecting Git remote: ${link(remoteUrl)}`);
const parsedUrl = parseRepoUrl(remoteUrl);
if (!parsedUrl) {
const repoInfo = parseRepoUrl(remoteUrl);
if (!repoInfo) {
output.error(
`Failed to parse Git repo data from the following remote URL: ${link(
remoteUrl
@@ -164,7 +164,7 @@ export default async function connect(
);
return 1;
}
const { provider, org: gitOrg, repo } = parsedUrl;
const { provider, org: gitOrg, repo } = repoInfo;
const repoPath = `${gitOrg}/${repo}`;
const checkAndConnect = await checkExistsAndConnect({

View File

@@ -4,10 +4,10 @@ import { Org } from '../../types';
import chalk from 'chalk';
import link from '../output/link';
import { isAPIError } from '../errors-ts';
import { Dictionary } from '@vercel/client';
import { Output } from '../output';
import { Dictionary } from '@vercel/client';
export interface ParsedRepoUrl {
export interface RepoInfo {
url: string;
provider: string;
org: string;
@@ -19,7 +19,7 @@ export async function disconnectGitProvider(
org: Org,
projectId: string
) {
const fetchUrl = `/v4/projects/${projectId}/link?${stringify({
const fetchUrl = `/v9/projects/${projectId}/link?${stringify({
teamId: org.type === 'team' ? org.id : undefined,
})}`;
return client.fetch(fetchUrl, {
@@ -37,7 +37,7 @@ export async function connectGitProvider(
type: string,
repo: string
) {
const fetchUrl = `/v4/projects/${projectId}/link?${stringify({
const fetchUrl = `/v9/projects/${projectId}/link?${stringify({
teamId: org.type === 'team' ? org.id : undefined,
})}`;
try {
@@ -52,22 +52,21 @@ export async function connectGitProvider(
}),
});
} catch (err: unknown) {
if (isAPIError(err)) {
const apiError = isAPIError(err);
if (
err.action === 'Install GitHub App' ||
err.code === 'repo_not_found'
apiError &&
(err.action === 'Install GitHub App' || err.code === 'repo_not_found')
) {
client.output.error(
`Failed to link ${chalk.cyan(
repo
)}. Make sure there aren't any typos and that you have access to the repository if it's private.`
);
} else if (err.action === 'Add a Login Connection') {
} else if (apiError && err.action === 'Add a Login Connection') {
client.output.error(
err.message.replace(repo, chalk.cyan(repo)) +
`\nVisit ${link(err.link)} for more information.`
);
}
} else {
client.output.error(
`Failed to connect the ${formatProvider(
@@ -92,7 +91,7 @@ export function formatProvider(type: string): string {
}
}
export function parseRepoUrl(originUrl: string): ParsedRepoUrl | null {
export function parseRepoUrl(originUrl: string): RepoInfo | null {
const isSSH = originUrl.startsWith('git@');
// Matches all characters between (// or @) and (.com or .org)
// eslint-disable-next-line prefer-named-capture-group
@@ -126,7 +125,6 @@ export function parseRepoUrl(originUrl: string): ParsedRepoUrl | null {
repo,
};
}
export function printRemoteUrls(
output: Output,
remoteUrls: Dictionary<string>

View File

@@ -0,0 +1,107 @@
import { Dictionary } from '@vercel/client';
import { parseRepoUrl } from '../git/connect-git-provider';
import Client from '../client';
import { Org, Project, ProjectSettings } from '../../types';
import { handleOptions } from './handle-options';
import {
promptGitConnectMultipleUrls,
promptGitConnectSingleUrl,
} from './git-connect-prompts';
function getProjectSettings(project: Project): ProjectSettings {
return {
createdAt: project.createdAt,
framework: project.framework,
devCommand: project.devCommand,
installCommand: project.installCommand,
buildCommand: project.buildCommand,
outputDirectory: project.outputDirectory,
rootDirectory: project.rootDirectory,
directoryListing: project.directoryListing,
nodeVersion: project.nodeVersion,
skipGitConnectDuringLink: project.skipGitConnectDuringLink,
};
}
export async function addGitConnection(
client: Client,
org: Org,
project: Project,
remoteUrls: Dictionary<string>,
settings?: ProjectSettings
): Promise<number | void> {
if (!settings) {
settings = getProjectSettings(project);
}
if (Object.keys(remoteUrls).length === 1) {
return addSingleGitRemote(
client,
org,
project,
remoteUrls,
settings || project
);
} else if (Object.keys(remoteUrls).length > 1 && !project.link) {
return addMultipleGitRemotes(
client,
org,
project,
remoteUrls,
settings || project
);
}
}
async function addSingleGitRemote(
client: Client,
org: Org,
project: Project,
remoteUrls: Dictionary<string>,
settings: ProjectSettings
) {
const [remoteName, remoteUrl] = Object.entries(remoteUrls)[0];
const repoInfo = parseRepoUrl(remoteUrl);
if (!repoInfo) {
client.output.debug(`Could not parse repo url ${repoInfo}.`);
return 1;
}
const { org: parsedOrg, repo, provider } = repoInfo;
const alreadyLinked =
project.link &&
project.link.org === parsedOrg &&
project.link.repo === repo &&
project.link.type === provider;
if (alreadyLinked) {
client.output.debug('Project already linked. Skipping...');
return;
}
const replace =
project.link &&
(project.link.org !== parsedOrg ||
project.link.repo !== repo ||
project.link.type !== provider);
const shouldConnect = await promptGitConnectSingleUrl(
client,
project,
remoteName,
remoteUrl,
replace
);
return handleOptions(shouldConnect, client, org, project, settings, repoInfo);
}
async function addMultipleGitRemotes(
client: Client,
org: Org,
project: Project,
remoteUrls: Dictionary<string>,
settings: ProjectSettings
) {
client.output.log('Found multiple Git remote URLs in Git config.');
const remoteUrlOrOptions = await promptGitConnectMultipleUrls(
client,
remoteUrls
);
return handleOptions(remoteUrlOrOptions, client, org, project, settings);
}

View File

@@ -0,0 +1,86 @@
import { Dictionary } from '@vercel/client';
import chalk from 'chalk';
import { Project } from '../../types';
import Client from '../client';
import { formatProvider } from '../git/connect-git-provider';
import list from '../input/list';
export async function promptGitConnectSingleUrl(
client: Client,
project: Project,
remoteName: string,
remoteUrl: string,
hasDiffConnectedProvider = false
) {
const { output } = client;
if (hasDiffConnectedProvider) {
const currentRepoPath = `${project.link!.org}/${project.link!.repo}`;
const currentProvider = project.link!.type;
output.print('\n');
output.log(
`Found Git remote URL ${chalk.cyan(
remoteUrl
)}, which is different from the connected ${formatProvider(
currentProvider
)} repository ${chalk.cyan(currentRepoPath)}.`
);
} else {
output.print('\n');
output.log(
`Found local Git remote "${remoteName}": ${chalk.cyan(remoteUrl)}`
);
}
return await list(client, {
message: hasDiffConnectedProvider
? 'Do you want to replace it?'
: `Do you want to connect "${remoteName}" to your Vercel project?`,
choices: [
{
name: 'Yes',
value: 'yes',
short: 'yes',
},
{
name: 'No',
value: 'no',
short: 'no',
},
{
name: 'Do not ask again for this project',
value: 'opt-out',
short: 'no (opt out)',
},
],
});
}
export async function promptGitConnectMultipleUrls(
client: Client,
remoteUrls: Dictionary<string>
) {
const staticOptions = [
{
name: 'No',
value: 'no',
short: 'no',
},
{
name: 'Do not ask again for this project',
value: 'opt-out',
short: 'no (opt out)',
},
];
let choices = [];
for (const url of Object.values(remoteUrls)) {
choices.push({
name: url,
value: url,
short: url,
});
}
choices = choices.concat(staticOptions);
return await list(client, {
message: 'Do you want to connect a Git repository to your Vercel project?',
choices,
});
}

View File

@@ -0,0 +1,98 @@
import chalk from 'chalk';
import { Org, Project, ProjectSettings } from '../../types';
import Client from '../client';
import {
connectGitProvider,
disconnectGitProvider,
formatProvider,
RepoInfo,
parseRepoUrl,
} from '../git/connect-git-provider';
import { Output } from '../output';
import { getCommandName } from '../pkg-name';
import updateProject from '../projects/update-project';
export async function handleOptions(
option: string,
client: Client,
org: Org,
project: Project,
settings: ProjectSettings,
repoInfo?: RepoInfo
) {
const { output } = client;
if (option === 'no') {
skip(output);
return;
} else if (option === 'opt-out') {
optOut(client, project, settings);
return;
} else if (option !== '') {
// Option is "yes" or a URL
// Ensure parsed url exists
if (!repoInfo) {
const _repoInfo = parseRepoUrl(option);
if (!_repoInfo) {
output.debug(`Could not parse repo url ${option}.`);
return 1;
}
repoInfo = _repoInfo;
}
return connect(client, org, project, repoInfo);
}
}
async function optOut(
client: Client,
project: Project,
settings: ProjectSettings
) {
settings.skipGitConnectDuringLink = true;
await updateProject(client, project.name, settings);
client.output
.log(`Opted out. You can re-enable this prompt by visiting the Settings > Git page on the
dashboard for this Project.`);
}
function skip(output: Output) {
output.log('Skipping...');
output.log(
`You can connect a Git repository in the future by running ${getCommandName(
'git connect'
)}.`
);
}
async function connect(
client: Client,
org: Org,
project: Project,
repoInfo: RepoInfo
): Promise<number | void> {
const { output } = client;
const { provider, org: parsedOrg, repo } = repoInfo;
const repoPath = `${parsedOrg}/${repo}`;
output.log('Connecting...');
if (project.link) {
await disconnectGitProvider(client, org, project.id);
}
const connect = await connectGitProvider(
client,
org,
project.id,
provider,
repoPath
);
if (connect !== 1) {
output.log(
`Connected ${formatProvider(provider)} repository ${chalk.cyan(
repoPath
)}!`
);
} else {
return connect;
}
}

View File

@@ -28,6 +28,8 @@ import { EmojiLabel } from '../emoji';
import createDeploy from '../deploy/create-deploy';
import Now, { CreateOptions } from '../index';
import { isAPIError } from '../errors-ts';
import { getRemoteUrls } from '../create-git-meta';
import { addGitConnection } from './add-git-connection';
export interface SetupAndLinkOptions {
forceDelete?: boolean;
@@ -128,6 +130,19 @@ export default async function setupAndLink(
} else {
const project = projectOrNewProjectName;
const remoteUrls = await getRemoteUrls(join(path, '.git/config'), output);
if (remoteUrls && !project.skipGitConnectDuringLink) {
const connectGit = await addGitConnection(
client,
org,
project,
remoteUrls
);
if (typeof connectGit === 'number') {
return { status: 'error', exitCode: connectGit };
}
}
await linkFolderToProject(
output,
path,
@@ -241,6 +256,21 @@ export default async function setupAndLink(
}
const project = await createProject(client, newProjectName);
const remoteUrls = await getRemoteUrls(join(path, '.git/config'), output);
if (remoteUrls) {
const connectGit = await addGitConnection(
client,
org,
project,
remoteUrls,
settings
);
if (typeof connectGit === 'number') {
return { status: 'error', exitCode: connectGit };
}
}
await updateProject(client, project.id, settings);
Object.assign(project, settings);

View File

@@ -7,7 +7,7 @@ export default async function createProject(
) {
const project = await client.fetch<Project>('/v1/projects', {
method: 'POST',
body: JSON.stringify({ name: projectName }),
body: { name: projectName },
});
return project;
}

View File

@@ -1,5 +1,5 @@
import Client from '../client';
import { ProjectSettings } from '../../types';
import type { JSONObject, ProjectSettings } from '../../types';
interface ProjectSettingsResponse extends ProjectSettings {
id: string;
@@ -13,11 +13,14 @@ export default async function updateProject(
prjNameOrId: string,
settings: ProjectSettings
) {
// `ProjectSettings` is technically compatible with JSONObject
const body = settings as JSONObject;
const res = await client.fetch<ProjectSettingsResponse>(
`/v2/projects/${encodeURIComponent(prjNameOrId)}`,
{
method: 'PATCH',
body: JSON.stringify(settings),
body,
}
);
return res;

View File

@@ -0,0 +1,2 @@
!.vercel
.vercel

View File

@@ -0,0 +1,16 @@
[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true
ignorecase = true
precomposeunicode = true
[remote "origin"]
url = https://github.com/user/repo.git
fetch = +refs/heads/*:refs/remotes/origin/*
[remote "secondary"]
url = https://github.com/user2/repo2.git
fetch = +refs/heads/*:refs/remotes/secondary/*
[remote "gitlab"]
url = https://gitlab.com/user/repo.git
fetch = +refs/heads/*:refs/remotes/gitlab/*

View File

@@ -0,0 +1,2 @@
!.vercel
.vercel

View File

@@ -0,0 +1,13 @@
[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true
ignorecase = true
precomposeunicode = true
[remote "origin"]
url = https://github.com/user2/repo2.git
fetch = +refs/heads/*:refs/remotes/origin/*
[branch "master"]
remote = origin
merge = refs/heads/master

View File

@@ -0,0 +1,2 @@
!.vercel
.vercel

View File

@@ -0,0 +1,13 @@
[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true
ignorecase = true
precomposeunicode = true
[remote "origin"]
url = https://github.com/user/repo.git
fetch = +refs/heads/*:refs/remotes/origin/*
[branch "master"]
remote = origin
merge = refs/heads/master

View File

@@ -49,6 +49,43 @@ export function useDeployment({
return deployment;
}
export function useDeploymentMissingProjectSettings() {
client.scenario.post('/:version/deployments', (_req, res) => {
res.status(400).json({
error: {
code: 'missing_project_settings',
message:
'The `projectSettings` object is required for new projects, but is missing in the deployment payload',
framework: {
name: 'Other',
slug: null,
logo: 'https://raw.githubusercontent.com/vercel/vercel/main/packages/frameworks/logos/other.svg',
description: 'No framework or an unoptimized framework.',
settings: {
installCommand: {
placeholder: '`yarn install`, `pnpm install`, or `npm install`',
},
buildCommand: {
placeholder: '`npm run vercel-build` or `npm run build`',
value: null,
},
devCommand: { placeholder: 'None', value: null },
outputDirectory: { placeholder: '`public` if it exists, or `.`' },
},
},
projectSettings: {
devCommand: null,
installCommand: null,
buildCommand: null,
outputDirectory: null,
rootDirectory: null,
framework: null,
},
},
});
});
}
beforeEach(() => {
deployments = new Map();
deploymentBuilds = new Map();

View File

@@ -131,6 +131,70 @@ export const defaultProject = {
],
};
/**
* Responds to any GET for a project with a 404.
* `useUnknownProject` should always come after `useProject`, if any,
* to allow `useProject` responses to still happen.
*/
export function useUnknownProject() {
let project: Project;
client.scenario.get(`/v8/projects/:projectNameOrId`, (_req, res) => {
res.status(404).send();
});
client.scenario.post(`/:version/projects`, (req, res) => {
const { name } = req.body;
project = {
...defaultProject,
name,
id: name,
};
res.json(project);
});
client.scenario.post(`/v9/projects/:projectNameOrId/link`, (req, res) => {
const { type, repo, org } = req.body;
const projName = req.params.projectNameOrId;
if (projName !== project.name && projName !== project.id) {
return res.status(404).send('Invalid Project name or ID');
}
if (
(type === 'github' || type === 'gitlab' || type === 'bitbucket') &&
(repo === 'user/repo' || repo === 'user2/repo2')
) {
project.link = {
type,
repo,
repoId: 1010,
org,
gitCredentialId: '',
sourceless: true,
createdAt: 1656109539791,
updatedAt: 1656109539791,
};
res.json(project);
} else {
if (type === 'github') {
res.status(400).json({
message: `To link a GitHub repository, you need to install the GitHub integration first. (400)\nInstall GitHub App: https://github.com/apps/vercel`,
action: 'Install GitHub App',
link: 'https://github.com/apps/vercel',
repo,
});
} else {
res.status(400).json({
code: 'repo_not_found',
message: `The repository "${repo}" couldn't be found in your linked ${formatProvider(
type
)} account.`,
});
}
}
});
client.scenario.patch(`/:version/projects/:projectNameOrId`, (req, res) => {
Object.assign(project, req.body);
res.json(project);
});
}
export function useProject(project: Partial<Project> = defaultProject) {
client.scenario.get(`/v8/projects/${project.name}`, (_req, res) => {
res.json(project);
@@ -138,6 +202,10 @@ export function useProject(project: Partial<Project> = defaultProject) {
client.scenario.get(`/v8/projects/${project.id}`, (_req, res) => {
res.json(project);
});
client.scenario.patch(`/:version/projects/${project.id}`, (req, res) => {
Object.assign(project, req.body);
res.json(project);
});
client.scenario.get(
`/v6/projects/${project.id}/system-env-values`,
(_req, res) => {
@@ -183,7 +251,7 @@ export function useProject(project: Partial<Project> = defaultProject) {
res.json(envs);
}
);
client.scenario.post(`/v4/projects/${project.id}/link`, (req, res) => {
client.scenario.post(`/v9/projects/${project.id}/link`, (req, res) => {
const { type, repo, org } = req.body;
if (
(type === 'github' || type === 'gitlab' || type === 'bitbucket') &&
@@ -218,7 +286,7 @@ export function useProject(project: Partial<Project> = defaultProject) {
}
}
});
client.scenario.delete(`/v4/projects/${project.id}/link`, (_req, res) => {
client.scenario.delete(`/v9/projects/${project.id}/link`, (_req, res) => {
if (project.link) {
project.link = undefined;
}

View File

@@ -39,6 +39,11 @@ describe('git', () => {
await expect(client.stderr).toOutput('Found project');
client.stdin.write('y\n');
await expect(client.stderr).toOutput(
'Do you want to connect "origin" to your Vercel project?'
);
client.stdin.write('n\n');
await expect(client.stderr).toOutput(
`Connecting Git remote: https://github.com/user/repo.git`
);

View File

@@ -0,0 +1,310 @@
import { join } from 'path';
import fs from 'fs-extra';
import link from '../../../src/commands/link';
import { useUser } from '../../mocks/user';
import { useTeams } from '../../mocks/team';
import {
defaultProject,
useUnknownProject,
useProject,
} from '../../mocks/project';
import { client } from '../../mocks/client';
import { useDeploymentMissingProjectSettings } from '../../mocks/deployment';
import { Project } from '../../../src/types';
describe('link', () => {
describe('git prompt', () => {
const originalCwd = process.cwd();
const fixture = (name: string) =>
join(__dirname, '../../fixtures/unit/link-connect-git', name);
it('should prompt to connect a new project with a single remote', async () => {
const cwd = fixture('single-remote');
try {
process.chdir(cwd);
await fs.rename(join(cwd, 'git'), join(cwd, '.git'));
useUser();
useUnknownProject();
useDeploymentMissingProjectSettings();
useTeams('team_dummy');
const linkPromise = link(client);
await expect(client.stderr).toOutput('Set up');
client.stdin.write('y\n');
await expect(client.stderr).toOutput('Which scope');
client.stdin.write('\r');
await expect(client.stderr).toOutput('Link to existing project?');
client.stdin.write('n\n');
await expect(client.stderr).toOutput('Whats your projects name?');
client.stdin.write('\r');
await expect(client.stderr).toOutput(
'In which directory is your code located?'
);
client.stdin.write('\r');
await expect(client.stderr).toOutput('Want to modify these settings?');
client.stdin.write('n\n');
await expect(client.stderr).toOutput(
'Found local Git remote "origin": https://github.com/user/repo.git'
);
await expect(client.stderr).toOutput(
'Do you want to connect "origin" to your Vercel project?'
);
client.stdin.write('\r');
await expect(client.stderr).toOutput(
'Connected GitHub repository user/repo!'
);
await expect(client.stderr).toOutput('Linked to');
await expect(linkPromise).resolves.toEqual(0);
} finally {
await fs.rename(join(cwd, '.git'), join(cwd, 'git'));
process.chdir(originalCwd);
}
});
it('should prompt to connect an existing project with a single remote to git', async () => {
const cwd = fixture('single-remote');
try {
process.chdir(cwd);
await fs.rename(join(cwd, 'git'), join(cwd, '.git'));
useUser();
useProject({
...defaultProject,
name: 'single-remote',
id: 'single-remote',
});
useTeams('team_dummy');
const linkPromise = link(client);
await expect(client.stderr).toOutput('Set up');
client.stdin.write('y\n');
await expect(client.stderr).toOutput('Which scope');
client.stdin.write('\r');
await expect(client.stderr).toOutput('Found project');
client.stdin.write('y\n');
await expect(client.stderr).toOutput(
'Found local Git remote "origin": https://github.com/user/repo.git'
);
await expect(client.stderr).toOutput(
'Do you want to connect "origin" to your Vercel project?'
);
client.stdin.write('\r');
await expect(client.stderr).toOutput(
'Connected GitHub repository user/repo!'
);
await expect(client.stderr).toOutput('Linked to');
await expect(linkPromise).resolves.toEqual(0);
} finally {
await fs.rename(join(cwd, '.git'), join(cwd, 'git'));
process.chdir(originalCwd);
}
});
it('should prompt to replace a connected repository if there is one remote', async () => {
const cwd = fixture('single-remote-existing-link');
try {
process.chdir(cwd);
await fs.rename(join(cwd, 'git'), join(cwd, '.git'));
useUser();
const project = useProject({
...defaultProject,
name: 'single-remote-existing-link',
id: 'single-remote-existing-link',
});
useTeams('team_dummy');
project.project.link = {
type: 'github',
org: 'user',
repo: 'repo',
repoId: 1010,
gitCredentialId: '',
sourceless: true,
createdAt: 1656109539791,
updatedAt: 1656109539791,
};
const linkPromise = link(client);
await expect(client.stderr).toOutput('Set up');
client.stdin.write('y\n');
await expect(client.stderr).toOutput('Which scope');
client.stdin.write('\r');
await expect(client.stderr).toOutput('Found project');
client.stdin.write('y\n');
await expect(client.stderr).toOutput(
`Found Git remote URL https://github.com/user2/repo2.git, which is different from the connected GitHub repository user/repo.`
);
await expect(client.stderr).toOutput('Do you want to replace it?');
client.stdin.write('\r');
await expect(client.stderr).toOutput(
'Connected GitHub repository user2/repo2!'
);
await expect(client.stderr).toOutput('Linked to');
await expect(linkPromise).resolves.toEqual(0);
} finally {
await fs.rename(join(cwd, '.git'), join(cwd, 'git'));
process.chdir(originalCwd);
}
});
it('should prompt to connect an existing project with multiple remotes', async () => {
const cwd = fixture('multiple-remotes');
try {
process.chdir(cwd);
await fs.rename(join(cwd, 'git'), join(cwd, '.git'));
useUser();
useProject({
...defaultProject,
name: 'multiple-remotes',
id: 'multiple-remotes',
});
useTeams('team_dummy');
const linkPromise = link(client);
await expect(client.stderr).toOutput('Set up');
client.stdin.write('y\n');
await expect(client.stderr).toOutput('Which scope');
client.stdin.write('\r');
await expect(client.stderr).toOutput('Found project');
client.stdin.write('y\n');
await expect(client.stderr).toOutput(
`> Do you want to connect a Git repository to your Vercel project?`
);
client.stdin.write('\r');
await expect(client.stderr).toOutput(
'Connected GitHub repository user/repo!'
);
await expect(client.stderr).toOutput('Linked to');
await expect(linkPromise).resolves.toEqual(0);
} finally {
await fs.rename(join(cwd, '.git'), join(cwd, 'git'));
process.chdir(originalCwd);
}
});
it('should not prompt to replace a connected repository if there is more than one remote', async () => {
const cwd = fixture('multiple-remotes');
try {
process.chdir(cwd);
await fs.rename(join(cwd, 'git'), join(cwd, '.git'));
useUser();
const project = useProject({
...defaultProject,
name: 'multiple-remotes',
id: 'multiple-remotes',
});
useTeams('team_dummy');
project.project.link = {
type: 'github',
org: 'user',
repo: 'repo',
repoId: 1010,
gitCredentialId: '',
sourceless: true,
createdAt: 1656109539791,
updatedAt: 1656109539791,
};
const linkPromise = link(client);
await expect(client.stderr).toOutput('Set up');
client.stdin.write('y\n');
await expect(client.stderr).toOutput('Which scope');
client.stdin.write('\r');
await expect(client.stderr).toOutput('Found project');
client.stdin.write('y\n');
expect(client.stderr).not.toOutput('Found multiple Git remote URLs');
await expect(client.stderr).toOutput('Linked to');
await expect(linkPromise).resolves.toEqual(0);
} finally {
await fs.rename(join(cwd, '.git'), join(cwd, 'git'));
process.chdir(originalCwd);
}
});
it('should set a project setting if user opts out', async () => {
const cwd = fixture('single-remote');
try {
process.chdir(cwd);
await fs.rename(join(cwd, 'git'), join(cwd, '.git'));
useUser();
useProject({
...defaultProject,
name: 'single-remote',
id: 'single-remote',
});
useTeams('team_dummy');
const linkPromise = link(client);
await expect(client.stderr).toOutput('Set up');
client.stdin.write('y\n');
await expect(client.stderr).toOutput('Which scope');
client.stdin.write('\r');
await expect(client.stderr).toOutput('Found project');
client.stdin.write('y\n');
await expect(client.stderr).toOutput(
'Found local Git remote "origin": https://github.com/user/repo.git'
);
await expect(client.stderr).toOutput(
'Do you want to connect "origin" to your Vercel project?'
);
client.stdin.write('\x1B[B'); // Down arrow
client.stdin.write('\x1B[B');
client.stdin.write('\r'); // Opt out
await expect(client.stderr).toOutput(`Opted out.`);
await expect(client.stderr).toOutput('Linked to');
await expect(linkPromise).resolves.toEqual(0);
const newProjectData: Project = await client.fetch(
`/v8/projects/single-remote`
);
expect(newProjectData.skipGitConnectDuringLink).toBeTruthy();
} finally {
await fs.rename(join(cwd, '.git'), join(cwd, 'git'));
process.chdir(originalCwd);
}
});
it('should not prompt to connect git if the project has skipGitConnectDuringLink property', async () => {
const cwd = fixture('single-remote');
try {
process.chdir(cwd);
await fs.rename(join(cwd, 'git'), join(cwd, '.git'));
useUser();
const project = useProject({
...defaultProject,
name: 'single-remote',
id: 'single-remote',
});
useTeams('team_dummy');
project.project.skipGitConnectDuringLink = true;
const linkPromise = link(client);
await expect(client.stderr).toOutput('Set up');
client.stdin.write('y\n');
await expect(client.stderr).toOutput('Which scope');
client.stdin.write('\r');
await expect(client.stderr).toOutput('Found project');
client.stdin.write('y\n');
expect(client.stderr).not.toOutput('Found local Git remote "origin"');
await expect(client.stderr).toOutput('Linked to');
await expect(linkPromise).resolves.toEqual(0);
} finally {
await fs.rename(join(cwd, '.git'), join(cwd, 'git'));
process.chdir(originalCwd);
}
});
});
});

View File

@@ -58,92 +58,92 @@ describe('getRemoteUrls', () => {
describe('parseRepoUrl', () => {
it('should be null when a url does not match the regex', () => {
const parsedUrl = parseRepoUrl('https://examplecom/foo');
expect(parsedUrl).toBeNull();
const repoInfo = parseRepoUrl('https://examplecom/foo');
expect(repoInfo).toBeNull();
});
it('should be null when a url does not contain org and repo data', () => {
const parsedUrl = parseRepoUrl('https://github.com/borked');
expect(parsedUrl).toBeNull();
const repoInfo = parseRepoUrl('https://github.com/borked');
expect(repoInfo).toBeNull();
});
it('should parse url with a period in the repo name', () => {
const parsedUrl = parseRepoUrl('https://github.com/vercel/next.js');
expect(parsedUrl).toBeDefined();
expect(parsedUrl?.provider).toEqual('github');
expect(parsedUrl?.org).toEqual('vercel');
expect(parsedUrl?.repo).toEqual('next.js');
const repoInfo = parseRepoUrl('https://github.com/vercel/next.js');
expect(repoInfo).toBeDefined();
expect(repoInfo?.provider).toEqual('github');
expect(repoInfo?.org).toEqual('vercel');
expect(repoInfo?.repo).toEqual('next.js');
});
it('should parse url that ends with .git', () => {
const parsedUrl = parseRepoUrl('https://github.com/vercel/next.js.git');
expect(parsedUrl).toBeDefined();
expect(parsedUrl?.provider).toEqual('github');
expect(parsedUrl?.org).toEqual('vercel');
expect(parsedUrl?.repo).toEqual('next.js');
const repoInfo = parseRepoUrl('https://github.com/vercel/next.js.git');
expect(repoInfo).toBeDefined();
expect(repoInfo?.provider).toEqual('github');
expect(repoInfo?.org).toEqual('vercel');
expect(repoInfo?.repo).toEqual('next.js');
});
it('should parse github https url', () => {
const parsedUrl = parseRepoUrl('https://github.com/vercel/vercel.git');
expect(parsedUrl).toBeDefined();
expect(parsedUrl?.provider).toEqual('github');
expect(parsedUrl?.org).toEqual('vercel');
expect(parsedUrl?.repo).toEqual('vercel');
const repoInfo = parseRepoUrl('https://github.com/vercel/vercel.git');
expect(repoInfo).toBeDefined();
expect(repoInfo?.provider).toEqual('github');
expect(repoInfo?.org).toEqual('vercel');
expect(repoInfo?.repo).toEqual('vercel');
});
it('should parse github https url without the .git suffix', () => {
const parsedUrl = parseRepoUrl('https://github.com/vercel/vercel');
expect(parsedUrl).toBeDefined();
expect(parsedUrl?.provider).toEqual('github');
expect(parsedUrl?.org).toEqual('vercel');
expect(parsedUrl?.repo).toEqual('vercel');
const repoInfo = parseRepoUrl('https://github.com/vercel/vercel');
expect(repoInfo).toBeDefined();
expect(repoInfo?.provider).toEqual('github');
expect(repoInfo?.org).toEqual('vercel');
expect(repoInfo?.repo).toEqual('vercel');
});
it('should parse github git url', () => {
const parsedUrl = parseRepoUrl('git://github.com/vercel/vercel.git');
expect(parsedUrl).toBeDefined();
expect(parsedUrl?.provider).toEqual('github');
expect(parsedUrl?.org).toEqual('vercel');
expect(parsedUrl?.repo).toEqual('vercel');
const repoInfo = parseRepoUrl('git://github.com/vercel/vercel.git');
expect(repoInfo).toBeDefined();
expect(repoInfo?.provider).toEqual('github');
expect(repoInfo?.org).toEqual('vercel');
expect(repoInfo?.repo).toEqual('vercel');
});
it('should parse github ssh url', () => {
const parsedUrl = parseRepoUrl('git@github.com:vercel/vercel.git');
expect(parsedUrl).toBeDefined();
expect(parsedUrl?.provider).toEqual('github');
expect(parsedUrl?.org).toEqual('vercel');
expect(parsedUrl?.repo).toEqual('vercel');
const repoInfo = parseRepoUrl('git@github.com:vercel/vercel.git');
expect(repoInfo).toBeDefined();
expect(repoInfo?.provider).toEqual('github');
expect(repoInfo?.org).toEqual('vercel');
expect(repoInfo?.repo).toEqual('vercel');
});
it('should parse gitlab https url', () => {
const parsedUrl = parseRepoUrl(
const repoInfo = parseRepoUrl(
'https://gitlab.com/gitlab-examples/knative-kotlin-app.git'
);
expect(parsedUrl).toBeDefined();
expect(parsedUrl?.provider).toEqual('gitlab');
expect(parsedUrl?.org).toEqual('gitlab-examples');
expect(parsedUrl?.repo).toEqual('knative-kotlin-app');
expect(repoInfo).toBeDefined();
expect(repoInfo?.provider).toEqual('gitlab');
expect(repoInfo?.org).toEqual('gitlab-examples');
expect(repoInfo?.repo).toEqual('knative-kotlin-app');
});
it('should parse gitlab ssh url', () => {
const parsedUrl = parseRepoUrl(
const repoInfo = parseRepoUrl(
'git@gitlab.com:gitlab-examples/knative-kotlin-app.git'
);
expect(parsedUrl).toBeDefined();
expect(parsedUrl?.provider).toEqual('gitlab');
expect(parsedUrl?.org).toEqual('gitlab-examples');
expect(parsedUrl?.repo).toEqual('knative-kotlin-app');
expect(repoInfo).toBeDefined();
expect(repoInfo?.provider).toEqual('gitlab');
expect(repoInfo?.org).toEqual('gitlab-examples');
expect(repoInfo?.repo).toEqual('knative-kotlin-app');
});
it('should parse bitbucket https url', () => {
const parsedUrl = parseRepoUrl(
const repoInfo = parseRepoUrl(
'https://bitbucket.org/atlassianlabs/maven-project-example.git'
);
expect(parsedUrl).toBeDefined();
expect(parsedUrl?.provider).toEqual('bitbucket');
expect(parsedUrl?.org).toEqual('atlassianlabs');
expect(parsedUrl?.repo).toEqual('maven-project-example');
expect(repoInfo).toBeDefined();
expect(repoInfo?.provider).toEqual('bitbucket');
expect(repoInfo?.org).toEqual('atlassianlabs');
expect(repoInfo?.repo).toEqual('maven-project-example');
});
it('should parse bitbucket ssh url', () => {
const parsedUrl = parseRepoUrl(
const repoInfo = parseRepoUrl(
'git@bitbucket.org:atlassianlabs/maven-project-example.git'
);
expect(parsedUrl).toBeDefined();
expect(parsedUrl?.provider).toEqual('bitbucket');
expect(parsedUrl?.org).toEqual('atlassianlabs');
expect(parsedUrl?.repo).toEqual('maven-project-example');
expect(repoInfo).toBeDefined();
expect(repoInfo?.provider).toEqual('bitbucket');
expect(repoInfo?.org).toEqual('atlassianlabs');
expect(repoInfo?.repo).toEqual('maven-project-example');
});
it('should parse url without a scheme', () => {
const parsedUrl = parseRepoUrl('github.com/user/repo');