flash_flow 1.2.1 → 1.2.2.a
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 +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
|