cli/azd/pkg/tools/github/github.go (515 lines of code) (raw):
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
package github
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"github.com/azure/azure-dev/cli/azd/pkg/config"
"github.com/azure/azure-dev/cli/azd/pkg/exec"
"github.com/azure/azure-dev/cli/azd/pkg/input"
"github.com/azure/azure-dev/cli/azd/pkg/osutil"
"github.com/azure/azure-dev/cli/azd/pkg/tools"
"github.com/blang/semver/v4"
)
var _ tools.ExternalTool = (*Cli)(nil)
func NewGitHubCli(ctx context.Context, console input.Console, commandRunner exec.CommandRunner) (*Cli, error) {
return newGitHubCliImplementation(ctx, console, commandRunner, http.DefaultClient, downloadGh, extractGhCli)
}
// Version is the minimum version of GitHub cli that we require (and the one we fetch when we fetch gh on
// behalf of a user).
var Version semver.Version = semver.MustParse("2.55.0")
// newGitHubCliImplementation is like NewGitHubCli but allows providing a custom transport to use when downloading the
// GitHub CLI, for testing purposes.
func newGitHubCliImplementation(
ctx context.Context,
console input.Console,
commandRunner exec.CommandRunner,
transporter policy.Transporter,
acquireGitHubCliImpl getGitHubCliImplementation,
extractImplementation extractGitHubCliFromFileImplementation,
) (*Cli, error) {
if override := os.Getenv("AZD_GH_TOOL_PATH"); override != "" {
log.Printf("using external github cli tool: %s", override)
cli := &Cli{
path: override,
commandRunner: commandRunner,
}
cli.logVersion(ctx)
return cli, nil
}
githubCliPath, err := azdGithubCliPath()
if err != nil {
return nil, fmt.Errorf("getting github cli default path: %w", err)
}
if _, err = os.Stat(githubCliPath); err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("getting file information from github cli default path: %w", err)
}
var installGhCli bool
if errors.Is(err, os.ErrNotExist) || !expectedVersionInstalled(ctx, commandRunner, githubCliPath) {
installGhCli = true
}
if installGhCli {
if err := os.MkdirAll(filepath.Dir(githubCliPath), osutil.PermissionDirectory); err != nil {
return nil, fmt.Errorf("creating github cli default path: %w", err)
}
msg := "setting up github connection"
console.ShowSpinner(ctx, msg, input.Step)
err = acquireGitHubCliImpl(ctx, transporter, Version, extractImplementation, githubCliPath)
console.StopSpinner(ctx, "", input.Step)
if err != nil {
return nil, fmt.Errorf("setting up github connection: %w", err)
}
}
cli := &Cli{
path: githubCliPath,
commandRunner: commandRunner,
}
cli.logVersion(ctx)
return cli, nil
}
// azdGithubCliPath returns the path where we store our local copy of github cli ($AZD_CONFIG_DIR/bin).
func azdGithubCliPath() (string, error) {
configDir, err := config.GetUserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(configDir, "bin", ghCliName()), nil
}
func ghCliName() string {
if runtime.GOOS == "windows" {
return "gh.exe"
}
return "gh"
}
var (
ErrGitHubCliNotLoggedIn = errors.New("gh cli is not logged in")
ErrUserNotAuthorized = errors.New("user is not authorized. " +
"Try running gh auth refresh with the required scopes to request additional authorization")
ErrRepositoryNameInUse = errors.New("repository name already in use")
// The hostname of the public GitHub service.
GitHubHostName = "github.com"
// Environment variable that gh cli uses for auth token overrides
TokenEnvVars = []string{"GITHUB_TOKEN", "GH_TOKEN"}
)
type Cli struct {
commandRunner exec.CommandRunner
path string
}
func (cli *Cli) CheckInstalled(ctx context.Context) error {
return nil
}
func expectedVersionInstalled(ctx context.Context, commandRunner exec.CommandRunner, binaryPath string) bool {
ghVersion, err := tools.ExecuteCommand(ctx, commandRunner, binaryPath, "--version")
if err != nil {
log.Printf("checking GitHub CLI version: %s", err.Error())
return false
}
ghSemver, err := tools.ExtractVersion(ghVersion)
if err != nil {
log.Printf("converting to semver version fails: %s", err.Error())
return false
}
if ghSemver.LT(Version) {
log.Printf("Found gh cli version %s. Expected version: %s.", ghSemver.String(), Version.String())
return false
}
return true
}
func (cli *Cli) Name() string {
return "GitHub CLI"
}
func (cli *Cli) BinaryPath() string {
return cli.path
}
func (cli *Cli) InstallUrl() string {
return "https://aka.ms/azure-dev/github-cli-install"
}
// The result from calling GetAuthStatus
type AuthStatus struct {
LoggedIn bool
}
func (cli *Cli) GetAuthStatus(ctx context.Context, hostname string) (AuthStatus, error) {
runArgs := cli.newRunArgs("auth", "status", "--hostname", hostname)
res, err := cli.commandRunner.Run(ctx, runArgs)
if err == nil {
authResult := AuthStatus{LoggedIn: true}
return authResult, nil
}
if isGhCliNotLoggedInMessageRegex.MatchString(res.Stderr) {
return AuthStatus{}, nil
} else if notLoggedIntoAnyGitHubHostsMessageRegex.MatchString(res.Stderr) {
return AuthStatus{}, nil
}
return AuthStatus{}, fmt.Errorf("failed running gh auth status: %w", err)
}
func (cli *Cli) Login(ctx context.Context, hostname string) error {
runArgs := cli.newRunArgs("auth", "login", "--hostname", hostname, "--scopes", "repo,workflow").
WithInteractive(true)
_, err := cli.commandRunner.Run(ctx, runArgs)
if err != nil {
return fmt.Errorf("failed running gh auth login: %w", err)
}
return nil
}
// ApiCallOptions represent the options for the ApiCall method.
type ApiCallOptions struct {
Headers []string
}
// ApiCall uses gh cli to call https://api.<hostname>/<path>.
func (cli *Cli) ApiCall(ctx context.Context, hostname, path string, options ApiCallOptions) (string, error) {
url := fmt.Sprintf("https://api.%s%s", hostname, path)
args := []string{"api", url}
for _, header := range options.Headers {
args = append(args, "-H", header)
}
// application/vnd.github.raw makes the API return the raw content of the file
runArgs := cli.newRunArgs(args...)
result, err := cli.commandRunner.Run(ctx, runArgs)
if err != nil {
return "", fmt.Errorf("failed running gh api: %s: %w", url, err)
}
return result.Stdout, nil
}
func ghOutputToList(output string) []string {
lines := strings.Split(output, "\n")
result := make([]string, len(lines)-1)
for i, line := range lines {
if line == "" {
continue
}
valueParts := strings.Split(line, "\t")
result[i] = valueParts[0]
}
return result
}
func (cli *Cli) ListSecrets(ctx context.Context, repoSlug string) ([]string, error) {
runArgs := cli.newRunArgs("-R", repoSlug, "secret", "list")
output, err := cli.run(ctx, runArgs)
if err != nil {
return nil, fmt.Errorf("failed running gh secret list: %w", err)
}
return ghOutputToList(output.Stdout), nil
}
func (cli *Cli) ListVariables(ctx context.Context, repoSlug string) ([]string, error) {
runArgs := cli.newRunArgs("-R", repoSlug, "variable", "list")
output, err := cli.run(ctx, runArgs)
if err != nil {
return nil, fmt.Errorf("failed running gh secret list: %w", err)
}
return ghOutputToList(output.Stdout), nil
}
func (cli *Cli) SetSecret(ctx context.Context, repoSlug string, name string, value string) error {
runArgs := cli.newRunArgs("-R", repoSlug, "secret", "set", name).WithStdIn(strings.NewReader(value))
_, err := cli.run(ctx, runArgs)
if err != nil {
return fmt.Errorf("failed running gh secret set: %w", err)
}
return nil
}
func (cli *Cli) SetVariable(ctx context.Context, repoSlug string, name string, value string) error {
runArgs := cli.newRunArgs("-R", repoSlug, "variable", "set", name).WithStdIn(strings.NewReader(value))
_, err := cli.run(ctx, runArgs)
if err != nil {
return fmt.Errorf("failed running gh variable set: %w", err)
}
return nil
}
func (cli *Cli) DeleteSecret(ctx context.Context, repoSlug string, name string) error {
runArgs := cli.newRunArgs("-R", repoSlug, "secret", "delete", name)
_, err := cli.run(ctx, runArgs)
if err != nil {
return fmt.Errorf("failed running gh secret delete: %w", err)
}
return nil
}
func (cli *Cli) DeleteVariable(ctx context.Context, repoSlug string, name string) error {
runArgs := cli.newRunArgs("-R", repoSlug, "variable", "delete", name)
_, err := cli.run(ctx, runArgs)
if err != nil {
return fmt.Errorf("failed running gh variable delete: %w", err)
}
return nil
}
// ghCliVersionRegexp fetches the version number from the output of gh --version, which looks like this:
//
// gh version 2.6.0 (2022-03-15)
// https://github.com/cli/cli/releases/tag/v2.6.0
var ghCliVersionRegexp = regexp.MustCompile(`gh version ([0-9]+\.[0-9]+\.[0-9]+)`)
// logVersion writes the version of the GitHub CLI to the debug log for diagnostics purposes, or an error if
// it could not be determined
func (cli *Cli) logVersion(ctx context.Context) {
if ver, err := cli.extractVersion(ctx); err == nil {
log.Printf("github cli version: %s", ver)
} else {
log.Printf("could not determine github cli version: %s", err)
}
}
// extractVersion gets the version of the GitHub CLI, from the output of `gh --version`
func (cli *Cli) extractVersion(ctx context.Context) (string, error) {
runArgs := cli.newRunArgs("--version")
res, err := cli.run(ctx, runArgs)
if err != nil {
return "", fmt.Errorf("error running gh --version: %w", err)
}
matches := ghCliVersionRegexp.FindStringSubmatch(res.Stdout)
if len(matches) != 2 {
return "", fmt.Errorf("could not extract version from output: %s", res.Stdout)
}
return matches[1], nil
}
type GhCliRepository struct {
// The slug for a repository (formatted as "<owner>/<name>")
NameWithOwner string
// The Url for the HTTPS endpoint for the repository
HttpsUrl string `json:"url"`
// The Url for the SSH endpoint for the repository
SshUrl string
}
func (cli *Cli) ListRepositories(ctx context.Context) ([]GhCliRepository, error) {
runArgs := cli.newRunArgs("repo", "list", "--no-archived", "--json", "nameWithOwner,url,sshUrl")
res, err := cli.run(ctx, runArgs)
if err != nil {
return nil, fmt.Errorf("failed running gh repo list: %w", err)
}
var repos []GhCliRepository
if err := json.Unmarshal([]byte(res.Stdout), &repos); err != nil {
return nil, fmt.Errorf("could not unmarshal output as a []GhCliRepository: %w, output: %s", err, res.Stdout)
}
return repos, nil
}
func (cli *Cli) ViewRepository(ctx context.Context, name string) (GhCliRepository, error) {
runArgs := cli.newRunArgs("repo", "view", name, "--json", "nameWithOwner,url,sshUrl")
res, err := cli.run(ctx, runArgs)
if err != nil {
return GhCliRepository{}, fmt.Errorf("failed running gh repo list: %w", err)
}
var repo GhCliRepository
if err := json.Unmarshal([]byte(res.Stdout), &repo); err != nil {
return GhCliRepository{},
fmt.Errorf("could not unmarshal output as a GhCliRepository: %w, output: %s", err, res.Stdout)
}
return repo, nil
}
func (cli *Cli) CreatePrivateRepository(ctx context.Context, name string) error {
runArgs := cli.newRunArgs("repo", "create", name, "--private")
res, err := cli.run(ctx, runArgs)
if repositoryNameInUseRegex.MatchString(res.Stderr) {
return ErrRepositoryNameInUse
} else if err != nil {
return fmt.Errorf("failed running gh repo create: %w", err)
}
return nil
}
const (
GitSshProtocolType = "ssh"
GitHttpsProtocolType = "https"
)
func (cli *Cli) GetGitProtocolType(ctx context.Context) (string, error) {
runArgs := cli.newRunArgs("config", "get", "git_protocol")
res, err := cli.run(ctx, runArgs)
if err != nil {
return "", fmt.Errorf("failed running gh config get git_protocol: %w", err)
}
return strings.TrimSpace(res.Stdout), nil
}
type GitHubActionsResponse struct {
TotalCount int `json:"total_count"`
}
// GitHubActionsExists gets the information from upstream about the workflows and
// return true if there is at least one workflow in the repo.
func (cli *Cli) GitHubActionsExists(ctx context.Context, repoSlug string) (bool, error) {
runArgs := cli.newRunArgs("api", "/repos/"+repoSlug+"/actions/workflows")
res, err := cli.run(ctx, runArgs)
if err != nil {
return false, fmt.Errorf("getting github actions: %w", err)
}
var jsonResponse GitHubActionsResponse
if err := json.Unmarshal([]byte(res.Stdout), &jsonResponse); err != nil {
return false, fmt.Errorf("could not unmarshal output as a GhActionsResponse: %w, output: %s", err, res.Stdout)
}
if jsonResponse.TotalCount == 0 {
return false, nil
}
return true, nil
}
func (cli *Cli) newRunArgs(args ...string) exec.RunArgs {
runArgs := exec.NewRunArgs(cli.path, args...)
if RunningOnCodespaces() {
runArgs = runArgs.WithEnv([]string{"GITHUB_TOKEN=", "GH_TOKEN="})
}
return runArgs
}
func (cli *Cli) run(ctx context.Context, runArgs exec.RunArgs) (exec.RunResult, error) {
res, err := cli.commandRunner.Run(ctx, runArgs)
if isGhCliNotLoggedInMessageRegex.MatchString(res.Stderr) {
return res, ErrGitHubCliNotLoggedIn
}
if isUserNotAuthorizedMessageRegex.MatchString(res.Stderr) {
return res, ErrUserNotAuthorized
}
return res, err
}
//nolint:lll
var isGhCliNotLoggedInMessageRegex = regexp.MustCompile(
"(To authenticate, please run `gh auth login`\\.)|(Try authenticating with: gh auth login)|(To re-authenticate, run: gh auth login)|(To get started with GitHub CLI, please run: gh auth login)",
)
var repositoryNameInUseRegex = regexp.MustCompile(`GraphQL: Name already exists on this account \(createRepository\)`)
var notLoggedIntoAnyGitHubHostsMessageRegex = regexp.MustCompile(
"You are not logged into any GitHub hosts.",
)
var isUserNotAuthorizedMessageRegex = regexp.MustCompile(
"HTTP 403: Resource not accessible by integration",
)
func extractFromZip(src, dst string) (string, error) {
zipReader, err := zip.OpenReader(src)
if err != nil {
return "", err
}
log.Printf("extract from zip %s", src)
defer zipReader.Close()
var extractedAt string
for _, file := range zipReader.File {
fileName := file.FileInfo().Name()
if !file.FileInfo().IsDir() && fileName == ghCliName() {
log.Printf("found cli at: %s", file.Name)
fileReader, err := file.Open()
if err != nil {
return extractedAt, err
}
filePath := filepath.Join(dst, fileName)
ghCliFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode())
if err != nil {
return extractedAt, err
}
defer ghCliFile.Close()
/* #nosec G110 - decompression bomb false positive */
_, err = io.Copy(ghCliFile, fileReader)
if err != nil {
return extractedAt, err
}
extractedAt = filePath
break
}
}
if extractedAt != "" {
log.Printf("extracted to: %s", extractedAt)
return extractedAt, nil
}
return extractedAt, fmt.Errorf("github cli binary was not found within the zip file")
}
func extractFromTar(src, dst string) (string, error) {
gzFile, err := os.Open(src)
if err != nil {
return "", err
}
defer gzFile.Close()
gzReader, err := gzip.NewReader(gzFile)
if err != nil {
return "", err
}
defer gzReader.Close()
var extractedAt string
// tarReader doesn't need to be closed as it is closed by the gz reader
tarReader := tar.NewReader(gzReader)
for {
fileHeader, err := tarReader.Next()
if errors.Is(err, io.EOF) {
return extractedAt, fmt.Errorf("did not find gh cli within tar file")
}
if fileHeader == nil {
continue
}
if err != nil {
return extractedAt, err
}
// Tha name contains the path, remove it
fileNameParts := strings.Split(fileHeader.Name, "/")
fileName := fileNameParts[len(fileNameParts)-1]
// cspell: disable-next-line `Typeflag` is comming fron *tar.Header
if fileHeader.Typeflag == tar.TypeReg && fileName == "gh" {
filePath := filepath.Join(dst, fileName)
ghCliFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileHeader.FileInfo().Mode())
if err != nil {
return extractedAt, err
}
defer ghCliFile.Close()
/* #nosec G110 - decompression bomb false positive */
_, err = io.Copy(ghCliFile, tarReader)
if err != nil {
return extractedAt, err
}
extractedAt = filePath
break
}
}
if extractedAt != "" {
return extractedAt, nil
}
return extractedAt, fmt.Errorf("extract from tar error. Extraction ended in unexpected state.")
}
// extractGhCli gets the Github cli from either a zip or a tar.gz
func extractGhCli(src, dst string) (string, error) {
if strings.HasSuffix(src, ".zip") {
return extractFromZip(src, dst)
} else if strings.HasSuffix(src, ".tar.gz") {
return extractFromTar(src, dst)
}
return "", fmt.Errorf("Unknown format while trying to extract")
}
// getGitHubCliImplementation defines the contract function to acquire the GitHub cli.
// The `outputPath` is the destination where the github cli is place it.
type getGitHubCliImplementation func(
ctx context.Context,
transporter policy.Transporter,
ghVersion semver.Version,
extractImplementation extractGitHubCliFromFileImplementation,
outputPath string) error
// extractGitHubCliFromFileImplementation defines how the cli is extracted
type extractGitHubCliFromFileImplementation func(src, dst string) (string, error)
// downloadGh downloads a given version of GitHub cli from the release site.
func downloadGh(
ctx context.Context,
transporter policy.Transporter,
ghVersion semver.Version,
extractImplementation extractGitHubCliFromFileImplementation,
path string) error {
binaryName := func(platform string) string {
return fmt.Sprintf("gh_%s_%s", ghVersion, platform)
}
systemArch := runtime.GOARCH
// arm and x86 not supported (similar to bicep)
var releaseName string
switch runtime.GOOS {
case "windows":
releaseName = binaryName(fmt.Sprintf("windows_%s.zip", systemArch))
case "darwin":
releaseName = binaryName(fmt.Sprintf("macOS_%s.zip", systemArch))
case "linux":
releaseName = binaryName(fmt.Sprintf("linux_%s.tar.gz", systemArch))
default:
return fmt.Errorf("unsupported platform")
}
// example: https://github.com/cli/cli/releases/download/v2.55.0/gh_2.55.0_linux_arm64.rpm
ghReleaseUrl := fmt.Sprintf("https://github.com/cli/cli/releases/download/v%s/%s", ghVersion, releaseName)
log.Printf("downloading github cli release %s -> %s", ghReleaseUrl, releaseName)
req, err := http.NewRequestWithContext(ctx, "GET", ghReleaseUrl, nil)
if err != nil {
return err
}
resp, err := transporter.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("http error %d", resp.StatusCode)
}
tmpPath := filepath.Dir(path)
compressedRelease, err := os.CreateTemp(tmpPath, releaseName)
if err != nil {
return err
}
defer func() {
_ = compressedRelease.Close()
_ = os.Remove(compressedRelease.Name())
}()
if _, err := io.Copy(compressedRelease, resp.Body); err != nil {
return err
}
if err := compressedRelease.Close(); err != nil {
return err
}
// change file name from temporal name to the final name, as the download has completed
compressedFileName := filepath.Join(tmpPath, releaseName)
if err := osutil.Rename(ctx, compressedRelease.Name(), compressedFileName); err != nil {
return err
}
defer func() {
log.Printf("delete %s", compressedFileName)
_ = os.Remove(compressedFileName)
}()
// unzip downloaded file
log.Printf("extracting file %s", compressedFileName)
_, err = extractImplementation(compressedFileName, tmpPath)
if err != nil {
return err
}
return nil
}
// RunningOnCodespaces check if the application is running on codespaces.
func RunningOnCodespaces() bool {
return os.Getenv("CODESPACES") == "true"
}