diff --git a/Makefile b/Makefile index 23b6368c1..5f6d75743 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ ifneq ($(GOFLAGS),) unexport GOPATH endif -MIN_COVERAGE = 89.4 +MIN_COVERAGE = 90.2 HELP_CMD = \ share/man/man1/hub-alias.1 \ diff --git a/commands/issue.go b/commands/issue.go index 309ec3a8e..36bd25930 100644 --- a/commands/issue.go +++ b/commands/issue.go @@ -21,6 +21,7 @@ issue [-a ] [-c ] [-@ ] [-s ] [-f ] [-M issue show [-f ] issue create [-oc] [-m |-F ] [--edit] [-a ] [-M ] [-l ] issue labels [--color] +issue transfer `, Long: `Manage GitHub Issues for the current repository. @@ -37,6 +38,9 @@ With no arguments, show a list of open issues. * _labels_: List the labels available in this repository. + * _transfer_: + Transfer an issue to another repository. + ## Options: -a, --assignee In list mode, display only issues assigned to . @@ -218,12 +222,18 @@ hub-pr(1), hub(1) --color `, } + + cmdTransfer = &Command{ + Key: "transfer", + Run: transferIssue, + } ) func init() { cmdIssue.Use(cmdShowIssue) cmdIssue.Use(cmdCreateIssue) cmdIssue.Use(cmdLabel) + cmdIssue.Use(cmdTransfer) CmdRunner.Use(cmdIssue) } @@ -717,3 +727,79 @@ func milestoneValueToNumber(value string, client *github.Client, project *github return 0, fmt.Errorf("error: no milestone found with name '%s'", value) } + +func transferIssue(cmd *Command, args *Args) { + if args.ParamsSize() < 2 { + utils.Check(cmd.UsageError("")) + } + + localRepo, err := github.LocalRepo() + utils.Check(err) + + project, err := localRepo.MainProject() + utils.Check(err) + + issueNumber, err := strconv.Atoi(args.GetParam(0)) + utils.Check(err) + targetOwner := project.Owner + targetRepo := args.GetParam(1) + if strings.Contains(targetRepo, "/") { + parts := strings.SplitN(targetRepo, "/", 2) + targetOwner = parts[0] + targetRepo = parts[1] + } + + gh := github.NewClient(project.Host) + + nodeIDsResponse := struct { + Source struct { + Issue struct { + ID string + } + } + Target struct { + ID string + } + }{} + err = gh.GraphQL(` + query($issue: Int!, $sourceOwner: String!, $sourceRepo: String!, $targetOwner: String!, $targetRepo: String!) { + source: repository(owner: $sourceOwner, name: $sourceRepo) { + issue(number: $issue) { + id + } + } + target: repository(owner: $targetOwner, name: $targetRepo) { + id + } + }`, map[string]interface{}{ + "issue": issueNumber, + "sourceOwner": project.Owner, + "sourceRepo": project.Name, + "targetOwner": targetOwner, + "targetRepo": targetRepo, + }, &nodeIDsResponse) + utils.Check(err) + + issueResponse := struct { + TransferIssue struct { + Issue struct { + URL string + } + } + }{} + err = gh.GraphQL(` + mutation($issue: ID!, $repo: ID!) { + transferIssue(input: {issueId: $issue, repositoryId: $repo}) { + issue { + url + } + } + }`, map[string]interface{}{ + "issue": nodeIDsResponse.Source.Issue.ID, + "repo": nodeIDsResponse.Target.ID, + }, &issueResponse) + utils.Check(err) + + ui.Println(issueResponse.TransferIssue.Issue.URL) + args.NoForward() +} diff --git a/features/issue-transfer.feature b/features/issue-transfer.feature new file mode 100644 index 000000000..37ed6d3f5 --- /dev/null +++ b/features/issue-transfer.feature @@ -0,0 +1,73 @@ +Feature: hub issue transfer + Background: + Given I am in "git://github.com/octocat/hello-world.git" git repo + And I am "srafi1" on github.com with OAuth token "OTOKEN" + + Scenario: Transfer issue + Given the GitHub API server: + """ + count = 0 + post('/graphql') { + halt 401 unless request.env['HTTP_AUTHORIZATION'] == 'token OTOKEN' + count += 1 + case count + when 1 + assert :query => /\A\s*query\(/, + :variables => { + :issue => 123, + :sourceOwner => "octocat", + :sourceRepo => "hello-world", + :targetOwner => "octocat", + :targetRepo => "spoon-knife", + } + json :data => { + :source => { :issue => { :id => "ISSUE-ID" } }, + :target => { :id => "REPO-ID" }, + } + when 2 + assert :query => /\A\s*mutation\(/, + :variables => { + :issue => "ISSUE-ID", + :repo => "REPO-ID", + } + json :data => { + :transferIssue => { :issue => { :url => "the://url" } } + } + else + status 400 + json :message => "request not stubbed" + end + } + """ + When I successfully run `hub issue transfer 123 spoon-knife` + Then the output should contain exactly "the://url\n" + + Scenario: Transfer to another owner + Given the GitHub API server: + """ + count = 0 + post('/graphql') { + count += 1 + case count + when 1 + assert :variables => { + :targetOwner => "monalisa", + :targetRepo => "playground", + } + json :data => {} + when 2 + json :errors => [ + { :message => "New repository must have the same owner as the current repository" }, + ] + else + status 400 + json :message => "request not stubbed" + end + } + """ + When I run `hub issue transfer 123 monalisa/playground` + Then the exit status should be 1 + Then the stderr should contain exactly: + """ + API error: New repository must have the same owner as the current repository\n + """ diff --git a/features/support/local_server.rb b/features/support/local_server.rb index af761470f..5c3921465 100644 --- a/features/support/local_server.rb +++ b/features/support/local_server.rb @@ -67,22 +67,21 @@ def json(value) JSON.generate value end - def assert(expected) + def assert(expected, data = params) expected.each do |key, value| if :no == value halt 422, json( - :message => "expected %s not to be passed; got %s" % [ - key.inspect, - params[key].inspect - ] - ) if params.key?(key.to_s) - elsif params[key] != value + :message => "did not expect any value for %p; got %p" % [key, data[key]] + ) if data.key?(key.to_s) + elsif Regexp === value halt 422, json( - :message => "expected %s to be %s; got %s" % [ - key.inspect, - value.inspect, - params[key].inspect - ] + :message => "expected %p to match %p; got %p" % [key, value, data[key] ] + ) unless value =~ data[key] + elsif Hash === value + assert(value, data[key]) + elsif data[key] != value + halt 422, json( + :message => "expected %p to be %p; got %p" % [key, value, data[key]] ) end end diff --git a/github/client.go b/github/client.go index 45394435b..1a58319bd 100644 --- a/github/client.go +++ b/github/client.go @@ -887,6 +887,45 @@ func (client *Client) GenericAPIRequest(method, path string, data interface{}, h }) } +// GraphQL facilitates performing a GraphQL request and parsing the response +func (client *Client) GraphQL(query string, variables interface{}, data interface{}) error { + api, err := client.simpleApi() + if err != nil { + return err + } + + payload := map[string]interface{}{ + "query": query, + "variables": variables, + } + resp, err := api.PostJSON("graphql", payload) + if err = checkStatus(200, "performing GraphQL", resp, err); err != nil { + return err + } + + responseData := struct { + Data interface{} + Errors []struct { + Message string + } + }{ + Data: data, + } + err = resp.Unmarshal(&responseData) + if err != nil { + return err + } + + if len(responseData.Errors) > 0 { + messages := []string{} + for _, e := range responseData.Errors { + messages = append(messages, e.Message) + } + return fmt.Errorf("API error: %s", strings.Join(messages, "; ")) + } + return nil +} + func (client *Client) CurrentUser() (user *User, err error) { api, err := client.simpleApi() if err != nil {