flash_flow 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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +10 -0
  3. data/.ruby-version +1 -0
  4. data/Gemfile +4 -0
  5. data/Gemfile.lock +81 -0
  6. data/LICENSE.txt +22 -0
  7. data/README.md +152 -0
  8. data/Rakefile +10 -0
  9. data/bin/flash_flow +23 -0
  10. data/flash_flow.gemspec +28 -0
  11. data/flash_flow.yml.erb.example +83 -0
  12. data/lib/flash_flow.rb +7 -0
  13. data/lib/flash_flow/branch_merger.rb +52 -0
  14. data/lib/flash_flow/cmd_runner.rb +37 -0
  15. data/lib/flash_flow/config.rb +71 -0
  16. data/lib/flash_flow/data.rb +6 -0
  17. data/lib/flash_flow/data/base.rb +58 -0
  18. data/lib/flash_flow/data/branch.rb +131 -0
  19. data/lib/flash_flow/data/collection.rb +181 -0
  20. data/lib/flash_flow/data/github.rb +129 -0
  21. data/lib/flash_flow/data/store.rb +33 -0
  22. data/lib/flash_flow/deploy.rb +184 -0
  23. data/lib/flash_flow/git.rb +248 -0
  24. data/lib/flash_flow/install.rb +19 -0
  25. data/lib/flash_flow/issue_tracker.rb +52 -0
  26. data/lib/flash_flow/issue_tracker/pivotal.rb +160 -0
  27. data/lib/flash_flow/lock.rb +25 -0
  28. data/lib/flash_flow/lock/github.rb +91 -0
  29. data/lib/flash_flow/notifier.rb +24 -0
  30. data/lib/flash_flow/notifier/hipchat.rb +36 -0
  31. data/lib/flash_flow/options.rb +36 -0
  32. data/lib/flash_flow/time_helper.rb +11 -0
  33. data/lib/flash_flow/version.rb +3 -0
  34. data/test/lib/data/test_base.rb +10 -0
  35. data/test/lib/data/test_branch.rb +203 -0
  36. data/test/lib/data/test_collection.rb +238 -0
  37. data/test/lib/data/test_github.rb +23 -0
  38. data/test/lib/data/test_store.rb +53 -0
  39. data/test/lib/issue_tracker/test_pivotal.rb +221 -0
  40. data/test/lib/lock/test_github.rb +70 -0
  41. data/test/lib/test_branch_merger.rb +76 -0
  42. data/test/lib/test_config.rb +84 -0
  43. data/test/lib/test_deploy.rb +175 -0
  44. data/test/lib/test_git.rb +73 -0
  45. data/test/lib/test_issue_tracker.rb +43 -0
  46. data/test/lib/test_notifier.rb +33 -0
  47. data/test/minitest_helper.rb +38 -0
  48. metadata +217 -0
