jigit 1.0.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.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. data/.gitattributes +1 -0
  3. data/.gitignore +60 -0
  4. data/.rspec +2 -0
  5. data/.rubocop.yml +117 -0
  6. data/.travis.yml +20 -0
  7. data/CHANGELOG.md +5 -0
  8. data/Dangerfile +22 -0
  9. data/Gemfile +7 -0
  10. data/Gemfile.lock +138 -0
  11. data/LICENSE +21 -0
  12. data/README.md +66 -0
  13. data/Rakefile +32 -0
  14. data/bin/jigit +5 -0
  15. data/jigit.gemspec +35 -0
  16. data/lib/jigit.rb +12 -0
  17. data/lib/jigit/commands/init.rb +309 -0
  18. data/lib/jigit/commands/issue.rb +56 -0
  19. data/lib/jigit/commands/runner.rb +22 -0
  20. data/lib/jigit/commands/start_issue.rb +58 -0
  21. data/lib/jigit/commands/stop_issue.rb +53 -0
  22. data/lib/jigit/core/jigitfile.rb +31 -0
  23. data/lib/jigit/core/jigitfile_constants.rb +15 -0
  24. data/lib/jigit/core/jigitfile_generator.rb +34 -0
  25. data/lib/jigit/git/git_hook.rb +11 -0
  26. data/lib/jigit/git/git_hook_installer.rb +60 -0
  27. data/lib/jigit/git/git_ignore_updater.rb +20 -0
  28. data/lib/jigit/git/post_checkout_hook.rb +23 -0
  29. data/lib/jigit/helpers/informator.rb +131 -0
  30. data/lib/jigit/helpers/keychain_storage.rb +19 -0
  31. data/lib/jigit/jira/jira_api_client.rb +80 -0
  32. data/lib/jigit/jira/jira_api_client_error.rb +10 -0
  33. data/lib/jigit/jira/jira_config.rb +16 -0
  34. data/lib/jigit/jira/jira_transition_finder.rb +16 -0
  35. data/lib/jigit/jira/resources/jira_issue.rb +34 -0
  36. data/lib/jigit/jira/resources/jira_status.rb +18 -0
  37. data/lib/jigit/jira/resources/jira_transition.rb +18 -0
  38. data/lib/jigit/version.rb +4 -0
  39. data/spec/fixtures/jigitfile_invalid.yaml +2 -0
  40. data/spec/fixtures/jigitfile_valid.yaml +5 -0
  41. data/spec/lib/integration/jigit/core/jigitfile_generator_spec.rb +27 -0
  42. data/spec/lib/integration/jigit/core/keychain_storage_spec.rb +35 -0
  43. data/spec/lib/integration/jigit/git/git_hook_installer_spec.rb +66 -0
  44. data/spec/lib/integration/jigit/git/git_ignore_updater_spec.rb +45 -0
  45. data/spec/lib/integration/jigit/jira/jira_api_client_spec.rb +154 -0
  46. data/spec/lib/unit/jigit/core/jigitfile_spec.rb +33 -0
  47. data/spec/lib/unit/jigit/git/post_checkout_hook_spec.rb +22 -0
  48. data/spec/lib/unit/jigit/git_hooks/post_checkout_hook_spec.rb +22 -0
  49. data/spec/lib/unit/jigit/jira/jira_config_spec.rb +23 -0
  50. data/spec/lib/unit/jigit/jira/jira_issue_spec.rb +101 -0
  51. data/spec/lib/unit/jigit/jira/jira_status_spec.rb +62 -0
  52. data/spec/lib/unit/jigit/jira/jira_transition_finder_spec.rb +70 -0
  53. data/spec/lib/unit/jigit/jira/jira_transition_spec.rb +64 -0
  54. data/spec/mock_responses/issue.json +1108 -0
  55. data/spec/mock_responses/issue_1002_transitions.json +49 -0
  56. data/spec/mock_responses/statuses.json +37 -0
  57. data/spec/spec_helper.rb +17 -0
  58. data/tasks/generate_jira_localhost.rake +20 -0
  59. metadata +293 -0
@@ -0,0 +1,22 @@
1
+ require "jigit/version"
2
+ require "jigit/helpers/informator"
3
+ require "claide"
4
+ require "cork"
5
+
6
+ module Jigit
7
+ class Runner < CLAide::Command
8
+ self.abstract_command = true
9
+ self.summary = "Jira + Git = onelove"
10
+ self.version = Jigit::VERSION
11
+ self.command = "jigit"
12
+
13
+ def initialize(argv)
14
+ super
15
+ @cork = Cork::Board.new(silent: argv.option("silent", false), verbose: argv.option("verbose", false))
16
+ end
17
+
18
+ def ui
19
+ @ui ||= Informator.new(@cork)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,58 @@
1
+ require "jigit/commands/issue"
2
+ require "jigit/jira/jira_transition_finder"
3
+
4
+ module Jigit
5
+ class StartIssueRunner < IssueRunner
6
+ self.abstract_command = false
7
+ self.summary = "Command to put the given JIRA issue to 'In Progress' state"
8
+ self.command = "start"
9
+
10
+ def run
11
+ self
12
+ begin
13
+ jira_issue = @jira_api_client.fetch_jira_issue(@issue_name)
14
+ return unless could_start_working_on_issue?(jira_issue, @issue_name)
15
+ return unless want_to_start_working_on_issue?(jira_issue)
16
+ put_issue_to_in_progress(jira_issue)
17
+ rescue Jigit::JiraInvalidIssueKeyError
18
+ ui.say "#{@issue_name} doesn't exist on JIRA, skipping..."
19
+ rescue Jigit::JiraAPIClientError => exception
20
+ ui.error "Error while executing issue start command: #{exception.message}"
21
+ rescue Jigit::NetworkError => exception
22
+ ui.error "Error while executing issue start command: #{exception.message}"
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def put_issue_to_in_progress(jira_issue)
29
+ transition_finder = Jigit::JiraTransitionFinder.new(@jira_api_client.fetch_issue_transitions(jira_issue))
30
+ to_in_progress_transition = transition_finder.find_transition_to(@jigitfile.in_progress_status)
31
+ unless to_in_progress_transition
32
+ ui.error("#{issue.key} doesn't have transition to '#{@jigitfile.in_progress_status}' status...")
33
+ return
34
+ end
35
+
36
+ jira_issue.make_transition(to_in_progress_transition.id)
37
+ ui.inform("#{@issue_name} now is '#{@jigitfile.in_progress_status}' 💪")
38
+ end
39
+
40
+ def want_to_start_working_on_issue?(jira_issue)
41
+ proceed_option = ui.ask_with_answers("Are you going to work on #{jira_issue.key}?\n", ["yes", "no"])
42
+ proceed_option == "yes"
43
+ end
44
+
45
+ def could_start_working_on_issue?(jira_issue, issue_name)
46
+ unless jira_issue
47
+ ui.say("#{issue_name} doesn't exist on JIRA, skipping...")
48
+ return false
49
+ end
50
+
51
+ if jira_issue.status.name == @jigitfile.in_progress_status
52
+ ui.say("#{jira_issue.key} is already #{@jigitfile.in_progress_status}...")
53
+ return false
54
+ end
55
+ return true
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,53 @@
1
+ require "jigit/commands/issue"
2
+ require "jigit/jira/jira_transition_finder"
3
+
4
+ module Jigit
5
+ class StopIssueRunner < IssueRunner
6
+ self.abstract_command = false
7
+ self.summary = "Command to put the given JIRA issue to any state, but 'In Progress'"
8
+ self.command = "stop"
9
+
10
+ def run
11
+ self
12
+ begin
13
+ jira_issue = @jira_api_client.fetch_jira_issue(@issue_name)
14
+ return unless issue_exists?(jira_issue)
15
+ new_status = ask_for_new_status_for_issue
16
+ put_issue_to_status(jira_issue, new_status)
17
+ rescue Jigit::JiraInvalidIssueKeyError
18
+ ui.say "#{@issue_name} doesn't exist on JIRA, skipping..."
19
+ rescue Jigit::NetworkError => exception
20
+ ui.error "Error while executing issue stop command: #{exception.message}"
21
+ rescue Jigit::JiraAPIClientError => exception
22
+ ui.error "Error while executing issue stop command: #{exception.message}"
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def put_issue_to_status(jira_issue, new_status)
29
+ transition_finder = Jigit::JiraTransitionFinder.new(@jira_api_client.fetch_issue_transitions(jira_issue))
30
+ to_new_status_transition = transition_finder.find_transition_to(new_status)
31
+ unless to_new_status_transition
32
+ ui.error("#{jira_issue.key} doesn't have transition to '#{new_status}' status...")
33
+ return
34
+ end
35
+ jira_issue.make_transition(to_new_status_transition.id)
36
+ ui.inform("#{jira_issue.key} now is '#{new_status}' 🎉")
37
+ end
38
+
39
+ def issue_exists?(jira_issue)
40
+ unless jira_issue
41
+ ui.say("#{@issue_name} doesn't exist on JIRA, skipping...")
42
+ return false
43
+ end
44
+ return true
45
+ end
46
+
47
+ def ask_for_new_status_for_issue
48
+ question = "You've stopped working on '#{@issue_name}', to which status do you want to put it\n"
49
+ new_status = ui.ask_with_answers(question, @jigitfile.other_statuses, true)
50
+ new_status
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,31 @@
1
+ require "yaml"
2
+ require "jigit/core/jigitfile_constants"
3
+
4
+ module Jigit
5
+ class Jigitfile
6
+ # @return [String] The status which represens In Progress state
7
+ attr_accessor :in_progress_status
8
+ # @return [Array] The other possible statuses
9
+ attr_accessor :other_statuses
10
+ # @return [String] JIRA server host
11
+ attr_accessor :host
12
+
13
+ def initialize(path)
14
+ raise "Path is a required parameter" if path.nil?
15
+ raise "Couldn't find Jigitfile file at '#{path}'" unless File.exist?(path)
16
+ jigitfile = File.read(path)
17
+ yaml_hash = read_data_from_yaml_file(jigitfile, path)
18
+ self.in_progress_status = yaml_hash[JigitfileConstants.in_progress_status]
19
+ self.other_statuses = yaml_hash[JigitfileConstants.other_statuses]
20
+ self.host = yaml_hash[JigitfileConstants.host]
21
+ end
22
+
23
+ private
24
+
25
+ def read_data_from_yaml_file(yaml_file, path)
26
+ YAML.load(yaml_file)
27
+ rescue Psych::SyntaxError
28
+ raise "File at '#{path}' doesn't have a legit YAML syntax"
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,15 @@
1
+ module Jigit
2
+ class JigitfileConstants
3
+ def self.in_progress_status
4
+ "in_progress_status"
5
+ end
6
+
7
+ def self.other_statuses
8
+ "other_statuses"
9
+ end
10
+
11
+ def self.host
12
+ "host"
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,34 @@
1
+ require "jigit/core/jigitfile_constants"
2
+ require "fileutils"
3
+ require "yaml"
4
+
5
+ module Jigit
6
+ class JigitfileGenerator
7
+ def initialize(path = nil)
8
+ @path = path ? path : ".jigit"
9
+ @jigitfile_hash = {}
10
+ end
11
+
12
+ def write_jira_host(host)
13
+ raise "Host can not be empty" if host.nil? || host.empty?
14
+ @jigitfile_hash[JigitfileConstants.host] = host
15
+ end
16
+
17
+ def write_in_progress_status_name(name)
18
+ raise "In progress status name can not be empty" if name.nil? || name.empty?
19
+ @jigitfile_hash[JigitfileConstants.in_progress_status] = name
20
+ end
21
+
22
+ def write_other_statuses(other_statuses)
23
+ raise "All statuses must be string" if other_statuses.select { |status| !status.kind_of?(String) }.count > 0
24
+ @jigitfile_hash[JigitfileConstants.other_statuses] = other_statuses
25
+ end
26
+
27
+ def save
28
+ FileUtils.mkdir_p(@path)
29
+ File.open("#{@path}/Jigitfile.yml", "w") do |file|
30
+ file.write(@jigitfile_hash.to_yaml)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,11 @@
1
+ module Jigit
2
+ class GitHook
3
+ def self.hook_lines
4
+ raise "GitHook subclass must specify the actual hook lines"
5
+ end
6
+
7
+ def self.name
8
+ raise "GitHook subclass must specify the actual name"
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,60 @@
1
+ require "fileutils"
2
+
3
+ module Jigit
4
+ # Command to setup the git hook for jigit
5
+ class GitHookInstaller
6
+ def initialize(git_hooks_folder = nil, git_path = nil)
7
+ @git_hooks_folder = git_hooks_folder ? git_hooks_folder : default_git_hooks_folder
8
+ @git_path = git_path ? git_path : default_git_path
9
+ @is_git_hook_file_new = false
10
+ end
11
+
12
+ def install(hook)
13
+ @git_hook_name = hook.name
14
+ @git_hook_file_path = "#{@git_hooks_folder}/#{@git_hook_name}"
15
+
16
+ ensure_git_hook_file_exists
17
+ ensure_git_hook_file_is_executable
18
+ write_hook_lines(hook.hook_lines)
19
+ end
20
+
21
+ private
22
+
23
+ def ensure_git_hook_file_exists
24
+ @git_hook_file_path = File.realpath(@git_hook_file_path) if File.symlink?(@git_hook_file_path)
25
+ return if File.exist?(@git_hook_file_path)
26
+
27
+ raise "Git folder is not found at '#{@git_path}'" unless Dir.exist?(@git_path)
28
+
29
+ FileUtils.mkdir_p(@git_hooks_folder)
30
+ @git_hook_file_path = "#{@git_hooks_folder}/#{@git_hook_name}"
31
+ FileUtils.touch(@git_hook_file_path)
32
+ FileUtils.chmod("u=xwr", @git_hook_file_path)
33
+ @is_git_hook_file_new = true
34
+ end
35
+
36
+ def ensure_git_hook_file_is_executable
37
+ raise "git hook file at '#{@git_hook_file_path}' is not executable by the effective user id of this process" unless File.executable?(@git_hook_file_path)
38
+ end
39
+
40
+ def write_hook_lines(hook_lines)
41
+ File.open(@git_hook_file_path, @is_git_hook_file_new ? "r+" : "a") do |f|
42
+ hook_lines.each do |line|
43
+ f.puts(line)
44
+ end
45
+ end
46
+ end
47
+
48
+ def git_hook_file_path
49
+ "#{default_git_hooks_folder}/#{@git_hook_name}"
50
+ end
51
+
52
+ def default_git_path
53
+ ".git"
54
+ end
55
+
56
+ def default_git_hooks_folder
57
+ "#{default_git_path}/hooks"
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,20 @@
1
+ module Jigit
2
+ class GitIgnoreUpdater
3
+ def initialize(git_ignore_path = nil)
4
+ @gitignore_path = git_ignore_path ? git_ignore_path : default_gitignore_path
5
+ raise "Gitignore file at #{@gitignore_path} is not found" unless @gitignore_path
6
+ end
7
+
8
+ def ignore(line_to_ignore)
9
+ File.open(@gitignore_path, "a") do |f|
10
+ f.puts(line_to_ignore)
11
+ end
12
+ end
13
+
14
+ private
15
+
16
+ def default_gitignore_path
17
+ ".gitignore"
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,23 @@
1
+ require "jigit/git/git_hook"
2
+
3
+ module Jigit
4
+ class PostCheckoutHook < GitHook
5
+ def self.hook_lines
6
+ ["#!/usr/bin/env bash",
7
+ "checkoutType=$3",
8
+ "[[ $checkoutType == 1 ]] && checkoutType='branch' || checkoutType='file'",
9
+ "if [ $checkoutType == 'branch' ]; then",
10
+ " newBranchName=`git symbolic-ref --short HEAD`",
11
+ " oldBranchName=`git rev-parse --abbrev-ref @{-1}`",
12
+ " if [ $newBranchName != $oldBranchName ]; then",
13
+ " jigit issue start --name=$newBranchName",
14
+ " jigit issue stop --name=$oldBranchName",
15
+ " fi",
16
+ "fi"]
17
+ end
18
+
19
+ def self.name
20
+ "post-checkout"
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,131 @@
1
+ require "cork"
2
+
3
+ module Jigit
4
+ # This class is heavily based on the Interviewer class from the Danger gem
5
+ # The original link is https://github.com/danger/danger/blob/master/lib/danger/commands/init_helpers/interviewer.rb
6
+ class Informator
7
+ attr_accessor :no_waiting, :ui, :no_delay
8
+
9
+ def initialize(cork_board)
10
+ @ui = cork_board
11
+ end
12
+
13
+ def show_prompt
14
+ ui.print("> ".bold.green)
15
+ end
16
+
17
+ def say(message)
18
+ ui.puts(message)
19
+ end
20
+
21
+ def inform(message)
22
+ ui.puts(message.green)
23
+ end
24
+
25
+ def link(url)
26
+ say " -> " + url.underline + "\n"
27
+ end
28
+
29
+ def pause(time)
30
+ sleep(time) unless @no_waiting
31
+ end
32
+
33
+ def header(title)
34
+ say title.yellow
35
+ say ""
36
+ pause 0.6
37
+ end
38
+
39
+ def important(message)
40
+ i = message.length + 8
41
+ inform("-" * i)
42
+ inform("--- " + message + " ---")
43
+ inform("-" * i)
44
+ end
45
+
46
+ def warn(message)
47
+ ui.puts(message.yellow)
48
+ end
49
+
50
+ def error(message)
51
+ ui.puts(message.red)
52
+ end
53
+
54
+ def wait_for_return
55
+ STDOUT.flush
56
+ STDIN.gets unless @no_delay
57
+ ui.puts
58
+ end
59
+
60
+ def ask(question)
61
+ STDIN.reopen(File.open("/dev/tty", "r"))
62
+
63
+ answer = ""
64
+ loop do
65
+ ui.puts "\n#{question}?"
66
+
67
+ show_prompt
68
+ answer = STDIN.gets.chomp
69
+
70
+ break unless answer.empty?
71
+
72
+ ui.print "\nYou need to provide an answer."
73
+ end
74
+ answer
75
+ end
76
+
77
+ def ask_with_answers(question, possible_answers, is_numerated = false)
78
+ STDIN.reopen(File.open("/dev/tty", "r"))
79
+
80
+ ui.print("\n#{question}? [")
81
+ possible_answers_to_print = is_numerated ? possible_answers.map.with_index { |answer, index| "#{index}. #{answer}" } : possible_answers
82
+ print_possible_answers(possible_answers_to_print)
83
+ answer = ""
84
+ loop do
85
+ show_prompt
86
+ answer = read_answer(possible_answers)
87
+
88
+ if is_numerated
89
+ numerated_answer = begin
90
+ Integer(answer)
91
+ rescue
92
+ break
93
+ end
94
+ break if numerated_answer < 0 && possible_answers.count <= numerated_answer
95
+ answer = possible_answers[numerated_answer]
96
+ end
97
+
98
+ break if possible_answers.include? answer
99
+
100
+ ui.print "\nPossible answers are ["
101
+ print_possible_answers(possible_answers)
102
+ end
103
+ answer
104
+ end
105
+
106
+ private
107
+
108
+ def read_answer(possible_answers)
109
+ answer = @no_waiting ? possible_answers[0] : STDIN.gets
110
+ answer = answer.chomp unless answer.nil?
111
+ answer = "yes" if answer == "y"
112
+ answer = "no" if answer == "n"
113
+
114
+ # default to first answer
115
+ if answer == ""
116
+ answer = possible_answers[0]
117
+ ui.puts "Using: " + answer.yellow
118
+ end
119
+ answer
120
+ end
121
+
122
+ def print_possible_answers(possible_answers)
123
+ possible_answers.each_with_index do |answer, i|
124
+ the_answer = i.zero? ? answer.underline : answer
125
+ ui.print " " + the_answer
126
+ ui.print(" /") if i != possible_answers.length - 1
127
+ end
128
+ ui.print " ]\n"
129
+ end
130
+ end
131
+ end