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.
- checksums.yaml +7 -0
- data/.gitattributes +1 -0
- data/.gitignore +60 -0
- data/.rspec +2 -0
- data/.rubocop.yml +117 -0
- data/.travis.yml +20 -0
- data/CHANGELOG.md +5 -0
- data/Dangerfile +22 -0
- data/Gemfile +7 -0
- data/Gemfile.lock +138 -0
- data/LICENSE +21 -0
- data/README.md +66 -0
- data/Rakefile +32 -0
- data/bin/jigit +5 -0
- data/jigit.gemspec +35 -0
- data/lib/jigit.rb +12 -0
- data/lib/jigit/commands/init.rb +309 -0
- data/lib/jigit/commands/issue.rb +56 -0
- data/lib/jigit/commands/runner.rb +22 -0
- data/lib/jigit/commands/start_issue.rb +58 -0
- data/lib/jigit/commands/stop_issue.rb +53 -0
- data/lib/jigit/core/jigitfile.rb +31 -0
- data/lib/jigit/core/jigitfile_constants.rb +15 -0
- data/lib/jigit/core/jigitfile_generator.rb +34 -0
- data/lib/jigit/git/git_hook.rb +11 -0
- data/lib/jigit/git/git_hook_installer.rb +60 -0
- data/lib/jigit/git/git_ignore_updater.rb +20 -0
- data/lib/jigit/git/post_checkout_hook.rb +23 -0
- data/lib/jigit/helpers/informator.rb +131 -0
- data/lib/jigit/helpers/keychain_storage.rb +19 -0
- data/lib/jigit/jira/jira_api_client.rb +80 -0
- data/lib/jigit/jira/jira_api_client_error.rb +10 -0
- data/lib/jigit/jira/jira_config.rb +16 -0
- data/lib/jigit/jira/jira_transition_finder.rb +16 -0
- data/lib/jigit/jira/resources/jira_issue.rb +34 -0
- data/lib/jigit/jira/resources/jira_status.rb +18 -0
- data/lib/jigit/jira/resources/jira_transition.rb +18 -0
- data/lib/jigit/version.rb +4 -0
- data/spec/fixtures/jigitfile_invalid.yaml +2 -0
- data/spec/fixtures/jigitfile_valid.yaml +5 -0
- data/spec/lib/integration/jigit/core/jigitfile_generator_spec.rb +27 -0
- data/spec/lib/integration/jigit/core/keychain_storage_spec.rb +35 -0
- data/spec/lib/integration/jigit/git/git_hook_installer_spec.rb +66 -0
- data/spec/lib/integration/jigit/git/git_ignore_updater_spec.rb +45 -0
- data/spec/lib/integration/jigit/jira/jira_api_client_spec.rb +154 -0
- data/spec/lib/unit/jigit/core/jigitfile_spec.rb +33 -0
- data/spec/lib/unit/jigit/git/post_checkout_hook_spec.rb +22 -0
- data/spec/lib/unit/jigit/git_hooks/post_checkout_hook_spec.rb +22 -0
- data/spec/lib/unit/jigit/jira/jira_config_spec.rb +23 -0
- data/spec/lib/unit/jigit/jira/jira_issue_spec.rb +101 -0
- data/spec/lib/unit/jigit/jira/jira_status_spec.rb +62 -0
- data/spec/lib/unit/jigit/jira/jira_transition_finder_spec.rb +70 -0
- data/spec/lib/unit/jigit/jira/jira_transition_spec.rb +64 -0
- data/spec/mock_responses/issue.json +1108 -0
- data/spec/mock_responses/issue_1002_transitions.json +49 -0
- data/spec/mock_responses/statuses.json +37 -0
- data/spec/spec_helper.rb +17 -0
- data/tasks/generate_jira_localhost.rake +20 -0
- 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,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,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
|