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.
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