mirror of
https://github.com/Microsoft/vscode
synced 2024-09-13 21:55:38 +00:00
GitHub - basic handling of push errors due to GHAS push protection (#185344)
This commit is contained in:
parent
3b2671fe9b
commit
4a395962a6
|
@ -4,7 +4,7 @@
|
|||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { TextDecoder } from 'util';
|
||||
import { commands, env, ProgressLocation, Uri, window, workspace, QuickPickOptions, FileType, l10n } from 'vscode';
|
||||
import { commands, env, ProgressLocation, Uri, window, workspace, QuickPickOptions, FileType, l10n, Disposable, TextDocumentContentProvider } from 'vscode';
|
||||
import { getOctokit } from './auth';
|
||||
import { GitErrorCodes, PushErrorHandler, Remote, Repository } from './typings/git';
|
||||
import * as path from 'path';
|
||||
|
@ -15,136 +15,6 @@ export function isInCodespaces(): boolean {
|
|||
return env.remoteName === 'codespaces';
|
||||
}
|
||||
|
||||
async function handlePushError(repository: Repository, remote: Remote, refspec: string, owner: string, repo: string): Promise<void> {
|
||||
const yes = l10n.t('Create Fork');
|
||||
const no = l10n.t('No');
|
||||
const askFork = l10n.t('You don\'t have permissions to push to "{0}/{1}" on GitHub. Would you like to create a fork and push to it instead?', owner, repo);
|
||||
|
||||
const answer = await window.showWarningMessage(askFork, { modal: true }, yes, no);
|
||||
if (answer !== yes) {
|
||||
return;
|
||||
}
|
||||
|
||||
const match = /^([^:]*):([^:]*)$/.exec(refspec);
|
||||
const localName = match ? match[1] : refspec;
|
||||
let remoteName = match ? match[2] : refspec;
|
||||
|
||||
const [octokit, ghRepository] = await window.withProgress({ location: ProgressLocation.Notification, cancellable: false, title: l10n.t('Create GitHub fork') }, async progress => {
|
||||
progress.report({ message: l10n.t('Forking "{0}/{1}"...', owner, repo), increment: 33 });
|
||||
|
||||
const octokit = await getOctokit();
|
||||
|
||||
type CreateForkResponseData = Awaited<ReturnType<typeof octokit.repos.createFork>>['data'];
|
||||
|
||||
// Issue: what if the repo already exists?
|
||||
let ghRepository: CreateForkResponseData;
|
||||
try {
|
||||
if (isInCodespaces()) {
|
||||
// Call into the codespaces extension to fork the repository
|
||||
const resp = await commands.executeCommand<{ repository: CreateForkResponseData; ref: string }>('github.codespaces.forkRepository');
|
||||
if (!resp) {
|
||||
throw new Error('Unable to fork respository');
|
||||
}
|
||||
|
||||
ghRepository = resp.repository;
|
||||
|
||||
if (resp.ref) {
|
||||
let ref = resp.ref;
|
||||
if (ref.startsWith('refs/heads/')) {
|
||||
ref = ref.substr(11);
|
||||
}
|
||||
|
||||
remoteName = ref;
|
||||
}
|
||||
} else {
|
||||
const resp = await octokit.repos.createFork({ owner, repo });
|
||||
ghRepository = resp.data;
|
||||
}
|
||||
} catch (ex) {
|
||||
console.error(ex);
|
||||
throw ex;
|
||||
}
|
||||
|
||||
progress.report({ message: l10n.t('Pushing changes...'), increment: 33 });
|
||||
|
||||
// Issue: what if there's already an `upstream` repo?
|
||||
await repository.renameRemote(remote.name, 'upstream');
|
||||
|
||||
// Issue: what if there's already another `origin` repo?
|
||||
const protocol = workspace.getConfiguration('github').get<'https' | 'ssh'>('gitProtocol');
|
||||
const remoteUrl = protocol === 'https' ? ghRepository.clone_url : ghRepository.ssh_url;
|
||||
await repository.addRemote('origin', remoteUrl);
|
||||
|
||||
try {
|
||||
await repository.fetch('origin', remoteName);
|
||||
await repository.setBranchUpstream(localName, `origin/${remoteName}`);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
|
||||
await repository.push('origin', localName, true);
|
||||
|
||||
return [octokit, ghRepository] as const;
|
||||
});
|
||||
|
||||
// yield
|
||||
(async () => {
|
||||
const openOnGitHub = l10n.t('Open on GitHub');
|
||||
const createPR = l10n.t('Create PR');
|
||||
const action = await window.showInformationMessage(l10n.t('The fork "{0}" was successfully created on GitHub.', ghRepository.full_name), openOnGitHub, createPR);
|
||||
|
||||
if (action === openOnGitHub) {
|
||||
await commands.executeCommand('vscode.open', Uri.parse(ghRepository.html_url));
|
||||
} else if (action === createPR) {
|
||||
const pr = await window.withProgress({ location: ProgressLocation.Notification, cancellable: false, title: l10n.t('Creating GitHub Pull Request...') }, async _ => {
|
||||
let title = `Update ${remoteName}`;
|
||||
const head = repository.state.HEAD?.name;
|
||||
|
||||
let body: string | undefined;
|
||||
|
||||
if (head) {
|
||||
const commit = await repository.getCommit(head);
|
||||
title = commit.message.split('\n')[0];
|
||||
body = commit.message.slice(title.length + 1).trim();
|
||||
}
|
||||
|
||||
const templates = await findPullRequestTemplates(repository.rootUri);
|
||||
if (templates.length > 0) {
|
||||
templates.sort((a, b) => a.path.localeCompare(b.path));
|
||||
|
||||
const template = await pickPullRequestTemplate(repository.rootUri, templates);
|
||||
|
||||
if (template) {
|
||||
body = new TextDecoder('utf-8').decode(await workspace.fs.readFile(template));
|
||||
}
|
||||
}
|
||||
|
||||
const { data: pr } = await octokit.pulls.create({
|
||||
owner,
|
||||
repo,
|
||||
title,
|
||||
body,
|
||||
head: `${ghRepository.owner.login}:${remoteName}`,
|
||||
base: ghRepository.default_branch
|
||||
});
|
||||
|
||||
await repository.setConfig(`branch.${localName}.remote`, 'upstream');
|
||||
await repository.setConfig(`branch.${localName}.merge`, `refs/heads/${remoteName}`);
|
||||
await repository.setConfig(`branch.${localName}.github-pr-owner-number`, `${owner}#${repo}#${pr.number}`);
|
||||
|
||||
return pr;
|
||||
});
|
||||
|
||||
const openPR = l10n.t('Open PR');
|
||||
const action = await window.showInformationMessage(l10n.t('The PR "{0}/{1}#{2}" was successfully created on GitHub.', owner, repo, pr.number), openPR);
|
||||
|
||||
if (action === openPR) {
|
||||
await commands.executeCommand('vscode.open', Uri.parse(pr.html_url));
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
const PR_TEMPLATE_FILES = [
|
||||
{ dir: '.', files: ['pull_request_template.md', 'PULL_REQUEST_TEMPLATE.md'] },
|
||||
{ dir: 'docs', files: ['pull_request_template.md', 'PULL_REQUEST_TEMPLATE.md'] },
|
||||
|
@ -207,10 +77,34 @@ export async function pickPullRequestTemplate(repositoryRootUri: Uri, templates:
|
|||
return pickedTemplate?.template;
|
||||
}
|
||||
|
||||
class CommandErrorOutputTextDocumentContentProvider implements TextDocumentContentProvider {
|
||||
|
||||
private items = new Map<string, string>();
|
||||
|
||||
set(uri: Uri, contents: string): void {
|
||||
this.items.set(uri.path, contents);
|
||||
}
|
||||
|
||||
delete(uri: Uri): void {
|
||||
this.items.delete(uri.path);
|
||||
}
|
||||
|
||||
provideTextDocumentContent(uri: Uri): string | undefined {
|
||||
return this.items.get(uri.path);
|
||||
}
|
||||
}
|
||||
|
||||
export class GithubPushErrorHandler implements PushErrorHandler {
|
||||
|
||||
async handlePushError(repository: Repository, remote: Remote, refspec: string, error: Error & { gitErrorCode: GitErrorCodes }): Promise<boolean> {
|
||||
if (error.gitErrorCode !== GitErrorCodes.PermissionDenied) {
|
||||
private disposables: Disposable[] = [];
|
||||
private commandErrors = new CommandErrorOutputTextDocumentContentProvider();
|
||||
|
||||
constructor() {
|
||||
this.disposables.push(workspace.registerTextDocumentContentProvider('github-output', this.commandErrors));
|
||||
}
|
||||
|
||||
async handlePushError(repository: Repository, remote: Remote, refspec: string, error: Error & { stderr: string; gitErrorCode: GitErrorCodes }): Promise<boolean> {
|
||||
if (error.gitErrorCode !== GitErrorCodes.PermissionDenied && error.gitErrorCode !== GitErrorCodes.PushRejected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -229,8 +123,175 @@ export class GithubPushErrorHandler implements PushErrorHandler {
|
|||
}
|
||||
|
||||
const [, owner, repo] = match;
|
||||
await handlePushError(repository, remote, refspec, owner, repo);
|
||||
|
||||
return true;
|
||||
if (error.gitErrorCode === GitErrorCodes.PermissionDenied) {
|
||||
await this.handlePermissionDeniedError(repository, remote, refspec, owner, repo);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Push protection
|
||||
if (/GH009: Secrets detected!/i.test(error.stderr)) {
|
||||
await this.handlePushProtectionError(owner, repo, error.stderr);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async handlePermissionDeniedError(repository: Repository, remote: Remote, refspec: string, owner: string, repo: string): Promise<void> {
|
||||
const yes = l10n.t('Create Fork');
|
||||
const no = l10n.t('No');
|
||||
const askFork = l10n.t('You don\'t have permissions to push to "{0}/{1}" on GitHub. Would you like to create a fork and push to it instead?', owner, repo);
|
||||
|
||||
const answer = await window.showWarningMessage(askFork, { modal: true }, yes, no);
|
||||
if (answer !== yes) {
|
||||
return;
|
||||
}
|
||||
|
||||
const match = /^([^:]*):([^:]*)$/.exec(refspec);
|
||||
const localName = match ? match[1] : refspec;
|
||||
let remoteName = match ? match[2] : refspec;
|
||||
|
||||
const [octokit, ghRepository] = await window.withProgress({ location: ProgressLocation.Notification, cancellable: false, title: l10n.t('Create GitHub fork') }, async progress => {
|
||||
progress.report({ message: l10n.t('Forking "{0}/{1}"...', owner, repo), increment: 33 });
|
||||
|
||||
const octokit = await getOctokit();
|
||||
|
||||
type CreateForkResponseData = Awaited<ReturnType<typeof octokit.repos.createFork>>['data'];
|
||||
|
||||
// Issue: what if the repo already exists?
|
||||
let ghRepository: CreateForkResponseData;
|
||||
try {
|
||||
if (isInCodespaces()) {
|
||||
// Call into the codespaces extension to fork the repository
|
||||
const resp = await commands.executeCommand<{ repository: CreateForkResponseData; ref: string }>('github.codespaces.forkRepository');
|
||||
if (!resp) {
|
||||
throw new Error('Unable to fork respository');
|
||||
}
|
||||
|
||||
ghRepository = resp.repository;
|
||||
|
||||
if (resp.ref) {
|
||||
let ref = resp.ref;
|
||||
if (ref.startsWith('refs/heads/')) {
|
||||
ref = ref.substr(11);
|
||||
}
|
||||
|
||||
remoteName = ref;
|
||||
}
|
||||
} else {
|
||||
const resp = await octokit.repos.createFork({ owner, repo });
|
||||
ghRepository = resp.data;
|
||||
}
|
||||
} catch (ex) {
|
||||
console.error(ex);
|
||||
throw ex;
|
||||
}
|
||||
|
||||
progress.report({ message: l10n.t('Pushing changes...'), increment: 33 });
|
||||
|
||||
// Issue: what if there's already an `upstream` repo?
|
||||
await repository.renameRemote(remote.name, 'upstream');
|
||||
|
||||
// Issue: what if there's already another `origin` repo?
|
||||
const protocol = workspace.getConfiguration('github').get<'https' | 'ssh'>('gitProtocol');
|
||||
const remoteUrl = protocol === 'https' ? ghRepository.clone_url : ghRepository.ssh_url;
|
||||
await repository.addRemote('origin', remoteUrl);
|
||||
|
||||
try {
|
||||
await repository.fetch('origin', remoteName);
|
||||
await repository.setBranchUpstream(localName, `origin/${remoteName}`);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
|
||||
await repository.push('origin', localName, true);
|
||||
|
||||
return [octokit, ghRepository] as const;
|
||||
});
|
||||
|
||||
// yield
|
||||
(async () => {
|
||||
const openOnGitHub = l10n.t('Open on GitHub');
|
||||
const createPR = l10n.t('Create PR');
|
||||
const action = await window.showInformationMessage(l10n.t('The fork "{0}" was successfully created on GitHub.', ghRepository.full_name), openOnGitHub, createPR);
|
||||
|
||||
if (action === openOnGitHub) {
|
||||
await commands.executeCommand('vscode.open', Uri.parse(ghRepository.html_url));
|
||||
} else if (action === createPR) {
|
||||
const pr = await window.withProgress({ location: ProgressLocation.Notification, cancellable: false, title: l10n.t('Creating GitHub Pull Request...') }, async _ => {
|
||||
let title = `Update ${remoteName}`;
|
||||
const head = repository.state.HEAD?.name;
|
||||
|
||||
let body: string | undefined;
|
||||
|
||||
if (head) {
|
||||
const commit = await repository.getCommit(head);
|
||||
title = commit.message.split('\n')[0];
|
||||
body = commit.message.slice(title.length + 1).trim();
|
||||
}
|
||||
|
||||
const templates = await findPullRequestTemplates(repository.rootUri);
|
||||
if (templates.length > 0) {
|
||||
templates.sort((a, b) => a.path.localeCompare(b.path));
|
||||
|
||||
const template = await pickPullRequestTemplate(repository.rootUri, templates);
|
||||
|
||||
if (template) {
|
||||
body = new TextDecoder('utf-8').decode(await workspace.fs.readFile(template));
|
||||
}
|
||||
}
|
||||
|
||||
const { data: pr } = await octokit.pulls.create({
|
||||
owner,
|
||||
repo,
|
||||
title,
|
||||
body,
|
||||
head: `${ghRepository.owner.login}:${remoteName}`,
|
||||
base: ghRepository.default_branch
|
||||
});
|
||||
|
||||
await repository.setConfig(`branch.${localName}.remote`, 'upstream');
|
||||
await repository.setConfig(`branch.${localName}.merge`, `refs/heads/${remoteName}`);
|
||||
await repository.setConfig(`branch.${localName}.github-pr-owner-number`, `${owner}#${repo}#${pr.number}`);
|
||||
|
||||
return pr;
|
||||
});
|
||||
|
||||
const openPR = l10n.t('Open PR');
|
||||
const action = await window.showInformationMessage(l10n.t('The PR "{0}/{1}#{2}" was successfully created on GitHub.', owner, repo, pr.number), openPR);
|
||||
|
||||
if (action === openPR) {
|
||||
await commands.executeCommand('vscode.open', Uri.parse(pr.html_url));
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
private async handlePushProtectionError(owner: string, repo: string, stderr: string): Promise<void> {
|
||||
// Open command output in an editor
|
||||
const timestamp = new Date().getTime();
|
||||
const uri = Uri.parse(`github-output:/github-error-${timestamp}`);
|
||||
this.commandErrors.set(uri, stderr);
|
||||
|
||||
try {
|
||||
const doc = await workspace.openTextDocument(uri);
|
||||
await window.showTextDocument(doc);
|
||||
}
|
||||
finally {
|
||||
this.commandErrors.set(uri, stderr);
|
||||
}
|
||||
|
||||
// Show dialog
|
||||
const learnMore = l10n.t('Learn More');
|
||||
const message = l10n.t('Your push to "{0}/{1}" was rejected by GitHub because push protection is enabled and one or more secrets were detected.', owner, repo);
|
||||
const answer = await window.showWarningMessage(message, { modal: true }, learnMore);
|
||||
if (answer === learnMore) {
|
||||
commands.executeCommand('vscode.open', 'https://aka.ms/vscode-github-push-protection');
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.disposables.forEach(d => d.dispose());
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue