diff --git a/.gitignore b/.gitignore index 7f8d49f4704abd388a469359d1e454be3583a0e5..746df08b8aa4e0de199700394b8c8afd362f3005 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ gh +gh.test diff --git a/config.go b/config.go index cfeb44671f2ca2a333ea22cc518d72e4e5c51314..df2069cb6fa0897a32c192f3b5926b14f06ebc4f 100644 --- a/config.go +++ b/config.go @@ -3,19 +3,25 @@ package main import ( "bufio" "encoding/json" - "log" "os" + "path/filepath" ) type Config struct { - User string - OauthToken string + User string `json:"user"` + Token string `json:"token"` } -func loadConfig(filename string) Config { +var ConfigFile string + +func init() { + ConfigFile = filepath.Join(os.Getenv("HOME"), ".config", "gh") +} + +func LoadConfig(filename string) (*Config, error) { f, err := os.Open(filename) if err != nil { - log.Fatal(err) + return nil, err } defer f.Close() @@ -25,8 +31,26 @@ func loadConfig(filename string) Config { var c Config err = dec.Decode(&c) if err != nil { - log.Fatal(err) + return nil, err } - return c + return &c, nil +} + +func SaveConfig(filename string, config Config) error { + err := os.MkdirAll(filepath.Dir(filename), 0771) + if err != nil { + return err + } + + f, err := os.Create(filename) + if err != nil { + return err + } + defer f.Close() + + enc := json.NewEncoder(f) + enc.Encode(config) + + return nil } diff --git a/config_test.go b/config_test.go index b07f04580bd0e24f539a0045ab2085d6eaf8e0f4..07b071b9d11c946de20b1c725fd3b9bdeeebf0fd 100644 --- a/config_test.go +++ b/config_test.go @@ -3,12 +3,20 @@ package main import ( "github.com/bmizerany/assert" "os" + "path/filepath" "testing" ) -func TestLoadConfig(t *testing.T) { - home := os.Getenv("HOME") - config := loadConfig(home + "/.config/gh") +func TestSaveConfig(t *testing.T) { + config := Config{"jingweno", "123"} + file := "./test_support/test" + err := SaveConfig(file, config) - assert.Equal(t, "jingweno", config.User) + assert.Equal(t, nil, err) + + newConfig, _ := LoadConfig(file) + assert.Equal(t, "jingweno", newConfig.User) + assert.Equal(t, "123", newConfig.Token) + + os.RemoveAll(filepath.Dir(file)) } diff --git a/exec_cmd.go b/exec_cmd.go new file mode 100644 index 0000000000000000000000000000000000000000..1568237f638da82cbb0f22262981552f3eefcf5c --- /dev/null +++ b/exec_cmd.go @@ -0,0 +1,39 @@ +package main + +import ( + "os" + "os/exec" +) + +type ExecCmd struct { + Name string + Args []string +} + +func (cmd *ExecCmd) WithArg(arg string) *ExecCmd { + cmd.Args = append(cmd.Args, arg) + + return cmd +} + +func (cmd *ExecCmd) ExecOutput() (string, error) { + output, err := exec.Command(cmd.Name, cmd.Args...).Output() + if err != nil { + return "", err + } + + return string(output), nil +} + +func (cmd *ExecCmd) Exec() error { + c := exec.Command(cmd.Name, cmd.Args...) + c.Stdin = os.Stdin + c.Stdout = os.Stdout + c.Stderr = os.Stderr + + return c.Run() +} + +func NewExecCmd(name string) *ExecCmd { + return &ExecCmd{name, make([]string, 0)} +} diff --git a/exec_cmd_test.go b/exec_cmd_test.go new file mode 100644 index 0000000000000000000000000000000000000000..d7506f559cd185f8cf2cfcc026be874ee480e655 --- /dev/null +++ b/exec_cmd_test.go @@ -0,0 +1,13 @@ +package main + +import ( + "github.com/bmizerany/assert" + "testing" +) + +func TestWithArg(t *testing.T) { + execCmd := NewExecCmd("git") + execCmd.WithArg("log").WithArg("--no-color") + assert.Equal(t, "git", execCmd.Name) + assert.Equal(t, 2, len(execCmd.Args)) +} diff --git a/git.go b/git.go new file mode 100644 index 0000000000000000000000000000000000000000..79b2507d016a21d3581856bff841d4025ef50c46 --- /dev/null +++ b/git.go @@ -0,0 +1,83 @@ +package main + +import ( + "fmt" + "log" + "path/filepath" + "regexp" + "strings" +) + +func FetchGitDir() string { + gitDir := execGitCmd([]string{"rev-parse", "-q", "--git-dir"})[0] + gitDir, err := filepath.Abs(gitDir) + if err != nil { + log.Fatal(err) + } + + return gitDir +} + +func FetchGitEditor() string { + return execGitCmd([]string{"var", "GIT_EDITOR"})[0] +} + +func FetchGitOwner() string { + remote := FetchGitRemote() + return mustMatchGitUrl(remote)[1] +} + +func FetchGitProject() string { + remote := FetchGitRemote() + return mustMatchGitUrl(remote)[2] +} + +func FetchGitHead() string { + return execGitCmd([]string{"symbolic-ref", "-q", "--short", "HEAD"})[0] +} + +// FIXME: only care about origin push remote now +func FetchGitRemote() string { + r := regexp.MustCompile("origin\t(.+github.com.+) \\(push\\)") + for _, output := range execGitCmd([]string{"remote", "-v"}) { + if r.MatchString(output) { + return r.FindStringSubmatch(output)[1] + } + } + + panic("Can't find remote") +} + +func FetchGitCommitLogs(sha1, sha2 string) string { + execCmd := NewExecCmd("git") + execCmd.WithArg("log").WithArg("--no-color") + execCmd.WithArg("--format=%h (%aN, %ar)%n%w(78,3,3)%s%n%+b") + execCmd.WithArg("--cherry") + shaRange := fmt.Sprintf("%s...%s", sha1, sha2) + execCmd.WithArg(shaRange) + + outputs, err := execCmd.ExecOutput() + if err != nil { + log.Fatal(err) + } + + return outputs +} + +func execGitCmd(input []string) (outputs []string) { + cmd := NewExecCmd("git") + for _, i := range input { + cmd.WithArg(i) + } + + out, err := cmd.ExecOutput() + if err != nil { + log.Fatal(err) + } + + for _, line := range strings.Split(out, "\n") { + outputs = append(outputs, string(line)) + } + + return outputs +} diff --git a/git_hub.go b/git_hub.go new file mode 100644 index 0000000000000000000000000000000000000000..6117966b3aa8f1db70858693ce248bc13b932764 --- /dev/null +++ b/git_hub.go @@ -0,0 +1,307 @@ +package main + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "github.com/howeyc/gopass" + "io/ioutil" + "net/http" +) + +const ( + GitHubUrl string = "https://" + GitHubHost + GitHubHost string = "api.github.com" + OAuthAppUrl string = "http://owenou.com/gh" +) + +type GitHubError struct { + Resource string `json:"resource"` + Field string `json:"field"` + Value string `json:"value"` + Code string `json:"code"` + Message string `json:"message"` +} + +type GitHubErrors struct { + Message string `json:"message"` + Errors []GitHubError `json:"errors"` +} + +type App struct { + Url string `json:"url"` + Name string `json:"name"` + ClientId string `json:"client_id"` +} + +type Authorization struct { + Scopes []string `json:"scopes"` + Url string `json:"url"` + App App `json:"app"` + Token string `json:"token"` + Note string `josn:"note"` + NoteUrl string `josn:"note_url"` +} + +func NewGitHub() *GitHub { + config, _ := LoadConfig(ConfigFile) + + var user, auth string + if config != nil { + user = config.User + auth = config.Token + } + + if len(user) == 0 { + user = FetchGitOwner() + } + + if len(auth) > 0 { + auth = "token " + auth + } + + return &GitHub{&http.Client{}, user, "", auth} +} + +func hashAuth(u, p string) string { + var a = fmt.Sprintf("%s:%s", u, p) + return base64.StdEncoding.EncodeToString([]byte(a)) +} + +type GitHub struct { + httpClient *http.Client + User string + Password string + Authorization string +} + +func (gh *GitHub) performBasicAuth() error { + msg := fmt.Sprintf("%s password for %s (never stored): ", GitHubHost, gh.User) + fmt.Print(msg) + + pass := gopass.GetPasswd() + if len(pass) == 0 { + return errors.New("Password cannot be empty.") + } + gh.Password = string(pass) + + return gh.obtainOAuthTokenWithBasicAuth() +} + +func (gh *GitHub) obtainOAuthTokenWithBasicAuth() error { + gh.Authorization = fmt.Sprintf("Basic %s", hashAuth(gh.User, gh.Password)) + response, err := gh.httpGet("/authorizations", nil) + if err != nil { + return err + } + + var auths []Authorization + err = unmarshalBody(response, &auths) + if err != nil { + return err + } + + var token string + for _, auth := range auths { + if auth.Url == OAuthAppUrl { + token = auth.Token + } + } + + if len(token) == 0 { + authParam := AuthorizationParams{} + authParam.Scopes = append(authParam.Scopes, "repo") + authParam.Note = "gh" + authParam.NoteUrl = OAuthAppUrl + + auth, err := gh.CreateAuthorization(authParam) + if err != nil { + return err + } + + token = auth.Token + } + + SaveConfig(ConfigFile, Config{gh.User, token}) + + gh.Authorization = "token " + token + + return nil +} + +func (gh *GitHub) performRequest(request *http.Request) (*http.Response, error) { + if len(gh.Authorization) == 0 { + err := gh.performBasicAuth() + if err != nil { + return nil, err + } + } + + request.Header.Set("Authorization", gh.Authorization) + + response, err := gh.httpClient.Do(request) + if err != nil { + return response, err + } + + if response.StatusCode >= 200 && response.StatusCode < 400 { + return response, err + } + + err = handleGitHubErrors(response) + + return response, err +} + +func handleGitHubErrors(response *http.Response) error { + body, err := ioutil.ReadAll(response.Body) + if err == nil { + var githubErrors GitHubErrors + err = json.Unmarshal(body, &githubErrors) + if err != nil { + return err + } + + errorMessages := make([]string, len(githubErrors.Errors)) + for _, e := range githubErrors.Errors { + switch e.Code { + case "custom": + errorMessages = append(errorMessages, e.Message) + case "missing_field": + errorMessages = append(errorMessages, "Missing field: "+e.Field) + case "invalid": + errorMessages = append(errorMessages, "Invalid value for "+e.Field+": "+e.Value) + case "unauthorized": + errorMessages = append(errorMessages, "Not allow to change field "+e.Field) + } + } + + var text string + for _, m := range errorMessages { + if len(m) > 0 { + text = text + m + "\n" + } + } + + if len(text) == 0 { + text = githubErrors.Message + } + + err = errors.New(text) + } + + return err +} + +func unmarshalBody(response *http.Response, v interface{}) error { + js, err := ioutil.ReadAll(response.Body) + if err != nil { + return err + } + + err = json.Unmarshal(js, v) + if err != nil { + return err + } + + return nil +} + +func (gh *GitHub) httpGet(uri string, extraHeaders map[string]string) (*http.Response, error) { + url := fmt.Sprintf("%s%s", GitHubUrl, uri) + request, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + if extraHeaders != nil { + for h, v := range extraHeaders { + request.Header.Set(h, v) + } + } + + return gh.performRequest(request) +} + +func (gh *GitHub) httpPost(uri string, extraHeaders map[string]string, content *bytes.Buffer) (*http.Response, error) { + url := fmt.Sprintf("%s%s", GitHubUrl, uri) + request, err := http.NewRequest("POST", url, content) + if err != nil { + return nil, err + } + + if extraHeaders != nil { + for h, v := range extraHeaders { + request.Header.Set(h, v) + } + } + + request.Header.Set("Content-Type", "application/json; charset=utf-8") + + return gh.performRequest(request) +} + +type PullRequestParams struct { + Title string `json:"title"` + Body string `json:"body"` + Base string `json:"base"` + Head string `json:"head"` +} + +type AuthorizationParams struct { + Scopes []string `json:"scopes"` + Note string `json:"note"` + NoteUrl string `json:"note_url"` + ClientId string `json:"client_id"` + ClientSecret string `json:"client_secret"` +} + +func (gh *GitHub) CreateAuthorization(authParam AuthorizationParams) (*Authorization, error) { + b, err := json.Marshal(authParam) + if err != nil { + return nil, err + } + + buffer := bytes.NewBuffer(b) + response, err := gh.httpPost("/authorizations", nil, buffer) + + var auth Authorization + err = unmarshalBody(response, &auth) + if err != nil { + return nil, err + } + + return &auth, nil +} + +type PullRequestResponse struct { + Url string `json:"url"` + HtmlUrl string `json:"html_url"` + DiffUrl string `json:"diff_url"` + PatchUrl string `json:"patch_url"` + IssueUrl string `json:"issue_url"` +} + +func (gh *GitHub) CreatePullRequest(owner, repo string, params PullRequestParams) (*PullRequestResponse, error) { + b, err := json.Marshal(params) + if err != nil { + return nil, err + } + + buffer := bytes.NewBuffer(b) + url := fmt.Sprintf("/repos/%s/%s/pulls", owner, repo) + response, err := gh.httpPost(url, nil, buffer) + if err != nil { + return nil, err + } + + var pullRequestResponse PullRequestResponse + err = unmarshalBody(response, pullRequestResponse) + if err != nil { + return nil, err + } + + return &pullRequestResponse, nil +} diff --git a/git_hub_test.go b/git_hub_test.go new file mode 100644 index 0000000000000000000000000000000000000000..2c3b9b869ae0a64b839b69bd5a98cd39f416fab5 --- /dev/null +++ b/git_hub_test.go @@ -0,0 +1,17 @@ +package main + +import ( + "github.com/bmizerany/assert" + "net/http" + "testing" +) + +func _TestCreatePullRequest(t *testing.T) { + config, _ := LoadConfig("./test_support/gh") + + client := &http.Client{} + gh := GitHub{client, "jingweno", "123", config.Token} + params := PullRequestParams{"title", "body", "jingweno:master", "jingweno:pull_request"} + _, err := gh.CreatePullRequest("jingweno", "gh", params) + assert.Equal(t, nil, err) +} diff --git a/git_test.go b/git_test.go new file mode 100644 index 0000000000000000000000000000000000000000..b7da69502c0725f74f29c65dc08f1663e0802a34 --- /dev/null +++ b/git_test.go @@ -0,0 +1,18 @@ +package main + +import ( + "github.com/bmizerany/assert" + "strings" + "testing" +) + +func TestGitMethods(t *testing.T) { + assert.T(t, strings.Contains(FetchGitDir(), ".git")) + assert.Equal(t, "vim", FetchGitEditor()) + assert.Equal(t, "git@github.com:jingweno/gh.git", FetchGitRemote()) + assert.Equal(t, "jingweno", FetchGitOwner()) + assert.Equal(t, "gh", FetchGitProject()) + assert.Equal(t, "pull_request", FetchGitHead()) + logs := FetchGitCommitLogs("master", "HEAD") + assert.T(t, len(logs) > 0) +} diff --git a/main.go b/main.go index 0e1e3d53f8f499ec8611ccfaaa3573d01001af51..7f4b1ed2f3a31a21f9e782cb93285a178ea2e7a5 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,7 @@ package main import ( "flag" "fmt" + "log" "os" "strings" ) @@ -40,10 +41,13 @@ func (c *Command) List() bool { return c.Short != "" } -var commands = []*Command{ - cmdPullRequest, - cmdHelp, -} +var ( + commands = []*Command{ + cmdPullRequest, + cmdHelp, + } + repo = NewRepo() +) func main() { args := os.Args[1:] @@ -67,3 +71,9 @@ func main() { fmt.Fprintf(os.Stderr, "Unknown command: %s\n", args[0]) usage() } + +func check(err error) { + if err != nil { + log.Fatal(err) + } +} diff --git a/pull_request.go b/pull_request.go index 418b0ce26236f476e09ec0468fb0e9fea57bbafd..6a1ac1c605bcb432306e221ab7bdc7fcdba059b1 100644 --- a/pull_request.go +++ b/pull_request.go @@ -1,7 +1,14 @@ package main import ( + "bufio" + "fmt" + "io/ioutil" "log" + "os" + "path/filepath" + "regexp" + "strings" ) var cmdPullRequest = &Command{ @@ -28,13 +35,136 @@ of title you can paste a full URL to an issue on GitHub. var flagPullRequestBase, flagPullRequestHead string func init() { - // TODO: make base default as USER:master, head default as USER:HEAD - cmdPullRequest.Flag.StringVar(&flagPullRequestBase, "b", "master", "BASE") - cmdPullRequest.Flag.StringVar(&flagPullRequestHead, "h", "HEAD", "HEAD") + cmdPullRequest.Flag.StringVar(&flagPullRequestBase, "b", repo.Base, "BASE") + cmdPullRequest.Flag.StringVar(&flagPullRequestHead, "h", repo.Head, "HEAD") } func pullRequest(cmd *Command, args []string) { - log.Println(args) - log.Println(flagPullRequestBase) - log.Println(flagPullRequestHead) + repo.Base = flagPullRequestBase + repo.Head = flagPullRequestHead + + messageFile := filepath.Join(repo.Dir, "PULLREQ_EDITMSG") + + err := writePullRequestChanges(messageFile) + if err != nil { + log.Fatal(err) + } + + editCmd := buildEditCommand(messageFile) + err = editCmd.Exec() + if err != nil { + log.Fatal(err) + } + + title, body, err := readTitleAndBodyFromFile(messageFile) + if err != nil { + log.Fatal(err) + } + if len(title) == 0 { + log.Fatal("Aborting due to empty pull request title") + } + + params := PullRequestParams{title, body, flagPullRequestBase, flagPullRequestHead} + gh := NewGitHub() + pullRequestResponse, err := gh.CreatePullRequest(repo.Owner, repo.Project, params) + if err != nil { + log.Fatal(err) + } + + fmt.Println(pullRequestResponse.HtmlUrl) +} + +func writePullRequestChanges(messageFile string) error { + message := ` +# Requesting a pull to %s from %s +# +# Write a message for this pull reuqest. The first block +# of the text is the title and the rest is description. +# +# Changes: +# +%s +` + startRegexp := regexp.MustCompilePOSIX("^") + endRegexp := regexp.MustCompilePOSIX(" +$") + + commitLogs := FetchGitCommitLogs(repo.Base, repo.Head) + commitLogs = strings.TrimSpace(commitLogs) + commitLogs = startRegexp.ReplaceAllString(commitLogs, "# ") + commitLogs = endRegexp.ReplaceAllString(commitLogs, "") + + message = fmt.Sprintf(message, repo.FullBase(), repo.FullHead(), commitLogs) + + return ioutil.WriteFile(messageFile, []byte(message), 0644) +} + +func getLocalBranch(branchName string) string { + result := strings.Split(branchName, ":") + + return result[len(result)-1] +} + +func buildEditCommand(messageFile string) *ExecCmd { + editor := repo.Editor + editCmd := NewExecCmd(editor) + r := regexp.MustCompile("^[mg]?vim$") + if r.MatchString(editor) { + editCmd.WithArg("-c") + editCmd.WithArg("set ft=gitcommit") + } + editCmd.WithArg(messageFile) + + return editCmd +} + +func readTitleAndBodyFromFile(messageFile string) (title, body string, err error) { + f, err := os.Open(messageFile) + defer f.Close() + if err != nil { + return "", "", err + } + + reader := bufio.NewReader(f) + + return readTitleAndBody(reader) +} + +func readTitleAndBody(reader *bufio.Reader) (title, body string, err error) { + r := regexp.MustCompile("\\S") + var titleParts, bodyParts []string + + line, err := readln(reader) + for err == nil { + if strings.HasPrefix(line, "#") { + break + } + if len(bodyParts) == 0 && r.MatchString(line) { + titleParts = append(titleParts, line) + } else { + bodyParts = append(bodyParts, line) + } + line, err = readln(reader) + } + + title = strings.Join(titleParts, " ") + title = strings.TrimSpace(title) + + body = strings.Join(bodyParts, "\n") + body = strings.TrimSpace(body) + + return title, body, nil +} + +func readln(r *bufio.Reader) (string, error) { + var ( + isPrefix bool = true + err error = nil + line, ln []byte + ) + for isPrefix && err == nil { + line, isPrefix, err = r.ReadLine() + ln = append(ln, line...) + } + + return string(ln), err } diff --git a/pull_request_test.go b/pull_request_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e64d58db5325624002bbb8a914a2c46258e79418 --- /dev/null +++ b/pull_request_test.go @@ -0,0 +1,24 @@ +package main + +import ( + "bufio" + "github.com/bmizerany/assert" + "strings" + "testing" +) + +func TestReadTitleAndBody(t *testing.T) { + message := `A title +A title continues + +A body +A body continues +# comment +` + r := strings.NewReader(message) + reader := bufio.NewReader(r) + title, body, err := readTitleAndBody(reader) + assert.Equal(t, nil, err) + assert.Equal(t, "A title A title continues", title) + assert.Equal(t, "A body\nA body continues", body) +} diff --git a/repo.go b/repo.go new file mode 100644 index 0000000000000000000000000000000000000000..638ae0e8c27279a215c811bd2ee4ef66bee34cd7 --- /dev/null +++ b/repo.go @@ -0,0 +1,28 @@ +package main + +type Repo struct { + Dir string + Editor string + Owner string + Project string + Base string + Head string +} + +func (r *Repo) FullBase() string { + return r.Owner + ":" + r.Base +} + +func (r *Repo) FullHead() string { + return r.Owner + ":" + r.Head +} + +func NewRepo() *Repo { + dir := FetchGitDir() + editor := FetchGitEditor() + owner := FetchGitOwner() + project := FetchGitProject() + head := FetchGitHead() + + return &Repo{dir, editor, owner, project, "master", head} +} diff --git a/url.go b/url.go new file mode 100644 index 0000000000000000000000000000000000000000..9b604a969ae5f9df020e62ffe0ecf2b48563dee0 --- /dev/null +++ b/url.go @@ -0,0 +1,24 @@ +package main + +import ( + "regexp" +) + +func mustMatchGitUrl(url string) []string { + sshRegex := regexp.MustCompile(".+:(.+)/(.+).git") + if sshRegex.MatchString(url) { + return sshRegex.FindStringSubmatch(url) + } + + httpRegex := regexp.MustCompile("https://.+/(.+)/(.+).git") + if httpRegex.MatchString(url) { + return httpRegex.FindStringSubmatch(url) + } + + readOnlyRegex := regexp.MustCompile("git://.+/(.+)/(.+).git") + if readOnlyRegex.MatchString(url) { + return readOnlyRegex.FindStringSubmatch(url) + } + + panic("Can't find owner") +} diff --git a/url_test.go b/url_test.go new file mode 100644 index 0000000000000000000000000000000000000000..88043b8b84cb19db1d48427b13b7a2ff319bf93d --- /dev/null +++ b/url_test.go @@ -0,0 +1,12 @@ +package main + +import ( + "github.com/bmizerany/assert" + "testing" +) + +func TestMustMatchGitUrl(t *testing.T) { + assert.Equal(t, "git://github.com/jingweno/gh.git", mustMatchGitUrl("git://github.com/jingweno/gh.git")[0]) + assert.Equal(t, "git@github.com:jingweno/gh.git", mustMatchGitUrl("git@github.com:jingweno/gh.git")[0]) + assert.Equal(t, "https://github.com/jingweno/gh.git", mustMatchGitUrl("https://github.com/jingweno/gh.git")[0]) +}