geet 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|