geet 0.3.0 → 0.3.1

Sign up to get free protection for your applications and to get access to all the features.
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)