houston-core 0.6.0 → 0.6.1

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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +69 -68
  3. data/app/adapters/houston/adapters/deployment/engineyard.rb +1 -1
  4. data/app/adapters/houston/adapters/version_control/git_adapter/github_repo.rb +19 -0
  5. data/app/adapters/houston/adapters/version_control/git_adapter/remote_repo.rb +27 -0
  6. data/app/assets/images/drag-grip.png +0 -0
  7. data/app/assets/javascripts/app/infinite_scroll.coffee +1 -1
  8. data/app/assets/javascripts/app/views/_show_sprint_view.coffee +9 -9
  9. data/app/assets/javascripts/application.js +1 -0
  10. data/app/assets/javascripts/core/app.coffee +5 -0
  11. data/app/assets/javascripts/{app → core}/stacked_area_graph.coffee +0 -0
  12. data/app/assets/javascripts/{app → core}/stacked_bar_graph.coffee +0 -0
  13. data/app/assets/javascripts/dashboard.js +1 -0
  14. data/app/assets/javascripts/vendor.js +0 -1
  15. data/app/assets/stylesheets/application/exceptions.scss +3 -1
  16. data/app/assets/stylesheets/application/navigation.scss +84 -21
  17. data/app/assets/stylesheets/application/releases.scss +32 -2
  18. data/app/assets/stylesheets/application/test.scss +34 -0
  19. data/app/assets/stylesheets/core/colors.scss.erb +33 -3
  20. data/app/assets/stylesheets/dashboard/dashboard.scss +11 -7
  21. data/app/assets/stylesheets/variables.scss +3 -0
  22. data/app/concerns/belongs_to_commit.rb +14 -0
  23. data/app/concerns/project_adapter.rb +24 -6
  24. data/app/controllers/api/v1/projects_controller.rb +18 -0
  25. data/app/controllers/api/v1/sprint_tasks_controller.rb +1 -1
  26. data/app/controllers/deploys_controller.rb +1 -0
  27. data/app/controllers/project_tests_controller.rb +49 -19
  28. data/app/controllers/releases_controller.rb +5 -0
  29. data/app/controllers/test_runs_controller.rb +16 -1
  30. data/app/helpers/test_run_helper.rb +24 -0
  31. data/app/models/deploy.rb +13 -0
  32. data/app/models/github/pull_request.rb +39 -4
  33. data/app/models/release.rb +42 -0
  34. data/app/models/task.rb +3 -2
  35. data/app/models/test_run.rb +4 -0
  36. data/app/presenters/project_presenter.rb +28 -0
  37. data/app/views/deploys/show.html.erb +4 -0
  38. data/app/views/github/pulls/index.html.erb +4 -1
  39. data/app/views/layouts/_mobile_navigation.html.erb +14 -17
  40. data/app/views/layouts/_navigation.html.erb +87 -87
  41. data/app/views/layouts/dashboard.html.erb +2 -2
  42. data/app/views/project_tests/index.html.erb +22 -7
  43. data/app/views/releases/_index.html.erb +65 -0
  44. data/app/views/releases/_results.html.erb +47 -0
  45. data/app/views/releases/index.html.erb +29 -65
  46. data/app/views/sprints/dashboard.html.erb +4 -2
  47. data/config/environments/production.rb +1 -1
  48. data/config/environments/test.rb +4 -1
  49. data/config/initializers/add_navigation_renderers.rb +6 -0
  50. data/config/initializers/requirements.rb +1 -0
  51. data/config/routes.rb +3 -0
  52. data/db/migrate/20151226154901_add_search_vector_to_releases.rb +6 -0
  53. data/db/migrate/20151226155305_generate_index_on_releases.rb +5 -0
  54. data/db/migrate/20151228183704_drop_unused_tables.rb +35 -0
  55. data/db/migrate/20160120145757_add_successful_to_deploys.rb +10 -0
  56. data/db/structure.sql +19 -67
  57. data/houston.gemspec +3 -3
  58. data/lib/configuration.rb +4 -2
  59. data/lib/core_ext/array.rb +37 -0
  60. data/lib/houston/version.rb +1 -1
  61. data/test/integration/ci_integration_test.rb +14 -13
  62. data/test/unit/models/project_test.rb +33 -0
  63. data/test/unit/models/pull_request_test.rb +71 -1
  64. metadata +24 -14
  65. 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
- #{attribute_name}_adapter.errors_with_parameters(self, *parameters_for_#{attribute_name}_adapter.values).each do |attribute, messages|
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} ||= #{attribute_name}_adapter
62
- .build(self, *parameters_for_#{attribute_name}_adapter.values)
79
+ @#{attribute_name} ||= #{adapter_method}
80
+ .build(self, *#{params_method}.values)
63
81
  .extend(FeatureSupport)
64
82
  end
65
83
 
66
- def parameters_for_#{attribute_name}_adapter
67
- #{attribute_name}_adapter.parameters.each_with_object({}) do |parameter, hash|
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 #{attribute_name}_adapter
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
@@ -21,7 +21,7 @@ module Api
21
21
  def mine
22
22
  render json: sprint.tasks
23
23
  .includes(:ticket => :project)
24
- .checked_out_by(current_user)
24
+ .checked_out_by(current_user, during: sprint)
25
25
  .map { |task| present_task(task) }
26
26
  end
27
27
 
@@ -24,6 +24,7 @@ class DeploysController < ApplicationController
24
24
  branch: branch,
25
25
  deployer: deployer,
26
26
  duration: milliseconds,
27
+ successful: params.fetch(:successful, true),
27
28
  completed_at: Time.now)
28
29
 
29
30
  head 200
@@ -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, 500).to_i
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
- @runs = @project.test_runs.where(sha: @commits.map(&:sha))
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
- @tests = @project.tests.order(:suite, :name)
14
- .joins(<<-SQL)
15
- LEFT JOIN LATERAL (
16
- SELECT COUNT(*) FROM test_results
17
- WHERE test_results.test_run_id IN (#{@runs.pluck(:id).join(",")})
18
- AND test_results.status='pass'
19
- AND test_results.test_id=tests.id
20
- ) "passes" ON TRUE
21
- LEFT JOIN LATERAL (
22
- SELECT COUNT(*) FROM test_results
23
- WHERE test_results.test_run_id IN (#{@runs.pluck(:id).join(",")})
24
- AND test_results.status='fail'
25
- AND test_results.test_id=tests.id
26
- ) "fails" ON TRUE
27
- SQL
28
- .where("passes.count + fails.count > 0")
29
- .pluck("tests.id", "tests.suite", "tests.name", "passes.count", "fails.count")
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]) || (raise ActiveRecord::RecordNotFound)
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
@@ -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, autosave: false
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
@@ -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