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