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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/.circleci/config.yml +37 -0
  3. data/.github/ISSUE_TEMPLATE.md +12 -0
  4. data/.gitignore +3 -0
  5. data/.rspec +2 -0
  6. data/Gemfile +6 -0
  7. data/Gemfile.lock +94 -0
  8. data/{LICENCE → LICENSE.txt} +1 -1
  9. data/README.md +79 -60
  10. data/Rakefile +6 -0
  11. data/exe/git-finish +3 -0
  12. data/exe/git-start +3 -0
  13. data/exe/git-story +3 -0
  14. data/exe/git-unstart +3 -0
  15. data/exe/story_branch +18 -0
  16. data/lib/story_branch.rb +2 -477
  17. data/lib/story_branch/cli.rb +93 -0
  18. data/lib/story_branch/command.rb +121 -0
  19. data/lib/story_branch/commands/.gitkeep +1 -0
  20. data/lib/story_branch/commands/add.rb +48 -0
  21. data/lib/story_branch/commands/create.rb +21 -0
  22. data/lib/story_branch/commands/finish.rb +20 -0
  23. data/lib/story_branch/commands/migrate.rb +100 -0
  24. data/lib/story_branch/commands/start.rb +20 -0
  25. data/lib/story_branch/commands/unstart.rb +20 -0
  26. data/lib/story_branch/config_manager.rb +18 -0
  27. data/lib/story_branch/git_utils.rb +85 -0
  28. data/lib/story_branch/main.rb +124 -0
  29. data/lib/story_branch/pivotal_utils.rb +146 -0
  30. data/lib/story_branch/string_utils.rb +23 -0
  31. data/lib/story_branch/templates/.gitkeep +1 -0
  32. data/lib/story_branch/templates/add/.gitkeep +1 -0
  33. data/lib/story_branch/templates/config/.gitkeep +1 -0
  34. data/lib/story_branch/templates/create/.gitkeep +1 -0
  35. data/lib/story_branch/templates/finish/.gitkeep +1 -0
  36. data/lib/story_branch/templates/migrate/.gitkeep +1 -0
  37. data/lib/story_branch/templates/start/.gitkeep +1 -0
  38. data/lib/story_branch/templates/unstart/.gitkeep +1 -0
  39. data/lib/story_branch/version.rb +3 -0
  40. data/story_branch.gemspec +54 -0
  41. metadata +168 -22
  42. data/bin/git-finish +0 -4
  43. data/bin/git-start +0 -4
  44. data/bin/git-story +0 -4
  45. 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,3 @@
1
+ module StoryBranch
2
+ VERSION = '0.3.0'.freeze
3
+ end
@@ -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