geet 0.3.0 → 0.3.1

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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +6 -10
  3. data/Gemfile.lock +8 -2
  4. data/README.md +27 -2
  5. data/Rakefile +3 -1
  6. data/bin/geet +37 -17
  7. data/geet.gemspec +3 -1
  8. data/lib/geet/commandline/configuration.rb +18 -5
  9. data/lib/geet/commandline/editor.rb +14 -24
  10. data/lib/geet/git/repository.rb +30 -84
  11. data/lib/geet/github/api_interface.rb +11 -3
  12. data/lib/geet/github/gist.rb +1 -0
  13. data/lib/geet/gitlab/api_interface.rb +5 -2
  14. data/lib/geet/helpers/os_helper.rb +36 -3
  15. data/lib/geet/helpers/summary_helper.rb +20 -0
  16. data/lib/geet/resources/{edit_summary_template.md → templates/edit_summary.md} +0 -3
  17. data/lib/geet/services/create_gist.rb +18 -2
  18. data/lib/geet/services/create_issue.rb +46 -21
  19. data/lib/geet/services/create_label.rb +8 -4
  20. data/lib/geet/services/create_pr.rb +67 -18
  21. data/lib/geet/services/list_issues.rb +9 -5
  22. data/lib/geet/services/list_labels.rb +6 -2
  23. data/lib/geet/services/list_milestones.rb +11 -7
  24. data/lib/geet/services/list_prs.rb +6 -2
  25. data/lib/geet/services/merge_pr.rb +18 -11
  26. data/lib/geet/utils/git_client.rb +160 -0
  27. data/lib/geet/utils/manual_list_selection.rb +41 -23
  28. data/lib/geet/version.rb +1 -1
  29. data/spec/integration/create_gist_spec.rb +2 -5
  30. data/spec/integration/create_issue_spec.rb +10 -10
  31. data/spec/integration/create_label_spec.rb +30 -5
  32. data/spec/integration/create_pr_spec.rb +85 -10
  33. data/spec/integration/list_issues_spec.rb +12 -11
  34. data/spec/integration/list_labels_spec.rb +28 -5
  35. data/spec/integration/list_milestones_spec.rb +30 -3
  36. data/spec/integration/list_prs_spec.rb +8 -7
  37. data/spec/integration/merge_pr_spec.rb +8 -7
  38. data/spec/vcr_cassettes/create_gist_private.yml +1 -1
  39. data/spec/vcr_cassettes/create_gist_public.yml +1 -1
  40. data/spec/vcr_cassettes/create_issue.yml +9 -9
  41. data/spec/vcr_cassettes/create_issue_upstream.yml +3 -3
  42. data/spec/vcr_cassettes/create_label.yml +1 -1
  43. data/spec/vcr_cassettes/create_label_upstream.yml +80 -0
  44. data/spec/vcr_cassettes/create_label_with_random_color.yml +1 -1
  45. data/spec/vcr_cassettes/create_pr.yml +13 -13
  46. data/spec/vcr_cassettes/create_pr_in_auto_mode_create_upstream.yml +235 -0
  47. data/spec/vcr_cassettes/create_pr_in_auto_mode_with_push.yml +235 -0
  48. data/spec/vcr_cassettes/create_pr_upstream.yml +4 -4
  49. data/spec/vcr_cassettes/github_com/list_issues.yml +5 -5
  50. data/spec/vcr_cassettes/github_com/list_issues_upstream.yml +6 -6
  51. data/spec/vcr_cassettes/github_com/list_issues_with_assignee.yml +4 -4
  52. data/spec/vcr_cassettes/github_com/list_labels.yml +1 -1
  53. data/spec/vcr_cassettes/github_com/list_labels_upstream.yml +78 -0
  54. data/spec/vcr_cassettes/gitlab_com/list_issues.yml +5 -5
  55. data/spec/vcr_cassettes/gitlab_com/list_labels.yml +1 -1
  56. data/spec/vcr_cassettes/list_milestones.yml +15 -15
  57. data/spec/vcr_cassettes/list_milestones_upstream.yml +155 -0
  58. data/spec/vcr_cassettes/list_prs.yml +6 -6
  59. data/spec/vcr_cassettes/list_prs_upstream.yml +3 -3
  60. data/spec/vcr_cassettes/merge_pr.yml +2 -2
  61. data/spec/vcr_cassettes/merge_pr_with_branch_deletion.yml +2 -2
  62. metadata +24 -4
  63. data/lib/geet/utils/git.rb +0 -30
