From 8dd03144ffdc6c0d486d6b705f9c7fba871ee7c3 Mon Sep 17 00:00:00 2001 From: nate smith Date: Thu, 3 Oct 2019 22:20:31 -0500 Subject: [PATCH] initial commit --- README.md | 11 + command/pr.go | 120 ++++ command/root.go | 17 + git/git.go | 348 ++++++++++++ git/ssh_config.go | 90 +++ git/url.go | 66 +++ github/client.go | 1140 ++++++++++++++++++++++++++++++++++++++ github/config.go | 409 ++++++++++++++ github/config_decoder.go | 61 ++ github/config_encoder.go | 52 ++ github/config_service.go | 43 ++ github/hosts.go | 64 +++ github/http.go | 537 ++++++++++++++++++ github/project.go | 181 ++++++ github/remote.go | 101 ++++ go.mod | 16 + go.sum | 67 +++ main.go | 15 + ui/ui.go | 98 ++++ utils/utils.go | 119 ++++ version/version.go | 17 + 21 files changed, 3572 insertions(+) create mode 100644 README.md create mode 100644 command/pr.go create mode 100644 command/root.go create mode 100644 git/git.go create mode 100644 git/ssh_config.go create mode 100644 git/url.go create mode 100644 github/client.go create mode 100644 github/config.go create mode 100644 github/config_decoder.go create mode 100644 github/config_encoder.go create mode 100644 github/config_service.go create mode 100644 github/hosts.go create mode 100644 github/http.go create mode 100644 github/project.go create mode 100644 github/remote.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 ui/ui.go create mode 100644 utils/utils.go create mode 100644 version/version.go diff --git a/README.md b/README.md new file mode 100644 index 000000000..277144578 --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +# Github Cli + +The #ce-cli team is working on a CLI tool to reduce the friction between GitHub and your local machine for people who use the command line primarily to interact with Git and GitHub. https://github.com/github/releases/issues/659 + +## Process + +1. For code we want to keep (production ready) create PRs and merge them into `master`. For prototype code we will merge them into the `prototype` branch because the code will most likely be garbage. We could have used one production and one prototype repo but that would have made communication more difficult and dispersed. + +2. Each week we’ll have a tracking issue to coordinate plans and distribute the workload. They look like this https://github.com/github/github-cli-prototype/labels/tracking%20issue. This issue can also be used for handoff messages like this https://github.slack.com/archives/C17LP0XU3/p1569378288317500. Using issues and a project board seems like overkill right now, so start lo-fi. + +3. We zoom as a team fortnight (Tuesday) diff --git a/command/pr.go b/command/pr.go new file mode 100644 index 000000000..169fffd01 --- /dev/null +++ b/command/pr.go @@ -0,0 +1,120 @@ +package command + +import ( + "fmt" + + "github.com/github/gh/git" + "github.com/github/gh/github" + "github.com/spf13/cobra" +) + +func init() { + RootCmd.AddCommand(prCmd) + prCmd.AddCommand(prListCmd) +} + +var prCmd = &cobra.Command{ + Use: "pr", + Short: "Work with pull requests", + Long: `This command allows you to +work with pull requests.`, + Args: cobra.MinimumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + fmt.Println("pr") + }, +} + +var prListCmd = &cobra.Command{ + Use: "list", + Short: "List pull requests", + Run: func(cmd *cobra.Command, args []string) { + ExecutePr() + }, +} + +type prFilter int + +const ( + createdByViewer prFilter = iota + reviewRequested +) + +func ExecutePr() { + // prsForCurrentBranch := pullRequestsForCurrentBranch() + prsCreatedByViewer := pullRequests(createdByViewer) + // prsRequestingReview := pullRequests(reviewRequested) + + fmt.Printf("🌭 count! %d\n", len(prsCreatedByViewer)) +} + +type searchBody struct { + Items []github.PullRequest `json:"items"` +} + +func pullRequestsForCurrentBranch() []github.PullRequest { + project := project() + client := github.NewClient(project.Host) + currentBranch, error := git.Head() + if error != nil { + panic(error) + } + + headWithOwner := fmt.Sprintf("%s:%s", project.Owner, currentBranch) + filterParams := map[string]interface{}{"headWithOwner": headWithOwner} + prs, error := client.FetchPullRequests(&project, filterParams, 10, nil) + if error != nil { + panic(error) + } + + return prs +} + +func pullRequests(filter prFilter) []github.PullRequest { + project := project() + client := github.NewClient(project.Host) + owner := project.Owner + name := project.Name + user, error := client.CurrentUser() + if error != nil { + panic(error) + } + + var headers map[string]string + var q string + if filter == createdByViewer { + q = fmt.Sprintf("user:%s repo:%s state:open is:pr author:%s", owner, name, user.Login) + } else if filter == reviewRequested { + q = fmt.Sprintf("user:%s repo:%s state:open review-requested:%s", owner, name, user.Login) + } else { + panic("This is not a fitler") + } + + data := map[string]interface{}{"q": q} + + response, error := client.GenericAPIRequest("GET", "search/issues", data, headers, 60) + if error != nil { + panic(fmt.Sprintf("GenericAPIRequest failed %+v", error)) + } + searchBody := searchBody{} + error = response.Unmarshal(&searchBody) + if error != nil { + panic(fmt.Sprintf("Unmarshal failed %+v", error)) + } + + return searchBody.Items +} + +func project() github.Project { + remotes, error := github.Remotes() + if error != nil { + panic(error) + } + + for _, remote := range remotes { + if project, error := remote.Project(); error == nil { + return *project + } + } + + panic("Could not get the project. What is a project? I don't know, it's kind of like a git repository I think?") +} diff --git a/command/root.go b/command/root.go new file mode 100644 index 000000000..a644189bf --- /dev/null +++ b/command/root.go @@ -0,0 +1,17 @@ +package command + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var RootCmd = &cobra.Command{ + Use: "gh", + Short: "GitHub CLI", + Long: `Do things with GitHub from your terminal`, + Args: cobra.MinimumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + fmt.Println("root") + }, +} diff --git a/git/git.go b/git/git.go new file mode 100644 index 000000000..1f96a59f0 --- /dev/null +++ b/git/git.go @@ -0,0 +1,348 @@ +package git + +import ( + "bytes" + "fmt" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "strings" +) + +var GlobalFlags []string + +func Version() (string, error) { + versionCmd := exec.Command("git", "version") + output, err := versionCmd.Output() + if err != nil { + return "", fmt.Errorf("error running git version: %s", err) + } + return firstLine(output), nil +} + +var cachedDir string + +func Dir() (string, error) { + if cachedDir != "" { + return cachedDir, nil + } + + dirCmd := exec.Command("git", "rev-parse", "-q", "--git-dir") + dirCmd.Stderr = nil + output, err := dirCmd.Output() + if err != nil { + return "", fmt.Errorf("Not a git repository (or any of the parent directories): .git") + } + + var chdir string + for i, flag := range GlobalFlags { + if flag == "-C" { + dir := GlobalFlags[i+1] + if filepath.IsAbs(dir) { + chdir = dir + } else { + chdir = filepath.Join(chdir, dir) + } + } + } + + gitDir := firstLine(output) + + if !filepath.IsAbs(gitDir) { + if chdir != "" { + gitDir = filepath.Join(chdir, gitDir) + } + + gitDir, err = filepath.Abs(gitDir) + if err != nil { + return "", err + } + + gitDir = filepath.Clean(gitDir) + } + + cachedDir = gitDir + return gitDir, nil +} + +func WorkdirName() (string, error) { + toplevelCmd := exec.Command("git", "rev-parse", "--show-toplevel") + toplevelCmd.Stderr = nil + output, err := toplevelCmd.Output() + dir := firstLine(output) + if dir == "" { + return "", fmt.Errorf("unable to determine git working directory") + } + return dir, err +} + +func HasFile(segments ...string) bool { + // The blessed way to resolve paths within git dir since Git 2.5.0 + pathCmd := exec.Command("git", "rev-parse", "-q", "--git-path", filepath.Join(segments...)) + pathCmd.Stderr = nil + if output, err := pathCmd.Output(); err == nil { + if lines := outputLines(output); len(lines) == 1 { + if _, err := os.Stat(lines[0]); err == nil { + return true + } + } + } + + // Fallback for older git versions + dir, err := Dir() + if err != nil { + return false + } + + s := []string{dir} + s = append(s, segments...) + path := filepath.Join(s...) + if _, err := os.Stat(path); err == nil { + return true + } + + return false +} + +func BranchAtRef(paths ...string) (name string, err error) { + dir, err := Dir() + if err != nil { + return + } + + segments := []string{dir} + segments = append(segments, paths...) + path := filepath.Join(segments...) + b, err := ioutil.ReadFile(path) + if err != nil { + return + } + + n := string(b) + refPrefix := "ref: " + if strings.HasPrefix(n, refPrefix) { + name = strings.TrimPrefix(n, refPrefix) + name = strings.TrimSpace(name) + } else { + err = fmt.Errorf("No branch info in %s: %s", path, n) + } + + return +} + +func Editor() (string, error) { + varCmd := exec.Command("git", "var", "GIT_EDITOR") + varCmd.Stderr = nil + output, err := varCmd.Output() + if err != nil { + return "", fmt.Errorf("Can't load git var: GIT_EDITOR") + } + + return os.ExpandEnv(firstLine(output)), nil +} + +func Head() (string, error) { + return BranchAtRef("HEAD") +} + +func SymbolicFullName(name string) (string, error) { + parseCmd := exec.Command("git", "rev-parse", "--symbolic-full-name", name) + parseCmd.Stderr = nil + output, err := parseCmd.Output() + if err != nil { + return "", fmt.Errorf("Unknown revision or path not in the working tree: %s", name) + } + + return firstLine(output), nil +} + +func Ref(ref string) (string, error) { + parseCmd := exec.Command("git", "rev-parse", "-q", ref) + parseCmd.Stderr = nil + output, err := parseCmd.Output() + if err != nil { + return "", fmt.Errorf("Unknown revision or path not in the working tree: %s", ref) + } + + return firstLine(output), nil +} + +func RefList(a, b string) ([]string, error) { + ref := fmt.Sprintf("%s...%s", a, b) + listCmd := exec.Command("git", "rev-list", "--cherry-pick", "--right-only", "--no-merges", ref) + listCmd.Stderr = nil + output, err := listCmd.Output() + if err != nil { + return nil, fmt.Errorf("Can't load rev-list for %s", ref) + } + + return outputLines(output), nil +} + +func NewRange(a, b string) (*Range, error) { + parseCmd := exec.Command("git", "rev-parse", "-q", a, b) + parseCmd.Stderr = nil + output, err := parseCmd.Output() + if err != nil { + return nil, err + } + + lines := outputLines(output) + if len(lines) != 2 { + return nil, fmt.Errorf("Can't parse range %s..%s", a, b) + } + return &Range{lines[0], lines[1]}, nil +} + +type Range struct { + A string + B string +} + +func (r *Range) IsIdentical() bool { + return strings.EqualFold(r.A, r.B) +} + +func (r *Range) IsAncestor() bool { + cmd := exec.Command("git", "merge-base", "--is-ancestor", r.A, r.B) + return cmd.Run() != nil +} + +func CommentChar(text string) (string, error) { + char, err := Config("core.commentchar") + if err != nil { + return "#", nil + } else if char == "auto" { + lines := strings.Split(text, "\n") + commentCharCandidates := strings.Split("#;@!$%^&|:", "") + candidateLoop: + for _, candidate := range commentCharCandidates { + for _, line := range lines { + if strings.HasPrefix(line, candidate) { + continue candidateLoop + } + } + return candidate, nil + } + return "", fmt.Errorf("unable to select a comment character that is not used in the current message") + } else { + return char, nil + } +} + +func Show(sha string) (string, error) { + cmd := exec.Command("git", "-c", "log.showSignature=false", "show", "-s", "--format=%s%n%+b", sha) + cmd.Stderr = nil + + output, err := cmd.Output() + return strings.TrimSpace(string(output)), err +} + +func Log(sha1, sha2 string) (string, error) { + shaRange := fmt.Sprintf("%s...%s", sha1, sha2) + cmd := exec.Command( + "-c", "log.showSignature=false", "log", "--no-color", + "--format=%h (%aN, %ar)%n%w(78,3,3)%s%n%+b", + "--cherry", shaRange) + outputs, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("Can't load git log %s..%s", sha1, sha2) + } + + return string(outputs), nil +} + +func Remotes() ([]string, error) { + remoteCmd := exec.Command("git", "remote", "-v") + remoteCmd.Stderr = nil + output, err := remoteCmd.Output() + return outputLines(output), err +} + +func Config(name string) (string, error) { + return gitGetConfig(name) +} + +func ConfigAll(name string) ([]string, error) { + mode := "--get-all" + if strings.Contains(name, "*") { + mode = "--get-regexp" + } + + configCmd := exec.Command("git", gitConfigCommand([]string{mode, name})...) + output, err := configCmd.Output() + if err != nil { + return nil, fmt.Errorf("Unknown config %s", name) + } + return outputLines(output), nil +} + +func GlobalConfig(name string) (string, error) { + return gitGetConfig("--global", name) +} + +func SetGlobalConfig(name, value string) error { + _, err := gitConfig("--global", name, value) + return err +} + +func gitGetConfig(args ...string) (string, error) { + configCmd := exec.Command("git", gitConfigCommand(args)...) + output, err := configCmd.Output() + if err != nil { + return "", fmt.Errorf("Unknown config %s", args[len(args)-1]) + } + + return firstLine(output), nil +} + +func gitConfig(args ...string) ([]string, error) { + configCmd := exec.Command("git", gitConfigCommand(args)...) + output, err := configCmd.Output() + return outputLines(output), err +} + +func gitConfigCommand(args []string) []string { + cmd := []string{"config"} + return append(cmd, args...) +} + +func Alias(name string) (string, error) { + return Config(fmt.Sprintf("alias.%s", name)) +} + +func Run(args ...string) error { + cmd := exec.Command("git", args...) + return cmd.Run() +} + +func LocalBranches() ([]string, error) { + branchesCmd := exec.Command("git", "branch", "--list") + output, err := branchesCmd.Output() + if err != nil { + return nil, err + } + + branches := []string{} + for _, branch := range outputLines(output) { + branches = append(branches, branch[2:]) + } + return branches, nil +} + +func outputLines(output []byte) []string { + lines := strings.TrimSuffix(string(output), "\n") + if lines == "" { + return []string{} + } + return strings.Split(lines, "\n") + +} + +func firstLine(output []byte) string { + if i := bytes.IndexAny(output, "\n"); i >= 0 { + return string(output)[0:i] + } + return string(output) +} diff --git a/git/ssh_config.go b/git/ssh_config.go new file mode 100644 index 000000000..6749d90cc --- /dev/null +++ b/git/ssh_config.go @@ -0,0 +1,90 @@ +package git + +import ( + "bufio" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/mitchellh/go-homedir" +) + +const ( + hostReStr = "(?i)^[ \t]*(host|hostname)[ \t]+(.+)$" +) + +type SSHConfig map[string]string + +func newSSHConfigReader() *SSHConfigReader { + configFiles := []string{ + "/etc/ssh_config", + "/etc/ssh/ssh_config", + } + if homedir, err := homedir.Dir(); err == nil { + userConfig := filepath.Join(homedir, ".ssh", "config") + configFiles = append([]string{userConfig}, configFiles...) + } + return &SSHConfigReader{ + Files: configFiles, + } +} + +type SSHConfigReader struct { + Files []string +} + +func (r *SSHConfigReader) Read() SSHConfig { + config := make(SSHConfig) + hostRe := regexp.MustCompile(hostReStr) + + for _, filename := range r.Files { + r.readFile(config, hostRe, filename) + } + + return config +} + +func (r *SSHConfigReader) readFile(c SSHConfig, re *regexp.Regexp, f string) error { + file, err := os.Open(f) + if err != nil { + return err + } + defer file.Close() + + hosts := []string{"*"} + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + match := re.FindStringSubmatch(line) + if match == nil { + continue + } + + names := strings.Fields(match[2]) + if strings.EqualFold(match[1], "host") { + hosts = names + } else { + for _, host := range hosts { + for _, name := range names { + c[host] = expandTokens(name, host) + } + } + } + } + + return scanner.Err() +} + +func expandTokens(text, host string) string { + re := regexp.MustCompile(`%[%h]`) + return re.ReplaceAllStringFunc(text, func(match string) string { + switch match { + case "%h": + return host + case "%%": + return "%" + } + return "" + }) +} diff --git a/git/url.go b/git/url.go new file mode 100644 index 000000000..f3f4adf99 --- /dev/null +++ b/git/url.go @@ -0,0 +1,66 @@ +package git + +import ( + "net/url" + "regexp" + "strings" +) + +var ( + cachedSSHConfig SSHConfig + protocolRe = regexp.MustCompile("^[a-zA-Z_+-]+://") +) + +type URLParser struct { + SSHConfig SSHConfig +} + +func (p *URLParser) Parse(rawURL string) (u *url.URL, err error) { + if !protocolRe.MatchString(rawURL) && + strings.Contains(rawURL, ":") && + // not a Windows path + !strings.Contains(rawURL, "\\") { + rawURL = "ssh://" + strings.Replace(rawURL, ":", "/", 1) + } + + u, err = url.Parse(rawURL) + if err != nil { + return + } + + if u.Scheme == "git+ssh" { + u.Scheme = "ssh" + } + + if u.Scheme != "ssh" { + return + } + + if strings.HasPrefix(u.Path, "//") { + u.Path = strings.TrimPrefix(u.Path, "/") + } + + if idx := strings.Index(u.Host, ":"); idx >= 0 { + u.Host = u.Host[0:idx] + } + + sshHost := p.SSHConfig[u.Host] + // ignore replacing host that fixes for limited network + // https://help.github.com/articles/using-ssh-over-the-https-port + ignoredHost := u.Host == "github.com" && sshHost == "ssh.github.com" + if !ignoredHost && sshHost != "" { + u.Host = sshHost + } + + return +} + +func ParseURL(rawURL string) (u *url.URL, err error) { + if cachedSSHConfig == nil { + cachedSSHConfig = newSSHConfigReader().Read() + } + + p := &URLParser{cachedSSHConfig} + + return p.Parse(rawURL) +} diff --git a/github/client.go b/github/client.go new file mode 100644 index 000000000..134368b45 --- /dev/null +++ b/github/client.go @@ -0,0 +1,1140 @@ +package github + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/github/gh/version" +) + +const ( + GitHubHost string = "github.com" + OAuthAppURL string = "https://hub.github.com/" +) + +var UserAgent = "Hub " + version.Version + +func NewClient(h string) *Client { + return NewClientWithHost(&Host{Host: h}) +} + +func NewClientWithHost(host *Host) *Client { + return &Client{Host: host} +} + +type Client struct { + Host *Host + cachedClient *simpleClient +} + +func (client *Client) FetchPullRequests(project *Project, filterParams map[string]interface{}, limit int, filter func(*PullRequest) bool) (pulls []PullRequest, err error) { + api, err := client.simpleApi() + if err != nil { + return + } + + path := fmt.Sprintf("repos/%s/%s/pulls?per_page=%d", project.Owner, project.Name, perPage(limit, 100)) + if filterParams != nil { + path = addQuery(path, filterParams) + } + + pulls = []PullRequest{} + var res *simpleResponse + + for path != "" { + res, err = api.GetFile(path, draftsType) + if err = checkStatus(200, "fetching pull requests", res, err); err != nil { + return + } + path = res.Link("next") + + pullsPage := []PullRequest{} + if err = res.Unmarshal(&pullsPage); err != nil { + return + } + for _, pr := range pullsPage { + if filter == nil || filter(&pr) { + pulls = append(pulls, pr) + if limit > 0 && len(pulls) == limit { + path = "" + break + } + } + } + } + + return +} + +func (client *Client) PullRequest(project *Project, id string) (pr *PullRequest, err error) { + api, err := client.simpleApi() + if err != nil { + return + } + + res, err := api.Get(fmt.Sprintf("repos/%s/%s/pulls/%s", project.Owner, project.Name, id)) + if err = checkStatus(200, "getting pull request", res, err); err != nil { + return + } + + pr = &PullRequest{} + err = res.Unmarshal(pr) + + return +} + +func (client *Client) PullRequestPatch(project *Project, id string) (patch io.ReadCloser, err error) { + api, err := client.simpleApi() + if err != nil { + return + } + + res, err := api.GetFile(fmt.Sprintf("repos/%s/%s/pulls/%s", project.Owner, project.Name, id), patchMediaType) + if err = checkStatus(200, "getting pull request patch", res, err); err != nil { + return + } + + return res.Body, nil +} + +func (client *Client) CreatePullRequest(project *Project, params map[string]interface{}) (pr *PullRequest, err error) { + api, err := client.simpleApi() + if err != nil { + return + } + + res, err := api.PostJSONPreview(fmt.Sprintf("repos/%s/%s/pulls", project.Owner, project.Name), params, draftsType) + if err = checkStatus(201, "creating pull request", res, err); err != nil { + if res != nil && res.StatusCode == 404 { + projectUrl := strings.SplitN(project.WebURL("", "", ""), "://", 2)[1] + err = fmt.Errorf("%s\nAre you sure that %s exists?", err, projectUrl) + } + return + } + + pr = &PullRequest{} + err = res.Unmarshal(pr) + + return +} + +func (client *Client) UpdatePullRequest(pr *PullRequest, params map[string]interface{}) (updatedPullRequest *PullRequest, err error) { + api, err := client.simpleApi() + if err != nil { + return + } + + res, err := api.PatchJSON(pr.ApiUrl, params) + if err = checkStatus(200, "updating pull request", res, err); err != nil { + return + } + + updatedPullRequest = &PullRequest{} + err = res.Unmarshal(updatedPullRequest) + return +} + +func (client *Client) RequestReview(project *Project, prNumber int, params map[string]interface{}) (err error) { + api, err := client.simpleApi() + if err != nil { + return + } + + res, err := api.PostJSON(fmt.Sprintf("repos/%s/%s/pulls/%d/requested_reviewers", project.Owner, project.Name, prNumber), params) + if err = checkStatus(201, "requesting reviewer", res, err); err != nil { + return + } + + res.Body.Close() + return +} + +func (client *Client) CommitPatch(project *Project, sha string) (patch io.ReadCloser, err error) { + api, err := client.simpleApi() + if err != nil { + return + } + + res, err := api.GetFile(fmt.Sprintf("repos/%s/%s/commits/%s", project.Owner, project.Name, sha), patchMediaType) + if err = checkStatus(200, "getting commit patch", res, err); err != nil { + return + } + + return res.Body, nil +} + +type Gist struct { + Files map[string]GistFile `json:"files"` +} +type GistFile struct { + RawUrl string `json:"raw_url"` +} + +func (client *Client) GistPatch(id string) (patch io.ReadCloser, err error) { + api, err := client.simpleApi() + if err != nil { + return + } + + res, err := api.Get(fmt.Sprintf("gists/%s", id)) + if err = checkStatus(200, "getting gist patch", res, err); err != nil { + return + } + + gist := Gist{} + if err = res.Unmarshal(&gist); err != nil { + return + } + rawUrl := "" + for _, file := range gist.Files { + rawUrl = file.RawUrl + break + } + + res, err = api.GetFile(rawUrl, textMediaType) + if err = checkStatus(200, "getting gist patch", res, err); err != nil { + return + } + + return res.Body, nil +} + +func (client *Client) Repository(project *Project) (repo *Repository, err error) { + api, err := client.simpleApi() + if err != nil { + return + } + + res, err := api.Get(fmt.Sprintf("repos/%s/%s", project.Owner, project.Name)) + if err = checkStatus(200, "getting repository info", res, err); err != nil { + return + } + + repo = &Repository{} + err = res.Unmarshal(&repo) + return +} + +func (client *Client) CreateRepository(project *Project, description, homepage string, isPrivate bool) (repo *Repository, err error) { + repoURL := "user/repos" + if project.Owner != client.Host.User { + repoURL = fmt.Sprintf("orgs/%s/repos", project.Owner) + } + + params := map[string]interface{}{ + "name": project.Name, + "description": description, + "homepage": homepage, + "private": isPrivate, + } + + api, err := client.simpleApi() + if err != nil { + return + } + + res, err := api.PostJSON(repoURL, params) + if err = checkStatus(201, "creating repository", res, err); err != nil { + return + } + + repo = &Repository{} + err = res.Unmarshal(repo) + return +} + +func (client *Client) DeleteRepository(project *Project) error { + api, err := client.simpleApi() + if err != nil { + return err + } + + repoURL := fmt.Sprintf("repos/%s/%s", project.Owner, project.Name) + res, err := api.Delete(repoURL) + return checkStatus(204, "deleting repository", res, err) +} + +type Release struct { + Name string `json:"name"` + TagName string `json:"tag_name"` + TargetCommitish string `json:"target_commitish"` + Body string `json:"body"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` + Assets []ReleaseAsset `json:"assets"` + TarballUrl string `json:"tarball_url"` + ZipballUrl string `json:"zipball_url"` + HtmlUrl string `json:"html_url"` + UploadUrl string `json:"upload_url"` + ApiUrl string `json:"url"` + CreatedAt time.Time `json:"created_at"` + PublishedAt time.Time `json:"published_at"` +} + +type ReleaseAsset struct { + Name string `json:"name"` + Label string `json:"label"` + DownloadUrl string `json:"browser_download_url"` + ApiUrl string `json:"url"` +} + +func (client *Client) FetchReleases(project *Project, limit int, filter func(*Release) bool) (releases []Release, err error) { + api, err := client.simpleApi() + if err != nil { + return + } + + path := fmt.Sprintf("repos/%s/%s/releases?per_page=%d", project.Owner, project.Name, perPage(limit, 100)) + + releases = []Release{} + var res *simpleResponse + + for path != "" { + res, err = api.Get(path) + if err = checkStatus(200, "fetching releases", res, err); err != nil { + return + } + path = res.Link("next") + + releasesPage := []Release{} + if err = res.Unmarshal(&releasesPage); err != nil { + return + } + for _, release := range releasesPage { + if filter == nil || filter(&release) { + releases = append(releases, release) + if limit > 0 && len(releases) == limit { + path = "" + break + } + } + } + } + + return +} + +func (client *Client) FetchRelease(project *Project, tagName string) (*Release, error) { + releases, err := client.FetchReleases(project, 100, func(release *Release) bool { + return release.TagName == tagName + }) + + if err == nil { + if len(releases) < 1 { + return nil, fmt.Errorf("Unable to find release with tag name `%s'", tagName) + } else { + return &releases[0], nil + } + } else { + return nil, err + } +} + +func (client *Client) CreateRelease(project *Project, releaseParams *Release) (release *Release, err error) { + api, err := client.simpleApi() + if err != nil { + return + } + + res, err := api.PostJSON(fmt.Sprintf("repos/%s/%s/releases", project.Owner, project.Name), releaseParams) + if err = checkStatus(201, "creating release", res, err); err != nil { + return + } + + release = &Release{} + err = res.Unmarshal(release) + return +} + +func (client *Client) EditRelease(release *Release, releaseParams map[string]interface{}) (updatedRelease *Release, err error) { + api, err := client.simpleApi() + if err != nil { + return + } + + res, err := api.PatchJSON(release.ApiUrl, releaseParams) + if err = checkStatus(200, "editing release", res, err); err != nil { + return + } + + updatedRelease = &Release{} + err = res.Unmarshal(updatedRelease) + return +} + +func (client *Client) DeleteRelease(release *Release) (err error) { + api, err := client.simpleApi() + if err != nil { + return + } + + res, err := api.Delete(release.ApiUrl) + if err = checkStatus(204, "deleting release", res, err); err != nil { + return + } + + return +} + +func (client *Client) UploadReleaseAsset(release *Release, filename, label string) (asset *ReleaseAsset, err error) { + api, err := client.simpleApi() + if err != nil { + return + } + + parts := strings.SplitN(release.UploadUrl, "{", 2) + uploadUrl := parts[0] + uploadUrl += "?name=" + url.QueryEscape(filepath.Base(filename)) + if label != "" { + uploadUrl += "&label=" + url.QueryEscape(label) + } + + res, err := api.PostFile(uploadUrl, filename) + if err = checkStatus(201, "uploading release asset", res, err); err != nil { + return + } + + asset = &ReleaseAsset{} + err = res.Unmarshal(asset) + return +} + +func (client *Client) DeleteReleaseAsset(asset *ReleaseAsset) (err error) { + api, err := client.simpleApi() + if err != nil { + return + } + + res, err := api.Delete(asset.ApiUrl) + err = checkStatus(204, "deleting release asset", res, err) + + return +} + +func (client *Client) DownloadReleaseAsset(url string) (asset io.ReadCloser, err error) { + api, err := client.simpleApi() + if err != nil { + return + } + + resp, err := api.GetFile(url, "application/octet-stream") + if err = checkStatus(200, "downloading asset", resp, err); err != nil { + return + } + + return resp.Body, err +} + +type CIStatusResponse struct { + State string `json:"state"` + Statuses []CIStatus `json:"statuses"` +} + +type CIStatus struct { + State string `json:"state"` + Context string `json:"context"` + TargetUrl string `json:"target_url"` +} + +type CheckRunsResponse struct { + CheckRuns []CheckRun `json:"check_runs"` +} + +type CheckRun struct { + Status string `json:"status"` + Conclusion string `json:"conclusion"` + Name string `json:"name"` + HtmlUrl string `json:"html_url"` +} + +func (client *Client) FetchCIStatus(project *Project, sha string) (status *CIStatusResponse, err error) { + api, err := client.simpleApi() + if err != nil { + return + } + + res, err := api.Get(fmt.Sprintf("repos/%s/%s/commits/%s/status", project.Owner, project.Name, sha)) + if err = checkStatus(200, "fetching statuses", res, err); err != nil { + return + } + + status = &CIStatusResponse{} + if err = res.Unmarshal(status); err != nil { + return + } + + sortStatuses := func() { + sort.Slice(status.Statuses, func(a, b int) bool { + sA := status.Statuses[a] + sB := status.Statuses[b] + cmp := strings.Compare(strings.ToLower(sA.Context), strings.ToLower(sB.Context)) + if cmp == 0 { + return strings.Compare(sA.TargetUrl, sB.TargetUrl) < 0 + } else { + return cmp < 0 + } + }) + } + sortStatuses() + + res, err = api.GetFile(fmt.Sprintf("repos/%s/%s/commits/%s/check-runs", project.Owner, project.Name, sha), checksType) + if err == nil && (res.StatusCode == 403 || res.StatusCode == 404 || res.StatusCode == 422) { + return + } + if err = checkStatus(200, "fetching checks", res, err); err != nil { + return + } + + checks := &CheckRunsResponse{} + if err = res.Unmarshal(checks); err != nil { + return + } + + for _, checkRun := range checks.CheckRuns { + state := "pending" + if checkRun.Status == "completed" { + state = checkRun.Conclusion + } + checkStatus := CIStatus{ + State: state, + Context: checkRun.Name, + TargetUrl: checkRun.HtmlUrl, + } + status.Statuses = append(status.Statuses, checkStatus) + } + + sortStatuses() + + return +} + +type Repository struct { + Name string `json:"name"` + FullName string `json:"full_name"` + Parent *Repository `json:"parent"` + Owner *User `json:"owner"` + Private bool `json:"private"` + HasWiki bool `json:"has_wiki"` + Permissions *RepositoryPermissions `json:"permissions"` + HtmlUrl string `json:"html_url"` + DefaultBranch string `json:"default_branch"` +} + +type RepositoryPermissions struct { + Admin bool `json:"admin"` + Push bool `json:"push"` + Pull bool `json:"pull"` +} + +func (client *Client) ForkRepository(project *Project, params map[string]interface{}) (repo *Repository, err error) { + api, err := client.simpleApi() + if err != nil { + return + } + + res, err := api.PostJSON(fmt.Sprintf("repos/%s/%s/forks", project.Owner, project.Name), params) + if err = checkStatus(202, "creating fork", res, err); err != nil { + return + } + + repo = &Repository{} + err = res.Unmarshal(repo) + + return +} + +type Comment struct { + Id int `json:"id"` + Body string `json:"body"` + User *User `json:"user"` + CreatedAt time.Time `json:"created_at"` +} + +type Issue struct { + Number int `json:"number"` + State string `json:"state"` + Title string `json:"title"` + Body string `json:"body"` + User *User `json:"user"` + + PullRequest *PullRequest `json:"pull_request"` + Head *PullRequestSpec `json:"head"` + Base *PullRequestSpec `json:"base"` + + MergeCommitSha string `json:"merge_commit_sha"` + MaintainerCanModify bool `json:"maintainer_can_modify"` + Draft bool `json:"draft"` + + Comments int `json:"comments"` + Labels []IssueLabel `json:"labels"` + Assignees []User `json:"assignees"` + Milestone *Milestone `json:"milestone"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + MergedAt time.Time `json:"merged_at"` + + RequestedReviewers []User `json:"requested_reviewers"` + RequestedTeams []Team `json:"requested_teams"` + + ApiUrl string `json:"url"` + HtmlUrl string `json:"html_url"` + + ClosedBy *User `json:"closed_by"` +} + +type PullRequest Issue + +type PullRequestSpec struct { + Label string `json:"label"` + Ref string `json:"ref"` + Sha string `json:"sha"` + Repo *Repository `json:"repo"` +} + +func (pr *PullRequest) IsSameRepo() bool { + return pr.Head != nil && pr.Head.Repo != nil && + pr.Head.Repo.Name == pr.Base.Repo.Name && + pr.Head.Repo.Owner.Login == pr.Base.Repo.Owner.Login +} + +func (pr *PullRequest) HasRequestedReviewer(name string) bool { + for _, user := range pr.RequestedReviewers { + if strings.EqualFold(user.Login, name) { + return true + } + } + return false +} + +func (pr *PullRequest) HasRequestedTeam(name string) bool { + for _, team := range pr.RequestedTeams { + if strings.EqualFold(team.Slug, name) { + return true + } + } + return false +} + +type IssueLabel struct { + Name string `json:"name"` + Color string `json:"color"` +} + +type User struct { + Login string `json:"login"` +} + +type Team struct { + Name string `json:"name"` + Slug string `json:"slug"` +} + +type Milestone struct { + Number int `json:"number"` + Title string `json:"title"` +} + +func (client *Client) FetchIssues(project *Project, filterParams map[string]interface{}, limit int, filter func(*Issue) bool) (issues []Issue, err error) { + api, err := client.simpleApi() + if err != nil { + return + } + + path := fmt.Sprintf("repos/%s/%s/issues?per_page=%d", project.Owner, project.Name, perPage(limit, 100)) + if filterParams != nil { + path = addQuery(path, filterParams) + } + + issues = []Issue{} + var res *simpleResponse + + for path != "" { + res, err = api.Get(path) + if err = checkStatus(200, "fetching issues", res, err); err != nil { + return + } + path = res.Link("next") + + issuesPage := []Issue{} + if err = res.Unmarshal(&issuesPage); err != nil { + return + } + for _, issue := range issuesPage { + if filter == nil || filter(&issue) { + issues = append(issues, issue) + if limit > 0 && len(issues) == limit { + path = "" + break + } + } + } + } + + return +} + +func (client *Client) FetchIssue(project *Project, number string) (issue *Issue, err error) { + api, err := client.simpleApi() + if err != nil { + return + } + + res, err := api.Get(fmt.Sprintf("repos/%s/%s/issues/%s", project.Owner, project.Name, number)) + if err = checkStatus(200, "fetching issue", res, err); err != nil { + return nil, err + } + + issue = &Issue{} + err = res.Unmarshal(issue) + return +} + +func (client *Client) FetchComments(project *Project, number string) (comments []Comment, err error) { + api, err := client.simpleApi() + if err != nil { + return + } + + res, err := api.Get(fmt.Sprintf("repos/%s/%s/issues/%s/comments", project.Owner, project.Name, number)) + if err = checkStatus(200, "fetching comments for issue", res, err); err != nil { + return nil, err + } + + comments = []Comment{} + err = res.Unmarshal(&comments) + return +} + +func (client *Client) CreateIssue(project *Project, params interface{}) (issue *Issue, err error) { + api, err := client.simpleApi() + if err != nil { + return + } + + res, err := api.PostJSON(fmt.Sprintf("repos/%s/%s/issues", project.Owner, project.Name), params) + if err = checkStatus(201, "creating issue", res, err); err != nil { + return + } + + issue = &Issue{} + err = res.Unmarshal(issue) + return +} + +func (client *Client) UpdateIssue(project *Project, issueNumber int, params map[string]interface{}) (err error) { + api, err := client.simpleApi() + if err != nil { + return + } + + res, err := api.PatchJSON(fmt.Sprintf("repos/%s/%s/issues/%d", project.Owner, project.Name, issueNumber), params) + if err = checkStatus(200, "updating issue", res, err); err != nil { + return + } + + res.Body.Close() + return +} + +type sortedLabels []IssueLabel + +func (s sortedLabels) Len() int { + return len(s) +} +func (s sortedLabels) Swap(i, j int) { + s[i], s[j] = s[j], s[i] +} +func (s sortedLabels) Less(i, j int) bool { + return strings.Compare(strings.ToLower(s[i].Name), strings.ToLower(s[j].Name)) < 0 +} + +func (client *Client) FetchLabels(project *Project) (labels []IssueLabel, err error) { + api, err := client.simpleApi() + if err != nil { + return + } + + path := fmt.Sprintf("repos/%s/%s/labels?per_page=100", project.Owner, project.Name) + + labels = []IssueLabel{} + var res *simpleResponse + + for path != "" { + res, err = api.Get(path) + if err = checkStatus(200, "fetching labels", res, err); err != nil { + return + } + path = res.Link("next") + + labelsPage := []IssueLabel{} + if err = res.Unmarshal(&labelsPage); err != nil { + return + } + labels = append(labels, labelsPage...) + } + + sort.Sort(sortedLabels(labels)) + + return +} + +func (client *Client) FetchMilestones(project *Project) (milestones []Milestone, err error) { + api, err := client.simpleApi() + if err != nil { + return + } + + path := fmt.Sprintf("repos/%s/%s/milestones?per_page=100", project.Owner, project.Name) + + milestones = []Milestone{} + var res *simpleResponse + + for path != "" { + res, err = api.Get(path) + if err = checkStatus(200, "fetching milestones", res, err); err != nil { + return + } + path = res.Link("next") + + milestonesPage := []Milestone{} + if err = res.Unmarshal(&milestonesPage); err != nil { + return + } + milestones = append(milestones, milestonesPage...) + } + + return +} + +func (client *Client) GenericAPIRequest(method, path string, data interface{}, headers map[string]string, ttl int) (*simpleResponse, error) { + api, err := client.simpleApi() + if err != nil { + return nil, err + } + api.CacheTTL = ttl + + var body io.Reader + switch d := data.(type) { + case map[string]interface{}: + if method == "GET" { + path = addQuery(path, d) + } else if len(d) > 0 { + json, err := json.Marshal(d) + if err != nil { + return nil, err + } + body = bytes.NewBuffer(json) + } + case io.Reader: + body = d + } + + return api.performRequest(method, path, body, func(req *http.Request) { + if body != nil { + req.Header.Set("Content-Type", "application/json; charset=utf-8") + } + for key, value := range headers { + req.Header.Set(key, value) + } + }) +} + +func (client *Client) CurrentUser() (user *User, err error) { + api, err := client.simpleApi() + if err != nil { + return + } + + res, err := api.Get("user") + if err = checkStatus(200, "getting current user", res, err); err != nil { + return + } + + user = &User{} + err = res.Unmarshal(user) + return +} + +type AuthorizationEntry struct { + Token string `json:"token"` +} + +func isToken(api *simpleClient, password string) bool { + api.PrepareRequest = func(req *http.Request) { + req.Header.Set("Authorization", "token "+password) + } + + res, _ := api.Get("user") + if res != nil && res.StatusCode == 200 { + return true + } + return false +} + +func (client *Client) FindOrCreateToken(user, password, twoFactorCode string) (token string, err error) { + api := client.apiClient() + + if len(password) >= 40 && isToken(api, password) { + return password, nil + } + + params := map[string]interface{}{ + "scopes": []string{"repo"}, + "note_url": OAuthAppURL, + } + + api.PrepareRequest = func(req *http.Request) { + req.SetBasicAuth(user, password) + if twoFactorCode != "" { + req.Header.Set("X-GitHub-OTP", twoFactorCode) + } + } + + count := 1 + maxTries := 9 + for { + params["note"], err = authTokenNote(count) + if err != nil { + return + } + + res, postErr := api.PostJSON("authorizations", params) + if postErr != nil { + err = postErr + break + } + + if res.StatusCode == 201 { + auth := &AuthorizationEntry{} + if err = res.Unmarshal(auth); err != nil { + return + } + token = auth.Token + break + } else if res.StatusCode == 422 && count < maxTries { + count++ + } else { + errInfo, e := res.ErrorInfo() + if e == nil { + err = errInfo + } else { + err = e + } + return + } + } + + return +} + +func (client *Client) ensureAccessToken() error { + if client.Host.AccessToken == "" { + host, err := CurrentConfig().PromptForHost(client.Host.Host) + if err != nil { + return err + } + client.Host = host + } + return nil +} + +func (client *Client) simpleApi() (c *simpleClient, err error) { + err = client.ensureAccessToken() + if err != nil { + return + } + + if client.cachedClient != nil { + c = client.cachedClient + return + } + + c = client.apiClient() + c.PrepareRequest = func(req *http.Request) { + clientDomain := normalizeHost(client.Host.Host) + if strings.HasPrefix(clientDomain, "api.github.") { + clientDomain = strings.TrimPrefix(clientDomain, "api.") + } + requestHost := strings.ToLower(req.URL.Host) + if requestHost == clientDomain || strings.HasSuffix(requestHost, "."+clientDomain) { + req.Header.Set("Authorization", "token "+client.Host.AccessToken) + } + } + + client.cachedClient = c + return +} + +func (client *Client) apiClient() *simpleClient { + unixSocket := os.ExpandEnv(client.Host.UnixSocket) + httpClient := newHttpClient(os.Getenv("HUB_TEST_HOST"), os.Getenv("HUB_VERBOSE") != "", unixSocket) + apiRoot := client.absolute(normalizeHost(client.Host.Host)) + if !strings.HasPrefix(apiRoot.Host, "api.github.") { + apiRoot.Path = "/api/v3/" + } + + return &simpleClient{ + httpClient: httpClient, + rootUrl: apiRoot, + } +} + +func (client *Client) absolute(host string) *url.URL { + u, err := url.Parse("https://" + host + "/") + if err != nil { + panic(err) + } else if client.Host != nil && client.Host.Protocol != "" { + u.Scheme = client.Host.Protocol + } + return u +} + +func normalizeHost(host string) string { + if host == "" { + return GitHubHost + } else if strings.EqualFold(host, GitHubHost) { + return "api.github.com" + } else if strings.EqualFold(host, "github.localhost") { + return "api.github.localhost" + } else { + return strings.ToLower(host) + } +} + +func checkStatus(expectedStatus int, action string, response *simpleResponse, err error) error { + if err != nil { + return fmt.Errorf("Error %s: %s", action, err.Error()) + } else if response.StatusCode != expectedStatus { + errInfo, err := response.ErrorInfo() + if err == nil { + return FormatError(action, errInfo) + } else { + return fmt.Errorf("Error %s: %s (HTTP %d)", action, err.Error(), response.StatusCode) + } + } else { + return nil + } +} + +func FormatError(action string, err error) (ee error) { + switch e := err.(type) { + default: + ee = err + case *errorInfo: + statusCode := e.Response.StatusCode + var reason string + if s := strings.SplitN(e.Response.Status, " ", 2); len(s) >= 2 { + reason = strings.TrimSpace(s[1]) + } + + errStr := fmt.Sprintf("Error %s: %s (HTTP %d)", action, reason, statusCode) + + var errorSentences []string + for _, err := range e.Errors { + switch err.Code { + case "custom": + errorSentences = append(errorSentences, err.Message) + case "missing_field": + errorSentences = append(errorSentences, fmt.Sprintf("Missing field: \"%s\"", err.Field)) + case "already_exists": + errorSentences = append(errorSentences, fmt.Sprintf("Duplicate value for \"%s\"", err.Field)) + case "invalid": + errorSentences = append(errorSentences, fmt.Sprintf("Invalid value for \"%s\"", err.Field)) + case "unauthorized": + errorSentences = append(errorSentences, fmt.Sprintf("Not allowed to change field \"%s\"", err.Field)) + } + } + + var errorMessage string + if len(errorSentences) > 0 { + errorMessage = strings.Join(errorSentences, "\n") + } else { + errorMessage = e.Message + if action == "getting current user" && e.Message == "Resource not accessible by integration" { + errorMessage = errorMessage + "\nYou must specify GITHUB_USER via environment variable." + } + } + + if errorMessage != "" { + errStr = fmt.Sprintf("%s\n%s", errStr, errorMessage) + } + + ee = fmt.Errorf(errStr) + } + + return +} + +func authTokenNote(num int) (string, error) { + n := os.Getenv("USER") + + if n == "" { + n = os.Getenv("USERNAME") + } + + if n == "" { + whoami := exec.Command("whoami") + whoamiOut, err := whoami.Output() + if err != nil { + return "", err + } + n = strings.TrimSpace(string(whoamiOut)) + } + + h, err := os.Hostname() + if err != nil { + return "", err + } + + if num > 1 { + return fmt.Sprintf("hub for %s@%s %d", n, h, num), nil + } + + return fmt.Sprintf("hub for %s@%s", n, h), nil +} + +func perPage(limit, max int) int { + if limit > 0 { + limit = limit + (limit / 2) + if limit < max { + return limit + } + } + return max +} + +func addQuery(path string, params map[string]interface{}) string { + if len(params) == 0 { + return path + } + + query := url.Values{} + for key, value := range params { + switch v := value.(type) { + case string: + query.Add(key, v) + case nil: + query.Add(key, "") + case int: + query.Add(key, fmt.Sprintf("%d", v)) + case bool: + query.Add(key, fmt.Sprintf("%v", v)) + } + } + + sep := "?" + if strings.Contains(path, sep) { + sep = "&" + } + return path + sep + query.Encode() +} diff --git a/github/config.go b/github/config.go new file mode 100644 index 000000000..1a27eef6b --- /dev/null +++ b/github/config.go @@ -0,0 +1,409 @@ +package github + +import ( + "bufio" + "fmt" + "io/ioutil" + "net/url" + "os" + "os/signal" + "path/filepath" + "strconv" + "strings" + "syscall" + + "github.com/github/gh/ui" + "github.com/github/gh/utils" + "github.com/mitchellh/go-homedir" + "golang.org/x/crypto/ssh/terminal" +) + +type yamlHost struct { + User string `yaml:"user"` + OAuthToken string `yaml:"oauth_token"` + Protocol string `yaml:"protocol"` + UnixSocket string `yaml:"unix_socket,omitempty"` +} + +type Host struct { + Host string `toml:"host"` + User string `toml:"user"` + AccessToken string `toml:"access_token"` + Protocol string `toml:"protocol"` + UnixSocket string `toml:"unix_socket,omitempty"` +} + +type Config struct { + Hosts []*Host `toml:"hosts"` +} + +func (c *Config) PromptForHost(host string) (h *Host, err error) { + token := c.DetectToken() + tokenFromEnv := token != "" + + if host != GitHubHost { + if _, e := url.Parse("https://" + host); e != nil { + err = fmt.Errorf("invalid hostname: %q", host) + return + } + } + + h = c.Find(host) + if h != nil { + if h.User == "" { + utils.Check(CheckWriteable(configsFile())) + // User is missing from the config: this is a broken config probably + // because it was created with an old (broken) version of hub. Let's fix + // it now. See issue #1007 for details. + user := c.PromptForUser(host) + if user == "" { + utils.Check(fmt.Errorf("missing user")) + } + h.User = user + err := newConfigService().Save(configsFile(), c) + utils.Check(err) + } + if tokenFromEnv { + h.AccessToken = token + } else { + return + } + } else { + h = &Host{ + Host: host, + AccessToken: token, + Protocol: "https", + } + c.Hosts = append(c.Hosts, h) + } + + client := NewClientWithHost(h) + + if !tokenFromEnv { + utils.Check(CheckWriteable(configsFile())) + err = c.authorizeClient(client, host) + if err != nil { + return + } + } + + userFromEnv := os.Getenv("GITHUB_USER") + repoFromEnv := os.Getenv("GITHUB_REPOSITORY") + if userFromEnv == "" && repoFromEnv != "" { + repoParts := strings.SplitN(repoFromEnv, "/", 2) + if len(repoParts) > 0 { + userFromEnv = repoParts[0] + } + } + if tokenFromEnv && userFromEnv != "" { + h.User = userFromEnv + } else { + var currentUser *User + currentUser, err = client.CurrentUser() + if err != nil { + return + } + h.User = currentUser.Login + } + + if !tokenFromEnv { + err = newConfigService().Save(configsFile(), c) + } + + return +} + +func (c *Config) authorizeClient(client *Client, host string) (err error) { + user := c.PromptForUser(host) + pass := c.PromptForPassword(host, user) + + var code, token string + for { + token, err = client.FindOrCreateToken(user, pass, code) + if err == nil { + break + } + + if ae, ok := err.(*errorInfo); ok && strings.HasPrefix(ae.Response.Header.Get("X-GitHub-OTP"), "required;") { + if code != "" { + ui.Errorln("warning: invalid two-factor code") + } + code = c.PromptForOTP() + } else { + break + } + } + + if err == nil { + client.Host.AccessToken = token + } + + return +} + +func (c *Config) DetectToken() string { + return os.Getenv("GITHUB_TOKEN") +} + +func (c *Config) PromptForUser(host string) (user string) { + user = os.Getenv("GITHUB_USER") + if user != "" { + return + } + + ui.Printf("%s username: ", host) + user = c.scanLine() + + return +} + +func (c *Config) PromptForPassword(host, user string) (pass string) { + pass = os.Getenv("GITHUB_PASSWORD") + if pass != "" { + return + } + + ui.Printf("%s password for %s (never stored): ", host, user) + if ui.IsTerminal(os.Stdin) { + if password, err := getPassword(); err == nil { + pass = password + } + } else { + pass = c.scanLine() + } + + return +} + +func (c *Config) PromptForOTP() string { + fmt.Print("two-factor authentication code: ") + return c.scanLine() +} + +func (c *Config) scanLine() string { + var line string + scanner := bufio.NewScanner(os.Stdin) + if scanner.Scan() { + line = scanner.Text() + } + utils.Check(scanner.Err()) + + return line +} + +func getPassword() (string, error) { + stdin := int(syscall.Stdin) + initialTermState, err := terminal.GetState(stdin) + if err != nil { + return "", err + } + + c := make(chan os.Signal) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + go func() { + s := <-c + terminal.Restore(stdin, initialTermState) + switch sig := s.(type) { + case syscall.Signal: + if int(sig) == 2 { + fmt.Println("^C") + } + } + os.Exit(1) + }() + + passBytes, err := terminal.ReadPassword(stdin) + if err != nil { + return "", err + } + + signal.Stop(c) + fmt.Print("\n") + return string(passBytes), nil +} + +func (c *Config) Find(host string) *Host { + for _, h := range c.Hosts { + if h.Host == host { + return h + } + } + + return nil +} + +func (c *Config) selectHost() *Host { + options := len(c.Hosts) + + if options == 1 { + return c.Hosts[0] + } + + prompt := "Select host:\n" + for idx, host := range c.Hosts { + prompt += fmt.Sprintf(" %d. %s\n", idx+1, host.Host) + } + prompt += fmt.Sprint("> ") + + ui.Printf(prompt) + index := c.scanLine() + i, err := strconv.Atoi(index) + if err != nil || i < 1 || i > options { + utils.Check(fmt.Errorf("Error: must enter a number [1-%d]", options)) + } + + return c.Hosts[i-1] +} + +var defaultConfigsFile string + +func configsFile() string { + if configFromEnv := os.Getenv("HUB_CONFIG"); configFromEnv != "" { + return configFromEnv + } + if defaultConfigsFile == "" { + var err error + defaultConfigsFile, err = determineConfigLocation() + utils.Check(err) + } + return defaultConfigsFile +} + +func homeConfig() (string, error) { + if home, err := homedir.Dir(); err != nil { + return "", err + } else { + return filepath.Join(home, ".config"), nil + } +} + +func determineConfigLocation() (string, error) { + var err error + + xdgHome := os.Getenv("XDG_CONFIG_HOME") + configDir := xdgHome + if configDir == "" { + if configDir, err = homeConfig(); err != nil { + return "", err + } + } + + xdgDirs := os.Getenv("XDG_CONFIG_DIRS") + if xdgDirs == "" { + xdgDirs = "/etc/xdg" + } + searchDirs := append([]string{configDir}, strings.Split(xdgDirs, ":")...) + + for _, dir := range searchDirs { + filename := filepath.Join(dir, "hub") + if _, err := os.Stat(filename); err == nil { + return filename, nil + } + } + + configFile := filepath.Join(configDir, "hub") + + if configDir == xdgHome { + if homeDir, _ := homeConfig(); homeDir != "" { + legacyConfig := filepath.Join(homeDir, "hub") + if _, err = os.Stat(legacyConfig); err == nil { + ui.Errorf("Notice: config file found but not respected at: %s\n", legacyConfig) + ui.Errorf("You might want to move it to `%s' to avoid re-authenticating.\n", configFile) + } + } + } + + return configFile, nil +} + +var currentConfig *Config +var configLoadedFrom = "" + +func CurrentConfig() *Config { + filename := configsFile() + if configLoadedFrom != filename { + currentConfig = &Config{} + newConfigService().Load(filename, currentConfig) + configLoadedFrom = filename + } + + return currentConfig +} + +func (c *Config) DefaultHost() (host *Host, err error) { + if GitHubHostEnv != "" { + host, err = c.PromptForHost(GitHubHostEnv) + } else if len(c.Hosts) > 0 { + host = c.selectHost() + // HACK: forces host to inherit GITHUB_TOKEN if applicable + host, err = c.PromptForHost(host.Host) + } else { + host, err = c.PromptForHost(DefaultGitHubHost()) + } + + return +} + +func (c *Config) DefaultHostNoPrompt() (*Host, error) { + if GitHubHostEnv != "" { + return c.PromptForHost(GitHubHostEnv) + } else if len(c.Hosts) > 0 { + host := c.Hosts[0] + // HACK: forces host to inherit GITHUB_TOKEN if applicable + return c.PromptForHost(host.Host) + } else { + return c.PromptForHost(GitHubHost) + } +} + +// CheckWriteable checks if config file is writeable. This should +// be called before asking for credentials and only if current +// operation needs to update the file. See issue #1314 for details. +func CheckWriteable(filename string) error { + // Check if file exists already. if it doesn't, we will delete it after + // checking for writeabilty + fileExistsAlready := false + + if _, err := os.Stat(filename); err == nil { + fileExistsAlready = true + } + + err := os.MkdirAll(filepath.Dir(filename), 0771) + if err != nil { + return err + } + + w, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0600) + if err != nil { + return err + } + w.Close() + + if !fileExistsAlready { + err := os.Remove(filename) + if err != nil { + return err + } + } + return nil +} + +// Public for testing purpose +func CreateTestConfigs(user, token string) *Config { + f, _ := ioutil.TempFile("", "test-config") + os.Setenv("HUB_CONFIG", f.Name()) + + host := &Host{ + User: "jingweno", + AccessToken: "123", + Host: GitHubHost, + } + + c := &Config{Hosts: []*Host{host}} + err := newConfigService().Save(f.Name(), c) + if err != nil { + panic(err) + } + + return c +} diff --git a/github/config_decoder.go b/github/config_decoder.go new file mode 100644 index 000000000..7e467b761 --- /dev/null +++ b/github/config_decoder.go @@ -0,0 +1,61 @@ +package github + +import ( + "io" + "io/ioutil" + + "github.com/BurntSushi/toml" + "gopkg.in/yaml.v2" +) + +type configDecoder interface { + Decode(r io.Reader, c *Config) error +} + +type tomlConfigDecoder struct { +} + +func (t *tomlConfigDecoder) Decode(r io.Reader, c *Config) error { + _, err := toml.DecodeReader(r, c) + return err +} + +type yamlConfigDecoder struct { +} + +func (y *yamlConfigDecoder) Decode(r io.Reader, c *Config) error { + d, err := ioutil.ReadAll(r) + if err != nil { + return err + } + + yc := yaml.MapSlice{} + err = yaml.Unmarshal(d, &yc) + + if err != nil { + return err + } + + for _, hostEntry := range yc { + v := hostEntry.Value.([]interface{}) + if len(v) < 1 { + continue + } + host := &Host{Host: hostEntry.Key.(string)} + for _, prop := range v[0].(yaml.MapSlice) { + switch prop.Key.(string) { + case "user": + host.User = prop.Value.(string) + case "oauth_token": + host.AccessToken = prop.Value.(string) + case "protocol": + host.Protocol = prop.Value.(string) + case "unix_socket": + host.UnixSocket = prop.Value.(string) + } + } + c.Hosts = append(c.Hosts, host) + } + + return nil +} diff --git a/github/config_encoder.go b/github/config_encoder.go new file mode 100644 index 000000000..87609d38d --- /dev/null +++ b/github/config_encoder.go @@ -0,0 +1,52 @@ +package github + +import ( + "io" + + "github.com/BurntSushi/toml" + "gopkg.in/yaml.v2" +) + +type configEncoder interface { + Encode(w io.Writer, c *Config) error +} + +type tomlConfigEncoder struct { +} + +func (t *tomlConfigEncoder) Encode(w io.Writer, c *Config) error { + enc := toml.NewEncoder(w) + return enc.Encode(c) +} + +type yamlConfigEncoder struct { +} + +func (y *yamlConfigEncoder) Encode(w io.Writer, c *Config) error { + yc := yaml.MapSlice{} + for _, h := range c.Hosts { + yc = append(yc, yaml.MapItem{ + Key: h.Host, + Value: []yamlHost{ + { + User: h.User, + OAuthToken: h.AccessToken, + Protocol: h.Protocol, + UnixSocket: h.UnixSocket, + }, + }, + }) + } + + d, err := yaml.Marshal(yc) + if err != nil { + return err + } + + n, err := w.Write(d) + if err == nil && n < len(d) { + err = io.ErrShortWrite + } + + return err +} diff --git a/github/config_service.go b/github/config_service.go new file mode 100644 index 000000000..3aaa6915f --- /dev/null +++ b/github/config_service.go @@ -0,0 +1,43 @@ +package github + +import ( + "os" + "path/filepath" +) + +func newConfigService() *configService { + return &configService{ + Encoder: &yamlConfigEncoder{}, + Decoder: &yamlConfigDecoder{}, + } +} + +type configService struct { + Encoder configEncoder + Decoder configDecoder +} + +func (s *configService) Save(filename string, c *Config) error { + err := os.MkdirAll(filepath.Dir(filename), 0771) + if err != nil { + return err + } + + w, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return err + } + defer w.Close() + + return s.Encoder.Encode(w, c) +} + +func (s *configService) Load(filename string, c *Config) error { + r, err := os.Open(filename) + if err != nil { + return err + } + defer r.Close() + + return s.Decoder.Decode(r, c) +} diff --git a/github/hosts.go b/github/hosts.go new file mode 100644 index 000000000..95c9f9c6d --- /dev/null +++ b/github/hosts.go @@ -0,0 +1,64 @@ +package github + +import ( + "fmt" + "net/url" + "os" + "strings" + + "github.com/github/gh/git" +) + +var ( + GitHubHostEnv = os.Getenv("GITHUB_HOST") + cachedHosts []string +) + +type GithubHostError struct { + url *url.URL +} + +func (e *GithubHostError) Error() string { + return fmt.Sprintf("Invalid GitHub URL: %s", e.url) +} + +func knownGitHubHostsInclude(host string) bool { + for _, hh := range knownGitHubHosts() { + if hh == host { + return true + } + } + + return false +} + +func knownGitHubHosts() []string { + if cachedHosts != nil { + return cachedHosts + } + + hosts := []string{} + defaultHost := DefaultGitHubHost() + hosts = append(hosts, defaultHost) + hosts = append(hosts, "ssh.github.com") + + ghHosts, _ := git.ConfigAll("hub.host") + for _, ghHost := range ghHosts { + ghHost = strings.TrimSpace(ghHost) + if ghHost != "" { + hosts = append(hosts, ghHost) + } + } + + cachedHosts = hosts + return hosts +} + +func DefaultGitHubHost() string { + defaultHost := GitHubHostEnv + if defaultHost == "" { + defaultHost = GitHubHost + } + + return defaultHost +} diff --git a/github/http.go b/github/http.go new file mode 100644 index 000000000..73b165db4 --- /dev/null +++ b/github/http.go @@ -0,0 +1,537 @@ +package github + +import ( + "bytes" + "context" + "crypto/md5" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "github.com/github/gh/ui" + "github.com/github/gh/utils" +) + +const apiPayloadVersion = "application/vnd.github.v3+json;charset=utf-8" +const patchMediaType = "application/vnd.github.v3.patch;charset=utf-8" +const textMediaType = "text/plain;charset=utf-8" +const checksType = "application/vnd.github.antiope-preview+json;charset=utf-8" +const draftsType = "application/vnd.github.shadow-cat-preview+json;charset=utf-8" +const cacheVersion = 2 + +var inspectHeaders = []string{ + "Authorization", + "X-GitHub-OTP", + "Location", + "Link", + "Accept", +} + +type verboseTransport struct { + Transport *http.Transport + Verbose bool + OverrideURL *url.URL + Out io.Writer + Colorized bool +} + +func (t *verboseTransport) RoundTrip(req *http.Request) (resp *http.Response, err error) { + if t.Verbose { + t.dumpRequest(req) + } + + if t.OverrideURL != nil { + port := "80" + if s := strings.Split(req.URL.Host, ":"); len(s) > 1 { + port = s[1] + } + + req = cloneRequest(req) + req.Header.Set("X-Original-Scheme", req.URL.Scheme) + req.Header.Set("X-Original-Port", port) + req.Host = req.URL.Host + req.URL.Scheme = t.OverrideURL.Scheme + req.URL.Host = t.OverrideURL.Host + } + + resp, err = t.Transport.RoundTrip(req) + + if err == nil && t.Verbose { + t.dumpResponse(resp) + } + + return +} + +func (t *verboseTransport) dumpRequest(req *http.Request) { + info := fmt.Sprintf("> %s %s://%s%s", req.Method, req.URL.Scheme, req.URL.Host, req.URL.RequestURI()) + t.verbosePrintln(info) + t.dumpHeaders(req.Header, ">") + body := t.dumpBody(req.Body) + if body != nil { + // reset body since it's been read + req.Body = body + } +} + +func (t *verboseTransport) dumpResponse(resp *http.Response) { + info := fmt.Sprintf("< HTTP %d", resp.StatusCode) + t.verbosePrintln(info) + t.dumpHeaders(resp.Header, "<") + body := t.dumpBody(resp.Body) + if body != nil { + // reset body since it's been read + resp.Body = body + } +} + +func (t *verboseTransport) dumpHeaders(header http.Header, indent string) { + for _, listed := range inspectHeaders { + for name, vv := range header { + if !strings.EqualFold(name, listed) { + continue + } + for _, v := range vv { + if v != "" { + r := regexp.MustCompile("(?i)^(basic|token) (.+)") + if r.MatchString(v) { + v = r.ReplaceAllString(v, "$1 [REDACTED]") + } + + info := fmt.Sprintf("%s %s: %s", indent, name, v) + t.verbosePrintln(info) + } + } + } + } +} + +func (t *verboseTransport) dumpBody(body io.ReadCloser) io.ReadCloser { + if body == nil { + return nil + } + + defer body.Close() + buf := new(bytes.Buffer) + _, err := io.Copy(buf, body) + utils.Check(err) + + if buf.Len() > 0 { + t.verbosePrintln(buf.String()) + } + + return ioutil.NopCloser(buf) +} + +func (t *verboseTransport) verbosePrintln(msg string) { + if t.Colorized { + msg = fmt.Sprintf("\033[36m%s\033[0m", msg) + } + + fmt.Fprintln(t.Out, msg) +} + +func newHttpClient(testHost string, verbose bool, unixSocket string) *http.Client { + var testURL *url.URL + if testHost != "" { + testURL, _ = url.Parse(testHost) + } + var httpTransport *http.Transport + if unixSocket != "" { + dialFunc := func(network, addr string) (net.Conn, error) { + return net.Dial("unix", unixSocket) + } + dialContext := func(_ context.Context, _, _ string) (net.Conn, error) { + return net.Dial("unix", unixSocket) + } + httpTransport = &http.Transport{ + DialContext: dialContext, + DialTLS: dialFunc, + ResponseHeaderTimeout: 30 * time.Second, + ExpectContinueTimeout: 10 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + } + } else { + httpTransport = &http.Transport{ + Proxy: proxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + TLSHandshakeTimeout: 10 * time.Second, + } + } + tr := &verboseTransport{ + Transport: httpTransport, + Verbose: verbose, + OverrideURL: testURL, + Out: ui.Stderr, + Colorized: ui.IsTerminal(os.Stderr), + } + + return &http.Client{ + Transport: tr, + } +} + +func cloneRequest(req *http.Request) *http.Request { + dup := new(http.Request) + *dup = *req + dup.URL, _ = url.Parse(req.URL.String()) + dup.Header = make(http.Header) + for k, s := range req.Header { + dup.Header[k] = s + } + return dup +} + +// An implementation of http.ProxyFromEnvironment that isn't broken +func proxyFromEnvironment(req *http.Request) (*url.URL, error) { + proxy := os.Getenv("http_proxy") + if proxy == "" { + proxy = os.Getenv("HTTP_PROXY") + } + if proxy == "" { + return nil, nil + } + + proxyURL, err := url.Parse(proxy) + if err != nil || !strings.HasPrefix(proxyURL.Scheme, "http") { + if proxyURL, err := url.Parse("http://" + proxy); err == nil { + return proxyURL, nil + } + } + + if err != nil { + return nil, fmt.Errorf("invalid proxy address %q: %v", proxy, err) + } + + return proxyURL, nil +} + +type simpleClient struct { + httpClient *http.Client + rootUrl *url.URL + PrepareRequest func(*http.Request) + CacheTTL int +} + +func (c *simpleClient) performRequest(method, path string, body io.Reader, configure func(*http.Request)) (*simpleResponse, error) { + url, err := url.Parse(path) + if err == nil { + url = c.rootUrl.ResolveReference(url) + return c.performRequestUrl(method, url, body, configure) + } else { + return nil, err + } +} + +func (c *simpleClient) performRequestUrl(method string, url *url.URL, body io.Reader, configure func(*http.Request)) (res *simpleResponse, err error) { + req, err := http.NewRequest(method, url.String(), body) + if err != nil { + return + } + if c.PrepareRequest != nil { + c.PrepareRequest(req) + } + req.Header.Set("User-Agent", UserAgent) + req.Header.Set("Accept", apiPayloadVersion) + + if configure != nil { + configure(req) + } + + key := cacheKey(req) + if cachedResponse := c.cacheRead(key, req); cachedResponse != nil { + res = &simpleResponse{cachedResponse} + return + } + + httpResponse, err := c.httpClient.Do(req) + if err != nil { + return + } + + c.cacheWrite(key, httpResponse) + res = &simpleResponse{httpResponse} + + return +} + +func isGraphQL(req *http.Request) bool { + return req.URL.Path == "/graphql" +} + +func canCache(req *http.Request) bool { + return strings.EqualFold(req.Method, "GET") || isGraphQL(req) +} + +func (c *simpleClient) cacheRead(key string, req *http.Request) (res *http.Response) { + if c.CacheTTL > 0 && canCache(req) { + f := cacheFile(key) + cacheInfo, err := os.Stat(f) + if err != nil { + return + } + if time.Since(cacheInfo.ModTime()).Seconds() > float64(c.CacheTTL) { + return + } + cf, err := os.Open(f) + if err != nil { + return + } + defer cf.Close() + + cb, err := ioutil.ReadAll(cf) + if err != nil { + return + } + parts := strings.SplitN(string(cb), "\r\n\r\n", 2) + if len(parts) < 2 { + return + } + + res = &http.Response{ + Body: ioutil.NopCloser(bytes.NewBufferString(parts[1])), + Header: http.Header{}, + } + headerLines := strings.Split(parts[0], "\r\n") + if len(headerLines) < 1 { + return + } + if proto := strings.SplitN(headerLines[0], " ", 3); len(proto) >= 3 { + res.Proto = proto[0] + res.Status = fmt.Sprintf("%s %s", proto[1], proto[2]) + if code, _ := strconv.Atoi(proto[1]); code > 0 { + res.StatusCode = code + } + } + for _, line := range headerLines[1:] { + kv := strings.SplitN(line, ":", 2) + if len(kv) >= 2 { + res.Header.Add(kv[0], strings.TrimLeft(kv[1], " ")) + } + } + } + return +} + +func (c *simpleClient) cacheWrite(key string, res *http.Response) { + if c.CacheTTL > 0 && canCache(res.Request) && res.StatusCode < 500 && res.StatusCode != 403 { + bodyCopy := &bytes.Buffer{} + bodyReplacement := readCloserCallback{ + Reader: io.TeeReader(res.Body, bodyCopy), + Closer: res.Body, + Callback: func() { + f := cacheFile(key) + err := os.MkdirAll(filepath.Dir(f), 0771) + if err != nil { + return + } + cf, err := os.OpenFile(f, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return + } + defer cf.Close() + fmt.Fprintf(cf, "%s %s\r\n", res.Proto, res.Status) + res.Header.Write(cf) + fmt.Fprintf(cf, "\r\n") + io.Copy(cf, bodyCopy) + }, + } + res.Body = &bodyReplacement + } +} + +type readCloserCallback struct { + Callback func() + Closer io.Closer + io.Reader +} + +func (rc *readCloserCallback) Close() error { + err := rc.Closer.Close() + if err == nil { + rc.Callback() + } + return err +} + +func cacheKey(req *http.Request) string { + path := strings.Replace(req.URL.EscapedPath(), "/", "-", -1) + if len(path) > 1 { + path = strings.TrimPrefix(path, "-") + } + host := req.Host + if host == "" { + host = req.URL.Host + } + hash := md5.New() + fmt.Fprintf(hash, "%d:", cacheVersion) + io.WriteString(hash, req.Header.Get("Accept")) + io.WriteString(hash, req.Header.Get("Authorization")) + queryParts := strings.Split(req.URL.RawQuery, "&") + sort.Strings(queryParts) + for _, q := range queryParts { + fmt.Fprintf(hash, "%s&", q) + } + if isGraphQL(req) && req.Body != nil { + if b, err := ioutil.ReadAll(req.Body); err == nil { + req.Body = ioutil.NopCloser(bytes.NewBuffer(b)) + hash.Write(b) + } + } + return fmt.Sprintf("%s/%s_%x", host, path, hash.Sum(nil)) +} + +func cacheFile(key string) string { + return path.Join(os.TempDir(), "hub", "api", key) +} + +func (c *simpleClient) jsonRequest(method, path string, body interface{}, configure func(*http.Request)) (*simpleResponse, error) { + json, err := json.Marshal(body) + if err != nil { + return nil, err + } + buf := bytes.NewBuffer(json) + + return c.performRequest(method, path, buf, func(req *http.Request) { + req.Header.Set("Content-Type", "application/json; charset=utf-8") + if configure != nil { + configure(req) + } + }) +} + +func (c *simpleClient) Get(path string) (*simpleResponse, error) { + return c.performRequest("GET", path, nil, nil) +} + +func (c *simpleClient) GetFile(path string, mimeType string) (*simpleResponse, error) { + return c.performRequest("GET", path, nil, func(req *http.Request) { + req.Header.Set("Accept", mimeType) + }) +} + +func (c *simpleClient) Delete(path string) (*simpleResponse, error) { + return c.performRequest("DELETE", path, nil, nil) +} + +func (c *simpleClient) PostJSON(path string, payload interface{}) (*simpleResponse, error) { + return c.jsonRequest("POST", path, payload, nil) +} + +func (c *simpleClient) PostJSONPreview(path string, payload interface{}, mimeType string) (*simpleResponse, error) { + return c.jsonRequest("POST", path, payload, func(req *http.Request) { + req.Header.Set("Accept", mimeType) + }) +} + +func (c *simpleClient) PatchJSON(path string, payload interface{}) (*simpleResponse, error) { + return c.jsonRequest("PATCH", path, payload, nil) +} + +func (c *simpleClient) PostFile(path, filename string) (*simpleResponse, error) { + stat, err := os.Stat(filename) + if err != nil { + return nil, err + } + + file, err := os.Open(filename) + if err != nil { + return nil, err + } + defer file.Close() + + return c.performRequest("POST", path, file, func(req *http.Request) { + req.ContentLength = stat.Size() + req.Header.Set("Content-Type", "application/octet-stream") + }) +} + +type simpleResponse struct { + *http.Response +} + +type errorInfo struct { + Message string `json:"message"` + Errors []fieldError `json:"errors"` + Response *http.Response +} +type errorInfoSimple struct { + Message string `json:"message"` + Errors []string `json:"errors"` +} +type fieldError struct { + Resource string `json:"resource"` + Message string `json:"message"` + Code string `json:"code"` + Field string `json:"field"` +} + +func (e *errorInfo) Error() string { + return e.Message +} + +func (res *simpleResponse) Unmarshal(dest interface{}) (err error) { + defer res.Body.Close() + + body, err := ioutil.ReadAll(res.Body) + if err != nil { + return + } + + return json.Unmarshal(body, dest) +} + +func (res *simpleResponse) ErrorInfo() (msg *errorInfo, err error) { + defer res.Body.Close() + + body, err := ioutil.ReadAll(res.Body) + if err != nil { + return + } + + msg = &errorInfo{} + err = json.Unmarshal(body, msg) + if err != nil { + msgSimple := &errorInfoSimple{} + if err = json.Unmarshal(body, msgSimple); err == nil { + msg.Message = msgSimple.Message + for _, errMsg := range msgSimple.Errors { + msg.Errors = append(msg.Errors, fieldError{ + Code: "custom", + Message: errMsg, + }) + } + } + } + if err == nil { + msg.Response = res.Response + } + + return +} + +func (res *simpleResponse) Link(name string) string { + linkVal := res.Header.Get("Link") + re := regexp.MustCompile(`<([^>]+)>; rel="([^"]+)"`) + for _, match := range re.FindAllStringSubmatch(linkVal, -1) { + if match[2] == name { + return match[1] + } + } + return "" +} diff --git a/github/project.go b/github/project.go new file mode 100644 index 000000000..dae4fe8de --- /dev/null +++ b/github/project.go @@ -0,0 +1,181 @@ +package github + +import ( + "fmt" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/github/gh/git" + "github.com/github/gh/utils" +) + +type Project struct { + Name string + Owner string + Host string + Protocol string +} + +func (p Project) String() string { + return fmt.Sprintf("%s/%s", p.Owner, p.Name) +} + +func (p *Project) SameAs(other *Project) bool { + return strings.ToLower(p.Owner) == strings.ToLower(other.Owner) && + strings.ToLower(p.Name) == strings.ToLower(other.Name) && + strings.ToLower(p.Host) == strings.ToLower(other.Host) +} + +func (p *Project) WebURL(name, owner, path string) string { + if owner == "" { + owner = p.Owner + } + if name == "" { + name = p.Name + } + + ownerWithName := fmt.Sprintf("%s/%s", owner, name) + if strings.Contains(ownerWithName, ".wiki") { + ownerWithName = strings.TrimSuffix(ownerWithName, ".wiki") + if path != "wiki" { + if strings.HasPrefix(path, "commits") { + path = "_history" + } else if path != "" { + path = fmt.Sprintf("_%s", path) + } + + if path != "" { + path = utils.ConcatPaths("wiki", path) + } else { + path = "wiki" + } + } + } + + url := fmt.Sprintf("%s://%s", p.Protocol, utils.ConcatPaths(p.Host, ownerWithName)) + if path != "" { + url = utils.ConcatPaths(url, path) + } + + return url +} + +func (p *Project) GitURL(name, owner string, isSSH bool) (url string) { + if name == "" { + name = p.Name + } + if owner == "" { + owner = p.Owner + } + + host := rawHost(p.Host) + + if preferredProtocol() == "https" { + url = fmt.Sprintf("https://%s/%s/%s.git", host, owner, name) + } else if isSSH || preferredProtocol() == "ssh" { + url = fmt.Sprintf("git@%s:%s/%s.git", host, owner, name) + } else { + url = fmt.Sprintf("git://%s/%s/%s.git", host, owner, name) + } + + return url +} + +// Remove the scheme from host when the host url is absolute. +func rawHost(host string) string { + u, err := url.Parse(host) + utils.Check(err) + + if u.IsAbs() { + return u.Host + } else { + return u.Path + } +} + +func preferredProtocol() string { + userProtocol := os.Getenv("HUB_PROTOCOL") + if userProtocol == "" { + userProtocol, _ = git.Config("hub.protocol") + } + return userProtocol +} + +func NewProjectFromURL(url *url.URL) (p *Project, err error) { + if !knownGitHubHostsInclude(url.Host) { + err = &GithubHostError{url} + return + } + + parts := strings.SplitN(url.Path, "/", 4) + if len(parts) <= 2 { + err = fmt.Errorf("Invalid GitHub URL: %s", url) + return + } + + name := strings.TrimSuffix(parts[2], ".git") + p = newProject(parts[1], name, url.Host, url.Scheme) + + return +} + +func NewProject(owner, name, host string) *Project { + return newProject(owner, name, host, "") +} + +func newProject(owner, name, host, protocol string) *Project { + if strings.Contains(owner, "/") { + result := strings.SplitN(owner, "/", 2) + owner = result[0] + if name == "" { + name = result[1] + } + } else if strings.Contains(name, "/") { + result := strings.SplitN(name, "/", 2) + if owner == "" { + owner = result[0] + } + name = result[1] + } + + if host == "" { + host = DefaultGitHubHost() + } + if host == "ssh.github.com" { + host = GitHubHost + } + + if protocol != "http" && protocol != "https" { + protocol = "" + } + if protocol == "" { + h := CurrentConfig().Find(host) + if h != nil { + protocol = h.Protocol + } + } + if protocol == "" { + protocol = "https" + } + + if owner == "" { + h := CurrentConfig().Find(host) + if h != nil { + owner = h.User + } + } + + return &Project{ + Name: name, + Owner: owner, + Host: host, + Protocol: protocol, + } +} + +func SanitizeProjectName(name string) string { + name = filepath.Base(name) + return strings.Replace(name, " ", "-", -1) +} diff --git a/github/remote.go b/github/remote.go new file mode 100644 index 000000000..0b1efedb2 --- /dev/null +++ b/github/remote.go @@ -0,0 +1,101 @@ +package github + +import ( + "fmt" + "net/url" + "regexp" + "strings" + + "github.com/github/gh/git" +) + +var ( + OriginNamesInLookupOrder = []string{"upstream", "github", "origin"} +) + +type Remote struct { + Name string + URL *url.URL + PushURL *url.URL +} + +func (remote *Remote) String() string { + return remote.Name +} + +func (remote *Remote) Project() (*Project, error) { + p, err := NewProjectFromURL(remote.URL) + if _, ok := err.(*GithubHostError); ok { + return NewProjectFromURL(remote.PushURL) + } + return p, err +} + +func Remotes() (remotes []Remote, err error) { + re := regexp.MustCompile(`(.+)\s+(.+)\s+\((push|fetch)\)`) + + rs, err := git.Remotes() + if err != nil { + err = fmt.Errorf("Can't load git remote") + return + } + + // build the remotes map + remotesMap := make(map[string]map[string]string) + for _, r := range rs { + if re.MatchString(r) { + match := re.FindStringSubmatch(r) + name := strings.TrimSpace(match[1]) + url := strings.TrimSpace(match[2]) + urlType := strings.TrimSpace(match[3]) + utm, ok := remotesMap[name] + if !ok { + utm = make(map[string]string) + remotesMap[name] = utm + } + utm[urlType] = url + } + } + + // construct remotes in priority order + names := OriginNamesInLookupOrder + for _, name := range names { + if u, ok := remotesMap[name]; ok { + r, err := newRemote(name, u) + if err == nil { + remotes = append(remotes, r) + delete(remotesMap, name) + } + } + } + + // the rest of the remotes + for n, u := range remotesMap { + r, err := newRemote(n, u) + if err == nil { + remotes = append(remotes, r) + } + } + + return +} + +func newRemote(name string, urlMap map[string]string) (Remote, error) { + r := Remote{} + + fetchURL, ferr := git.ParseURL(urlMap["fetch"]) + pushURL, perr := git.ParseURL(urlMap["push"]) + if ferr != nil && perr != nil { + return r, fmt.Errorf("No valid remote URLs") + } + + r.Name = name + if ferr == nil { + r.URL = fetchURL + } + if perr == nil { + r.PushURL = pushURL + } + + return r, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 000000000..e62101c7a --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module github.com/github/gh + +go 1.13 + +require ( + github.com/AlecAivazis/survey/v2 v2.0.4 // indirect + github.com/BurntSushi/toml v0.3.1 + github.com/atotto/clipboard v0.1.2 // indirect + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 + github.com/mattn/go-colorable v0.1.2 + github.com/mattn/go-isatty v0.0.9 + github.com/mitchellh/go-homedir v1.1.0 + github.com/spf13/cobra v0.0.5 + golang.org/x/crypto v0.0.0-20190926180335-cea2066c6411 + gopkg.in/yaml.v2 v2.2.2 +) diff --git a/go.sum b/go.sum new file mode 100644 index 000000000..aec34e489 --- /dev/null +++ b/go.sum @@ -0,0 +1,67 @@ +github.com/AlecAivazis/survey v1.8.7 h1:QIBq36/0wfYpXxdBqDXNAjKHx1bKnRGu/EDnva27k84= +github.com/AlecAivazis/survey/v2 v2.0.4 h1:qzXnJSzXEvmUllWqMBWpZndvT2YfoAUzAMvZUax3L2M= +github.com/AlecAivazis/survey/v2 v2.0.4/go.mod h1:WYBhg6f0y/fNYUuesWQc0PKbJcEliGcYHB9sNT3Bg74= +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8/go.mod h1:oX5x61PbNXchhh0oikYAH+4Pcfw5LKv21+Jnpr6r6Pc= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/atotto/clipboard v0.1.2 h1:YZCtFu5Ie8qX2VmVTBnrqLSiU9XOWwqNRmdT3gIQzbY= +github.com/atotto/clipboard v0.1.2/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/github/hub v2.11.2+incompatible h1:H0wUQmNZVxF2+XyGPTsOxUVrRrFnTq133tezo6u4X4U= +github.com/github/hub v2.11.2+incompatible/go.mod h1:zQrzJEdze2hfWJDgktd/L6sROjAdCThFrzjbxw4keTs= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174/go.mod h1:DqJ97dSdRW1W22yXSB90986pcOyQ7r45iio1KN2ez1A= +github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/kr/pty v1.1.4/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.9 h1:d5US/mDsogSGW37IV293h//ZFaeajb69h+EHFsv2xGg= +github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v0.0.5 h1:f0B+LkLX6DtmRH1isoNA9VTtNUK9K8xYd28JNNfOv/s= +github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= +github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190926180335-cea2066c6411 h1:kuW9k4QvBJpRjC3rxEytsfIYPs8oGY3Jw7iR36h0FIY= +golang.org/x/crypto v0.0.0-20190926180335-cea2066c6411/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190530182044-ad28b68e88f1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a h1:aYOabOQFp6Vj6W1F80affTUvO9UxmJRx8K0gsfABByQ= +golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/main.go b/main.go new file mode 100644 index 000000000..e64374a52 --- /dev/null +++ b/main.go @@ -0,0 +1,15 @@ +package main + +import ( + "fmt" + "os" + + "github.com/github/gh/command" +) + +func main() { + if err := command.RootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} diff --git a/ui/ui.go b/ui/ui.go new file mode 100644 index 000000000..a824ce963 --- /dev/null +++ b/ui/ui.go @@ -0,0 +1,98 @@ +package ui + +import ( + "fmt" + "io" + "os" + + "github.com/mattn/go-colorable" + "github.com/mattn/go-isatty" +) + +type UI interface { + Print(a ...interface{}) (n int, err error) + Printf(format string, a ...interface{}) (n int, err error) + Println(a ...interface{}) (n int, err error) + Errorf(format string, a ...interface{}) (n int, err error) + Errorln(a ...interface{}) (n int, err error) +} + +var ( + Stdout = colorable.NewColorableStdout() + Stderr = colorable.NewColorableStderr() + Default UI = Console{Stdout: Stdout, Stderr: Stderr} +) + +func Print(a ...interface{}) (n int) { + n, err := Default.Print(a...) + if err != nil { + // If something as basic as printing to stdout fails, just panic and exit + os.Exit(1) + } + return +} + +func Printf(format string, a ...interface{}) (n int) { + n, err := Default.Printf(format, a...) + if err != nil { + // If something as basic as printing to stdout fails, just panic and exit + os.Exit(1) + } + return +} + +func Println(a ...interface{}) (n int) { + n, err := Default.Println(a...) + if err != nil { + // If something as basic as printing to stdout fails, just panic and exit + os.Exit(1) + } + return +} + +func Errorf(format string, a ...interface{}) (n int) { + n, err := Default.Errorf(format, a...) + if err != nil { + // If something as basic as printing to stderr fails, just panic and exit + os.Exit(1) + } + return +} + +func Errorln(a ...interface{}) (n int) { + n, err := Default.Errorln(a...) + if err != nil { + // If something as basic as printing to stderr fails, just panic and exit + os.Exit(1) + } + return +} + +func IsTerminal(f *os.File) bool { + return isatty.IsTerminal(f.Fd()) +} + +type Console struct { + Stdout io.Writer + Stderr io.Writer +} + +func (c Console) Print(a ...interface{}) (n int, err error) { + return fmt.Fprint(c.Stdout, a...) +} + +func (c Console) Printf(format string, a ...interface{}) (n int, err error) { + return fmt.Fprintf(c.Stdout, format, a...) +} + +func (c Console) Println(a ...interface{}) (n int, err error) { + return fmt.Fprintln(c.Stdout, a...) +} + +func (c Console) Errorf(format string, a ...interface{}) (n int, err error) { + return fmt.Fprintf(c.Stderr, format, a...) +} + +func (c Console) Errorln(a ...interface{}) (n int, err error) { + return fmt.Fprintln(c.Stderr, a...) +} diff --git a/utils/utils.go b/utils/utils.go new file mode 100644 index 000000000..dc77e7276 --- /dev/null +++ b/utils/utils.go @@ -0,0 +1,119 @@ +package utils + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/github/gh/ui" + "github.com/kballard/go-shellquote" +) + +var timeNow = time.Now + +func Check(err error) { + if err != nil { + ui.Errorln(err) + os.Exit(1) + } +} + +func ConcatPaths(paths ...string) string { + return strings.Join(paths, "/") +} + +func BrowserLauncher() ([]string, error) { + browser := os.Getenv("BROWSER") + if browser == "" { + browser = searchBrowserLauncher(runtime.GOOS) + } else { + browser = os.ExpandEnv(browser) + } + + if browser == "" { + return nil, errors.New("Please set $BROWSER to a web launcher") + } + + return shellquote.Split(browser) +} + +func searchBrowserLauncher(goos string) (browser string) { + switch goos { + case "darwin": + browser = "open" + case "windows": + browser = "cmd /c start" + default: + candidates := []string{"xdg-open", "cygstart", "x-www-browser", "firefox", + "opera", "mozilla", "netscape"} + for _, b := range candidates { + path, err := exec.LookPath(b) + if err == nil { + browser = path + break + } + } + } + + return browser +} + +func CommandPath(cmd string) (string, error) { + if runtime.GOOS == "windows" { + cmd = cmd + ".exe" + } + + path, err := exec.LookPath(cmd) + if err != nil { + return "", err + } + + path, err = filepath.Abs(path) + if err != nil { + return "", err + } + + return filepath.EvalSymlinks(path) +} + +func TimeAgo(t time.Time) string { + duration := timeNow().Sub(t) + minutes := duration.Minutes() + hours := duration.Hours() + days := hours / 24 + months := days / 30 + years := months / 12 + + var val int + var unit string + + if minutes < 1 { + return "now" + } else if hours < 1 { + val = int(minutes) + unit = "minute" + } else if days < 1 { + val = int(hours) + unit = "hour" + } else if months < 1 { + val = int(days) + unit = "day" + } else if years < 1 { + val = int(months) + unit = "month" + } else { + val = int(years) + unit = "year" + } + + var plural string + if val > 1 { + plural = "s" + } + return fmt.Sprintf("%d %s%s ago", val, unit, plural) +} diff --git a/version/version.go b/version/version.go new file mode 100644 index 000000000..87488561f --- /dev/null +++ b/version/version.go @@ -0,0 +1,17 @@ +package version + +import ( + "fmt" + + "github.com/github/gh/git" +) + +var Version = "2.12.7" + +func FullVersion() (string, error) { + gitVersion, err := git.Version() + if err != nil { + gitVersion = "git version (unavailable)" + } + return fmt.Sprintf("%s\nhub version %s", gitVersion, Version), err +}