initial commit

This commit is contained in:
nate smith 2019-10-03 22:20:31 -05:00
commit 8dd03144ff
21 changed files with 3572 additions and 0 deletions

11
README.md Normal file
View 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 well 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

409
github/config.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}