提交 1f83610f 编写于 作者: J Jingwen Owen Ou

Merge branch 'pull_request'

......@@ -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
}
......@@ -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))
}
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)}
}
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))
}
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
}
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
}
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)
}
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)
}
......@@ -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)
}
}
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
}
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)
}
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}
}
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")
}
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])
}
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册