flash_flow 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +10 -0
- data/.ruby-version +1 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +81 -0
- data/LICENSE.txt +22 -0
- data/README.md +152 -0
- data/Rakefile +10 -0
- data/bin/flash_flow +23 -0
- data/flash_flow.gemspec +28 -0
- data/flash_flow.yml.erb.example +83 -0
- data/lib/flash_flow.rb +7 -0
- data/lib/flash_flow/branch_merger.rb +52 -0
- data/lib/flash_flow/cmd_runner.rb +37 -0
- data/lib/flash_flow/config.rb +71 -0
- data/lib/flash_flow/data.rb +6 -0
- data/lib/flash_flow/data/base.rb +58 -0
- data/lib/flash_flow/data/branch.rb +131 -0
- data/lib/flash_flow/data/collection.rb +181 -0
- data/lib/flash_flow/data/github.rb +129 -0
- data/lib/flash_flow/data/store.rb +33 -0
- data/lib/flash_flow/deploy.rb +184 -0
- data/lib/flash_flow/git.rb +248 -0
- data/lib/flash_flow/install.rb +19 -0
- data/lib/flash_flow/issue_tracker.rb +52 -0
- data/lib/flash_flow/issue_tracker/pivotal.rb +160 -0
- data/lib/flash_flow/lock.rb +25 -0
- data/lib/flash_flow/lock/github.rb +91 -0
- data/lib/flash_flow/notifier.rb +24 -0
- data/lib/flash_flow/notifier/hipchat.rb +36 -0
- data/lib/flash_flow/options.rb +36 -0
- data/lib/flash_flow/time_helper.rb +11 -0
- data/lib/flash_flow/version.rb +3 -0
- data/test/lib/data/test_base.rb +10 -0
- data/test/lib/data/test_branch.rb +203 -0
- data/test/lib/data/test_collection.rb +238 -0
- data/test/lib/data/test_github.rb +23 -0
- data/test/lib/data/test_store.rb +53 -0
- data/test/lib/issue_tracker/test_pivotal.rb +221 -0
- data/test/lib/lock/test_github.rb +70 -0
- data/test/lib/test_branch_merger.rb +76 -0
- data/test/lib/test_config.rb +84 -0
- data/test/lib/test_deploy.rb +175 -0
- data/test/lib/test_git.rb +73 -0
- data/test/lib/test_issue_tracker.rb +43 -0
- data/test/lib/test_notifier.rb +33 -0
- data/test/minitest_helper.rb +38 -0
- 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
|