story_branch 0.2.11 → 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.circleci/config.yml +37 -0
- data/.github/ISSUE_TEMPLATE.md +12 -0
- data/.gitignore +3 -0
- data/.rspec +2 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +94 -0
- data/{LICENCE → LICENSE.txt} +1 -1
- data/README.md +79 -60
- data/Rakefile +6 -0
- data/exe/git-finish +3 -0
- data/exe/git-start +3 -0
- data/exe/git-story +3 -0
- data/exe/git-unstart +3 -0
- data/exe/story_branch +18 -0
- data/lib/story_branch.rb +2 -477
- data/lib/story_branch/cli.rb +93 -0
- data/lib/story_branch/command.rb +121 -0
- data/lib/story_branch/commands/.gitkeep +1 -0
- data/lib/story_branch/commands/add.rb +48 -0
- data/lib/story_branch/commands/create.rb +21 -0
- data/lib/story_branch/commands/finish.rb +20 -0
- data/lib/story_branch/commands/migrate.rb +100 -0
- data/lib/story_branch/commands/start.rb +20 -0
- data/lib/story_branch/commands/unstart.rb +20 -0
- data/lib/story_branch/config_manager.rb +18 -0
- data/lib/story_branch/git_utils.rb +85 -0
- data/lib/story_branch/main.rb +124 -0
- data/lib/story_branch/pivotal_utils.rb +146 -0
- data/lib/story_branch/string_utils.rb +23 -0
- data/lib/story_branch/templates/.gitkeep +1 -0
- data/lib/story_branch/templates/add/.gitkeep +1 -0
- data/lib/story_branch/templates/config/.gitkeep +1 -0
- data/lib/story_branch/templates/create/.gitkeep +1 -0
- data/lib/story_branch/templates/finish/.gitkeep +1 -0
- data/lib/story_branch/templates/migrate/.gitkeep +1 -0
- data/lib/story_branch/templates/start/.gitkeep +1 -0
- data/lib/story_branch/templates/unstart/.gitkeep +1 -0
- data/lib/story_branch/version.rb +3 -0
- data/story_branch.gemspec +54 -0
- metadata +168 -22
- data/bin/git-finish +0 -4
- data/bin/git-start +0 -4
- data/bin/git-story +0 -4
- data/bin/git-unstart +0 -4
@@ -0,0 +1,18 @@
|
|
1
|
+
require 'tty-config'
|
2
|
+
|
3
|
+
module StoryBranch
|
4
|
+
# Config manager class is simply a wrapper around
|
5
|
+
# TTY::Config with the configuration file name set
|
6
|
+
# so we DRY our code.
|
7
|
+
class ConfigManager
|
8
|
+
# TODO: Might be worht moving the configuration filename
|
9
|
+
# to a constant
|
10
|
+
def self.init_config(path, should_read = true)
|
11
|
+
config = ::TTY::Config.new
|
12
|
+
config.filename = '.story_branch'
|
13
|
+
config.append_path path
|
14
|
+
config.read if config.persisted? && should_read
|
15
|
+
config
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
require 'git'
|
2
|
+
require 'levenshtein'
|
3
|
+
|
4
|
+
module StoryBranch
|
5
|
+
class GitUtils
|
6
|
+
def self.g
|
7
|
+
::Git.open '.'
|
8
|
+
end
|
9
|
+
|
10
|
+
def self.existing_branch?(name)
|
11
|
+
branch_names.each do |n|
|
12
|
+
return true if Levenshtein.distance(n, name) < 3
|
13
|
+
existing_branch_name = n.match(/(.*)(-[1-9][0-9]+$)/)
|
14
|
+
next unless existing_branch_name
|
15
|
+
levenshtein_distance = Levenshtein.distance existing_branch_name[1], name
|
16
|
+
return true if levenshtein_distance < 3
|
17
|
+
end
|
18
|
+
false
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.existing_story?(id)
|
22
|
+
branch_names.each do |n|
|
23
|
+
branch_id = n.match(/-[1-9][0-9]+$/)
|
24
|
+
next unless branch_id
|
25
|
+
return true if branch_id.to_s == "-#{id}"
|
26
|
+
end
|
27
|
+
false
|
28
|
+
end
|
29
|
+
|
30
|
+
def self.branch_names
|
31
|
+
g.branches.map(&:name)
|
32
|
+
end
|
33
|
+
|
34
|
+
def self.current_branch
|
35
|
+
g.current_branch
|
36
|
+
end
|
37
|
+
|
38
|
+
def self.current_story
|
39
|
+
current_branch.match(/(.*)-(\d+$)/)
|
40
|
+
end
|
41
|
+
|
42
|
+
def self.current_branch_story_parts
|
43
|
+
matches = current_story
|
44
|
+
return unless matches.length == 3
|
45
|
+
{ title: matches[1], id: matches[2] }
|
46
|
+
end
|
47
|
+
|
48
|
+
def self.create_branch(name)
|
49
|
+
g.branch(name).create
|
50
|
+
g.branch(name).checkout
|
51
|
+
end
|
52
|
+
|
53
|
+
def self.status_collect(status, regex)
|
54
|
+
status.select{|e|
|
55
|
+
e.match(regex)
|
56
|
+
}.map{ |e|
|
57
|
+
e.match(regex)[1]
|
58
|
+
}
|
59
|
+
end
|
60
|
+
|
61
|
+
def self.status
|
62
|
+
modified_rx = /^ M (.*)/
|
63
|
+
untracked_rx = /^\?\? (.*)/
|
64
|
+
staged_rx = /^M (.*)/
|
65
|
+
added_rx = /^A (.*)/
|
66
|
+
status = g.lib.send(:command, 'status', '-s').lines
|
67
|
+
return nil if status.empty?
|
68
|
+
{
|
69
|
+
modified: status_collect(status, modified_rx),
|
70
|
+
untracked: status_collect(status, untracked_rx),
|
71
|
+
added: status_collect(status, added_rx),
|
72
|
+
staged: status_collect(status, staged_rx)
|
73
|
+
}
|
74
|
+
end
|
75
|
+
|
76
|
+
def self.status?(state)
|
77
|
+
return false unless status
|
78
|
+
!status[state].empty?
|
79
|
+
end
|
80
|
+
|
81
|
+
def self.commit(message)
|
82
|
+
g.commit(message)
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
@@ -0,0 +1,124 @@
|
|
1
|
+
require_relative './string_utils'
|
2
|
+
require_relative './pivotal_utils'
|
3
|
+
require_relative './config_manager'
|
4
|
+
|
5
|
+
module StoryBranch
|
6
|
+
class Main
|
7
|
+
ERRORS = {
|
8
|
+
'Stories in the started state must be estimated.' =>
|
9
|
+
"Error: Pivotal won't allow you to start an unestimated story"
|
10
|
+
}.freeze
|
11
|
+
|
12
|
+
attr_accessor :p
|
13
|
+
|
14
|
+
def initialize
|
15
|
+
@p = PivotalUtils.new
|
16
|
+
@p.project_id = project_id
|
17
|
+
@p.api_key = config.fetch(project_id, :api_key)
|
18
|
+
@p.finish_tag = config.fetch(project_id, :finish_tag, default: 'Finishes')
|
19
|
+
exit unless @p.valid?
|
20
|
+
end
|
21
|
+
|
22
|
+
# TODO:
|
23
|
+
# Move these methods to the command logic.
|
24
|
+
def create_story_branch
|
25
|
+
puts 'Connecting with Pivotal Tracker'
|
26
|
+
@p.project
|
27
|
+
puts 'Getting stories...'
|
28
|
+
stories = @p.display_stories :started, false
|
29
|
+
if stories.empty?
|
30
|
+
puts 'No stories started, exiting'
|
31
|
+
exit
|
32
|
+
end
|
33
|
+
story = @p.select_story stories
|
34
|
+
return unless story
|
35
|
+
@p.create_feature_branch story
|
36
|
+
rescue Blanket::Unauthorized
|
37
|
+
unauthorised_message
|
38
|
+
return nil
|
39
|
+
end
|
40
|
+
|
41
|
+
def story_finish
|
42
|
+
puts 'Connecting with Pivotal Tracker'
|
43
|
+
@p.project
|
44
|
+
|
45
|
+
unless @p.is_current_branch_a_story?
|
46
|
+
warn "Your current branch: '#{GitUtils.current_branch}' is not linked to a Pivotal Tracker story."
|
47
|
+
return nil
|
48
|
+
end
|
49
|
+
|
50
|
+
if GitUtils.status?(:untracked) || GitUtils.status?(:modified)
|
51
|
+
puts 'There are unstaged changes'
|
52
|
+
puts 'Use git add to stage changes before running git finish'
|
53
|
+
puts 'Use git stash if you want to hide changes for this commit'
|
54
|
+
return nil
|
55
|
+
end
|
56
|
+
|
57
|
+
unless GitUtils.status?(:added) || GitUtils.status?(:staged)
|
58
|
+
puts 'There are no staged changes.'
|
59
|
+
puts 'Nothing to do'
|
60
|
+
return nil
|
61
|
+
end
|
62
|
+
|
63
|
+
puts 'Use standard finishing commit message: [y/N]?'
|
64
|
+
commit_message = "[Finishes ##{GitUtils.current_branch_story_parts[:id]}] #{StoryBranch::StringUtils.undashed GitUtils.current_branch_story_parts[:title]}"
|
65
|
+
puts commit_message
|
66
|
+
|
67
|
+
if gets.chomp!.casecmp('y').zero?
|
68
|
+
GitUtils.commit commit_message
|
69
|
+
else
|
70
|
+
puts 'Aborted'
|
71
|
+
end
|
72
|
+
rescue Blanket::Unauthorized
|
73
|
+
unauthorised_message
|
74
|
+
return nil
|
75
|
+
end
|
76
|
+
|
77
|
+
def story_start
|
78
|
+
pick_and_update(:unstarted, { current_state: 'started' }, 'started', true)
|
79
|
+
end
|
80
|
+
|
81
|
+
def story_unstart
|
82
|
+
pick_and_update(:started, { current_state: 'unstarted' }, 'unstarted', false)
|
83
|
+
end
|
84
|
+
|
85
|
+
private
|
86
|
+
|
87
|
+
def config
|
88
|
+
return @config if @config
|
89
|
+
@config = ConfigManager.init_config(Dir.home)
|
90
|
+
@config
|
91
|
+
end
|
92
|
+
|
93
|
+
def project_id
|
94
|
+
return @project_id if @project_id
|
95
|
+
local_config = ConfigManager.init_config('.')
|
96
|
+
@project_id = local_config.fetch(:project_id)
|
97
|
+
@project_id
|
98
|
+
end
|
99
|
+
|
100
|
+
def unauthorised_message
|
101
|
+
warn 'Pivotal API key or Project ID invalid'
|
102
|
+
end
|
103
|
+
|
104
|
+
def pick_and_update(filter, hash, msg, is_estimated)
|
105
|
+
puts 'Connecting with Pivotal Tracker'
|
106
|
+
@p.project
|
107
|
+
puts 'Getting stories...'
|
108
|
+
stories = @p.filtered_stories_list filter, is_estimated
|
109
|
+
story = @p.select_story stories
|
110
|
+
if story
|
111
|
+
result = @p.story_update story, hash
|
112
|
+
raise result.error if result.error
|
113
|
+
puts "#{story.id} #{msg}"
|
114
|
+
end
|
115
|
+
rescue Blanket::Unauthorized
|
116
|
+
unauthorised_message
|
117
|
+
return nil
|
118
|
+
end
|
119
|
+
|
120
|
+
def story_estimate
|
121
|
+
# TODO: estimate a story
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
@@ -0,0 +1,146 @@
|
|
1
|
+
require 'blanket'
|
2
|
+
require 'rb-readline'
|
3
|
+
require_relative './git_utils'
|
4
|
+
|
5
|
+
module StoryBranch
|
6
|
+
class PivotalUtils
|
7
|
+
API_URL = 'https://www.pivotaltracker.com/services/v5/'.freeze
|
8
|
+
attr_accessor :api_key, :project_id, :finish_tag
|
9
|
+
|
10
|
+
def valid?
|
11
|
+
!@api_key.nil? && !@project_id.nil?
|
12
|
+
end
|
13
|
+
|
14
|
+
def api
|
15
|
+
fail 'API key must be specified' unless @api_key
|
16
|
+
Blanket.wrap API_URL, headers: { 'X-TrackerToken' => @api_key }
|
17
|
+
end
|
18
|
+
|
19
|
+
def project
|
20
|
+
return @project if @project
|
21
|
+
fail 'Project ID must be set' unless @project_id
|
22
|
+
@project = api.projects(@project_id.to_i)
|
23
|
+
@project
|
24
|
+
end
|
25
|
+
|
26
|
+
def story_accessor
|
27
|
+
project.stories
|
28
|
+
end
|
29
|
+
|
30
|
+
def is_current_branch_a_story?
|
31
|
+
StoryBranch::GitUtils.current_story and
|
32
|
+
StoryBranch::GitUtils.current_story.length == 3 and
|
33
|
+
filtered_stories_list(:started, true)
|
34
|
+
.map(&:id)
|
35
|
+
.include? StoryBranch::GitUtils.current_story[2].to_i
|
36
|
+
end
|
37
|
+
|
38
|
+
def story_from_current_branch
|
39
|
+
story_accessor.get(StoryBranch::GitUtils.current_story[2].to_i) if StoryBranch::GitUtils.current_story.length == 3
|
40
|
+
end
|
41
|
+
|
42
|
+
# TODO: Maybe add some other predicates
|
43
|
+
# - Filtering on where a story lives (Backlog, IceBox)
|
44
|
+
# - Filtering on labels
|
45
|
+
# as the need arises...
|
46
|
+
#
|
47
|
+
def filtered_stories_list(state, estimated)
|
48
|
+
options = { with_state: state.to_s }
|
49
|
+
stories = [* story_accessor.get(params: options).payload]
|
50
|
+
if estimated
|
51
|
+
stories.select do |s|
|
52
|
+
s.story_type == 'bug' || s.story_type == 'chore' ||
|
53
|
+
(s.story_type == 'feature' && s.estimate && s.estimate >= 0)
|
54
|
+
end
|
55
|
+
else
|
56
|
+
stories
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def display_stories(state, estimated)
|
61
|
+
filtered_stories_list(state, estimated).each {|s| puts one_line_story s }
|
62
|
+
end
|
63
|
+
|
64
|
+
def one_line_story(s)
|
65
|
+
"#{s.id} - #{s.name}"
|
66
|
+
end
|
67
|
+
|
68
|
+
# TODO: Use TTY prompt with pagination
|
69
|
+
def select_story(stories)
|
70
|
+
story_texts = stories.map { |s| one_line_story s }
|
71
|
+
puts 'Leave blank to exit, use <up>/<down> to scroll through stories, TAB to list all and auto-complete'
|
72
|
+
story_selection = readline('Select a story: ', story_texts)
|
73
|
+
return nil if story_selection == '' || story_selection.nil?
|
74
|
+
story = stories.select { |s| story_matcher s, story_selection }.first
|
75
|
+
if story.nil?
|
76
|
+
puts "Not found: #{story_selection}"
|
77
|
+
return nil
|
78
|
+
else
|
79
|
+
puts "Selected : #{one_line_story story}"
|
80
|
+
return story
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
def story_update(story, hash)
|
85
|
+
project.stories(story.id).put(body: hash).payload
|
86
|
+
end
|
87
|
+
|
88
|
+
def story_matcher(story, selection)
|
89
|
+
m = selection.match(/^(\d*) /)
|
90
|
+
return false unless m
|
91
|
+
id = m.captures.first
|
92
|
+
story.id.to_s == id
|
93
|
+
end
|
94
|
+
|
95
|
+
def create_feature_branch(story)
|
96
|
+
dashed_story_name = StoryBranch::StringUtils.normalised_branch_name story.name
|
97
|
+
feature_branch_name = nil
|
98
|
+
puts "You are checked out at: #{StoryBranch::GitUtils.current_branch}"
|
99
|
+
while feature_branch_name.nil? || feature_branch_name == ''
|
100
|
+
puts 'Provide a new branch name... (TAB for suggested name)' if [nil, ''].include? feature_branch_name
|
101
|
+
feature_branch_name = readline('Name of feature branch: ', [dashed_story_name])
|
102
|
+
end
|
103
|
+
feature_branch_name.chomp!
|
104
|
+
return unless validate_branch_name(feature_branch_name, story.id)
|
105
|
+
feature_branch_name_with_story_id = "#{feature_branch_name}-#{story.id}"
|
106
|
+
puts "Creating: #{feature_branch_name_with_story_id} with #{StoryBranch::GitUtils.current_branch} as parent"
|
107
|
+
StoryBranch::GitUtils.create_branch feature_branch_name_with_story_id
|
108
|
+
end
|
109
|
+
|
110
|
+
# Branch name validation
|
111
|
+
def validate_branch_name(name, id)
|
112
|
+
if StoryBranch::GitUtils.existing_story? id
|
113
|
+
puts "Error: An existing branch has the same story id: #{id}"
|
114
|
+
return false
|
115
|
+
end
|
116
|
+
if StoryBranch::GitUtils.existing_branch? name
|
117
|
+
puts 'Error: This name is very similar to an existing branch. Avoid confusion and use a more unique name.'
|
118
|
+
return false
|
119
|
+
end
|
120
|
+
unless valid_branch_name? name
|
121
|
+
puts "Error: #{name}\nis an invalid name."
|
122
|
+
return false
|
123
|
+
end
|
124
|
+
true
|
125
|
+
end
|
126
|
+
|
127
|
+
def valid_branch_name?(name)
|
128
|
+
# Valid names begin with a letter and are followed by alphanumeric
|
129
|
+
# with _ . - as allowed punctuation
|
130
|
+
valid = /[a-zA-Z][-._0-9a-zA-Z]*/
|
131
|
+
name.match valid
|
132
|
+
end
|
133
|
+
|
134
|
+
def readline(prompt, completions = [])
|
135
|
+
# Store the state of the terminal
|
136
|
+
RbReadline.clear_history
|
137
|
+
if completions.length.positive?
|
138
|
+
completions.each { |i| Readline::HISTORY.push i }
|
139
|
+
RbReadline.rl_completer_word_break_characters = ''
|
140
|
+
Readline.completion_proc = proc { |s| completions.grep(/#{Regexp.escape(s)}/) }
|
141
|
+
Readline.completion_append_character = ''
|
142
|
+
end
|
143
|
+
Readline.readline(prompt, false)
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module StoryBranch
|
2
|
+
class StringUtils
|
3
|
+
def self.dashed(s)
|
4
|
+
s.tr(' _,./:;+&', '-')
|
5
|
+
end
|
6
|
+
|
7
|
+
def self.simple_sanitize(s)
|
8
|
+
strip_newlines(s.tr('\'"%!@#$(){}[]*\\?', ''))
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.normalised_branch_name(s)
|
12
|
+
simple_sanitize((dashed s).downcase).squeeze('-')
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.strip_newlines(s)
|
16
|
+
s.tr "\n", '-'
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.undashed(s)
|
20
|
+
s.tr(/-/, ' ').capitalize
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1 @@
|
|
1
|
+
#
|
@@ -0,0 +1 @@
|
|
1
|
+
#
|
@@ -0,0 +1 @@
|
|
1
|
+
#
|
@@ -0,0 +1 @@
|
|
1
|
+
#
|
@@ -0,0 +1 @@
|
|
1
|
+
#
|
@@ -0,0 +1 @@
|
|
1
|
+
#
|
@@ -0,0 +1 @@
|
|
1
|
+
#
|
@@ -0,0 +1 @@
|
|
1
|
+
#
|
@@ -0,0 +1,54 @@
|
|
1
|
+
lib = File.expand_path('../lib', __FILE__)
|
2
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
3
|
+
require 'story_branch/version'
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = 'story_branch'
|
7
|
+
spec.license = 'MIT'
|
8
|
+
spec.version = StoryBranch::VERSION
|
9
|
+
spec.authors = [
|
10
|
+
'Jason Milkins',
|
11
|
+
'Rui Baltazar',
|
12
|
+
'Dominic Wong',
|
13
|
+
'Ranhiru Cooray',
|
14
|
+
'Gabe Hollombe'
|
15
|
+
]
|
16
|
+
spec.email = [
|
17
|
+
'jasonm23@gmail.com',
|
18
|
+
'rui.p.baltazar@gmail.com',
|
19
|
+
'dominic.wong.617@gmail.com',
|
20
|
+
'ranhiru@gmail.com',
|
21
|
+
'gabe@neo.com'
|
22
|
+
]
|
23
|
+
|
24
|
+
spec.summary = 'Create git branches based on pivotal tracker stories'
|
25
|
+
spec.description = 'Simple gem that fetches the available stories in your \
|
26
|
+
pivotaltracker project and allows you to create a git branch with the name \
|
27
|
+
based on the selected story'
|
28
|
+
spec.homepage = 'https://github.com/story-branch/story_branch'
|
29
|
+
|
30
|
+
# Specify which files should be added to the gem when it is released.
|
31
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
32
|
+
spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
|
33
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
34
|
+
end
|
35
|
+
spec.bindir = 'exe'
|
36
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
37
|
+
spec.require_paths = ['lib']
|
38
|
+
|
39
|
+
spec.add_runtime_dependency 'blanket_wrapper', '~> 3.0'
|
40
|
+
spec.add_runtime_dependency 'git', '~> 1.2'
|
41
|
+
spec.add_runtime_dependency 'levenshtein-ffi', '~> 1.0'
|
42
|
+
spec.add_runtime_dependency 'pastel', '~> 0.7.2'
|
43
|
+
spec.add_runtime_dependency 'rb-readline', '~> 0.5'
|
44
|
+
spec.add_runtime_dependency 'thor', '~> 0.20.0'
|
45
|
+
spec.add_runtime_dependency 'tty-command', '~> 0.8.0'
|
46
|
+
spec.add_runtime_dependency 'tty-config', '~> 0.2.0'
|
47
|
+
spec.add_runtime_dependency 'tty-pager', '~> 0.11.0'
|
48
|
+
spec.add_runtime_dependency 'tty-prompt', '~> 0.16.1'
|
49
|
+
|
50
|
+
spec.add_development_dependency 'bundler', '~> 1.16'
|
51
|
+
spec.add_development_dependency 'fakefs', '~> 0.14'
|
52
|
+
spec.add_development_dependency 'rake', '~> 10.0'
|
53
|
+
spec.add_development_dependency 'rspec', '~> 3.0'
|
54
|
+
end
|