houston-core 0.6.0 → 0.6.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/Gemfile.lock +69 -68
- data/app/adapters/houston/adapters/deployment/engineyard.rb +1 -1
- data/app/adapters/houston/adapters/version_control/git_adapter/github_repo.rb +19 -0
- data/app/adapters/houston/adapters/version_control/git_adapter/remote_repo.rb +27 -0
- data/app/assets/images/drag-grip.png +0 -0
- data/app/assets/javascripts/app/infinite_scroll.coffee +1 -1
- data/app/assets/javascripts/app/views/_show_sprint_view.coffee +9 -9
- data/app/assets/javascripts/application.js +1 -0
- data/app/assets/javascripts/core/app.coffee +5 -0
- data/app/assets/javascripts/{app → core}/stacked_area_graph.coffee +0 -0
- data/app/assets/javascripts/{app → core}/stacked_bar_graph.coffee +0 -0
- data/app/assets/javascripts/dashboard.js +1 -0
- data/app/assets/javascripts/vendor.js +0 -1
- data/app/assets/stylesheets/application/exceptions.scss +3 -1
- data/app/assets/stylesheets/application/navigation.scss +84 -21
- data/app/assets/stylesheets/application/releases.scss +32 -2
- data/app/assets/stylesheets/application/test.scss +34 -0
- data/app/assets/stylesheets/core/colors.scss.erb +33 -3
- data/app/assets/stylesheets/dashboard/dashboard.scss +11 -7
- data/app/assets/stylesheets/variables.scss +3 -0
- data/app/concerns/belongs_to_commit.rb +14 -0
- data/app/concerns/project_adapter.rb +24 -6
- data/app/controllers/api/v1/projects_controller.rb +18 -0
- data/app/controllers/api/v1/sprint_tasks_controller.rb +1 -1
- data/app/controllers/deploys_controller.rb +1 -0
- data/app/controllers/project_tests_controller.rb +49 -19
- data/app/controllers/releases_controller.rb +5 -0
- data/app/controllers/test_runs_controller.rb +16 -1
- data/app/helpers/test_run_helper.rb +24 -0
- data/app/models/deploy.rb +13 -0
- data/app/models/github/pull_request.rb +39 -4
- data/app/models/release.rb +42 -0
- data/app/models/task.rb +3 -2
- data/app/models/test_run.rb +4 -0
- data/app/presenters/project_presenter.rb +28 -0
- data/app/views/deploys/show.html.erb +4 -0
- data/app/views/github/pulls/index.html.erb +4 -1
- data/app/views/layouts/_mobile_navigation.html.erb +14 -17
- data/app/views/layouts/_navigation.html.erb +87 -87
- data/app/views/layouts/dashboard.html.erb +2 -2
- data/app/views/project_tests/index.html.erb +22 -7
- data/app/views/releases/_index.html.erb +65 -0
- data/app/views/releases/_results.html.erb +47 -0
- data/app/views/releases/index.html.erb +29 -65
- data/app/views/sprints/dashboard.html.erb +4 -2
- data/config/environments/production.rb +1 -1
- data/config/environments/test.rb +4 -1
- data/config/initializers/add_navigation_renderers.rb +6 -0
- data/config/initializers/requirements.rb +1 -0
- data/config/routes.rb +3 -0
- data/db/migrate/20151226154901_add_search_vector_to_releases.rb +6 -0
- data/db/migrate/20151226155305_generate_index_on_releases.rb +5 -0
- data/db/migrate/20151228183704_drop_unused_tables.rb +35 -0
- data/db/migrate/20160120145757_add_successful_to_deploys.rb +10 -0
- data/db/structure.sql +19 -67
- data/houston.gemspec +3 -3
- data/lib/configuration.rb +4 -2
- data/lib/core_ext/array.rb +37 -0
- data/lib/houston/version.rb +1 -1
- data/test/integration/ci_integration_test.rb +14 -13
- data/test/unit/models/project_test.rb +33 -0
- data/test/unit/models/pull_request_test.rb +71 -1
- metadata +24 -14
- data/app/models/historical_head.rb +0 -5
@@ -7,6 +7,20 @@ module BelongsToCommit
|
|
7
7
|
validates :sha, presence: {message: "must refer to a commit"}
|
8
8
|
end
|
9
9
|
|
10
|
+
module ClassMethods
|
11
|
+
def find_by_sha!(sha)
|
12
|
+
find_by_sha(sha) || raise(ActiveRecord::RecordNotFound)
|
13
|
+
end
|
14
|
+
|
15
|
+
def find_by_sha(sha)
|
16
|
+
with_sha_like(sha).first if sha
|
17
|
+
end
|
18
|
+
|
19
|
+
def with_sha_like(sha)
|
20
|
+
where(["sha LIKE ?", "#{sha.strip}%"])
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
10
24
|
private
|
11
25
|
|
12
26
|
def identify_commit
|
@@ -18,6 +18,7 @@ module ProjectAdapter
|
|
18
18
|
adapter.define_methods!
|
19
19
|
|
20
20
|
validate adapter.validation_method
|
21
|
+
before_update adapter.before_update_method
|
21
22
|
end
|
22
23
|
end
|
23
24
|
|
@@ -41,6 +42,18 @@ module ProjectAdapter
|
|
41
42
|
:"#{attribute_name}_configuration_is_valid"
|
42
43
|
end
|
43
44
|
|
45
|
+
def before_update_method
|
46
|
+
:"#{attribute_name}_before_update"
|
47
|
+
end
|
48
|
+
|
49
|
+
def adapter_method
|
50
|
+
:"#{attribute_name}_adapter"
|
51
|
+
end
|
52
|
+
|
53
|
+
def params_method
|
54
|
+
:"parameters_for_#{attribute_name}_adapter"
|
55
|
+
end
|
56
|
+
|
44
57
|
def define_methods!
|
45
58
|
model.module_eval <<-RUBY
|
46
59
|
def self.with_#{attribute_name}
|
@@ -52,24 +65,29 @@ module ProjectAdapter
|
|
52
65
|
end
|
53
66
|
|
54
67
|
def #{validation_method}
|
55
|
-
#{
|
68
|
+
#{adapter_method}.errors_with_parameters(self, *#{params_method}.values).each do |attribute, messages|
|
56
69
|
errors.add(attribute, messages) if messages.any?
|
57
70
|
end
|
58
71
|
end
|
59
72
|
|
73
|
+
def #{before_update_method}
|
74
|
+
return true unless #{attribute_name}.respond_to?(:before_update)
|
75
|
+
#{attribute_name}.before_update(self)
|
76
|
+
end
|
77
|
+
|
60
78
|
def #{attribute_name}
|
61
|
-
@#{attribute_name} ||= #{
|
62
|
-
.build(self,
|
79
|
+
@#{attribute_name} ||= #{adapter_method}
|
80
|
+
.build(self, *#{params_method}.values)
|
63
81
|
.extend(FeatureSupport)
|
64
82
|
end
|
65
83
|
|
66
|
-
def
|
67
|
-
#{
|
84
|
+
def #{params_method}
|
85
|
+
#{adapter_method}.parameters.each_with_object({}) do |parameter, hash|
|
68
86
|
hash[parameter] = extended_attributes[parameter.to_s]
|
69
87
|
end
|
70
88
|
end
|
71
89
|
|
72
|
-
def #{
|
90
|
+
def #{adapter_method}
|
73
91
|
#{namespace}.adapter(#{attribute_name}_name)
|
74
92
|
end
|
75
93
|
RUBY
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module Api
|
2
|
+
module V1
|
3
|
+
class ProjectsController < ApplicationController
|
4
|
+
before_filter :api_authenticate!
|
5
|
+
skip_before_filter :verify_authenticity_token
|
6
|
+
|
7
|
+
rescue_from ActiveRecord::RecordNotFound do
|
8
|
+
head 404
|
9
|
+
end
|
10
|
+
|
11
|
+
def index
|
12
|
+
@projects = Project.unretired
|
13
|
+
render json: ProjectPresenter.new(@projects)
|
14
|
+
end
|
15
|
+
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -2,31 +2,61 @@ class ProjectTestsController < ApplicationController
|
|
2
2
|
|
3
3
|
def index
|
4
4
|
@project = Project.find_by_slug! params[:slug]
|
5
|
+
@title = "#{@project.name} Tests"
|
5
6
|
|
6
7
|
head = params.fetch :at, @project.head_sha
|
7
|
-
commits = params.fetch(:limit,
|
8
|
+
commits = params.fetch(:limit, 128).to_i
|
8
9
|
|
9
10
|
@commits = Houston.benchmark("[project_tests#index] fetch commits") {
|
10
11
|
@project.repo.ancestors(head, including_self: true, limit: commits) }
|
11
|
-
|
12
|
+
shas = @commits.map(&:sha)
|
13
|
+
test_run_id_by_sha = Hash[@project.test_runs.where(sha: shas).pluck(:sha, :id)]
|
14
|
+
test_run_ids = test_run_id_by_sha.values
|
12
15
|
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
)
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
16
|
+
# We're looking at the history of the tests that exist in the
|
17
|
+
# most recent commit; ignore tests that didn't exist before this
|
18
|
+
test_ids = Houston.benchmark("[project_tests#index] pick tests") do
|
19
|
+
latest_test_run_id = @project.test_runs.where(sha: shas)
|
20
|
+
.joins("LEFT OUTER JOIN test_results ON test_runs.id=test_results.test_run_id")
|
21
|
+
.where.not("test_results.test_run_id" => nil)
|
22
|
+
.limit(1)
|
23
|
+
.pluck(:id)
|
24
|
+
.first
|
25
|
+
TestResult.where(test_run_id: latest_test_run_id).pluck("DISTINCT test_id")
|
26
|
+
end
|
27
|
+
|
28
|
+
# Get all the results that we're going to graph
|
29
|
+
test_results = Houston.benchmark("[project_tests#index] load results") do
|
30
|
+
TestResult.where(test_run_id: test_run_ids, test_id: test_ids).pluck(:test_run_id, :test_id, :status, :duration)
|
31
|
+
end
|
32
|
+
|
33
|
+
# Now we need to map results to tests
|
34
|
+
# and make sure that they're in the same order
|
35
|
+
# that the last 200 commits occurred in.
|
36
|
+
@tests = Houston.benchmark("[project_tests#index] map results") do
|
37
|
+
map = Hash.new { |hash, test_id| hash[test_id] = {} }
|
38
|
+
durations = Hash.new { |hash, test_id| hash[test_id] = [] }
|
39
|
+
test_results.each do |(test_run_id, test_id, status, duration)|
|
40
|
+
map[test_id][test_run_id] = status
|
41
|
+
durations[test_id] << duration if duration
|
42
|
+
end
|
43
|
+
|
44
|
+
@project.tests
|
45
|
+
.where(id: test_ids)
|
46
|
+
.order(:suite, :name)
|
47
|
+
.pluck("tests.id", :suite, :name)
|
48
|
+
.map do |(id, suite, name)|
|
49
|
+
status_by_test_run_id = map[id]
|
50
|
+
d = durations[id]
|
51
|
+
{ id: id,
|
52
|
+
suite: suite,
|
53
|
+
name: name,
|
54
|
+
duration_avg: d.mean,
|
55
|
+
duration5: d.percentile(5),
|
56
|
+
duration95: d.percentile(95),
|
57
|
+
results: shas.map { |sha| status_by_test_run_id[test_run_id_by_sha[sha]] } }
|
58
|
+
end
|
59
|
+
end
|
30
60
|
end
|
31
61
|
|
32
62
|
def show
|
@@ -10,6 +10,11 @@ class ReleasesController < ApplicationController
|
|
10
10
|
def index
|
11
11
|
@title = "Releases • #{@project.name}"
|
12
12
|
@title << " (#{@environment})" if @environment
|
13
|
+
@q = params[:q]
|
14
|
+
@q = nil if @q.blank?
|
15
|
+
@releases = @project.releases.preload(:deploy).search(@q) if @q
|
16
|
+
|
17
|
+
render partial: (@q ? "releases/results" : "releases/index") if request.xhr?
|
13
18
|
end
|
14
19
|
|
15
20
|
def new
|
@@ -1,5 +1,6 @@
|
|
1
1
|
class TestRunsController < ApplicationController
|
2
2
|
before_filter :find_test_run
|
3
|
+
skip_before_filter :verify_authenticity_token, only: [:save_results]
|
3
4
|
|
4
5
|
def show
|
5
6
|
@title = "Test Results for #{@test_run.sha[0...8]}"
|
@@ -25,11 +26,25 @@ class TestRunsController < ApplicationController
|
|
25
26
|
end
|
26
27
|
end
|
27
28
|
|
29
|
+
def save_results
|
30
|
+
results_url = params[:results_url]
|
31
|
+
|
32
|
+
if results_url.blank?
|
33
|
+
message = "#{@project.ci_server_name} is not appropriately configured to build #{@project.name}."
|
34
|
+
additional_info = "#{@project.ci_server_name} did not supply 'results_url' when it triggered the post_build hook"
|
35
|
+
ProjectNotification.ci_configuration_error(@test_run, message, additional_info: additional_info).deliver!
|
36
|
+
return
|
37
|
+
end
|
38
|
+
|
39
|
+
@test_run.completed!(results_url)
|
40
|
+
head :ok
|
41
|
+
end
|
42
|
+
|
28
43
|
private
|
29
44
|
|
30
45
|
def find_test_run
|
31
46
|
@project = Project.find_by_slug!(params[:slug])
|
32
|
-
@test_run = @project.test_runs.find_by_sha(params[:commit])
|
47
|
+
@test_run = @project.test_runs.find_by_sha!(params[:commit])
|
33
48
|
end
|
34
49
|
|
35
50
|
end
|
@@ -49,4 +49,28 @@ module TestRunHelper
|
|
49
49
|
end
|
50
50
|
end
|
51
51
|
|
52
|
+
def test_results_pass_count(test)
|
53
|
+
test[:results].count { |result| result == "pass" }
|
54
|
+
end
|
55
|
+
|
56
|
+
def test_results_fail_count(test)
|
57
|
+
test[:results].count { |result| result == "fail" }
|
58
|
+
end
|
59
|
+
|
60
|
+
def test_results_count(test)
|
61
|
+
test[:results].count { |result| !result.nil? }
|
62
|
+
end
|
63
|
+
|
64
|
+
def test_results_graph(test)
|
65
|
+
html = "<svg class=\"dot-graph\">"
|
66
|
+
percent = 100 / test[:results].length.to_f
|
67
|
+
left = 0
|
68
|
+
test[:results].reverse_each do |result|
|
69
|
+
html << "<rect x=\"#{left}%\" y=\"0\" rx=\"2\" ry=\"2\" width=\"#{percent}%\" height=\"100%\" class=\"dot-graph-rect #{result}\" data-index=\"0\"></rect>" if result
|
70
|
+
left += percent
|
71
|
+
end
|
72
|
+
html << "</svg>"
|
73
|
+
html.html_safe
|
74
|
+
end
|
75
|
+
|
52
76
|
end
|
data/app/models/deploy.rb
CHANGED
@@ -59,6 +59,14 @@ class Deploy < ActiveRecord::Base
|
|
59
59
|
completed_at.present?
|
60
60
|
end
|
61
61
|
|
62
|
+
def succeeded?
|
63
|
+
successful?
|
64
|
+
end
|
65
|
+
|
66
|
+
def failed?
|
67
|
+
!successful?
|
68
|
+
end
|
69
|
+
|
62
70
|
def environment
|
63
71
|
environment_name
|
64
72
|
end
|
@@ -96,6 +104,11 @@ private
|
|
96
104
|
def notify_if_completed
|
97
105
|
if just_completed?
|
98
106
|
update_column :duration, completed_at - created_at if duration.nil?
|
107
|
+
if successful?
|
108
|
+
Houston.observer.fire "deploy:succeeded", self
|
109
|
+
else
|
110
|
+
Houston.observer.fire "deploy:failed", self
|
111
|
+
end
|
99
112
|
Houston.observer.fire "deploy:completed", self
|
100
113
|
end
|
101
114
|
end
|
@@ -8,10 +8,12 @@ module Github
|
|
8
8
|
belongs_to :project
|
9
9
|
belongs_to :user
|
10
10
|
has_and_belongs_to_many :commits
|
11
|
+
belongs_to :base, class_name: "Commit", foreign_key: "base_sha", primary_key: "sha"
|
12
|
+
belongs_to :head, class_name: "Commit", foreign_key: "head_sha", primary_key: "sha"
|
11
13
|
|
12
14
|
before_validation :associate_project_with_self, if: :repo_changed?
|
13
15
|
before_save :associate_user_with_self, if: :username_changed?
|
14
|
-
after_commit :associate_commits_with_self
|
16
|
+
after_commit :associate_commits_with_self
|
15
17
|
|
16
18
|
after_destroy { Houston.observer.fire "github:pull:closed", self }
|
17
19
|
after_create { Houston.observer.fire "github:pull:opened", self }
|
@@ -139,6 +141,11 @@ module Github
|
|
139
141
|
def labeled(*labels)
|
140
142
|
where(["exists (select 1 from jsonb_array_elements(pull_requests.json_labels) as \"label\" where \"label\"->>'name' IN (?))", labels])
|
141
143
|
end
|
144
|
+
alias :with_labels :labeled
|
145
|
+
|
146
|
+
def without_labels(*labels)
|
147
|
+
where(["not exists (select 1 from jsonb_array_elements(pull_requests.json_labels) as \"label\" where \"label\"->>'name' IN (?))", labels])
|
148
|
+
end
|
142
149
|
end
|
143
150
|
|
144
151
|
|
@@ -147,6 +154,14 @@ module Github
|
|
147
154
|
self.json_labels = value.map { |label| label.to_h.stringify_keys.pick("name", "color") }
|
148
155
|
end
|
149
156
|
|
157
|
+
def labeled?(*values)
|
158
|
+
values.all? { |value| labels.any? { |label| label["name"] == value } }
|
159
|
+
end
|
160
|
+
|
161
|
+
def labeled_any?(*values)
|
162
|
+
values.any? { |value| labels.any? { |label| label["name"] == value } }
|
163
|
+
end
|
164
|
+
|
150
165
|
def labels
|
151
166
|
json_labels
|
152
167
|
end
|
@@ -173,20 +188,32 @@ module Github
|
|
173
188
|
|
174
189
|
|
175
190
|
|
191
|
+
def to_s
|
192
|
+
"#{repo}##{number}"
|
193
|
+
end
|
194
|
+
|
195
|
+
|
196
|
+
|
197
|
+
def publish_commit_status!(status={})
|
198
|
+
project.repo.create_commit_status(head_sha, status)
|
199
|
+
end
|
200
|
+
|
201
|
+
|
202
|
+
|
176
203
|
def merge_attributes(pr)
|
177
204
|
self.repo = pr["base"]["repo"]["name"] unless repo
|
178
205
|
self.number = pr["number"] unless number
|
179
206
|
self.username = pr["user"]["login"] unless username
|
180
207
|
self.avatar_url = pr["user"]["avatar_url"] unless avatar_url
|
181
208
|
self.url = pr["html_url"] unless url
|
182
|
-
self.base_sha = pr["base"]["sha"] unless base_sha
|
183
209
|
self.base_ref = pr["base"]["ref"] unless base_ref
|
210
|
+
self.head_ref = pr["head"]["ref"] unless head_ref
|
211
|
+
self.created_at = pr["created_at"] unless created_at
|
184
212
|
|
185
|
-
self.created_at = pr["created_at"]
|
186
213
|
self.title = pr["title"]
|
187
214
|
self.body = pr["body"]
|
215
|
+
self.base_sha = pr["base"]["sha"]
|
188
216
|
self.head_sha = pr["head"]["sha"]
|
189
|
-
self.head_ref = pr["head"]["ref"]
|
190
217
|
self.labels = pr["labels"] if pr.key?("labels")
|
191
218
|
|
192
219
|
self
|
@@ -203,9 +230,17 @@ module Github
|
|
203
230
|
end
|
204
231
|
|
205
232
|
def associate_commits_with_self
|
233
|
+
return unless commits_changes_before_commit?
|
234
|
+
|
206
235
|
Houston.try({max_tries: 2, base: 0}, ActiveRecord::RecordNotUnique) do
|
207
236
|
self.commits = project.commits.between(base_sha, head_sha)
|
208
237
|
end
|
238
|
+
|
239
|
+
Houston.observer.fire "github:pull:synchronize", self
|
240
|
+
end
|
241
|
+
|
242
|
+
def commits_changes_before_commit?
|
243
|
+
previous_changes.key?(:base_sha) || previous_changes.key?(:head_sha)
|
209
244
|
end
|
210
245
|
|
211
246
|
end
|
data/app/models/release.rb
CHANGED
@@ -5,6 +5,7 @@ class Release < ActiveRecord::Base
|
|
5
5
|
after_create :release_each_task!
|
6
6
|
after_create :release_each_antecedent!
|
7
7
|
after_create { Houston.observer.fire "release:create", self }
|
8
|
+
after_save :update_search_vector, :if => :search_vector_should_change?
|
8
9
|
|
9
10
|
belongs_to :project
|
10
11
|
belongs_to :user
|
@@ -79,6 +80,30 @@ class Release < ActiveRecord::Base
|
|
79
80
|
AND releases.created_at=most_recent_releases.created_at
|
80
81
|
SQL
|
81
82
|
end
|
83
|
+
|
84
|
+
def reindex!
|
85
|
+
update_all "search_vector = to_tsvector('english', release_changes)"
|
86
|
+
end
|
87
|
+
|
88
|
+
def search(query_string)
|
89
|
+
config = PgSearch::Configuration.new({against: "plain_text"}, self)
|
90
|
+
normalizer = PgSearch::Normalizer.new(config)
|
91
|
+
options = { dictionary: "english", tsvector_column: "search_vector" }
|
92
|
+
query = PgSearch::Features::TSearch.new(query_string, options, config.columns, self, normalizer)
|
93
|
+
|
94
|
+
excerpt = ts_headline(:release_changes, query,
|
95
|
+
start_sel: "<em>",
|
96
|
+
stop_sel: "</em>",
|
97
|
+
|
98
|
+
# Hack: show the entire value of `release_changes`
|
99
|
+
min_words: 65534,
|
100
|
+
max_words: 65535,
|
101
|
+
max_fragments: 0)
|
102
|
+
|
103
|
+
columns = (column_names - %w{release_changes search_vector}).map { |column| "releases.\"#{column}\"" }
|
104
|
+
columns.push excerpt.as("release_changes")
|
105
|
+
where(query.conditions).select(*columns)
|
106
|
+
end
|
82
107
|
end
|
83
108
|
|
84
109
|
|
@@ -183,6 +208,15 @@ class Release < ActiveRecord::Base
|
|
183
208
|
|
184
209
|
|
185
210
|
|
211
|
+
def update_search_vector
|
212
|
+
self.class.where(id: id).reindex!
|
213
|
+
end
|
214
|
+
|
215
|
+
def search_vector_should_change?
|
216
|
+
(changed & %w{release_changes}).any?
|
217
|
+
end
|
218
|
+
|
219
|
+
|
186
220
|
private
|
187
221
|
|
188
222
|
def identify_commit(sha)
|
@@ -214,4 +248,12 @@ private
|
|
214
248
|
end
|
215
249
|
end
|
216
250
|
|
251
|
+
# http://www.postgresql.org/docs/9.1/static/textsearch-controls.html#TEXTSEARCH-HEADLINE
|
252
|
+
def self.ts_headline(column, query, options={})
|
253
|
+
column = arel_table[column] if column.is_a?(Symbol)
|
254
|
+
options = options.map { |(key, value)| "#{key.to_s.camelize}=#{value}" }.join(", ")
|
255
|
+
tsquery = Arel.sql(query.send(:tsquery))
|
256
|
+
Arel::Nodes::NamedFunction.new("ts_headline", [column, Arel::Nodes.build_quoted(tsquery), Arel::Nodes.build_quoted(options)])
|
257
|
+
end
|
258
|
+
|
217
259
|
end
|