cli/azd/pkg/pipeline/github_provider.go (685 lines of code) (raw):
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
package pipeline
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"net/url"
"path/filepath"
"regexp"
"slices"
"strings"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
"github.com/azure/azure-dev/cli/azd/pkg/account"
"github.com/azure/azure-dev/cli/azd/pkg/entraid"
"github.com/azure/azure-dev/cli/azd/pkg/environment"
githubRemote "github.com/azure/azure-dev/cli/azd/pkg/github"
"github.com/azure/azure-dev/cli/azd/pkg/graphsdk"
"github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning"
"github.com/azure/azure-dev/cli/azd/pkg/input"
"github.com/azure/azure-dev/cli/azd/pkg/output"
"github.com/azure/azure-dev/cli/azd/pkg/output/ux"
"github.com/azure/azure-dev/cli/azd/pkg/tools"
"github.com/azure/azure-dev/cli/azd/pkg/tools/git"
"github.com/azure/azure-dev/cli/azd/pkg/tools/github"
)
// GitHubScmProvider implements ScmProvider using GitHub as the provider
// for source control manager.
type GitHubScmProvider struct {
newGitHubRepoCreated bool
console input.Console
ghCli *github.Cli
gitCli *git.Cli
}
func NewGitHubScmProvider(
console input.Console,
ghCli *github.Cli,
gitCli *git.Cli,
) ScmProvider {
return &GitHubScmProvider{
console: console,
ghCli: ghCli,
gitCli: gitCli,
}
}
// *** subareaProvider implementation ******
// requiredTools return the list of external tools required by
// GitHub provider during its execution.
func (p *GitHubScmProvider) requiredTools(ctx context.Context) ([]tools.ExternalTool, error) {
return []tools.ExternalTool{p.ghCli}, nil
}
// preConfigureCheck check the current state of external tools and any
// other dependency to be as expected for execution.
func (p *GitHubScmProvider) preConfigureCheck(
ctx context.Context,
pipelineManagerArgs PipelineManagerArgs,
infraOptions provisioning.Options,
projectPath string,
) (bool, error) {
return ensureGitHubLogin(ctx, projectPath, p.ghCli, p.gitCli, github.GitHubHostName, p.console)
}
// name returns the name of the provider
func (p *GitHubScmProvider) Name() string {
return gitHubDisplayName
}
// *** scmProvider implementation ******
// configureGitRemote uses GitHub cli to guide user on setting a remote url
// for the local git project
func (p *GitHubScmProvider) configureGitRemote(
ctx context.Context,
repoPath string,
remoteName string,
) (string, error) {
// used to detect when the GitHub has created a new repo
p.newGitHubRepoCreated = false
// There are a few ways to configure the remote so offer a choice to the user.
idx, err := p.console.Select(ctx, input.ConsoleOptions{
Message: "How would you like to configure your git remote to GitHub?",
Options: []string{
"Select an existing GitHub project",
"Create a new private GitHub repository",
"Enter a remote URL directly",
},
DefaultValue: "Create a new private GitHub repository",
})
if err != nil {
return "", fmt.Errorf("prompting for remote configuration type: %w", err)
}
var remoteUrl string
switch idx {
// Select from an existing GitHub project
case 0:
remoteUrl, err = getRemoteUrlFromExisting(ctx, p.ghCli, p.console)
if err != nil {
return "", fmt.Errorf("getting remote from existing repository: %w", err)
}
// Create a new project
case 1:
remoteUrl, err = getRemoteUrlFromNewRepository(ctx, p.ghCli, repoPath, p.console)
if err != nil {
return "", fmt.Errorf("getting remote from new repository: %w", err)
}
p.newGitHubRepoCreated = true
// Enter a URL directly.
case 2:
remoteUrl, err = getRemoteUrlFromPrompt(ctx, remoteName, p.console)
if err != nil {
return "", fmt.Errorf("getting remote from prompt: %w", err)
}
default:
panic(fmt.Sprintf("unexpected selection index %d", idx))
}
return remoteUrl, nil
}
// defines the structure of an ssl git remote
var gitHubRemoteGitUrlRegex = regexp.MustCompile(`^git@[a-zA-Z0-9.-_]+:(.*?)(?:\.git)?$`)
// defines the structure of an HTTPS git remote
var gitHubRemoteHttpsUrlRegex = regexp.MustCompile(`^https://(?:www\.)?[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/(.*?)(?:\.git)?$`)
// ErrRemoteHostIsNotGitHub the error used when a non GitHub remote is found
var ErrRemoteHostIsNotGitHub = errors.New("not a github host")
// gitRepoDetails extracts the information from a GitHub remote url into general scm concepts
// like owner, name and path
func (p *GitHubScmProvider) gitRepoDetails(ctx context.Context, remoteUrl string) (*gitRepositoryDetails, error) {
slug := ""
for _, r := range []*regexp.Regexp{gitHubRemoteGitUrlRegex, gitHubRemoteHttpsUrlRegex} {
captures := r.FindStringSubmatch(remoteUrl)
if captures != nil {
slug = captures[1]
}
}
if slug == "" {
return nil, ErrRemoteHostIsNotGitHub
}
slugParts := strings.Split(slug, "/")
repoDetails := &gitRepositoryDetails{
owner: slugParts[0],
repoName: slugParts[1],
remote: remoteUrl,
}
repoDetails.url = fmt.Sprintf(
"https://github.com/%s/%s",
repoDetails.owner,
repoDetails.repoName)
return repoDetails, nil
}
// preventGitPush validate if GitHub actions are disabled and won't work before pushing
// changes to upstream.
func (p *GitHubScmProvider) preventGitPush(
ctx context.Context,
gitRepo *gitRepositoryDetails,
remoteName string,
branchName string) (bool, error) {
// Don't need to check for preventing push on new created repos
// Only check when using an existing repo in case github actions are disabled
if !p.newGitHubRepoCreated {
slug := gitRepo.owner + "/" + gitRepo.repoName
return p.notifyWhenGitHubActionsAreDisabled(ctx, gitRepo.gitProjectPath, slug)
}
return false, nil
}
func (p *GitHubScmProvider) GitPush(
ctx context.Context,
gitRepo *gitRepositoryDetails,
remoteName string,
branchName string) error {
return p.gitCli.PushUpstream(ctx, gitRepo.gitProjectPath, remoteName, branchName)
}
// enum type for taking a choice after finding GitHub actions disabled.
type gitHubActionsEnablingChoice int
// defines the options upon detecting GitHub actions disabled.
const (
manualChoice gitHubActionsEnablingChoice = iota
cancelChoice
)
// enables gitHubActionsEnablingChoice to produce a string value.
func (selection gitHubActionsEnablingChoice) String() string {
switch selection {
case manualChoice:
return "I have manually enabled GitHub Actions. Continue with pushing my changes."
case cancelChoice:
return "Exit without pushing my changes. I don't need to run GitHub actions right now."
}
panic("Tried to convert invalid input gitHubActionsEnablingChoice to string")
}
// notifyWhenGitHubActionsAreDisabled uses GitHub cli to check if actions are disabled
// or if at least one workflow is not listed. Returns true after interacting with user
// and if user decides to stop a current petition to push changes to upstream.
func (p *GitHubScmProvider) notifyWhenGitHubActionsAreDisabled(
ctx context.Context,
gitProjectPath,
repoSlug string,
) (bool, error) {
ghActionsInUpstreamRepo, err := p.ghCli.GitHubActionsExists(ctx, repoSlug)
if err != nil {
return false, err
}
if ghActionsInUpstreamRepo {
// upstream is already listing GitHub actions.
// There's no need to check if there are local workflows
return false, nil
}
// Upstream has no GitHub actions listed.
// See if there's at least one workflow file within .github/workflows
ghLocalWorkflowFiles := false
defaultGitHubWorkflowPathLocation := filepath.Join(
gitProjectPath,
".github",
"workflows")
err = filepath.WalkDir(defaultGitHubWorkflowPathLocation,
func(directoryName string, file fs.DirEntry, e error) error {
if e != nil {
return e
}
fileName := file.Name()
fileExtension := filepath.Ext(fileName)
if fileExtension == ".yml" || fileExtension == ".yaml" {
// ** workflow file found.
// Now check if this file is already tracked by git.
// If the file is not tracked, it means this is a new file (never pushed to mainstream)
// A git untracked file should not be considered as GitHub workflow until it is pushed.
newFile, err := p.gitCli.IsUntrackedFile(ctx, gitProjectPath, directoryName)
if err != nil {
return fmt.Errorf("checking workflow file %w", err)
}
if !newFile {
ghLocalWorkflowFiles = true
}
}
return nil
})
if err != nil {
return false, fmt.Errorf("Getting GitHub local workflow files %w", err)
}
if ghLocalWorkflowFiles {
message := fmt.Sprintf("\n%s\n"+
" - If you forked and cloned a template, enable actions here: %s.\n"+
" - Otherwise, check the GitHub Actions permissions here: %s.\n",
output.WithHighLightFormat("GitHub actions are currently disabled for your repository."),
output.WithHighLightFormat("https://github.com/%s/actions", repoSlug),
output.WithHighLightFormat("https://github.com/%s/settings/actions", repoSlug))
p.console.Message(ctx, message)
rawSelection, err := p.console.Select(ctx, input.ConsoleOptions{
Message: "What would you like to do now?",
Options: []string{
manualChoice.String(),
cancelChoice.String(),
},
DefaultValue: manualChoice.String(),
})
if err != nil {
return false, fmt.Errorf("prompting to enable github actions: %w", err)
}
choice := gitHubActionsEnablingChoice(rawSelection)
if choice == manualChoice {
return false, nil
}
if choice == cancelChoice {
return true, nil
}
}
return false, nil
}
const (
federatedIdentityIssuer = "https://token.actions.githubusercontent.com"
federatedIdentityAudience = "api://AzureADTokenExchange"
)
// GitHubCiProvider implements a CiProvider using GitHub to manage CI pipelines as
// GitHub actions.
type GitHubCiProvider struct {
env *environment.Environment
credentialProvider account.SubscriptionCredentialProvider
entraIdService entraid.EntraIdService
ghCli *github.Cli
gitCli *git.Cli
console input.Console
}
func NewGitHubCiProvider(
env *environment.Environment,
credentialProvider account.SubscriptionCredentialProvider,
entraIdService entraid.EntraIdService,
ghCli *github.Cli,
gitCli *git.Cli,
console input.Console) CiProvider {
return &GitHubCiProvider{
env: env,
credentialProvider: credentialProvider,
entraIdService: entraIdService,
ghCli: ghCli,
gitCli: gitCli,
console: console,
}
}
// *** subareaProvider implementation ******
// requiredTools defines the requires tools for GitHub to be used as CI manager
func (p *GitHubCiProvider) requiredTools(ctx context.Context) ([]tools.ExternalTool, error) {
return []tools.ExternalTool{p.ghCli}, nil
}
// preConfigureCheck validates that current state of tools and GitHub is as expected to
// execute.
func (p *GitHubCiProvider) preConfigureCheck(
ctx context.Context,
pipelineManagerArgs PipelineManagerArgs,
infraOptions provisioning.Options,
projectPath string,
) (bool, error) {
updated, err := ensureGitHubLogin(ctx, projectPath, p.ghCli, p.gitCli, github.GitHubHostName, p.console)
if err != nil {
return updated, err
}
authType := PipelineAuthType(pipelineManagerArgs.PipelineAuthTypeName)
// Federated Auth + Terraform is not a supported combination
if infraOptions.Provider == provisioning.Terraform {
// Throw error if Federated auth is explicitly requested
if authType == AuthTypeFederated {
return false, fmt.Errorf(
//nolint:lll
"Terraform does not support federated authentication. To explicitly use client credentials set the %s flag. %w",
output.WithBackticks("--auth-type client-credentials"),
ErrAuthNotSupported,
)
} else if authType == "" {
// If not explicitly set, show warning
p.console.MessageUxItem(
ctx,
&ux.WarningMessage{
//nolint:lll
Description: "Terraform provisioning does not support federated authentication, defaulting to Service Principal with client ID and client secret.\n",
},
)
}
}
return updated, nil
}
// name returns the name of the provider.
func (p *GitHubCiProvider) Name() string {
return gitHubDisplayName
}
func (p *GitHubCiProvider) credentialOptions(
ctx context.Context,
repoDetails *gitRepositoryDetails,
infraOptions provisioning.Options,
authType PipelineAuthType,
credentials *entraid.AzureCredentials,
) (*CredentialOptions, error) {
// Default auth type to client-credentials for terraform
if infraOptions.Provider == provisioning.Terraform && authType == "" {
authType = AuthTypeClientCredentials
}
if authType == AuthTypeClientCredentials {
return &CredentialOptions{
EnableClientCredentials: true,
}, nil
}
// If not specified default to federated credentials
if authType == "" || authType == AuthTypeFederated {
// Configure federated auth for both main branch and current branch
branches := []string{repoDetails.branch}
if !slices.Contains(branches, "main") {
branches = append(branches, "main")
}
repoSlug := repoDetails.owner + "/" + repoDetails.repoName
credentialSafeName := strings.ReplaceAll(repoSlug, "/", "-")
federatedCredentials := []*graphsdk.FederatedIdentityCredential{
{
Name: url.PathEscape(fmt.Sprintf("%s-pull_request", credentialSafeName)),
Issuer: federatedIdentityIssuer,
Subject: fmt.Sprintf("repo:%s:pull_request", repoSlug),
Description: to.Ptr("Created by Azure Developer CLI"),
Audiences: []string{federatedIdentityAudience},
},
}
for _, branch := range branches {
branchCredentials := &graphsdk.FederatedIdentityCredential{
Name: url.PathEscape(fmt.Sprintf("%s-%s", credentialSafeName, branch)),
Issuer: federatedIdentityIssuer,
Subject: fmt.Sprintf("repo:%s:ref:refs/heads/%s", repoSlug, branch),
Description: to.Ptr("Created by Azure Developer CLI"),
Audiences: []string{federatedIdentityAudience},
}
federatedCredentials = append(federatedCredentials, branchCredentials)
}
return &CredentialOptions{
EnableFederatedCredentials: true,
FederatedCredentialOptions: federatedCredentials,
}, nil
}
return &CredentialOptions{
EnableClientCredentials: false,
EnableFederatedCredentials: false,
}, nil
}
// *** ciProvider implementation ******
// configureConnection set up GitHub account with Azure Credentials for
// GitHub actions to use a service principal account to log in to Azure
// and make changes on behalf of a user.
func (p *GitHubCiProvider) configureConnection(
ctx context.Context,
repoDetails *gitRepositoryDetails,
infraOptions provisioning.Options,
servicePrincipal *graphsdk.ServicePrincipal,
credentialOptions *CredentialOptions,
credentials *entraid.AzureCredentials,
) error {
repoSlug := repoDetails.owner + "/" + repoDetails.repoName
if credentialOptions.EnableClientCredentials {
err := p.configureClientCredentialsAuth(ctx, infraOptions, repoSlug, credentials)
if err != nil {
return fmt.Errorf("configuring client credentials auth: %w", err)
}
}
if err := p.setPipelineVariables(ctx, repoSlug, infraOptions, servicePrincipal); err != nil {
return fmt.Errorf("failed setting pipeline variables: %w", err)
}
return nil
}
// setPipelineVariables sets all the pipeline variables required for the pipeline to run. This includes the environment
// variables that the core of AZD uses (AZURE_ENV_NAME) as well as the variables that the provisioning system needs to run
// (AZURE_SUBSCRIPTION_ID, AZURE_LOCATION) as well as scenario specific variables (AZURE_RESOURCE_GROUP for resource group
// scoped deployments, a series of RS_ variables for terraform remote state)
func (p *GitHubCiProvider) setPipelineVariables(
ctx context.Context,
repoSlug string,
infraOptions provisioning.Options,
servicePrincipal *graphsdk.ServicePrincipal,
) error {
for name, value := range map[string]string{
environment.EnvNameEnvVarName: p.env.Name(),
environment.LocationEnvVarName: p.env.GetLocation(),
environment.SubscriptionIdEnvVarName: p.env.GetSubscriptionId(),
environment.TenantIdEnvVarName: *servicePrincipal.AppOwnerOrganizationId,
"AZURE_CLIENT_ID": servicePrincipal.AppId,
} {
if err := p.ghCli.SetVariable(ctx, repoSlug, name, value); err != nil {
return fmt.Errorf("failed setting %s variable: %w", name, err)
}
p.console.MessageUxItem(ctx, &ux.CreatedRepoValue{
Name: name,
Kind: ux.GitHubVariable,
})
}
if infraOptions.Provider == provisioning.Terraform {
remoteStateKeys := []string{"RS_RESOURCE_GROUP", "RS_STORAGE_ACCOUNT", "RS_CONTAINER_NAME"}
for _, key := range remoteStateKeys {
value, ok := p.env.LookupEnv(key)
if !ok || strings.TrimSpace(value) == "" {
p.console.StopSpinner(ctx, "Configuring terraform", input.StepWarning)
p.console.MessageUxItem(ctx, &ux.WarningMessage{
Description: "Terraform Remote State configuration is invalid",
HidePrefix: true,
})
p.console.Message(
ctx,
fmt.Sprintf(
"Visit %s for more information on configuring Terraform remote state",
output.WithLinkFormat("https://aka.ms/azure-dev/terraform"),
),
)
p.console.Message(ctx, "")
return errors.New("terraform remote state is not correctly configured")
}
// env var was found
if err := p.ghCli.SetVariable(ctx, repoSlug, key, value); err != nil {
return fmt.Errorf("setting terraform remote state variables: %w", err)
}
p.console.MessageUxItem(ctx, &ux.CreatedRepoValue{
Name: key,
Kind: ux.GitHubVariable,
})
}
}
if infraOptions.Provider == provisioning.Bicep {
if rgName, has := p.env.LookupEnv(environment.ResourceGroupEnvVarName); has {
if err := p.ghCli.SetVariable(ctx, repoSlug, environment.ResourceGroupEnvVarName, rgName); err != nil {
return fmt.Errorf("failed setting %s variable: %w", environment.ResourceGroupEnvVarName, err)
}
}
}
return nil
}
// Configures Github for standard Service Principal authentication with client id & secret
func (p *GitHubCiProvider) configureClientCredentialsAuth(
ctx context.Context,
infraOptions provisioning.Options,
repoSlug string,
credentials *entraid.AzureCredentials,
) error {
/* #nosec G101 - Potential hardcoded credentials - false positive */
secretName := "AZURE_CREDENTIALS"
credsJson, err := json.Marshal(credentials)
if err != nil {
return fmt.Errorf("failed marshalling azure credentials: %w", err)
}
if err := p.ghCli.SetSecret(ctx, repoSlug, secretName, string(credsJson)); err != nil {
return fmt.Errorf("failed setting %s secret: %w", secretName, err)
}
p.console.MessageUxItem(ctx, &ux.CreatedRepoValue{
Name: secretName,
Kind: ux.GitHubSecret,
})
if infraOptions.Provider == provisioning.Terraform {
for key, info := range map[string]struct {
value string
secret bool
}{
"ARM_TENANT_ID": {credentials.TenantId, false},
"ARM_CLIENT_ID": {credentials.ClientId, false},
"ARM_CLIENT_SECRET": {credentials.ClientSecret, true},
} {
if !info.secret {
if err := p.ghCli.SetVariable(ctx, repoSlug, key, info.value); err != nil {
return fmt.Errorf("setting github variable %s:: %w", key, err)
}
p.console.MessageUxItem(ctx, &ux.CreatedRepoValue{
Name: key,
Kind: ux.GitHubVariable,
})
} else {
if err := p.ghCli.SetSecret(ctx, repoSlug, key, info.value); err != nil {
return fmt.Errorf("setting github secret %s:: %w", key, err)
}
p.console.MessageUxItem(ctx, &ux.CreatedRepoValue{
Name: key,
Kind: ux.GitHubSecret,
})
}
}
}
return nil
}
// configurePipeline is a no-op for GitHub, as the pipeline is automatically
// created by creating the workflow files in .github directory.
func (p *GitHubCiProvider) configurePipeline(
ctx context.Context,
repoDetails *gitRepositoryDetails,
options *configurePipelineOptions,
) (CiPipeline, error) {
repoSlug := repoDetails.owner + "/" + repoDetails.repoName
// Variables and Secrets for a gh-actions are independent from the gh-action. They are set on the repository level.
// We need to clean up the previous values before setting the new ones.
// By doing this, we are handling:
// - When a secret is moved to be a variable (or vice versa). Don't leak the previous value on the pipeline.
// - When there was a previous additional variable/secret set and then it was updated to empty string or unset from .env.
msg := ""
var procErr error
ciSecrets, ciVariables := []string{}, []string{}
if len(options.projectVariables) > 0 {
msg = "Setting up project's variables to be used in the pipeline"
ciSecretsInstance, err := p.ghCli.ListSecrets(ctx, repoSlug)
if err != nil {
return nil, fmt.Errorf("unable to get list of repository secrets: %w", err)
}
ciVariablesInstance, err := p.ghCli.ListVariables(ctx, repoSlug)
if err != nil {
return nil, fmt.Errorf("unable to get list of repository variables: %w", err)
}
ciSecrets = ciSecretsInstance
ciVariables = ciVariablesInstance
p.console.ShowSpinner(ctx, msg, input.Step)
}
defer func() {
if msg != "" {
p.console.StopSpinner(ctx, msg, input.GetStepResultFormat(procErr))
}
if procErr == nil {
p.console.MessageUxItem(ctx, &ux.MultilineMessage{
Lines: []string{
"",
"GitHub Action secrets are now configured. You can view GitHub action secrets that were " +
"created at this link:",
output.WithLinkFormat("https://github.com/%s/settings/secrets/actions", repoSlug),
""},
})
}
}()
// create map of variables for O(1) lookup during clean up
variablesAndSecretsMap := make(map[string]string, len(options.projectVariables)+len(options.projectSecrets))
for _, value := range options.projectVariables {
variablesAndSecretsMap[value] = value
}
for _, value := range options.projectSecrets {
variablesAndSecretsMap[value] = value
}
// iterate the existing secrets on the pipeline and remove the ones matching the project's secrets or variables
for _, existingSecret := range ciSecrets {
if _, willBeUpdated := options.secrets[existingSecret]; willBeUpdated {
// if the secret will be updated, we don't need to delete it
continue
}
// only delete if the secret is defined in the project's secrets or variables (azure.yaml)
if _, exists := variablesAndSecretsMap[existingSecret]; exists {
deleteErr := p.ghCli.DeleteSecret(ctx, repoSlug, existingSecret)
if deleteErr != nil {
procErr = fmt.Errorf("failed deleting %s secret: %w", existingSecret, deleteErr)
return nil, procErr
}
}
}
// iterate the existing variables on the pipeline and remove the ones matching the project's secrets or variables
for _, existingVariable := range ciVariables {
if _, willBeUpdated := options.variables[existingVariable]; willBeUpdated {
// if the variable will be updated, we don't need to delete it
continue
}
// only delete if the variable is defined in the project's secrets or variables (azure.yaml)
if _, exists := variablesAndSecretsMap[existingVariable]; exists {
deleteErr := p.ghCli.DeleteVariable(ctx, repoSlug, existingVariable)
if deleteErr != nil {
procErr = fmt.Errorf("failed deleting %s variable: %w", existingVariable, deleteErr)
return nil, procErr
}
}
}
// set the new variables and secrets
for key, value := range options.secrets {
if err := p.ghCli.SetSecret(ctx, repoSlug, key, value); err != nil {
procErr = fmt.Errorf("failed setting %s secret: %w", key, err)
return nil, procErr
}
}
for key, value := range options.variables {
if err := p.ghCli.SetVariable(ctx, repoSlug, key, value); err != nil {
procErr = fmt.Errorf("failed setting %s secret: %w", key, err)
return nil, procErr
}
}
return &workflow{
repoDetails: repoDetails,
}, nil
}
// workflow is the implementation for a CiPipeline for GitHub
type workflow struct {
repoDetails *gitRepositoryDetails
}
func (w *workflow) name() string {
return "actions"
}
func (w *workflow) url() string {
return w.repoDetails.url + "/actions"
}
// ensureGitHubLogin ensures the user is logged into the GitHub CLI. If not, it prompt the user
// if they would like to log in and if so runs `gh auth login` interactively.
func ensureGitHubLogin(
ctx context.Context,
projectPath string,
ghCli *github.Cli,
gitCli *git.Cli,
hostname string,
console input.Console) (bool, error) {
authResult, err := ghCli.GetAuthStatus(ctx, hostname)
if err != nil {
return false, err
}
if authResult.LoggedIn {
return false, nil
}
for {
var accept bool
accept, err := console.Confirm(ctx, input.ConsoleOptions{
Message: "This command requires you to be logged into GitHub. Log in using the GitHub CLI?",
DefaultValue: true,
})
if err != nil {
return false, fmt.Errorf("prompting to log in to github: %w", err)
}
if !accept {
return false, errors.New("interactive GitHub login declined; use `gh auth login` to log into GitHub")
}
ghGitProtocol, err := ghCli.GetGitProtocolType(ctx)
if err != nil {
return false, err
}
if err := ghCli.Login(ctx, hostname); err == nil {
if github.RunningOnCodespaces() && projectPath != "" && ghGitProtocol == github.GitHttpsProtocolType {
// For HTTPS, using gh as credential helper will avoid git asking for password
// Credential helper is only set for codespaces to improve the experience,
// see more about this here: https://github.com/Azure/azure-dev/issues/2451
if err := gitCli.SetGitHubAuthForRepo(
ctx, projectPath, fmt.Sprintf("https://%s", hostname), ghCli.BinaryPath()); err != nil {
return false, err
}
}
return true, nil
}
fmt.Fprintln(console.Handles().Stdout, "There was an issue logging into GitHub.")
}
}
// getRemoteUrlFromExisting let user to select an existing repository from his/her account and
// returns the remote url for that repository.
func getRemoteUrlFromExisting(ctx context.Context, ghCli *github.Cli, console input.Console) (string, error) {
repos, err := ghCli.ListRepositories(ctx)
if err != nil {
return "", fmt.Errorf("listing existing repositories: %w", err)
}
options := make([]string, 0, len(repos))
for _, repo := range repos {
options = append(options, repo.NameWithOwner)
}
if len(options) == 0 {
return "", errors.New("no existing GitHub repositories found")
}
repoIdx, err := console.Select(ctx, input.ConsoleOptions{
Message: "Choose an existing GitHub repository",
Options: options,
})
if err != nil {
return "", fmt.Errorf("prompting for repository: %w", err)
}
return selectRemoteUrl(ctx, ghCli, repos[repoIdx])
}
// selectRemoteUrl let user to type and enter the url from an existing GitHub repo.
// If the url is valid, the remote url is returned. Otherwise an error is returned.
func selectRemoteUrl(ctx context.Context, ghCli *github.Cli, repo github.GhCliRepository) (string, error) {
protocolType, err := ghCli.GetGitProtocolType(ctx)
if err != nil {
return "", fmt.Errorf("detecting default protocol: %w", err)
}
switch protocolType {
case github.GitHttpsProtocolType:
return repo.HttpsUrl, nil
case github.GitSshProtocolType:
return repo.SshUrl, nil
default:
panic(fmt.Sprintf("unexpected protocol type: %s", protocolType))
}
}
// getRemoteUrlFromNewRepository creates a new repository on GitHub and returns its remote url
func getRemoteUrlFromNewRepository(
ctx context.Context,
ghCli *github.Cli,
currentPathName string,
console input.Console,
) (string, error) {
var repoName string
currentDirectoryName := filepath.Base(currentPathName)
for {
name, err := console.Prompt(ctx, input.ConsoleOptions{
Message: "Enter the name for your new repository OR Hit enter to use this name:",
DefaultValue: currentDirectoryName,
})
if err != nil {
return "", fmt.Errorf("asking for new repository name: %w", err)
}
err = ghCli.CreatePrivateRepository(ctx, name)
if errors.Is(err, github.ErrRepositoryNameInUse) {
console.Message(ctx, fmt.Sprintf("error: the repository name '%s' is already in use\n", name))
continue // try again
} else if err != nil {
return "", fmt.Errorf("creating repository: %w", err)
} else {
repoName = name
break
}
}
repo, err := ghCli.ViewRepository(ctx, repoName)
if err != nil {
return "", fmt.Errorf("fetching repository info: %w", err)
}
return selectRemoteUrl(ctx, ghCli, repo)
}
// getRemoteUrlFromPrompt interactively prompts the user for a URL for a GitHub repository. It validates
// that the URL is well formed and is in the correct format for a GitHub repository.
func getRemoteUrlFromPrompt(ctx context.Context, remoteName string, console input.Console) (string, error) {
remoteUrl := ""
for remoteUrl == "" {
promptValue, err := console.Prompt(ctx, input.ConsoleOptions{
Message: fmt.Sprintf("Enter the url to use for remote %s:", remoteName),
})
if err != nil {
return "", fmt.Errorf("prompting for remote url: %w", err)
}
remoteUrl = promptValue
if _, err := githubRemote.GetSlugForRemote(remoteUrl); errors.Is(err, githubRemote.ErrRemoteHostIsNotGitHub) {
fmt.Fprintf(console.Handles().Stdout, "error: \"%s\" is not a valid GitHub URL.\n", remoteUrl)
// So we retry from the loop.
remoteUrl = ""
}
}
return remoteUrl, nil
}