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.
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