geet 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.rubocop.yml +19 -0
- data/.rubocop_todo.yml +52 -0
- data/.ruby-version +1 -0
- data/Gemfile +7 -0
- data/Gemfile.lock +15 -0
- data/LICENSE.md +676 -0
- data/README.md +30 -0
- data/bin/geet +27 -0
- data/geet.gemspec +24 -0
- data/lib/geet/git/repository.rb +80 -0
- data/lib/geet/git_hub/abstract_issue.rb +34 -0
- data/lib/geet/git_hub/account.rb +19 -0
- data/lib/geet/git_hub/api_helper.rb +120 -0
- data/lib/geet/git_hub/issue.rb +44 -0
- data/lib/geet/git_hub/pr.rb +31 -0
- data/lib/geet/git_hub/remote_repository.rb +42 -0
- data/lib/geet/helpers/configuration_helper.rb +54 -0
- data/lib/geet/helpers/os_helper.rb +17 -0
- data/lib/geet/services/create_issue.rb +80 -0
- data/lib/geet/services/create_pr.rb +82 -0
- data/lib/geet/services/list_issues.rb +18 -0
- data/lib/geet/version.rb +3 -0
- metadata +83 -0
data/README.md
ADDED
@@ -0,0 +1,30 @@
|
|
1
|
+
# Geet
|
2
|
+
|
3
|
+
Command line interface for performing Git hosting service operations.
|
4
|
+
|
5
|
+
The current version supports only creating PRs/issues.
|
6
|
+
|
7
|
+
This tool is very similar to [Hub](https://github.com/github/hub), but it supports more complex operations, fully specified via command line.
|
8
|
+
|
9
|
+
## Samples
|
10
|
+
|
11
|
+
Basic creation of an issue and a PR (both actions will open the pages with the result in the browser):
|
12
|
+
|
13
|
+
$ geet issue 'Issue Title' 'Issue Description'
|
14
|
+
$ geet pr 'PR Title' 'PR Description
|
15
|
+
>
|
16
|
+
> Closes #1' --label-patterns "code review" --reviewer-patterns john,tom,adrian
|
17
|
+
|
18
|
+
Create an issue, adding the label matching `bug`, and assigning it to the collaborators matching `john`, `tom`, `kevin`:
|
19
|
+
|
20
|
+
$ geet issue 'Issue Title' 'Issue Description' --label-patterns "code review" --assignee-patterns john,tom,kevin
|
21
|
+
|
22
|
+
Create a PR, adding the label matching `code review`, and requesting reviews from the collaborators matching `john`, `tom`, `adrian`:
|
23
|
+
|
24
|
+
$ geet pr 'PR Title' 'PR Description
|
25
|
+
>
|
26
|
+
> Closes #1' --label-patterns "code review" --reviewer-patterns john,tom,adrian
|
27
|
+
|
28
|
+
For the help:
|
29
|
+
|
30
|
+
$ geet --help
|
data/bin/geet
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require_relative '../lib/geet/helpers/configuration_helper.rb'
|
5
|
+
require_relative '../lib/geet/git/repository.rb'
|
6
|
+
|
7
|
+
include Geet
|
8
|
+
|
9
|
+
configuration_helper = Helpers::ConfigurationHelper.new
|
10
|
+
|
11
|
+
command, options = configuration_helper.decode_argv || exit
|
12
|
+
api_token = configuration_helper.api_token
|
13
|
+
|
14
|
+
title, description = options.values_at(:title, :description)
|
15
|
+
|
16
|
+
repository = Git::Repository.new(api_token)
|
17
|
+
|
18
|
+
case command
|
19
|
+
when Helpers::ConfigurationHelper::ISSUE_CREATE_COMMAND
|
20
|
+
Services::CreateIssue.new.execute(repository, title, description, options)
|
21
|
+
when Helpers::ConfigurationHelper::ISSUE_LIST_COMMAND
|
22
|
+
Services::ListIssues.new.execute(repository)
|
23
|
+
when Helpers::ConfigurationHelper::PR_CREATE_COMMAND
|
24
|
+
Services::CreatePr.new.execute(repository, title, description, options)
|
25
|
+
else
|
26
|
+
raise "Internal error - Unrecognized command #{command.inspect}"
|
27
|
+
end
|
data/geet.gemspec
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
# encoding: UTF-8
|
2
|
+
|
3
|
+
$LOAD_PATH << File.expand_path("lib", __dir__)
|
4
|
+
|
5
|
+
require "geet/version"
|
6
|
+
|
7
|
+
Gem::Specification.new do |s|
|
8
|
+
s.name = "geet"
|
9
|
+
s.version = Geet::VERSION
|
10
|
+
s.platform = Gem::Platform::RUBY
|
11
|
+
s.authors = ["Saverio Miroddi"]
|
12
|
+
s.date = "2017-10-18"
|
13
|
+
s.email = ["saverio.pub2@gmail.com"]
|
14
|
+
s.homepage = "https://github.com/saveriomiroddi/geet"
|
15
|
+
s.summary = "Commandline interface for performing SCM (eg. GitHub) operations (eg. PR creation)."
|
16
|
+
s.description = "Commandline interface for performing SCM (eg. GitHub) operations (eg. PR creation)."
|
17
|
+
s.license = "GPL-3.0"
|
18
|
+
|
19
|
+
s.add_runtime_dependency "simple_scripting", "~> 0.9.3"
|
20
|
+
|
21
|
+
s.files = `git ls-files`.split("\n")
|
22
|
+
s.executables << "geet"
|
23
|
+
s.require_paths = ["lib"]
|
24
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'forwardable'
|
4
|
+
|
5
|
+
Dir[File.join(__dir__, '../**/remote_repository.rb')].each { |repository_file| require repository_file }
|
6
|
+
Dir[File.join(__dir__, '../**/account.rb')].each { |account_file| require account_file }
|
7
|
+
Dir[File.join(__dir__, '../**/api_helper.rb')].each { |helper_file| require helper_file }
|
8
|
+
Dir[File.join(__dir__, '../services/*.rb')].each { |helper_file| require helper_file }
|
9
|
+
|
10
|
+
module Geet
|
11
|
+
module Git
|
12
|
+
# This class represents, for convenience, both the local and the remote repository, but the
|
13
|
+
# remote code is separated in each provider module.
|
14
|
+
class Repository
|
15
|
+
extend Forwardable
|
16
|
+
|
17
|
+
def_delegators :@remote_repository, :collaborators, :labels
|
18
|
+
def_delegators :@remote_repository, :create_issue, :list_issues
|
19
|
+
def_delegators :@remote_repository, :create_pr
|
20
|
+
def_delegators :@account, :authenticated_user
|
21
|
+
|
22
|
+
DOMAIN_PROVIDERS_MAPPING = {
|
23
|
+
'github.com' => Geet::GitHub
|
24
|
+
}.freeze
|
25
|
+
|
26
|
+
# For simplicity, we match any character except the ones the separators.
|
27
|
+
REMOTE_ORIGIN_REGEX = %r{\Agit@([^:]+):([^/]+)/(.*?)(\.git)?\Z}
|
28
|
+
|
29
|
+
def initialize(api_token)
|
30
|
+
the_provider_domain = provider_domain
|
31
|
+
provider_module = DOMAIN_PROVIDERS_MAPPING[the_provider_domain] || raise("Provider not supported for domain: #{provider_domain}")
|
32
|
+
|
33
|
+
api_helper = provider_module::ApiHelper.new(api_token, user, owner, repo)
|
34
|
+
|
35
|
+
@remote_repository = provider_module::RemoteRepository.new(self, api_helper)
|
36
|
+
@account = provider_module::Account.new(api_helper)
|
37
|
+
end
|
38
|
+
|
39
|
+
# METADATA
|
40
|
+
|
41
|
+
def user
|
42
|
+
`git config --get user.email`.strip
|
43
|
+
end
|
44
|
+
|
45
|
+
def provider_domain
|
46
|
+
remote_origin[REMOTE_ORIGIN_REGEX, 1]
|
47
|
+
end
|
48
|
+
|
49
|
+
def owner
|
50
|
+
remote_origin[REMOTE_ORIGIN_REGEX, 2]
|
51
|
+
end
|
52
|
+
|
53
|
+
def repo
|
54
|
+
remote_origin[REMOTE_ORIGIN_REGEX, 3]
|
55
|
+
end
|
56
|
+
|
57
|
+
# DATA
|
58
|
+
|
59
|
+
def current_head
|
60
|
+
`git rev-parse --abbrev-ref HEAD`.strip
|
61
|
+
end
|
62
|
+
|
63
|
+
# OTHER
|
64
|
+
|
65
|
+
private
|
66
|
+
|
67
|
+
# The result is in the format `git@github.com:saveriomiroddi/geet.git`
|
68
|
+
#
|
69
|
+
def remote_origin
|
70
|
+
origin = `git ls-remote --get-url origin`.strip
|
71
|
+
|
72
|
+
if origin !~ REMOTE_ORIGIN_REGEX
|
73
|
+
raise("Unexpected remote reference format: #{origin.inspect}")
|
74
|
+
end
|
75
|
+
|
76
|
+
origin
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Geet
|
4
|
+
module GitHub
|
5
|
+
# For clarity, in this class we keep only the identical logic between the subclasses, but
|
6
|
+
# other methods could be moved in here at some complexity cost.
|
7
|
+
class AbstractIssue
|
8
|
+
attr_reader :issue_number
|
9
|
+
|
10
|
+
def initialize(repository, issue_number, api_helper)
|
11
|
+
@repository = repository
|
12
|
+
@issue_number = issue_number
|
13
|
+
@api_helper = api_helper
|
14
|
+
end
|
15
|
+
|
16
|
+
# params:
|
17
|
+
# users: String, or Array of strings.
|
18
|
+
#
|
19
|
+
def assign_user(users)
|
20
|
+
request_data = { assignees: Array(users) }
|
21
|
+
request_address = "#{@api_helper.repo_link}/issues/#{@issue_number}/assignees"
|
22
|
+
|
23
|
+
@api_helper.send_request(request_address, data: request_data)
|
24
|
+
end
|
25
|
+
|
26
|
+
def add_labels(labels)
|
27
|
+
request_data = labels
|
28
|
+
request_address = "#{@api_helper.repo_link}/issues/#{@issue_number}/labels"
|
29
|
+
|
30
|
+
@api_helper.send_request(request_address, data: request_data)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Geet
|
4
|
+
module GitHub
|
5
|
+
class Account
|
6
|
+
def initialize(api_helper)
|
7
|
+
@api_helper = api_helper
|
8
|
+
end
|
9
|
+
|
10
|
+
def authenticated_user
|
11
|
+
request_address = 'https://api.github.com/user'
|
12
|
+
|
13
|
+
response = @api_helper.send_request(request_address)
|
14
|
+
|
15
|
+
response.fetch('login')
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,120 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
require 'open3'
|
5
|
+
require 'shellwords'
|
6
|
+
|
7
|
+
module Geet
|
8
|
+
module GitHub
|
9
|
+
class ApiHelper
|
10
|
+
def initialize(api_token, user, owner, repo)
|
11
|
+
@api_token = api_token
|
12
|
+
@user = user
|
13
|
+
@owner = owner
|
14
|
+
@repo = repo
|
15
|
+
end
|
16
|
+
|
17
|
+
def repo_link
|
18
|
+
"https://api.github.com/repos/#{@owner}/#{@repo}"
|
19
|
+
end
|
20
|
+
|
21
|
+
# Send a request.
|
22
|
+
#
|
23
|
+
# Returns the parsed response, or an Array, in case of multipage.
|
24
|
+
#
|
25
|
+
# params:
|
26
|
+
# :data: (Hash) if present, will generate a POST request
|
27
|
+
# :multipage: set true for paged GitHub responses (eg. issues); it will make the method
|
28
|
+
# return an array, with the concatenated (parsed) responses
|
29
|
+
#
|
30
|
+
def send_request(address, data: nil, multipage: false)
|
31
|
+
# `--data` implies `-X POST`
|
32
|
+
#
|
33
|
+
if data
|
34
|
+
escaped_request_body = JSON.generate(data).shellescape
|
35
|
+
data_option = "--data #{escaped_request_body}"
|
36
|
+
end
|
37
|
+
|
38
|
+
# filled only on :multipage
|
39
|
+
parsed_responses = []
|
40
|
+
|
41
|
+
loop do
|
42
|
+
command = %(curl --verbose --silent --user "#{@user}:#{@api_token}" #{data_option} #{address})
|
43
|
+
response_metadata, response_body = nil
|
44
|
+
|
45
|
+
Open3.popen3(command) do |_, stdout, stderr, wait_thread|
|
46
|
+
response_metadata = stderr.readlines.join
|
47
|
+
response_body = stdout.readlines.join
|
48
|
+
|
49
|
+
if !wait_thread.value.success?
|
50
|
+
puts response_metadata
|
51
|
+
puts "Error! Command: #{command}"
|
52
|
+
exit
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
parsed_response = JSON.parse(response_body)
|
57
|
+
|
58
|
+
if error?(response_metadata)
|
59
|
+
formatted_error = decode_and_format_error(parsed_response)
|
60
|
+
raise(formatted_error)
|
61
|
+
end
|
62
|
+
|
63
|
+
return parsed_response if !multipage
|
64
|
+
|
65
|
+
parsed_responses.concat(parsed_response)
|
66
|
+
|
67
|
+
address = link_next_page(response_metadata)
|
68
|
+
|
69
|
+
return parsed_responses if address.nil?
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
private
|
74
|
+
|
75
|
+
def decode_and_format_error(response)
|
76
|
+
message = response['message']
|
77
|
+
|
78
|
+
if response.key?('errors')
|
79
|
+
message += ':'
|
80
|
+
|
81
|
+
error_details = response['errors'].map do |error_data|
|
82
|
+
error_code = error_data.fetch('code')
|
83
|
+
|
84
|
+
if error_code == 'custom'
|
85
|
+
" #{error_data.fetch('message')}"
|
86
|
+
else
|
87
|
+
" #{error_code} (#{error_data.fetch('field')})"
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
message += error_details.join(', ')
|
92
|
+
end
|
93
|
+
|
94
|
+
message
|
95
|
+
end
|
96
|
+
|
97
|
+
def error?(response_metadata)
|
98
|
+
status_header = find_header_content(response_metadata, 'Status')
|
99
|
+
|
100
|
+
!!(status_header =~ /^4\d\d/)
|
101
|
+
end
|
102
|
+
|
103
|
+
def link_next_page(response_metadata)
|
104
|
+
link_header = find_header_content(response_metadata, 'Link')
|
105
|
+
|
106
|
+
return nil if link_header.nil?
|
107
|
+
|
108
|
+
link_header[/<(\S+)>; rel="next"/, 1]
|
109
|
+
end
|
110
|
+
|
111
|
+
def find_header_content(response_metadata, header_name)
|
112
|
+
response_metadata.split("\n").each do |header|
|
113
|
+
return Regexp.last_match(1) if header =~ /^< #{header_name}: (.*)/
|
114
|
+
end
|
115
|
+
|
116
|
+
nil
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'abstract_issue'
|
4
|
+
|
5
|
+
module Geet
|
6
|
+
module GitHub
|
7
|
+
class Issue < AbstractIssue
|
8
|
+
def self.create(repository, title, description, api_helper)
|
9
|
+
request_address = "#{api_helper.repo_link}/issues"
|
10
|
+
request_data = { title: title, body: description, base: 'master' }
|
11
|
+
|
12
|
+
response = api_helper.send_request(request_address, data: request_data)
|
13
|
+
|
14
|
+
issue_number = response.fetch('number')
|
15
|
+
|
16
|
+
new(repository, issue_number, api_helper)
|
17
|
+
end
|
18
|
+
|
19
|
+
# Returns an array of Struct(:number, :title); once this workflow is extended,
|
20
|
+
# the struct will likely be converted to a standard class.
|
21
|
+
#
|
22
|
+
# See https://developer.github.com/v3/issues/#list-issues-for-a-repository
|
23
|
+
#
|
24
|
+
def self.list(repository, api_helper)
|
25
|
+
request_address = "#{api_helper.repo_link}/issues"
|
26
|
+
|
27
|
+
response = api_helper.send_request(request_address, multipage: true)
|
28
|
+
issue_class = Struct.new(:number, :title, :link)
|
29
|
+
|
30
|
+
response.map do |issue_data|
|
31
|
+
number = issue_data.fetch('number')
|
32
|
+
title = issue_data.fetch('title')
|
33
|
+
link = issue_data.fetch('html_url')
|
34
|
+
|
35
|
+
issue_class.new(number, title, link)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def link
|
40
|
+
"https://github.com/#{@repository.owner}/#{@repository.repo}/issues/#{@issue_number}"
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'abstract_issue'
|
4
|
+
|
5
|
+
module Geet
|
6
|
+
module GitHub
|
7
|
+
class PR < AbstractIssue
|
8
|
+
def self.create(repository, title, description, head, api_helper)
|
9
|
+
request_address = "#{api_helper.repo_link}/pulls"
|
10
|
+
request_data = { title: title, body: description, head: head, base: 'master' }
|
11
|
+
|
12
|
+
response = api_helper.send_request(request_address, data: request_data)
|
13
|
+
|
14
|
+
issue_number = response.fetch('number')
|
15
|
+
|
16
|
+
new(repository, issue_number, api_helper)
|
17
|
+
end
|
18
|
+
|
19
|
+
def link
|
20
|
+
"https://github.com/#{@repository.owner}/#{@repository.repo}/pull/#{@issue_number}"
|
21
|
+
end
|
22
|
+
|
23
|
+
def request_review(reviewers)
|
24
|
+
request_data = { reviewers: reviewers }
|
25
|
+
request_address = "#{@api_helper.repo_link}/pulls/#{@issue_number}/requested_reviewers"
|
26
|
+
|
27
|
+
@api_helper.send_request(request_address, data: request_data)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'api_helper.rb'
|
4
|
+
require_relative 'issue'
|
5
|
+
require_relative 'pr.rb'
|
6
|
+
|
7
|
+
module Geet
|
8
|
+
module GitHub
|
9
|
+
class RemoteRepository
|
10
|
+
def initialize(local_repository, api_helper)
|
11
|
+
@local_repository = local_repository
|
12
|
+
@api_helper = api_helper
|
13
|
+
end
|
14
|
+
|
15
|
+
def collaborators
|
16
|
+
url = "https://api.github.com/repos/#{@local_repository.owner}/#{@local_repository.repo}/collaborators"
|
17
|
+
response = @api_helper.send_request(url, multipage: true)
|
18
|
+
|
19
|
+
response.map { |user_entry| user_entry.fetch('login') }
|
20
|
+
end
|
21
|
+
|
22
|
+
def labels
|
23
|
+
url = "https://api.github.com/repos/#{@local_repository.owner}/#{@local_repository.repo}/labels"
|
24
|
+
response = @api_helper.send_request(url, multipage: true)
|
25
|
+
|
26
|
+
response.map { |label_entry| label_entry['name'] }
|
27
|
+
end
|
28
|
+
|
29
|
+
def create_issue(title, description)
|
30
|
+
Geet::GitHub::Issue.create(@local_repository, title, description, @api_helper)
|
31
|
+
end
|
32
|
+
|
33
|
+
def list_issues
|
34
|
+
Geet::GitHub::Issue.list(@local_repository, @api_helper)
|
35
|
+
end
|
36
|
+
|
37
|
+
def create_pr(title, description, head: @local_repository.current_head)
|
38
|
+
Geet::GitHub::PR.create(@local_repository, title, description, head, @api_helper)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|