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,129 @@
1
+ require 'octokit'
2
+ require 'flash_flow/data/branch'
3
+
4
+ module FlashFlow
5
+ module Data
6
+ class Github
7
+
8
+ attr_accessor :repo, :unmergeable_label
9
+
10
+ def initialize(config={})
11
+ initialize_connection!(config['token'])
12
+ @repo = config['repo']
13
+ @master_branch = config['master_branch'] || master
14
+ @unmergeable_label = config['unmergeable_label'] || 'unmergeable'
15
+ @do_not_merge_label = config['do_not_merge_label'] || 'do not merge'
16
+ end
17
+
18
+ def initialize_connection!(token)
19
+ if token.nil?
20
+ raise RuntimeError.new("Github token must be set in your flash_flow config file.")
21
+ end
22
+ octokit.configure do |c|
23
+ c.access_token = token
24
+ end
25
+ end
26
+
27
+ def remove_from_merge(branch)
28
+ pr = pr_for(branch)
29
+ if pr && @do_not_merge_label
30
+ add_label(pr.number, @do_not_merge_label)
31
+ end
32
+ end
33
+
34
+ def fetch
35
+ pull_requests.map do |pr|
36
+ Branch.from_hash(
37
+ 'remote_url' => pr.head.repo.ssh_url,
38
+ 'ref' => pr.head.ref,
39
+ 'status' => status_from_labels(pr),
40
+ 'metadata' => metadata(pr)
41
+ )
42
+ end
43
+ end
44
+
45
+ def add_to_merge(branch)
46
+ pr = pr_for(branch)
47
+
48
+ pr ||= create_pr(branch.ref, branch.ref, branch.ref)
49
+ branch.add_metadata(metadata(pr))
50
+
51
+ if pr && @do_not_merge_label
52
+ remove_label(pr.number, @do_not_merge_label)
53
+ end
54
+ end
55
+
56
+ def mark_success(branch)
57
+ remove_label(branch.metadata['pr_number'], @unmergeable_label)
58
+ end
59
+
60
+ def mark_failure(branch)
61
+ add_label(branch.metadata['pr_number'], @unmergeable_label)
62
+ end
63
+
64
+ private
65
+
66
+ def status_from_labels(pull_request)
67
+ case
68
+ when has_label?(pull_request.number, @do_not_merge_label)
69
+ 'removed'
70
+ when has_label?(pull_request.number, @unmergeable_label)
71
+ 'fail'
72
+ else
73
+ nil
74
+ end
75
+ end
76
+
77
+ def pr_for(branch)
78
+ pull_requests.detect { |p| branch.remote_url == p.head.repo.ssh_url && branch.ref == p.head.ref }
79
+ end
80
+
81
+ def update_pr(pr_number)
82
+ octokit.update_pull_request(repo, pr_number, {})
83
+ end
84
+
85
+ def create_pr(branch, title, body)
86
+ pr = octokit.create_pull_request(repo, @master_branch, branch, title, body)
87
+ pull_requests << pr
88
+ pr
89
+ end
90
+
91
+ def pull_requests
92
+ @pull_requests ||= octokit.pull_requests(repo).sort_by(&:updated_at)
93
+ end
94
+
95
+ def remove_label(pull_request_number, label)
96
+ if has_label?(pull_request_number, label)
97
+ octokit.remove_label(repo, pull_request_number, label)
98
+ end
99
+ end
100
+
101
+ def add_label(pull_request_number, label)
102
+ unless has_label?(pull_request_number, label)
103
+ octokit.add_labels_to_an_issue(repo, pull_request_number, [label])
104
+ end
105
+ end
106
+
107
+ def has_label?(pull_request_number, label_name)
108
+ labels(pull_request_number).detect { |label| label.name == label_name }
109
+ end
110
+
111
+ def labels(pull_request_number)
112
+ @labels ||= {}
113
+ @labels[pull_request_number] ||= octokit.labels_for_issue(repo, pull_request_number)
114
+ end
115
+
116
+ def metadata(pr)
117
+ {
118
+ 'pr_number' => pr.number,
119
+ 'user_url' => pr.user.html_url,
120
+ 'repo_url' => pr.head.repo.html_url
121
+ }
122
+ end
123
+
124
+ def octokit
125
+ Octokit
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,33 @@
1
+ require 'json'
2
+ require 'flash_flow/data'
3
+
4
+ module FlashFlow
5
+ module Data
6
+ class Store
7
+ def initialize(filename, git, opts={})
8
+ @filename = filename
9
+ @git = git
10
+ @logger = opts[:logger] || Logger.new('/dev/null')
11
+ end
12
+
13
+ def get
14
+ file_contents = @git.read_file_from_merge_branch(@filename)
15
+ JSON.parse(file_contents)
16
+
17
+ rescue JSON::ParserError, Errno::ENOENT
18
+ @logger.error "Unable to read branch info from file: #{@filename}"
19
+ {}
20
+ end
21
+
22
+ def write(branches, file=nil)
23
+ @git.in_temp_merge_branch do
24
+ file ||= File.open(@filename, 'w')
25
+ file.puts JSON.pretty_generate(branches)
26
+ file.close
27
+
28
+ @git.add_and_commit(@filename, 'Branch Info', add: { force: true })
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,184 @@
1
+ require 'logger'
2
+
3
+ require 'flash_flow/git'
4
+ require 'flash_flow/data'
5
+ require 'flash_flow/lock'
6
+ require 'flash_flow/notifier'
7
+ require 'flash_flow/branch_merger'
8
+
9
+ module FlashFlow
10
+ class Deploy
11
+
12
+ class OutOfSyncWithRemote < RuntimeError ; end
13
+
14
+ def initialize(opts={})
15
+ @do_not_merge = opts[:do_not_merge]
16
+ @force = opts[:force]
17
+ @rerere_forget = opts[:rerere_forget]
18
+ @stories = [opts[:stories]].flatten.compact
19
+
20
+ @git = Git.new(Config.configuration.git, logger)
21
+ @lock = Lock::Base.new(Config.configuration.lock)
22
+ @notifier = Notifier::Base.new(Config.configuration.notifier)
23
+ @data = Data::Base.new(Config.configuration.branches, Config.configuration.branch_info_file, @git, logger: logger)
24
+ end
25
+
26
+ def logger
27
+ @logger ||= FlashFlow::Config.configuration.logger
28
+ end
29
+
30
+ def run
31
+ check_version
32
+ check_repo
33
+ puts "Building #{@git.merge_branch}... Log can be found in #{FlashFlow::Config.configuration.log_file}"
34
+ logger.info "\n\n### Beginning #{@git.merge_branch} merge ###\n\n"
35
+
36
+ fetch(@git.merge_remote)
37
+ @git.in_original_merge_branch do
38
+ @git.initialize_rerere
39
+ end
40
+
41
+ begin
42
+ @lock.with_lock do
43
+ open_pull_request
44
+
45
+ @git.reset_temp_merge_branch
46
+ @git.in_temp_merge_branch do
47
+ merge_branches
48
+ commit_branch_info
49
+ commit_rerere
50
+ end
51
+
52
+ @git.copy_temp_to_merge_branch
53
+ @git.delete_temp_merge_branch
54
+ @git.push_merge_branch
55
+ end
56
+
57
+ print_errors
58
+ logger.info "### Finished #{@git.merge_branch} merge ###"
59
+ rescue Lock::Error, OutOfSyncWithRemote => e
60
+ puts 'Failure!'
61
+ puts e.message
62
+ ensure
63
+ @git.run("checkout #{@git.working_branch}")
64
+ end
65
+ end
66
+
67
+ def check_repo
68
+ if @git.staged_and_working_dir_files.any?
69
+ raise RuntimeError.new('You have changes in your working directory. Please stash and try again')
70
+ end
71
+ end
72
+
73
+ def check_version
74
+ data_version = @data.version
75
+ return if data_version.nil?
76
+
77
+ written_version = data_version.split(".").map(&:to_i)
78
+ running_version = FlashFlow::VERSION.split(".").map(&:to_i)
79
+
80
+ unless written_version[0] < running_version[0] ||
81
+ (written_version[0] == running_version[0] && written_version[1] <= running_version[1]) # Ignore the point release number
82
+ raise RuntimeError.new("Your version of flash flow (#{FlashFlow::VERSION}) is behind the version that was last used (#{data_version}) by a member of your team. Please upgrade to at least #{written_version[0]}.#{written_version[1]}.0 and try again.")
83
+ end
84
+ end
85
+
86
+ def commit_branch_info
87
+ @stories.each do |story_id|
88
+ @data.add_story(@git.merge_remote, @git.working_branch, story_id)
89
+ end
90
+ @data.save!
91
+ end
92
+
93
+ def commit_rerere
94
+ current_branches = @data.merged_branches.to_a.select { |branch| !@git.master_branch_contains?(branch.sha) && (Time.now - branch.updated_at < two_weeks) }
95
+ current_rereres = current_branches.map { |branch| branch.resolutions.to_h.values }.flatten
96
+
97
+ @git.commit_rerere(current_rereres)
98
+ end
99
+
100
+ def two_weeks
101
+ 60 * 60 * 24 * 14
102
+ end
103
+
104
+ def merge_branches
105
+ @data.mergeable.each do |branch|
106
+ remote = @git.fetch_remote_for_url(branch.remote_url)
107
+ if remote.nil?
108
+ raise RuntimeError.new("No remote found for #{branch.remote_url}. Please run 'git remote add *your_remote_name* #{branch.remote_url}' and try again.")
109
+ end
110
+
111
+ fetch(branch.remote)
112
+ git_merge(branch, branch.ref == @git.working_branch)
113
+ end
114
+ end
115
+
116
+ def git_merge(branch, is_working_branch)
117
+ merger = BranchMerger.new(@git, branch)
118
+ forget_rerere = is_working_branch && @rerere_forget
119
+
120
+ case merger.do_merge(forget_rerere)
121
+ when :deleted
122
+ @data.mark_deleted(branch)
123
+ @notifier.deleted_branch(branch) unless is_working_branch
124
+
125
+ when :success
126
+ branch.sha = merger.sha
127
+ @data.mark_success(branch)
128
+ @data.set_resolutions(branch, merger.resolutions)
129
+
130
+ when :conflict
131
+ @data.mark_failure(branch, merger.conflict_sha)
132
+ @notifier.merge_conflict(branch) unless is_working_branch
133
+ end
134
+ end
135
+
136
+ def open_pull_request
137
+ return false if [@git.master_branch, @git.merge_branch].include?(@git.working_branch)
138
+
139
+ # TODO - This should use the actual remote for the branch we're on
140
+ @git.push(@git.working_branch, force: @force)
141
+ raise OutOfSyncWithRemote.new("Your branch is out of sync with the remote. If you want to force push, run 'flash_flow -f'") unless @git.last_success?
142
+
143
+ # TODO - This should use the actual remote for the branch we're on
144
+ if @do_not_merge
145
+ @data.remove_from_merge(@git.merge_remote, @git.working_branch)
146
+ else
147
+ @data.add_to_merge(@git.merge_remote, @git.working_branch)
148
+ end
149
+ end
150
+
151
+ def print_errors
152
+ puts format_errors
153
+ end
154
+
155
+ def format_errors
156
+ errors = []
157
+ branch_not_merged = nil
158
+ @data.failures.each do |full_ref, failure|
159
+ if failure.ref == @git.working_branch
160
+ branch_not_merged = "\nERROR: Your branch did not merge to #{@git.merge_branch}. Run the following commands to fix the merge conflict and then re-run this script:\n\n git checkout #{failure.metadata['conflict_sha']}\n git merge #{@git.working_branch}\n # Resolve the conflicts\n git add <conflicted files>\n git commit --no-edit"
161
+ else
162
+ errors << "WARNING: Unable to merge branch #{failure.remote}/#{failure.ref} to #{@git.merge_branch} due to conflicts."
163
+ end
164
+ end
165
+ errors << branch_not_merged if branch_not_merged
166
+
167
+ if errors.empty?
168
+ "Success!"
169
+ else
170
+ errors.join("\n")
171
+ end
172
+ end
173
+
174
+ private
175
+
176
+ def fetch(remote)
177
+ @fetched_remotes ||= {}
178
+ unless @fetched_remotes[remote]
179
+ @git.fetch(remote)
180
+ @fetched_remotes[remote] = true
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,248 @@
1
+ require 'flash_flow/cmd_runner'
2
+
3
+ module FlashFlow
4
+ class Git
5
+ ATTRIBUTES = [:merge_remote, :merge_branch, :master_branch, :use_rerere]
6
+ attr_reader *ATTRIBUTES
7
+ attr_reader :working_branch
8
+
9
+ UNMERGED_STATUSES = %w{DD AU UD UA DU AA UU}
10
+
11
+ def initialize(config, logger=nil)
12
+ @cmd_runner = CmdRunner.new(logger: logger)
13
+
14
+ ATTRIBUTES.each do |attr|
15
+ unless config.has_key?(attr.to_s)
16
+ raise RuntimeError.new("git configuration missing. Required config parameters: #{ATTRIBUTES}")
17
+ end
18
+
19
+ instance_variable_set("@#{attr}", config[attr.to_s])
20
+ end
21
+
22
+ @working_branch = current_branch
23
+ end
24
+
25
+ def last_stdout
26
+ @cmd_runner.last_stdout
27
+ end
28
+
29
+ def last_command
30
+ @cmd_runner.last_command
31
+ end
32
+
33
+ def last_success?
34
+ @cmd_runner.last_success?
35
+ end
36
+
37
+ def run(cmd)
38
+ @cmd_runner.run("git #{cmd}")
39
+ end
40
+
41
+ def add_and_commit(files, message, opts={})
42
+ files = [files].flatten
43
+ run("add #{'-f ' if opts[:add] && opts[:add][:force]}#{files.join(' ')}")
44
+ run("commit -m '#{message}'")
45
+ end
46
+
47
+ def push(branch, options)
48
+ run("push #{'-f' if options[:force]} #{merge_remote} #{branch}")
49
+ end
50
+
51
+ def merge(branch)
52
+ run("merge #{branch}")
53
+ end
54
+
55
+ def fetch(remote)
56
+ run("fetch #{remote}")
57
+ end
58
+
59
+ def master_branch_contains?(ref)
60
+ run("branch --contains #{ref}")
61
+ last_stdout.split("\n").detect { |str| str[2..-1] == master_branch }
62
+ end
63
+
64
+ def in_original_merge_branch
65
+ begin
66
+ starting_branch = current_branch
67
+ run("checkout #{merge_remote}/#{merge_branch}")
68
+
69
+ yield
70
+ ensure
71
+ run("checkout #{starting_branch}")
72
+ end
73
+ end
74
+
75
+ def read_file_from_merge_branch(filename)
76
+ run("show #{merge_remote}/#{merge_branch}:#{filename}")
77
+ last_stdout
78
+ end
79
+
80
+ def initialize_rerere
81
+ return unless use_rerere
82
+
83
+ @cmd_runner.run('mkdir .git/rr-cache')
84
+ @cmd_runner.run('cp -R rr-cache/* .git/rr-cache/')
85
+ end
86
+
87
+ def commit_rerere(current_rereres)
88
+ return unless use_rerere
89
+ @cmd_runner.run('mkdir rr-cache')
90
+ @cmd_runner.run('rm -rf rr-cache/*')
91
+ current_rereres.each do |rerere|
92
+ @cmd_runner.run("cp -R .git/rr-cache/#{rerere} rr-cache/")
93
+ end
94
+
95
+ run('add rr-cache/')
96
+ run("commit -m 'Update rr-cache'")
97
+ end
98
+
99
+ def rerere_resolve!
100
+ return false unless use_rerere
101
+
102
+ merging_files = staged_and_working_dir_files.select { |s| UNMERGED_STATUSES.include?(s[0..1]) }.map { |s| s[3..-1] }
103
+
104
+ conflicts = merging_files.map do |file|
105
+ File.open(file) { |f| f.grep(/>>>>/) }
106
+ end
107
+
108
+ if conflicts.all? { |c| c.empty? }
109
+ run("add #{merging_files.join(" ")}")
110
+ run('commit --no-edit')
111
+
112
+ resolutions(merging_files)
113
+ else
114
+ false
115
+ end
116
+ end
117
+
118
+ def resolutions(files)
119
+ {}.tap do |hash|
120
+ files.map do |file|
121
+ hash[file] = resolution_candidates(file)
122
+ end.flatten
123
+ end
124
+ end
125
+
126
+ # git rerere doesn't give you a deterministic way to determine which resolution was used
127
+ def resolution_candidates(file)
128
+ @cmd_runner.run("diff -q --from-file #{file} .git/rr-cache/*/postimage")
129
+ different_files = split_diff_lines(@cmd_runner.last_stdout)
130
+
131
+ @cmd_runner.run('ls -la .git/rr-cache/*/postimage')
132
+ all_files = split_diff_lines(@cmd_runner.last_stdout)
133
+
134
+ all_files - different_files
135
+ end
136
+
137
+ def split_diff_lines(arr)
138
+ arr.split("\n").map { |s| s.split(".git/rr-cache/").last.split("/postimage").first }
139
+ end
140
+
141
+ def remotes
142
+ run('remote -v')
143
+ last_stdout.split("\n")
144
+ end
145
+
146
+ def remotes_hash
147
+ return @remotes_hash if @remotes_hash
148
+
149
+ @remotes_hash = {}
150
+ remotes.each do |r|
151
+ name = r.split[0]
152
+ url = r.split[1]
153
+ @remotes_hash[name] ||= url
154
+ end
155
+ @remotes_hash
156
+ end
157
+
158
+ def fetch_remote_for_url(url)
159
+ fetch_remotes = remotes.grep(Regexp.new(url)).grep(/ \(fetch\)/)
160
+ fetch_remotes.map { |remote| remote.to_s.split("\t").first }.first
161
+ end
162
+
163
+ def staged_and_working_dir_files
164
+ run("status --porcelain")
165
+ last_stdout.split("\n").reject { |line| line[0..1] == '??' }
166
+ end
167
+
168
+ def current_branch
169
+ run("rev-parse --abbrev-ref HEAD")
170
+ last_stdout.strip
171
+ end
172
+
173
+ def most_recent_commit
174
+ run("show -s --format=%cd head")
175
+ end
176
+
177
+ def reset_temp_merge_branch
178
+ in_branch(master_branch) do
179
+ run("fetch #{merge_remote}")
180
+ run("branch -D #{temp_merge_branch}")
181
+ run("checkout -b #{temp_merge_branch}")
182
+ run("reset --hard #{merge_remote}/#{master_branch}")
183
+ end
184
+ end
185
+
186
+ def push_merge_branch
187
+ run("push -f #{merge_remote} #{merge_branch}")
188
+ end
189
+
190
+ def copy_temp_to_merge_branch
191
+ run("checkout #{temp_merge_branch}")
192
+ run("merge --strategy=ours --no-edit #{merge_branch}")
193
+ run("checkout #{merge_branch}")
194
+ run("merge #{temp_merge_branch}")
195
+
196
+ squash_commits
197
+ end
198
+
199
+ def commit_message(log)
200
+ "Flash Flow run from branch: #{working_branch}\n\n#{log}".gsub(/'/, '')
201
+ end
202
+
203
+ def delete_temp_merge_branch
204
+ in_merge_branch do
205
+ run("branch -d #{temp_merge_branch}")
206
+ end
207
+ end
208
+
209
+ def in_temp_merge_branch(&block)
210
+ in_branch(temp_merge_branch, &block)
211
+ end
212
+
213
+ def in_merge_branch(&block)
214
+ in_branch(merge_branch, &block)
215
+ end
216
+
217
+ private
218
+
219
+ def squash_commits
220
+ # There are three commits created by flash flow that we don't need in the message
221
+ run("log #{merge_remote}/#{merge_branch}..#{merge_branch}~3")
222
+ log = last_stdout
223
+
224
+ # Get all the files that differ between existing acceptance and new acceptance
225
+ run("diff --name-only #{merge_remote}/#{merge_branch} #{merge_branch}")
226
+ files = last_stdout.split("\n")
227
+ run("reset #{merge_remote}/#{merge_branch}")
228
+ run("add #{files.map { |f| "'#{f}'" }.join(" ")}")
229
+
230
+ run("commit -m '#{commit_message(log)}'")
231
+ end
232
+
233
+ def temp_merge_branch
234
+ "flash_flow/#{merge_branch}"
235
+ end
236
+
237
+ def in_branch(branch)
238
+ begin
239
+ starting_branch = current_branch
240
+ run("checkout #{branch}")
241
+
242
+ yield
243
+ ensure
244
+ run("checkout #{starting_branch}")
245
+ end
246
+ end
247
+ end
248
+ end