@@ -3,12 +3,16 @@
3
3
  module Geet
4
4
  module Services
5
5
  class ListIssues
6
- def execute(repository, assignee_pattern: nil, output: $stdout, **)
7
- assignee_thread = find_assignee(repository, assignee_pattern, output) if assignee_pattern
6
+ def initialize(repository)
7
+ @repository = repository
8
+ end
9
+
10
+ def execute(assignee_pattern: nil, output: $stdout, **)
11
+ assignee_thread = find_assignee(assignee_pattern, output) if assignee_pattern
8
12
 
9
13
  assignee = assignee_thread&.join&.value
10
14
 
11
- issues = repository.issues(assignee: assignee)
15
+ issues = @repository.issues(assignee: assignee)
12
16
 
13
17
  issues.each do |issue|
14
18
  output.puts "#{issue.number}. #{issue.title} (#{issue.link})"
@@ -17,11 +21,11 @@ module Geet
17
21
 
18
22
  private
19
23
 
20
- def find_assignee(repository, assignee_pattern, output)
24
+ def find_assignee(assignee_pattern, output)
21
25
  output.puts 'Finding assignee...'
22
26
 
23
27
  Thread.new do
24
- collaborators = repository.collaborators
28
+ collaborators = @repository.collaborators
25
29
  collaborator = collaborators.find { |collaborator| collaborator =~ /#{assignee_pattern}/i }
26
30
  collaborator || raise("No collaborator found for pattern: #{assignee_pattern.inspect}")
27
31
  end
@@ -3,8 +3,12 @@
3
3
  module Geet
4
4
  module Services
5
5
  class ListLabels
6
- def execute(repository, output: $stdout)
7
- labels = repository.labels
6
+ def initialize(repository)
7
+ @repository = repository
8
+ end
9
+
10
+ def execute(output: $stdout)
11
+ labels = @repository.labels
8
12
 
9
13
  labels.each do |label|
10
14
  output.puts "- #{label.name} (##{label.color})"
@@ -3,9 +3,13 @@
3
3
  module Geet
4
4
  module Services
5
5
  class ListMilestones
6
- def execute(repository, output: $stdout)
7
- milestones = find_milestones(repository, output)
8
- issues_by_milestone_number = find_milestone_issues(repository, milestones, output)
6
+ def initialize(repository)
7
+ @repository = repository
8
+ end
9
+
10
+ def execute(output: $stdout)
11
+ milestones = find_milestones(output)
12
+ issues_by_milestone_number = find_milestone_issues(milestones, output)
9
13
 
10
14
  output.puts
11
15
 
@@ -30,13 +34,13 @@ module Geet
30
34
  description
31
35
  end
32
36
 
33
- def find_milestones(repository, output)
37
+ def find_milestones(output)
34
38
  output.puts 'Finding milestones...'
35
39
 
36
- repository.milestones
40
+ @repository.milestones
37
41
  end
38
42
 
39
- def find_milestone_issues(repository, milestones, output)
43
+ def find_milestone_issues(milestones, output)
40
44
  output.puts 'Finding issues...'
41
45
 
42
46
  # Interestingly, on MRI, concurrent hash access is not a problem without mutex,
@@ -46,7 +50,7 @@ module Geet
46
50
 
47
51
  issue_threads = milestones.map do |milestone|
48
52
  Thread.new do
49
- issues = repository.abstract_issues(milestone: milestone.number)
53
+ issues = @repository.abstract_issues(milestone: milestone.number)
50
54
 
51
55
  mutex.synchronize do
52
56
  issues_by_milestone_number[milestone.number] = issues
@@ -3,8 +3,12 @@
3
3
  module Geet
4
4
  module Services
5
5
  class ListPrs
6
- def execute(repository, output: $stdout)
7
- prs = repository.prs
6
+ def initialize(repository)
7
+ @repository = repository
8
+ end
9
+
10
+ def execute(output: $stdout)
11
+ prs = @repository.prs
8
12
 
9
13
  prs.each do |pr|
10
14
  output.puts "#{pr.number}. #{pr.title} (#{pr.link})"
@@ -3,25 +3,32 @@
3
3
  module Geet
4
4
  module Services
5
5
  class MergePr
6
- def execute(repository, delete_branch: false, output: $stdout)
7
- merge_head = find_merge_head(repository)
8
- pr = checked_find_branch_pr(repository, merge_head, output)
6
+ DEFAULT_GIT_CLIENT = Geet::Utils::GitClient.new
7
+
8
+ def initialize(repository, git_client: DEFAULT_GIT_CLIENT)
9
+ @repository = repository
10
+ @git_client = git_client
11
+ end
12
+
13
+ def execute(delete_branch: false, output: $stdout)
14
+ merge_head = find_merge_head
15
+ pr = checked_find_branch_pr(merge_head, output)
9
16
  merge_pr(pr, output)
10
- do_delete_branch(repository, output) if delete_branch
17
+ do_delete_branch(output) if delete_branch
11
18
  pr
12
19
  end
13
20
 
14
21
  private
15
22
 
16
- def find_merge_head(repository)
17
- repository.current_branch
23
+ def find_merge_head
24
+ @git_client.current_branch
18
25
  end
19
26
 
20
27
  # Expect to find only one.
21
- def checked_find_branch_pr(repository, head, output)
28
+ def checked_find_branch_pr(head, output)
22
29
  output.puts "Finding PR with head (#{head})..."
23
30
 
24
- prs = repository.prs(head: head)
31
+ prs = @repository.prs(head: head)
25
32
 
26
33
  raise "Expected to find only one PR for the current branch; found: #{prs.size}" if prs.size != 1
27
34
 
@@ -34,10 +41,10 @@ module Geet
34
41
  pr.merge
35
42
  end
36
43
 
37
- def do_delete_branch(repository, output)
38
- output.puts "Deleting branch #{repository.current_branch}..."
44
+ def do_delete_branch(output)
45
+ output.puts "Deleting branch #{@git_client.current_branch}..."
39
46
 
40
- repository.delete_branch(repository.current_branch)
47
+ @repository.delete_branch(@git_client.current_branch)
41
48
  end
42
49
  end
43
50
  end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+ require_relative '../helpers/os_helper.rb'
5
+
6
+ module Geet
7
+ module Utils
8
+ # Represents the git program interface; used for performing git operations.
9
+ #
10
+ class GitClient
11
+ include Geet::Helpers::OsHelper
12
+
13
+ ORIGIN_NAME = 'origin'
14
+ UPSTREAM_NAME = 'upstream'
15
+
16
+ # For simplicity, we match any character except the ones the separators.
17
+ REMOTE_ORIGIN_REGEX = %r{
18
+ \A
19
+ (?:https://(.+?)/|git@(.+?):)
20
+ ([^/]+/.*?)
21
+ (?:\.git)?
22
+ \Z
23
+ }x
24
+
25
+ UPSTREAM_BRANCH_REGEX = %r{\A[^/]+/([^/]+)\Z}
26
+
27
+ CLEAN_TREE_MESSAGE_REGEX = /^nothing to commit, working tree clean$/
28
+
29
+ def initialize(location: nil)
30
+ @location = location
31
+ end
32
+
33
+ ##########################################################################
34
+ # BRANCH/TREE APIS
35
+ ##########################################################################
36
+
37
+ # Return the commit shas between HEAD and `limit`, excluding the already applied commits
38
+ # (which start with `-`)
39
+ #
40
+ def cherry(limit)
41
+ raw_commits = execute_command("git #{gitdir_option} cherry #{limit.shellescape}")
42
+
43
+ raw_commits.split("\n").grep(/^\+/).map { |line| line[3..-1] }
44
+ end
45
+
46
+ def current_branch
47
+ branch = execute_command("git #{gitdir_option} rev-parse --abbrev-ref HEAD")
48
+
49
+ raise "Couldn't find current branch" if branch == 'HEAD'
50
+
51
+ branch
52
+ end
53
+
54
+ # Not to be confused with `upstream` repository!
55
+ #
56
+ # return: nil, if the upstream branch is not configured.
57
+ #
58
+ def upstream_branch
59
+ head_symbolic_ref = execute_command("git #{gitdir_option} symbolic-ref -q HEAD")
60
+
61
+ raw_upstream_branch = execute_command("git #{gitdir_option} for-each-ref --format='%(upstream:short)' #{head_symbolic_ref.shellescape}").strip
62
+
63
+ if raw_upstream_branch != ''
64
+ raw_upstream_branch[UPSTREAM_BRANCH_REGEX, 1] || raise("Unexpected upstream format: #{raw_upstream_branch}")
65
+ else
66
+ nil
67
+ end
68
+ end
69
+
70
+ def working_tree_clean?
71
+ git_message = execute_command("git #{gitdir_option} status")
72
+
73
+ !!(git_message =~ CLEAN_TREE_MESSAGE_REGEX)
74
+ end
75
+
76
+ ##########################################################################
77
+ # COMMIT/OBJECT APIS
78
+ ##########################################################################
79
+
80
+ # Show the description ("<subject>\n\n<body>") for the given git object.
81
+ #
82
+ def show_description(object)
83
+ execute_command("git #{gitdir_option} show --quiet --format='%s\n\n%b' #{object.shellescape}")
84
+ end
85
+
86
+ ##########################################################################
87
+ # REPOSITORY/REMOTE APIS
88
+ ##########################################################################
89
+
90
+ # Example: `donaldduck/geet`
91
+ #
92
+ def path(upstream: false)
93
+ remote_name = upstream ? UPSTREAM_NAME : ORIGIN_NAME
94
+
95
+ remote(remote_name)[REMOTE_ORIGIN_REGEX, 3]
96
+ end
97
+
98
+ def provider_domain
99
+ # We assume that it's not possible to have origin and upstream on different providers.
100
+ #
101
+ remote_url = remote(ORIGIN_NAME)
102
+
103
+ domain = remote_url[REMOTE_ORIGIN_REGEX, 1] || remote_url[REMOTE_ORIGIN_REGEX, 2]
104
+
105
+ raise "Can't identify domain in the provider domain string: #{domain}" if domain !~ /(.*)\.\w+/
106
+
107
+ domain
108
+ end
109
+
110
+ # Returns the URL of the remote with the given name.
111
+ # Sanity checks are performed.
112
+ #
113
+ # The result is in the format `git@github.com:donaldduck/geet.git`
114
+ #
115
+ def remote(name)
116
+ remote_url = execute_command("git #{gitdir_option} ls-remote --get-url #{name}")
117
+
118
+ if remote_url == name
119
+ raise "Remote #{name.inspect} not found!"
120
+ elsif remote_url !~ REMOTE_ORIGIN_REGEX
121
+ raise "Unexpected remote reference format: #{remote_url.inspect}"
122
+ end
123
+
124
+ remote_url
125
+ end
126
+
127
+ # Doesn't sanity check for the remote url format; this action is for querying
128
+ # purposes, any any action that needs to work with the remote, uses #remote.
129
+ #
130
+ def remote_defined?(name)
131
+ remote_url = execute_command("git #{gitdir_option} ls-remote --get-url #{name}")
132
+
133
+ # If the remote is not define, `git ls-remote` will return the passed value.
134
+ remote_url != name
135
+ end
136
+
137
+ ##########################################################################
138
+ # OPERATION APIS
139
+ ##########################################################################
140
+
141
+ # upstream_branch: create an upstream branch.
142
+ #
143
+ def push(upstream_branch: nil)
144
+ upstream_branch_option = "-u origin #{upstream_branch.shellescape}" if upstream_branch
145
+
146
+ execute_command("git #{gitdir_option} push #{upstream_branch_option}")
147
+ end
148
+
149
+ ##########################################################################
150
+ # INTERNAL HELPERS
151
+ ##########################################################################
152
+
153
+ private
154
+
155
+ def gitdir_option
156
+ "--git-dir #{@location.shellescape}/.git" if @location
157
+ end
158
+ end
159
+ end
160
+ end
@@ -5,41 +5,59 @@ require 'temp-fork-tp-filter'
5
5
  module Geet
6
6
  module Utils
7
7
  class ManualListSelection
8
- PAGER_SIZE = 16
8
+ NO_SELECTION_KEY = '(none)'
9
9
 
10
- PROMPT_METHODS = {
11
- single: :select,
12
- multiple: :multi_select,
13
- }
10
+ PAGER_SIZE = 16
14
11
 
15
- # selection_type: :single or :multiple
12
+ # entry_type: description of the entries type.
13
+ # entries: array of objects; if they're not strings, must also pass :instance_method.
14
+ # this value must not be empty.
15
+ # selection_type: :single or :multiple
16
+ # instance_method: required when non-string objects are passed as entries; its invocation on
17
+ # each object must return a string, which is used as key.
18
+ #
19
+ # returns: the selected entry or array of entries. for single selection, if no entries are
20
+ # chosen, nil is returned.
21
+ #
16
22
  def select(entry_type, entries, selection_type, instance_method: nil)
17
- raise "No #{entry_type} provided!" if entries.empty?
23
+ check_entries(entries)
18
24
 
19
- prompt_method = find_prompt_method(selection_type)
20
- prompt_title = "Please select the #{entry_type}(s):"
25
+ entries = create_entries_map(entries, instance_method)
26
+
27
+ result = show_prompt(entry_type, selection_type, entries)
28
+
29
+ result
30
+ end
31
+
32
+ private
21
33
 
22
- if instance_method
23
- entries = entries.each_with_object({}) do |entry, current_map|
24
- current_map[entry.send(instance_method)] = entry
25
- end
34
+ def check_entries(entries)
35
+ raise "No #{entry_type} provided!" if entries.empty?
36
+ end
37
+
38
+ def create_entries_map(entries, instance_method)
39
+ entries.each_with_object({}) do |entry, current_map|
40
+ key = instance_method ? entry.send(instance_method) : entry
41
+ current_map[key] = entry
26
42
  end
43
+ end
27
44
 
28
- selected_entries = TTY::Prompt.new.send(prompt_method, prompt_title, entries, filter: true, per_page: PAGER_SIZE)
45
+ def show_prompt(entry_type, selection_type, entries)
46
+ prompt_title = "Please select the #{entry_type}(s):"
29
47
 
30
- if selected_entries.is_a?(Array)
31
- raise "No #{entry_type} selected!" if selected_entries.empty?
48
+ case selection_type
49
+ when :single
50
+ entries = add_no_selection_entry(entries)
51
+ TTY::Prompt.new.select(prompt_title, entries, filter: true, per_page: PAGER_SIZE)
52
+ when :multiple
53
+ TTY::Prompt.new.multi_select(prompt_title, entries, filter: true, per_page: PAGER_SIZE)
32
54
  else
33
- raise "No #{entry_type} selected!" if selected_entries.nil?
55
+ raise "Unexpected selection type: #{selection_type.inspect}"
34
56
  end
35
-
36
- selected_entries
37
57
  end
38
58
 
39
- private
40
-
41
- def find_prompt_method(selection_type)
42
- PROMPT_METHODS[selection_type] || raise("Unrecognized selection_type: #{selection_type}")
59
+ def add_no_selection_entry(entries)
60
+ {NO_SELECTION_KEY => nil}.merge(entries)
43
61
  end
44
62
  end
45
63
  end
data/lib/geet/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Geet
4
- VERSION = '0.3.0'
4
+ VERSION = '0.3.1'
5
5
  end
@@ -7,7 +7,6 @@ require_relative '../../lib/geet/git/repository'
7
7
  require_relative '../../lib/geet/services/create_gist'
8
8
 
9
9
  describe Geet::Services::CreateGist do
10
- let(:repository) { Geet::Git::Repository.new }
11
10
  let(:tempfile) { Tempfile.open('geet_gist') { |file| file << 'testcontent' } }
12
11
 
13
12
  it 'should create a public gist' do
@@ -20,8 +19,7 @@ describe Geet::Services::CreateGist do
20
19
 
21
20
  VCR.use_cassette('create_gist_public') do
22
21
  described_class.new.execute(
23
- repository, tempfile.path,
24
- description: 'testdescription', publik: true, no_browse: true, output: actual_output
22
+ tempfile.path, description: 'testdescription', publik: true, no_browse: true, output: actual_output
25
23
  )
26
24
  end
27
25
 
@@ -38,8 +36,7 @@ describe Geet::Services::CreateGist do
38
36
 
39
37
  VCR.use_cassette('create_gist_private') do
40
38
  described_class.new.execute(
41
- repository, tempfile.path,
42
- description: 'testdescription', no_browse: true, output: actual_output
39
+ tempfile.path, description: 'testdescription', no_browse: true, output: actual_output
43
40
  )
44
41
  end
45
42
 
@@ -6,12 +6,13 @@ require_relative '../../lib/geet/git/repository'
6
6
  require_relative '../../lib/geet/services/create_issue'
7
7
 
8
8
  describe Geet::Services::CreateIssue do
9
- let(:repository) { Geet::Git::Repository.new }
10
- let(:upstream_repository) { Geet::Git::Repository.new(upstream: true) }
9
+ let(:git_client) { Geet::Utils::GitClient.new }
10
+ let(:repository) { Geet::Git::Repository.new(git_client: git_client) }
11
+ let(:upstream_repository) { Geet::Git::Repository.new(upstream: true, git_client: git_client) }
11
12
 
12
13
  context 'with labels, assignees and milestones' do
13
14
  it 'should create an issue' do
14
- allow(repository).to receive(:remote).with('origin').and_return('git@github.com:donaldduck/testrepo')
15
+ allow(git_client).to receive(:remote).with('origin').and_return('git@github.com:donaldduck/testrepo')
15
16
 
16
17
  expected_output = <<~STR
17
18
  Finding labels...
@@ -27,8 +28,8 @@ describe Geet::Services::CreateIssue do
27
28
  actual_output = StringIO.new
28
29
 
29
30
  actual_created_issue = VCR.use_cassette('create_issue') do
30
- described_class.new.execute(
31
- repository, 'Title', 'Description',
31
+ described_class.new(repository).execute(
32
+ 'Title', 'Description',
32
33
  label_patterns: 'bug,invalid', milestone_pattern: '0.0.1', assignee_patterns: 'nald-ts,nald-fr',
33
34
  no_open_issue: true, output: actual_output
34
35
  )
@@ -43,13 +44,12 @@ describe Geet::Services::CreateIssue do
43
44
  end
44
45
 
45
46
  it 'should create an upstream issue' do
46
- allow(upstream_repository).to receive(:current_branch).and_return('mybranch')
47
- allow(upstream_repository).to receive(:remote).with('origin').and_return('git@github.com:donaldduck/testrepo')
48
- allow(upstream_repository).to receive(:remote).with('upstream').and_return('git@github.com:donald-fr/testrepo_u')
47
+ allow(git_client).to receive(:current_branch).and_return('mybranch')
48
+ allow(git_client).to receive(:remote).with('origin').and_return('git@github.com:donaldduck/testrepo')
49
+ allow(git_client).to receive(:remote).with('upstream').and_return('git@github.com:donald-fr/testrepo_u')
49
50
 
50
51
  expected_output = <<~STR
51
52
  Creating the issue...
52
- Assigning authenticated user...
53
53
  Issue address: https://github.com/donald-fr/testrepo_u/issues/7
54
54
  STR
55
55
 
@@ -57,7 +57,7 @@ describe Geet::Services::CreateIssue do
57
57
 
58
58
  actual_created_issue = VCR.use_cassette('create_issue_upstream') do
59
59
  create_options = { no_open_issue: true, output: actual_output }
60
- described_class.new.execute(upstream_repository, 'Title', 'Description', create_options)
60
+ described_class.new(upstream_repository).execute('Title', 'Description', create_options)
61
61
  end
62
62
 
63
63
  expect(actual_output.string).to eql(expected_output)