From f5d1d4acf7b1a01064feec629fee3d3207b5914f Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 7 Jul 2016 12:48:10 +0100 Subject: [PATCH 001/133] Fixed width on project visibility icon in project list Closes #19583 --- CHANGELOG | 1 + app/views/shared/projects/_project.html.haml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index bc9bb7747a4..eb7cd7dd1f7 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -40,6 +40,7 @@ v 8.10.0 (unreleased) - Metrics for Rouge::Plugins::Redcarpet and Rouge::Formatters::HTMLGitlab - RailsCache metris now includes fetch_hit/fetch_miss and read_hit/read_miss info. - Allow [ci skip] to be in any case and allow [skip ci]. !4785 (simon_w) + - Made project list visibility icon fixed width - Set import_url validation to be more strict - Add basic system information like memory and disk usage to the admin panel - Don't garbage collect commits that have related DB records like comments diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index b8b66d08db8..92803838d02 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -24,7 +24,7 @@ = icon('star') = project.star_count %span.visibility-icon.has-tooltip{data: { container: 'body', placement: 'left' }, title: visibility_icon_description(project)} - = visibility_level_icon(project.visibility_level, fw: false) + = visibility_level_icon(project.visibility_level, fw: true) .title = link_to project_path(project), class: dom_class(project) do From 26055b16b58afd73e31f7aaacb9aaa79ba3794c2 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 5 Jul 2016 13:52:20 +0100 Subject: [PATCH 002/133] Highlight empty lines Closes #19484 --- app/assets/stylesheets/framework/highlight.scss | 10 ++++++++-- app/views/projects/blob/_editor.html.haml | 2 +- .../files/project_owner_creates_license_file_spec.rb | 4 ++-- ...ink_to_create_license_file_in_empty_project_spec.rb | 2 +- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/app/assets/stylesheets/framework/highlight.scss b/app/assets/stylesheets/framework/highlight.scss index 7cf4d4fba42..51ae9df9685 100644 --- a/app/assets/stylesheets/framework/highlight.scss +++ b/app/assets/stylesheets/framework/highlight.scss @@ -6,11 +6,11 @@ table-layout: fixed; pre { - padding: 10px; + padding: 10px 0; border: none; border-radius: 0; font-family: $monospace_font; - font-size: $code_font_size !important; + font-size: 0; line-height: $code_line_height !important; margin: 0; overflow: auto; @@ -21,12 +21,18 @@ code { font-family: $monospace_font; + font-size: 0; white-space: pre; word-wrap: normal; padding: 0; .line { display: inline-block; + width: 100%; + min-height: 19px; + padding-left: 10px; + padding-right: 10px; + font-size: $code_font_size; } } } diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml index ff379bafb26..0237e152b54 100644 --- a/app/views/projects/blob/_editor.html.haml +++ b/app/views/projects/blob/_editor.html.haml @@ -24,7 +24,7 @@ .encoding-selector = select_tag :encoding, options_for_select([ "base64", "text" ], "text"), class: 'select2' - .file-content.code + .file-editor.code %pre.js-edit-mode-pane#editor #{params[:content] || local_assigns[:blob_data]} - if local_assigns[:path] .js-edit-mode-pane#preview.hide diff --git a/spec/features/projects/files/project_owner_creates_license_file_spec.rb b/spec/features/projects/files/project_owner_creates_license_file_spec.rb index e1e105e6bbe..26f8a8fab2f 100644 --- a/spec/features/projects/files/project_owner_creates_license_file_spec.rb +++ b/spec/features/projects/files/project_owner_creates_license_file_spec.rb @@ -23,7 +23,7 @@ feature 'project owner creates a license file', feature: true, js: true do select_template('MIT License') - file_content = find('.file-content') + file_content = first('.file-editor') expect(file_content).to have_content('The MIT License (MIT)') expect(file_content).to have_content("Copyright (c) #{Time.now.year} #{project.namespace.human_name}") @@ -46,7 +46,7 @@ feature 'project owner creates a license file', feature: true, js: true do select_template('MIT License') - file_content = find('.file-content') + file_content = first('.file-editor') expect(file_content).to have_content('The MIT License (MIT)') expect(file_content).to have_content("Copyright (c) #{Time.now.year} #{project.namespace.human_name}") diff --git a/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb b/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb index 67aac25e427..bebec666eb5 100644 --- a/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb +++ b/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb @@ -22,7 +22,7 @@ feature 'project owner sees a link to create a license file in empty project', f select_template('MIT License') - file_content = find('.file-content') + file_content = first('.file-editor') expect(file_content).to have_content('The MIT License (MIT)') expect(file_content).to have_content("Copyright (c) #{Time.now.year} #{project.namespace.human_name}") From 5cf89e70b70de008d5b91e89ce015522616e96cb Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 14 Jul 2016 09:12:10 +0100 Subject: [PATCH 003/133] Uses white-space instead of setting font size to 0 --- app/assets/stylesheets/framework/highlight.scss | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/app/assets/stylesheets/framework/highlight.scss b/app/assets/stylesheets/framework/highlight.scss index 51ae9df9685..11b2a4cbf89 100644 --- a/app/assets/stylesheets/framework/highlight.scss +++ b/app/assets/stylesheets/framework/highlight.scss @@ -10,7 +10,7 @@ border: none; border-radius: 0; font-family: $monospace_font; - font-size: 0; + font-size: $code_font_size; line-height: $code_line_height !important; margin: 0; overflow: auto; @@ -21,18 +21,16 @@ code { font-family: $monospace_font; - font-size: 0; - white-space: pre; + white-space: normal; word-wrap: normal; padding: 0; .line { - display: inline-block; + display: block; width: 100%; min-height: 19px; padding-left: 10px; padding-right: 10px; - font-size: $code_font_size; } } } From cf33acb32b12cf482e0279043e8cd02131c456e6 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 19 Jul 2016 19:19:22 +0100 Subject: [PATCH 004/133] Fixed wrapping of lines on smaller viewports --- app/assets/stylesheets/framework/highlight.scss | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/assets/stylesheets/framework/highlight.scss b/app/assets/stylesheets/framework/highlight.scss index 11b2a4cbf89..07c8874bf03 100644 --- a/app/assets/stylesheets/framework/highlight.scss +++ b/app/assets/stylesheets/framework/highlight.scss @@ -20,6 +20,8 @@ border-left: 1px solid; code { + display: inline-block; + min-width: 100%; font-family: $monospace_font; white-space: normal; word-wrap: normal; @@ -31,6 +33,7 @@ min-height: 19px; padding-left: 10px; padding-right: 10px; + white-space: pre; } } } From 10b8c62b8617e4e2a648f07505701caac3abca64 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" Date: Thu, 21 Jul 2016 12:45:49 +0100 Subject: [PATCH 005/133] Added new spec descriptions and scenarios --- spec/features/merge_requests/diffs_spec.rb | 169 +++++++++++++++++++++ 1 file changed, 169 insertions(+) diff --git a/spec/features/merge_requests/diffs_spec.rb b/spec/features/merge_requests/diffs_spec.rb index c9a0059645d..9ac08b06da8 100644 --- a/spec/features/merge_requests/diffs_spec.rb +++ b/spec/features/merge_requests/diffs_spec.rb @@ -22,4 +22,173 @@ feature 'Diffs URL', js: true, feature: true do expect(page).to have_css('.diffs.tab-pane.active') end end + + context 'when hovering over the parallel view diff file' do + let(:comment_button_class) { '.add-diff-note' } + + before(:each) do + visit diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request) + click_link 'Side-by-side' + # @old_line_number = first '.diff-line-num.old_line:not(.empty-cell)' + # @new_line_number = first '.diff-line-num.new_line:not(.empty-cell)' + # @old_line = first '.line_content[data-line-type="old"]' + # @new_line = first '.line_content[data-line-type="new"]' + end + + context 'with an old line on the left and no line on the right' do + it 'should allow commenting on the left side' do + puts first('//*[contains(concat(" ", @class, " "), " line_holder ") and child::*[contains(concat(" ", @class, " "), " line_content ") and contains(concat(" ", @class, " "), " old ")] and child::*[contains(concat(" ", @class, " ")," line_content ") and contains(concat(" ", @class, " ")," new ")]]') + expect(page).to have_content 'NOPE' + end + + it 'should not allow commenting on the right side' do + + end + end + + context 'with no line on the left and a new line on the right' do + it 'should allow commenting on the right side' do + + end + + it 'should not allow commenting on the left side' do + + end + end + + context 'with an old line on the left and a new line on the right' do + it 'should allow commenting on the left side' do + + end + + it 'should allow commenting on the right side' do + + end + end + + context 'with an unchanged line on the left and an unchanged line on the right' do + it 'should allow commenting on the left side' do + + end + + it 'should allow commenting on the right side' do + + end + end + + context 'with a match line' do + it 'should not allow commenting on the left side' do + + end + + it 'should not allow commenting on the right side' do + + end + end + + # it 'shows a comment button on the old side when hovering over an old line number' do + # @old_line_number.hover + # expect(@old_line_number).to have_css comment_button_class + # expect(@new_line_number).not_to have_css comment_button_class + # end + # + # it 'shows a comment button on the old side when hovering over an old line' do + # @old_line.hover + # expect(@old_line_number).to have_css comment_button_class + # expect(@new_line_number).not_to have_css comment_button_class + # end + # + # it 'shows a comment button on the new side when hovering over a new line number' do + # @new_line_number.hover + # expect(@new_line_number).to have_css comment_button_class + # expect(@old_line_number).not_to have_css comment_button_class + # end + # + # it 'shows a comment button on the new side when hovering over a new line' do + # @new_line.hover + # expect(@new_line_number).to have_css comment_button_class + # expect(@old_line_number).not_to have_css comment_button_class + # end + end + + context 'when hovering over the inline view diff file' do + let(:comment_button_class) { '.add-diff-note' } + + before(:each) do + visit diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request) + click_link 'Inline' + # @old_line_number = first '.diff-line-num.old_line:not(.unfold)' + # @new_line_number = first '.diff-line-num.new_line:not(.unfold)' + # @new_line = first '.line_content:not(.match)' + end + + context 'with a new line' do + it 'should allow commenting' do + + end + end + + context 'with an old line' do + it 'should allow commenting' do + + end + end + + context 'with an unchanged line' do + it 'should allow commenting' do + + end + end + + context 'with a match line' do + it 'should not allow commenting' do + + end + end + + # it 'shows a comment button on the old side when hovering over an old line number' do + # @old_line_number.hover + # expect(@old_line_number).to have_css comment_button_class + # expect(@new_line_number).not_to have_css comment_button_class + # end + # + # it 'shows a comment button on the new side when hovering over a new line number' do + # @new_line_number.hover + # expect(@old_line_number).to have_css comment_button_class + # expect(@new_line_number).not_to have_css comment_button_class + # end + # + # it 'shows a comment button on the new side when hovering over a new line' do + # @new_line.hover + # expect(@old_line_number).to have_css comment_button_class + # expect(@new_line_number).not_to have_css comment_button_class + # end + end + + # context 'when clicking a comment button' do + # let(:test_note_comment) { 'this is a test note!' } + # let(:note_class) { '.new-note' } + # + # before(:each) do + # visit diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request) + # click_link 'Inline' + # first('.diff-line-num.old_line:not(.unfold)').hover + # find('.add-diff-note').click + # end + # + # it 'shows a note form' do + # expect(page).to have_css note_class + # end + # + # it 'can be submitted and viewed' do + # fill_in 'note[note]', with: test_note_comment + # click_button 'Comment' + # expect(page).to have_content test_note_comment + # end + # + # it 'can be closed' do + # find('.note-form-actions .btn-cancel').click + # expect(page).not_to have_css note_class + # end + # end end From dd79472bacd73fc3ece7129539e20d8bc78e22f1 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" Date: Fri, 22 Jul 2016 06:51:58 +0100 Subject: [PATCH 006/133] Finished up intial version that uses XPath extensively --- spec/features/merge_requests/diffs_spec.rb | 275 ++++++++++----------- 1 file changed, 129 insertions(+), 146 deletions(-) diff --git a/spec/features/merge_requests/diffs_spec.rb b/spec/features/merge_requests/diffs_spec.rb index 9ac08b06da8..ae237ad5e3f 100644 --- a/spec/features/merge_requests/diffs_spec.rb +++ b/spec/features/merge_requests/diffs_spec.rb @@ -23,172 +23,155 @@ feature 'Diffs URL', js: true, feature: true do end end - context 'when hovering over the parallel view diff file' do + context 'diff notes' do let(:comment_button_class) { '.add-diff-note' } + let(:notes_holder_input_class) { 'js-temp-notes-holder' } + let(:notes_holder_input_xpath) { './following-sibling::*[contains(concat(" ", @class, " "), " notes_holder ")]' } + let(:test_note_comment) { 'this is a test note!' } + # line_holder = //*[contains(concat(" ", @class, " "), " line_holder "] + # old_line = child::*[contains(concat(" ", @class, " "), " line_content ") and contains(concat(" ", @class, " "), " old ")] + # new_line = child::*[contains(concat(" ", @class, " ")," line_content ") and contains(concat(" ", @class, " ")," new ")] + # match_line = child::*[contains(concat(" ", @class, " ")," line_content ") and contains(concat(" ", @class, " ")," match ")] + # unchanged_line = child::*[contains(concat(" ", @class, " ")," line_content ") and not(contains(concat(" ", @class, " ")," old ")) and not(contains(concat(" ", @class, " ")," new ")) and not(contains(concat(" ", @class, " ")," match ")) and boolean(node()[1])] + # no_line = child::*[contains(concat(" ", @class, " ")," line_content ") and not(contains(concat(" ", @class, " ")," old ")) and not(contains(concat(" ", @class, " ")," new ")) and not(contains(concat(" ", @class, " ")," match ")) and not(boolean(node()[1]))] - before(:each) do - visit diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request) - click_link 'Side-by-side' - # @old_line_number = first '.diff-line-num.old_line:not(.empty-cell)' - # @new_line_number = first '.diff-line-num.new_line:not(.empty-cell)' - # @old_line = first '.line_content[data-line-type="old"]' - # @new_line = first '.line_content[data-line-type="new"]' - end - - context 'with an old line on the left and no line on the right' do - it 'should allow commenting on the left side' do - puts first('//*[contains(concat(" ", @class, " "), " line_holder ") and child::*[contains(concat(" ", @class, " "), " line_content ") and contains(concat(" ", @class, " "), " old ")] and child::*[contains(concat(" ", @class, " ")," line_content ") and contains(concat(" ", @class, " ")," new ")]]') - expect(page).to have_content 'NOPE' + context 'when hovering over the parallel view diff file' do + before(:each) do + visit diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request) + click_link 'Side-by-side' end - it 'should not allow commenting on the right side' do + context 'with an old line on the left and no line on the right' do + let(:line_holder) { first :xpath, '//*[contains(concat(" ", @class, " "), " line_holder ") and child::*[contains(concat(" ", @class, " "), " line_content ") and contains(concat(" ", @class, " "), " old ")] and child::*[contains(concat(" ", @class, " ")," line_content ") and not(contains(concat(" ", @class, " ")," old ")) and not(contains(concat(" ", @class, " ")," new ")) and not(contains(concat(" ", @class, " ")," match ")) and not(boolean(node()[1]))]]' } + it 'should allow commenting on the left side' do + should_allow_commenting line_holder, 'left' + end + + it 'should not allow commenting on the right side' do + should_not_allow_commenting line_holder, 'right' + end + end + + context 'with no line on the left and a new line on the right' do + let(:line_holder) { first :xpath, '//*[contains(concat(" ", @class, " "), " line_holder ") and child::*[contains(concat(" ", @class, " ")," line_content ") and not(contains(concat(" ", @class, " ")," old ")) and not(contains(concat(" ", @class, " ")," new ")) and not(contains(concat(" ", @class, " ")," match ")) and not(boolean(node()[1]))] and child::*[contains(concat(" ", @class, " ")," line_content ") and contains(concat(" ", @class, " ")," new ")]]' } + + it 'should not allow commenting on the left side' do + should_not_allow_commenting line_holder, 'left' + end + + it 'should allow commenting on the right side' do + should_allow_commenting line_holder, 'right' + end + end + + context 'with an old line on the left and a new line on the right' do + let(:line_holder) { first :xpath, '//*[contains(concat(" ", @class, " "), " line_holder ") and child::*[contains(concat(" ", @class, " "), " line_content ") and contains(concat(" ", @class, " "), " old ")] and child::*[contains(concat(" ", @class, " ")," line_content ") and contains(concat(" ", @class, " ")," new ")]]' } + + it 'should allow commenting on the left side' do + should_allow_commenting line_holder, 'left' + end + + it 'should allow commenting on the right side' do + should_allow_commenting line_holder, 'right' + end + end + + context 'with an unchanged line on the left and an unchanged line on the right' do + let(:line_holder) { first :xpath, '//*[contains(concat(" ", @class, " "), " line_holder ") and child::*[contains(concat(" ", @class, " ")," line_content ") and not(contains(concat(" ", @class, " ")," old ")) and not(contains(concat(" ", @class, " ")," new ")) and not(contains(concat(" ", @class, " ")," match ")) and boolean(node()[1])] and child::*[contains(concat(" ", @class, " ")," line_content ") and not(contains(concat(" ", @class, " ")," old ")) and not(contains(concat(" ", @class, " ")," new ")) and not(contains(concat(" ", @class, " ")," match ")) and boolean(node()[1])]]' } + + it 'should allow commenting on the left side' do + should_allow_commenting line_holder, 'left' + end + + it 'should allow commenting on the right side' do + should_allow_commenting line_holder, 'right' + end + end + + context 'with a match line' do + let(:line_holder) { first :xpath, '//*[contains(concat(" ", @class, " "), " line_holder ") and child::*[contains(concat(" ", @class, " "), " line_content ") and contains(concat(" ", @class, " "), " match ")] and child::*[contains(concat(" ", @class, " ")," line_content ") and contains(concat(" ", @class, " ")," match ")]]' } + + it 'should not allow commenting on the left side' do + should_not_allow_commenting line_holder, 'left' + end + + it 'should not allow commenting on the right side' do + should_not_allow_commenting line_holder, 'right' + end end end - context 'with no line on the left and a new line on the right' do - it 'should allow commenting on the right side' do + context 'when hovering over the inline view diff file' do + let(:comment_button_class) { '.add-diff-note' } + before(:each) do + visit diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request) + click_link 'Inline' end - it 'should not allow commenting on the left side' do + context 'with a new line' do + let(:line_holder) { first :xpath, '//*[contains(concat(" ", @class, " "), " line_holder ") and child::*[contains(concat(" ", @class, " "), " line_content ") and contains(concat(" ", @class, " "), " new ")]]' } + it 'should allow commenting' do + should_allow_commenting line_holder + end + end + + context 'with an old line' do + let(:line_holder) { first :xpath, '//*[contains(concat(" ", @class, " "), " line_holder ") and child::*[contains(concat(" ", @class, " "), " line_content ") and contains(concat(" ", @class, " "), " old ")]]' } + + it 'should allow commenting' do + should_allow_commenting line_holder + end + end + + context 'with an unchanged line' do + let(:line_holder) { first :xpath, '//*[contains(concat(" ", @class, " "), " line_holder ") and child::*[contains(concat(" ", @class, " ")," line_content ") and not(contains(concat(" ", @class, " ")," old ")) and not(contains(concat(" ", @class, " ")," new ")) and not(contains(concat(" ", @class, " ")," match ")) and boolean(node()[1])] and child::*[contains(concat(" ", @class, " ")," line_content ") and not(contains(concat(" ", @class, " ")," old ")) and not(contains(concat(" ", @class, " ")," new ")) and not(contains(concat(" ", @class, " ")," match ")) and boolean(node()[1])]]' } + + it 'should allow commenting' do + should_allow_commenting line_holder + end + end + + context 'with a match line' do + let(:line_holder) { first :xpath, '//*[contains(concat(" ", @class, " "), " line_holder ") and child::*[contains(concat(" ", @class, " "), " line_content ") and contains(concat(" ", @class, " "), " match ")]]' } + + it 'should not allow commenting' do + should_not_allow_commenting line_holder + end end end - context 'with an old line on the left and a new line on the right' do - it 'should allow commenting on the left side' do - - end - - it 'should allow commenting on the right side' do - - end + def should_allow_commenting(line_holder, diff_side = nil) + line = get_line diff_side + line[:content].hover + expect(line[:num]).to have_css comment_button_class + line[:num].find(comment_button_class).trigger 'click' + expect(line_holder).to have_xpath notes_holder_input_xpath + notes_holder_input = line_holder.find(:xpath, notes_holder_input_xpath) + expect(notes_holder_input[:class].include? notes_holder_input_class).to be true + notes_holder_input.fill_in 'note[note]', with: test_note_comment + click_button 'Comment' + expect(line_holder).to have_xpath notes_holder_input_xpath + notes_holder_saved = line_holder.find(:xpath, notes_holder_input_xpath) + expect(notes_holder_saved[:class].include? notes_holder_input_class).to be false + expect(notes_holder_saved).to have_content test_note_comment end - context 'with an unchanged line on the left and an unchanged line on the right' do - it 'should allow commenting on the left side' do - - end - - it 'should allow commenting on the right side' do - - end + def should_not_allow_commenting(line_holder, diff_side = nil) + line = get_line diff_side + line[:content].hover + expect(line[:num]).not_to have_css comment_button_class end - context 'with a match line' do - it 'should not allow commenting on the left side' do - - end - - it 'should not allow commenting on the right side' do - + def get_line(diff_side = nil) + if diff_side.nil? + { content: line_holder.first('.line_content'), num: line_holder.first('.diff-line-num') } + else + side_index = diff_side == 'left' ? 0 : 1 + { content: line_holder.all('.line_content')[side_index], num: line_holder.all('.diff-line-num')[side_index] } end end - - # it 'shows a comment button on the old side when hovering over an old line number' do - # @old_line_number.hover - # expect(@old_line_number).to have_css comment_button_class - # expect(@new_line_number).not_to have_css comment_button_class - # end - # - # it 'shows a comment button on the old side when hovering over an old line' do - # @old_line.hover - # expect(@old_line_number).to have_css comment_button_class - # expect(@new_line_number).not_to have_css comment_button_class - # end - # - # it 'shows a comment button on the new side when hovering over a new line number' do - # @new_line_number.hover - # expect(@new_line_number).to have_css comment_button_class - # expect(@old_line_number).not_to have_css comment_button_class - # end - # - # it 'shows a comment button on the new side when hovering over a new line' do - # @new_line.hover - # expect(@new_line_number).to have_css comment_button_class - # expect(@old_line_number).not_to have_css comment_button_class - # end end - - context 'when hovering over the inline view diff file' do - let(:comment_button_class) { '.add-diff-note' } - - before(:each) do - visit diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request) - click_link 'Inline' - # @old_line_number = first '.diff-line-num.old_line:not(.unfold)' - # @new_line_number = first '.diff-line-num.new_line:not(.unfold)' - # @new_line = first '.line_content:not(.match)' - end - - context 'with a new line' do - it 'should allow commenting' do - - end - end - - context 'with an old line' do - it 'should allow commenting' do - - end - end - - context 'with an unchanged line' do - it 'should allow commenting' do - - end - end - - context 'with a match line' do - it 'should not allow commenting' do - - end - end - - # it 'shows a comment button on the old side when hovering over an old line number' do - # @old_line_number.hover - # expect(@old_line_number).to have_css comment_button_class - # expect(@new_line_number).not_to have_css comment_button_class - # end - # - # it 'shows a comment button on the new side when hovering over a new line number' do - # @new_line_number.hover - # expect(@old_line_number).to have_css comment_button_class - # expect(@new_line_number).not_to have_css comment_button_class - # end - # - # it 'shows a comment button on the new side when hovering over a new line' do - # @new_line.hover - # expect(@old_line_number).to have_css comment_button_class - # expect(@new_line_number).not_to have_css comment_button_class - # end - end - - # context 'when clicking a comment button' do - # let(:test_note_comment) { 'this is a test note!' } - # let(:note_class) { '.new-note' } - # - # before(:each) do - # visit diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request) - # click_link 'Inline' - # first('.diff-line-num.old_line:not(.unfold)').hover - # find('.add-diff-note').click - # end - # - # it 'shows a note form' do - # expect(page).to have_css note_class - # end - # - # it 'can be submitted and viewed' do - # fill_in 'note[note]', with: test_note_comment - # click_button 'Comment' - # expect(page).to have_content test_note_comment - # end - # - # it 'can be closed' do - # find('.note-form-actions .btn-cancel').click - # expect(page).not_to have_css note_class - # end - # end end From 59ddf726a6866483c4ec9beaf27423206e3ed3e9 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" Date: Fri, 22 Jul 2016 16:48:50 +0100 Subject: [PATCH 007/133] Fixed failing tests with WaitForAjax --- spec/features/merge_requests/diffs_spec.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/spec/features/merge_requests/diffs_spec.rb b/spec/features/merge_requests/diffs_spec.rb index ae237ad5e3f..dbc50ea5265 100644 --- a/spec/features/merge_requests/diffs_spec.rb +++ b/spec/features/merge_requests/diffs_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' feature 'Diffs URL', js: true, feature: true do + include WaitForAjax + before do login_as :admin @merge_request = create(:merge_request) @@ -153,6 +155,7 @@ feature 'Diffs URL', js: true, feature: true do expect(notes_holder_input[:class].include? notes_holder_input_class).to be true notes_holder_input.fill_in 'note[note]', with: test_note_comment click_button 'Comment' + wait_for_ajax expect(line_holder).to have_xpath notes_holder_input_xpath notes_holder_saved = line_holder.find(:xpath, notes_holder_input_xpath) expect(notes_holder_saved[:class].include? notes_holder_input_class).to be false From ef5a5d0ba30709f4e146ee31441d092759c32911 Mon Sep 17 00:00:00 2001 From: Luke Bennett Date: Mon, 25 Jul 2016 04:34:49 +0100 Subject: [PATCH 008/133] Tidying up spec for new implementation of css ID selectors --- spec/features/merge_requests/diffs_spec.rb | 62 ++++++++++++++-------- 1 file changed, 39 insertions(+), 23 deletions(-) diff --git a/spec/features/merge_requests/diffs_spec.rb b/spec/features/merge_requests/diffs_spec.rb index dbc50ea5265..f5e8677561f 100644 --- a/spec/features/merge_requests/diffs_spec.rb +++ b/spec/features/merge_requests/diffs_spec.rb @@ -30,12 +30,12 @@ feature 'Diffs URL', js: true, feature: true do let(:notes_holder_input_class) { 'js-temp-notes-holder' } let(:notes_holder_input_xpath) { './following-sibling::*[contains(concat(" ", @class, " "), " notes_holder ")]' } let(:test_note_comment) { 'this is a test note!' } - # line_holder = //*[contains(concat(" ", @class, " "), " line_holder "] - # old_line = child::*[contains(concat(" ", @class, " "), " line_content ") and contains(concat(" ", @class, " "), " old ")] - # new_line = child::*[contains(concat(" ", @class, " ")," line_content ") and contains(concat(" ", @class, " ")," new ")] - # match_line = child::*[contains(concat(" ", @class, " ")," line_content ") and contains(concat(" ", @class, " ")," match ")] - # unchanged_line = child::*[contains(concat(" ", @class, " ")," line_content ") and not(contains(concat(" ", @class, " ")," old ")) and not(contains(concat(" ", @class, " ")," new ")) and not(contains(concat(" ", @class, " ")," match ")) and boolean(node()[1])] - # no_line = child::*[contains(concat(" ", @class, " ")," line_content ") and not(contains(concat(" ", @class, " ")," old ")) and not(contains(concat(" ", @class, " ")," new ")) and not(contains(concat(" ", @class, " ")," match ")) and not(boolean(node()[1]))] + # line_holder = + # old_line = + # new_line = + # match_line = + # unchanged_line = + # no_line = context 'when hovering over the parallel view diff file' do before(:each) do @@ -146,35 +146,51 @@ feature 'Diffs URL', js: true, feature: true do end def should_allow_commenting(line_holder, diff_side = nil) - line = get_line diff_side + line = get_line_components line_holder, diff_side line[:content].hover expect(line[:num]).to have_css comment_button_class + comment_on_line line_holder, line + wait_for_ajax + assert_comment_persistence + end + + def should_not_allow_commenting(line_holder, diff_side = nil) + line = get_line_components line_holder, diff_side + line[:content].hover + expect(line[:num]).not_to have_css comment_button_class + end + + def get_line_components(line_holder, diff_side = nil) + if diff_side.nil? + get_inline_line_components line_holder + else + get_parallel_line_components line_holder, diff_side + end + end + + def get_inline_line_components(line_holder) + { content: line_holder.first('.line_content'), num: line_holder.first('.diff-line-num') } + end + + def get_parallel_line_components(line_holder, diff_side = nil) + side_index = diff_side == 'left' ? 0 : 1 + { content: line_holder.all('.line_content')[side_index], num: line_holder.all('.diff-line-num')[side_index] } + end + + def comment_on_line(line_holder, line) line[:num].find(comment_button_class).trigger 'click' expect(line_holder).to have_xpath notes_holder_input_xpath notes_holder_input = line_holder.find(:xpath, notes_holder_input_xpath) expect(notes_holder_input[:class].include? notes_holder_input_class).to be true notes_holder_input.fill_in 'note[note]', with: test_note_comment click_button 'Comment' - wait_for_ajax + end + + def assert_comment_persistence(line_holder) expect(line_holder).to have_xpath notes_holder_input_xpath notes_holder_saved = line_holder.find(:xpath, notes_holder_input_xpath) expect(notes_holder_saved[:class].include? notes_holder_input_class).to be false expect(notes_holder_saved).to have_content test_note_comment end - - def should_not_allow_commenting(line_holder, diff_side = nil) - line = get_line diff_side - line[:content].hover - expect(line[:num]).not_to have_css comment_button_class - end - - def get_line(diff_side = nil) - if diff_side.nil? - { content: line_holder.first('.line_content'), num: line_holder.first('.diff-line-num') } - else - side_index = diff_side == 'left' ? 0 : 1 - { content: line_holder.all('.line_content')[side_index], num: line_holder.all('.diff-line-num')[side_index] } - end - end end end From f9806bdef291113d6112769093020af3dcb1000c Mon Sep 17 00:00:00 2001 From: Luke Bennett Date: Mon, 25 Jul 2016 05:52:26 +0100 Subject: [PATCH 009/133] Finished css replacement of xpath selectors and tidying up spec --- spec/features/merge_requests/diffs_spec.rb | 31 +++++++++++----------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/spec/features/merge_requests/diffs_spec.rb b/spec/features/merge_requests/diffs_spec.rb index f5e8677561f..d93fc5e84ee 100644 --- a/spec/features/merge_requests/diffs_spec.rb +++ b/spec/features/merge_requests/diffs_spec.rb @@ -30,12 +30,6 @@ feature 'Diffs URL', js: true, feature: true do let(:notes_holder_input_class) { 'js-temp-notes-holder' } let(:notes_holder_input_xpath) { './following-sibling::*[contains(concat(" ", @class, " "), " notes_holder ")]' } let(:test_note_comment) { 'this is a test note!' } - # line_holder = - # old_line = - # new_line = - # match_line = - # unchanged_line = - # no_line = context 'when hovering over the parallel view diff file' do before(:each) do @@ -44,7 +38,7 @@ feature 'Diffs URL', js: true, feature: true do end context 'with an old line on the left and no line on the right' do - let(:line_holder) { first :xpath, '//*[contains(concat(" ", @class, " "), " line_holder ") and child::*[contains(concat(" ", @class, " "), " line_content ") and contains(concat(" ", @class, " "), " old ")] and child::*[contains(concat(" ", @class, " ")," line_content ") and not(contains(concat(" ", @class, " ")," old ")) and not(contains(concat(" ", @class, " ")," new ")) and not(contains(concat(" ", @class, " ")," match ")) and not(boolean(node()[1]))]]' } + let(:line_holder) { find('[id="6eb14e00385d2fb284765eb1cd8d420d33d63fc9_23_22"]').find(:xpath, '..') } it 'should allow commenting on the left side' do should_allow_commenting line_holder, 'left' @@ -56,7 +50,7 @@ feature 'Diffs URL', js: true, feature: true do end context 'with no line on the left and a new line on the right' do - let(:line_holder) { first :xpath, '//*[contains(concat(" ", @class, " "), " line_holder ") and child::*[contains(concat(" ", @class, " ")," line_content ") and not(contains(concat(" ", @class, " ")," old ")) and not(contains(concat(" ", @class, " ")," new ")) and not(contains(concat(" ", @class, " ")," match ")) and not(boolean(node()[1]))] and child::*[contains(concat(" ", @class, " ")," line_content ") and contains(concat(" ", @class, " ")," new ")]]' } + let(:line_holder) { find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_15_15"]').find(:xpath, '..') } it 'should not allow commenting on the left side' do should_not_allow_commenting line_holder, 'left' @@ -68,7 +62,7 @@ feature 'Diffs URL', js: true, feature: true do end context 'with an old line on the left and a new line on the right' do - let(:line_holder) { first :xpath, '//*[contains(concat(" ", @class, " "), " line_holder ") and child::*[contains(concat(" ", @class, " "), " line_content ") and contains(concat(" ", @class, " "), " old ")] and child::*[contains(concat(" ", @class, " ")," line_content ") and contains(concat(" ", @class, " ")," new ")]]' } + let(:line_holder) { find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_9_9"]').find(:xpath, '..') } it 'should allow commenting on the left side' do should_allow_commenting line_holder, 'left' @@ -80,7 +74,7 @@ feature 'Diffs URL', js: true, feature: true do end context 'with an unchanged line on the left and an unchanged line on the right' do - let(:line_holder) { first :xpath, '//*[contains(concat(" ", @class, " "), " line_holder ") and child::*[contains(concat(" ", @class, " ")," line_content ") and not(contains(concat(" ", @class, " ")," old ")) and not(contains(concat(" ", @class, " ")," new ")) and not(contains(concat(" ", @class, " ")," match ")) and boolean(node()[1])] and child::*[contains(concat(" ", @class, " ")," line_content ") and not(contains(concat(" ", @class, " ")," old ")) and not(contains(concat(" ", @class, " ")," new ")) and not(contains(concat(" ", @class, " ")," match ")) and boolean(node()[1])]]' } + let(:line_holder) { first('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_7_7"]').find(:xpath, '..') } it 'should allow commenting on the left side' do should_allow_commenting line_holder, 'left' @@ -92,7 +86,7 @@ feature 'Diffs URL', js: true, feature: true do end context 'with a match line' do - let(:line_holder) { first :xpath, '//*[contains(concat(" ", @class, " "), " line_holder ") and child::*[contains(concat(" ", @class, " "), " line_content ") and contains(concat(" ", @class, " "), " match ")] and child::*[contains(concat(" ", @class, " ")," line_content ") and contains(concat(" ", @class, " ")," match ")]]' } + let(:line_holder) { first('.match').find(:xpath, '..') } it 'should not allow commenting on the left side' do should_not_allow_commenting line_holder, 'left' @@ -113,7 +107,7 @@ feature 'Diffs URL', js: true, feature: true do end context 'with a new line' do - let(:line_holder) { first :xpath, '//*[contains(concat(" ", @class, " "), " line_holder ") and child::*[contains(concat(" ", @class, " "), " line_content ") and contains(concat(" ", @class, " "), " new ")]]' } + let(:line_holder) { find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]') } it 'should allow commenting' do should_allow_commenting line_holder @@ -121,7 +115,7 @@ feature 'Diffs URL', js: true, feature: true do end context 'with an old line' do - let(:line_holder) { first :xpath, '//*[contains(concat(" ", @class, " "), " line_holder ") and child::*[contains(concat(" ", @class, " "), " line_content ") and contains(concat(" ", @class, " "), " old ")]]' } + let(:line_holder) { find('[id="6eb14e00385d2fb284765eb1cd8d420d33d63fc9_22_22"]') } it 'should allow commenting' do should_allow_commenting line_holder @@ -129,7 +123,7 @@ feature 'Diffs URL', js: true, feature: true do end context 'with an unchanged line' do - let(:line_holder) { first :xpath, '//*[contains(concat(" ", @class, " "), " line_holder ") and child::*[contains(concat(" ", @class, " ")," line_content ") and not(contains(concat(" ", @class, " ")," old ")) and not(contains(concat(" ", @class, " ")," new ")) and not(contains(concat(" ", @class, " ")," match ")) and boolean(node()[1])] and child::*[contains(concat(" ", @class, " ")," line_content ") and not(contains(concat(" ", @class, " ")," old ")) and not(contains(concat(" ", @class, " ")," new ")) and not(contains(concat(" ", @class, " ")," match ")) and boolean(node()[1])]]' } + let(:line_holder) { find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_7_7"]') } it 'should allow commenting' do should_allow_commenting line_holder @@ -137,7 +131,7 @@ feature 'Diffs URL', js: true, feature: true do end context 'with a match line' do - let(:line_holder) { first :xpath, '//*[contains(concat(" ", @class, " "), " line_holder ") and child::*[contains(concat(" ", @class, " "), " line_content ") and contains(concat(" ", @class, " "), " match ")]]' } + let(:line_holder) { first('.match') } it 'should not allow commenting' do should_not_allow_commenting line_holder @@ -149,9 +143,11 @@ feature 'Diffs URL', js: true, feature: true do line = get_line_components line_holder, diff_side line[:content].hover expect(line[:num]).to have_css comment_button_class + comment_on_line line_holder, line wait_for_ajax - assert_comment_persistence + + assert_comment_persistence line_holder end def should_not_allow_commenting(line_holder, diff_side = nil) @@ -180,14 +176,17 @@ feature 'Diffs URL', js: true, feature: true do def comment_on_line(line_holder, line) line[:num].find(comment_button_class).trigger 'click' expect(line_holder).to have_xpath notes_holder_input_xpath + notes_holder_input = line_holder.find(:xpath, notes_holder_input_xpath) expect(notes_holder_input[:class].include? notes_holder_input_class).to be true + notes_holder_input.fill_in 'note[note]', with: test_note_comment click_button 'Comment' end def assert_comment_persistence(line_holder) expect(line_holder).to have_xpath notes_holder_input_xpath + notes_holder_saved = line_holder.find(:xpath, notes_holder_input_xpath) expect(notes_holder_saved[:class].include? notes_holder_input_class).to be false expect(notes_holder_saved).to have_content test_note_comment From 020ea32e767b9ad033f9fedcaa902865a01fa944 Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Tue, 2 Aug 2016 18:06:31 +0800 Subject: [PATCH 010/133] Implement pipeline hooks, extracted from !5525 Closes #20115 --- CHANGELOG | 1 + app/controllers/concerns/service_params.rb | 15 ++--- app/controllers/projects/hooks_controller.rb | 1 + app/models/ci/pipeline.rb | 10 ++- app/models/hooks/project_hook.rb | 1 + app/models/hooks/web_hook.rb | 1 + app/models/service.rb | 5 ++ app/services/ci/create_pipeline_service.rb | 1 + .../projects/hooks/_project_hook.html.haml | 2 +- app/views/shared/web_hooks/_form.html.haml | 7 ++ ...081025_add_pipeline_events_to_web_hooks.rb | 16 +++++ ...8103734_add_pipeline_events_to_services.rb | 16 +++++ lib/api/entities.rb | 6 +- lib/api/project_hooks.rb | 2 + .../data_builder/pipeline_data_builder.rb | 66 +++++++++++++++++++ .../pipeline_data_builder_spec.rb | 32 +++++++++ spec/models/build_spec.rb | 6 +- spec/models/ci/pipeline_spec.rb | 33 +++++++++- spec/requests/api/project_hooks_spec.rb | 7 +- 19 files changed, 210 insertions(+), 18 deletions(-) create mode 100644 db/migrate/20160728081025_add_pipeline_events_to_web_hooks.rb create mode 100644 db/migrate/20160728103734_add_pipeline_events_to_services.rb create mode 100644 lib/gitlab/data_builder/pipeline_data_builder.rb create mode 100644 spec/lib/gitlab/data_builder/pipeline_data_builder_spec.rb diff --git a/CHANGELOG b/CHANGELOG index c099c63ce86..3199e66a0d9 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -28,6 +28,7 @@ v 8.11.0 (unreleased) - The overhead of instrumented method calls has been reduced - Remove `search_id` of labels dropdown filter to fix 'Missleading URI for labels in Merge Requests and Issues view'. !5368 (Scott Le) - Load project invited groups and members eagerly in `ProjectTeam#fetch_members` + - Add pipeline events hook - Bump gitlab_git to speedup DiffCollection iterations - Make branches sortable without push permission !5462 (winniehell) - Check for Ci::Build artifacts at database level on pipeline partial diff --git a/app/controllers/concerns/service_params.rb b/app/controllers/concerns/service_params.rb index 471d15af913..58877c5ad5d 100644 --- a/app/controllers/concerns/service_params.rb +++ b/app/controllers/concerns/service_params.rb @@ -7,11 +7,12 @@ module ServiceParams :build_key, :server, :teamcity_url, :drone_url, :build_type, :description, :issues_url, :new_issue_url, :restrict_to_branch, :channel, :colorize_messages, :channels, - :push_events, :issues_events, :merge_requests_events, :tag_push_events, - :note_events, :build_events, :wiki_page_events, - :notify_only_broken_builds, :add_pusher, - :send_from_committer_email, :disable_diffs, :external_wiki_url, - :notify, :color, + # See app/helpers/services_helper.rb + # for why we need issues_events and merge_requests_events. + :issues_events, :merge_requests_events, + :notify_only_broken_builds, :notify_only_broken_pipelines, + :add_pusher, :send_from_committer_email, :disable_diffs, + :external_wiki_url, :notify, :color, :server_host, :server_port, :default_irc_uri, :enable_ssl_verification, :jira_issue_transition_id] @@ -19,9 +20,7 @@ module ServiceParams FILTER_BLANK_PARAMS = [:password] def service_params - dynamic_params = [] - dynamic_params.concat(@service.event_channel_names) - + dynamic_params = @service.event_channel_names + @service.event_names service_params = params.permit(:id, service: ALLOWED_PARAMS + dynamic_params) if service_params[:service].is_a?(Hash) diff --git a/app/controllers/projects/hooks_controller.rb b/app/controllers/projects/hooks_controller.rb index a60027ff477..b5624046387 100644 --- a/app/controllers/projects/hooks_controller.rb +++ b/app/controllers/projects/hooks_controller.rb @@ -56,6 +56,7 @@ class Projects::HooksController < Projects::ApplicationController def hook_params params.require(:hook).permit( :build_events, + :pipeline_events, :enable_ssl_verification, :issues_events, :merge_requests_events, diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index bce6a992af6..4e6ccf48c68 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -237,7 +237,15 @@ module Ci self.started_at = statuses.started_at self.finished_at = statuses.finished_at self.duration = statuses.latest.duration - save + saved = save + execute_hooks if saved && !skip_ci? + saved + end + + def execute_hooks + pipeline_data = Gitlab::DataBuilder::PipelineDataBuilder.build(self) + project.execute_hooks(pipeline_data, :pipeline_hooks) + project.execute_services(pipeline_data.dup, :pipeline_hooks) end def keep_around_commits diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb index ba42a8eeb70..836a75b0608 100644 --- a/app/models/hooks/project_hook.rb +++ b/app/models/hooks/project_hook.rb @@ -5,5 +5,6 @@ class ProjectHook < WebHook scope :note_hooks, -> { where(note_events: true) } scope :merge_request_hooks, -> { where(merge_requests_events: true) } scope :build_hooks, -> { where(build_events: true) } + scope :pipeline_hooks, -> { where(pipeline_events: true) } scope :wiki_page_hooks, -> { where(wiki_page_events: true) } end diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb index 8b87b6c3d64..f365dee3141 100644 --- a/app/models/hooks/web_hook.rb +++ b/app/models/hooks/web_hook.rb @@ -8,6 +8,7 @@ class WebHook < ActiveRecord::Base default_value_for :merge_requests_events, false default_value_for :tag_push_events, false default_value_for :build_events, false + default_value_for :pipeline_events, false default_value_for :enable_ssl_verification, true scope :push_hooks, -> { where(push_events: true) } diff --git a/app/models/service.rb b/app/models/service.rb index 40cd9b861f0..e4cd44f542a 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -36,6 +36,7 @@ class Service < ActiveRecord::Base scope :merge_request_hooks, -> { where(merge_requests_events: true, active: true) } scope :note_hooks, -> { where(note_events: true, active: true) } scope :build_hooks, -> { where(build_events: true, active: true) } + scope :pipeline_hooks, -> { where(pipeline_events: true, active: true) } scope :wiki_page_hooks, -> { where(wiki_page_events: true, active: true) } scope :external_issue_trackers, -> { issue_trackers.active.without_defaults } @@ -86,6 +87,10 @@ class Service < ActiveRecord::Base [] end + def event_names + supported_events.map { |event| "#{event}_events" } + end + def event_field(event) nil end diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index be91bf0db85..7a8b0683acb 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -27,6 +27,7 @@ module Ci end pipeline.save! + pipeline.touch unless pipeline.create_builds(current_user) pipeline.errors.add(:base, 'No builds for this pipeline.') diff --git a/app/views/projects/hooks/_project_hook.html.haml b/app/views/projects/hooks/_project_hook.html.haml index 8151187d499..3fcf1692e09 100644 --- a/app/views/projects/hooks/_project_hook.html.haml +++ b/app/views/projects/hooks/_project_hook.html.haml @@ -3,7 +3,7 @@ .col-md-8.col-lg-7 %strong.light-header= hook.url %div - - %w(push_events tag_push_events issues_events note_events merge_requests_events build_events wiki_page_events).each do |trigger| + - %w(push_events tag_push_events issues_events note_events merge_requests_events build_events pipeline_events wiki_page_events).each do |trigger| - if hook.send(trigger) %span.label.label-gray.deploy-project-label= trigger.titleize .col-md-4.col-lg-5.text-right-lg.prepend-top-5 diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml index 2585ed9360b..106161d6515 100644 --- a/app/views/shared/web_hooks/_form.html.haml +++ b/app/views/shared/web_hooks/_form.html.haml @@ -65,6 +65,13 @@ %strong Build events %p.light This url will be triggered when the build status changes + %li + = f.check_box :pipeline_events, class: 'pull-left' + .prepend-left-20 + = f.label :pipeline_events, class: 'list-label' do + %strong Pipeline events + %p.light + This url will be triggered when the pipeline status changes %li = f.check_box :wiki_page_events, class: 'pull-left' .prepend-left-20 diff --git a/db/migrate/20160728081025_add_pipeline_events_to_web_hooks.rb b/db/migrate/20160728081025_add_pipeline_events_to_web_hooks.rb new file mode 100644 index 00000000000..b800e6d7283 --- /dev/null +++ b/db/migrate/20160728081025_add_pipeline_events_to_web_hooks.rb @@ -0,0 +1,16 @@ +class AddPipelineEventsToWebHooks < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_column_with_default(:web_hooks, :pipeline_events, :boolean, + default: false, allow_null: false) + end + + def down + remove_column(:web_hooks, :pipeline_events) + end +end diff --git a/db/migrate/20160728103734_add_pipeline_events_to_services.rb b/db/migrate/20160728103734_add_pipeline_events_to_services.rb new file mode 100644 index 00000000000..bcd24fe1566 --- /dev/null +++ b/db/migrate/20160728103734_add_pipeline_events_to_services.rb @@ -0,0 +1,16 @@ +class AddPipelineEventsToServices < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_column_with_default(:services, :pipeline_events, :boolean, + default: false, allow_null: false) + end + + def down + remove_column(:services, :pipeline_events) + end +end diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 3e21b7a0b8a..b6f6b11d97b 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -48,7 +48,8 @@ module API class ProjectHook < Hook expose :project_id, :push_events - expose :issues_events, :merge_requests_events, :tag_push_events, :note_events, :build_events + expose :issues_events, :merge_requests_events, :tag_push_events + expose :note_events, :build_events, :pipeline_events expose :enable_ssl_verification end @@ -342,7 +343,8 @@ module API class ProjectService < Grape::Entity expose :id, :title, :created_at, :updated_at, :active - expose :push_events, :issues_events, :merge_requests_events, :tag_push_events, :note_events, :build_events + expose :push_events, :issues_events, :merge_requests_events + expose :tag_push_events, :note_events, :build_events, :pipeline_events # Expose serialized properties expose :properties do |service, options| field_names = service.fields. diff --git a/lib/api/project_hooks.rb b/lib/api/project_hooks.rb index 6bb70bc8bc3..3f63cd678e8 100644 --- a/lib/api/project_hooks.rb +++ b/lib/api/project_hooks.rb @@ -45,6 +45,7 @@ module API :tag_push_events, :note_events, :build_events, + :pipeline_events, :enable_ssl_verification ] @hook = user_project.hooks.new(attrs) @@ -78,6 +79,7 @@ module API :tag_push_events, :note_events, :build_events, + :pipeline_events, :enable_ssl_verification ] diff --git a/lib/gitlab/data_builder/pipeline_data_builder.rb b/lib/gitlab/data_builder/pipeline_data_builder.rb new file mode 100644 index 00000000000..13417ba09eb --- /dev/null +++ b/lib/gitlab/data_builder/pipeline_data_builder.rb @@ -0,0 +1,66 @@ +module Gitlab + module DataBuilder + module PipelineDataBuilder + module_function + + def build(pipeline) + { + object_kind: 'pipeline', + object_attributes: hook_attrs(pipeline), + user: pipeline.user.try(:hook_attrs), + project: pipeline.project.hook_attrs(backward: false), + commit: pipeline.commit.try(:hook_attrs), + builds: pipeline.builds.map(&method(:build_hook_attrs)) + } + end + + def hook_attrs(pipeline) + first_pending_build = pipeline.builds.first_pending + config_processor = pipeline.config_processor + + { + id: pipeline.id, + ref: pipeline.ref, + tag: pipeline.tag, + sha: pipeline.sha, + before_sha: pipeline.before_sha, + status: pipeline.status, + stage: first_pending_build.try(:stage), + stages: config_processor.try(:stages), + created_at: pipeline.created_at, + finished_at: pipeline.finished_at, + duration: pipeline.duration + } + end + + def build_hook_attrs(build) + { + id: build.id, + stage: build.stage, + name: build.name, + status: build.status, + created_at: build.created_at, + started_at: build.started_at, + finished_at: build.finished_at, + when: build.when, + manual: build.manual?, + user: build.user.try(:hook_attrs), + runner: build.runner && runner_hook_attrs(build.runner), + artifacts_file: { + filename: build.artifacts_file.filename, + size: build.artifacts_size + } + } + end + + def runner_hook_attrs(runner) + { + id: runner.id, + description: runner.description, + active: runner.active?, + is_shared: runner.is_shared? + } + end + end + end +end diff --git a/spec/lib/gitlab/data_builder/pipeline_data_builder_spec.rb b/spec/lib/gitlab/data_builder/pipeline_data_builder_spec.rb new file mode 100644 index 00000000000..24d39b318c0 --- /dev/null +++ b/spec/lib/gitlab/data_builder/pipeline_data_builder_spec.rb @@ -0,0 +1,32 @@ +require 'spec_helper' + +describe Gitlab::DataBuilder::PipelineDataBuilder do + let(:user) { create(:user) } + let(:project) { create(:project) } + let(:pipeline) do + create(:ci_pipeline, + project: project, status: 'success', + sha: project.commit.sha, ref: project.default_branch) + end + let!(:build) { create(:ci_build, pipeline: pipeline) } + + describe '.build' do + let(:data) { Gitlab::DataBuilder::PipelineDataBuilder.build(pipeline) } + let(:attributes) { data[:object_attributes] } + let(:build_data) { data[:builds].first } + let(:project_data) { data[:project] } + + it { expect(attributes).to be_a(Hash) } + it { expect(attributes[:ref]).to eq(pipeline.ref) } + it { expect(attributes[:sha]).to eq(pipeline.sha) } + it { expect(attributes[:tag]).to eq(pipeline.tag) } + it { expect(attributes[:id]).to eq(pipeline.id) } + it { expect(attributes[:status]).to eq(pipeline.status) } + + it { expect(build_data).to be_a(Hash) } + it { expect(build_data[:id]).to eq(build.id) } + it { expect(build_data[:status]).to eq(build.status) } + + it { expect(project_data).to eq(project.hook_attrs(backward: false)) } + end +end diff --git a/spec/models/build_spec.rb b/spec/models/build_spec.rb index dc88697199b..47c489e6af1 100644 --- a/spec/models/build_spec.rb +++ b/spec/models/build_spec.rb @@ -275,7 +275,8 @@ describe Ci::Build, models: true do context 'when yaml_variables are undefined' do before do - build.yaml_variables = nil + build.update(yaml_variables: nil) + build.reload # reload pipeline so that it resets config_processor end context 'use from gitlab-ci.yml' do @@ -854,7 +855,8 @@ describe Ci::Build, models: true do context 'if is undefined' do before do - build.when = nil + build.update(when: nil) + build.reload # reload pipeline so that it resets config_processor end context 'use from gitlab-ci.yml' do diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 0d4c86955ce..aa05fc78f94 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -513,7 +513,7 @@ describe Ci::Pipeline, models: true do create :ci_build, :success, pipeline: pipeline, name: 'rspec' create :ci_build, :allowed_to_fail, :failed, pipeline: pipeline, name: 'rubocop' end - + it 'returns true' do is_expected.to be_truthy end @@ -524,7 +524,7 @@ describe Ci::Pipeline, models: true do create :ci_build, :success, pipeline: pipeline, name: 'rspec' create :ci_build, :allowed_to_fail, :success, pipeline: pipeline, name: 'rubocop' end - + it 'returns false' do is_expected.to be_falsey end @@ -542,4 +542,33 @@ describe Ci::Pipeline, models: true do end end end + + describe '#execute_hooks' do + let!(:hook) do + create(:project_hook, project: project, pipeline_events: enabled) + end + let(:enabled) { raise NotImplementedError } + + before do + WebMock.stub_request(:post, hook.url) + pipeline.touch + ProjectWebHookWorker.drain + end + + context 'with pipeline hooks enabled' do + let(:enabled) { true } + + it 'executes pipeline_hook after touched' do + expect(WebMock).to have_requested(:post, hook.url).once + end + end + + context 'with pipeline hooks disabled' do + let(:enabled) { false } + + it 'did not execute pipeline_hook after touched' do + expect(WebMock).not_to have_requested(:post, hook.url) + end + end + end end diff --git a/spec/requests/api/project_hooks_spec.rb b/spec/requests/api/project_hooks_spec.rb index fd1fffa6223..504deed81f9 100644 --- a/spec/requests/api/project_hooks_spec.rb +++ b/spec/requests/api/project_hooks_spec.rb @@ -8,8 +8,9 @@ describe API::API, 'ProjectHooks', api: true do let!(:hook) do create(:project_hook, project: project, url: "http://example.com", - push_events: true, merge_requests_events: true, tag_push_events: true, - issues_events: true, note_events: true, build_events: true, + push_events: true, merge_requests_events: true, + tag_push_events: true, issues_events: true, note_events: true, + build_events: true, pipeline_events: true, enable_ssl_verification: true) end @@ -33,6 +34,7 @@ describe API::API, 'ProjectHooks', api: true do expect(json_response.first['tag_push_events']).to eq(true) expect(json_response.first['note_events']).to eq(true) expect(json_response.first['build_events']).to eq(true) + expect(json_response.first['pipeline_events']).to eq(true) expect(json_response.first['enable_ssl_verification']).to eq(true) end end @@ -91,6 +93,7 @@ describe API::API, 'ProjectHooks', api: true do expect(json_response['tag_push_events']).to eq(false) expect(json_response['note_events']).to eq(false) expect(json_response['build_events']).to eq(false) + expect(json_response['pipeline_events']).to eq(false) expect(json_response['enable_ssl_verification']).to eq(true) end From 62f115dd25c4d3639dceac1b3b81c9fe42eeedd3 Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Wed, 3 Aug 2016 14:35:11 +0800 Subject: [PATCH 011/133] Introduce execute_hooks_unless_ci_skipped --- app/models/ci/pipeline.rb | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 4e6ccf48c68..f8506e33295 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -18,6 +18,7 @@ module Ci # Invalidate object and save if when touched after_touch :update_state + after_touch :execute_hooks_unless_ci_skipped after_save :keep_around_commits # ref can't be HEAD or SHA, can only be branch/tag name @@ -237,9 +238,11 @@ module Ci self.started_at = statuses.started_at self.finished_at = statuses.finished_at self.duration = statuses.latest.duration - saved = save - execute_hooks if saved && !skip_ci? - saved + save + end + + def execute_hooks_unless_ci_skipped + execute_hooks unless skip_ci? end def execute_hooks From 853b0dffe787aa185d299ac1868619a231b6965b Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Wed, 3 Aug 2016 17:24:59 +0800 Subject: [PATCH 012/133] Use when instead of if, feedback: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5620/diffs#note_13540211 --- spec/models/build_spec.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/spec/models/build_spec.rb b/spec/models/build_spec.rb index 47c489e6af1..1837986e0f4 100644 --- a/spec/models/build_spec.rb +++ b/spec/models/build_spec.rb @@ -853,7 +853,7 @@ describe Ci::Build, models: true do describe '#when' do subject { build.when } - context 'if is undefined' do + context 'when `when` is undefined' do before do build.update(when: nil) build.reload # reload pipeline so that it resets config_processor @@ -864,13 +864,13 @@ describe Ci::Build, models: true do stub_ci_pipeline_yaml_file(config) end - context 'if config is not found' do + context 'when config is not found' do let(:config) { nil } it { is_expected.to eq('on_success') } end - context 'if config does not have a questioned job' do + context 'when config does not have a questioned job' do let(:config) do YAML.dump({ test_other: { @@ -882,7 +882,7 @@ describe Ci::Build, models: true do it { is_expected.to eq('on_success') } end - context 'if config has when' do + context 'when config has when' do let(:config) do YAML.dump({ test: { From db123d29e73419e9a3e3fc3afe03fd626ae9d776 Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Wed, 3 Aug 2016 17:33:34 +0800 Subject: [PATCH 013/133] More descriptive comments, feedback: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5620#note_13539571 --- app/controllers/concerns/service_params.rb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/controllers/concerns/service_params.rb b/app/controllers/concerns/service_params.rb index 58877c5ad5d..a69877edfd4 100644 --- a/app/controllers/concerns/service_params.rb +++ b/app/controllers/concerns/service_params.rb @@ -7,8 +7,12 @@ module ServiceParams :build_key, :server, :teamcity_url, :drone_url, :build_type, :description, :issues_url, :new_issue_url, :restrict_to_branch, :channel, :colorize_messages, :channels, - # See app/helpers/services_helper.rb - # for why we need issues_events and merge_requests_events. + # We're using `issues_events` and `merge_requests_events` + # in the view so we still need to explicitly state them + # here. `Service#event_names` would only give + # `issue_events` and `merge_request_events` (singular!) + # See app/helpers/services_helper.rb for how we + # make those event names plural as special case. :issues_events, :merge_requests_events, :notify_only_broken_builds, :notify_only_broken_pipelines, :add_pusher, :send_from_committer_email, :disable_diffs, From 3691a391524911a21d5af1c75cb4cd16a8a6e475 Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Wed, 3 Aug 2016 23:06:28 +0800 Subject: [PATCH 014/133] We don't have to touch it because builds would touch pipeline anyway --- app/services/ci/create_pipeline_service.rb | 1 - app/services/create_commit_builds_service.rb | 10 ---------- 2 files changed, 11 deletions(-) diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index 7a8b0683acb..be91bf0db85 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -27,7 +27,6 @@ module Ci end pipeline.save! - pipeline.touch unless pipeline.create_builds(current_user) pipeline.errors.add(:base, 'No builds for this pipeline.') diff --git a/app/services/create_commit_builds_service.rb b/app/services/create_commit_builds_service.rb index 0b66b854dea..5e77768abe7 100644 --- a/app/services/create_commit_builds_service.rb +++ b/app/services/create_commit_builds_service.rb @@ -53,17 +53,7 @@ class CreateCommitBuildsService return false end - save_pipeline! - end - - private - - ## - # Create a new pipeline and touch object to calculate status - # - def save_pipeline! @pipeline.save! - @pipeline.touch @pipeline end end From 9e06bde269b80b3af01b7cc00bdada1ce2e5e563 Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Wed, 3 Aug 2016 23:39:14 +0800 Subject: [PATCH 015/133] Make sure we only fire hooks upon status changed --- app/models/ci/pipeline.rb | 6 +++--- spec/models/ci/pipeline_spec.rb | 22 +++++++++++++++++++++- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index f8506e33295..822ba7b6c00 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -18,7 +18,7 @@ module Ci # Invalidate object and save if when touched after_touch :update_state - after_touch :execute_hooks_unless_ci_skipped + after_save :execute_hooks_if_status_changed after_save :keep_around_commits # ref can't be HEAD or SHA, can only be branch/tag name @@ -241,8 +241,8 @@ module Ci save end - def execute_hooks_unless_ci_skipped - execute_hooks unless skip_ci? + def execute_hooks_if_status_changed + execute_hooks if status_changed? && !skip_ci? end def execute_hooks diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index aa05fc78f94..a8c49daf9bb 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -551,7 +551,7 @@ describe Ci::Pipeline, models: true do before do WebMock.stub_request(:post, hook.url) - pipeline.touch + pipeline.save ProjectWebHookWorker.drain end @@ -561,6 +561,26 @@ describe Ci::Pipeline, models: true do it 'executes pipeline_hook after touched' do expect(WebMock).to have_requested(:post, hook.url).once end + + context 'with multiple builds' do + def create_build(name) + create(:ci_build, :pending, pipeline: pipeline, name: name) + end + + let(:build_a) { create_build('a') } + let(:build_b) { create_build('b') } + + before do + build_a.run + build_b.run + build_a.success + build_b.success + end + + it 'fires 3 hooks' do + expect(WebMock).to have_requested(:post, hook.url).times(3) + end + end end context 'with pipeline hooks disabled' do From 77540619d5ce0e63edec89ea3d406fa457696a0e Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Wed, 3 Aug 2016 23:54:44 +0800 Subject: [PATCH 016/133] Test against the status in the payload --- spec/models/ci/pipeline_spec.rb | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index a8c49daf9bb..b0496a017f4 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -567,6 +567,12 @@ describe Ci::Pipeline, models: true do create(:ci_build, :pending, pipeline: pipeline, name: name) end + def requested status + have_requested(:post, hook.url).with do |req| + JSON.parse(req.body)['object_attributes']['status'] == status + end.once + end + let(:build_a) { create_build('a') } let(:build_b) { create_build('b') } @@ -578,7 +584,9 @@ describe Ci::Pipeline, models: true do end it 'fires 3 hooks' do - expect(WebMock).to have_requested(:post, hook.url).times(3) + %w(pending running success).each do |status| + expect(WebMock).to requested(status) + end end end end From 3f7680a61933cd2f710236474b01e9cd9b849beb Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Thu, 4 Aug 2016 00:04:01 +0800 Subject: [PATCH 017/133] I was too used to this style... --- spec/models/ci/pipeline_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index b0496a017f4..b20c617f089 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -567,7 +567,7 @@ describe Ci::Pipeline, models: true do create(:ci_build, :pending, pipeline: pipeline, name: name) end - def requested status + def requested(status) have_requested(:post, hook.url).with do |req| JSON.parse(req.body)['object_attributes']['status'] == status end.once From 94b3d33de1417b31ef3994e43f901941dc302ca0 Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Thu, 4 Aug 2016 00:46:58 +0800 Subject: [PATCH 018/133] If we use Rails magic it's breaking this test: spec/lib/gitlab/data_builder/pipeline_data_builder_spec.rb Because it would trigger the event just after saved and it would load no builds and cache it. We should really avoid adding more magic. --- app/models/ci/pipeline.rb | 10 ++++------ spec/models/ci/pipeline_spec.rb | 2 +- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 822ba7b6c00..ca41a998a2b 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -18,7 +18,6 @@ module Ci # Invalidate object and save if when touched after_touch :update_state - after_save :execute_hooks_if_status_changed after_save :keep_around_commits # ref can't be HEAD or SHA, can only be branch/tag name @@ -230,6 +229,7 @@ module Ci def update_state statuses.reload + last_status = status self.status = if yaml_errors.blank? statuses.latest.status || 'skipped' else @@ -238,11 +238,9 @@ module Ci self.started_at = statuses.started_at self.finished_at = statuses.finished_at self.duration = statuses.latest.duration - save - end - - def execute_hooks_if_status_changed - execute_hooks if status_changed? && !skip_ci? + saved = save + execute_hooks if last_status != status && saved && !skip_ci? + saved end def execute_hooks diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index b20c617f089..326d3c9b44d 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -551,7 +551,7 @@ describe Ci::Pipeline, models: true do before do WebMock.stub_request(:post, hook.url) - pipeline.save + pipeline.touch ProjectWebHookWorker.drain end From 431792f78404c4b83aa8c962ff306f4aacd35f8b Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Thu, 4 Aug 2016 01:05:17 +0800 Subject: [PATCH 019/133] Revert "We don't have to touch it because builds would touch pipeline anyway" This reverts commit 3691a391524911a21d5af1c75cb4cd16a8a6e475. --- app/services/ci/create_pipeline_service.rb | 1 + app/services/create_commit_builds_service.rb | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index be91bf0db85..7a8b0683acb 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -27,6 +27,7 @@ module Ci end pipeline.save! + pipeline.touch unless pipeline.create_builds(current_user) pipeline.errors.add(:base, 'No builds for this pipeline.') diff --git a/app/services/create_commit_builds_service.rb b/app/services/create_commit_builds_service.rb index 5e77768abe7..0b66b854dea 100644 --- a/app/services/create_commit_builds_service.rb +++ b/app/services/create_commit_builds_service.rb @@ -53,7 +53,17 @@ class CreateCommitBuildsService return false end + save_pipeline! + end + + private + + ## + # Create a new pipeline and touch object to calculate status + # + def save_pipeline! @pipeline.save! + @pipeline.touch @pipeline end end From 80671bf75cdac3f50615253b058fa04da6235a4f Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Thu, 4 Aug 2016 01:18:33 +0800 Subject: [PATCH 020/133] Separate the concern for executing hooks and updating states --- app/models/ci/pipeline.rb | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index ca41a998a2b..81991e8aa60 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -228,8 +228,18 @@ module Ci end def update_state - statuses.reload last_status = status + + if update_state_from_commit_statuses + execute_hooks if last_status != status && !skip_ci? + true + else + false + end + end + + def update_state_from_commit_statuses + statuses.reload self.status = if yaml_errors.blank? statuses.latest.status || 'skipped' else @@ -238,9 +248,7 @@ module Ci self.started_at = statuses.started_at self.finished_at = statuses.finished_at self.duration = statuses.latest.duration - saved = save - execute_hooks if last_status != status && saved && !skip_ci? - saved + save end def execute_hooks From 984367f957c8f8d02fa82b08817e2f2f318c6bff Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Thu, 4 Aug 2016 23:44:27 +0800 Subject: [PATCH 021/133] Move those builders to their own namespace, feedback: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5620#note_13540099 --- app/models/ci/build.rb | 4 ++-- .../project_services/builds_email_service.rb | 2 +- app/models/service.rb | 2 +- app/services/delete_branch_service.rb | 2 +- app/services/delete_tag_service.rb | 2 +- app/services/git_push_service.rb | 4 ++-- app/services/git_tag_push_service.rb | 4 ++-- app/services/notes/post_process_service.rb | 2 +- app/services/test_hook_service.rb | 3 ++- .../{ => data_builder}/build_data_builder.rb | 6 +++-- .../{ => data_builder}/note_data_builder.rb | 6 +++-- .../{ => data_builder}/push_data_builder.rb | 6 +++-- .../build_data_builder_spec.rb | 4 ++-- .../note_data_builder_spec.rb | 4 ++-- .../push_data_builder_spec.rb | 2 +- .../project_services/assembla_service_spec.rb | 3 ++- .../builds_email_service_spec.rb | 8 ++++--- .../project_services/drone_ci_service_spec.rb | 4 +++- .../project_services/flowdock_service_spec.rb | 3 ++- .../gemnasium_service_spec.rb | 3 ++- .../project_services/hipchat_service_spec.rb | 24 +++++++++++++------ .../project_services/irker_service_spec.rb | 4 +++- .../project_services/jira_service_spec.rb | 3 ++- .../project_services/pushover_service_spec.rb | 4 +++- .../project_services/slack_service_spec.rb | 4 +++- spec/models/user_spec.rb | 4 +++- spec/workers/build_email_worker_spec.rb | 2 +- spec/workers/emails_on_push_worker_spec.rb | 4 +++- 28 files changed, 79 insertions(+), 44 deletions(-) rename lib/gitlab/{ => data_builder}/build_data_builder.rb (96%) rename lib/gitlab/{ => data_builder}/note_data_builder.rb (96%) rename lib/gitlab/{ => data_builder}/push_data_builder.rb (97%) rename spec/lib/gitlab/{ => data_builder}/build_data_builder_spec.rb (85%) rename spec/lib/gitlab/{ => data_builder}/note_data_builder_spec.rb (96%) rename spec/lib/gitlab/{ => data_builder}/push_data_builder_spec.rb (97%) diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 08f396210c9..b919846af22 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -349,7 +349,7 @@ module Ci def execute_hooks return unless project - build_data = Gitlab::BuildDataBuilder.build(self) + build_data = Gitlab::DataBuilder::BuildDataBuilder.build(self) project.execute_hooks(build_data.dup, :build_hooks) project.execute_services(build_data.dup, :build_hooks) project.running_or_pending_build_count(force: true) @@ -461,7 +461,7 @@ module Ci def build_attributes_from_config return {} unless pipeline.config_processor - + pipeline.config_processor.build_attributes(name) end end diff --git a/app/models/project_services/builds_email_service.rb b/app/models/project_services/builds_email_service.rb index 5e166471077..bf8c68244a1 100644 --- a/app/models/project_services/builds_email_service.rb +++ b/app/models/project_services/builds_email_service.rb @@ -52,7 +52,7 @@ class BuildsEmailService < Service def test_data(project = nil, user = nil) build = project.builds.last - Gitlab::BuildDataBuilder.build(build) + Gitlab::DataBuilder::BuildDataBuilder.build(build) end def fields diff --git a/app/models/service.rb b/app/models/service.rb index e4cd44f542a..76f588f234d 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -80,7 +80,7 @@ class Service < ActiveRecord::Base end def test_data(project, user) - Gitlab::PushDataBuilder.build_sample(project, user) + Gitlab::DataBuilder::PushDataBuilder.build_sample(project, user) end def event_channel_names diff --git a/app/services/delete_branch_service.rb b/app/services/delete_branch_service.rb index 87f066edb6f..33c0fdc3c9d 100644 --- a/app/services/delete_branch_service.rb +++ b/app/services/delete_branch_service.rb @@ -39,7 +39,7 @@ class DeleteBranchService < BaseService end def build_push_data(branch) - Gitlab::PushDataBuilder + Gitlab::DataBuilder::PushDataBuilder .build(project, current_user, branch.target.sha, Gitlab::Git::BLANK_SHA, "#{Gitlab::Git::BRANCH_REF_PREFIX}#{branch.name}", []) end end diff --git a/app/services/delete_tag_service.rb b/app/services/delete_tag_service.rb index 32e0eed6b63..41f8006b46c 100644 --- a/app/services/delete_tag_service.rb +++ b/app/services/delete_tag_service.rb @@ -33,7 +33,7 @@ class DeleteTagService < BaseService end def build_push_data(tag) - Gitlab::PushDataBuilder + Gitlab::DataBuilder::PushDataBuilder .build(project, current_user, tag.target.sha, Gitlab::Git::BLANK_SHA, "#{Gitlab::Git::TAG_REF_PREFIX}#{tag.name}", []) end end diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb index 3f6a177bf3a..473eb5d902f 100644 --- a/app/services/git_push_service.rb +++ b/app/services/git_push_service.rb @@ -138,12 +138,12 @@ class GitPushService < BaseService end def build_push_data - @push_data ||= Gitlab::PushDataBuilder. + @push_data ||= Gitlab::DataBuilder::PushDataBuilder. build(@project, current_user, params[:oldrev], params[:newrev], params[:ref], push_commits) end def build_push_data_system_hook - @push_data_system ||= Gitlab::PushDataBuilder. + @push_data_system ||= Gitlab::DataBuilder::PushDataBuilder. build(@project, current_user, params[:oldrev], params[:newrev], params[:ref], []) end diff --git a/app/services/git_tag_push_service.rb b/app/services/git_tag_push_service.rb index 969530c4fdc..73bbbc36270 100644 --- a/app/services/git_tag_push_service.rb +++ b/app/services/git_tag_push_service.rb @@ -34,12 +34,12 @@ class GitTagPushService < BaseService end end - Gitlab::PushDataBuilder. + Gitlab::DataBuilder::PushDataBuilder. build(project, current_user, params[:oldrev], params[:newrev], params[:ref], commits, message) end def build_system_push_data - Gitlab::PushDataBuilder. + Gitlab::DataBuilder::PushDataBuilder. build(project, current_user, params[:oldrev], params[:newrev], params[:ref], [], '') end end diff --git a/app/services/notes/post_process_service.rb b/app/services/notes/post_process_service.rb index 534c48aefff..a9ee7949936 100644 --- a/app/services/notes/post_process_service.rb +++ b/app/services/notes/post_process_service.rb @@ -16,7 +16,7 @@ module Notes end def hook_data - Gitlab::NoteDataBuilder.build(@note, @note.author) + Gitlab::DataBuilder::NoteDataBuilder.build(@note, @note.author) end def execute_note_hooks diff --git a/app/services/test_hook_service.rb b/app/services/test_hook_service.rb index e85e58751e7..60b85882092 100644 --- a/app/services/test_hook_service.rb +++ b/app/services/test_hook_service.rb @@ -1,6 +1,7 @@ class TestHookService def execute(hook, current_user) - data = Gitlab::PushDataBuilder.build_sample(hook.project, current_user) + data = Gitlab::DataBuilder::PushDataBuilder. + build_sample(hook.project, current_user) hook.execute(data, 'push_hooks') end end diff --git a/lib/gitlab/build_data_builder.rb b/lib/gitlab/data_builder/build_data_builder.rb similarity index 96% rename from lib/gitlab/build_data_builder.rb rename to lib/gitlab/data_builder/build_data_builder.rb index 9f45aefda0f..5175645e238 100644 --- a/lib/gitlab/build_data_builder.rb +++ b/lib/gitlab/data_builder/build_data_builder.rb @@ -1,6 +1,8 @@ module Gitlab - class BuildDataBuilder - class << self + module DataBuilder + module BuildDataBuilder + module_function + def build(build) project = build.project commit = build.pipeline diff --git a/lib/gitlab/note_data_builder.rb b/lib/gitlab/data_builder/note_data_builder.rb similarity index 96% rename from lib/gitlab/note_data_builder.rb rename to lib/gitlab/data_builder/note_data_builder.rb index 8bdc89a7751..12ae1b99f9c 100644 --- a/lib/gitlab/note_data_builder.rb +++ b/lib/gitlab/data_builder/note_data_builder.rb @@ -1,6 +1,8 @@ module Gitlab - class NoteDataBuilder - class << self + module DataBuilder + module NoteDataBuilder + module_function + # Produce a hash of post-receive data # # For all notes: diff --git a/lib/gitlab/push_data_builder.rb b/lib/gitlab/data_builder/push_data_builder.rb similarity index 97% rename from lib/gitlab/push_data_builder.rb rename to lib/gitlab/data_builder/push_data_builder.rb index c8f12577112..f0cad51dd36 100644 --- a/lib/gitlab/push_data_builder.rb +++ b/lib/gitlab/data_builder/push_data_builder.rb @@ -1,6 +1,8 @@ module Gitlab - class PushDataBuilder - class << self + module DataBuilder + module PushDataBuilder + module_function + # Produce a hash of post-receive data # # data = { diff --git a/spec/lib/gitlab/build_data_builder_spec.rb b/spec/lib/gitlab/data_builder/build_data_builder_spec.rb similarity index 85% rename from spec/lib/gitlab/build_data_builder_spec.rb rename to spec/lib/gitlab/data_builder/build_data_builder_spec.rb index 23ae5cfacc4..41b9207df2d 100644 --- a/spec/lib/gitlab/build_data_builder_spec.rb +++ b/spec/lib/gitlab/data_builder/build_data_builder_spec.rb @@ -1,11 +1,11 @@ require 'spec_helper' -describe 'Gitlab::BuildDataBuilder' do +describe Gitlab::DataBuilder::BuildDataBuilder do let(:build) { create(:ci_build) } describe '.build' do let(:data) do - Gitlab::BuildDataBuilder.build(build) + Gitlab::DataBuilder::BuildDataBuilder.build(build) end it { expect(data).to be_a(Hash) } diff --git a/spec/lib/gitlab/note_data_builder_spec.rb b/spec/lib/gitlab/data_builder/note_data_builder_spec.rb similarity index 96% rename from spec/lib/gitlab/note_data_builder_spec.rb rename to spec/lib/gitlab/data_builder/note_data_builder_spec.rb index 3d6bcdfd873..bc5d6cdc358 100644 --- a/spec/lib/gitlab/note_data_builder_spec.rb +++ b/spec/lib/gitlab/data_builder/note_data_builder_spec.rb @@ -1,9 +1,9 @@ require 'spec_helper' -describe 'Gitlab::NoteDataBuilder', lib: true do +describe Gitlab::DataBuilder::NoteDataBuilder, lib: true do let(:project) { create(:project) } let(:user) { create(:user) } - let(:data) { Gitlab::NoteDataBuilder.build(note, user) } + let(:data) { Gitlab::DataBuilder::NoteDataBuilder.build(note, user) } let(:fixed_time) { Time.at(1425600000) } # Avoid time precision errors before(:each) do diff --git a/spec/lib/gitlab/push_data_builder_spec.rb b/spec/lib/gitlab/data_builder/push_data_builder_spec.rb similarity index 97% rename from spec/lib/gitlab/push_data_builder_spec.rb rename to spec/lib/gitlab/data_builder/push_data_builder_spec.rb index 6bd7393aaa7..beb3e0eda7e 100644 --- a/spec/lib/gitlab/push_data_builder_spec.rb +++ b/spec/lib/gitlab/data_builder/push_data_builder_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Gitlab::PushDataBuilder, lib: true do +describe Gitlab::DataBuilder::PushDataBuilder, lib: true do let(:project) { create(:project) } let(:user) { create(:user) } diff --git a/spec/models/project_services/assembla_service_spec.rb b/spec/models/project_services/assembla_service_spec.rb index 17e9361dd5c..63d1c4ce845 100644 --- a/spec/models/project_services/assembla_service_spec.rb +++ b/spec/models/project_services/assembla_service_spec.rb @@ -39,7 +39,8 @@ describe AssemblaService, models: true do token: 'verySecret', subdomain: 'project_name' ) - @sample_data = Gitlab::PushDataBuilder.build_sample(project, user) + @sample_data = Gitlab::DataBuilder::PushDataBuilder. + build_sample(project, user) @api_url = 'https://atlas.assembla.com/spaces/project_name/github_tool?secret_key=verySecret' WebMock.stub_request(:post, @api_url) end diff --git a/spec/models/project_services/builds_email_service_spec.rb b/spec/models/project_services/builds_email_service_spec.rb index ca2cd8aa551..2c8c842babe 100644 --- a/spec/models/project_services/builds_email_service_spec.rb +++ b/spec/models/project_services/builds_email_service_spec.rb @@ -1,7 +1,9 @@ require 'spec_helper' describe BuildsEmailService do - let(:data) { Gitlab::BuildDataBuilder.build(create(:ci_build)) } + let(:data) do + Gitlab::DataBuilder::BuildDataBuilder.build(create(:ci_build)) + end describe 'Validations' do context 'when service is active' do @@ -39,7 +41,7 @@ describe BuildsEmailService do describe '#test' do it 'sends email' do - data = Gitlab::BuildDataBuilder.build(create(:ci_build)) + data = Gitlab::DataBuilder::BuildDataBuilder.build(create(:ci_build)) subject.recipients = 'test@gitlab.com' expect(BuildEmailWorker).to receive(:perform_async) @@ -49,7 +51,7 @@ describe BuildsEmailService do context 'notify only failed builds is true' do it 'sends email' do - data = Gitlab::BuildDataBuilder.build(create(:ci_build)) + data = Gitlab::DataBuilder::BuildDataBuilder.build(create(:ci_build)) data[:build_status] = "success" subject.recipients = 'test@gitlab.com' diff --git a/spec/models/project_services/drone_ci_service_spec.rb b/spec/models/project_services/drone_ci_service_spec.rb index 3a8e67438fc..eb6955d5048 100644 --- a/spec/models/project_services/drone_ci_service_spec.rb +++ b/spec/models/project_services/drone_ci_service_spec.rb @@ -84,7 +84,9 @@ describe DroneCiService, models: true do include_context :drone_ci_service let(:user) { create(:user, username: 'username') } - let(:push_sample_data) { Gitlab::PushDataBuilder.build_sample(project, user) } + let(:push_sample_data) do + Gitlab::DataBuilder::PushDataBuilder.build_sample(project, user) + end it do service_hook = double diff --git a/spec/models/project_services/flowdock_service_spec.rb b/spec/models/project_services/flowdock_service_spec.rb index b7e627e6518..a021f82b374 100644 --- a/spec/models/project_services/flowdock_service_spec.rb +++ b/spec/models/project_services/flowdock_service_spec.rb @@ -52,7 +52,8 @@ describe FlowdockService, models: true do service_hook: true, token: 'verySecret' ) - @sample_data = Gitlab::PushDataBuilder.build_sample(project, user) + @sample_data = Gitlab::DataBuilder::PushDataBuilder. + build_sample(project, user) @api_url = 'https://api.flowdock.com/v1/messages' WebMock.stub_request(:post, @api_url) end diff --git a/spec/models/project_services/gemnasium_service_spec.rb b/spec/models/project_services/gemnasium_service_spec.rb index a08f1ac229f..39ce8ee3387 100644 --- a/spec/models/project_services/gemnasium_service_spec.rb +++ b/spec/models/project_services/gemnasium_service_spec.rb @@ -55,7 +55,8 @@ describe GemnasiumService, models: true do token: 'verySecret', api_key: 'GemnasiumUserApiKey' ) - @sample_data = Gitlab::PushDataBuilder.build_sample(project, user) + @sample_data = Gitlab::DataBuilder::PushDataBuilder. + build_sample(project, user) end it "should call Gemnasium service" do expect(Gemnasium::GitlabService).to receive(:execute).with(an_instance_of(Hash)).once diff --git a/spec/models/project_services/hipchat_service_spec.rb b/spec/models/project_services/hipchat_service_spec.rb index 62ae5f6cf74..79d7fb776f9 100644 --- a/spec/models/project_services/hipchat_service_spec.rb +++ b/spec/models/project_services/hipchat_service_spec.rb @@ -48,7 +48,9 @@ describe HipchatService, models: true do let(:project_name) { project.name_with_namespace.gsub(/\s/, '') } let(:token) { 'verySecret' } let(:server_url) { 'https://hipchat.example.com'} - let(:push_sample_data) { Gitlab::PushDataBuilder.build_sample(project, user) } + let(:push_sample_data) do + Gitlab::DataBuilder::PushDataBuilder.build_sample(project, user) + end before(:each) do allow(hipchat).to receive_messages( @@ -108,7 +110,15 @@ describe HipchatService, models: true do end context 'tag_push events' do - let(:push_sample_data) { Gitlab::PushDataBuilder.build(project, user, Gitlab::Git::BLANK_SHA, '1' * 40, 'refs/tags/test', []) } + let(:push_sample_data) do + Gitlab::DataBuilder::PushDataBuilder.build( + project, + user, + Gitlab::Git::BLANK_SHA, + '1' * 40, + 'refs/tags/test', + []) + end it "should call Hipchat API for tag push events" do hipchat.execute(push_sample_data) @@ -185,7 +195,7 @@ describe HipchatService, models: true do end it "should call Hipchat API for commit comment events" do - data = Gitlab::NoteDataBuilder.build(commit_note, user) + data = Gitlab::DataBuilder::NoteDataBuilder.build(commit_note, user) hipchat.execute(data) expect(WebMock).to have_requested(:post, api_url).once @@ -217,7 +227,7 @@ describe HipchatService, models: true do end it "should call Hipchat API for merge request comment events" do - data = Gitlab::NoteDataBuilder.build(merge_request_note, user) + data = Gitlab::DataBuilder::NoteDataBuilder.build(merge_request_note, user) hipchat.execute(data) expect(WebMock).to have_requested(:post, api_url).once @@ -244,7 +254,7 @@ describe HipchatService, models: true do end it "should call Hipchat API for issue comment events" do - data = Gitlab::NoteDataBuilder.build(issue_note, user) + data = Gitlab::DataBuilder::NoteDataBuilder.build(issue_note, user) hipchat.execute(data) message = hipchat.send(:create_message, data) @@ -270,7 +280,7 @@ describe HipchatService, models: true do end it "should call Hipchat API for snippet comment events" do - data = Gitlab::NoteDataBuilder.build(snippet_note, user) + data = Gitlab::DataBuilder::NoteDataBuilder.build(snippet_note, user) hipchat.execute(data) expect(WebMock).to have_requested(:post, api_url).once @@ -292,7 +302,7 @@ describe HipchatService, models: true do context 'build events' do let(:build) { create(:ci_build) } - let(:data) { Gitlab::BuildDataBuilder.build(build) } + let(:data) { Gitlab::DataBuilder::BuildDataBuilder.build(build) } context 'for failed' do before { build.drop } diff --git a/spec/models/project_services/irker_service_spec.rb b/spec/models/project_services/irker_service_spec.rb index 4ee022a5171..d49948e693d 100644 --- a/spec/models/project_services/irker_service_spec.rb +++ b/spec/models/project_services/irker_service_spec.rb @@ -46,7 +46,9 @@ describe IrkerService, models: true do let(:irker) { IrkerService.new } let(:user) { create(:user) } let(:project) { create(:project) } - let(:sample_data) { Gitlab::PushDataBuilder.build_sample(project, user) } + let(:sample_data) do + Gitlab::DataBuilder::PushDataBuilder.build_sample(project, user) + end let(:recipients) { '#commits irc://test.net/#test ftp://bad' } let(:colorize_messages) { '1' } diff --git a/spec/models/project_services/jira_service_spec.rb b/spec/models/project_services/jira_service_spec.rb index 5a97cf370da..13aec5172e9 100644 --- a/spec/models/project_services/jira_service_spec.rb +++ b/spec/models/project_services/jira_service_spec.rb @@ -66,7 +66,8 @@ describe JiraService, models: true do password: 'gitlab_jira_password' ) @jira_service.save # will build API URL, as api_url was not specified above - @sample_data = Gitlab::PushDataBuilder.build_sample(project, user) + @sample_data = Gitlab::DataBuilder::PushDataBuilder. + build_sample(project, user) # https://github.com/bblimke/webmock#request-with-basic-authentication @api_url = 'http://gitlab_jira_username:gitlab_jira_password@jira.example.com/rest/api/2/issue/JIRA-123/transitions' @comment_url = 'http://gitlab_jira_username:gitlab_jira_password@jira.example.com/rest/api/2/issue/JIRA-123/comment' diff --git a/spec/models/project_services/pushover_service_spec.rb b/spec/models/project_services/pushover_service_spec.rb index 555d9757b47..8c0141b4788 100644 --- a/spec/models/project_services/pushover_service_spec.rb +++ b/spec/models/project_services/pushover_service_spec.rb @@ -48,7 +48,9 @@ describe PushoverService, models: true do let(:pushover) { PushoverService.new } let(:user) { create(:user) } let(:project) { create(:project) } - let(:sample_data) { Gitlab::PushDataBuilder.build_sample(project, user) } + let(:sample_data) do + Gitlab::DataBuilder::PushDataBuilder.build_sample(project, user) + end let(:api_key) { 'verySecret' } let(:user_key) { 'verySecret' } diff --git a/spec/models/project_services/slack_service_spec.rb b/spec/models/project_services/slack_service_spec.rb index df511b1bc4c..373ab8bd79e 100644 --- a/spec/models/project_services/slack_service_spec.rb +++ b/spec/models/project_services/slack_service_spec.rb @@ -45,7 +45,9 @@ describe SlackService, models: true do let(:slack) { SlackService.new } let(:user) { create(:user) } let(:project) { create(:project) } - let(:push_sample_data) { Gitlab::PushDataBuilder.build_sample(project, user) } + let(:push_sample_data) do + Gitlab::DataBuilder::PushDataBuilder.build_sample(project, user) + end let(:webhook_url) { 'https://hooks.slack.com/services/SVRWFV0VVAR97N/B02R25XN3/ZBqu7xMupaEEICInN685' } let(:username) { 'slack_username' } let(:channel) { 'slack_channel' } diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 2a5a7fb2fc6..9394e9dc2e2 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -895,7 +895,9 @@ describe User, models: true do subject { create(:user) } let!(:project1) { create(:project) } let!(:project2) { create(:project, forked_from_project: project1) } - let!(:push_data) { Gitlab::PushDataBuilder.build_sample(project2, subject) } + let!(:push_data) do + Gitlab::DataBuilder::PushDataBuilder.build_sample(project2, subject) + end let!(:push_event) { create(:event, action: Event::PUSHED, project: project2, target: project1, author: subject, data: push_data) } before do diff --git a/spec/workers/build_email_worker_spec.rb b/spec/workers/build_email_worker_spec.rb index 98deae0a588..9317ad60220 100644 --- a/spec/workers/build_email_worker_spec.rb +++ b/spec/workers/build_email_worker_spec.rb @@ -5,7 +5,7 @@ describe BuildEmailWorker do let(:build) { create(:ci_build) } let(:user) { create(:user) } - let(:data) { Gitlab::BuildDataBuilder.build(build) } + let(:data) { Gitlab::DataBuilder::BuildDataBuilder.build(build) } subject { BuildEmailWorker.new } diff --git a/spec/workers/emails_on_push_worker_spec.rb b/spec/workers/emails_on_push_worker_spec.rb index 796751efe8d..1ecc594c311 100644 --- a/spec/workers/emails_on_push_worker_spec.rb +++ b/spec/workers/emails_on_push_worker_spec.rb @@ -5,7 +5,9 @@ describe EmailsOnPushWorker do let(:project) { create(:project) } let(:user) { create(:user) } - let(:data) { Gitlab::PushDataBuilder.build_sample(project, user) } + let(:data) do + Gitlab::DataBuilder::PushDataBuilder.build_sample(project, user) + end let(:recipients) { user.email } let(:perform) { subject.perform(project.id, recipients, data.stringify_keys) } From c245cb39854658290b9155f4c4041351259d509d Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Fri, 5 Aug 2016 00:26:04 +0800 Subject: [PATCH 022/133] Missed renaming them --- spec/models/project_services/slack_service_spec.rb | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/spec/models/project_services/slack_service_spec.rb b/spec/models/project_services/slack_service_spec.rb index 373ab8bd79e..8b15024de7b 100644 --- a/spec/models/project_services/slack_service_spec.rb +++ b/spec/models/project_services/slack_service_spec.rb @@ -197,7 +197,8 @@ describe SlackService, models: true do it "uses the right channel" do slack.update_attributes(note_channel: "random") - note_data = Gitlab::NoteDataBuilder.build(issue_note, user) + note_data = Gitlab::DataBuilder::NoteDataBuilder. + build(issue_note, user) expect(Slack::Notifier).to receive(:new). with(webhook_url, channel: "random"). @@ -237,7 +238,7 @@ describe SlackService, models: true do end it "should call Slack API for commit comment events" do - data = Gitlab::NoteDataBuilder.build(commit_note, user) + data = Gitlab::DataBuilder::NoteDataBuilder.build(commit_note, user) slack.execute(data) expect(WebMock).to have_requested(:post, webhook_url).once @@ -251,7 +252,8 @@ describe SlackService, models: true do end it "should call Slack API for merge request comment events" do - data = Gitlab::NoteDataBuilder.build(merge_request_note, user) + data = Gitlab::DataBuilder::NoteDataBuilder. + build(merge_request_note, user) slack.execute(data) expect(WebMock).to have_requested(:post, webhook_url).once @@ -264,7 +266,7 @@ describe SlackService, models: true do end it "should call Slack API for issue comment events" do - data = Gitlab::NoteDataBuilder.build(issue_note, user) + data = Gitlab::DataBuilder::NoteDataBuilder.build(issue_note, user) slack.execute(data) expect(WebMock).to have_requested(:post, webhook_url).once @@ -278,7 +280,7 @@ describe SlackService, models: true do end it "should call Slack API for snippet comment events" do - data = Gitlab::NoteDataBuilder.build(snippet_note, user) + data = Gitlab::DataBuilder::NoteDataBuilder.build(snippet_note, user) slack.execute(data) expect(WebMock).to have_requested(:post, webhook_url).once From aa75728853ec03c845adc6ba1ae483023ae8bcd4 Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Fri, 5 Aug 2016 00:27:12 +0800 Subject: [PATCH 023/133] Removed the abstract let, feedback: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5620#note_13580861 --- spec/models/ci/pipeline_spec.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 326d3c9b44d..f45684414e6 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -547,7 +547,6 @@ describe Ci::Pipeline, models: true do let!(:hook) do create(:project_hook, project: project, pipeline_events: enabled) end - let(:enabled) { raise NotImplementedError } before do WebMock.stub_request(:post, hook.url) From 3b2c5a85414090d93de33e26912b3ac2d771dfe9 Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Fri, 5 Aug 2016 00:31:55 +0800 Subject: [PATCH 024/133] Touch it after builds were created, aligning with: CreateCommitBuildsService --- app/services/ci/create_pipeline_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index 7a8b0683acb..b3772968ef3 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -27,13 +27,13 @@ module Ci end pipeline.save! - pipeline.touch unless pipeline.create_builds(current_user) pipeline.errors.add(:base, 'No builds for this pipeline.') end pipeline.save + pipeline.touch pipeline end From 584258dbb82f76c627d8552fc96689c7879b36f6 Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Fri, 5 Aug 2016 00:43:16 +0800 Subject: [PATCH 025/133] Share nothing so it's safest, feedback: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5620#note_13581090 --- app/models/ci/pipeline.rb | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 81991e8aa60..59ab8b5ce35 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -252,9 +252,12 @@ module Ci end def execute_hooks - pipeline_data = Gitlab::DataBuilder::PipelineDataBuilder.build(self) project.execute_hooks(pipeline_data, :pipeline_hooks) - project.execute_services(pipeline_data.dup, :pipeline_hooks) + project.execute_services(pipeline_data, :pipeline_hooks) + end + + def pipeline_data + Gitlab::DataBuilder::PipelineDataBuilder.build(self) end def keep_around_commits From a92dd5449524780c2a56e9627059b6c0e2362805 Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Fri, 5 Aug 2016 00:44:17 +0800 Subject: [PATCH 026/133] Define utility functions later, feedback: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5620#note_13581143 --- spec/models/ci/pipeline_spec.rb | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index f45684414e6..542264eb1d9 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -562,16 +562,6 @@ describe Ci::Pipeline, models: true do end context 'with multiple builds' do - def create_build(name) - create(:ci_build, :pending, pipeline: pipeline, name: name) - end - - def requested(status) - have_requested(:post, hook.url).with do |req| - JSON.parse(req.body)['object_attributes']['status'] == status - end.once - end - let(:build_a) { create_build('a') } let(:build_b) { create_build('b') } @@ -587,6 +577,16 @@ describe Ci::Pipeline, models: true do expect(WebMock).to requested(status) end end + + def create_build(name) + create(:ci_build, :pending, pipeline: pipeline, name: name) + end + + def requested(status) + have_requested(:post, hook.url).with do |req| + JSON.parse(req.body)['object_attributes']['status'] == status + end.once + end end end From 901536b36f3f5d95bd9ba33a2b99ef1c171c1133 Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Fri, 5 Aug 2016 00:53:07 +0800 Subject: [PATCH 027/133] No need to check that as in CreateCommitBuildsService: Feedback: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5620#note_13581358 --- app/models/ci/pipeline.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 59ab8b5ce35..d6b75411022 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -231,7 +231,7 @@ module Ci last_status = status if update_state_from_commit_statuses - execute_hooks if last_status != status && !skip_ci? + execute_hooks if last_status != status true else false From f88d4523f3e9523494a96c149c28796df8023e8d Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Fri, 5 Aug 2016 13:53:43 +0800 Subject: [PATCH 028/133] We still need to skip loading config_processor if skip_ci? --- lib/gitlab/data_builder/pipeline_data_builder.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/gitlab/data_builder/pipeline_data_builder.rb b/lib/gitlab/data_builder/pipeline_data_builder.rb index 13417ba09eb..a4c770b630f 100644 --- a/lib/gitlab/data_builder/pipeline_data_builder.rb +++ b/lib/gitlab/data_builder/pipeline_data_builder.rb @@ -16,7 +16,7 @@ module Gitlab def hook_attrs(pipeline) first_pending_build = pipeline.builds.first_pending - config_processor = pipeline.config_processor + config_processor = pipeline.config_processor unless pipeline.skip_ci? { id: pipeline.id, From 5607fdf9fe51da75099effbaf8277c98290d6d96 Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Fri, 5 Aug 2016 14:37:45 +0800 Subject: [PATCH 029/133] Let's make sure cache were cleared: I can't reproduce this failure locally. Here's the failure: https://gitlab.com/gitlab-org/gitlab-ce/builds/2864250 --- spec/helpers/notes_helper_spec.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/spec/helpers/notes_helper_spec.rb b/spec/helpers/notes_helper_spec.rb index af371248ae9..f782c153017 100644 --- a/spec/helpers/notes_helper_spec.rb +++ b/spec/helpers/notes_helper_spec.rb @@ -38,6 +38,10 @@ describe NotesHelper do end describe '#preload_max_access_for_authors' do + before do + RequestStore.clear! # make sure cache were cleared + end + it 'loads multiple users' do expected_access = { owner.id => Gitlab::Access::OWNER, From 0013e59fef7fa21e1f24796ad5c97973bf04e0e3 Mon Sep 17 00:00:00 2001 From: Luke Bennett Date: Fri, 5 Aug 2016 13:56:26 +0100 Subject: [PATCH 030/133] Moved notes scenarios to 'diff_notes_spec.rb' --- features/project/merge_requests.feature | 10 - .../merge_requests/diff_notes_spec.rb | 179 ++++++++++++++++++ spec/features/merge_requests/diffs_spec.rb | 170 ----------------- 3 files changed, 179 insertions(+), 180 deletions(-) create mode 100644 spec/features/merge_requests/diff_notes_spec.rb diff --git a/features/project/merge_requests.feature b/features/project/merge_requests.feature index 21768c15c17..5c1a0099a58 100644 --- a/features/project/merge_requests.feature +++ b/features/project/merge_requests.feature @@ -106,16 +106,6 @@ Feature: Project Merge Requests And I sort the list by "Least popular" Then The list should be sorted by "Least popular" - @javascript - Scenario: I comment on a merge request diff - Given project "Shop" have "Bug NS-05" open merge request with diffs inside - And I visit merge request page "Bug NS-05" - And I click on the Changes tab - And I leave a comment like "Line is wrong" on diff - And I switch to the merge request's comments tab - Then I should see a discussion has started on diff - And I should see a badge of "1" next to the discussion link - @javascript Scenario: I see a new comment on merge request diff from another user in the discussion tab Given project "Shop" have "Bug NS-05" open merge request with diffs inside diff --git a/spec/features/merge_requests/diff_notes_spec.rb b/spec/features/merge_requests/diff_notes_spec.rb new file mode 100644 index 00000000000..8b01601b004 --- /dev/null +++ b/spec/features/merge_requests/diff_notes_spec.rb @@ -0,0 +1,179 @@ +require 'spec_helper' + +feature 'Diff notes', js: true, feature: true do + include WaitForAjax + + before do + login_as :admin + @merge_request = create(:merge_request) + @project = @merge_request.source_project + end + + context 'merge request diffs' do + let(:comment_button_class) { '.add-diff-note' } + let(:notes_holder_input_class) { 'js-temp-notes-holder' } + let(:notes_holder_input_xpath) { './following-sibling::*[contains(concat(" ", @class, " "), " notes_holder ")]' } + let(:test_note_comment) { 'this is a test note!' } + + context 'when hovering over the parallel view diff file' do + before(:each) do + visit diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request) + click_link 'Side-by-side' + end + + context 'with an old line on the left and no line on the right' do + let(:line_holder) { find('[id="6eb14e00385d2fb284765eb1cd8d420d33d63fc9_23_22"]').find(:xpath, '..') } + + it 'should allow commenting on the left side' do + should_allow_commenting line_holder, 'left' + end + + it 'should not allow commenting on the right side' do + should_not_allow_commenting line_holder, 'right' + end + end + + context 'with no line on the left and a new line on the right' do + let(:line_holder) { find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_15_15"]').find(:xpath, '..') } + + it 'should not allow commenting on the left side' do + should_not_allow_commenting line_holder, 'left' + end + + it 'should allow commenting on the right side' do + should_allow_commenting line_holder, 'right' + end + end + + context 'with an old line on the left and a new line on the right' do + let(:line_holder) { find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_9_9"]').find(:xpath, '..') } + + it 'should allow commenting on the left side' do + should_allow_commenting line_holder, 'left' + end + + it 'should allow commenting on the right side' do + should_allow_commenting line_holder, 'right' + end + end + + context 'with an unchanged line on the left and an unchanged line on the right' do + let(:line_holder) { first('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_7_7"]').find(:xpath, '..') } + + it 'should allow commenting on the left side' do + should_allow_commenting line_holder, 'left' + end + + it 'should allow commenting on the right side' do + should_allow_commenting line_holder, 'right' + end + end + + context 'with a match line' do + let(:line_holder) { first('.match').find(:xpath, '..') } + + it 'should not allow commenting on the left side' do + should_not_allow_commenting line_holder, 'left' + end + + it 'should not allow commenting on the right side' do + should_not_allow_commenting line_holder, 'right' + end + end + end + + context 'when hovering over the inline view diff file' do + let(:comment_button_class) { '.add-diff-note' } + + before(:each) do + visit diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request) + click_link 'Inline' + end + + context 'with a new line' do + let(:line_holder) { find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]') } + + it 'should allow commenting' do + should_allow_commenting line_holder + end + end + + context 'with an old line' do + let(:line_holder) { find('[id="6eb14e00385d2fb284765eb1cd8d420d33d63fc9_22_22"]') } + + it 'should allow commenting' do + should_allow_commenting line_holder + end + end + + context 'with an unchanged line' do + let(:line_holder) { find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_7_7"]') } + + it 'should allow commenting' do + should_allow_commenting line_holder + end + end + + context 'with a match line' do + let(:line_holder) { first('.match') } + + it 'should not allow commenting' do + should_not_allow_commenting line_holder + end + end + end + + def should_allow_commenting(line_holder, diff_side = nil) + line = get_line_components line_holder, diff_side + line[:content].hover + expect(line[:num]).to have_css comment_button_class + + comment_on_line line_holder, line + wait_for_ajax + + assert_comment_persistence line_holder + end + + def should_not_allow_commenting(line_holder, diff_side = nil) + line = get_line_components line_holder, diff_side + line[:content].hover + expect(line[:num]).not_to have_css comment_button_class + end + + def get_line_components(line_holder, diff_side = nil) + if diff_side.nil? + get_inline_line_components line_holder + else + get_parallel_line_components line_holder, diff_side + end + end + + def get_inline_line_components(line_holder) + { content: line_holder.first('.line_content'), num: line_holder.first('.diff-line-num') } + end + + def get_parallel_line_components(line_holder, diff_side = nil) + side_index = diff_side == 'left' ? 0 : 1 + { content: line_holder.all('.line_content')[side_index], num: line_holder.all('.diff-line-num')[side_index] } + end + + def comment_on_line(line_holder, line) + line[:num].find(comment_button_class).trigger 'click' + expect(line_holder).to have_xpath notes_holder_input_xpath + + notes_holder_input = line_holder.find(:xpath, notes_holder_input_xpath) + expect(notes_holder_input[:class].include? notes_holder_input_class).to be true + + notes_holder_input.fill_in 'note[note]', with: test_note_comment + click_button 'Comment' + end + + def assert_comment_persistence(line_holder) + expect(line_holder).to have_xpath notes_holder_input_xpath + + notes_holder_saved = line_holder.find(:xpath, notes_holder_input_xpath) + expect(notes_holder_saved[:class].include? notes_holder_input_class).to be false + expect(notes_holder_saved).to have_content test_note_comment + end + end +end diff --git a/spec/features/merge_requests/diffs_spec.rb b/spec/features/merge_requests/diffs_spec.rb index d93fc5e84ee..c9a0059645d 100644 --- a/spec/features/merge_requests/diffs_spec.rb +++ b/spec/features/merge_requests/diffs_spec.rb @@ -1,8 +1,6 @@ require 'spec_helper' feature 'Diffs URL', js: true, feature: true do - include WaitForAjax - before do login_as :admin @merge_request = create(:merge_request) @@ -24,172 +22,4 @@ feature 'Diffs URL', js: true, feature: true do expect(page).to have_css('.diffs.tab-pane.active') end end - - context 'diff notes' do - let(:comment_button_class) { '.add-diff-note' } - let(:notes_holder_input_class) { 'js-temp-notes-holder' } - let(:notes_holder_input_xpath) { './following-sibling::*[contains(concat(" ", @class, " "), " notes_holder ")]' } - let(:test_note_comment) { 'this is a test note!' } - - context 'when hovering over the parallel view diff file' do - before(:each) do - visit diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request) - click_link 'Side-by-side' - end - - context 'with an old line on the left and no line on the right' do - let(:line_holder) { find('[id="6eb14e00385d2fb284765eb1cd8d420d33d63fc9_23_22"]').find(:xpath, '..') } - - it 'should allow commenting on the left side' do - should_allow_commenting line_holder, 'left' - end - - it 'should not allow commenting on the right side' do - should_not_allow_commenting line_holder, 'right' - end - end - - context 'with no line on the left and a new line on the right' do - let(:line_holder) { find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_15_15"]').find(:xpath, '..') } - - it 'should not allow commenting on the left side' do - should_not_allow_commenting line_holder, 'left' - end - - it 'should allow commenting on the right side' do - should_allow_commenting line_holder, 'right' - end - end - - context 'with an old line on the left and a new line on the right' do - let(:line_holder) { find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_9_9"]').find(:xpath, '..') } - - it 'should allow commenting on the left side' do - should_allow_commenting line_holder, 'left' - end - - it 'should allow commenting on the right side' do - should_allow_commenting line_holder, 'right' - end - end - - context 'with an unchanged line on the left and an unchanged line on the right' do - let(:line_holder) { first('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_7_7"]').find(:xpath, '..') } - - it 'should allow commenting on the left side' do - should_allow_commenting line_holder, 'left' - end - - it 'should allow commenting on the right side' do - should_allow_commenting line_holder, 'right' - end - end - - context 'with a match line' do - let(:line_holder) { first('.match').find(:xpath, '..') } - - it 'should not allow commenting on the left side' do - should_not_allow_commenting line_holder, 'left' - end - - it 'should not allow commenting on the right side' do - should_not_allow_commenting line_holder, 'right' - end - end - end - - context 'when hovering over the inline view diff file' do - let(:comment_button_class) { '.add-diff-note' } - - before(:each) do - visit diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request) - click_link 'Inline' - end - - context 'with a new line' do - let(:line_holder) { find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]') } - - it 'should allow commenting' do - should_allow_commenting line_holder - end - end - - context 'with an old line' do - let(:line_holder) { find('[id="6eb14e00385d2fb284765eb1cd8d420d33d63fc9_22_22"]') } - - it 'should allow commenting' do - should_allow_commenting line_holder - end - end - - context 'with an unchanged line' do - let(:line_holder) { find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_7_7"]') } - - it 'should allow commenting' do - should_allow_commenting line_holder - end - end - - context 'with a match line' do - let(:line_holder) { first('.match') } - - it 'should not allow commenting' do - should_not_allow_commenting line_holder - end - end - end - - def should_allow_commenting(line_holder, diff_side = nil) - line = get_line_components line_holder, diff_side - line[:content].hover - expect(line[:num]).to have_css comment_button_class - - comment_on_line line_holder, line - wait_for_ajax - - assert_comment_persistence line_holder - end - - def should_not_allow_commenting(line_holder, diff_side = nil) - line = get_line_components line_holder, diff_side - line[:content].hover - expect(line[:num]).not_to have_css comment_button_class - end - - def get_line_components(line_holder, diff_side = nil) - if diff_side.nil? - get_inline_line_components line_holder - else - get_parallel_line_components line_holder, diff_side - end - end - - def get_inline_line_components(line_holder) - { content: line_holder.first('.line_content'), num: line_holder.first('.diff-line-num') } - end - - def get_parallel_line_components(line_holder, diff_side = nil) - side_index = diff_side == 'left' ? 0 : 1 - { content: line_holder.all('.line_content')[side_index], num: line_holder.all('.diff-line-num')[side_index] } - end - - def comment_on_line(line_holder, line) - line[:num].find(comment_button_class).trigger 'click' - expect(line_holder).to have_xpath notes_holder_input_xpath - - notes_holder_input = line_holder.find(:xpath, notes_holder_input_xpath) - expect(notes_holder_input[:class].include? notes_holder_input_class).to be true - - notes_holder_input.fill_in 'note[note]', with: test_note_comment - click_button 'Comment' - end - - def assert_comment_persistence(line_holder) - expect(line_holder).to have_xpath notes_holder_input_xpath - - notes_holder_saved = line_holder.find(:xpath, notes_holder_input_xpath) - expect(notes_holder_saved[:class].include? notes_holder_input_class).to be false - expect(notes_holder_saved).to have_content test_note_comment - end - end end From 20308867dd8832f3e7ae2cc87334c075ce8c5400 Mon Sep 17 00:00:00 2001 From: Luke Bennett Date: Wed, 10 Aug 2016 11:27:02 +0100 Subject: [PATCH 031/133] Review changes --- .../merge_requests/diff_notes_spec.rb | 66 +++++++------------ 1 file changed, 23 insertions(+), 43 deletions(-) diff --git a/spec/features/merge_requests/diff_notes_spec.rb b/spec/features/merge_requests/diff_notes_spec.rb index 8b01601b004..12e89742b79 100644 --- a/spec/features/merge_requests/diff_notes_spec.rb +++ b/spec/features/merge_requests/diff_notes_spec.rb @@ -22,129 +22,109 @@ feature 'Diff notes', js: true, feature: true do end context 'with an old line on the left and no line on the right' do - let(:line_holder) { find('[id="6eb14e00385d2fb284765eb1cd8d420d33d63fc9_23_22"]').find(:xpath, '..') } - it 'should allow commenting on the left side' do - should_allow_commenting line_holder, 'left' + should_allow_commenting(find('[id="6eb14e00385d2fb284765eb1cd8d420d33d63fc9_23_22"]').find(:xpath, '..'), 'left') end it 'should not allow commenting on the right side' do - should_not_allow_commenting line_holder, 'right' + should_not_allow_commenting(find('[id="6eb14e00385d2fb284765eb1cd8d420d33d63fc9_23_22"]').find(:xpath, '..'), 'right') end end context 'with no line on the left and a new line on the right' do - let(:line_holder) { find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_15_15"]').find(:xpath, '..') } - it 'should not allow commenting on the left side' do - should_not_allow_commenting line_holder, 'left' + should_not_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_15_15"]').find(:xpath, '..'), 'left') end it 'should allow commenting on the right side' do - should_allow_commenting line_holder, 'right' + should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_15_15"]').find(:xpath, '..'), 'right') end end context 'with an old line on the left and a new line on the right' do - let(:line_holder) { find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_9_9"]').find(:xpath, '..') } - it 'should allow commenting on the left side' do - should_allow_commenting line_holder, 'left' + should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_9_9"]').find(:xpath, '..'), 'left') end it 'should allow commenting on the right side' do - should_allow_commenting line_holder, 'right' + should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_9_9"]').find(:xpath, '..'), 'right') end end context 'with an unchanged line on the left and an unchanged line on the right' do - let(:line_holder) { first('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_7_7"]').find(:xpath, '..') } - it 'should allow commenting on the left side' do - should_allow_commenting line_holder, 'left' + should_allow_commenting(first('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_7_7"]').find(:xpath, '..'), 'left') end it 'should allow commenting on the right side' do - should_allow_commenting line_holder, 'right' + should_allow_commenting(first('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_7_7"]').find(:xpath, '..'), 'right') end end context 'with a match line' do - let(:line_holder) { first('.match').find(:xpath, '..') } - it 'should not allow commenting on the left side' do - should_not_allow_commenting line_holder, 'left' + should_not_allow_commenting(first('.match').find(:xpath, '..'), 'left') end it 'should not allow commenting on the right side' do - should_not_allow_commenting line_holder, 'right' + should_not_allow_commenting(first('.match').find(:xpath, '..'), 'right') end end end context 'when hovering over the inline view diff file' do - let(:comment_button_class) { '.add-diff-note' } - - before(:each) do + before do visit diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request) click_link 'Inline' end context 'with a new line' do - let(:line_holder) { find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]') } - it 'should allow commenting' do - should_allow_commenting line_holder + should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]')) end end context 'with an old line' do - let(:line_holder) { find('[id="6eb14e00385d2fb284765eb1cd8d420d33d63fc9_22_22"]') } - it 'should allow commenting' do - should_allow_commenting line_holder + should_allow_commenting(find('[id="6eb14e00385d2fb284765eb1cd8d420d33d63fc9_22_22"]')) end end context 'with an unchanged line' do - let(:line_holder) { find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_7_7"]') } - it 'should allow commenting' do - should_allow_commenting line_holder + should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_7_7"]')) end end context 'with a match line' do - let(:line_holder) { first('.match') } - it 'should not allow commenting' do - should_not_allow_commenting line_holder + should_not_allow_commenting(first('.match')) end end end def should_allow_commenting(line_holder, diff_side = nil) - line = get_line_components line_holder, diff_side + line = get_line_components(line_holder, diff_side) line[:content].hover expect(line[:num]).to have_css comment_button_class - comment_on_line line_holder, line + comment_on_line(line_holder, line) wait_for_ajax - assert_comment_persistence line_holder + assert_comment_persistence(line_holder) end def should_not_allow_commenting(line_holder, diff_side = nil) - line = get_line_components line_holder, diff_side + line = get_line_components(line_holder, diff_side) line[:content].hover expect(line[:num]).not_to have_css comment_button_class end def get_line_components(line_holder, diff_side = nil) if diff_side.nil? - get_inline_line_components line_holder + get_inline_line_components(line_holder) else - get_parallel_line_components line_holder, diff_side + get_parallel_line_components(line_holder, diff_side) end end @@ -162,7 +142,7 @@ feature 'Diff notes', js: true, feature: true do expect(line_holder).to have_xpath notes_holder_input_xpath notes_holder_input = line_holder.find(:xpath, notes_holder_input_xpath) - expect(notes_holder_input[:class].include? notes_holder_input_class).to be true + expect(notes_holder_input[:class]).to include(notes_holder_input_class) notes_holder_input.fill_in 'note[note]', with: test_note_comment click_button 'Comment' @@ -172,7 +152,7 @@ feature 'Diff notes', js: true, feature: true do expect(line_holder).to have_xpath notes_holder_input_xpath notes_holder_saved = line_holder.find(:xpath, notes_holder_input_xpath) - expect(notes_holder_saved[:class].include? notes_holder_input_class).to be false + expect(notes_holder_saved[:class]).not_to include(notes_holder_input_class) expect(notes_holder_saved).to have_content test_note_comment end end From 91cd5ae71cbede8f4a05a61d98c4e252cdbfe8c7 Mon Sep 17 00:00:00 2001 From: Chris Peressini Date: Thu, 11 Aug 2016 10:11:17 +0000 Subject: [PATCH 032/133] Add reference to product map. Remove line that says the GitLab logo and user picture are in the sidebar. --- doc/development/ui_guide.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/doc/development/ui_guide.md b/doc/development/ui_guide.md index 3a8c823e026..2d1d504202c 100644 --- a/doc/development/ui_guide.md +++ b/doc/development/ui_guide.md @@ -15,11 +15,14 @@ repository and maintained by GitLab UX designers. ## Navigation GitLab's layout contains 2 sections: the left sidebar and the content. The left sidebar contains a static navigation menu. -This menu will be visible regardless of what page you visit. The left sidebar also contains the GitLab logo -and the current user's profile picture. The content section contains a header and the content itself. -The header describes the current GitLab page and what navigation is -available to user in this area. Depending on the area (project, group, profile setting) the header name and navigation may change. For example when user visits one of the -project pages the header will contain a project name and navigation for that project. When the user visits a group page it will contain a group name and navigation related to this group. +This menu will be visible regardless of what page you visit. +The content section contains a header and the content itself. The header describes the current GitLab page and what navigation is +available to the user in this area. Depending on the area (project, group, profile setting) the header name and navigation may change. For example, when the user visits one of the +project pages the header will contain the project's name and navigation for that project. When the user visits a group page it will contain the group's name and navigation related to this group. + +You can see a visual representation of the navigation in GitLab in the GitLab Product Map, which is located in the [Design Repository](gitlab-map-graffle) +along with [PDF](gitlab-map-pdf) and [PNG](gitlab-map-png) exports. + ### Adding new tab to header navigation @@ -99,3 +102,6 @@ Do not use both green and blue button in one form. display counts in the UI. [number_with_delimiter]: http://api.rubyonrails.org/classes/ActionView/Helpers/NumberHelper.html#method-i-number_with_delimiter +[gitlab-map-graffle]: https://gitlab.com/gitlab-org/gitlab-design/blob/master/production/resources/gitlab-map.graffle +[gitlab-map-pdf]: https://gitlab.com/gitlab-org/gitlab-design/raw/master/production/resources/gitlab-map.pdf +[gitlab-map-png]: https://gitlab.com/gitlab-org/gitlab-design/raw/master/production/resources/gitlab-map.png \ No newline at end of file From efab1677a7a2959a28a09393a2b6519072005534 Mon Sep 17 00:00:00 2001 From: James Lopez Date: Thu, 11 Aug 2016 11:03:26 +0200 Subject: [PATCH 033/133] Fix attribute inclusion in import/export config ignored in some cases --- CHANGELOG | 3 +++ lib/gitlab/import_export/json_hash_builder.rb | 9 +++------ spec/lib/gitlab/import_export/reader_spec.rb | 3 ++- spec/support/import_export/import_export.yml | 4 ++++ 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 5aa5cbec279..b8f06f4c445 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -90,6 +90,9 @@ v 8.11.0 (unreleased) - Fix importing GitLab projects with an invalid MR source project - Sort folders with submodules in Files view !5521 +v 8.10.6 (unreleased) + - Fix import/export configuration missing some included attributes + v 8.10.5 - Add a data migration to fix some missing timestamps in the members table. !5670 - Revert the "Defend against 'Host' header injection" change in the source NGINX templates. !5706 diff --git a/lib/gitlab/import_export/json_hash_builder.rb b/lib/gitlab/import_export/json_hash_builder.rb index 008300bde45..b7db0e7a06a 100644 --- a/lib/gitlab/import_export/json_hash_builder.rb +++ b/lib/gitlab/import_export/json_hash_builder.rb @@ -57,10 +57,7 @@ module Gitlab # +value+ existing model to be included in the hash # +json_config_hash+ the original hash containing the root model def create_model_value(current_key, value, json_config_hash) - parsed_hash = { include: value } - parse_hash(value, parsed_hash) - - json_config_hash[current_key] = parsed_hash + json_config_hash[current_key] = parse_hash(value, { include: value }) end # Calls attributes finder to parse the hash and add any attributes to it @@ -69,8 +66,8 @@ module Gitlab # +parsed_hash+ the original hash def parse_hash(value, parsed_hash) @attributes_finder.parse(value) do |hash| - parsed_hash = { include: hash_or_merge(value, hash) } - end + { include: hash_or_merge(value, hash) } + end || parsed_hash end # Adds new model configuration to an existing hash with key +current_key+ diff --git a/spec/lib/gitlab/import_export/reader_spec.rb b/spec/lib/gitlab/import_export/reader_spec.rb index b76e14deca1..b6dec41d218 100644 --- a/spec/lib/gitlab/import_export/reader_spec.rb +++ b/spec/lib/gitlab/import_export/reader_spec.rb @@ -12,7 +12,8 @@ describe Gitlab::ImportExport::Reader, lib: true do except: [:iid], include: [:merge_request_diff, :merge_request_test] } }, - { commit_statuses: { include: :commit } }] + { commit_statuses: { include: :commit } }, + { project_members: { include: { user: { only: [:email] } } } }] } end diff --git a/spec/support/import_export/import_export.yml b/spec/support/import_export/import_export.yml index 3ceec506401..17136dee000 100644 --- a/spec/support/import_export/import_export.yml +++ b/spec/support/import_export/import_export.yml @@ -7,6 +7,8 @@ project_tree: - :merge_request_test - commit_statuses: - :commit + - project_members: + - :user included_attributes: project: @@ -14,6 +16,8 @@ included_attributes: - :path merge_requests: - :id + user: + - :email excluded_attributes: merge_requests: From 5f7394070f92acb2b7858b792034b30102fe1d9b Mon Sep 17 00:00:00 2001 From: Yorick Peterse Date: Thu, 11 Aug 2016 14:22:21 +0200 Subject: [PATCH 034/133] Added documentation on adding database indexes --- doc/development/README.md | 4 + doc/development/adding_database_indexes.md | 123 +++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 doc/development/adding_database_indexes.md diff --git a/doc/development/README.md b/doc/development/README.md index bf67b5d8dff..57f37da6f80 100644 --- a/doc/development/README.md +++ b/doc/development/README.md @@ -30,7 +30,11 @@ - [Rake tasks](rake_tasks.md) for development - [Shell commands](shell_commands.md) in the GitLab codebase - [Sidekiq debugging](sidekiq_debugging.md) + +## Databases + - [What requires downtime?](what_requires_downtime.md) +- [Adding database indexes](adding_database_indexes.md) ## Compliance diff --git a/doc/development/adding_database_indexes.md b/doc/development/adding_database_indexes.md new file mode 100644 index 00000000000..ea6f14da3b9 --- /dev/null +++ b/doc/development/adding_database_indexes.md @@ -0,0 +1,123 @@ +# Adding Database Indexes + +Indexes can be used to speed up database queries, but when should you add a new +index? Traditionally the answer to this question has been to add an index for +every column used for filtering or joining data. For example, consider the +following query: + +```sql +SELECT * +FROM projects +WHERE user_id = 2; +``` + +Here we are filtering by the `user_id` column and as such a developer may decide +to index this column. + +While in certain cases indexing columns using the above approach may make sense +it can actually have a negative impact. Whenever you write data to a table any +existing indexes need to be updated. The more indexes there are the slower this +can potentially become. Indexes can also take up quite some disk space depending +on the amount of data indexed and the index type. For example, PostgreSQL offers +"GIN" indexes which can be used to index certain data types that can not be +indexed by regular btree indexes. These indexes however generally take up more +data and are slower to update compared to btree indexes. + +Because of all this one should not blindly add a new index for every column used +to filter data by. Instead one should ask themselves the following questions: + +1. Can I write my query in such a way that it re-uses as many existing indexes + as possible? +2. Is the data going to be large enough that using an index will actually be + faster than just iterating over the rows in the table? +3. Is the overhead of maintaining the index worth the reduction in query + timings? + +We'll explore every question in detail below. + +## Re-using Queries + +The first step is to make sure your query re-uses as many existing indexes as +possible. For example, consider the following query: + +```sql +SELECT * +FROM todos +WHERE user_id = 123 +AND state = 'open'; +``` + +Now imagine we already have an index on the `user_id` column but not on the +`state` column. One may think this query will perform badly due to `state` being +unindexed. In reality the query may perform just fine given the index on +`user_id` can filter out enough rows. + +The best way to determine if indexes are re-used is to run your query using +`EXPLAIN ANALYZE`. Depending on any extra tables that may be joined and +other columns being used for filtering you may find an extra index is not going +to make much (if any) difference. On the other hand you may determine that the +index _may_ make a difference. + +In short: + +1. Try to write your query in such a way that it re-uses as many existing + indexes as possible. +2. Run the query using `EXPLAIN ANALYZE` and study the output to find the most + ideal query. + +## Data Size + +A database may decide not to use an index despite it existing in case a regular +sequence scan (= simply iterating over all existing rows) is faster. This is +especially the case for small tables. + +If a table is expected to grow in size and you expect your query has to filter +out a lot of rows you may want to consider adding an index. If the table size is +very small (e.g. only a handful of rows) or any existing indexes filter out +enough rows you may _not_ want to add a new index. + +## Maintenance Overhead + +Indexes have to be updated on every table write. In case of PostgreSQL _all_ +existing indexes will be updated whenever data is written to a table. As a +result of this having many indexes on the same table will slow down writes. + +Because of this one should ask themselves: is the reduction in query performance +worth the overhead of maintaining an extra index? + +If adding an index reduces SELECT timings by 5 milliseconds but increases +INSERT/UPDATE/DELETE timings by 10 milliseconds then the index may not be worth +it. On the other hand, if SELECT timings are reduced but INSERT/UPDATE/DELETE +timings are not affected you may want to add the index after all. + +## Finding Unused Indexes + +To see which indexes are unused you can run the following query: + +```sql +SELECT relname as table_name, indexrelname as index_name, idx_scan, idx_tup_read, idx_tup_fetch, pg_size_pretty(pg_relation_size(indexrelname::regclass)) +FROM pg_stat_all_indexes +WHERE schemaname = 'public' +AND idx_scan = 0 +AND idx_tup_read = 0 +AND idx_tup_fetch = 0 +ORDER BY pg_relation_size(indexrelname::regclass) desc; +``` + +This query outputs a list containing all indexes that are never used and sorts +them by indexes sizes in descending order. This query can be useful to +determine if any previously indexes are useful after all. More information on +the meaning of the various columns can be found at +. + +Because the output of this query relies on the actual usage of your database it +may be affected by factors such as (but not limited to): + +* Certain queries never being executed, thus not being able to use certain + indexes. +* Certain tables having little data, resulting in PostgreSQL using sequence + scans instead of index scans. + +In other words, this data is only reliable for a frequently used database with +plenty of data and with as many GitLab features enabled (and being used) as +possible. From 30f9596c612abc19dd060fa3a8e8ae3d92001d45 Mon Sep 17 00:00:00 2001 From: James Lopez Date: Thu, 11 Aug 2016 16:59:37 +0200 Subject: [PATCH 035/133] Fix permissions check in controller, added relevant spec and updated docs --- .../import/gitlab_projects_controller.rb | 5 + app/views/projects/new.html.haml | 2 +- doc/user/project/settings/import_export.md | 3 +- features/dashboard/new_project.feature | 2 +- features/steps/dashboard/new_project.rb | 3 +- .../import_export/import_file_spec.rb | 98 ++++++++++++------- 6 files changed, 69 insertions(+), 44 deletions(-) diff --git a/app/controllers/import/gitlab_projects_controller.rb b/app/controllers/import/gitlab_projects_controller.rb index 3ec173abcdb..7d0eff37635 100644 --- a/app/controllers/import/gitlab_projects_controller.rb +++ b/app/controllers/import/gitlab_projects_controller.rb @@ -1,5 +1,6 @@ class Import::GitlabProjectsController < Import::BaseController before_action :verify_gitlab_project_import_enabled + before_action :authenticate_admin! def new @namespace_id = project_params[:namespace_id] @@ -47,4 +48,8 @@ class Import::GitlabProjectsController < Import::BaseController :path, :namespace_id, :file ) end + + def authenticate_admin! + render_404 unless current_user.is_admin? + end end diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index adcc984f506..ea4898f2107 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -77,7 +77,7 @@ = link_to "#", class: 'btn js-toggle-button import_git' do = icon('git', text: 'Repo by URL') %div{ class: 'import_gitlab_project' } - - if gitlab_project_import_enabled? + - if gitlab_project_import_enabled? && current_user.is_admin? = link_to new_import_gitlab_project_path, class: 'btn btn_import_gitlab_project project-submit' do = icon('gitlab', text: 'GitLab export') diff --git a/doc/user/project/settings/import_export.md b/doc/user/project/settings/import_export.md index 2513def49a4..08ff89ce6ae 100644 --- a/doc/user/project/settings/import_export.md +++ b/doc/user/project/settings/import_export.md @@ -7,8 +7,7 @@ > than that of the exporter. > - For existing installations, the project import option has to be enabled in > application settings (`/admin/application_settings`) under 'Import sources'. -> Ask your administrator if you don't see the **GitLab export** button when -> creating a new project. +> You will have to be an administrator to enable and use the import functionality. > - You can find some useful raketasks if you are an administrator in the > [import_export](../../../administration/raketasks/project_import_export.md) > raketask. diff --git a/features/dashboard/new_project.feature b/features/dashboard/new_project.feature index 8ddafb6a7ac..046e2815d4e 100644 --- a/features/dashboard/new_project.feature +++ b/features/dashboard/new_project.feature @@ -9,7 +9,7 @@ Background: @javascript Scenario: I should see New Projects page Then I see "New Project" page - Then I see all possible import optios + Then I see all possible import options @javascript Scenario: I should see instructions on how to import from Git URL diff --git a/features/steps/dashboard/new_project.rb b/features/steps/dashboard/new_project.rb index 727a6a71373..f2b44734601 100644 --- a/features/steps/dashboard/new_project.rb +++ b/features/steps/dashboard/new_project.rb @@ -14,14 +14,13 @@ class Spinach::Features::NewProject < Spinach::FeatureSteps expect(page).to have_content('Project name') end - step 'I see all possible import optios' do + step 'I see all possible import options' do expect(page).to have_link('GitHub') expect(page).to have_link('Bitbucket') expect(page).to have_link('GitLab.com') expect(page).to have_link('Gitorious.org') expect(page).to have_link('Google Code') expect(page).to have_link('Repo by URL') - expect(page).to have_link('GitLab export') end step 'I click on "Import project from GitHub"' do diff --git a/spec/features/projects/import_export/import_file_spec.rb b/spec/features/projects/import_export/import_file_spec.rb index 7835e1678ad..f707ccf4e93 100644 --- a/spec/features/projects/import_export/import_file_spec.rb +++ b/spec/features/projects/import_export/import_file_spec.rb @@ -3,8 +3,9 @@ require 'spec_helper' feature 'project import', feature: true, js: true do include Select2Helper - let(:user) { create(:admin) } - let!(:namespace) { create(:namespace, name: "asd", owner: user) } + let(:admin) { create(:admin) } + let(:normal_user) { create(:user) } + let!(:namespace) { create(:namespace, name: "asd", owner: admin) } let(:file) { File.join(Rails.root, 'spec', 'features', 'projects', 'import_export', 'test_project_export.tar.gz') } let(:export_path) { "#{Dir::tmpdir}/import_file_spec" } let(:project) { Project.last } @@ -12,66 +13,87 @@ feature 'project import', feature: true, js: true do background do allow_any_instance_of(Gitlab::ImportExport).to receive(:storage_path).and_return(export_path) - login_as(user) end after(:each) do FileUtils.rm_rf(export_path, secure: true) end - scenario 'user imports an exported project successfully' do - expect(Project.all.count).to be_zero + context 'admin user' do + before do + login_as(admin) + end - visit new_project_path + scenario 'user imports an exported project successfully' do + expect(Project.all.count).to be_zero - select2('2', from: '#project_namespace_id') - fill_in :project_path, with: 'test-project-path', visible: true - click_link 'GitLab export' + visit new_project_path - expect(page).to have_content('GitLab project export') - expect(URI.parse(current_url).query).to eq('namespace_id=2&path=test-project-path') + select2('2', from: '#project_namespace_id') + fill_in :project_path, with: 'test-project-path', visible: true + click_link 'GitLab export' - attach_file('file', file) + expect(page).to have_content('GitLab project export') + expect(URI.parse(current_url).query).to eq('namespace_id=2&path=test-project-path') - click_on 'Import project' # import starts + attach_file('file', file) - expect(project).not_to be_nil - expect(project.issues).not_to be_empty - expect(project.merge_requests).not_to be_empty - expect(project_hook).to exist - expect(wiki_exists?).to be true - expect(project.import_status).to eq('finished') - end + click_on 'Import project' # import starts - scenario 'invalid project' do - project = create(:project, namespace_id: 2) + expect(project).not_to be_nil + expect(project.issues).not_to be_empty + expect(project.merge_requests).not_to be_empty + expect(project_hook).to exist + expect(wiki_exists?).to be true + expect(project.import_status).to eq('finished') + end - visit new_project_path + scenario 'invalid project' do + project = create(:project, namespace_id: 2) - select2('2', from: '#project_namespace_id') - fill_in :project_path, with: project.name, visible: true - click_link 'GitLab export' + visit new_project_path - attach_file('file', file) - click_on 'Import project' + select2('2', from: '#project_namespace_id') + fill_in :project_path, with: project.name, visible: true + click_link 'GitLab export' - page.within('.flash-container') do - expect(page).to have_content('Project could not be imported') + attach_file('file', file) + click_on 'Import project' + + page.within('.flash-container') do + expect(page).to have_content('Project could not be imported') + end + end + + scenario 'project with no name' do + create(:project, namespace_id: 2) + + visit new_project_path + + select2('2', from: '#project_namespace_id') + + # click on disabled element + find(:link, 'GitLab export').trigger('click') + + page.within('.flash-container') do + expect(page).to have_content('Please enter path and name') + end end end - scenario 'project with no name' do - create(:project, namespace_id: 2) + context 'normal user' do + before do + login_as(normal_user) + end - visit new_project_path + scenario 'non-admin user is not allowed to import a project' do + expect(Project.all.count).to be_zero - select2('2', from: '#project_namespace_id') + visit new_project_path - # click on disabled element - find(:link, 'GitLab export').trigger('click') + fill_in :project_path, with: 'test-project-path', visible: true - page.within('.flash-container') do - expect(page).to have_content('Please enter path and name') + expect(page).not_to have_content('GitLab export') end end From ffa75a497a23bf6f87de626fee08ff4538a12587 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Thu, 11 Aug 2016 17:23:07 +0200 Subject: [PATCH 036/133] Remove stage parameter from send payload --- app/models/ci/pipeline.rb | 2 ++ db/schema.rb | 2 ++ lib/gitlab/data_builder/pipeline_data_builder.rb | 6 +----- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 9545a6e3ab9..e2663f50dd1 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -19,6 +19,8 @@ module Ci after_save :keep_around_commits + delegate :stages, to: :statuses + # ref can't be HEAD or SHA, can only be branch/tag name scope :latest_successful_for, ->(ref = default_branch) do where(ref: ref).success.order(id: :desc).limit(1) diff --git a/db/schema.rb b/db/schema.rb index 6c85e1e9dba..5b901b17265 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -895,6 +895,7 @@ ActiveRecord::Schema.define(version: 20160810142633) do t.string "category", default: "common", null: false t.boolean "default", default: false t.boolean "wiki_page_events", default: true + t.boolean "pipeline_events", default: false, null: false end add_index "services", ["project_id"], name: "index_services_on_project_id", using: :btree @@ -1098,6 +1099,7 @@ ActiveRecord::Schema.define(version: 20160810142633) do t.boolean "build_events", default: false, null: false t.boolean "wiki_page_events", default: false, null: false t.string "token" + t.boolean "pipeline_events", default: false, null: false end add_index "web_hooks", ["project_id"], name: "index_web_hooks_on_project_id", using: :btree diff --git a/lib/gitlab/data_builder/pipeline_data_builder.rb b/lib/gitlab/data_builder/pipeline_data_builder.rb index a4c770b630f..3dc4d50fcde 100644 --- a/lib/gitlab/data_builder/pipeline_data_builder.rb +++ b/lib/gitlab/data_builder/pipeline_data_builder.rb @@ -15,9 +15,6 @@ module Gitlab end def hook_attrs(pipeline) - first_pending_build = pipeline.builds.first_pending - config_processor = pipeline.config_processor unless pipeline.skip_ci? - { id: pipeline.id, ref: pipeline.ref, @@ -25,8 +22,7 @@ module Gitlab sha: pipeline.sha, before_sha: pipeline.before_sha, status: pipeline.status, - stage: first_pending_build.try(:stage), - stages: config_processor.try(:stages), + stages: pipeline.stages, created_at: pipeline.created_at, finished_at: pipeline.finished_at, duration: pipeline.duration From 99928aca755f4ccf98a58445a0176b80cd16159c Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Thu, 11 Aug 2016 17:23:35 +0200 Subject: [PATCH 037/133] Enhance a pipeline event tests to analyse number of returned builds --- .../pipeline_data_builder_spec.rb | 8 +++- spec/models/ci/pipeline_spec.rb | 39 +++++++++---------- 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/spec/lib/gitlab/data_builder/pipeline_data_builder_spec.rb b/spec/lib/gitlab/data_builder/pipeline_data_builder_spec.rb index 24d39b318c0..8a2f00c4347 100644 --- a/spec/lib/gitlab/data_builder/pipeline_data_builder_spec.rb +++ b/spec/lib/gitlab/data_builder/pipeline_data_builder_spec.rb @@ -3,11 +3,15 @@ require 'spec_helper' describe Gitlab::DataBuilder::PipelineDataBuilder do let(:user) { create(:user) } let(:project) { create(:project) } + let(:pipeline) do create(:ci_pipeline, - project: project, status: 'success', - sha: project.commit.sha, ref: project.default_branch) + project: project, + status: 'success', + sha: project.commit.sha, + ref: project.default_branch) end + let!(:build) { create(:ci_build, pipeline: pipeline) } describe '.build' do diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index ceae2e60f2f..7da044d4f16 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' describe Ci::Pipeline, models: true do let(:project) { FactoryGirl.create :empty_project } - let(:pipeline) { FactoryGirl.create :ci_pipeline, project: project } + let(:pipeline) { FactoryGirl.create :ci_empty_pipeline, project: project } it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:user) } @@ -18,6 +18,8 @@ describe Ci::Pipeline, models: true do it { is_expected.to respond_to :git_author_email } it { is_expected.to respond_to :short_sha } + it { is_expected.to delegate_method(:stages).to(:statuses) } + describe '#valid_commit_sha' do context 'commit.sha can not start with 00000000' do before do @@ -261,43 +263,40 @@ describe Ci::Pipeline, models: true do end before do - WebMock.stub_request(:post, hook.url) - pipeline.touch ProjectWebHookWorker.drain end context 'with pipeline hooks enabled' do let(:enabled) { true } - it 'executes pipeline_hook after touched' do - expect(WebMock).to have_requested(:post, hook.url).once - end - context 'with multiple builds' do - let(:build_a) { create_build('a') } - let(:build_b) { create_build('b') } + let!(:build_a) { create_build('a') } + let!(:build_b) { create_build('b') } - before do + it 'fires exactly 3 hooks' do + stub_request('pending') + build_a.queue + build_b.queue + + stub_request('running') build_a.run build_b.run + + stub_request('success') build_a.success build_b.success end - it 'fires 3 hooks' do - %w(pending running success).each do |status| - expect(WebMock).to requested(status) - end - end - def create_build(name) create(:ci_build, :pending, pipeline: pipeline, name: name) end - def requested(status) - have_requested(:post, hook.url).with do |req| - JSON.parse(req.body)['object_attributes']['status'] == status - end.once + def stub_request(status) + WebMock.stub_request(:post, hook.url).with do |req| + json_body = JSON.parse(req.body) + json_body['object_attributes']['status'] == status && + json_body['builds'].length == 2 + end end end end From 478990bb3ee0aa6939b656763a97d637189f062d Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Thu, 11 Aug 2016 18:37:36 +0200 Subject: [PATCH 038/133] Fix pipeline status change from pending to running --- app/models/ci/pipeline.rb | 3 ++- app/models/commit_status.rb | 6 +++++ spec/models/ci/pipeline_spec.rb | 45 +++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index e2663f50dd1..bf8750ca0f6 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -187,6 +187,7 @@ module Ci def process! Ci::ProcessPipelineService.new(project, user).execute(self) + reload_status! end @@ -197,7 +198,7 @@ module Ci end def reload_status! - statuses.reload + reload self.status = if yaml_errors.blank? statuses.latest.status || 'skipped' diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 20713314a25..3ab44461179 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -76,6 +76,12 @@ class CommitStatus < ActiveRecord::Base commit_status.pipeline.process! if commit_status.pipeline end + + around_transition any => [:pending, :running] do |commit_status, block| + block.call + + commit_status.pipeline.reload_status! if commit_status.pipeline + end end delegate :sha, :short_sha, to: :pipeline diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 7da044d4f16..317f4147545 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -257,6 +257,51 @@ describe Ci::Pipeline, models: true do end end + describe '#status' do + let!(:build) { create(:ci_build, :created, pipeline: pipeline, name: 'test') } + + subject { pipeline.reload.status } + + context 'on queuing' do + before { build.queue } + + it { is_expected.to eq('pending') } + end + + context 'on run' do + before do + build.queue + build.run + end + + it { is_expected.to eq('running') } + end + + context 'on drop' do + before do + build.drop + end + + it { is_expected.to eq('failed') } + end + + context 'on success' do + before do + build.success + end + + it { is_expected.to eq('success') } + end + + context 'on cancel' do + before do + build.cancel + end + + it { is_expected.to eq('canceled') } + end + end + describe '#execute_hooks' do let!(:hook) do create(:project_hook, project: project, pipeline_events: enabled) From e2c01f397f2f00138d2b4e618306ee2aa141c97c Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Thu, 11 Aug 2016 18:37:50 +0200 Subject: [PATCH 039/133] Fix tests for pipeline events --- app/models/ci/pipeline.rb | 4 +- spec/models/ci/pipeline_spec.rb | 68 ++++++++++++++++++++++++--------- 2 files changed, 52 insertions(+), 20 deletions(-) diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index bf8750ca0f6..f8c0e27a5c3 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -208,8 +208,10 @@ module Ci self.started_at = statuses.started_at self.finished_at = statuses.finished_at self.duration = statuses.latest.duration + + should_execute_hooks = status_changed? save - execute_hooks if status_changed? + execute_hooks if should_execute_hooks end private diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 317f4147545..685c4178e89 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' describe Ci::Pipeline, models: true do let(:project) { FactoryGirl.create :empty_project } - let(:pipeline) { FactoryGirl.create :ci_empty_pipeline, project: project } + let(:pipeline) { FactoryGirl.create :ci_empty_pipeline, status: 'created', project: project } it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:user) } @@ -303,6 +303,9 @@ describe Ci::Pipeline, models: true do end describe '#execute_hooks' do + let!(:build_a) { create_build('a') } + let!(:build_b) { create_build('b') } + let!(:hook) do create(:project_hook, project: project, pipeline_events: enabled) end @@ -314,30 +317,48 @@ describe Ci::Pipeline, models: true do context 'with pipeline hooks enabled' do let(:enabled) { true } + before do + WebMock.stub_request(:post, hook.url) + end + context 'with multiple builds' do - let!(:build_a) { create_build('a') } - let!(:build_b) { create_build('b') } + context 'when build is queued' do + before do + build_a.queue + build_b.queue + end - it 'fires exactly 3 hooks' do - stub_request('pending') - build_a.queue - build_b.queue - - stub_request('running') - build_a.run - build_b.run - - stub_request('success') - build_a.success - build_b.success + it 'receive a pending event once' do + expect(WebMock).to requested('pending').once + end end - def create_build(name) - create(:ci_build, :pending, pipeline: pipeline, name: name) + context 'when build is run' do + before do + build_a.queue + build_a.run + build_b.queue + build_b.run + end + + it 'receive a running event once' do + expect(WebMock).to requested('running').once + end end - def stub_request(status) - WebMock.stub_request(:post, hook.url).with do |req| + context 'when all builds succeed' do + before do + build_a.success + build_b.success + end + + it 'receive a success event once' do + expect(WebMock).to requested('success').once + end + end + + def requested(status) + have_requested(:post, hook.url).with do |req| json_body = JSON.parse(req.body) json_body['object_attributes']['status'] == status && json_body['builds'].length == 2 @@ -349,9 +370,18 @@ describe Ci::Pipeline, models: true do context 'with pipeline hooks disabled' do let(:enabled) { false } + before do + build_a.queue + build_b.queue + end + it 'did not execute pipeline_hook after touched' do expect(WebMock).not_to have_requested(:post, hook.url) end end + + def create_build(name) + create(:ci_build, :created, pipeline: pipeline, name: name) + end end end From 49f72e705fa225175834b5e6b2b1f78f1f608b9c Mon Sep 17 00:00:00 2001 From: "Z.J. van de Weg" Date: Tue, 2 Aug 2016 14:01:22 +0200 Subject: [PATCH 040/133] Show deployment status on a MR view --- CHANGELOG | 1 + app/models/deployment.rb | 7 ++++ app/models/environment.rb | 6 +++ app/models/merge_request.rb | 6 +++ .../merge_requests/widget/_heading.html.haml | 40 ++++++++++++------- 5 files changed, 46 insertions(+), 14 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 8cefaf5d70a..7fb3ccb09ab 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -37,6 +37,7 @@ v 8.11.0 (unreleased) - Update `timeago` plugin to use multiple string/locale settings - Remove unused images (ClemMakesApps) - Limit git rev-list output count to one in forced push check + - Show deployment status on merge requests - Clean up unused routes (Josef Strzibny) - Fix issue on empty project to allow developers to only push to protected branches if given permission - Add green outline to New Branch button. !5447 (winniehell) diff --git a/app/models/deployment.rb b/app/models/deployment.rb index 1a7cd60817e..67a4f3998ec 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -36,4 +36,11 @@ class Deployment < ActiveRecord::Base def manual_actions deployable.try(:other_actions) end + + def deployed_to(ref) + commit = project.commit(ref) + return false unless commit + + project.repository.merge_base(commit.id, sha) == commit.id + end end diff --git a/app/models/environment.rb b/app/models/environment.rb index baed106e8c8..f6fdb8d1ecf 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -25,4 +25,10 @@ class Environment < ActiveRecord::Base def nullify_external_url self.external_url = nil if self.external_url.blank? end + + def deployed_to?(ref) + return unless last_deployment + + last_deployment.deployed_to(ref) + end end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index b1fb3ce5d69..85e4d1f6b51 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -590,6 +590,12 @@ class MergeRequest < ActiveRecord::Base !pipeline || pipeline.success? end + def environments + target_project.environments.select do |environment| + environment.deployed_to?(ref_path) + end + end + def state_human_name if merged? "Merged" diff --git a/app/views/projects/merge_requests/widget/_heading.html.haml b/app/views/projects/merge_requests/widget/_heading.html.haml index 6ef640bb654..0581659b742 100644 --- a/app/views/projects/merge_requests/widget/_heading.html.haml +++ b/app/views/projects/merge_requests/widget/_heading.html.haml @@ -1,21 +1,22 @@ - if @pipeline .mr-widget-heading - - %w[success success_with_warnings skipped canceled failed running pending].each do |status| - .ci_widget{ class: "ci-#{status}", style: ("display:none" unless @pipeline.status == status) } - = ci_icon_for_status(status) - %span - CI build - = ci_label_for_status(status) - for - - commit = @merge_request.diff_head_commit - = succeed "." do - = link_to @pipeline.short_sha, namespace_project_commit_path(@merge_request.source_project.namespace, @merge_request.source_project, @pipeline.sha), class: "monospace" - %span.ci-coverage - = link_to "View details", builds_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: "js-show-tab", data: {action: 'builds'} + - @merge_request.environments.each do |environments| + - %w[success success_with_warnings skipped canceled failed running pending].each do |status| + .ci_widget{ class: "ci-#{status}", style: ("display:none" unless @pipeline.status == status) } + = ci_icon_for_status(status) + %span + CI build + = ci_label_for_status(status) + for + - commit = @merge_request.diff_head_commit + = succeed "." do + = link_to @pipeline.short_sha, namespace_project_commit_path(@merge_request.source_project.namespace, @merge_request.source_project, @pipeline.sha), class: "monospace" + %span.ci-coverage + = link_to "View details", builds_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: "js-show-tab", data: {action: 'builds'} - elsif @merge_request.has_ci? - - # Compatibility with old CI integrations (ex jenkins) when you request status from CI server via AJAX - - # Remove in later versions when services like Jenkins will set CI status via Commit status API + // Compatibility with old CI integrations (ex jenkins) when you request status from CI server via AJAX + // Remove in later versions when services like Jenkins will set CI status via Commit status API .mr-widget-heading - %w[success skipped canceled failed running pending].each do |status| .ci_widget{class: "ci-#{status}", style: "display:none"} @@ -42,3 +43,14 @@ .ci_widget.ci-error{style: "display:none"} = icon("times-circle") Could not connect to the CI server. Please check your settings and try again. + +- @merge_request.environments.each do |environment| + .mr-widget-heading + .ci_widget{ class: "ci-success" } + = ci_icon_for_status("success") + %span + Released to #{environment.name} + = succeed '.' do + = time_ago_with_tooltip(environment.created_at, placement: 'bottom') + - if environment.external_url + = link_to icon('external-link'), environment.external_url From 826862d48ef80ddd849b9e3cb05ef37ba7be41e9 Mon Sep 17 00:00:00 2001 From: "Z.J. van de Weg" Date: Wed, 3 Aug 2016 10:22:01 +0200 Subject: [PATCH 041/133] Tests for release status heading on MR#show --- .../merge_requests/widget/_heading.html.haml | 4 ++-- .../merge_requests/_heading.html.haml_spec.rb | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 spec/views/projects/merge_requests/_heading.html.haml_spec.rb diff --git a/app/views/projects/merge_requests/widget/_heading.html.haml b/app/views/projects/merge_requests/widget/_heading.html.haml index 0581659b742..9590c1dbbd1 100644 --- a/app/views/projects/merge_requests/widget/_heading.html.haml +++ b/app/views/projects/merge_requests/widget/_heading.html.haml @@ -15,8 +15,8 @@ = link_to "View details", builds_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: "js-show-tab", data: {action: 'builds'} - elsif @merge_request.has_ci? - // Compatibility with old CI integrations (ex jenkins) when you request status from CI server via AJAX - // Remove in later versions when services like Jenkins will set CI status via Commit status API + - # Compatibility with old CI integrations (ex jenkins) when you request status from CI server via AJAX + - # Remove in later versions when services like Jenkins will set CI status via Commit status API .mr-widget-heading - %w[success skipped canceled failed running pending].each do |status| .ci_widget{class: "ci-#{status}", style: "display:none"} diff --git a/spec/views/projects/merge_requests/_heading.html.haml_spec.rb b/spec/views/projects/merge_requests/_heading.html.haml_spec.rb new file mode 100644 index 00000000000..b78c9c7e9ef --- /dev/null +++ b/spec/views/projects/merge_requests/_heading.html.haml_spec.rb @@ -0,0 +1,23 @@ +require 'spec_helper' + +describe 'projects/merge_requests/widget/_heading' do + include Devise::TestHelpers + + context 'when released to an environment' do + let(:project) { merge_request.target_project } + let(:merge_request) { create(:merge_request, :merged) } + let(:environment) { create(:environment, project: project) } + let!(:deployment) { create(:deployment, environment: environment, + sha: 'a5391128b0ef5d21df5dd23d98557f4ef12fae20') } + + before do + assign(:merge_request, merge_request) + + render + end + + it 'displays that the environment is deployed' do + expect(rendered).to match('Released to') + end + end +end From b497b0ce3fc3c1882639f9c7d55f7991ce41f15d Mon Sep 17 00:00:00 2001 From: "Z.J. van de Weg" Date: Wed, 3 Aug 2016 13:37:39 +0200 Subject: [PATCH 042/133] Incorporate feedback --- CHANGELOG | 2 +- app/models/deployment.rb | 4 ++-- app/models/environment.rb | 6 +++--- app/models/merge_request.rb | 2 +- spec/models/deployment_spec.rb | 20 +++++++++++++++++++ spec/models/environment_spec.rb | 10 ++++++++++ spec/models/merge_request_spec.rb | 15 ++++++++++++++ .../merge_requests/_heading.html.haml_spec.rb | 6 ++++-- 8 files changed, 56 insertions(+), 9 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 7fb3ccb09ab..b26216f33eb 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -37,7 +37,7 @@ v 8.11.0 (unreleased) - Update `timeago` plugin to use multiple string/locale settings - Remove unused images (ClemMakesApps) - Limit git rev-list output count to one in forced push check - - Show deployment status on merge requests + - Show deployment status on merge requests with external URLs - Clean up unused routes (Josef Strzibny) - Fix issue on empty project to allow developers to only push to protected branches if given permission - Add green outline to New Branch button. !5447 (winniehell) diff --git a/app/models/deployment.rb b/app/models/deployment.rb index 67a4f3998ec..19b08f49d96 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -37,10 +37,10 @@ class Deployment < ActiveRecord::Base deployable.try(:other_actions) end - def deployed_to(ref) + def deployed_to?(ref) commit = project.commit(ref) return false unless commit - project.repository.merge_base(commit.id, sha) == commit.id + project.repository.is_ancestor?(commit.id, sha) end end diff --git a/app/models/environment.rb b/app/models/environment.rb index f6fdb8d1ecf..7247125f8a0 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -26,9 +26,9 @@ class Environment < ActiveRecord::Base self.external_url = nil if self.external_url.blank? end - def deployed_to?(ref) - return unless last_deployment + def deployed_from?(ref) + return false unless last_deployment - last_deployment.deployed_to(ref) + last_deployment.deployed_to?(ref) end end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 85e4d1f6b51..945b0d76505 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -592,7 +592,7 @@ class MergeRequest < ActiveRecord::Base def environments target_project.environments.select do |environment| - environment.deployed_to?(ref_path) + environment.deployed_from?(ref_path) end end diff --git a/spec/models/deployment_spec.rb b/spec/models/deployment_spec.rb index 7df3df4bb9e..107f8b38acf 100644 --- a/spec/models/deployment_spec.rb +++ b/spec/models/deployment_spec.rb @@ -15,4 +15,24 @@ describe Deployment, models: true do it { is_expected.to validate_presence_of(:ref) } it { is_expected.to validate_presence_of(:sha) } + + describe '#deployed_to?' do + let(:project) { create(:project) } + let(:environment) { create(:environment, project: project) } + let(:deployment) do + create(:deployment, environment: environment, sha: '5f923865dde3436854e9ceb9cdb7815618d4e849') + end + + context 'when there is no project commit' do + it 'returns false' do + expect(deployment.deployed_to?('random-branch')).to be false + end + end + + context 'when they share the same tree branch' do + it 'returns true' do + expect(deployment.deployed_to?('HEAD')).to be true + end + end + end end diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb index 8a84ac0a7c7..e65b4f82eff 100644 --- a/spec/models/environment_spec.rb +++ b/spec/models/environment_spec.rb @@ -30,4 +30,14 @@ describe Environment, models: true do expect(env.external_url).to be_nil end end + + describe '#deployed_from?' do + let(:environment) { create(:environment) } + + context 'without a last deployment' do + it "returns false" do + expect(environment.deployed_from?('HEAD')).to be false + end + end + end end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 3270b877c1a..0727dd29951 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -674,6 +674,21 @@ describe MergeRequest, models: true do end end + describe "#environments" do + let(:project) { create(:project) } + + let!(:deployment) { create(:deployment, environment: environment, sha: '5f923865dde3436854e9ceb9cdb7815618d4e849') } + + let!(:environment) { create(:environment, project: project) } + let!(:environment1) { create(:environment, project: project) } + + let(:merge_request) { create(:merge_request, source_project: project) } + + it 'selects deployed environments' do + expect(merge_request.environments).to eq [environment] + end + end + describe "#reload_diff" do let(:note) { create(:diff_note_on_merge_request, project: subject.project, noteable: subject) } diff --git a/spec/views/projects/merge_requests/_heading.html.haml_spec.rb b/spec/views/projects/merge_requests/_heading.html.haml_spec.rb index b78c9c7e9ef..843a496f4c3 100644 --- a/spec/views/projects/merge_requests/_heading.html.haml_spec.rb +++ b/spec/views/projects/merge_requests/_heading.html.haml_spec.rb @@ -7,8 +7,10 @@ describe 'projects/merge_requests/widget/_heading' do let(:project) { merge_request.target_project } let(:merge_request) { create(:merge_request, :merged) } let(:environment) { create(:environment, project: project) } - let!(:deployment) { create(:deployment, environment: environment, - sha: 'a5391128b0ef5d21df5dd23d98557f4ef12fae20') } + let!(:deployment) do + create(:deployment, environment: environment, + sha: 'a5391128b0ef5d21df5dd23d98557f4ef12fae20') + end before do assign(:merge_request, merge_request) From 03ea01946524a74773b24430c81804c2724b84b6 Mon Sep 17 00:00:00 2001 From: "Z.J. van de Weg" Date: Thu, 4 Aug 2016 11:29:41 +0200 Subject: [PATCH 043/133] CI build status not per environment --- .../merge_requests/widget/_heading.html.haml | 36 +++++++++---------- spec/models/deployment_spec.rb | 3 +- spec/models/merge_request_spec.rb | 5 ++- .../merge_requests/_heading.html.haml_spec.rb | 2 +- 4 files changed, 24 insertions(+), 22 deletions(-) diff --git a/app/views/projects/merge_requests/widget/_heading.html.haml b/app/views/projects/merge_requests/widget/_heading.html.haml index 9590c1dbbd1..16e923b831c 100644 --- a/app/views/projects/merge_requests/widget/_heading.html.haml +++ b/app/views/projects/merge_requests/widget/_heading.html.haml @@ -1,18 +1,17 @@ - if @pipeline .mr-widget-heading - - @merge_request.environments.each do |environments| - - %w[success success_with_warnings skipped canceled failed running pending].each do |status| - .ci_widget{ class: "ci-#{status}", style: ("display:none" unless @pipeline.status == status) } - = ci_icon_for_status(status) - %span - CI build - = ci_label_for_status(status) - for - - commit = @merge_request.diff_head_commit - = succeed "." do - = link_to @pipeline.short_sha, namespace_project_commit_path(@merge_request.source_project.namespace, @merge_request.source_project, @pipeline.sha), class: "monospace" - %span.ci-coverage - = link_to "View details", builds_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: "js-show-tab", data: {action: 'builds'} + - %w[success success_with_warnings skipped canceled failed running pending].each do |status| + .ci_widget{ class: "ci-#{status}", style: ("display:none" unless @pipeline.status == status) } + = ci_icon_for_status(status) + %span + CI build + = ci_label_for_status(status) + for + - commit = @merge_request.diff_head_commit + = succeed "." do + = link_to @pipeline.short_sha, namespace_project_commit_path(@merge_request.source_project.namespace, @merge_request.source_project, @pipeline.sha), class: "monospace" + %span.ci-coverage + = link_to "View details", builds_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: "js-show-tab", data: {action: 'builds'} - elsif @merge_request.has_ci? - # Compatibility with old CI integrations (ex jenkins) when you request status from CI server via AJAX @@ -48,9 +47,8 @@ .mr-widget-heading .ci_widget{ class: "ci-success" } = ci_icon_for_status("success") - %span - Released to #{environment.name} - = succeed '.' do - = time_ago_with_tooltip(environment.created_at, placement: 'bottom') - - if environment.external_url - = link_to icon('external-link'), environment.external_url + %span.hidden-sm + Released to #{environment.name}. + - external_url = environment.external_url + - if external_url + = link_to icon('external-link', text: "View on #{external_url.gsub(/\A.*?:\/\//, '')}"), external_url diff --git a/spec/models/deployment_spec.rb b/spec/models/deployment_spec.rb index 107f8b38acf..d7cb142dd32 100644 --- a/spec/models/deployment_spec.rb +++ b/spec/models/deployment_spec.rb @@ -20,7 +20,8 @@ describe Deployment, models: true do let(:project) { create(:project) } let(:environment) { create(:environment, project: project) } let(:deployment) do - create(:deployment, environment: environment, sha: '5f923865dde3436854e9ceb9cdb7815618d4e849') + create(:deployment, environment: environment, + sha: '5f923865dde3436854e9ceb9cdb7815618d4e849') end context 'when there is no project commit' do diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 0727dd29951..e605720a2dd 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -677,7 +677,10 @@ describe MergeRequest, models: true do describe "#environments" do let(:project) { create(:project) } - let!(:deployment) { create(:deployment, environment: environment, sha: '5f923865dde3436854e9ceb9cdb7815618d4e849') } + let!(:deployment) do + create(:deployment, environment: environment, + sha: '5f923865dde3436854e9ceb9cdb7815618d4e849') + end let!(:environment) { create(:environment, project: project) } let!(:environment1) { create(:environment, project: project) } diff --git a/spec/views/projects/merge_requests/_heading.html.haml_spec.rb b/spec/views/projects/merge_requests/_heading.html.haml_spec.rb index 843a496f4c3..0302f14e660 100644 --- a/spec/views/projects/merge_requests/_heading.html.haml_spec.rb +++ b/spec/views/projects/merge_requests/_heading.html.haml_spec.rb @@ -19,7 +19,7 @@ describe 'projects/merge_requests/widget/_heading' do end it 'displays that the environment is deployed' do - expect(rendered).to match('Released to') + expect(rendered).to match("Released to #{environment.name}") end end end From c42f5f8b56a919473d9adceaf84c9ef77179c5cb Mon Sep 17 00:00:00 2001 From: James Lopez Date: Thu, 11 Aug 2016 21:42:34 +0200 Subject: [PATCH 044/133] refactor parse_hash based on feedback --- lib/gitlab/import_export/json_hash_builder.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/gitlab/import_export/json_hash_builder.rb b/lib/gitlab/import_export/json_hash_builder.rb index b7db0e7a06a..0cc10f40087 100644 --- a/lib/gitlab/import_export/json_hash_builder.rb +++ b/lib/gitlab/import_export/json_hash_builder.rb @@ -57,17 +57,17 @@ module Gitlab # +value+ existing model to be included in the hash # +json_config_hash+ the original hash containing the root model def create_model_value(current_key, value, json_config_hash) - json_config_hash[current_key] = parse_hash(value, { include: value }) + json_config_hash[current_key] = parse_hash(value) || { include: value } end # Calls attributes finder to parse the hash and add any attributes to it # # +value+ existing model to be included in the hash # +parsed_hash+ the original hash - def parse_hash(value, parsed_hash) + def parse_hash(value) @attributes_finder.parse(value) do |hash| { include: hash_or_merge(value, hash) } - end || parsed_hash + end end # Adds new model configuration to an existing hash with key +current_key+ From 5f5503fb94abc3703c0ff08ad5b7f6be0bf4b7ac Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Fri, 12 Aug 2016 15:11:16 +0800 Subject: [PATCH 045/133] Make the comment more clear, feedback: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5620#note_13810778 --- spec/helpers/notes_helper_spec.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spec/helpers/notes_helper_spec.rb b/spec/helpers/notes_helper_spec.rb index fb0ace73707..853b8b2f7f7 100644 --- a/spec/helpers/notes_helper_spec.rb +++ b/spec/helpers/notes_helper_spec.rb @@ -39,7 +39,9 @@ describe NotesHelper do describe '#preload_max_access_for_authors' do before do - RequestStore.clear! # make sure cache were cleared + # #preload_max_access_for_authors would read cache from RequestStore, + # so we should make sure it's clean. + RequestStore.clear! end it 'loads multiple users' do From ce4a669d03d5d1b1982361333cff647c0cd114e9 Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Fri, 12 Aug 2016 15:16:14 +0800 Subject: [PATCH 046/133] Prefer described_class, feedback: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5620#note_13810811 And other similar places. --- spec/lib/gitlab/data_builder/build_data_builder_spec.rb | 2 +- spec/lib/gitlab/data_builder/note_data_builder_spec.rb | 2 +- spec/lib/gitlab/data_builder/pipeline_data_builder_spec.rb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/lib/gitlab/data_builder/build_data_builder_spec.rb b/spec/lib/gitlab/data_builder/build_data_builder_spec.rb index 41b9207df2d..359e6e09d10 100644 --- a/spec/lib/gitlab/data_builder/build_data_builder_spec.rb +++ b/spec/lib/gitlab/data_builder/build_data_builder_spec.rb @@ -5,7 +5,7 @@ describe Gitlab::DataBuilder::BuildDataBuilder do describe '.build' do let(:data) do - Gitlab::DataBuilder::BuildDataBuilder.build(build) + described_class.build(build) end it { expect(data).to be_a(Hash) } diff --git a/spec/lib/gitlab/data_builder/note_data_builder_spec.rb b/spec/lib/gitlab/data_builder/note_data_builder_spec.rb index bc5d6cdc358..56b2312007d 100644 --- a/spec/lib/gitlab/data_builder/note_data_builder_spec.rb +++ b/spec/lib/gitlab/data_builder/note_data_builder_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe Gitlab::DataBuilder::NoteDataBuilder, lib: true do let(:project) { create(:project) } let(:user) { create(:user) } - let(:data) { Gitlab::DataBuilder::NoteDataBuilder.build(note, user) } + let(:data) { described_class.build(note, user) } let(:fixed_time) { Time.at(1425600000) } # Avoid time precision errors before(:each) do diff --git a/spec/lib/gitlab/data_builder/pipeline_data_builder_spec.rb b/spec/lib/gitlab/data_builder/pipeline_data_builder_spec.rb index 8a2f00c4347..a35c53c4054 100644 --- a/spec/lib/gitlab/data_builder/pipeline_data_builder_spec.rb +++ b/spec/lib/gitlab/data_builder/pipeline_data_builder_spec.rb @@ -15,7 +15,7 @@ describe Gitlab::DataBuilder::PipelineDataBuilder do let!(:build) { create(:ci_build, pipeline: pipeline) } describe '.build' do - let(:data) { Gitlab::DataBuilder::PipelineDataBuilder.build(pipeline) } + let(:data) { described_class.build(pipeline) } let(:attributes) { data[:object_attributes] } let(:build_data) { data[:builds].first } let(:project_data) { data[:project] } From aaf30b4e8180588fbd26c11967ec036081f0a87e Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Fri, 12 Aug 2016 15:19:28 +0800 Subject: [PATCH 047/133] if -> when; when -> `when`; %w() -> %w[]; and fix some typos: Feedback: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5620#note_13810945 --- spec/models/build_spec.rb | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/spec/models/build_spec.rb b/spec/models/build_spec.rb index 395316a8766..3d66ccf3f28 100644 --- a/spec/models/build_spec.rb +++ b/spec/models/build_spec.rb @@ -42,7 +42,7 @@ describe Ci::Build, models: true do describe '#ignored?' do subject { build.ignored? } - context 'if build is not allowed to fail' do + context 'when build is not allowed to fail' do before do build.allow_failure = false end @@ -64,7 +64,7 @@ describe Ci::Build, models: true do end end - context 'if build is allowed to fail' do + context 'when build is allowed to fail' do before do build.allow_failure = true end @@ -92,7 +92,7 @@ describe Ci::Build, models: true do it { is_expected.to be_empty } - context 'if build.trace contains text' do + context 'when build.trace contains text' do let(:text) { 'example output' } before do build.trace = text @@ -102,7 +102,7 @@ describe Ci::Build, models: true do it { expect(subject.length).to be >= text.length } end - context 'if build.trace hides token' do + context 'when build.trace hides token' do let(:token) { 'my_secret_token' } before do @@ -284,13 +284,13 @@ describe Ci::Build, models: true do stub_ci_pipeline_yaml_file(config) end - context 'if config is not found' do + context 'when config is not found' do let(:config) { nil } it { is_expected.to eq(predefined_variables) } end - context 'if config does not have a questioned job' do + context 'when config does not have a questioned job' do let(:config) do YAML.dump({ test_other: { @@ -302,7 +302,7 @@ describe Ci::Build, models: true do it { is_expected.to eq(predefined_variables) } end - context 'if config has variables' do + context 'when config has variables' do let(:config) do YAML.dump({ test: { @@ -394,7 +394,7 @@ describe Ci::Build, models: true do it { is_expected.to be_falsey } end - context 'if there are runner' do + context 'when there are runners' do let(:runner) { create(:ci_runner) } before do @@ -424,8 +424,8 @@ describe Ci::Build, models: true do describe '#stuck?' do subject { build.stuck? } - %w(pending).each do |state| - context "if commit_status.status is #{state}" do + %w[pending].each do |state| + context "when commit_status.status is #{state}" do before do build.status = state end @@ -445,8 +445,8 @@ describe Ci::Build, models: true do end end - %w(success failed canceled running).each do |state| - context "if commit_status.status is #{state}" do + %w[success failed canceled running].each do |state| + context "when commit_status.status is #{state}" do before do build.status = state end @@ -768,7 +768,7 @@ describe Ci::Build, models: true do describe '#when' do subject { build.when } - context 'if is undefined' do + context 'when `when` is undefined' do before do build.when = nil end @@ -778,13 +778,13 @@ describe Ci::Build, models: true do stub_ci_pipeline_yaml_file(config) end - context 'if config is not found' do + context 'when config is not found' do let(:config) { nil } it { is_expected.to eq('on_success') } end - context 'if config does not have a questioned job' do + context 'when config does not have a questioned job' do let(:config) do YAML.dump({ test_other: { @@ -796,7 +796,7 @@ describe Ci::Build, models: true do it { is_expected.to eq('on_success') } end - context 'if config has when' do + context 'when config has `when`' do let(:config) do YAML.dump({ test: { @@ -882,7 +882,7 @@ describe Ci::Build, models: true do subject { build.play } - it 'enques a build' do + it 'enqueues a build' do is_expected.to be_pending is_expected.to eq(build) end From abd3ac4a038a83cce5db2aa93f685c921710abba Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Fri, 12 Aug 2016 15:29:49 +0800 Subject: [PATCH 048/133] Make it more grammatically correct, feedback: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5620#note_13811203 --- spec/models/ci/pipeline_spec.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 685c4178e89..2266b954aeb 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -329,7 +329,7 @@ describe Ci::Pipeline, models: true do end it 'receive a pending event once' do - expect(WebMock).to requested('pending').once + expect(WebMock).to have_requested_pipeline_hook('pending').once end end @@ -342,7 +342,7 @@ describe Ci::Pipeline, models: true do end it 'receive a running event once' do - expect(WebMock).to requested('running').once + expect(WebMock).to have_requested_pipeline_hook('running').once end end @@ -353,11 +353,11 @@ describe Ci::Pipeline, models: true do end it 'receive a success event once' do - expect(WebMock).to requested('success').once + expect(WebMock).to have_requested_pipeline_hook('success').once end end - def requested(status) + def have_requested_pipeline_hook(status) have_requested(:post, hook.url).with do |req| json_body = JSON.parse(req.body) json_body['object_attributes']['status'] == status && From 0a20897bbee538761352595e8f632c747b6d1b35 Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Fri, 12 Aug 2016 15:33:28 +0800 Subject: [PATCH 049/133] Prefer extend self over module_function, feedback: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5620#note_13672004 https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5620#note_13810498 --- lib/gitlab/data_builder/build_data_builder.rb | 2 +- lib/gitlab/data_builder/note_data_builder.rb | 2 +- lib/gitlab/data_builder/pipeline_data_builder.rb | 2 +- lib/gitlab/data_builder/push_data_builder.rb | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/gitlab/data_builder/build_data_builder.rb b/lib/gitlab/data_builder/build_data_builder.rb index 5175645e238..1b42769bf5b 100644 --- a/lib/gitlab/data_builder/build_data_builder.rb +++ b/lib/gitlab/data_builder/build_data_builder.rb @@ -1,7 +1,7 @@ module Gitlab module DataBuilder module BuildDataBuilder - module_function + extend self def build(build) project = build.project diff --git a/lib/gitlab/data_builder/note_data_builder.rb b/lib/gitlab/data_builder/note_data_builder.rb index 12ae1b99f9c..78dc583abc7 100644 --- a/lib/gitlab/data_builder/note_data_builder.rb +++ b/lib/gitlab/data_builder/note_data_builder.rb @@ -1,7 +1,7 @@ module Gitlab module DataBuilder module NoteDataBuilder - module_function + extend self # Produce a hash of post-receive data # diff --git a/lib/gitlab/data_builder/pipeline_data_builder.rb b/lib/gitlab/data_builder/pipeline_data_builder.rb index 3dc4d50fcde..1cba74c7065 100644 --- a/lib/gitlab/data_builder/pipeline_data_builder.rb +++ b/lib/gitlab/data_builder/pipeline_data_builder.rb @@ -1,7 +1,7 @@ module Gitlab module DataBuilder module PipelineDataBuilder - module_function + extend self def build(pipeline) { diff --git a/lib/gitlab/data_builder/push_data_builder.rb b/lib/gitlab/data_builder/push_data_builder.rb index f0cad51dd36..f0debe7b19f 100644 --- a/lib/gitlab/data_builder/push_data_builder.rb +++ b/lib/gitlab/data_builder/push_data_builder.rb @@ -1,7 +1,7 @@ module Gitlab module DataBuilder module PushDataBuilder - module_function + extend self # Produce a hash of post-receive data # From d5264e8804bca70e613c418a9d346f5787c6fc7a Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Fri, 12 Aug 2016 16:09:29 +0800 Subject: [PATCH 050/133] Simplify the name for data builder, feedback: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5620#note_13671791 --- app/models/ci/build.rb | 2 +- app/models/ci/pipeline.rb | 2 +- .../project_services/builds_email_service.rb | 3 +-- app/models/service.rb | 2 +- app/services/delete_branch_service.rb | 9 +++++++-- app/services/delete_tag_service.rb | 9 +++++++-- app/services/git_push_service.rb | 18 +++++++++++++---- app/services/git_tag_push_service.rb | 20 +++++++++++++++---- app/services/notes/post_process_service.rb | 2 +- app/services/test_hook_service.rb | 3 +-- .../{build_data_builder.rb => build.rb} | 2 +- .../{note_data_builder.rb => note.rb} | 2 +- .../{pipeline_data_builder.rb => pipeline.rb} | 2 +- .../{push_data_builder.rb => push.rb} | 2 +- ...ild_data_builder_spec.rb => build_spec.rb} | 2 +- ...note_data_builder_spec.rb => note_spec.rb} | 2 +- ..._data_builder_spec.rb => pipeline_spec.rb} | 2 +- ...push_data_builder_spec.rb => push_spec.rb} | 2 +- .../project_services/assembla_service_spec.rb | 3 +-- .../builds_email_service_spec.rb | 6 +++--- .../project_services/campfire_service_spec.rb | 2 +- .../project_services/drone_ci_service_spec.rb | 2 +- .../project_services/flowdock_service_spec.rb | 3 +-- .../gemnasium_service_spec.rb | 3 +-- .../project_services/hipchat_service_spec.rb | 14 ++++++------- .../project_services/irker_service_spec.rb | 2 +- .../project_services/jira_service_spec.rb | 3 +-- .../project_services/pushover_service_spec.rb | 2 +- .../project_services/slack_service_spec.rb | 14 ++++++------- spec/models/user_spec.rb | 2 +- spec/workers/build_email_worker_spec.rb | 2 +- spec/workers/emails_on_push_worker_spec.rb | 4 +--- 32 files changed, 85 insertions(+), 63 deletions(-) rename lib/gitlab/data_builder/{build_data_builder.rb => build.rb} (98%) rename lib/gitlab/data_builder/{note_data_builder.rb => note.rb} (98%) rename lib/gitlab/data_builder/{pipeline_data_builder.rb => pipeline.rb} (98%) rename lib/gitlab/data_builder/{push_data_builder.rb => push.rb} (99%) rename spec/lib/gitlab/data_builder/{build_data_builder_spec.rb => build_spec.rb} (92%) rename spec/lib/gitlab/data_builder/{note_data_builder_spec.rb => note_spec.rb} (98%) rename spec/lib/gitlab/data_builder/{pipeline_data_builder_spec.rb => pipeline_spec.rb} (95%) rename spec/lib/gitlab/data_builder/{push_data_builder_spec.rb => push_spec.rb} (97%) diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 05b11f3b115..e2b0b996b6f 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -343,7 +343,7 @@ module Ci def execute_hooks return unless project - build_data = Gitlab::DataBuilder::BuildDataBuilder.build(self) + build_data = Gitlab::DataBuilder::Build.build(self) project.execute_hooks(build_data.dup, :build_hooks) project.execute_services(build_data.dup, :build_hooks) project.running_or_pending_build_count(force: true) diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index f8c0e27a5c3..2bfe8aa5ddd 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -222,7 +222,7 @@ module Ci end def pipeline_data - Gitlab::DataBuilder::PipelineDataBuilder.build(self) + Gitlab::DataBuilder::Pipeline.build(self) end def keep_around_commits diff --git a/app/models/project_services/builds_email_service.rb b/app/models/project_services/builds_email_service.rb index bf8c68244a1..fa66e5864b8 100644 --- a/app/models/project_services/builds_email_service.rb +++ b/app/models/project_services/builds_email_service.rb @@ -51,8 +51,7 @@ class BuildsEmailService < Service end def test_data(project = nil, user = nil) - build = project.builds.last - Gitlab::DataBuilder::BuildDataBuilder.build(build) + Gitlab::DataBuilder::Build.build(project.builds.last) end def fields diff --git a/app/models/service.rb b/app/models/service.rb index 76f588f234d..09b4717a523 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -80,7 +80,7 @@ class Service < ActiveRecord::Base end def test_data(project, user) - Gitlab::DataBuilder::PushDataBuilder.build_sample(project, user) + Gitlab::DataBuilder::Push.build_sample(project, user) end def event_channel_names diff --git a/app/services/delete_branch_service.rb b/app/services/delete_branch_service.rb index 33c0fdc3c9d..918eddaa53a 100644 --- a/app/services/delete_branch_service.rb +++ b/app/services/delete_branch_service.rb @@ -39,7 +39,12 @@ class DeleteBranchService < BaseService end def build_push_data(branch) - Gitlab::DataBuilder::PushDataBuilder - .build(project, current_user, branch.target.sha, Gitlab::Git::BLANK_SHA, "#{Gitlab::Git::BRANCH_REF_PREFIX}#{branch.name}", []) + Gitlab::DataBuilder::Push.build( + project, + current_user, + branch.target.sha, + Gitlab::Git::BLANK_SHA, + "#{Gitlab::Git::BRANCH_REF_PREFIX}#{branch.name}", + []) end end diff --git a/app/services/delete_tag_service.rb b/app/services/delete_tag_service.rb index 41f8006b46c..d0cb151a010 100644 --- a/app/services/delete_tag_service.rb +++ b/app/services/delete_tag_service.rb @@ -33,7 +33,12 @@ class DeleteTagService < BaseService end def build_push_data(tag) - Gitlab::DataBuilder::PushDataBuilder - .build(project, current_user, tag.target.sha, Gitlab::Git::BLANK_SHA, "#{Gitlab::Git::TAG_REF_PREFIX}#{tag.name}", []) + Gitlab::DataBuilder::Push.build( + project, + current_user, + tag.target.sha, + Gitlab::Git::BLANK_SHA, + "#{Gitlab::Git::TAG_REF_PREFIX}#{tag.name}", + []) end end diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb index b7c5cfb58b4..e3f25ff1597 100644 --- a/app/services/git_push_service.rb +++ b/app/services/git_push_service.rb @@ -138,13 +138,23 @@ class GitPushService < BaseService end def build_push_data - @push_data ||= Gitlab::DataBuilder::PushDataBuilder. - build(@project, current_user, params[:oldrev], params[:newrev], params[:ref], push_commits) + @push_data ||= Gitlab::DataBuilder::Push.build( + @project, + current_user, + params[:oldrev], + params[:newrev], + params[:ref], + push_commits) end def build_push_data_system_hook - @push_data_system ||= Gitlab::DataBuilder::PushDataBuilder. - build(@project, current_user, params[:oldrev], params[:newrev], params[:ref], []) + @push_data_system ||= Gitlab::DataBuilder::Push.build( + @project, + current_user, + params[:oldrev], + params[:newrev], + params[:ref], + []) end def push_to_existing_branch? diff --git a/app/services/git_tag_push_service.rb b/app/services/git_tag_push_service.rb index a578aaaa3b1..e6002b03b93 100644 --- a/app/services/git_tag_push_service.rb +++ b/app/services/git_tag_push_service.rb @@ -34,12 +34,24 @@ class GitTagPushService < BaseService end end - Gitlab::DataBuilder::PushDataBuilder. - build(project, current_user, params[:oldrev], params[:newrev], params[:ref], commits, message) + Gitlab::DataBuilder::Push.build( + project, + current_user, + params[:oldrev], + params[:newrev], + params[:ref], + commits, + message) end def build_system_push_data - Gitlab::DataBuilder::PushDataBuilder. - build(project, current_user, params[:oldrev], params[:newrev], params[:ref], [], '') + Gitlab::DataBuilder::Push.build( + project, + current_user, + params[:oldrev], + params[:newrev], + params[:ref], + [], + '') end end diff --git a/app/services/notes/post_process_service.rb b/app/services/notes/post_process_service.rb index a9ee7949936..e4cd3fc7833 100644 --- a/app/services/notes/post_process_service.rb +++ b/app/services/notes/post_process_service.rb @@ -16,7 +16,7 @@ module Notes end def hook_data - Gitlab::DataBuilder::NoteDataBuilder.build(@note, @note.author) + Gitlab::DataBuilder::Note.build(@note, @note.author) end def execute_note_hooks diff --git a/app/services/test_hook_service.rb b/app/services/test_hook_service.rb index 60b85882092..280c81f7d2d 100644 --- a/app/services/test_hook_service.rb +++ b/app/services/test_hook_service.rb @@ -1,7 +1,6 @@ class TestHookService def execute(hook, current_user) - data = Gitlab::DataBuilder::PushDataBuilder. - build_sample(hook.project, current_user) + data = Gitlab::DataBuilder::Push.build_sample(hook.project, current_user) hook.execute(data, 'push_hooks') end end diff --git a/lib/gitlab/data_builder/build_data_builder.rb b/lib/gitlab/data_builder/build.rb similarity index 98% rename from lib/gitlab/data_builder/build_data_builder.rb rename to lib/gitlab/data_builder/build.rb index 1b42769bf5b..6548e6475c6 100644 --- a/lib/gitlab/data_builder/build_data_builder.rb +++ b/lib/gitlab/data_builder/build.rb @@ -1,6 +1,6 @@ module Gitlab module DataBuilder - module BuildDataBuilder + module Build extend self def build(build) diff --git a/lib/gitlab/data_builder/note_data_builder.rb b/lib/gitlab/data_builder/note.rb similarity index 98% rename from lib/gitlab/data_builder/note_data_builder.rb rename to lib/gitlab/data_builder/note.rb index 78dc583abc7..50fea1232af 100644 --- a/lib/gitlab/data_builder/note_data_builder.rb +++ b/lib/gitlab/data_builder/note.rb @@ -1,6 +1,6 @@ module Gitlab module DataBuilder - module NoteDataBuilder + module Note extend self # Produce a hash of post-receive data diff --git a/lib/gitlab/data_builder/pipeline_data_builder.rb b/lib/gitlab/data_builder/pipeline.rb similarity index 98% rename from lib/gitlab/data_builder/pipeline_data_builder.rb rename to lib/gitlab/data_builder/pipeline.rb index 1cba74c7065..06a783ebc1c 100644 --- a/lib/gitlab/data_builder/pipeline_data_builder.rb +++ b/lib/gitlab/data_builder/pipeline.rb @@ -1,6 +1,6 @@ module Gitlab module DataBuilder - module PipelineDataBuilder + module Pipeline extend self def build(pipeline) diff --git a/lib/gitlab/data_builder/push_data_builder.rb b/lib/gitlab/data_builder/push.rb similarity index 99% rename from lib/gitlab/data_builder/push_data_builder.rb rename to lib/gitlab/data_builder/push.rb index f0debe7b19f..4f81863da35 100644 --- a/lib/gitlab/data_builder/push_data_builder.rb +++ b/lib/gitlab/data_builder/push.rb @@ -1,6 +1,6 @@ module Gitlab module DataBuilder - module PushDataBuilder + module Push extend self # Produce a hash of post-receive data diff --git a/spec/lib/gitlab/data_builder/build_data_builder_spec.rb b/spec/lib/gitlab/data_builder/build_spec.rb similarity index 92% rename from spec/lib/gitlab/data_builder/build_data_builder_spec.rb rename to spec/lib/gitlab/data_builder/build_spec.rb index 359e6e09d10..6c71e98066b 100644 --- a/spec/lib/gitlab/data_builder/build_data_builder_spec.rb +++ b/spec/lib/gitlab/data_builder/build_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Gitlab::DataBuilder::BuildDataBuilder do +describe Gitlab::DataBuilder::Build do let(:build) { create(:ci_build) } describe '.build' do diff --git a/spec/lib/gitlab/data_builder/note_data_builder_spec.rb b/spec/lib/gitlab/data_builder/note_spec.rb similarity index 98% rename from spec/lib/gitlab/data_builder/note_data_builder_spec.rb rename to spec/lib/gitlab/data_builder/note_spec.rb index 56b2312007d..9a4dec91e56 100644 --- a/spec/lib/gitlab/data_builder/note_data_builder_spec.rb +++ b/spec/lib/gitlab/data_builder/note_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Gitlab::DataBuilder::NoteDataBuilder, lib: true do +describe Gitlab::DataBuilder::Note, lib: true do let(:project) { create(:project) } let(:user) { create(:user) } let(:data) { described_class.build(note, user) } diff --git a/spec/lib/gitlab/data_builder/pipeline_data_builder_spec.rb b/spec/lib/gitlab/data_builder/pipeline_spec.rb similarity index 95% rename from spec/lib/gitlab/data_builder/pipeline_data_builder_spec.rb rename to spec/lib/gitlab/data_builder/pipeline_spec.rb index a35c53c4054..a68f5943a6a 100644 --- a/spec/lib/gitlab/data_builder/pipeline_data_builder_spec.rb +++ b/spec/lib/gitlab/data_builder/pipeline_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Gitlab::DataBuilder::PipelineDataBuilder do +describe Gitlab::DataBuilder::Pipeline do let(:user) { create(:user) } let(:project) { create(:project) } diff --git a/spec/lib/gitlab/data_builder/push_data_builder_spec.rb b/spec/lib/gitlab/data_builder/push_spec.rb similarity index 97% rename from spec/lib/gitlab/data_builder/push_data_builder_spec.rb rename to spec/lib/gitlab/data_builder/push_spec.rb index beb3e0eda7e..b73434e8dd7 100644 --- a/spec/lib/gitlab/data_builder/push_data_builder_spec.rb +++ b/spec/lib/gitlab/data_builder/push_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Gitlab::DataBuilder::PushDataBuilder, lib: true do +describe Gitlab::DataBuilder::Push, lib: true do let(:project) { create(:project) } let(:user) { create(:user) } diff --git a/spec/models/project_services/assembla_service_spec.rb b/spec/models/project_services/assembla_service_spec.rb index d20decc13a7..d672d80156c 100644 --- a/spec/models/project_services/assembla_service_spec.rb +++ b/spec/models/project_services/assembla_service_spec.rb @@ -39,8 +39,7 @@ describe AssemblaService, models: true do token: 'verySecret', subdomain: 'project_name' ) - @sample_data = Gitlab::DataBuilder::PushDataBuilder. - build_sample(project, user) + @sample_data = Gitlab::DataBuilder::Push.build_sample(project, user) @api_url = 'https://atlas.assembla.com/spaces/project_name/github_tool?secret_key=verySecret' WebMock.stub_request(:post, @api_url) end diff --git a/spec/models/project_services/builds_email_service_spec.rb b/spec/models/project_services/builds_email_service_spec.rb index 2c8c842babe..0194f9e2563 100644 --- a/spec/models/project_services/builds_email_service_spec.rb +++ b/spec/models/project_services/builds_email_service_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' describe BuildsEmailService do let(:data) do - Gitlab::DataBuilder::BuildDataBuilder.build(create(:ci_build)) + Gitlab::DataBuilder::Build.build(create(:ci_build)) end describe 'Validations' do @@ -41,7 +41,7 @@ describe BuildsEmailService do describe '#test' do it 'sends email' do - data = Gitlab::DataBuilder::BuildDataBuilder.build(create(:ci_build)) + data = Gitlab::DataBuilder::Build.build(create(:ci_build)) subject.recipients = 'test@gitlab.com' expect(BuildEmailWorker).to receive(:perform_async) @@ -51,7 +51,7 @@ describe BuildsEmailService do context 'notify only failed builds is true' do it 'sends email' do - data = Gitlab::DataBuilder::BuildDataBuilder.build(create(:ci_build)) + data = Gitlab::DataBuilder::Build.build(create(:ci_build)) data[:build_status] = "success" subject.recipients = 'test@gitlab.com' diff --git a/spec/models/project_services/campfire_service_spec.rb b/spec/models/project_services/campfire_service_spec.rb index 1adf93258f3..c76ae21421b 100644 --- a/spec/models/project_services/campfire_service_spec.rb +++ b/spec/models/project_services/campfire_service_spec.rb @@ -54,7 +54,7 @@ describe CampfireService, models: true do subdomain: 'project-name', room: 'test-room' ) - @sample_data = Gitlab::PushDataBuilder.build_sample(project, user) + @sample_data = Gitlab::DataBuilder::Push.build_sample(project, user) @rooms_url = 'https://verySecret:X@project-name.campfirenow.com/rooms.json' @headers = { 'Content-Type' => 'application/json; charset=utf-8' } end diff --git a/spec/models/project_services/drone_ci_service_spec.rb b/spec/models/project_services/drone_ci_service_spec.rb index eb6955d5048..8ef892259f2 100644 --- a/spec/models/project_services/drone_ci_service_spec.rb +++ b/spec/models/project_services/drone_ci_service_spec.rb @@ -85,7 +85,7 @@ describe DroneCiService, models: true do let(:user) { create(:user, username: 'username') } let(:push_sample_data) do - Gitlab::DataBuilder::PushDataBuilder.build_sample(project, user) + Gitlab::DataBuilder::Push.build_sample(project, user) end it do diff --git a/spec/models/project_services/flowdock_service_spec.rb b/spec/models/project_services/flowdock_service_spec.rb index 1f00562fa05..d2557019756 100644 --- a/spec/models/project_services/flowdock_service_spec.rb +++ b/spec/models/project_services/flowdock_service_spec.rb @@ -52,8 +52,7 @@ describe FlowdockService, models: true do service_hook: true, token: 'verySecret' ) - @sample_data = Gitlab::DataBuilder::PushDataBuilder. - build_sample(project, user) + @sample_data = Gitlab::DataBuilder::Push.build_sample(project, user) @api_url = 'https://api.flowdock.com/v1/messages' WebMock.stub_request(:post, @api_url) end diff --git a/spec/models/project_services/gemnasium_service_spec.rb b/spec/models/project_services/gemnasium_service_spec.rb index 5940e44e57d..3d0b6c9816b 100644 --- a/spec/models/project_services/gemnasium_service_spec.rb +++ b/spec/models/project_services/gemnasium_service_spec.rb @@ -55,8 +55,7 @@ describe GemnasiumService, models: true do token: 'verySecret', api_key: 'GemnasiumUserApiKey' ) - @sample_data = Gitlab::DataBuilder::PushDataBuilder. - build_sample(project, user) + @sample_data = Gitlab::DataBuilder::Push.build_sample(project, user) end it "calls Gemnasium service" do expect(Gemnasium::GitlabService).to receive(:execute).with(an_instance_of(Hash)).once diff --git a/spec/models/project_services/hipchat_service_spec.rb b/spec/models/project_services/hipchat_service_spec.rb index bb6c75d6050..34eafbe555d 100644 --- a/spec/models/project_services/hipchat_service_spec.rb +++ b/spec/models/project_services/hipchat_service_spec.rb @@ -49,7 +49,7 @@ describe HipchatService, models: true do let(:token) { 'verySecret' } let(:server_url) { 'https://hipchat.example.com'} let(:push_sample_data) do - Gitlab::DataBuilder::PushDataBuilder.build_sample(project, user) + Gitlab::DataBuilder::Push.build_sample(project, user) end before(:each) do @@ -111,7 +111,7 @@ describe HipchatService, models: true do context 'tag_push events' do let(:push_sample_data) do - Gitlab::DataBuilder::PushDataBuilder.build( + Gitlab::DataBuilder::Push.build( project, user, Gitlab::Git::BLANK_SHA, @@ -195,7 +195,7 @@ describe HipchatService, models: true do end it "calls Hipchat API for commit comment events" do - data = Gitlab::DataBuilder::NoteDataBuilder.build(commit_note, user) + data = Gitlab::DataBuilder::Note.build(commit_note, user) hipchat.execute(data) expect(WebMock).to have_requested(:post, api_url).once @@ -227,7 +227,7 @@ describe HipchatService, models: true do end it "calls Hipchat API for merge request comment events" do - data = Gitlab::DataBuilder::NoteDataBuilder.build(merge_request_note, user) + data = Gitlab::DataBuilder::Note.build(merge_request_note, user) hipchat.execute(data) expect(WebMock).to have_requested(:post, api_url).once @@ -254,7 +254,7 @@ describe HipchatService, models: true do end it "calls Hipchat API for issue comment events" do - data = Gitlab::DataBuilder::NoteDataBuilder.build(issue_note, user) + data = Gitlab::DataBuilder::Note.build(issue_note, user) hipchat.execute(data) message = hipchat.send(:create_message, data) @@ -280,7 +280,7 @@ describe HipchatService, models: true do end it "calls Hipchat API for snippet comment events" do - data = Gitlab::DataBuilder::NoteDataBuilder.build(snippet_note, user) + data = Gitlab::DataBuilder::Note.build(snippet_note, user) hipchat.execute(data) expect(WebMock).to have_requested(:post, api_url).once @@ -303,7 +303,7 @@ describe HipchatService, models: true do context 'build events' do let(:pipeline) { create(:ci_empty_pipeline) } let(:build) { create(:ci_build, pipeline: pipeline) } - let(:data) { Gitlab::DataBuilder::BuildDataBuilder.build(build) } + let(:data) { Gitlab::DataBuilder::Build.build(build) } context 'for failed' do before { build.drop } diff --git a/spec/models/project_services/irker_service_spec.rb b/spec/models/project_services/irker_service_spec.rb index 646f0d54424..3bed0e6093f 100644 --- a/spec/models/project_services/irker_service_spec.rb +++ b/spec/models/project_services/irker_service_spec.rb @@ -47,7 +47,7 @@ describe IrkerService, models: true do let(:user) { create(:user) } let(:project) { create(:project) } let(:sample_data) do - Gitlab::DataBuilder::PushDataBuilder.build_sample(project, user) + Gitlab::DataBuilder::Push.build_sample(project, user) end let(:recipients) { '#commits irc://test.net/#test ftp://bad' } diff --git a/spec/models/project_services/jira_service_spec.rb b/spec/models/project_services/jira_service_spec.rb index 88800bb61d4..9037ca5cc20 100644 --- a/spec/models/project_services/jira_service_spec.rb +++ b/spec/models/project_services/jira_service_spec.rb @@ -66,8 +66,7 @@ describe JiraService, models: true do password: 'gitlab_jira_password' ) @jira_service.save # will build API URL, as api_url was not specified above - @sample_data = Gitlab::DataBuilder::PushDataBuilder. - build_sample(project, user) + @sample_data = Gitlab::DataBuilder::Push.build_sample(project, user) # https://github.com/bblimke/webmock#request-with-basic-authentication @api_url = 'http://gitlab_jira_username:gitlab_jira_password@jira.example.com/rest/api/2/issue/JIRA-123/transitions' @comment_url = 'http://gitlab_jira_username:gitlab_jira_password@jira.example.com/rest/api/2/issue/JIRA-123/comment' diff --git a/spec/models/project_services/pushover_service_spec.rb b/spec/models/project_services/pushover_service_spec.rb index f1a245ead2c..5959c81577d 100644 --- a/spec/models/project_services/pushover_service_spec.rb +++ b/spec/models/project_services/pushover_service_spec.rb @@ -49,7 +49,7 @@ describe PushoverService, models: true do let(:user) { create(:user) } let(:project) { create(:project) } let(:sample_data) do - Gitlab::DataBuilder::PushDataBuilder.build_sample(project, user) + Gitlab::DataBuilder::Push.build_sample(project, user) end let(:api_key) { 'verySecret' } diff --git a/spec/models/project_services/slack_service_spec.rb b/spec/models/project_services/slack_service_spec.rb index 75f07397fb1..28af68d13b4 100644 --- a/spec/models/project_services/slack_service_spec.rb +++ b/spec/models/project_services/slack_service_spec.rb @@ -46,7 +46,7 @@ describe SlackService, models: true do let(:user) { create(:user) } let(:project) { create(:project) } let(:push_sample_data) do - Gitlab::DataBuilder::PushDataBuilder.build_sample(project, user) + Gitlab::DataBuilder::Push.build_sample(project, user) end let(:webhook_url) { 'https://hooks.slack.com/services/SVRWFV0VVAR97N/B02R25XN3/ZBqu7xMupaEEICInN685' } let(:username) { 'slack_username' } @@ -197,8 +197,7 @@ describe SlackService, models: true do it "uses the right channel" do slack.update_attributes(note_channel: "random") - note_data = Gitlab::DataBuilder::NoteDataBuilder. - build(issue_note, user) + note_data = Gitlab::DataBuilder::Note.build(issue_note, user) expect(Slack::Notifier).to receive(:new). with(webhook_url, channel: "random"). @@ -238,7 +237,7 @@ describe SlackService, models: true do end it "calls Slack API for commit comment events" do - data = Gitlab::DataBuilder::NoteDataBuilder.build(commit_note, user) + data = Gitlab::DataBuilder::Note.build(commit_note, user) slack.execute(data) expect(WebMock).to have_requested(:post, webhook_url).once @@ -252,8 +251,7 @@ describe SlackService, models: true do end it "calls Slack API for merge request comment events" do - data = Gitlab::DataBuilder::NoteDataBuilder. - build(merge_request_note, user) + data = Gitlab::DataBuilder::Note.build(merge_request_note, user) slack.execute(data) expect(WebMock).to have_requested(:post, webhook_url).once @@ -266,7 +264,7 @@ describe SlackService, models: true do end it "calls Slack API for issue comment events" do - data = Gitlab::DataBuilder::NoteDataBuilder.build(issue_note, user) + data = Gitlab::DataBuilder::Note.build(issue_note, user) slack.execute(data) expect(WebMock).to have_requested(:post, webhook_url).once @@ -280,7 +278,7 @@ describe SlackService, models: true do end it "calls Slack API for snippet comment events" do - data = Gitlab::DataBuilder::NoteDataBuilder.build(snippet_note, user) + data = Gitlab::DataBuilder::Note.build(snippet_note, user) slack.execute(data) expect(WebMock).to have_requested(:post, webhook_url).once diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index bd86415050c..54505f6b822 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -896,7 +896,7 @@ describe User, models: true do let!(:project1) { create(:project) } let!(:project2) { create(:project, forked_from_project: project1) } let!(:push_data) do - Gitlab::DataBuilder::PushDataBuilder.build_sample(project2, subject) + Gitlab::DataBuilder::Push.build_sample(project2, subject) end let!(:push_event) { create(:event, action: Event::PUSHED, project: project2, target: project1, author: subject, data: push_data) } diff --git a/spec/workers/build_email_worker_spec.rb b/spec/workers/build_email_worker_spec.rb index 9317ad60220..788b92c1b84 100644 --- a/spec/workers/build_email_worker_spec.rb +++ b/spec/workers/build_email_worker_spec.rb @@ -5,7 +5,7 @@ describe BuildEmailWorker do let(:build) { create(:ci_build) } let(:user) { create(:user) } - let(:data) { Gitlab::DataBuilder::BuildDataBuilder.build(build) } + let(:data) { Gitlab::DataBuilder::Build.build(build) } subject { BuildEmailWorker.new } diff --git a/spec/workers/emails_on_push_worker_spec.rb b/spec/workers/emails_on_push_worker_spec.rb index 1ecc594c311..eecc32875a5 100644 --- a/spec/workers/emails_on_push_worker_spec.rb +++ b/spec/workers/emails_on_push_worker_spec.rb @@ -5,9 +5,7 @@ describe EmailsOnPushWorker do let(:project) { create(:project) } let(:user) { create(:user) } - let(:data) do - Gitlab::DataBuilder::PushDataBuilder.build_sample(project, user) - end + let(:data) { Gitlab::DataBuilder::Push.build_sample(project, user) } let(:recipients) { user.email } let(:perform) { subject.perform(project.id, recipients, data.stringify_keys) } From 07fc2f852a0b4136b6d97c1d9773819c47e7e8e7 Mon Sep 17 00:00:00 2001 From: "Z.J. van de Weg" Date: Tue, 9 Aug 2016 15:11:14 +0200 Subject: [PATCH 051/133] Method names changed to #includes_commit? --- .../stylesheets/pages/merge_requests.scss | 5 ++- app/models/deployment.rb | 3 +- app/models/environment.rb | 4 +-- app/models/merge_request.rb | 4 ++- .../merge_requests/widget/_heading.html.haml | 9 ++++-- db/schema.rb | 2 +- spec/models/deployment_spec.rb | 13 +++++--- spec/models/environment_spec.rb | 31 ++++++++++++++++--- spec/models/merge_request_spec.rb | 11 +++---- .../merge_requests/_heading.html.haml_spec.rb | 7 +++-- 10 files changed, 60 insertions(+), 29 deletions(-) diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 0a661e529f0..b4636269518 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -69,6 +69,10 @@ &.ci-success { color: $gl-success; + + a.environment { + color: inherit; + } } &.ci-success_with_warnings { @@ -126,7 +130,6 @@ &.has-conflicts .fa-exclamation-triangle { color: $gl-warning; } - } p:last-child { diff --git a/app/models/deployment.rb b/app/models/deployment.rb index 19b08f49d96..1e338889714 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -37,8 +37,7 @@ class Deployment < ActiveRecord::Base deployable.try(:other_actions) end - def deployed_to?(ref) - commit = project.commit(ref) + def includes_commit?(commit) return false unless commit project.repository.is_ancestor?(commit.id, sha) diff --git a/app/models/environment.rb b/app/models/environment.rb index 7247125f8a0..75e6f869786 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -26,9 +26,9 @@ class Environment < ActiveRecord::Base self.external_url = nil if self.external_url.blank? end - def deployed_from?(ref) + def includes_commit?(commit) return false unless last_deployment - last_deployment.deployed_to?(ref) + last_deployment.includes_commit?(commit) end end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 945b0d76505..491ee2792ec 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -591,8 +591,10 @@ class MergeRequest < ActiveRecord::Base end def environments + return unless diff_head_commit + target_project.environments.select do |environment| - environment.deployed_from?(ref_path) + environment.includes_commit?(diff_head_commit) end end diff --git a/app/views/projects/merge_requests/widget/_heading.html.haml b/app/views/projects/merge_requests/widget/_heading.html.haml index 16e923b831c..494695a03a5 100644 --- a/app/views/projects/merge_requests/widget/_heading.html.haml +++ b/app/views/projects/merge_requests/widget/_heading.html.haml @@ -45,10 +45,13 @@ - @merge_request.environments.each do |environment| .mr-widget-heading - .ci_widget{ class: "ci-success" } + .ci_widget.ci-success = ci_icon_for_status("success") %span.hidden-sm - Released to #{environment.name}. + Deployed to + = succeed '.' do + = link_to environment.name, namespace_project_environment_path(@project.namespace, @project, environment), class: 'environment' - external_url = environment.external_url - if external_url - = link_to icon('external-link', text: "View on #{external_url.gsub(/\A.*?:\/\//, '')}"), external_url + = link_to external_url, target: '_blank' do + = icon('external-link', text: "View on #{external_url.gsub(/\A.*?:\/\//, '')}", right: true) diff --git a/db/schema.rb b/db/schema.rb index 6c85e1e9dba..1aa4e8a73d0 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -589,12 +589,12 @@ ActiveRecord::Schema.define(version: 20160810142633) do t.datetime "locked_at" t.integer "updated_by_id" t.string "merge_error" - t.text "merge_params" t.boolean "merge_when_build_succeeds", default: false, null: false t.integer "merge_user_id" t.string "merge_commit_sha" t.datetime "deleted_at" t.string "in_progress_merge_commit_sha" + t.text "merge_params" end add_index "merge_requests", ["assignee_id"], name: "index_merge_requests_on_assignee_id", using: :btree diff --git a/spec/models/deployment_spec.rb b/spec/models/deployment_spec.rb index d7cb142dd32..bfff639ad78 100644 --- a/spec/models/deployment_spec.rb +++ b/spec/models/deployment_spec.rb @@ -16,23 +16,26 @@ describe Deployment, models: true do it { is_expected.to validate_presence_of(:ref) } it { is_expected.to validate_presence_of(:sha) } - describe '#deployed_to?' do + describe '#includes_commit?' do let(:project) { create(:project) } let(:environment) { create(:environment, project: project) } let(:deployment) do - create(:deployment, environment: environment, - sha: '5f923865dde3436854e9ceb9cdb7815618d4e849') + create(:deployment, environment: environment, sha: project.commit.id) end context 'when there is no project commit' do it 'returns false' do - expect(deployment.deployed_to?('random-branch')).to be false + commit = project.commit('feature') + + expect(deployment.includes_commit?(commit)).to be false end end context 'when they share the same tree branch' do it 'returns true' do - expect(deployment.deployed_to?('HEAD')).to be true + commit = project.commit + + expect(deployment.includes_commit?(commit)).to be true end end end diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb index e65b4f82eff..c881897926e 100644 --- a/spec/models/environment_spec.rb +++ b/spec/models/environment_spec.rb @@ -31,12 +31,35 @@ describe Environment, models: true do end end - describe '#deployed_from?' do - let(:environment) { create(:environment) } - + describe '#includes_commit?' do context 'without a last deployment' do it "returns false" do - expect(environment.deployed_from?('HEAD')).to be false + expect(environment.includes_commit?('HEAD')).to be false + end + end + + context 'with a last deployment' do + let(:project) { create(:project) } + let(:environment) { create(:environment, project: project) } + + let!(:deployment) do + create(:deployment, environment: environment, sha: project.commit('master').id) + end + + context 'in the same branch' do + it 'returns true' do + expect(environment.includes_commit?(RepoHelpers.sample_commit)).to be true + end + end + + context 'not in the same branch' do + before do + deployment.update(sha: project.commit('feature').id) + end + + it 'returns false' do + expect(environment.includes_commit?(RepoHelpers.sample_commit)).to be false + end end end end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index e605720a2dd..35a4418ebb3 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -676,18 +676,15 @@ describe MergeRequest, models: true do describe "#environments" do let(:project) { create(:project) } - - let!(:deployment) do - create(:deployment, environment: environment, - sha: '5f923865dde3436854e9ceb9cdb7815618d4e849') - end - let!(:environment) { create(:environment, project: project) } let!(:environment1) { create(:environment, project: project) } - + let!(:environment2) { create(:environment, project: project) } let(:merge_request) { create(:merge_request, source_project: project) } it 'selects deployed environments' do + create(:deployment, environment: environment, sha: project.commit('master').id) + create(:deployment, environment: environment1, sha: project.commit('feature').id) + expect(merge_request.environments).to eq [environment] end end diff --git a/spec/views/projects/merge_requests/_heading.html.haml_spec.rb b/spec/views/projects/merge_requests/_heading.html.haml_spec.rb index 0302f14e660..733b2dfa7ff 100644 --- a/spec/views/projects/merge_requests/_heading.html.haml_spec.rb +++ b/spec/views/projects/merge_requests/_heading.html.haml_spec.rb @@ -8,18 +8,19 @@ describe 'projects/merge_requests/widget/_heading' do let(:merge_request) { create(:merge_request, :merged) } let(:environment) { create(:environment, project: project) } let!(:deployment) do - create(:deployment, environment: environment, - sha: 'a5391128b0ef5d21df5dd23d98557f4ef12fae20') + create(:deployment, environment: environment, sha: project.commit('master').id) end before do assign(:merge_request, merge_request) + assign(:project, project) render end it 'displays that the environment is deployed' do - expect(rendered).to match("Released to #{environment.name}") + expect(rendered).to match("Deployed to") + expect(rendered).to match("#{environment.name}") end end end From 9fdcbcb0de414967618cfd7f26141e85805fcb54 Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Fri, 12 Aug 2016 16:48:47 +0800 Subject: [PATCH 052/133] Have trait all_events_enabled so that's easier to reuse, feedback: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5620#note_13823349 --- spec/factories/project_hooks.rb | 12 ++++++++++++ spec/requests/api/project_hooks_spec.rb | 7 +++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/spec/factories/project_hooks.rb b/spec/factories/project_hooks.rb index 3195fb3ddcc..c709432c865 100644 --- a/spec/factories/project_hooks.rb +++ b/spec/factories/project_hooks.rb @@ -5,5 +5,17 @@ FactoryGirl.define do trait :token do token { SecureRandom.hex(10) } end + + trait :all_events_enabled do + %w[push_events + merge_requests_events + tag_push_events + issues_events + note_events + build_events + pipeline_events].each do |event| + send(event, true) + end + end end end diff --git a/spec/requests/api/project_hooks_spec.rb b/spec/requests/api/project_hooks_spec.rb index 92c38e69283..914e88c9487 100644 --- a/spec/requests/api/project_hooks_spec.rb +++ b/spec/requests/api/project_hooks_spec.rb @@ -7,10 +7,9 @@ describe API::API, 'ProjectHooks', api: true do let!(:project) { create(:project, creator_id: user.id, namespace: user.namespace) } let!(:hook) do create(:project_hook, - project: project, url: "http://example.com", - push_events: true, merge_requests_events: true, - tag_push_events: true, issues_events: true, note_events: true, - build_events: true, pipeline_events: true, + :all_events_enabled, + project: project, + url: 'http://example.com', enable_ssl_verification: true) end From 2c06cf98a6dc982caf81c2e4faba195ece9a3b77 Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Fri, 12 Aug 2016 17:14:11 +0800 Subject: [PATCH 053/133] Fix tests. We cannot reload unless it's already saved: Not sure if this is the right fix... Or maybe we should actually merge: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5782 --- app/services/ci/create_pipeline_service.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index 7398fd8e10a..dabf94fe4c4 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -93,7 +93,10 @@ module Ci def error(message, save: false) pipeline.errors.add(:base, message) - pipeline.reload_status! if save + if save + pipeline.save + pipeline.reload_status! + end pipeline end end From 706d872eb2ebb462b5c226890120f51cf15ba7c5 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Fri, 12 Aug 2016 12:06:37 +0200 Subject: [PATCH 054/133] Make `execute_methods` public --- app/models/ci/pipeline.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 40615097804..ad836bbebb8 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -255,13 +255,13 @@ module Ci self.duration = statuses.latest.duration end - private - def execute_hooks project.execute_hooks(pipeline_data, :pipeline_hooks) project.execute_services(pipeline_data, :pipeline_hooks) end + private + def pipeline_data Gitlab::DataBuilder::Pipeline.build(self) end From 160aaca0fbfaa9848dec769019df9c0c78059b56 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Fri, 12 Aug 2016 12:11:28 +0200 Subject: [PATCH 055/133] Make pipeline to be in created state for hooks tests --- spec/models/ci/pipeline_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 9c5d56fe5bb..a3f9934971a 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' describe Ci::Pipeline, models: true do let(:project) { FactoryGirl.create :empty_project } - let(:pipeline) { FactoryGirl.create :ci_empty_pipeline, project: project } + let(:pipeline) { FactoryGirl.create :ci_empty_pipeline, status: 'created', project: project } it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:user) } From a391652fe60930e2139ecfacb175da6aa0f3b1e9 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Fri, 12 Aug 2016 12:23:47 +0200 Subject: [PATCH 056/133] Fix test failures --- app/models/ci/pipeline.rb | 2 +- spec/features/projects/pipelines_spec.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index ad836bbebb8..98185ecd447 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -23,7 +23,7 @@ module Ci state_machine :status, initial: :created do event :queue do - transition :created => :pending + transition created: :pending transition any - [:created, :pending] => :running end diff --git a/spec/features/projects/pipelines_spec.rb b/spec/features/projects/pipelines_spec.rb index b57652b3ea2..29d150bc597 100644 --- a/spec/features/projects/pipelines_spec.rb +++ b/spec/features/projects/pipelines_spec.rb @@ -64,7 +64,7 @@ describe "Pipelines" do before { click_link('Retry') } it { expect(page).not_to have_link('Retry') } - it { expect(page).to have_selector('.ci-pending') } + it { expect(page).to have_selector('.ci-running') } end end From a52fe1648e8a91f6e2f4b3a5c966a06b7f9e01e3 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Fri, 12 Aug 2016 17:34:43 +0200 Subject: [PATCH 057/133] Rename queue to enqueue in tests --- Gemfile | 1 + Gemfile.lock | 3 +++ bin/spring | 2 +- spec/models/ci/pipeline_spec.rb | 12 ++++++------ 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/Gemfile b/Gemfile index 8b44b54e22c..9bcfc0302b6 100644 --- a/Gemfile +++ b/Gemfile @@ -296,6 +296,7 @@ group :development, :test do gem 'spring-commands-rspec', '~> 1.0.4' gem 'spring-commands-spinach', '~> 1.1.0' gem 'spring-commands-teaspoon', '~> 0.0.2' + gem "spring-commands-sidekiq" gem 'rubocop', '~> 0.41.2', require: false gem 'rubocop-rspec', '~> 1.5.0', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 3ba6048143c..4bd5b78a047 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -696,6 +696,8 @@ GEM spring (1.7.2) spring-commands-rspec (1.0.4) spring (>= 0.9.1) + spring-commands-sidekiq (1.0.0) + spring (>= 0.9.1) spring-commands-spinach (1.1.0) spring (>= 0.9.1) spring-commands-teaspoon (0.0.2) @@ -955,6 +957,7 @@ DEPENDENCIES spinach-rerun-reporter (~> 0.0.2) spring (~> 1.7.0) spring-commands-rspec (~> 1.0.4) + spring-commands-sidekiq spring-commands-spinach (~> 1.1.0) spring-commands-teaspoon (~> 0.0.2) sprockets (~> 3.6.0) diff --git a/bin/spring b/bin/spring index e0d140fe0c7..7fe232c3aae 100755 --- a/bin/spring +++ b/bin/spring @@ -3,7 +3,7 @@ # This file loads spring without using Bundler, in order to be fast. # It gets overwritten when you run the `spring binstub` command. -unless (defined?(Spring) || ENV['ENABLE_SPRING'] != '1') && File.basename($0) != 'spring' +unless defined?(Spring) require 'rubygems' require 'bundler' diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 19f1aacaabc..8137e9f8f71 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -335,8 +335,8 @@ describe Ci::Pipeline, models: true do context 'with multiple builds' do context 'when build is queued' do before do - build_a.queue - build_b.queue + build_a.enqueue + build_b.enqueue end it 'receive a pending event once' do @@ -346,9 +346,9 @@ describe Ci::Pipeline, models: true do context 'when build is run' do before do - build_a.queue + build_a.enqueue build_a.run - build_b.queue + build_b.enqueue build_b.run end @@ -382,8 +382,8 @@ describe Ci::Pipeline, models: true do let(:enabled) { false } before do - build_a.queue - build_b.queue + build_a.enqueue + build_b.enqueue end it 'did not execute pipeline_hook after touched' do From 1f2253545ba7a902212bace29f144a2246eeedab Mon Sep 17 00:00:00 2001 From: Paco Guzman Date: Fri, 8 Jul 2016 18:42:47 +0200 Subject: [PATCH 058/133] Use cache for todos counter calling TodoService --- CHANGELOG | 1 + app/models/user.rb | 4 ++-- lib/api/todos.rb | 2 +- spec/requests/api/todos_spec.rb | 12 ++++++++++++ 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 6fe1720796d..c3b4c28dc84 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -270,6 +270,7 @@ v 8.10.0 - Fix new snippet style bug (elliotec) - Instrument Rinku usage - Be explicit to define merge request discussion variables + - Use cache for todos counter calling TodoService - Metrics for Rouge::Plugins::Redcarpet and Rouge::Formatters::HTMLGitlab - RailsCache metris now includes fetch_hit/fetch_miss and read_hit/read_miss info. - Allow [ci skip] to be in any case and allow [skip ci]. !4785 (simon_w) diff --git a/app/models/user.rb b/app/models/user.rb index 73368be7b1b..87a2d999843 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -809,13 +809,13 @@ class User < ActiveRecord::Base def todos_done_count(force: false) Rails.cache.fetch(['users', id, 'todos_done_count'], force: force) do - todos.done.count + TodosFinder.new(self, state: :done).execute.count end end def todos_pending_count(force: false) Rails.cache.fetch(['users', id, 'todos_pending_count'], force: force) do - todos.pending.count + TodosFinder.new(self, state: :pending).execute.count end end diff --git a/lib/api/todos.rb b/lib/api/todos.rb index 26c24c3baff..a90a667fafe 100644 --- a/lib/api/todos.rb +++ b/lib/api/todos.rb @@ -73,7 +73,7 @@ module API # delete do todos = find_todos - todos.each(&:done) + TodoService.new.mark_todos_as_done(todos, current_user) todos.length end diff --git a/spec/requests/api/todos_spec.rb b/spec/requests/api/todos_spec.rb index 3ccd0af652f..887a2ba5b84 100644 --- a/spec/requests/api/todos_spec.rb +++ b/spec/requests/api/todos_spec.rb @@ -117,6 +117,12 @@ describe API::Todos, api: true do expect(response.status).to eq(200) expect(pending_1.reload).to be_done end + + it 'updates todos cache' do + expect_any_instance_of(User).to receive(:update_todos_count_cache).and_call_original + + delete api("/todos/#{pending_1.id}", john_doe) + end end end @@ -139,6 +145,12 @@ describe API::Todos, api: true do expect(pending_2.reload).to be_done expect(pending_3.reload).to be_done end + + it 'updates todos cache' do + expect_any_instance_of(User).to receive(:update_todos_count_cache).and_call_original + + delete api("/todos", john_doe) + end end end From 8af6bea81ca696427faddec5c8e0d5a25c7e2d22 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Fri, 12 Aug 2016 17:52:02 +0200 Subject: [PATCH 059/133] Added documentation for pipeline hooks --- doc/web_hooks/web_hooks.md | 168 +++++++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) diff --git a/doc/web_hooks/web_hooks.md b/doc/web_hooks/web_hooks.md index d4b28d875cd..33c1a79d59c 100644 --- a/doc/web_hooks/web_hooks.md +++ b/doc/web_hooks/web_hooks.md @@ -754,6 +754,174 @@ X-Gitlab-Event: Wiki Page Hook } ``` +## Pipeline events + +Triggered on status change of Pipeline. + +**Request Header**: + +``` +X-Gitlab-Event: Pipeline Hook +``` + +**Request Body**: + +```json +{ + "object_kind": "pipeline", + "object_attributes":{ + "id": 31, + "ref": "master", + "tag": false, + "sha": "bcbb5ec396a2c0f828686f14fac9b80b780504f2", + "before_sha": "bcbb5ec396a2c0f828686f14fac9b80b780504f2", + "status": "success", + "stages":[ + "build", + "test", + "deploy" + ], + "created_at": "2016-08-12 15:23:28 UTC", + "finished_at": "2016-08-12 15:26:29 UTC", + "duration": 63 + }, + "user":{ + "name": "Administrator", + "username": "root", + "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon" + }, + "project":{ + "name": "Gitlab Test", + "description": "Atque in sunt eos similique dolores voluptatem.", + "web_url": "http://192.168.64.1:3005/gitlab-org/gitlab-test", + "avatar_url": null, + "git_ssh_url": "git@192.168.64.1:gitlab-org/gitlab-test.git", + "git_http_url": "http://192.168.64.1:3005/gitlab-org/gitlab-test.git", + "namespace": "Gitlab Org", + "visibility_level": 20, + "path_with_namespace": "gitlab-org/gitlab-test", + "default_branch": "master" + }, + "commit":{ + "id": "bcbb5ec396a2c0f828686f14fac9b80b780504f2", + "message": "test\n", + "timestamp": "2016-08-12T17:23:21+02:00", + "url": "http://example.com/gitlab-org/gitlab-test/commit/bcbb5ec396a2c0f828686f14fac9b80b780504f2", + "author":{ + "name": "User", + "email": "user@gitlab.com" + } + }, + "builds":[ + { + "id": 380, + "stage": "deploy", + "name": "production", + "status": "skipped", + "created_at": "2016-08-12 15:23:28 UTC", + "started_at": null, + "finished_at": null, + "when": "manual", + "manual": true, + "user":{ + "name": "Administrator", + "username": "root", + "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon" + }, + "runner": null, + "artifacts_file":{ + "filename": null, + "size": null + } + }, + { + "id": 377, + "stage": "test", + "name": "test-image", + "status": "success", + "created_at": "2016-08-12 15:23:28 UTC", + "started_at": "2016-08-12 15:26:12 UTC", + "finished_at": null, + "when": "on_success", + "manual": false, + "user":{ + "name": "Administrator", + "username": "root", + "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon" + }, + "runner": null, + "artifacts_file":{ + "filename": null, + "size": null + } + }, + { + "id": 378, + "stage": "test", + "name": "test-build", + "status": "success", + "created_at": "2016-08-12 15:23:28 UTC", + "started_at": "2016-08-12 15:26:12 UTC", + "finished_at": "2016-08-12 15:26:29 UTC", + "when": "on_success", + "manual": false, + "user":{ + "name": "Administrator", + "username": "root", + "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon" + }, + "runner": null, + "artifacts_file":{ + "filename": null, + "size": null + } + }, + { + "id": 376, + "stage": "build", + "name": "build-image", + "status": "success", + "created_at": "2016-08-12 15:23:28 UTC", + "started_at": "2016-08-12 15:24:56 UTC", + "finished_at": "2016-08-12 15:25:26 UTC", + "when": "on_success", + "manual": false, + "user":{ + "name": "Administrator", + "username": "root", + "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon" + }, + "runner": null, + "artifacts_file":{ + "filename": null, + "size": null + } + }, + { + "id": 379, + "stage": "deploy", + "name": "staging", + "status": "created", + "created_at": "2016-08-12 15:23:28 UTC", + "started_at": null, + "finished_at": null, + "when": "on_success", + "manual": false, + "user":{ + "name": "Administrator", + "username": "root", + "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon" + }, + "runner": null, + "artifacts_file":{ + "filename": null, + "size": null + } + } + ] +} +``` + #### Example webhook receiver If you want to see GitLab's webhooks in action for testing purposes you can use From f8b53ba20b74181a46985b0c7dde742239bd54f8 Mon Sep 17 00:00:00 2001 From: Paco Guzman Date: Thu, 11 Aug 2016 18:39:50 +0200 Subject: [PATCH 060/133] Recover usage of Todos counter cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We’re being kept up to date the counter data but we’re not using it. The only thing which is not real if is the number of projects that the user read changes the number of todos can be stale for some time. The counters will be sync just after the user receives a new todo or mark any as done --- app/controllers/dashboard/todos_controller.rb | 4 +-- app/helpers/todos_helper.rb | 4 +-- app/services/todo_service.rb | 3 +- lib/api/todos.rb | 6 ++-- spec/services/todo_service_spec.rb | 36 +++++++++++++++++++ 5 files changed, 44 insertions(+), 9 deletions(-) diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb index 19a76a5b5d8..1243bb96d4d 100644 --- a/app/controllers/dashboard/todos_controller.rb +++ b/app/controllers/dashboard/todos_controller.rb @@ -37,8 +37,8 @@ class Dashboard::TodosController < Dashboard::ApplicationController def todos_counts { - count: TodosFinder.new(current_user, state: :pending).execute.count, - done_count: TodosFinder.new(current_user, state: :done).execute.count + count: current_user.todos_pending_count, + done_count: current_user.todos_done_count } end end diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index e3a208f826a..0465327060e 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -1,10 +1,10 @@ module TodosHelper def todos_pending_count - @todos_pending_count ||= TodosFinder.new(current_user, state: :pending).execute.count + @todos_pending_count ||= current_user.todos_pending_count end def todos_done_count - @todos_done_count ||= TodosFinder.new(current_user, state: :done).execute.count + @todos_done_count ||= current_user.todos_done_count end def todo_action_name(todo) diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index 6b48d68cccb..eb833dd82ac 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -144,8 +144,9 @@ class TodoService def mark_todos_as_done(todos, current_user) todos = current_user.todos.where(id: todos.map(&:id)) unless todos.respond_to?(:update_all) - todos.update_all(state: :done) + marked_todos = todos.update_all(state: :done) current_user.update_todos_count_cache + marked_todos end # When user marks an issue as todo diff --git a/lib/api/todos.rb b/lib/api/todos.rb index a90a667fafe..19df13d8aac 100644 --- a/lib/api/todos.rb +++ b/lib/api/todos.rb @@ -61,9 +61,9 @@ module API # delete ':id' do todo = current_user.todos.find(params[:id]) - todo.done + TodoService.new.mark_todos_as_done([todo], current_user) - present todo, with: Entities::Todo, current_user: current_user + present todo.reload, with: Entities::Todo, current_user: current_user end # Mark all todos as done @@ -74,8 +74,6 @@ module API delete do todos = find_todos TodoService.new.mark_todos_as_done(todos, current_user) - - todos.length end end end diff --git a/spec/services/todo_service_spec.rb b/spec/services/todo_service_spec.rb index 34d8ea9090e..6c3cbeae13c 100644 --- a/spec/services/todo_service_spec.rb +++ b/spec/services/todo_service_spec.rb @@ -472,6 +472,42 @@ describe TodoService, services: true do expect(john_doe.todos_pending_count).to eq(1) end + describe '#mark_todos_as_done' do + let(:issue) { create(:issue, project: project, author: author, assignee: john_doe) } + + it 'marks a relation of todos as done' do + create(:todo, :mentioned, user: john_doe, target: issue, project: project) + + todos = TodosFinder.new(john_doe, {}).execute + expect { TodoService.new.mark_todos_as_done(todos, john_doe) } + .to change { john_doe.todos.done.count }.from(0).to(1) + end + + it 'marks an array of todos as done' do + todo = create(:todo, :mentioned, user: john_doe, target: issue, project: project) + + expect { TodoService.new.mark_todos_as_done([todo], john_doe) } + .to change { todo.reload.state }.from('pending').to('done') + end + + it 'returns the number of updated todos' do # Needed on API + todo = create(:todo, :mentioned, user: john_doe, target: issue, project: project) + + expect(TodoService.new.mark_todos_as_done([todo], john_doe)).to eq(1) + end + + it 'caches the number of todos of a user', :caching do + create(:todo, :mentioned, user: john_doe, target: issue, project: project) + todo = create(:todo, :mentioned, user: john_doe, target: issue, project: project) + TodoService.new.mark_todos_as_done([todo], john_doe) + + expect_any_instance_of(TodosFinder).not_to receive(:execute) + + expect(john_doe.todos_done_count).to eq(1) + expect(john_doe.todos_pending_count).to eq(1) + end + end + def should_create_todo(attributes = {}) attributes.reverse_merge!( project: project, From f7abe46109c5e90ff2ac0a8f812ea43016b5c2bc Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Fri, 12 Aug 2016 21:58:28 +0200 Subject: [PATCH 061/133] Remove changes not related to this MR --- Gemfile | 1 - Gemfile.lock | 3 --- bin/spring | 2 +- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/Gemfile b/Gemfile index 9bcfc0302b6..8b44b54e22c 100644 --- a/Gemfile +++ b/Gemfile @@ -296,7 +296,6 @@ group :development, :test do gem 'spring-commands-rspec', '~> 1.0.4' gem 'spring-commands-spinach', '~> 1.1.0' gem 'spring-commands-teaspoon', '~> 0.0.2' - gem "spring-commands-sidekiq" gem 'rubocop', '~> 0.41.2', require: false gem 'rubocop-rspec', '~> 1.5.0', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 4bd5b78a047..3ba6048143c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -696,8 +696,6 @@ GEM spring (1.7.2) spring-commands-rspec (1.0.4) spring (>= 0.9.1) - spring-commands-sidekiq (1.0.0) - spring (>= 0.9.1) spring-commands-spinach (1.1.0) spring (>= 0.9.1) spring-commands-teaspoon (0.0.2) @@ -957,7 +955,6 @@ DEPENDENCIES spinach-rerun-reporter (~> 0.0.2) spring (~> 1.7.0) spring-commands-rspec (~> 1.0.4) - spring-commands-sidekiq spring-commands-spinach (~> 1.1.0) spring-commands-teaspoon (~> 0.0.2) sprockets (~> 3.6.0) diff --git a/bin/spring b/bin/spring index 7fe232c3aae..e0d140fe0c7 100755 --- a/bin/spring +++ b/bin/spring @@ -3,7 +3,7 @@ # This file loads spring without using Bundler, in order to be fast. # It gets overwritten when you run the `spring binstub` command. -unless defined?(Spring) +unless (defined?(Spring) || ENV['ENABLE_SPRING'] != '1') && File.basename($0) != 'spring' require 'rubygems' require 'bundler' From 5822a333d4c1bb43304c781f3a8b8d3eb99861a8 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Fri, 12 Aug 2016 22:09:26 +0200 Subject: [PATCH 062/133] Capitalise URL on web_hooks/form --- app/views/shared/web_hooks/_form.html.haml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml index a672c28de39..d2ec6c3ddef 100644 --- a/app/views/shared/web_hooks/_form.html.haml +++ b/app/views/shared/web_hooks/_form.html.haml @@ -29,56 +29,56 @@ = f.label :push_events, class: 'list-label' do %strong Push events %p.light - This url will be triggered by a push to the repository + This URL will be triggered by a push to the repository %li = f.check_box :tag_push_events, class: 'pull-left' .prepend-left-20 = f.label :tag_push_events, class: 'list-label' do %strong Tag push events %p.light - This url will be triggered when a new tag is pushed to the repository + This URL will be triggered when a new tag is pushed to the repository %li = f.check_box :note_events, class: 'pull-left' .prepend-left-20 = f.label :note_events, class: 'list-label' do %strong Comments %p.light - This url will be triggered when someone adds a comment + This URL will be triggered when someone adds a comment %li = f.check_box :issues_events, class: 'pull-left' .prepend-left-20 = f.label :issues_events, class: 'list-label' do %strong Issues events %p.light - This url will be triggered when an issue is created/updated/merged + This URL will be triggered when an issue is created/updated/merged %li = f.check_box :merge_requests_events, class: 'pull-left' .prepend-left-20 = f.label :merge_requests_events, class: 'list-label' do %strong Merge Request events %p.light - This url will be triggered when a merge request is created/updated/merged + This URL will be triggered when a merge request is created/updated/merged %li = f.check_box :build_events, class: 'pull-left' .prepend-left-20 = f.label :build_events, class: 'list-label' do %strong Build events %p.light - This url will be triggered when the build status changes + This URL will be triggered when the build status changes %li = f.check_box :pipeline_events, class: 'pull-left' .prepend-left-20 = f.label :pipeline_events, class: 'list-label' do %strong Pipeline events %p.light - This url will be triggered when the pipeline status changes + This URL will be triggered when the pipeline status changes %li = f.check_box :wiki_page_events, class: 'pull-left' .prepend-left-20 = f.label :wiki_page_events, class: 'list-label' do %strong Wiki Page events %p.light - This url will be triggered when a wiki page is created/updated + This URL will be triggered when a wiki page is created/updated .form-group = f.label :enable_ssl_verification, "SSL verification", class: 'label-light checkbox' .checkbox From cd4d8b91eb77b12a168bbca3a7ee9ec8bf1fba5e Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Fri, 12 Aug 2016 22:09:58 +0200 Subject: [PATCH 063/133] Make explicit call for all event types for ProjectHook factory --- spec/factories/project_hooks.rb | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/spec/factories/project_hooks.rb b/spec/factories/project_hooks.rb index c709432c865..4fd51a23490 100644 --- a/spec/factories/project_hooks.rb +++ b/spec/factories/project_hooks.rb @@ -7,15 +7,13 @@ FactoryGirl.define do end trait :all_events_enabled do - %w[push_events - merge_requests_events - tag_push_events - issues_events - note_events - build_events - pipeline_events].each do |event| - send(event, true) - end + push_events true + merge_requests_events true + tag_push_events true + issues_events true + note_events true + build_events true + pipeline_events true end end end From 60858c3d8e5118b5ef6958cf681ba6ae3e141b5c Mon Sep 17 00:00:00 2001 From: Drew Blessing Date: Fri, 12 Aug 2016 15:55:53 -0500 Subject: [PATCH 064/133] Add archived badge to project listing --- CHANGELOG | 1 + app/views/shared/projects/_project.html.haml | 2 ++ 2 files changed, 3 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index 96965a20f69..6e096b480c0 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -54,6 +54,7 @@ v 8.11.0 (unreleased) - Optimize checking if a user has read access to a list of issues !5370 - Store all DB secrets in secrets.yml, under descriptive names !5274 - Nokogiri's various parsing methods are now instrumented + - Add archived badge to project list !5798 - Add simple identifier to public SSH keys (muteor) - Admin page now references docs instead of a specific file !5600 (AnAverageHuman) - Add a way to send an email and create an issue based on private personal token. Find the email address from issues page. !3363 diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index 92803838d02..281ec728e41 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -12,6 +12,8 @@ %li.project-row{ class: css_class } = cache(cache_key) do .controls + - if project.archived + %span.label.label-warning archived - if project.commit.try(:status) %span = render_commit_status(project.commit) From 2f06027dc318bd8c4e177444a3168a0129a53687 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Rodr=C3=ADguez?= Date: Fri, 12 Aug 2016 18:27:42 -0400 Subject: [PATCH 065/133] Change the order of the access rules to check simpler first, and add specs --- lib/gitlab/checks/change_access.rb | 2 +- spec/lib/gitlab/checks/change_access_spec.rb | 99 ++++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 spec/lib/gitlab/checks/change_access_spec.rb diff --git a/lib/gitlab/checks/change_access.rb b/lib/gitlab/checks/change_access.rb index 52f117e963b..4b32eb966aa 100644 --- a/lib/gitlab/checks/change_access.rb +++ b/lib/gitlab/checks/change_access.rb @@ -11,7 +11,7 @@ module Gitlab end def exec - error = protected_branch_checks || tag_checks || push_checks + error = push_checks || tag_checks || protected_branch_checks if error GitAccessStatus.new(false, error) diff --git a/spec/lib/gitlab/checks/change_access_spec.rb b/spec/lib/gitlab/checks/change_access_spec.rb new file mode 100644 index 00000000000..39069b49978 --- /dev/null +++ b/spec/lib/gitlab/checks/change_access_spec.rb @@ -0,0 +1,99 @@ +require 'spec_helper' + +describe Gitlab::Checks::ChangeAccess, lib: true do + describe '#exec' do + let(:user) { create(:user) } + let(:project) { create(:project) } + let(:user_access) { Gitlab::UserAccess.new(user, project: project) } + let(:changes) do + { + oldrev: 'be93687618e4b132087f430a4d8fc3a609c9b77c', + newrev: '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51', + ref: 'refs/heads/master' + } + end + + subject { described_class.new(changes, project: project, user_access: user_access).exec } + + before { allow(user_access).to receive(:can_do_action?).with(:push_code).and_return(true) } + + context 'without failed checks' do + it "doesn't return any error" do + expect(subject.status).to be(true) + end + end + + context 'when the user is not allowed to push code' do + it 'returns an error' do + expect(user_access).to receive(:can_do_action?).with(:push_code).and_return(false) + + expect(subject.status).to be(false) + expect(subject.message).to eq('You are not allowed to push code to this project.') + end + end + + context 'tags check' do + let(:changes) do + { + oldrev: 'be93687618e4b132087f430a4d8fc3a609c9b77c', + newrev: '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51', + ref: 'refs/tags/v1.0.0' + } + end + + it 'returns an error if the user is not allowed to update tags' do + expect(user_access).to receive(:can_do_action?).with(:admin_project).and_return(false) + + expect(subject.status).to be(false) + expect(subject.message).to eq('You are not allowed to change existing tags on this project.') + end + end + + context 'protected branches check' do + before do + allow(project).to receive(:protected_branch?).with('master').and_return(true) + end + + it 'returns an error if the user is not allowed to do forced pushes to protected branches' do + expect(Gitlab::Checks::ForcePush).to receive(:force_push?).and_return(true) + expect(user_access).to receive(:can_do_action?).with(:force_push_code_to_protected_branches).and_return(false) + + expect(subject.status).to be(false) + expect(subject.message).to eq('You are not allowed to force push code to a protected branch on this project.') + end + + it 'returns an error if the user is not allowed to merge to protected branches' do + expect_any_instance_of(Gitlab::Checks::MatchingMergeRequest).to receive(:match?).and_return(true) + expect(user_access).to receive(:can_merge_to_branch?).and_return(false) + expect(user_access).to receive(:can_push_to_branch?).and_return(false) + + expect(subject.status).to be(false) + expect(subject.message).to eq('You are not allowed to merge code into protected branches on this project.') + end + + it 'returns an error if the user is not allowed to push to protected branches' do + expect(user_access).to receive(:can_push_to_branch?).and_return(false) + + expect(subject.status).to be(false) + expect(subject.message).to eq('You are not allowed to push code to protected branches on this project.') + end + + context 'branch deletion' do + let(:changes) do + { + oldrev: 'be93687618e4b132087f430a4d8fc3a609c9b77c', + newrev: '0000000000000000000000000000000000000000', + ref: 'refs/heads/master' + } + end + + it 'returns an error if the user is not allowed to delete protected branches' do + expect(user_access).to receive(:can_do_action?).with(:remove_protected_branches).and_return(false) + + expect(subject.status).to be(false) + expect(subject.message).to eq('You are not allowed to delete protected branches from this project.') + end + end + end + end +end From 8779da45033d82f8f10d1fa6ca51d59d54d0ed2c Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Thu, 11 Aug 2016 19:39:18 -0500 Subject: [PATCH 066/133] Don't show new MR URL after push when it doesn't make sense --- .../merge_requests/get_urls_service.rb | 13 ++++++- .../merge_requests/get_urls_service_spec.rb | 34 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/app/services/merge_requests/get_urls_service.rb b/app/services/merge_requests/get_urls_service.rb index 501fd135e16..08c1f72d65a 100644 --- a/app/services/merge_requests/get_urls_service.rb +++ b/app/services/merge_requests/get_urls_service.rb @@ -30,10 +30,21 @@ module MergeRequests end def get_branches(changes) + return [] if project.empty_repo? + return [] unless project.merge_requests_enabled + changes_list = Gitlab::ChangesList.new(changes) changes_list.map do |change| next unless Gitlab::Git.branch_ref?(change[:ref]) - Gitlab::Git.branch_name(change[:ref]) + + # Deleted branch + next if Gitlab::Git.blank_ref?(change[:newrev]) + + # Default branch + branch_name = Gitlab::Git.branch_name(change[:ref]) + next if branch_name == project.default_branch + + branch_name end.compact end diff --git a/spec/services/merge_requests/get_urls_service_spec.rb b/spec/services/merge_requests/get_urls_service_spec.rb index ec26770c3eb..8a4b76367e3 100644 --- a/spec/services/merge_requests/get_urls_service_spec.rb +++ b/spec/services/merge_requests/get_urls_service_spec.rb @@ -7,7 +7,9 @@ describe MergeRequests::GetUrlsService do let(:new_merge_request_url) { "http://localhost/#{project.namespace.name}/#{project.path}/merge_requests/new?merge_request%5Bsource_branch%5D=#{source_branch}" } let(:show_merge_request_url) { "http://localhost/#{project.namespace.name}/#{project.path}/merge_requests/#{merge_request.iid}" } let(:new_branch_changes) { "#{Gitlab::Git::BLANK_SHA} 570e7b2abdd848b95f2f578043fc23bd6f6fd24d refs/heads/#{source_branch}" } + let(:deleted_branch_changes) { "d14d6c0abdd253381df51a723d58691b2ee1ab08 #{Gitlab::Git::BLANK_SHA} refs/heads/#{source_branch}" } let(:existing_branch_changes) { "d14d6c0abdd253381df51a723d58691b2ee1ab08 570e7b2abdd848b95f2f578043fc23bd6f6fd24d refs/heads/#{source_branch}" } + let(:default_branch_changes) { "d14d6c0abdd253381df51a723d58691b2ee1ab08 570e7b2abdd848b95f2f578043fc23bd6f6fd24d refs/heads/master" } describe "#execute" do shared_examples 'new_merge_request_link' do @@ -32,6 +34,28 @@ describe MergeRequests::GetUrlsService do end end + shared_examples 'no_merge_request_url' do + it 'returns no URL' do + result = service.execute(changes) + expect(result).to be_empty + end + end + + context 'pushing to default branch' do + let(:changes) { default_branch_changes } + it_behaves_like 'no_merge_request_url' + end + + context 'pushing to project with MRs disabled' do + let(:changes) { new_branch_changes } + + before do + project.merge_requests_enabled = false + end + + it_behaves_like 'no_merge_request_url' + end + context 'pushing one completely new branch' do let(:changes) { new_branch_changes } it_behaves_like 'new_merge_request_link' @@ -42,6 +66,11 @@ describe MergeRequests::GetUrlsService do it_behaves_like 'new_merge_request_link' end + context 'pushing to deleted branch' do + let(:changes) { deleted_branch_changes } + it_behaves_like 'no_merge_request_url' + end + context 'pushing to existing branch and merge request opened' do let!(:merge_request) { create(:merge_request, source_project: project, source_branch: source_branch) } let(:changes) { existing_branch_changes } @@ -61,6 +90,11 @@ describe MergeRequests::GetUrlsService do let(:changes) { existing_branch_changes } # Source project is now the forked one let(:service) { MergeRequests::GetUrlsService.new(forked_project) } + + before do + allow(forked_project).to receive(:empty_repo?).and_return(false) + end + it_behaves_like 'show_merge_request_url' end From 60aee5b7afb2ba175606fda59d1f0742f7c4270c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Rodr=C3=ADguez?= Date: Fri, 12 Aug 2016 21:56:40 -0400 Subject: [PATCH 067/133] Add method missing from EE --- app/models/project_wiki.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index a255710f577..46f70da2452 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -56,6 +56,10 @@ class ProjectWiki end end + def repository_exists? + !!repository.exists? + end + def empty? pages.empty? end From d18fe2094b42ccf2ee23b01c4cdcf58dc42aaf03 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Tue, 9 Aug 2016 20:19:14 +0200 Subject: [PATCH 068/133] Use new PhantomJS version --- .gitlab-ci.yml | 1 + scripts/prepare_build.sh | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index e8d54e768d3..34ab1f90fca 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -15,6 +15,7 @@ variables: USE_DB: "true" USE_BUNDLE_INSTALL: "true" GIT_DEPTH: "20" + PHANTOMJS_DEB: "phantomjs_2.1.1+dfsg-2_amd64.deb" before_script: - source ./scripts/prepare_build.sh diff --git a/scripts/prepare_build.sh b/scripts/prepare_build.sh index 7e71a030901..9c5a9713189 100755 --- a/scripts/prepare_build.sh +++ b/scripts/prepare_build.sh @@ -20,10 +20,10 @@ if [ -f /.dockerenv ] || [ -f ./dockerinit ]; then # Install phantomjs package pushd vendor/apt - if [ ! -e phantomjs_1.9.8-0jessie_amd64.deb ]; then - wget -q https://gitlab.com/axil/phantomjs-debian/raw/master/phantomjs_1.9.8-0jessie_amd64.deb + if [ ! -e "$PHANTOMJS_DEB" ]; then + wget -q "http://ftp.us.debian.org/debian/pool/main/p/phantomjs/$PHANTOMJS_DEB" fi - dpkg -i phantomjs_1.9.8-0jessie_amd64.deb + dpkg -i "$PHANTOMJS_DEB" popd # Try to install packages From e6173e056756cb65c60c16b02e3a36f84222b6e3 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Tue, 9 Aug 2016 20:25:11 +0200 Subject: [PATCH 069/133] Install latest stable phantomjs --- .gitlab-ci.yml | 2 +- scripts/prepare_build.sh | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 34ab1f90fca..be5614520a5 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -15,7 +15,7 @@ variables: USE_DB: "true" USE_BUNDLE_INSTALL: "true" GIT_DEPTH: "20" - PHANTOMJS_DEB: "phantomjs_2.1.1+dfsg-2_amd64.deb" + PHANTOMJS_VERSION: "2.1.1" before_script: - source ./scripts/prepare_build.sh diff --git a/scripts/prepare_build.sh b/scripts/prepare_build.sh index 9c5a9713189..bfca5d76391 100755 --- a/scripts/prepare_build.sh +++ b/scripts/prepare_build.sh @@ -20,10 +20,11 @@ if [ -f /.dockerenv ] || [ -f ./dockerinit ]; then # Install phantomjs package pushd vendor/apt - if [ ! -e "$PHANTOMJS_DEB" ]; then - wget -q "http://ftp.us.debian.org/debian/pool/main/p/phantomjs/$PHANTOMJS_DEB" + PHANTOMJS_FILE="phantomjs-$PHANTOMJS_VERSION-linux-x86_64" + if [ ! -d "$PHANTOMJS_FILE" ]; then + curl -q "https://bitbucket.org/ariya/phantomjs/downloads/$PHANTOMJS_FILE.tar.bz2" | tar jx fi - dpkg -i "$PHANTOMJS_DEB" + cp "$PHANTOMJS_FILE/bin/phantomjs" "/usr/bin/" popd # Try to install packages From 65572b3b9658667962df27376d01d1c55d7d9270 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Tue, 9 Aug 2016 20:29:53 +0200 Subject: [PATCH 070/133] Fix file downloading --- scripts/prepare_build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/prepare_build.sh b/scripts/prepare_build.sh index bfca5d76391..e6a673c94e9 100755 --- a/scripts/prepare_build.sh +++ b/scripts/prepare_build.sh @@ -22,7 +22,7 @@ if [ -f /.dockerenv ] || [ -f ./dockerinit ]; then pushd vendor/apt PHANTOMJS_FILE="phantomjs-$PHANTOMJS_VERSION-linux-x86_64" if [ ! -d "$PHANTOMJS_FILE" ]; then - curl -q "https://bitbucket.org/ariya/phantomjs/downloads/$PHANTOMJS_FILE.tar.bz2" | tar jx + curl -q -L "https://bitbucket.org/ariya/phantomjs/downloads/$PHANTOMJS_FILE.tar.bz2" | tar jx fi cp "$PHANTOMJS_FILE/bin/phantomjs" "/usr/bin/" popd From 2675c9d632c9560a94e98cd3830e2dfdfbbc2bd4 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Thu, 11 Aug 2016 11:05:36 +0530 Subject: [PATCH 071/133] Fix `U2fSpec` for PhantomJS versions > 2. - We weren't explicilty waiting for the page to load while navigating to the "Manage two-factor authentication" page. This was probably incidentally working for PhantomJS 1.x versions. --- spec/features/u2f_spec.rb | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/spec/features/u2f_spec.rb b/spec/features/u2f_spec.rb index 9335f5bf120..d370f90f7d9 100644 --- a/spec/features/u2f_spec.rb +++ b/spec/features/u2f_spec.rb @@ -1,8 +1,16 @@ require 'spec_helper' feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: true, js: true do + include WaitForAjax + before { allow_any_instance_of(U2fHelper).to receive(:inject_u2f_api?).and_return(true) } + def manage_two_factor_authentication + click_on 'Manage Two-Factor Authentication' + expect(page).to have_content("Setup New U2F Device") + wait_for_ajax + end + def register_u2f_device(u2f_device = nil) u2f_device ||= FakeU2fDevice.new(page) u2f_device.respond_to_u2f_registration @@ -34,7 +42,7 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: describe 'when 2FA via OTP is enabled' do it 'allows registering a new device' do visit profile_account_path - click_on 'Manage Two-Factor Authentication' + manage_two_factor_authentication expect(page.body).to match("You've already enabled two-factor authentication using mobile") register_u2f_device @@ -46,15 +54,15 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: visit profile_account_path # First device - click_on 'Manage Two-Factor Authentication' + manage_two_factor_authentication register_u2f_device expect(page.body).to match('Your U2F device was registered') # Second device - click_on 'Manage Two-Factor Authentication' + manage_two_factor_authentication register_u2f_device expect(page.body).to match('Your U2F device was registered') - click_on 'Manage Two-Factor Authentication' + manage_two_factor_authentication expect(page.body).to match('You have 2 U2F devices registered') end end @@ -62,7 +70,7 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: it 'allows the same device to be registered for multiple users' do # First user visit profile_account_path - click_on 'Manage Two-Factor Authentication' + manage_two_factor_authentication u2f_device = register_u2f_device expect(page.body).to match('Your U2F device was registered') logout @@ -71,7 +79,7 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: user = login_as(:user) user.update_attribute(:otp_required_for_login, true) visit profile_account_path - click_on 'Manage Two-Factor Authentication' + manage_two_factor_authentication register_u2f_device(u2f_device) expect(page.body).to match('Your U2F device was registered') @@ -81,7 +89,7 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: context "when there are form errors" do it "doesn't register the device if there are errors" do visit profile_account_path - click_on 'Manage Two-Factor Authentication' + manage_two_factor_authentication # Have the "u2f device" respond with bad data page.execute_script("u2f.register = function(_,_,_,callback) { callback('bad response'); };") @@ -96,7 +104,7 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: it "allows retrying registration" do visit profile_account_path - click_on 'Manage Two-Factor Authentication' + manage_two_factor_authentication # Failed registration page.execute_script("u2f.register = function(_,_,_,callback) { callback('bad response'); };") @@ -122,7 +130,7 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: login_as(user) user.update_attribute(:otp_required_for_login, true) visit profile_account_path - click_on 'Manage Two-Factor Authentication' + manage_two_factor_authentication @u2f_device = register_u2f_device logout end @@ -161,7 +169,7 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: current_user = login_as(:user) current_user.update_attribute(:otp_required_for_login, true) visit profile_account_path - click_on 'Manage Two-Factor Authentication' + manage_two_factor_authentication register_u2f_device logout @@ -182,7 +190,7 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: current_user = login_as(:user) current_user.update_attribute(:otp_required_for_login, true) visit profile_account_path - click_on 'Manage Two-Factor Authentication' + manage_two_factor_authentication register_u2f_device(@u2f_device) logout @@ -248,7 +256,7 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: user = login_as(:user) user.update_attribute(:otp_required_for_login, true) visit profile_account_path - click_on 'Manage Two-Factor Authentication' + manage_two_factor_authentication expect(page).to have_content("Your U2F device needs to be set up.") register_u2f_device end From f668da11cb225a9b2c2f1d3ba9d1e9fb2797d4f9 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 11 Aug 2016 17:48:32 +0100 Subject: [PATCH 072/133] Updated tests --- features/steps/dashboard/event_filters.rb | 6 ++++++ features/steps/dashboard/issues.rb | 5 +++++ features/steps/dashboard/merge_requests.rb | 5 +++++ features/steps/dashboard/new_project.rb | 2 ++ features/steps/project/builds/artifacts.rb | 1 + features/steps/project/forked_merge_requests.rb | 3 +++ features/steps/project/issues/issues.rb | 1 + features/steps/project/merge_requests.rb | 2 ++ features/steps/project/source/browse_files.rb | 1 + features/steps/project/wiki.rb | 2 ++ features/steps/shared/issuable.rb | 4 +--- spec/features/issuables/default_sort_order_spec.rb | 14 +++++++------- spec/features/issues/filter_issues_spec.rb | 2 +- spec/features/merge_requests/create_new_mr_spec.rb | 2 ++ spec/features/profiles/preferences_spec.rb | 4 ++++ .../project_owner_creates_license_file_spec.rb | 1 + ...to_create_license_file_in_empty_project_spec.rb | 1 + spec/features/variables_spec.rb | 1 + 18 files changed, 46 insertions(+), 11 deletions(-) diff --git a/features/steps/dashboard/event_filters.rb b/features/steps/dashboard/event_filters.rb index 726b37cfde5..97b17abb470 100644 --- a/features/steps/dashboard/event_filters.rb +++ b/features/steps/dashboard/event_filters.rb @@ -4,26 +4,32 @@ class Spinach::Features::EventFilters < Spinach::FeatureSteps include SharedProject step 'I should see push event' do + sleep 1 expect(page).to have_selector('span.pushed') end step 'I should not see push event' do + sleep 1 expect(page).not_to have_selector('span.pushed') end step 'I should see new member event' do + sleep 1 expect(page).to have_selector('span.joined') end step 'I should not see new member event' do + sleep 1 expect(page).not_to have_selector('span.joined') end step 'I should see merge request event' do + sleep 1 expect(page).to have_selector('span.accepted') end step 'I should not see merge request event' do + sleep 1 expect(page).not_to have_selector('span.accepted') end diff --git a/features/steps/dashboard/issues.rb b/features/steps/dashboard/issues.rb index 8706f0e8e78..39c65bb6cde 100644 --- a/features/steps/dashboard/issues.rb +++ b/features/steps/dashboard/issues.rb @@ -43,9 +43,14 @@ class Spinach::Features::DashboardIssues < Spinach::FeatureSteps step 'I click "All" link' do find(".js-author-search").click + expect(page).to have_selector(".dropdown-menu-author li a") find(".dropdown-menu-author li a", match: :first).click + expect(page).not_to have_selector(".dropdown-menu-author li a") + find(".js-assignee-search").click + expect(page).to have_selector(".dropdown-menu-assignee li a") find(".dropdown-menu-assignee li a", match: :first).click + expect(page).not_to have_selector(".dropdown-menu-assignee li a") end def should_see(issue) diff --git a/features/steps/dashboard/merge_requests.rb b/features/steps/dashboard/merge_requests.rb index 06db36c7014..6777101fb15 100644 --- a/features/steps/dashboard/merge_requests.rb +++ b/features/steps/dashboard/merge_requests.rb @@ -47,9 +47,14 @@ class Spinach::Features::DashboardMergeRequests < Spinach::FeatureSteps step 'I click "All" link' do find(".js-author-search").click + expect(page).to have_selector(".dropdown-menu-author li a") find(".dropdown-menu-author li a", match: :first).click + expect(page).not_to have_selector(".dropdown-menu-author li a") + find(".js-assignee-search").click + expect(page).to have_selector(".dropdown-menu-assignee li a") find(".dropdown-menu-assignee li a", match: :first).click + expect(page).not_to have_selector(".dropdown-menu-assignee li a") end def should_see(merge_request) diff --git a/features/steps/dashboard/new_project.rb b/features/steps/dashboard/new_project.rb index 727a6a71373..dcfa88f69fc 100644 --- a/features/steps/dashboard/new_project.rb +++ b/features/steps/dashboard/new_project.rb @@ -29,6 +29,7 @@ class Spinach::Features::NewProject < Spinach::FeatureSteps end step 'I am redirected to the GitHub import page' do + expect(page).to have_content('Import Projects from GitHub') expect(current_path).to eq new_import_github_path end @@ -47,6 +48,7 @@ class Spinach::Features::NewProject < Spinach::FeatureSteps end step 'I redirected to Google Code import page' do + expect(page).to have_content('Import projects from Google Code') expect(current_path).to eq new_import_google_code_path end end diff --git a/features/steps/project/builds/artifacts.rb b/features/steps/project/builds/artifacts.rb index b4a32ed2e38..055fca036d3 100644 --- a/features/steps/project/builds/artifacts.rb +++ b/features/steps/project/builds/artifacts.rb @@ -10,6 +10,7 @@ class Spinach::Features::ProjectBuildsArtifacts < Spinach::FeatureSteps step 'I click artifacts browse button' do click_link 'Browse' + expect(page).not_to have_selector('.build-sidebar') end step 'I should see content of artifacts archive' do diff --git a/features/steps/project/forked_merge_requests.rb b/features/steps/project/forked_merge_requests.rb index 6b56a77b832..dacab6c7977 100644 --- a/features/steps/project/forked_merge_requests.rb +++ b/features/steps/project/forked_merge_requests.rb @@ -34,6 +34,9 @@ class Spinach::Features::ProjectForkedMergeRequests < Spinach::FeatureSteps end step 'I fill out a "Merge Request On Forked Project" merge request' do + expect(page).to have_content('Source branch') + expect(page).to have_content('Target branch') + first('.js-source-project').click first('.dropdown-source-project a', text: @forked_project.path_with_namespace) diff --git a/features/steps/project/issues/issues.rb b/features/steps/project/issues/issues.rb index 35f166c7c08..c1592e1d142 100644 --- a/features/steps/project/issues/issues.rb +++ b/features/steps/project/issues/issues.rb @@ -355,5 +355,6 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps def filter_issue(text) fill_in 'issue_search', with: text + find('#issue_search').native.send_keys(:return) end end diff --git a/features/steps/project/merge_requests.rb b/features/steps/project/merge_requests.rb index a02a54923a5..53d1aedf27f 100644 --- a/features/steps/project/merge_requests.rb +++ b/features/steps/project/merge_requests.rb @@ -489,10 +489,12 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps end step 'I fill in merge request search with "Fe"' do + sleep 1 fill_in 'issue_search', with: "Fe" end step 'I click the "Target branch" dropdown' do + expect(page).to have_content('Target branch') first('.target_branch').click end diff --git a/features/steps/project/source/browse_files.rb b/features/steps/project/source/browse_files.rb index 9a8896acb15..2b74e964ca1 100644 --- a/features/steps/project/source/browse_files.rb +++ b/features/steps/project/source/browse_files.rb @@ -69,6 +69,7 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps end step 'I edit code' do + expect(page).to have_selector('.file-editor') set_new_content end diff --git a/features/steps/project/wiki.rb b/features/steps/project/wiki.rb index 732dc5d0b93..07a955b1a14 100644 --- a/features/steps/project/wiki.rb +++ b/features/steps/project/wiki.rb @@ -142,7 +142,9 @@ class Spinach::Features::ProjectWiki < Spinach::FeatureSteps end step 'I edit the Wiki page with a path' do + expect(page).to have_content('three') click_on 'three' + expect(find('.nav-text')).to have_content('Three') click_on 'Edit' end diff --git a/features/steps/shared/issuable.rb b/features/steps/shared/issuable.rb index b5fd24d246f..aa666a954bc 100644 --- a/features/steps/shared/issuable.rb +++ b/features/steps/shared/issuable.rb @@ -133,9 +133,7 @@ module SharedIssuable end step 'The list should be sorted by "Oldest updated"' do - page.within('.content div.dropdown.inline.prepend-left-10') do - expect(page.find('button.dropdown-toggle.btn')).to have_content('Oldest updated') - end + expect(find('.issues-filters')).to have_content('Oldest updated') end step 'I click link "Next" in the sidebar' do diff --git a/spec/features/issuables/default_sort_order_spec.rb b/spec/features/issuables/default_sort_order_spec.rb index 0d495cd04aa..9114f751b55 100644 --- a/spec/features/issuables/default_sort_order_spec.rb +++ b/spec/features/issuables/default_sort_order_spec.rb @@ -55,7 +55,7 @@ describe 'Projects > Issuables > Default sort order', feature: true do it 'is "last updated"' do visit_merge_requests_with_state(project, 'merged') - expect(selected_sort_order).to eq('last updated') + expect(find('.issues-other-filters')).to have_content('Last updated') expect(first_merge_request).to include(last_updated_issuable.title) expect(last_merge_request).to include(first_updated_issuable.title) end @@ -67,7 +67,7 @@ describe 'Projects > Issuables > Default sort order', feature: true do it 'is "last updated"' do visit_merge_requests_with_state(project, 'closed') - expect(selected_sort_order).to eq('last updated') + expect(find('.issues-other-filters')).to have_content('Last updated') expect(first_merge_request).to include(last_updated_issuable.title) expect(last_merge_request).to include(first_updated_issuable.title) end @@ -79,7 +79,7 @@ describe 'Projects > Issuables > Default sort order', feature: true do it 'is "last created"' do visit_merge_requests_with_state(project, 'all') - expect(selected_sort_order).to eq('last created') + expect(find('.issues-other-filters')).to have_content('Last created') expect(first_merge_request).to include(last_created_issuable.title) expect(last_merge_request).to include(first_created_issuable.title) end @@ -108,7 +108,7 @@ describe 'Projects > Issuables > Default sort order', feature: true do it 'is "last created"' do visit_issues project - expect(selected_sort_order).to eq('last created') + expect(find('.issues-other-filters')).to have_content('Last created') expect(first_issue).to include(last_created_issuable.title) expect(last_issue).to include(first_created_issuable.title) end @@ -120,7 +120,7 @@ describe 'Projects > Issuables > Default sort order', feature: true do it 'is "last created"' do visit_issues_with_state(project, 'open') - expect(selected_sort_order).to eq('last created') + expect(find('.issues-other-filters')).to have_content('Last created') expect(first_issue).to include(last_created_issuable.title) expect(last_issue).to include(first_created_issuable.title) end @@ -132,7 +132,7 @@ describe 'Projects > Issuables > Default sort order', feature: true do it 'is "last updated"' do visit_issues_with_state(project, 'closed') - expect(selected_sort_order).to eq('last updated') + expect(find('.issues-other-filters')).to have_content('Last updated') expect(first_issue).to include(last_updated_issuable.title) expect(last_issue).to include(first_updated_issuable.title) end @@ -144,7 +144,7 @@ describe 'Projects > Issuables > Default sort order', feature: true do it 'is "last created"' do visit_issues_with_state(project, 'all') - expect(selected_sort_order).to eq('last created') + expect(find('.issues-other-filters')).to have_content('Last created') expect(first_issue).to include(last_created_issuable.title) expect(last_issue).to include(first_created_issuable.title) end diff --git a/spec/features/issues/filter_issues_spec.rb b/spec/features/issues/filter_issues_spec.rb index ea81ee54c90..e262f285868 100644 --- a/spec/features/issues/filter_issues_spec.rb +++ b/spec/features/issues/filter_issues_spec.rb @@ -117,7 +117,7 @@ describe 'Filter issues', feature: true do find('.dropdown-menu-user-link', text: user.username).click - wait_for_ajax + expect(page).not_to have_selector('.issues-list .issue') find('.js-label-select').click diff --git a/spec/features/merge_requests/create_new_mr_spec.rb b/spec/features/merge_requests/create_new_mr_spec.rb index e296078bad8..11c9de3c4bf 100644 --- a/spec/features/merge_requests/create_new_mr_spec.rb +++ b/spec/features/merge_requests/create_new_mr_spec.rb @@ -13,6 +13,8 @@ feature 'Create New Merge Request', feature: true, js: true do it 'generates a diff for an orphaned branch' do click_link 'New Merge Request' + expect(page).to have_content('Source branch') + expect(page).to have_content('Target branch') first('.js-source-branch').click first('.dropdown-source-branch .dropdown-content a', text: 'orphaned-branch').click diff --git a/spec/features/profiles/preferences_spec.rb b/spec/features/profiles/preferences_spec.rb index 787bf42d048..259ed7531d8 100644 --- a/spec/features/profiles/preferences_spec.rb +++ b/spec/features/profiles/preferences_spec.rb @@ -68,10 +68,14 @@ describe 'Profile > Preferences', feature: true do allowing_for_delay do find('#logo').click + + expect(page).to have_content('You don\'t have starred projects yet') expect(page.current_path).to eq starred_dashboard_projects_path end click_link 'Your Projects' + + expect(page).not_to have_content('You don\'t have starred projects yet') expect(page.current_path).to eq dashboard_projects_path end end diff --git a/spec/features/projects/files/project_owner_creates_license_file_spec.rb b/spec/features/projects/files/project_owner_creates_license_file_spec.rb index e1e105e6bbe..dbd07464444 100644 --- a/spec/features/projects/files/project_owner_creates_license_file_spec.rb +++ b/spec/features/projects/files/project_owner_creates_license_file_spec.rb @@ -39,6 +39,7 @@ feature 'project owner creates a license file', feature: true, js: true do scenario 'project master creates a license file from the "Add license" link' do click_link 'Add License' + expect(page).to have_content('New File') expect(current_path).to eq( namespace_project_new_blob_path(project.namespace, project, 'master')) expect(find('#file_name').value).to eq('LICENSE') diff --git a/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb b/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb index 67aac25e427..45bf0c0d038 100644 --- a/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb +++ b/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb @@ -14,6 +14,7 @@ feature 'project owner sees a link to create a license file in empty project', f visit namespace_project_path(project.namespace, project) click_link 'Create empty bare repository' click_on 'LICENSE' + expect(page).to have_content('New File') expect(current_path).to eq( namespace_project_new_blob_path(project.namespace, project, 'master')) diff --git a/spec/features/variables_spec.rb b/spec/features/variables_spec.rb index 61f2bc61e0c..d7880d5778f 100644 --- a/spec/features/variables_spec.rb +++ b/spec/features/variables_spec.rb @@ -42,6 +42,7 @@ describe 'Project variables', js: true do find('.btn-variable-edit').click end + expect(page).to have_content('Update variable') fill_in('variable_key', with: 'key') fill_in('variable_value', with: 'key value') click_button('Save variable') From 59955fbbbd5f807654e0861f49b182f27654d4cc Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 11 Aug 2016 18:09:17 +0100 Subject: [PATCH 073/133] Used mirrored version on GitLab --- scripts/prepare_build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/prepare_build.sh b/scripts/prepare_build.sh index e6a673c94e9..c1ee808e75d 100755 --- a/scripts/prepare_build.sh +++ b/scripts/prepare_build.sh @@ -22,7 +22,7 @@ if [ -f /.dockerenv ] || [ -f ./dockerinit ]; then pushd vendor/apt PHANTOMJS_FILE="phantomjs-$PHANTOMJS_VERSION-linux-x86_64" if [ ! -d "$PHANTOMJS_FILE" ]; then - curl -q -L "https://bitbucket.org/ariya/phantomjs/downloads/$PHANTOMJS_FILE.tar.bz2" | tar jx + curl -q -L "https://gitlab.com/iamphill/phantomjs/raw/master/$PHANTOMJS_FILE.tar.bz2" | tar jx fi cp "$PHANTOMJS_FILE/bin/phantomjs" "/usr/bin/" popd From 61bdc80dbad253f6532a83ae2554de82deb40e5f Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Fri, 12 Aug 2016 10:10:59 +0100 Subject: [PATCH 074/133] Updated failing tests --- app/assets/javascripts/issuable.js | 11 ++++------- features/steps/dashboard/dashboard.rb | 1 + features/steps/dashboard/event_filters.rb | 10 ++++++++++ features/steps/project/source/browse_files.rb | 1 + features/support/wait_for_ajax.rb | 11 +++++++++++ 5 files changed, 27 insertions(+), 7 deletions(-) create mode 100644 features/support/wait_for_ajax.rb diff --git a/app/assets/javascripts/issuable.js b/app/assets/javascripts/issuable.js index f27f1bad1f7..d0305c6c6a1 100644 --- a/app/assets/javascripts/issuable.js +++ b/app/assets/javascripts/issuable.js @@ -5,13 +5,10 @@ this.Issuable = { init: function() { - if (!issuable_created) { - issuable_created = true; - Issuable.initTemplates(); - Issuable.initSearch(); - Issuable.initChecks(); - return Issuable.initLabelFilterRemove(); - } + Issuable.initTemplates(); + Issuable.initSearch(); + Issuable.initChecks(); + return Issuable.initLabelFilterRemove(); }, initTemplates: function() { return Issuable.labelRow = _.template('<% _.each(labels, function(label){ %> <%- label.title %> <% }); %>'); diff --git a/features/steps/dashboard/dashboard.rb b/features/steps/dashboard/dashboard.rb index 80ed4c6d64c..a7d61bc28e0 100644 --- a/features/steps/dashboard/dashboard.rb +++ b/features/steps/dashboard/dashboard.rb @@ -26,6 +26,7 @@ class Spinach::Features::Dashboard < Spinach::FeatureSteps end step 'I see prefilled new Merge Request page' do + expect(page).to have_selector('.merge-request-form') expect(current_path).to eq new_namespace_project_merge_request_path(@project.namespace, @project) expect(find("#merge_request_target_project_id").value).to eq @project.id.to_s expect(find("input#merge_request_source_branch").value).to eq "fix" diff --git a/features/steps/dashboard/event_filters.rb b/features/steps/dashboard/event_filters.rb index 97b17abb470..1806bb138eb 100644 --- a/features/steps/dashboard/event_filters.rb +++ b/features/steps/dashboard/event_filters.rb @@ -1,34 +1,44 @@ class Spinach::Features::EventFilters < Spinach::FeatureSteps + include WaitForAjax include SharedAuthentication include SharedPaths include SharedProject step 'I should see push event' do + wait_for_ajax sleep 1 expect(page).to have_selector('span.pushed') end step 'I should not see push event' do + wait_for_ajax + save_and_open_screenshot sleep 1 + save_and_open_screenshot expect(page).not_to have_selector('span.pushed') + save_and_open_screenshot end step 'I should see new member event' do + wait_for_ajax sleep 1 expect(page).to have_selector('span.joined') end step 'I should not see new member event' do + wait_for_ajax sleep 1 expect(page).not_to have_selector('span.joined') end step 'I should see merge request event' do + wait_for_ajax sleep 1 expect(page).to have_selector('span.accepted') end step 'I should not see merge request event' do + wait_for_ajax sleep 1 expect(page).not_to have_selector('span.accepted') end diff --git a/features/steps/project/source/browse_files.rb b/features/steps/project/source/browse_files.rb index 2b74e964ca1..841d191d55b 100644 --- a/features/steps/project/source/browse_files.rb +++ b/features/steps/project/source/browse_files.rb @@ -132,6 +132,7 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps step 'I click on "New file" link in repo' do find('.add-to-tree').click click_link 'New file' + expect(page).to have_selector('.file-editor') end step 'I click on "Upload file" link in repo' do diff --git a/features/support/wait_for_ajax.rb b/features/support/wait_for_ajax.rb new file mode 100644 index 00000000000..b90fc112671 --- /dev/null +++ b/features/support/wait_for_ajax.rb @@ -0,0 +1,11 @@ +module WaitForAjax + def wait_for_ajax + Timeout.timeout(Capybara.default_max_wait_time) do + loop until finished_all_ajax_requests? + end + end + + def finished_all_ajax_requests? + page.evaluate_script('jQuery.active').zero? + end +end From a0cd87d175a2e2c7a19595f7fa4d250309850a35 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Fri, 12 Aug 2016 10:41:17 +0100 Subject: [PATCH 075/133] Removed screenshot command :poop: --- features/steps/dashboard/event_filters.rb | 3 --- 1 file changed, 3 deletions(-) diff --git a/features/steps/dashboard/event_filters.rb b/features/steps/dashboard/event_filters.rb index 1806bb138eb..c9896cbc24a 100644 --- a/features/steps/dashboard/event_filters.rb +++ b/features/steps/dashboard/event_filters.rb @@ -12,11 +12,8 @@ class Spinach::Features::EventFilters < Spinach::FeatureSteps step 'I should not see push event' do wait_for_ajax - save_and_open_screenshot sleep 1 - save_and_open_screenshot expect(page).not_to have_selector('span.pushed') - save_and_open_screenshot end step 'I should see new member event' do From c617cbfcd66a5ded22041e06b5e68454c7145029 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Fri, 12 Aug 2016 12:47:17 +0100 Subject: [PATCH 076/133] Fixed filtering tests --- features/steps/dashboard/event_filters.rb | 15 +++------------ features/steps/project/issues/issues.rb | 3 ++- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/features/steps/dashboard/event_filters.rb b/features/steps/dashboard/event_filters.rb index c9896cbc24a..2708e191f75 100644 --- a/features/steps/dashboard/event_filters.rb +++ b/features/steps/dashboard/event_filters.rb @@ -5,38 +5,26 @@ class Spinach::Features::EventFilters < Spinach::FeatureSteps include SharedProject step 'I should see push event' do - wait_for_ajax - sleep 1 expect(page).to have_selector('span.pushed') end step 'I should not see push event' do - wait_for_ajax - sleep 1 expect(page).not_to have_selector('span.pushed') end step 'I should see new member event' do - wait_for_ajax - sleep 1 expect(page).to have_selector('span.joined') end step 'I should not see new member event' do - wait_for_ajax - sleep 1 expect(page).not_to have_selector('span.joined') end step 'I should see merge request event' do - wait_for_ajax - sleep 1 expect(page).to have_selector('span.accepted') end step 'I should not see merge request event' do - wait_for_ajax - sleep 1 expect(page).not_to have_selector('span.accepted') end @@ -86,13 +74,16 @@ class Spinach::Features::EventFilters < Spinach::FeatureSteps When 'I click "push" event filter' do click_link("push_event_filter") + sleep 1 end When 'I click "team" event filter' do click_link("team_event_filter") + sleep 1 end When 'I click "merge" event filter' do click_link("merged_event_filter") + sleep 1 end end diff --git a/features/steps/project/issues/issues.rb b/features/steps/project/issues/issues.rb index c1592e1d142..daee90b3767 100644 --- a/features/steps/project/issues/issues.rb +++ b/features/steps/project/issues/issues.rb @@ -354,7 +354,8 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps end def filter_issue(text) + sleep 1 fill_in 'issue_search', with: text - find('#issue_search').native.send_keys(:return) + sleep 1 end end From 5d8ad797503c76ab9818584cf3b2e945ff8cfd68 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Fri, 12 Aug 2016 13:39:29 +0100 Subject: [PATCH 077/133] Filters test fix --- features/steps/dashboard/event_filters.rb | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/features/steps/dashboard/event_filters.rb b/features/steps/dashboard/event_filters.rb index 2708e191f75..3ff260c0027 100644 --- a/features/steps/dashboard/event_filters.rb +++ b/features/steps/dashboard/event_filters.rb @@ -73,17 +73,20 @@ class Spinach::Features::EventFilters < Spinach::FeatureSteps end When 'I click "push" event filter' do - click_link("push_event_filter") sleep 1 + click_link("Push events") + sleep 2 end When 'I click "team" event filter' do - click_link("team_event_filter") sleep 1 + click_link("Team") + sleep 2 end When 'I click "merge" event filter' do - click_link("merged_event_filter") sleep 1 + click_link("Merge events") + sleep 2 end end From 1392cad8956c490b5ba6106678ab16d790662148 Mon Sep 17 00:00:00 2001 From: Connor Shea Date: Fri, 12 Aug 2016 11:44:43 -0600 Subject: [PATCH 078/133] Remove sleeping and replace escaped text. --- features/steps/dashboard/event_filters.rb | 12 ++++++------ spec/features/profiles/preferences_spec.rb | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/features/steps/dashboard/event_filters.rb b/features/steps/dashboard/event_filters.rb index 3ff260c0027..ca3cd0ecc4e 100644 --- a/features/steps/dashboard/event_filters.rb +++ b/features/steps/dashboard/event_filters.rb @@ -73,20 +73,20 @@ class Spinach::Features::EventFilters < Spinach::FeatureSteps end When 'I click "push" event filter' do - sleep 1 + wait_for_ajax click_link("Push events") - sleep 2 + wait_for_ajax end When 'I click "team" event filter' do - sleep 1 + wait_for_ajax click_link("Team") - sleep 2 + wait_for_ajax end When 'I click "merge" event filter' do - sleep 1 + wait_for_ajax click_link("Merge events") - sleep 2 + wait_for_ajax end end diff --git a/spec/features/profiles/preferences_spec.rb b/spec/features/profiles/preferences_spec.rb index 259ed7531d8..d14a1158b67 100644 --- a/spec/features/profiles/preferences_spec.rb +++ b/spec/features/profiles/preferences_spec.rb @@ -69,13 +69,13 @@ describe 'Profile > Preferences', feature: true do allowing_for_delay do find('#logo').click - expect(page).to have_content('You don\'t have starred projects yet') + expect(page).to have_content("You don't have starred projects yet") expect(page.current_path).to eq starred_dashboard_projects_path end click_link 'Your Projects' - expect(page).not_to have_content('You don\'t have starred projects yet') + expect(page).not_to have_content("You don't have starred projects yet") expect(page.current_path).to eq dashboard_projects_path end end From 2cf3c1c31a80d2827c179535362de111ffa72404 Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran Date: Fri, 12 Aug 2016 15:29:39 -0500 Subject: [PATCH 079/133] Update phantomjs link --- scripts/prepare_build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/prepare_build.sh b/scripts/prepare_build.sh index c1ee808e75d..388c1a4dafb 100755 --- a/scripts/prepare_build.sh +++ b/scripts/prepare_build.sh @@ -22,7 +22,7 @@ if [ -f /.dockerenv ] || [ -f ./dockerinit ]; then pushd vendor/apt PHANTOMJS_FILE="phantomjs-$PHANTOMJS_VERSION-linux-x86_64" if [ ! -d "$PHANTOMJS_FILE" ]; then - curl -q -L "https://gitlab.com/iamphill/phantomjs/raw/master/$PHANTOMJS_FILE.tar.bz2" | tar jx + curl -q -L "https://s3.amazonaws.com/gitlab-build-helpers/phantomjs-2.1.1-linux-x86_64.tar.bz2" | tar jx fi cp "$PHANTOMJS_FILE/bin/phantomjs" "/usr/bin/" popd From 504a3b5e6f0b2e2957cf1e4d9d8eebbf32234bdb Mon Sep 17 00:00:00 2001 From: Ahmad Sherif Date: Sun, 14 Aug 2016 22:27:49 +0200 Subject: [PATCH 080/133] Fix a memory leak caused by Banzai::Filter::SanitizationFilter In Banzai::Filter::SanitizationFilter#customize_whitelist, we append three lambdas that has reference to the SanitizationFilter instance, which in turn (potentially) has a reference to the following chain: context hash -> Project instance -> Repository instance -> lookup hash -> various Rugged instances -> various mmap-ed git pack files. All of the above is not garbage collected because the array we append the lambdas to is the constant HTML::Pipeline::SanitizationFilter::WHITELIST. --- CHANGELOG | 1 + lib/banzai/filter/sanitization_filter.rb | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index 6e096b480c0..fa9b81d3303 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -108,6 +108,7 @@ v 8.11.0 (unreleased) - Sort folders with submodules in Files view !5521 - Each `File::exists?` replaced to `File::exist?` because of deprecate since ruby version 2.2.0 - Add auto-completition in pipeline (Katarzyna Kobierska Ula Budziszewska) + - Fix a memory leak caused by Banzai::Filter::SanitizationFilter v 8.10.5 - Add a data migration to fix some missing timestamps in the members table. !5670 diff --git a/lib/banzai/filter/sanitization_filter.rb b/lib/banzai/filter/sanitization_filter.rb index ca80aac5a08..6e13282d5f4 100644 --- a/lib/banzai/filter/sanitization_filter.rb +++ b/lib/banzai/filter/sanitization_filter.rb @@ -7,7 +7,7 @@ module Banzai UNSAFE_PROTOCOLS = %w(data javascript vbscript).freeze def whitelist - whitelist = super + whitelist = super.dup customize_whitelist(whitelist) @@ -42,6 +42,8 @@ module Banzai # Allow any protocol in `a` elements... whitelist[:protocols].delete('a') + whitelist[:transformers] = whitelist[:transformers].dup + # ...but then remove links with unsafe protocols whitelist[:transformers].push(remove_unsafe_links) From ade0c2c8922c0838ba85cf69419cbb109453d6b2 Mon Sep 17 00:00:00 2001 From: Frank West Date: Mon, 8 Aug 2016 03:29:23 +0000 Subject: [PATCH 081/133] Prevents accidental overwrites of commits from UI Currently when a user performs an update of a file through the UI and there has already been a change committed to the file the previous commits will be overwritten without a check to see if the file has been changed. This commit uses the last commit sha at the time the user starts editing the file and compares it with the current sha of the file being edited to ensure they are the same before committing the file. If the shas do not match we throw an exception preventing the commit from the commit from occurring. Fixes #5857 --- CHANGELOG | 1 + app/controllers/projects/blob_controller.rb | 14 +++- app/services/files/base_service.rb | 1 + app/services/files/update_service.rb | 23 +++++ app/views/projects/blob/edit.html.haml | 9 +- .../projects/files/editing_a_file_spec.rb | 34 ++++++++ spec/services/files/update_service_spec.rb | 84 +++++++++++++++++++ 7 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 spec/features/projects/files/editing_a_file_spec.rb create mode 100644 spec/services/files/update_service_spec.rb diff --git a/CHANGELOG b/CHANGELOG index 6e096b480c0..d424d6aebc6 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -108,6 +108,7 @@ v 8.11.0 (unreleased) - Sort folders with submodules in Files view !5521 - Each `File::exists?` replaced to `File::exist?` because of deprecate since ruby version 2.2.0 - Add auto-completition in pipeline (Katarzyna Kobierska Ula Budziszewska) + - Ensure file editing in UI does not overwrite commited changes without warning user v 8.10.5 - Add a data migration to fix some missing timestamps in the members table. !5670 diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 19d051720e9..cdf9a04bacf 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -17,6 +17,7 @@ class Projects::BlobController < Projects::ApplicationController before_action :require_branch_head, only: [:edit, :update] before_action :editor_variables, except: [:show, :preview, :diff] before_action :validate_diff_params, only: :diff + before_action :set_last_commit_sha, only: [:edit, :update] def new commit unless @repository.empty? @@ -33,7 +34,6 @@ class Projects::BlobController < Projects::ApplicationController end def edit - @last_commit = Gitlab::Git::Commit.last_for_path(@repository, @ref, @path).sha blob.load_all_data!(@repository) end @@ -55,6 +55,10 @@ class Projects::BlobController < Projects::ApplicationController create_commit(Files::UpdateService, success_path: after_edit_path, failure_view: :edit, failure_path: namespace_project_blob_path(@project.namespace, @project, @id)) + + rescue Files::UpdateService::FileChangedError + @conflict = true + render :edit end def preview @@ -152,7 +156,8 @@ class Projects::BlobController < Projects::ApplicationController file_path: @file_path, commit_message: params[:commit_message], file_content: params[:content], - file_content_encoding: params[:encoding] + file_content_encoding: params[:encoding], + last_commit_sha: params[:last_commit_sha] } end @@ -161,4 +166,9 @@ class Projects::BlobController < Projects::ApplicationController render nothing: true end end + + def set_last_commit_sha + @last_commit_sha = Gitlab::Git::Commit. + last_for_path(@repository, @ref, @path).sha + end end diff --git a/app/services/files/base_service.rb b/app/services/files/base_service.rb index c4a206f785e..ea94818713b 100644 --- a/app/services/files/base_service.rb +++ b/app/services/files/base_service.rb @@ -15,6 +15,7 @@ module Files else params[:file_content] end + @last_commit_sha = params[:last_commit_sha] # Validate parameters validate diff --git a/app/services/files/update_service.rb b/app/services/files/update_service.rb index 8d2b5083179..4fc3b640799 100644 --- a/app/services/files/update_service.rb +++ b/app/services/files/update_service.rb @@ -2,11 +2,34 @@ require_relative "base_service" module Files class UpdateService < Files::BaseService + class FileChangedError < StandardError; end + def commit repository.update_file(current_user, @file_path, @file_content, branch: @target_branch, previous_path: @previous_path, message: @commit_message) end + + private + + def validate + super + + if file_has_changed? + raise FileChangedError.new("You are attempting to update a file that has changed since you started editing it.") + end + end + + def file_has_changed? + return false unless @last_commit_sha && last_commit + + @last_commit_sha != last_commit.sha + end + + def last_commit + @last_commit ||= Gitlab::Git::Commit. + last_for_path(@source_project.repository, @source_branch, @file_path) + end end end diff --git a/app/views/projects/blob/edit.html.haml b/app/views/projects/blob/edit.html.haml index b1c9895f43e..7b0621f9401 100644 --- a/app/views/projects/blob/edit.html.haml +++ b/app/views/projects/blob/edit.html.haml @@ -1,5 +1,11 @@ - page_title "Edit", @blob.path, @ref +- if @conflict + .alert.alert-danger + Someone edited the file the same time you did. Please check out + = link_to "the file", namespace_project_blob_path(@project.namespace, @project, tree_join(@target_branch, @file_path)), target: "_blank" + and make sure your changes will not unintentionally remove theirs. + .file-editor %ul.nav-links.no-bottom.js-edit-mode %li.active @@ -13,8 +19,7 @@ = form_tag(namespace_project_update_blob_path(@project.namespace, @project, @id), method: :put, class: 'form-horizontal js-quick-submit js-requires-input js-edit-blob-form') do = render 'projects/blob/editor', ref: @ref, path: @path, blob_data: @blob.data = render 'shared/new_commit_form', placeholder: "Update #{@blob.name}" - - = hidden_field_tag 'last_commit', @last_commit + = hidden_field_tag 'last_commit_sha', @last_commit_sha = hidden_field_tag 'content', '', id: "file-content" = hidden_field_tag 'from_merge_request_id', params[:from_merge_request_id] = render 'projects/commit_button', ref: @ref, cancel_path: namespace_project_blob_path(@project.namespace, @project, @id) diff --git a/spec/features/projects/files/editing_a_file_spec.rb b/spec/features/projects/files/editing_a_file_spec.rb new file mode 100644 index 00000000000..fe047e00409 --- /dev/null +++ b/spec/features/projects/files/editing_a_file_spec.rb @@ -0,0 +1,34 @@ +require 'spec_helper' + +feature 'User wants to edit a file', feature: true do + include WaitForAjax + + let(:project) { create(:project) } + let(:user) { create(:user) } + let(:commit_params) do + { + source_branch: project.default_branch, + target_branch: project.default_branch, + commit_message: "Committing First Update", + file_path: ".gitignore", + file_content: "First Update", + last_commit_sha: Gitlab::Git::Commit.last_for_path(project.repository, project.default_branch, + ".gitignore").sha + } + end + + background do + project.team << [user, :master] + login_as user + visit namespace_project_edit_blob_path(project.namespace, project, + File.join(project.default_branch, '.gitignore')) + end + + scenario 'file has been updated since the user opened the edit page' do + Files::UpdateService.new(project, user, commit_params).execute + + click_button 'Commit Changes' + + expect(page).to have_content 'Someone edited the file the same time you did.' + end +end diff --git a/spec/services/files/update_service_spec.rb b/spec/services/files/update_service_spec.rb new file mode 100644 index 00000000000..d019e50649f --- /dev/null +++ b/spec/services/files/update_service_spec.rb @@ -0,0 +1,84 @@ +require "spec_helper" + +describe Files::UpdateService do + subject { described_class.new(project, user, commit_params) } + + let(:project) { create(:project) } + let(:user) { create(:user) } + let(:file_path) { 'files/ruby/popen.rb' } + let(:new_contents) { "New Content" } + let(:commit_params) do + { + file_path: file_path, + commit_message: "Update File", + file_content: new_contents, + file_content_encoding: "text", + last_commit_sha: last_commit_sha, + source_project: project, + source_branch: project.default_branch, + target_branch: project.default_branch, + } + end + + before do + project.team << [user, :master] + end + + describe "#execute" do + context "when the file's last commit sha does not match the supplied last_commit_sha" do + let(:last_commit_sha) { "foo" } + + it "returns a hash with the correct error message and a :error status " do + expect { subject.execute }. + to raise_error(Files::UpdateService::FileChangedError, + "You are attempting to update a file that has changed since you started editing it.") + end + end + + context "when the file's last commit sha does match the supplied last_commit_sha" do + let(:last_commit_sha) { Gitlab::Git::Commit.last_for_path(project.repository, project.default_branch, file_path).sha } + + it "returns a hash with the :success status " do + results = subject.execute + + expect(results).to match({ status: :success }) + end + + it "updates the file with the new contents" do + subject.execute + + results = project.repository.blob_at_branch(project.default_branch, file_path) + + expect(results.data).to eq(new_contents) + end + end + + context "when the last_commit_sha is not supplied" do + let(:commit_params) do + { + file_path: file_path, + commit_message: "Update File", + file_content: new_contents, + file_content_encoding: "text", + source_project: project, + source_branch: project.default_branch, + target_branch: project.default_branch, + } + end + + it "returns a hash with the :success status " do + results = subject.execute + + expect(results).to match({ status: :success }) + end + + it "updates the file with the new contents" do + subject.execute + + results = project.repository.blob_at_branch(project.default_branch, file_path) + + expect(results.data).to eq(new_contents) + end + end + end +end From 4e4ca27ab148f16d9252634e3e2f58447acdeeef Mon Sep 17 00:00:00 2001 From: Egor Lynko Date: Wed, 10 Aug 2016 17:15:27 +0300 Subject: [PATCH 082/133] Ability to specify branches for pivotal tracker integration --- CHANGELOG | 1 + .../pivotaltracker_service.rb | 31 ++++++-- doc/api/services.md | 4 +- .../pivotaltracker_service_spec.rb | 71 +++++++++++++++++++ 4 files changed, 101 insertions(+), 6 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 6e096b480c0..43fe73d9b9a 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -2,6 +2,7 @@ Please view this file on the master branch, on stable branches it's out of date. v 8.11.0 (unreleased) - Remove the http_parser.rb dependency by removing the tinder gem. !5758 (tbalthazar) + - Ability to specify branches for Pivotal Tracker integration (Egor Lynko) - Fix don't pass a local variable called `i` to a partial. !20510 (herminiotorres) - Fix rename `add_users_into_project` and `projects_ids`. !20512 (herminiotorres) - Fix the title of the toggle dropdown button. !5515 (herminiotorres) diff --git a/app/models/project_services/pivotaltracker_service.rb b/app/models/project_services/pivotaltracker_service.rb index ad19b7795da..5301f9fa0ff 100644 --- a/app/models/project_services/pivotaltracker_service.rb +++ b/app/models/project_services/pivotaltracker_service.rb @@ -1,7 +1,9 @@ class PivotaltrackerService < Service include HTTParty - prop_accessor :token + API_ENDPOINT = 'https://www.pivotaltracker.com/services/v5/source_commits' + + prop_accessor :token, :restrict_to_branch validates :token, presence: true, if: :activated? def title @@ -18,7 +20,17 @@ class PivotaltrackerService < Service def fields [ - { type: 'text', name: 'token', placeholder: '' } + { + type: 'text', + name: 'token', + placeholder: 'Pivotal Tracker API token.' + }, + { + type: 'text', + name: 'restrict_to_branch', + placeholder: 'Comma-separated list of branches which will be ' \ + 'automatically inspected. Leave blank to include all branches.' + } ] end @@ -28,8 +40,8 @@ class PivotaltrackerService < Service def execute(data) return unless supported_events.include?(data[:object_kind]) + return unless allowed_branch?(data[:ref]) - url = 'https://www.pivotaltracker.com/services/v5/source_commits' data[:commits].each do |commit| message = { 'source_commit' => { @@ -40,7 +52,7 @@ class PivotaltrackerService < Service } } PivotaltrackerService.post( - url, + API_ENDPOINT, body: message.to_json, headers: { 'Content-Type' => 'application/json', @@ -49,4 +61,15 @@ class PivotaltrackerService < Service ) end end + + private + + def allowed_branch?(ref) + return true unless ref.present? && restrict_to_branch.present? + + branch = Gitlab::Git.ref_name(ref) + allowed_branches = restrict_to_branch.split(',').map(&:strip) + + branch.present? && allowed_branches.include?(branch) + end end diff --git a/doc/api/services.md b/doc/api/services.md index f821a614047..579fdc0c8c9 100644 --- a/doc/api/services.md +++ b/doc/api/services.md @@ -355,7 +355,7 @@ PUT /projects/:id/services/gemnasium Parameters: -- `api_key` (**required**) - Your personal API KEY on gemnasium.com +- `api_key` (**required**) - Your personal API KEY on gemnasium.com - `token` (**required**) - The project's slug on gemnasium.com ### Delete Gemnasium service @@ -503,6 +503,7 @@ PUT /projects/:id/services/pivotaltracker Parameters: - `token` (**required**) +- `restrict_to_branch` (optional) - Comma-separated list of branches which will be automatically inspected. Leave blank to include all branches. ### Delete PivotalTracker service @@ -661,4 +662,3 @@ Get JetBrains TeamCity CI service settings for a project. ``` GET /projects/:id/services/teamcity ``` - diff --git a/spec/models/project_services/pivotaltracker_service_spec.rb b/spec/models/project_services/pivotaltracker_service_spec.rb index f37edd4d970..d098d988521 100644 --- a/spec/models/project_services/pivotaltracker_service_spec.rb +++ b/spec/models/project_services/pivotaltracker_service_spec.rb @@ -39,4 +39,75 @@ describe PivotaltrackerService, models: true do it { is_expected.not_to validate_presence_of(:token) } end end + + describe 'Execute' do + let(:service) do + PivotaltrackerService.new.tap do |service| + service.token = 'secret_api_token' + end + end + + let(:url) { PivotaltrackerService::API_ENDPOINT } + + def push_data(branch: 'master') + { + object_kind: 'push', + ref: "refs/heads/#{branch}", + commits: [ + { + id: '21c12ea', + author: { + name: 'Some User' + }, + url: 'https://example.com/commit', + message: 'commit message', + } + ] + } + end + + before do + WebMock.stub_request(:post, url) + end + + it 'should post correct message' do + service.execute(push_data) + expect(WebMock).to have_requested(:post, url).with( + body: { + 'source_commit' => { + 'commit_id' => '21c12ea', + 'author' => 'Some User', + 'url' => 'https://example.com/commit', + 'message' => 'commit message' + } + }, + headers: { + 'Content-Type' => 'application/json', + 'X-TrackerToken' => 'secret_api_token' + } + ).once + end + + context 'when allowed branches is specified' do + let(:service) do + super().tap do |service| + service.restrict_to_branch = 'master,v10' + end + end + + it 'should post message if branch is in the list' do + service.execute(push_data(branch: 'master')) + service.execute(push_data(branch: 'v10')) + + expect(WebMock).to have_requested(:post, url).twice + end + + it 'should not post message if branch is not in the list' do + service.execute(push_data(branch: 'mas')) + service.execute(push_data(branch: 'v11')) + + expect(WebMock).not_to have_requested(:post, url) + end + end + end end From 501a7e89978a03e7e10f497c5ad0d4f87319dbe5 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Mon, 15 Aug 2016 11:43:23 +0100 Subject: [PATCH 083/133] Used phantomjs variable --- scripts/prepare_build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/prepare_build.sh b/scripts/prepare_build.sh index 388c1a4dafb..76b2178c79c 100755 --- a/scripts/prepare_build.sh +++ b/scripts/prepare_build.sh @@ -22,7 +22,7 @@ if [ -f /.dockerenv ] || [ -f ./dockerinit ]; then pushd vendor/apt PHANTOMJS_FILE="phantomjs-$PHANTOMJS_VERSION-linux-x86_64" if [ ! -d "$PHANTOMJS_FILE" ]; then - curl -q -L "https://s3.amazonaws.com/gitlab-build-helpers/phantomjs-2.1.1-linux-x86_64.tar.bz2" | tar jx + curl -q -L "https://s3.amazonaws.com/gitlab-build-helpers/$PHANTOMJS_FILE.tar.bz2" | tar jx fi cp "$PHANTOMJS_FILE/bin/phantomjs" "/usr/bin/" popd From d13c36f72de6b1c56e2063dafddd14ecbb430b9a Mon Sep 17 00:00:00 2001 From: Ahmad Sherif Date: Fri, 12 Aug 2016 14:29:59 +0200 Subject: [PATCH 084/133] Speed up todos queries by limiting the projects set we join with Closes #20828 --- CHANGELOG | 1 + app/finders/projects_finder.rb | 3 ++- app/finders/todos_finder.rb | 20 ++++++++++---------- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 658bd92a824..c6c2220b133 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -110,6 +110,7 @@ v 8.11.0 (unreleased) - Each `File::exists?` replaced to `File::exist?` because of deprecate since ruby version 2.2.0 - Add auto-completition in pipeline (Katarzyna Kobierska Ula Budziszewska) - Fix a memory leak caused by Banzai::Filter::SanitizationFilter + - Speed up todos queries by limiting the projects set we join with v 8.10.5 - Add a data migration to fix some missing timestamps in the members table. !5670 diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb index 2f0a9659d15..56877b6d75a 100644 --- a/app/finders/projects_finder.rb +++ b/app/finders/projects_finder.rb @@ -1,6 +1,7 @@ class ProjectsFinder < UnionFinder - def execute(current_user = nil, options = {}) + def execute(current_user = nil, options = {}, &block) segments = all_projects(current_user) + segments.map!(&block) if block find_union(segments, Project) end diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb index ff866c2faa5..9b24a86e1c1 100644 --- a/app/finders/todos_finder.rb +++ b/app/finders/todos_finder.rb @@ -27,9 +27,11 @@ class TodosFinder items = by_action_id(items) items = by_action(items) items = by_author(items) - items = by_project(items) items = by_state(items) items = by_type(items) + # Filtering by project HAS TO be the last because we use + # the project IDs yielded by the todos query thus far + items = by_project(items) items.reorder(id: :desc) end @@ -91,13 +93,10 @@ class TodosFinder @project end - def projects - return @projects if defined?(@projects) - - if project? - @projects = project - else - @projects = ProjectsFinder.new.execute(current_user) + def projects(items) + item_project_ids = items.reorder(nil).select(:project_id) + ProjectsFinder.new.execute(current_user) do |relation| + relation.where(id: item_project_ids) end end @@ -136,8 +135,9 @@ class TodosFinder def by_project(items) if project? items = items.where(project: project) - elsif projects - items = items.merge(projects).joins(:project) + else + item_projects = projects(items) + items = items.merge(item_projects).joins(:project) end items From 76db0dc115316e53ed7d2189cc8789f0a0cef3c2 Mon Sep 17 00:00:00 2001 From: Ahmad Sherif Date: Fri, 12 Aug 2016 15:52:58 +0200 Subject: [PATCH 085/133] Pass project IDs relation to ProjectsFinder instead of using a block --- app/finders/projects_finder.rb | 4 ++-- app/finders/todos_finder.rb | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb index 56877b6d75a..c7911736812 100644 --- a/app/finders/projects_finder.rb +++ b/app/finders/projects_finder.rb @@ -1,7 +1,7 @@ class ProjectsFinder < UnionFinder - def execute(current_user = nil, options = {}, &block) + def execute(current_user = nil, project_ids_relation = nil) segments = all_projects(current_user) - segments.map!(&block) if block + segments.map! { |s| s.where(id: project_ids_relation) } if project_ids_relation find_union(segments, Project) end diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb index 9b24a86e1c1..4fe0070552e 100644 --- a/app/finders/todos_finder.rb +++ b/app/finders/todos_finder.rb @@ -95,9 +95,7 @@ class TodosFinder def projects(items) item_project_ids = items.reorder(nil).select(:project_id) - ProjectsFinder.new.execute(current_user) do |relation| - relation.where(id: item_project_ids) - end + ProjectsFinder.new.execute(current_user, item_project_ids) end def type? From e93de6066b8a69bc741fcdf3ef3113dd9b187878 Mon Sep 17 00:00:00 2001 From: Ahmad Sherif Date: Fri, 12 Aug 2016 17:14:39 +0200 Subject: [PATCH 086/133] Fix ProjectsFinder spec Follow-up on 1003454c --- spec/finders/projects_finder_spec.rb | 69 +++++----------------------- 1 file changed, 12 insertions(+), 57 deletions(-) diff --git a/spec/finders/projects_finder_spec.rb b/spec/finders/projects_finder_spec.rb index 0a1cc3b3df7..e4b4b2d9b20 100644 --- a/spec/finders/projects_finder_spec.rb +++ b/spec/finders/projects_finder_spec.rb @@ -23,71 +23,26 @@ describe ProjectsFinder do let(:finder) { described_class.new } - describe 'without a group' do - describe 'without a user' do - subject { finder.execute } + describe 'without a user' do + subject { finder.execute } - it { is_expected.to eq([public_project]) } - end - - describe 'with a user' do - subject { finder.execute(user) } - - describe 'without private projects' do - it { is_expected.to eq([public_project, internal_project]) } - end - - describe 'with private projects' do - before do - private_project.team.add_user(user, Gitlab::Access::MASTER) - end - - it do - is_expected.to eq([public_project, internal_project, - private_project]) - end - end - end + it { is_expected.to eq([public_project]) } end - describe 'with a group' do - describe 'without a user' do - subject { finder.execute(nil, group: group) } + describe 'with a user' do + subject { finder.execute(user) } - it { is_expected.to eq([public_project]) } + describe 'without private projects' do + it { is_expected.to eq([public_project, internal_project]) } end - describe 'with a user' do - subject { finder.execute(user, group: group) } - - describe 'without shared projects' do - it { is_expected.to eq([public_project, internal_project]) } + describe 'with private projects' do + before do + private_project.team.add_user(user, Gitlab::Access::MASTER) end - describe 'with shared projects and group membership' do - before do - group.add_user(user, Gitlab::Access::DEVELOPER) - - shared_project.project_group_links. - create(group_access: Gitlab::Access::MASTER, group: group) - end - - it do - is_expected.to eq([shared_project, public_project, internal_project]) - end - end - - describe 'with shared projects and project membership' do - before do - shared_project.team.add_user(user, Gitlab::Access::DEVELOPER) - - shared_project.project_group_links. - create(group_access: Gitlab::Access::MASTER, group: group) - end - - it do - is_expected.to eq([shared_project, public_project, internal_project]) - end + it do + is_expected.to eq([public_project, internal_project, private_project]) end end end From be870760d5490a394e3f3e1b7b3fadb8424c38a8 Mon Sep 17 00:00:00 2001 From: Ahmad Sherif Date: Fri, 12 Aug 2016 17:30:44 +0200 Subject: [PATCH 087/133] Add a spec for ProjectsFinder project_ids_relation option Follow-up 1003454c --- spec/finders/projects_finder_spec.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/spec/finders/projects_finder_spec.rb b/spec/finders/projects_finder_spec.rb index e4b4b2d9b20..7a3a74335e8 100644 --- a/spec/finders/projects_finder_spec.rb +++ b/spec/finders/projects_finder_spec.rb @@ -46,5 +46,13 @@ describe ProjectsFinder do end end end + + describe 'with project_ids_relation' do + let(:project_ids_relation) { Project.where(id: internal_project.id) } + + subject { finder.execute(user, project_ids_relation) } + + it { is_expected.to eq([internal_project]) } + end end end From 8171544b3d44df6ce810aa436bf87d137bc9b28f Mon Sep 17 00:00:00 2001 From: Yorick Peterse Date: Fri, 12 Aug 2016 17:19:17 +0200 Subject: [PATCH 088/133] Limit the size of SVGs when viewing them as blobs This ensures that SVGs greater than 2 megabytes are not scrubbed and rendered. This in turn prevents requests from timing out due to reading/scrubbing large SVGs potentially taking a lot of time (and memory). The use of 2 megabytes is completely arbitrary. Fixes gitlab-org/gitlab-ce#1435 --- CHANGELOG | 1 + app/models/blob.rb | 7 +++++++ app/views/projects/blob/_image.html.haml | 16 +++++++++++----- spec/models/blob_spec.rb | 22 ++++++++++++++++++++++ 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 6e096b480c0..a1cd42d24f0 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -32,6 +32,7 @@ v 8.11.0 (unreleased) - Add "No one can push" as an option for protected branches. !5081 - Improve performance of AutolinkFilter#text_parse by using XPath - Add experimental Redis Sentinel support !1877 + - Rendering of SVGs as blobs is now limited to SVGs with a size smaller or equal to 2MB - Fix branches page dropdown sort initial state (ClemMakesApps) - Environments have an url to link to - Various redundant database indexes have been removed diff --git a/app/models/blob.rb b/app/models/blob.rb index 0df2805e448..12cc5aaafba 100644 --- a/app/models/blob.rb +++ b/app/models/blob.rb @@ -3,6 +3,9 @@ class Blob < SimpleDelegator CACHE_TIME = 60 # Cache raw blobs referred to by a (mutable) ref for 1 minute CACHE_TIME_IMMUTABLE = 3600 # Cache blobs referred to by an immutable reference for 1 hour + # The maximum size of an SVG that can be displayed. + MAXIMUM_SVG_SIZE = 2.megabytes + # Wrap a Gitlab::Git::Blob object, or return nil when given nil # # This method prevents the decorated object from evaluating to "truthy" when @@ -31,6 +34,10 @@ class Blob < SimpleDelegator text? && language && language.name == 'SVG' end + def size_within_svg_limits? + size <= MAXIMUM_SVG_SIZE + end + def video? UploaderHelper::VIDEO_EXT.include?(extname.downcase.delete('.')) end diff --git a/app/views/projects/blob/_image.html.haml b/app/views/projects/blob/_image.html.haml index 18caddabd39..4c356d1f07f 100644 --- a/app/views/projects/blob/_image.html.haml +++ b/app/views/projects/blob/_image.html.haml @@ -1,9 +1,15 @@ .file-content.image_file - if blob.svg? - - # We need to scrub SVG but we cannot do so in the RawController: it would - - # be wrong/strange if RawController modified the data. - - blob.load_all_data!(@repository) - - blob = sanitize_svg(blob) - %img{src: "data:#{blob.mime_type};base64,#{Base64.encode64(blob.data)}"} + - if blob.size_within_svg_limits? + - # We need to scrub SVG but we cannot do so in the RawController: it would + - # be wrong/strange if RawController modified the data. + - blob.load_all_data!(@repository) + - blob = sanitize_svg(blob) + %img{src: "data:#{blob.mime_type};base64,#{Base64.encode64(blob.data)}"} + - else + .nothing-here-block + The SVG could not be displayed as it is too large, you can + #{link_to('view the raw file', namespace_project_raw_path(@project.namespace, @project, @id), target: '_blank')} + instead. - else %img{src: namespace_project_raw_path(@project.namespace, @project, tree_join(@commit.id, blob.path))} diff --git a/spec/models/blob_spec.rb b/spec/models/blob_spec.rb index 1e5d6a34f83..cee20234e1f 100644 --- a/spec/models/blob_spec.rb +++ b/spec/models/blob_spec.rb @@ -94,4 +94,26 @@ describe Blob do expect(blob.to_partial_path).to eq 'download' end end + + describe '#size_within_svg_limits?' do + let(:blob) { described_class.decorate(double(:blob)) } + + it 'returns true when the blob size is smaller than the SVG limit' do + expect(blob).to receive(:size).and_return(42) + + expect(blob.size_within_svg_limits?).to eq(true) + end + + it 'returns true when the blob size is equal to the SVG limit' do + expect(blob).to receive(:size).and_return(Blob::MAXIMUM_SVG_SIZE) + + expect(blob.size_within_svg_limits?).to eq(true) + end + + it 'returns false when the blob size is larger than the SVG limit' do + expect(blob).to receive(:size).and_return(1.terabyte) + + expect(blob.size_within_svg_limits?).to eq(false) + end + end end From 6f0b5800a92e5a0a9b94a36e013baa6361d638d5 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Mon, 8 Aug 2016 13:37:16 +0200 Subject: [PATCH 089/133] Add empty test coverage badge class and specs --- lib/gitlab/badge/coverage.rb | 17 +++++++++++++++++ spec/lib/gitlab/badge/coverage_spec.rb | 19 +++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 lib/gitlab/badge/coverage.rb create mode 100644 spec/lib/gitlab/badge/coverage_spec.rb diff --git a/lib/gitlab/badge/coverage.rb b/lib/gitlab/badge/coverage.rb new file mode 100644 index 00000000000..94af3a7ec34 --- /dev/null +++ b/lib/gitlab/badge/coverage.rb @@ -0,0 +1,17 @@ +module Gitlab + module Badge + ## + # Test coverage badge + # + class Coverage + def initialize(project, ref, job = nil) + @project = project + @ref = ref + @job = job + end + + def coverage + end + end + end +end diff --git a/spec/lib/gitlab/badge/coverage_spec.rb b/spec/lib/gitlab/badge/coverage_spec.rb new file mode 100644 index 00000000000..343ca3c0f85 --- /dev/null +++ b/spec/lib/gitlab/badge/coverage_spec.rb @@ -0,0 +1,19 @@ +require 'spec_helper' + +describe Gitlab::Badge::Coverage do + let(:project) { create(:project) } + + let(:pipeline) do + create(:ci_pipeline, project: project, + sha: project.commit.id, + ref: 'master') + end + + let(:badge) { described_class.new(project, 'master') } + + context 'builds exist' do + end + + context 'build does not exist' do + end +end From 9f0b46c05aef9d0352bfaa5e42e34143227de8ff Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Wed, 10 Aug 2016 14:12:31 +0200 Subject: [PATCH 090/133] Move badges to separate modules and add base class --- app/controllers/projects/badges_controller.rb | 2 +- .../projects/pipelines_settings_controller.rb | 2 +- lib/gitlab/badge/base.rb | 21 ++++++++++++ lib/gitlab/badge/build.rb | 30 ----------------- lib/gitlab/badge/build/metadata.rb | 2 +- lib/gitlab/badge/build/status.rb | 32 +++++++++++++++++++ lib/gitlab/badge/build/template.rb | 2 +- lib/gitlab/badge/coverage.rb | 17 ---------- lib/gitlab/badge/coverage/report.rb | 19 +++++++++++ .../{build_spec.rb => build/status_spec.rb} | 2 +- .../report_spec.rb} | 8 +++-- 11 files changed, 82 insertions(+), 55 deletions(-) create mode 100644 lib/gitlab/badge/base.rb delete mode 100644 lib/gitlab/badge/build.rb create mode 100644 lib/gitlab/badge/build/status.rb delete mode 100644 lib/gitlab/badge/coverage.rb create mode 100644 lib/gitlab/badge/coverage/report.rb rename spec/lib/gitlab/badge/{build_spec.rb => build/status_spec.rb} (98%) rename spec/lib/gitlab/badge/{coverage_spec.rb => coverage/report_spec.rb} (64%) diff --git a/app/controllers/projects/badges_controller.rb b/app/controllers/projects/badges_controller.rb index d0f5071d2cc..e026ceaf757 100644 --- a/app/controllers/projects/badges_controller.rb +++ b/app/controllers/projects/badges_controller.rb @@ -4,7 +4,7 @@ class Projects::BadgesController < Projects::ApplicationController before_action :no_cache_headers, except: [:index] def build - badge = Gitlab::Badge::Build.new(project, params[:ref]) + badge = Gitlab::Badge::Build::Status.new(project, params[:ref]) respond_to do |format| format.html { render_404 } diff --git a/app/controllers/projects/pipelines_settings_controller.rb b/app/controllers/projects/pipelines_settings_controller.rb index 75dd3648e45..fe7cb748f43 100644 --- a/app/controllers/projects/pipelines_settings_controller.rb +++ b/app/controllers/projects/pipelines_settings_controller.rb @@ -3,7 +3,7 @@ class Projects::PipelinesSettingsController < Projects::ApplicationController def show @ref = params[:ref] || @project.default_branch || 'master' - @build_badge = Gitlab::Badge::Build.new(@project, @ref).metadata + @build_badge = Gitlab::Badge::Build::Status.new(@project, @ref).metadata end def update diff --git a/lib/gitlab/badge/base.rb b/lib/gitlab/badge/base.rb new file mode 100644 index 00000000000..229e7b5aa57 --- /dev/null +++ b/lib/gitlab/badge/base.rb @@ -0,0 +1,21 @@ +module Gitlab + module Badge + class Base + def key_text + raise NotImplementedError + end + + def value_text + raise NotImplementedError + end + + def metadata + raise NotImplementedError + end + + def template + raise NotImplementedError + end + end + end +end diff --git a/lib/gitlab/badge/build.rb b/lib/gitlab/badge/build.rb deleted file mode 100644 index 1de721a2269..00000000000 --- a/lib/gitlab/badge/build.rb +++ /dev/null @@ -1,30 +0,0 @@ -module Gitlab - module Badge - ## - # Build badge - # - class Build - delegate :key_text, :value_text, to: :template - - def initialize(project, ref) - @project = project - @ref = ref - @sha = @project.commit(@ref).try(:sha) - end - - def status - @project.pipelines - .where(sha: @sha, ref: @ref) - .status || 'unknown' - end - - def metadata - @metadata ||= Build::Metadata.new(@project, @ref) - end - - def template - @template ||= Build::Template.new(status) - end - end - end -end diff --git a/lib/gitlab/badge/build/metadata.rb b/lib/gitlab/badge/build/metadata.rb index 553ef8d7b16..fbe10b948c4 100644 --- a/lib/gitlab/badge/build/metadata.rb +++ b/lib/gitlab/badge/build/metadata.rb @@ -1,6 +1,6 @@ module Gitlab module Badge - class Build + module Build ## # Class that describes build badge metadata # diff --git a/lib/gitlab/badge/build/status.rb b/lib/gitlab/badge/build/status.rb new file mode 100644 index 00000000000..a72e284d513 --- /dev/null +++ b/lib/gitlab/badge/build/status.rb @@ -0,0 +1,32 @@ +module Gitlab + module Badge + module Build + ## + # Build status badge + # + class Status < Badge::Base + delegate :key_text, :value_text, to: :template + + def initialize(project, ref) + @project = project + @ref = ref + @sha = @project.commit(@ref).try(:sha) + end + + def status + @project.pipelines + .where(sha: @sha, ref: @ref) + .status || 'unknown' + end + + def metadata + @metadata ||= Build::Metadata.new(@project, @ref) + end + + def template + @template ||= Build::Template.new(status) + end + end + end + end +end diff --git a/lib/gitlab/badge/build/template.rb b/lib/gitlab/badge/build/template.rb index deba3b669b3..779569d0cd7 100644 --- a/lib/gitlab/badge/build/template.rb +++ b/lib/gitlab/badge/build/template.rb @@ -1,6 +1,6 @@ module Gitlab module Badge - class Build + module Build ## # Class that represents a build badge template. # diff --git a/lib/gitlab/badge/coverage.rb b/lib/gitlab/badge/coverage.rb deleted file mode 100644 index 94af3a7ec34..00000000000 --- a/lib/gitlab/badge/coverage.rb +++ /dev/null @@ -1,17 +0,0 @@ -module Gitlab - module Badge - ## - # Test coverage badge - # - class Coverage - def initialize(project, ref, job = nil) - @project = project - @ref = ref - @job = job - end - - def coverage - end - end - end -end diff --git a/lib/gitlab/badge/coverage/report.rb b/lib/gitlab/badge/coverage/report.rb new file mode 100644 index 00000000000..e6de15e085f --- /dev/null +++ b/lib/gitlab/badge/coverage/report.rb @@ -0,0 +1,19 @@ +module Gitlab + module Badge + module Coverage + ## + # Test coverage report badge + # + class Report < Badge::Base + def initialize(project, ref, job = nil) + @project = project + @ref = ref + @job = job + end + + def coverage + end + end + end + end +end diff --git a/spec/lib/gitlab/badge/build_spec.rb b/spec/lib/gitlab/badge/build/status_spec.rb similarity index 98% rename from spec/lib/gitlab/badge/build_spec.rb rename to spec/lib/gitlab/badge/build/status_spec.rb index bb8144d5122..fa5bc068918 100644 --- a/spec/lib/gitlab/badge/build_spec.rb +++ b/spec/lib/gitlab/badge/build/status_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Gitlab::Badge::Build do +describe Gitlab::Badge::Build::Status do let(:project) { create(:project) } let(:sha) { project.commit.sha } let(:branch) { 'master' } diff --git a/spec/lib/gitlab/badge/coverage_spec.rb b/spec/lib/gitlab/badge/coverage/report_spec.rb similarity index 64% rename from spec/lib/gitlab/badge/coverage_spec.rb rename to spec/lib/gitlab/badge/coverage/report_spec.rb index 343ca3c0f85..57b89dd8cda 100644 --- a/spec/lib/gitlab/badge/coverage_spec.rb +++ b/spec/lib/gitlab/badge/coverage/report_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Gitlab::Badge::Coverage do +describe Gitlab::Badge::Coverage::Report do let(:project) { create(:project) } let(:pipeline) do @@ -9,11 +9,13 @@ describe Gitlab::Badge::Coverage do ref: 'master') end - let(:badge) { described_class.new(project, 'master') } + let(:badge) do + described_class.new(project, 'master') + end context 'builds exist' do end - context 'build does not exist' do + context 'builds do not exist' do end end From f3de46e6b0d4cc61e00c884753a8c9eec66f66c4 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 11 Aug 2016 11:16:14 +0200 Subject: [PATCH 091/133] Refactor badge template and metadata classes --- lib/gitlab/badge/base.rb | 4 +-- lib/gitlab/badge/build/metadata.rb | 6 ++-- lib/gitlab/badge/build/status.rb | 11 ++++-- lib/gitlab/badge/build/template.rb | 12 +++---- spec/lib/gitlab/badge/build/metadata_spec.rb | 25 +++++++------- spec/lib/gitlab/badge/build/status_spec.rb | 36 +++++++------------- spec/lib/gitlab/badge/build/template_spec.rb | 22 +++++++----- 7 files changed, 58 insertions(+), 58 deletions(-) diff --git a/lib/gitlab/badge/base.rb b/lib/gitlab/badge/base.rb index 229e7b5aa57..909fa24fa90 100644 --- a/lib/gitlab/badge/base.rb +++ b/lib/gitlab/badge/base.rb @@ -1,11 +1,11 @@ module Gitlab module Badge class Base - def key_text + def entity raise NotImplementedError end - def value_text + def status raise NotImplementedError end diff --git a/lib/gitlab/badge/build/metadata.rb b/lib/gitlab/badge/build/metadata.rb index fbe10b948c4..52a10b19298 100644 --- a/lib/gitlab/badge/build/metadata.rb +++ b/lib/gitlab/badge/build/metadata.rb @@ -9,9 +9,9 @@ module Gitlab include ActionView::Helpers::AssetTagHelper include ActionView::Helpers::UrlHelper - def initialize(project, ref) - @project = project - @ref = ref + def initialize(badge) + @project = badge.project + @ref = badge.ref end def to_html diff --git a/lib/gitlab/badge/build/status.rb b/lib/gitlab/badge/build/status.rb index a72e284d513..50aa45e5406 100644 --- a/lib/gitlab/badge/build/status.rb +++ b/lib/gitlab/badge/build/status.rb @@ -5,14 +5,19 @@ module Gitlab # Build status badge # class Status < Badge::Base - delegate :key_text, :value_text, to: :template + attr_reader :project, :ref def initialize(project, ref) @project = project @ref = ref + @sha = @project.commit(@ref).try(:sha) end + def entity + 'build' + end + def status @project.pipelines .where(sha: @sha, ref: @ref) @@ -20,11 +25,11 @@ module Gitlab end def metadata - @metadata ||= Build::Metadata.new(@project, @ref) + @metadata ||= Build::Metadata.new(self) end def template - @template ||= Build::Template.new(status) + @template ||= Build::Template.new(self) end end end diff --git a/lib/gitlab/badge/build/template.rb b/lib/gitlab/badge/build/template.rb index 779569d0cd7..f52589ff736 100644 --- a/lib/gitlab/badge/build/template.rb +++ b/lib/gitlab/badge/build/template.rb @@ -17,16 +17,17 @@ module Gitlab unknown: '#9f9f9f' } - def initialize(status) - @status = status + def initialize(badge) + @entity = badge.entity + @status = badge.status end def key_text - 'build' + @entity.to_s end def value_text - @status + @status.to_s end def key_width @@ -42,8 +43,7 @@ module Gitlab end def value_color - STATUS_COLOR[@status.to_sym] || - STATUS_COLOR[:unknown] + STATUS_COLOR[@status.to_sym] || STATUS_COLOR[:unknown] end def key_text_anchor diff --git a/spec/lib/gitlab/badge/build/metadata_spec.rb b/spec/lib/gitlab/badge/build/metadata_spec.rb index ad5388215c2..4044a82d16c 100644 --- a/spec/lib/gitlab/badge/build/metadata_spec.rb +++ b/spec/lib/gitlab/badge/build/metadata_spec.rb @@ -1,16 +1,15 @@ require 'spec_helper' describe Gitlab::Badge::Build::Metadata do - let(:project) { create(:project) } - let(:branch) { 'master' } - let(:badge) { described_class.new(project, branch) } + let(:badge) { double(project: create(:project), ref: 'feature') } + let(:metadata) { described_class.new(badge) } describe '#to_html' do - let(:html) { Nokogiri::HTML.parse(badge.to_html) } + let(:html) { Nokogiri::HTML.parse(metadata.to_html) } let(:a_href) { html.at('a') } it 'points to link' do - expect(a_href[:href]).to eq badge.link_url + expect(a_href[:href]).to eq metadata.link_url end it 'contains clickable image' do @@ -19,19 +18,21 @@ describe Gitlab::Badge::Build::Metadata do end describe '#to_markdown' do - subject { badge.to_markdown } + subject { metadata.to_markdown } - it { is_expected.to include badge.image_url } - it { is_expected.to include badge.link_url } + it { is_expected.to include metadata.image_url } + it { is_expected.to include metadata.link_url } end describe '#image_url' do - subject { badge.image_url } - it { is_expected.to include "badges/#{branch}/build.svg" } + it 'returns valid url' do + expect(metadata.image_url).to include 'badges/feature/build.svg' + end end describe '#link_url' do - subject { badge.link_url } - it { is_expected.to include "commits/#{branch}" } + it 'returns valid link' do + expect(metadata.link_url).to include 'commits/feature' + end end end diff --git a/spec/lib/gitlab/badge/build/status_spec.rb b/spec/lib/gitlab/badge/build/status_spec.rb index fa5bc068918..38eebb2a176 100644 --- a/spec/lib/gitlab/badge/build/status_spec.rb +++ b/spec/lib/gitlab/badge/build/status_spec.rb @@ -6,6 +6,18 @@ describe Gitlab::Badge::Build::Status do let(:branch) { 'master' } let(:badge) { described_class.new(project, branch) } + describe '#entity' do + it 'always says build' do + expect(badge.entity).to eq 'build' + end + end + + describe '#template' do + it 'returns badge template' do + expect(badge.template.key_text).to eq 'build' + end + end + describe '#metadata' do it 'returns badge metadata' do expect(badge.metadata.image_url) @@ -13,12 +25,6 @@ describe Gitlab::Badge::Build::Status do end end - describe '#key_text' do - it 'always says build' do - expect(badge.key_text).to eq 'build' - end - end - context 'build exists' do let!(:build) { create_build(project, sha, branch) } @@ -30,12 +36,6 @@ describe Gitlab::Badge::Build::Status do expect(badge.status).to eq 'success' end end - - describe '#value_text' do - it 'returns correct value text' do - expect(badge.value_text).to eq 'success' - end - end end context 'build failed' do @@ -46,12 +46,6 @@ describe Gitlab::Badge::Build::Status do expect(badge.status).to eq 'failed' end end - - describe '#value_text' do - it 'has correct value text' do - expect(badge.value_text).to eq 'failed' - end - end end context 'when outdated pipeline for given ref exists' do @@ -87,12 +81,6 @@ describe Gitlab::Badge::Build::Status do expect(badge.status).to eq 'unknown' end end - - describe '#value_text' do - it 'has correct value text' do - expect(badge.value_text).to eq 'unknown' - end - end end def create_build(project, sha, branch) diff --git a/spec/lib/gitlab/badge/build/template_spec.rb b/spec/lib/gitlab/badge/build/template_spec.rb index 86dead3c54e..a7e21fb8bb1 100644 --- a/spec/lib/gitlab/badge/build/template_spec.rb +++ b/spec/lib/gitlab/badge/build/template_spec.rb @@ -1,8 +1,8 @@ require 'spec_helper' describe Gitlab::Badge::Build::Template do - let(:status) { 'success' } - let(:template) { described_class.new(status) } + let(:badge) { double(entity: 'build', status: 'success') } + let(:template) { described_class.new(badge) } describe '#key_text' do it 'is always says build' do @@ -34,15 +34,15 @@ describe Gitlab::Badge::Build::Template do describe '#value_color' do context 'when status is success' do - let(:status) { 'success' } - it 'has expected color' do expect(template.value_color).to eq '#4c1' end end context 'when status is failed' do - let(:status) { 'failed' } + before do + allow(badge).to receive(:status).and_return('failed') + end it 'has expected color' do expect(template.value_color).to eq '#e05d44' @@ -50,7 +50,9 @@ describe Gitlab::Badge::Build::Template do end context 'when status is running' do - let(:status) { 'running' } + before do + allow(badge).to receive(:status).and_return('running') + end it 'has expected color' do expect(template.value_color).to eq '#dfb317' @@ -58,7 +60,9 @@ describe Gitlab::Badge::Build::Template do end context 'when status is unknown' do - let(:status) { 'unknown' } + before do + allow(badge).to receive(:status).and_return('unknown') + end it 'has expected color' do expect(template.value_color).to eq '#9f9f9f' @@ -66,7 +70,9 @@ describe Gitlab::Badge::Build::Template do end context 'when status does not match any known statuses' do - let(:status) { 'invalid status' } + before do + allow(badge).to receive(:status).and_return('invalid') + end it 'has expected color' do expect(template.value_color).to eq '#9f9f9f' From f0ff1bfdcc43decd1888f7b8d4a9e8c4dd5540d9 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 11 Aug 2016 12:38:03 +0200 Subject: [PATCH 092/133] Implement the main class of test coverage badge --- lib/gitlab/badge/coverage/report.rb | 31 +++++++- spec/lib/gitlab/badge/coverage/report_spec.rb | 78 ++++++++++++++++--- 2 files changed, 99 insertions(+), 10 deletions(-) diff --git a/lib/gitlab/badge/coverage/report.rb b/lib/gitlab/badge/coverage/report.rb index e6de15e085f..f06142003e3 100644 --- a/lib/gitlab/badge/coverage/report.rb +++ b/lib/gitlab/badge/coverage/report.rb @@ -5,13 +5,42 @@ module Gitlab # Test coverage report badge # class Report < Badge::Base + attr_reader :project, :ref, :job + def initialize(project, ref, job = nil) @project = project @ref = ref @job = job + + @pipeline = @project.pipelines + .where(ref: @ref) + .where(sha: @project.commit(@ref).try(:sha)) + .first end - def coverage + def entity + 'coverage' + end + + def status + @coverage ||= raw_coverage + return unless @coverage + + @coverage.to_i + end + + private + + def raw_coverage + return unless @pipeline + + if @job.blank? + @pipeline.coverage + else + @pipeline.builds + .find_by(name: @job) + .try(:coverage) + end end end end diff --git a/spec/lib/gitlab/badge/coverage/report_spec.rb b/spec/lib/gitlab/badge/coverage/report_spec.rb index 57b89dd8cda..46b14873ee9 100644 --- a/spec/lib/gitlab/badge/coverage/report_spec.rb +++ b/spec/lib/gitlab/badge/coverage/report_spec.rb @@ -2,20 +2,80 @@ require 'spec_helper' describe Gitlab::Badge::Coverage::Report do let(:project) { create(:project) } - - let(:pipeline) do - create(:ci_pipeline, project: project, - sha: project.commit.id, - ref: 'master') - end + let(:job_name) { nil } let(:badge) do - described_class.new(project, 'master') + described_class.new(project, 'master', job_name) end - context 'builds exist' do + describe '#entity' do + it 'describes a coverage' do + expect(badge.entity).to eq 'coverage' + end end - context 'builds do not exist' do + shared_examples 'unknown coverage report' do + context 'particular job specified' do + let(:job_name) { '' } + + it 'returns nil' do + expect(badge.status).to be_nil + end + end + + context 'particular job not specified' do + let(:job_name) { nil } + + it 'returns nil' do + expect(badge.status).to be_nil + end + end + end + + context 'pipeline exists' do + let!(:pipeline) do + create(:ci_pipeline, project: project, + sha: project.commit.id, + ref: 'master') + end + + context 'builds exist' do + before do + create(:ci_build, name: 'first', pipeline: pipeline, coverage: 40) + create(:ci_build, pipeline: pipeline, coverage: 60) + end + + context 'particular job specified' do + let(:job_name) { 'first' } + + it 'returns coverage for the particular job' do + expect(badge.status).to eq 40 + end + end + + context 'particular job not specified' do + let(:job_name) { '' } + + it 'returns arithemetic mean for the pipeline' do + expect(badge.status).to eq 50 + end + end + end + + context 'builds do not exist' do + it_behaves_like 'unknown coverage report' + + context 'particular job specified' do + let(:job_name) { 'nonexistent' } + + it 'retruns nil' do + expect(badge.status).to be_nil + end + end + end + end + + context 'pipeline does not exist' do + it_behaves_like 'unknown coverage report' end end From cdb0caaf320a67f193a7ab9faac9271a625d8b98 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 11 Aug 2016 13:56:20 +0200 Subject: [PATCH 093/133] Extract common badge metadata test examples --- spec/lib/gitlab/badge/build/metadata_spec.rb | 21 ++------------------ spec/lib/gitlab/badge/shared/metadata.rb | 21 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 19 deletions(-) create mode 100644 spec/lib/gitlab/badge/shared/metadata.rb diff --git a/spec/lib/gitlab/badge/build/metadata_spec.rb b/spec/lib/gitlab/badge/build/metadata_spec.rb index 4044a82d16c..d9354458707 100644 --- a/spec/lib/gitlab/badge/build/metadata_spec.rb +++ b/spec/lib/gitlab/badge/build/metadata_spec.rb @@ -1,28 +1,11 @@ require 'spec_helper' +require 'lib/gitlab/badge/shared/metadata' describe Gitlab::Badge::Build::Metadata do let(:badge) { double(project: create(:project), ref: 'feature') } let(:metadata) { described_class.new(badge) } - describe '#to_html' do - let(:html) { Nokogiri::HTML.parse(metadata.to_html) } - let(:a_href) { html.at('a') } - - it 'points to link' do - expect(a_href[:href]).to eq metadata.link_url - end - - it 'contains clickable image' do - expect(a_href.children.first.name).to eq 'img' - end - end - - describe '#to_markdown' do - subject { metadata.to_markdown } - - it { is_expected.to include metadata.image_url } - it { is_expected.to include metadata.link_url } - end + it_behaves_like 'badge metadata' describe '#image_url' do it 'returns valid url' do diff --git a/spec/lib/gitlab/badge/shared/metadata.rb b/spec/lib/gitlab/badge/shared/metadata.rb new file mode 100644 index 00000000000..0cf18514251 --- /dev/null +++ b/spec/lib/gitlab/badge/shared/metadata.rb @@ -0,0 +1,21 @@ +shared_examples 'badge metadata' do + describe '#to_html' do + let(:html) { Nokogiri::HTML.parse(metadata.to_html) } + let(:a_href) { html.at('a') } + + it 'points to link' do + expect(a_href[:href]).to eq metadata.link_url + end + + it 'contains clickable image' do + expect(a_href.children.first.name).to eq 'img' + end + end + + describe '#to_markdown' do + subject { metadata.to_markdown } + + it { is_expected.to include metadata.image_url } + it { is_expected.to include metadata.link_url } + end +end From 7b840c8483e2fe17a6c04474323cfb57c3c8a7d3 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 11 Aug 2016 13:58:57 +0200 Subject: [PATCH 094/133] Add coverage report badge metadata class --- config/routes.rb | 5 ++- lib/gitlab/badge/coverage/metadata.rb | 38 +++++++++++++++++++ .../gitlab/badge/coverage/metadata_spec.rb | 24 ++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 lib/gitlab/badge/coverage/metadata.rb create mode 100644 spec/lib/gitlab/badge/coverage/metadata_spec.rb diff --git a/config/routes.rb b/config/routes.rb index 9a98fab15a3..cf6773917cf 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -869,7 +869,10 @@ Rails.application.routes.draw do resources :badges, only: [:index] do collection do scope '*ref', constraints: { ref: Gitlab::Regex.git_reference_regex } do - get :build, constraints: { format: /svg/ } + constraints format: /svg/ do + get :build + get :coverage + end end end end diff --git a/lib/gitlab/badge/coverage/metadata.rb b/lib/gitlab/badge/coverage/metadata.rb new file mode 100644 index 00000000000..ae9e5e84051 --- /dev/null +++ b/lib/gitlab/badge/coverage/metadata.rb @@ -0,0 +1,38 @@ +module Gitlab + module Badge + module Coverage + ## + # Class that describes coverage badge metadata + # + class Metadata + include Gitlab::Application.routes.url_helpers + include ActionView::Helpers::AssetTagHelper + include ActionView::Helpers::UrlHelper + + def initialize(badge) + @project = badge.project + @ref = badge.ref + @job = badge.job + end + + def to_html + link_to(image_tag(image_url, alt: 'coverage report'), link_url) + end + + def to_markdown + "[![coverage report](#{image_url})](#{link_url})" + end + + def image_url + coverage_namespace_project_badges_url(@project.namespace, + @project, @ref, + format: :svg) + end + + def link_url + namespace_project_commits_url(@project.namespace, @project, id: @ref) + end + end + end + end +end diff --git a/spec/lib/gitlab/badge/coverage/metadata_spec.rb b/spec/lib/gitlab/badge/coverage/metadata_spec.rb new file mode 100644 index 00000000000..995b1a329ba --- /dev/null +++ b/spec/lib/gitlab/badge/coverage/metadata_spec.rb @@ -0,0 +1,24 @@ +require 'spec_helper' +require 'lib/gitlab/badge/shared/metadata' + +describe Gitlab::Badge::Coverage::Metadata do + let(:badge) do + double(project: create(:project), ref: 'feature', job: 'test') + end + + let(:metadata) { described_class.new(badge) } + + it_behaves_like 'badge metadata' + + describe '#image_url' do + it 'returns valid url' do + expect(metadata.image_url).to include 'badges/feature/coverage.svg' + end + end + + describe '#link_url' do + it 'returns valid link' do + expect(metadata.link_url).to include 'commits/feature' + end + end +end From cc244160c5e2ecda2818348cb6b909f1dcb96e44 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 11 Aug 2016 14:08:27 +0200 Subject: [PATCH 095/133] Extract the abstract base class of badge metadata --- lib/gitlab/badge/build/metadata.rb | 14 ++------ lib/gitlab/badge/coverage/metadata.rb | 14 ++------ lib/gitlab/badge/metadata.rb | 36 +++++++++++++++++++ spec/lib/gitlab/badge/build/metadata_spec.rb | 6 ++++ .../gitlab/badge/coverage/metadata_spec.rb | 6 ++++ 5 files changed, 54 insertions(+), 22 deletions(-) create mode 100644 lib/gitlab/badge/metadata.rb diff --git a/lib/gitlab/badge/build/metadata.rb b/lib/gitlab/badge/build/metadata.rb index 52a10b19298..f87a7b7942e 100644 --- a/lib/gitlab/badge/build/metadata.rb +++ b/lib/gitlab/badge/build/metadata.rb @@ -4,22 +4,14 @@ module Gitlab ## # Class that describes build badge metadata # - class Metadata - include Gitlab::Application.routes.url_helpers - include ActionView::Helpers::AssetTagHelper - include ActionView::Helpers::UrlHelper - + class Metadata < Badge::Metadata def initialize(badge) @project = badge.project @ref = badge.ref end - def to_html - link_to(image_tag(image_url, alt: 'build status'), link_url) - end - - def to_markdown - "[![build status](#{image_url})](#{link_url})" + def title + 'build status' end def image_url diff --git a/lib/gitlab/badge/coverage/metadata.rb b/lib/gitlab/badge/coverage/metadata.rb index ae9e5e84051..53588185622 100644 --- a/lib/gitlab/badge/coverage/metadata.rb +++ b/lib/gitlab/badge/coverage/metadata.rb @@ -4,23 +4,15 @@ module Gitlab ## # Class that describes coverage badge metadata # - class Metadata - include Gitlab::Application.routes.url_helpers - include ActionView::Helpers::AssetTagHelper - include ActionView::Helpers::UrlHelper - + class Metadata < Badge::Metadata def initialize(badge) @project = badge.project @ref = badge.ref @job = badge.job end - def to_html - link_to(image_tag(image_url, alt: 'coverage report'), link_url) - end - - def to_markdown - "[![coverage report](#{image_url})](#{link_url})" + def title + 'coverage report' end def image_url diff --git a/lib/gitlab/badge/metadata.rb b/lib/gitlab/badge/metadata.rb new file mode 100644 index 00000000000..548f85b78bb --- /dev/null +++ b/lib/gitlab/badge/metadata.rb @@ -0,0 +1,36 @@ +module Gitlab + module Badge + ## + # Abstract class for badge metadata + # + class Metadata + include Gitlab::Application.routes.url_helpers + include ActionView::Helpers::AssetTagHelper + include ActionView::Helpers::UrlHelper + + def initialize(badge) + @badge = badge + end + + def to_html + link_to(image_tag(image_url, alt: title), link_url) + end + + def to_markdown + "[![#{title}](#{image_url})](#{link_url})" + end + + def title + raise NotImplementedError + end + + def image_url + raise NotImplementedError + end + + def link_url + raise NotImplementedError + end + end + end +end diff --git a/spec/lib/gitlab/badge/build/metadata_spec.rb b/spec/lib/gitlab/badge/build/metadata_spec.rb index d9354458707..d678e522721 100644 --- a/spec/lib/gitlab/badge/build/metadata_spec.rb +++ b/spec/lib/gitlab/badge/build/metadata_spec.rb @@ -7,6 +7,12 @@ describe Gitlab::Badge::Build::Metadata do it_behaves_like 'badge metadata' + describe '#title' do + it 'returns build status title' do + expect(metadata.title).to eq 'build status' + end + end + describe '#image_url' do it 'returns valid url' do expect(metadata.image_url).to include 'badges/feature/build.svg' diff --git a/spec/lib/gitlab/badge/coverage/metadata_spec.rb b/spec/lib/gitlab/badge/coverage/metadata_spec.rb index 995b1a329ba..74eaf7eaf8b 100644 --- a/spec/lib/gitlab/badge/coverage/metadata_spec.rb +++ b/spec/lib/gitlab/badge/coverage/metadata_spec.rb @@ -10,6 +10,12 @@ describe Gitlab::Badge::Coverage::Metadata do it_behaves_like 'badge metadata' + describe '#title' do + it 'returns coverage report title' do + expect(metadata.title).to eq 'coverage report' + end + end + describe '#image_url' do it 'returns valid url' do expect(metadata.image_url).to include 'badges/feature/coverage.svg' From 796efcc704558119c1e5cb298d45a4f592662cb7 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 11 Aug 2016 14:46:47 +0200 Subject: [PATCH 096/133] Add template class for coverage report badge --- lib/gitlab/badge/coverage/template.rb | 69 ++++++++++ .../gitlab/badge/coverage/template_spec.rb | 130 ++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 lib/gitlab/badge/coverage/template.rb create mode 100644 spec/lib/gitlab/badge/coverage/template_spec.rb diff --git a/lib/gitlab/badge/coverage/template.rb b/lib/gitlab/badge/coverage/template.rb new file mode 100644 index 00000000000..6f9a38b07dc --- /dev/null +++ b/lib/gitlab/badge/coverage/template.rb @@ -0,0 +1,69 @@ +module Gitlab + module Badge + module Coverage + ## + # Class that represents a coverage badge template. + # + # Template object will be passed to badge.svg.erb template. + # + class Template + STATUS_COLOR = { + good: '#4c1', + acceptable: '#b0c', + medium: '#dfb317', + low: '#e05d44', + unknown: '#9f9f9f' + } + + def initialize(badge) + @entity = badge.entity + @status = badge.status + end + + def key_text + @entity.to_s + end + + def value_text + @status ? "#{@status}%" : 'unknown' + end + + def key_width + 62 + end + + def value_width + @status ? 32 : 58 + end + + def key_color + '#555' + end + + def value_color + case @status + when nil then STATUS_COLOR[:unknown] + when 95..100 then STATUS_COLOR[:good] + when 90..95 then STATUS_COLOR[:acceptable] + when 75..90 then STATUS_COLOR[:medium] + when 0..75 then STATUS_COLOR[:low] + else + STATUS_COLOR[:unknown] + end + end + + def key_text_anchor + key_width / 2 + end + + def value_text_anchor + key_width + (value_width / 2) + end + + def width + key_width + value_width + end + end + end + end +end diff --git a/spec/lib/gitlab/badge/coverage/template_spec.rb b/spec/lib/gitlab/badge/coverage/template_spec.rb new file mode 100644 index 00000000000..a45de1dc677 --- /dev/null +++ b/spec/lib/gitlab/badge/coverage/template_spec.rb @@ -0,0 +1,130 @@ +require 'spec_helper' + +describe Gitlab::Badge::Coverage::Template do + let(:badge) { double(entity: 'coverage', status: 90) } + let(:template) { described_class.new(badge) } + + describe '#key_text' do + it 'is always says coverage' do + expect(template.key_text).to eq 'coverage' + end + end + + describe '#value_text' do + context 'when coverage is known' do + it 'returns coverage percentage' do + expect(template.value_text).to eq '90%' + end + end + + context 'when coverage is unknown' do + before do + allow(badge).to receive(:status).and_return(nil) + end + + it 'returns string that says coverage is unknown' do + expect(template.value_text).to eq 'unknown' + end + end + end + + describe '#key_width' do + it 'has a fixed key width' do + expect(template.key_width).to eq 62 + end + end + + describe '#value_width' do + context 'when coverage is known' do + it 'is narrower when coverage is known' do + expect(template.value_width).to eq 32 + end + end + + context 'when coverage is unknown' do + before do + allow(badge).to receive(:status).and_return(nil) + end + + it 'is wider when coverage is unknown to fit text' do + expect(template.value_width).to eq 58 + end + end + end + + describe '#key_color' do + it 'always has the same color' do + expect(template.key_color).to eq '#555' + end + end + + describe '#value_color' do + context 'when coverage is good' do + before do + allow(badge).to receive(:status).and_return(98) + end + + it 'is green' do + expect(template.value_color).to eq '#4c1' + end + end + + context 'when coverage is acceptable' do + before do + allow(badge).to receive(:status).and_return(90) + end + + it 'is green-orange' do + expect(template.value_color).to eq '#b0c' + end + end + + context 'when coverage is medium' do + before do + allow(badge).to receive(:status).and_return(75) + end + + it 'is orange-yellow' do + expect(template.value_color).to eq '#dfb317' + end + end + + context 'when coverage is low' do + before do + allow(badge).to receive(:status).and_return(50) + end + + it 'is red' do + expect(template.value_color).to eq '#e05d44' + end + end + + context 'when coverage is unknown' do + before do + allow(badge).to receive(:status).and_return(nil) + end + + it 'is grey' do + expect(template.value_color).to eq '#9f9f9f' + end + end + end + + describe '#width' do + context 'when coverage is known' do + it 'returns the key width plus value width' do + expect(template.width).to eq 94 + end + end + + context 'when coverage is unknown' do + before do + allow(badge).to receive(:status).and_return(nil) + end + + it 'returns key width plus wider value width' do + expect(template.width).to eq 120 + end + end + end +end From dbb9d6a726285bdf59cc789aac584e712ea1280c Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 11 Aug 2016 15:01:14 +0200 Subject: [PATCH 097/133] Extract base abstract template for badges --- lib/gitlab/badge/build/template.rb | 18 +--------- lib/gitlab/badge/coverage/template.rb | 19 +---------- lib/gitlab/badge/template.rb | 49 +++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 35 deletions(-) create mode 100644 lib/gitlab/badge/template.rb diff --git a/lib/gitlab/badge/build/template.rb b/lib/gitlab/badge/build/template.rb index f52589ff736..2b95ddfcb53 100644 --- a/lib/gitlab/badge/build/template.rb +++ b/lib/gitlab/badge/build/template.rb @@ -6,7 +6,7 @@ module Gitlab # # Template object will be passed to badge.svg.erb template. # - class Template + class Template < Badge::Template STATUS_COLOR = { success: '#4c1', failed: '#e05d44', @@ -38,25 +38,9 @@ module Gitlab 54 end - def key_color - '#555' - end - def value_color STATUS_COLOR[@status.to_sym] || STATUS_COLOR[:unknown] end - - def key_text_anchor - key_width / 2 - end - - def value_text_anchor - key_width + (value_width / 2) - end - - def width - key_width + value_width - end end end end diff --git a/lib/gitlab/badge/coverage/template.rb b/lib/gitlab/badge/coverage/template.rb index 6f9a38b07dc..49a10d3e8e1 100644 --- a/lib/gitlab/badge/coverage/template.rb +++ b/lib/gitlab/badge/coverage/template.rb @@ -6,7 +6,7 @@ module Gitlab # # Template object will be passed to badge.svg.erb template. # - class Template + class Template < Badge::Template STATUS_COLOR = { good: '#4c1', acceptable: '#b0c', @@ -36,13 +36,8 @@ module Gitlab @status ? 32 : 58 end - def key_color - '#555' - end - def value_color case @status - when nil then STATUS_COLOR[:unknown] when 95..100 then STATUS_COLOR[:good] when 90..95 then STATUS_COLOR[:acceptable] when 75..90 then STATUS_COLOR[:medium] @@ -51,18 +46,6 @@ module Gitlab STATUS_COLOR[:unknown] end end - - def key_text_anchor - key_width / 2 - end - - def value_text_anchor - key_width + (value_width / 2) - end - - def width - key_width + value_width - end end end end diff --git a/lib/gitlab/badge/template.rb b/lib/gitlab/badge/template.rb new file mode 100644 index 00000000000..bfeb0052642 --- /dev/null +++ b/lib/gitlab/badge/template.rb @@ -0,0 +1,49 @@ +module Gitlab + module Badge + ## + # Abstract template class for badges + # + class Template + def initialize(badge) + @entity = badge.entity + @status = badge.status + end + + def key_text + raise NotImplementedError + end + + def value_text + raise NotImplementedError + end + + def key_width + raise NotImplementedError + end + + def value_width + raise NotImplementedError + end + + def value_color + raise NotImplementedError + end + + def key_color + '#555' + end + + def key_text_anchor + key_width / 2 + end + + def value_text_anchor + key_width + (value_width / 2) + end + + def width + key_width + value_width + end + end + end +end From 3e481f154f8e93a54cef8216c70ad5ab2d91f0f1 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 11 Aug 2016 15:04:37 +0200 Subject: [PATCH 098/133] Add metadata and template methods for coverage badge --- lib/gitlab/badge/coverage/report.rb | 8 ++++++++ spec/lib/gitlab/badge/coverage/report_spec.rb | 12 ++++++++++++ 2 files changed, 20 insertions(+) diff --git a/lib/gitlab/badge/coverage/report.rb b/lib/gitlab/badge/coverage/report.rb index f06142003e3..3d56ea3e47a 100644 --- a/lib/gitlab/badge/coverage/report.rb +++ b/lib/gitlab/badge/coverage/report.rb @@ -29,6 +29,14 @@ module Gitlab @coverage.to_i end + def metadata + @metadata ||= Coverage::Metadata.new(self) + end + + def template + @template ||= Coverage::Template.new(self) + end + private def raw_coverage diff --git a/spec/lib/gitlab/badge/coverage/report_spec.rb b/spec/lib/gitlab/badge/coverage/report_spec.rb index 46b14873ee9..1ff49602486 100644 --- a/spec/lib/gitlab/badge/coverage/report_spec.rb +++ b/spec/lib/gitlab/badge/coverage/report_spec.rb @@ -14,6 +14,18 @@ describe Gitlab::Badge::Coverage::Report do end end + describe '#metadata' do + it 'returns correct metadata' do + expect(badge.metadata.image_url).to include 'coverage.svg' + end + end + + describe '#template' do + it 'returns correct template' do + expect(badge.template.key_text).to eq 'coverage' + end + end + shared_examples 'unknown coverage report' do context 'particular job specified' do let(:job_name) { '' } From b6ca47f6d4ac80150759deeecf4d0e8dedcc9b88 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 11 Aug 2016 15:17:13 +0200 Subject: [PATCH 099/133] Add method for coverage badge in badges controller --- app/controllers/projects/badges_controller.rb | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/app/controllers/projects/badges_controller.rb b/app/controllers/projects/badges_controller.rb index e026ceaf757..6c25cd83a24 100644 --- a/app/controllers/projects/badges_controller.rb +++ b/app/controllers/projects/badges_controller.rb @@ -4,11 +4,24 @@ class Projects::BadgesController < Projects::ApplicationController before_action :no_cache_headers, except: [:index] def build - badge = Gitlab::Badge::Build::Status.new(project, params[:ref]) + build_status = Gitlab::Badge::Build::Status + .new(project, params[:ref]) + render_badge build_status + end + + def coverage + coverage_report = Gitlab::Badge::Coverage::Report + .new(project, params[:ref], params[:job]) + + render_badge coverage_report + end + + private + + def render_badge(badge) respond_to do |format| format.html { render_404 } - format.svg do render 'badge', locals: { badge: badge.template } end From b646adef131eb41521ca0a56884d5b3862c4f5ef Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Fri, 12 Aug 2016 14:30:17 +0200 Subject: [PATCH 100/133] Expose coverage report badge in pipeline settings --- .../projects/pipelines_settings_controller.rb | 8 ++- .../pipelines_settings/show.html.haml | 52 ++++++++++--------- spec/features/projects/badges/list_spec.rb | 44 +++++++++++----- 3 files changed, 66 insertions(+), 38 deletions(-) diff --git a/app/controllers/projects/pipelines_settings_controller.rb b/app/controllers/projects/pipelines_settings_controller.rb index fe7cb748f43..9136633b87a 100644 --- a/app/controllers/projects/pipelines_settings_controller.rb +++ b/app/controllers/projects/pipelines_settings_controller.rb @@ -3,7 +3,13 @@ class Projects::PipelinesSettingsController < Projects::ApplicationController def show @ref = params[:ref] || @project.default_branch || 'master' - @build_badge = Gitlab::Badge::Build::Status.new(@project, @ref).metadata + + @badges = [Gitlab::Badge::Build::Status, + Gitlab::Badge::Coverage::Report] + + @badges.map! do |badge| + badge.new(@project, @ref).metadata + end end def update diff --git a/app/views/projects/pipelines_settings/show.html.haml b/app/views/projects/pipelines_settings/show.html.haml index 228bad36ebd..5570dc0481b 100644 --- a/app/views/projects/pipelines_settings/show.html.haml +++ b/app/views/projects/pipelines_settings/show.html.haml @@ -77,27 +77,31 @@ %hr .row.prepend-top-default - .col-lg-3.profile-settings-sidebar - %h4.prepend-top-0 - Builds Badge - .col-lg-9 - .prepend-top-10 - .panel.panel-default - .panel-heading - %b Builds badge · - = @build_badge.to_html - .pull-right - = render 'shared/ref_switcher', destination: 'badges', align_right: true - .panel-body - .row - .col-md-2.text-center - Markdown - .col-md-10.code.js-syntax-highlight - = highlight('.md', @build_badge.to_markdown) - .row - %hr - .row - .col-md-2.text-center - HTML - .col-md-10.code.js-syntax-highlight - = highlight('.html', @build_badge.to_html) + - @badges.each do |badge| + .row{ class: badge.title.gsub(' ', '-') } + .col-lg-3.profile-settings-sidebar + %h4.prepend-top-0 + = badge.title.capitalize + .col-lg-9 + .prepend-top-10 + .panel.panel-default + .panel-heading + %b + = badge.title.capitalize + · + = badge.to_html + .pull-right + = render 'shared/ref_switcher', destination: 'badges', align_right: true + .panel-body + .row + .col-md-2.text-center + Markdown + .col-md-10.code.js-syntax-highlight + = highlight('.md', badge.to_markdown) + .row + %hr + .row + .col-md-2.text-center + HTML + .col-md-10.code.js-syntax-highlight + = highlight('.html', badge.to_html) diff --git a/spec/features/projects/badges/list_spec.rb b/spec/features/projects/badges/list_spec.rb index 75166bca119..67a4a5d1ab1 100644 --- a/spec/features/projects/badges/list_spec.rb +++ b/spec/features/projects/badges/list_spec.rb @@ -9,25 +9,43 @@ feature 'list of badges' do visit namespace_project_pipelines_settings_path(project.namespace, project) end - scenario 'user displays list of badges' do - expect(page).to have_content 'build status' - expect(page).to have_content 'Markdown' - expect(page).to have_content 'HTML' - expect(page).to have_css('.highlight', count: 2) - expect(page).to have_xpath("//img[@alt='build status']") + scenario 'user wants to see build status badge' do + page.within('.build-status') do + expect(page).to have_content 'build status' + expect(page).to have_content 'Markdown' + expect(page).to have_content 'HTML' + expect(page).to have_css('.highlight', count: 2) + expect(page).to have_xpath("//img[@alt='build status']") - page.within('.highlight', match: :first) do - expect(page).to have_content 'badges/master/build.svg' + page.within('.highlight', match: :first) do + expect(page).to have_content 'badges/master/build.svg' + end end end - scenario 'user changes current ref on badges list page', js: true do - first('.js-project-refs-dropdown').click + scenario 'user wants to see coverage report badge' do + page.within('.coverage-report') do + expect(page).to have_content 'coverage report' + expect(page).to have_content 'Markdown' + expect(page).to have_content 'HTML' + expect(page).to have_css('.highlight', count: 2) + expect(page).to have_xpath("//img[@alt='coverage report']") - page.within '.project-refs-form' do - click_link 'improve/awesome' + page.within('.highlight', match: :first) do + expect(page).to have_content 'badges/master/coverage.svg' + end end + end - expect(page).to have_content 'badges/improve/awesome/build.svg' + scenario 'user changes current ref of build status badge', js: true do + page.within('.build-status') do + first('.js-project-refs-dropdown').click + + page.within '.project-refs-form' do + click_link 'improve/awesome' + end + + expect(page).to have_content 'badges/improve/awesome/build.svg' + end end end From eec0f5da873a5730e71560330277bf83958a6146 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Fri, 12 Aug 2016 14:32:46 +0200 Subject: [PATCH 101/133] Extract pipeline badges to the separate view partial --- .../pipelines_settings/_badges.html.haml | 28 ++++++++++++++++++ .../pipelines_settings/show.html.haml | 29 +------------------ 2 files changed, 29 insertions(+), 28 deletions(-) create mode 100644 app/views/projects/pipelines_settings/_badges.html.haml diff --git a/app/views/projects/pipelines_settings/_badges.html.haml b/app/views/projects/pipelines_settings/_badges.html.haml new file mode 100644 index 00000000000..436ae4c237a --- /dev/null +++ b/app/views/projects/pipelines_settings/_badges.html.haml @@ -0,0 +1,28 @@ +- badges.each do |badge| + .row{ class: badge.title.gsub(' ', '-') } + .col-lg-3.profile-settings-sidebar + %h4.prepend-top-0 + = badge.title.capitalize + .col-lg-9 + .prepend-top-10 + .panel.panel-default + .panel-heading + %b + = badge.title.capitalize + · + = badge.to_html + .pull-right + = render 'shared/ref_switcher', destination: 'badges', align_right: true + .panel-body + .row + .col-md-2.text-center + Markdown + .col-md-10.code.js-syntax-highlight + = highlight('.md', badge.to_markdown) + .row + %hr + .row + .col-md-2.text-center + HTML + .col-md-10.code.js-syntax-highlight + = highlight('.html', badge.to_html) diff --git a/app/views/projects/pipelines_settings/show.html.haml b/app/views/projects/pipelines_settings/show.html.haml index 5570dc0481b..3213fe07ef5 100644 --- a/app/views/projects/pipelines_settings/show.html.haml +++ b/app/views/projects/pipelines_settings/show.html.haml @@ -77,31 +77,4 @@ %hr .row.prepend-top-default - - @badges.each do |badge| - .row{ class: badge.title.gsub(' ', '-') } - .col-lg-3.profile-settings-sidebar - %h4.prepend-top-0 - = badge.title.capitalize - .col-lg-9 - .prepend-top-10 - .panel.panel-default - .panel-heading - %b - = badge.title.capitalize - · - = badge.to_html - .pull-right - = render 'shared/ref_switcher', destination: 'badges', align_right: true - .panel-body - .row - .col-md-2.text-center - Markdown - .col-md-10.code.js-syntax-highlight - = highlight('.md', badge.to_markdown) - .row - %hr - .row - .col-md-2.text-center - HTML - .col-md-10.code.js-syntax-highlight - = highlight('.html', badge.to_html) + = render partial: 'badges', object: @badges From 0c81279ea2da8751c58d211d9eb857d227f4318d Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Fri, 12 Aug 2016 15:10:02 +0200 Subject: [PATCH 102/133] Render collection of badges instead of using iterator --- .../pipelines_settings/_badge.html.haml | 27 ++++++++++++++++++ .../pipelines_settings/_badges.html.haml | 28 ------------------- .../pipelines_settings/show.html.haml | 2 +- 3 files changed, 28 insertions(+), 29 deletions(-) create mode 100644 app/views/projects/pipelines_settings/_badge.html.haml delete mode 100644 app/views/projects/pipelines_settings/_badges.html.haml diff --git a/app/views/projects/pipelines_settings/_badge.html.haml b/app/views/projects/pipelines_settings/_badge.html.haml new file mode 100644 index 00000000000..7b7fa56d993 --- /dev/null +++ b/app/views/projects/pipelines_settings/_badge.html.haml @@ -0,0 +1,27 @@ +.row{ class: badge.title.gsub(' ', '-') } + .col-lg-3.profile-settings-sidebar + %h4.prepend-top-0 + = badge.title.capitalize + .col-lg-9 + .prepend-top-10 + .panel.panel-default + .panel-heading + %b + = badge.title.capitalize + · + = badge.to_html + .pull-right + = render 'shared/ref_switcher', destination: 'badges', align_right: true + .panel-body + .row + .col-md-2.text-center + Markdown + .col-md-10.code.js-syntax-highlight + = highlight('.md', badge.to_markdown) + .row + %hr + .row + .col-md-2.text-center + HTML + .col-md-10.code.js-syntax-highlight + = highlight('.html', badge.to_html) diff --git a/app/views/projects/pipelines_settings/_badges.html.haml b/app/views/projects/pipelines_settings/_badges.html.haml deleted file mode 100644 index 436ae4c237a..00000000000 --- a/app/views/projects/pipelines_settings/_badges.html.haml +++ /dev/null @@ -1,28 +0,0 @@ -- badges.each do |badge| - .row{ class: badge.title.gsub(' ', '-') } - .col-lg-3.profile-settings-sidebar - %h4.prepend-top-0 - = badge.title.capitalize - .col-lg-9 - .prepend-top-10 - .panel.panel-default - .panel-heading - %b - = badge.title.capitalize - · - = badge.to_html - .pull-right - = render 'shared/ref_switcher', destination: 'badges', align_right: true - .panel-body - .row - .col-md-2.text-center - Markdown - .col-md-10.code.js-syntax-highlight - = highlight('.md', badge.to_markdown) - .row - %hr - .row - .col-md-2.text-center - HTML - .col-md-10.code.js-syntax-highlight - = highlight('.html', badge.to_html) diff --git a/app/views/projects/pipelines_settings/show.html.haml b/app/views/projects/pipelines_settings/show.html.haml index 3213fe07ef5..8c7222bfe3d 100644 --- a/app/views/projects/pipelines_settings/show.html.haml +++ b/app/views/projects/pipelines_settings/show.html.haml @@ -77,4 +77,4 @@ %hr .row.prepend-top-default - = render partial: 'badges', object: @badges + = render partial: 'badge', collection: @badges From b13e1d795a06499d0264b1103108057a7862826f Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Fri, 12 Aug 2016 15:26:55 +0200 Subject: [PATCH 103/133] Add small corrections to test coverage report badge --- lib/gitlab/badge/coverage/template.rb | 4 ++-- spec/lib/gitlab/badge/coverage/template_spec.rb | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/gitlab/badge/coverage/template.rb b/lib/gitlab/badge/coverage/template.rb index 49a10d3e8e1..06e0d084e9f 100644 --- a/lib/gitlab/badge/coverage/template.rb +++ b/lib/gitlab/badge/coverage/template.rb @@ -9,7 +9,7 @@ module Gitlab class Template < Badge::Template STATUS_COLOR = { good: '#4c1', - acceptable: '#b0c', + acceptable: '#a3c51c', medium: '#dfb317', low: '#e05d44', unknown: '#9f9f9f' @@ -33,7 +33,7 @@ module Gitlab end def value_width - @status ? 32 : 58 + @status ? 36 : 58 end def value_color diff --git a/spec/lib/gitlab/badge/coverage/template_spec.rb b/spec/lib/gitlab/badge/coverage/template_spec.rb index a45de1dc677..383bae6e087 100644 --- a/spec/lib/gitlab/badge/coverage/template_spec.rb +++ b/spec/lib/gitlab/badge/coverage/template_spec.rb @@ -37,7 +37,7 @@ describe Gitlab::Badge::Coverage::Template do describe '#value_width' do context 'when coverage is known' do it 'is narrower when coverage is known' do - expect(template.value_width).to eq 32 + expect(template.value_width).to eq 36 end end @@ -75,7 +75,7 @@ describe Gitlab::Badge::Coverage::Template do end it 'is green-orange' do - expect(template.value_color).to eq '#b0c' + expect(template.value_color).to eq '#a3c51c' end end @@ -113,7 +113,7 @@ describe Gitlab::Badge::Coverage::Template do describe '#width' do context 'when coverage is known' do it 'returns the key width plus value width' do - expect(template.width).to eq 94 + expect(template.width).to eq 98 end end From dfd913892993338cfcf50d3340a1582b18ae8cac Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Mon, 15 Aug 2016 11:41:01 +0200 Subject: [PATCH 104/133] Add feature specs for test coverage badge --- .../features/projects/badges/coverage_spec.rb | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 spec/features/projects/badges/coverage_spec.rb diff --git a/spec/features/projects/badges/coverage_spec.rb b/spec/features/projects/badges/coverage_spec.rb new file mode 100644 index 00000000000..af86d3c338a --- /dev/null +++ b/spec/features/projects/badges/coverage_spec.rb @@ -0,0 +1,73 @@ +require 'spec_helper' + +feature 'test coverage badge' do + given!(:user) { create(:user) } + given!(:project) { create(:project, :private) } + + given!(:pipeline) do + create(:ci_pipeline, project: project, + ref: 'master', + sha: project.commit.id) + end + + context 'when user has access to view badge' do + background do + project.team << [user, :developer] + login_as(user) + end + + scenario 'user requests coverage badge image for pipeline' do + create_job(coverage: 100, name: 'test:1') + create_job(coverage: 90, name: 'test:2') + + show_test_coverage_badge + + expect_coverage_badge('95%') + end + + scenario 'user requests coverage badge for specific job' do + create_job(coverage: 50, name: 'test:1') + create_job(coverage: 50, name: 'test:2') + create_job(coverage: 85, name: 'coverage') + + show_test_coverage_badge(job: 'coverage') + + expect_coverage_badge('85%') + end + + scenario 'user requests coverage badge for pipeline without coverage' do + create_job(coverage: nil, name: 'test') + + show_test_coverage_badge + + expect_coverage_badge('unknown') + end + end + + context 'when user does not have access to view badge' do + background { login_as(user) } + + scenario 'user requests test coverage badge image' do + show_test_coverage_badge + + expect(page).to have_http_status(404) + end + end + + def create_job(coverage:, name:) + create(:ci_build, name: name, + coverage: coverage, + pipeline: pipeline) + end + + def show_test_coverage_badge(job: nil) + visit coverage_namespace_project_badges_path( + project.namespace, project, ref: :master, job: job, format: :svg) + end + + def expect_coverage_badge(coverage) + svg = Nokogiri::XML.parse(page.body) + expect(page.response_headers['Content-Type']).to include('image/svg+xml') + expect(svg.at(%Q{text:contains("#{coverage}")})).to be_truthy + end +end From a0ed41a52f189dd8ed98262f7e67aa23daf9f9e1 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Mon, 15 Aug 2016 15:13:56 +0200 Subject: [PATCH 105/133] Add documentation for test coverage report badge --- doc/ci/pipelines.md | 35 +++++++++++++++++++++++++++++++++++ doc/ci/quick_start/README.md | 12 ++---------- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/doc/ci/pipelines.md b/doc/ci/pipelines.md index 48a9f994759..d90d7aca4fd 100644 --- a/doc/ci/pipelines.md +++ b/doc/ci/pipelines.md @@ -32,6 +32,41 @@ project. Clicking on a pipeline will show the builds that were run for that pipeline. +## Badges + +There are build status and test coverage report badges available. + +Go to pipeline settings to see available badges and code you can use to embed +badges in the `README.md` or your website. + +### Build status badge + +You can access a build status badge image using following link: + +``` +http://example.gitlab.com/namespace/project/badges/branch/build.svg +``` + +### Test coverage report badge + +GitLab makes it possible to define the regular expression for coverage report, +that each build log will be matched against. This means that each build in the +pipeline can have the test coverage percentage value defined. + +You can access test coverage badge using following link: + +``` +http://example.gitlab.com/namespace/project/badges/branch/coverage.svg +``` + +If you would like to get the coverage report from the specific job, you can add +a `job=coverage_job_name` parameter to the URL. For example, it is possible to +use following Markdown code to embed the est coverage report into `README.md`: + +```markdown +![coverage](http://gitlab.com/gitlab-org/gitlab-ce/badges/master/coverage.svg?job=coverage) +``` + [builds]: #builds [jobs]: yaml/README.md#jobs [stages]: yaml/README.md#stages diff --git a/doc/ci/quick_start/README.md b/doc/ci/quick_start/README.md index 6a3c416d995..c835ebc2d44 100644 --- a/doc/ci/quick_start/README.md +++ b/doc/ci/quick_start/README.md @@ -218,21 +218,13 @@ project's settings. For more information read the [Builds emails service documentation](../../project_services/builds_emails.md). -## Builds badge - -You can access a builds badge image using following link: - -``` -http://example.gitlab.com/namespace/project/badges/branch/build.svg -``` - -Awesome! You started using CI in GitLab! - ## Examples Visit the [examples README][examples] to see a list of examples using GitLab CI with various languages. +Awesome! You started using CI in GitLab! + [runner-install]: https://gitlab.com/gitlab-org/gitlab-ci-multi-runner/tree/master#install-gitlab-runner [blog-ci]: https://about.gitlab.com/2015/05/06/why-were-replacing-gitlab-ci-jobs-with-gitlab-ci-dot-yml/ [examples]: ../examples/README.md From 48c4c8f06c9b82c7f54a663e27514da5e9a9e2f8 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Mon, 15 Aug 2016 15:16:46 +0200 Subject: [PATCH 106/133] Add Changelog entry for test coverage report badge --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index 008c5c64284..f58029f1da4 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,7 @@ Please view this file on the master branch, on stable branches it's out of date. v 8.11.0 (unreleased) + - Add test coverage report badge. !5708 - Remove the http_parser.rb dependency by removing the tinder gem. !5758 (tbalthazar) - Ability to specify branches for Pivotal Tracker integration (Egor Lynko) - Fix don't pass a local variable called `i` to a partial. !20510 (herminiotorres) From 95419679f23f0628d1885dd9656cc159e9d55ea9 Mon Sep 17 00:00:00 2001 From: Patricio Cano Date: Wed, 27 Jul 2016 19:03:06 -0500 Subject: [PATCH 107/133] Lay the ground works to submit information to Akismet - New concern `AkismetSubmittable` to allow issues and other `Spammable` models to be submitted to Akismet. - New model `UserAgentDetail` to store information needed for Akismet. - Services needed for their creation and tests. --- app/models/concerns/akismet_submittable.rb | 15 +++++++++++++ app/models/issue.rb | 1 + app/models/user_agent_detail.rb | 16 ++++++++++++++ app/services/issues/create_service.rb | 5 +++++ app/services/user_agent_detail_service.rb | 12 ++++++++++ ...0160727163552_create_user_agent_details.rb | 12 ++++++++++ db/schema.rb | 9 ++++++++ .../projects/issues_controller_spec.rb | 20 +++++++++++++++++ spec/factories/user_agent_details.rb | 10 +++++++++ spec/models/user_agent_detail_spec.rb | 22 +++++++++++++++++++ 10 files changed, 122 insertions(+) create mode 100644 app/models/concerns/akismet_submittable.rb create mode 100644 app/models/user_agent_detail.rb create mode 100644 app/services/user_agent_detail_service.rb create mode 100644 db/migrate/20160727163552_create_user_agent_details.rb create mode 100644 spec/factories/user_agent_details.rb create mode 100644 spec/models/user_agent_detail_spec.rb diff --git a/app/models/concerns/akismet_submittable.rb b/app/models/concerns/akismet_submittable.rb new file mode 100644 index 00000000000..17821688941 --- /dev/null +++ b/app/models/concerns/akismet_submittable.rb @@ -0,0 +1,15 @@ +module AkismetSubmittable + extend ActiveSupport::Concern + + included do + has_one :user_agent_detail, as: :subject + end + + def can_be_submitted? + if user_agent_detail + user_agent_detail.submittable? + else + false + end + end +end diff --git a/app/models/issue.rb b/app/models/issue.rb index d62ffb21467..6c2635498e5 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -8,6 +8,7 @@ class Issue < ActiveRecord::Base include Taskable include Spammable include FasterCacheKeys + include AkismetSubmittable DueDateStruct = Struct.new(:title, :name).freeze NoDueDate = DueDateStruct.new('No Due Date', '0').freeze diff --git a/app/models/user_agent_detail.rb b/app/models/user_agent_detail.rb new file mode 100644 index 00000000000..6d76dff20e3 --- /dev/null +++ b/app/models/user_agent_detail.rb @@ -0,0 +1,16 @@ +class UserAgentDetail < ActiveRecord::Base + belongs_to :subject, polymorphic: true + + validates :user_agent, + presence: true + validates :ip_address, + presence: true + validates :subject_id, + presence: true + validates :subject_type, + presence: true + + def submittable? + user_agent.present? && ip_address.present? + end +end diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index 5e2de2ccf64..8e9d74103c7 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -15,6 +15,7 @@ module Issues notification_service.new_issue(issue, current_user) todo_service.new_issue(issue, current_user) event_service.open_issue(issue, current_user) + user_agent_detail_service(issue, request).create issue.create_cross_references!(current_user) execute_hooks(issue, 'open') end @@ -27,5 +28,9 @@ module Issues def spam_check_service SpamCheckService.new(project, current_user, params) end + + def user_agent_detail_service(issue, request) + UserAgentDetailService.new(issue, request) + end end end diff --git a/app/services/user_agent_detail_service.rb b/app/services/user_agent_detail_service.rb new file mode 100644 index 00000000000..dd995955be3 --- /dev/null +++ b/app/services/user_agent_detail_service.rb @@ -0,0 +1,12 @@ +class UserAgentDetailService + attr_accessor :subject, :request + + def initialize(subject, request) + @subject, @request = subject, request + end + + def create + return unless request + subject.create_user_agent_detail(user_agent: request.env['HTTP_USER_AGENT'], ip_address: request.env['action_dispatch.remote_ip'].to_s) + end +end diff --git a/db/migrate/20160727163552_create_user_agent_details.rb b/db/migrate/20160727163552_create_user_agent_details.rb new file mode 100644 index 00000000000..05c21a476fa --- /dev/null +++ b/db/migrate/20160727163552_create_user_agent_details.rb @@ -0,0 +1,12 @@ +class CreateUserAgentDetails < ActiveRecord::Migration + def change + create_table :user_agent_details do |t| + t.string :user_agent, null: false + t.string :ip_address, null: false + t.integer :subject_id, null: false + t.string :subject_type, null: false + + t.timestamps null: false + end + end +end diff --git a/db/schema.rb b/db/schema.rb index f008a6bd7a7..2e5ffac5935 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -999,6 +999,15 @@ ActiveRecord::Schema.define(version: 20160810142633) do add_index "u2f_registrations", ["key_handle"], name: "index_u2f_registrations_on_key_handle", using: :btree add_index "u2f_registrations", ["user_id"], name: "index_u2f_registrations_on_user_id", using: :btree + create_table "user_agent_details", force: :cascade do |t| + t.string "user_agent", null: false + t.string "ip_address", null: false + t.integer "subject_id", null: false + t.string "subject_type", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "users", force: :cascade do |t| t.string "email", default: "", null: false t.string "encrypted_password", default: "", null: false diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index b6a0276846c..4e39826d694 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -300,6 +300,26 @@ describe Projects::IssuesController do expect(spam_logs[0].title).to eq('Spam Title') end end + + context 'user agent details are saved' do + before do + request.env['action_dispatch.remote_ip'] = '127.0.0.1' + end + + def post_new_issue + sign_in(user) + project = create(:empty_project, :public) + post :create, { + namespace_id: project.namespace.to_param, + project_id: project.to_param, + issue: { title: 'Title', description: 'Description' } + } + end + + it 'creates a user agent detail' do + expect{ post_new_issue }.to change(UserAgentDetail, :count) + end + end end describe "DELETE #destroy" do diff --git a/spec/factories/user_agent_details.rb b/spec/factories/user_agent_details.rb new file mode 100644 index 00000000000..5fc40915911 --- /dev/null +++ b/spec/factories/user_agent_details.rb @@ -0,0 +1,10 @@ +FactoryGirl.define do + factory :user_agent_detail do + ip_address '127.0.0.1' + user_agent 'AppleWebKit/537.36' + + trait :on_issue do + association :subject, factory: :issue + end + end +end diff --git a/spec/models/user_agent_detail_spec.rb b/spec/models/user_agent_detail_spec.rb new file mode 100644 index 00000000000..8debcbbeba6 --- /dev/null +++ b/spec/models/user_agent_detail_spec.rb @@ -0,0 +1,22 @@ +require 'rails_helper' + +describe UserAgentDetail, type: :model do + describe '.submittable?' do + it 'should be submittable' do + detail = create(:user_agent_detail, :on_issue) + expect(detail.submittable?).to be_truthy + end + end + + describe '.valid?' do + it 'should be valid with a subject' do + detail = create(:user_agent_detail, :on_issue) + expect(detail).to be_valid + end + + it 'should not be valid without a subject' do + detail = build(:user_agent_detail) + expect(detail).not_to be_valid + end + end +end From 722fc84e3d4785fb3a9db5f1c7d2aabad22e8e01 Mon Sep 17 00:00:00 2001 From: Patricio Cano Date: Thu, 28 Jul 2016 19:02:56 -0500 Subject: [PATCH 108/133] Complete refactor of the `Spammable` concern and tests: - Merged `AkismetSubmittable` into `Spammable` - Clean up `SpamCheckService` - Added tests for `Spammable` - Added submit (ham or spam) options to `AkismetHelper` --- app/models/concerns/akismet_submittable.rb | 15 ----- app/models/concerns/spammable.rb | 64 ++++++++++++++++++++-- app/models/issue.rb | 2 + app/services/issues/create_service.rb | 2 +- app/services/spam_check_service.rb | 22 +++----- lib/api/issues.rb | 2 +- lib/gitlab/akismet_helper.rb | 34 ++++++++++++ spec/factories/user_agent_details.rb | 2 + spec/models/concerns/spammable_spec.rb | 41 ++++++++++++++ spec/models/user_agent_detail_spec.rb | 5 -- 10 files changed, 148 insertions(+), 41 deletions(-) delete mode 100644 app/models/concerns/akismet_submittable.rb create mode 100644 spec/models/concerns/spammable_spec.rb diff --git a/app/models/concerns/akismet_submittable.rb b/app/models/concerns/akismet_submittable.rb deleted file mode 100644 index 17821688941..00000000000 --- a/app/models/concerns/akismet_submittable.rb +++ /dev/null @@ -1,15 +0,0 @@ -module AkismetSubmittable - extend ActiveSupport::Concern - - included do - has_one :user_agent_detail, as: :subject - end - - def can_be_submitted? - if user_agent_detail - user_agent_detail.submittable? - else - false - end - end -end diff --git a/app/models/concerns/spammable.rb b/app/models/concerns/spammable.rb index 3b8e6df2da9..bbf6a3e0be3 100644 --- a/app/models/concerns/spammable.rb +++ b/app/models/concerns/spammable.rb @@ -1,16 +1,70 @@ module Spammable extend ActiveSupport::Concern + include Gitlab::AkismetHelper - included do - attr_accessor :spam - after_validation :check_for_spam, on: :create + module ClassMethods + def attr_spammable(*attrs) + attrs.each do |attr| + spammable_attrs << attr.to_s + end + end end - def spam? + included do + has_one :user_agent_detail, as: :subject, dependent: :destroy + attr_accessor :spam + after_validation :check_for_spam, on: :create + + cattr_accessor :spammable_attrs, instance_accessor: false do + [] + end + end + + def can_be_submitted? + if user_agent_detail + user_agent_detail.submittable? + else + false + end + end + + def submit_ham + return unless akismet_enabled? && can_be_submitted? + ham!(user_agent_detail, spammable_text, creator) + end + + def submit_spam + return unless akismet_enabled? && can_be_submitted? + spam!(user_agent_detail, spammable_text, creator) + end + + def spam?(env, user) + is_spam?(env, user, spammable_text) + end + + def spam_detected? @spam end def check_for_spam - self.errors.add(:base, "Your #{self.class.name.underscore} has been recognized as spam and has been discarded.") if spam? + self.errors.add(:base, "Your #{self.class.name.underscore} has been recognized as spam and has been discarded.") if spam_detected? + end + + private + + def spammable_text + result = [] + self.class.spammable_attrs.each do |entry| + result << self.send(entry) + end + result.reject(&:blank?).join("\n") + end + + def creator + if self.author_id + User.find(self.author_id) + elsif self.creator_id + User.find(self.creator_id) + end end end diff --git a/app/models/issue.rb b/app/models/issue.rb index 6c2635498e5..5408e24b21c 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -37,6 +37,8 @@ class Issue < ActiveRecord::Base scope :order_due_date_asc, -> { reorder('issues.due_date IS NULL, issues.due_date ASC') } scope :order_due_date_desc, -> { reorder('issues.due_date IS NULL, issues.due_date DESC') } + attr_spammable :title, :description + state_machine :state, initial: :opened do event :close do transition [:reopened, :opened] => :closed diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index 8e9d74103c7..d580834be83 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -8,7 +8,7 @@ module Issues issue = project.issues.new(params) issue.author = params[:author] || current_user - issue.spam = spam_check_service.execute(request, api) + issue.spam = spam_check_service.execute(request, api, issue) if issue.save issue.update_attributes(label_ids: label_params) diff --git a/app/services/spam_check_service.rb b/app/services/spam_check_service.rb index 7c3e692bde9..7d6754546a8 100644 --- a/app/services/spam_check_service.rb +++ b/app/services/spam_check_service.rb @@ -1,23 +1,17 @@ class SpamCheckService < BaseService - include Gitlab::AkismetHelper + attr_accessor :request, :api, :subject - attr_accessor :request, :api + def execute(request, api, subject) + @request, @api, @subject = request, api, subject + return false unless request || subject.check_for_spam?(project) + return false unless subject.spam?(request.env, current_user) - def execute(request, api) - @request, @api = request, api - return false unless request || check_for_spam?(project) - return false unless is_spam?(request.env, current_user, text) - create_spam_log true end private - - def text - [params[:title], params[:description]].reject(&:blank?).join("\n") - end def spam_log_attrs { @@ -25,9 +19,9 @@ class SpamCheckService < BaseService project_id: project.id, title: params[:title], description: params[:description], - source_ip: client_ip(request.env), - user_agent: user_agent(request.env), - noteable_type: 'Issue', + source_ip: subject.client_ip(request.env), + user_agent: subject.user_agent(request.env), + noteable_type: subject.class.to_s, via_api: api } end diff --git a/lib/api/issues.rb b/lib/api/issues.rb index c4d3134da6c..7bbfc137c2c 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -160,7 +160,7 @@ module API issue = ::Issues::CreateService.new(project, current_user, attrs.merge(request: request, api: true)).execute - if issue.spam? + if issue.spam_detected? render_api_error!({ error: 'Spam detected' }, 400) end diff --git a/lib/gitlab/akismet_helper.rb b/lib/gitlab/akismet_helper.rb index 207736b59db..19e73820321 100644 --- a/lib/gitlab/akismet_helper.rb +++ b/lib/gitlab/akismet_helper.rb @@ -43,5 +43,39 @@ module Gitlab false end end + + def ham!(details, text, user) + client = akismet_client + + params = { + type: 'comment', + text: text, + author: user.name, + author_email: user.email + } + + begin + client.submit_ham(details.ip_address, details.user_agent, params) + rescue => e + Rails.logger.error("Unable to connect to Akismet: #{e}, skipping!") + end + end + + def spam!(details, text, user) + client = akismet_client + + params = { + type: 'comment', + text: text, + author: user.name, + author_email: user.email + } + + begin + client.submit_spam(details.ip_address, details.user_agent, params) + rescue => e + Rails.logger.error("Unable to connect to Akismet: #{e}, skipping!") + end + end end end diff --git a/spec/factories/user_agent_details.rb b/spec/factories/user_agent_details.rb index 5fc40915911..10de5dcb329 100644 --- a/spec/factories/user_agent_details.rb +++ b/spec/factories/user_agent_details.rb @@ -2,6 +2,8 @@ FactoryGirl.define do factory :user_agent_detail do ip_address '127.0.0.1' user_agent 'AppleWebKit/537.36' + subject_id 1 + subject_type 'Issue' trait :on_issue do association :subject, factory: :issue diff --git a/spec/models/concerns/spammable_spec.rb b/spec/models/concerns/spammable_spec.rb new file mode 100644 index 00000000000..e61a6dcb69d --- /dev/null +++ b/spec/models/concerns/spammable_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' + +describe Issue, 'Spammable' do + let(:issue) { create(:issue, description: 'Test Desc.') } + + describe 'Associations' do + it { is_expected.to have_one(:user_agent_detail).dependent(:destroy) } + end + + describe 'ClassMethods' do + it 'should return correct attr_spammable' do + expect(issue.send(:spammable_text)).to eq("#{issue.title}\n#{issue.description}") + end + end + + describe 'InstanceMethods' do + it 'should return the correct creator' do + expect(issue.send(:creator).id).to eq(issue.author_id) + end + + it 'should be invalid if spam' do + issue.spam = true + expect(issue.valid?).to be_truthy + end + + it 'should be submittable' do + create(:user_agent_detail, subject_id: issue.id, subject_type: issue.class.to_s) + expect(issue.can_be_submitted?).to be_truthy + end + end + + describe 'AkismetMethods' do + before do + allow_any_instance_of(Gitlab::AkismetHelper).to receive_messages(check_for_spam?: true, is_spam?: true, ham!: nil, spam!: nil) + end + + it { expect(issue.spam?(:mock_env, :mock_user)).to be_truthy } + it { expect(issue.submit_spam).to be_nil } + it { expect(issue.submit_ham).to be_nil } + end +end diff --git a/spec/models/user_agent_detail_spec.rb b/spec/models/user_agent_detail_spec.rb index 8debcbbeba6..ba21161fc7f 100644 --- a/spec/models/user_agent_detail_spec.rb +++ b/spec/models/user_agent_detail_spec.rb @@ -13,10 +13,5 @@ describe UserAgentDetail, type: :model do detail = create(:user_agent_detail, :on_issue) expect(detail).to be_valid end - - it 'should not be valid without a subject' do - detail = build(:user_agent_detail) - expect(detail).not_to be_valid - end end end From 64ab2b3d9f10366249c03a6bcf5e8b1d20010d8f Mon Sep 17 00:00:00 2001 From: Patricio Cano Date: Fri, 29 Jul 2016 23:18:32 -0500 Subject: [PATCH 109/133] Refactored spam related code even further - Removed unnecessary column from `SpamLog` - Moved creation of SpamLogs out of its own service and into SpamCheckService - Simplified code in SpamCheckService. - Moved move spam related code into Spammable concern --- app/controllers/admin/spam_logs_controller.rb | 6 +++ app/models/concerns/spammable.rb | 44 ++++++++++++------- app/models/issue.rb | 13 ++++++ app/services/create_spam_log_service.rb | 13 ------ app/services/issues/create_service.rb | 34 +++++++------- app/services/spam_check_service.rb | 35 ++++++++------- app/services/user_agent_detail_service.rb | 8 ++-- ...173930_remove_project_id_from_spam_logs.rb | 29 ++++++++++++ db/schema.rb | 1 - lib/api/issues.rb | 2 +- lib/gitlab/akismet_helper.rb | 8 +--- .../projects/issues_controller_spec.rb | 2 +- spec/lib/gitlab/akismet_helper_spec.rb | 11 ----- spec/models/concerns/spammable_spec.rb | 21 +++++++-- spec/requests/api/issues_spec.rb | 3 +- 15 files changed, 137 insertions(+), 93 deletions(-) delete mode 100644 app/services/create_spam_log_service.rb create mode 100644 db/migrate/20160729173930_remove_project_id_from_spam_logs.rb diff --git a/app/controllers/admin/spam_logs_controller.rb b/app/controllers/admin/spam_logs_controller.rb index 3a2f0185315..bc6fce0ec4e 100644 --- a/app/controllers/admin/spam_logs_controller.rb +++ b/app/controllers/admin/spam_logs_controller.rb @@ -14,4 +14,10 @@ class Admin::SpamLogsController < Admin::ApplicationController head :ok end end + + def ham + spam_log = SpamLog.find(params[:id]) + + Gitlab::AkismetHelper.ham!(spam_log.source_ip, spam_log.user_agent, spam_log.description, spam_log.user) + end end diff --git a/app/models/concerns/spammable.rb b/app/models/concerns/spammable.rb index bbf6a3e0be3..5c75275b6e2 100644 --- a/app/models/concerns/spammable.rb +++ b/app/models/concerns/spammable.rb @@ -28,26 +28,42 @@ module Spammable end end - def submit_ham - return unless akismet_enabled? && can_be_submitted? - ham!(user_agent_detail, spammable_text, creator) - end - def submit_spam return unless akismet_enabled? && can_be_submitted? - spam!(user_agent_detail, spammable_text, creator) + spam!(user_agent_detail, spammable_text, owner) end - def spam?(env, user) - is_spam?(env, user, spammable_text) + def spam_detected?(env) + @spam = is_spam?(env, owner, spammable_text) end - def spam_detected? + def spam? @spam end def check_for_spam - self.errors.add(:base, "Your #{self.class.name.underscore} has been recognized as spam and has been discarded.") if spam_detected? + self.errors.add(:base, "Your #{self.class.name.underscore} has been recognized as spam and has been discarded.") if spam? + end + + def owner_id + if self.respond_to?(:author_id) + self.author_id + elsif self.respond_to?(:creator_id) + self.creator_id + end + end + + # Override this method if an additional check is needed before calling Akismet + def check_for_spam? + akismet_enabled? + end + + def spam_title + raise 'Implement in included model!' + end + + def spam_description + raise 'Implement in included model!' end private @@ -60,11 +76,7 @@ module Spammable result.reject(&:blank?).join("\n") end - def creator - if self.author_id - User.find(self.author_id) - elsif self.creator_id - User.find(self.creator_id) - end + def owner + User.find(owner_id) end end diff --git a/app/models/issue.rb b/app/models/issue.rb index 5408e24b21c..40028e56489 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -265,4 +265,17 @@ class Issue < ActiveRecord::Base def overdue? due_date.try(:past?) || false end + + # To allow polymorphism with Spammable + def check_for_spam? + super && project.public? + end + + def spam_title + title + end + + def spam_description + description + end end diff --git a/app/services/create_spam_log_service.rb b/app/services/create_spam_log_service.rb deleted file mode 100644 index 59a66fde47a..00000000000 --- a/app/services/create_spam_log_service.rb +++ /dev/null @@ -1,13 +0,0 @@ -class CreateSpamLogService < BaseService - def initialize(project, user, params) - super(project, user, params) - end - - def execute - spam_params = params.merge({ user_id: @current_user.id, - project_id: @project.id } ) - spam_log = SpamLog.new(spam_params) - spam_log.save - spam_log - end -end diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index d580834be83..9f8a642a75b 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -3,34 +3,34 @@ module Issues def execute filter_params label_params = params.delete(:label_ids) - request = params.delete(:request) - api = params.delete(:api) - issue = project.issues.new(params) - issue.author = params[:author] || current_user + @request = params.delete(:request) + @api = params.delete(:api) + @issue = project.issues.new(params) + @issue.author = params[:author] || current_user - issue.spam = spam_check_service.execute(request, api, issue) + spam_check_service.execute - if issue.save - issue.update_attributes(label_ids: label_params) - notification_service.new_issue(issue, current_user) - todo_service.new_issue(issue, current_user) - event_service.open_issue(issue, current_user) - user_agent_detail_service(issue, request).create - issue.create_cross_references!(current_user) - execute_hooks(issue, 'open') + if @issue.save + @issue.update_attributes(label_ids: label_params) + notification_service.new_issue(@issue, current_user) + todo_service.new_issue(@issue, current_user) + event_service.open_issue(@issue, current_user) + user_agent_detail_service.create + @issue.create_cross_references!(current_user) + execute_hooks(@issue, 'open') end - issue + @issue end private def spam_check_service - SpamCheckService.new(project, current_user, params) + SpamCheckService.new(@request, @api, @issue) end - def user_agent_detail_service(issue, request) - UserAgentDetailService.new(issue, request) + def user_agent_detail_service + UserAgentDetailService.new(@issue, @request) end end end diff --git a/app/services/spam_check_service.rb b/app/services/spam_check_service.rb index 7d6754546a8..71b9436a22e 100644 --- a/app/services/spam_check_service.rb +++ b/app/services/spam_check_service.rb @@ -1,32 +1,33 @@ -class SpamCheckService < BaseService - attr_accessor :request, :api, :subject +class SpamCheckService + attr_accessor :request, :api, :spammable - def execute(request, api, subject) - @request, @api, @subject = request, api, subject - return false unless request || subject.check_for_spam?(project) - return false unless subject.spam?(request.env, current_user) + def initialize(request, api, spammable) + @request, @api, @spammable = request, api, spammable + end - create_spam_log - - true + def execute + if request && spammable.check_for_spam? + if spammable.spam_detected?(request.env) + create_spam_log + end + end end private def spam_log_attrs { - user_id: current_user.id, - project_id: project.id, - title: params[:title], - description: params[:description], - source_ip: subject.client_ip(request.env), - user_agent: subject.user_agent(request.env), - noteable_type: subject.class.to_s, + user_id: spammable.owner_id, + title: spammable.spam_title, + description: spammable.spam_description, + source_ip: spammable.client_ip(request.env), + user_agent: spammable.user_agent(request.env), + noteable_type: spammable.class.to_s, via_api: api } end def create_spam_log - CreateSpamLogService.new(project, current_user, spam_log_attrs).execute + SpamLog.create(spam_log_attrs) end end diff --git a/app/services/user_agent_detail_service.rb b/app/services/user_agent_detail_service.rb index dd995955be3..c07e2ca12a6 100644 --- a/app/services/user_agent_detail_service.rb +++ b/app/services/user_agent_detail_service.rb @@ -1,12 +1,12 @@ class UserAgentDetailService - attr_accessor :subject, :request + attr_accessor :spammable, :request - def initialize(subject, request) - @subject, @request = subject, request + def initialize(spammable, request) + @spammable, @request = spammable, request end def create return unless request - subject.create_user_agent_detail(user_agent: request.env['HTTP_USER_AGENT'], ip_address: request.env['action_dispatch.remote_ip'].to_s) + spammable.create_user_agent_detail(user_agent: request.env['HTTP_USER_AGENT'], ip_address: request.env['action_dispatch.remote_ip'].to_s) end end diff --git a/db/migrate/20160729173930_remove_project_id_from_spam_logs.rb b/db/migrate/20160729173930_remove_project_id_from_spam_logs.rb new file mode 100644 index 00000000000..5950874d5af --- /dev/null +++ b/db/migrate/20160729173930_remove_project_id_from_spam_logs.rb @@ -0,0 +1,29 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class RemoveProjectIdFromSpamLogs < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = true + + # When a migration requires downtime you **must** uncomment the following + # constant and define a short and easy to understand explanation as to why the + # migration requires downtime. + DOWNTIME_REASON = 'Removing a table that contains data that is not used anywhere.' + + # When using the methods "add_concurrent_index" or "add_column_with_default" + # you must disable the use of transactions as these methods can not run in an + # existing transaction. When using "add_concurrent_index" make sure that this + # method is the _only_ method called in the migration, any other changes + # should go in a separate migration. This ensures that upon failure _only_ the + # index creation fails and can be retried or reverted easily. + # + # To disable transactions uncomment the following line and remove these + # comments: + # disable_ddl_transaction! + + def change + remove_column :spam_logs, :project_id, :integer + end +end diff --git a/db/schema.rb b/db/schema.rb index 2e5ffac5935..cc881e54763 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -926,7 +926,6 @@ ActiveRecord::Schema.define(version: 20160810142633) do t.string "source_ip" t.string "user_agent" t.boolean "via_api" - t.integer "project_id" t.string "noteable_type" t.string "title" t.text "description" diff --git a/lib/api/issues.rb b/lib/api/issues.rb index 7bbfc137c2c..c4d3134da6c 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -160,7 +160,7 @@ module API issue = ::Issues::CreateService.new(project, current_user, attrs.merge(request: request, api: true)).execute - if issue.spam_detected? + if issue.spam? render_api_error!({ error: 'Spam detected' }, 400) end diff --git a/lib/gitlab/akismet_helper.rb b/lib/gitlab/akismet_helper.rb index 19e73820321..b74d8176cc7 100644 --- a/lib/gitlab/akismet_helper.rb +++ b/lib/gitlab/akismet_helper.rb @@ -17,10 +17,6 @@ module Gitlab env['HTTP_USER_AGENT'] end - def check_for_spam?(project) - akismet_enabled? && project.public? - end - def is_spam?(environment, user, text) client = akismet_client ip_address = client_ip(environment) @@ -44,7 +40,7 @@ module Gitlab end end - def ham!(details, text, user) + def ham!(ip_address, user_agent, text, user) client = akismet_client params = { @@ -55,7 +51,7 @@ module Gitlab } begin - client.submit_ham(details.ip_address, details.user_agent, params) + client.submit_ham(ip_address, user_agent, params) rescue => e Rails.logger.error("Unable to connect to Akismet: #{e}, skipping!") end diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 4e39826d694..efca838613f 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -274,7 +274,7 @@ describe Projects::IssuesController do describe 'POST #create' do context 'Akismet is enabled' do before do - allow_any_instance_of(Gitlab::AkismetHelper).to receive(:check_for_spam?).and_return(true) + allow_any_instance_of(Spammable).to receive(:check_for_spam?).and_return(true) allow_any_instance_of(Gitlab::AkismetHelper).to receive(:is_spam?).and_return(true) end diff --git a/spec/lib/gitlab/akismet_helper_spec.rb b/spec/lib/gitlab/akismet_helper_spec.rb index b08396da4d2..80b4f912d41 100644 --- a/spec/lib/gitlab/akismet_helper_spec.rb +++ b/spec/lib/gitlab/akismet_helper_spec.rb @@ -10,17 +10,6 @@ describe Gitlab::AkismetHelper, type: :helper do allow_any_instance_of(ApplicationSetting).to receive(:akismet_api_key).and_return('12345') end - describe '#check_for_spam?' do - it 'returns true for public project' do - expect(helper.check_for_spam?(project)).to eq(true) - end - - it 'returns false for private project' do - project.update_attribute(:visibility_level, Gitlab::VisibilityLevel::PRIVATE) - expect(helper.check_for_spam?(project)).to eq(false) - end - end - describe '#is_spam?' do it 'returns true for spam' do environment = { diff --git a/spec/models/concerns/spammable_spec.rb b/spec/models/concerns/spammable_spec.rb index e61a6dcb69d..3f7d2721d22 100644 --- a/spec/models/concerns/spammable_spec.rb +++ b/spec/models/concerns/spammable_spec.rb @@ -15,7 +15,7 @@ describe Issue, 'Spammable' do describe 'InstanceMethods' do it 'should return the correct creator' do - expect(issue.send(:creator).id).to eq(issue.author_id) + expect(issue.send(:owner).id).to eq(issue.author_id) end it 'should be invalid if spam' do @@ -27,15 +27,28 @@ describe Issue, 'Spammable' do create(:user_agent_detail, subject_id: issue.id, subject_type: issue.class.to_s) expect(issue.can_be_submitted?).to be_truthy end + + describe '#check_for_spam?' do + before do + allow_any_instance_of(Gitlab::AkismetHelper).to receive(:akismet_enabled?).and_return(true) + end + it 'returns true for public project' do + issue.project.update_attribute(:visibility_level, Gitlab::VisibilityLevel::PUBLIC) + expect(issue.check_for_spam?).to eq(true) + end + + it 'returns false for other visibility levels' do + expect(issue.check_for_spam?).to eq(false) + end + end end describe 'AkismetMethods' do before do - allow_any_instance_of(Gitlab::AkismetHelper).to receive_messages(check_for_spam?: true, is_spam?: true, ham!: nil, spam!: nil) + allow_any_instance_of(Gitlab::AkismetHelper).to receive_messages(is_spam?: true, spam!: nil) end - it { expect(issue.spam?(:mock_env, :mock_user)).to be_truthy } + it { expect(issue.spam_detected?(:mock_env)).to be_truthy } it { expect(issue.submit_spam).to be_nil } - it { expect(issue.submit_ham).to be_nil } end end diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb index 3cd4e981fb2..353b01d4a09 100644 --- a/spec/requests/api/issues_spec.rb +++ b/spec/requests/api/issues_spec.rb @@ -531,7 +531,7 @@ describe API::API, api: true do describe 'POST /projects/:id/issues with spam filtering' do before do - allow_any_instance_of(Gitlab::AkismetHelper).to receive(:check_for_spam?).and_return(true) + allow_any_instance_of(Spammable).to receive(:check_for_spam?).and_return(true) allow_any_instance_of(Gitlab::AkismetHelper).to receive(:is_spam?).and_return(true) end @@ -554,7 +554,6 @@ describe API::API, api: true do expect(spam_logs[0].description).to eq('content here') expect(spam_logs[0].user).to eq(user) expect(spam_logs[0].noteable_type).to eq('Issue') - expect(spam_logs[0].project_id).to eq(project.id) end end From abf2dcd25c4a176801314872733ede91297d1ab0 Mon Sep 17 00:00:00 2001 From: Patricio Cano Date: Mon, 1 Aug 2016 12:14:03 -0500 Subject: [PATCH 110/133] Allow `SpamLog` to be submitted as ham - Added `submitted_as_ham` to `SpamLog` to mark which logs have been submitted to Akismet. - Added routes and controller action. --- app/controllers/admin/spam_logs_controller.rb | 11 ++++++++-- app/models/spam_log.rb | 4 ++++ app/views/admin/spam_logs/_spam_log.html.haml | 5 +++++ config/routes.rb | 6 +++++- ...63709_add_submitted_as_ham_to_spam_logs.rb | 20 +++++++++++++++++++ db/schema.rb | 1 + lib/gitlab/akismet_helper.rb | 4 ++++ spec/models/concerns/spammable_spec.rb | 5 +++-- 8 files changed, 51 insertions(+), 5 deletions(-) create mode 100644 db/migrate/20160801163709_add_submitted_as_ham_to_spam_logs.rb diff --git a/app/controllers/admin/spam_logs_controller.rb b/app/controllers/admin/spam_logs_controller.rb index bc6fce0ec4e..d15f00bf84c 100644 --- a/app/controllers/admin/spam_logs_controller.rb +++ b/app/controllers/admin/spam_logs_controller.rb @@ -1,4 +1,6 @@ class Admin::SpamLogsController < Admin::ApplicationController + include Gitlab::AkismetHelper + def index @spam_logs = SpamLog.order(id: :desc).page(params[:page]) end @@ -15,9 +17,14 @@ class Admin::SpamLogsController < Admin::ApplicationController end end - def ham + def mark_as_ham spam_log = SpamLog.find(params[:id]) - Gitlab::AkismetHelper.ham!(spam_log.source_ip, spam_log.user_agent, spam_log.description, spam_log.user) + if ham!(spam_log.source_ip, spam_log.user_agent, spam_log.text, spam_log.user) + spam_log.update_attribute(:submitted_as_ham, true) + redirect_to admin_spam_logs_path, notice: 'Spam log successfully submitted as ham.' + else + redirect_to admin_spam_logs_path, notice: 'Error with Akismet. Please check the logs for more info.' + end end end diff --git a/app/models/spam_log.rb b/app/models/spam_log.rb index 12df68ef83b..3b8b9833565 100644 --- a/app/models/spam_log.rb +++ b/app/models/spam_log.rb @@ -7,4 +7,8 @@ class SpamLog < ActiveRecord::Base user.block user.destroy end + + def text + [title, description].join("\n") + end end diff --git a/app/views/admin/spam_logs/_spam_log.html.haml b/app/views/admin/spam_logs/_spam_log.html.haml index 8aea67f4497..f2b6106fb4a 100644 --- a/app/views/admin/spam_logs/_spam_log.html.haml +++ b/app/views/admin/spam_logs/_spam_log.html.haml @@ -24,6 +24,11 @@ = link_to 'Remove user', admin_spam_log_path(spam_log, remove_user: true), data: { confirm: "USER #{user.name} WILL BE REMOVED! Are you sure?" }, method: :delete, class: "btn btn-xs btn-remove" %td + - if spam_log.submitted_as_ham? + .btn.btn-xs.disabled + Submitted as Ham + - else + = link_to 'Submit as ham', mark_as_ham_admin_spam_log_path(spam_log), method: :post, class: 'btn btn-xs btn-warning' - if user && !user.blocked? = link_to 'Block user', block_admin_user_path(user), data: {confirm: 'USER WILL BE BLOCKED! Are you sure?'}, method: :put, class: "btn btn-xs" - else diff --git a/config/routes.rb b/config/routes.rb index 9a98fab15a3..8214bc26d59 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -252,7 +252,11 @@ Rails.application.routes.draw do resource :impersonation, only: :destroy resources :abuse_reports, only: [:index, :destroy] - resources :spam_logs, only: [:index, :destroy] + resources :spam_logs, only: [:index, :destroy] do + member do + post :mark_as_ham + end + end resources :applications diff --git a/db/migrate/20160801163709_add_submitted_as_ham_to_spam_logs.rb b/db/migrate/20160801163709_add_submitted_as_ham_to_spam_logs.rb new file mode 100644 index 00000000000..296f1dfac7b --- /dev/null +++ b/db/migrate/20160801163709_add_submitted_as_ham_to_spam_logs.rb @@ -0,0 +1,20 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddSubmittedAsHamToSpamLogs < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + # When a migration requires downtime you **must** uncomment the following + # constant and define a short and easy to understand explanation as to why the + # migration requires downtime. + # DOWNTIME_REASON = '' + + disable_ddl_transaction! + + def change + add_column_with_default :spam_logs, :submitted_as_ham, :boolean, default: false + end +end diff --git a/db/schema.rb b/db/schema.rb index cc881e54763..355eed13b68 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -931,6 +931,7 @@ ActiveRecord::Schema.define(version: 20160810142633) do t.text "description" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.boolean "submitted_as_ham", default: false, null: false end create_table "subscriptions", force: :cascade do |t| diff --git a/lib/gitlab/akismet_helper.rb b/lib/gitlab/akismet_helper.rb index b74d8176cc7..bd71a1aaa51 100644 --- a/lib/gitlab/akismet_helper.rb +++ b/lib/gitlab/akismet_helper.rb @@ -52,8 +52,10 @@ module Gitlab begin client.submit_ham(ip_address, user_agent, params) + true rescue => e Rails.logger.error("Unable to connect to Akismet: #{e}, skipping!") + false end end @@ -69,8 +71,10 @@ module Gitlab begin client.submit_spam(details.ip_address, details.user_agent, params) + true rescue => e Rails.logger.error("Unable to connect to Akismet: #{e}, skipping!") + false end end end diff --git a/spec/models/concerns/spammable_spec.rb b/spec/models/concerns/spammable_spec.rb index 3f7d2721d22..7492c42f71e 100644 --- a/spec/models/concerns/spammable_spec.rb +++ b/spec/models/concerns/spammable_spec.rb @@ -45,10 +45,11 @@ describe Issue, 'Spammable' do describe 'AkismetMethods' do before do - allow_any_instance_of(Gitlab::AkismetHelper).to receive_messages(is_spam?: true, spam!: nil) + allow_any_instance_of(Gitlab::AkismetHelper).to receive_messages(is_spam?: true, spam!: true, akismet_enabled?: true) + allow_any_instance_of(Spammable).to receive(:can_be_submitted?).and_return(true) end it { expect(issue.spam_detected?(:mock_env)).to be_truthy } - it { expect(issue.submit_spam).to be_nil } + it { expect(issue.submit_spam).to be_truthy } end end From 96399a81cbb2e8a0f666241eeaff7cc784c26983 Mon Sep 17 00:00:00 2001 From: Patricio Cano Date: Tue, 2 Aug 2016 16:21:57 -0500 Subject: [PATCH 111/133] Allow `Issue` to be submitted as spam - Added controller actions as reusable concerns - Added controller tests --- app/assets/stylesheets/framework/buttons.scss | 4 +++ app/controllers/concerns/spammable_actions.rb | 32 +++++++++++++++++++ app/controllers/projects/issues_controller.rb | 2 ++ app/models/concerns/spammable.rb | 18 +++++++++-- app/services/system_note_service.rb | 17 ++++++++++ app/views/admin/spam_logs/_spam_log.html.haml | 2 +- app/views/projects/issues/show.html.haml | 11 +++++-- config/routes.rb | 1 + ...0160727163552_create_user_agent_details.rb | 1 + db/schema.rb | 1 + .../admin/spam_logs_controller_spec.rb | 12 +++++++ .../projects/issues_controller_spec.rb | 29 +++++++++++++++++ spec/models/concerns/spammable_spec.rb | 9 +++--- 13 files changed, 129 insertions(+), 10 deletions(-) create mode 100644 app/controllers/concerns/spammable_actions.rb diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index 473530cf094..f1fe1697d30 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -164,6 +164,10 @@ @include btn-outline($white-light, $orange-normal, $orange-normal, $orange-light, $white-light, $orange-light); } + &.btn-spam { + @include btn-outline($white-light, $red-normal, $red-normal, $red-light, $white-light, $red-light); + } + &.btn-danger, &.btn-remove, &.btn-red { diff --git a/app/controllers/concerns/spammable_actions.rb b/app/controllers/concerns/spammable_actions.rb new file mode 100644 index 00000000000..85be25d84cc --- /dev/null +++ b/app/controllers/concerns/spammable_actions.rb @@ -0,0 +1,32 @@ +module SpammableActions + extend ActiveSupport::Concern + + included do + before_action :authorize_submit_spammable!, only: :mark_as_spam + end + + def mark_as_spam + if spammable.submit_spam + spammable.user_agent_detail.update_attribute(:submitted, true) + + if spammable.is_a?(Issuable) + SystemNoteService.submit_spam(spammable, spammable.project, current_user) + end + + redirect_to spammable, notice: 'Issue was submitted to Akismet successfully.' + else + flash[:error] = 'Error with Akismet. Please check the logs for more info.' + redirect_to spammable + end + end + + private + + def spammable + raise NotImplementedError + end + + def authorize_submit_spammable! + access_denied! unless current_user.admin? + end +end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 660e0eba06f..e9fb11e8f94 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -4,6 +4,7 @@ class Projects::IssuesController < Projects::ApplicationController include IssuableActions include ToggleAwardEmoji include IssuableCollections + include SpammableActions before_action :redirect_to_external_issue_tracker, only: [:index, :new] before_action :module_enabled @@ -185,6 +186,7 @@ class Projects::IssuesController < Projects::ApplicationController alias_method :subscribable_resource, :issue alias_method :issuable, :issue alias_method :awardable, :issue + alias_method :spammable, :issue def authorize_read_issue! return render_404 unless can?(current_user, :read_issue, @issue) diff --git a/app/models/concerns/spammable.rb b/app/models/concerns/spammable.rb index 5c75275b6e2..f272e7c5a55 100644 --- a/app/models/concerns/spammable.rb +++ b/app/models/concerns/spammable.rb @@ -22,7 +22,7 @@ module Spammable def can_be_submitted? if user_agent_detail - user_agent_detail.submittable? + user_agent_detail.submittable? && akismet_enabled? else false end @@ -41,6 +41,14 @@ module Spammable @spam end + def submitted? + if user_agent_detail + user_agent_detail.submitted + else + false + end + end + def check_for_spam self.errors.add(:base, "Your #{self.class.name.underscore} has been recognized as spam and has been discarded.") if spam? end @@ -53,17 +61,21 @@ module Spammable end end + def to_ability_name + self.class.to_s.underscore + end + # Override this method if an additional check is needed before calling Akismet def check_for_spam? akismet_enabled? end def spam_title - raise 'Implement in included model!' + raise NotImplementedError end def spam_description - raise 'Implement in included model!' + raise NotImplementedError end private diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index e13dc9265b8..56d3329f5bd 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -395,6 +395,23 @@ module SystemNoteService create_note(noteable: noteable, project: project, author: author, note: body) end + # Called when the status of a Issuable is submitted as spam + # + # noteable - Noteable object + # project - Project owning noteable + # author - User performing the change + # + # Example Note text: + # + # "Issue submitted as spam." + # + # Returns the created Note object + def submit_spam(noteable, project, author) + body = "Submitted #{noteable.class.to_s.downcase} as spam" + + create_note(noteable: noteable, project: project, author: author, note: body) + end + private def notes_for_mentioner(mentioner, noteable, notes) diff --git a/app/views/admin/spam_logs/_spam_log.html.haml b/app/views/admin/spam_logs/_spam_log.html.haml index f2b6106fb4a..4ce4eab8753 100644 --- a/app/views/admin/spam_logs/_spam_log.html.haml +++ b/app/views/admin/spam_logs/_spam_log.html.haml @@ -26,7 +26,7 @@ %td - if spam_log.submitted_as_ham? .btn.btn-xs.disabled - Submitted as Ham + Submitted as ham - else = link_to 'Submit as ham', mark_as_ham_admin_spam_log_path(spam_log), method: :post, class: 'btn btn-xs btn-warning' - if user && !user.blocked? diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index e5cce16a171..30e6a35db53 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -37,14 +37,21 @@ = link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, status_only: true, format: 'json'), data: {no_turbolink: true}, class: "btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' %li = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue) + - if @issue.can_be_submitted? && current_user.admin? + - unless @issue.submitted? + %li + = link_to 'Submit as spam', mark_as_spam_namespace_project_issue_path(@project.namespace, @project, @issue), method: :post, class: 'btn-spam', title: 'Submit as spam' + - if can?(current_user, :create_issue, @project) = link_to new_namespace_project_issue_path(@project.namespace, @project), class: 'hidden-xs hidden-sm btn btn-grouped new-issue-link btn-new btn-inverted', title: 'New issue', id: 'new_issue_link' do New issue - if can?(current_user, :update_issue, @issue) = link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, status_only: true, format: 'json'), data: {no_turbolink: true}, class: "hidden-xs hidden-sm btn btn-grouped btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' = link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, status_only: true, format: 'json'), data: {no_turbolink: true}, class: "hidden-xs hidden-sm btn btn-grouped btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' - = link_to edit_namespace_project_issue_path(@project.namespace, @project, @issue), class: 'hidden-xs hidden-sm btn btn-grouped issuable-edit' do - Edit + = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue), class: 'hidden-xs hidden-sm btn btn-grouped issuable-edit' + - if @issue.can_be_submitted? && current_user.admin? + - unless @issue.submitted? + = link_to 'Submit as spam', mark_as_spam_namespace_project_issue_path(@project.namespace, @project, @issue), method: :post, class: 'hidden-xs hidden-sm btn btn-grouped btn-spam', title: 'Submit as spam' .issue-details.issuable-details diff --git a/config/routes.rb b/config/routes.rb index 8214bc26d59..2cf2d111920 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -817,6 +817,7 @@ Rails.application.routes.draw do member do post :toggle_subscription post :toggle_award_emoji + post :mark_as_spam get :referenced_merge_requests get :related_branches get :can_create_branch diff --git a/db/migrate/20160727163552_create_user_agent_details.rb b/db/migrate/20160727163552_create_user_agent_details.rb index 05c21a476fa..f9a02f310da 100644 --- a/db/migrate/20160727163552_create_user_agent_details.rb +++ b/db/migrate/20160727163552_create_user_agent_details.rb @@ -5,6 +5,7 @@ class CreateUserAgentDetails < ActiveRecord::Migration t.string :ip_address, null: false t.integer :subject_id, null: false t.string :subject_type, null: false + t.boolean :submitted, default: false t.timestamps null: false end diff --git a/db/schema.rb b/db/schema.rb index 355eed13b68..5ac08099e90 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1004,6 +1004,7 @@ ActiveRecord::Schema.define(version: 20160810142633) do t.string "ip_address", null: false t.integer "subject_id", null: false t.string "subject_type", null: false + t.boolean "submitted", default: false t.datetime "created_at", null: false t.datetime "updated_at", null: false end diff --git a/spec/controllers/admin/spam_logs_controller_spec.rb b/spec/controllers/admin/spam_logs_controller_spec.rb index 520a4f6f9c5..f94afd1139d 100644 --- a/spec/controllers/admin/spam_logs_controller_spec.rb +++ b/spec/controllers/admin/spam_logs_controller_spec.rb @@ -34,4 +34,16 @@ describe Admin::SpamLogsController do expect { User.find(user.id) }.to raise_error(ActiveRecord::RecordNotFound) end end + + describe '#mark_as_ham' do + before do + allow_any_instance_of(Gitlab::AkismetHelper).to receive(:ham!).and_return(true) + end + it 'submits the log as ham' do + post :mark_as_ham, id: first_spam.id + + expect(response).to have_http_status(302) + expect(SpamLog.find(first_spam.id).submitted_as_ham).to be_truthy + end + end end diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index efca838613f..8fcde9a38bc 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -322,6 +322,35 @@ describe Projects::IssuesController do end end + describe 'POST #mark_as_spam' do + context 'properly submits to Akismet' do + before do + allow_any_instance_of(Spammable).to receive_messages(can_be_submitted?: true, submit_spam: true) + end + + def post_spam + admin = create(:admin) + create(:user_agent_detail, subject: issue) + project.team << [admin, :master] + sign_in(admin) + post :mark_as_spam, { + namespace_id: project.namespace.path, + project_id: project.path, + id: issue.iid + } + end + + it 'creates a system note' do + expect{ post_spam }.to change(Note, :count) + end + + it 'updates issue' do + post_spam + expect(issue.submitted?).to be_truthy + end + end + end + describe "DELETE #destroy" do context "when the user is a developer" do before { sign_in(user) } diff --git a/spec/models/concerns/spammable_spec.rb b/spec/models/concerns/spammable_spec.rb index 7492c42f71e..4e52d05918f 100644 --- a/spec/models/concerns/spammable_spec.rb +++ b/spec/models/concerns/spammable_spec.rb @@ -14,6 +14,10 @@ describe Issue, 'Spammable' do end describe 'InstanceMethods' do + before do + allow_any_instance_of(Gitlab::AkismetHelper).to receive(:akismet_enabled?).and_return(true) + end + it 'should return the correct creator' do expect(issue.send(:owner).id).to eq(issue.author_id) end @@ -24,14 +28,11 @@ describe Issue, 'Spammable' do end it 'should be submittable' do - create(:user_agent_detail, subject_id: issue.id, subject_type: issue.class.to_s) + create(:user_agent_detail, subject: issue) expect(issue.can_be_submitted?).to be_truthy end describe '#check_for_spam?' do - before do - allow_any_instance_of(Gitlab::AkismetHelper).to receive(:akismet_enabled?).and_return(true) - end it 'returns true for public project' do issue.project.update_attribute(:visibility_level, Gitlab::VisibilityLevel::PUBLIC) expect(issue.check_for_spam?).to eq(true) From 7179165af7553720089a0b7e7024374c371e2f90 Mon Sep 17 00:00:00 2001 From: Patricio Cano Date: Thu, 4 Aug 2016 12:29:43 -0500 Subject: [PATCH 112/133] Added Documentation and CHANGELOG --- CHANGELOG | 1 + doc/integration/akismet.md | 23 +++++++++++++++++++++++ doc/integration/img/spam_log.png | Bin 0 -> 187190 bytes doc/integration/img/submit_issue.png | Bin 0 -> 176450 bytes 4 files changed, 24 insertions(+) create mode 100644 doc/integration/img/spam_log.png create mode 100644 doc/integration/img/submit_issue.png diff --git a/CHANGELOG b/CHANGELOG index ccc60846787..4aad4545509 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -81,6 +81,7 @@ v 8.11.0 (unreleased) - Make "New issue" button in Issue page less obtrusive !5457 (winniehell) - Gitlab::Metrics.current_transaction needs to be public for RailsQueueDuration - Fix search for notes which belongs to deleted objects + - Allow Akismet to be trained by submitting issues as spam or ham !5538 - Add GitLab Workhorse version to admin dashboard (Katarzyna Kobierska Ula Budziszewska) - Allow branch names ending with .json for graph and network page !5579 (winniehell) - Add the `sprockets-es6` gem diff --git a/doc/integration/akismet.md b/doc/integration/akismet.md index c222d21612f..06c787cfcc7 100644 --- a/doc/integration/akismet.md +++ b/doc/integration/akismet.md @@ -33,3 +33,26 @@ To use Akismet: 7. Save the configuration. ![Screenshot of Akismet settings](img/akismet_settings.png) + + +## Training + +> *Note:* Training the Akismet filter is only available in 8.11 and above. + +As a way to better recognize between spam and ham, you can train the Akismet +filter whenever there is a false positive or false negative. + +When an entry is recognized as spam, it is rejected and added to the Spam Logs. +From here you can review if they are really spam. If one of them is not really +spam, you can use the `Submit as ham` button to tell Akismet that it falsely +recognized an entry as spam. + +![Screenshot of Spam Logs](img/spam_log.png) + +If an entry that is actually spam was not recognized as such, you will be able +to also submit this to Akismet. The `Submit as spam` button will only appear +to admin users. + +![Screenshot of Issue](img/submit_issue.png) + +Training Akismet will help it to recognize spam more accurately in the future. diff --git a/doc/integration/img/spam_log.png b/doc/integration/img/spam_log.png new file mode 100644 index 0000000000000000000000000000000000000000..8d5744486908ab01c22a615c033868cd35126464 GIT binary patch literal 187190 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ℑ=b=K&we4cq2QyjOR3Jj6cLRL4LVPvKVw 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 z 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 literal 0 HcmV?d00001 diff --git a/doc/integration/img/submit_issue.png b/doc/integration/img/submit_issue.png new file mode 100644 index 0000000000000000000000000000000000000000..9fd9c20f6b36d9845944835e14079efa8d647dc0 GIT binary patch literal 176450 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 literal 0 HcmV?d00001 From 43e756d4eafd79f4d2f366b646ebb94af78b5a4c Mon Sep 17 00:00:00 2001 From: Patricio Cano Date: Fri, 5 Aug 2016 17:10:08 -0500 Subject: [PATCH 113/133] Refactored AkismetHelper into AkismetService and cleaned up `Spammable` - Refactored SpamCheckService into SpamService --- app/controllers/admin/spam_logs_controller.rb | 4 +- app/controllers/concerns/spammable_actions.rb | 8 +- app/models/concerns/spammable.rb | 92 ++++++++----------- app/models/issue.rb | 13 +-- app/services/akismet_service.rb | 78 ++++++++++++++++ app/services/issues/create_service.rb | 6 +- app/services/spam_check_service.rb | 33 ------- app/services/spam_service.rb | 64 +++++++++++++ app/services/system_note_service.rb | 4 +- ...0160727163552_create_user_agent_details.rb | 5 + ...173930_remove_project_id_from_spam_logs.rb | 2 +- lib/api/issues.rb | 2 - lib/gitlab/akismet_helper.rb | 81 ---------------- .../admin/spam_logs_controller_spec.rb | 2 +- .../projects/issues_controller_spec.rb | 6 +- spec/lib/gitlab/akismet_helper_spec.rb | 24 ----- spec/models/concerns/spammable_spec.rb | 27 ++---- spec/requests/api/issues_spec.rb | 2 +- 18 files changed, 212 insertions(+), 241 deletions(-) create mode 100644 app/services/akismet_service.rb delete mode 100644 app/services/spam_check_service.rb create mode 100644 app/services/spam_service.rb delete mode 100644 lib/gitlab/akismet_helper.rb delete mode 100644 spec/lib/gitlab/akismet_helper_spec.rb diff --git a/app/controllers/admin/spam_logs_controller.rb b/app/controllers/admin/spam_logs_controller.rb index d15f00bf84c..7876d2ee767 100644 --- a/app/controllers/admin/spam_logs_controller.rb +++ b/app/controllers/admin/spam_logs_controller.rb @@ -1,5 +1,4 @@ class Admin::SpamLogsController < Admin::ApplicationController - include Gitlab::AkismetHelper def index @spam_logs = SpamLog.order(id: :desc).page(params[:page]) @@ -20,8 +19,7 @@ class Admin::SpamLogsController < Admin::ApplicationController def mark_as_ham spam_log = SpamLog.find(params[:id]) - if ham!(spam_log.source_ip, spam_log.user_agent, spam_log.text, spam_log.user) - spam_log.update_attribute(:submitted_as_ham, true) + if SpamService.new(spam_log).mark_as_ham! redirect_to admin_spam_logs_path, notice: 'Spam log successfully submitted as ham.' else redirect_to admin_spam_logs_path, notice: 'Error with Akismet. Please check the logs for more info.' diff --git a/app/controllers/concerns/spammable_actions.rb b/app/controllers/concerns/spammable_actions.rb index 85be25d84cc..296811267e5 100644 --- a/app/controllers/concerns/spammable_actions.rb +++ b/app/controllers/concerns/spammable_actions.rb @@ -6,13 +6,7 @@ module SpammableActions end def mark_as_spam - if spammable.submit_spam - spammable.user_agent_detail.update_attribute(:submitted, true) - - if spammable.is_a?(Issuable) - SystemNoteService.submit_spam(spammable, spammable.project, current_user) - end - + if SpamService.new(spammable).mark_as_spam!(current_user) redirect_to spammable, notice: 'Issue was submitted to Akismet successfully.' else flash[:error] = 'Error with Akismet. Please check the logs for more info.' diff --git a/app/models/concerns/spammable.rb b/app/models/concerns/spammable.rb index f272e7c5a55..694e2efcade 100644 --- a/app/models/concerns/spammable.rb +++ b/app/models/concerns/spammable.rb @@ -1,55 +1,36 @@ module Spammable extend ActiveSupport::Concern - include Gitlab::AkismetHelper module ClassMethods - def attr_spammable(*attrs) - attrs.each do |attr| - spammable_attrs << attr.to_s - end + def attr_spammable(attr, options = {}) + spammable_attrs << [attr.to_s, options] end end included do has_one :user_agent_detail, as: :subject, dependent: :destroy attr_accessor :spam - after_validation :check_for_spam, on: :create + after_validation :spam_detected?, on: :create cattr_accessor :spammable_attrs, instance_accessor: false do [] end + delegate :submitted?, to: :user_agent_detail, allow_nil: true end def can_be_submitted? if user_agent_detail - user_agent_detail.submittable? && akismet_enabled? + user_agent_detail.submittable? else false end end - def submit_spam - return unless akismet_enabled? && can_be_submitted? - spam!(user_agent_detail, spammable_text, owner) - end - - def spam_detected?(env) - @spam = is_spam?(env, owner, spammable_text) - end - def spam? @spam end - def submitted? - if user_agent_detail - user_agent_detail.submitted - else - false - end - end - - def check_for_spam + def spam_detected? self.errors.add(:base, "Your #{self.class.name.underscore} has been recognized as spam and has been discarded.") if spam? end @@ -61,34 +42,41 @@ module Spammable end end - def to_ability_name - self.class.to_s.underscore - end - - # Override this method if an additional check is needed before calling Akismet - def check_for_spam? - akismet_enabled? - end - - def spam_title - raise NotImplementedError - end - - def spam_description - raise NotImplementedError - end - - private - - def spammable_text - result = [] - self.class.spammable_attrs.each do |entry| - result << self.send(entry) - end - result.reject(&:blank?).join("\n") - end - def owner User.find(owner_id) end + + def spam_title + attr = self.class.spammable_attrs.select do |_, options| + options.fetch(:spam_title, false) + end + + attr = attr[0].first + + public_send(attr) if respond_to?(attr.to_sym) + end + + def spam_description + attr = self.class.spammable_attrs.select do |_, options| + options.fetch(:spam_description, false) + end + + attr = attr[0].first + + public_send(attr) if respond_to?(attr.to_sym) + end + + def spammable_text + result = [] + self.class.spammable_attrs.map do |attr| + result << public_send(attr.first) + end + + result.reject(&:blank?).join("\n") + end + + # Override in Spammable if further checks are necessary + def check_for_spam? + current_application_settings.akismet_enabled + end end diff --git a/app/models/issue.rb b/app/models/issue.rb index 40028e56489..ab98d0cf9df 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -37,7 +37,8 @@ class Issue < ActiveRecord::Base scope :order_due_date_asc, -> { reorder('issues.due_date IS NULL, issues.due_date ASC') } scope :order_due_date_desc, -> { reorder('issues.due_date IS NULL, issues.due_date DESC') } - attr_spammable :title, :description + attr_spammable :title, spam_title: true + attr_spammable :description, spam_description: true state_machine :state, initial: :opened do event :close do @@ -266,16 +267,8 @@ class Issue < ActiveRecord::Base due_date.try(:past?) || false end - # To allow polymorphism with Spammable + # Only issues on public projects should be checked for spam def check_for_spam? super && project.public? end - - def spam_title - title - end - - def spam_description - description - end end diff --git a/app/services/akismet_service.rb b/app/services/akismet_service.rb new file mode 100644 index 00000000000..c09663bce85 --- /dev/null +++ b/app/services/akismet_service.rb @@ -0,0 +1,78 @@ +class AkismetService + attr_accessor :spammable + + def initialize(spammable) + @spammable = spammable + end + + def client_ip(env) + env['action_dispatch.remote_ip'].to_s + end + + def user_agent(env) + env['HTTP_USER_AGENT'] + end + + def is_spam?(environment) + ip_address = client_ip(environment) + user_agent = user_agent(environment) + + params = { + type: 'comment', + text: spammable.spammable_text, + created_at: DateTime.now, + author: spammable.owner.name, + author_email: spammable.owner.email, + referrer: environment['HTTP_REFERER'], + } + + begin + is_spam, is_blatant = akismet_client.check(ip_address, user_agent, params) + is_spam || is_blatant + rescue => e + Rails.logger.error("Unable to connect to Akismet: #{e}, skipping check") + false + end + end + + def ham! + params = { + type: 'comment', + text: spammable.text, + author: spammable.user.name, + author_email: spammable.user.email + } + + begin + akismet_client.submit_ham(spammable.source_ip, spammable.user_agent, params) + true + rescue => e + Rails.logger.error("Unable to connect to Akismet: #{e}, skipping!") + false + end + end + + def spam! + params = { + type: 'comment', + text: spammable.spammable_text, + author: spammable.owner.name, + author_email: spammable.owner.email + } + + begin + akismet_client.submit_spam(spammable.user_agent_detail.ip_address, spammable.user_agent_detail.user_agent, params) + true + rescue => e + Rails.logger.error("Unable to connect to Akismet: #{e}, skipping!") + false + end + end + + private + + def akismet_client + @akismet_client ||= ::Akismet::Client.new(current_application_settings.akismet_api_key, + Gitlab.config.gitlab.url) + end +end diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index 9f8a642a75b..67125d5c0e4 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -8,7 +8,7 @@ module Issues @issue = project.issues.new(params) @issue.author = params[:author] || current_user - spam_check_service.execute + @issue.spam = spam_service.check(@api, @request) if @issue.save @issue.update_attributes(label_ids: label_params) @@ -25,8 +25,8 @@ module Issues private - def spam_check_service - SpamCheckService.new(@request, @api, @issue) + def spam_service + SpamService.new(@issue) end def user_agent_detail_service diff --git a/app/services/spam_check_service.rb b/app/services/spam_check_service.rb deleted file mode 100644 index 71b9436a22e..00000000000 --- a/app/services/spam_check_service.rb +++ /dev/null @@ -1,33 +0,0 @@ -class SpamCheckService - attr_accessor :request, :api, :spammable - - def initialize(request, api, spammable) - @request, @api, @spammable = request, api, spammable - end - - def execute - if request && spammable.check_for_spam? - if spammable.spam_detected?(request.env) - create_spam_log - end - end - end - - private - - def spam_log_attrs - { - user_id: spammable.owner_id, - title: spammable.spam_title, - description: spammable.spam_description, - source_ip: spammable.client_ip(request.env), - user_agent: spammable.user_agent(request.env), - noteable_type: spammable.class.to_s, - via_api: api - } - end - - def create_spam_log - SpamLog.create(spam_log_attrs) - end -end diff --git a/app/services/spam_service.rb b/app/services/spam_service.rb new file mode 100644 index 00000000000..ad60de368aa --- /dev/null +++ b/app/services/spam_service.rb @@ -0,0 +1,64 @@ +class SpamService + attr_accessor :spammable + + def initialize(spammable) + @spammable = spammable + end + + def check(api, request) + return false unless request && spammable.check_for_spam? + return false unless akismet.is_spam?(request.env) + + create_spam_log(api, request) + true + end + + def mark_as_spam!(current_user) + return false unless akismet_enabled? && spammable.can_be_submitted? + if akismet.spam! + spammable.user_agent_detail.update_attribute(:submitted, true) + + if spammable.is_a?(Issuable) + SystemNoteService.submit_spam(spammable, spammable.project, current_user) + end + true + else + false + end + end + + def mark_as_ham! + return false unless spammable.is_a?(SpamLog) + + if akismet.ham! + spammable.update_attribute(:submitted_as_ham, true) + true + else + false + end + end + + private + + def akismet + @akismet ||= AkismetService.new(spammable) + end + + def akismet_enabled? + current_application_settings.akismet_enabled + end + + def create_spam_log(api, request) + SpamLog.create( + { + user_id: spammable.owner_id, + title: spammable.spam_title, + description: spammable.spam_description, + source_ip: akismet.client_ip(request.env), + user_agent: akismet.user_agent(request.env), + noteable_type: spammable.class.to_s, + via_api: api + } + ) + end +end diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 56d3329f5bd..35c9ce909e6 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -395,7 +395,7 @@ module SystemNoteService create_note(noteable: noteable, project: project, author: author, note: body) end - # Called when the status of a Issuable is submitted as spam + # Called when Issuable is submitted as spam # # noteable - Noteable object # project - Project owning noteable @@ -407,7 +407,7 @@ module SystemNoteService # # Returns the created Note object def submit_spam(noteable, project, author) - body = "Submitted #{noteable.class.to_s.downcase} as spam" + body = "Submitted this #{noteable.class.to_s.downcase} as spam" create_note(noteable: noteable, project: project, author: author, note: body) end diff --git a/db/migrate/20160727163552_create_user_agent_details.rb b/db/migrate/20160727163552_create_user_agent_details.rb index f9a02f310da..6677f5e80ba 100644 --- a/db/migrate/20160727163552_create_user_agent_details.rb +++ b/db/migrate/20160727163552_create_user_agent_details.rb @@ -1,4 +1,9 @@ class CreateUserAgentDetails < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + def change create_table :user_agent_details do |t| t.string :user_agent, null: false diff --git a/db/migrate/20160729173930_remove_project_id_from_spam_logs.rb b/db/migrate/20160729173930_remove_project_id_from_spam_logs.rb index 5950874d5af..e28ab31d629 100644 --- a/db/migrate/20160729173930_remove_project_id_from_spam_logs.rb +++ b/db/migrate/20160729173930_remove_project_id_from_spam_logs.rb @@ -10,7 +10,7 @@ class RemoveProjectIdFromSpamLogs < ActiveRecord::Migration # When a migration requires downtime you **must** uncomment the following # constant and define a short and easy to understand explanation as to why the # migration requires downtime. - DOWNTIME_REASON = 'Removing a table that contains data that is not used anywhere.' + DOWNTIME_REASON = 'Removing a column that contains data that is not used anywhere.' # When using the methods "add_concurrent_index" or "add_column_with_default" # you must disable the use of transactions as these methods can not run in an diff --git a/lib/api/issues.rb b/lib/api/issues.rb index c4d3134da6c..077258faee1 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -3,8 +3,6 @@ module API class Issues < Grape::API before { authenticate! } - helpers ::Gitlab::AkismetHelper - helpers do def filter_issues_state(issues, state) case state diff --git a/lib/gitlab/akismet_helper.rb b/lib/gitlab/akismet_helper.rb deleted file mode 100644 index bd71a1aaa51..00000000000 --- a/lib/gitlab/akismet_helper.rb +++ /dev/null @@ -1,81 +0,0 @@ -module Gitlab - module AkismetHelper - def akismet_enabled? - current_application_settings.akismet_enabled - end - - def akismet_client - @akismet_client ||= ::Akismet::Client.new(current_application_settings.akismet_api_key, - Gitlab.config.gitlab.url) - end - - def client_ip(env) - env['action_dispatch.remote_ip'].to_s - end - - def user_agent(env) - env['HTTP_USER_AGENT'] - end - - def is_spam?(environment, user, text) - client = akismet_client - ip_address = client_ip(environment) - user_agent = user_agent(environment) - - params = { - type: 'comment', - text: text, - created_at: DateTime.now, - author: user.name, - author_email: user.email, - referrer: environment['HTTP_REFERER'], - } - - begin - is_spam, is_blatant = client.check(ip_address, user_agent, params) - is_spam || is_blatant - rescue => e - Rails.logger.error("Unable to connect to Akismet: #{e}, skipping check") - false - end - end - - def ham!(ip_address, user_agent, text, user) - client = akismet_client - - params = { - type: 'comment', - text: text, - author: user.name, - author_email: user.email - } - - begin - client.submit_ham(ip_address, user_agent, params) - true - rescue => e - Rails.logger.error("Unable to connect to Akismet: #{e}, skipping!") - false - end - end - - def spam!(details, text, user) - client = akismet_client - - params = { - type: 'comment', - text: text, - author: user.name, - author_email: user.email - } - - begin - client.submit_spam(details.ip_address, details.user_agent, params) - true - rescue => e - Rails.logger.error("Unable to connect to Akismet: #{e}, skipping!") - false - end - end - end -end diff --git a/spec/controllers/admin/spam_logs_controller_spec.rb b/spec/controllers/admin/spam_logs_controller_spec.rb index f94afd1139d..ac0441d0a4e 100644 --- a/spec/controllers/admin/spam_logs_controller_spec.rb +++ b/spec/controllers/admin/spam_logs_controller_spec.rb @@ -37,7 +37,7 @@ describe Admin::SpamLogsController do describe '#mark_as_ham' do before do - allow_any_instance_of(Gitlab::AkismetHelper).to receive(:ham!).and_return(true) + allow_any_instance_of(AkismetService).to receive(:ham!).and_return(true) end it 'submits the log as ham' do post :mark_as_ham, id: first_spam.id diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 8fcde9a38bc..0e8d4b80b0e 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -275,7 +275,7 @@ describe Projects::IssuesController do context 'Akismet is enabled' do before do allow_any_instance_of(Spammable).to receive(:check_for_spam?).and_return(true) - allow_any_instance_of(Gitlab::AkismetHelper).to receive(:is_spam?).and_return(true) + allow_any_instance_of(AkismetService).to receive(:is_spam?).and_return(true) end def post_spam_issue @@ -325,7 +325,9 @@ describe Projects::IssuesController do describe 'POST #mark_as_spam' do context 'properly submits to Akismet' do before do - allow_any_instance_of(Spammable).to receive_messages(can_be_submitted?: true, submit_spam: true) + allow_any_instance_of(AkismetService).to receive_messages(spam!: true) + allow_any_instance_of(ApplicationSetting).to receive_messages(akismet_enabled: true) + allow_any_instance_of(SpamService).to receive_messages(can_be_submitted?: true) end def post_spam diff --git a/spec/lib/gitlab/akismet_helper_spec.rb b/spec/lib/gitlab/akismet_helper_spec.rb deleted file mode 100644 index 80b4f912d41..00000000000 --- a/spec/lib/gitlab/akismet_helper_spec.rb +++ /dev/null @@ -1,24 +0,0 @@ -require 'spec_helper' - -describe Gitlab::AkismetHelper, type: :helper do - let(:project) { create(:project, :public) } - let(:user) { create(:user) } - - before do - allow(Gitlab.config.gitlab).to receive(:url).and_return(Settings.send(:build_gitlab_url)) - allow_any_instance_of(ApplicationSetting).to receive(:akismet_enabled).and_return(true) - allow_any_instance_of(ApplicationSetting).to receive(:akismet_api_key).and_return('12345') - end - - describe '#is_spam?' do - it 'returns true for spam' do - environment = { - 'action_dispatch.remote_ip' => '127.0.0.1', - 'HTTP_USER_AGENT' => 'Test User Agent' - } - - allow_any_instance_of(::Akismet::Client).to receive(:check).and_return([true, true]) - expect(helper.is_spam?(environment, user, 'Is this spam?')).to eq(true) - end - end -end diff --git a/spec/models/concerns/spammable_spec.rb b/spec/models/concerns/spammable_spec.rb index 4e52d05918f..7944305e7b3 100644 --- a/spec/models/concerns/spammable_spec.rb +++ b/spec/models/concerns/spammable_spec.rb @@ -14,25 +14,24 @@ describe Issue, 'Spammable' do end describe 'InstanceMethods' do - before do - allow_any_instance_of(Gitlab::AkismetHelper).to receive(:akismet_enabled?).and_return(true) - end - it 'should return the correct creator' do - expect(issue.send(:owner).id).to eq(issue.author_id) + expect(issue.owner_id).to eq(issue.author_id) end it 'should be invalid if spam' do - issue.spam = true - expect(issue.valid?).to be_truthy + issue = build(:issue, spam: true) + expect(issue.valid?).to be_falsey end - it 'should be submittable' do + it 'should not be submitted' do create(:user_agent_detail, subject: issue) - expect(issue.can_be_submitted?).to be_truthy + expect(issue.submitted?).to be_falsey end describe '#check_for_spam?' do + before do + allow_any_instance_of(ApplicationSetting).to receive(:akismet_enabled).and_return(true) + end it 'returns true for public project' do issue.project.update_attribute(:visibility_level, Gitlab::VisibilityLevel::PUBLIC) expect(issue.check_for_spam?).to eq(true) @@ -43,14 +42,4 @@ describe Issue, 'Spammable' do end end end - - describe 'AkismetMethods' do - before do - allow_any_instance_of(Gitlab::AkismetHelper).to receive_messages(is_spam?: true, spam!: true, akismet_enabled?: true) - allow_any_instance_of(Spammable).to receive(:can_be_submitted?).and_return(true) - end - - it { expect(issue.spam_detected?(:mock_env)).to be_truthy } - it { expect(issue.submit_spam).to be_truthy } - end end diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb index 353b01d4a09..30b939c797c 100644 --- a/spec/requests/api/issues_spec.rb +++ b/spec/requests/api/issues_spec.rb @@ -532,7 +532,7 @@ describe API::API, api: true do describe 'POST /projects/:id/issues with spam filtering' do before do allow_any_instance_of(Spammable).to receive(:check_for_spam?).and_return(true) - allow_any_instance_of(Gitlab::AkismetHelper).to receive(:is_spam?).and_return(true) + allow_any_instance_of(AkismetService).to receive_messages(is_spam?: true) end let(:params) do From 1b0aa72d71b1900e4a6254ebdf9af97c7377eda2 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Mon, 15 Aug 2016 14:06:44 +0200 Subject: [PATCH 114/133] Fix pipeline and build seeds in development environment When we depend on state machine events in seeds, it is likely that we will break fixtures from time to time because when transition rules change, using events most likely invalidates some objects in seeds. --- db/fixtures/development/14_builds.rb | 59 ++++++++++++++-------------- 1 file changed, 29 insertions(+), 30 deletions(-) diff --git a/db/fixtures/development/14_builds.rb b/db/fixtures/development/14_builds.rb index e65abe4ef77..6441a036e75 100644 --- a/db/fixtures/development/14_builds.rb +++ b/db/fixtures/development/14_builds.rb @@ -1,5 +1,21 @@ class Gitlab::Seeder::Builds STAGES = %w[build notify_build test notify_test deploy notify_deploy] + BUILDS = [ + { name: 'build:linux', stage: 'build', status: :success }, + { name: 'build:osx', stage: 'build', status: :success }, + { name: 'slack post build', stage: 'notify_build', status: :success }, + { name: 'rspec:linux', stage: 'test', status: :success }, + { name: 'rspec:windows', stage: 'test', status: :success }, + { name: 'rspec:windows', stage: 'test', status: :success }, + { name: 'rspec:osx', stage: 'test', status_event: :success }, + { name: 'spinach:linux', stage: 'test', status: :pending }, + { name: 'spinach:osx', stage: 'test', status: :canceled }, + { name: 'cucumber:linux', stage: 'test', status: :running }, + { name: 'cucumber:osx', stage: 'test', status: :failed }, + { name: 'slack post test', stage: 'notify_test', status: :success }, + { name: 'staging', stage: 'deploy', environment: 'staging', status: :success }, + { name: 'production', stage: 'deploy', environment: 'production', when: 'manual', status: :success }, + ] def initialize(project) @project = project @@ -8,25 +24,7 @@ class Gitlab::Seeder::Builds def seed! pipelines.each do |pipeline| begin - build_create!(pipeline, name: 'build:linux', stage: 'build', status_event: :success) - build_create!(pipeline, name: 'build:osx', stage: 'build', status_event: :success) - - build_create!(pipeline, name: 'slack post build', stage: 'notify_build', status_event: :success) - - build_create!(pipeline, name: 'rspec:linux', stage: 'test', status_event: :success) - build_create!(pipeline, name: 'rspec:windows', stage: 'test', status_event: :success) - build_create!(pipeline, name: 'rspec:windows', stage: 'test', status_event: :success) - build_create!(pipeline, name: 'rspec:osx', stage: 'test', status_event: :success) - build_create!(pipeline, name: 'spinach:linux', stage: 'test', status: :pending) - build_create!(pipeline, name: 'spinach:osx', stage: 'test', status_event: :cancel) - build_create!(pipeline, name: 'cucumber:linux', stage: 'test', status_event: :run) - build_create!(pipeline, name: 'cucumber:osx', stage: 'test', status_event: :drop) - - build_create!(pipeline, name: 'slack post test', stage: 'notify_test', status_event: :success) - - build_create!(pipeline, name: 'staging', stage: 'deploy', environment: 'staging', status_event: :success) - build_create!(pipeline, name: 'production', stage: 'deploy', environment: 'production', when: 'manual', status: :success) - + BUILDS.each { |opts| build_create!(pipeline, opts) } commit_status_create!(pipeline, name: 'jenkins', status: :success) print '.' @@ -48,22 +46,23 @@ class Gitlab::Seeder::Builds def build_create!(pipeline, opts = {}) attributes = build_attributes_for(pipeline, opts) - build = Ci::Build.create!(attributes) - if opts[:name].start_with?('build') - artifacts_cache_file(artifacts_archive_path) do |file| - build.artifacts_file = file + Ci::Build.create!(attributes) do |build| + if opts[:name].start_with?('build') + artifacts_cache_file(artifacts_archive_path) do |file| + build.artifacts_file = file + end + + artifacts_cache_file(artifacts_metadata_path) do |file| + build.artifacts_metadata = file + end end - artifacts_cache_file(artifacts_metadata_path) do |file| - build.artifacts_metadata = file + if %w(running success failed).include?(build.status) + # We need to set build trace after saving a build (id required) + build.trace = FFaker::Lorem.paragraphs(6).join("\n\n") end end - - if %w(running success failed).include?(build.status) - # We need to set build trace after saving a build (id required) - build.trace = FFaker::Lorem.paragraphs(6).join("\n\n") - end end def commit_status_create!(pipeline, opts = {}) From 7638ebe4236cfdd0e7a76f87ba077acf57a7e806 Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran Date: Fri, 12 Aug 2016 14:57:19 -0500 Subject: [PATCH 115/133] Restore `Largest repository` sort option on admin projects page --- app/helpers/sorting_helper.rb | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index e1c0b497550..8b138a8e69f 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -20,13 +20,19 @@ module SortingHelper end def projects_sort_options_hash - { + options = { sort_value_name => sort_title_name, sort_value_recently_updated => sort_title_recently_updated, sort_value_oldest_updated => sort_title_oldest_updated, sort_value_recently_created => sort_title_recently_created, sort_value_oldest_created => sort_title_oldest_created, } + + if current_controller?('admin/projects') + options.merge!(sort_value_largest_repo => sort_title_largest_repo) + end + + options end def sort_title_priority From e8aab1cd1550dd14408ae4c7b51f45110898b949 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Mon, 15 Aug 2016 23:26:40 +0200 Subject: [PATCH 116/133] This fixes a long running tests due to changed Sidekiq state --- .../admin/groups_controller_spec.rb | 5 +++-- spec/controllers/groups_controller_spec.rb | 5 +++-- .../project_services/irker_service_spec.rb | 21 ++++++++++--------- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/spec/controllers/admin/groups_controller_spec.rb b/spec/controllers/admin/groups_controller_spec.rb index 0239aea47fb..602de72d23f 100644 --- a/spec/controllers/admin/groups_controller_spec.rb +++ b/spec/controllers/admin/groups_controller_spec.rb @@ -7,12 +7,13 @@ describe Admin::GroupsController do before do sign_in(admin) - Sidekiq::Testing.fake! end describe 'DELETE #destroy' do it 'schedules a group destroy' do - expect { delete :destroy, id: project.group.path }.to change(GroupDestroyWorker.jobs, :size).by(1) + Sidekiq::Testing.fake! do + expect { delete :destroy, id: project.group.path }.to change(GroupDestroyWorker.jobs, :size).by(1) + end end it 'redirects to the admin group path' do diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb index 4ae6364207b..a763e2c5ba8 100644 --- a/spec/controllers/groups_controller_spec.rb +++ b/spec/controllers/groups_controller_spec.rb @@ -89,12 +89,13 @@ describe GroupsController do context 'as the group owner' do before do - Sidekiq::Testing.fake! sign_in(user) end it 'schedules a group destroy' do - expect { delete :destroy, id: group.path }.to change(GroupDestroyWorker.jobs, :size).by(1) + Sidekiq::Testing.fake! do + expect { delete :destroy, id: group.path }.to change(GroupDestroyWorker.jobs, :size).by(1) + end end it 'redirects to the root path' do diff --git a/spec/models/project_services/irker_service_spec.rb b/spec/models/project_services/irker_service_spec.rb index b528baaf15c..a8f2d626477 100644 --- a/spec/models/project_services/irker_service_spec.rb +++ b/spec/models/project_services/irker_service_spec.rb @@ -52,19 +52,20 @@ describe IrkerService, models: true do let(:colorize_messages) { '1' } before do + @irker_server = TCPServer.new 'localhost', 0 + allow(irker).to receive_messages( - active: true, - project: project, - project_id: project.id, - service_hook: true, - server_host: 'localhost', - server_port: 6659, - default_irc_uri: 'irc://chat.freenode.net/', - recipients: recipients, - colorize_messages: colorize_messages) + active: true, + project: project, + project_id: project.id, + service_hook: true, + server_host: @irker_server.addr[2], + server_port: @irker_server.addr[1], + default_irc_uri: 'irc://chat.freenode.net/', + recipients: recipients, + colorize_messages: colorize_messages) irker.valid? - @irker_server = TCPServer.new 'localhost', 6659 end after do From cd8ba09ee8276b1f2c3ef186156c91ec92cddea3 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Mon, 15 Aug 2016 23:33:36 +0200 Subject: [PATCH 117/133] Make rubocop happy --- .../project_services/irker_service_spec.rb | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/spec/models/project_services/irker_service_spec.rb b/spec/models/project_services/irker_service_spec.rb index a8f2d626477..ea718232255 100644 --- a/spec/models/project_services/irker_service_spec.rb +++ b/spec/models/project_services/irker_service_spec.rb @@ -55,15 +55,15 @@ describe IrkerService, models: true do @irker_server = TCPServer.new 'localhost', 0 allow(irker).to receive_messages( - active: true, - project: project, - project_id: project.id, - service_hook: true, - server_host: @irker_server.addr[2], - server_port: @irker_server.addr[1], - default_irc_uri: 'irc://chat.freenode.net/', - recipients: recipients, - colorize_messages: colorize_messages) + active: true, + project: project, + project_id: project.id, + service_hook: true, + server_host: @irker_server.addr[2], + server_port: @irker_server.addr[1], + default_irc_uri: 'irc://chat.freenode.net/', + recipients: recipients, + colorize_messages: colorize_messages) irker.valid? end From 7cdb5173f105247463a96f6526868627f0b26a85 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Mon, 15 Aug 2016 23:33:36 +0200 Subject: [PATCH 118/133] Make rubocop happy --- .../project_services/irker_service_spec.rb | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/spec/models/project_services/irker_service_spec.rb b/spec/models/project_services/irker_service_spec.rb index ec092297f4f..ffb17fd3259 100644 --- a/spec/models/project_services/irker_service_spec.rb +++ b/spec/models/project_services/irker_service_spec.rb @@ -57,15 +57,15 @@ describe IrkerService, models: true do @irker_server = TCPServer.new 'localhost', 0 allow(irker).to receive_messages( - active: true, - project: project, - project_id: project.id, - service_hook: true, - server_host: @irker_server.addr[2], - server_port: @irker_server.addr[1], - default_irc_uri: 'irc://chat.freenode.net/', - recipients: recipients, - colorize_messages: colorize_messages) + active: true, + project: project, + project_id: project.id, + service_hook: true, + server_host: @irker_server.addr[2], + server_port: @irker_server.addr[1], + default_irc_uri: 'irc://chat.freenode.net/', + recipients: recipients, + colorize_messages: colorize_messages) irker.valid? end From fb0a2e270f466f819a4c5cbe89067c48f88ca3f2 Mon Sep 17 00:00:00 2001 From: Connor Shea Date: Mon, 15 Aug 2016 12:20:48 -0600 Subject: [PATCH 119/133] Upgrade httpclient gem from 2.7.0.1 to 2.8.2. Fixes deprecation warnings from Ruby 2.3. Changelog: https://github.com/nahi/httpclient/blob/b51d7a8bb78f71726b08fbda5abfb900d627569f/CHANGELOG.md#changes-in-282 --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 3ba6048143c..2244c20203b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -338,7 +338,7 @@ GEM httparty (0.13.7) json (~> 1.8) multi_xml (>= 0.5.2) - httpclient (2.7.0.1) + httpclient (2.8.2) i18n (0.7.0) ice_nine (0.11.1) influxdb (0.2.3) From 5994c11910822463faeabb7b5f11d6529036db9d Mon Sep 17 00:00:00 2001 From: Patricio Cano Date: Tue, 9 Aug 2016 12:43:47 -0500 Subject: [PATCH 120/133] Further refactor and syntax fixes. --- app/controllers/admin/spam_logs_controller.rb | 5 +- app/controllers/concerns/spammable_actions.rb | 9 +-- app/models/concerns/spammable.rb | 42 ++++------ app/models/issue.rb | 3 +- app/models/user_agent_detail.rb | 11 +-- app/services/akismet_service.rb | 59 +++++++------- app/services/ham_service.rb | 26 ++++++ app/services/issues/create_service.rb | 4 +- app/services/spam_service.rb | 80 +++++++++++-------- app/services/system_note_service.rb | 17 ---- app/services/user_agent_detail_service.rb | 1 + app/views/projects/issues/show.html.haml | 12 ++- ...0160727163552_create_user_agent_details.rb | 2 +- db/schema.rb | 2 +- .../admin/spam_logs_controller_spec.rb | 2 +- .../projects/issues_controller_spec.rb | 13 +-- spec/factories/user_agent_details.rb | 7 +- spec/models/concerns/spammable_spec.rb | 14 +--- spec/models/user_agent_detail_spec.rb | 22 ++++- spec/requests/api/issues_spec.rb | 2 +- 20 files changed, 162 insertions(+), 171 deletions(-) create mode 100644 app/services/ham_service.rb diff --git a/app/controllers/admin/spam_logs_controller.rb b/app/controllers/admin/spam_logs_controller.rb index 7876d2ee767..2abfa22712d 100644 --- a/app/controllers/admin/spam_logs_controller.rb +++ b/app/controllers/admin/spam_logs_controller.rb @@ -1,5 +1,4 @@ class Admin::SpamLogsController < Admin::ApplicationController - def index @spam_logs = SpamLog.order(id: :desc).page(params[:page]) end @@ -19,10 +18,10 @@ class Admin::SpamLogsController < Admin::ApplicationController def mark_as_ham spam_log = SpamLog.find(params[:id]) - if SpamService.new(spam_log).mark_as_ham! + if HamService.new(spam_log).mark_as_ham! redirect_to admin_spam_logs_path, notice: 'Spam log successfully submitted as ham.' else - redirect_to admin_spam_logs_path, notice: 'Error with Akismet. Please check the logs for more info.' + redirect_to admin_spam_logs_path, alert: 'Error with Akismet. Please check the logs for more info.' end end end diff --git a/app/controllers/concerns/spammable_actions.rb b/app/controllers/concerns/spammable_actions.rb index 296811267e5..29e243c66a3 100644 --- a/app/controllers/concerns/spammable_actions.rb +++ b/app/controllers/concerns/spammable_actions.rb @@ -6,18 +6,17 @@ module SpammableActions end def mark_as_spam - if SpamService.new(spammable).mark_as_spam!(current_user) - redirect_to spammable, notice: 'Issue was submitted to Akismet successfully.' + if SpamService.new(spammable).mark_as_spam! + redirect_to spammable, notice: "#{spammable.class.to_s} was submitted to Akismet successfully." else - flash[:error] = 'Error with Akismet. Please check the logs for more info.' - redirect_to spammable + redirect_to spammable, alert: 'Error with Akismet. Please check the logs for more info.' end end private def spammable - raise NotImplementedError + raise NotImplementedError, "#{self.class} does not implement #{__method__}" end def authorize_submit_spammable! diff --git a/app/models/concerns/spammable.rb b/app/models/concerns/spammable.rb index 694e2efcade..ce54fe5d3bf 100644 --- a/app/models/concerns/spammable.rb +++ b/app/models/concerns/spammable.rb @@ -9,16 +9,19 @@ module Spammable included do has_one :user_agent_detail, as: :subject, dependent: :destroy + attr_accessor :spam - after_validation :spam_detected?, on: :create + + after_validation :check_for_spam, on: :create cattr_accessor :spammable_attrs, instance_accessor: false do [] end - delegate :submitted?, to: :user_agent_detail, allow_nil: true + + delegate :ip_address, :user_agent, to: :user_agent_detail, allow_nil: true end - def can_be_submitted? + def submittable_as_spam? if user_agent_detail user_agent_detail.submittable? else @@ -30,46 +33,29 @@ module Spammable @spam end - def spam_detected? + def check_for_spam self.errors.add(:base, "Your #{self.class.name.underscore} has been recognized as spam and has been discarded.") if spam? end - def owner_id - if self.respond_to?(:author_id) - self.author_id - elsif self.respond_to?(:creator_id) - self.creator_id - end - end - - def owner - User.find(owner_id) - end - def spam_title - attr = self.class.spammable_attrs.select do |_, options| + attr = self.class.spammable_attrs.find do |_, options| options.fetch(:spam_title, false) end - attr = attr[0].first - - public_send(attr) if respond_to?(attr.to_sym) + public_send(attr.first) if attr && respond_to?(attr.first.to_sym) end def spam_description - attr = self.class.spammable_attrs.select do |_, options| + attr = self.class.spammable_attrs.find do |_, options| options.fetch(:spam_description, false) end - attr = attr[0].first - - public_send(attr) if respond_to?(attr.to_sym) + public_send(attr.first) if attr && respond_to?(attr.first.to_sym) end def spammable_text - result = [] - self.class.spammable_attrs.map do |attr| - result << public_send(attr.first) + result = self.class.spammable_attrs.map do |attr| + public_send(attr.first) end result.reject(&:blank?).join("\n") @@ -77,6 +63,6 @@ module Spammable # Override in Spammable if further checks are necessary def check_for_spam? - current_application_settings.akismet_enabled + true end end diff --git a/app/models/issue.rb b/app/models/issue.rb index ab98d0cf9df..788611305fe 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -8,7 +8,6 @@ class Issue < ActiveRecord::Base include Taskable include Spammable include FasterCacheKeys - include AkismetSubmittable DueDateStruct = Struct.new(:title, :name).freeze NoDueDate = DueDateStruct.new('No Due Date', '0').freeze @@ -269,6 +268,6 @@ class Issue < ActiveRecord::Base # Only issues on public projects should be checked for spam def check_for_spam? - super && project.public? + project.public? end end diff --git a/app/models/user_agent_detail.rb b/app/models/user_agent_detail.rb index 6d76dff20e3..0949c6ef083 100644 --- a/app/models/user_agent_detail.rb +++ b/app/models/user_agent_detail.rb @@ -1,16 +1,9 @@ class UserAgentDetail < ActiveRecord::Base belongs_to :subject, polymorphic: true - validates :user_agent, - presence: true - validates :ip_address, - presence: true - validates :subject_id, - presence: true - validates :subject_type, - presence: true + validates :user_agent, :ip_address, :subject_id, :subject_type, presence: true def submittable? - user_agent.present? && ip_address.present? + !submitted? end end diff --git a/app/services/akismet_service.rb b/app/services/akismet_service.rb index c09663bce85..5c60addbe7c 100644 --- a/app/services/akismet_service.rb +++ b/app/services/akismet_service.rb @@ -1,33 +1,26 @@ class AkismetService - attr_accessor :spammable + attr_accessor :owner, :text, :options - def initialize(spammable) - @spammable = spammable + def initialize(owner, text, options = {}) + @owner = owner + @text = text + @options = options end - def client_ip(env) - env['action_dispatch.remote_ip'].to_s - end - - def user_agent(env) - env['HTTP_USER_AGENT'] - end - - def is_spam?(environment) - ip_address = client_ip(environment) - user_agent = user_agent(environment) + def is_spam? + return false unless akismet_enabled? params = { type: 'comment', - text: spammable.spammable_text, + text: text, created_at: DateTime.now, - author: spammable.owner.name, - author_email: spammable.owner.email, - referrer: environment['HTTP_REFERER'], + author: owner.name, + author_email: owner.email, + referrer: options[:referrer], } begin - is_spam, is_blatant = akismet_client.check(ip_address, user_agent, params) + is_spam, is_blatant = akismet_client.check(options[:ip_address], options[:user_agent], params) is_spam || is_blatant rescue => e Rails.logger.error("Unable to connect to Akismet: #{e}, skipping check") @@ -35,16 +28,18 @@ class AkismetService end end - def ham! + def submit_ham + return false unless akismet_enabled? + params = { type: 'comment', - text: spammable.text, - author: spammable.user.name, - author_email: spammable.user.email + text: text, + author: owner.name, + author_email: owner.email } begin - akismet_client.submit_ham(spammable.source_ip, spammable.user_agent, params) + akismet_client.submit_ham(options[:ip_address], options[:user_agent], params) true rescue => e Rails.logger.error("Unable to connect to Akismet: #{e}, skipping!") @@ -52,16 +47,18 @@ class AkismetService end end - def spam! + def submit_spam + return false unless akismet_enabled? + params = { type: 'comment', - text: spammable.spammable_text, - author: spammable.owner.name, - author_email: spammable.owner.email + text: text, + author: owner.name, + author_email: owner.email } begin - akismet_client.submit_spam(spammable.user_agent_detail.ip_address, spammable.user_agent_detail.user_agent, params) + akismet_client.submit_spam(options[:ip_address], options[:user_agent], params) true rescue => e Rails.logger.error("Unable to connect to Akismet: #{e}, skipping!") @@ -75,4 +72,8 @@ class AkismetService @akismet_client ||= ::Akismet::Client.new(current_application_settings.akismet_api_key, Gitlab.config.gitlab.url) end + + def akismet_enabled? + current_application_settings.akismet_enabled + end end diff --git a/app/services/ham_service.rb b/app/services/ham_service.rb new file mode 100644 index 00000000000..b0e1799b489 --- /dev/null +++ b/app/services/ham_service.rb @@ -0,0 +1,26 @@ +class HamService + attr_accessor :spam_log + + def initialize(spam_log) + @spam_log = spam_log + end + + def mark_as_ham! + if akismet.submit_ham + spam_log.update_attribute(:submitted_as_ham, true) + else + false + end + end + + private + + def akismet + @akismet ||= AkismetService.new( + spam_log.user, + spam_log.text, + ip_address: spam_log.source_ip, + user_agent: spam_log.user_agent + ) + end +end diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index 67125d5c0e4..65550ab8ec6 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -8,7 +8,7 @@ module Issues @issue = project.issues.new(params) @issue.author = params[:author] || current_user - @issue.spam = spam_service.check(@api, @request) + @issue.spam = spam_service.check(@api) if @issue.save @issue.update_attributes(label_ids: label_params) @@ -26,7 +26,7 @@ module Issues private def spam_service - SpamService.new(@issue) + SpamService.new(@issue, @request) end def user_agent_detail_service diff --git a/app/services/spam_service.rb b/app/services/spam_service.rb index ad60de368aa..48903291799 100644 --- a/app/services/spam_service.rb +++ b/app/services/spam_service.rb @@ -1,38 +1,35 @@ class SpamService - attr_accessor :spammable + attr_accessor :spammable, :request, :options - def initialize(spammable) + def initialize(spammable, request = nil) @spammable = spammable - end + @request = request + @options = {} - def check(api, request) - return false unless request && spammable.check_for_spam? - return false unless akismet.is_spam?(request.env) - - create_spam_log(api, request) - true - end - - def mark_as_spam!(current_user) - return false unless akismet_enabled? && spammable.can_be_submitted? - if akismet.spam! - spammable.user_agent_detail.update_attribute(:submitted, true) - - if spammable.is_a?(Issuable) - SystemNoteService.submit_spam(spammable, spammable.project, current_user) - end - true + if @request + @options[:ip_address] = @request.env['action_dispatch.remote_ip'].to_s + @options[:user_agent] = @request.env['HTTP_USER_AGENT'] + @options[:referrer] = @request.env['HTTP_REFERRER'] else - false + @options[:ip_address] = @spammable.ip_address + @options[:user_agent] = @spammable.user_agent end end - def mark_as_ham! - return false unless spammable.is_a?(SpamLog) + def check(api = false) + return false unless request && check_for_spam? - if akismet.ham! - spammable.update_attribute(:submitted_as_ham, true) - true + return false unless akismet.is_spam? + + create_spam_log(api) + true + end + + def mark_as_spam! + return false unless spammable.submittable_as_spam? + + if akismet.submit_spam + spammable.user_agent_detail.update_attribute(:submitted, true) else false end @@ -41,21 +38,38 @@ class SpamService private def akismet - @akismet ||= AkismetService.new(spammable) + @akismet ||= AkismetService.new( + spammable_owner, + spammable.spammable_text, + options + ) end - def akismet_enabled? - current_application_settings.akismet_enabled + def spammable_owner + @user ||= User.find(spammable_owner_id) end - def create_spam_log(api, request) + def spammable_owner_id + @owner_id ||= + if spammable.respond_to?(:author_id) + spammable.author_id + elsif spammable.respond_to?(:creator_id) + spammable.creator_id + end + end + + def check_for_spam? + spammable.check_for_spam? + end + + def create_spam_log(api) SpamLog.create( { - user_id: spammable.owner_id, + user_id: spammable_owner_id, title: spammable.spam_title, description: spammable.spam_description, - source_ip: akismet.client_ip(request.env), - user_agent: akismet.user_agent(request.env), + source_ip: options[:ip_address], + user_agent: options[:user_agent], noteable_type: spammable.class.to_s, via_api: api } diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 35c9ce909e6..e13dc9265b8 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -395,23 +395,6 @@ module SystemNoteService create_note(noteable: noteable, project: project, author: author, note: body) end - # Called when Issuable is submitted as spam - # - # noteable - Noteable object - # project - Project owning noteable - # author - User performing the change - # - # Example Note text: - # - # "Issue submitted as spam." - # - # Returns the created Note object - def submit_spam(noteable, project, author) - body = "Submitted this #{noteable.class.to_s.downcase} as spam" - - create_note(noteable: noteable, project: project, author: author, note: body) - end - private def notes_for_mentioner(mentioner, noteable, notes) diff --git a/app/services/user_agent_detail_service.rb b/app/services/user_agent_detail_service.rb index c07e2ca12a6..a1ee3df5fe1 100644 --- a/app/services/user_agent_detail_service.rb +++ b/app/services/user_agent_detail_service.rb @@ -7,6 +7,7 @@ class UserAgentDetailService def create return unless request + spammable.create_user_agent_detail(user_agent: request.env['HTTP_USER_AGENT'], ip_address: request.env['action_dispatch.remote_ip'].to_s) end end diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 30e6a35db53..9f1a046ea74 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -37,10 +37,9 @@ = link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, status_only: true, format: 'json'), data: {no_turbolink: true}, class: "btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' %li = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue) - - if @issue.can_be_submitted? && current_user.admin? - - unless @issue.submitted? - %li - = link_to 'Submit as spam', mark_as_spam_namespace_project_issue_path(@project.namespace, @project, @issue), method: :post, class: 'btn-spam', title: 'Submit as spam' + - if @issue.submittable_as_spam? && current_user.admin? + %li + = link_to 'Submit as spam', mark_as_spam_namespace_project_issue_path(@project.namespace, @project, @issue), method: :post, class: 'btn-spam', title: 'Submit as spam' - if can?(current_user, :create_issue, @project) = link_to new_namespace_project_issue_path(@project.namespace, @project), class: 'hidden-xs hidden-sm btn btn-grouped new-issue-link btn-new btn-inverted', title: 'New issue', id: 'new_issue_link' do @@ -48,10 +47,9 @@ - if can?(current_user, :update_issue, @issue) = link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, status_only: true, format: 'json'), data: {no_turbolink: true}, class: "hidden-xs hidden-sm btn btn-grouped btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' = link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, status_only: true, format: 'json'), data: {no_turbolink: true}, class: "hidden-xs hidden-sm btn btn-grouped btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' - = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue), class: 'hidden-xs hidden-sm btn btn-grouped issuable-edit' - - if @issue.can_be_submitted? && current_user.admin? - - unless @issue.submitted? + - if @issue.submittable_as_spam? && current_user.admin? = link_to 'Submit as spam', mark_as_spam_namespace_project_issue_path(@project.namespace, @project, @issue), method: :post, class: 'hidden-xs hidden-sm btn btn-grouped btn-spam', title: 'Submit as spam' + = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue), class: 'hidden-xs hidden-sm btn btn-grouped issuable-edit' .issue-details.issuable-details diff --git a/db/migrate/20160727163552_create_user_agent_details.rb b/db/migrate/20160727163552_create_user_agent_details.rb index 6677f5e80ba..ed4ccfedc0a 100644 --- a/db/migrate/20160727163552_create_user_agent_details.rb +++ b/db/migrate/20160727163552_create_user_agent_details.rb @@ -10,7 +10,7 @@ class CreateUserAgentDetails < ActiveRecord::Migration t.string :ip_address, null: false t.integer :subject_id, null: false t.string :subject_type, null: false - t.boolean :submitted, default: false + t.boolean :submitted, default: false, null: false t.timestamps null: false end diff --git a/db/schema.rb b/db/schema.rb index 5ac08099e90..52ba60ace11 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1004,7 +1004,7 @@ ActiveRecord::Schema.define(version: 20160810142633) do t.string "ip_address", null: false t.integer "subject_id", null: false t.string "subject_type", null: false - t.boolean "submitted", default: false + t.boolean "submitted", default: false, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false end diff --git a/spec/controllers/admin/spam_logs_controller_spec.rb b/spec/controllers/admin/spam_logs_controller_spec.rb index ac0441d0a4e..585ca31389d 100644 --- a/spec/controllers/admin/spam_logs_controller_spec.rb +++ b/spec/controllers/admin/spam_logs_controller_spec.rb @@ -37,7 +37,7 @@ describe Admin::SpamLogsController do describe '#mark_as_ham' do before do - allow_any_instance_of(AkismetService).to receive(:ham!).and_return(true) + allow_any_instance_of(AkismetService).to receive(:submit_ham).and_return(true) end it 'submits the log as ham' do post :mark_as_ham, id: first_spam.id diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 0e8d4b80b0e..0836b71056c 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -274,7 +274,7 @@ describe Projects::IssuesController do describe 'POST #create' do context 'Akismet is enabled' do before do - allow_any_instance_of(Spammable).to receive(:check_for_spam?).and_return(true) + allow_any_instance_of(SpamService).to receive(:check_for_spam?).and_return(true) allow_any_instance_of(AkismetService).to receive(:is_spam?).and_return(true) end @@ -317,7 +317,7 @@ describe Projects::IssuesController do end it 'creates a user agent detail' do - expect{ post_new_issue }.to change(UserAgentDetail, :count) + expect{ post_new_issue }.to change(UserAgentDetail, :count).by(1) end end end @@ -325,9 +325,8 @@ describe Projects::IssuesController do describe 'POST #mark_as_spam' do context 'properly submits to Akismet' do before do - allow_any_instance_of(AkismetService).to receive_messages(spam!: true) + allow_any_instance_of(AkismetService).to receive_messages(submit_spam: true) allow_any_instance_of(ApplicationSetting).to receive_messages(akismet_enabled: true) - allow_any_instance_of(SpamService).to receive_messages(can_be_submitted?: true) end def post_spam @@ -342,13 +341,9 @@ describe Projects::IssuesController do } end - it 'creates a system note' do - expect{ post_spam }.to change(Note, :count) - end - it 'updates issue' do post_spam - expect(issue.submitted?).to be_truthy + expect(issue.submittable_as_spam?).to be_falsey end end end diff --git a/spec/factories/user_agent_details.rb b/spec/factories/user_agent_details.rb index 10de5dcb329..9763cc0cf15 100644 --- a/spec/factories/user_agent_details.rb +++ b/spec/factories/user_agent_details.rb @@ -2,11 +2,6 @@ FactoryGirl.define do factory :user_agent_detail do ip_address '127.0.0.1' user_agent 'AppleWebKit/537.36' - subject_id 1 - subject_type 'Issue' - - trait :on_issue do - association :subject, factory: :issue - end + association :subject, factory: :issue end end diff --git a/spec/models/concerns/spammable_spec.rb b/spec/models/concerns/spammable_spec.rb index 7944305e7b3..32935bc0b09 100644 --- a/spec/models/concerns/spammable_spec.rb +++ b/spec/models/concerns/spammable_spec.rb @@ -9,29 +9,17 @@ describe Issue, 'Spammable' do describe 'ClassMethods' do it 'should return correct attr_spammable' do - expect(issue.send(:spammable_text)).to eq("#{issue.title}\n#{issue.description}") + expect(issue.spammable_text).to eq("#{issue.title}\n#{issue.description}") end end describe 'InstanceMethods' do - it 'should return the correct creator' do - expect(issue.owner_id).to eq(issue.author_id) - end - it 'should be invalid if spam' do issue = build(:issue, spam: true) expect(issue.valid?).to be_falsey end - it 'should not be submitted' do - create(:user_agent_detail, subject: issue) - expect(issue.submitted?).to be_falsey - end - describe '#check_for_spam?' do - before do - allow_any_instance_of(ApplicationSetting).to receive(:akismet_enabled).and_return(true) - end it 'returns true for public project' do issue.project.update_attribute(:visibility_level, Gitlab::VisibilityLevel::PUBLIC) expect(issue.check_for_spam?).to eq(true) diff --git a/spec/models/user_agent_detail_spec.rb b/spec/models/user_agent_detail_spec.rb index ba21161fc7f..a8c25766e73 100644 --- a/spec/models/user_agent_detail_spec.rb +++ b/spec/models/user_agent_detail_spec.rb @@ -2,16 +2,30 @@ require 'rails_helper' describe UserAgentDetail, type: :model do describe '.submittable?' do - it 'should be submittable' do - detail = create(:user_agent_detail, :on_issue) + it 'is submittable when not already submitted' do + detail = build(:user_agent_detail) + expect(detail.submittable?).to be_truthy end + + it 'is not submittable when already submitted' do + detail = build(:user_agent_detail, submitted: true) + + expect(detail.submittable?).to be_falsey + end end describe '.valid?' do - it 'should be valid with a subject' do - detail = create(:user_agent_detail, :on_issue) + it 'is valid with a subject' do + detail = build(:user_agent_detail) + expect(detail).to be_valid end + + it 'is invalid without a subject' do + detail = build(:user_agent_detail, subject: nil) + + expect(detail).not_to be_valid + end end end diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb index 30b939c797c..a40e1a93b71 100644 --- a/spec/requests/api/issues_spec.rb +++ b/spec/requests/api/issues_spec.rb @@ -531,7 +531,7 @@ describe API::API, api: true do describe 'POST /projects/:id/issues with spam filtering' do before do - allow_any_instance_of(Spammable).to receive(:check_for_spam?).and_return(true) + allow_any_instance_of(SpamService).to receive(:check_for_spam?).and_return(true) allow_any_instance_of(AkismetService).to receive_messages(is_spam?: true) end From e805a6470031d942f7de604fdf7acfc7cf4f0b1a Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Tue, 16 Aug 2016 10:39:13 +0530 Subject: [PATCH 121/133] Backport changes from gitlab-org/gitlab-ee!581 to CE. !581 has a lot of changes that would cause merge conflicts if not properly backported to CE. This commit/MR serves as a better foundation for gitlab-org/gitlab-ee!581. = Changes = 1. Move from `has_one {merge,push}_access_level` to `has_many`, with the `length` of the association limited to `1`. This is _effectively_ a `has_one` association, but should cause less conflicts with EE, which is set to `has_many`. This has a number of related changes in the views, specs, and factories. 2. Make `gon` variable loading more consistent (with EE!581) in the `ProtectedBranchesController`. Also use `::` to prefix the `ProtectedBranches` services, because this is required in EE. 3. Extract a `ProtectedBranchAccess` concern from the two access level models. This concern only has a single `humanize` method here, but will have more methods in EE. 4. Add `form_errors` to the protected branches creation form. This is not strictly required for EE compatibility, but was an oversight nonetheless. --- .../protected_branch_create.js.es6 | 4 +-- .../javascripts/protected_branch_edit.js.es6 | 10 ++++--- .../projects/protected_branches_controller.rb | 26 ++++++++++++------- .../concerns/protected_branch_access.rb | 7 +++++ app/models/protected_branch.rb | 11 +++++--- .../protected_branch/merge_access_level.rb | 6 ++--- .../protected_branch/push_access_level.rb | 6 ++--- .../protected_branches/create_service.rb | 18 +------------ .../_create_protected_branch.html.haml | 9 ++++--- .../_protected_branch.html.haml | 12 ++++----- lib/api/entities.rb | 4 +-- spec/factories/protected_branches.rb | 10 +++---- spec/features/protected_branches_spec.rb | 13 ++++++---- spec/services/git_push_service_spec.rb | 6 ++--- 14 files changed, 72 insertions(+), 70 deletions(-) create mode 100644 app/models/concerns/protected_branch_access.rb diff --git a/app/assets/javascripts/protected_branch_create.js.es6 b/app/assets/javascripts/protected_branch_create.js.es6 index 00e20a03b04..2efca2414dc 100644 --- a/app/assets/javascripts/protected_branch_create.js.es6 +++ b/app/assets/javascripts/protected_branch_create.js.es6 @@ -44,8 +44,8 @@ // Enable submit button const $branchInput = this.$wrap.find('input[name="protected_branch[name]"]'); - const $allowedToMergeInput = this.$wrap.find('input[name="protected_branch[merge_access_level_attributes][access_level]"]'); - const $allowedToPushInput = this.$wrap.find('input[name="protected_branch[push_access_level_attributes][access_level]"]'); + const $allowedToMergeInput = this.$wrap.find('input[name="protected_branch[merge_access_levels_attributes][0][access_level]"]'); + const $allowedToPushInput = this.$wrap.find('input[name="protected_branch[push_access_levels_attributes][0][access_level]"]'); if ($branchInput.val() && $allowedToMergeInput.val() && $allowedToPushInput.val()){ this.$form.find('input[type="submit"]').removeAttr('disabled'); diff --git a/app/assets/javascripts/protected_branch_edit.js.es6 b/app/assets/javascripts/protected_branch_edit.js.es6 index 8d42e268ebc..a59fcbfa082 100644 --- a/app/assets/javascripts/protected_branch_edit.js.es6 +++ b/app/assets/javascripts/protected_branch_edit.js.es6 @@ -39,12 +39,14 @@ _method: 'PATCH', id: this.$wrap.data('banchId'), protected_branch: { - merge_access_level_attributes: { + merge_access_levels_attributes: [{ + id: this.$allowedToMergeDropdown.data('access-level-id'), access_level: $allowedToMergeInput.val() - }, - push_access_level_attributes: { + }], + push_access_levels_attributes: [{ + id: this.$allowedToPushDropdown.data('access-level-id'), access_level: $allowedToPushInput.val() - } + }] } }, success: () => { diff --git a/app/controllers/projects/protected_branches_controller.rb b/app/controllers/projects/protected_branches_controller.rb index d28ec6e2eac..9a438d5512c 100644 --- a/app/controllers/projects/protected_branches_controller.rb +++ b/app/controllers/projects/protected_branches_controller.rb @@ -9,16 +9,16 @@ class Projects::ProtectedBranchesController < Projects::ApplicationController def index @protected_branch = @project.protected_branches.new - load_protected_branches_gon_variables + load_gon_index end def create - @protected_branch = ProtectedBranches::CreateService.new(@project, current_user, protected_branch_params).execute + @protected_branch = ::ProtectedBranches::CreateService.new(@project, current_user, protected_branch_params).execute if @protected_branch.persisted? redirect_to namespace_project_protected_branches_path(@project.namespace, @project) else load_protected_branches - load_protected_branches_gon_variables + load_gon_index render :index end end @@ -28,7 +28,7 @@ class Projects::ProtectedBranchesController < Projects::ApplicationController end def update - @protected_branch = ProtectedBranches::UpdateService.new(@project, current_user, protected_branch_params).execute(@protected_branch) + @protected_branch = ::ProtectedBranches::UpdateService.new(@project, current_user, protected_branch_params).execute(@protected_branch) if @protected_branch.valid? respond_to do |format| @@ -58,17 +58,23 @@ class Projects::ProtectedBranchesController < Projects::ApplicationController def protected_branch_params params.require(:protected_branch).permit(:name, - merge_access_level_attributes: [:access_level], - push_access_level_attributes: [:access_level]) + merge_access_levels_attributes: [:access_level, :id], + push_access_levels_attributes: [:access_level, :id]) end def load_protected_branches @protected_branches = @project.protected_branches.order(:name).page(params[:page]) end - def load_protected_branches_gon_variables - gon.push({ open_branches: @project.open_branches.map { |br| { text: br.name, id: br.name, title: br.name } }, - push_access_levels: ProtectedBranch::PushAccessLevel.human_access_levels.map { |id, text| { id: id, text: text } }, - merge_access_levels: ProtectedBranch::MergeAccessLevel.human_access_levels.map { |id, text| { id: id, text: text } } }) + def access_levels_options + { + push_access_levels: ProtectedBranch::PushAccessLevel.human_access_levels.map { |id, text| { id: id, text: text, before_divider: true } }, + merge_access_levels: ProtectedBranch::MergeAccessLevel.human_access_levels.map { |id, text| { id: id, text: text, before_divider: true } } + } + end + + def load_gon_index + params = { open_branches: @project.open_branches.map { |br| { text: br.name, id: br.name, title: br.name } } } + gon.push(params.merge(access_levels_options)) end end diff --git a/app/models/concerns/protected_branch_access.rb b/app/models/concerns/protected_branch_access.rb new file mode 100644 index 00000000000..5a7b36070e7 --- /dev/null +++ b/app/models/concerns/protected_branch_access.rb @@ -0,0 +1,7 @@ +module ProtectedBranchAccess + extend ActiveSupport::Concern + + def humanize + self.class.human_access_levels[self.access_level] + end +end diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb index 226b3f54342..6240912a6e1 100644 --- a/app/models/protected_branch.rb +++ b/app/models/protected_branch.rb @@ -5,11 +5,14 @@ class ProtectedBranch < ActiveRecord::Base validates :name, presence: true validates :project, presence: true - has_one :merge_access_level, dependent: :destroy - has_one :push_access_level, dependent: :destroy + has_many :merge_access_levels, dependent: :destroy + has_many :push_access_levels, dependent: :destroy - accepts_nested_attributes_for :push_access_level - accepts_nested_attributes_for :merge_access_level + validates_length_of :merge_access_levels, is: 1, message: "are restricted to a single instance per protected branch." + validates_length_of :push_access_levels, is: 1, message: "are restricted to a single instance per protected branch." + + accepts_nested_attributes_for :push_access_levels + accepts_nested_attributes_for :merge_access_levels def commit project.commit(self.name) diff --git a/app/models/protected_branch/merge_access_level.rb b/app/models/protected_branch/merge_access_level.rb index b1112ee737d..806b3ccd275 100644 --- a/app/models/protected_branch/merge_access_level.rb +++ b/app/models/protected_branch/merge_access_level.rb @@ -1,4 +1,6 @@ class ProtectedBranch::MergeAccessLevel < ActiveRecord::Base + include ProtectedBranchAccess + belongs_to :protected_branch delegate :project, to: :protected_branch @@ -17,8 +19,4 @@ class ProtectedBranch::MergeAccessLevel < ActiveRecord::Base project.team.max_member_access(user.id) >= access_level end - - def humanize - self.class.human_access_levels[self.access_level] - end end diff --git a/app/models/protected_branch/push_access_level.rb b/app/models/protected_branch/push_access_level.rb index 6a5e49cf453..92e9c51d883 100644 --- a/app/models/protected_branch/push_access_level.rb +++ b/app/models/protected_branch/push_access_level.rb @@ -1,4 +1,6 @@ class ProtectedBranch::PushAccessLevel < ActiveRecord::Base + include ProtectedBranchAccess + belongs_to :protected_branch delegate :project, to: :protected_branch @@ -20,8 +22,4 @@ class ProtectedBranch::PushAccessLevel < ActiveRecord::Base project.team.max_member_access(user.id) >= access_level end - - def humanize - self.class.human_access_levels[self.access_level] - end end diff --git a/app/services/protected_branches/create_service.rb b/app/services/protected_branches/create_service.rb index 6150a2a83c9..a84e335340d 100644 --- a/app/services/protected_branches/create_service.rb +++ b/app/services/protected_branches/create_service.rb @@ -5,23 +5,7 @@ module ProtectedBranches def execute raise Gitlab::Access::AccessDeniedError unless can?(current_user, :admin_project, project) - protected_branch = project.protected_branches.new(params) - - ProtectedBranch.transaction do - protected_branch.save! - - if protected_branch.push_access_level.blank? - protected_branch.create_push_access_level!(access_level: Gitlab::Access::MASTER) - end - - if protected_branch.merge_access_level.blank? - protected_branch.create_merge_access_level!(access_level: Gitlab::Access::MASTER) - end - end - - protected_branch - rescue ActiveRecord::RecordInvalid - protected_branch + project.protected_branches.create(params) end end end diff --git a/app/views/projects/protected_branches/_create_protected_branch.html.haml b/app/views/projects/protected_branches/_create_protected_branch.html.haml index 85d0c494ba8..d4c6fa24768 100644 --- a/app/views/projects/protected_branches/_create_protected_branch.html.haml +++ b/app/views/projects/protected_branches/_create_protected_branch.html.haml @@ -5,6 +5,7 @@ Protect a branch .panel-body .form-horizontal + = form_errors(@protected_branch) .form-group = f.label :name, class: 'col-md-2 text-right' do Branch: @@ -18,19 +19,19 @@ %code production/* are supported .form-group - %label.col-md-2.text-right{ for: 'merge_access_level_attributes' } + %label.col-md-2.text-right{ for: 'merge_access_levels_attributes' } Allowed to merge: .col-md-10 = dropdown_tag('Select', options: { toggle_class: 'js-allowed-to-merge wide', - data: { field_name: 'protected_branch[merge_access_level_attributes][access_level]', input_id: 'merge_access_level_attributes' }}) + data: { field_name: 'protected_branch[merge_access_levels_attributes][0][access_level]', input_id: 'merge_access_levels_attributes' }}) .form-group - %label.col-md-2.text-right{ for: 'push_access_level_attributes' } + %label.col-md-2.text-right{ for: 'push_access_levels_attributes' } Allowed to push: .col-md-10 = dropdown_tag('Select', options: { toggle_class: 'js-allowed-to-push wide', - data: { field_name: 'protected_branch[push_access_level_attributes][access_level]', input_id: 'push_access_level_attributes' }}) + data: { field_name: 'protected_branch[push_access_levels_attributes][0][access_level]', input_id: 'push_access_levels_attributes' }}) .panel-footer = f.submit 'Protect', class: 'btn-create btn', disabled: true diff --git a/app/views/projects/protected_branches/_protected_branch.html.haml b/app/views/projects/protected_branches/_protected_branch.html.haml index e2e01ee78f8..eb4c67daa80 100644 --- a/app/views/projects/protected_branches/_protected_branch.html.haml +++ b/app/views/projects/protected_branches/_protected_branch.html.haml @@ -14,15 +14,15 @@ - else (branch was removed from repository) %td - = hidden_field_tag "allowed_to_merge_#{protected_branch.id}", protected_branch.merge_access_level.access_level - = dropdown_tag( (protected_branch.merge_access_level.humanize || 'Select') , + = hidden_field_tag "allowed_to_merge_#{protected_branch.id}", protected_branch.merge_access_levels.first.access_level + = dropdown_tag( (protected_branch.merge_access_levels.first.humanize || 'Select') , options: { toggle_class: 'js-allowed-to-merge', dropdown_class: 'dropdown-menu-selectable js-allowed-to-merge-container', - data: { field_name: "allowed_to_merge_#{protected_branch.id}" }}) + data: { field_name: "allowed_to_merge_#{protected_branch.id}", access_level_id: protected_branch.merge_access_levels.first.id }}) %td - = hidden_field_tag "allowed_to_push_#{protected_branch.id}", protected_branch.push_access_level.access_level - = dropdown_tag( (protected_branch.push_access_level.humanize || 'Select') , + = hidden_field_tag "allowed_to_push_#{protected_branch.id}", protected_branch.push_access_levels.first.access_level + = dropdown_tag( (protected_branch.push_access_levels.first.humanize || 'Select') , options: { toggle_class: 'js-allowed-to-push', dropdown_class: 'dropdown-menu-selectable js-allowed-to-push-container', - data: { field_name: "allowed_to_push_#{protected_branch.id}" }}) + data: { field_name: "allowed_to_push_#{protected_branch.id}", access_level_id: protected_branch.push_access_levels.first.id }}) - if can_admin_project %td = link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_branch], data: { confirm: 'Branch will be writable for developers. Are you sure?' }, method: :delete, class: 'btn btn-warning' diff --git a/lib/api/entities.rb b/lib/api/entities.rb index ae74d14a4bb..7bce427adf6 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -129,12 +129,12 @@ module API expose :developers_can_push do |repo_branch, options| project = options[:project] - project.protected_branches.matching(repo_branch.name).any? { |protected_branch| protected_branch.push_access_level.access_level == Gitlab::Access::DEVELOPER } + project.protected_branches.matching(repo_branch.name).any? { |protected_branch| protected_branch.push_access_levels.first.access_level == Gitlab::Access::DEVELOPER } end expose :developers_can_merge do |repo_branch, options| project = options[:project] - project.protected_branches.matching(repo_branch.name).any? { |protected_branch| protected_branch.merge_access_level.access_level == Gitlab::Access::DEVELOPER } + project.protected_branches.matching(repo_branch.name).any? { |protected_branch| protected_branch.merge_access_levels.first.access_level == Gitlab::Access::DEVELOPER } end end diff --git a/spec/factories/protected_branches.rb b/spec/factories/protected_branches.rb index 5575852c2d7..3b21174987f 100644 --- a/spec/factories/protected_branches.rb +++ b/spec/factories/protected_branches.rb @@ -4,25 +4,25 @@ FactoryGirl.define do project after(:create) do |protected_branch| - protected_branch.create_push_access_level!(access_level: Gitlab::Access::MASTER) - protected_branch.create_merge_access_level!(access_level: Gitlab::Access::MASTER) + protected_branch.push_access_levels.create!(access_level: Gitlab::Access::MASTER) + protected_branch.merge_access_levels.create!(access_level: Gitlab::Access::MASTER) end trait :developers_can_push do after(:create) do |protected_branch| - protected_branch.push_access_level.update!(access_level: Gitlab::Access::DEVELOPER) + protected_branch.push_access_levels.first.update!(access_level: Gitlab::Access::DEVELOPER) end end trait :developers_can_merge do after(:create) do |protected_branch| - protected_branch.merge_access_level.update!(access_level: Gitlab::Access::DEVELOPER) + protected_branch.merge_access_levels.first.update!(access_level: Gitlab::Access::DEVELOPER) end end trait :no_one_can_push do after(:create) do |protected_branch| - protected_branch.push_access_level.update!(access_level: Gitlab::Access::NO_ACCESS) + protected_branch.push_access_levels.first.update!(access_level: Gitlab::Access::NO_ACCESS) end end end diff --git a/spec/features/protected_branches_spec.rb b/spec/features/protected_branches_spec.rb index 3499460c84d..5beb658b2f5 100644 --- a/spec/features/protected_branches_spec.rb +++ b/spec/features/protected_branches_spec.rb @@ -71,7 +71,10 @@ feature 'Projected Branches', feature: true, js: true do project.repository.add_branch(user, 'production-stable', 'master') project.repository.add_branch(user, 'staging-stable', 'master') project.repository.add_branch(user, 'development', 'master') - create(:protected_branch, project: project, name: "*-stable") + + visit namespace_project_protected_branches_path(project.namespace, project) + set_protected_branch_name('*-stable') + click_on "Protect" visit namespace_project_protected_branches_path(project.namespace, project) click_on "2 matching branches" @@ -96,7 +99,7 @@ feature 'Projected Branches', feature: true, js: true do click_on "Protect" expect(ProtectedBranch.count).to eq(1) - expect(ProtectedBranch.last.push_access_level.access_level).to eq(access_type_id) + expect(ProtectedBranch.last.push_access_levels.first.access_level).to eq(access_type_id) end it "allows updating protected branches so that #{access_type_name} can push to them" do @@ -112,7 +115,7 @@ feature 'Projected Branches', feature: true, js: true do end wait_for_ajax - expect(ProtectedBranch.last.push_access_level.access_level).to eq(access_type_id) + expect(ProtectedBranch.last.push_access_levels.first.access_level).to eq(access_type_id) end end @@ -127,7 +130,7 @@ feature 'Projected Branches', feature: true, js: true do click_on "Protect" expect(ProtectedBranch.count).to eq(1) - expect(ProtectedBranch.last.merge_access_level.access_level).to eq(access_type_id) + expect(ProtectedBranch.last.merge_access_levels.first.access_level).to eq(access_type_id) end it "allows updating protected branches so that #{access_type_name} can merge to them" do @@ -143,7 +146,7 @@ feature 'Projected Branches', feature: true, js: true do end wait_for_ajax - expect(ProtectedBranch.last.merge_access_level.access_level).to eq(access_type_id) + expect(ProtectedBranch.last.merge_access_levels.first.access_level).to eq(access_type_id) end end end diff --git a/spec/services/git_push_service_spec.rb b/spec/services/git_push_service_spec.rb index 80f6ebac86c..850b45f84f9 100644 --- a/spec/services/git_push_service_spec.rb +++ b/spec/services/git_push_service_spec.rb @@ -228,7 +228,7 @@ describe GitPushService, services: true do execute_service(project, user, @blankrev, 'newrev', 'refs/heads/master' ) expect(project.protected_branches).not_to be_empty expect(project.protected_branches.first.push_access_level.access_level).to eq(Gitlab::Access::MASTER) - expect(project.protected_branches.first.merge_access_level.access_level).to eq(Gitlab::Access::MASTER) + expect(project.protected_branches.first.merge_access_levels.first.access_level).to eq(Gitlab::Access::MASTER) end it "when pushing a branch for the first time with default branch protection disabled" do @@ -250,7 +250,7 @@ describe GitPushService, services: true do expect(project.protected_branches).not_to be_empty expect(project.protected_branches.last.push_access_level.access_level).to eq(Gitlab::Access::DEVELOPER) - expect(project.protected_branches.last.merge_access_level.access_level).to eq(Gitlab::Access::MASTER) + expect(project.protected_branches.last.merge_access_levels.first.access_level).to eq(Gitlab::Access::MASTER) end it "when pushing a branch for the first time with default branch protection set to 'developers can merge'" do @@ -261,7 +261,7 @@ describe GitPushService, services: true do execute_service(project, user, @blankrev, 'newrev', 'refs/heads/master' ) expect(project.protected_branches).not_to be_empty expect(project.protected_branches.first.push_access_level.access_level).to eq(Gitlab::Access::MASTER) - expect(project.protected_branches.first.merge_access_level.access_level).to eq(Gitlab::Access::DEVELOPER) + expect(project.protected_branches.first.merge_access_levels.first.access_level).to eq(Gitlab::Access::DEVELOPER) end it "when pushing new commits to existing branch" do From 4c28d62672b0f51feede94d9f207f4043c4431f1 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Tue, 16 Aug 2016 11:14:45 +0530 Subject: [PATCH 122/133] Don't select an access level if already selected. 1. This is in regard to the protected branches feature spec. 2. For example, if "Masters" is already selected, don't re-select "Masters" during the spec. 3. This is due to a bug in the frontend implementation, where selecting an already-selected access level _deselects_ it, which is something we don't need. I'll create a separate issue for this. 4. This hasn't turned up before, because we were manually creating missing access levels prior to e805a64. Now, we just use nested attributes, and missing access levels fail validation. --- spec/features/protected_branches_spec.rb | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/spec/features/protected_branches_spec.rb b/spec/features/protected_branches_spec.rb index 5beb658b2f5..709ce7f33b9 100644 --- a/spec/features/protected_branches_spec.rb +++ b/spec/features/protected_branches_spec.rb @@ -93,8 +93,12 @@ feature 'Projected Branches', feature: true, js: true do visit namespace_project_protected_branches_path(project.namespace, project) set_protected_branch_name('master') within('.new_protected_branch') do - find(".js-allowed-to-push").click - within(".dropdown.open .dropdown-menu") { click_on access_type_name } + allowed_to_push_button = find(".js-allowed-to-push") + + unless allowed_to_push_button.text == access_type_name + allowed_to_push_button.click + within(".dropdown.open .dropdown-menu") { click_on access_type_name } + end end click_on "Protect" @@ -124,8 +128,12 @@ feature 'Projected Branches', feature: true, js: true do visit namespace_project_protected_branches_path(project.namespace, project) set_protected_branch_name('master') within('.new_protected_branch') do - find(".js-allowed-to-merge").click - within(".dropdown.open .dropdown-menu") { click_on access_type_name } + allowed_to_merge_button = find(".js-allowed-to-merge") + + unless allowed_to_merge_button.text == access_type_name + allowed_to_merge_button.click + within(".dropdown.open .dropdown-menu") { click_on access_type_name } + end end click_on "Protect" From e9f483355ef07a63d664126c1200762bd1e11271 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Tue, 16 Aug 2016 12:00:19 +0530 Subject: [PATCH 123/133] Move the "update" portion of the protected branch view into a partial. 1. To improve EE compatibility. --- .../protected_branches/_protected_branch.html.haml | 13 +++---------- .../_update_protected_branch.html.haml | 10 ++++++++++ 2 files changed, 13 insertions(+), 10 deletions(-) create mode 100644 app/views/projects/protected_branches/_update_protected_branch.html.haml diff --git a/app/views/projects/protected_branches/_protected_branch.html.haml b/app/views/projects/protected_branches/_protected_branch.html.haml index eb4c67daa80..0628134b1bb 100644 --- a/app/views/projects/protected_branches/_protected_branch.html.haml +++ b/app/views/projects/protected_branches/_protected_branch.html.haml @@ -13,16 +13,9 @@ = time_ago_with_tooltip(commit.committed_date) - else (branch was removed from repository) - %td - = hidden_field_tag "allowed_to_merge_#{protected_branch.id}", protected_branch.merge_access_levels.first.access_level - = dropdown_tag( (protected_branch.merge_access_levels.first.humanize || 'Select') , - options: { toggle_class: 'js-allowed-to-merge', dropdown_class: 'dropdown-menu-selectable js-allowed-to-merge-container', - data: { field_name: "allowed_to_merge_#{protected_branch.id}", access_level_id: protected_branch.merge_access_levels.first.id }}) - %td - = hidden_field_tag "allowed_to_push_#{protected_branch.id}", protected_branch.push_access_levels.first.access_level - = dropdown_tag( (protected_branch.push_access_levels.first.humanize || 'Select') , - options: { toggle_class: 'js-allowed-to-push', dropdown_class: 'dropdown-menu-selectable js-allowed-to-push-container', - data: { field_name: "allowed_to_push_#{protected_branch.id}", access_level_id: protected_branch.push_access_levels.first.id }}) + + = render partial: 'update_protected_branch', locals: { protected_branch: protected_branch } + - if can_admin_project %td = link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_branch], data: { confirm: 'Branch will be writable for developers. Are you sure?' }, method: :delete, class: 'btn btn-warning' diff --git a/app/views/projects/protected_branches/_update_protected_branch.html.haml b/app/views/projects/protected_branches/_update_protected_branch.html.haml new file mode 100644 index 00000000000..d6044aacaec --- /dev/null +++ b/app/views/projects/protected_branches/_update_protected_branch.html.haml @@ -0,0 +1,10 @@ +%td + = hidden_field_tag "allowed_to_merge_#{protected_branch.id}", protected_branch.merge_access_levels.first.access_level + = dropdown_tag( (protected_branch.merge_access_levels.first.humanize || 'Select') , + options: { toggle_class: 'js-allowed-to-merge', dropdown_class: 'dropdown-menu-selectable js-allowed-to-merge-container', + data: { field_name: "allowed_to_merge_#{protected_branch.id}", access_level_id: protected_branch.merge_access_levels.first.id }}) +%td + = hidden_field_tag "allowed_to_push_#{protected_branch.id}", protected_branch.push_access_levels.first.access_level + = dropdown_tag( (protected_branch.push_access_levels.first.humanize || 'Select') , + options: { toggle_class: 'js-allowed-to-push', dropdown_class: 'dropdown-menu-selectable js-allowed-to-push-container', + data: { field_name: "allowed_to_push_#{protected_branch.id}", access_level_id: protected_branch.push_access_levels.first.id }}) From 4ddbbcd11a6f03ae36efd4b9016974c34a1465ed Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Tue, 16 Aug 2016 12:00:56 +0530 Subject: [PATCH 124/133] Improve EE compatibility with protected branch access levels. 1. Change a few incorrect `access_level` to `access_levels.first` that were missed in e805a64. 2. `API::Entities` can iterate over all access levels instead of just the first one. This makes no difference to CE, and makes it more compatible with EE. --- lib/api/entities.rb | 6 ++++-- lib/gitlab/user_access.rb | 4 ++-- spec/services/git_push_service_spec.rb | 6 +++--- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 7bce427adf6..ec455e67329 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -129,12 +129,14 @@ module API expose :developers_can_push do |repo_branch, options| project = options[:project] - project.protected_branches.matching(repo_branch.name).any? { |protected_branch| protected_branch.push_access_levels.first.access_level == Gitlab::Access::DEVELOPER } + access_levels = project.protected_branches.matching(repo_branch.name).map(&:push_access_levels).flatten + access_levels.any? { |access_level| access_level.access_level == Gitlab::Access::DEVELOPER } end expose :developers_can_merge do |repo_branch, options| project = options[:project] - project.protected_branches.matching(repo_branch.name).any? { |protected_branch| protected_branch.merge_access_levels.first.access_level == Gitlab::Access::DEVELOPER } + access_levels = project.protected_branches.matching(repo_branch.name).map(&:merge_access_levels).flatten + access_levels.any? { |access_level| access_level.access_level == Gitlab::Access::DEVELOPER } end end diff --git a/lib/gitlab/user_access.rb b/lib/gitlab/user_access.rb index c55a7fc4d3d..9858d2e7d83 100644 --- a/lib/gitlab/user_access.rb +++ b/lib/gitlab/user_access.rb @@ -32,7 +32,7 @@ module Gitlab if project.protected_branch?(ref) return true if project.empty_repo? && project.user_can_push_to_empty_repo?(user) - access_levels = project.protected_branches.matching(ref).map(&:push_access_level) + access_levels = project.protected_branches.matching(ref).map(&:push_access_levels).flatten access_levels.any? { |access_level| access_level.check_access(user) } else user.can?(:push_code, project) @@ -43,7 +43,7 @@ module Gitlab return false unless user if project.protected_branch?(ref) - access_levels = project.protected_branches.matching(ref).map(&:merge_access_level) + access_levels = project.protected_branches.matching(ref).map(&:merge_access_levels).flatten access_levels.any? { |access_level| access_level.check_access(user) } else user.can?(:push_code, project) diff --git a/spec/services/git_push_service_spec.rb b/spec/services/git_push_service_spec.rb index 850b45f84f9..7585623b5ef 100644 --- a/spec/services/git_push_service_spec.rb +++ b/spec/services/git_push_service_spec.rb @@ -227,7 +227,7 @@ describe GitPushService, services: true do expect(project.default_branch).to eq("master") execute_service(project, user, @blankrev, 'newrev', 'refs/heads/master' ) expect(project.protected_branches).not_to be_empty - expect(project.protected_branches.first.push_access_level.access_level).to eq(Gitlab::Access::MASTER) + expect(project.protected_branches.first.push_access_levels.first.access_level).to eq(Gitlab::Access::MASTER) expect(project.protected_branches.first.merge_access_levels.first.access_level).to eq(Gitlab::Access::MASTER) end @@ -249,7 +249,7 @@ describe GitPushService, services: true do execute_service(project, user, @blankrev, 'newrev', 'refs/heads/master' ) expect(project.protected_branches).not_to be_empty - expect(project.protected_branches.last.push_access_level.access_level).to eq(Gitlab::Access::DEVELOPER) + expect(project.protected_branches.last.push_access_levels.first.access_level).to eq(Gitlab::Access::DEVELOPER) expect(project.protected_branches.last.merge_access_levels.first.access_level).to eq(Gitlab::Access::MASTER) end @@ -260,7 +260,7 @@ describe GitPushService, services: true do expect(project.default_branch).to eq("master") execute_service(project, user, @blankrev, 'newrev', 'refs/heads/master' ) expect(project.protected_branches).not_to be_empty - expect(project.protected_branches.first.push_access_level.access_level).to eq(Gitlab::Access::MASTER) + expect(project.protected_branches.first.push_access_levels.first.access_level).to eq(Gitlab::Access::MASTER) expect(project.protected_branches.first.merge_access_levels.first.access_level).to eq(Gitlab::Access::DEVELOPER) end From 37651d2f4ce12c16945a5b67360c67768cddb465 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Tue, 16 Aug 2016 12:45:48 +0530 Subject: [PATCH 125/133] Fix the protected branches factory. 1. Previously, we were using `after_create` to create access levels. 2. At the time of protected branch creation, there are _no_ access levels present, which is invalid, and creation fails. 3. Fixed by setting access levels before the protected branch is created. --- spec/factories/protected_branches.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/factories/protected_branches.rb b/spec/factories/protected_branches.rb index 3b21174987f..42853cac112 100644 --- a/spec/factories/protected_branches.rb +++ b/spec/factories/protected_branches.rb @@ -3,9 +3,9 @@ FactoryGirl.define do name project - after(:create) do |protected_branch| - protected_branch.push_access_levels.create!(access_level: Gitlab::Access::MASTER) - protected_branch.merge_access_levels.create!(access_level: Gitlab::Access::MASTER) + before(:create) do |protected_branch| + protected_branch.push_access_levels.new(access_level: Gitlab::Access::MASTER) + protected_branch.merge_access_levels.new(access_level: Gitlab::Access::MASTER) end trait :developers_can_push do From 5b52da9c32842849f0b6430a6a820fc7456b4841 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Tue, 16 Aug 2016 10:00:13 +0200 Subject: [PATCH 126/133] Revert unrelevant changes --- app/models/ci/pipeline.rb | 5 +++-- spec/helpers/notes_helper_spec.rb | 3 +-- spec/models/build_spec.rb | 34 ++++++++++++++----------------- 3 files changed, 19 insertions(+), 23 deletions(-) diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 08b104ccfc8..130afeb724e 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -250,8 +250,9 @@ module Ci end def execute_hooks - project.execute_hooks(pipeline_data, :pipeline_hooks) - project.execute_services(pipeline_data, :pipeline_hooks) + data = pipeline_data + project.execute_hooks(data, :pipeline_hooks) + project.execute_services(data, :pipeline_hooks) end private diff --git a/spec/helpers/notes_helper_spec.rb b/spec/helpers/notes_helper_spec.rb index 853b8b2f7f7..9c577501f00 100644 --- a/spec/helpers/notes_helper_spec.rb +++ b/spec/helpers/notes_helper_spec.rb @@ -39,8 +39,7 @@ describe NotesHelper do describe '#preload_max_access_for_authors' do before do - # #preload_max_access_for_authors would read cache from RequestStore, - # so we should make sure it's clean. + # This method reads cache from RequestStore, so make sure it's clean. RequestStore.clear! end diff --git a/spec/models/build_spec.rb b/spec/models/build_spec.rb index 79f872b2624..ee2c3d04984 100644 --- a/spec/models/build_spec.rb +++ b/spec/models/build_spec.rb @@ -275,8 +275,7 @@ describe Ci::Build, models: true do context 'when yaml_variables are undefined' do before do - build.update(yaml_variables: nil) - build.reload # reload pipeline so that it resets config_processor + build.yaml_variables = nil end context 'use from gitlab-ci.yml' do @@ -424,24 +423,22 @@ describe Ci::Build, models: true do describe '#stuck?' do subject { build.stuck? } - %w[pending].each do |state| - context "when commit_status.status is #{state}" do + context "when commit_status.status is pending" do + before do + build.status = 'pending' + end + + it { is_expected.to be_truthy } + + context "and there are specific runner" do + let(:runner) { create(:ci_runner, contacted_at: 1.second.ago) } + before do - build.status = state + build.project.runners << runner + runner.save end - it { is_expected.to be_truthy } - - context "and there are specific runner" do - let(:runner) { create(:ci_runner, contacted_at: 1.second.ago) } - - before do - build.project.runners << runner - runner.save - end - - it { is_expected.to be_falsey } - end + it { is_expected.to be_falsey } end end @@ -904,8 +901,7 @@ describe Ci::Build, models: true do context 'when `when` is undefined' do before do - build.update(when: nil) - build.reload # reload pipeline so that it resets config_processor + build.when = nil end context 'use from gitlab-ci.yml' do From dd3b738d5b3eb70217d7ac7f9fe441498d2e8e7e Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Tue, 16 Aug 2016 13:34:56 +0530 Subject: [PATCH 127/133] Fix failing tests relating to backporting ee!581. 1. `GitPushService` was still using `{merge,push}_access_level_attributes` instead of `{merge,push}_access_levels_attributes`. 2. The branches API creates access levels regardless of the state of the `developers_can_{push,merge}` parameters. This is in line with the UI, where Master access is the default for a new protected branch. 3. Use `after(:build)` to create access levels in the `protected_branches` factory, so that `factories_spec` passes. It only builds records, so we need to create access levels on `build` as well. --- app/services/git_push_service.rb | 8 ++++---- lib/api/branches.rb | 29 ++++++++++++++++------------ spec/factories/protected_branches.rb | 2 +- spec/models/project_spec.rb | 6 +++--- 4 files changed, 25 insertions(+), 20 deletions(-) diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb index 6f521462cf3..d5fb2018d24 100644 --- a/app/services/git_push_service.rb +++ b/app/services/git_push_service.rb @@ -91,12 +91,12 @@ class GitPushService < BaseService params = { name: @project.default_branch, - push_access_level_attributes: { + push_access_levels_attributes: [{ access_level: current_application_settings.default_branch_protection == PROTECTION_DEV_CAN_PUSH ? Gitlab::Access::DEVELOPER : Gitlab::Access::MASTER - }, - merge_access_level_attributes: { + }], + merge_access_levels_attributes: [{ access_level: current_application_settings.default_branch_protection == PROTECTION_DEV_CAN_MERGE ? Gitlab::Access::DEVELOPER : Gitlab::Access::MASTER - } + }] } ProtectedBranches::CreateService.new(@project, current_user, params).execute diff --git a/lib/api/branches.rb b/lib/api/branches.rb index a77afe634f6..b615703df93 100644 --- a/lib/api/branches.rb +++ b/lib/api/branches.rb @@ -61,22 +61,27 @@ module API name: @branch.name } - unless developers_can_merge.nil? - protected_branch_params.merge!({ - merge_access_level_attributes: { - access_level: developers_can_merge ? Gitlab::Access::DEVELOPER : Gitlab::Access::MASTER - } - }) + # If `developers_can_merge` is switched off, _all_ `DEVELOPER` + # merge_access_levels need to be deleted. + if developers_can_merge == false + protected_branch.merge_access_levels.where(access_level: Gitlab::Access::DEVELOPER).destroy_all end - unless developers_can_push.nil? - protected_branch_params.merge!({ - push_access_level_attributes: { - access_level: developers_can_push ? Gitlab::Access::DEVELOPER : Gitlab::Access::MASTER - } - }) + # If `developers_can_push` is switched off, _all_ `DEVELOPER` + # push_access_levels need to be deleted. + if developers_can_push == false + protected_branch.push_access_levels.where(access_level: Gitlab::Access::DEVELOPER).destroy_all end + protected_branch_params.merge!( + merge_access_levels_attributes: [{ + access_level: developers_can_merge ? Gitlab::Access::DEVELOPER : Gitlab::Access::MASTER + }], + push_access_levels_attributes: [{ + access_level: developers_can_push ? Gitlab::Access::DEVELOPER : Gitlab::Access::MASTER + }] + ) + if protected_branch service = ProtectedBranches::UpdateService.new(user_project, current_user, protected_branch_params) service.execute(protected_branch) diff --git a/spec/factories/protected_branches.rb b/spec/factories/protected_branches.rb index 42853cac112..b2695e0482a 100644 --- a/spec/factories/protected_branches.rb +++ b/spec/factories/protected_branches.rb @@ -3,7 +3,7 @@ FactoryGirl.define do name project - before(:create) do |protected_branch| + after(:build) do |protected_branch| protected_branch.push_access_levels.new(access_level: Gitlab::Access::MASTER) protected_branch.merge_access_levels.new(access_level: Gitlab::Access::MASTER) end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 9c3b4712cab..0a32a486703 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -1089,13 +1089,13 @@ describe Project, models: true do let(:project) { create(:project) } it 'returns true when the branch matches a protected branch via direct match' do - project.protected_branches.create!(name: 'foo') + create(:protected_branch, project: project, name: "foo") expect(project.protected_branch?('foo')).to eq(true) end it 'returns true when the branch matches a protected branch via wildcard match' do - project.protected_branches.create!(name: 'production/*') + create(:protected_branch, project: project, name: "production/*") expect(project.protected_branch?('production/some-branch')).to eq(true) end @@ -1105,7 +1105,7 @@ describe Project, models: true do end it 'returns false when the branch does not match a protected branch via wildcard match' do - project.protected_branches.create!(name: 'production/*') + create(:protected_branch, project: project, name: "production/*") expect(project.protected_branch?('staging/some-branch')).to eq(false) end From 0e2cecfd628f00e19c33d84f76e2859ebf8a073e Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Tue, 16 Aug 2016 14:10:28 +0530 Subject: [PATCH 128/133] Fix API::BranchesSpec. - Previously created a protected branch manually, without using a factory. --- spec/requests/api/branches_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/requests/api/branches_spec.rb b/spec/requests/api/branches_spec.rb index 9444138f93d..3fd989dd7a6 100644 --- a/spec/requests/api/branches_spec.rb +++ b/spec/requests/api/branches_spec.rb @@ -243,7 +243,7 @@ describe API::API, api: true do end it "removes protected branch" do - project.protected_branches.create(name: branch_name) + create(:protected_branch, project: project, name: branch_name) delete api("/projects/#{project.id}/repository/branches/#{branch_name}", user) expect(response).to have_http_status(405) expect(json_response['message']).to eq('Protected branch cant be removed') From 2193ae222b3337f03c18dd7d27408a1b138c2f92 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Tue, 16 Aug 2016 15:16:08 +0530 Subject: [PATCH 129/133] Backport `AutocompleteController#load_project` from EE!581. - This is an optimization that was made in !581, and it needs to be backported to CE to avoid merge conflicts in the future. --- app/controllers/autocomplete_controller.rb | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb index d828d163c28..441030fb545 100644 --- a/app/controllers/autocomplete_controller.rb +++ b/app/controllers/autocomplete_controller.rb @@ -55,11 +55,8 @@ class AutocompleteController < ApplicationController def find_users @users = - if params[:project_id].present? - project = Project.find(params[:project_id]) - return render_404 unless can?(current_user, :read_project, project) - - project.team.users + if @project + @project.team.users elsif params[:group_id].present? group = Group.find(params[:group_id]) return render_404 unless can?(current_user, :read_group, group) @@ -71,4 +68,14 @@ class AutocompleteController < ApplicationController User.none end end + + def load_project + @project ||= begin + if params[:project_id].present? + project = Project.find(params[:project_id]) + return render_404 unless can?(current_user, :read_project, project) + project + end + end + end end From f898a47e146391d869eeade989143513fb5c4ed0 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Tue, 16 Aug 2016 15:24:55 +0530 Subject: [PATCH 130/133] Fix a missed `before_action` for `AutocompleteController`. - `#find_users` depends on a project being loaded. - Missed adding this in 2193ae222b3337f03c18dd7d27408a1b138c2f92 --- app/controllers/autocomplete_controller.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb index 441030fb545..e1641ba6265 100644 --- a/app/controllers/autocomplete_controller.rb +++ b/app/controllers/autocomplete_controller.rb @@ -1,5 +1,6 @@ class AutocompleteController < ApplicationController skip_before_action :authenticate_user!, only: [:users] + before_action :load_project, only: [:users] before_action :find_users, only: [:users] def users From b44b09b302e6f4d19c7e6b2dac3be62fe83b31b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20D=C3=A1vila=20Santos?= Date: Tue, 16 Aug 2016 16:14:54 +0000 Subject: [PATCH 131/133] Revert "Merge branch '19957-write-tests-for-adding-comments-for-different-line-types-in-diff' into 'master'" This reverts merge request !5417 --- features/project/merge_requests.feature | 10 ++ .../merge_requests/diff_notes_spec.rb | 159 ------------------ 2 files changed, 10 insertions(+), 159 deletions(-) delete mode 100644 spec/features/merge_requests/diff_notes_spec.rb diff --git a/features/project/merge_requests.feature b/features/project/merge_requests.feature index 1b8e4262e40..6bac6011467 100644 --- a/features/project/merge_requests.feature +++ b/features/project/merge_requests.feature @@ -106,6 +106,16 @@ Feature: Project Merge Requests And I sort the list by "Least popular" Then The list should be sorted by "Least popular" + @javascript + Scenario: I comment on a merge request diff + Given project "Shop" have "Bug NS-05" open merge request with diffs inside + And I visit merge request page "Bug NS-05" + And I click on the Changes tab + And I leave a comment like "Line is wrong" on diff + And I switch to the merge request's comments tab + Then I should see a discussion has started on diff + And I should see a badge of "1" next to the discussion link + @javascript Scenario: I see a new comment on merge request diff from another user in the discussion tab Given project "Shop" have "Bug NS-05" open merge request with diffs inside diff --git a/spec/features/merge_requests/diff_notes_spec.rb b/spec/features/merge_requests/diff_notes_spec.rb deleted file mode 100644 index 12e89742b79..00000000000 --- a/spec/features/merge_requests/diff_notes_spec.rb +++ /dev/null @@ -1,159 +0,0 @@ -require 'spec_helper' - -feature 'Diff notes', js: true, feature: true do - include WaitForAjax - - before do - login_as :admin - @merge_request = create(:merge_request) - @project = @merge_request.source_project - end - - context 'merge request diffs' do - let(:comment_button_class) { '.add-diff-note' } - let(:notes_holder_input_class) { 'js-temp-notes-holder' } - let(:notes_holder_input_xpath) { './following-sibling::*[contains(concat(" ", @class, " "), " notes_holder ")]' } - let(:test_note_comment) { 'this is a test note!' } - - context 'when hovering over the parallel view diff file' do - before(:each) do - visit diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request) - click_link 'Side-by-side' - end - - context 'with an old line on the left and no line on the right' do - it 'should allow commenting on the left side' do - should_allow_commenting(find('[id="6eb14e00385d2fb284765eb1cd8d420d33d63fc9_23_22"]').find(:xpath, '..'), 'left') - end - - it 'should not allow commenting on the right side' do - should_not_allow_commenting(find('[id="6eb14e00385d2fb284765eb1cd8d420d33d63fc9_23_22"]').find(:xpath, '..'), 'right') - end - end - - context 'with no line on the left and a new line on the right' do - it 'should not allow commenting on the left side' do - should_not_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_15_15"]').find(:xpath, '..'), 'left') - end - - it 'should allow commenting on the right side' do - should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_15_15"]').find(:xpath, '..'), 'right') - end - end - - context 'with an old line on the left and a new line on the right' do - it 'should allow commenting on the left side' do - should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_9_9"]').find(:xpath, '..'), 'left') - end - - it 'should allow commenting on the right side' do - should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_9_9"]').find(:xpath, '..'), 'right') - end - end - - context 'with an unchanged line on the left and an unchanged line on the right' do - it 'should allow commenting on the left side' do - should_allow_commenting(first('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_7_7"]').find(:xpath, '..'), 'left') - end - - it 'should allow commenting on the right side' do - should_allow_commenting(first('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_7_7"]').find(:xpath, '..'), 'right') - end - end - - context 'with a match line' do - it 'should not allow commenting on the left side' do - should_not_allow_commenting(first('.match').find(:xpath, '..'), 'left') - end - - it 'should not allow commenting on the right side' do - should_not_allow_commenting(first('.match').find(:xpath, '..'), 'right') - end - end - end - - context 'when hovering over the inline view diff file' do - before do - visit diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request) - click_link 'Inline' - end - - context 'with a new line' do - it 'should allow commenting' do - should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]')) - end - end - - context 'with an old line' do - it 'should allow commenting' do - should_allow_commenting(find('[id="6eb14e00385d2fb284765eb1cd8d420d33d63fc9_22_22"]')) - end - end - - context 'with an unchanged line' do - it 'should allow commenting' do - should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_7_7"]')) - end - end - - context 'with a match line' do - it 'should not allow commenting' do - should_not_allow_commenting(first('.match')) - end - end - end - - def should_allow_commenting(line_holder, diff_side = nil) - line = get_line_components(line_holder, diff_side) - line[:content].hover - expect(line[:num]).to have_css comment_button_class - - comment_on_line(line_holder, line) - wait_for_ajax - - assert_comment_persistence(line_holder) - end - - def should_not_allow_commenting(line_holder, diff_side = nil) - line = get_line_components(line_holder, diff_side) - line[:content].hover - expect(line[:num]).not_to have_css comment_button_class - end - - def get_line_components(line_holder, diff_side = nil) - if diff_side.nil? - get_inline_line_components(line_holder) - else - get_parallel_line_components(line_holder, diff_side) - end - end - - def get_inline_line_components(line_holder) - { content: line_holder.first('.line_content'), num: line_holder.first('.diff-line-num') } - end - - def get_parallel_line_components(line_holder, diff_side = nil) - side_index = diff_side == 'left' ? 0 : 1 - { content: line_holder.all('.line_content')[side_index], num: line_holder.all('.diff-line-num')[side_index] } - end - - def comment_on_line(line_holder, line) - line[:num].find(comment_button_class).trigger 'click' - expect(line_holder).to have_xpath notes_holder_input_xpath - - notes_holder_input = line_holder.find(:xpath, notes_holder_input_xpath) - expect(notes_holder_input[:class]).to include(notes_holder_input_class) - - notes_holder_input.fill_in 'note[note]', with: test_note_comment - click_button 'Comment' - end - - def assert_comment_persistence(line_holder) - expect(line_holder).to have_xpath notes_holder_input_xpath - - notes_holder_saved = line_holder.find(:xpath, notes_holder_input_xpath) - expect(notes_holder_saved[:class]).not_to include(notes_holder_input_class) - expect(notes_holder_saved).to have_content test_note_comment - end - end -end From 8c101fc313208b2256f9b9a2d596a0b398f173e0 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Tue, 16 Aug 2016 22:19:49 +0530 Subject: [PATCH 132/133] Backport EE assertions in protected branch related specs. - Use assertions in the vein of `merge_access_levels.map(&:access_level)` instead of `merge_access_levels.first.access_level` --- spec/features/protected_branches_spec.rb | 8 ++++---- spec/services/git_push_service_spec.rb | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/spec/features/protected_branches_spec.rb b/spec/features/protected_branches_spec.rb index 709ce7f33b9..a0ee6cab7ec 100644 --- a/spec/features/protected_branches_spec.rb +++ b/spec/features/protected_branches_spec.rb @@ -103,7 +103,7 @@ feature 'Projected Branches', feature: true, js: true do click_on "Protect" expect(ProtectedBranch.count).to eq(1) - expect(ProtectedBranch.last.push_access_levels.first.access_level).to eq(access_type_id) + expect(ProtectedBranch.last.push_access_levels.map(&:access_level)).to eq([access_type_id]) end it "allows updating protected branches so that #{access_type_name} can push to them" do @@ -119,7 +119,7 @@ feature 'Projected Branches', feature: true, js: true do end wait_for_ajax - expect(ProtectedBranch.last.push_access_levels.first.access_level).to eq(access_type_id) + expect(ProtectedBranch.last.push_access_levels.map(&:access_level)).to include(access_type_id) end end @@ -138,7 +138,7 @@ feature 'Projected Branches', feature: true, js: true do click_on "Protect" expect(ProtectedBranch.count).to eq(1) - expect(ProtectedBranch.last.merge_access_levels.first.access_level).to eq(access_type_id) + expect(ProtectedBranch.last.merge_access_levels.map(&:access_level)).to eq([access_type_id]) end it "allows updating protected branches so that #{access_type_name} can merge to them" do @@ -154,7 +154,7 @@ feature 'Projected Branches', feature: true, js: true do end wait_for_ajax - expect(ProtectedBranch.last.merge_access_levels.first.access_level).to eq(access_type_id) + expect(ProtectedBranch.last.merge_access_levels.map(&:access_level)).to include(access_type_id) end end end diff --git a/spec/services/git_push_service_spec.rb b/spec/services/git_push_service_spec.rb index 7585623b5ef..6ac1fa8f182 100644 --- a/spec/services/git_push_service_spec.rb +++ b/spec/services/git_push_service_spec.rb @@ -227,8 +227,8 @@ describe GitPushService, services: true do expect(project.default_branch).to eq("master") execute_service(project, user, @blankrev, 'newrev', 'refs/heads/master' ) expect(project.protected_branches).not_to be_empty - expect(project.protected_branches.first.push_access_levels.first.access_level).to eq(Gitlab::Access::MASTER) - expect(project.protected_branches.first.merge_access_levels.first.access_level).to eq(Gitlab::Access::MASTER) + expect(project.protected_branches.first.push_access_levels.map(&:access_level)).to eq([Gitlab::Access::MASTER]) + expect(project.protected_branches.first.merge_access_levels.map(&:access_level)).to eq([Gitlab::Access::MASTER]) end it "when pushing a branch for the first time with default branch protection disabled" do @@ -249,8 +249,8 @@ describe GitPushService, services: true do execute_service(project, user, @blankrev, 'newrev', 'refs/heads/master' ) expect(project.protected_branches).not_to be_empty - expect(project.protected_branches.last.push_access_levels.first.access_level).to eq(Gitlab::Access::DEVELOPER) - expect(project.protected_branches.last.merge_access_levels.first.access_level).to eq(Gitlab::Access::MASTER) + expect(project.protected_branches.last.push_access_levels.map(&:access_level)).to eq([Gitlab::Access::DEVELOPER]) + expect(project.protected_branches.last.merge_access_levels.map(&:access_level)).to eq([Gitlab::Access::MASTER]) end it "when pushing a branch for the first time with default branch protection set to 'developers can merge'" do @@ -260,8 +260,8 @@ describe GitPushService, services: true do expect(project.default_branch).to eq("master") execute_service(project, user, @blankrev, 'newrev', 'refs/heads/master' ) expect(project.protected_branches).not_to be_empty - expect(project.protected_branches.first.push_access_levels.first.access_level).to eq(Gitlab::Access::MASTER) - expect(project.protected_branches.first.merge_access_levels.first.access_level).to eq(Gitlab::Access::DEVELOPER) + expect(project.protected_branches.first.push_access_levels.map(&:access_level)).to eq([Gitlab::Access::MASTER]) + expect(project.protected_branches.first.merge_access_levels.map(&:access_level)).to eq([Gitlab::Access::DEVELOPER]) end it "when pushing new commits to existing branch" do From 28726729452ef64270534806e75a9595ea1a659d Mon Sep 17 00:00:00 2001 From: Felipe Artur Date: Fri, 24 Jun 2016 16:43:46 -0300 Subject: [PATCH 133/133] Load issues and merge requests templates from repository --- CHANGELOG | 1 + app/assets/javascripts/api.js | 51 +++++----- app/assets/javascripts/application.js | 1 + .../javascripts/blob/template_selector.js | 22 ++++- app/assets/javascripts/dispatcher.js | 2 + .../issuable_template_selector.js.es6 | 51 ++++++++++ .../issuable_template_selectors.js.es6 | 29 ++++++ .../stylesheets/framework/dropdowns.scss | 7 +- app/assets/stylesheets/pages/issuable.scss | 9 ++ .../projects/templates_controller.rb | 19 ++++ app/helpers/blob_helper.rb | 41 ++++++-- app/views/shared/issuable/_filter.html.haml | 2 +- app/views/shared/issuable/_form.html.haml | 24 ++++- config/routes.rb | 5 + doc/workflow/README.md | 1 + doc/workflow/description_templates.md | 12 +++ doc/workflow/img/description_templates.png | Bin 0 -> 57670 bytes lib/api/templates.rb | 26 ++--- lib/gitlab/template/base_template.rb | 71 +++++++++----- .../template/finders/base_template_finder.rb | 35 +++++++ .../finders/global_template_finder.rb | 38 ++++++++ .../template/finders/repo_template_finder.rb | 59 ++++++++++++ .../{gitignore.rb => gitignore_template.rb} | 6 +- ...ab_ci_yml.rb => gitlab_ci_yml_template.rb} | 6 +- lib/gitlab/template/issue_template.rb | 19 ++++ lib/gitlab/template/merge_request_template.rb | 19 ++++ .../projects/templates_controller_spec.rb | 48 ++++++++++ .../projects/issuable_templates_spec.rb | 89 ++++++++++++++++++ ...ore_spec.rb => gitignore_template_spec.rb} | 4 +- .../template/gitlab_ci_yml_template_spec.rb | 41 ++++++++ .../gitlab/template/issue_template_spec.rb | 89 ++++++++++++++++++ .../template/merge_request_template_spec.rb | 89 ++++++++++++++++++ spec/requests/api/templates_spec.rb | 65 +++++++------ 33 files changed, 875 insertions(+), 106 deletions(-) create mode 100644 app/assets/javascripts/templates/issuable_template_selector.js.es6 create mode 100644 app/assets/javascripts/templates/issuable_template_selectors.js.es6 create mode 100644 app/controllers/projects/templates_controller.rb create mode 100644 doc/workflow/description_templates.md create mode 100644 doc/workflow/img/description_templates.png create mode 100644 lib/gitlab/template/finders/base_template_finder.rb create mode 100644 lib/gitlab/template/finders/global_template_finder.rb create mode 100644 lib/gitlab/template/finders/repo_template_finder.rb rename lib/gitlab/template/{gitignore.rb => gitignore_template.rb} (63%) rename lib/gitlab/template/{gitlab_ci_yml.rb => gitlab_ci_yml_template.rb} (72%) create mode 100644 lib/gitlab/template/issue_template.rb create mode 100644 lib/gitlab/template/merge_request_template.rb create mode 100644 spec/controllers/projects/templates_controller_spec.rb create mode 100644 spec/features/projects/issuable_templates_spec.rb rename spec/lib/gitlab/template/{gitignore_spec.rb => gitignore_template_spec.rb} (88%) create mode 100644 spec/lib/gitlab/template/gitlab_ci_yml_template_spec.rb create mode 100644 spec/lib/gitlab/template/issue_template_spec.rb create mode 100644 spec/lib/gitlab/template/merge_request_template_spec.rb diff --git a/CHANGELOG b/CHANGELOG index 9299639a3ab..aececed9add 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -40,6 +40,7 @@ v 8.11.0 (unreleased) - Various redundant database indexes have been removed - Update `timeago` plugin to use multiple string/locale settings - Remove unused images (ClemMakesApps) + - Get issue and merge request description templates from repositories - Limit git rev-list output count to one in forced push check - Show deployment status on merge requests with external URLs - Clean up unused routes (Josef Strzibny) diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 49c2ac0dac3..84b292e59c6 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -9,10 +9,11 @@ licensePath: "/api/:version/licenses/:key", gitignorePath: "/api/:version/gitignores/:key", gitlabCiYmlPath: "/api/:version/gitlab_ci_ymls/:key", + issuableTemplatePath: "/:namespace_path/:project_path/templates/:type/:key", + group: function(group_id, callback) { - var url; - url = Api.buildUrl(Api.groupPath); - url = url.replace(':id', group_id); + var url = Api.buildUrl(Api.groupPath) + .replace(':id', group_id); return $.ajax({ url: url, data: { @@ -24,8 +25,7 @@ }); }, groups: function(query, skip_ldap, callback) { - var url; - url = Api.buildUrl(Api.groupsPath); + var url = Api.buildUrl(Api.groupsPath); return $.ajax({ url: url, data: { @@ -39,8 +39,7 @@ }); }, namespaces: function(query, callback) { - var url; - url = Api.buildUrl(Api.namespacesPath); + var url = Api.buildUrl(Api.namespacesPath); return $.ajax({ url: url, data: { @@ -54,8 +53,7 @@ }); }, projects: function(query, order, callback) { - var url; - url = Api.buildUrl(Api.projectsPath); + var url = Api.buildUrl(Api.projectsPath); return $.ajax({ url: url, data: { @@ -70,9 +68,8 @@ }); }, newLabel: function(project_id, data, callback) { - var url; - url = Api.buildUrl(Api.labelsPath); - url = url.replace(':id', project_id); + var url = Api.buildUrl(Api.labelsPath) + .replace(':id', project_id); data.private_token = gon.api_token; return $.ajax({ url: url, @@ -86,9 +83,8 @@ }); }, groupProjects: function(group_id, query, callback) { - var url; - url = Api.buildUrl(Api.groupProjectsPath); - url = url.replace(':id', group_id); + var url = Api.buildUrl(Api.groupProjectsPath) + .replace(':id', group_id); return $.ajax({ url: url, data: { @@ -102,8 +98,8 @@ }); }, licenseText: function(key, data, callback) { - var url; - url = Api.buildUrl(Api.licensePath).replace(':key', key); + var url = Api.buildUrl(Api.licensePath) + .replace(':key', key); return $.ajax({ url: url, data: data @@ -112,19 +108,32 @@ }); }, gitignoreText: function(key, callback) { - var url; - url = Api.buildUrl(Api.gitignorePath).replace(':key', key); + var url = Api.buildUrl(Api.gitignorePath) + .replace(':key', key); return $.get(url, function(gitignore) { return callback(gitignore); }); }, gitlabCiYml: function(key, callback) { - var url; - url = Api.buildUrl(Api.gitlabCiYmlPath).replace(':key', key); + var url = Api.buildUrl(Api.gitlabCiYmlPath) + .replace(':key', key); return $.get(url, function(file) { return callback(file); }); }, + issueTemplate: function(namespacePath, projectPath, key, type, callback) { + var url = Api.buildUrl(Api.issuableTemplatePath) + .replace(':key', key) + .replace(':type', type) + .replace(':project_path', projectPath) + .replace(':namespace_path', namespacePath); + $.ajax({ + url: url, + dataType: 'json' + }).done(function(file) { + callback(null, file); + }).error(callback); + }, buildUrl: function(url) { if (gon.relative_url_root != null) { url = gon.relative_url_root + url; diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index f1aab067351..e596b98603b 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -41,6 +41,7 @@ /*= require date.format */ /*= require_directory ./behaviors */ /*= require_directory ./blob */ +/*= require_directory ./templates */ /*= require_directory ./commit */ /*= require_directory ./extensions */ /*= require_directory ./lib/utils */ diff --git a/app/assets/javascripts/blob/template_selector.js b/app/assets/javascripts/blob/template_selector.js index 2cf0a6631b8..b0a37ef0e0a 100644 --- a/app/assets/javascripts/blob/template_selector.js +++ b/app/assets/javascripts/blob/template_selector.js @@ -9,6 +9,7 @@ } this.onClick = bind(this.onClick, this); this.dropdown = opts.dropdown, this.data = opts.data, this.pattern = opts.pattern, this.wrapper = opts.wrapper, this.editor = opts.editor, this.fileEndpoint = opts.fileEndpoint, this.$input = (ref = opts.$input) != null ? ref : $('#file_name'); + this.dropdownIcon = $('.fa-chevron-down', this.dropdown); this.buildDropdown(); this.bindEvents(); this.onFilenameUpdate(); @@ -60,11 +61,26 @@ return this.requestFile(item); }; - TemplateSelector.prototype.requestFile = function(item) {}; + TemplateSelector.prototype.requestFile = function(item) { + // This `requestFile` method is an abstract method that should + // be added by all subclasses. + }; - TemplateSelector.prototype.requestFileSuccess = function(file) { + TemplateSelector.prototype.requestFileSuccess = function(file, skipFocus) { this.editor.setValue(file.content, 1); - return this.editor.focus(); + if (!skipFocus) this.editor.focus(); + }; + + TemplateSelector.prototype.startLoadingSpinner = function() { + this.dropdownIcon + .addClass('fa-spinner fa-spin') + .removeClass('fa-chevron-down'); + }; + + TemplateSelector.prototype.stopLoadingSpinner = function() { + this.dropdownIcon + .addClass('fa-chevron-down') + .removeClass('fa-spinner fa-spin'); }; return TemplateSelector; diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 3946e861976..7160fa71ce5 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -55,6 +55,7 @@ shortcut_handler = new ShortcutsNavigation(); new GLForm($('.issue-form')); new IssuableForm($('.issue-form')); + new IssuableTemplateSelectors(); break; case 'projects:merge_requests:new': case 'projects:merge_requests:edit': @@ -62,6 +63,7 @@ shortcut_handler = new ShortcutsNavigation(); new GLForm($('.merge-request-form')); new IssuableForm($('.merge-request-form')); + new IssuableTemplateSelectors(); break; case 'projects:tags:new': new ZenMode(); diff --git a/app/assets/javascripts/templates/issuable_template_selector.js.es6 b/app/assets/javascripts/templates/issuable_template_selector.js.es6 new file mode 100644 index 00000000000..c32ddf80219 --- /dev/null +++ b/app/assets/javascripts/templates/issuable_template_selector.js.es6 @@ -0,0 +1,51 @@ +/*= require ../blob/template_selector */ + +((global) => { + class IssuableTemplateSelector extends TemplateSelector { + constructor(...args) { + super(...args); + this.projectPath = this.dropdown.data('project-path'); + this.namespacePath = this.dropdown.data('namespace-path'); + this.issuableType = this.wrapper.data('issuable-type'); + this.titleInput = $(`#${this.issuableType}_title`); + + let initialQuery = { + name: this.dropdown.data('selected') + }; + + if (initialQuery.name) this.requestFile(initialQuery); + + $('.reset-template', this.dropdown.parent()).on('click', () => { + if (this.currentTemplate) this.setInputValueToTemplateContent(); + }); + } + + requestFile(query) { + this.startLoadingSpinner(); + Api.issueTemplate(this.namespacePath, this.projectPath, query.name, this.issuableType, (err, currentTemplate) => { + this.currentTemplate = currentTemplate; + if (err) return; // Error handled by global AJAX error handler + this.stopLoadingSpinner(); + this.setInputValueToTemplateContent(); + }); + return; + } + + setInputValueToTemplateContent() { + // `this.requestFileSuccess` sets the value of the description input field + // to the content of the template selected. + if (this.titleInput.val() === '') { + // If the title has not yet been set, focus the title input and + // skip focusing the description input by setting `true` as the 2nd + // argument to `requestFileSuccess`. + this.requestFileSuccess(this.currentTemplate, true); + this.titleInput.focus(); + } else { + this.requestFileSuccess(this.currentTemplate); + } + return; + } + } + + global.IssuableTemplateSelector = IssuableTemplateSelector; +})(window); diff --git a/app/assets/javascripts/templates/issuable_template_selectors.js.es6 b/app/assets/javascripts/templates/issuable_template_selectors.js.es6 new file mode 100644 index 00000000000..bd8cdde033e --- /dev/null +++ b/app/assets/javascripts/templates/issuable_template_selectors.js.es6 @@ -0,0 +1,29 @@ +((global) => { + class IssuableTemplateSelectors { + constructor(opts = {}) { + this.$dropdowns = opts.$dropdowns || $('.js-issuable-selector'); + this.editor = opts.editor || this.initEditor(); + + this.$dropdowns.each((i, dropdown) => { + let $dropdown = $(dropdown); + new IssuableTemplateSelector({ + pattern: /(\.md)/, + data: $dropdown.data('data'), + wrapper: $dropdown.closest('.js-issuable-selector-wrap'), + dropdown: $dropdown, + editor: this.editor + }); + }); + } + + initEditor() { + let editor = $('.markdown-area'); + // Proxy ace-editor's .setValue to jQuery's .val + editor.setValue = editor.val; + editor.getValue = editor.val; + return editor; + } + } + + global.IssuableTemplateSelectors = IssuableTemplateSelectors; +})(window); diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index e8eafa15899..f1635a53763 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -56,9 +56,13 @@ position: absolute; top: 50%; right: 6px; - margin-top: -4px; + margin-top: -6px; color: $dropdown-toggle-icon-color; font-size: 10px; + &.fa-spinner { + font-size: 16px; + margin-top: -8px; + } } &:hover, { @@ -406,6 +410,7 @@ font-size: 14px; a { + cursor: pointer; padding-left: 10px; } } diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 7a50bc9c832..46c4a11aa2e 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -395,3 +395,12 @@ display: inline-block; line-height: 18px; } + +.js-issuable-selector-wrap { + .js-issuable-selector { + width: 100%; + } + @media (max-width: $screen-sm-max) { + margin-bottom: $gl-padding; + } +} diff --git a/app/controllers/projects/templates_controller.rb b/app/controllers/projects/templates_controller.rb new file mode 100644 index 00000000000..694b468c8d3 --- /dev/null +++ b/app/controllers/projects/templates_controller.rb @@ -0,0 +1,19 @@ +class Projects::TemplatesController < Projects::ApplicationController + before_action :authenticate_user!, :get_template_class + + def show + template = @template_type.find(params[:key], project) + + respond_to do |format| + format.json { render json: template.to_json } + end + end + + private + + def get_template_class + template_types = { issue: Gitlab::Template::IssueTemplate, merge_request: Gitlab::Template::MergeRequestTemplate }.with_indifferent_access + @template_type = template_types[params[:template_type]] + render json: [], status: 404 unless @template_type + end +end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 48c27828219..1cb5d847626 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -182,17 +182,42 @@ module BlobHelper } end + def selected_template(issuable) + templates = issuable_templates(issuable) + params[:issuable_template] if templates.include?(params[:issuable_template]) + end + + def can_add_template?(issuable) + names = issuable_templates(issuable) + names.empty? && can?(current_user, :push_code, @project) && !@project.private? + end + + def merge_request_template_names + @merge_request_templates ||= Gitlab::Template::MergeRequestTemplate.dropdown_names(ref_project) + end + + def issue_template_names + @issue_templates ||= Gitlab::Template::IssueTemplate.dropdown_names(ref_project) + end + + def issuable_templates(issuable) + @issuable_templates ||= + if issuable.is_a?(Issue) + issue_template_names + elsif issuable.is_a?(MergeRequest) + merge_request_template_names + end + end + + def ref_project + @ref_project ||= @target_project || @project + end + def gitignore_names - @gitignore_names ||= - Gitlab::Template::Gitignore.categories.keys.map do |k| - [k, Gitlab::Template::Gitignore.by_category(k).map { |t| { name: t.name } }] - end.to_h + @gitignore_names ||= Gitlab::Template::GitignoreTemplate.dropdown_names end def gitlab_ci_ymls - @gitlab_ci_ymls ||= - Gitlab::Template::GitlabCiYml.categories.keys.map do |k| - [k, Gitlab::Template::GitlabCiYml.by_category(k).map { |t| { name: t.name } }] - end.to_h + @gitlab_ci_ymls ||= Gitlab::Template::GitlabCiYmlTemplate.dropdown_names end end diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml index 0b7fa8c7d06..c957cd84479 100644 --- a/app/views/shared/issuable/_filter.html.haml +++ b/app/views/shared/issuable/_filter.html.haml @@ -45,7 +45,7 @@ .filter-item.inline = dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true } }) .filter-item.inline.labels-filter - = render "shared/issuable/label_dropdown", classes: ['js-filter-bulk-update', 'js-multiselect'], show_create: false, show_footer: false, extra_options: false, filter_submit: false, show_footer: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true } + = render "shared/issuable/label_dropdown", classes: ['js-filter-bulk-update', 'js-multiselect'], show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true } .filter-item.inline = dropdown_tag("Subscription", options: { toggle_class: "js-subscription-event", title: "Change subscription", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]" } } ) do %ul diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index c30bdb0ae91..210b43c7e0b 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -2,7 +2,22 @@ .form-group = f.label :title, class: 'control-label' - .col-sm-10 + + - issuable_template_names = issuable_templates(issuable) + + - if issuable_template_names.any? + .col-sm-3.col-lg-2 + .js-issuable-selector-wrap{ data: { issuable_type: issuable.class.to_s.underscore.downcase } } + - title = selected_template(issuable) || "Choose a template" + + = dropdown_tag(title, options: { toggle_class: 'js-issuable-selector', + title: title, filter: true, placeholder: 'Filter', footer_content: true, + data: { data: issuable_template_names, field_name: 'issuable_template', selected: selected_template(issuable), project_path: @project.path, namespace_path: @project.namespace.path } } ) do + %ul.dropdown-footer-list + %li + %a.reset-template + Reset template + %div{ class: issuable_template_names.any? ? 'col-sm-7 col-lg-8' : 'col-sm-10' } = f.text_field :title, maxlength: 255, autofocus: true, autocomplete: 'off', class: 'form-control pad', required: true @@ -23,6 +38,13 @@ to prevent a %strong Work In Progress merge request from being merged before it's ready. + + - if can_add_template?(issuable) + %p.help-block + Add + = link_to "issuable templates", help_page_path('workflow/description_templates') + to help your contributors communicate effectively! + .form-group.detail-page-description = f.label :description, 'Description', class: 'control-label' .col-sm-10 diff --git a/config/routes.rb b/config/routes.rb index 1d2db91344f..63a8827a6a2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -528,6 +528,11 @@ Rails.application.routes.draw do put '/update/*id', to: 'blob#update', constraints: { id: /.+/ }, as: 'update_blob' post '/preview/*id', to: 'blob#preview', constraints: { id: /.+/ }, as: 'preview_blob' + # + # Templates + # + get '/templates/:template_type/:key' => 'templates#show', as: :template + scope do get( '/blob/*id/diff', diff --git a/doc/workflow/README.md b/doc/workflow/README.md index 49dec613716..993349e5b46 100644 --- a/doc/workflow/README.md +++ b/doc/workflow/README.md @@ -17,6 +17,7 @@ - [Share projects with other groups](share_projects_with_other_groups.md) - [Web Editor](web_editor.md) - [Releases](releases.md) +- [Issuable Templates](issuable_templates.md) - [Milestones](milestones.md) - [Merge Requests](merge_requests.md) - [Revert changes](revert_changes.md) diff --git a/doc/workflow/description_templates.md b/doc/workflow/description_templates.md new file mode 100644 index 00000000000..9514564af02 --- /dev/null +++ b/doc/workflow/description_templates.md @@ -0,0 +1,12 @@ +# Description templates + +Description templates allow you to define context-specific templates for issue and merge request description fields for your project. When in use, users that create a new issue or merge request can select a description template to help them communicate with other contributors effectively. + +Every GitLab project can define its own set of description templates as they are added to the root directory of a GitLab project's repository. + +Description templates are written in markdown _(`.md`)_ and stored in your projects repository under the `/.gitlab/issue_templates/` and `/.gitlab/merge_request_templates/` directories. + +![Description templates](img/description_templates.png) + +_Example:_ +`/.gitlab/issue_templates/bug.md` will enable the `bug` dropdown option for new issues. When `bug` is selected, the content from the `bug.md` template file will be copied to the issue description field. diff --git a/doc/workflow/img/description_templates.png b/doc/workflow/img/description_templates.png new file mode 100644 index 0000000000000000000000000000000000000000..af2e9403826121a061c882ff509c8ba5653673b7 GIT binary patch literal 57670 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 literal 0 HcmV?d00001 diff --git a/lib/api/templates.rb b/lib/api/templates.rb index 18408797756..b9e718147e1 100644 --- a/lib/api/templates.rb +++ b/lib/api/templates.rb @@ -1,21 +1,28 @@ module API class Templates < Grape::API - TEMPLATE_TYPES = { - gitignores: Gitlab::Template::Gitignore, - gitlab_ci_ymls: Gitlab::Template::GitlabCiYml + GLOBAL_TEMPLATE_TYPES = { + gitignores: Gitlab::Template::GitignoreTemplate, + gitlab_ci_ymls: Gitlab::Template::GitlabCiYmlTemplate }.freeze - TEMPLATE_TYPES.each do |template, klass| + helpers do + def render_response(template_type, template) + not_found!(template_type.to_s.singularize) unless template + present template, with: Entities::Template + end + end + + GLOBAL_TEMPLATE_TYPES.each do |template_type, klass| # Get the list of the available template # # Example Request: # GET /gitignores # GET /gitlab_ci_ymls - get template.to_s do + get template_type.to_s do present klass.all, with: Entities::TemplatesList end - # Get the text for a specific template + # Get the text for a specific template present in local filesystem # # Parameters: # name (required) - The name of a template @@ -23,13 +30,10 @@ module API # Example Request: # GET /gitignores/Elixir # GET /gitlab_ci_ymls/Ruby - get "#{template}/:name" do + get "#{template_type}/:name" do required_attributes! [:name] - new_template = klass.find(params[:name]) - not_found!(template.to_s.singularize) unless new_template - - present new_template, with: Entities::Template + render_response(template_type, new_template) end end end diff --git a/lib/gitlab/template/base_template.rb b/lib/gitlab/template/base_template.rb index 760ff3e614a..7ebec8e2cff 100644 --- a/lib/gitlab/template/base_template.rb +++ b/lib/gitlab/template/base_template.rb @@ -1,8 +1,9 @@ module Gitlab module Template class BaseTemplate - def initialize(path) + def initialize(path, project = nil) @path = path + @finder = self.class.finder(project) end def name @@ -10,23 +11,32 @@ module Gitlab end def content - File.read(@path) + @finder.read(@path) + end + + def to_json + { name: name, content: content } end class << self - def all - self.categories.keys.flat_map { |cat| by_category(cat) } + def all(project = nil) + if categories.any? + categories.keys.flat_map { |cat| by_category(cat, project) } + else + by_category("", project) + end end - def find(key) - file_name = "#{key}#{self.extension}" - - directory = select_directory(file_name) - directory ? new(File.join(category_directory(directory), file_name)) : nil + def find(key, project = nil) + path = self.finder(project).find(key) + path.present? ? new(path, project) : nil end + # Set categories as sub directories + # Example: { "category_name_1" => "directory_path_1", "category_name_2" => "directory_name_2" } + # Default is no category with all files in base dir of each class def categories - raise NotImplementedError + {} end def extension @@ -37,30 +47,41 @@ module Gitlab raise NotImplementedError end - def by_category(category) - templates_for_directory(category_directory(category)) + # Defines which strategy will be used to get templates files + # RepoTemplateFinder - Finds templates on project repository, templates are filtered perproject + # GlobalTemplateFinder - Finds templates on gitlab installation source, templates can be used in all projects + def finder(project = nil) + raise NotImplementedError + end + + def by_category(category, project = nil) + directory = category_directory(category) + files = finder(project).list_files_for(directory) + + files.map { |f| new(f, project) } end def category_directory(category) + return base_dir unless category.present? + File.join(base_dir, categories[category]) end - private + # If template is organized by category it returns { category_name: [{ name: template_name }, { name: template2_name }] } + # If no category is present returns [{ name: template_name }, { name: template2_name}] + def dropdown_names(project = nil) + return [] if project && !project.repository.exists? - def select_directory(file_name) - categories.keys.find do |category| - File.exist?(File.join(category_directory(category), file_name)) + if categories.any? + categories.keys.map do |category| + files = self.by_category(category, project) + [category, files.map { |t| { name: t.name } }] + end.to_h + else + files = self.all(project) + files.map { |t| { name: t.name } } end end - - def templates_for_directory(dir) - dir << '/' unless dir.end_with?('/') - Dir.glob(File.join(dir, "*#{self.extension}")).select { |f| f =~ filter_regex }.map { |f| new(f) } - end - - def filter_regex - @filter_reges ||= /#{Regexp.escape(extension)}\z/ - end end end end diff --git a/lib/gitlab/template/finders/base_template_finder.rb b/lib/gitlab/template/finders/base_template_finder.rb new file mode 100644 index 00000000000..473b05257c6 --- /dev/null +++ b/lib/gitlab/template/finders/base_template_finder.rb @@ -0,0 +1,35 @@ +module Gitlab + module Template + module Finders + class BaseTemplateFinder + def initialize(base_dir) + @base_dir = base_dir + end + + def list_files_for + raise NotImplementedError + end + + def read + raise NotImplementedError + end + + def find + raise NotImplementedError + end + + def category_directory(category) + return @base_dir unless category.present? + + @base_dir + @categories[category] + end + + class << self + def filter_regex(extension) + /#{Regexp.escape(extension)}\z/ + end + end + end + end + end +end diff --git a/lib/gitlab/template/finders/global_template_finder.rb b/lib/gitlab/template/finders/global_template_finder.rb new file mode 100644 index 00000000000..831da45191f --- /dev/null +++ b/lib/gitlab/template/finders/global_template_finder.rb @@ -0,0 +1,38 @@ +# Searches and reads file present on Gitlab installation directory +module Gitlab + module Template + module Finders + class GlobalTemplateFinder < BaseTemplateFinder + def initialize(base_dir, extension, categories = {}) + @categories = categories + @extension = extension + super(base_dir) + end + + def read(path) + File.read(path) + end + + def find(key) + file_name = "#{key}#{@extension}" + + directory = select_directory(file_name) + directory ? File.join(category_directory(directory), file_name) : nil + end + + def list_files_for(dir) + dir << '/' unless dir.end_with?('/') + Dir.glob(File.join(dir, "*#{@extension}")).select { |f| f =~ self.class.filter_regex(@extension) } + end + + private + + def select_directory(file_name) + @categories.keys.find do |category| + File.exist?(File.join(category_directory(category), file_name)) + end + end + end + end + end +end diff --git a/lib/gitlab/template/finders/repo_template_finder.rb b/lib/gitlab/template/finders/repo_template_finder.rb new file mode 100644 index 00000000000..22c39436cb2 --- /dev/null +++ b/lib/gitlab/template/finders/repo_template_finder.rb @@ -0,0 +1,59 @@ +# Searches and reads files present on each Gitlab project repository +module Gitlab + module Template + module Finders + class RepoTemplateFinder < BaseTemplateFinder + # Raised when file is not found + class FileNotFoundError < StandardError; end + + def initialize(project, base_dir, extension, categories = {}) + @categories = categories + @extension = extension + @repository = project.repository + @commit = @repository.head_commit if @repository.exists? + + super(base_dir) + end + + def read(path) + blob = @repository.blob_at(@commit.id, path) if @commit + raise FileNotFoundError if blob.nil? + blob.data + end + + def find(key) + file_name = "#{key}#{@extension}" + directory = select_directory(file_name) + raise FileNotFoundError if directory.nil? + + category_directory(directory) + file_name + end + + def list_files_for(dir) + return [] unless @commit + + dir << '/' unless dir.end_with?('/') + + entries = @repository.tree(:head, dir).entries + + names = entries.map(&:name) + names.select { |f| f =~ self.class.filter_regex(@extension) } + end + + private + + def select_directory(file_name) + return [] unless @commit + + # Insert root as directory + directories = ["", @categories.keys] + + directories.find do |category| + path = category_directory(category) + file_name + @repository.blob_at(@commit.id, path) + end + end + end + end + end +end diff --git a/lib/gitlab/template/gitignore.rb b/lib/gitlab/template/gitignore_template.rb similarity index 63% rename from lib/gitlab/template/gitignore.rb rename to lib/gitlab/template/gitignore_template.rb index 964fbfd4de3..8d2a9d2305c 100644 --- a/lib/gitlab/template/gitignore.rb +++ b/lib/gitlab/template/gitignore_template.rb @@ -1,6 +1,6 @@ module Gitlab module Template - class Gitignore < BaseTemplate + class GitignoreTemplate < BaseTemplate class << self def extension '.gitignore' @@ -16,6 +16,10 @@ module Gitlab def base_dir Rails.root.join('vendor/gitignore') end + + def finder(project = nil) + Gitlab::Template::Finders::GlobalTemplateFinder.new(self.base_dir, self.extension, self.categories) + end end end end diff --git a/lib/gitlab/template/gitlab_ci_yml.rb b/lib/gitlab/template/gitlab_ci_yml_template.rb similarity index 72% rename from lib/gitlab/template/gitlab_ci_yml.rb rename to lib/gitlab/template/gitlab_ci_yml_template.rb index 7f480fe33c0..8d1a1ed54c9 100644 --- a/lib/gitlab/template/gitlab_ci_yml.rb +++ b/lib/gitlab/template/gitlab_ci_yml_template.rb @@ -1,6 +1,6 @@ module Gitlab module Template - class GitlabCiYml < BaseTemplate + class GitlabCiYmlTemplate < BaseTemplate def content explanation = "# This file is a template, and might need editing before it works on your project." [explanation, super].join("\n") @@ -21,6 +21,10 @@ module Gitlab def base_dir Rails.root.join('vendor/gitlab-ci-yml') end + + def finder(project = nil) + Gitlab::Template::Finders::GlobalTemplateFinder.new(self.base_dir, self.extension, self.categories) + end end end end diff --git a/lib/gitlab/template/issue_template.rb b/lib/gitlab/template/issue_template.rb new file mode 100644 index 00000000000..c6fa8d3eafc --- /dev/null +++ b/lib/gitlab/template/issue_template.rb @@ -0,0 +1,19 @@ +module Gitlab + module Template + class IssueTemplate < BaseTemplate + class << self + def extension + '.md' + end + + def base_dir + '.gitlab/issue_templates/' + end + + def finder(project) + Gitlab::Template::Finders::RepoTemplateFinder.new(project, self.base_dir, self.extension, self.categories) + end + end + end + end +end diff --git a/lib/gitlab/template/merge_request_template.rb b/lib/gitlab/template/merge_request_template.rb new file mode 100644 index 00000000000..f826c02f3b5 --- /dev/null +++ b/lib/gitlab/template/merge_request_template.rb @@ -0,0 +1,19 @@ +module Gitlab + module Template + class MergeRequestTemplate < BaseTemplate + class << self + def extension + '.md' + end + + def base_dir + '.gitlab/merge_request_templates/' + end + + def finder(project) + Gitlab::Template::Finders::RepoTemplateFinder.new(project, self.base_dir, self.extension, self.categories) + end + end + end + end +end diff --git a/spec/controllers/projects/templates_controller_spec.rb b/spec/controllers/projects/templates_controller_spec.rb new file mode 100644 index 00000000000..7b3a26d7ca7 --- /dev/null +++ b/spec/controllers/projects/templates_controller_spec.rb @@ -0,0 +1,48 @@ +require 'spec_helper' + +describe Projects::TemplatesController do + let(:project) { create(:project) } + let(:user) { create(:user) } + let(:user2) { create(:user) } + let(:file_path_1) { '.gitlab/issue_templates/bug.md' } + let(:body) { JSON.parse(response.body) } + + before do + project.team << [user, :developer] + sign_in(user) + end + + before do + project.team.add_user(user, Gitlab::Access::MASTER) + project.repository.commit_file(user, file_path_1, "something valid", "test 3", "master", false) + end + + describe '#show' do + it 'renders template name and content as json' do + get(:show, namespace_id: project.namespace.to_param, template_type: "issue", key: "bug", project_id: project.path, format: :json) + + expect(response.status).to eq(200) + expect(body["name"]).to eq("bug") + expect(body["content"]).to eq("something valid") + end + + it 'renders 404 when unauthorized' do + sign_in(user2) + get(:show, namespace_id: project.namespace.to_param, template_type: "issue", key: "bug", project_id: project.path, format: :json) + + expect(response.status).to eq(404) + end + + it 'renders 404 when template type is not found' do + sign_in(user) + get(:show, namespace_id: project.namespace.to_param, template_type: "dont_exist", key: "bug", project_id: project.path, format: :json) + + expect(response.status).to eq(404) + end + + it 'renders 404 without errors' do + sign_in(user) + expect { get(:show, namespace_id: project.namespace.to_param, template_type: "dont_exist", key: "bug", project_id: project.path, format: :json) }.not_to raise_error + end + end +end diff --git a/spec/features/projects/issuable_templates_spec.rb b/spec/features/projects/issuable_templates_spec.rb new file mode 100644 index 00000000000..4a83740621a --- /dev/null +++ b/spec/features/projects/issuable_templates_spec.rb @@ -0,0 +1,89 @@ +require 'spec_helper' + +feature 'issuable templates', feature: true, js: true do + include WaitForAjax + + let(:user) { create(:user) } + let(:project) { create(:project, :public) } + + before do + project.team << [user, :master] + login_as user + end + + context 'user creates an issue using templates' do + let(:template_content) { 'this is a test "bug" template' } + let(:issue) { create(:issue, author: user, assignee: user, project: project) } + + background do + project.repository.commit_file(user, '.gitlab/issue_templates/bug.md', template_content, 'added issue template', 'master', false) + visit edit_namespace_project_issue_path project.namespace, project, issue + fill_in :'issue[title]', with: 'test issue title' + end + + scenario 'user selects "bug" template' do + select_template 'bug' + wait_for_ajax + preview_template + save_changes + end + end + + context 'user creates a merge request using templates' do + let(:template_content) { 'this is a test "feature-proposal" template' } + let(:merge_request) { create(:merge_request, :with_diffs, source_project: project) } + + background do + project.repository.commit_file(user, '.gitlab/merge_request_templates/feature-proposal.md', template_content, 'added merge request template', 'master', false) + visit edit_namespace_project_merge_request_path project.namespace, project, merge_request + fill_in :'merge_request[title]', with: 'test merge request title' + end + + scenario 'user selects "feature-proposal" template' do + select_template 'feature-proposal' + wait_for_ajax + preview_template + save_changes + end + end + + context 'user creates a merge request from a forked project using templates' do + let(:template_content) { 'this is a test "feature-proposal" template' } + let(:fork_user) { create(:user) } + let(:fork_project) { create(:project, :public) } + let(:merge_request) { create(:merge_request, :with_diffs, source_project: fork_project) } + + background do + logout + project.team << [fork_user, :developer] + fork_project.team << [fork_user, :master] + create(:forked_project_link, forked_to_project: fork_project, forked_from_project: project) + login_as fork_user + fork_project.repository.commit_file(fork_user, '.gitlab/merge_request_templates/feature-proposal.md', template_content, 'added merge request template', 'master', false) + visit edit_namespace_project_merge_request_path fork_project.namespace, fork_project, merge_request + fill_in :'merge_request[title]', with: 'test merge request title' + end + + scenario 'user selects "feature-proposal" template' do + select_template 'feature-proposal' + wait_for_ajax + preview_template + save_changes + end + end + + def preview_template + click_link 'Preview' + expect(page).to have_content template_content + end + + def save_changes + click_button "Save changes" + expect(page).to have_content template_content + end + + def select_template(name) + first('.js-issuable-selector').click + first('.js-issuable-selector-wrap .dropdown-content a', text: name).click + end +end diff --git a/spec/lib/gitlab/template/gitignore_spec.rb b/spec/lib/gitlab/template/gitignore_template_spec.rb similarity index 88% rename from spec/lib/gitlab/template/gitignore_spec.rb rename to spec/lib/gitlab/template/gitignore_template_spec.rb index bc0ec9325cc..9750a012e22 100644 --- a/spec/lib/gitlab/template/gitignore_spec.rb +++ b/spec/lib/gitlab/template/gitignore_template_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Gitlab::Template::Gitignore do +describe Gitlab::Template::GitignoreTemplate do subject { described_class } describe '.all' do @@ -24,7 +24,7 @@ describe Gitlab::Template::Gitignore do it 'returns the Gitignore object of a valid file' do ruby = subject.find('Ruby') - expect(ruby).to be_a Gitlab::Template::Gitignore + expect(ruby).to be_a Gitlab::Template::GitignoreTemplate expect(ruby.name).to eq('Ruby') end end diff --git a/spec/lib/gitlab/template/gitlab_ci_yml_template_spec.rb b/spec/lib/gitlab/template/gitlab_ci_yml_template_spec.rb new file mode 100644 index 00000000000..e3b8321eda3 --- /dev/null +++ b/spec/lib/gitlab/template/gitlab_ci_yml_template_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' + +describe Gitlab::Template::GitlabCiYmlTemplate do + subject { described_class } + + describe '.all' do + it 'strips the gitlab-ci suffix' do + expect(subject.all.first.name).not_to end_with('.gitlab-ci.yml') + end + + it 'combines the globals and rest' do + all = subject.all.map(&:name) + + expect(all).to include('Elixir') + expect(all).to include('Docker') + expect(all).to include('Ruby') + end + end + + describe '.find' do + it 'returns nil if the file does not exist' do + expect(subject.find('mepmep-yadida')).to be nil + end + + it 'returns the GitlabCiYml object of a valid file' do + ruby = subject.find('Ruby') + + expect(ruby).to be_a Gitlab::Template::GitlabCiYmlTemplate + expect(ruby.name).to eq('Ruby') + end + end + + describe '#content' do + it 'loads the full file' do + gitignore = subject.new(Rails.root.join('vendor/gitlab-ci-yml/Ruby.gitlab-ci.yml')) + + expect(gitignore.name).to eq 'Ruby' + expect(gitignore.content).to start_with('#') + end + end +end diff --git a/spec/lib/gitlab/template/issue_template_spec.rb b/spec/lib/gitlab/template/issue_template_spec.rb new file mode 100644 index 00000000000..f770857e958 --- /dev/null +++ b/spec/lib/gitlab/template/issue_template_spec.rb @@ -0,0 +1,89 @@ +require 'spec_helper' + +describe Gitlab::Template::IssueTemplate do + subject { described_class } + + let(:user) { create(:user) } + let(:project) { create(:project) } + let(:file_path_1) { '.gitlab/issue_templates/bug.md' } + let(:file_path_2) { '.gitlab/issue_templates/template_test.md' } + let(:file_path_3) { '.gitlab/issue_templates/feature_proposal.md' } + + before do + project.team.add_user(user, Gitlab::Access::MASTER) + project.repository.commit_file(user, file_path_1, "something valid", "test 3", "master", false) + project.repository.commit_file(user, file_path_2, "template_test", "test 1", "master", false) + project.repository.commit_file(user, file_path_3, "feature_proposal", "test 2", "master", false) + end + + describe '.all' do + it 'strips the md suffix' do + expect(subject.all(project).first.name).not_to end_with('.issue_template') + end + + it 'combines the globals and rest' do + all = subject.all(project).map(&:name) + + expect(all).to include('bug') + expect(all).to include('feature_proposal') + expect(all).to include('template_test') + end + end + + describe '.find' do + it 'returns nil if the file does not exist' do + expect { subject.find('mepmep-yadida', project) }.to raise_error(Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError) + end + + it 'returns the issue object of a valid file' do + ruby = subject.find('bug', project) + + expect(ruby).to be_a Gitlab::Template::IssueTemplate + expect(ruby.name).to eq('bug') + end + end + + describe '.by_category' do + it 'return array of templates' do + all = subject.by_category('', project).map(&:name) + expect(all).to include('bug') + expect(all).to include('feature_proposal') + expect(all).to include('template_test') + end + + context 'when repo is bare or empty' do + let(:empty_project) { create(:empty_project) } + before { empty_project.team.add_user(user, Gitlab::Access::MASTER) } + + it "returns empty array" do + templates = subject.by_category('', empty_project) + expect(templates).to be_empty + end + end + end + + describe '#content' do + it 'loads the full file' do + issue_template = subject.new('.gitlab/issue_templates/bug.md', project) + + expect(issue_template.name).to eq 'bug' + expect(issue_template.content).to eq('something valid') + end + + it 'raises error when file is not found' do + issue_template = subject.new('.gitlab/issue_templates/bugnot.md', project) + expect { issue_template.content }.to raise_error(Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError) + end + + context "when repo is empty" do + let(:empty_project) { create(:empty_project) } + + before { empty_project.team.add_user(user, Gitlab::Access::MASTER) } + + it "raises file not found" do + issue_template = subject.new('.gitlab/issue_templates/not_existent.md', empty_project) + expect { issue_template.content }.to raise_error(Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError) + end + end + end +end diff --git a/spec/lib/gitlab/template/merge_request_template_spec.rb b/spec/lib/gitlab/template/merge_request_template_spec.rb new file mode 100644 index 00000000000..bb0f68043fa --- /dev/null +++ b/spec/lib/gitlab/template/merge_request_template_spec.rb @@ -0,0 +1,89 @@ +require 'spec_helper' + +describe Gitlab::Template::MergeRequestTemplate do + subject { described_class } + + let(:user) { create(:user) } + let(:project) { create(:project) } + let(:file_path_1) { '.gitlab/merge_request_templates/bug.md' } + let(:file_path_2) { '.gitlab/merge_request_templates/template_test.md' } + let(:file_path_3) { '.gitlab/merge_request_templates/feature_proposal.md' } + + before do + project.team.add_user(user, Gitlab::Access::MASTER) + project.repository.commit_file(user, file_path_1, "something valid", "test 3", "master", false) + project.repository.commit_file(user, file_path_2, "template_test", "test 1", "master", false) + project.repository.commit_file(user, file_path_3, "feature_proposal", "test 2", "master", false) + end + + describe '.all' do + it 'strips the md suffix' do + expect(subject.all(project).first.name).not_to end_with('.issue_template') + end + + it 'combines the globals and rest' do + all = subject.all(project).map(&:name) + + expect(all).to include('bug') + expect(all).to include('feature_proposal') + expect(all).to include('template_test') + end + end + + describe '.find' do + it 'returns nil if the file does not exist' do + expect { subject.find('mepmep-yadida', project) }.to raise_error(Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError) + end + + it 'returns the merge request object of a valid file' do + ruby = subject.find('bug', project) + + expect(ruby).to be_a Gitlab::Template::MergeRequestTemplate + expect(ruby.name).to eq('bug') + end + end + + describe '.by_category' do + it 'return array of templates' do + all = subject.by_category('', project).map(&:name) + expect(all).to include('bug') + expect(all).to include('feature_proposal') + expect(all).to include('template_test') + end + + context 'when repo is bare or empty' do + let(:empty_project) { create(:empty_project) } + before { empty_project.team.add_user(user, Gitlab::Access::MASTER) } + + it "returns empty array" do + templates = subject.by_category('', empty_project) + expect(templates).to be_empty + end + end + end + + describe '#content' do + it 'loads the full file' do + issue_template = subject.new('.gitlab/merge_request_templates/bug.md', project) + + expect(issue_template.name).to eq 'bug' + expect(issue_template.content).to eq('something valid') + end + + it 'raises error when file is not found' do + issue_template = subject.new('.gitlab/merge_request_templates/bugnot.md', project) + expect { issue_template.content }.to raise_error(Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError) + end + + context "when repo is empty" do + let(:empty_project) { create(:empty_project) } + + before { empty_project.team.add_user(user, Gitlab::Access::MASTER) } + + it "raises file not found" do + issue_template = subject.new('.gitlab/merge_request_templates/not_existent.md', empty_project) + expect { issue_template.content }.to raise_error(Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError) + end + end + end +end diff --git a/spec/requests/api/templates_spec.rb b/spec/requests/api/templates_spec.rb index 68d0f41b489..5bd5b861792 100644 --- a/spec/requests/api/templates_spec.rb +++ b/spec/requests/api/templates_spec.rb @@ -3,50 +3,53 @@ require 'spec_helper' describe API::Templates, api: true do include ApiHelpers - describe 'the Template Entity' do - before { get api('/gitignores/Ruby') } + context 'global templates' do + describe 'the Template Entity' do + before { get api('/gitignores/Ruby') } - it { expect(json_response['name']).to eq('Ruby') } - it { expect(json_response['content']).to include('*.gem') } - end + it { expect(json_response['name']).to eq('Ruby') } + it { expect(json_response['content']).to include('*.gem') } + end - describe 'the TemplateList Entity' do - before { get api('/gitignores') } + describe 'the TemplateList Entity' do + before { get api('/gitignores') } - it { expect(json_response.first['name']).not_to be_nil } - it { expect(json_response.first['content']).to be_nil } - end + it { expect(json_response.first['name']).not_to be_nil } + it { expect(json_response.first['content']).to be_nil } + end - context 'requesting gitignores' do - describe 'GET /gitignores' do - it 'returns a list of available gitignore templates' do - get api('/gitignores') + context 'requesting gitignores' do + describe 'GET /gitignores' do + it 'returns a list of available gitignore templates' do + get api('/gitignores') - expect(response).to have_http_status(200) - expect(json_response).to be_an Array - expect(json_response.size).to be > 15 + expect(response.status).to eq(200) + expect(json_response).to be_an Array + expect(json_response.size).to be > 15 + end end end - end - context 'requesting gitlab-ci-ymls' do - describe 'GET /gitlab_ci_ymls' do - it 'returns a list of available gitlab_ci_ymls' do - get api('/gitlab_ci_ymls') + context 'requesting gitlab-ci-ymls' do + describe 'GET /gitlab_ci_ymls' do + it 'returns a list of available gitlab_ci_ymls' do + get api('/gitlab_ci_ymls') - expect(response).to have_http_status(200) - expect(json_response).to be_an Array - expect(json_response.first['name']).not_to be_nil + expect(response.status).to eq(200) + expect(json_response).to be_an Array + expect(json_response.first['name']).not_to be_nil + end end end - end - describe 'GET /gitlab_ci_ymls/Ruby' do - it 'adds a disclaimer on the top' do - get api('/gitlab_ci_ymls/Ruby') + describe 'GET /gitlab_ci_ymls/Ruby' do + it 'adds a disclaimer on the top' do + get api('/gitlab_ci_ymls/Ruby') - expect(response).to have_http_status(200) - expect(json_response['content']).to start_with("# This file is a template,") + expect(response).to have_http_status(200) + expect(json_response['name']).not_to be_nil + expect(json_response['content']).to start_with("# This file is a template,") + end end end end