flash_flow 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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