@@ -0,0 +1,19 @@
1
+ require 'fileutils'
2
+
3
+ module FlashFlow
4
+ class Install
5
+ def self.install
6
+ FileUtils.mkdir 'config' unless Dir.exists?('config')
7
+ dest_file = 'config/flash_flow.yml.erb'
8
+
9
+ FileUtils.cp example_file, dest_file
10
+
11
+ puts "Flash flow config file is in #{dest_file}"
12
+ end
13
+
14
+ def self.example_file
15
+ "#{File.dirname(__FILE__)}/../../flash_flow.yml.erb.example"
16
+ end
17
+
18
+ end
19
+ end
@@ -0,0 +1,52 @@
1
+ require 'logger'
2
+
3
+ require 'flash_flow/git'
4
+ require 'flash_flow/data'
5
+ require 'flash_flow/data/store'
6
+ require 'flash_flow/issue_tracker/pivotal'
7
+
8
+ module FlashFlow
9
+ module IssueTracker
10
+ class Base
11
+ def initialize(_config=nil)
12
+ @config = _config
13
+ issue_tracker_class_name = @config && @config['class'] && @config['class']['name']
14
+ return unless issue_tracker_class_name
15
+
16
+ @issue_tracker_class = Object.const_get(issue_tracker_class_name)
17
+ end
18
+
19
+ def stories_pushed
20
+ issue_tracker.stories_pushed if issue_tracker.respond_to?(:stories_pushed)
21
+ end
22
+
23
+ def stories_delivered
24
+ issue_tracker.stories_delivered if issue_tracker.respond_to?(:stories_delivered)
25
+ end
26
+
27
+ def production_deploy
28
+ issue_tracker.production_deploy if issue_tracker.respond_to?(:production_deploy)
29
+ end
30
+
31
+ def release_notes(hours, file=STDOUT)
32
+ issue_tracker.release_notes(hours, file) if issue_tracker.respond_to?(:release_notes)
33
+ end
34
+
35
+ private
36
+
37
+ def git
38
+ @git ||= Git.new(Config.configuration.git, Config.configuration.logger)
39
+ end
40
+
41
+ def get_branches
42
+ branch_info_store = Data::Base.new(Config.configuration.branches, Config.configuration.branch_info_file, git, logger: Config.configuration.logger)
43
+
44
+ branch_info_store.saved_branches
45
+ end
46
+
47
+ def issue_tracker
48
+ @issue_tracker ||= @issue_tracker_class && @issue_tracker_class.new(get_branches, git, @config['class'])
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,160 @@
1
+ require 'pivotal-tracker'
2
+ require 'time'
3
+ require 'flash_flow/time_helper'
4
+
5
+ module FlashFlow
6
+ module IssueTracker
7
+ class Pivotal
8
+ include TimeHelper
9
+
10
+ def initialize(branches, git, opts={})
11
+ @branches = branches
12
+ @git = git
13
+ @timezone = opts['timezone'] || "UTC"
14
+
15
+ PivotalTracker::Client.token = opts['token']
16
+ PivotalTracker::Client.use_ssl = true
17
+ @project = PivotalTracker::Project.find(opts['project_id'])
18
+ end
19
+
20
+ def stories_pushed
21
+ if merged_working_branch
22
+ merged_working_branch.stories.to_a.each do |story_id|
23
+ finish(story_id)
24
+ end
25
+ end
26
+ end
27
+
28
+ def stories_delivered
29
+ merged_branches.each do |branch|
30
+ branch.stories.to_a.each do |story_id|
31
+ deliver(story_id)
32
+ end
33
+ end
34
+ removed_branches.each do |branch|
35
+ branch.stories.to_a.each do |story_id|
36
+ undeliver(story_id)
37
+ end
38
+ end
39
+ end
40
+
41
+ def production_deploy
42
+ shipped_branches.each do |branch|
43
+ branch.stories.to_a.each do |story_id|
44
+ comment(story_id)
45
+ end
46
+ end
47
+ end
48
+
49
+ def release_notes(hours, file=STDOUT)
50
+ release_stories = done_and_current_stories.map do |story|
51
+ shipped_text = has_shipped_text?(story)
52
+ format_release_data(story.id, story.name, shipped_text) if shipped_text
53
+ end.compact
54
+
55
+ release_notes = release_by(release_stories, hours)
56
+ print_release_notes(release_notes, file)
57
+ end
58
+
59
+ private
60
+
61
+ def undeliver(story_id)
62
+ story = get_story(story_id)
63
+
64
+ if story && story.current_state == 'delivered'
65
+ story.current_state = 'finished'
66
+ story.update
67
+ end
68
+ end
69
+
70
+ def deliver(story_id)
71
+ story = get_story(story_id)
72
+
73
+ if story && story.current_state == 'finished'
74
+ story.current_state = 'delivered'
75
+ story.update
76
+ end
77
+ end
78
+
79
+ def finish(story_id)
80
+ story = get_story(story_id)
81
+
82
+ if story && story.current_state == 'started'
83
+ story.current_state = 'finished'
84
+ story.update
85
+ end
86
+ end
87
+
88
+ def comment(story_id)
89
+ story = get_story(story_id)
90
+ if story
91
+ unless has_shipped_text?(story)
92
+ story.notes.create(:text => with_time_zone(@timezone) { Time.now.strftime(note_time_format) })
93
+ end
94
+ end
95
+ end
96
+
97
+ def note_prefix
98
+ 'Shipped to production on'
99
+ end
100
+
101
+ def note_time_format
102
+ "#{note_prefix} %m/%d/%Y at %H:%M"
103
+ end
104
+
105
+ def format_release_data(story_id, story_name, shipped_text)
106
+ {id: story_id, title: story_name, time: Time.strptime(shipped_text, note_time_format)}
107
+ end
108
+
109
+ def shipped?(branch)
110
+ branch.sha && @git.master_branch_contains?(branch.sha)
111
+ end
112
+
113
+ def done_and_current_stories
114
+ [@project.iteration(:done).last(2).map(&:stories) + @project.iteration(:current).stories].flatten
115
+ end
116
+
117
+ def release_by(release_stories, hours)
118
+ release_stories
119
+ .select { |story| story[:time] >= (Time.now - hours.to_i*60*60) }
120
+ .sort_by {|story| story[:time] }.reverse
121
+ end
122
+
123
+ def print_release_notes(release_notes, file=nil)
124
+ file ||= STDOUT
125
+ release_notes.each do |story|
126
+ file.puts "PT##{story[:id]} #{story[:title]} (#{story[:time]})"
127
+ end
128
+ end
129
+
130
+ def get_story(story_id)
131
+ @project.stories.find(story_id)
132
+ end
133
+
134
+ def already_has_comment?(story, comment)
135
+ story.notes.all.map(&:text).detect { |text| text =~ comment }
136
+ end
137
+
138
+ def has_shipped_text?(story)
139
+ already_has_comment?(story, Regexp.new("^#{note_prefix}"))
140
+ end
141
+
142
+ def shipped_branches
143
+ @branches.select { |b| shipped?(b) }
144
+ end
145
+
146
+ def merged_branches
147
+ @branches.select { |b| b.success? }
148
+ end
149
+
150
+ def removed_branches
151
+ @branches.select { |b| b.removed? }
152
+ end
153
+
154
+ def merged_working_branch
155
+ merged_branches.detect { |b| b.ref == @git.working_branch }
156
+ end
157
+
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,25 @@
1
+ require 'flash_flow/lock/github'
2
+
3
+ module FlashFlow
4
+ module Lock
5
+ class Error < RuntimeError; end
6
+
7
+ class Base
8
+ def initialize(config=nil)
9
+ lock_class_name = config && config['class'] && config['class']['name']
10
+ return unless lock_class_name
11
+
12
+ lock_class = Object.const_get(lock_class_name)
13
+ @lock = lock_class.new(config['class'])
14
+ end
15
+
16
+ def with_lock(&block)
17
+ if @lock
18
+ @lock.with_lock(&block)
19
+ else
20
+ yield
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,91 @@
1
+ require 'octokit'
2
+
3
+ module FlashFlow
4
+ module Lock
5
+ class Github
6
+
7
+ attr_reader :config
8
+ private :config
9
+
10
+ def initialize(config)
11
+ @config = config
12
+
13
+ verify_params!
14
+ initialize_connection!
15
+ end
16
+
17
+ def with_lock(&block)
18
+ if issue_open?
19
+ raise Lock::Error.new(error_message)
20
+ else
21
+ open_issue
22
+
23
+ begin
24
+ block.call
25
+ ensure
26
+ close_issue
27
+ end
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def error_message
34
+ last_event = get_last_event
35
+ actor = last_event[:actor][:login]
36
+ time = last_event[:created_at]
37
+ issue_link = "https://github.com/#{repo}/issues/#{issue_id}"
38
+ minutes_ago = ((Time.now - time).to_i / 60) rescue 'unknown'
39
+
40
+ "#{actor} started running flash_flow #{minutes_ago} minutes ago. To manually unlock flash_flow, go here: #{issue_link} and close the issue and re-run flash_flow."
41
+ end
42
+
43
+ def get_last_event
44
+ Octokit.issue_events(repo, issue_id)
45
+ response = Octokit.last_response
46
+ pages = response.rels[:last]
47
+ if pages
48
+ return pages.get.data.last
49
+ else
50
+ response.data.last
51
+ end
52
+ end
53
+
54
+ def issue_open?
55
+ get_last_event.event == 'reopened'
56
+ end
57
+
58
+ def open_issue
59
+ Octokit.reopen_issue(repo, issue_id)
60
+ end
61
+
62
+ def close_issue
63
+ Octokit.close_issue(repo, issue_id)
64
+ end
65
+
66
+ def verify_params!
67
+ unless token && repo && issue_id
68
+ raise Lock::Error.new("Github token, repo, and issue_id must all be set to use the Github lock.")
69
+ end
70
+ end
71
+
72
+ def initialize_connection!
73
+ Octokit.configure do |c|
74
+ c.access_token = token
75
+ end
76
+ end
77
+
78
+ def token
79
+ config['token']
80
+ end
81
+
82
+ def repo
83
+ config['repo']
84
+ end
85
+
86
+ def issue_id
87
+ config['issue_id']
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,24 @@
1
+ require 'flash_flow/data'
2
+ require 'flash_flow/notifier/hipchat'
3
+
4
+ module FlashFlow
5
+ module Notifier
6
+ class Base
7
+ def initialize(config=nil)
8
+ notifier_class_name = config && config['class'] && config['class']['name']
9
+ return unless notifier_class_name
10
+
11
+ @notifier_class = Object.const_get(notifier_class_name)
12
+ @notifier = @notifier_class.new(config['class'])
13
+ end
14
+
15
+ def merge_conflict(branch)
16
+ @notifier.merge_conflict(branch) if @notifier.respond_to?(:merge_conflict)
17
+ end
18
+
19
+ def deleted_branch(branch)
20
+ @notifier.deleted_branch(branch) if @notifier.respond_to?(:deleted_branch)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,36 @@
1
+ require 'hipchat'
2
+
3
+ module FlashFlow
4
+ module Notifier
5
+ class Hipchat
6
+
7
+ def initialize(config={})
8
+ @client = initialize_connection!(config['token'])
9
+ @room = config['room']
10
+ end
11
+
12
+ def merge_conflict(branch)
13
+ user_name = branch.metadata['user_url'].split('/').last
14
+ user_url_link = %{<a href="#{branch.metadata['user_url']}">#{user_name}</a>}
15
+ ref_link = %{<a href="#{branch.metadata['repo_url']}/tree/#{branch.ref}">#{branch.ref}</a>}
16
+
17
+ message = %{#{user_url_link}'s branch (#{ref_link}) did not merge successfully}
18
+ @client[@room].send("FlashFlow", message)
19
+ end
20
+
21
+ private
22
+
23
+ def initialize_connection!(token)
24
+ if token.nil?
25
+ raise RuntimeError.new("Hipchat token must be set in your flash flow config.")
26
+ end
27
+
28
+ hipchat_client.new(token, api_version: "v2")
29
+ end
30
+
31
+ def hipchat_client
32
+ HipChat::Client
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,36 @@
1
+ require 'optparse'
2
+
3
+ module FlashFlow
4
+ class Options
5
+ def self.parse
6
+ options = {}
7
+ opt_parser = OptionParser.new do |opts|
8
+ opts.banner = "Usage: flash_flow [options]"
9
+ opts.separator ""
10
+
11
+ opts.on('--install', 'Copy flash_flow.yml.erb to your repo and exit') { |v| options[:install] = true }
12
+ opts.on('--prod-deploy', 'Run IssueTracker#deploy_production and exit') { |v| options[:prod_deploy] = true }
13
+ opts.on('--review-deploy', 'Run IssueTracker#deploy_review and exit') { |v| options[:review_deploy] = true }
14
+ opts.on('--release-notes hours', 'Run IssueTracker#release_notes and exit') { |v| options[:release_notes] = v }
15
+ opts.on('-n', '--no-merge', 'Run flash flow, but do not merge this branch') { |v| options[:do_not_merge] = true }
16
+ opts.on('--rerere-forget', 'Delete the saved patch for this branch and let the merge fail if there is a conflict') { |v| options[:rerere_forget] = true }
17
+ opts.on('--story id1', 'story id for this branch') { |v| options[:stories] = [v] }
18
+ opts.on('--stories id1,id2', 'comma-delimited list of story ids for this branch') { |v| options[:stories] = v.split(',') }
19
+ opts.on('-f', '--force-push', 'Force push your branch') { |v| options[:force] = v }
20
+ opts.on('-c', '--config-file FILE_PATH', 'The path to your config file. Defaults to config/flash_flow.yml.erb') { |v| options[:config_file] = v }
21
+
22
+ opts.on_tail("-h", "--help", "Show this message") do
23
+ puts opts
24
+ exit
25
+ end
26
+ end
27
+
28
+ opt_parser.parse!
29
+
30
+ options[:stories] ||= []
31
+ options[:config_file] ||= './config/flash_flow.yml.erb'
32
+
33
+ options
34
+ end
35
+ end
36
+ end