initial commit
This commit is contained in:
commit
8dd03144ff
21 changed files with 3572 additions and 0 deletions
11
README.md
Normal file
11
README.md
Normal file
|
|
@ -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)
|
||||
120
command/pr.go
Normal file
120
command/pr.go
Normal file
|
|
@ -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?")
|
||||
}
|
||||
17
command/root.go
Normal file
17
command/root.go
Normal file
|
|
@ -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")
|
||||
},
|
||||
}
|
||||
348
git/git.go
Normal file
348
git/git.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
90
git/ssh_config.go
Normal file
90
git/ssh_config.go
Normal file
|
|
@ -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 ""
|
||||
})
|
||||
}
|
||||
66
git/url.go
Normal file
66
git/url.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
1140
github/client.go
Normal file
1140
github/client.go
Normal file
File diff suppressed because it is too large
Load diff
409
github/config.go
Normal file
409
github/config.go
Normal file
|
|
@ -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
|
||||
}
|
||||
61
github/config_decoder.go
Normal file
61
github/config_decoder.go
Normal file
|
|
@ -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
|
||||
}
|
||||
52
github/config_encoder.go
Normal file
52
github/config_encoder.go
Normal file
|
|
@ -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
|
||||
}
|
||||
43
github/config_service.go
Normal file
43
github/config_service.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
64
github/hosts.go
Normal file
64
github/hosts.go
Normal file
|
|
@ -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
|
||||
}
|
||||
537
github/http.go
Normal file
537
github/http.go
Normal file
|
|
@ -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 ""
|
||||
}
|
||||
181
github/project.go
Normal file
181
github/project.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
101
github/remote.go
Normal file
101
github/remote.go
Normal file
|
|
@ -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
|
||||
}
|
||||
16
go.mod
Normal file
16
go.mod
Normal file
|
|
@ -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
|
||||
)
|
||||
67
go.sum
Normal file
67
go.sum
Normal file
|
|
@ -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=
|
||||
15
main.go
Normal file
15
main.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
98
ui/ui.go
Normal file
98
ui/ui.go
Normal file
|
|
@ -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...)
|
||||
}
|
||||
119
utils/utils.go
Normal file
119
utils/utils.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
17
version/version.go
Normal file
17
version/version.go
Normal file
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue