flash_flow 1.2.1 → 1.2.2.a
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/Gemfile.lock +3 -1
- data/bin/flash_flow +2 -0
- data/flash_flow.gemspec +1 -0
- data/lib/flash_flow.rb +1 -0
- data/lib/flash_flow/cmd_runner.rb +21 -5
- data/lib/flash_flow/data/base.rb +6 -14
- data/lib/flash_flow/data/branch.rb +2 -1
- data/lib/flash_flow/data/collection.rb +22 -3
- data/lib/flash_flow/data/github.rb +14 -4
- data/lib/flash_flow/data/store.rb +4 -2
- data/lib/flash_flow/deploy.rb +31 -48
- data/lib/flash_flow/git.rb +25 -11
- data/lib/flash_flow/issue_tracker.rb +21 -1
- data/lib/flash_flow/issue_tracker/pivotal.rb +44 -3
- data/lib/flash_flow/merge_master.rb +6 -0
- data/lib/flash_flow/merge_master/merge_status.html.erb +144 -0
- data/lib/flash_flow/merge_master/release_graph.rb +135 -0
- data/lib/flash_flow/merge_master/status.rb +108 -0
- data/lib/flash_flow/options.rb +1 -0
- data/lib/flash_flow/resolve.rb +18 -35
- data/lib/flash_flow/shadow_repo.rb +7 -14
- data/lib/flash_flow/version.rb +1 -1
- data/test/lib/data/test_collection.rb +50 -0
- data/test/lib/data/test_store.rb +3 -0
- data/test/lib/issue_tracker/test_pivotal.rb +71 -0
- data/test/lib/merge_master/test_release_graph.rb +74 -0
- data/test/lib/merge_master/test_status.rb +119 -0
- data/test/lib/test_deploy.rb +6 -8
- data/test/lib/test_git.rb +4 -4
- data/test/lib/test_issue_tracker.rb +15 -0
- data/test/lib/test_resolve.rb +0 -29
- data/test/minitest_helper.rb +6 -4
- data/update_gem.sh +5 -0
- metadata +27 -4
@@ -32,10 +32,30 @@ module FlashFlow
|
|
32
32
|
issue_tracker.release_notes(hours, file) if issue_tracker.respond_to?(:release_notes)
|
33
33
|
end
|
34
34
|
|
35
|
+
def story_deployable?(story_id)
|
36
|
+
issue_tracker.story_deployable?(story_id) if issue_tracker.respond_to?(:story_deployable?)
|
37
|
+
end
|
38
|
+
|
39
|
+
def story_link(story_id)
|
40
|
+
issue_tracker.story_link(story_id) if issue_tracker.respond_to?(:story_link)
|
41
|
+
end
|
42
|
+
|
43
|
+
def story_title(story_id)
|
44
|
+
issue_tracker.story_title(story_id) if issue_tracker.respond_to?(:story_title)
|
45
|
+
end
|
46
|
+
|
47
|
+
def release_keys(story_id)
|
48
|
+
issue_tracker.release_keys(story_id) if issue_tracker.respond_to?(:release_keys)
|
49
|
+
end
|
50
|
+
|
51
|
+
def stories_for_release(release_key)
|
52
|
+
issue_tracker.stories_for_release(release_key) if issue_tracker.respond_to?(:stories_for_release)
|
53
|
+
end
|
54
|
+
|
35
55
|
private
|
36
56
|
|
37
57
|
def git
|
38
|
-
@git ||=
|
58
|
+
@git ||= ShadowGit.new(Config.configuration.git, Config.configuration.logger)
|
39
59
|
end
|
40
60
|
|
41
61
|
def get_branches
|
@@ -11,10 +11,11 @@ module FlashFlow
|
|
11
11
|
@branches = branches
|
12
12
|
@git = git
|
13
13
|
@timezone = opts['timezone'] || "UTC"
|
14
|
+
@project_id = opts['project_id']
|
14
15
|
|
15
16
|
PivotalTracker::Client.token = opts['token']
|
16
17
|
PivotalTracker::Client.use_ssl = true
|
17
|
-
@
|
18
|
+
@release_label_prefix = Regexp.new("^#{opts['release_label_prefix'] || 'release'}", Regexp::IGNORECASE)
|
18
19
|
end
|
19
20
|
|
20
21
|
def stories_pushed
|
@@ -56,8 +57,42 @@ module FlashFlow
|
|
56
57
|
print_release_notes(release_notes, file)
|
57
58
|
end
|
58
59
|
|
60
|
+
def story_deployable?(story_id)
|
61
|
+
story = get_story(story_id)
|
62
|
+
|
63
|
+
story.current_state == 'accepted'
|
64
|
+
end
|
65
|
+
|
66
|
+
def story_link(story_id)
|
67
|
+
story = get_story(story_id)
|
68
|
+
|
69
|
+
story.url if story
|
70
|
+
end
|
71
|
+
|
72
|
+
def story_title(story_id)
|
73
|
+
story = get_story(story_id)
|
74
|
+
|
75
|
+
story.name if story
|
76
|
+
end
|
77
|
+
|
78
|
+
def release_keys(story_id)
|
79
|
+
story = get_story(story_id)
|
80
|
+
|
81
|
+
return [] if story.labels.nil?
|
82
|
+
|
83
|
+
story.labels.split(",").map(&:strip).select { |label| label =~ @release_label_prefix }.map(&:strip)
|
84
|
+
end
|
85
|
+
|
86
|
+
def stories_for_release(release_key)
|
87
|
+
stories_for_label(release_key)
|
88
|
+
end
|
89
|
+
|
59
90
|
private
|
60
91
|
|
92
|
+
def project
|
93
|
+
@project ||= PivotalTracker::Project.find(@project_id)
|
94
|
+
end
|
95
|
+
|
61
96
|
def undeliver(story_id)
|
62
97
|
story = get_story(story_id)
|
63
98
|
|
@@ -111,7 +146,12 @@ module FlashFlow
|
|
111
146
|
end
|
112
147
|
|
113
148
|
def done_and_current_stories
|
114
|
-
[
|
149
|
+
[project.iteration(:done).last(2).map(&:stories) + project.iteration(:current).stories].flatten
|
150
|
+
end
|
151
|
+
|
152
|
+
def stories_for_label(label)
|
153
|
+
@stories_for_label ||= {}
|
154
|
+
@stories_for_label[label] ||= project.stories.all(label: label).map(&:id)
|
115
155
|
end
|
116
156
|
|
117
157
|
def release_by(release_stories, hours)
|
@@ -128,7 +168,8 @@ module FlashFlow
|
|
128
168
|
end
|
129
169
|
|
130
170
|
def get_story(story_id)
|
131
|
-
@
|
171
|
+
@stories ||= {}
|
172
|
+
@stories[story_id] ||= project.stories.find(story_id)
|
132
173
|
end
|
133
174
|
|
134
175
|
def already_has_comment?(story, comment)
|
@@ -0,0 +1,144 @@
|
|
1
|
+
<html>
|
2
|
+
<head>
|
3
|
+
<style>
|
4
|
+
.story {
|
5
|
+
padding: 15px 0;
|
6
|
+
}
|
7
|
+
|
8
|
+
span {
|
9
|
+
margin-left: 30px;
|
10
|
+
}
|
11
|
+
|
12
|
+
.branch {
|
13
|
+
border: solid black 1px;
|
14
|
+
margin: 30px 0;
|
15
|
+
padding: 30px;
|
16
|
+
}
|
17
|
+
|
18
|
+
img {
|
19
|
+
margin: 60px;
|
20
|
+
}
|
21
|
+
|
22
|
+
.good-to-go {
|
23
|
+
background-color: lightgreen;
|
24
|
+
}
|
25
|
+
|
26
|
+
.status.shippable {
|
27
|
+
color: green;
|
28
|
+
}
|
29
|
+
|
30
|
+
.status {
|
31
|
+
margin-right: 30px;
|
32
|
+
}
|
33
|
+
|
34
|
+
.status.not-shippable {
|
35
|
+
color: lightpink;
|
36
|
+
}
|
37
|
+
|
38
|
+
.story .status {
|
39
|
+
margin-left: 30px;
|
40
|
+
width: 14%;
|
41
|
+
min-width: 100px;
|
42
|
+
display: inline-block;
|
43
|
+
}
|
44
|
+
|
45
|
+
.release {
|
46
|
+
padding-left: 15px;
|
47
|
+
}
|
48
|
+
</style>
|
49
|
+
</head>
|
50
|
+
<body>
|
51
|
+
<% @branches.each do |branch, branch_hash| %>
|
52
|
+
<div class="branch <%= branch_hash[:shippable?] ? 'good-to-go' : '' %>">
|
53
|
+
<h2 class="status <%= branch_hash[:shippable?] ? 'shippable' : 'not-shippable' %>">
|
54
|
+
<%= branch_hash[:shippable?] ? 'Ready to ship' : 'Can not ship' %>
|
55
|
+
</h2>
|
56
|
+
|
57
|
+
<h2>
|
58
|
+
<span class="status <%= branch_hash[:branch_can_ship?] ? 'shippable' : 'not-shippable' %>"><%= branch_hash[:branch_can_ship?] ? 'Code ready' : 'Code not ready' %></span>
|
59
|
+
<a href='<%= branch_hash[:branch_url] %>'><%= branch_hash[:name] %></a>
|
60
|
+
</h2>
|
61
|
+
|
62
|
+
<% (branch_hash[:connected_branches].to_a - [branch]).each do |connected_branch| %>
|
63
|
+
<% connected_branch_hash = @branches[connected_branch] %>
|
64
|
+
<h2>
|
65
|
+
<span style="margin-right: 30px;">Must ship with: </span>
|
66
|
+
<a href='<%= connected_branch_hash[:branch_url] %>'><%= connected_branch_hash[:name] %></a>
|
67
|
+
</h2>
|
68
|
+
<% end %>
|
69
|
+
|
70
|
+
<div class="grouping">
|
71
|
+
<% if branch_hash[:stories].empty? %>
|
72
|
+
<h3>
|
73
|
+
This branch has no stories
|
74
|
+
</h3>
|
75
|
+
<% else %>
|
76
|
+
<h3>
|
77
|
+
Stories for this branch
|
78
|
+
</h3>
|
79
|
+
<% branch_hash[:my_stories].each do |story_id| %>
|
80
|
+
<% story = @stories[story_id] %>
|
81
|
+
<div class="story">
|
82
|
+
<span class="status <%= story[:can_ship?] ? 'shippable' : 'not-shippable' %>"><%= story[:can_ship?] ? 'Story ready' : 'Story not ready' %></span>
|
83
|
+
<a href='<%= story[:url] %>'><%= story[:title] %></a>
|
84
|
+
<span><%= story[:release_keys].empty? ? '' : "Related releases: #{story[:release_keys].join(", ")}" %></span>
|
85
|
+
</div>
|
86
|
+
<% end %>
|
87
|
+
|
88
|
+
<% unless (branch_hash[:stories] - branch_hash[:my_stories]).empty? %>
|
89
|
+
<h3>
|
90
|
+
Stories that must be released with this branch
|
91
|
+
</h3>
|
92
|
+
<% (branch_hash[:stories] - branch_hash[:my_stories]).each do |story_id| %>
|
93
|
+
<% story = @stories[story_id] %>
|
94
|
+
<div class="story">
|
95
|
+
<span class="status <%= story[:can_ship?] ? 'shippable' : 'not-shippable' %>"><%= story[:can_ship?] ? 'Story ready' : 'Story not ready' %></span>
|
96
|
+
<a href='<%= story[:url] %>'><%= story[:title] %></a>
|
97
|
+
<span><%= story[:release_keys].empty? ? '' : "Related releases: #{story[:release_keys].join(", ")}" %></span>
|
98
|
+
</div>
|
99
|
+
<% end %>
|
100
|
+
<% end %>
|
101
|
+
<% end %>
|
102
|
+
</div>
|
103
|
+
|
104
|
+
<div class="grouping">
|
105
|
+
<% if branch_hash[:releases].empty? %>
|
106
|
+
<h3>
|
107
|
+
This branch has no associated releases
|
108
|
+
</h3>
|
109
|
+
<% else %>
|
110
|
+
<h3>
|
111
|
+
Releases for this branch
|
112
|
+
</h3>
|
113
|
+
<% branch_hash[:releases].each do |release_key| %>
|
114
|
+
<div class="release">
|
115
|
+
<h4><%= release_key %></h4>
|
116
|
+
<% @releases[release_key][:stories].each do |story_id| %>
|
117
|
+
<% story = @stories[story_id] %>
|
118
|
+
<div class="story">
|
119
|
+
<span class="status <%= story[:can_ship?] ? 'shippable' : 'not-shippable' %>"><%= story[:can_ship?] ? 'Story ready' : 'Story not ready' %></span>
|
120
|
+
<a href='<%= story[:url] %>'><%= story[:title] %></a>
|
121
|
+
<span><%= story[:release_keys].empty? ? '' : "Related releases: #{story[:release_keys].join(", ")}" %></span>
|
122
|
+
</div>
|
123
|
+
<% end %>
|
124
|
+
</div>
|
125
|
+
<% end %>
|
126
|
+
<% end %>
|
127
|
+
</div>
|
128
|
+
|
129
|
+
<% if branch_hash[:stories].size > 1 %>
|
130
|
+
<% if branch_hash[:image] %>
|
131
|
+
<img src='file:///<%= branch_hash[:image] %>'/>
|
132
|
+
<% else %>
|
133
|
+
<img src=""/>
|
134
|
+
|
135
|
+
<h2 class="warning">GraphViz not installed.</h2>
|
136
|
+
<h4 class="warning">On a Mac, "brew install graphviz" to see a graphical version of your branch's dependencies</h4>
|
137
|
+
<% end %>
|
138
|
+
<% end %>
|
139
|
+
</div>
|
140
|
+
|
141
|
+
|
142
|
+
<% end %>
|
143
|
+
</body>
|
144
|
+
</html>
|
@@ -0,0 +1,135 @@
|
|
1
|
+
require 'graphviz'
|
2
|
+
|
3
|
+
module FlashFlow
|
4
|
+
module MergeMaster
|
5
|
+
class ReleaseGraph
|
6
|
+
attr_accessor :branches, :issue_tracker
|
7
|
+
|
8
|
+
def self.build(branches, issue_tracker)
|
9
|
+
instance = new(branches, issue_tracker)
|
10
|
+
instance.build
|
11
|
+
instance
|
12
|
+
end
|
13
|
+
|
14
|
+
def initialize(branches, issue_tracker)
|
15
|
+
@issue_tracker = issue_tracker
|
16
|
+
@branches = branches
|
17
|
+
end
|
18
|
+
|
19
|
+
def output(png_file)
|
20
|
+
graph.output(png: png_file)
|
21
|
+
png_file
|
22
|
+
rescue StandardError => e
|
23
|
+
if e.message =~ /GraphViz/i
|
24
|
+
nil
|
25
|
+
else
|
26
|
+
raise e
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def build
|
31
|
+
queue = []
|
32
|
+
|
33
|
+
branches.each do |branch|
|
34
|
+
seen_branches[branch] = true
|
35
|
+
queue.unshift([branch, graph.add_node(branch.ref)]) #, color: branch[:shippable?] ? 'green' : 'red', shape: 'record', label: "<f0>#{branch[:name]}")
|
36
|
+
end
|
37
|
+
|
38
|
+
while !queue.empty?
|
39
|
+
element, node = queue.pop
|
40
|
+
|
41
|
+
case
|
42
|
+
when seen_branches.has_key?(element)
|
43
|
+
element.stories.to_a.map(&:to_s).each do |story_id|
|
44
|
+
story_id = story_id.to_s
|
45
|
+
seen_stories[story_id] = true
|
46
|
+
find_or_add_node(node, queue, story_id)
|
47
|
+
end
|
48
|
+
|
49
|
+
when seen_stories.has_key?(element)
|
50
|
+
issue_tracker.release_keys(element).each do |release_key|
|
51
|
+
seen_releases[release_key] = true
|
52
|
+
find_or_add_node(node, queue, release_key)
|
53
|
+
end
|
54
|
+
|
55
|
+
when seen_releases.has_key?(element)
|
56
|
+
issue_tracker.stories_for_release(element).map(&:to_s).each do |story_id|
|
57
|
+
story_id = story_id.to_s
|
58
|
+
seen_stories[story_id] = true
|
59
|
+
find_or_add_node(node, queue, story_id)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def connected_branches(node_id)
|
66
|
+
visited = connected(node_id)
|
67
|
+
|
68
|
+
seen_branches.keys.select { |k| visited[k.ref] }
|
69
|
+
end
|
70
|
+
|
71
|
+
def connected_stories(node_id)
|
72
|
+
visited = connected(node_id)
|
73
|
+
|
74
|
+
seen_stories.keys.select { |k| visited[k] }
|
75
|
+
end
|
76
|
+
|
77
|
+
def connected_releases(node_id)
|
78
|
+
visited = connected(node_id)
|
79
|
+
|
80
|
+
seen_releases.keys.select { |k| visited[k] }
|
81
|
+
end
|
82
|
+
|
83
|
+
private
|
84
|
+
|
85
|
+
def find_or_add_node(node, queue, identifier)
|
86
|
+
other_node = graph.find_node(identifier)
|
87
|
+
unless other_node
|
88
|
+
other_node = graph.add_node(identifier)
|
89
|
+
queue.unshift([identifier, other_node])
|
90
|
+
end
|
91
|
+
|
92
|
+
graph.add_edge(node, other_node) unless all_neighbors(node).include?(other_node)
|
93
|
+
end
|
94
|
+
|
95
|
+
def seen_branches
|
96
|
+
@seen_branches ||= {}
|
97
|
+
end
|
98
|
+
|
99
|
+
def seen_releases
|
100
|
+
@seen_releases ||= {}
|
101
|
+
end
|
102
|
+
|
103
|
+
def seen_stories
|
104
|
+
@seen_stories ||= {}
|
105
|
+
end
|
106
|
+
|
107
|
+
|
108
|
+
def connected(node_id)
|
109
|
+
node = graph.find_node(node_id)
|
110
|
+
visited = {}
|
111
|
+
search(node, visited) if node
|
112
|
+
|
113
|
+
visited
|
114
|
+
end
|
115
|
+
|
116
|
+
def all_neighbors(node)
|
117
|
+
(node.neighbors | node.incidents)
|
118
|
+
end
|
119
|
+
|
120
|
+
def search(node, visited)
|
121
|
+
return if visited[node.id]
|
122
|
+
|
123
|
+
visited[node.id] = true
|
124
|
+
|
125
|
+
all_neighbors(node).each do |neighbor|
|
126
|
+
search(neighbor, visited)
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
def graph
|
131
|
+
@graph ||= GraphViz.new(:G, :type => :graph)
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
@@ -0,0 +1,108 @@
|
|
1
|
+
require 'flash_flow/merge_master/release_graph'
|
2
|
+
|
3
|
+
module FlashFlow
|
4
|
+
module MergeMaster
|
5
|
+
class Status
|
6
|
+
attr_reader :issue_tracker, :collection, :stories, :releases
|
7
|
+
|
8
|
+
def initialize(issue_tracker_config, branches_config, branch_info_file, git_config, opts={})
|
9
|
+
@issue_tracker = IssueTracker::Base.new(issue_tracker_config)
|
10
|
+
@collection = Data::Base.new(branches_config, branch_info_file, ShadowGit.new(git_config)).merged_branches
|
11
|
+
end
|
12
|
+
|
13
|
+
def status(filename=nil)
|
14
|
+
filename = File.dirname(__FILE__) + '/merge_status.html'
|
15
|
+
@branches = branches
|
16
|
+
|
17
|
+
template = ERB.new File.read(File.dirname(__FILE__) + '/merge_status.html.erb')
|
18
|
+
html = template.result(binding)
|
19
|
+
File.open(filename, 'w') do |f|
|
20
|
+
f.puts html
|
21
|
+
end
|
22
|
+
`open #{filename}`
|
23
|
+
end
|
24
|
+
|
25
|
+
def branches
|
26
|
+
g = ReleaseGraph.build(collection.current_branches, issue_tracker)
|
27
|
+
|
28
|
+
branch_hash = {}
|
29
|
+
collection.current_branches.each_with_index do |branch, i|
|
30
|
+
connected_branches = g.connected_branches(branch.ref)
|
31
|
+
connected_stories = g.connected_stories(branch.ref)
|
32
|
+
connected_releases = g.connected_releases(branch.ref)
|
33
|
+
add_stories(connected_stories)
|
34
|
+
add_releases(connected_releases)
|
35
|
+
sub_g = ReleaseGraph.build(connected_branches, issue_tracker)
|
36
|
+
graph_file = sub_g.output("/tmp/graph-#{i}.png")
|
37
|
+
|
38
|
+
branch_hash[branch] =
|
39
|
+
Hash.new.tap do |hash|
|
40
|
+
hash[:name] = branch.ref
|
41
|
+
hash[:branch_url] = collection.branch_link(branch)
|
42
|
+
hash[:branch_can_ship?] = collection.can_ship?(branch)
|
43
|
+
hash[:connected_branches] = connected_branches
|
44
|
+
hash[:image] = graph_file
|
45
|
+
hash[:my_stories] = branch.stories.to_a
|
46
|
+
hash[:stories] = connected_stories
|
47
|
+
hash[:releases] = connected_releases
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
mark_as_shippable(branch_hash)
|
52
|
+
branch_hash
|
53
|
+
end
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
def add_stories(story_list)
|
58
|
+
@stories ||= {}
|
59
|
+
story_list.each do |story_id|
|
60
|
+
@stories[story_id] ||= story_info_hash(story_id)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def add_releases(release_list)
|
65
|
+
@releases ||= {}
|
66
|
+
release_list.each do |release_key|
|
67
|
+
@releases[release_key] ||= {stories: issue_tracker.stories_for_release(release_key).map(&:to_s)}
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def mark_as_shippable(branches)
|
72
|
+
branches.each do |_, b|
|
73
|
+
b[:shippable?] = b[:branch_can_ship?] &&
|
74
|
+
unshippable_stories(b[:stories]).empty? &&
|
75
|
+
unshippable_releases(b[:releases]).empty?
|
76
|
+
end
|
77
|
+
|
78
|
+
branches.each do |_, b|
|
79
|
+
b[:shippable?] &= b[:connected_branches].all? do |other_branch|
|
80
|
+
branches[other_branch][:shippable?]
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def unshippable_releases(arr)
|
86
|
+
arr.select do |release_key|
|
87
|
+
!unshippable_stories(@releases[release_key][:stories]).empty?
|
88
|
+
end
|
89
|
+
|
90
|
+
end
|
91
|
+
|
92
|
+
def unshippable_stories(arr)
|
93
|
+
arr.select { |story| !@stories[story][:can_ship?] }
|
94
|
+
end
|
95
|
+
|
96
|
+
def story_info_hash(story_id)
|
97
|
+
{
|
98
|
+
id: story_id,
|
99
|
+
url: issue_tracker.story_link(story_id),
|
100
|
+
title: issue_tracker.story_title(story_id),
|
101
|
+
can_ship?: issue_tracker.story_deployable?(story_id),
|
102
|
+
release_keys: issue_tracker.release_keys(story_id)
|
103
|
+
}
|
104
|
+
end
|
105
|
+
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|