story_branch 0.2.11 → 0.3.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 +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
|