From 368cbe30675edeffbe90f494806d6f9e6b34c7ee Mon Sep 17 00:00:00 2001 From: Jeremy Maziarz Date: Mon, 6 Oct 2014 19:19:21 -0400 Subject: [PATCH 001/194] Fix link paths for http and ssh in admin project show view --- app/views/admin/projects/show.html.haml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml index 6d53619985..c7ea430acf 100644 --- a/app/views/admin/projects/show.html.haml +++ b/app/views/admin/projects/show.html.haml @@ -42,11 +42,11 @@ %li %span.light http: %strong - = link_to @project.http_url_to_repo + = link_to @project.http_url_to_repo, project_path(@project) %li %span.light ssh: %strong - = link_to @project.ssh_url_to_repo + = link_to @project.ssh_url_to_repo, project_path(@project) - if @project.repository.exists? %li %span.light fs: From aa5c9abb97c38b8cd53d37c3ea67a19d0a9f28e3 Mon Sep 17 00:00:00 2001 From: Johannes Hund Date: Wed, 7 Jan 2015 20:04:02 +0100 Subject: [PATCH 002/194] add necessary dir for uploads in update to 6.7 rake task gitlab:backup:create crashes otherwise --- doc/update/6.6-to-6.7.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/doc/update/6.6-to-6.7.md b/doc/update/6.6-to-6.7.md index 77ac4d0bfa..35b731019c 100644 --- a/doc/update/6.6-to-6.7.md +++ b/doc/update/6.6-to-6.7.md @@ -70,6 +70,9 @@ sudo -u git -H gzip /home/git/gitlab-shell/gitlab-shell.log.1 # Close access to gitlab-satellites for others sudo chmod u+rwx,g=rx,o-rwx /home/git/gitlab-satellites + +# Add directory for uploads +sudo -u git -H mkdir -p /home/git/gitlab/public/uploads ``` ## 5. Start application From 01bd632c66617378ee4410025166e3c8131d821c Mon Sep 17 00:00:00 2001 From: Patricio Cano Date: Tue, 3 Mar 2015 12:31:54 -0800 Subject: [PATCH 003/194] Created documentation on how to fork a project. --- doc/workflow/README.md | 1 + doc/workflow/forking/branch_select.png | Bin 0 -> 55352 bytes doc/workflow/forking/fork_button.png | Bin 0 -> 68271 bytes doc/workflow/forking/groups.png | Bin 0 -> 98109 bytes doc/workflow/forking/merge_request.png | Bin 0 -> 60597 bytes doc/workflow/forking_workflow.md | 33 +++++++++++++++++++++++++ 6 files changed, 34 insertions(+) create mode 100644 doc/workflow/forking/branch_select.png create mode 100644 doc/workflow/forking/fork_button.png create mode 100644 doc/workflow/forking/groups.png create mode 100644 doc/workflow/forking/merge_request.png create mode 100644 doc/workflow/forking_workflow.md diff --git a/doc/workflow/README.md b/doc/workflow/README.md index 6e70235f5b..7e3fc49f70 100644 --- a/doc/workflow/README.md +++ b/doc/workflow/README.md @@ -1,6 +1,7 @@ # Workflow - [Feature branch workflow](workflow.md) +- [Code forking workflow](forking_workflow.md) - [Project Features](project_features.md) - [Authorization for merge requests](authorization_for_merge_requests.md) - [Groups](groups.md) diff --git a/doc/workflow/forking/branch_select.png b/doc/workflow/forking/branch_select.png new file mode 100644 index 0000000000000000000000000000000000000000..275f64d113baa4800fe6a49c366d5bb3c1237068 GIT binary patch literal 55352 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 literal 0 HcmV?d00001 diff --git a/doc/workflow/forking/fork_button.png b/doc/workflow/forking/fork_button.png new file mode 100644 index 0000000000000000000000000000000000000000..def4266476ad23531e7299ace79fa4e999b3d94c GIT binary patch literal 68271 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 Cb&u>|DgwR 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 literal 0 HcmV?d00001 diff --git a/doc/workflow/forking/merge_request.png b/doc/workflow/forking/merge_request.png new file mode 100644 index 0000000000000000000000000000000000000000..2dc00ed08a1df18629be937740f32ef35abbb87f GIT binary patch literal 60597 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 P_QzU3_mWeM)OoNyoN>6L9M2;4Lp zVaKU?ZevH5HQ(lv>fAJNZP^jXYc|+A?QVdt2$>iLUgzo%(%F%(nqiktF3S8q+l`g4 zc;*vPaTFTz#2VBi4#S>TWtzgC9#7%0Kwp3U#oPD`zWED(0y?TyN9Y{V>mG!T47}68 zQ|uU7sEg`bLd%WIsh06v2c-I?M@xAr#4k)3A#L~bFI|^KZLb9Iaiq^*=m{kRke z>Zv$UbS{)I{z1Y?-ZX|ldcbUoOEp4M#r{oqhoe#J007$la6iM9ox2(|I;&j z_Pn#-Ir@&jvmE59U#+`ptySx~FCg@ppS>%hw%gT&4>fnpOOO_rZ z

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 BNcR8$ literal 0 HcmV?d00001 diff --git a/doc/workflow/forking_workflow.md b/doc/workflow/forking_workflow.md new file mode 100644 index 0000000000..32cee3e4f3 --- /dev/null +++ b/doc/workflow/forking_workflow.md @@ -0,0 +1,33 @@ +# Code forking workflow + +Forking a project to your own namespace is useful if you have no write access to the project you want to contribute +to. If you do have write access, we recommend using the **[Code Branching Workflow](https://about.gitlab.com/2014/09/29/gitlab-flow/)**. + +## Creating a fork + +In order to create a fork of a project, all you need to do is click on the fork button located on the top right side +of the screen, close to the project's URL and right next to the stars button. + +![Fork button](/forking/fork_button.png) + +Once you do that you will see a screen where you can choose the namespace, to where you want to add the fork. This page +will contain the groups you have write access to. Choose one of the groups and the project will be added there. + +![Groups view](/forking/groups.png) + +After the forking is done, you can start working on the newly created repository. There you will have full Owner access, +so you can set it up as you please. + +## Merging upstream + +Once you are ready to send your code back to the main project, you need to create a Merge request. Choose your forked +project's main branch as the source and the original project's main branch as the destination and create the merge request. + +![Selecting branches](/forking/branch_select.png) + +You can then assign the Merge Request to someone so they can review your changes. After they have reviewed them, the will +be added to the main project, if maintainer chooses to do so. + +![New merge request](/forking/merge_request.png) + + From 92b1b15f6f2579159160bde3efc41d632a92453e Mon Sep 17 00:00:00 2001 From: Patricio Cano Date: Tue, 3 Mar 2015 12:38:45 -0800 Subject: [PATCH 004/194] Corrected images paths --- doc/workflow/forking_workflow.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/workflow/forking_workflow.md b/doc/workflow/forking_workflow.md index 32cee3e4f3..dee9e43214 100644 --- a/doc/workflow/forking_workflow.md +++ b/doc/workflow/forking_workflow.md @@ -8,12 +8,12 @@ to. If you do have write access, we recommend using the **[Code Branching Workfl In order to create a fork of a project, all you need to do is click on the fork button located on the top right side of the screen, close to the project's URL and right next to the stars button. -![Fork button](/forking/fork_button.png) +![Fork button](forking/fork_button.png) Once you do that you will see a screen where you can choose the namespace, to where you want to add the fork. This page will contain the groups you have write access to. Choose one of the groups and the project will be added there. -![Groups view](/forking/groups.png) +![Groups view](forking/groups.png) After the forking is done, you can start working on the newly created repository. There you will have full Owner access, so you can set it up as you please. @@ -23,11 +23,11 @@ so you can set it up as you please. Once you are ready to send your code back to the main project, you need to create a Merge request. Choose your forked project's main branch as the source and the original project's main branch as the destination and create the merge request. -![Selecting branches](/forking/branch_select.png) +![Selecting branches](forking/branch_select.png) You can then assign the Merge Request to someone so they can review your changes. After they have reviewed them, the will be added to the main project, if maintainer chooses to do so. -![New merge request](/forking/merge_request.png) +![New merge request](forking/merge_request.png) From 09dbe452e3a57ab6ec68b0af76b32502cbeec00e Mon Sep 17 00:00:00 2001 From: Patricio Cano Date: Tue, 3 Mar 2015 21:44:53 -0800 Subject: [PATCH 005/194] Corrected sentence about GitLab Flow --- doc/workflow/forking_workflow.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/doc/workflow/forking_workflow.md b/doc/workflow/forking_workflow.md index dee9e43214..2c680a4c12 100644 --- a/doc/workflow/forking_workflow.md +++ b/doc/workflow/forking_workflow.md @@ -1,7 +1,9 @@ # Code forking workflow Forking a project to your own namespace is useful if you have no write access to the project you want to contribute -to. If you do have write access, we recommend using the **[Code Branching Workflow](https://about.gitlab.com/2014/09/29/gitlab-flow/)**. +to. If you do have write access or can request it we recommend working together in the same repository since it is simpler. +See our **[GitLab Flow](https://about.gitlab.com/2014/09/29/gitlab-flow/)** article for more information about using +branches to work together. ## Creating a fork From 455a4fe91a32d0ab2de72d1e62a7a6edfcb40d31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sa=C5=A1o=20Stanovnik?= Date: Tue, 24 Mar 2015 00:11:41 +0100 Subject: [PATCH 006/194] Fixed generating SSH key fingerprints in OpenSSH 6.8. --- app/models/key.rb | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/app/models/key.rb b/app/models/key.rb index e2e59296ee..b74daf50fe 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -86,7 +86,24 @@ class Key < ActiveRecord::Base Tempfile.open('gitlab_key_file') do |file| file.puts key file.rewind - cmd_output, cmd_status = popen(%W(ssh-keygen -lf #{file.path}), '/tmp') + + # OpenSSH 6.8 introduces a new default output format for fingerprints. + # Check the version and decide which command to use. + version_output, version_status = popen(%W(ssh -V)) + explicit_fingerprint_algorithm = false + if version_status.zero? + out, _ = version_output.scan /.*?(\d)\.(\d).*?,/ + major, minor = out[0], out[1] + if major.to_i > 6 or (major.to_i == 6 and minor.to_i >= 8) + explicit_fingerprint_algorithm = true + end + end + + if explicit_fingerprint_algorithm + cmd_output, cmd_status = popen(%W(ssh-keygen -E md5 -lf #{file.path}), '/tmp') + else + cmd_output, cmd_status = popen(%W(ssh-keygen -lf #{file.path}), '/tmp') + end end if cmd_status.zero? From 05d4658b3233b3f37876772c10fbf5e648682154 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sa=C5=A1o=20Stanovnik?= Date: Tue, 24 Mar 2015 00:49:30 +0100 Subject: [PATCH 007/194] Use 2 spaces for indentation, not 4. --- app/models/key.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/key.rb b/app/models/key.rb index b74daf50fe..1c41f8b8d7 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -95,7 +95,7 @@ class Key < ActiveRecord::Base out, _ = version_output.scan /.*?(\d)\.(\d).*?,/ major, minor = out[0], out[1] if major.to_i > 6 or (major.to_i == 6 and minor.to_i >= 8) - explicit_fingerprint_algorithm = true + explicit_fingerprint_algorithm = true end end From 38384bd9521a42683d9ba33ac482993496c6f986 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sa=C5=A1o=20Stanovnik?= Date: Tue, 24 Mar 2015 10:55:31 +0100 Subject: [PATCH 008/194] The new fingerprint format wis incompatible with the previous regex. --- app/models/key.rb | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/app/models/key.rb b/app/models/key.rb index 1c41f8b8d7..480a16f933 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -83,14 +83,14 @@ class Key < ActiveRecord::Base cmd_status = 0 cmd_output = '' + explicit_fingerprint_algorithm = false Tempfile.open('gitlab_key_file') do |file| file.puts key file.rewind - + # OpenSSH 6.8 introduces a new default output format for fingerprints. # Check the version and decide which command to use. version_output, version_status = popen(%W(ssh -V)) - explicit_fingerprint_algorithm = false if version_status.zero? out, _ = version_output.scan /.*?(\d)\.(\d).*?,/ major, minor = out[0], out[1] @@ -107,8 +107,15 @@ class Key < ActiveRecord::Base end if cmd_status.zero? - cmd_output.gsub /(\h{2}:)+\h{2}/ do |match| - self.fingerprint = match + if explicit_fingerprint_algorithm + cmd_output.gsub /(MD5:)(\h{2}:)+\h{2}/ do |match| + match.slice! /^MD5:/ + self.fingerprint = match + end + else + cmd_output.gsub /(\h{2}:)+\h{2}/ do |match| + self.fingerprint = match + end end end end From 81ff63d6849773f4cb5720bbcacfa92c93548f9b Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 26 Mar 2015 21:23:31 +0000 Subject: [PATCH 009/194] Changes the width of sidebar in issues and MRs when affix event fires --- app/assets/javascripts/issue.js.coffee | 4 ++++ app/assets/javascripts/merge_request.js.coffee | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/issue.js.coffee b/app/assets/javascripts/issue.js.coffee index bf71c144ea..89c17ed4dc 100644 --- a/app/assets/javascripts/issue.js.coffee +++ b/app/assets/javascripts/issue.js.coffee @@ -22,3 +22,7 @@ class @Issue @top = ($('.issuable-affix').offset().top - 70) bottom: -> @bottom = $('.footer').outerHeight(true) + $('.issuable-affix').on 'affix.bs.affix', -> + $(@).width($(@).outerWidth()) + .on 'affixed-top.bs.affix affixed-bottom.bs.affix', -> + $(@).width('') diff --git a/app/assets/javascripts/merge_request.js.coffee b/app/assets/javascripts/merge_request.js.coffee index 6127d2bb48..8ce5a3645c 100644 --- a/app/assets/javascripts/merge_request.js.coffee +++ b/app/assets/javascripts/merge_request.js.coffee @@ -26,6 +26,10 @@ class @MergeRequest @top = ($('.issuable-affix').offset().top - 70) bottom: -> @bottom = $('.footer').outerHeight(true) + $('.issuable-affix').on 'affix.bs.affix', -> + $(@).width($(@).outerWidth()) + .on 'affixed-top.bs.affix affixed-bottom.bs.affix', -> + $(@).width('') # Local jQuery finder $: (selector) -> @@ -164,4 +168,3 @@ class @MergeRequest else setTimeout(merge_request.mergeInProgress, 3000) dataType: 'json' - From 7e26571d8d5aebc3caae911b8d8d215f8b5b5ec0 Mon Sep 17 00:00:00 2001 From: fabien Date: Fri, 27 Mar 2015 16:18:58 +0100 Subject: [PATCH 010/194] Update gemnasium-gitlab-service to 0.2.6 Fix compatibily issue with Ruby 2.0 --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 7da4d3c358..eb9cb36782 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -188,7 +188,7 @@ GEM dotenv (>= 0.7) thor (>= 0.13.6) formatador (0.2.4) - gemnasium-gitlab-service (0.2.4) + gemnasium-gitlab-service (0.2.6) rugged (~> 0.21) gemojione (2.0.0) json From 08eac512bdcd9181476afc3e4bc0b132ebd48478 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 31 Mar 2015 12:30:09 +0200 Subject: [PATCH 011/194] Move EmailsOnPush highlight CSS inline instead of being in every notification email. --- app/helpers/emails_helper.rb | 6 +----- app/views/layouts/notify.html.haml | 3 +-- app/views/notify/repository_push_email.html.haml | 3 +-- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb index 08476f8516..6c253213c3 100644 --- a/app/helpers/emails_helper.rb +++ b/app/helpers/emails_helper.rb @@ -33,12 +33,8 @@ module EmailsHelper end end - def add_email_highlight_css - Rugments::Themes::Github.render(scope: '.highlight') - end - def color_email_diff(diffcontent) - formatter = Rugments::Formatters::HTML.new(cssclass: 'highlight') + formatter = Rugments::Formatters::HTML.new(cssclass: "highlight", inline_theme: :github) lexer = Rugments::Lexers::Diff.new raw formatter.format(lexer.lex(diffcontent)) end diff --git a/app/views/layouts/notify.html.haml b/app/views/layouts/notify.html.haml index 7eec93abdf..00c7cedce4 100644 --- a/app/views/layouts/notify.html.haml +++ b/app/views/layouts/notify.html.haml @@ -27,8 +27,7 @@ } .file-stats .deleted-file { color: #B00; - } - #{add_email_highlight_css} + }} %body %div.content = yield diff --git a/app/views/notify/repository_push_email.html.haml b/app/views/notify/repository_push_email.html.haml index bbf7004c90..a374a66233 100644 --- a/app/views/notify/repository_push_email.html.haml +++ b/app/views/notify/repository_push_email.html.haml @@ -59,8 +59,7 @@ %strong = diff.new_path %hr - %pre - = color_email_diff(diff.diff) + = color_email_diff(diff.diff) %br - if @compare.timeout From 5ad0eb4378ce3da98fed210c0383687900e9414a Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 31 Mar 2015 12:34:07 +0200 Subject: [PATCH 012/194] Update gitlab_git to 7.1.5, to fix final line in EmailsOnPush email diff being rendered as error. --- CHANGELOG | 1 + Gemfile | 2 +- Gemfile.lock | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 37054da46b..4623a1f1a4 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -48,6 +48,7 @@ v 7.10.0 (unreleased) - Prevent note form from being cleared when submitting failed. - Improve file icons rendering on tree (Sullivan Sénéchal) - API: Add pagination to project events + - Fix final line in EmailsOnPush email diff being rendered as error. v 7.9.0 - Send EmailsOnPush email when branch or tag is created or deleted. diff --git a/Gemfile b/Gemfile index e767aec505..05830dec82 100644 --- a/Gemfile +++ b/Gemfile @@ -39,7 +39,7 @@ gem "browser" # Extracting information from a git repository # Provide access to Gitlab::Git library -gem "gitlab_git", '~> 7.1.3' +gem "gitlab_git", '~> 7.1.5' # Ruby/Rack Git Smart-HTTP Server Handler gem 'gitlab-grack', '~> 2.0.0.rc2', require: 'grack' diff --git a/Gemfile.lock b/Gemfile.lock index ed8663b358..bc278a0e73 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -212,7 +212,7 @@ GEM mime-types (~> 1.19) gitlab_emoji (0.1.0) gemojione (~> 2.0) - gitlab_git (7.1.3) + gitlab_git (7.1.5) activesupport (~> 4.0) charlock_holmes (~> 0.6) gitlab-linguist (~> 3.0) @@ -703,7 +703,7 @@ DEPENDENCIES gitlab-grack (~> 2.0.0.rc2) gitlab-linguist (~> 3.0.1) gitlab_emoji (~> 0.1) - gitlab_git (~> 7.1.3) + gitlab_git (~> 7.1.5) gitlab_meta (= 7.0) gitlab_omniauth-ldap (= 1.2.1) gollum-lib (~> 4.0.2) From cef0fe244f4faf55bc1d4bd7402129ce80d427e9 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 31 Mar 2015 14:17:20 +0200 Subject: [PATCH 013/194] Update gitlab_git to 7.1.6 because 7.1.5 had broken language detection. --- Gemfile | 2 +- Gemfile.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile b/Gemfile index 05830dec82..cce1f47ef1 100644 --- a/Gemfile +++ b/Gemfile @@ -39,7 +39,7 @@ gem "browser" # Extracting information from a git repository # Provide access to Gitlab::Git library -gem "gitlab_git", '~> 7.1.5' +gem "gitlab_git", '~> 7.1.6' # Ruby/Rack Git Smart-HTTP Server Handler gem 'gitlab-grack', '~> 2.0.0.rc2', require: 'grack' diff --git a/Gemfile.lock b/Gemfile.lock index bc278a0e73..d7a292d4e5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -212,7 +212,7 @@ GEM mime-types (~> 1.19) gitlab_emoji (0.1.0) gemojione (~> 2.0) - gitlab_git (7.1.5) + gitlab_git (7.1.6) activesupport (~> 4.0) charlock_holmes (~> 0.6) gitlab-linguist (~> 3.0) @@ -703,7 +703,7 @@ DEPENDENCIES gitlab-grack (~> 2.0.0.rc2) gitlab-linguist (~> 3.0.1) gitlab_emoji (~> 0.1) - gitlab_git (~> 7.1.5) + gitlab_git (~> 7.1.6) gitlab_meta (= 7.0) gitlab_omniauth-ldap (= 1.2.1) gollum-lib (~> 4.0.2) From 6fe26e3e468ae0b79e2e05b4d4dd608cd9bbcd94 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 31 Mar 2015 17:49:19 +0100 Subject: [PATCH 014/194] Removed help link from sidebar --- app/views/layouts/nav/_dashboard.html.haml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml index e4f630c6a1..511c0694b8 100644 --- a/app/views/layouts/nav/_dashboard.html.haml +++ b/app/views/layouts/nav/_dashboard.html.haml @@ -31,8 +31,3 @@ %span Merge Requests %span.count= current_user.assigned_merge_requests.opened.count - = nav_link(controller: :help) do - = link_to help_path, title: 'Help' do - %i.fa.fa-question-circle - %span - Help From eaf919ec7ded9efb8fd46318c39c92059410d378 Mon Sep 17 00:00:00 2001 From: Dominik Sander Date: Wed, 1 Apr 2015 23:49:34 +0200 Subject: [PATCH 015/194] Fix event-title max-width in the dashboard For view port sizes between 1260px and 1200px pixels the event-title was too wide, thus not truncated and got pushed into a second line. 100% - 174px was chosen as the max-width because 141px is the widest .event-item-timestamp can get (with 'less than a minute ago') + 24px for the avatar + 8px avatar margin + 1px to fit into the gap. --- app/assets/stylesheets/pages/events.scss | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/assets/stylesheets/pages/events.scss b/app/assets/stylesheets/pages/events.scss index 83daea10ed..d4af7506d5 100644 --- a/app/assets/stylesheets/pages/events.scss +++ b/app/assets/stylesheets/pages/events.scss @@ -45,7 +45,8 @@ padding: 12px 0px; border-bottom: 1px solid #eee; .event-title { - @include str-truncated(72%); + max-width: 70%; + @include str-truncated(calc(100% - 174px)); font-weight: 500; font-size: 14px; .author_name { From dfd256f29ee817b5ffc563bb554a02d26ae44502 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Fri, 20 Mar 2015 05:11:12 -0700 Subject: [PATCH 016/194] Support configurable attachment size via Application Settings Fix bug where error messages from Dropzone would not be displayed on the issues page Closes #1258 --- CHANGELOG | 2 + .../javascripts/dropzone_input.js.coffee | 15 ++++--- .../admin/application_settings_controller.rb | 1 + app/controllers/application_controller.rb | 1 + app/models/application_setting.rb | 4 +- app/models/note.rb | 10 ++++- app/services/projects/upload_service.rb | 8 +++- .../application_settings/_form.html.haml | 5 +++ app/views/projects/notes/_form.html.haml | 2 +- config/initializers/1_settings.rb | 1 + ...attachment_size_to_application_settings.rb | 5 +++ db/schema.rb | 3 +- features/project/issues/issues.feature | 1 + features/steps/project/issues/issues.rb | 6 +++ lib/file_size_validator.rb | 12 +++++- lib/gitlab/current_settings.rb | 3 +- spec/lib/file_size_validator_spec.rb | 43 +++++++++++++++++++ spec/services/projects/upload_service_spec.rb | 10 +++++ 18 files changed, 117 insertions(+), 15 deletions(-) create mode 100644 db/migrate/20150328132231_add_max_attachment_size_to_application_settings.rb create mode 100644 spec/lib/file_size_validator_spec.rb diff --git a/CHANGELOG b/CHANGELOG index 7cca0835af..57d0bba1cf 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,11 +1,13 @@ Please view this file on the master branch, on stable branches it's out of date. v 7.10.0 (unreleased) + - Fix bug where error messages from Dropzone would not be displayed on the issues page (Stan Hu) - Fix broken side-by-side diff view on merge request page (Stan Hu) - Set Application controller default URL options to ensure all url_for calls are consistent (Stan Hu) - Allow HTML tags in Markdown input - Fix code unfold not working on Compare commits page (Stan Hu) - Fix dots in Wiki slugs causing errors (Stan Hu) + - Make maximum attachment size configurable via Application Settings (Stan Hu) - Update poltergeist to version 1.6.0 to support PhantomJS 2.0 (Zeger-Jan van de Weg) - Fix cross references when usernames, milestones, or project names contain underscores (Stan Hu) - Disable reference creation for comments surrounded by code/preformatted blocks (Stan Hu) diff --git a/app/assets/javascripts/dropzone_input.js.coffee b/app/assets/javascripts/dropzone_input.js.coffee index 06e9f0001a..fca2a290e2 100644 --- a/app/assets/javascripts/dropzone_input.js.coffee +++ b/app/assets/javascripts/dropzone_input.js.coffee @@ -10,6 +10,7 @@ class @DropzoneInput iconSpinner = "" btnAlert = "" project_uploads_path = window.project_uploads_path or null + max_file_size = gon.max_file_size or 10 form_textarea = $(form).find("textarea.markdown-area") form_textarea.wrap "

" @@ -76,7 +77,7 @@ class @DropzoneInput dictDefaultMessage: "" clickable: true paramName: "file" - maxFilesize: 10 + maxFilesize: max_file_size uploadMultiple: false headers: "X-CSRF-Token": $("meta[name=\"csrf-token\"]").attr("content") @@ -108,9 +109,10 @@ class @DropzoneInput return error: (temp, errorMessage) -> - checkIfMsgExists = $(".error-alert").children().length + errorAlert = $(form).find('.error-alert') + checkIfMsgExists = errorAlert.children().length if checkIfMsgExists is 0 - $(".error-alert").append divAlert + errorAlert.append divAlert $(".div-dropzone-alert").append btnAlert + errorMessage return @@ -221,9 +223,10 @@ class @DropzoneInput "display": "none" showError = (message) -> - checkIfMsgExists = $(".error-alert").children().length + errorAlert = $(form).find('.error-alert') + checkIfMsgExists = errorAlert.children().length if checkIfMsgExists is 0 - $(".error-alert").append divAlert + errorAlert.append divAlert $(".div-dropzone-alert").append btnAlert + message closeAlertMessage = -> @@ -237,4 +240,4 @@ class @DropzoneInput formatLink: (link) -> text = "[#{link.alt}](#{link.url})" text = "!#{text}" if link.is_image - text \ No newline at end of file + text diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index 9a5685877f..b5fda196bf 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -38,6 +38,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController :twitter_sharing_enabled, :sign_in_text, :home_page_url, + :max_attachment_size, restricted_visibility_levels: [] ) end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 2809f90c0d..80e983b531 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -203,6 +203,7 @@ class ApplicationController < ActionController::Base gon.api_version = API::API.version gon.relative_url_root = Gitlab.config.gitlab.relative_url_root gon.default_avatar_url = URI::join(Gitlab.config.gitlab.url, ActionController::Base.helpers.image_path('no_avatar.png')).to_s + gon.max_file_size = current_application_settings.max_attachment_size; if current_user gon.current_user_id = current_user.id diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 1c87db613a..6e98c4c2f0 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -16,6 +16,7 @@ # default_branch_protection :integer default(2) # twitter_sharing_enabled :boolean default(TRUE) # restricted_visibility_levels :text +# max_attachment_size :integer default(10) # class ApplicationSetting < ActiveRecord::Base @@ -49,7 +50,8 @@ class ApplicationSetting < ActiveRecord::Base twitter_sharing_enabled: Settings.gitlab['twitter_sharing_enabled'], gravatar_enabled: Settings.gravatar['enabled'], sign_in_text: Settings.extra['sign_in_text'], - restricted_visibility_levels: Settings.gitlab['restricted_visibility_levels'] + restricted_visibility_levels: Settings.gitlab['restricted_visibility_levels'], + max_attachment_size: Settings.gitlab['max_attachment_size'] ) end diff --git a/app/models/note.rb b/app/models/note.rb index e86160e7cd..fdab4517df 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -22,6 +22,7 @@ require 'file_size_validator' class Note < ActiveRecord::Base include Mentionable + include Gitlab::CurrentSettings default_value_for :system, false @@ -36,7 +37,8 @@ class Note < ActiveRecord::Base validates :note, :project, presence: true validates :line_code, format: { with: /\A[a-z0-9]+_\d+_\d+\Z/ }, allow_blank: true - validates :attachment, file_size: { maximum: 10.megabytes.to_i } + # Attachments are deprecated and are handled by Markdown uploader + validates :attachment, file_size: { maximum: :max_attachment_size } validates :noteable_id, presence: true, if: ->(n) { n.noteable_type.present? && n.noteable_type != 'Commit' } validates :commit_id, presence: true, if: ->(n) { n.noteable_type == 'Commit' } @@ -321,6 +323,10 @@ class Note < ActiveRecord::Base end end + def max_attachment_size + current_application_settings.max_attachment_size.megabytes.to_i + end + def commit_author @commit_author ||= project.team.users.find_by(email: noteable.author_email) || @@ -451,7 +457,7 @@ class Note < ActiveRecord::Base prev_match_line = line else prev_lines << line - + break if generate_line_code(line) == self.line_code prev_lines.shift if prev_lines.length >= max_number_of_lines diff --git a/app/services/projects/upload_service.rb b/app/services/projects/upload_service.rb index a186c97628..992a7a7a1d 100644 --- a/app/services/projects/upload_service.rb +++ b/app/services/projects/upload_service.rb @@ -5,7 +5,7 @@ module Projects end def execute - return nil unless @file + return nil unless @file and @file.size <= max_attachment_size uploader = FileUploader.new(@project) uploader.store!(@file) @@ -18,5 +18,11 @@ module Projects 'is_image' => uploader.image? } end + + private + + def max_attachment_size + current_application_settings.max_attachment_size.megabytes.to_i + end end end diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml index edfcccfcf4..4f3565c67e 100644 --- a/app/views/admin/application_settings/_form.html.haml +++ b/app/views/admin/application_settings/_form.html.haml @@ -60,5 +60,10 @@ .col-sm-10 = f.text_area :sign_in_text, class: 'form-control', rows: 4 .help-block Markdown enabled + .form-group + = f.label :max_attachment_size, 'Maximum attachment size (MB)', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :max_attachment_size, class: 'form-control' + .form-actions = f.submit 'Save', class: 'btn btn-primary' diff --git a/app/views/projects/notes/_form.html.haml b/app/views/projects/notes/_form.html.haml index be96c30214..2ada6cb670 100644 --- a/app/views/projects/notes/_form.html.haml +++ b/app/views/projects/notes/_form.html.haml @@ -12,7 +12,7 @@ .comment-hints.clearfix .pull-left Comments are parsed with #{link_to "GitLab Flavored Markdown", help_page_path("markdown", "markdown"),{ target: '_blank', tabindex: -1 }} .pull-right Attach files by dragging & dropping or #{link_to "selecting them", '#', class: 'markdown-selector', tabindex: -1 }. - + .error-alert .note-form-actions .buttons diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index 15c1ae9466..4c37fbf7ca 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -119,6 +119,7 @@ Settings.gitlab['username_changing_enabled'] = true if Settings.gitlab['username Settings.gitlab['issue_closing_pattern'] = '((?:[Cc]los(?:e[sd]?|ing)|[Ff]ix(?:e[sd]|ing)?|[Rr]esolv(?:e[sd]?|ing)) +(?:(?:issues? +)?#\d+(?:(?:, *| +and +)?))+)' if Settings.gitlab['issue_closing_pattern'].nil? Settings.gitlab['default_projects_features'] ||= {} Settings.gitlab['webhook_timeout'] ||= 10 +Settings.gitlab['max_attachment_size'] ||= 10 Settings.gitlab.default_projects_features['issues'] = true if Settings.gitlab.default_projects_features['issues'].nil? Settings.gitlab.default_projects_features['merge_requests'] = true if Settings.gitlab.default_projects_features['merge_requests'].nil? Settings.gitlab.default_projects_features['wiki'] = true if Settings.gitlab.default_projects_features['wiki'].nil? diff --git a/db/migrate/20150328132231_add_max_attachment_size_to_application_settings.rb b/db/migrate/20150328132231_add_max_attachment_size_to_application_settings.rb new file mode 100644 index 0000000000..1d161674a9 --- /dev/null +++ b/db/migrate/20150328132231_add_max_attachment_size_to_application_settings.rb @@ -0,0 +1,5 @@ +class AddMaxAttachmentSizeToApplicationSettings < ActiveRecord::Migration + def change + add_column :application_settings, :max_attachment_size, :integer, default: 10, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 4a445ae583..14e32a7946 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20150324155957) do +ActiveRecord::Schema.define(version: 20150328132231) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -28,6 +28,7 @@ ActiveRecord::Schema.define(version: 20150324155957) do t.integer "default_branch_protection", default: 2 t.boolean "twitter_sharing_enabled", default: true t.text "restricted_visibility_levels" + t.integer "max_attachment_size", default: 10, null: false end create_table "broadcast_messages", force: true do |t| diff --git a/features/project/issues/issues.feature b/features/project/issues/issues.feature index b9031f6f32..af01c7058e 100644 --- a/features/project/issues/issues.feature +++ b/features/project/issues/issues.feature @@ -42,6 +42,7 @@ Feature: Project Issues Given I visit issue page "Release 0.4" And I leave a comment like "XML attached" Then I should see comment "XML attached" + And I should see an error alert section within the comment form @javascript Scenario: I search issue diff --git a/features/steps/project/issues/issues.rb b/features/steps/project/issues/issues.rb index e8ca3f7c17..e2834d51a2 100644 --- a/features/steps/project/issues/issues.rb +++ b/features/steps/project/issues/issues.rb @@ -204,6 +204,12 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps end end + step 'I should see an error alert section within the comment form' do + within(".js-main-target-form") do + find(".error-alert") + end + end + step 'The code block should be unchanged' do page.should have_content("```\nCommand [1]: /usr/local/bin/git , see [text](doc/text)\n```") end diff --git a/lib/file_size_validator.rb b/lib/file_size_validator.rb index 42970c1be5..2eae55e534 100644 --- a/lib/file_size_validator.rb +++ b/lib/file_size_validator.rb @@ -25,8 +25,8 @@ class FileSizeValidator < ActiveModel::EachValidator keys.each do |key| value = options[key] - unless value.is_a?(Integer) && value >= 0 - raise ArgumentError, ":#{key} must be a nonnegative Integer" + unless (value.is_a?(Integer) && value >= 0) || value.is_a?(Symbol) + raise ArgumentError, ":#{key} must be a nonnegative Integer or symbol" end end end @@ -39,6 +39,14 @@ class FileSizeValidator < ActiveModel::EachValidator CHECKS.each do |key, validity_check| next unless check_value = options[key] + check_value = + case check_value + when Integer + check_value + when Symbol + record.send(check_value) + end + value ||= [] if key == :maximum value_size = value.size diff --git a/lib/gitlab/current_settings.rb b/lib/gitlab/current_settings.rb index 0ebebfa09c..d8f696d247 100644 --- a/lib/gitlab/current_settings.rb +++ b/lib/gitlab/current_settings.rb @@ -20,7 +20,8 @@ module Gitlab signin_enabled: Settings.gitlab['signin_enabled'], gravatar_enabled: Settings.gravatar['enabled'], sign_in_text: Settings.extra['sign_in_text'], - restricted_visibility_levels: Settings.gitlab['restricted_visibility_levels'] + restricted_visibility_levels: Settings.gitlab['restricted_visibility_levels'], + max_attachment_size: Settings.gitlab['max_attachment_size'] ) end end diff --git a/spec/lib/file_size_validator_spec.rb b/spec/lib/file_size_validator_spec.rb new file mode 100644 index 0000000000..5c89c85471 --- /dev/null +++ b/spec/lib/file_size_validator_spec.rb @@ -0,0 +1,43 @@ +require 'spec_helper' + +describe 'Gitlab::FileSizeValidatorSpec' do + let(:validator) { FileSizeValidator.new(options) } + let(:attachment) { AttachmentUploader.new } + let(:note) { create(:note) } + + describe 'options uses an integer' do + let(:options) { { maximum: 10, attributes: { attachment: attachment } } } + + it 'attachment exceeds maximum limit' do + allow(attachment).to receive(:size) { 100 } + validator.validate_each(note, :attachment, attachment) + expect(note.errors).to have_key(:attachment) + end + + it 'attachment under maximum limit' do + allow(attachment).to receive(:size) { 1 } + validator.validate_each(note, :attachment, attachment) + expect(note.errors).not_to have_key(:attachment) + end + end + + describe 'options uses a symbol' do + let(:options) { { maximum: :test, + attributes: { attachment: attachment } } } + before do + allow(note).to receive(:test) { 10 } + end + + it 'attachment exceeds maximum limit' do + allow(attachment).to receive(:size) { 100 } + validator.validate_each(note, :attachment, attachment) + expect(note.errors).to have_key(:attachment) + end + + it 'attachment under maximum limit' do + allow(attachment).to receive(:size) { 1 } + validator.validate_each(note, :attachment, attachment) + expect(note.errors).not_to have_key(:attachment) + end + end +end diff --git a/spec/services/projects/upload_service_spec.rb b/spec/services/projects/upload_service_spec.rb index fc34b45648..e5c47015a0 100644 --- a/spec/services/projects/upload_service_spec.rb +++ b/spec/services/projects/upload_service_spec.rb @@ -67,6 +67,16 @@ describe Projects::UploadService do it { expect(@link_to_file['url']).to match("/#{@project.path_with_namespace}") } it { expect(@link_to_file['url']).to match('doc_sample.txt') } end + + context 'for too large a file' do + before do + txt = fixture_file_upload(Rails.root + 'spec/fixtures/doc_sample.txt', 'text/plain') + allow(txt).to receive(:size) { 1000.megabytes.to_i } + @link_to_file = upload_file(@project.repository, txt) + end + + it { expect(@link_to_file).to eq(nil) } + end end def upload_file(repository, file) From b1ef1aa59f5ccb78be6d2462b56ed6bafebe65c0 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 27 Mar 2015 11:37:45 +0100 Subject: [PATCH 017/194] Slightly refactor ReferenceExtractor. --- app/models/concerns/mentionable.rb | 8 ++- lib/gitlab/closing_issue_extractor.rb | 6 +-- lib/gitlab/reference_extractor.rb | 70 +++++++++++++-------------- 3 files changed, 41 insertions(+), 43 deletions(-) diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb index d96e07034e..52eb87d1db 100644 --- a/app/models/concerns/mentionable.rb +++ b/app/models/concerns/mentionable.rb @@ -65,12 +65,10 @@ module Mentionable # Extract GFM references to other Mentionables from this Mentionable. Always excludes its #local_reference. def references(p = project, text = mentionable_text) return [] if text.blank? - ext = Gitlab::ReferenceExtractor.new - ext.analyze(text, p) + ext = Gitlab::ReferenceExtractor.new(p) + ext.analyze(text) - (ext.issues_for(p) + - ext.merge_requests_for(p) + - ext.commits_for(p)).uniq - [local_reference] + (ext.issues + ext.merge_requests + ext.commits).uniq - [local_reference] end # Create a cross-reference Note for each GFM reference to another Mentionable found in +mentionable_text+. diff --git a/lib/gitlab/closing_issue_extractor.rb b/lib/gitlab/closing_issue_extractor.rb index a9fd59f03d..5643d60c80 100644 --- a/lib/gitlab/closing_issue_extractor.rb +++ b/lib/gitlab/closing_issue_extractor.rb @@ -9,9 +9,9 @@ module Gitlab md = message.scan(ISSUE_CLOSING_REGEX) md.each do |ref| - extractor = Gitlab::ReferenceExtractor.new - extractor.analyze(ref[0], project) - issues += extractor.issues_for(project) + extractor = Gitlab::ReferenceExtractor.new(project) + extractor.analyze(ref[0]) + issues += extractor.issues end end diff --git a/lib/gitlab/reference_extractor.rb b/lib/gitlab/reference_extractor.rb index 1058d4c43d..90e0481871 100644 --- a/lib/gitlab/reference_extractor.rb +++ b/lib/gitlab/reference_extractor.rb @@ -1,89 +1,89 @@ module Gitlab # Extract possible GFM references from an arbitrary String for further processing. class ReferenceExtractor - attr_accessor :users, :labels, :issues, :merge_requests, :snippets, :commits, :commit_ranges + attr_accessor :project, :references include Markdown - def initialize - @users, @labels, @issues, @merge_requests, @snippets, @commits, @commit_ranges = - [], [], [], [], [], [], [] + def initialize(project) + @project = project + + @references = Hash.new { [] } end - def analyze(string, project) - text = string.dup + def analyze(text) + text = text.dup # Remove preformatted/code blocks so that references are not included text.gsub!(%r{
.*?
|.*?}m) { |match| '' } text.gsub!(%r{^```.*?^```}m) { |match| '' } - parse_references(text, project) + parse_references(text) end # Given a valid project, resolve the extracted identifiers of the requested type to # model objects. - def users_for(project) - users.map do |entry| + def users + references[:users].map do |entry| project.users.where(username: entry[:id]).first - end.reject(&:nil?) + end.compact end - def labels_for(project = nil) - labels.map do |entry| + def labels + references[:labels].map do |entry| project.labels.where(id: entry[:id]).first - end.reject(&:nil?) + end.compact end - def issues_for(project = nil) - issues.map do |entry| - if should_lookup?(project, entry[:project]) + def issues + references[:issues].map do |entry| + if should_lookup?(entry[:project]) entry[:project].issues.where(iid: entry[:id]).first end - end.reject(&:nil?) + end.compact end - def merge_requests_for(project = nil) - merge_requests.map do |entry| - if should_lookup?(project, entry[:project]) + def merge_requests + references[:merge_requests].map do |entry| + if should_lookup?(entry[:project]) entry[:project].merge_requests.where(iid: entry[:id]).first end - end.reject(&:nil?) + end.compact end - def snippets_for(project) - snippets.map do |entry| + def snippets + references[:snippets].map do |entry| project.snippets.where(id: entry[:id]).first - end.reject(&:nil?) + end.compact end - def commits_for(project = nil) - commits.map do |entry| + def commits + references[:commits].map do |entry| repo = entry[:project].repository if entry[:project] - if should_lookup?(project, entry[:project]) + if should_lookup?(entry[:project]) repo.commit(entry[:id]) if repo end - end.reject(&:nil?) + end.compact end - def commit_ranges_for(project = nil) - commit_ranges.map do |entry| + def commit_ranges + references[:commit_ranges].map do |entry| repo = entry[:project].repository if entry[:project] - if repo && should_lookup?(project, entry[:project]) + if repo && should_lookup?(entry[:project]) from_id, to_id = entry[:id].split(/\.{2,3}/, 2) [repo.commit(from_id), repo.commit(to_id)] end - end.reject(&:nil?) + end.compact end private def reference_link(type, identifier, project, _) - # Append identifier to the appropriate collection. - send("#{type}s") << { project: project, id: identifier } + references[type] << { project: project, id: identifier } end - def should_lookup?(project, entry_project) + def should_lookup?(entry_project) if entry_project.nil? false else From ca58e369c9f2a72402cfcf4d86d29c115b1b909c Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 27 Mar 2015 11:38:22 +0100 Subject: [PATCH 018/194] Only allow user to reference objects they have access to. --- app/helpers/gitlab_markdown_helper.rb | 2 +- lib/gitlab/markdown.rb | 38 ++++++++++++++------------- lib/gitlab/reference_extractor.rb | 14 +++++++--- 3 files changed, 32 insertions(+), 22 deletions(-) diff --git a/app/helpers/gitlab_markdown_helper.rb b/app/helpers/gitlab_markdown_helper.rb index 7ca3f05863..a4157d6253 100644 --- a/app/helpers/gitlab_markdown_helper.rb +++ b/app/helpers/gitlab_markdown_helper.rb @@ -19,7 +19,7 @@ module GitlabMarkdownHelper escape_once(body) end - gfm_body = gfm(escaped_body, @project, html_options) + gfm_body = gfm(escaped_body, @project, current_user, html_options) gfm_body.gsub!(%r{.*?
}m) do |match| "#{match}#{link_to("", url, html_options)[0..-5]}" # "".length +1 diff --git a/lib/gitlab/markdown.rb b/lib/gitlab/markdown.rb index 1dbea48ac1..7a3c8823e4 100644 --- a/lib/gitlab/markdown.rb +++ b/lib/gitlab/markdown.rb @@ -39,7 +39,7 @@ module Gitlab # text - the source text # project - the project # html_options - extra options for the reference links as given to link_to - def gfm(text, project = @project, html_options = {}) + def gfm(text, project = @project, user = current_user, html_options = {}) gfm_with_options(text, {}, project, html_options) end @@ -51,7 +51,7 @@ module Gitlab # - reference_only_path - Use relative path for reference links # project - the project # html_options - extra options for the reference links as given to link_to - def gfm_with_options(text, options = {}, project = @project, html_options = {}) + def gfm_with_options(text, options = {}, project = @project, user = current_user, html_options = {}) return text if text.nil? # Duplicate the string so we don't alter the original, then call to_str @@ -78,7 +78,7 @@ module Gitlab # TODO: add popups with additional information - text = parse(text, project) + text = parse(text, project, user) # Insert pre block extractions text.gsub!(/\{gfm-extraction-(\h{32})\}/) do @@ -155,8 +155,8 @@ module Gitlab # text - Text to parse # # Returns parsed text - def parse(text, project = @project) - parse_references(text, project) if project + def parse(text, project = @project, user = current_user) + parse_references(text, project, user) if project text end @@ -182,7 +182,7 @@ module Gitlab TYPES = [:user, :issue, :label, :merge_request, :snippet, :commit, :commit_range].freeze - def parse_references(text, project = @project) + def parse_references(text, project = @project, user = current_user) # parse reference links text.gsub!(REFERENCE_PATTERN) do |match| type = TYPES.select{|t| !$~[t].nil?}.first @@ -192,11 +192,12 @@ module Gitlab project_path = $LAST_MATCH_INFO[:project] if project_path actual_project = ::Project.find_with_namespace(project_path) + actual_project ||= nil unless can?(user, :read_project, actual_project) project_prefix = project_path end parse_result($LAST_MATCH_INFO, type, - actual_project, project_prefix) || match + actual_project, user, project_prefix) || match end end @@ -204,7 +205,7 @@ module Gitlab # link. Returns nil if +type+ is nil, if the match string is an HTML # entity, if the reference is invalid, or if the matched text includes an # invalid project path. - def parse_result(match_info, type, project, project_prefix) + def parse_result(match_info, type, project, user, project_prefix) prefix = match_info[:prefix] suffix = match_info[:suffix] @@ -212,7 +213,7 @@ module Gitlab return nil if project.nil? && !project_prefix.nil? identifier = match_info[type] - ref_link = reference_link(type, identifier, project, project_prefix) + ref_link = reference_link(type, identifier, project, user, project_prefix) if ref_link "#{prefix}#{ref_link}#{suffix}" @@ -233,11 +234,11 @@ module Gitlab # identifier - Object identifier (Issue ID, SHA hash, etc.) # # Returns string rendered by the processing method - def reference_link(type, identifier, project = @project, prefix_text = nil) + def reference_link(type, identifier, project = @project, user = current_user, prefix_text = nil) send("reference_#{type}", identifier, project, prefix_text) end - def reference_user(identifier, project = @project, _ = nil) + def reference_user(identifier, project = @project, user = current_user, _ = nil) link_options = html_options.merge( class: "gfm gfm-project_member #{html_options[:class]}" ) @@ -251,6 +252,7 @@ module Gitlab elsif namespace = Namespace.find_by(path: identifier) url = if namespace.is_a?(Group) + return nil unless can?(user, :read_group, namespace) group_url(identifier, only_path: options[:reference_only_path]) else user_url(identifier, only_path: options[:reference_only_path]) @@ -260,7 +262,7 @@ module Gitlab end end - def reference_label(identifier, project = @project, _ = nil) + def reference_label(identifier, project = @project, user = current_user, _ = nil) if label = project.labels.find_by(id: identifier) link_options = html_options.merge( class: "gfm gfm-label #{html_options[:class]}" @@ -273,7 +275,7 @@ module Gitlab end end - def reference_issue(identifier, project = @project, prefix_text = nil) + def reference_issue(identifier, project = @project, user = current_user, prefix_text = nil) if project.default_issues_tracker? if project.issue_exists? identifier url = url_for_issue(identifier, project, only_path: options[:reference_only_path]) @@ -293,7 +295,7 @@ module Gitlab end end - def reference_merge_request(identifier, project = @project, prefix_text = nil) + def reference_merge_request(identifier, project = @project, user = current_user, prefix_text = nil) if merge_request = project.merge_requests.find_by(iid: identifier) link_options = html_options.merge( title: "Merge Request: #{merge_request.title}", @@ -306,7 +308,7 @@ module Gitlab end end - def reference_snippet(identifier, project = @project, _ = nil) + def reference_snippet(identifier, project = @project, user = current_user, _ = nil) if snippet = project.snippets.find_by(id: identifier) link_options = html_options.merge( title: "Snippet: #{snippet.title}", @@ -321,7 +323,7 @@ module Gitlab end end - def reference_commit(identifier, project = @project, prefix_text = nil) + def reference_commit(identifier, project = @project, user = current_user, prefix_text = nil) if project.valid_repo? && commit = project.repository.commit(identifier) link_options = html_options.merge( title: commit.link_title, @@ -337,7 +339,7 @@ module Gitlab end end - def reference_commit_range(identifier, project = @project, prefix_text = nil) + def reference_commit_range(identifier, project = @project, user = current_user, prefix_text = nil) from_id, to_id = identifier.split(/\.{2,3}/, 2) inclusive = identifier !~ /\.{3}/ @@ -363,7 +365,7 @@ module Gitlab end end - def reference_external_issue(identifier, project = @project, prefix_text = nil) + def reference_external_issue(identifier, project = @project, user = current_user, prefix_text = nil) url = url_for_issue(identifier, project, only_path: options[:reference_only_path]) title = project.external_issue_tracker.title diff --git a/lib/gitlab/reference_extractor.rb b/lib/gitlab/reference_extractor.rb index 90e0481871..3bf6f14a7b 100644 --- a/lib/gitlab/reference_extractor.rb +++ b/lib/gitlab/reference_extractor.rb @@ -1,16 +1,24 @@ module Gitlab # Extract possible GFM references from an arbitrary String for further processing. class ReferenceExtractor - attr_accessor :project, :references + attr_accessor :project, :current_user, :references include Markdown - def initialize(project) + def initialize(project, current_user = nil) @project = project + @current_user = user @references = Hash.new { [] } end + def can?(user, action, subject) + # When extracting references, no user means access to everything. + return true if user.nil? + + Ability.abilities.allowed?(user, action, subject) + end + def analyze(text) text = text.dup @@ -79,7 +87,7 @@ module Gitlab private - def reference_link(type, identifier, project, _) + def reference_link(type, identifier, project, user, _) references[type] << { project: project, id: identifier } end From d2bd60675913bba47a6c3fb43a282da6bfe20646 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 27 Mar 2015 11:45:06 +0100 Subject: [PATCH 019/194] Refactor Mentionable mentioned users to use ReferenceExtractor. --- app/models/concerns/mentionable.rb | 23 ++++++----------------- lib/gitlab/reference_extractor.rb | 12 ++++++++++-- 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb index 52eb87d1db..db75a34f59 100644 --- a/app/models/concerns/mentionable.rb +++ b/app/models/concerns/mentionable.rb @@ -43,28 +43,17 @@ module Mentionable end def mentioned_users - users = [] - return users if mentionable_text.blank? - has_project = self.respond_to? :project - matches = mentionable_text.scan(/@[a-zA-Z][a-zA-Z0-9_\-\.]*/) - matches.each do |match| - identifier = match.delete "@" - if identifier == "all" - users.push(*project.team.members.flatten) - elsif namespace = Namespace.find_by(path: identifier) - if namespace.is_a?(Group) - users.push(*namespace.users) - else - users << namespace.owner - end - end - end - users.uniq + return [] if mentionable_text.blank? + + ext = Gitlab::ReferenceExtractor.new(self.project) + ext.analyze(text) + ext.users.uniq end # Extract GFM references to other Mentionables from this Mentionable. Always excludes its #local_reference. def references(p = project, text = mentionable_text) return [] if text.blank? + ext = Gitlab::ReferenceExtractor.new(p) ext.analyze(text) diff --git a/lib/gitlab/reference_extractor.rb b/lib/gitlab/reference_extractor.rb index 3bf6f14a7b..422d35aceb 100644 --- a/lib/gitlab/reference_extractor.rb +++ b/lib/gitlab/reference_extractor.rb @@ -34,8 +34,16 @@ module Gitlab def users references[:users].map do |entry| - project.users.where(username: entry[:id]).first - end.compact + if entry[:id] == "all" + project.team.members.flatten + elsif namespace = Namespace.find_by(path: entry[:id]) + if namespace.is_a?(Group) + namespace.users + else + namespace.owner + end + end + end.flatten.compact end def labels From 403b727138e22ce8ba83332ab03fa21fb7f72a1c Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 27 Mar 2015 11:53:23 +0100 Subject: [PATCH 020/194] Slightly refactor getting note notification recipients. --- app/models/concerns/mentionable.rb | 2 +- app/services/notification_service.rb | 37 +++++++++++++++++----------- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb index db75a34f59..1ab0a28fdd 100644 --- a/app/models/concerns/mentionable.rb +++ b/app/models/concerns/mentionable.rb @@ -53,7 +53,7 @@ module Mentionable # Extract GFM references to other Mentionables from this Mentionable. Always excludes its #local_reference. def references(p = project, text = mentionable_text) return [] if text.blank? - + ext = Gitlab::ReferenceExtractor.new(p) ext.analyze(text) diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index cc5853144c..42547f6f48 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -123,32 +123,29 @@ class NotificationService return true if note.note.start_with?('Status changed to closed') return true if note.cross_reference? && note.system == true - opts = { noteable_type: note.noteable_type, project_id: note.project_id } - target = note.noteable - if target.respond_to?(:participants) - recipients = target.participants - else - recipients = note.mentioned_users - end + recipients = [] if note.commit_id.present? - opts.merge!(commit_id: note.commit_id) recipients << note.commit_author - else - opts.merge!(noteable_id: note.noteable_id) end # Get users who left comment in thread - recipients = recipients.concat(User.where(id: Note.where(opts).pluck(:author_id))) + recipients = recipients.concat(noteable_commenters(note)) # Merge project watchers recipients = recipients.concat(project_watchers(note.project)).compact.uniq - # Reject mention users unless mentioned in comment - recipients = reject_mention_users(recipients - note.mentioned_users, note.project) - recipients = recipients + note.mentioned_users + # Reject users with Mention notification level + recipients = reject_mention_users(recipients, note.project) + + # Add explicitly mentioned users + if target.respond_to?(:participants) + recipients = recipients.concat(target.participants) + else + recipients = recipients.concat(note.mentioned_users) + end # Reject mutes users recipients = reject_muted_users(recipients, note.project) @@ -195,6 +192,18 @@ class NotificationService protected + def noteable_commenters(note) + opts = { noteable_type: note.noteable_type, project_id: note.project_id } + + if note.commit_id.present? + opts.merge!(commit_id: note.commit_id) + else + opts.merge!(noteable_id: note.noteable_id) + end + + User.where(id: Note.where(opts).pluck(:author_id)) + end + # Get project users with WATCH notification level def project_watchers(project) project_members = project_member_notification(project) From d9698628d9042e820917e1144535888da147c228 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 27 Mar 2015 12:16:34 +0100 Subject: [PATCH 021/194] Add Commit#author and #committer. --- app/helpers/commits_helper.rb | 7 ++++--- app/models/commit.rb | 8 ++++++++ app/services/git_push_service.rb | 2 +- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index 5aae697e2f..d13d80be29 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -134,12 +134,13 @@ module CommitsHelper # avatar: true will prepend the avatar image # size: size of the avatar image in px def commit_person_link(commit, options = {}) + user = commit.send(options[:source]) + source_name = clean(commit.send "#{options[:source]}_name".to_sym) source_email = clean(commit.send "#{options[:source]}_email".to_sym) - user = User.find_for_commit(source_email, source_name) - person_name = user.nil? ? source_name : user.name - person_email = user.nil? ? source_email : user.email + person_name = user.try(:name) || source_name + person_email = user.try(:email) || source_email text = if options[:avatar] diff --git a/app/models/commit.rb b/app/models/commit.rb index e0461809e1..481300171b 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -126,6 +126,14 @@ class Commit "commit #{id}" end + def author + User.find_for_commit(author_email, author_name) + end + + def committer + User.find_for_commit(committer_email, committer_name) + end + def method_missing(m, *args, &block) @raw.send(m, *args, &block) end diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb index 1f0b29dff5..bb066e39f6 100644 --- a/app/services/git_push_service.rb +++ b/app/services/git_push_service.rb @@ -127,6 +127,6 @@ class GitPushService end def commit_user(commit) - User.find_for_commit(commit.author_email, commit.author_name) || user + commit.author || user end end From 756e7aa8c347ccf211db03a7fa16ab33c021f820 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 27 Mar 2015 12:18:15 +0100 Subject: [PATCH 022/194] Don't allow full access to guests in ReferenceExtractor --- lib/gitlab/reference_extractor.rb | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/gitlab/reference_extractor.rb b/lib/gitlab/reference_extractor.rb index 422d35aceb..719274394f 100644 --- a/lib/gitlab/reference_extractor.rb +++ b/lib/gitlab/reference_extractor.rb @@ -13,9 +13,6 @@ module Gitlab end def can?(user, action, subject) - # When extracting references, no user means access to everything. - return true if user.nil? - Ability.abilities.allowed?(user, action, subject) end From e62b5a2b072eb1bc8587b095e906bd194475eacc Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 27 Mar 2015 12:19:34 +0100 Subject: [PATCH 023/194] Only allow user to see participants from groups they have access to. --- app/models/concerns/issuable.rb | 8 ++++---- app/models/concerns/mentionable.rb | 4 ++-- app/services/projects/participants_service.rb | 4 ++-- app/views/projects/issues/_discussion.html.haml | 4 ++-- .../projects/merge_requests/show/_participants.html.haml | 4 ++-- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 88ac83744d..478134dff6 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -118,16 +118,16 @@ module Issuable end # Return all users participating on the discussion - def participants + def participants(current_user = self.author) users = [] users << author users << assignee if is_assigned? mentions = [] - mentions << self.mentioned_users + mentions << self.mentioned_users(current_user) notes.each do |note| users << note.author - mentions << note.mentioned_users + mentions << note.mentioned_users(current_user) end users.concat(mentions.reduce([], :|)).uniq @@ -140,7 +140,7 @@ module Issuable return subscription.subscribed end - participants.include?(user) + participants(user).include?(user) end def toggle_subscription(user) diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb index 1ab0a28fdd..41bcfa6be2 100644 --- a/app/models/concerns/mentionable.rb +++ b/app/models/concerns/mentionable.rb @@ -42,10 +42,10 @@ module Mentionable Note.cross_reference_exists?(target, local_reference) end - def mentioned_users + def mentioned_users(current_user = nil) return [] if mentionable_text.blank? - ext = Gitlab::ReferenceExtractor.new(self.project) + ext = Gitlab::ReferenceExtractor.new(self.project, current_user) ext.analyze(text) ext.users.uniq end diff --git a/app/services/projects/participants_service.rb b/app/services/projects/participants_service.rb index bcbacbff56..bcdde0950c 100644 --- a/app/services/projects/participants_service.rb +++ b/app/services/projects/participants_service.rb @@ -21,10 +21,10 @@ module Projects users = case type when "Issue" issue = @project.issues.find_by_iid(id) - issue ? issue.participants : [] + issue ? issue.participants(user) : [] when "MergeRequest" merge_request = @project.merge_requests.find_by_iid(id) - merge_request ? merge_request.participants : [] + merge_request ? merge_request.participants(user) : [] when "Commit" author_ids = Note.for_commit_id(id).pluck(:author_id).uniq User.where(id: author_ids) diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml index 0d3028d50b..288b48f458 100644 --- a/app/views/projects/issues/_discussion.html.haml +++ b/app/views/projects/issues/_discussion.html.haml @@ -9,8 +9,8 @@ .votes-holder.pull-right #votes= render 'votes/votes_block', votable: @issue .participants - %span= pluralize(@issue.participants.count, 'participant') - - @issue.participants.each do |participant| + %span= pluralize(@issue.participants(current_user).count, 'participant') + - @issue.participants(current_user).each do |participant| = link_to_member(@project, participant, name: false, size: 24) .voting_notes#notes= render "projects/notes/notes_with_form" %aside.col-md-3 diff --git a/app/views/projects/merge_requests/show/_participants.html.haml b/app/views/projects/merge_requests/show/_participants.html.haml index 4f34af1737..9c93fa55fe 100644 --- a/app/views/projects/merge_requests/show/_participants.html.haml +++ b/app/views/projects/merge_requests/show/_participants.html.haml @@ -1,4 +1,4 @@ .participants - %span #{@merge_request.participants.count} participants - - @merge_request.participants.each do |participant| + %span #{@merge_request.participants(current_user).count} participants + - @merge_request.participants(current_user).each do |participant| = link_to_member(@project, participant, name: false, size: 24) From 65bb0c34066eef77647e1d60e6bac7a12993cd93 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 27 Mar 2015 12:19:48 +0100 Subject: [PATCH 024/194] Only allow users to cross-reference and close issues they have access to. --- app/models/commit.rb | 4 ++-- app/models/concerns/mentionable.rb | 6 +++--- app/models/merge_request.rb | 6 +++--- app/services/git_push_service.rb | 4 ++-- lib/gitlab/closing_issue_extractor.rb | 4 ++-- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/app/models/commit.rb b/app/models/commit.rb index 481300171b..084cf42007 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -117,8 +117,8 @@ class Commit # Discover issues should be closed when this commit is pushed to a project's # default branch. - def closes_issues(project) - Gitlab::ClosingIssueExtractor.closed_by_message_in_project(safe_message, project) + def closes_issues(project, current_user = self.committer) + Gitlab::ClosingIssueExtractor.closed_by_message_in_project(safe_message, project, current_user) end # Mentionable override. diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb index 41bcfa6be2..bf0465728e 100644 --- a/app/models/concerns/mentionable.rb +++ b/app/models/concerns/mentionable.rb @@ -51,10 +51,10 @@ module Mentionable end # Extract GFM references to other Mentionables from this Mentionable. Always excludes its #local_reference. - def references(p = project, text = mentionable_text) + def references(p = project, current_user = self.author, text = mentionable_text) return [] if text.blank? - ext = Gitlab::ReferenceExtractor.new(p) + ext = Gitlab::ReferenceExtractor.new(p, current_user) ext.analyze(text) (ext.issues + ext.merge_requests + ext.commits).uniq - [local_reference] @@ -83,7 +83,7 @@ module Mentionable # Only proceed if the saved changes actually include a chance to an attr_mentionable field. return unless mentionable_changed - preexisting = references(p, original) + preexisting = references(p, self.author, original) create_cross_references!(p, a, preexisting) end end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 5634f9a686..167d7f9c71 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -257,11 +257,11 @@ class MergeRequest < ActiveRecord::Base end # Return the set of issues that will be closed if this merge request is accepted. - def closes_issues + def closes_issues(current_user = self.author) if target_branch == project.default_branch - issues = commits.flat_map { |c| c.closes_issues(project) } + issues = commits.flat_map { |c| c.closes_issues(project, current_user) } issues.push(*Gitlab::ClosingIssueExtractor. - closed_by_message_in_project(description, project)) + closed_by_message_in_project(description, project, current_user)) issues.uniq.sort_by(&:id) else [] diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb index bb066e39f6..31e0167d24 100644 --- a/app/services/git_push_service.rb +++ b/app/services/git_push_service.rb @@ -70,7 +70,7 @@ class GitPushService # Close issues if these commits were pushed to the project's default branch and the commit message matches the # closing regex. Exclude any mentioned Issues from cross-referencing even if the commits are being pushed to # a different branch. - issues_to_close = commit.closes_issues(project) + issues_to_close = commit.closes_issues(project, user) # Load commit author only if needed. # For push with 1k commits it prevents 900+ requests in database @@ -87,7 +87,7 @@ class GitPushService # Create cross-reference notes for any other references. Omit any issues that were referenced in an # issue-closing phrase, or have already been mentioned from this commit (probably from this commit # being pushed to a different branch). - refs = commit.references(project) - issues_to_close + refs = commit.references(project, user) - issues_to_close refs.reject! { |r| commit.has_mentioned?(r) } if refs.present? diff --git a/lib/gitlab/closing_issue_extractor.rb b/lib/gitlab/closing_issue_extractor.rb index 5643d60c80..6425193d85 100644 --- a/lib/gitlab/closing_issue_extractor.rb +++ b/lib/gitlab/closing_issue_extractor.rb @@ -2,14 +2,14 @@ module Gitlab module ClosingIssueExtractor ISSUE_CLOSING_REGEX = Regexp.new(Gitlab.config.gitlab.issue_closing_pattern) - def self.closed_by_message_in_project(message, project) + def self.closed_by_message_in_project(message, project, current_user = nil) issues = [] unless message.nil? md = message.scan(ISSUE_CLOSING_REGEX) md.each do |ref| - extractor = Gitlab::ReferenceExtractor.new(project) + extractor = Gitlab::ReferenceExtractor.new(project, current_user) extractor.analyze(ref[0]) issues += extractor.issues end From 8361ba81abbab2d28ae5018daf531992710dc4ca Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 27 Mar 2015 12:20:30 +0100 Subject: [PATCH 025/194] Add changelog item. --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index 259e1a3007..28aac95362 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -52,6 +52,7 @@ v 7.10.0 (unreleased) - Don't show commit comment button when user is not signed in. - Fix admin user projects lists. - Don't leak private group existence by redirecting from namespace controller to group controller. + - Only allow users to reference groups, projects, issues, MRs, commits they have access to. v 7.9.2 - Contains no changes From b277f23a538ffbbf100719f4d7434a9e458d5b5e Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Thu, 2 Apr 2015 07:02:47 -0700 Subject: [PATCH 026/194] Improve regexp to prevent false positives If a filename happened to contain "db" and enough "rwx" characters before, then this test would previously fail. For example: ``` drwxr-xr-x gitlab-runner/gitlab-runner 0 2015-04-02 07:46 uploads/tmp/cassidy.stokes8477/gitlabhq/36d972fa55d6b44810fc6fd843473adb/ ``` Adding a space before the "db" match string tightens up the regexp and reduces the chance of unintended match. --- spec/tasks/gitlab/backup_rake_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/tasks/gitlab/backup_rake_spec.rb b/spec/tasks/gitlab/backup_rake_spec.rb index 3d5d21c2a1..199ff7e162 100644 --- a/spec/tasks/gitlab/backup_rake_spec.rb +++ b/spec/tasks/gitlab/backup_rake_spec.rb @@ -87,7 +87,7 @@ describe 'gitlab:app namespace rake task' do expect(tar_contents).to match('db/') expect(tar_contents).to match('uploads/') expect(tar_contents).to match('repositories/') - expect(tar_contents).not_to match(/^.{4,9}[rwx].*(db|uploads|repositories)\/$/) + expect(tar_contents).not_to match(/^.{4,9}[rwx].* (db|uploads|repositories)\/$/) end it 'should delete temp directories' do From c5d7660000be72dd03ac52641debbd2bcf6fbc4d Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 27 Mar 2015 12:58:23 +0100 Subject: [PATCH 027/194] Fix errors. --- app/mailers/emails/groups.rb | 1 + app/mailers/emails/profile.rb | 6 +- app/mailers/emails/projects.rb | 5 +- app/mailers/notify.rb | 12 +- app/models/concerns/mentionable.rb | 2 +- app/services/projects/participants_service.rb | 19 +-- lib/gitlab/markdown.rb | 4 +- lib/gitlab/reference_extractor.rb | 24 +-- spec/helpers/gitlab_markdown_helper_spec.rb | 8 + spec/lib/gitlab/reference_extractor_spec.rb | 153 +++++++++--------- 10 files changed, 114 insertions(+), 120 deletions(-) diff --git a/app/mailers/emails/groups.rb b/app/mailers/emails/groups.rb index 26f43bf955..626eb593d5 100644 --- a/app/mailers/emails/groups.rb +++ b/app/mailers/emails/groups.rb @@ -4,6 +4,7 @@ module Emails @group_member = GroupMember.find(group_member_id) @group = @group_member.group @target_url = group_url(@group) + @current_user = @group_member.user mail(to: @group_member.user.email, subject: subject("Access to group was granted")) end diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb index ab5b076535..3a83b08310 100644 --- a/app/mailers/emails/profile.rb +++ b/app/mailers/emails/profile.rb @@ -1,7 +1,7 @@ module Emails module Profile def new_user_email(user_id, token = nil) - @user = User.find(user_id) + @current_user = @user = User.find(user_id) @target_url = user_url(@user) @token = token mail(to: @user.notification_email, subject: subject("Account was created for you")) @@ -9,13 +9,13 @@ module Emails def new_email_email(email_id) @email = Email.find(email_id) - @user = @email.user + @current_user = @user = @email.user mail(to: @user.notification_email, subject: subject("Email was added to your account")) end def new_ssh_key_email(key_id) @key = Key.find(key_id) - @user = @key.user + @current_user = @user = @key.user @target_url = user_url(@user) mail(to: @user.notification_email, subject: subject("SSH key was added to your account")) end diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb index 3cd812825e..20a863c374 100644 --- a/app/mailers/emails/projects.rb +++ b/app/mailers/emails/projects.rb @@ -4,12 +4,13 @@ module Emails @project_member = ProjectMember.find user_project_id @project = @project_member.project @target_url = namespace_project_url(@project.namespace, @project) + @current_user = @project_member.user mail(to: @project_member.user.email, subject: subject("Access to project was granted")) end def project_was_moved_email(project_id, user_id) - @user = User.find user_id + @current_user = @user = User.find user_id @project = Project.find project_id @target_url = namespace_project_url(@project.namespace, @project) mail(to: @user.notification_email, @@ -28,7 +29,7 @@ module Emails end @project = Project.find(project_id) - @author = User.find(author_id) + @current_user = @author = User.find(author_id) @reverse_compare = reverse_compare @compare = compare @ref_name = Gitlab::Git.ref_name(ref) diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb index 8fcdd3bc85..5dbbfac9c8 100644 --- a/app/mailers/notify.rb +++ b/app/mailers/notify.rb @@ -13,6 +13,9 @@ class Notify < ActionMailer::Base add_template_helper MergeRequestsHelper add_template_helper EmailsHelper + attr_accessor :current_user + helper_method :current_user, :can? + default_url_options[:host] = Gitlab.config.gitlab.host default_url_options[:protocol] = Gitlab.config.gitlab.protocol default_url_options[:port] = Gitlab.config.gitlab.port unless Gitlab.config.gitlab_on_standard_port? @@ -79,9 +82,8 @@ class Notify < ActionMailer::Base # # Returns a String containing the User's email address. def recipient(recipient_id) - if recipient = User.find(recipient_id) - recipient.notification_email - end + @current_user = User.find(recipient_id) + @current_user.notification_email end # Set the References header field @@ -154,4 +156,8 @@ class Notify < ActionMailer::Base mail(headers, &block) end + + def can? + Ability.abilities.allowed?(user, action, subject) + end end diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb index bf0465728e..b7882a2bb1 100644 --- a/app/models/concerns/mentionable.rb +++ b/app/models/concerns/mentionable.rb @@ -46,7 +46,7 @@ module Mentionable return [] if mentionable_text.blank? ext = Gitlab::ReferenceExtractor.new(self.project, current_user) - ext.analyze(text) + ext.analyze(mentionable_text) ext.users.uniq end diff --git a/app/services/projects/participants_service.rb b/app/services/projects/participants_service.rb index bcdde0950c..ae6260bcda 100644 --- a/app/services/projects/participants_service.rb +++ b/app/services/projects/participants_service.rb @@ -1,10 +1,5 @@ module Projects class ParticipantsService < BaseService - def initialize(project, user) - @project = project - @user = user - end - def execute(note_type, note_id) participating = if note_type && note_id @@ -12,7 +7,7 @@ module Projects else [] end - project_members = sorted(@project.team.members) + project_members = sorted(project.team.members) participants = all_members + groups + project_members + participating participants.uniq end @@ -20,11 +15,11 @@ module Projects def participants_in(type, id) users = case type when "Issue" - issue = @project.issues.find_by_iid(id) - issue ? issue.participants(user) : [] + issue = project.issues.find_by_iid(id) + issue ? issue.participants(current_user) : [] when "MergeRequest" - merge_request = @project.merge_requests.find_by_iid(id) - merge_request ? merge_request.participants(user) : [] + merge_request = project.merge_requests.find_by_iid(id) + merge_request ? merge_request.participants(current_user) : [] when "Commit" author_ids = Note.for_commit_id(id).pluck(:author_id).uniq User.where(id: author_ids) @@ -41,14 +36,14 @@ module Projects end def groups - @user.authorized_groups.sort_by(&:path).map do |group| + current_user.authorized_groups.sort_by(&:path).map do |group| count = group.users.count { username: group.path, name: "#{group.name} (#{count})" } end end def all_members - count = @project.team.members.flatten.count + count = project.team.members.flatten.count [{ username: "all", name: "All Project and Group Members (#{count})" }] end end diff --git a/lib/gitlab/markdown.rb b/lib/gitlab/markdown.rb index 7a3c8823e4..e7c261b745 100644 --- a/lib/gitlab/markdown.rb +++ b/lib/gitlab/markdown.rb @@ -192,7 +192,7 @@ module Gitlab project_path = $LAST_MATCH_INFO[:project] if project_path actual_project = ::Project.find_with_namespace(project_path) - actual_project ||= nil unless can?(user, :read_project, actual_project) + actual_project = nil unless can?(user, :read_project, actual_project) project_prefix = project_path end @@ -235,7 +235,7 @@ module Gitlab # # Returns string rendered by the processing method def reference_link(type, identifier, project = @project, user = current_user, prefix_text = nil) - send("reference_#{type}", identifier, project, prefix_text) + send("reference_#{type}", identifier, project, user, prefix_text) end def reference_user(identifier, project = @project, user = current_user, _ = nil) diff --git a/lib/gitlab/reference_extractor.rb b/lib/gitlab/reference_extractor.rb index 719274394f..2f38c1dcc8 100644 --- a/lib/gitlab/reference_extractor.rb +++ b/lib/gitlab/reference_extractor.rb @@ -7,7 +7,7 @@ module Gitlab def initialize(project, current_user = nil) @project = project - @current_user = user + @current_user = current_user @references = Hash.new { [] } end @@ -51,7 +51,7 @@ module Gitlab def issues references[:issues].map do |entry| - if should_lookup?(entry[:project]) + if entry[:project].default_issues_tracker? entry[:project].issues.where(iid: entry[:id]).first end end.compact @@ -59,9 +59,7 @@ module Gitlab def merge_requests references[:merge_requests].map do |entry| - if should_lookup?(entry[:project]) - entry[:project].merge_requests.where(iid: entry[:id]).first - end + entry[:project].merge_requests.where(iid: entry[:id]).first end.compact end @@ -73,17 +71,15 @@ module Gitlab def commits references[:commits].map do |entry| - repo = entry[:project].repository if entry[:project] - if should_lookup?(entry[:project]) - repo.commit(entry[:id]) if repo - end + repo = entry[:project].repository + repo.commit(entry[:id]) if repo end.compact end def commit_ranges references[:commit_ranges].map do |entry| repo = entry[:project].repository if entry[:project] - if repo && should_lookup?(entry[:project]) + if repo from_id, to_id = entry[:id].split(/\.{2,3}/, 2) [repo.commit(from_id), repo.commit(to_id)] end @@ -95,13 +91,5 @@ module Gitlab def reference_link(type, identifier, project, user, _) references[type] << { project: project, id: identifier } end - - def should_lookup?(entry_project) - if entry_project.nil? - false - else - project.nil? || entry_project.default_issues_tracker? - end - end end end diff --git a/spec/helpers/gitlab_markdown_helper_spec.rb b/spec/helpers/gitlab_markdown_helper_spec.rb index 0d06c6ffb8..944e743675 100644 --- a/spec/helpers/gitlab_markdown_helper_spec.rb +++ b/spec/helpers/gitlab_markdown_helper_spec.rb @@ -4,6 +4,11 @@ describe GitlabMarkdownHelper do include ApplicationHelper include IssuesHelper + # TODO: Properly test this + def can?(*) + true + end + let!(:project) { create(:project) } let(:empty_project) { create(:empty_project) } @@ -15,6 +20,9 @@ describe GitlabMarkdownHelper do let(:snippet) { create(:project_snippet, project: project) } let(:member) { project.project_members.where(user_id: user).first } + # Helper expects a current_user method. + let(:current_user) { user } + def url_helper(image_name) File.join(root_url, 'assets', image_name) end diff --git a/spec/lib/gitlab/reference_extractor_spec.rb b/spec/lib/gitlab/reference_extractor_spec.rb index b3f4bb5aed..8ed4d5bc68 100644 --- a/spec/lib/gitlab/reference_extractor_spec.rb +++ b/spec/lib/gitlab/reference_extractor_spec.rb @@ -1,73 +1,76 @@ require 'spec_helper' describe Gitlab::ReferenceExtractor do + let(:project) { create(:project) } + subject { Gitlab::ReferenceExtractor.new(project) } + it 'extracts username references' do - subject.analyze('this contains a @user reference', nil) - expect(subject.users).to eq([{ project: nil, id: 'user' }]) + subject.analyze('this contains a @user reference') + expect(subject.users).to eq([{ project: project, id: 'user' }]) end it 'extracts issue references' do - subject.analyze('this one talks about issue #1234', nil) - expect(subject.issues).to eq([{ project: nil, id: '1234' }]) + subject.analyze('this one talks about issue #1234') + expect(subject.issues).to eq([{ project: project, id: '1234' }]) end it 'extracts JIRA issue references' do - subject.analyze('this one talks about issue JIRA-1234', nil) - expect(subject.issues).to eq([{ project: nil, id: 'JIRA-1234' }]) + subject.analyze('this one talks about issue JIRA-1234') + expect(subject.issues).to eq([{ project: project, id: 'JIRA-1234' }]) end it 'extracts merge request references' do - subject.analyze("and here's !43, a merge request", nil) - expect(subject.merge_requests).to eq([{ project: nil, id: '43' }]) + subject.analyze("and here's !43, a merge request") + expect(subject.merge_requests).to eq([{ project: project, id: '43' }]) end it 'extracts snippet ids' do - subject.analyze('snippets like $12 get extracted as well', nil) - expect(subject.snippets).to eq([{ project: nil, id: '12' }]) + subject.analyze('snippets like $12 get extracted as well') + expect(subject.snippets).to eq([{ project: project, id: '12' }]) end it 'extracts commit shas' do - subject.analyze('commit shas 98cf0ae3 are pulled out as Strings', nil) - expect(subject.commits).to eq([{ project: nil, id: '98cf0ae3' }]) + subject.analyze('commit shas 98cf0ae3 are pulled out as Strings') + expect(subject.commits).to eq([{ project: project, id: '98cf0ae3' }]) end it 'extracts commit ranges' do - subject.analyze('here you go, a commit range: 98cf0ae3...98cf0ae4', nil) - expect(subject.commit_ranges).to eq([{ project: nil, id: '98cf0ae3...98cf0ae4' }]) + subject.analyze('here you go, a commit range: 98cf0ae3...98cf0ae4') + expect(subject.commit_ranges).to eq([{ project: project, id: '98cf0ae3...98cf0ae4' }]) end it 'extracts multiple references and preserves their order' do - subject.analyze('@me and @you both care about this', nil) + subject.analyze('@me and @you both care about this') expect(subject.users).to eq([ - { project: nil, id: 'me' }, - { project: nil, id: 'you' } + { project: project, id: 'me' }, + { project: project, id: 'you' } ]) end it 'leaves the original note unmodified' do text = 'issue #123 is just the worst, @user' - subject.analyze(text, nil) + subject.analyze(text) expect(text).to eq('issue #123 is just the worst, @user') end it 'extracts no references for
..
blocks' do - subject.analyze("
def puts '#1 issue'\nend\n
```", nil) + subject.analyze("
def puts '#1 issue'\nend\n
```") expect(subject.issues).to be_blank end it 'extracts no references for .. blocks' do - subject.analyze("def puts '!1 request'\nend\n```", nil) + subject.analyze("def puts '!1 request'\nend\n```") expect(subject.merge_requests).to be_blank end it 'extracts no references for code blocks with language' do - subject.analyze("this code:\n```ruby\ndef puts '#1 issue'\nend\n```", nil) + subject.analyze("this code:\n```ruby\ndef puts '#1 issue'\nend\n```") expect(subject.issues).to be_blank end it 'extracts issue references for invalid code blocks' do - subject.analyze('test: ```this one talks about issue #1234```', nil) - expect(subject.issues).to eq([{ project: nil, id: '1234' }]) + subject.analyze('test: ```this one talks about issue #1234```') + expect(subject.issues).to eq([{ project: project, id: '1234' }]) end it 'handles all possible kinds of references' do @@ -75,70 +78,64 @@ describe Gitlab::ReferenceExtractor do expect(subject).to respond_to(*accessors) end - context 'with a project' do - let(:project) { create(:project) } + it 'accesses valid user objects on the project team' do + @u_foo = create(:user, username: 'foo') + @u_bar = create(:user, username: 'bar') + create(:user, username: 'offteam') - it 'accesses valid user objects on the project team' do - @u_foo = create(:user, username: 'foo') - @u_bar = create(:user, username: 'bar') - create(:user, username: 'offteam') + project.team << [@u_foo, :reporter] + project.team << [@u_bar, :guest] - project.team << [@u_foo, :reporter] - project.team << [@u_bar, :guest] + subject.analyze('@foo, @baduser, @bar, and @offteam') + expect(subject.users).to eq([@u_foo, @u_bar]) + end - subject.analyze('@foo, @baduser, @bar, and @offteam', project) - expect(subject.users_for(project)).to eq([@u_foo, @u_bar]) - end + it 'accesses valid issue objects' do + @i0 = create(:issue, project: project) + @i1 = create(:issue, project: project) - it 'accesses valid issue objects' do - @i0 = create(:issue, project: project) - @i1 = create(:issue, project: project) + subject.analyze("##{@i0.iid}, ##{@i1.iid}, and #999.") + expect(subject.issues).to eq([@i0, @i1]) + end - subject.analyze("##{@i0.iid}, ##{@i1.iid}, and #999.", project) - expect(subject.issues_for(project)).to eq([@i0, @i1]) - end + it 'accesses valid merge requests' do + @m0 = create(:merge_request, source_project: project, target_project: project, source_branch: 'aaa') + @m1 = create(:merge_request, source_project: project, target_project: project, source_branch: 'bbb') - it 'accesses valid merge requests' do - @m0 = create(:merge_request, source_project: project, target_project: project, source_branch: 'aaa') - @m1 = create(:merge_request, source_project: project, target_project: project, source_branch: 'bbb') + subject.analyze("!999, !#{@m1.iid}, and !#{@m0.iid}.") + expect(subject.merge_requests).to eq([@m1, @m0]) + end - subject.analyze("!999, !#{@m1.iid}, and !#{@m0.iid}.", project) - expect(subject.merge_requests_for(project)).to eq([@m1, @m0]) - end + it 'accesses valid snippets' do + @s0 = create(:project_snippet, project: project) + @s1 = create(:project_snippet, project: project) + @s2 = create(:project_snippet) - it 'accesses valid snippets' do - @s0 = create(:project_snippet, project: project) - @s1 = create(:project_snippet, project: project) - @s2 = create(:project_snippet) + subject.analyze("$#{@s0.id}, $999, $#{@s2.id}, $#{@s1.id}") + expect(subject.snippets).to eq([@s0, @s1]) + end - subject.analyze("$#{@s0.id}, $999, $#{@s2.id}, $#{@s1.id}", project) - expect(subject.snippets_for(project)).to eq([@s0, @s1]) - end + it 'accesses valid commits' do + commit = project.repository.commit('master') - it 'accesses valid commits' do - commit = project.repository.commit('master') + subject.analyze("this references commits #{commit.sha[0..6]} and 012345") + extracted = subject.commits + expect(extracted.size).to eq(1) + expect(extracted[0].sha).to eq(commit.sha) + expect(extracted[0].message).to eq(commit.message) + end - subject.analyze("this references commits #{commit.sha[0..6]} and 012345", - project) - extracted = subject.commits_for(project) - expect(extracted.size).to eq(1) - expect(extracted[0].sha).to eq(commit.sha) - expect(extracted[0].message).to eq(commit.message) - end + it 'accesses valid commit ranges' do + commit = project.repository.commit('master') + earlier_commit = project.repository.commit('master~2') - it 'accesses valid commit ranges' do - commit = project.repository.commit('master') - earlier_commit = project.repository.commit('master~2') - - subject.analyze("this references commits #{earlier_commit.sha[0..6]}...#{commit.sha[0..6]}", - project) - extracted = subject.commit_ranges_for(project) - expect(extracted.size).to eq(1) - expect(extracted[0][0].sha).to eq(earlier_commit.sha) - expect(extracted[0][0].message).to eq(earlier_commit.message) - expect(extracted[0][1].sha).to eq(commit.sha) - expect(extracted[0][1].message).to eq(commit.message) - end + subject.analyze("this references commits #{earlier_commit.sha[0..6]}...#{commit.sha[0..6]}") + extracted = subject.commit_ranges + expect(extracted.size).to eq(1) + expect(extracted[0][0].sha).to eq(earlier_commit.sha) + expect(extracted[0][0].message).to eq(earlier_commit.message) + expect(extracted[0][1].sha).to eq(commit.sha) + expect(extracted[0][1].message).to eq(commit.message) end context 'with a project with an underscore' do @@ -146,12 +143,10 @@ describe Gitlab::ReferenceExtractor do let(:issue) { create(:issue, project: project) } it 'handles project issue references' do - subject.analyze("this refers issue #{project.path_with_namespace}##{issue.iid}", - project) - extracted = subject.issues_for(project) + subject.analyze("this refers issue #{project.path_with_namespace}##{issue.iid}") + extracted = subject.issues expect(extracted.size).to eq(1) expect(extracted).to eq([issue]) end - end end From f04549056806d2bcb63441ebdffe1032711e83c8 Mon Sep 17 00:00:00 2001 From: Cristian Medina Date: Thu, 5 Feb 2015 01:42:34 +0000 Subject: [PATCH 028/194] Modified lib/api/entities.rb to expose Project class tag_list property to the API Updated projects.md to show tag_list field when performing GETs Updated projects_spec.rb to include check for tag_list key in project list Added changes to the CHANGELOG --- CHANGELOG | 1 + doc/api/projects.md | 12 ++++++++++++ lib/api/entities.rb | 2 +- spec/requests/api/projects_spec.rb | 9 ++++++++- 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index c67ce17dc3..156792a4f8 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -56,6 +56,7 @@ v 7.10.0 (unreleased) - Fix "Hello @username." references not working by no longer allowing usernames to end in period. - Archive repositories in background worker. - Import GitHub, Bitbucket or GitLab.com projects owned by authenticated user into current namespace. + - Project labels are now available over the API under the "tag_list" field (Cristian Medina) v 7.9.2 diff --git a/doc/api/projects.md b/doc/api/projects.md index 7fe244477d..55d525fef6 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -44,6 +44,10 @@ Parameters: "ssh_url_to_repo": "git@example.com:diaspora/diaspora-client.git", "http_url_to_repo": "http://example.com/diaspora/diaspora-client.git", "web_url": "http://example.com/diaspora/diaspora-client", + "tag_list": [ + "example", + "disapora client" + ], "owner": { "id": 3, "name": "Diaspora", @@ -80,6 +84,10 @@ Parameters: "ssh_url_to_repo": "git@example.com:brightbox/puppet.git", "http_url_to_repo": "http://example.com/brightbox/puppet.git", "web_url": "http://example.com/brightbox/puppet", + "tag_list": [ + "example", + "puppet" + ], "owner": { "id": 4, "name": "Brightbox", @@ -163,6 +171,10 @@ Parameters: "ssh_url_to_repo": "git@example.com:diaspora/diaspora-project-site.git", "http_url_to_repo": "http://example.com/diaspora/diaspora-project-site.git", "web_url": "http://example.com/diaspora/diaspora-project-site", + "tag_list": [ + "example", + "disapora project" + ], "owner": { "id": 3, "name": "Diaspora", diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 489be21078..51cb934616 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -46,7 +46,7 @@ module API end class Project < Grape::Entity - expose :id, :description, :default_branch + expose :id, :description, :default_branch, :tag_list expose :public?, as: :public expose :archived?, as: :archived expose :visibility_level, :ssh_url_to_repo, :http_url_to_repo, :web_url diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index b713f1fe89..cc387378d3 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -57,7 +57,14 @@ describe API::API, api: true do expect(json_response.first['name']).to eq(project.name) expect(json_response.first['owner']['username']).to eq(user.username) end - + + it 'should include the project labels as the tag_list' do + get api('/projects', user) + response.status.should == 200 + json_response.should be_an Array + json_response.first.keys.should include('tag_list') + end + context 'and using search' do it 'should return searched project' do get api('/projects', user), { search: project.name } From edc4a56d26792b5b5bac21f45948412675ad7ebb Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 27 Mar 2015 14:43:48 +0100 Subject: [PATCH 029/194] Allow admin to create public deploy keys that are accessible to any project. --- CHANGELOG | 1 + .../admin/deploy_keys_controller.rb | 45 +++++++++++++++++++ .../projects/deploy_keys_controller.rb | 18 +++----- app/models/deploy_key.rb | 7 +++ app/models/deploy_keys_project.rb | 6 ++- app/models/user.rb | 10 ++++- app/views/admin/deploy_keys/index.html.haml | 27 +++++++++++ app/views/admin/deploy_keys/new.html.haml | 26 +++++++++++ app/views/admin/deploy_keys/show.html.haml | 34 ++++++++++++++ app/views/layouts/nav/_admin.html.haml | 5 +++ .../deploy_keys/_deploy_key.html.haml | 29 +++++++++--- .../projects/deploy_keys/index.html.haml | 24 ++++++---- config/routes.rb | 4 +- .../20150327122227_add_public_to_key.rb | 5 +++ db/schema.rb | 1 + spec/models/deploy_keys_project_spec.rb | 27 ++++++++--- 16 files changed, 233 insertions(+), 36 deletions(-) create mode 100644 app/controllers/admin/deploy_keys_controller.rb create mode 100644 app/views/admin/deploy_keys/index.html.haml create mode 100644 app/views/admin/deploy_keys/new.html.haml create mode 100644 app/views/admin/deploy_keys/show.html.haml create mode 100644 db/migrate/20150327122227_add_public_to_key.rb diff --git a/CHANGELOG b/CHANGELOG index 1934d03634..a44e910d5a 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -58,6 +58,7 @@ v 7.10.0 (unreleased) - Fix "Hello @username." references not working by no longer allowing usernames to end in period. - Archive repositories in background worker. - Import GitHub, Bitbucket or GitLab.com projects owned by authenticated user into current namespace. + - Allow admin to create public deploy keys that are accessible to any project. v 7.9.2 diff --git a/app/controllers/admin/deploy_keys_controller.rb b/app/controllers/admin/deploy_keys_controller.rb new file mode 100644 index 0000000000..0664b26ddd --- /dev/null +++ b/app/controllers/admin/deploy_keys_controller.rb @@ -0,0 +1,45 @@ +class Admin::DeployKeysController < Admin::ApplicationController + before_filter :deploy_key, only: [:show, :destroy] + + def index + @deploy_keys = DeployKey.are_public + end + + def show + + end + + def new + @deploy_key = DeployKey.new(public: true) + end + + def create + @deploy_key = DeployKey.new(deploy_key_params) + @deploy_key.public = true + + if @deploy_key.save + redirect_to admin_deploy_keys_path + else + render "new" + end + end + + def destroy + deploy_key.destroy + + respond_to do |format| + format.html { redirect_to admin_deploy_keys_path } + format.json { head :ok } + end + end + + protected + + def deploy_key + @deploy_key ||= DeployKey.find(params[:id]) + end + + def deploy_key_params + params.require(:deploy_key).permit(:key, :title) + end +end diff --git a/app/controllers/projects/deploy_keys_controller.rb b/app/controllers/projects/deploy_keys_controller.rb index 679a5d76ec..176d112a4d 100644 --- a/app/controllers/projects/deploy_keys_controller.rb +++ b/app/controllers/projects/deploy_keys_controller.rb @@ -9,6 +9,8 @@ class Projects::DeployKeysController < Projects::ApplicationController def index @enabled_keys = @project.deploy_keys @available_keys = available_keys - @enabled_keys + @available_project_keys = current_user.project_deploy_keys - @enabled_keys + @available_public_keys = DeployKey.are_public - @available_project_keys - @enabled_keys end def show @@ -32,18 +34,9 @@ class Projects::DeployKeysController < Projects::ApplicationController end end - def destroy - @key = @project.deploy_keys.find(params[:id]) - @key.destroy - - respond_to do |format| - format.html { redirect_to namespace_project_deploy_keys_path(@project.namespace, @project) } - format.js { render nothing: true } - end - end - def enable - @project.deploy_keys << available_keys.find(params[:id]) + @key = current_user.accessible_deploy_keys.find(params[:id]) + @project.deploy_keys << @key redirect_to namespace_project_deploy_keys_path(@project.namespace, @project) @@ -52,8 +45,7 @@ class Projects::DeployKeysController < Projects::ApplicationController def disable @project.deploy_keys_projects.find_by(deploy_key_id: params[:id]).destroy - redirect_to namespace_project_deploy_keys_path(@project.namespace, - @project) + redirect_to :back end protected diff --git a/app/models/deploy_key.rb b/app/models/deploy_key.rb index 570f5e91c1..1770dde320 100644 --- a/app/models/deploy_key.rb +++ b/app/models/deploy_key.rb @@ -7,6 +7,7 @@ # created_at :datetime # updated_at :datetime # key :text +# public :boolean default(FALSE) # title :string(255) # type :string(255) # fingerprint :string(255) @@ -17,4 +18,10 @@ class DeployKey < Key has_many :projects, through: :deploy_keys_projects scope :in_projects, ->(projects) { joins(:deploy_keys_projects).where('deploy_keys_projects.project_id in (?)', projects) } + scope :are_public, -> { where(public: true) } + scope :are_private, -> { where(public: false) } + + def private? + !public? + end end diff --git a/app/models/deploy_keys_project.rb b/app/models/deploy_keys_project.rb index 7e88903b9a..5f679f4b3e 100644 --- a/app/models/deploy_keys_project.rb +++ b/app/models/deploy_keys_project.rb @@ -22,6 +22,10 @@ class DeployKeysProject < ActiveRecord::Base private def destroy_orphaned_deploy_key - self.deploy_key.destroy if self.deploy_key.deploy_keys_projects.length == 0 + # Public deploy keys are never automatically deleted + return if self.deploy_key.public? + return if self.deploy_key.deploy_keys_projects.length > 0 + + self.deploy_key.destroy end end diff --git a/app/models/user.rb b/app/models/user.rb index 515f29ea0b..ea6b1367bb 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -414,8 +414,16 @@ class User < ActiveRecord::Base @ldap_identity ||= identities.find_by(["provider LIKE ?", "ldap%"]) end + def project_deploy_keys + DeployKey.in_projects(self.authorized_projects.pluck(:id)) + end + def accessible_deploy_keys - DeployKey.in_projects(self.authorized_projects.pluck(:id)).uniq + @accessible_deploy_keys ||= begin + key_ids = project_deploy_keys.pluck(:id) + key_ids.push(*DeployKey.are_public.pluck(:id)) + DeployKey.where(id: key_ids) + end end def created_by diff --git a/app/views/admin/deploy_keys/index.html.haml b/app/views/admin/deploy_keys/index.html.haml new file mode 100644 index 0000000000..2ae83ab95f --- /dev/null +++ b/app/views/admin/deploy_keys/index.html.haml @@ -0,0 +1,27 @@ +.panel.panel-default + .panel-heading + Public deploy keys (#{@deploy_keys.count}) + .panel-head-actions + = link_to 'New Deploy Key', new_admin_deploy_key_path, class: "btn btn-new btn-sm" + - if @deploy_keys.any? + %table.table + %thead.panel-heading + %tr + %th Title + %th Fingerprint + %th Added at + %th + %tbody + - @deploy_keys.each do |deploy_key| + %tr + %td + = link_to admin_deploy_key_path(deploy_key) do + %strong= deploy_key.title + %td + %span + (#{deploy_key.fingerprint}) + %td + %span.cgray + added #{time_ago_with_tooltip(deploy_key.created_at)} + %td + = link_to 'Remove', admin_deploy_key_path(deploy_key), data: { confirm: 'Are you sure?'}, method: :delete, class: "btn btn-sm btn-remove delete-key pull-right" diff --git a/app/views/admin/deploy_keys/new.html.haml b/app/views/admin/deploy_keys/new.html.haml new file mode 100644 index 0000000000..c00049424c --- /dev/null +++ b/app/views/admin/deploy_keys/new.html.haml @@ -0,0 +1,26 @@ +%h3.page-title New public deploy key +%hr + +%div + = form_for [:admin, @deploy_key], html: { class: 'deploy-key-form form-horizontal' } do |f| + -if @deploy_key.errors.any? + .alert.alert-danger + %ul + - @deploy_key.errors.full_messages.each do |msg| + %li= msg + + .form-group + = f.label :title, class: "control-label" + .col-sm-10= f.text_field :title, class: 'form-control' + .form-group + = f.label :key, class: "control-label" + .col-sm-10 + %p.light + Paste a machine public key here. Read more about how to generate it + = link_to "here", help_page_path("ssh", "README") + = f.text_area :key, class: "form-control thin_area", rows: 5 + + .form-actions + = f.submit 'Create', class: "btn-create btn" + = link_to "Cancel", admin_deploy_keys_path, class: "btn btn-cancel" + diff --git a/app/views/admin/deploy_keys/show.html.haml b/app/views/admin/deploy_keys/show.html.haml new file mode 100644 index 0000000000..cfa2adf92e --- /dev/null +++ b/app/views/admin/deploy_keys/show.html.haml @@ -0,0 +1,34 @@ +.row + .col-md-4 + .panel.panel-default + .panel-heading + Deploy Key + %ul.well-list + %li + %span.light Title: + %strong= @deploy_key.title + %li + %span.light Created on: + %strong= @deploy_key.created_at.stamp("Aug 21, 2011") + + .panel.panel-default + .panel-heading Projects (#{@deploy_key.deploy_keys_projects.count}) + - if @deploy_key.deploy_keys_projects.any? + %ul.well-list + - @deploy_key.projects.each do |project| + %li + %span + %strong + = link_to project.name_with_namespace, [:admin, project.namespace.becomes(Namespace), project] + .pull-right + = link_to disable_namespace_project_deploy_key_path(project.namespace, project, @deploy_key), data: { confirm: "Are you sure?" }, method: :put, class: "btn-xs btn btn-remove", title: 'Remove deploy key from project' do + %i.fa.fa-times.fa-inverse + + .col-md-8 + %p + %span.light Fingerprint: + %strong= @deploy_key.fingerprint + %pre.well-pre + = @deploy_key.key + .pull-right + = link_to 'Remove', admin_deploy_key_path(@deploy_key), data: {confirm: 'Are you sure?'}, method: :delete, class: "btn btn-remove delete-key" diff --git a/app/views/layouts/nav/_admin.html.haml b/app/views/layouts/nav/_admin.html.haml index 2f38d596c6..34efceb37d 100644 --- a/app/views/layouts/nav/_admin.html.haml +++ b/app/views/layouts/nav/_admin.html.haml @@ -19,6 +19,11 @@ %i.fa.fa-group %span Groups + = nav_link(controller: :deploy_keys) do + = link_to admin_deploy_keys_path, title: 'Deploy Keys' do + %i.fa.fa-key + %span + Deploy Keys = nav_link(controller: :logs) do = link_to admin_logs_path, title: 'Logs' do %i.fa.fa-file-text diff --git a/app/views/projects/deploy_keys/_deploy_key.html.haml b/app/views/projects/deploy_keys/_deploy_key.html.haml index a2faa9d5e2..8837a65918 100644 --- a/app/views/projects/deploy_keys/_deploy_key.html.haml +++ b/app/views/projects/deploy_keys/_deploy_key.html.haml @@ -5,21 +5,38 @@ %i.fa.fa-plus Enable - else - - if deploy_key.projects.count > 1 + - if deploy_key.projects.count > 1 || deploy_key.public? = link_to disable_namespace_project_deploy_key_path(@project.namespace, @project, deploy_key), class: 'btn btn-sm', method: :put do %i.fa.fa-power-off Disable - else - = link_to 'Remove', namespace_project_deploy_key_path(@project.namespace, @project, deploy_key), data: { confirm: 'You are going to remove deploy key. Are you sure?'}, method: :delete, class: "btn btn-remove delete-key btn-sm pull-right" + = link_to 'Remove', disable_namespace_project_deploy_key_path(@project.namespace, @project, deploy_key), data: { confirm: 'You are going to remove deploy key. Are you sure?'}, method: :put, class: "btn btn-remove delete-key btn-sm pull-right" - - key_project = deploy_key.projects.include?(@project) ? @project : deploy_key.projects.first - = link_to namespace_project_deploy_key_path(key_project.namespace, key_project, deploy_key) do + - if deploy_key.projects.include?(@project) + - key_project = @project + - else + - key_project = deploy_key.projects.find { |project| can?(current_user, :read_project, project) } + + - if key_project + = link_to namespace_project_deploy_key_path(key_project.namespace, key_project, deploy_key) do + %i.fa.fa-key + %strong= deploy_key.title + - else %i.fa.fa-key %strong= deploy_key.title + %p.light.prepend-top-10 - - deploy_key.projects.map(&:name_with_namespace).each do |project_name| - %span.label.label-gray.deploy-project-label= project_name + - if deploy_key.public? + %span.label.label-info.deploy-project-label + Public deploy key + + - deploy_key.projects.each do |project| + - if can?(current_user, :read_project, project) + %span.label.label-gray.deploy-project-label + = link_to namespace_project_path(project.namespace, project) do + = project.name_with_namespace + %small.pull-right Created #{time_ago_with_tooltip(deploy_key.created_at)} diff --git a/app/views/projects/deploy_keys/index.html.haml b/app/views/projects/deploy_keys/index.html.haml index c02a18146e..186cdfeea3 100644 --- a/app/views/projects/deploy_keys/index.html.haml +++ b/app/views/projects/deploy_keys/index.html.haml @@ -22,11 +22,19 @@ .light-well .nothing-here-block Create a #{link_to 'new deploy key', new_namespace_project_deploy_key_path(@project.namespace, @project)} or add an existing one .col-md-6.available-keys - %h5 - %strong Deploy keys - from projects available to you - %ul.bordered-list - = render @available_keys - - if @available_keys.blank? - .light-well - .nothing-here-block Deploy keys from projects you have access to will be displayed here + - unless @available_project_keys.blank? && @available_public_keys.any? + %h5 + %strong Deploy keys + from projects you have access to + %ul.bordered-list + = render @available_project_keys + - if @available_project_keys.blank? + .light-well + .nothing-here-block Deploy keys from projects you have access to will be displayed here + + - if @available_public_keys.any? + %h5 + %strong Public deploy keys + available to any project + %ul.bordered-list + = render @available_public_keys diff --git a/config/routes.rb b/config/routes.rb index 388858d267..f4dc2dca2f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -145,6 +145,8 @@ Gitlab::Application.routes.draw do end end + resources :deploy_keys, only: [:index, :show, :new, :create, :destroy] + resources :hooks, only: [:index, :create, :destroy] do get :test end @@ -393,7 +395,7 @@ Gitlab::Application.routes.draw do end end - resources :deploy_keys, constraints: { id: /\d+/ } do + resources :deploy_keys, constraints: { id: /\d+/ }, only: [:index, :show, :new, :create] do member do put :enable put :disable diff --git a/db/migrate/20150327122227_add_public_to_key.rb b/db/migrate/20150327122227_add_public_to_key.rb new file mode 100644 index 0000000000..6ffbf4cda1 --- /dev/null +++ b/db/migrate/20150327122227_add_public_to_key.rb @@ -0,0 +1,5 @@ +class AddPublicToKey < ActiveRecord::Migration + def change + add_column :keys, :public, :boolean, default: false, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 14e32a7946..1346bc3430 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -132,6 +132,7 @@ ActiveRecord::Schema.define(version: 20150328132231) do t.string "title" t.string "type" t.string "fingerprint" + t.boolean "public", default: false, null: false end add_index "keys", ["created_at", "id"], name: "index_keys_on_created_at_and_id", using: :btree diff --git a/spec/models/deploy_keys_project_spec.rb b/spec/models/deploy_keys_project_spec.rb index f351aab923..7032b77714 100644 --- a/spec/models/deploy_keys_project_spec.rb +++ b/spec/models/deploy_keys_project_spec.rb @@ -28,17 +28,32 @@ describe DeployKeysProject do let(:deploy_key) { subject.deploy_key } context "when the deploy key is only used by this project" do - it "destroys the deploy key" do - subject.destroy + context "when the deploy key is public" do + before do + deploy_key.update_attribute(:public, true) + end - expect { - deploy_key.reload - }.to raise_error(ActiveRecord::RecordNotFound) + it "doesn't destroy the deploy key" do + subject.destroy + + expect { + deploy_key.reload + }.not_to raise_error(ActiveRecord::RecordNotFound) + end + end + + context "when the deploy key is private" do + it "destroys the deploy key" do + subject.destroy + + expect { + deploy_key.reload + }.to raise_error(ActiveRecord::RecordNotFound) + end end end context "when the deploy key is used by more than one project" do - let!(:other_project) { create(:project) } before do From a243e44ca1ee57dc28ef72bd3521c17726ba5026 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 27 Mar 2015 17:26:13 +0100 Subject: [PATCH 030/194] Fix failing specs. --- spec/routing/project_routing_spec.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/spec/routing/project_routing_spec.rb b/spec/routing/project_routing_spec.rb index d9bd91f599..042352311d 100644 --- a/spec/routing/project_routing_spec.rb +++ b/spec/routing/project_routing_spec.rb @@ -168,12 +168,11 @@ end # project_deploy_keys GET /:project_id/deploy_keys(.:format) deploy_keys#index # POST /:project_id/deploy_keys(.:format) deploy_keys#create # new_project_deploy_key GET /:project_id/deploy_keys/new(.:format) deploy_keys#new -# edit_project_deploy_key GET /:project_id/deploy_keys/:id/edit(.:format) deploy_keys#edit # project_deploy_key GET /:project_id/deploy_keys/:id(.:format) deploy_keys#show -# PUT /:project_id/deploy_keys/:id(.:format) deploy_keys#update # DELETE /:project_id/deploy_keys/:id(.:format) deploy_keys#destroy describe Projects::DeployKeysController, 'routing' do it_behaves_like 'RESTful project resources' do + let(:actions) { [:index, :show, :new, :create] } let(:controller) { 'deploy_keys' } end end From ed097df62758880eb6412770f661266d4a6e9299 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 3 Apr 2015 12:22:44 +0200 Subject: [PATCH 031/194] Clean up code. --- app/controllers/admin/deploy_keys_controller.rb | 14 +++++++++----- .../projects/deploy_keys_controller.rb | 15 ++++++++++----- app/helpers/projects_helper.rb | 8 ++++++++ app/models/deploy_key.rb | 13 ++++++++++++- app/models/deploy_keys_project.rb | 6 ++---- .../projects/deploy_keys/_deploy_key.html.haml | 16 +++++----------- app/views/projects/deploy_keys/index.html.haml | 3 ++- 7 files changed, 48 insertions(+), 27 deletions(-) diff --git a/app/controllers/admin/deploy_keys_controller.rb b/app/controllers/admin/deploy_keys_controller.rb index 0664b26ddd..e93603bef3 100644 --- a/app/controllers/admin/deploy_keys_controller.rb +++ b/app/controllers/admin/deploy_keys_controller.rb @@ -1,8 +1,9 @@ class Admin::DeployKeysController < Admin::ApplicationController + before_filter :deploy_keys, only: [:index] before_filter :deploy_key, only: [:show, :destroy] def index - @deploy_keys = DeployKey.are_public + end def show @@ -10,12 +11,11 @@ class Admin::DeployKeysController < Admin::ApplicationController end def new - @deploy_key = DeployKey.new(public: true) + @deploy_key = deploy_keys.new end def create - @deploy_key = DeployKey.new(deploy_key_params) - @deploy_key.public = true + @deploy_key = deploy_keys.new(deploy_key_params) if @deploy_key.save redirect_to admin_deploy_keys_path @@ -36,7 +36,11 @@ class Admin::DeployKeysController < Admin::ApplicationController protected def deploy_key - @deploy_key ||= DeployKey.find(params[:id]) + @deploy_key ||= deploy_keys.find(params[:id]) + end + + def deploy_keys + @deploy_keys ||= DeployKey.are_public end def deploy_key_params diff --git a/app/controllers/projects/deploy_keys_controller.rb b/app/controllers/projects/deploy_keys_controller.rb index 176d112a4d..6fba3ce299 100644 --- a/app/controllers/projects/deploy_keys_controller.rb +++ b/app/controllers/projects/deploy_keys_controller.rb @@ -8,9 +8,14 @@ class Projects::DeployKeysController < Projects::ApplicationController def index @enabled_keys = @project.deploy_keys - @available_keys = available_keys - @enabled_keys + + @available_keys = accessible_keys - @enabled_keys @available_project_keys = current_user.project_deploy_keys - @enabled_keys - @available_public_keys = DeployKey.are_public - @available_project_keys - @enabled_keys + @available_public_keys = DeployKey.are_public - @enabled_keys + + # Public keys that are already used by another accessible project are already + # in @available_project_keys. + @available_public_keys -= @available_project_keys end def show @@ -35,7 +40,7 @@ class Projects::DeployKeysController < Projects::ApplicationController end def enable - @key = current_user.accessible_deploy_keys.find(params[:id]) + @key = accessible_keys.find(params[:id]) @project.deploy_keys << @key redirect_to namespace_project_deploy_keys_path(@project.namespace, @@ -50,8 +55,8 @@ class Projects::DeployKeysController < Projects::ApplicationController protected - def available_keys - @available_keys ||= current_user.accessible_deploy_keys + def accessible_keys + @accessible_keys ||= current_user.accessible_deploy_keys end def deploy_key_params diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index e3734023be..ebbd2bfd77 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -127,6 +127,14 @@ module ProjectsHelper html + count_html end + def project_for_deploy_key(deploy_key) + if deploy_key.projects.include?(@project) + @project + else + deploy_key.projects.find { |project| can?(current_user, :read_project, project) } + end + end + private def get_project_nav_tabs(project, current_user) diff --git a/app/models/deploy_key.rb b/app/models/deploy_key.rb index 1770dde320..85d52d558c 100644 --- a/app/models/deploy_key.rb +++ b/app/models/deploy_key.rb @@ -19,9 +19,20 @@ class DeployKey < Key scope :in_projects, ->(projects) { joins(:deploy_keys_projects).where('deploy_keys_projects.project_id in (?)', projects) } scope :are_public, -> { where(public: true) } - scope :are_private, -> { where(public: false) } def private? !public? end + + def orphaned? + self.deploy_keys_projects.length == 0 + end + + def almost_orphaned? + self.deploy_keys_projects.length == 1 + end + + def destroyed_when_orphaned? + self.private? + end end diff --git a/app/models/deploy_keys_project.rb b/app/models/deploy_keys_project.rb index 5f679f4b3e..18db521741 100644 --- a/app/models/deploy_keys_project.rb +++ b/app/models/deploy_keys_project.rb @@ -22,10 +22,8 @@ class DeployKeysProject < ActiveRecord::Base private def destroy_orphaned_deploy_key - # Public deploy keys are never automatically deleted - return if self.deploy_key.public? - return if self.deploy_key.deploy_keys_projects.length > 0 - + return unless self.deploy_key.destroyed_when_orphaned? && self.deploy_key.orphaned? + self.deploy_key.destroy end end diff --git a/app/views/projects/deploy_keys/_deploy_key.html.haml b/app/views/projects/deploy_keys/_deploy_key.html.haml index 8837a65918..c577dfa8d5 100644 --- a/app/views/projects/deploy_keys/_deploy_key.html.haml +++ b/app/views/projects/deploy_keys/_deploy_key.html.haml @@ -5,21 +5,15 @@ %i.fa.fa-plus Enable - else - - if deploy_key.projects.count > 1 || deploy_key.public? + - if deploy_key.destroyed_when_orphaned? && deploy_key.almost_orphaned? + = link_to 'Remove', disable_namespace_project_deploy_key_path(@project.namespace, @project, deploy_key), data: { confirm: 'You are going to remove deploy key. Are you sure?'}, method: :put, class: "btn btn-remove delete-key btn-sm pull-right" + - else = link_to disable_namespace_project_deploy_key_path(@project.namespace, @project, deploy_key), class: 'btn btn-sm', method: :put do %i.fa.fa-power-off Disable - - else - = link_to 'Remove', disable_namespace_project_deploy_key_path(@project.namespace, @project, deploy_key), data: { confirm: 'You are going to remove deploy key. Are you sure?'}, method: :put, class: "btn btn-remove delete-key btn-sm pull-right" - - - if deploy_key.projects.include?(@project) - - key_project = @project - - else - - key_project = deploy_key.projects.find { |project| can?(current_user, :read_project, project) } - - - if key_project - = link_to namespace_project_deploy_key_path(key_project.namespace, key_project, deploy_key) do + - if project = project_for_deploy_key(deploy_key) + = link_to namespace_project_deploy_key_path(project.namespace, project, deploy_key) do %i.fa.fa-key %strong= deploy_key.title - else diff --git a/app/views/projects/deploy_keys/index.html.haml b/app/views/projects/deploy_keys/index.html.haml index 186cdfeea3..472a13a852 100644 --- a/app/views/projects/deploy_keys/index.html.haml +++ b/app/views/projects/deploy_keys/index.html.haml @@ -22,7 +22,8 @@ .light-well .nothing-here-block Create a #{link_to 'new deploy key', new_namespace_project_deploy_key_path(@project.namespace, @project)} or add an existing one .col-md-6.available-keys - - unless @available_project_keys.blank? && @available_public_keys.any? + - # If there are available public deploy keys but no available project deploy keys, only public deploy keys are shown. + - if @available_project_keys.any? || @available_public_keys.blank? %h5 %strong Deploy keys from projects you have access to From 9b3624071ec8932d468213109486f462a1770a2a Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 3 Apr 2015 12:40:14 +0200 Subject: [PATCH 032/194] Add project deploy keys tests. --- features/project/deploy_keys.feature | 21 +++++++++++++++++++-- features/steps/project/deploy_keys.rb | 26 +++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/features/project/deploy_keys.feature b/features/project/deploy_keys.feature index 13e3b9bbd2..a71f6124d9 100644 --- a/features/project/deploy_keys.feature +++ b/features/project/deploy_keys.feature @@ -6,7 +6,17 @@ Feature: Project Deploy Keys Scenario: I should see deploy keys list Given project has deploy key When I visit project deploy keys page - Then I should see project deploy keys + Then I should see project deploy key + + Scenario: I should see project deploy keys + Given other project has deploy key + When I visit project deploy keys page + Then I should see other project deploy key + + Scenario: I should see public deploy keys + Given public deploy key exists + When I visit project deploy keys page + Then I should see public deploy key Scenario: I add new deploy key Given I visit project deploy keys page @@ -15,9 +25,16 @@ Feature: Project Deploy Keys Then I should be on deploy keys page And I should see newly created deploy key - Scenario: I attach deploy key to project + Scenario: I attach other project deploy key to project Given other project has deploy key And I visit project deploy keys page When I click attach deploy key Then I should be on deploy keys page And I should see newly created deploy key + + Scenario: I attach public deploy key to project + Given public deploy key exists + And I visit project deploy keys page + When I click attach deploy key + Then I should be on deploy keys page + And I should see newly created deploy key diff --git a/features/steps/project/deploy_keys.rb b/features/steps/project/deploy_keys.rb index 4bf5cb5fa4..50e14513a7 100644 --- a/features/steps/project/deploy_keys.rb +++ b/features/steps/project/deploy_keys.rb @@ -7,12 +7,24 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps create(:deploy_keys_project, project: @project) end - step 'I should see project deploy keys' do + step 'I should see project deploy key' do within '.enabled-keys' do page.should have_content deploy_key.title end end + step 'I should see other project deploy key' do + within '.available-keys' do + page.should have_content other_deploy_key.title + end + end + + step 'I should see public deploy key' do + within '.available-keys' do + page.should have_content public_deploy_key.title + end + end + step 'I click \'New Deploy Key\'' do click_link 'New Deploy Key' end @@ -39,6 +51,10 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps create(:deploy_keys_project, project: @second_project) end + step 'public deploy key exists' do + create(:deploy_key, public: true) + end + step 'I click attach deploy key' do within '.available-keys' do click_link 'Enable' @@ -50,4 +66,12 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps def deploy_key @project.deploy_keys.last end + + def other_deploy_key + @second_project.deploy_keys.last + end + + def public_deploy_key + DeployKey.are_public.last + end end From 6237cae1f16475d874ea761a62350344bdbff617 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 3 Apr 2015 12:51:01 +0200 Subject: [PATCH 033/194] Add admin deploy keys tests. --- features/admin/deploy_keys.feature | 21 +++++++++++ features/steps/admin/deploy_keys.rb | 57 +++++++++++++++++++++++++++++ spec/factories.rb | 3 ++ 3 files changed, 81 insertions(+) create mode 100644 features/admin/deploy_keys.feature create mode 100644 features/steps/admin/deploy_keys.rb diff --git a/features/admin/deploy_keys.feature b/features/admin/deploy_keys.feature new file mode 100644 index 0000000000..9df47eb51f --- /dev/null +++ b/features/admin/deploy_keys.feature @@ -0,0 +1,21 @@ +@admin +Feature: Admin Deploy Keys + Background: + Given I sign in as an admin + And there are public deploy keys in system + + Scenario: Deploy Keys list + When I visit admin deploy keys page + Then I should see all public deploy keys + + Scenario: Deploy Keys show + When I visit admin deploy keys page + And I click on first deploy key + Then I should see deploy key details + + Scenario: Deploy Keys new + When I visit admin deploy keys page + And I click 'New Deploy Key' + And I submit new deploy key + Then I should be on admin deploy keys page + And I should see newly created deploy key diff --git a/features/steps/admin/deploy_keys.rb b/features/steps/admin/deploy_keys.rb new file mode 100644 index 0000000000..fb0b611762 --- /dev/null +++ b/features/steps/admin/deploy_keys.rb @@ -0,0 +1,57 @@ +class Spinach::Features::AdminDeployKeys < Spinach::FeatureSteps + include SharedAuthentication + include SharedPaths + include SharedAdmin + + step 'there are public deploy keys in system' do + create(:deploy_key, public: true) + create(:another_deploy_key, public: true) + end + + step 'I should see all public deploy keys' do + DeployKey.are_public.each do |p| + page.should have_content p.title + end + end + + step 'I click on first deploy key' do + click_link DeployKey.are_public.first.title + end + + step 'I should see deploy key details' do + deploy_key = DeployKey.are_public.first + current_path.should == admin_deploy_key_path(deploy_key) + page.should have_content(deploy_key.title) + page.should have_content(deploy_key.key) + end + + step 'I visit admin deploy key page' do + visit admin_deploy_key_path(deploy_key) + end + + step 'I visit admin deploy keys page' do + visit admin_deploy_keys_path + end + + step 'I click \'New Deploy Key\'' do + click_link 'New Deploy Key' + end + + step 'I submit new deploy key' do + fill_in "deploy_key_title", with: "laptop" + fill_in "deploy_key_key", with: "ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAzrEJUIR6Y03TCE9rIJ+GqTBvgb8t1jI9h5UBzCLuK4VawOmkLornPqLDrGbm6tcwM/wBrrLvVOqi2HwmkKEIecVO0a64A4rIYScVsXIniHRS6w5twyn1MD3sIbN+socBDcaldECQa2u1dI3tnNVcs8wi77fiRe7RSxePsJceGoheRQgC8AZ510UdIlO+9rjIHUdVN7LLyz512auAfYsgx1OfablkQ/XJcdEwDNgi9imI6nAXhmoKUm1IPLT2yKajTIC64AjLOnE0YyCh6+7RFMpiMyu1qiOCpdjYwTgBRiciNRZCH8xIedyCoAmiUgkUT40XYHwLuwiPJICpkAzp7Q== user@laptop" + click_button "Create" + end + + step 'I should be on admin deploy keys page' do + current_path.should == admin_deploy_keys_path + end + + step 'I should see newly created deploy key' do + page.should have_content(deploy_key.title) + end + + def deploy_key + @deploy_key ||= DeployKey.are_public.first + end +end diff --git a/spec/factories.rb b/spec/factories.rb index fc103e5b13..b9bfd3cebb 100644 --- a/spec/factories.rb +++ b/spec/factories.rb @@ -111,6 +111,9 @@ FactoryGirl.define do key do "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDmTillFzNTrrGgwaCKaSj+QCz81E6jBc/s9av0+3b1Hwfxgkqjl4nAK/OD2NjgyrONDTDfR8cRN4eAAy6nY8GLkOyYBDyuc5nTMqs5z3yVuTwf3koGm/YQQCmo91psZ2BgDFTor8SVEE5Mm1D1k3JDMhDFxzzrOtRYFPci9lskTJaBjpqWZ4E9rDTD2q/QZntCqbC3wE9uSemRQB5f8kik7vD/AD8VQXuzKladrZKkzkONCPWsXDspUitjM8HkQdOf0PsYn1CMUC1xKYbCxkg5TkEosIwGv6CoEArUrdu/4+10LVslq494mAvEItywzrluCLCnwELfW+h/m8UHoVhZ" end + + factory :another_deploy_key, class: 'DeployKey' do + end end factory :invalid_key do From 7b5bc32cadbf2c0a3ac1e80643e46786fd8b1b56 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 3 Apr 2015 15:29:27 +0200 Subject: [PATCH 034/194] Allow projects to be imported from Google Code. --- app/assets/stylesheets/base/mixins.scss | 16 + app/assets/stylesheets/pages/notes.scss | 16 +- .../import/google_code_controller.rb | 63 +++ app/models/project.rb | 9 + app/views/import/google_code/new.html.haml | 17 + app/views/import/google_code/status.html.haml | 45 ++ app/views/projects/new.html.haml | 4 + app/workers/repository_import_worker.rb | 2 + config/routes.rb | 6 + ...150327150017_add_import_data_to_project.rb | 5 + db/schema.rb | 1 + lib/gitlab/google_code_import/client.rb | 23 + lib/gitlab/google_code_import/importer.rb | 327 +++++++++++++++ .../google_code_import/project_creator.rb | 40 ++ lib/gitlab/google_code_import/repository.rb | 39 ++ .../import/google_code_controller_spec.rb | 47 +++ spec/fixtures/GoogleCodeProjectHosting.json | 397 ++++++++++++++++++ ...ect_creator.rb => project_creator_spec.rb} | 0 .../gitlab/google_code_import/client_spec.rb | 34 ++ .../google_code_import/importer_spec.rb | 69 +++ .../project_creator_spec.rb | 24 ++ 21 files changed, 1170 insertions(+), 14 deletions(-) create mode 100644 app/controllers/import/google_code_controller.rb create mode 100644 app/views/import/google_code/new.html.haml create mode 100644 app/views/import/google_code/status.html.haml create mode 100644 db/migrate/20150327150017_add_import_data_to_project.rb create mode 100644 lib/gitlab/google_code_import/client.rb create mode 100644 lib/gitlab/google_code_import/importer.rb create mode 100644 lib/gitlab/google_code_import/project_creator.rb create mode 100644 lib/gitlab/google_code_import/repository.rb create mode 100644 spec/controllers/import/google_code_controller_spec.rb create mode 100644 spec/fixtures/GoogleCodeProjectHosting.json rename spec/lib/gitlab/gitorious_import/{project_creator.rb => project_creator_spec.rb} (100%) create mode 100644 spec/lib/gitlab/google_code_import/client_spec.rb create mode 100644 spec/lib/gitlab/google_code_import/importer_spec.rb create mode 100644 spec/lib/gitlab/google_code_import/project_creator_spec.rb diff --git a/app/assets/stylesheets/base/mixins.scss b/app/assets/stylesheets/base/mixins.scss index ccba65e3fd..216f25cdcd 100644 --- a/app/assets/stylesheets/base/mixins.scss +++ b/app/assets/stylesheets/base/mixins.scss @@ -119,6 +119,22 @@ li { line-height: 1.5; } + + a[href*="/uploads/"], a[href*="storage.googleapis.com/google-code-attachments/"] { + &:before { + margin-right: 4px; + + font: normal normal normal 14px/1 FontAwesome; + font-size: inherit; + text-rendering: auto; + -webkit-font-smoothing: antialiased; + content: "\f0c6"; + } + + &:hover:before { + text-decoration: none; + } + } } @mixin str-truncated($max_width: 82%) { diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index d66093bc2e..facd7e1931 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -62,20 +62,8 @@ ul.notes { word-wrap: break-word; @include md-typography; - a[href*="/uploads/"] { - &:before { - margin-right: 4px; - - font: normal normal normal 14px/1 FontAwesome; - font-size: inherit; - text-rendering: auto; - -webkit-font-smoothing: antialiased; - content: "\f0c6"; - } - - &:hover:before { - text-decoration: none; - } + hr { + margin: 10px 0; } } } diff --git a/app/controllers/import/google_code_controller.rb b/app/controllers/import/google_code_controller.rb new file mode 100644 index 0000000000..9542a33193 --- /dev/null +++ b/app/controllers/import/google_code_controller.rb @@ -0,0 +1,63 @@ +class Import::GoogleCodeController < Import::BaseController + + def new + + end + + def callback + dump_file = params[:dump_file] + + unless dump_file.respond_to?(:read) + return redirect_to :back, alert: "You need to upload a Google Takeout JSON file." + end + + begin + dump = JSON.parse(dump_file.read) + rescue + return redirect_to :back, alert: "The uploaded file is not a valid Google Takeout JSON file." + end + + unless Gitlab::GoogleCodeImport::Client.new(dump).valid? + return redirect_to :back, alert: "The uploaded file is not a valid Google Takeout JSON file." + end + + session[:google_code_dump] = dump + redirect_to status_import_google_code_path + end + + def status + unless client.valid? + return redirect_to new_import_google_path + end + + @repos = client.repos + + @already_added_projects = current_user.created_projects.where(import_type: "google_code") + already_added_projects_names = @already_added_projects.pluck(:import_source) + + @repos.reject! { |repo| already_added_projects_names.include? repo.name } + end + + def jobs + jobs = current_user.created_projects.where(import_type: "google_code").to_json(only: [:id, :import_status]) + render json: jobs + end + + def create + @repo_id = params[:repo_id] + repo = client.repo(@repo_id) + @target_namespace = current_user.namespace + @project_name = repo.name + + namespace = @target_namespace + + @project = Gitlab::GoogleCodeImport::ProjectCreator.new(repo, namespace, current_user).execute + end + + private + + def client + @client ||= Gitlab::GoogleCodeImport::Client.new(session[:google_code_dump]) + end + +end diff --git a/app/models/project.rb b/app/models/project.rb index 79572f255d..9b0d5c509b 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -27,6 +27,7 @@ # import_type :string(255) # import_source :string(255) # avatar :string(255) +# import_data :text # require 'carrierwave/orm/activerecord' @@ -50,6 +51,8 @@ class Project < ActiveRecord::Base default_value_for :wall_enabled, false default_value_for :snippets_enabled, gitlab_config_features.snippets + serialize :import_data, JSON + # set last_activity_at to the same as created_at after_create :set_last_activity_at def set_last_activity_at @@ -185,6 +188,7 @@ class Project < ActiveRecord::Base state :failed after_transition any => :started, do: :add_import_job + after_transition any => :finished, do: :clear_import_data end class << self @@ -262,6 +266,11 @@ class Project < ActiveRecord::Base RepositoryImportWorker.perform_in(2.seconds, id) end + def clear_import_data + self.import_data = nil + self.save + end + def import? import_url.present? end diff --git a/app/views/import/google_code/new.html.haml b/app/views/import/google_code/new.html.haml new file mode 100644 index 0000000000..8df5b5d31f --- /dev/null +++ b/app/views/import/google_code/new.html.haml @@ -0,0 +1,17 @@ +%h3.page-title + %i.fa.fa-google + Import projects from Google Code +%hr + += form_tag callback_import_google_code_path, class: 'form-horizontal', multipart: true do + %ul + %li + Use Google Takeout etc + + .form-group + = label_tag :dump_file, "Google Takeout JSON file", class: 'control-label' + .col-sm-10 + %input{type: "file", name: "dump_file", id: "dump_file"} + + .form-actions + = submit_tag 'Select projects to import', class: "btn btn-create" diff --git a/app/views/import/google_code/status.html.haml b/app/views/import/google_code/status.html.haml new file mode 100644 index 0000000000..eba9c5296b --- /dev/null +++ b/app/views/import/google_code/status.html.haml @@ -0,0 +1,45 @@ +%h3.page-title + %i.fa.fa-google + Import projects from Google Code + +%p.light + Select projects you want to import. +%hr +%p + = button_tag 'Import all projects', class: "btn btn-success js-import-all" + +%table.table.import-jobs + %thead + %tr + %th From Google Code + %th To GitLab + %th Status + %tbody + - @already_added_projects.each do |project| + %tr{id: "project_#{project.id}", class: "#{project_status_css_class(project.import_status)}"} + %td + = link_to project.import_source, "https://code.google.com/p/#{project.import_source}", target: "_blank" + %td + %strong= link_to project.path_with_namespace, [project.namespace.becomes(Namespace), project] + %td.job-status + - if project.import_status == 'finished' + %span + %i.fa.fa-check + done + - elsif project.import_status == 'started' + %i.fa.fa-spinner.fa-spin + started + - else + = project.human_import_status_name + + - @repos.each do |repo| + %tr{id: "repo_#{repo.id}"} + %td + = link_to repo.name, "https://code.google.com/p/#{repo.name}", target: "_blank" + %td.import-target + = "#{current_user.username}/#{repo.name}" + %td.import-actions.job-status + = button_tag "Import", class: "btn js-add-to-import" + +:coffeescript + new ImporterStatus("#{jobs_import_google_code_path}", "#{import_google_code_path}") diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index 69909a8554..a06c85b425 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -62,6 +62,10 @@ %i.icon-gitorious.icon-gitorious-small Gitorious.org + = link_to new_import_google_code_path, class: 'btn' do + %i.fa.fa-google + Google Code + = link_to "#", class: 'btn js-toggle-button' do %i.fa.fa-git %span Any repo by URL diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb index 437640d230..e6a50afedb 100644 --- a/app/workers/repository_import_worker.rb +++ b/app/workers/repository_import_worker.rb @@ -18,6 +18,8 @@ class RepositoryImportWorker Gitlab::GitlabImport::Importer.new(project).execute elsif project.import_type == 'bitbucket' Gitlab::BitbucketImport::Importer.new(project).execute + elsif project.import_type == 'google_code' + Gitlab::GoogleCodeImport::Importer.new(project).execute else true end diff --git a/config/routes.rb b/config/routes.rb index 388858d267..88e0227121 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -81,6 +81,12 @@ Gitlab::Application.routes.draw do get :callback get :jobs end + + resource :google_code, only: [:create, :new], controller: :google_code do + get :status + post :callback + get :jobs + end end # diff --git a/db/migrate/20150327150017_add_import_data_to_project.rb b/db/migrate/20150327150017_add_import_data_to_project.rb new file mode 100644 index 0000000000..12c00339ee --- /dev/null +++ b/db/migrate/20150327150017_add_import_data_to_project.rb @@ -0,0 +1,5 @@ +class AddImportDataToProject < ActiveRecord::Migration + def change + add_column :projects, :import_data, :text + end +end diff --git a/db/schema.rb b/db/schema.rb index 14e32a7946..390cd4f36d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -342,6 +342,7 @@ ActiveRecord::Schema.define(version: 20150328132231) do t.integer "star_count", default: 0, null: false t.string "import_type" t.string "import_source" + t.text "import_data" end add_index "projects", ["created_at", "id"], name: "index_projects_on_created_at_and_id", using: :btree diff --git a/lib/gitlab/google_code_import/client.rb b/lib/gitlab/google_code_import/client.rb new file mode 100644 index 0000000000..0514494d3a --- /dev/null +++ b/lib/gitlab/google_code_import/client.rb @@ -0,0 +1,23 @@ +module Gitlab + module GoogleCodeImport + class Client + attr_reader :raw_data + + def initialize(raw_data) + @raw_data = raw_data + end + + def valid? + raw_data.is_a?(Hash) && raw_data["kind"] == "projecthosting#user" && raw_data.has_key?("projects") + end + + def repos + @repos ||= raw_data["projects"].map { |raw_repo| GoogleCodeImport::Repository.new(raw_repo) }.select(&:git?) + end + + def repo(id) + repos.find { |repo| repo.id == id } + end + end + end +end diff --git a/lib/gitlab/google_code_import/importer.rb b/lib/gitlab/google_code_import/importer.rb new file mode 100644 index 0000000000..da0f9a10b0 --- /dev/null +++ b/lib/gitlab/google_code_import/importer.rb @@ -0,0 +1,327 @@ +module Gitlab + module GoogleCodeImport + class Importer + attr_reader :project, :repo + + def initialize(project) + @project = project + @repo = GoogleCodeImport::Repository.new(project.import_data) + + @closed_statuses = [] + @known_labels = Set.new + end + + def execute + return true unless repo.valid? + + import_status_labels + + import_labels + + import_issues + + true + end + + private + + def import_status_labels + repo.raw_data["issuesConfig"]["statuses"].each do |status| + closed = !status["meansOpen"] + @closed_statuses << status["status"] if closed + + name = nice_status_name(status["status"]) + create_label(name) + @known_labels << name + end + end + + def import_labels + repo.raw_data["issuesConfig"]["labels"].each do |label| + name = nice_label_name(label["label"]) + create_label(name) + @known_labels << name + end + end + + def import_issues + return unless repo.raw_data["issues"] + + last_id = 0 + + deleted_issues = [] + + issues = repo.raw_data["issues"]["items"] + issues.each_with_index do |raw_issue, i| + while raw_issue["id"] > last_id + 1 + last_id += 1 + + issue = project.issues.create!( + title: "Deleted issue", + description: "*This issue has been deleted*", + author_id: project.creator_id, + state: "closed" + ) + deleted_issues << issue + end + last_id = raw_issue["id"] + + author = mask_email(raw_issue["author"]["name"]) + author_link = raw_issue["author"]["htmlLink"] + date = DateTime.parse(raw_issue["published"]).to_formatted_s(:long) + + body = [] + body << "*By [#{author}](#{author_link}) on #{date}*" + body << "---" + + comments = raw_issue["comments"]["items"] + + issue_comment = comments.shift + + content = format_content(issue_comment["content"]) + if content.blank? + content = "*(No description has been entered for this issue)*" + end + body << content + + attachments = format_attachments(raw_issue["id"], 0, issue_comment["attachments"]) + if attachments.any? + body << "---" + body += attachments + end + + labels = [] + raw_issue["labels"].each do |label| + name = nice_label_name(label) + labels << name + + unless @known_labels.include?(name) + create_label(name) + @known_labels << name + end + end + labels << nice_status_name(raw_issue["status"]) + + issue = project.issues.create!( + title: raw_issue["title"], + description: body.join("\n\n"), + author_id: project.creator_id, + state: raw_issue["state"] == "closed" ? "closed" : "opened" + ) + issue.add_labels_by_names(labels) + + import_issue_comments(issue, comments) + end + + deleted_issues.each(&:destroy!) + end + + def import_issue_comments(issue, comments) + comments.each_with_index do |raw_comment, i| + next if raw_comment.has_key?("deletedBy") + + author = mask_email(raw_comment["author"]["name"]) + author_link = raw_comment["author"]["htmlLink"] + date = DateTime.parse(raw_comment["published"]).to_formatted_s(:long) + + body = [] + body << "*By [#{author}](#{author_link}) on #{date}*" + body << "---" + + content = format_content(raw_comment["content"]) + if content.blank? + content = "*(No comment has been entered for this change)*" + end + body << content + + updates = format_updates(raw_comment["updates"]) + if updates.any? + body << "---" + body += updates + end + + attachments = format_attachments(issue.iid, raw_comment["id"], raw_comment["attachments"]) + if attachments.any? + body << "---" + body += attachments + end + + comment = issue.notes.create!( + project_id: project.id, + author_id: project.creator_id, + note: body.join("\n\n") + ) + end + end + + def nice_label_color(name) + case name + when /\AComponent:/ + "#fff39e" + when /\AOpSys:/ + "#e2e2e2" + when /\AMilestone:/ + "#fee3ff" + + when *@closed_statuses.map { |s| nice_status_name(s) } + "#cfcfcf" + when "Status: New" + "#428bca" + when "Status: Accepted" + "#5cb85c" + when "Status: NeedInfo" + "#f0ad4e" + when "Status: Started" + "#8e44ad" + when "Status: Wishlist" + "#a8d695" + # + when "Priority: Critical" + "#ffcfcf" + when "Priority: High" + "#deffcf" + when "Priority: Medium" + "#fff5cc" + when "Priority: Low" + "#cfe9ff" + # + when "Type: Defect" + "#d9534f" + when "Type: Enhancement" + "#44ad8e" + when "Type: Other" + "#7f8c8d" + when "Type: Review" + "#8e44ad" + when "Type: Task" + "#4b6dd0" + else + "#e2e2e2" + end + end + + def nice_label_name(name) + name.sub("-", ": ") + end + + def nice_status_name(name) + "Status: #{name}" + end + + def mask_email(author) + parts = author.split("@", 2) + parts[0] = "#{parts[0][0...-3]}..." + parts.join("@") + end + + def linkify_issues(s) + s.gsub(/([Ii]ssue) ([0-9]+)/, '\1 #\2') + end + + def escape_for_markdown(s) + s = s.gsub("*", "\\*") + s = s.gsub("#", "\\#") + s = s.gsub("`", "\\`") + s = s.gsub(":", "\\:") + s = s.gsub("-", "\\-") + s = s.gsub("+", "\\+") + s = s.gsub("_", "\\_") + s = s.gsub("(", "\\(") + s = s.gsub(")", "\\)") + s = s.gsub("[", "\\[") + s = s.gsub("]", "\\]") + s = s.gsub("<", "\\<") + s = s.gsub(">", "\\>") + s = s.gsub("\r", "") + s = s.gsub("\n", " \n") + s + end + + def create_label(name) + color = nice_label_color(name) + project.labels.create!(name: name, color: color) + end + + def format_content(raw_content) + linkify_issues(escape_for_markdown(raw_content)) + end + + def format_updates(raw_updates) + updates = [] + + if raw_updates.has_key?("status") + updates << "*Status: #{raw_updates["status"]}*" + end + + if raw_updates.has_key?("cc") + cc = raw_updates["cc"].map do |l| + deleted = l.start_with?("-") + l = l[1..-1] if deleted + l = mask_email(l) + l = "~~#{l}~~" if deleted + l + end + + updates << "*Cc: #{cc.join(", ")}*" + end + + if raw_updates.has_key?("labels") + labels = raw_updates["labels"].map do |l| + deleted = l.start_with?("-") + l = l[1..-1] if deleted + l = nice_label_name(l) + l = "~~#{l}~~" if deleted + l + end + + updates << "*Labels: #{labels.join(", ")}*" + end + + if raw_updates.has_key?("owner") + updates << "*Owner: #{raw_updates["owner"]}*" + end + + if raw_updates.has_key?("mergedInto") + updates << "*Merged into: ##{raw_updates["mergedInto"]}*" + end + + if raw_updates.has_key?("blockedOn") + blocked_ons = raw_updates["blockedOn"].map do |raw_blocked_on| + name, id = raw_blocked_on.split(":", 2) + if name == project.import_source + "##{id}" + else + "#{project.namespace.path}/#{name}##{id}" + end + end + updates << "*Blocked on: #{blocked_ons.join(", ")}*" + end + + if raw_updates.has_key?("blocking") + blockings = raw_updates["blocking"].map do |raw_blocked_on| + name, id = raw_blocked_on.split(":", 2) + if name == project.import_source + "##{id}" + else + "#{project.namespace.path}/#{name}##{id}" + end + end + updates << "*Blocking: #{blockings.join(", ")}*" + end + + updates + end + + def format_attachments(issue_id, comment_id, raw_attachments) + return [] unless raw_attachments + + raw_attachments.map do |attachment| + next if attachment["isDeleted"] + + link = "https://storage.googleapis.com/google-code-attachments/#{@repo.name}/issue-#{issue_id}/comment-#{comment_id}/#{attachment["fileName"]}" + "[#{attachment["fileName"]}](#{link})" + end.compact + end + end + end +end diff --git a/lib/gitlab/google_code_import/project_creator.rb b/lib/gitlab/google_code_import/project_creator.rb new file mode 100644 index 0000000000..933e144d8e --- /dev/null +++ b/lib/gitlab/google_code_import/project_creator.rb @@ -0,0 +1,40 @@ +module Gitlab + module GoogleCodeImport + class ProjectCreator + attr_reader :repo, :namespace, :current_user + + def initialize(repo, namespace, current_user) + @repo = repo + @namespace = namespace + @current_user = current_user + end + + def execute + @project = Project.new( + name: repo.name, + path: repo.name, + description: repo.summary, + namespace: namespace, + creator: current_user, + visibility_level: Gitlab::VisibilityLevel::PUBLIC, + import_type: "google_code", + import_source: repo.name, + import_url: repo.import_url, + import_data: repo.raw_data + ) + + if @project.save! + @project.reload + + if @project.import_failed? + @project.import_retry + else + @project.import_start + end + end + + @project + end + end + end +end diff --git a/lib/gitlab/google_code_import/repository.rb b/lib/gitlab/google_code_import/repository.rb new file mode 100644 index 0000000000..39a884d829 --- /dev/null +++ b/lib/gitlab/google_code_import/repository.rb @@ -0,0 +1,39 @@ +module Gitlab + module GoogleCodeImport + class Repository + attr_accessor :raw_data + + def initialize(raw_data) + @raw_data = raw_data + end + + def valid? + raw_data.is_a?(Hash) && raw_data["kind"] == "projecthosting#project" + end + + def id + raw_data["externalId"] + end + + def name + raw_data["name"] + end + + def summary + raw_data["summary"] + end + + def description + raw_data["description"] + end + + def git? + raw_data["versionControlSystem"] == "git" + end + + def import_url + raw_data["repositoryUrls"].first + end + end + end +end diff --git a/spec/controllers/import/google_code_controller_spec.rb b/spec/controllers/import/google_code_controller_spec.rb new file mode 100644 index 0000000000..037cddb460 --- /dev/null +++ b/spec/controllers/import/google_code_controller_spec.rb @@ -0,0 +1,47 @@ +require 'spec_helper' + +describe Import::GoogleCodeController do + let(:user) { create(:user) } + let(:dump_file) { fixture_file_upload(Rails.root + 'spec/fixtures/GoogleCodeProjectHosting.json', 'application/json') } + + before do + sign_in(user) + end + + describe "POST callback" do + it "stores Google Takeout dump list in session" do + post :callback, dump_file: dump_file + + expect(session[:google_code_dump]).to be_a(Hash) + expect(session[:google_code_dump]["kind"]).to eq("projecthosting#user") + expect(session[:google_code_dump]).to have_key("projects") + end + end + + describe "GET status" do + before do + @repo = OpenStruct.new(name: 'vim') + controller.stub_chain(:client, :valid?).and_return(true) + end + + it "assigns variables" do + @project = create(:project, import_type: 'google_code', creator_id: user.id) + controller.stub_chain(:client, :repos).and_return([@repo]) + + get :status + + expect(assigns(:already_added_projects)).to eq([@project]) + expect(assigns(:repos)).to eq([@repo]) + end + + it "does not show already added project" do + @project = create(:project, import_type: 'google_code', creator_id: user.id, import_source: 'vim') + controller.stub_chain(:client, :repos).and_return([@repo]) + + get :status + + expect(assigns(:already_added_projects)).to eq([@project]) + expect(assigns(:repos)).to eq([]) + end + end +end diff --git a/spec/fixtures/GoogleCodeProjectHosting.json b/spec/fixtures/GoogleCodeProjectHosting.json new file mode 100644 index 0000000000..a1d8d32adf --- /dev/null +++ b/spec/fixtures/GoogleCodeProjectHosting.json @@ -0,0 +1,397 @@ +{ + "kind" : "projecthosting#user", + "id" : "@WRRVSlFXARlCVgB6", + "projects" : [ { + "kind" : "projecthosting#project", + "name" : "pmn", + "externalId" : "pmn", + "htmlLink" : "/p/pmn/", + "summary" : "Shows an icon in the system tray when you have new emails", + "description" : "IMAP client that shows an icon in the system tray when you have new emails.", + "labels" : [ "Mail" ], + "versionControlSystem" : "svn", + "repositoryUrls" : [ "https://pmn.googlecode.com/svn/" ], + "issuesConfig" : { + "kind" : "projecthosting#projectIssueConfig", + "statuses" : [ { + "status" : "New", + "meansOpen" : true, + "description" : "Issue has not had initial review yet" + }, { + "status" : "Accepted", + "meansOpen" : true, + "description" : "Problem reproduced / Need acknowledged" + }, { + "status" : "Started", + "meansOpen" : true, + "description" : "Work on this issue has begun" + }, { + "status" : "Fixed", + "meansOpen" : false, + "description" : "Developer made source code changes, QA should verify" + }, { + "status" : "Verified", + "meansOpen" : false, + "description" : "QA has verified that the fix worked" + }, { + "status" : "Invalid", + "meansOpen" : false, + "description" : "This was not a valid issue report" + }, { + "status" : "Duplicate", + "meansOpen" : false, + "description" : "This report duplicates an existing issue" + }, { + "status" : "WontFix", + "meansOpen" : false, + "description" : "We decided to not take action on this issue" + }, { + "status" : "Done", + "meansOpen" : false, + "description" : "The requested non-coding task was completed" + } ], + "labels" : [ { + "label" : "Type-Defect", + "description" : "Report of a software defect" + }, { + "label" : "Type-Enhancement", + "description" : "Request for enhancement" + }, { + "label" : "Type-Task", + "description" : "Work item that doesn't change the code or docs" + }, { + "label" : "Type-Review", + "description" : "Request for a source code review" + }, { + "label" : "Type-Other", + "description" : "Some other kind of issue" + }, { + "label" : "Priority-Critical", + "description" : "Must resolve in the specified milestone" + }, { + "label" : "Priority-High", + "description" : "Strongly want to resolve in the specified milestone" + }, { + "label" : "Priority-Medium", + "description" : "Normal priority" + }, { + "label" : "Priority-Low", + "description" : "Might slip to later milestone" + }, { + "label" : "OpSys-All", + "description" : "Affects all operating systems" + }, { + "label" : "OpSys-Windows", + "description" : "Affects Windows users" + }, { + "label" : "OpSys-Linux", + "description" : "Affects Linux users" + }, { + "label" : "OpSys-OSX", + "description" : "Affects Mac OS X users" + }, { + "label" : "Milestone-Release1.0", + "description" : "All essential functionality working" + }, { + "label" : "Component-UI", + "description" : "Issue relates to program UI" + }, { + "label" : "Component-Logic", + "description" : "Issue relates to application logic" + }, { + "label" : "Component-Persistence", + "description" : "Issue relates to data storage components" + }, { + "label" : "Component-Scripts", + "description" : "Utility and installation scripts" + }, { + "label" : "Component-Docs", + "description" : "Issue relates to end-user documentation" + }, { + "label" : "Security", + "description" : "Security risk to users" + }, { + "label" : "Performance", + "description" : "Performance issue" + }, { + "label" : "Usability", + "description" : "Affects program usability" + }, { + "label" : "Maintainability", + "description" : "Hinders future changes" + } ], + "prompts" : [ { + "name" : "Defect report from user", + "title" : "Enter one-line summary", + "description" : "What steps will reproduce the problem?\n1. \n2. \n3. \n\nWhat is the expected output? What do you see instead?\n\n\nWhat version of the product are you using? On what operating system?\n\n\nPlease provide any additional information below.\n", + "titleMustBeEdited" : true, + "status" : "New", + "labels" : [ "Type-Defect", "Priority-Medium" ] + }, { + "name" : "Defect report from developer", + "title" : "Enter one-line summary", + "description" : "What steps will reproduce the problem?\n1. \n2. \n3. \n\nWhat is the expected output? What do you see instead?\n\n\nPlease use labels and text to provide additional information.\n", + "titleMustBeEdited" : true, + "status" : "Accepted", + "labels" : [ "Type-Defect", "Priority-Medium" ], + "membersOnly" : true + }, { + "name" : "Review request", + "title" : "Code review request", + "description" : "Branch name:\n\nPurpose of code changes on this branch:\n\n\nWhen reviewing my code changes, please focus on:\n\n\nAfter the review, I'll merge this branch into:\n/trunk\n", + "status" : "New", + "labels" : [ "Type-Review", "Priority-Medium" ], + "membersOnly" : true, + "defaultToMember" : false + } ], + "defaultPromptForMembers" : 1, + "defaultPromptForNonMembers" : 0 + }, + "role" : "owner", + "members" : [ { + "kind" : "projecthosting#issuePerson", + "name" : "mrovi9000", + "htmlLink" : "https://code.google.com/u/106736353629303906862/" + } ], + "issues" : { + "kind" : "projecthosting#issueList", + "totalResults" : 0, + "items" : [ ] + } + }, { + "kind" : "projecthosting#project", + "name" : "tint2", + "externalId" : "tint2", + "htmlLink" : "/p/tint2/", + "summary" : "tint2 is a lightweight panel/taskbar.", + "description" : "tint2 is a simple _*panel/taskbar*_ unintrusive and light (memory / cpu / aestetic).
We follow freedesktop specifications.\r\n \r\n=== 0.11 features ===\r\n * panel with taskbar, systray, clock and battery status\r\n * easy to customize : color/transparency on font, icon, border and background\r\n * pager like capability : send task from one workspace to another, switch workspace\r\n * multi-monitor capability : one panel per monitor, show task from current monitor\r\n * customize mouse event\r\n * window manager's menu\r\n * tooltip\r\n * autohide\r\n * clock timezones\r\n * real & fake transparency with autodetection of composite manager\r\n * panel's theme switcher 'tint2conf' \r\n\r\n=== Other project ===\r\n * Lightweight volume control http://softwarebakery.com/maato/volumeicon.html\r\n * Lightweight calendar http://code.google.com/p/gsimplecal/\r\n * Graphical config tool http://code.google.com/p/tintwizard/\r\n * Command line theme switcher http://github.com/dbbolton/scripts/blob/master/tint2theme\r\n\r\n\r\n=== Snapshot SVN ===\r\n\r\nhttp://img252.imageshack.us/img252/1433/wallpaper2td.jpg\r\n\r\n\r\n", + "labels" : [ "taskbar", "panel", "lightweight", "desktop", "openbox", "pager", "tint2" ], + "versionControlSystem" : "git", + "repositoryUrls" : [ "https://tint2.googlecode.com/git/" ], + "issuesConfig" : { + "kind" : "projecthosting#projectIssueConfig", + "defaultColumns" : [ "ID", "Status", "Type", "Milestone", "Priority", "Component", "Owner", "Summary", "Modified", "Stars" ], + "defaultSorting" : [ "-ID" ], + "statuses" : [ { + "status" : "New", + "meansOpen" : true, + "description" : "Issue has not had initial review yet" + }, { + "status" : "NeedInfo", + "meansOpen" : true, + "description" : "More information is needed before deciding what action should be taken" + }, { + "status" : "Accepted", + "meansOpen" : true, + "description" : "A Defect that a developer has reproduced or an Enhancement that a developer has committed to addressing" + }, { + "status" : "Wishlist", + "meansOpen" : true, + "description" : "An Enhancement which is valid, but no developers have committed to addressing" + }, { + "status" : "Started", + "meansOpen" : true, + "description" : "Work on this issue has begun" + }, { + "status" : "Fixed", + "meansOpen" : false, + "description" : "Work has completed" + }, { + "status" : "Invalid", + "meansOpen" : false, + "description" : "This was not a valid issue report" + }, { + "status" : "Duplicate", + "meansOpen" : false, + "description" : "This report duplicates an existing issue" + }, { + "status" : "WontFix", + "meansOpen" : false, + "description" : "We decided to not take action on this issue" + }, { + "status" : "Incomplete", + "meansOpen" : false, + "description" : "Not enough information and no activity for a long period of time" + } ], + "labels" : [ { + "label" : "Type-Defect", + "description" : "Report of a software defect" + }, { + "label" : "Type-Enhancement", + "description" : "Request for enhancement" + }, { + "label" : "Type-Task", + "description" : "Work item that does not change the code" + }, { + "label" : "Type-Review", + "description" : "Request for a source code review" + }, { + "label" : "Type-Other", + "description" : "Some other kind of issue" + }, { + "label" : "Milestone-0.12", + "description" : "Fix should be included in release 0.12" + }, { + "label" : "Priority-Critical", + "description" : "Must resolve in the specified milestone" + }, { + "label" : "Priority-High", + "description" : "Strongly want to resolve in the specified milestone" + }, { + "label" : "Priority-Medium", + "description" : "Normal priority" + }, { + "label" : "Priority-Low", + "description" : "Might slip to later milestone" + }, { + "label" : "OpSys-All", + "description" : "Affects all operating systems" + }, { + "label" : "OpSys-Windows", + "description" : "Affects Windows users" + }, { + "label" : "OpSys-Linux", + "description" : "Affects Linux users" + }, { + "label" : "OpSys-OSX", + "description" : "Affects Mac OS X users" + }, { + "label" : "Security", + "description" : "Security risk to users" + }, { + "label" : "Performance", + "description" : "Performance issue" + }, { + "label" : "Usability", + "description" : "Affects program usability" + }, { + "label" : "Maintainability", + "description" : "Hinders future changes" + }, { + "label" : "Component-Panel", + "description" : "Issue relates to the panel (e.g. positioning, hiding, transparency)" + }, { + "label" : "Component-Taskbar", + "description" : "Issue relates to the taskbar (e.g. tasks, multiple desktops)" + }, { + "label" : "Component-Battery", + "description" : "Issue relates to the battery" + }, { + "label" : "Component-Systray", + "description" : "Issue relates to the system tray" + }, { + "label" : "Component-Clock", + "description" : "Issue relates to the clock" + }, { + "label" : "Component-Launcher", + "description" : "Issue relates to the launcher" + }, { + "label" : "Component-Tint2conf", + "description" : "Issue relates to the configuration GUI (tint2conf)" + }, { + "label" : "Component-Docs", + "description" : "Issue relates to end-user documentation" + }, { + "label" : "Component-New", + "description" : "Issue describes a new component proposal" + } ], + "prompts" : [ { + "name" : "Defect report from user", + "title" : "Enter one-line summary", + "description" : "What steps will reproduce the problem?\n1.\n2.\n3.\n\nWhat is the expected output? What do you see instead?\n\n\nWhat version of the product are you using? On what operating system?\n\n\nWhich window manager (e.g. openbox, xfwm, metacity, mutter, kwin) or\nwhich desktop environment (e.g. Gnome 2, Gnome 3, LXDE, XFCE, KDE)\nare you using?\n\n\nPlease provide any additional information below. It might be helpful\nto attach your tint2rc file (usually located at ~/.config/tint2/tint2rc).", + "titleMustBeEdited" : true, + "status" : "New", + "labels" : [ "Priority-Medium" ], + "defaultToMember" : true + }, { + "name" : "Defect report from developer", + "title" : "Enter one-line summary", + "description" : "What steps will reproduce the problem?\n1.\n2.\n3.\n\nWhat is the expected output? What do you see instead?\n\n\nPlease use labels and text to provide additional information.", + "titleMustBeEdited" : true, + "status" : "Accepted", + "labels" : [ "Type-Defect", "Priority-Medium" ], + "membersOnly" : true, + "defaultToMember" : true + }, { + "name" : "Review request", + "title" : "Code review request", + "description" : "Purpose of code changes on this branch:\n\n\nWhen reviewing my code changes, please focus on:\n\n\nAfter the review, I'll merge this branch into:\n/trunk", + "status" : "New", + "labels" : [ "Type-Review", "Priority-Medium" ], + "membersOnly" : true, + "defaultToMember" : true + } ], + "defaultPromptForMembers" : 1, + "defaultPromptForNonMembers" : 0, + "usersCanSetLabels" : false + }, + "role" : "owner", + "issues" : { + "kind" : "projecthosting#issueList", + "totalResults" : 473, + "items" : [ { + "kind" : "projecthosting#issue", + "id" : 169, + "title" : "Scrolling through tasks", + "summary" : "Scrolling through tasks", + "stars" : 1, + "starred" : false, + "status" : "Fixed", + "state" : "closed", + "labels" : [ "Type-Enhancement", "Priority-Medium" ], + "author" : { + "kind" : "projecthosting#issuePerson", + "name" : "schattenpr...", + "htmlLink" : "https://code.google.com/u/106498139506637530000/" + }, + "updated" : "2009-11-18T05:14:58.000Z", + "published" : "2009-11-18T00:20:19.000Z", + "closed" : "2009-11-18T05:14:58.000Z", + "projectId" : "tint2", + "canComment" : true, + "canEdit" : true, + "comments" : { + "kind" : "projecthosting#issueCommentList", + "totalResults" : 2, + "items" : [ { + "id" : 0, + "kind" : "projecthosting#issueComment", + "author" : { + "kind" : "projecthosting#issuePerson", + "name" : "schattenpr...", + "htmlLink" : "https://code.google.com/u/10649813950663753000/" + }, + "content" : "I like to scroll through the tasks with my scrollwheel (like in fluxbox). \r\n\r\nPatch is attached that adds two new mouse-actions (next_task+prev_task) \r\nthat can be used for exactly that purpose. \r\n\r\nall the best!", + "published" : "2009-11-18T00:20:19.000Z", + "updates" : { + "kind" : "projecthosting#issueCommentUpdate" + }, + "canDelete" : true, + "attachments" : [ { + "attachmentId" : "8901002890399325565", + "fileName" : "tint2_task_scrolling.diff", + "fileSize" : 3059, + "mimetype" : "text/x-c++; charset=us-ascii" + } ] + }, { + "id" : 1, + "kind" : "projecthosting#issueComment", + "author" : { + "kind" : "projecthosting#issuePerson", + "name" : "thilo...", + "htmlLink" : "https://code.google.com/u/104224918623172014000/" + }, + "content" : "applied, thanks.\r\n", + "published" : "2009-11-18T05:14:58.000Z", + "updates" : { + "kind" : "projecthosting#issueCommentUpdate", + "status" : "Fixed", + "labels" : [ "-Type-Defect", "Type-Enhancement" ] + }, + "canDelete" : true + } ] + } + } ] + } + } ] +} diff --git a/spec/lib/gitlab/gitorious_import/project_creator.rb b/spec/lib/gitlab/gitorious_import/project_creator_spec.rb similarity index 100% rename from spec/lib/gitlab/gitorious_import/project_creator.rb rename to spec/lib/gitlab/gitorious_import/project_creator_spec.rb diff --git a/spec/lib/gitlab/google_code_import/client_spec.rb b/spec/lib/gitlab/google_code_import/client_spec.rb new file mode 100644 index 0000000000..d2bf871daa --- /dev/null +++ b/spec/lib/gitlab/google_code_import/client_spec.rb @@ -0,0 +1,34 @@ +require "spec_helper" + +describe Gitlab::GoogleCodeImport::Client do + let(:raw_data) { JSON.parse(File.read(Rails.root.join("spec/fixtures/GoogleCodeProjectHosting.json"))) } + subject { described_class.new(raw_data) } + + describe "#valid?" do + context "when the data is valid" do + it "returns true" do + expect(subject).to be_valid + end + end + + context "when the data is invalid" do + let(:raw_data) { "No clue" } + + it "returns true" do + expect(subject).to_not be_valid + end + end + end + + describe "#repos" do + it "returns only Git repositories" do + expect(subject.repos.length).to eq(1) + end + end + + describe "#repo" do + it "returns the referenced repository" do + expect(subject.repo("tint2").name).to eq("tint2") + end + end +end diff --git a/spec/lib/gitlab/google_code_import/importer_spec.rb b/spec/lib/gitlab/google_code_import/importer_spec.rb new file mode 100644 index 0000000000..ee5f99c12b --- /dev/null +++ b/spec/lib/gitlab/google_code_import/importer_spec.rb @@ -0,0 +1,69 @@ +require "spec_helper" + +describe Gitlab::GoogleCodeImport::Importer do + let(:raw_data) { JSON.parse(File.read(Rails.root.join("spec/fixtures/GoogleCodeProjectHosting.json"))) } + let(:client) { Gitlab::GoogleCodeImport::Client.new(raw_data) } + let(:import_data) { client.repo("tint2").raw_data } + let(:project) { create(:project, import_data: import_data) } + subject { described_class.new(project) } + + describe "#execute" do + it "imports status labels" do + subject.execute + + %w(New NeedInfo Accepted Wishlist Started Fixed Invalid Duplicate WontFix Incomplete).each do |status| + expect(project.labels.find_by(name: "Status: #{status}")).to_not be_nil + end + end + + it "imports labels" do + subject.execute + + %w( + Type-Defect Type-Enhancement Type-Task Type-Review Type-Other Milestone-0.12 Priority-Critical + Priority-High Priority-Medium Priority-Low OpSys-All OpSys-Windows OpSys-Linux OpSys-OSX Security + Performance Usability Maintainability Component-Panel Component-Taskbar Component-Battery + Component-Systray Component-Clock Component-Launcher Component-Tint2conf Component-Docs Component-New + ).each do |label| + label.sub!("-", ": ") + expect(project.labels.find_by(name: label)).to_not be_nil + end + end + + it "imports issues" do + subject.execute + + issue = project.issues.first + expect(issue).to_not be_nil + expect(issue.iid).to eq(169) + expect(issue.state).to eq("closed") + expect(issue.label_names).to include("Priority: Medium") + expect(issue.label_names).to include("Status: Fixed") + expect(issue.label_names).to include("Type: Enhancement") + expect(issue.title).to eq("Scrolling through tasks") + expect(issue.state).to eq("closed") + expect(issue.description).to include("schattenpr...") + expect(issue.description).to include("https://code.google.com/u/106498139506637530000/") + expect(issue.description).to include("November 18, 2009 00:20") + expect(issue.description).to include('I like to scroll through the tasks with my scrollwheel \(like in fluxbox\).') + expect(issue.description).to include('Patch is attached that adds two new mouse\-actions \(next\_taskprev\_task\)') + expect(issue.description).to include('that can be used for exactly that purpose.') + expect(issue.description).to include('all the best!') + expect(issue.description).to include('https://storage.googleapis.com/google-code-attachments/tint2/issue-169/comment-0/tint2_task_scrolling.diff') + end + + it "imports issue comments" do + subject.execute + + note = project.issues.first.notes.first + expect(note).to_not be_nil + expect(note.note).to include("thilo...") + expect(note.note).to include("https://code.google.com/u/104224918623172014000/") + expect(note.note).to include("November 18, 2009 05:14") + expect(note.note).to include("applied, thanks.") + expect(note.note).to include("Status: Fixed") + expect(note.note).to include("~~Type: Defect~~") + expect(note.note).to include("Type: Enhancement") + end + end +end diff --git a/spec/lib/gitlab/google_code_import/project_creator_spec.rb b/spec/lib/gitlab/google_code_import/project_creator_spec.rb new file mode 100644 index 0000000000..7fca396f15 --- /dev/null +++ b/spec/lib/gitlab/google_code_import/project_creator_spec.rb @@ -0,0 +1,24 @@ +require 'spec_helper' + +describe Gitlab::GoogleCodeImport::ProjectCreator do + let(:user) { create(:user) } + let(:repo) { + Gitlab::GoogleCodeImport::Repository.new( + "name" => 'vim', + "summary" => 'VI Improved', + "repositoryUrls" => [ "https://vim.googlecode.com/git/" ] + ) + } + let(:namespace) { create(:namespace) } + + it 'creates project' do + allow_any_instance_of(Project).to receive(:add_import_job) + + project_creator = Gitlab::GoogleCodeImport::ProjectCreator.new(repo, namespace, user) + project_creator.execute + project = Project.last + + expect(project.import_url).to eq("https://vim.googlecode.com/git/") + expect(project.visibility_level).to eq(Gitlab::VisibilityLevel::PUBLIC) + end +end From 81a5a9713d69c738912d27cb10e74577aa4f4978 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Wed, 25 Mar 2015 20:17:24 -0700 Subject: [PATCH 035/194] Add ability to configure Reply-To address in gitlab.yml Closes #1305 --- CHANGELOG | 1 + app/mailers/notify.rb | 2 +- config/gitlab.yml.example | 1 + config/initializers/1_settings.rb | 1 + spec/mailers/notify_spec.rb | 8 ++++++++ 5 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index 259e1a3007..1810d20117 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 7.10.0 (unreleased) + - Add ability to configure Reply-To address in gitlab.yml (Stan Hu) - Fix broken side-by-side diff view on merge request page (Stan Hu) - Set Application controller default URL options to ensure all url_for calls are consistent (Stan Hu) - Allow HTML tags in Markdown input diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb index 8fcdd3bc85..0c186ab586 100644 --- a/app/mailers/notify.rb +++ b/app/mailers/notify.rb @@ -19,7 +19,7 @@ class Notify < ActionMailer::Base default_url_options[:script_name] = Gitlab.config.gitlab.relative_url_root default from: Proc.new { default_sender_address.format } - default reply_to: "noreply@#{Gitlab.config.gitlab.host}" + default reply_to: Gitlab.config.gitlab.email_reply_to # Just send email with 2 seconds delay def self.delay diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index 760a589d6e..3d91b67e74 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -49,6 +49,7 @@ production: &base # Email address used in the "From" field in mails sent by GitLab email_from: example@example.com email_display_name: GitLab + email_reply_to: noreply@example.com # Email server smtp settings are in config/initializers/smtp_settings.rb.sample diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index 15c1ae9466..fb2dddf2c9 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -103,6 +103,7 @@ Settings.gitlab['protocol'] ||= Settings.gitlab.https ? "https" : "http" Settings.gitlab['email_enabled'] ||= true if Settings.gitlab['email_enabled'].nil? Settings.gitlab['email_from'] ||= "gitlab@#{Settings.gitlab.host}" Settings.gitlab['email_display_name'] ||= "GitLab" +Settings.gitlab['email_reply_to'] ||= "noreply@#{Settings.gitlab.host}" Settings.gitlab['url'] ||= Settings.send(:build_gitlab_url) Settings.gitlab['user'] ||= 'git' Settings.gitlab['user_home'] ||= begin diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index ba42f9e5c7..7c4235ab37 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -7,9 +7,12 @@ describe Notify do let(:gitlab_sender_display_name) { Gitlab.config.gitlab.email_display_name } let(:gitlab_sender) { Gitlab.config.gitlab.email_from } + let(:gitlab_sender_reply_to) { Gitlab.config.gitlab.email_reply_to } let(:recipient) { create(:user, email: 'recipient@example.com') } let(:project) { create(:project) } + around(:each) { ActionMailer::Base.deliveries.clear } + before(:each) do email = recipient.emails.create(email: "notifications@example.com") recipient.update_attribute(:notification_email, email.email) @@ -27,6 +30,11 @@ describe Notify do expect(sender.display_name).to eq(gitlab_sender_display_name) expect(sender.address).to eq(gitlab_sender) end + + it 'has a Reply-To address' do + reply_to = subject.header[:reply_to].addresses + expect(reply_to).to eq([gitlab_sender_reply_to]) + end end shared_examples 'an email starting a new thread' do |message_id_prefix| From 17e23108788b4407dba4480f7b2e96013e023b32 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 3 Apr 2015 16:02:39 +0200 Subject: [PATCH 036/194] Add instructions to Google Code import page. --- .../import/google_code_controller.rb | 6 +-- app/views/import/google_code/new.html.haml | 54 +++++++++++++++---- 2 files changed, 47 insertions(+), 13 deletions(-) diff --git a/app/controllers/import/google_code_controller.rb b/app/controllers/import/google_code_controller.rb index 9542a33193..9bf29386bc 100644 --- a/app/controllers/import/google_code_controller.rb +++ b/app/controllers/import/google_code_controller.rb @@ -8,17 +8,17 @@ class Import::GoogleCodeController < Import::BaseController dump_file = params[:dump_file] unless dump_file.respond_to?(:read) - return redirect_to :back, alert: "You need to upload a Google Takeout JSON file." + return redirect_to :back, alert: "You need to upload a Google Takeout archive." end begin dump = JSON.parse(dump_file.read) rescue - return redirect_to :back, alert: "The uploaded file is not a valid Google Takeout JSON file." + return redirect_to :back, alert: "The uploaded file is not a valid Google Takeout archive." end unless Gitlab::GoogleCodeImport::Client.new(dump).valid? - return redirect_to :back, alert: "The uploaded file is not a valid Google Takeout JSON file." + return redirect_to :back, alert: "The uploaded file is not a valid Google Takeout archive." end session[:google_code_dump] = dump diff --git a/app/views/import/google_code/new.html.haml b/app/views/import/google_code/new.html.haml index 8df5b5d31f..0b4edc6842 100644 --- a/app/views/import/google_code/new.html.haml +++ b/app/views/import/google_code/new.html.haml @@ -4,14 +4,48 @@ %hr = form_tag callback_import_google_code_path, class: 'form-horizontal', multipart: true do - %ul + %p + Follow the steps below to export your Google Code project data. + In the next step, you'll be able to select the projects you want to import. + %ol %li - Use Google Takeout etc - - .form-group - = label_tag :dump_file, "Google Takeout JSON file", class: 'control-label' - .col-sm-10 - %input{type: "file", name: "dump_file", id: "dump_file"} - - .form-actions - = submit_tag 'Select projects to import', class: "btn btn-create" + %p + Go to + #{link_to "Google Takeout", "https://www.google.com/settings/takeout", target: "_blank"}. + %li + %p + Make sure you're logged into the account that owns the projects you'd like to import. + %li + %p + Click the Select none button on the right, since we only need "Google Code Project Hosting". + %li + %p + Scroll down to Google Code Project Hosting and enable the switch on the right. + %li + %p + Choose Next at the bottom of the page. + %li + %p + Leave the "File type" and "Delivery method" options on their default values. + %li + %p + Choose Create archive and wait for archiving to complete. + %li + %p + Click the Download button and wait for downloading to complete. + %li + %p + Find the downloaded ZIP file and decompress it. + %li + %p + Find the newly extracted Takeout/Google Code Project Hosting/GoogleCodeProjectHosting.json file. + %li + %p + Upload GoogleCodeProjectHosting.json here: + %p + %input{type: "file", name: "dump_file", id: "dump_file"} + %li + %p + Continue to the next step: + %p + = submit_tag 'Select projects to import', class: "btn btn-create" From c590cd3443887c1d625ee2310ff5a3da7a0faee3 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 3 Apr 2015 16:21:32 +0200 Subject: [PATCH 037/194] Mask owner email when updated. --- lib/gitlab/google_code_import/importer.rb | 24 ++++++++++------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/lib/gitlab/google_code_import/importer.rb b/lib/gitlab/google_code_import/importer.rb index da0f9a10b0..84ad151657 100644 --- a/lib/gitlab/google_code_import/importer.rb +++ b/lib/gitlab/google_code_import/importer.rb @@ -169,13 +169,9 @@ module Gitlab "#428bca" when "Status: Accepted" "#5cb85c" - when "Status: NeedInfo" - "#f0ad4e" when "Status: Started" "#8e44ad" - when "Status: Wishlist" - "#a8d695" - # + when "Priority: Critical" "#ffcfcf" when "Priority: High" @@ -184,17 +180,17 @@ module Gitlab "#fff5cc" when "Priority: Low" "#cfe9ff" - # + when "Type: Defect" "#d9534f" when "Type: Enhancement" "#44ad8e" - when "Type: Other" - "#7f8c8d" - when "Type: Review" - "#8e44ad" when "Type: Task" "#4b6dd0" + when "Type: Review" + "#8e44ad" + when "Type: Other" + "#7f8c8d" else "#e2e2e2" end @@ -253,6 +249,10 @@ module Gitlab updates << "*Status: #{raw_updates["status"]}*" end + if raw_updates.has_key?("owner") + updates << "*Owner: #{mask_email(raw_updates["owner"])}*" + end + if raw_updates.has_key?("cc") cc = raw_updates["cc"].map do |l| deleted = l.start_with?("-") @@ -277,10 +277,6 @@ module Gitlab updates << "*Labels: #{labels.join(", ")}*" end - if raw_updates.has_key?("owner") - updates << "*Owner: #{raw_updates["owner"]}*" - end - if raw_updates.has_key?("mergedInto") updates << "*Merged into: ##{raw_updates["mergedInto"]}*" end From e8fb197da285e7af1d39ba626f2f0f5c20c17eed Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 3 Apr 2015 17:09:15 +0200 Subject: [PATCH 038/194] Fix tests. --- spec/lib/gitlab/gitorious_import/project_creator_spec.rb | 3 +-- spec/lib/gitlab/google_code_import/project_creator_spec.rb | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/spec/lib/gitlab/gitorious_import/project_creator_spec.rb b/spec/lib/gitlab/gitorious_import/project_creator_spec.rb index cf2318bb3a..90271ea15e 100644 --- a/spec/lib/gitlab/gitorious_import/project_creator_spec.rb +++ b/spec/lib/gitlab/gitorious_import/project_creator_spec.rb @@ -9,8 +9,7 @@ describe Gitlab::GitoriousImport::ProjectCreator do allow_any_instance_of(Project).to receive(:add_import_job) project_creator = Gitlab::GitoriousImport::ProjectCreator.new(repo, namespace, user) - project_creator.execute - project = Project.last + project = project_creator.execute expect(project.name).to eq("Bar Baz Qux") expect(project.path).to eq("bar-baz-qux") diff --git a/spec/lib/gitlab/google_code_import/project_creator_spec.rb b/spec/lib/gitlab/google_code_import/project_creator_spec.rb index 7fca396f15..6cdd4dbbfb 100644 --- a/spec/lib/gitlab/google_code_import/project_creator_spec.rb +++ b/spec/lib/gitlab/google_code_import/project_creator_spec.rb @@ -15,8 +15,7 @@ describe Gitlab::GoogleCodeImport::ProjectCreator do allow_any_instance_of(Project).to receive(:add_import_job) project_creator = Gitlab::GoogleCodeImport::ProjectCreator.new(repo, namespace, user) - project_creator.execute - project = Project.last + project = project_creator.execute expect(project.import_url).to eq("https://vim.googlecode.com/git/") expect(project.visibility_level).to eq(Gitlab::VisibilityLevel::PUBLIC) From 7b9ae32eff09558cf55190b5aed32ad97b31bf55 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 3 Apr 2015 18:02:16 +0200 Subject: [PATCH 039/194] Allow byebug to be used in test. --- Gemfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Gemfile b/Gemfile index e767aec505..36838f52f4 100644 --- a/Gemfile +++ b/Gemfile @@ -208,7 +208,6 @@ group :development do gem "letter_opener" gem 'quiet_assets', '~> 1.0.1' gem 'rack-mini-profiler', require: false - gem "byebug" # Better errors handler gem 'better_errors' @@ -257,6 +256,8 @@ group :development, :test do gem "spring", '~> 1.3.1' gem "spring-commands-rspec", '1.0.4' gem "spring-commands-spinach", '1.0.0' + + gem "byebug" end group :test do From 9d647197da793b429102755e069686e6928de26e Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 3 Apr 2015 18:03:01 +0200 Subject: [PATCH 040/194] Don't require user to every gfm call. --- app/helpers/gitlab_markdown_helper.rb | 2 +- lib/gitlab/markdown.rb | 42 +++++++++++++-------------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/app/helpers/gitlab_markdown_helper.rb b/app/helpers/gitlab_markdown_helper.rb index a4157d6253..7ca3f05863 100644 --- a/app/helpers/gitlab_markdown_helper.rb +++ b/app/helpers/gitlab_markdown_helper.rb @@ -19,7 +19,7 @@ module GitlabMarkdownHelper escape_once(body) end - gfm_body = gfm(escaped_body, @project, current_user, html_options) + gfm_body = gfm(escaped_body, @project, html_options) gfm_body.gsub!(%r{.*?}m) do |match| "#{match}#{link_to("", url, html_options)[0..-5]}" # "".length +1 diff --git a/lib/gitlab/markdown.rb b/lib/gitlab/markdown.rb index e7c261b745..830de39a88 100644 --- a/lib/gitlab/markdown.rb +++ b/lib/gitlab/markdown.rb @@ -39,7 +39,7 @@ module Gitlab # text - the source text # project - the project # html_options - extra options for the reference links as given to link_to - def gfm(text, project = @project, user = current_user, html_options = {}) + def gfm(text, project = @project, html_options = {}) gfm_with_options(text, {}, project, html_options) end @@ -51,7 +51,7 @@ module Gitlab # - reference_only_path - Use relative path for reference links # project - the project # html_options - extra options for the reference links as given to link_to - def gfm_with_options(text, options = {}, project = @project, user = current_user, html_options = {}) + def gfm_with_options(text, options = {}, project = @project, html_options = {}) return text if text.nil? # Duplicate the string so we don't alter the original, then call to_str @@ -78,7 +78,7 @@ module Gitlab # TODO: add popups with additional information - text = parse(text, project, user) + text = parse(text, project) # Insert pre block extractions text.gsub!(/\{gfm-extraction-(\h{32})\}/) do @@ -155,8 +155,8 @@ module Gitlab # text - Text to parse # # Returns parsed text - def parse(text, project = @project, user = current_user) - parse_references(text, project, user) if project + def parse(text, project = @project) + parse_references(text, project) if project text end @@ -182,7 +182,7 @@ module Gitlab TYPES = [:user, :issue, :label, :merge_request, :snippet, :commit, :commit_range].freeze - def parse_references(text, project = @project, user = current_user) + def parse_references(text, project = @project) # parse reference links text.gsub!(REFERENCE_PATTERN) do |match| type = TYPES.select{|t| !$~[t].nil?}.first @@ -192,12 +192,12 @@ module Gitlab project_path = $LAST_MATCH_INFO[:project] if project_path actual_project = ::Project.find_with_namespace(project_path) - actual_project = nil unless can?(user, :read_project, actual_project) + actual_project = nil unless can?(current_user, :read_project, actual_project) project_prefix = project_path end parse_result($LAST_MATCH_INFO, type, - actual_project, user, project_prefix) || match + actual_project, project_prefix) || match end end @@ -205,7 +205,7 @@ module Gitlab # link. Returns nil if +type+ is nil, if the match string is an HTML # entity, if the reference is invalid, or if the matched text includes an # invalid project path. - def parse_result(match_info, type, project, user, project_prefix) + def parse_result(match_info, type, project, project_prefix) prefix = match_info[:prefix] suffix = match_info[:suffix] @@ -213,7 +213,7 @@ module Gitlab return nil if project.nil? && !project_prefix.nil? identifier = match_info[type] - ref_link = reference_link(type, identifier, project, user, project_prefix) + ref_link = reference_link(type, identifier, project, project_prefix) if ref_link "#{prefix}#{ref_link}#{suffix}" @@ -234,11 +234,11 @@ module Gitlab # identifier - Object identifier (Issue ID, SHA hash, etc.) # # Returns string rendered by the processing method - def reference_link(type, identifier, project = @project, user = current_user, prefix_text = nil) - send("reference_#{type}", identifier, project, user, prefix_text) + def reference_link(type, identifier, project = @project, prefix_text = nil) + send("reference_#{type}", identifier, project, prefix_text) end - def reference_user(identifier, project = @project, user = current_user, _ = nil) + def reference_user(identifier, project = @project, _ = nil) link_options = html_options.merge( class: "gfm gfm-project_member #{html_options[:class]}" ) @@ -252,7 +252,7 @@ module Gitlab elsif namespace = Namespace.find_by(path: identifier) url = if namespace.is_a?(Group) - return nil unless can?(user, :read_group, namespace) + return nil unless can?(current_user, :read_group, namespace) group_url(identifier, only_path: options[:reference_only_path]) else user_url(identifier, only_path: options[:reference_only_path]) @@ -262,7 +262,7 @@ module Gitlab end end - def reference_label(identifier, project = @project, user = current_user, _ = nil) + def reference_label(identifier, project = @project, _ = nil) if label = project.labels.find_by(id: identifier) link_options = html_options.merge( class: "gfm gfm-label #{html_options[:class]}" @@ -275,7 +275,7 @@ module Gitlab end end - def reference_issue(identifier, project = @project, user = current_user, prefix_text = nil) + def reference_issue(identifier, project = @project, prefix_text = nil) if project.default_issues_tracker? if project.issue_exists? identifier url = url_for_issue(identifier, project, only_path: options[:reference_only_path]) @@ -295,7 +295,7 @@ module Gitlab end end - def reference_merge_request(identifier, project = @project, user = current_user, prefix_text = nil) + def reference_merge_request(identifier, project = @project, prefix_text = nil) if merge_request = project.merge_requests.find_by(iid: identifier) link_options = html_options.merge( title: "Merge Request: #{merge_request.title}", @@ -308,7 +308,7 @@ module Gitlab end end - def reference_snippet(identifier, project = @project, user = current_user, _ = nil) + def reference_snippet(identifier, project = @project, _ = nil) if snippet = project.snippets.find_by(id: identifier) link_options = html_options.merge( title: "Snippet: #{snippet.title}", @@ -323,7 +323,7 @@ module Gitlab end end - def reference_commit(identifier, project = @project, user = current_user, prefix_text = nil) + def reference_commit(identifier, project = @project, prefix_text = nil) if project.valid_repo? && commit = project.repository.commit(identifier) link_options = html_options.merge( title: commit.link_title, @@ -339,7 +339,7 @@ module Gitlab end end - def reference_commit_range(identifier, project = @project, user = current_user, prefix_text = nil) + def reference_commit_range(identifier, project = @project, prefix_text = nil) from_id, to_id = identifier.split(/\.{2,3}/, 2) inclusive = identifier !~ /\.{3}/ @@ -365,7 +365,7 @@ module Gitlab end end - def reference_external_issue(identifier, project = @project, user = current_user, prefix_text = nil) + def reference_external_issue(identifier, project = @project, prefix_text = nil) url = url_for_issue(identifier, project, only_path: options[:reference_only_path]) title = project.external_issue_tracker.title From b492f0f86ea0f0b4e954e1b7ed0b84d08d784272 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 3 Apr 2015 18:03:15 +0200 Subject: [PATCH 041/194] Refactor ReferenceExtractor. --- lib/gitlab/reference_extractor.rb | 57 +++++++++++++++---------------- 1 file changed, 28 insertions(+), 29 deletions(-) diff --git a/lib/gitlab/reference_extractor.rb b/lib/gitlab/reference_extractor.rb index 2f38c1dcc8..1a9a346d16 100644 --- a/lib/gitlab/reference_extractor.rb +++ b/lib/gitlab/reference_extractor.rb @@ -8,8 +8,6 @@ module Gitlab def initialize(project, current_user = nil) @project = project @current_user = current_user - - @references = Hash.new { [] } end def can?(user, action, subject) @@ -23,6 +21,7 @@ module Gitlab text.gsub!(%r{
.*?
|.*?}m) { |match| '' } text.gsub!(%r{^```.*?^```}m) { |match| '' } + @references = Hash.new { |hash, type| hash[type] = [] } parse_references(text) end @@ -30,66 +29,66 @@ module Gitlab # model objects. def users - references[:users].map do |entry| - if entry[:id] == "all" + references[:user].uniq.map do |project, identifier| + if identifier == "all" project.team.members.flatten - elsif namespace = Namespace.find_by(path: entry[:id]) + elsif namespace = Namespace.find_by(path: identifier) if namespace.is_a?(Group) namespace.users else namespace.owner end end - end.flatten.compact + end.flatten.compact.uniq end def labels - references[:labels].map do |entry| - project.labels.where(id: entry[:id]).first - end.compact + references[:label].uniq.map do |project, identifier| + project.labels.where(id: identifier).first + end.compact.uniq end def issues - references[:issues].map do |entry| - if entry[:project].default_issues_tracker? - entry[:project].issues.where(iid: entry[:id]).first + references[:issue].uniq.map do |project, identifier| + if project.default_issues_tracker? + project.issues.where(iid: identifier).first end - end.compact + end.compact.uniq end def merge_requests - references[:merge_requests].map do |entry| - entry[:project].merge_requests.where(iid: entry[:id]).first - end.compact + references[:merge_request].uniq.map do |project, identifier| + project.merge_requests.where(iid: identifier).first + end.compact.uniq end def snippets - references[:snippets].map do |entry| - project.snippets.where(id: entry[:id]).first - end.compact + references[:snippet].uniq.map do |project, identifier| + project.snippets.where(id: identifier).first + end.compact.uniq end def commits - references[:commits].map do |entry| - repo = entry[:project].repository - repo.commit(entry[:id]) if repo - end.compact + references[:commit].uniq.map do |project, identifier| + repo = project.repository + repo.commit(identifier) if repo + end.compact.uniq end def commit_ranges - references[:commit_ranges].map do |entry| - repo = entry[:project].repository if entry[:project] + references[:commit_range].uniq.map do |project, identifier| + repo = project.repository if repo - from_id, to_id = entry[:id].split(/\.{2,3}/, 2) + from_id, to_id = identifier.split(/\.{2,3}/, 2) [repo.commit(from_id), repo.commit(to_id)] end - end.compact + end.compact.uniq end private - def reference_link(type, identifier, project, user, _) - references[type] << { project: project, id: identifier } + def reference_link(type, identifier, project, _) + references[type] << [project, identifier] end end end From e33ddfebf2248fe31bb27fe1d34048df97ed61b0 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 3 Apr 2015 18:03:26 +0200 Subject: [PATCH 042/194] Refactor ClosingIssueExtractor. --- app/models/commit.rb | 2 +- app/models/merge_request.rb | 4 ++-- lib/gitlab/closing_issue_extractor.rb | 23 +++++++++++------------ 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/app/models/commit.rb b/app/models/commit.rb index 084cf42007..7a0ad13765 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -118,7 +118,7 @@ class Commit # Discover issues should be closed when this commit is pushed to a project's # default branch. def closes_issues(project, current_user = self.committer) - Gitlab::ClosingIssueExtractor.closed_by_message_in_project(safe_message, project, current_user) + Gitlab::ClosingIssueExtractor.new(project, current_user).closed_by_message(safe_message) end # Mentionable override. diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 167d7f9c71..35cb920d8b 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -260,8 +260,8 @@ class MergeRequest < ActiveRecord::Base def closes_issues(current_user = self.author) if target_branch == project.default_branch issues = commits.flat_map { |c| c.closes_issues(project, current_user) } - issues.push(*Gitlab::ClosingIssueExtractor. - closed_by_message_in_project(description, project, current_user)) + issues.push(*Gitlab::ClosingIssueExtractor.new(project, current_user). + closed_by_message(description)) issues.uniq.sort_by(&:id) else [] diff --git a/lib/gitlab/closing_issue_extractor.rb b/lib/gitlab/closing_issue_extractor.rb index 6425193d85..ab184d95c0 100644 --- a/lib/gitlab/closing_issue_extractor.rb +++ b/lib/gitlab/closing_issue_extractor.rb @@ -1,21 +1,20 @@ module Gitlab - module ClosingIssueExtractor + class ClosingIssueExtractor ISSUE_CLOSING_REGEX = Regexp.new(Gitlab.config.gitlab.issue_closing_pattern) - def self.closed_by_message_in_project(message, project, current_user = nil) - issues = [] + def initialize(project, current_user = nil) + @extractor = Gitlab::ReferenceExtractor.new(project, current_user) + end - unless message.nil? - md = message.scan(ISSUE_CLOSING_REGEX) + def closed_by_message(message) + return [] if message.nil? + + closing_statements = message.scan(ISSUE_CLOSING_REGEX). + map { |ref| ref[0] }.join(" ") - md.each do |ref| - extractor = Gitlab::ReferenceExtractor.new(project, current_user) - extractor.analyze(ref[0]) - issues += extractor.issues - end - end + @extractor.analyze(closing_statements) - issues.uniq + @extractor.issues end end end From 16e1076e6f69626e1d8bf53f52dc67baee9fb51e Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 3 Apr 2015 18:03:34 +0200 Subject: [PATCH 043/194] Update tests. --- .../gitlab/closing_issue_extractor_spec.rb | 62 ++++++++++--------- spec/lib/gitlab/reference_extractor_spec.rb | 40 ++++++------ 2 files changed, 54 insertions(+), 48 deletions(-) diff --git a/spec/lib/gitlab/closing_issue_extractor_spec.rb b/spec/lib/gitlab/closing_issue_extractor_spec.rb index c96ee78e5f..cb7b0fbb89 100644 --- a/spec/lib/gitlab/closing_issue_extractor_spec.rb +++ b/spec/lib/gitlab/closing_issue_extractor_spec.rb @@ -5,126 +5,128 @@ describe Gitlab::ClosingIssueExtractor do let(:issue) { create(:issue, project: project) } let(:iid1) { issue.iid } - describe :closed_by_message_in_project do + subject { described_class.new(project, project.creator) } + + describe "#closed_by_message" do context 'with a single reference' do it do message = "Awesome commit (Closes ##{iid1})" - expect(subject.closed_by_message_in_project(message, project)).to eq([issue]) + expect(subject.closed_by_message(message)).to eq([issue]) end it do message = "Awesome commit (closes ##{iid1})" - expect(subject.closed_by_message_in_project(message, project)).to eq([issue]) + expect(subject.closed_by_message(message)).to eq([issue]) end it do message = "Closed ##{iid1}" - expect(subject.closed_by_message_in_project(message, project)).to eq([issue]) + expect(subject.closed_by_message(message)).to eq([issue]) end it do message = "closed ##{iid1}" - expect(subject.closed_by_message_in_project(message, project)).to eq([issue]) + expect(subject.closed_by_message(message)).to eq([issue]) end it do message = "Closing ##{iid1}" - expect(subject.closed_by_message_in_project(message, project)).to eq([issue]) + expect(subject.closed_by_message(message)).to eq([issue]) end it do message = "closing ##{iid1}" - expect(subject.closed_by_message_in_project(message, project)).to eq([issue]) + expect(subject.closed_by_message(message)).to eq([issue]) end it do message = "Close ##{iid1}" - expect(subject.closed_by_message_in_project(message, project)).to eq([issue]) + expect(subject.closed_by_message(message)).to eq([issue]) end it do message = "close ##{iid1}" - expect(subject.closed_by_message_in_project(message, project)).to eq([issue]) + expect(subject.closed_by_message(message)).to eq([issue]) end it do message = "Awesome commit (Fixes ##{iid1})" - expect(subject.closed_by_message_in_project(message, project)).to eq([issue]) + expect(subject.closed_by_message(message)).to eq([issue]) end it do message = "Awesome commit (fixes ##{iid1})" - expect(subject.closed_by_message_in_project(message, project)).to eq([issue]) + expect(subject.closed_by_message(message)).to eq([issue]) end it do message = "Fixed ##{iid1}" - expect(subject.closed_by_message_in_project(message, project)).to eq([issue]) + expect(subject.closed_by_message(message)).to eq([issue]) end it do message = "fixed ##{iid1}" - expect(subject.closed_by_message_in_project(message, project)).to eq([issue]) + expect(subject.closed_by_message(message)).to eq([issue]) end it do message = "Fixing ##{iid1}" - expect(subject.closed_by_message_in_project(message, project)).to eq([issue]) + expect(subject.closed_by_message(message)).to eq([issue]) end it do message = "fixing ##{iid1}" - expect(subject.closed_by_message_in_project(message, project)).to eq([issue]) + expect(subject.closed_by_message(message)).to eq([issue]) end it do message = "Fix ##{iid1}" - expect(subject.closed_by_message_in_project(message, project)).to eq([issue]) + expect(subject.closed_by_message(message)).to eq([issue]) end it do message = "fix ##{iid1}" - expect(subject.closed_by_message_in_project(message, project)).to eq([issue]) + expect(subject.closed_by_message(message)).to eq([issue]) end it do message = "Awesome commit (Resolves ##{iid1})" - expect(subject.closed_by_message_in_project(message, project)).to eq([issue]) + expect(subject.closed_by_message(message)).to eq([issue]) end it do message = "Awesome commit (resolves ##{iid1})" - expect(subject.closed_by_message_in_project(message, project)).to eq([issue]) + expect(subject.closed_by_message(message)).to eq([issue]) end it do message = "Resolved ##{iid1}" - expect(subject.closed_by_message_in_project(message, project)).to eq([issue]) + expect(subject.closed_by_message(message)).to eq([issue]) end it do message = "resolved ##{iid1}" - expect(subject.closed_by_message_in_project(message, project)).to eq([issue]) + expect(subject.closed_by_message(message)).to eq([issue]) end it do message = "Resolving ##{iid1}" - expect(subject.closed_by_message_in_project(message, project)).to eq([issue]) + expect(subject.closed_by_message(message)).to eq([issue]) end it do message = "resolving ##{iid1}" - expect(subject.closed_by_message_in_project(message, project)).to eq([issue]) + expect(subject.closed_by_message(message)).to eq([issue]) end it do message = "Resolve ##{iid1}" - expect(subject.closed_by_message_in_project(message, project)).to eq([issue]) + expect(subject.closed_by_message(message)).to eq([issue]) end it do message = "resolve ##{iid1}" - expect(subject.closed_by_message_in_project(message, project)).to eq([issue]) + expect(subject.closed_by_message(message)).to eq([issue]) end end @@ -137,28 +139,28 @@ describe Gitlab::ClosingIssueExtractor do it 'fetches issues in single line message' do message = "Closes ##{iid1} and fix ##{iid2}" - expect(subject.closed_by_message_in_project(message, project)). + expect(subject.closed_by_message(message)). to eq([issue, other_issue]) end it 'fetches comma-separated issues references in single line message' do message = "Closes ##{iid1}, closes ##{iid2}" - expect(subject.closed_by_message_in_project(message, project)). + expect(subject.closed_by_message(message)). to eq([issue, other_issue]) end it 'fetches comma-separated issues numbers in single line message' do message = "Closes ##{iid1}, ##{iid2} and ##{iid3}" - expect(subject.closed_by_message_in_project(message, project)). + expect(subject.closed_by_message(message)). to eq([issue, other_issue, third_issue]) end it 'fetches issues in multi-line message' do message = "Awesome commit (closes ##{iid1})\nAlso fixes ##{iid2}" - expect(subject.closed_by_message_in_project(message, project)). + expect(subject.closed_by_message(message)). to eq([issue, other_issue]) end @@ -166,7 +168,7 @@ describe Gitlab::ClosingIssueExtractor do message = "Awesome commit (closes ##{iid1})\n"\ "Also fixing issues ##{iid2}, ##{iid3} and #4" - expect(subject.closed_by_message_in_project(message, project)). + expect(subject.closed_by_message(message)). to eq([issue, other_issue, third_issue]) end end diff --git a/spec/lib/gitlab/reference_extractor_spec.rb b/spec/lib/gitlab/reference_extractor_spec.rb index 8ed4d5bc68..c9fb62b61a 100644 --- a/spec/lib/gitlab/reference_extractor_spec.rb +++ b/spec/lib/gitlab/reference_extractor_spec.rb @@ -2,48 +2,48 @@ require 'spec_helper' describe Gitlab::ReferenceExtractor do let(:project) { create(:project) } - subject { Gitlab::ReferenceExtractor.new(project) } + subject { Gitlab::ReferenceExtractor.new(project, project.creator) } it 'extracts username references' do subject.analyze('this contains a @user reference') - expect(subject.users).to eq([{ project: project, id: 'user' }]) + expect(subject.references[:user]).to eq([[project, 'user']]) end it 'extracts issue references' do subject.analyze('this one talks about issue #1234') - expect(subject.issues).to eq([{ project: project, id: '1234' }]) + expect(subject.references[:issue]).to eq([[project, '1234']]) end it 'extracts JIRA issue references' do subject.analyze('this one talks about issue JIRA-1234') - expect(subject.issues).to eq([{ project: project, id: 'JIRA-1234' }]) + expect(subject.references[:issue]).to eq([[project, 'JIRA-1234']]) end it 'extracts merge request references' do subject.analyze("and here's !43, a merge request") - expect(subject.merge_requests).to eq([{ project: project, id: '43' }]) + expect(subject.references[:merge_request]).to eq([[project, '43']]) end it 'extracts snippet ids' do subject.analyze('snippets like $12 get extracted as well') - expect(subject.snippets).to eq([{ project: project, id: '12' }]) + expect(subject.references[:snippet]).to eq([[project, '12']]) end it 'extracts commit shas' do subject.analyze('commit shas 98cf0ae3 are pulled out as Strings') - expect(subject.commits).to eq([{ project: project, id: '98cf0ae3' }]) + expect(subject.references[:commit]).to eq([[project, '98cf0ae3']]) end it 'extracts commit ranges' do subject.analyze('here you go, a commit range: 98cf0ae3...98cf0ae4') - expect(subject.commit_ranges).to eq([{ project: project, id: '98cf0ae3...98cf0ae4' }]) + expect(subject.references[:commit_range]).to eq([[project, '98cf0ae3...98cf0ae4']]) end it 'extracts multiple references and preserves their order' do subject.analyze('@me and @you both care about this') - expect(subject.users).to eq([ - { project: project, id: 'me' }, - { project: project, id: 'you' } + expect(subject.references[:user]).to eq([ + [project, 'me'], + [project, 'you'] ]) end @@ -70,7 +70,7 @@ describe Gitlab::ReferenceExtractor do it 'extracts issue references for invalid code blocks' do subject.analyze('test: ```this one talks about issue #1234```') - expect(subject.issues).to eq([{ project: project, id: '1234' }]) + expect(subject.references[:issue]).to eq([[project, '1234']]) end it 'handles all possible kinds of references' do @@ -78,16 +78,16 @@ describe Gitlab::ReferenceExtractor do expect(subject).to respond_to(*accessors) end - it 'accesses valid user objects on the project team' do + it 'accesses valid user objects' do @u_foo = create(:user, username: 'foo') @u_bar = create(:user, username: 'bar') - create(:user, username: 'offteam') + @u_offteam = create(:user, username: 'offteam') project.team << [@u_foo, :reporter] project.team << [@u_bar, :guest] subject.analyze('@foo, @baduser, @bar, and @offteam') - expect(subject.users).to eq([@u_foo, @u_bar]) + expect(subject.users).to eq([@u_foo, @u_bar, @u_offteam]) end it 'accesses valid issue objects' do @@ -139,11 +139,15 @@ describe Gitlab::ReferenceExtractor do end context 'with a project with an underscore' do - let(:project) { create(:project, path: 'test_project') } - let(:issue) { create(:issue, project: project) } + let(:other_project) { create(:project, path: 'test_project') } + let(:issue) { create(:issue, project: other_project) } + + before do + other_project.team << [project.creator, :developer] + end it 'handles project issue references' do - subject.analyze("this refers issue #{project.path_with_namespace}##{issue.iid}") + subject.analyze("this refers issue #{other_project.path_with_namespace}##{issue.iid}") extracted = subject.issues expect(extracted.size).to eq(1) expect(extracted).to eq([issue]) From bc2edc359d38014289c86d021fd49a77d1071b06 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 3 Apr 2015 18:38:31 +0200 Subject: [PATCH 044/194] Remove sidekiq-limit_fetch because it require Redis 2.6. --- Gemfile | 1 - Gemfile.lock | 3 --- config/initializers/4_sidekiq.rb | 2 -- 3 files changed, 6 deletions(-) diff --git a/Gemfile b/Gemfile index 0dd285d0fd..e767aec505 100644 --- a/Gemfile +++ b/Gemfile @@ -122,7 +122,6 @@ gem 'slim' gem 'sinatra', require: nil gem 'sidekiq', '~> 3.3' gem 'sidetiq', '0.6.3' -gem 'sidekiq-limit_fetch' # HTTP requests gem "httparty" diff --git a/Gemfile.lock b/Gemfile.lock index c6ecdb4db0..ed8663b358 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -547,8 +547,6 @@ GEM json redis (>= 3.0.6) redis-namespace (>= 1.3.1) - sidekiq-limit_fetch (2.4.1) - sidekiq (>= 2.6.5, < 4.0) sidetiq (0.6.3) celluloid (>= 0.14.1) ice_cube (= 0.11.1) @@ -771,7 +769,6 @@ DEPENDENCIES settingslogic shoulda-matchers (~> 2.7.0) sidekiq (~> 3.3) - sidekiq-limit_fetch sidetiq (= 0.6.3) simplecov sinatra diff --git a/config/initializers/4_sidekiq.rb b/config/initializers/4_sidekiq.rb index da2e8a08b0..e856499732 100644 --- a/config/initializers/4_sidekiq.rb +++ b/config/initializers/4_sidekiq.rb @@ -25,5 +25,3 @@ Sidekiq.configure_client do |config| namespace: 'resque:gitlab' } end - -Sidekiq::Queue["archive_repo"].limit = 2 From ee9b427f4363071be0337cfc8262e85c8fc01d10 Mon Sep 17 00:00:00 2001 From: Jeremy Maziarz Date: Fri, 3 Apr 2015 16:38:19 -0400 Subject: [PATCH 045/194] Updated CHANGELOG for MR 214 --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index f4d726bf1c..7b410dfe85 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -60,6 +60,7 @@ v 7.10.0 (unreleased) - Archive repositories in background worker. - Import GitHub, Bitbucket or GitLab.com projects owned by authenticated user into current namespace. - Project labels are now available over the API under the "tag_list" field (Cristian Medina) + - Fixed link paths for HTTP and SSH on the admin project view (Jeremy Maziarz) v 7.9.2 From 06399b3261a4118bf5ab2d2c6fcee7aad869bc66 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Sat, 4 Apr 2015 14:19:05 +0200 Subject: [PATCH 046/194] Show Google Code image attachments inline. --- lib/gitlab/google_code_import/importer.rb | 8 ++++++-- spec/fixtures/GoogleCodeProjectHosting.json | 5 +++++ spec/lib/gitlab/google_code_import/importer_spec.rb | 3 ++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/lib/gitlab/google_code_import/importer.rb b/lib/gitlab/google_code_import/importer.rb index 84ad151657..8bdb46237f 100644 --- a/lib/gitlab/google_code_import/importer.rb +++ b/lib/gitlab/google_code_import/importer.rb @@ -314,8 +314,12 @@ module Gitlab raw_attachments.map do |attachment| next if attachment["isDeleted"] - link = "https://storage.googleapis.com/google-code-attachments/#{@repo.name}/issue-#{issue_id}/comment-#{comment_id}/#{attachment["fileName"]}" - "[#{attachment["fileName"]}](#{link})" + filename = attachment["fileName"] + link = "https://storage.googleapis.com/google-code-attachments/#{@repo.name}/issue-#{issue_id}/comment-#{comment_id}/#{filename}" + + text = "[#{filename}](#{link})" + text = "!#{text}" if filename =~ /\.(png|jpg|jpeg|gif|bmp|tiff)\z/ + text end.compact end end diff --git a/spec/fixtures/GoogleCodeProjectHosting.json b/spec/fixtures/GoogleCodeProjectHosting.json index a1d8d32adf..85e354f8d0 100644 --- a/spec/fixtures/GoogleCodeProjectHosting.json +++ b/spec/fixtures/GoogleCodeProjectHosting.json @@ -372,6 +372,11 @@ "fileName" : "tint2_task_scrolling.diff", "fileSize" : 3059, "mimetype" : "text/x-c++; charset=us-ascii" + }, { + "attachmentId" : "000", + "fileName" : "screenshot.png", + "fileSize" : 0, + "mimetype" : "image/png" } ] }, { "id" : 1, diff --git a/spec/lib/gitlab/google_code_import/importer_spec.rb b/spec/lib/gitlab/google_code_import/importer_spec.rb index ee5f99c12b..408553a98f 100644 --- a/spec/lib/gitlab/google_code_import/importer_spec.rb +++ b/spec/lib/gitlab/google_code_import/importer_spec.rb @@ -49,7 +49,8 @@ describe Gitlab::GoogleCodeImport::Importer do expect(issue.description).to include('Patch is attached that adds two new mouse\-actions \(next\_taskprev\_task\)') expect(issue.description).to include('that can be used for exactly that purpose.') expect(issue.description).to include('all the best!') - expect(issue.description).to include('https://storage.googleapis.com/google-code-attachments/tint2/issue-169/comment-0/tint2_task_scrolling.diff') + expect(issue.description).to include('[tint2_task_scrolling.diff](https://storage.googleapis.com/google-code-attachments/tint2/issue-169/comment-0/tint2_task_scrolling.diff)') + expect(issue.description).to include('![screenshot.png](https://storage.googleapis.com/google-code-attachments/tint2/issue-169/comment-0/screenshot.png)') end it "imports issue comments" do From af4577d77919ffcdb1dd00b20259c22b8835d5e1 Mon Sep 17 00:00:00 2001 From: Ben Bodenmiller Date: Sat, 4 Apr 2015 05:46:18 -0700 Subject: [PATCH 047/194] remove extra scrollbar on background jobs --- app/views/admin/background_jobs/show.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/admin/background_jobs/show.html.haml b/app/views/admin/background_jobs/show.html.haml index 8db2b2a709..4ef8e878a7 100644 --- a/app/views/admin/background_jobs/show.html.haml +++ b/app/views/admin/background_jobs/show.html.haml @@ -41,4 +41,4 @@ .panel.panel-default - %iframe{src: sidekiq_path, width: '100%', height: 900, style: "border: none"} + %iframe{src: sidekiq_path, width: '100%', height: 970, style: "border: none"} From f2af30f19534c639c17fa5356ab1c18f518f2e98 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Mon, 6 Apr 2015 13:26:41 +0200 Subject: [PATCH 048/194] Skip email confirmation when set by admin or via LDAP. --- CHANGELOG | 1 + app/controllers/admin/users_controller.rb | 2 +- lib/gitlab/ldap/user.rb | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index db3e5744b0..7d2edfbe35 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -62,6 +62,7 @@ v 7.10.0 (unreleased) - Project labels are now available over the API under the "tag_list" field (Cristian Medina) - Fixed link paths for HTTP and SSH on the admin project view (Jeremy Maziarz) - Fix and improve help rendering (Sullivan Sénéchal) + - Skip email confirmation when set by admin or via LDAP. v 7.9.2 diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 693970e534..b4c011f213 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -72,8 +72,8 @@ class Admin::UsersController < Admin::ApplicationController end respond_to do |format| + user.skip_reconfirmation! if user.update_attributes(user_params_with_pass) - user.confirm! format.html { redirect_to [:admin, user], notice: 'User was successfully updated.' } format.json { head :ok } else diff --git a/lib/gitlab/ldap/user.rb b/lib/gitlab/ldap/user.rb index cfa8692659..806f43c469 100644 --- a/lib/gitlab/ldap/user.rb +++ b/lib/gitlab/ldap/user.rb @@ -39,6 +39,7 @@ module Gitlab end def update_user_attributes + gl_user.skip_reconfirmation! gl_user.email = auth_hash.email # Build new identity only if we dont have have same one From f915a4b80fdae28492e6c630534d8b66462f670a Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Mon, 6 Apr 2015 14:02:49 +0200 Subject: [PATCH 049/194] Warn when gitlab-shell version doesn't match requirement. --- CHANGELOG | 1 + config/initializers/5_backend.rb | 7 +++++++ lib/gitlab/backend/shell.rb | 2 +- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index db3e5744b0..87675a056b 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -62,6 +62,7 @@ v 7.10.0 (unreleased) - Project labels are now available over the API under the "tag_list" field (Cristian Medina) - Fixed link paths for HTTP and SSH on the admin project view (Jeremy Maziarz) - Fix and improve help rendering (Sullivan Sénéchal) + - Warn when gitlab-shell version doesn't match requirement. v 7.9.2 diff --git a/config/initializers/5_backend.rb b/config/initializers/5_backend.rb index 7c2e7f3900..80d641d73a 100644 --- a/config/initializers/5_backend.rb +++ b/config/initializers/5_backend.rb @@ -6,3 +6,10 @@ require Rails.root.join("lib", "gitlab", "backend", "shell") # GitLab shell adapter require Rails.root.join("lib", "gitlab", "backend", "shell_adapter") + +required_version = Gitlab::VersionInfo.parse(Gitlab::Shell.version_required) +current_version = Gitlab::VersionInfo.parse(Gitlab::Shell.new.version) + +unless current_version.valid? && required_version <= current_version + warn "WARNING: This version of GitLab depends on gitlab-shell #{required_version}, but you're running #{current_version}. Please update gitlab-shell." +end diff --git a/lib/gitlab/backend/shell.rb b/lib/gitlab/backend/shell.rb index aabc7f1e69..530f9d93de 100644 --- a/lib/gitlab/backend/shell.rb +++ b/lib/gitlab/backend/shell.rb @@ -240,7 +240,7 @@ module Gitlab gitlab_shell_version_file = "#{gitlab_shell_path}/VERSION" if File.readable?(gitlab_shell_version_file) - File.read(gitlab_shell_version_file) + File.read(gitlab_shell_version_file).chomp end end From 9c2301808b2d96d41d8972d114c9f77dd2b2471f Mon Sep 17 00:00:00 2001 From: Valery Sizov Date: Mon, 6 Apr 2015 15:20:58 +0300 Subject: [PATCH 050/194] CI project forking --- .../project_services/gitlab_ci_service.rb | 24 +++++++++++++++++++ app/services/projects/fork_service.rb | 6 +++++ app/workers/fork_registrator_worker.rb | 12 ++++++++++ 3 files changed, 42 insertions(+) create mode 100644 app/workers/fork_registrator_worker.rb diff --git a/app/models/project_services/gitlab_ci_service.rb b/app/models/project_services/gitlab_ci_service.rb index edaeeffc22..d66a133195 100644 --- a/app/models/project_services/gitlab_ci_service.rb +++ b/app/models/project_services/gitlab_ci_service.rb @@ -59,6 +59,26 @@ class GitlabCiService < CiService end end + def register_fork(new_project, user_token) + params = { + id: new_project.id, + name_with_namespace: new_project.name_with_namespace, + web_url: new_project.web_url, + default_branch: new_project.default_branch, + ssh_url_to_repo: new_project.ssh_url_to_repo + } + + HTTParty.post( + register_fork_path, + body: { + project_id: project.id, + project_token: token, + user_token: user_token, + data: params.to_yaml}, + verify: false + ) + end + def commit_coverage(sha, ref) response = get_ci_build(sha, ref) @@ -75,6 +95,10 @@ class GitlabCiService < CiService project_url + "?ref=" + project.default_branch end + def register_fork_path + project_url.sub(/projects\/\d*/, 'api/v1/forks') + end + def status_img_path project_url + "/status.png?ref=" + project.default_branch end diff --git a/app/services/projects/fork_service.rb b/app/services/projects/fork_service.rb index 6b0d4aca3e..b5150fefc4 100644 --- a/app/services/projects/fork_service.rb +++ b/app/services/projects/fork_service.rb @@ -40,12 +40,18 @@ module Projects if project.save project.team << [@current_user, :master] end + #Now fork the repo unless gitlab_shell.fork_repository(@from_project.path_with_namespace, project.namespace.path) raise 'forking failed in gitlab-shell' end + project.ensure_satellite_exists end + + if @from_project.gitlab_ci? + ForkRegistratorWorker.perform_async(@from_project.id, project.id, @current_user.private_token) + end rescue => ex project.errors.add(:base, 'Fork transaction failed.') project.destroy diff --git a/app/workers/fork_registrator_worker.rb b/app/workers/fork_registrator_worker.rb new file mode 100644 index 0000000000..49d040d65e --- /dev/null +++ b/app/workers/fork_registrator_worker.rb @@ -0,0 +1,12 @@ +class ForkRegistratorWorker + include Sidekiq::Worker + + sidekiq_options queue: :default + + def perform(from_project_id, to_project_id, private_token) + from_project = Project.find(from_project_id) + to_project = Project.find(to_project_id) + + from_project.gitlab_ci_service.register_fork(to_project, private_token) + end +end From e69e430ce70a0a679214669437c7521611e4b77c Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Mon, 6 Apr 2015 14:50:43 +0200 Subject: [PATCH 051/194] Allow projects to be imported into namespaces user is master of. --- app/controllers/import/base_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/import/base_controller.rb b/app/controllers/import/base_controller.rb index edb8bd4160..93a7ace353 100644 --- a/app/controllers/import/base_controller.rb +++ b/app/controllers/import/base_controller.rb @@ -8,7 +8,7 @@ class Import::BaseController < ApplicationController namespace.add_owner(current_user) rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid namespace = Namespace.find_by_path_or_name(@target_namespace) - unless namespace.owner == current_user + unless current_user.can?(:create_projects, namespace) @already_been_taken = true return false end From 99abbe7dbe5dfbeba5869ffa35cac2b36cd5578c Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Mon, 6 Apr 2015 14:51:09 +0200 Subject: [PATCH 052/194] Use Projects::CreateService to create imported projects so hooks are executed. --- .../bitbucket_import/project_creator.rb | 19 +++---------------- lib/gitlab/github_import/project_creator.rb | 19 +++---------------- lib/gitlab/gitlab_import/project_creator.rb | 19 +++---------------- .../gitorious_import/project_creator.rb | 19 +++---------------- 4 files changed, 12 insertions(+), 64 deletions(-) diff --git a/lib/gitlab/bitbucket_import/project_creator.rb b/lib/gitlab/bitbucket_import/project_creator.rb index db33af2c2d..54420e62c9 100644 --- a/lib/gitlab/bitbucket_import/project_creator.rb +++ b/lib/gitlab/bitbucket_import/project_creator.rb @@ -10,29 +10,16 @@ module Gitlab end def execute - @project = Project.new( + ::Projects::CreateService.new(current_user, name: repo["name"], path: repo["slug"], description: repo["description"], - namespace: namespace, - creator: current_user, + namespace_id: namespace.id, visibility_level: repo["is_private"] ? Gitlab::VisibilityLevel::PRIVATE : Gitlab::VisibilityLevel::PUBLIC, import_type: "bitbucket", import_source: "#{repo["owner"]}/#{repo["slug"]}", import_url: "ssh://git@bitbucket.org/#{repo["owner"]}/#{repo["slug"]}.git" - ) - - if @project.save! - @project.reload - - if @project.import_failed? - @project.import_retry - else - @project.import_start - end - end - - @project + ).execute end end end diff --git a/lib/gitlab/github_import/project_creator.rb b/lib/gitlab/github_import/project_creator.rb index 9439ca6cbf..2723eec933 100644 --- a/lib/gitlab/github_import/project_creator.rb +++ b/lib/gitlab/github_import/project_creator.rb @@ -10,29 +10,16 @@ module Gitlab end def execute - @project = Project.new( + ::Projects::CreateService.new(current_user, name: repo.name, path: repo.name, description: repo.description, - namespace: namespace, - creator: current_user, + namespace_id: namespace.id, visibility_level: repo.private ? Gitlab::VisibilityLevel::PRIVATE : Gitlab::VisibilityLevel::PUBLIC, import_type: "github", import_source: repo.full_name, import_url: repo.clone_url.sub("https://", "https://#{current_user.github_access_token}@") - ) - - if @project.save! - @project.reload - - if @project.import_failed? - @project.import_retry - else - @project.import_start - end - end - - @project + ).execute end end end diff --git a/lib/gitlab/gitlab_import/project_creator.rb b/lib/gitlab/gitlab_import/project_creator.rb index 6424d56f8f..f0d7141bf5 100644 --- a/lib/gitlab/gitlab_import/project_creator.rb +++ b/lib/gitlab/gitlab_import/project_creator.rb @@ -10,29 +10,16 @@ module Gitlab end def execute - @project = Project.new( + ::Projects::CreateService.new(current_user, name: repo["name"], path: repo["path"], description: repo["description"], - namespace: namespace, - creator: current_user, + namespace_id: namespace.id, visibility_level: repo["visibility_level"], import_type: "gitlab", import_source: repo["path_with_namespace"], import_url: repo["http_url_to_repo"].sub("://", "://oauth2:#{current_user.gitlab_access_token}@") - ) - - if @project.save! - @project.reload - - if @project.import_failed? - @project.import_retry - else - @project.import_start - end - end - - @project + ).execute end end end diff --git a/lib/gitlab/gitorious_import/project_creator.rb b/lib/gitlab/gitorious_import/project_creator.rb index 3cbebe5399..cc9a91c91f 100644 --- a/lib/gitlab/gitorious_import/project_creator.rb +++ b/lib/gitlab/gitorious_import/project_creator.rb @@ -10,29 +10,16 @@ module Gitlab end def execute - @project = Project.new( + ::Projects::CreateService.new(current_user, name: repo.name, path: repo.path, description: repo.description, - namespace: namespace, - creator: current_user, + namespace_id: namespace.id, visibility_level: Gitlab::VisibilityLevel::PUBLIC, import_type: "gitorious", import_source: repo.full_name, import_url: repo.import_url - ) - - if @project.save! - @project.reload - - if @project.import_failed? - @project.import_retry - else - @project.import_start - end - end - - @project + ).execute end end end From 4740242a67f10749b9d8ad213d259c9f5d0eea98 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Mon, 6 Apr 2015 14:51:41 +0200 Subject: [PATCH 053/194] Change text of project creation event for imports. --- app/models/event.rb | 6 +++++- app/views/events/event/_created_project.html.haml | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/models/event.rb b/app/models/event.rb index 57f6d5cd4e..c9a88ffa8e 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -183,7 +183,11 @@ class Event < ActiveRecord::Base elsif commented? "commented on" elsif created_project? - "created" + if project.import? + "imported" + else + "created" + end else "opened" end diff --git a/app/views/events/event/_created_project.html.haml b/app/views/events/event/_created_project.html.haml index 3c7153d235..552525f4a0 100644 --- a/app/views/events/event/_created_project.html.haml +++ b/app/views/events/event/_created_project.html.haml @@ -18,10 +18,10 @@ %a.twitter-share-button{ | href: "https://twitter.com/share", | "data-url" => event.project.web_url, | - "data-text" => "I just created a new project in GitLab! GitLab is version control on your server.", | + "data-text" => "I just #{event.project.imported? ? "imported" : "created"} a new project in GitLab! GitLab is version control on your server.", | "data-size" => "medium", | "data-related" => "gitlab", | "data-hashtags" => "gitlab", | "data-count" => "none"} Tweet - %script{src: "//platform.twitter.com/widgets.js"} \ No newline at end of file + %script{src: "//platform.twitter.com/widgets.js"} From ee9e922e965f0430d6c3ddaa7db05e81c4dfb7de Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Mon, 6 Apr 2015 15:04:13 +0200 Subject: [PATCH 054/194] Move project file links to tab bar. --- CHANGELOG | 2 +- app/views/projects/show.html.haml | 29 +++++++++++++++-------------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index db3e5744b0..53edc2ce11 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -20,7 +20,7 @@ v 7.10.0 (unreleased) - Omit the "email patches" link and fix plain diff view for merge commits - List new commits for newly pushed branch in activity view. - Add sidetiq gem dependency to match EE - - Add changelog, license and contribution guide links to project sidebar. + - Add changelog, license and contribution guide links to project tab bar. - Improve diff UI - Fix alignment of navbar toggle button (Cody Mize) - Fix checkbox rendering for nested task lists diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml index 85113ffa7e..160a17cebe 100644 --- a/app/views/projects/show.html.haml +++ b/app/views/projects/show.html.haml @@ -4,15 +4,27 @@ = render "home_panel" -- readme = @repository.readme %ul.nav.nav-tabs %li.active = link_to '#tab-activity', 'data-toggle' => 'tab' do Activity - - if readme + - if @repository.readme %li = link_to '#tab-readme', 'data-toggle' => 'tab' do Readme + - if @repository.changelog + %li + = link_to changelog_url(@project) do + Changelog + - if @repository.contribution_guide + %li + = link_to contribution_guide_url(@project) do + Contribution guide + - if @repository.license + %li + = link_to license_url(@project) do + License + .project-home-links - unless @project.empty_repo? = link_to pluralize(number_with_delimiter(@repository.commit_count), 'commit'), namespace_project_commits_path(@project.namespace, @project, @ref || @repository.root_ref) @@ -56,17 +68,6 @@ Version: %span.count = @repository.blob_by_oid(version.id).data - - elsif @repository.changelog - = link_to changelog_url(@project), class: 'btn btn-block' do - View changelog - - - if @repository.contribution_guide - = link_to contribution_guide_url(@project), class: 'btn btn-block' do - View contribution guide - - - if @repository.license - = link_to license_url(@project), class: 'btn btn-block' do - View license .prepend-top-10.append-bottom-10 %p @@ -96,7 +97,7 @@ %span.light CI provided by = link_to ci_service.title, ci_service.builds_path, :'data-no-turbolink' => 'data-no-turbolink' - - if readme + - if readme = @repository.readme .tab-pane#tab-readme %article.readme-holder#README = link_to namespace_project_blob_path(@project.namespace, @project, tree_join(@repository.root_ref, readme.name)) do From cf0e84cf04162bf63b36ebc3b6034c3d44abe997 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Mon, 6 Apr 2015 15:12:26 +0200 Subject: [PATCH 055/194] Add icons to Add dropdown items. --- CHANGELOG | 2 +- app/views/projects/_dropdown.html.haml | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index db3e5744b0..6b3d32d261 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -62,7 +62,7 @@ v 7.10.0 (unreleased) - Project labels are now available over the API under the "tag_list" field (Cristian Medina) - Fixed link paths for HTTP and SSH on the admin project view (Jeremy Maziarz) - Fix and improve help rendering (Sullivan Sénéchal) - + - Add icons to Add dropdown items. v 7.9.2 - Contains no changes diff --git a/app/views/projects/_dropdown.html.haml b/app/views/projects/_dropdown.html.haml index 3adb308728..3036f11bb2 100644 --- a/app/views/projects/_dropdown.html.haml +++ b/app/views/projects/_dropdown.html.haml @@ -6,28 +6,32 @@ - if @project.issues_enabled && can?(current_user, :write_issue, @project) %li = link_to url_for_new_issue(@project, only_path: true), title: "New Issue" do + %i.fa.fa-fw.fa-exclamation-circle New issue - if @project.merge_requests_enabled && can?(current_user, :write_merge_request, @project) %li = link_to new_namespace_project_merge_request_path(@project.namespace, @project), title: "New Merge Request" do + %i.fa.fa-fw.fa-tasks New merge request - if @project.snippets_enabled && can?(current_user, :write_snippet, @project) %li = link_to new_namespace_project_snippet_path(@project.namespace, @project), title: "New Snippet" do + %i.fa.fa-fw.fa-file-text-o New snippet - if can?(current_user, :admin_project_member, @project) %li = link_to namespace_project_project_members_path(@project.namespace, @project), title: "New project member" do + %i.fa.fa-fw.fa-users New project member - if can? current_user, :push_code, @project %li.divider %li = link_to new_namespace_project_branch_path(@project.namespace, @project) do - %i.fa.fa-code-fork - Git branch + %i.fa.fa-fw.fa-code-fork + New branch %li = link_to new_namespace_project_tag_path(@project.namespace, @project) do - %i.fa.fa-tag - Git tag + %i.fa.fa-fw.fa-tag + New tag From bafec400f153153cad3920ff128e5ba4ccc8ebf7 Mon Sep 17 00:00:00 2001 From: Valery Sizov Date: Mon, 6 Apr 2015 16:36:56 +0300 Subject: [PATCH 056/194] CI forking: tests --- .../project_services/gitlab_ci_service.rb | 20 +++++++++++------- app/services/projects/fork_service.rb | 2 +- ..._worker.rb => fork_registration_worker.rb} | 4 ++-- .../gitlab_ci_service_spec.rb | 21 +++++++++++++++++++ spec/services/projects/fork_service_spec.rb | 14 ++++++++++++- spec/workers/fork_registration_worker_spec.rb | 10 +++++++++ 6 files changed, 59 insertions(+), 12 deletions(-) rename app/workers/{fork_registrator_worker.rb => fork_registration_worker.rb} (67%) create mode 100644 spec/workers/fork_registration_worker_spec.rb diff --git a/app/models/project_services/gitlab_ci_service.rb b/app/models/project_services/gitlab_ci_service.rb index d66a133195..0f9838a575 100644 --- a/app/models/project_services/gitlab_ci_service.rb +++ b/app/models/project_services/gitlab_ci_service.rb @@ -18,6 +18,8 @@ # class GitlabCiService < CiService + API_PREFIX = "api/v1" + prop_accessor :project_url, :token validates :project_url, presence: true, if: :activated? validates :token, presence: true, if: :activated? @@ -59,7 +61,7 @@ class GitlabCiService < CiService end end - def register_fork(new_project, user_token) + def fork_registration(new_project, private_token) params = { id: new_project.id, name_with_namespace: new_project.name_with_namespace, @@ -69,12 +71,12 @@ class GitlabCiService < CiService } HTTParty.post( - register_fork_path, + fork_registration_path, body: { project_id: project.id, project_token: token, - user_token: user_token, - data: params.to_yaml}, + private_token: private_token, + data: params }, verify: false ) end @@ -95,10 +97,6 @@ class GitlabCiService < CiService project_url + "?ref=" + project.default_branch end - def register_fork_path - project_url.sub(/projects\/\d*/, 'api/v1/forks') - end - def status_img_path project_url + "/status.png?ref=" + project.default_branch end @@ -121,4 +119,10 @@ class GitlabCiService < CiService { type: 'text', name: 'project_url', placeholder: 'http://ci.gitlabhq.com/projects/3' } ] end + + private + + def fork_registration_path + project_url.sub(/projects\/\d*/, "#{API_PREFIX}/forks") + end end diff --git a/app/services/projects/fork_service.rb b/app/services/projects/fork_service.rb index b5150fefc4..4ec98696a6 100644 --- a/app/services/projects/fork_service.rb +++ b/app/services/projects/fork_service.rb @@ -50,7 +50,7 @@ module Projects end if @from_project.gitlab_ci? - ForkRegistratorWorker.perform_async(@from_project.id, project.id, @current_user.private_token) + ForkRegistrationWorker.perform_async(@from_project.id, project.id, @current_user.private_token) end rescue => ex project.errors.add(:base, 'Fork transaction failed.') diff --git a/app/workers/fork_registrator_worker.rb b/app/workers/fork_registration_worker.rb similarity index 67% rename from app/workers/fork_registrator_worker.rb rename to app/workers/fork_registration_worker.rb index 49d040d65e..fffa8b3a65 100644 --- a/app/workers/fork_registrator_worker.rb +++ b/app/workers/fork_registration_worker.rb @@ -1,4 +1,4 @@ -class ForkRegistratorWorker +class ForkRegistrationWorker include Sidekiq::Worker sidekiq_options queue: :default @@ -7,6 +7,6 @@ class ForkRegistratorWorker from_project = Project.find(from_project_id) to_project = Project.find(to_project_id) - from_project.gitlab_ci_service.register_fork(to_project, private_token) + from_project.gitlab_ci_service.fork_registration(to_project, private_token) end end diff --git a/spec/models/project_services/gitlab_ci_service_spec.rb b/spec/models/project_services/gitlab_ci_service_spec.rb index 610f33c582..6a557d839c 100644 --- a/spec/models/project_services/gitlab_ci_service_spec.rb +++ b/spec/models/project_services/gitlab_ci_service_spec.rb @@ -46,4 +46,25 @@ describe GitlabCiService do it { expect(@service.build_page("2ab7834c", 'master')).to eq("http://ci.gitlab.org/projects/2/refs/master/commits/2ab7834c")} end end + + describe "Fork registration" do + before do + @old_project = create(:empty_project) + @project = create(:empty_project) + @user = create(:user) + + @service = GitlabCiService.new + @service.stub( + service_hook: true, + project_url: 'http://ci.gitlab.org/projects/2', + token: 'verySecret', + project: @old_project + ) + end + + it "performs http reuquest to ci" do + stub_request(:post, "http://ci.gitlab.org/api/v1/forks") + @service.fork_registration(@project, @user.private_token) + end + end end diff --git a/spec/services/projects/fork_service_spec.rb b/spec/services/projects/fork_service_spec.rb index e55a2e3f8a..c9025bdf13 100644 --- a/spec/services/projects/fork_service_spec.rb +++ b/spec/services/projects/fork_service_spec.rb @@ -40,6 +40,17 @@ describe Projects::ForkService do expect(@to_project.errors[:base]).not_to include("Fork transaction failed.") end end + + context 'GitLab CI is enabled' do + it "calls fork registrator for CI" do + @from_project.build_missing_services + @from_project.gitlab_ci_service.update_attributes(active: true) + + expect(ForkRegistrationWorker).to receive(:perform_async) + + fork_project(@from_project, @to_user) + end + end end describe :fork_to_namespace do @@ -89,7 +100,8 @@ describe Projects::ForkService do def fork_project(from_project, user, fork_success = true, params = {}) context = Projects::ForkService.new(from_project, user, params) - shell = double('gitlab_shell').stub(fork_repository: fork_success) + shell = double('gitlab_shell') + shell.stub(fork_repository: fork_success) context.stub(gitlab_shell: shell) context.execute end diff --git a/spec/workers/fork_registration_worker_spec.rb b/spec/workers/fork_registration_worker_spec.rb new file mode 100644 index 0000000000..cc6f574b29 --- /dev/null +++ b/spec/workers/fork_registration_worker_spec.rb @@ -0,0 +1,10 @@ + +require 'spec_helper' + +describe ForkRegistrationWorker do + context "as a resque worker" do + it "reponds to #perform" do + expect(ForkRegistrationWorker.new).to respond_to(:perform) + end + end +end From 167a6b9eefd2076d08b4f1504f8b5ff3fb281026 Mon Sep 17 00:00:00 2001 From: Robert Speicher Date: Mon, 6 Apr 2015 23:02:06 -0400 Subject: [PATCH 057/194] Render a 404 when RefsController#logs_tree gets an HTML request Fixes #2152 --- app/controllers/projects/refs_controller.rb | 5 +++ .../projects/refs_controller_spec.rb | 41 +++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 spec/controllers/projects/refs_controller_spec.rb diff --git a/app/controllers/projects/refs_controller.rb b/app/controllers/projects/refs_controller.rb index 67acf45ab7..ec3b2b8d75 100644 --- a/app/controllers/projects/refs_controller.rb +++ b/app/controllers/projects/refs_controller.rb @@ -55,5 +55,10 @@ class Projects::RefsController < Projects::ApplicationController commit: last_commit } end + + respond_to do |format| + format.html { render_404 } + format.js + end end end diff --git a/spec/controllers/projects/refs_controller_spec.rb b/spec/controllers/projects/refs_controller_spec.rb new file mode 100644 index 0000000000..c254ab7cb6 --- /dev/null +++ b/spec/controllers/projects/refs_controller_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' + +describe Projects::RefsController do + let(:project) { create(:project) } + let(:user) { create(:user) } + + before do + sign_in(user) + project.team << [user, :developer] + end + + describe 'GET #logs_tree' do + def default_get(format = :html) + get :logs_tree, namespace_id: project.namespace.to_param, + project_id: project.to_param, id: 'master', + path: 'foo/bar/baz.html', format: format + end + + def xhr_get(format = :html) + xhr :get, :logs_tree, namespace_id: project.namespace.to_param, + project_id: project.to_param, id: 'master', + path: 'foo/bar/baz.html', format: format + end + + it 'never throws MissingTemplate' do + expect { default_get }.not_to raise_error + expect { xhr_get }.not_to raise_error + end + + it 'renders 404 for non-JS requests' do + xhr_get + + expect(response).to be_not_found + end + + it 'renders JS' do + xhr_get(:js) + expect(response).to be_success + end + end +end From 26a6cec90be5df25588427f940831427fbb83345 Mon Sep 17 00:00:00 2001 From: Robert Speicher Date: Tue, 7 Apr 2015 00:36:00 -0400 Subject: [PATCH 058/194] Update Chart.js --- vendor/assets/javascripts/chart-lib.min.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/vendor/assets/javascripts/chart-lib.min.js b/vendor/assets/javascripts/chart-lib.min.js index 626e6c3cdb..3a0a2c8734 100644 --- a/vendor/assets/javascripts/chart-lib.min.js +++ b/vendor/assets/javascripts/chart-lib.min.js @@ -1,11 +1,11 @@ /*! * Chart.js * http://chartjs.org/ - * Version: 1.0.1-beta.4 + * Version: 1.0.2 * - * Copyright 2014 Nick Downie + * Copyright 2015 Nick Downie * Released under the MIT license * https://github.com/nnnick/Chart.js/blob/master/LICENSE.md */ -(function(){"use strict";var t=this,i=t.Chart,e=function(t){this.canvas=t.canvas,this.ctx=t;this.width=t.canvas.width,this.height=t.canvas.height;return this.aspectRatio=this.width/this.height,s.retinaScale(this),this};e.defaults={global:{animation:!0,animationSteps:60,animationEasing:"easeOutQuart",showScale:!0,scaleOverride:!1,scaleSteps:null,scaleStepWidth:null,scaleStartValue:null,scaleLineColor:"rgba(0,0,0,.1)",scaleLineWidth:1,scaleShowLabels:!0,scaleLabel:"<%=value%>",scaleIntegersOnly:!0,scaleBeginAtZero:!1,scaleFontFamily:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",scaleFontSize:12,scaleFontStyle:"normal",scaleFontColor:"#666",responsive:!1,maintainAspectRatio:!0,showTooltips:!0,tooltipEvents:["mousemove","touchstart","touchmove","mouseout"],tooltipFillColor:"rgba(0,0,0,0.8)",tooltipFontFamily:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",tooltipFontSize:14,tooltipFontStyle:"normal",tooltipFontColor:"#fff",tooltipTitleFontFamily:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",tooltipTitleFontSize:14,tooltipTitleFontStyle:"bold",tooltipTitleFontColor:"#fff",tooltipYPadding:6,tooltipXPadding:6,tooltipCaretSize:8,tooltipCornerRadius:6,tooltipXOffset:10,tooltipTemplate:"<%if (label){%><%=label%>: <%}%><%= value %>",multiTooltipTemplate:"<%= value %>",multiTooltipKeyBackground:"#fff",onAnimationProgress:function(){},onAnimationComplete:function(){}}},e.types={};var s=e.helpers={},n=s.each=function(t,i,e){var s=Array.prototype.slice.call(arguments,3);if(t)if(t.length===+t.length){var n;for(n=0;n=0;s--){var n=t[s];if(i(n))return n}},s.inherits=function(t){var i=this,e=t&&t.hasOwnProperty("constructor")?t.constructor:function(){return i.apply(this,arguments)},s=function(){this.constructor=e};return s.prototype=i.prototype,e.prototype=new s,e.extend=r,t&&a(e.prototype,t),e.__super__=i.prototype,e}),c=s.noop=function(){},u=s.uid=function(){var t=0;return function(){return"chart-"+t++}}(),d=s.warn=function(t){window.console&&"function"==typeof window.console.warn&&console.warn(t)},p=s.amd="function"==typeof t.define&&t.define.amd,f=s.isNumber=function(t){return!isNaN(parseFloat(t))&&isFinite(t)},g=s.max=function(t){return Math.max.apply(Math,t)},m=s.min=function(t){return Math.min.apply(Math,t)},v=(s.cap=function(t,i,e){if(f(i)){if(t>i)return i}else if(f(e)&&e>t)return e;return t},s.getDecimalPlaces=function(t){return t%1!==0&&f(t)?t.toString().split(".")[1].length:0}),x=s.radians=function(t){return t*(Math.PI/180)},S=(s.getAngleFromPoint=function(t,i){var e=i.x-t.x,s=i.y-t.y,n=Math.sqrt(e*e+s*s),o=2*Math.PI+Math.atan2(s,e);return 0>e&&0>s&&(o+=2*Math.PI),{angle:o,distance:n}},s.aliasPixel=function(t){return t%2===0?0:.5}),y=(s.splineCurve=function(t,i,e,s){var n=Math.sqrt(Math.pow(i.x-t.x,2)+Math.pow(i.y-t.y,2)),o=Math.sqrt(Math.pow(e.x-i.x,2)+Math.pow(e.y-i.y,2)),a=s*n/(n+o),h=s*o/(n+o);return{inner:{x:i.x-a*(e.x-t.x),y:i.y-a*(e.y-t.y)},outer:{x:i.x+h*(e.x-t.x),y:i.y+h*(e.y-t.y)}}},s.calculateOrderOfMagnitude=function(t){return Math.floor(Math.log(t)/Math.LN10)}),C=(s.calculateScaleRange=function(t,i,e,s,n){var o=2,a=Math.floor(i/(1.5*e)),h=o>=a,l=g(t),r=m(t);l===r&&(l+=.5,r>=.5&&!s?r-=.5:l+=.5);for(var c=Math.abs(l-r),u=y(c),d=Math.ceil(l/(1*Math.pow(10,u)))*Math.pow(10,u),p=s?0:Math.floor(r/(1*Math.pow(10,u)))*Math.pow(10,u),f=d-p,v=Math.pow(10,u),x=Math.round(f/v);(x>a||a>2*x)&&!h;)if(x>a)v*=2,x=Math.round(f/v),x%1!==0&&(h=!0);else if(n&&u>=0){if(v/2%1!==0)break;v/=2,x=Math.round(f/v)}else v/=2,x=Math.round(f/v);return h&&(x=o,v=f/x),{steps:x,stepValue:v,min:p,max:p+x*v}},s.template=function(t,i){function e(t,i){var e=/\W/.test(t)?new Function("obj","var p=[],print=function(){p.push.apply(p,arguments);};with(obj){p.push('"+t.replace(/[\r\t\n]/g," ").split("<%").join(" ").replace(/((^|%>)[^\t]*)'/g,"$1\r").replace(/\t=(.*?)%>/g,"',$1,'").split(" ").join("');").split("%>").join("p.push('").split("\r").join("\\'")+"');}return p.join('');"):s[t]=s[t];return i?e(i):e}if(t instanceof Function)return t(i);var s={};return e(t,i)}),b=(s.generateLabels=function(t,i,e,s){var o=new Array(i);return labelTemplateString&&n(o,function(i,n){o[n]=C(t,{value:e+s*(n+1)})}),o},s.easingEffects={linear:function(t){return t},easeInQuad:function(t){return t*t},easeOutQuad:function(t){return-1*t*(t-2)},easeInOutQuad:function(t){return(t/=.5)<1?.5*t*t:-0.5*(--t*(t-2)-1)},easeInCubic:function(t){return t*t*t},easeOutCubic:function(t){return 1*((t=t/1-1)*t*t+1)},easeInOutCubic:function(t){return(t/=.5)<1?.5*t*t*t:.5*((t-=2)*t*t+2)},easeInQuart:function(t){return t*t*t*t},easeOutQuart:function(t){return-1*((t=t/1-1)*t*t*t-1)},easeInOutQuart:function(t){return(t/=.5)<1?.5*t*t*t*t:-0.5*((t-=2)*t*t*t-2)},easeInQuint:function(t){return 1*(t/=1)*t*t*t*t},easeOutQuint:function(t){return 1*((t=t/1-1)*t*t*t*t+1)},easeInOutQuint:function(t){return(t/=.5)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2)},easeInSine:function(t){return-1*Math.cos(t/1*(Math.PI/2))+1},easeOutSine:function(t){return 1*Math.sin(t/1*(Math.PI/2))},easeInOutSine:function(t){return-0.5*(Math.cos(Math.PI*t/1)-1)},easeInExpo:function(t){return 0===t?1:1*Math.pow(2,10*(t/1-1))},easeOutExpo:function(t){return 1===t?1:1*(-Math.pow(2,-10*t/1)+1)},easeInOutExpo:function(t){return 0===t?0:1===t?1:(t/=.5)<1?.5*Math.pow(2,10*(t-1)):.5*(-Math.pow(2,-10*--t)+2)},easeInCirc:function(t){return t>=1?t:-1*(Math.sqrt(1-(t/=1)*t)-1)},easeOutCirc:function(t){return 1*Math.sqrt(1-(t=t/1-1)*t)},easeInOutCirc:function(t){return(t/=.5)<1?-0.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1)},easeInElastic:function(t){var i=1.70158,e=0,s=1;return 0===t?0:1==(t/=1)?1:(e||(e=.3),st?-.5*s*Math.pow(2,10*(t-=1))*Math.sin(2*(1*t-i)*Math.PI/e):s*Math.pow(2,-10*(t-=1))*Math.sin(2*(1*t-i)*Math.PI/e)*.5+1)},easeInBack:function(t){var i=1.70158;return 1*(t/=1)*t*((i+1)*t-i)},easeOutBack:function(t){var i=1.70158;return 1*((t=t/1-1)*t*((i+1)*t+i)+1)},easeInOutBack:function(t){var i=1.70158;return(t/=.5)<1?.5*t*t*(((i*=1.525)+1)*t-i):.5*((t-=2)*t*(((i*=1.525)+1)*t+i)+2)},easeInBounce:function(t){return 1-b.easeOutBounce(1-t)},easeOutBounce:function(t){return(t/=1)<1/2.75?7.5625*t*t:2/2.75>t?1*(7.5625*(t-=1.5/2.75)*t+.75):2.5/2.75>t?1*(7.5625*(t-=2.25/2.75)*t+.9375):1*(7.5625*(t-=2.625/2.75)*t+.984375)},easeInOutBounce:function(t){return.5>t?.5*b.easeInBounce(2*t):.5*b.easeOutBounce(2*t-1)+.5}}),w=s.requestAnimFrame=function(){return window.requestAnimationFrame||window.webkitRequestAnimationFrame||window.mozRequestAnimationFrame||window.oRequestAnimationFrame||window.msRequestAnimationFrame||function(t){return window.setTimeout(t,1e3/60)}}(),P=(s.cancelAnimFrame=function(){return window.cancelAnimationFrame||window.webkitCancelAnimationFrame||window.mozCancelAnimationFrame||window.oCancelAnimationFrame||window.msCancelAnimationFrame||function(t){return window.clearTimeout(t,1e3/60)}}(),s.animationLoop=function(t,i,e,s,n,o){var a=0,h=b[e]||b.linear,l=function(){a++;var e=a/i,r=h(e);t.call(o,r,e,a),s.call(o,r,e),i>a?o.animationFrame=w(l):n.apply(o)};w(l)},s.getRelativePosition=function(t){var i,e,s=t.originalEvent||t,n=t.currentTarget||t.srcElement,o=n.getBoundingClientRect();return s.touches?(i=s.touches[0].clientX-o.left,e=s.touches[0].clientY-o.top):(i=s.clientX-o.left,e=s.clientY-o.top),{x:i,y:e}},s.addEvent=function(t,i,e){t.addEventListener?t.addEventListener(i,e):t.attachEvent?t.attachEvent("on"+i,e):t["on"+i]=e}),L=s.removeEvent=function(t,i,e){t.removeEventListener?t.removeEventListener(i,e,!1):t.detachEvent?t.detachEvent("on"+i,e):t["on"+i]=c},k=(s.bindEvents=function(t,i,e){t.events||(t.events={}),n(i,function(i){t.events[i]=function(){e.apply(t,arguments)},P(t.chart.canvas,i,t.events[i])})},s.unbindEvents=function(t,i){n(i,function(i,e){L(t.chart.canvas,e,i)})}),F=s.getMaximumWidth=function(t){var i=t.parentNode;return i.clientWidth},R=s.getMaximumHeight=function(t){var i=t.parentNode;return i.clientHeight},A=(s.getMaximumSize=s.getMaximumWidth,s.retinaScale=function(t){var i=t.ctx,e=t.canvas.width,s=t.canvas.height;window.devicePixelRatio&&(i.canvas.style.width=e+"px",i.canvas.style.height=s+"px",i.canvas.height=s*window.devicePixelRatio,i.canvas.width=e*window.devicePixelRatio,i.scale(window.devicePixelRatio,window.devicePixelRatio))}),T=s.clear=function(t){t.ctx.clearRect(0,0,t.width,t.height)},M=s.fontString=function(t,i,e){return i+" "+t+"px "+e},W=s.longestText=function(t,i,e){t.font=i;var s=0;return n(e,function(i){var e=t.measureText(i).width;s=e>s?e:s}),s},z=s.drawRoundedRectangle=function(t,i,e,s,n,o){t.beginPath(),t.moveTo(i+o,e),t.lineTo(i+s-o,e),t.quadraticCurveTo(i+s,e,i+s,e+o),t.lineTo(i+s,e+n-o),t.quadraticCurveTo(i+s,e+n,i+s-o,e+n),t.lineTo(i+o,e+n),t.quadraticCurveTo(i,e+n,i,e+n-o),t.lineTo(i,e+o),t.quadraticCurveTo(i,e,i+o,e),t.closePath()};e.instances={},e.Type=function(t,i,s){this.options=i,this.chart=s,this.id=u(),e.instances[this.id]=this,i.responsive&&this.resize(),this.initialize.call(this,t)},a(e.Type.prototype,{initialize:function(){return this},clear:function(){return T(this.chart),this},stop:function(){return s.cancelAnimFrame.call(t,this.animationFrame),this},resize:function(t){this.stop();var i=this.chart.canvas,e=F(this.chart.canvas),s=this.options.maintainAspectRatio?e/this.chart.aspectRatio:R(this.chart.canvas);return i.width=this.chart.width=e,i.height=this.chart.height=s,A(this.chart),"function"==typeof t&&t.apply(this,Array.prototype.slice.call(arguments,1)),this},reflow:c,render:function(t){return t&&this.reflow(),this.options.animation&&!t?s.animationLoop(this.draw,this.options.animationSteps,this.options.animationEasing,this.options.onAnimationProgress,this.options.onAnimationComplete,this):(this.draw(),this.options.onAnimationComplete.call(this)),this},generateLegend:function(){return C(this.options.legendTemplate,this)},destroy:function(){this.clear(),k(this,this.events),delete e.instances[this.id]},showTooltip:function(t,i){"undefined"==typeof this.activeElements&&(this.activeElements=[]);var o=function(t){var i=!1;return t.length!==this.activeElements.length?i=!0:(n(t,function(t,e){t!==this.activeElements[e]&&(i=!0)},this),i)}.call(this,t);if(o||i){if(this.activeElements=t,this.draw(),t.length>0)if(this.datasets&&this.datasets.length>1){for(var a,h,r=this.datasets.length-1;r>=0&&(a=this.datasets[r].points||this.datasets[r].bars||this.datasets[r].segments,h=l(a,t[0]),-1===h);r--);var c=[],u=[],d=function(){var t,i,e,n,o,a=[],l=[],r=[];return s.each(this.datasets,function(i){t=i.points||i.bars||i.segments,t[h]&&t[h].hasValue()&&a.push(t[h])}),s.each(a,function(t){l.push(t.x),r.push(t.y),c.push(s.template(this.options.multiTooltipTemplate,t)),u.push({fill:t._saved.fillColor||t.fillColor,stroke:t._saved.strokeColor||t.strokeColor})},this),o=m(r),e=g(r),n=m(l),i=g(l),{x:n>this.chart.width/2?n:i,y:(o+e)/2}}.call(this,h);new e.MultiTooltip({x:d.x,y:d.y,xPadding:this.options.tooltipXPadding,yPadding:this.options.tooltipYPadding,xOffset:this.options.tooltipXOffset,fillColor:this.options.tooltipFillColor,textColor:this.options.tooltipFontColor,fontFamily:this.options.tooltipFontFamily,fontStyle:this.options.tooltipFontStyle,fontSize:this.options.tooltipFontSize,titleTextColor:this.options.tooltipTitleFontColor,titleFontFamily:this.options.tooltipTitleFontFamily,titleFontStyle:this.options.tooltipTitleFontStyle,titleFontSize:this.options.tooltipTitleFontSize,cornerRadius:this.options.tooltipCornerRadius,labels:c,legendColors:u,legendColorBackground:this.options.multiTooltipKeyBackground,title:t[0].label,chart:this.chart,ctx:this.chart.ctx}).draw()}else n(t,function(t){var i=t.tooltipPosition();new e.Tooltip({x:Math.round(i.x),y:Math.round(i.y),xPadding:this.options.tooltipXPadding,yPadding:this.options.tooltipYPadding,fillColor:this.options.tooltipFillColor,textColor:this.options.tooltipFontColor,fontFamily:this.options.tooltipFontFamily,fontStyle:this.options.tooltipFontStyle,fontSize:this.options.tooltipFontSize,caretHeight:this.options.tooltipCaretSize,cornerRadius:this.options.tooltipCornerRadius,text:C(this.options.tooltipTemplate,t),chart:this.chart}).draw()},this);return this}},toBase64Image:function(){return this.chart.canvas.toDataURL.apply(this.chart.canvas,arguments)}}),e.Type.extend=function(t){var i=this,s=function(){return i.apply(this,arguments)};if(s.prototype=o(i.prototype),a(s.prototype,t),s.extend=e.Type.extend,t.name||i.prototype.name){var n=t.name||i.prototype.name,l=e.defaults[i.prototype.name]?o(e.defaults[i.prototype.name]):{};e.defaults[n]=a(l,t.defaults),e.types[n]=s,e.prototype[n]=function(t,i){var o=h(e.defaults.global,e.defaults[n],i||{});return new s(t,o,this)}}else d("Name not provided for this chart, so it hasn't been registered");return i},e.Element=function(t){a(this,t),this.initialize.apply(this,arguments),this.save()},a(e.Element.prototype,{initialize:function(){},restore:function(t){return t?n(t,function(t){this[t]=this._saved[t]},this):a(this,this._saved),this},save:function(){return this._saved=o(this),delete this._saved._saved,this},update:function(t){return n(t,function(t,i){this._saved[i]=this[i],this[i]=t},this),this},transition:function(t,i){return n(t,function(t,e){this[e]=(t-this._saved[e])*i+this._saved[e]},this),this},tooltipPosition:function(){return{x:this.x,y:this.y}},hasValue:function(){return f(this.value)}}),e.Element.extend=r,e.Point=e.Element.extend({display:!0,inRange:function(t,i){var e=this.hitDetectionRadius+this.radius;return Math.pow(t-this.x,2)+Math.pow(i-this.y,2)=this.startAngle&&e.angle<=this.endAngle,o=e.distance>=this.innerRadius&&e.distance<=this.outerRadius;return n&&o},tooltipPosition:function(){var t=this.startAngle+(this.endAngle-this.startAngle)/2,i=(this.outerRadius-this.innerRadius)/2+this.innerRadius;return{x:this.x+Math.cos(t)*i,y:this.y+Math.sin(t)*i}},draw:function(t){var i=this.ctx;i.beginPath(),i.arc(this.x,this.y,this.outerRadius,this.startAngle,this.endAngle),i.arc(this.x,this.y,this.innerRadius,this.endAngle,this.startAngle,!0),i.closePath(),i.strokeStyle=this.strokeColor,i.lineWidth=this.strokeWidth,i.fillStyle=this.fillColor,i.fill(),i.lineJoin="bevel",this.showStroke&&i.stroke()}}),e.Rectangle=e.Element.extend({draw:function(){var t=this.ctx,i=this.width/2,e=this.x-i,s=this.x+i,n=this.base-(this.base-this.y),o=this.strokeWidth/2;this.showStroke&&(e+=o,s-=o,n+=o),t.beginPath(),t.fillStyle=this.fillColor,t.strokeStyle=this.strokeColor,t.lineWidth=this.strokeWidth,t.moveTo(e,this.base),t.lineTo(e,n),t.lineTo(s,n),t.lineTo(s,this.base),t.fill(),this.showStroke&&t.stroke()},height:function(){return this.base-this.y},inRange:function(t,i){return t>=this.x-this.width/2&&t<=this.x+this.width/2&&i>=this.y&&i<=this.base}}),e.Tooltip=e.Element.extend({draw:function(){var t=this.chart.ctx;t.font=M(this.fontSize,this.fontStyle,this.fontFamily),this.xAlign="center",this.yAlign="above";var i=2,e=t.measureText(this.text).width+2*this.xPadding,s=this.fontSize+2*this.yPadding,n=s+this.caretHeight+i;this.x+e/2>this.chart.width?this.xAlign="left":this.x-e/2<0&&(this.xAlign="right"),this.y-n<0&&(this.yAlign="below");var o=this.x-e/2,a=this.y-n;switch(t.fillStyle=this.fillColor,this.yAlign){case"above":t.beginPath(),t.moveTo(this.x,this.y-i),t.lineTo(this.x+this.caretHeight,this.y-(i+this.caretHeight)),t.lineTo(this.x-this.caretHeight,this.y-(i+this.caretHeight)),t.closePath(),t.fill();break;case"below":a=this.y+i+this.caretHeight,t.beginPath(),t.moveTo(this.x,this.y+i),t.lineTo(this.x+this.caretHeight,this.y+i+this.caretHeight),t.lineTo(this.x-this.caretHeight,this.y+i+this.caretHeight),t.closePath(),t.fill()}switch(this.xAlign){case"left":o=this.x-e+(this.cornerRadius+this.caretHeight);break;case"right":o=this.x-(this.cornerRadius+this.caretHeight)}z(t,o,a,e,s,this.cornerRadius),t.fill(),t.fillStyle=this.textColor,t.textAlign="center",t.textBaseline="middle",t.fillText(this.text,o+e/2,a+s/2)}}),e.MultiTooltip=e.Element.extend({initialize:function(){this.font=M(this.fontSize,this.fontStyle,this.fontFamily),this.titleFont=M(this.titleFontSize,this.titleFontStyle,this.titleFontFamily),this.height=this.labels.length*this.fontSize+(this.labels.length-1)*(this.fontSize/2)+2*this.yPadding+1.5*this.titleFontSize,this.ctx.font=this.titleFont;var t=this.ctx.measureText(this.title).width,i=W(this.ctx,this.font,this.labels)+this.fontSize+3,e=g([i,t]);this.width=e+2*this.xPadding;var s=this.height/2;this.y-s<0?this.y=s:this.y+s>this.chart.height&&(this.y=this.chart.height-s),this.x>this.chart.width/2?this.x-=this.xOffset+this.width:this.x+=this.xOffset},getLineHeight:function(t){var i=this.y-this.height/2+this.yPadding,e=t-1;return 0===t?i+this.titleFontSize/2:i+(1.5*this.fontSize*e+this.fontSize/2)+1.5*this.titleFontSize},draw:function(){z(this.ctx,this.x,this.y-this.height/2,this.width,this.height,this.cornerRadius);var t=this.ctx;t.fillStyle=this.fillColor,t.fill(),t.closePath(),t.textAlign="left",t.textBaseline="middle",t.fillStyle=this.titleTextColor,t.font=this.titleFont,t.fillText(this.title,this.x+this.xPadding,this.getLineHeight(0)),t.font=this.font,s.each(this.labels,function(i,e){t.fillStyle=this.textColor,t.fillText(i,this.x+this.xPadding+this.fontSize+3,this.getLineHeight(e+1)),t.fillStyle=this.legendColorBackground,t.fillRect(this.x+this.xPadding,this.getLineHeight(e+1)-this.fontSize/2,this.fontSize,this.fontSize),t.fillStyle=this.legendColors[e].fill,t.fillRect(this.x+this.xPadding,this.getLineHeight(e+1)-this.fontSize/2,this.fontSize,this.fontSize)},this)}}),e.Scale=e.Element.extend({initialize:function(){this.fit()},buildYLabels:function(){this.yLabels=[];for(var t=v(this.stepValue),i=0;i<=this.steps;i++)this.yLabels.push(C(this.templateString,{value:(this.min+i*this.stepValue).toFixed(t)}));this.yLabelWidth=this.display&&this.showLabels?W(this.ctx,this.font,this.yLabels):0},addXLabel:function(t){this.xLabels.push(t),this.valuesCount++,this.fit()},removeXLabel:function(){this.xLabels.shift(),this.valuesCount--,this.fit()},fit:function(){this.startPoint=this.display?this.fontSize:0,this.endPoint=this.display?this.height-1.5*this.fontSize-5:this.height,this.startPoint+=this.padding,this.endPoint-=this.padding;var t,i=this.endPoint-this.startPoint;for(this.calculateYRange(i),this.buildYLabels(),this.calculateXLabelRotation();i>this.endPoint-this.startPoint;)i=this.endPoint-this.startPoint,t=this.yLabelWidth,this.calculateYRange(i),this.buildYLabels(),tthis.yLabelWidth+10?e/2:this.yLabelWidth+10,this.xLabelRotation=0,this.display){var n,o=W(this.ctx,this.font,this.xLabels);this.xLabelWidth=o;for(var a=Math.floor(this.calculateX(1)-this.calculateX(0))-6;this.xLabelWidth>a&&0===this.xLabelRotation||this.xLabelWidth>a&&this.xLabelRotation<=90&&this.xLabelRotation>0;)n=Math.cos(x(this.xLabelRotation)),t=n*e,i=n*s,t+this.fontSize/2>this.yLabelWidth+8&&(this.xScalePaddingLeft=t+this.fontSize/2),this.xScalePaddingRight=this.fontSize/2,this.xLabelRotation++,this.xLabelWidth=n*o;this.xLabelRotation>0&&(this.endPoint-=Math.sin(x(this.xLabelRotation))*o+3)}else this.xLabelWidth=0,this.xScalePaddingRight=this.padding,this.xScalePaddingLeft=this.padding},calculateYRange:c,drawingArea:function(){return this.startPoint-this.endPoint},calculateY:function(t){var i=this.drawingArea()/(this.min-this.max);return this.endPoint-i*(t-this.min)},calculateX:function(t){var i=(this.xLabelRotation>0,this.width-(this.xScalePaddingLeft+this.xScalePaddingRight)),e=i/(this.valuesCount-(this.offsetGridLines?0:1)),s=e*t+this.xScalePaddingLeft;return this.offsetGridLines&&(s+=e/2),Math.round(s)},update:function(t){s.extend(this,t),this.fit()},draw:function(){var t=this.ctx,i=(this.endPoint-this.startPoint)/this.steps,e=Math.round(this.xScalePaddingLeft);this.display&&(t.fillStyle=this.textColor,t.font=this.font,n(this.yLabels,function(n,o){var a=this.endPoint-i*o,h=Math.round(a);t.textAlign="right",t.textBaseline="middle",this.showLabels&&t.fillText(n,e-10,a),t.beginPath(),o>0?(t.lineWidth=this.gridLineWidth,t.strokeStyle=this.gridLineColor):(t.lineWidth=this.lineWidth,t.strokeStyle=this.lineColor),h+=s.aliasPixel(t.lineWidth),t.moveTo(e,h),t.lineTo(this.width,h),t.stroke(),t.closePath(),t.lineWidth=this.lineWidth,t.strokeStyle=this.lineColor,t.beginPath(),t.moveTo(e-5,h),t.lineTo(e,h),t.stroke(),t.closePath()},this),n(this.xLabels,function(i,e){var s=this.calculateX(e)+S(this.lineWidth),n=this.calculateX(e-(this.offsetGridLines?.5:0))+S(this.lineWidth),o=this.xLabelRotation>0;t.beginPath(),e>0?(t.lineWidth=this.gridLineWidth,t.strokeStyle=this.gridLineColor):(t.lineWidth=this.lineWidth,t.strokeStyle=this.lineColor),t.moveTo(n,this.endPoint),t.lineTo(n,this.startPoint-3),t.stroke(),t.closePath(),t.lineWidth=this.lineWidth,t.strokeStyle=this.lineColor,t.beginPath(),t.moveTo(n,this.endPoint),t.lineTo(n,this.endPoint+5),t.stroke(),t.closePath(),t.save(),t.translate(s,o?this.endPoint+12:this.endPoint+8),t.rotate(-1*x(this.xLabelRotation)),t.font=this.font,t.textAlign=o?"right":"center",t.textBaseline=o?"middle":"top",t.fillText(i,0,0),t.restore()},this))}}),e.RadialScale=e.Element.extend({initialize:function(){this.size=m([this.height,this.width]),this.drawingArea=this.display?this.size/2-(this.fontSize/2+this.backdropPaddingY):this.size/2},calculateCenterOffset:function(t){var i=this.drawingArea/(this.max-this.min);return(t-this.min)*i},update:function(){this.lineArc?this.drawingArea=this.display?this.size/2-(this.fontSize/2+this.backdropPaddingY):this.size/2:this.setScaleSize(),this.buildYLabels()},buildYLabels:function(){this.yLabels=[];for(var t=v(this.stepValue),i=0;i<=this.steps;i++)this.yLabels.push(C(this.templateString,{value:(this.min+i*this.stepValue).toFixed(t)}))},getCircumference:function(){return 2*Math.PI/this.valuesCount},setScaleSize:function(){var t,i,e,s,n,o,a,h,l,r,c,u,d=m([this.height/2-this.pointLabelFontSize-5,this.width/2]),p=this.width,g=0;for(this.ctx.font=M(this.pointLabelFontSize,this.pointLabelFontStyle,this.pointLabelFontFamily),i=0;ip&&(p=t.x+s,n=i),t.x-sp&&(p=t.x+e,n=i):i>this.valuesCount/2&&t.x-e0){var s,n=e*(this.drawingArea/this.steps),o=this.yCenter-n;if(this.lineWidth>0)if(t.strokeStyle=this.lineColor,t.lineWidth=this.lineWidth,this.lineArc)t.beginPath(),t.arc(this.xCenter,this.yCenter,n,0,2*Math.PI),t.closePath(),t.stroke();else{t.beginPath();for(var a=0;a=0;i--){if(this.angleLineWidth>0){var e=this.getPointPosition(i,this.calculateCenterOffset(this.max));t.beginPath(),t.moveTo(this.xCenter,this.yCenter),t.lineTo(e.x,e.y),t.stroke(),t.closePath()}var s=this.getPointPosition(i,this.calculateCenterOffset(this.max)+5);t.font=M(this.pointLabelFontSize,this.pointLabelFontStyle,this.pointLabelFontFamily),t.fillStyle=this.pointLabelFontColor;var o=this.labels.length,a=this.labels.length/2,h=a/2,l=h>i||i>o-h,r=i===h||i===o-h;t.textAlign=0===i?"center":i===a?"center":a>i?"left":"right",t.textBaseline=r?"middle":l?"bottom":"top",t.fillText(this.labels[i],s.x,s.y)}}}}}),s.addEvent(window,"resize",function(){var t;return function(){clearTimeout(t),t=setTimeout(function(){n(e.instances,function(t){t.options.responsive&&t.resize(t.render,!0)})},50)}}()),p?define(function(){return e}):"object"==typeof module&&module.exports&&(module.exports=e),t.Chart=e,e.noConflict=function(){return t.Chart=i,e}}).call(this),function(){"use strict";var t=this,i=t.Chart,e=i.helpers,s={scaleBeginAtZero:!0,scaleShowGridLines:!0,scaleGridLineColor:"rgba(0,0,0,.05)",scaleGridLineWidth:1,barShowStroke:!0,barStrokeWidth:2,barValueSpacing:5,barDatasetSpacing:1,legendTemplate:'
    <% for (var i=0; i
  • <%if(datasets[i].label){%><%=datasets[i].label%><%}%>
  • <%}%>
'};i.Type.extend({name:"Bar",defaults:s,initialize:function(t){var s=this.options;this.ScaleClass=i.Scale.extend({offsetGridLines:!0,calculateBarX:function(t,i,e){var n=this.calculateBaseWidth(),o=this.calculateX(e)-n/2,a=this.calculateBarWidth(t);return o+a*i+i*s.barDatasetSpacing+a/2},calculateBaseWidth:function(){return this.calculateX(1)-this.calculateX(0)-2*s.barValueSpacing},calculateBarWidth:function(t){var i=this.calculateBaseWidth()-(t-1)*s.barDatasetSpacing;return i/t}}),this.datasets=[],this.options.showTooltips&&e.bindEvents(this,this.options.tooltipEvents,function(t){var i="mouseout"!==t.type?this.getBarsAtEvent(t):[];this.eachBars(function(t){t.restore(["fillColor","strokeColor"])}),e.each(i,function(t){t.fillColor=t.highlightFill,t.strokeColor=t.highlightStroke}),this.showTooltip(i)}),this.BarClass=i.Rectangle.extend({strokeWidth:this.options.barStrokeWidth,showStroke:this.options.barShowStroke,ctx:this.chart.ctx}),e.each(t.datasets,function(i){var s={label:i.label||null,fillColor:i.fillColor,strokeColor:i.strokeColor,bars:[]};this.datasets.push(s),e.each(i.data,function(e,n){s.bars.push(new this.BarClass({value:e,label:t.labels[n],datasetLabel:i.label,strokeColor:i.strokeColor,fillColor:i.fillColor,highlightFill:i.highlightFill||i.fillColor,highlightStroke:i.highlightStroke||i.strokeColor}))},this)},this),this.buildScale(t.labels),this.BarClass.prototype.base=this.scale.endPoint,this.eachBars(function(t,i,s){e.extend(t,{width:this.scale.calculateBarWidth(this.datasets.length),x:this.scale.calculateBarX(this.datasets.length,s,i),y:this.scale.endPoint}),t.save()},this),this.render()},update:function(){this.scale.update(),e.each(this.activeElements,function(t){t.restore(["fillColor","strokeColor"])}),this.eachBars(function(t){t.save()}),this.render()},eachBars:function(t){e.each(this.datasets,function(i,s){e.each(i.bars,t,this,s)},this)},getBarsAtEvent:function(t){for(var i,s=[],n=e.getRelativePosition(t),o=function(t){s.push(t.bars[i])},a=0;a<% for (var i=0; i
  • <%if(segments[i].label){%><%=segments[i].label%><%}%>
  • <%}%>'}; -i.Type.extend({name:"Doughnut",defaults:s,initialize:function(t){this.segments=[],this.outerRadius=(e.min([this.chart.width,this.chart.height])-this.options.segmentStrokeWidth/2)/2,this.SegmentArc=i.Arc.extend({ctx:this.chart.ctx,x:this.chart.width/2,y:this.chart.height/2}),this.options.showTooltips&&e.bindEvents(this,this.options.tooltipEvents,function(t){var i="mouseout"!==t.type?this.getSegmentsAtEvent(t):[];e.each(this.segments,function(t){t.restore(["fillColor"])}),e.each(i,function(t){t.fillColor=t.highlightColor}),this.showTooltip(i)}),this.calculateTotal(t),e.each(t,function(t,i){this.addData(t,i,!0)},this),this.render()},getSegmentsAtEvent:function(t){var i=[],s=e.getRelativePosition(t);return e.each(this.segments,function(t){t.inRange(s.x,s.y)&&i.push(t)},this),i},addData:function(t,i,e){var s=i||this.segments.length;this.segments.splice(s,0,new this.SegmentArc({value:t.value,outerRadius:this.options.animateScale?0:this.outerRadius,innerRadius:this.options.animateScale?0:this.outerRadius/100*this.options.percentageInnerCutout,fillColor:t.color,highlightColor:t.highlight||t.color,showStroke:this.options.segmentShowStroke,strokeWidth:this.options.segmentStrokeWidth,strokeColor:this.options.segmentStrokeColor,startAngle:1.5*Math.PI,circumference:this.options.animateRotate?0:this.calculateCircumference(t.value),label:t.label})),e||(this.reflow(),this.update())},calculateCircumference:function(t){return 2*Math.PI*(t/this.total)},calculateTotal:function(t){this.total=0,e.each(t,function(t){this.total+=t.value},this)},update:function(){this.calculateTotal(this.segments),e.each(this.activeElements,function(t){t.restore(["fillColor"])}),e.each(this.segments,function(t){t.save()}),this.render()},removeData:function(t){var i=e.isNumber(t)?t:this.segments.length-1;this.segments.splice(i,1),this.reflow(),this.update()},reflow:function(){e.extend(this.SegmentArc.prototype,{x:this.chart.width/2,y:this.chart.height/2}),this.outerRadius=(e.min([this.chart.width,this.chart.height])-this.options.segmentStrokeWidth/2)/2,e.each(this.segments,function(t){t.update({outerRadius:this.outerRadius,innerRadius:this.outerRadius/100*this.options.percentageInnerCutout})},this)},draw:function(t){var i=t?t:1;this.clear(),e.each(this.segments,function(t,e){t.transition({circumference:this.calculateCircumference(t.value),outerRadius:this.outerRadius,innerRadius:this.outerRadius/100*this.options.percentageInnerCutout},i),t.endAngle=t.startAngle+t.circumference,t.draw(),0===e&&(t.startAngle=1.5*Math.PI),e<% for (var i=0; i
  • <%if(datasets[i].label){%><%=datasets[i].label%><%}%>
  • <%}%>'};i.Type.extend({name:"Line",defaults:s,initialize:function(t){this.PointClass=i.Point.extend({strokeWidth:this.options.pointDotStrokeWidth,radius:this.options.pointDotRadius,display:this.options.pointDot,hitDetectionRadius:this.options.pointHitDetectionRadius,ctx:this.chart.ctx,inRange:function(t){return Math.pow(t-this.x,2)0&&ithis.scale.endPoint?t.controlPoints.outer.y=this.scale.endPoint:t.controlPoints.outer.ythis.scale.endPoint?t.controlPoints.inner.y=this.scale.endPoint:t.controlPoints.inner.y0&&(s.lineTo(h[h.length-1].x,this.scale.endPoint),s.lineTo(h[0].x,this.scale.endPoint),s.fillStyle=t.fillColor,s.closePath(),s.fill()),e.each(h,function(t){t.draw()})},this)}})}.call(this),function(){"use strict";var t=this,i=t.Chart,e=i.helpers,s={scaleShowLabelBackdrop:!0,scaleBackdropColor:"rgba(255,255,255,0.75)",scaleBeginAtZero:!0,scaleBackdropPaddingY:2,scaleBackdropPaddingX:2,scaleShowLine:!0,segmentShowStroke:!0,segmentStrokeColor:"#fff",segmentStrokeWidth:2,animationSteps:100,animationEasing:"easeOutBounce",animateRotate:!0,animateScale:!1,legendTemplate:'
      <% for (var i=0; i
    • <%if(segments[i].label){%><%=segments[i].label%><%}%>
    • <%}%>
    '};i.Type.extend({name:"PolarArea",defaults:s,initialize:function(t){this.segments=[],this.SegmentArc=i.Arc.extend({showStroke:this.options.segmentShowStroke,strokeWidth:this.options.segmentStrokeWidth,strokeColor:this.options.segmentStrokeColor,ctx:this.chart.ctx,innerRadius:0,x:this.chart.width/2,y:this.chart.height/2}),this.scale=new i.RadialScale({display:this.options.showScale,fontStyle:this.options.scaleFontStyle,fontSize:this.options.scaleFontSize,fontFamily:this.options.scaleFontFamily,fontColor:this.options.scaleFontColor,showLabels:this.options.scaleShowLabels,showLabelBackdrop:this.options.scaleShowLabelBackdrop,backdropColor:this.options.scaleBackdropColor,backdropPaddingY:this.options.scaleBackdropPaddingY,backdropPaddingX:this.options.scaleBackdropPaddingX,lineWidth:this.options.scaleShowLine?this.options.scaleLineWidth:0,lineColor:this.options.scaleLineColor,lineArc:!0,width:this.chart.width,height:this.chart.height,xCenter:this.chart.width/2,yCenter:this.chart.height/2,ctx:this.chart.ctx,templateString:this.options.scaleLabel,valuesCount:t.length}),this.updateScaleRange(t),this.scale.update(),e.each(t,function(t,i){this.addData(t,i,!0)},this),this.options.showTooltips&&e.bindEvents(this,this.options.tooltipEvents,function(t){var i="mouseout"!==t.type?this.getSegmentsAtEvent(t):[];e.each(this.segments,function(t){t.restore(["fillColor"])}),e.each(i,function(t){t.fillColor=t.highlightColor}),this.showTooltip(i)}),this.render()},getSegmentsAtEvent:function(t){var i=[],s=e.getRelativePosition(t);return e.each(this.segments,function(t){t.inRange(s.x,s.y)&&i.push(t)},this),i},addData:function(t,i,e){var s=i||this.segments.length;this.segments.splice(s,0,new this.SegmentArc({fillColor:t.color,highlightColor:t.highlight||t.color,label:t.label,value:t.value,outerRadius:this.options.animateScale?0:this.scale.calculateCenterOffset(t.value),circumference:this.options.animateRotate?0:this.scale.getCircumference(),startAngle:1.5*Math.PI})),e||(this.reflow(),this.update())},removeData:function(t){var i=e.isNumber(t)?t:this.segments.length-1;this.segments.splice(i,1),this.reflow(),this.update()},calculateTotal:function(t){this.total=0,e.each(t,function(t){this.total+=t.value},this),this.scale.valuesCount=this.segments.length},updateScaleRange:function(t){var i=[];e.each(t,function(t){i.push(t.value)});var s=this.options.scaleOverride?{steps:this.options.scaleSteps,stepValue:this.options.scaleStepWidth,min:this.options.scaleStartValue,max:this.options.scaleStartValue+this.options.scaleSteps*this.options.scaleStepWidth}:e.calculateScaleRange(i,e.min([this.chart.width,this.chart.height])/2,this.options.scaleFontSize,this.options.scaleBeginAtZero,this.options.scaleIntegersOnly);e.extend(this.scale,s,{size:e.min([this.chart.width,this.chart.height]),xCenter:this.chart.width/2,yCenter:this.chart.height/2})},update:function(){this.calculateTotal(this.segments),e.each(this.segments,function(t){t.save()}),this.render()},reflow:function(){e.extend(this.SegmentArc.prototype,{x:this.chart.width/2,y:this.chart.height/2}),this.updateScaleRange(this.segments),this.scale.update(),e.extend(this.scale,{xCenter:this.chart.width/2,yCenter:this.chart.height/2}),e.each(this.segments,function(t){t.update({outerRadius:this.scale.calculateCenterOffset(t.value)})},this)},draw:function(t){var i=t||1;this.clear(),e.each(this.segments,function(t,e){t.transition({circumference:this.scale.getCircumference(),outerRadius:this.scale.calculateCenterOffset(t.value)},i),t.endAngle=t.startAngle+t.circumference,0===e&&(t.startAngle=1.5*Math.PI),e<% for (var i=0; i
  • <%if(datasets[i].label){%><%=datasets[i].label%><%}%>
  • <%}%>'},initialize:function(t){this.PointClass=i.Point.extend({strokeWidth:this.options.pointDotStrokeWidth,radius:this.options.pointDotRadius,display:this.options.pointDot,hitDetectionRadius:this.options.pointHitDetectionRadius,ctx:this.chart.ctx}),this.datasets=[],this.buildScale(t),this.options.showTooltips&&e.bindEvents(this,this.options.tooltipEvents,function(t){var i="mouseout"!==t.type?this.getPointsAtEvent(t):[];this.eachPoints(function(t){t.restore(["fillColor","strokeColor"])}),e.each(i,function(t){t.fillColor=t.highlightFill,t.strokeColor=t.highlightStroke}),this.showTooltip(i)}),e.each(t.datasets,function(i){var s={label:i.label||null,fillColor:i.fillColor,strokeColor:i.strokeColor,pointColor:i.pointColor,pointStrokeColor:i.pointStrokeColor,points:[]};this.datasets.push(s),e.each(i.data,function(e,n){var o;this.scale.animation||(o=this.scale.getPointPosition(n,this.scale.calculateCenterOffset(e))),s.points.push(new this.PointClass({value:e,label:t.labels[n],datasetLabel:i.label,x:this.options.animation?this.scale.xCenter:o.x,y:this.options.animation?this.scale.yCenter:o.y,strokeColor:i.pointStrokeColor,fillColor:i.pointColor,highlightFill:i.pointHighlightFill||i.pointColor,highlightStroke:i.pointHighlightStroke||i.pointStrokeColor}))},this)},this),this.render()},eachPoints:function(t){e.each(this.datasets,function(i){e.each(i.points,t,this)},this)},getPointsAtEvent:function(t){var i=e.getRelativePosition(t),s=e.getAngleFromPoint({x:this.scale.xCenter,y:this.scale.yCenter},i),n=2*Math.PI/this.scale.valuesCount,o=Math.round((s.angle-1.5*Math.PI)/n),a=[];return(o>=this.scale.valuesCount||0>o)&&(o=0),s.distance<=this.scale.drawingArea&&e.each(this.datasets,function(t){a.push(t.points[o])}),a},buildScale:function(t){this.scale=new i.RadialScale({display:this.options.showScale,fontStyle:this.options.scaleFontStyle,fontSize:this.options.scaleFontSize,fontFamily:this.options.scaleFontFamily,fontColor:this.options.scaleFontColor,showLabels:this.options.scaleShowLabels,showLabelBackdrop:this.options.scaleShowLabelBackdrop,backdropColor:this.options.scaleBackdropColor,backdropPaddingY:this.options.scaleBackdropPaddingY,backdropPaddingX:this.options.scaleBackdropPaddingX,lineWidth:this.options.scaleShowLine?this.options.scaleLineWidth:0,lineColor:this.options.scaleLineColor,angleLineColor:this.options.angleLineColor,angleLineWidth:this.options.angleShowLineOut?this.options.angleLineWidth:0,pointLabelFontColor:this.options.pointLabelFontColor,pointLabelFontSize:this.options.pointLabelFontSize,pointLabelFontFamily:this.options.pointLabelFontFamily,pointLabelFontStyle:this.options.pointLabelFontStyle,height:this.chart.height,width:this.chart.width,xCenter:this.chart.width/2,yCenter:this.chart.height/2,ctx:this.chart.ctx,templateString:this.options.scaleLabel,labels:t.labels,valuesCount:t.datasets[0].data.length}),this.scale.setScaleSize(),this.updateScaleRange(t.datasets),this.scale.buildYLabels()},updateScaleRange:function(t){var i=function(){var i=[];return e.each(t,function(t){t.data?i=i.concat(t.data):e.each(t.points,function(t){i.push(t.value)})}),i}(),s=this.options.scaleOverride?{steps:this.options.scaleSteps,stepValue:this.options.scaleStepWidth,min:this.options.scaleStartValue,max:this.options.scaleStartValue+this.options.scaleSteps*this.options.scaleStepWidth}:e.calculateScaleRange(i,e.min([this.chart.width,this.chart.height])/2,this.options.scaleFontSize,this.options.scaleBeginAtZero,this.options.scaleIntegersOnly);e.extend(this.scale,s)},addData:function(t,i){this.scale.valuesCount++,e.each(t,function(t,e){var s=this.scale.getPointPosition(this.scale.valuesCount,this.scale.calculateCenterOffset(t));this.datasets[e].points.push(new this.PointClass({value:t,label:i,x:s.x,y:s.y,strokeColor:this.datasets[e].pointStrokeColor,fillColor:this.datasets[e].pointColor}))},this),this.scale.labels.push(i),this.reflow(),this.update()},removeData:function(){this.scale.valuesCount--,this.scale.labels.shift(),e.each(this.datasets,function(t){t.points.shift()},this),this.reflow(),this.update()},update:function(){this.eachPoints(function(t){t.save()}),this.reflow(),this.render()},reflow:function(){e.extend(this.scale,{width:this.chart.width,height:this.chart.height,size:e.min([this.chart.width,this.chart.height]),xCenter:this.chart.width/2,yCenter:this.chart.height/2}),this.updateScaleRange(this.datasets),this.scale.setScaleSize(),this.scale.buildYLabels()},draw:function(t){var i=t||1,s=this.chart.ctx;this.clear(),this.scale.draw(),e.each(this.datasets,function(t){e.each(t.points,function(t,e){t.hasValue()&&t.transition(this.scale.getPointPosition(e,this.scale.calculateCenterOffset(t.value)),i)},this),s.lineWidth=this.options.datasetStrokeWidth,s.strokeStyle=t.strokeColor,s.beginPath(),e.each(t.points,function(t,i){0===i?s.moveTo(t.x,t.y):s.lineTo(t.x,t.y)},this),s.closePath(),s.stroke(),s.fillStyle=t.fillColor,s.fill(),e.each(t.points,function(t){t.hasValue()&&t.draw()})},this)}})}.call(this); \ No newline at end of file +(function(){"use strict";var t=this,i=t.Chart,e=function(t){this.canvas=t.canvas,this.ctx=t;var i=function(t,i){return t["offset"+i]?t["offset"+i]:document.defaultView.getComputedStyle(t).getPropertyValue(i)},e=this.width=i(t.canvas,"Width"),n=this.height=i(t.canvas,"Height");t.canvas.width=e,t.canvas.height=n;var e=this.width=t.canvas.width,n=this.height=t.canvas.height;return this.aspectRatio=this.width/this.height,s.retinaScale(this),this};e.defaults={global:{animation:!0,animationSteps:60,animationEasing:"easeOutQuart",showScale:!0,scaleOverride:!1,scaleSteps:null,scaleStepWidth:null,scaleStartValue:null,scaleLineColor:"rgba(0,0,0,.1)",scaleLineWidth:1,scaleShowLabels:!0,scaleLabel:"<%=value%>",scaleIntegersOnly:!0,scaleBeginAtZero:!1,scaleFontFamily:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",scaleFontSize:12,scaleFontStyle:"normal",scaleFontColor:"#666",responsive:!1,maintainAspectRatio:!0,showTooltips:!0,customTooltips:!1,tooltipEvents:["mousemove","touchstart","touchmove","mouseout"],tooltipFillColor:"rgba(0,0,0,0.8)",tooltipFontFamily:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",tooltipFontSize:14,tooltipFontStyle:"normal",tooltipFontColor:"#fff",tooltipTitleFontFamily:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",tooltipTitleFontSize:14,tooltipTitleFontStyle:"bold",tooltipTitleFontColor:"#fff",tooltipYPadding:6,tooltipXPadding:6,tooltipCaretSize:8,tooltipCornerRadius:6,tooltipXOffset:10,tooltipTemplate:"<%if (label){%><%=label%>: <%}%><%= value %>",multiTooltipTemplate:"<%= value %>",multiTooltipKeyBackground:"#fff",onAnimationProgress:function(){},onAnimationComplete:function(){}}},e.types={};var s=e.helpers={},n=s.each=function(t,i,e){var s=Array.prototype.slice.call(arguments,3);if(t)if(t.length===+t.length){var n;for(n=0;n=0;s--){var n=t[s];if(i(n))return n}},s.inherits=function(t){var i=this,e=t&&t.hasOwnProperty("constructor")?t.constructor:function(){return i.apply(this,arguments)},s=function(){this.constructor=e};return s.prototype=i.prototype,e.prototype=new s,e.extend=r,t&&a(e.prototype,t),e.__super__=i.prototype,e}),c=s.noop=function(){},u=s.uid=function(){var t=0;return function(){return"chart-"+t++}}(),d=s.warn=function(t){window.console&&"function"==typeof window.console.warn&&console.warn(t)},p=s.amd="function"==typeof define&&define.amd,f=s.isNumber=function(t){return!isNaN(parseFloat(t))&&isFinite(t)},g=s.max=function(t){return Math.max.apply(Math,t)},m=s.min=function(t){return Math.min.apply(Math,t)},v=(s.cap=function(t,i,e){if(f(i)){if(t>i)return i}else if(f(e)&&e>t)return e;return t},s.getDecimalPlaces=function(t){return t%1!==0&&f(t)?t.toString().split(".")[1].length:0}),S=s.radians=function(t){return t*(Math.PI/180)},x=(s.getAngleFromPoint=function(t,i){var e=i.x-t.x,s=i.y-t.y,n=Math.sqrt(e*e+s*s),o=2*Math.PI+Math.atan2(s,e);return 0>e&&0>s&&(o+=2*Math.PI),{angle:o,distance:n}},s.aliasPixel=function(t){return t%2===0?0:.5}),y=(s.splineCurve=function(t,i,e,s){var n=Math.sqrt(Math.pow(i.x-t.x,2)+Math.pow(i.y-t.y,2)),o=Math.sqrt(Math.pow(e.x-i.x,2)+Math.pow(e.y-i.y,2)),a=s*n/(n+o),h=s*o/(n+o);return{inner:{x:i.x-a*(e.x-t.x),y:i.y-a*(e.y-t.y)},outer:{x:i.x+h*(e.x-t.x),y:i.y+h*(e.y-t.y)}}},s.calculateOrderOfMagnitude=function(t){return Math.floor(Math.log(t)/Math.LN10)}),C=(s.calculateScaleRange=function(t,i,e,s,n){var o=2,a=Math.floor(i/(1.5*e)),h=o>=a,l=g(t),r=m(t);l===r&&(l+=.5,r>=.5&&!s?r-=.5:l+=.5);for(var c=Math.abs(l-r),u=y(c),d=Math.ceil(l/(1*Math.pow(10,u)))*Math.pow(10,u),p=s?0:Math.floor(r/(1*Math.pow(10,u)))*Math.pow(10,u),f=d-p,v=Math.pow(10,u),S=Math.round(f/v);(S>a||a>2*S)&&!h;)if(S>a)v*=2,S=Math.round(f/v),S%1!==0&&(h=!0);else if(n&&u>=0){if(v/2%1!==0)break;v/=2,S=Math.round(f/v)}else v/=2,S=Math.round(f/v);return h&&(S=o,v=f/S),{steps:S,stepValue:v,min:p,max:p+S*v}},s.template=function(t,i){function e(t,i){var e=/\W/.test(t)?new Function("obj","var p=[],print=function(){p.push.apply(p,arguments);};with(obj){p.push('"+t.replace(/[\r\t\n]/g," ").split("<%").join(" ").replace(/((^|%>)[^\t]*)'/g,"$1\r").replace(/\t=(.*?)%>/g,"',$1,'").split(" ").join("');").split("%>").join("p.push('").split("\r").join("\\'")+"');}return p.join('');"):s[t]=s[t];return i?e(i):e}if(t instanceof Function)return t(i);var s={};return e(t,i)}),w=(s.generateLabels=function(t,i,e,s){var o=new Array(i);return labelTemplateString&&n(o,function(i,n){o[n]=C(t,{value:e+s*(n+1)})}),o},s.easingEffects={linear:function(t){return t},easeInQuad:function(t){return t*t},easeOutQuad:function(t){return-1*t*(t-2)},easeInOutQuad:function(t){return(t/=.5)<1?.5*t*t:-0.5*(--t*(t-2)-1)},easeInCubic:function(t){return t*t*t},easeOutCubic:function(t){return 1*((t=t/1-1)*t*t+1)},easeInOutCubic:function(t){return(t/=.5)<1?.5*t*t*t:.5*((t-=2)*t*t+2)},easeInQuart:function(t){return t*t*t*t},easeOutQuart:function(t){return-1*((t=t/1-1)*t*t*t-1)},easeInOutQuart:function(t){return(t/=.5)<1?.5*t*t*t*t:-0.5*((t-=2)*t*t*t-2)},easeInQuint:function(t){return 1*(t/=1)*t*t*t*t},easeOutQuint:function(t){return 1*((t=t/1-1)*t*t*t*t+1)},easeInOutQuint:function(t){return(t/=.5)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2)},easeInSine:function(t){return-1*Math.cos(t/1*(Math.PI/2))+1},easeOutSine:function(t){return 1*Math.sin(t/1*(Math.PI/2))},easeInOutSine:function(t){return-0.5*(Math.cos(Math.PI*t/1)-1)},easeInExpo:function(t){return 0===t?1:1*Math.pow(2,10*(t/1-1))},easeOutExpo:function(t){return 1===t?1:1*(-Math.pow(2,-10*t/1)+1)},easeInOutExpo:function(t){return 0===t?0:1===t?1:(t/=.5)<1?.5*Math.pow(2,10*(t-1)):.5*(-Math.pow(2,-10*--t)+2)},easeInCirc:function(t){return t>=1?t:-1*(Math.sqrt(1-(t/=1)*t)-1)},easeOutCirc:function(t){return 1*Math.sqrt(1-(t=t/1-1)*t)},easeInOutCirc:function(t){return(t/=.5)<1?-0.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1)},easeInElastic:function(t){var i=1.70158,e=0,s=1;return 0===t?0:1==(t/=1)?1:(e||(e=.3),st?-.5*s*Math.pow(2,10*(t-=1))*Math.sin(2*(1*t-i)*Math.PI/e):s*Math.pow(2,-10*(t-=1))*Math.sin(2*(1*t-i)*Math.PI/e)*.5+1)},easeInBack:function(t){var i=1.70158;return 1*(t/=1)*t*((i+1)*t-i)},easeOutBack:function(t){var i=1.70158;return 1*((t=t/1-1)*t*((i+1)*t+i)+1)},easeInOutBack:function(t){var i=1.70158;return(t/=.5)<1?.5*t*t*(((i*=1.525)+1)*t-i):.5*((t-=2)*t*(((i*=1.525)+1)*t+i)+2)},easeInBounce:function(t){return 1-w.easeOutBounce(1-t)},easeOutBounce:function(t){return(t/=1)<1/2.75?7.5625*t*t:2/2.75>t?1*(7.5625*(t-=1.5/2.75)*t+.75):2.5/2.75>t?1*(7.5625*(t-=2.25/2.75)*t+.9375):1*(7.5625*(t-=2.625/2.75)*t+.984375)},easeInOutBounce:function(t){return.5>t?.5*w.easeInBounce(2*t):.5*w.easeOutBounce(2*t-1)+.5}}),b=s.requestAnimFrame=function(){return window.requestAnimationFrame||window.webkitRequestAnimationFrame||window.mozRequestAnimationFrame||window.oRequestAnimationFrame||window.msRequestAnimationFrame||function(t){return window.setTimeout(t,1e3/60)}}(),P=s.cancelAnimFrame=function(){return window.cancelAnimationFrame||window.webkitCancelAnimationFrame||window.mozCancelAnimationFrame||window.oCancelAnimationFrame||window.msCancelAnimationFrame||function(t){return window.clearTimeout(t,1e3/60)}}(),L=(s.animationLoop=function(t,i,e,s,n,o){var a=0,h=w[e]||w.linear,l=function(){a++;var e=a/i,r=h(e);t.call(o,r,e,a),s.call(o,r,e),i>a?o.animationFrame=b(l):n.apply(o)};b(l)},s.getRelativePosition=function(t){var i,e,s=t.originalEvent||t,n=t.currentTarget||t.srcElement,o=n.getBoundingClientRect();return s.touches?(i=s.touches[0].clientX-o.left,e=s.touches[0].clientY-o.top):(i=s.clientX-o.left,e=s.clientY-o.top),{x:i,y:e}},s.addEvent=function(t,i,e){t.addEventListener?t.addEventListener(i,e):t.attachEvent?t.attachEvent("on"+i,e):t["on"+i]=e}),k=s.removeEvent=function(t,i,e){t.removeEventListener?t.removeEventListener(i,e,!1):t.detachEvent?t.detachEvent("on"+i,e):t["on"+i]=c},F=(s.bindEvents=function(t,i,e){t.events||(t.events={}),n(i,function(i){t.events[i]=function(){e.apply(t,arguments)},L(t.chart.canvas,i,t.events[i])})},s.unbindEvents=function(t,i){n(i,function(i,e){k(t.chart.canvas,e,i)})}),R=s.getMaximumWidth=function(t){var i=t.parentNode;return i.clientWidth},T=s.getMaximumHeight=function(t){var i=t.parentNode;return i.clientHeight},A=(s.getMaximumSize=s.getMaximumWidth,s.retinaScale=function(t){var i=t.ctx,e=t.canvas.width,s=t.canvas.height;window.devicePixelRatio&&(i.canvas.style.width=e+"px",i.canvas.style.height=s+"px",i.canvas.height=s*window.devicePixelRatio,i.canvas.width=e*window.devicePixelRatio,i.scale(window.devicePixelRatio,window.devicePixelRatio))}),M=s.clear=function(t){t.ctx.clearRect(0,0,t.width,t.height)},W=s.fontString=function(t,i,e){return i+" "+t+"px "+e},z=s.longestText=function(t,i,e){t.font=i;var s=0;return n(e,function(i){var e=t.measureText(i).width;s=e>s?e:s}),s},B=s.drawRoundedRectangle=function(t,i,e,s,n,o){t.beginPath(),t.moveTo(i+o,e),t.lineTo(i+s-o,e),t.quadraticCurveTo(i+s,e,i+s,e+o),t.lineTo(i+s,e+n-o),t.quadraticCurveTo(i+s,e+n,i+s-o,e+n),t.lineTo(i+o,e+n),t.quadraticCurveTo(i,e+n,i,e+n-o),t.lineTo(i,e+o),t.quadraticCurveTo(i,e,i+o,e),t.closePath()};e.instances={},e.Type=function(t,i,s){this.options=i,this.chart=s,this.id=u(),e.instances[this.id]=this,i.responsive&&this.resize(),this.initialize.call(this,t)},a(e.Type.prototype,{initialize:function(){return this},clear:function(){return M(this.chart),this},stop:function(){return P(this.animationFrame),this},resize:function(t){this.stop();var i=this.chart.canvas,e=R(this.chart.canvas),s=this.options.maintainAspectRatio?e/this.chart.aspectRatio:T(this.chart.canvas);return i.width=this.chart.width=e,i.height=this.chart.height=s,A(this.chart),"function"==typeof t&&t.apply(this,Array.prototype.slice.call(arguments,1)),this},reflow:c,render:function(t){return t&&this.reflow(),this.options.animation&&!t?s.animationLoop(this.draw,this.options.animationSteps,this.options.animationEasing,this.options.onAnimationProgress,this.options.onAnimationComplete,this):(this.draw(),this.options.onAnimationComplete.call(this)),this},generateLegend:function(){return C(this.options.legendTemplate,this)},destroy:function(){this.clear(),F(this,this.events);var t=this.chart.canvas;t.width=this.chart.width,t.height=this.chart.height,t.style.removeProperty?(t.style.removeProperty("width"),t.style.removeProperty("height")):(t.style.removeAttribute("width"),t.style.removeAttribute("height")),delete e.instances[this.id]},showTooltip:function(t,i){"undefined"==typeof this.activeElements&&(this.activeElements=[]);var o=function(t){var i=!1;return t.length!==this.activeElements.length?i=!0:(n(t,function(t,e){t!==this.activeElements[e]&&(i=!0)},this),i)}.call(this,t);if(o||i){if(this.activeElements=t,this.draw(),this.options.customTooltips&&this.options.customTooltips(!1),t.length>0)if(this.datasets&&this.datasets.length>1){for(var a,h,r=this.datasets.length-1;r>=0&&(a=this.datasets[r].points||this.datasets[r].bars||this.datasets[r].segments,h=l(a,t[0]),-1===h);r--);var c=[],u=[],d=function(){var t,i,e,n,o,a=[],l=[],r=[];return s.each(this.datasets,function(i){t=i.points||i.bars||i.segments,t[h]&&t[h].hasValue()&&a.push(t[h])}),s.each(a,function(t){l.push(t.x),r.push(t.y),c.push(s.template(this.options.multiTooltipTemplate,t)),u.push({fill:t._saved.fillColor||t.fillColor,stroke:t._saved.strokeColor||t.strokeColor})},this),o=m(r),e=g(r),n=m(l),i=g(l),{x:n>this.chart.width/2?n:i,y:(o+e)/2}}.call(this,h);new e.MultiTooltip({x:d.x,y:d.y,xPadding:this.options.tooltipXPadding,yPadding:this.options.tooltipYPadding,xOffset:this.options.tooltipXOffset,fillColor:this.options.tooltipFillColor,textColor:this.options.tooltipFontColor,fontFamily:this.options.tooltipFontFamily,fontStyle:this.options.tooltipFontStyle,fontSize:this.options.tooltipFontSize,titleTextColor:this.options.tooltipTitleFontColor,titleFontFamily:this.options.tooltipTitleFontFamily,titleFontStyle:this.options.tooltipTitleFontStyle,titleFontSize:this.options.tooltipTitleFontSize,cornerRadius:this.options.tooltipCornerRadius,labels:c,legendColors:u,legendColorBackground:this.options.multiTooltipKeyBackground,title:t[0].label,chart:this.chart,ctx:this.chart.ctx,custom:this.options.customTooltips}).draw()}else n(t,function(t){var i=t.tooltipPosition();new e.Tooltip({x:Math.round(i.x),y:Math.round(i.y),xPadding:this.options.tooltipXPadding,yPadding:this.options.tooltipYPadding,fillColor:this.options.tooltipFillColor,textColor:this.options.tooltipFontColor,fontFamily:this.options.tooltipFontFamily,fontStyle:this.options.tooltipFontStyle,fontSize:this.options.tooltipFontSize,caretHeight:this.options.tooltipCaretSize,cornerRadius:this.options.tooltipCornerRadius,text:C(this.options.tooltipTemplate,t),chart:this.chart,custom:this.options.customTooltips}).draw()},this);return this}},toBase64Image:function(){return this.chart.canvas.toDataURL.apply(this.chart.canvas,arguments)}}),e.Type.extend=function(t){var i=this,s=function(){return i.apply(this,arguments)};if(s.prototype=o(i.prototype),a(s.prototype,t),s.extend=e.Type.extend,t.name||i.prototype.name){var n=t.name||i.prototype.name,l=e.defaults[i.prototype.name]?o(e.defaults[i.prototype.name]):{};e.defaults[n]=a(l,t.defaults),e.types[n]=s,e.prototype[n]=function(t,i){var o=h(e.defaults.global,e.defaults[n],i||{});return new s(t,o,this)}}else d("Name not provided for this chart, so it hasn't been registered");return i},e.Element=function(t){a(this,t),this.initialize.apply(this,arguments),this.save()},a(e.Element.prototype,{initialize:function(){},restore:function(t){return t?n(t,function(t){this[t]=this._saved[t]},this):a(this,this._saved),this},save:function(){return this._saved=o(this),delete this._saved._saved,this},update:function(t){return n(t,function(t,i){this._saved[i]=this[i],this[i]=t},this),this},transition:function(t,i){return n(t,function(t,e){this[e]=(t-this._saved[e])*i+this._saved[e]},this),this},tooltipPosition:function(){return{x:this.x,y:this.y}},hasValue:function(){return f(this.value)}}),e.Element.extend=r,e.Point=e.Element.extend({display:!0,inRange:function(t,i){var e=this.hitDetectionRadius+this.radius;return Math.pow(t-this.x,2)+Math.pow(i-this.y,2)=this.startAngle&&e.angle<=this.endAngle,o=e.distance>=this.innerRadius&&e.distance<=this.outerRadius;return n&&o},tooltipPosition:function(){var t=this.startAngle+(this.endAngle-this.startAngle)/2,i=(this.outerRadius-this.innerRadius)/2+this.innerRadius;return{x:this.x+Math.cos(t)*i,y:this.y+Math.sin(t)*i}},draw:function(t){var i=this.ctx;i.beginPath(),i.arc(this.x,this.y,this.outerRadius,this.startAngle,this.endAngle),i.arc(this.x,this.y,this.innerRadius,this.endAngle,this.startAngle,!0),i.closePath(),i.strokeStyle=this.strokeColor,i.lineWidth=this.strokeWidth,i.fillStyle=this.fillColor,i.fill(),i.lineJoin="bevel",this.showStroke&&i.stroke()}}),e.Rectangle=e.Element.extend({draw:function(){var t=this.ctx,i=this.width/2,e=this.x-i,s=this.x+i,n=this.base-(this.base-this.y),o=this.strokeWidth/2;this.showStroke&&(e+=o,s-=o,n+=o),t.beginPath(),t.fillStyle=this.fillColor,t.strokeStyle=this.strokeColor,t.lineWidth=this.strokeWidth,t.moveTo(e,this.base),t.lineTo(e,n),t.lineTo(s,n),t.lineTo(s,this.base),t.fill(),this.showStroke&&t.stroke()},height:function(){return this.base-this.y},inRange:function(t,i){return t>=this.x-this.width/2&&t<=this.x+this.width/2&&i>=this.y&&i<=this.base}}),e.Tooltip=e.Element.extend({draw:function(){var t=this.chart.ctx;t.font=W(this.fontSize,this.fontStyle,this.fontFamily),this.xAlign="center",this.yAlign="above";var i=this.caretPadding=2,e=t.measureText(this.text).width+2*this.xPadding,s=this.fontSize+2*this.yPadding,n=s+this.caretHeight+i;this.x+e/2>this.chart.width?this.xAlign="left":this.x-e/2<0&&(this.xAlign="right"),this.y-n<0&&(this.yAlign="below");var o=this.x-e/2,a=this.y-n;if(t.fillStyle=this.fillColor,this.custom)this.custom(this);else{switch(this.yAlign){case"above":t.beginPath(),t.moveTo(this.x,this.y-i),t.lineTo(this.x+this.caretHeight,this.y-(i+this.caretHeight)),t.lineTo(this.x-this.caretHeight,this.y-(i+this.caretHeight)),t.closePath(),t.fill();break;case"below":a=this.y+i+this.caretHeight,t.beginPath(),t.moveTo(this.x,this.y+i),t.lineTo(this.x+this.caretHeight,this.y+i+this.caretHeight),t.lineTo(this.x-this.caretHeight,this.y+i+this.caretHeight),t.closePath(),t.fill()}switch(this.xAlign){case"left":o=this.x-e+(this.cornerRadius+this.caretHeight);break;case"right":o=this.x-(this.cornerRadius+this.caretHeight)}B(t,o,a,e,s,this.cornerRadius),t.fill(),t.fillStyle=this.textColor,t.textAlign="center",t.textBaseline="middle",t.fillText(this.text,o+e/2,a+s/2)}}}),e.MultiTooltip=e.Element.extend({initialize:function(){this.font=W(this.fontSize,this.fontStyle,this.fontFamily),this.titleFont=W(this.titleFontSize,this.titleFontStyle,this.titleFontFamily),this.height=this.labels.length*this.fontSize+(this.labels.length-1)*(this.fontSize/2)+2*this.yPadding+1.5*this.titleFontSize,this.ctx.font=this.titleFont;var t=this.ctx.measureText(this.title).width,i=z(this.ctx,this.font,this.labels)+this.fontSize+3,e=g([i,t]);this.width=e+2*this.xPadding;var s=this.height/2;this.y-s<0?this.y=s:this.y+s>this.chart.height&&(this.y=this.chart.height-s),this.x>this.chart.width/2?this.x-=this.xOffset+this.width:this.x+=this.xOffset},getLineHeight:function(t){var i=this.y-this.height/2+this.yPadding,e=t-1;return 0===t?i+this.titleFontSize/2:i+(1.5*this.fontSize*e+this.fontSize/2)+1.5*this.titleFontSize},draw:function(){if(this.custom)this.custom(this);else{B(this.ctx,this.x,this.y-this.height/2,this.width,this.height,this.cornerRadius);var t=this.ctx;t.fillStyle=this.fillColor,t.fill(),t.closePath(),t.textAlign="left",t.textBaseline="middle",t.fillStyle=this.titleTextColor,t.font=this.titleFont,t.fillText(this.title,this.x+this.xPadding,this.getLineHeight(0)),t.font=this.font,s.each(this.labels,function(i,e){t.fillStyle=this.textColor,t.fillText(i,this.x+this.xPadding+this.fontSize+3,this.getLineHeight(e+1)),t.fillStyle=this.legendColorBackground,t.fillRect(this.x+this.xPadding,this.getLineHeight(e+1)-this.fontSize/2,this.fontSize,this.fontSize),t.fillStyle=this.legendColors[e].fill,t.fillRect(this.x+this.xPadding,this.getLineHeight(e+1)-this.fontSize/2,this.fontSize,this.fontSize)},this)}}}),e.Scale=e.Element.extend({initialize:function(){this.fit()},buildYLabels:function(){this.yLabels=[];for(var t=v(this.stepValue),i=0;i<=this.steps;i++)this.yLabels.push(C(this.templateString,{value:(this.min+i*this.stepValue).toFixed(t)}));this.yLabelWidth=this.display&&this.showLabels?z(this.ctx,this.font,this.yLabels):0},addXLabel:function(t){this.xLabels.push(t),this.valuesCount++,this.fit()},removeXLabel:function(){this.xLabels.shift(),this.valuesCount--,this.fit()},fit:function(){this.startPoint=this.display?this.fontSize:0,this.endPoint=this.display?this.height-1.5*this.fontSize-5:this.height,this.startPoint+=this.padding,this.endPoint-=this.padding;var t,i=this.endPoint-this.startPoint;for(this.calculateYRange(i),this.buildYLabels(),this.calculateXLabelRotation();i>this.endPoint-this.startPoint;)i=this.endPoint-this.startPoint,t=this.yLabelWidth,this.calculateYRange(i),this.buildYLabels(),tthis.yLabelWidth+10?e/2:this.yLabelWidth+10,this.xLabelRotation=0,this.display){var n,o=z(this.ctx,this.font,this.xLabels);this.xLabelWidth=o;for(var a=Math.floor(this.calculateX(1)-this.calculateX(0))-6;this.xLabelWidth>a&&0===this.xLabelRotation||this.xLabelWidth>a&&this.xLabelRotation<=90&&this.xLabelRotation>0;)n=Math.cos(S(this.xLabelRotation)),t=n*e,i=n*s,t+this.fontSize/2>this.yLabelWidth+8&&(this.xScalePaddingLeft=t+this.fontSize/2),this.xScalePaddingRight=this.fontSize/2,this.xLabelRotation++,this.xLabelWidth=n*o;this.xLabelRotation>0&&(this.endPoint-=Math.sin(S(this.xLabelRotation))*o+3)}else this.xLabelWidth=0,this.xScalePaddingRight=this.padding,this.xScalePaddingLeft=this.padding},calculateYRange:c,drawingArea:function(){return this.startPoint-this.endPoint},calculateY:function(t){var i=this.drawingArea()/(this.min-this.max);return this.endPoint-i*(t-this.min)},calculateX:function(t){var i=(this.xLabelRotation>0,this.width-(this.xScalePaddingLeft+this.xScalePaddingRight)),e=i/Math.max(this.valuesCount-(this.offsetGridLines?0:1),1),s=e*t+this.xScalePaddingLeft;return this.offsetGridLines&&(s+=e/2),Math.round(s)},update:function(t){s.extend(this,t),this.fit()},draw:function(){var t=this.ctx,i=(this.endPoint-this.startPoint)/this.steps,e=Math.round(this.xScalePaddingLeft);this.display&&(t.fillStyle=this.textColor,t.font=this.font,n(this.yLabels,function(n,o){var a=this.endPoint-i*o,h=Math.round(a),l=this.showHorizontalLines;t.textAlign="right",t.textBaseline="middle",this.showLabels&&t.fillText(n,e-10,a),0!==o||l||(l=!0),l&&t.beginPath(),o>0?(t.lineWidth=this.gridLineWidth,t.strokeStyle=this.gridLineColor):(t.lineWidth=this.lineWidth,t.strokeStyle=this.lineColor),h+=s.aliasPixel(t.lineWidth),l&&(t.moveTo(e,h),t.lineTo(this.width,h),t.stroke(),t.closePath()),t.lineWidth=this.lineWidth,t.strokeStyle=this.lineColor,t.beginPath(),t.moveTo(e-5,h),t.lineTo(e,h),t.stroke(),t.closePath()},this),n(this.xLabels,function(i,e){var s=this.calculateX(e)+x(this.lineWidth),n=this.calculateX(e-(this.offsetGridLines?.5:0))+x(this.lineWidth),o=this.xLabelRotation>0,a=this.showVerticalLines;0!==e||a||(a=!0),a&&t.beginPath(),e>0?(t.lineWidth=this.gridLineWidth,t.strokeStyle=this.gridLineColor):(t.lineWidth=this.lineWidth,t.strokeStyle=this.lineColor),a&&(t.moveTo(n,this.endPoint),t.lineTo(n,this.startPoint-3),t.stroke(),t.closePath()),t.lineWidth=this.lineWidth,t.strokeStyle=this.lineColor,t.beginPath(),t.moveTo(n,this.endPoint),t.lineTo(n,this.endPoint+5),t.stroke(),t.closePath(),t.save(),t.translate(s,o?this.endPoint+12:this.endPoint+8),t.rotate(-1*S(this.xLabelRotation)),t.font=this.font,t.textAlign=o?"right":"center",t.textBaseline=o?"middle":"top",t.fillText(i,0,0),t.restore()},this))}}),e.RadialScale=e.Element.extend({initialize:function(){this.size=m([this.height,this.width]),this.drawingArea=this.display?this.size/2-(this.fontSize/2+this.backdropPaddingY):this.size/2},calculateCenterOffset:function(t){var i=this.drawingArea/(this.max-this.min);return(t-this.min)*i},update:function(){this.lineArc?this.drawingArea=this.display?this.size/2-(this.fontSize/2+this.backdropPaddingY):this.size/2:this.setScaleSize(),this.buildYLabels()},buildYLabels:function(){this.yLabels=[];for(var t=v(this.stepValue),i=0;i<=this.steps;i++)this.yLabels.push(C(this.templateString,{value:(this.min+i*this.stepValue).toFixed(t)}))},getCircumference:function(){return 2*Math.PI/this.valuesCount},setScaleSize:function(){var t,i,e,s,n,o,a,h,l,r,c,u,d=m([this.height/2-this.pointLabelFontSize-5,this.width/2]),p=this.width,g=0;for(this.ctx.font=W(this.pointLabelFontSize,this.pointLabelFontStyle,this.pointLabelFontFamily),i=0;ip&&(p=t.x+s,n=i),t.x-sp&&(p=t.x+e,n=i):i>this.valuesCount/2&&t.x-e0){var s,n=e*(this.drawingArea/this.steps),o=this.yCenter-n;if(this.lineWidth>0)if(t.strokeStyle=this.lineColor,t.lineWidth=this.lineWidth,this.lineArc)t.beginPath(),t.arc(this.xCenter,this.yCenter,n,0,2*Math.PI),t.closePath(),t.stroke();else{t.beginPath();for(var a=0;a=0;i--){if(this.angleLineWidth>0){var e=this.getPointPosition(i,this.calculateCenterOffset(this.max));t.beginPath(),t.moveTo(this.xCenter,this.yCenter),t.lineTo(e.x,e.y),t.stroke(),t.closePath()}var s=this.getPointPosition(i,this.calculateCenterOffset(this.max)+5);t.font=W(this.pointLabelFontSize,this.pointLabelFontStyle,this.pointLabelFontFamily),t.fillStyle=this.pointLabelFontColor;var o=this.labels.length,a=this.labels.length/2,h=a/2,l=h>i||i>o-h,r=i===h||i===o-h;t.textAlign=0===i?"center":i===a?"center":a>i?"left":"right",t.textBaseline=r?"middle":l?"bottom":"top",t.fillText(this.labels[i],s.x,s.y)}}}}}),s.addEvent(window,"resize",function(){var t;return function(){clearTimeout(t),t=setTimeout(function(){n(e.instances,function(t){t.options.responsive&&t.resize(t.render,!0)})},50)}}()),p?define(function(){return e}):"object"==typeof module&&module.exports&&(module.exports=e),t.Chart=e,e.noConflict=function(){return t.Chart=i,e}}).call(this),function(){"use strict";var t=this,i=t.Chart,e=i.helpers,s={scaleBeginAtZero:!0,scaleShowGridLines:!0,scaleGridLineColor:"rgba(0,0,0,.05)",scaleGridLineWidth:1,scaleShowHorizontalLines:!0,scaleShowVerticalLines:!0,barShowStroke:!0,barStrokeWidth:2,barValueSpacing:5,barDatasetSpacing:1,legendTemplate:'
      <% for (var i=0; i
    • <%if(datasets[i].label){%><%=datasets[i].label%><%}%>
    • <%}%>
    '};i.Type.extend({name:"Bar",defaults:s,initialize:function(t){var s=this.options;this.ScaleClass=i.Scale.extend({offsetGridLines:!0,calculateBarX:function(t,i,e){var n=this.calculateBaseWidth(),o=this.calculateX(e)-n/2,a=this.calculateBarWidth(t);return o+a*i+i*s.barDatasetSpacing+a/2},calculateBaseWidth:function(){return this.calculateX(1)-this.calculateX(0)-2*s.barValueSpacing},calculateBarWidth:function(t){var i=this.calculateBaseWidth()-(t-1)*s.barDatasetSpacing;return i/t}}),this.datasets=[],this.options.showTooltips&&e.bindEvents(this,this.options.tooltipEvents,function(t){var i="mouseout"!==t.type?this.getBarsAtEvent(t):[];this.eachBars(function(t){t.restore(["fillColor","strokeColor"])}),e.each(i,function(t){t.fillColor=t.highlightFill,t.strokeColor=t.highlightStroke}),this.showTooltip(i)}),this.BarClass=i.Rectangle.extend({strokeWidth:this.options.barStrokeWidth,showStroke:this.options.barShowStroke,ctx:this.chart.ctx}),e.each(t.datasets,function(i){var s={label:i.label||null,fillColor:i.fillColor,strokeColor:i.strokeColor,bars:[]};this.datasets.push(s),e.each(i.data,function(e,n){s.bars.push(new this.BarClass({value:e,label:t.labels[n],datasetLabel:i.label,strokeColor:i.strokeColor,fillColor:i.fillColor,highlightFill:i.highlightFill||i.fillColor,highlightStroke:i.highlightStroke||i.strokeColor}))},this)},this),this.buildScale(t.labels),this.BarClass.prototype.base=this.scale.endPoint,this.eachBars(function(t,i,s){e.extend(t,{width:this.scale.calculateBarWidth(this.datasets.length),x:this.scale.calculateBarX(this.datasets.length,s,i),y:this.scale.endPoint}),t.save()},this),this.render()},update:function(){this.scale.update(),e.each(this.activeElements,function(t){t.restore(["fillColor","strokeColor"])}),this.eachBars(function(t){t.save()}),this.render()},eachBars:function(t){e.each(this.datasets,function(i,s){e.each(i.bars,t,this,s)},this)},getBarsAtEvent:function(t){for(var i,s=[],n=e.getRelativePosition(t),o=function(t){s.push(t.bars[i])},a=0;a<% for (var i=0; i
  • <%if(segments[i].label){%><%=segments[i].label%><%}%>
  • <%}%>'};i.Type.extend({name:"Doughnut",defaults:s,initialize:function(t){this.segments=[],this.outerRadius=(e.min([this.chart.width,this.chart.height])-this.options.segmentStrokeWidth/2)/2,this.SegmentArc=i.Arc.extend({ctx:this.chart.ctx,x:this.chart.width/2,y:this.chart.height/2}),this.options.showTooltips&&e.bindEvents(this,this.options.tooltipEvents,function(t){var i="mouseout"!==t.type?this.getSegmentsAtEvent(t):[];e.each(this.segments,function(t){t.restore(["fillColor"])}),e.each(i,function(t){t.fillColor=t.highlightColor}),this.showTooltip(i)}),this.calculateTotal(t),e.each(t,function(t,i){this.addData(t,i,!0)},this),this.render()},getSegmentsAtEvent:function(t){var i=[],s=e.getRelativePosition(t);return e.each(this.segments,function(t){t.inRange(s.x,s.y)&&i.push(t)},this),i},addData:function(t,i,e){var s=i||this.segments.length;this.segments.splice(s,0,new this.SegmentArc({value:t.value,outerRadius:this.options.animateScale?0:this.outerRadius,innerRadius:this.options.animateScale?0:this.outerRadius/100*this.options.percentageInnerCutout,fillColor:t.color,highlightColor:t.highlight||t.color,showStroke:this.options.segmentShowStroke,strokeWidth:this.options.segmentStrokeWidth,strokeColor:this.options.segmentStrokeColor,startAngle:1.5*Math.PI,circumference:this.options.animateRotate?0:this.calculateCircumference(t.value),label:t.label})),e||(this.reflow(),this.update())},calculateCircumference:function(t){return 2*Math.PI*(Math.abs(t)/this.total)},calculateTotal:function(t){this.total=0,e.each(t,function(t){this.total+=Math.abs(t.value)},this)},update:function(){this.calculateTotal(this.segments),e.each(this.activeElements,function(t){t.restore(["fillColor"])}),e.each(this.segments,function(t){t.save()}),this.render()},removeData:function(t){var i=e.isNumber(t)?t:this.segments.length-1;this.segments.splice(i,1),this.reflow(),this.update()},reflow:function(){e.extend(this.SegmentArc.prototype,{x:this.chart.width/2,y:this.chart.height/2}),this.outerRadius=(e.min([this.chart.width,this.chart.height])-this.options.segmentStrokeWidth/2)/2,e.each(this.segments,function(t){t.update({outerRadius:this.outerRadius,innerRadius:this.outerRadius/100*this.options.percentageInnerCutout})},this)},draw:function(t){var i=t?t:1;this.clear(),e.each(this.segments,function(t,e){t.transition({circumference:this.calculateCircumference(t.value),outerRadius:this.outerRadius,innerRadius:this.outerRadius/100*this.options.percentageInnerCutout},i),t.endAngle=t.startAngle+t.circumference,t.draw(),0===e&&(t.startAngle=1.5*Math.PI),e<% for (var i=0; i
  • <%if(datasets[i].label){%><%=datasets[i].label%><%}%>
  • <%}%>'};i.Type.extend({name:"Line",defaults:s,initialize:function(t){this.PointClass=i.Point.extend({strokeWidth:this.options.pointDotStrokeWidth,radius:this.options.pointDotRadius,display:this.options.pointDot,hitDetectionRadius:this.options.pointHitDetectionRadius,ctx:this.chart.ctx,inRange:function(t){return Math.pow(t-this.x,2)0&&ithis.scale.endPoint?t.controlPoints.outer.y=this.scale.endPoint:t.controlPoints.outer.ythis.scale.endPoint?t.controlPoints.inner.y=this.scale.endPoint:t.controlPoints.inner.y0&&(s.lineTo(h[h.length-1].x,this.scale.endPoint),s.lineTo(h[0].x,this.scale.endPoint),s.fillStyle=t.fillColor,s.closePath(),s.fill()),e.each(h,function(t){t.draw()})},this)}})}.call(this),function(){"use strict";var t=this,i=t.Chart,e=i.helpers,s={scaleShowLabelBackdrop:!0,scaleBackdropColor:"rgba(255,255,255,0.75)",scaleBeginAtZero:!0,scaleBackdropPaddingY:2,scaleBackdropPaddingX:2,scaleShowLine:!0,segmentShowStroke:!0,segmentStrokeColor:"#fff",segmentStrokeWidth:2,animationSteps:100,animationEasing:"easeOutBounce",animateRotate:!0,animateScale:!1,legendTemplate:'
      <% for (var i=0; i
    • <%if(segments[i].label){%><%=segments[i].label%><%}%>
    • <%}%>
    '};i.Type.extend({name:"PolarArea",defaults:s,initialize:function(t){this.segments=[],this.SegmentArc=i.Arc.extend({showStroke:this.options.segmentShowStroke,strokeWidth:this.options.segmentStrokeWidth,strokeColor:this.options.segmentStrokeColor,ctx:this.chart.ctx,innerRadius:0,x:this.chart.width/2,y:this.chart.height/2}),this.scale=new i.RadialScale({display:this.options.showScale,fontStyle:this.options.scaleFontStyle,fontSize:this.options.scaleFontSize,fontFamily:this.options.scaleFontFamily,fontColor:this.options.scaleFontColor,showLabels:this.options.scaleShowLabels,showLabelBackdrop:this.options.scaleShowLabelBackdrop,backdropColor:this.options.scaleBackdropColor,backdropPaddingY:this.options.scaleBackdropPaddingY,backdropPaddingX:this.options.scaleBackdropPaddingX,lineWidth:this.options.scaleShowLine?this.options.scaleLineWidth:0,lineColor:this.options.scaleLineColor,lineArc:!0,width:this.chart.width,height:this.chart.height,xCenter:this.chart.width/2,yCenter:this.chart.height/2,ctx:this.chart.ctx,templateString:this.options.scaleLabel,valuesCount:t.length}),this.updateScaleRange(t),this.scale.update(),e.each(t,function(t,i){this.addData(t,i,!0)},this),this.options.showTooltips&&e.bindEvents(this,this.options.tooltipEvents,function(t){var i="mouseout"!==t.type?this.getSegmentsAtEvent(t):[];e.each(this.segments,function(t){t.restore(["fillColor"])}),e.each(i,function(t){t.fillColor=t.highlightColor}),this.showTooltip(i)}),this.render()},getSegmentsAtEvent:function(t){var i=[],s=e.getRelativePosition(t);return e.each(this.segments,function(t){t.inRange(s.x,s.y)&&i.push(t)},this),i},addData:function(t,i,e){var s=i||this.segments.length;this.segments.splice(s,0,new this.SegmentArc({fillColor:t.color,highlightColor:t.highlight||t.color,label:t.label,value:t.value,outerRadius:this.options.animateScale?0:this.scale.calculateCenterOffset(t.value),circumference:this.options.animateRotate?0:this.scale.getCircumference(),startAngle:1.5*Math.PI})),e||(this.reflow(),this.update())},removeData:function(t){var i=e.isNumber(t)?t:this.segments.length-1;this.segments.splice(i,1),this.reflow(),this.update()},calculateTotal:function(t){this.total=0,e.each(t,function(t){this.total+=t.value},this),this.scale.valuesCount=this.segments.length},updateScaleRange:function(t){var i=[];e.each(t,function(t){i.push(t.value)});var s=this.options.scaleOverride?{steps:this.options.scaleSteps,stepValue:this.options.scaleStepWidth,min:this.options.scaleStartValue,max:this.options.scaleStartValue+this.options.scaleSteps*this.options.scaleStepWidth}:e.calculateScaleRange(i,e.min([this.chart.width,this.chart.height])/2,this.options.scaleFontSize,this.options.scaleBeginAtZero,this.options.scaleIntegersOnly);e.extend(this.scale,s,{size:e.min([this.chart.width,this.chart.height]),xCenter:this.chart.width/2,yCenter:this.chart.height/2})},update:function(){this.calculateTotal(this.segments),e.each(this.segments,function(t){t.save()}),this.reflow(),this.render()},reflow:function(){e.extend(this.SegmentArc.prototype,{x:this.chart.width/2,y:this.chart.height/2}),this.updateScaleRange(this.segments),this.scale.update(),e.extend(this.scale,{xCenter:this.chart.width/2,yCenter:this.chart.height/2}),e.each(this.segments,function(t){t.update({outerRadius:this.scale.calculateCenterOffset(t.value)})},this)},draw:function(t){var i=t||1;this.clear(),e.each(this.segments,function(t,e){t.transition({circumference:this.scale.getCircumference(),outerRadius:this.scale.calculateCenterOffset(t.value)},i),t.endAngle=t.startAngle+t.circumference,0===e&&(t.startAngle=1.5*Math.PI),e<% for (var i=0; i
  • <%if(datasets[i].label){%><%=datasets[i].label%><%}%>
  • <%}%>'},initialize:function(t){this.PointClass=i.Point.extend({strokeWidth:this.options.pointDotStrokeWidth,radius:this.options.pointDotRadius,display:this.options.pointDot,hitDetectionRadius:this.options.pointHitDetectionRadius,ctx:this.chart.ctx}),this.datasets=[],this.buildScale(t),this.options.showTooltips&&e.bindEvents(this,this.options.tooltipEvents,function(t){var i="mouseout"!==t.type?this.getPointsAtEvent(t):[];this.eachPoints(function(t){t.restore(["fillColor","strokeColor"])}),e.each(i,function(t){t.fillColor=t.highlightFill,t.strokeColor=t.highlightStroke}),this.showTooltip(i)}),e.each(t.datasets,function(i){var s={label:i.label||null,fillColor:i.fillColor,strokeColor:i.strokeColor,pointColor:i.pointColor,pointStrokeColor:i.pointStrokeColor,points:[]};this.datasets.push(s),e.each(i.data,function(e,n){var o;this.scale.animation||(o=this.scale.getPointPosition(n,this.scale.calculateCenterOffset(e))),s.points.push(new this.PointClass({value:e,label:t.labels[n],datasetLabel:i.label,x:this.options.animation?this.scale.xCenter:o.x,y:this.options.animation?this.scale.yCenter:o.y,strokeColor:i.pointStrokeColor,fillColor:i.pointColor,highlightFill:i.pointHighlightFill||i.pointColor,highlightStroke:i.pointHighlightStroke||i.pointStrokeColor}))},this)},this),this.render()},eachPoints:function(t){e.each(this.datasets,function(i){e.each(i.points,t,this)},this)},getPointsAtEvent:function(t){var i=e.getRelativePosition(t),s=e.getAngleFromPoint({x:this.scale.xCenter,y:this.scale.yCenter},i),n=2*Math.PI/this.scale.valuesCount,o=Math.round((s.angle-1.5*Math.PI)/n),a=[];return(o>=this.scale.valuesCount||0>o)&&(o=0),s.distance<=this.scale.drawingArea&&e.each(this.datasets,function(t){a.push(t.points[o])}),a},buildScale:function(t){this.scale=new i.RadialScale({display:this.options.showScale,fontStyle:this.options.scaleFontStyle,fontSize:this.options.scaleFontSize,fontFamily:this.options.scaleFontFamily,fontColor:this.options.scaleFontColor,showLabels:this.options.scaleShowLabels,showLabelBackdrop:this.options.scaleShowLabelBackdrop,backdropColor:this.options.scaleBackdropColor,backdropPaddingY:this.options.scaleBackdropPaddingY,backdropPaddingX:this.options.scaleBackdropPaddingX,lineWidth:this.options.scaleShowLine?this.options.scaleLineWidth:0,lineColor:this.options.scaleLineColor,angleLineColor:this.options.angleLineColor,angleLineWidth:this.options.angleShowLineOut?this.options.angleLineWidth:0,pointLabelFontColor:this.options.pointLabelFontColor,pointLabelFontSize:this.options.pointLabelFontSize,pointLabelFontFamily:this.options.pointLabelFontFamily,pointLabelFontStyle:this.options.pointLabelFontStyle,height:this.chart.height,width:this.chart.width,xCenter:this.chart.width/2,yCenter:this.chart.height/2,ctx:this.chart.ctx,templateString:this.options.scaleLabel,labels:t.labels,valuesCount:t.datasets[0].data.length}),this.scale.setScaleSize(),this.updateScaleRange(t.datasets),this.scale.buildYLabels()},updateScaleRange:function(t){var i=function(){var i=[];return e.each(t,function(t){t.data?i=i.concat(t.data):e.each(t.points,function(t){i.push(t.value)})}),i}(),s=this.options.scaleOverride?{steps:this.options.scaleSteps,stepValue:this.options.scaleStepWidth,min:this.options.scaleStartValue,max:this.options.scaleStartValue+this.options.scaleSteps*this.options.scaleStepWidth}:e.calculateScaleRange(i,e.min([this.chart.width,this.chart.height])/2,this.options.scaleFontSize,this.options.scaleBeginAtZero,this.options.scaleIntegersOnly);e.extend(this.scale,s)},addData:function(t,i){this.scale.valuesCount++,e.each(t,function(t,e){var s=this.scale.getPointPosition(this.scale.valuesCount,this.scale.calculateCenterOffset(t));this.datasets[e].points.push(new this.PointClass({value:t,label:i,x:s.x,y:s.y,strokeColor:this.datasets[e].pointStrokeColor,fillColor:this.datasets[e].pointColor}))},this),this.scale.labels.push(i),this.reflow(),this.update()},removeData:function(){this.scale.valuesCount--,this.scale.labels.shift(),e.each(this.datasets,function(t){t.points.shift()},this),this.reflow(),this.update()},update:function(){this.eachPoints(function(t){t.save()}),this.reflow(),this.render()},reflow:function(){e.extend(this.scale,{width:this.chart.width,height:this.chart.height,size:e.min([this.chart.width,this.chart.height]),xCenter:this.chart.width/2,yCenter:this.chart.height/2}),this.updateScaleRange(this.datasets),this.scale.setScaleSize(),this.scale.buildYLabels()},draw:function(t){var i=t||1,s=this.chart.ctx;this.clear(),this.scale.draw(),e.each(this.datasets,function(t){e.each(t.points,function(t,e){t.hasValue()&&t.transition(this.scale.getPointPosition(e,this.scale.calculateCenterOffset(t.value)),i)},this),s.lineWidth=this.options.datasetStrokeWidth,s.strokeStyle=t.strokeColor,s.beginPath(),e.each(t.points,function(t,i){0===i?s.moveTo(t.x,t.y):s.lineTo(t.x,t.y)},this),s.closePath(),s.stroke(),s.fillStyle=t.fillColor,s.fill(),e.each(t.points,function(t){t.hasValue()&&t.draw()})},this)}})}.call(this); \ No newline at end of file From 5cc11cfcab05b0109663757ae965ea3b1beabcd1 Mon Sep 17 00:00:00 2001 From: Robert Speicher Date: Tue, 7 Apr 2015 00:36:31 -0400 Subject: [PATCH 059/194] Lower the pointHitDetectionRadius for commit charts Fixes #2088 --- app/views/projects/graphs/commits.html.haml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/views/projects/graphs/commits.html.haml b/app/views/projects/graphs/commits.html.haml index 4a5d09b950..78b4c1923d 100644 --- a/app/views/projects/graphs/commits.html.haml +++ b/app/views/projects/graphs/commits.html.haml @@ -54,7 +54,7 @@ } ctx = $("#hour-chart").get(0).getContext("2d"); - new Chart(ctx).Line(data,{"scaleOverlay": true, responsive: true}); + new Chart(ctx).Line(data,{"scaleOverlay": true, responsive: true, pointHitDetectionRadius: 2}) data = { labels : #{@commits_per_week_days.keys.to_json}, @@ -68,7 +68,7 @@ } ctx = $("#weekday-chart").get(0).getContext("2d"); - new Chart(ctx).Line(data,{"scaleOverlay": true, responsive: true}); + new Chart(ctx).Line(data,{"scaleOverlay": true, responsive: true, pointHitDetectionRadius: 2}) data = { labels : #{@commits_per_month.keys.to_json}, @@ -82,4 +82,4 @@ } ctx = $("#month-chart").get(0).getContext("2d"); - new Chart(ctx).Line(data,{"scaleOverlay": true, responsive: true}); + new Chart(ctx).Line(data, {"scaleOverlay": true, responsive: true, pointHitDetectionRadius: 2}) From f7190e428f12a2e458bf4b1421a51ab43c88d7a9 Mon Sep 17 00:00:00 2001 From: Robert Speicher Date: Wed, 1 Apr 2015 21:50:28 -0400 Subject: [PATCH 060/194] Typo -- findAndollowLink -> findAndFollowLink --- .../shortcuts_dashboard_navigation.js.coffee | 10 ++++----- .../javascripts/shortcuts_navigation.coffee | 22 +++++++++---------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/app/assets/javascripts/shortcuts_dashboard_navigation.js.coffee b/app/assets/javascripts/shortcuts_dashboard_navigation.js.coffee index d522d9f3b9..4a05bdccdb 100644 --- a/app/assets/javascripts/shortcuts_dashboard_navigation.js.coffee +++ b/app/assets/javascripts/shortcuts_dashboard_navigation.js.coffee @@ -3,12 +3,12 @@ class @ShortcutsDashboardNavigation extends Shortcuts constructor: -> super() - Mousetrap.bind('g a', -> ShortcutsDashboardNavigation.findAndollowLink('.shortcuts-activity')) - Mousetrap.bind('g p', -> ShortcutsDashboardNavigation.findAndollowLink('.shortcuts-projects')) - Mousetrap.bind('g i', -> ShortcutsDashboardNavigation.findAndollowLink('.shortcuts-issues')) - Mousetrap.bind('g m', -> ShortcutsDashboardNavigation.findAndollowLink('.shortcuts-merge_requests')) + Mousetrap.bind('g a', -> ShortcutsDashboardNavigation.findAndFollowLink('.shortcuts-activity')) + Mousetrap.bind('g i', -> ShortcutsDashboardNavigation.findAndFollowLink('.shortcuts-issues')) + Mousetrap.bind('g m', -> ShortcutsDashboardNavigation.findAndFollowLink('.shortcuts-merge_requests')) + Mousetrap.bind('g p', -> ShortcutsDashboardNavigation.findAndFollowLink('.shortcuts-projects')) - @findAndollowLink: (selector) -> + @findAndFollowLink: (selector) -> link = $(selector).attr('href') if link window.location = link diff --git a/app/assets/javascripts/shortcuts_navigation.coffee b/app/assets/javascripts/shortcuts_navigation.coffee index e592b700e7..31895fbf2b 100644 --- a/app/assets/javascripts/shortcuts_navigation.coffee +++ b/app/assets/javascripts/shortcuts_navigation.coffee @@ -3,18 +3,18 @@ class @ShortcutsNavigation extends Shortcuts constructor: -> super() - Mousetrap.bind('g p', -> ShortcutsNavigation.findAndollowLink('.shortcuts-project')) - Mousetrap.bind('g f', -> ShortcutsNavigation.findAndollowLink('.shortcuts-tree')) - Mousetrap.bind('g c', -> ShortcutsNavigation.findAndollowLink('.shortcuts-commits')) - Mousetrap.bind('g n', -> ShortcutsNavigation.findAndollowLink('.shortcuts-network')) - Mousetrap.bind('g g', -> ShortcutsNavigation.findAndollowLink('.shortcuts-graphs')) - Mousetrap.bind('g i', -> ShortcutsNavigation.findAndollowLink('.shortcuts-issues')) - Mousetrap.bind('g m', -> ShortcutsNavigation.findAndollowLink('.shortcuts-merge_requests')) - Mousetrap.bind('g w', -> ShortcutsNavigation.findAndollowLink('.shortcuts-wiki')) - Mousetrap.bind('g s', -> ShortcutsNavigation.findAndollowLink('.shortcuts-snippets')) + Mousetrap.bind('g p', -> ShortcutsNavigation.findAndFollowLink('.shortcuts-project')) + Mousetrap.bind('g f', -> ShortcutsNavigation.findAndFollowLink('.shortcuts-tree')) + Mousetrap.bind('g c', -> ShortcutsNavigation.findAndFollowLink('.shortcuts-commits')) + Mousetrap.bind('g n', -> ShortcutsNavigation.findAndFollowLink('.shortcuts-network')) + Mousetrap.bind('g g', -> ShortcutsNavigation.findAndFollowLink('.shortcuts-graphs')) + Mousetrap.bind('g i', -> ShortcutsNavigation.findAndFollowLink('.shortcuts-issues')) + Mousetrap.bind('g m', -> ShortcutsNavigation.findAndFollowLink('.shortcuts-merge_requests')) + Mousetrap.bind('g w', -> ShortcutsNavigation.findAndFollowLink('.shortcuts-wiki')) + Mousetrap.bind('g s', -> ShortcutsNavigation.findAndFollowLink('.shortcuts-snippets')) @enabledHelp.push('.hidden-shortcut.project') - - @findAndollowLink: (selector) -> + + @findAndFollowLink: (selector) -> link = $(selector).attr('href') if link window.location = link From 2a06bec3a3b3f43ca9abd96c285b880fb113d530 Mon Sep 17 00:00:00 2001 From: Igor Kostenko Date: Tue, 7 Apr 2015 16:49:15 +0100 Subject: [PATCH 061/194] gitlab 7.9.2 --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index f0a8b9f53d..bb25bb677c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -11,7 +11,7 @@ RUN apt-get update -q \ # If the Omnibus package version below is outdated please contribute a merge request to update it. # If you run GitLab Enterprise Edition point it to a location where you have downloaded it. RUN TMP_FILE=$(mktemp); \ - wget -q -O $TMP_FILE https://downloads-packages.s3.amazonaws.com/ubuntu-14.04/gitlab_7.9.1-omnibus.1-1_amd64.deb \ + wget -q -O $TMP_FILE https://downloads-packages.s3.amazonaws.com/ubuntu-14.04/gitlab_7.9.2-omnibus-1_amd64.deb \ && dpkg -i $TMP_FILE \ && rm -f $TMP_FILE From 3143edfea6b71a6a26470e3f4a9cffa773f584ec Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Tue, 7 Apr 2015 08:58:12 -0700 Subject: [PATCH 062/194] Fix bug where Wiki pages that include a '/' were no longer accessible Closes #1363 --- CHANGELOG | 1 + app/controllers/projects/wikis_controller.rb | 6 +++- app/helpers/wiki_helper.rb | 22 ++++++++++++ features/project/wiki.feature | 24 +++++++++++++ features/steps/project/wiki.rb | 36 ++++++++++++++++++++ 5 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 app/helpers/wiki_helper.rb diff --git a/CHANGELOG b/CHANGELOG index d176ee3fa6..441bdc7f4a 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 7.10.0 (unreleased) + - Fix bug where Wiki pages that included a '/' were no longer accessible (Stan Hu) - Fix bug where error messages from Dropzone would not be displayed on the issues page (Stan Hu) - Add ability to configure Reply-To address in gitlab.yml (Stan Hu) - Fix broken side-by-side diff view on merge request page (Stan Hu) diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb index 643167947b..aeb7f0699f 100644 --- a/app/controllers/projects/wikis_controller.rb +++ b/app/controllers/projects/wikis_controller.rb @@ -5,6 +5,7 @@ class Projects::WikisController < Projects::ApplicationController before_filter :authorize_write_wiki!, only: [:edit, :create, :history] before_filter :authorize_admin_wiki!, only: :destroy before_filter :load_project_wiki + include WikiHelper def pages @wiki_pages = Kaminari.paginate_array(@project_wiki.pages).page(params[:page]).per(PER_PAGE) @@ -45,7 +46,10 @@ class Projects::WikisController < Projects::ApplicationController return render('empty') unless can?(current_user, :write_wiki, @project) if @page.update(content, format, message) - redirect_to [@project.namespace.becomes(Namespace), @project, @page], notice: 'Wiki was successfully updated.' + redirect_to( + namespace_project_wiki_path(@project.namespace, @project, @page), + notice: 'Wiki was successfully updated.' + ) else render 'edit' end diff --git a/app/helpers/wiki_helper.rb b/app/helpers/wiki_helper.rb new file mode 100644 index 0000000000..a3bc64c010 --- /dev/null +++ b/app/helpers/wiki_helper.rb @@ -0,0 +1,22 @@ +module WikiHelper + # Rails v4.1.9+ escapes all model IDs, converting slashes into %2F. The + # only way around this is to implement our own path generators. + def namespace_project_wiki_path(namespace, project, wiki_page, *args) + slug = + case wiki_page + when Symbol + wiki_page + else + wiki_page.slug + end + namespace_project_path(namespace, project) + "/wikis/#{slug}" + end + + def edit_namespace_project_wiki_path(namespace, project, wiki_page, *args) + namespace_project_wiki_path(namespace, project, wiki_page) + '/edit' + end + + def history_namespace_project_wiki_path(namespace, project, wiki_page, *args) + namespace_project_wiki_path(namespace, project, wiki_page) + '/history' + end +end diff --git a/features/project/wiki.feature b/features/project/wiki.feature index 4a8c771dda..977cd609a1 100644 --- a/features/project/wiki.feature +++ b/features/project/wiki.feature @@ -62,3 +62,27 @@ Feature: Project Wiki And I browse to wiki page with images And I click on image link Then I should see the new wiki page form + + @javascript + Scenario: New Wiki page that has a path + Given I create a New page with paths + And I click on the "Pages" button + Then I should see non-escaped link in the pages list + + @javascript + Scenario: Edit Wiki page that has a path + Given I create a New page with paths + And I click on the "Pages" button + And I edit the Wiki page with a path + Then I should see a non-escaped path + And I should see the Editing page + And I change the content + Then I should see the updated content + + @javascript + Scenario: View the page history of a Wiki page that has a path + Given I create a New page with paths + And I click on the "Pages" button + And I view the page history of a Wiki page that has a path + Then I should see a non-escaped path + And I should see the page history diff --git a/features/steps/project/wiki.rb b/features/steps/project/wiki.rb index cd7d5eac24..bb93e582a1 100644 --- a/features/steps/project/wiki.rb +++ b/features/steps/project/wiki.rb @@ -3,6 +3,7 @@ class Spinach::Features::ProjectWiki < Spinach::FeatureSteps include SharedProject include SharedNote include SharedPaths + include WikiHelper step 'I click on the Cancel button' do within(:css, ".form-actions") do @@ -123,6 +124,41 @@ class Spinach::Features::ProjectWiki < Spinach::FeatureSteps page.should have_content('Editing - image.jpg') end + step 'I create a New page with paths' do + click_on 'New Page' + fill_in 'Page slug', with: 'one/two/three' + click_on 'Build' + fill_in "wiki_content", with: 'wiki content' + click_on "Create page" + current_path.should include 'one/two/three' + end + + step 'I should see non-escaped link in the pages list' do + page.should have_xpath("//a[@href='/#{project.path_with_namespace}/wikis/one/two/three']") + end + + step 'I edit the Wiki page with a path' do + click_on 'three' + click_on 'Edit' + end + + step 'I should see a non-escaped path' do + current_path.should include 'one/two/three' + end + + step 'I should see the Editing page' do + page.should have_content('Editing') + end + + step 'I view the page history of a Wiki page that has a path' do + click_on 'three' + click_on 'Page History' + end + + step 'I should see the page history' do + page.should have_content('History for') + end + def wiki @project_wiki = ProjectWiki.new(project, current_user) end From b9069f31b98a7b6b2c1ad512c6becd1b6e70ea6c Mon Sep 17 00:00:00 2001 From: Robert Speicher Date: Mon, 6 Apr 2015 22:14:41 -0400 Subject: [PATCH 063/194] Make the New Application form's error messages visible --- app/views/admin/applications/_form.html.haml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/views/admin/applications/_form.html.haml b/app/views/admin/applications/_form.html.haml index b77d188a38..fa4e6335c7 100644 --- a/app/views/admin/applications/_form.html.haml +++ b/app/views/admin/applications/_form.html.haml @@ -1,13 +1,15 @@ = form_for [:admin, @application], url: @url, html: {class: 'form-horizontal', role: 'form'} do |f| - if application.errors.any? - .alert.alert-danger{"data-alert" => ""} - %p Whoops! Check your form for possible errors - = content_tag :div, class: "form-group#{' has-error' if application.errors[:name].present?}" do + .alert.alert-danger + %button{ type: "button", class: "close", "data-dismiss" => "alert"} × + - application.errors.full_messages.each do |msg| + %p= msg + = content_tag :div, class: 'form-group' do = f.label :name, class: 'col-sm-2 control-label' .col-sm-10 = f.text_field :name, class: 'form-control' = doorkeeper_errors_for application, :name - = content_tag :div, class: "form-group#{' has-error' if application.errors[:redirect_uri].present?}" do + = content_tag :div, class: 'form-group' do = f.label :redirect_uri, class: 'col-sm-2 control-label' .col-sm-10 = f.text_area :redirect_uri, class: 'form-control' From 45c82b3845d90cc2453d58f1d9ca82e315ae6ac3 Mon Sep 17 00:00:00 2001 From: Patricio Cano Date: Tue, 7 Apr 2015 13:18:37 -0500 Subject: [PATCH 064/194] Added 7.9.3 to CHANGELOG --- CHANGELOG | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index db3e5744b0..5a190d9b60 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -63,6 +63,8 @@ v 7.10.0 (unreleased) - Fixed link paths for HTTP and SSH on the admin project view (Jeremy Maziarz) - Fix and improve help rendering (Sullivan Sénéchal) +v 7.9.3 + - Contains no changes v 7.9.2 - Contains no changes From 56a19b8043565ea941b31746c52383db14f0b84b Mon Sep 17 00:00:00 2001 From: Patricio Cano Date: Tue, 7 Apr 2015 15:24:15 -0500 Subject: [PATCH 065/194] Added step to bump all versions (CE, EE, CI) every time there is a patch release and the reasoning behind it --- doc/release/patch.md | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/doc/release/patch.md b/doc/release/patch.md index 68156ae9c0..4c7b471785 100644 --- a/doc/release/patch.md +++ b/doc/release/patch.md @@ -35,18 +35,15 @@ git clone git@dev.gitlab.org:gitlab/release-tools.git cd release-tools ``` -Bump version in stable branch, create release tag and push to remotes: +Bump all versions in stable branch, even if the changes affect only EE, CE, or CI. Since all the versions are synced now, +it doesn't make sense to say upgrade CE to 7.2, EE to 7.3 and CI to 7.1. + +Create release tag and push to remotes: ``` bundle exec rake release["x.x.x"] ``` -Or if you need to release only EE: - -``` -CE=false be rake release['x.x.x'] -``` - ### Release 1. [Build new packages with the latest version](https://gitlab.com/gitlab-org/omnibus-gitlab/blob/master/doc/release.md) From 87140384109095612f527e947737cc7d65c3ff9d Mon Sep 17 00:00:00 2001 From: Robert Speicher Date: Tue, 7 Apr 2015 17:46:00 -0400 Subject: [PATCH 066/194] Improve wording below signup box [ci skip] --- app/views/devise/shared/_signup_box.html.haml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml index dcf60c9043..9dc6aeffd5 100644 --- a/app/views/devise/shared/_signup_box.html.haml +++ b/app/views/devise/shared/_signup_box.html.haml @@ -22,5 +22,6 @@ .clearfix.prepend-top-20 %p - %span.light Did not receive confirmation email? - = link_to "Send again", new_confirmation_path(resource_name) \ No newline at end of file + %span.light Didn't receive a confirmation email? + = succeed '.' do + = link_to "Request a new one", new_confirmation_path(resource_name) From a332e7bd027d8d7f7f76f85d02c1594a4b5eb9c9 Mon Sep 17 00:00:00 2001 From: Patricio Cano Date: Tue, 7 Apr 2015 19:27:04 -0500 Subject: [PATCH 067/194] Implemented changes suggested by Job. --- doc/workflow/forking_workflow.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/doc/workflow/forking_workflow.md b/doc/workflow/forking_workflow.md index 2c680a4c12..16a17290cb 100644 --- a/doc/workflow/forking_workflow.md +++ b/doc/workflow/forking_workflow.md @@ -12,23 +12,24 @@ of the screen, close to the project's URL and right next to the stars button. ![Fork button](forking/fork_button.png) -Once you do that you will see a screen where you can choose the namespace, to where you want to add the fork. This page -will contain the groups you have write access to. Choose one of the groups and the project will be added there. +Once you do that you'll be presented with a screen where you can choose the namespace to fork to. Only namespaces +(groups and your own namespace) where you have write access to, will be shown. Click on the namespace to create your +fork there. ![Groups view](forking/groups.png) -After the forking is done, you can start working on the newly created repository. There you will have full Owner access, -so you can set it up as you please. +After the forking is done, you can start working on the newly created repository. There you will have full +[Owner](../permissions/permissions.md) access, so you can set it up as you please. ## Merging upstream -Once you are ready to send your code back to the main project, you need to create a Merge request. Choose your forked +Once you are ready to send your code back to the main project, you need to create a merge request. Choose your forked project's main branch as the source and the original project's main branch as the destination and create the merge request. ![Selecting branches](forking/branch_select.png) -You can then assign the Merge Request to someone so they can review your changes. After they have reviewed them, the will -be added to the main project, if maintainer chooses to do so. +You can then assign the merge request to someone to have them review your changes. Upon pressing the 'Accept Merge Request' +button, your changes will be added to the repository and branch you're merging into. ![New merge request](forking/merge_request.png) From 3769e41ad893769d85f5c822049649584f319747 Mon Sep 17 00:00:00 2001 From: Patricio Cano Date: Tue, 7 Apr 2015 19:37:56 -0500 Subject: [PATCH 068/194] Corrected title of forking workflow --- doc/workflow/forking_workflow.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/workflow/forking_workflow.md b/doc/workflow/forking_workflow.md index 16a17290cb..8edf7c6ab3 100644 --- a/doc/workflow/forking_workflow.md +++ b/doc/workflow/forking_workflow.md @@ -1,4 +1,4 @@ -# Code forking workflow +# Project forking workflow Forking a project to your own namespace is useful if you have no write access to the project you want to contribute to. If you do have write access or can request it we recommend working together in the same repository since it is simpler. From 2794e57beb47677e51a40935dd59f7793de5ae85 Mon Sep 17 00:00:00 2001 From: "J. Daniel Schmidt" Date: Wed, 8 Apr 2015 10:51:36 +0200 Subject: [PATCH 069/194] clean_path should not drop the last character Fixes https://github.com/gitlabhq/gitlabhq/issues/9098 --- app/models/namespace.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/namespace.rb b/app/models/namespace.rb index dd74165f88..fcce8b46df 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -62,7 +62,7 @@ class Namespace < ActiveRecord::Base path.gsub!(/@.*\z/, "") path.gsub!(/\.git\z/, "") path.gsub!(/\A-/, "") - path.gsub!(/.\z/, "") + path.gsub!(/\.\z/, "") path.gsub!(/[^a-zA-Z0-9_\-\.]/, "") counter = 0 From 706be563179c8842a9370b40c52a79256534d2a5 Mon Sep 17 00:00:00 2001 From: "J. Daniel Schmidt" Date: Wed, 8 Apr 2015 15:50:38 +0200 Subject: [PATCH 070/194] clean_path should drop all trailing dots, not just one --- app/models/namespace.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/namespace.rb b/app/models/namespace.rb index fcce8b46df..8d9a85750e 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -62,7 +62,7 @@ class Namespace < ActiveRecord::Base path.gsub!(/@.*\z/, "") path.gsub!(/\.git\z/, "") path.gsub!(/\A-/, "") - path.gsub!(/\.\z/, "") + path.gsub!(/\.+\z/, "") path.gsub!(/[^a-zA-Z0-9_\-\.]/, "") counter = 0 From d03cec0e697418482890f943dd5424e7a8a170be Mon Sep 17 00:00:00 2001 From: "J. Daniel Schmidt" Date: Wed, 8 Apr 2015 15:53:07 +0200 Subject: [PATCH 071/194] clean_path should drop all leading dashes, not just one --- app/models/namespace.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 8d9a85750e..a0d79d7e5c 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -61,7 +61,7 @@ class Namespace < ActiveRecord::Base def clean_path(path) path.gsub!(/@.*\z/, "") path.gsub!(/\.git\z/, "") - path.gsub!(/\A-/, "") + path.gsub!(/\A-+/, "") path.gsub!(/\.+\z/, "") path.gsub!(/[^a-zA-Z0-9_\-\.]/, "") From 57afaf9d92eb7d34d51d89ba7af350531f2e0fde Mon Sep 17 00:00:00 2001 From: Robert Speicher Date: Wed, 8 Apr 2015 14:26:04 -0400 Subject: [PATCH 072/194] Upon successful login, clear `reset_password_token` field Closes #1942 --- app/controllers/sessions_controller.rb | 8 ++++- spec/features/users_spec.rb | 43 ++++++++++++++++++++------ 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 7b6982c507..3f11d7afe6 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -26,6 +26,12 @@ class SessionsController < Devise::SessionsController end def create - super + super do |resource| + # User has successfully signed in, so clear any unused reset tokens + if resource.reset_password_token.present? + resource.update_attributes(reset_password_token: nil, + reset_password_sent_at: nil) + end + end end end diff --git a/spec/features/users_spec.rb b/spec/features/users_spec.rb index 21a3a4bf93..4cfaab03ca 100644 --- a/spec/features/users_spec.rb +++ b/spec/features/users_spec.rb @@ -1,14 +1,37 @@ require 'spec_helper' -describe 'Users', feature: true do - describe "GET /users/sign_in" do - it "should create a new user account" do - visit new_user_session_path - fill_in "user_name", with: "Name Surname" - fill_in "user_username", with: "Great" - fill_in "user_email", with: "name@mail.com" - fill_in "user_password_sign_up", with: "password1234" - expect { click_button "Sign up" }.to change { User.count }.by(1) - end +feature 'Users' do + around do |ex| + old_url_options = Rails.application.routes.default_url_options + Rails.application.routes.default_url_options = { host: 'example.foo' } + ex.run + Rails.application.routes.default_url_options = old_url_options + end + + scenario 'GET /users/sign_in creates a new user account' do + visit new_user_session_path + fill_in 'user_name', with: 'Name Surname' + fill_in 'user_username', with: 'Great' + fill_in 'user_email', with: 'name@mail.com' + fill_in 'user_password_sign_up', with: 'password1234' + expect { click_button 'Sign up' }.to change { User.count }.by(1) + end + + scenario 'Successful user signin invalidates password reset token' do + user = create(:user) + expect(user.reset_password_token).to be_nil + + visit new_user_password_path + fill_in 'user_email', with: user.email + click_button 'Reset password' + + user.reload + expect(user.reset_password_token).not_to be_nil + + login_with(user) + expect(current_path).to eq root_path + + user.reload + expect(user.reset_password_token).to be_nil end end From 6848312f3758dd895310b8367079dfd8a45903a2 Mon Sep 17 00:00:00 2001 From: Patricio Cano Date: Wed, 8 Apr 2015 16:31:27 -0500 Subject: [PATCH 073/194] Corrected title of forking workflow on the README link. --- doc/workflow/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/workflow/README.md b/doc/workflow/README.md index 7e3fc49f70..7e996dc47d 100644 --- a/doc/workflow/README.md +++ b/doc/workflow/README.md @@ -1,7 +1,7 @@ # Workflow - [Feature branch workflow](workflow.md) -- [Code forking workflow](forking_workflow.md) +- [Project forking workflow](forking_workflow.md) - [Project Features](project_features.md) - [Authorization for merge requests](authorization_for_merge_requests.md) - [Groups](groups.md) From 3d2c3878408bd27af64293a22eff6857ad9b38dd Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Mon, 30 Mar 2015 23:53:42 -0700 Subject: [PATCH 074/194] Move current user to the top of the list in author/assignee filters Closes #1321 --- CHANGELOG | 1 + app/assets/javascripts/users_select.js.coffee | 8 ++++++++ app/helpers/selects_helper.rb | 2 ++ app/views/projects/issues/_issue_context.html.haml | 2 +- app/views/shared/_issuable_filter.html.haml | 4 ++-- features/project/issues/issues.feature | 6 ++++++ features/steps/project/issues/issues.rb | 12 ++++++++++++ features/steps/shared/project.rb | 7 +++++++ 8 files changed, 39 insertions(+), 3 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 0878c03207..702fdfe620 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -4,6 +4,7 @@ v 7.10.0 (unreleased) - Fix bug where Wiki pages that included a '/' were no longer accessible (Stan Hu) - Fix bug where error messages from Dropzone would not be displayed on the issues page (Stan Hu) - Add ability to configure Reply-To address in gitlab.yml (Stan Hu) + - Move current user to the top of the list in assignee/author filters (Stan Hu) - Fix broken side-by-side diff view on merge request page (Stan Hu) - Set Application controller default URL options to ensure all url_for calls are consistent (Stan Hu) - Allow HTML tags in Markdown input diff --git a/app/assets/javascripts/users_select.js.coffee b/app/assets/javascripts/users_select.js.coffee index f464067686..ccd85f2455 100644 --- a/app/assets/javascripts/users_select.js.coffee +++ b/app/assets/javascripts/users_select.js.coffee @@ -8,6 +8,7 @@ class @UsersSelect @groupId = $(select).data('group-id') showNullUser = $(select).data('null-user') showAnyUser = $(select).data('any-user') + firstUser = $(select).data('first-user') $(select).select2 placeholder: "Search for a user" @@ -32,6 +33,13 @@ class @UsersSelect id: 0 } + if firstUser + # Move current user to the front of the list + for obj, index in data.results + if obj.username == firstUser + data.results.splice(index, 1) + data.results.unshift(obj) + break if showNullUser data.results.unshift(nullUser) if showAnyUser diff --git a/app/helpers/selects_helper.rb b/app/helpers/selects_helper.rb index 457cd3fa46..54e0f4f9b3 100644 --- a/app/helpers/selects_helper.rb +++ b/app/helpers/selects_helper.rb @@ -8,12 +8,14 @@ module SelectsHelper null_user = opts[:null_user] || false any_user = opts[:any_user] || false + first_user = opts[:first_user] && current_user ? current_user.username : false html = { class: css_class, 'data-placeholder' => placeholder, 'data-null-user' => null_user, 'data-any-user' => any_user, + 'data-first-user' => first_user } unless opts[:scope] == :all diff --git a/app/views/projects/issues/_issue_context.html.haml b/app/views/projects/issues/_issue_context.html.haml index 52e3805041..9228074d83 100644 --- a/app/views/projects/issues/_issue_context.html.haml +++ b/app/views/projects/issues/_issue_context.html.haml @@ -8,7 +8,7 @@ - else none - if can?(current_user, :modify_issue, @issue) - = users_select_tag('issue[assignee_id]', placeholder: 'Select assignee', class: 'custom-form-control js-select2 js-assignee', selected: @issue.assignee_id, null_user: true) + = users_select_tag('issue[assignee_id]', placeholder: 'Select assignee', class: 'custom-form-control js-select2 js-assignee', selected: @issue.assignee_id, null_user: true, first_user: true) %div.prepend-top-20.clearfix .issuable-context-title diff --git a/app/views/shared/_issuable_filter.html.haml b/app/views/shared/_issuable_filter.html.haml index f169733f2e..83f5a3a801 100644 --- a/app/views/shared/_issuable_filter.html.haml +++ b/app/views/shared/_issuable_filter.html.haml @@ -24,11 +24,11 @@ .issues-other-filters .filter-item.inline = users_select_tag(:assignee_id, selected: params[:assignee_id], - placeholder: 'Assignee', class: 'trigger-submit', any_user: true, null_user: true) + placeholder: 'Assignee', class: 'trigger-submit', any_user: true, null_user: true, first_user: true) .filter-item.inline = users_select_tag(:author_id, selected: params[:author_id], - placeholder: 'Author', class: 'trigger-submit', any_user: true) + placeholder: 'Author', class: 'trigger-submit', any_user: true, first_user: true) .filter-item.inline.milestone-filter = select_tag('milestone_id', projects_milestones_options, class: "select2 trigger-submit", prompt: 'Milestone') diff --git a/features/project/issues/issues.feature b/features/project/issues/issues.feature index af01c7058e..eb813884d1 100644 --- a/features/project/issues/issues.feature +++ b/features/project/issues/issues.feature @@ -25,6 +25,12 @@ Feature: Project Issues Given I click link "Release 0.4" Then I should see issue "Release 0.4" + @javascript + Scenario: I visit issue page + Given I add a user to project "Shop" + And I click "author" dropdown + Then I see current user as the first user + Scenario: I submit new unassigned issue Given I click link "New Issue" And I submit new issue "500 error on profile" diff --git a/features/steps/project/issues/issues.rb b/features/steps/project/issues/issues.rb index e2834d51a2..b8e282b202 100644 --- a/features/steps/project/issues/issues.rb +++ b/features/steps/project/issues/issues.rb @@ -59,6 +59,18 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps click_link "New Issue" end + step 'I click "author" dropdown' do + first('.ajax-users-select').click + end + + step 'I see current user as the first user' do + expect(page).to have_selector('.user-result', visible: true, count: 4) + users = page.all('.user-name') + users[0].text.should == 'Any' + users[1].text.should == 'Unassigned' + users[2].text.should == current_user.name + end + step 'I submit new issue "500 error on profile"' do fill_in "issue_title", with: "500 error on profile" click_button "Submit new issue" diff --git a/features/steps/shared/project.rb b/features/steps/shared/project.rb index 41f71ae29c..b60ac5e342 100644 --- a/features/steps/shared/project.rb +++ b/features/steps/shared/project.rb @@ -14,6 +14,13 @@ module SharedProject @project.team << [@user, :master] end + # Add another user to project "Shop" + step 'I add a user to project "Shop"' do + @project = Project.find_by(name: "Shop") + other_user = create(:user, name: 'Alpha') + @project.team << [other_user, :master] + end + # Create another specific project called "Forum" step 'I own project "Forum"' do @project = Project.find_by(name: "Forum") From eabbcf4ea84a4630f817716d18170d60e2b250cb Mon Sep 17 00:00:00 2001 From: Dmitriy Zaporozhets Date: Thu, 9 Apr 2015 13:31:44 +0300 Subject: [PATCH 075/194] Bump gitlab-shell Signed-off-by: Dmitriy Zaporozhets --- CHANGELOG | 1 + GITLAB_SHELL_VERSION | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index 0878c03207..40293c5a68 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -64,6 +64,7 @@ v 7.10.0 (unreleased) - Fixed link paths for HTTP and SSH on the admin project view (Jeremy Maziarz) - Fix and improve help rendering (Sullivan Sénéchal) - Fix final line in EmailsOnPush email diff being rendered as error. + - Fix git over ssh errors 'fatal: protocol error: bad line length character' v 7.9.3 - Contains no changes diff --git a/GITLAB_SHELL_VERSION b/GITLAB_SHELL_VERSION index e70b4523ae..6a6a3d8e35 100644 --- a/GITLAB_SHELL_VERSION +++ b/GITLAB_SHELL_VERSION @@ -1 +1 @@ -2.6.0 +2.6.1 From e28bc41f762a4bf00d20f5fd61bb30542ee74c10 Mon Sep 17 00:00:00 2001 From: Valery Sizov Date: Thu, 9 Apr 2015 13:53:08 +0300 Subject: [PATCH 076/194] update changelog --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index c67ce17dc3..877dd7b31f 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -56,6 +56,7 @@ v 7.10.0 (unreleased) - Fix "Hello @username." references not working by no longer allowing usernames to end in period. - Archive repositories in background worker. - Import GitHub, Bitbucket or GitLab.com projects owned by authenticated user into current namespace. + - Authometic setup GitLab CI project for forks if origin project has GitLab CI enabled v 7.9.2 From a3601d6005ff4a5f109444e7ab5333d0c229d3ed Mon Sep 17 00:00:00 2001 From: Robert Speicher Date: Thu, 9 Apr 2015 12:21:59 -0400 Subject: [PATCH 077/194] Revert "Preload lib/" This reverts commit 5511a731bcc05034abacd4ec01ff7d5a2a3b89d3. --- config/initializers/2_app.rb | 5 ----- 1 file changed, 5 deletions(-) diff --git a/config/initializers/2_app.rb b/config/initializers/2_app.rb index 655590dff0..688cdf5f4b 100644 --- a/config/initializers/2_app.rb +++ b/config/initializers/2_app.rb @@ -6,8 +6,3 @@ module Gitlab Settings end end - -# -# Load all libs for threadsafety -# -Dir["#{Rails.root}/lib/**/*.rb"].each { |file| require file } From 2cefdbb535a2883f514e1b3482eefbe072976be3 Mon Sep 17 00:00:00 2001 From: Robert Speicher Date: Thu, 9 Apr 2015 13:36:05 -0400 Subject: [PATCH 078/194] Move lib/gitlab/oauth to lib/gitlab/o_auth Lets Rails autoload these files by name --- lib/gitlab/ldap/user.rb | 2 +- lib/gitlab/{oauth => o_auth}/auth_hash.rb | 0 lib/gitlab/{oauth => o_auth}/user.rb | 0 spec/lib/gitlab/{oauth => o_auth}/auth_hash_spec.rb | 0 spec/lib/gitlab/{oauth => o_auth}/user_spec.rb | 0 5 files changed, 1 insertion(+), 1 deletion(-) rename lib/gitlab/{oauth => o_auth}/auth_hash.rb (100%) rename lib/gitlab/{oauth => o_auth}/user.rb (100%) rename spec/lib/gitlab/{oauth => o_auth}/auth_hash_spec.rb (100%) rename spec/lib/gitlab/{oauth => o_auth}/user_spec.rb (100%) diff --git a/lib/gitlab/ldap/user.rb b/lib/gitlab/ldap/user.rb index cfa8692659..b04f5b4ac3 100644 --- a/lib/gitlab/ldap/user.rb +++ b/lib/gitlab/ldap/user.rb @@ -1,4 +1,4 @@ -require 'gitlab/oauth/user' +require 'gitlab/o_auth/user' # LDAP extension for User model # diff --git a/lib/gitlab/oauth/auth_hash.rb b/lib/gitlab/o_auth/auth_hash.rb similarity index 100% rename from lib/gitlab/oauth/auth_hash.rb rename to lib/gitlab/o_auth/auth_hash.rb diff --git a/lib/gitlab/oauth/user.rb b/lib/gitlab/o_auth/user.rb similarity index 100% rename from lib/gitlab/oauth/user.rb rename to lib/gitlab/o_auth/user.rb diff --git a/spec/lib/gitlab/oauth/auth_hash_spec.rb b/spec/lib/gitlab/o_auth/auth_hash_spec.rb similarity index 100% rename from spec/lib/gitlab/oauth/auth_hash_spec.rb rename to spec/lib/gitlab/o_auth/auth_hash_spec.rb diff --git a/spec/lib/gitlab/oauth/user_spec.rb b/spec/lib/gitlab/o_auth/user_spec.rb similarity index 100% rename from spec/lib/gitlab/oauth/user_spec.rb rename to spec/lib/gitlab/o_auth/user_spec.rb From 91c39fd8c70e45aeb2d39f4f2b08164d01300b89 Mon Sep 17 00:00:00 2001 From: Robert Speicher Date: Thu, 9 Apr 2015 13:56:38 -0400 Subject: [PATCH 079/194] Add lib/gitlab.rb to autoload non-conventional paths --- config/initializers/1_settings.rb | 2 ++ lib/gitlab.rb | 5 +++++ 2 files changed, 7 insertions(+) create mode 100644 lib/gitlab.rb diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index de79595d03..e6b00c531a 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -1,3 +1,5 @@ +require 'gitlab' # Load lib/gitlab.rb as soon as possible + class Settings < Settingslogic source ENV.fetch('GITLAB_CONFIG') { "#{Rails.root}/config/gitlab.yml" } namespace Rails.env diff --git a/lib/gitlab.rb b/lib/gitlab.rb new file mode 100644 index 0000000000..5fc1862c3e --- /dev/null +++ b/lib/gitlab.rb @@ -0,0 +1,5 @@ +require 'gitlab/git' + +module Gitlab + autoload :Satellite, 'gitlab/satellite/satellite' +end From abae9b8d63d9c8ec953ce8df2167b52365e7e16c Mon Sep 17 00:00:00 2001 From: Robert Speicher Date: Thu, 9 Apr 2015 13:57:10 -0400 Subject: [PATCH 080/194] Add autoloads for lib/gitlab/satellite/files/* These files don't match their naming convention, but for organizational purposes it makes sense. --- lib/gitlab/satellite/satellite.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/gitlab/satellite/satellite.rb b/lib/gitlab/satellite/satellite.rb index f24c6199c4..398643d68d 100644 --- a/lib/gitlab/satellite/satellite.rb +++ b/lib/gitlab/satellite/satellite.rb @@ -1,5 +1,10 @@ module Gitlab module Satellite + autoload :DeleteFileAction, 'gitlab/satellite/files/delete_file_action' + autoload :EditFileAction, 'gitlab/satellite/files/edit_file_action' + autoload :FileAction, 'gitlab/satellite/files/file_action' + autoload :NewFileAction, 'gitlab/satellite/files/new_file_action' + class CheckoutFailed < StandardError; end class CommitFailed < StandardError; end class PushFailed < StandardError; end From c9a1634f3183be512e4b94c140a83fa467c238ee Mon Sep 17 00:00:00 2001 From: Robert Speicher Date: Thu, 9 Apr 2015 15:34:55 -0400 Subject: [PATCH 081/194] Move lib/gitlab/contributors to its correct path --- lib/gitlab/{contributors.rb => contributor.rb} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename lib/gitlab/{contributors.rb => contributor.rb} (100%) diff --git a/lib/gitlab/contributors.rb b/lib/gitlab/contributor.rb similarity index 100% rename from lib/gitlab/contributors.rb rename to lib/gitlab/contributor.rb From b142d449c6dbefbf16a55d53bd28867a682f341a Mon Sep 17 00:00:00 2001 From: Robert Speicher Date: Wed, 8 Apr 2015 12:24:06 -0400 Subject: [PATCH 082/194] Update redcarpet to 3.2.3 --- Gemfile | 2 +- Gemfile.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile b/Gemfile index cce1f47ef1..a3720ce17f 100644 --- a/Gemfile +++ b/Gemfile @@ -94,7 +94,7 @@ gem 'html-pipeline-gitlab', '~> 0.1' gem "github-markup" # Required markup gems by github-markdown -gem 'redcarpet', '~> 3.1.2' +gem 'redcarpet', '~> 3.2.3' gem 'RedCloth' gem 'rdoc', '~>3.6' gem 'org-ruby', '= 0.9.12' diff --git a/Gemfile.lock b/Gemfile.lock index d7a292d4e5..747dbe1b03 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -457,7 +457,7 @@ GEM ffi (>= 0.5.0) rdoc (3.12.2) json (~> 1.4) - redcarpet (3.1.2) + redcarpet (3.2.3) redis (3.1.0) redis-actionpack (4.0.0) actionpack (~> 4) @@ -755,7 +755,7 @@ DEPENDENCIES rb-fsevent rb-inotify rdoc (~> 3.6) - redcarpet (~> 3.1.2) + redcarpet (~> 3.2.3) redis-rails request_store rspec-rails (= 2.99) From 13313d9e31b0d32dad925cae378d4f8ff8abcecf Mon Sep 17 00:00:00 2001 From: Robert Speicher Date: Wed, 8 Apr 2015 12:35:57 -0400 Subject: [PATCH 083/194] Disable RedCarpet's `escape_html` option This option defaults to true in RedCarpet 3.2.0, but we handle sanitization later in the process with html-pipeline. Closes #2211 --- app/helpers/gitlab_markdown_helper.rb | 38 +++++++++++++++------------ 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/app/helpers/gitlab_markdown_helper.rb b/app/helpers/gitlab_markdown_helper.rb index 7ca3f05863..17266656a4 100644 --- a/app/helpers/gitlab_markdown_helper.rb +++ b/app/helpers/gitlab_markdown_helper.rb @@ -31,24 +31,28 @@ module GitlabMarkdownHelper def markdown(text, options={}) unless @markdown && options == @options @options = options - gitlab_renderer = Redcarpet::Render::GitlabHTML.new(self, - user_color_scheme_class, - { - # see https://github.com/vmg/redcarpet#darling-i-packed-you-a-couple-renderers-for-lunch- - with_toc_data: true, - safe_links_only: true - }.merge(options)) - @markdown = Redcarpet::Markdown.new(gitlab_renderer, - # see https://github.com/vmg/redcarpet#and-its-like-really-simple-to-use - no_intra_emphasis: true, - tables: true, - fenced_code_blocks: true, - autolink: true, - strikethrough: true, - lax_spacing: true, - space_after_headers: true, - superscript: true) + + # see https://github.com/vmg/redcarpet#darling-i-packed-you-a-couple-renderers-for-lunch + rend = Redcarpet::Render::GitlabHTML.new(self, user_color_scheme_class, { + with_toc_data: true, + safe_links_only: true, + # Handled further down the line by HTML::Pipeline::SanitizationFilter + escape_html: false + }.merge(options)) + + # see https://github.com/vmg/redcarpet#and-its-like-really-simple-to-use + @markdown = Redcarpet::Markdown.new(rend, + no_intra_emphasis: true, + tables: true, + fenced_code_blocks: true, + autolink: true, + strikethrough: true, + lax_spacing: true, + space_after_headers: true, + superscript: true + ) end + @markdown.render(text).html_safe end From 5a0ff2f52f3fbfa32b09e342552f03f851f4365b Mon Sep 17 00:00:00 2001 From: Robert Speicher Date: Wed, 8 Apr 2015 16:19:05 -0400 Subject: [PATCH 084/194] Be more explicit about which "Markdown" ReferenceExtractor includes --- lib/gitlab/reference_extractor.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/gitlab/reference_extractor.rb b/lib/gitlab/reference_extractor.rb index 1058d4c43d..c0419585c4 100644 --- a/lib/gitlab/reference_extractor.rb +++ b/lib/gitlab/reference_extractor.rb @@ -3,7 +3,7 @@ module Gitlab class ReferenceExtractor attr_accessor :users, :labels, :issues, :merge_requests, :snippets, :commits, :commit_ranges - include Markdown + include ::Gitlab::Markdown def initialize @users, @labels, @issues, @merge_requests, @snippets, @commits, @commit_ranges = From 7d089701f1a9c15117e3e2d6f21eabaf08659ff6 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Thu, 9 Apr 2015 22:07:32 -0700 Subject: [PATCH 085/194] Fix broken file browsing with a submodule that has a relative link Closes #775 --- CHANGELOG | 1 + app/helpers/submodule_helper.rb | 19 ++++++++++----- spec/helpers/submodule_helper_spec.rb | 35 +++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 0878c03207..7e74c9b253 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 7.10.0 (unreleased) + - Fix broken file browsing with a submodule that contains a relative link (Stan Hu) - Fix bug where Wiki pages that included a '/' were no longer accessible (Stan Hu) - Fix bug where error messages from Dropzone would not be displayed on the issues page (Stan Hu) - Add ability to configure Reply-To address in gitlab.yml (Stan Hu) diff --git a/app/helpers/submodule_helper.rb b/app/helpers/submodule_helper.rb index 241462e5e4..99231084cf 100644 --- a/app/helpers/submodule_helper.rb +++ b/app/helpers/submodule_helper.rb @@ -53,15 +53,22 @@ module SubmoduleHelper end def relative_self_links(url, commit) - if url.scan(/(\.\.\/)/).size == 2 - base = url[/([^\/]*\/[^\/]*)\.git/, 1] - else - base = [ @project.group.path, '/', url[/([^\/]*)\.git/, 1] ].join('') + # Map relative links to a namespace and project + # For example: + # ../bar.git -> same namespace, repo bar + # ../foo/bar.git -> namespace foo, repo bar + # ../../foo/bar/baz.git -> namespace bar, repo baz + components = url.split('/') + base = components.pop.gsub(/.git$/, '') + namespace = components.pop.gsub(/^\.\.$/, '') + + if namespace.empty? + namespace = @project.group.path end [ - namespace_project_path(base.namespace, base), - namespace_project_tree_path(base.namespace, base, commit) + namespace_project_path(namespace, base), + namespace_project_tree_path(namespace, base, commit) ] end end diff --git a/spec/helpers/submodule_helper_spec.rb b/spec/helpers/submodule_helper_spec.rb index aef1108e33..e99c3f5bc1 100644 --- a/spec/helpers/submodule_helper_spec.rb +++ b/spec/helpers/submodule_helper_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe SubmoduleHelper do + include RepoHelpers + describe 'submodule links' do let(:submodule_item) { double(id: 'hash', path: 'rack') } let(:config) { Gitlab.config.gitlab } @@ -111,6 +113,39 @@ describe SubmoduleHelper do expect(submodule_links(submodule_item)).to eq([ repo.submodule_url_for, nil ]) end end + + context 'submodules with relative links' do + let(:group) { create(:group) } + let(:project) { create(:project, group: group) } + + before do + self.instance_variable_set(:@project, project) + end + + it 'one level down' do + commit_id = sample_commit[:id] + result = relative_self_links('../test.git', commit_id) + expect(result).to eq(["/#{group.path}/test", "/#{group.path}/test/tree/#{commit_id}"]) + end + + it 'two levels down' do + commit_id = sample_commit[:id] + result = relative_self_links('../../test.git', commit_id) + expect(result).to eq(["/#{group.path}/test", "/#{group.path}/test/tree/#{commit_id}"]) + end + + it 'one level down with namespace and repo' do + commit_id = sample_commit[:id] + result = relative_self_links('../foobar/test.git', commit_id) + expect(result).to eq(["/foobar/test", "/foobar/test/tree/#{commit_id}"]) + end + + it 'two levels down with namespace and repo' do + commit_id = sample_commit[:id] + result = relative_self_links('../foobar/baz/test.git', commit_id) + expect(result).to eq(["/baz/test", "/baz/test/tree/#{commit_id}"]) + end + end end def stub_url(url) From 562a9b1a87ff0880a5a60f1a01d79d09bbaf7ea7 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Apr 2015 16:10:25 +0200 Subject: [PATCH 086/194] Bust group page project list cache when namespace name or path changes. --- CHANGELOG | 3 ++- app/views/shared/_project.html.haml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 38a4117eb8..c06b44e657 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -66,7 +66,8 @@ v 7.10.0 (unreleased) - Fixed link paths for HTTP and SSH on the admin project view (Jeremy Maziarz) - Fix and improve help rendering (Sullivan Sénéchal) - Fix final line in EmailsOnPush email diff being rendered as error. - - Authometic setup GitLab CI project for forks if origin project has GitLab CI enabled + - Automatically setup GitLab CI project for forks if origin project has GitLab CI enabled + - Bust group page project list cache when namespace name or path changes. v 7.9.3 - Contains no changes diff --git a/app/views/shared/_project.html.haml b/app/views/shared/_project.html.haml index 8746970c23..722a7f7ce0 100644 --- a/app/views/shared/_project.html.haml +++ b/app/views/shared/_project.html.haml @@ -1,4 +1,4 @@ -= cache [project, controller.controller_name, controller.action_name] do += cache [project.namespace, project, controller.controller_name, controller.action_name] do = link_to project_path(project), class: dom_class(project) do - if avatar .dash-project-avatar From 93133f4da9d950f47ca0ba2e437f9c004567e6f7 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Apr 2015 18:07:31 +0200 Subject: [PATCH 087/194] Fix directory traversal vulnerability around uploads routes. --- CHANGELOG | 2 ++ config/routes.rb | 8 ++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 0878c03207..a126850c18 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,8 @@ Please view this file on the master branch, on stable branches it's out of date. v 7.10.0 (unreleased) + - Fix directory traversal vulnerability around uploads routes. + - Fix broken file browsing with a submodule that contains a relative link (Stan Hu) - Fix bug where Wiki pages that included a '/' were no longer accessible (Stan Hu) - Fix bug where error messages from Dropzone would not be displayed on the issues page (Stan Hu) - Add ability to configure Reply-To address in gitlab.yml (Stan Hu) diff --git a/config/routes.rb b/config/routes.rb index c1b85b025b..29207ed4d9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -91,18 +91,18 @@ Gitlab::Application.routes.draw do # Note attachments and User/Group/Project avatars get ":model/:mounted_as/:id/:filename", to: "uploads#show", - constraints: { model: /note|user|group|project/, mounted_as: /avatar|attachment/, filename: /.+/ } + constraints: { model: /note|user|group|project/, mounted_as: /avatar|attachment/, filename: /[^\/]+/ } # Project markdown uploads get ":namespace_id/:project_id/:secret/:filename", to: "projects/uploads#show", - constraints: { namespace_id: /[a-zA-Z.0-9_\-]+/, project_id: /[a-zA-Z.0-9_\-]+/, filename: /.+/ } + constraints: { namespace_id: /[a-zA-Z.0-9_\-]+/, project_id: /[a-zA-Z.0-9_\-]+/, filename: /[^\/]+/ } end # Redirect old note attachments path to new uploads path. get "files/note/:id/:filename", to: redirect("uploads/note/attachment/%{id}/%{filename}"), - constraints: { filename: /.+/ } + constraints: { filename: /[^\/]+/ } # # Explore area @@ -485,7 +485,7 @@ Gitlab::Application.routes.draw do resources :uploads, only: [:create] do collection do - get ":secret/:filename", action: :show, as: :show, constraints: { filename: /.+/ } + get ":secret/:filename", action: :show, as: :show, constraints: { filename: /[^\/]+/ } end end end From edd05fc48c748ba0945652c8c83e7617ccc029fc Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Apr 2015 18:16:46 +0200 Subject: [PATCH 088/194] Fix directory traversal vulnerability around help pages. --- CHANGELOG | 1 + app/controllers/help_controller.rb | 20 +++++++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index a126850c18..9f838ede78 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 7.10.0 (unreleased) - Fix directory traversal vulnerability around uploads routes. + - Fix directory traversal vulnerability around help pages. - Fix broken file browsing with a submodule that contains a relative link (Stan Hu) - Fix bug where Wiki pages that included a '/' were no longer accessible (Stan Hu) - Fix bug where error messages from Dropzone would not be displayed on the issues page (Stan Hu) diff --git a/app/controllers/help_controller.rb b/app/controllers/help_controller.rb index fbd9e67e6d..0010caad77 100644 --- a/app/controllers/help_controller.rb +++ b/app/controllers/help_controller.rb @@ -3,7 +3,7 @@ class HelpController < ApplicationController end def show - @filepath = params[:filepath] + @filepath = clean_path_info(params[:filepath]) @format = params[:format] respond_to do |format| @@ -36,4 +36,22 @@ class HelpController < ApplicationController def ui end + + # Taken from ActionDispatch::FileHandler + PATH_SEPS = Regexp.union(*[::File::SEPARATOR, ::File::ALT_SEPARATOR].compact) + + def clean_path_info(path_info) + parts = path_info.split PATH_SEPS + + clean = [] + + parts.each do |part| + next if part.empty? || part == '.' + part == '..' ? clean.pop : clean << part + end + + clean.unshift '/' if parts.empty? || parts.first.empty? + + ::File.join(*clean) + end end From 6cf7dd625a7db143c146de1b146cba7dbcbc2576 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Apr 2015 18:27:42 +0200 Subject: [PATCH 089/194] Fix persistent XSS vulnerability around profile website URLs. --- CHANGELOG | 2 ++ app/models/user.rb | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 0878c03207..3c119a93d3 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,8 @@ Please view this file on the master branch, on stable branches it's out of date. v 7.10.0 (unreleased) + - Fix persistent XSS vulnerability around profile website URLs. + - Fix broken file browsing with a submodule that contains a relative link (Stan Hu) - Fix bug where Wiki pages that included a '/' were no longer accessible (Stan Hu) - Fix bug where error messages from Dropzone would not be displayed on the issues page (Stan Hu) - Add ability to configure Reply-To address in gitlab.yml (Stan Hu) diff --git a/app/models/user.rb b/app/models/user.rb index 515f29ea0b..e2b6757bc4 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -486,13 +486,13 @@ class User < ActiveRecord::Base end def full_website_url - return "http://#{website_url}" if website_url !~ /^https?:\/\// + return "http://#{website_url}" if website_url !~ /\Ahttps?:\/\// website_url end def short_website_url - website_url.gsub(/https?:\/\//, '') + website_url.sub(/\Ahttps?:\/\//, '') end def all_ssh_keys From 0ece6bd82839d0c9e3e27b65a6a201771f09190e Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Apr 2015 18:30:02 +0200 Subject: [PATCH 090/194] Use `\A` and `\z` in regexes instead of `^` and `$`. --- app/controllers/application_controller.rb | 2 +- app/helpers/application_helper.rb | 2 +- app/helpers/gitlab_markdown_helper.rb | 4 ++-- app/helpers/submodule_helper.rb | 2 +- app/models/project_services/irker_service.rb | 2 +- app/models/repository.rb | 4 ++-- app/services/create_tag_service.rb | 4 +--- app/workers/post_receive.rb | 4 ++-- config/initializers/devise.rb | 2 +- 9 files changed, 12 insertions(+), 14 deletions(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 80e983b531..0521a9ef8c 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -153,7 +153,7 @@ class ApplicationController < ActionController::Base end def method_missing(method_sym, *arguments, &block) - if method_sym.to_s =~ /^authorize_(.*)!$/ + if method_sym.to_s =~ /\Aauthorize_(.*)!\z/ authorize_project!($1.to_sym) else super diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 3f3509bb18..b5b0015542 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -125,7 +125,7 @@ module ApplicationHelper # If reference is commit id - we should add it to branch/tag selectbox if(@ref && !options.flatten.include?(@ref) && - @ref =~ /^[0-9a-zA-Z]{6,52}$/) + @ref =~ /\A[0-9a-zA-Z]{6,52}\z/) options << ['Commit', [@ref]] end diff --git a/app/helpers/gitlab_markdown_helper.rb b/app/helpers/gitlab_markdown_helper.rb index 17266656a4..aa1de2f50e 100644 --- a/app/helpers/gitlab_markdown_helper.rb +++ b/app/helpers/gitlab_markdown_helper.rb @@ -13,7 +13,7 @@ module GitlabMarkdownHelper def link_to_gfm(body, url, html_options = {}) return "" if body.blank? - escaped_body = if body =~ /^\ 1 uri.to_s diff --git a/app/models/repository.rb b/app/models/repository.rb index 7276949887..263a436d52 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -199,7 +199,7 @@ class Repository def changelog cache.fetch(:changelog) do tree(:head).blobs.find do |file| - file.name =~ /^(changelog|history)/i + file.name =~ /\A(changelog|history)/i end end end @@ -207,7 +207,7 @@ class Repository def license cache.fetch(:license) do tree(:head).blobs.find do |file| - file.name =~ /^license/i + file.name =~ /\Alicense/i end end end diff --git a/app/services/create_tag_service.rb b/app/services/create_tag_service.rb index 4115d68992..25f9e20324 100644 --- a/app/services/create_tag_service.rb +++ b/app/services/create_tag_service.rb @@ -13,9 +13,7 @@ class CreateTagService < BaseService return error('Tag already exists') end - if message - message.gsub!(/^\s+|\s+$/, '') - end + message.strip! if message repository.add_tag(tag_name, ref, message) new_tag = repository.find_tag(tag_name) diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb index 0c3ee6ba4f..33d8cc8861 100644 --- a/app/workers/post_receive.rb +++ b/app/workers/post_receive.rb @@ -11,8 +11,8 @@ class PostReceive log("Check gitlab.yml config for correct gitlab_shell.repos_path variable. \"#{Gitlab.config.gitlab_shell.repos_path}\" does not match \"#{repo_path}\"") end - repo_path.gsub!(/\.git$/, "") - repo_path.gsub!(/^\//, "") + repo_path.gsub!(/\.git\z/, "") + repo_path.gsub!(/\A\//, "") project = Project.find_with_namespace(repo_path) diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 79abe3c695..9dce495106 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -208,7 +208,7 @@ Devise.setup do |config| if Gitlab::LDAP::Config.enabled? Gitlab.config.ldap.servers.values.each do |server| if server['allow_username_or_email_login'] - email_stripping_proc = ->(name) {name.gsub(/@.*$/,'')} + email_stripping_proc = ->(name) {name.gsub(/@.*\z/,'')} else email_stripping_proc = ->(name) {name} end From 1fdc610fa54bbf768fbcad37088fd0adfc8d64fd Mon Sep 17 00:00:00 2001 From: Christoph Dreis Date: Fri, 10 Apr 2015 18:35:05 +0200 Subject: [PATCH 091/194] Include creator_id in project info of API --- lib/api/entities.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 51cb934616..36332bc651 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -54,6 +54,7 @@ module API expose :name, :name_with_namespace expose :path, :path_with_namespace expose :issues_enabled, :merge_requests_enabled, :wiki_enabled, :snippets_enabled, :created_at, :last_activity_at + expose :creator_id expose :namespace expose :forked_from_project, using: Entities::ForkedFromProject, if: lambda{ | project, options | project.forked? } expose :avatar_url From 1f813024bacc8ea6ac066c9707aeb414fade0e0a Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Apr 2015 18:39:10 +0200 Subject: [PATCH 092/194] Don't leak existence of project via search autocomplete. --- CHANGELOG | 2 ++ app/controllers/search_controller.rb | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index 0878c03207..0a61fee1cb 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,8 @@ Please view this file on the master branch, on stable branches it's out of date. v 7.10.0 (unreleased) + - Don't leak existence of project via search autocomplete. + - Fix broken file browsing with a submodule that contains a relative link (Stan Hu) - Fix bug where Wiki pages that included a '/' were no longer accessible (Stan Hu) - Fix bug where error messages from Dropzone would not be displayed on the issues page (Stan Hu) - Add ability to configure Reply-To address in gitlab.yml (Stan Hu) diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index a3284c82d3..16a5ee2ae3 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -35,7 +35,12 @@ class SearchController < ApplicationController def autocomplete term = params[:term] - @project = Project.find(params[:project_id]) if params[:project_id].present? + + if params[:project_id].present? + @project = Project.find_by(id: params[:project_id]) + @project = nil unless can?(current_user, :read_project, @project) + end + @ref = params[:project_ref] if params[:project_ref].present? render json: search_autocomplete_opts(term).to_json From abc06c25319cadc9d0618c17a2a5539d10ce1b38 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Apr 2015 18:39:36 +0200 Subject: [PATCH 093/194] Don't leak existence of group or project via search. --- CHANGELOG | 1 + app/controllers/search_controller.rb | 15 +++++++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 0a61fee1cb..adb1911844 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 7.10.0 (unreleased) - Don't leak existence of project via search autocomplete. + - Don't leak existence of group or project via search. - Fix broken file browsing with a submodule that contains a relative link (Stan Hu) - Fix bug where Wiki pages that included a '/' were no longer accessible (Stan Hu) - Fix bug where error messages from Dropzone would not be displayed on the issues page (Stan Hu) diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 16a5ee2ae3..c5828d0b2d 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -3,15 +3,22 @@ class SearchController < ApplicationController def show return if params[:search].nil? || params[:search].blank? - @project = Project.find_by(id: params[:project_id]) if params[:project_id].present? - @group = Group.find_by(id: params[:group_id]) if params[:group_id].present? + + if params[:project_id].present? + @project = Project.find_by(id: params[:project_id]) + @project = nil unless can?(current_user, :download_code, @project) + end + + if params[:group_id].present? + @group = Group.find_by(id: params[:group_id]) + @group = nil unless can?(current_user, :read_group, @group) + end + @scope = params[:scope] @show_snippets = params[:snippets].eql? 'true' @search_results = if @project - return access_denied! unless can?(current_user, :download_code, @project) - unless %w(blobs notes issues merge_requests wiki_blobs). include?(@scope) @scope = 'blobs' From d570d0a2804990fc59126828616011d310392d64 Mon Sep 17 00:00:00 2001 From: Christoph Dreis Date: Fri, 10 Apr 2015 20:05:01 +0200 Subject: [PATCH 094/194] Update API documentation of projects with creator_id --- doc/api/projects.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/doc/api/projects.md b/doc/api/projects.md index 55d525fef6..971fe96fb8 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -63,6 +63,7 @@ Parameters: "snippets_enabled": false, "created_at": "2013-09-30T13: 46: 02Z", "last_activity_at": "2013-09-30T13: 46: 02Z", + "creator_id": 3, "namespace": { "created_at": "2013-09-30T13: 46: 02Z", "description": "", @@ -103,6 +104,7 @@ Parameters: "snippets_enabled": false, "created_at": "2013-09-30T13:46:02Z", "last_activity_at": "2013-09-30T13:46:02Z", + "creator_id": 3, "namespace": { "created_at": "2013-09-30T13:46:02Z", "description": "", @@ -190,6 +192,7 @@ Parameters: "snippets_enabled": false, "created_at": "2013-09-30T13: 46: 02Z", "last_activity_at": "2013-09-30T13: 46: 02Z", + "creator_id": 3, "namespace": { "created_at": "2013-09-30T13: 46: 02Z", "description": "", From efe023e795ab5e1d44f0ead436a363faaf41c538 Mon Sep 17 00:00:00 2001 From: Ben Bodenmiller Date: Sat, 11 Apr 2015 01:38:34 -0700 Subject: [PATCH 095/194] document support for inline HTML --- doc/markdown/markdown.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/doc/markdown/markdown.md b/doc/markdown/markdown.md index 965d8fc313..1d5fd4c8b0 100644 --- a/doc/markdown/markdown.md +++ b/doc/markdown/markdown.md @@ -421,7 +421,7 @@ Quote break. You can also use raw HTML in your Markdown, and it'll mostly work pretty well. -Note that inline HTML is disabled in the default Gitlab configuration, although it is [possible](https://github.com/gitlabhq/gitlabhq/pull/8007/commits) for the system administrator to enable it. +See the documentation for HTML::Pipeline's [SanitizationFilter](http://www.rubydoc.info/gems/html-pipeline/HTML/Pipeline/SanitizationFilter#WHITELIST-constant) class for the list of allowed HTML tags and attributes. In addition to the default `SanitizationFilter` whitelist, GitLab allows the `class`, `id`, and `style` attributes. ```no-highlight
    @@ -441,8 +441,6 @@ Note that inline HTML is disabled in the default Gitlab configuration, although
    Does *not* work **very** well. Use HTML tags.
    -See the documentation for HTML::Pipeline's [SanitizationFilter](http://www.rubydoc.info/gems/html-pipeline/HTML/Pipeline/SanitizationFilter#WHITELIST-constant) class for the list of allowed HTML tags and attributes. In addition to the default `SanitizationFilter` whitelist, GitLab allows the `class`, `id`, and `style` attributes. - ## Horizontal Rule ``` From cebb74a0c0575f00f8904b204559979486fc714f Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Sat, 11 Apr 2015 11:37:58 +0200 Subject: [PATCH 096/194] Fix project import URL regex to prevent arbitary local repos from being imported. --- CHANGELOG | 1 + app/models/project.rb | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index 0878c03207..f69f7471a5 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 7.10.0 (unreleased) + - Fix project import URL regex to prevent arbitary local repos from being imported. - Fix bug where Wiki pages that included a '/' were no longer accessible (Stan Hu) - Fix bug where error messages from Dropzone would not be displayed on the issues page (Stan Hu) - Add ability to configure Reply-To address in gitlab.yml (Stan Hu) diff --git a/app/models/project.rb b/app/models/project.rb index 79572f255d..d7712e6457 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -137,7 +137,7 @@ class Project < ActiveRecord::Base validates_uniqueness_of :name, scope: :namespace_id validates_uniqueness_of :path, scope: :namespace_id validates :import_url, - format: { with: URI::regexp(%w(ssh git http https)), message: 'should be a valid url' }, + format: { with: /\A#{URI.regexp(%w(ssh git http https))}\z/, message: 'should be a valid url' }, if: :import? validates :star_count, numericality: { greater_than_or_equal_to: 0 } validate :check_limit, on: :create From 9e52a2dcaba58f6bf4ce7c9858951e1936d73740 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Sat, 11 Apr 2015 11:38:14 +0200 Subject: [PATCH 097/194] Don't use URI.regexp to validate since it doens't have start/end anchors. --- app/models/application_setting.rb | 2 +- app/models/hooks/web_hook.rb | 2 +- app/models/project_services/bamboo_service.rb | 2 +- app/models/project_services/external_wiki_service.rb | 2 +- app/models/project_services/teamcity_service.rb | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 6e98c4c2f0..0d8365c4ff 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -24,7 +24,7 @@ class ApplicationSetting < ActiveRecord::Base validates :home_page_url, allow_blank: true, - format: { with: URI::regexp(%w(http https)), message: "should be a valid url" }, + format: { with: /\A#{URI.regexp(%w(http https))}\z/, message: "should be a valid url" }, if: :home_page_url_column_exist validates_each :restricted_visibility_levels do |record, attr, value| diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb index defef7216f..315d96af1b 100644 --- a/app/models/hooks/web_hook.rb +++ b/app/models/hooks/web_hook.rb @@ -28,7 +28,7 @@ class WebHook < ActiveRecord::Base default_timeout Gitlab.config.gitlab.webhook_timeout validates :url, presence: true, - format: { with: URI::regexp(%w(http https)), message: "should be a valid url" } + format: { with: /\A#{URI.regexp(%w(http https))}\z/, message: "should be a valid url" } def execute(data) parsed_url = URI.parse(url) diff --git a/app/models/project_services/bamboo_service.rb b/app/models/project_services/bamboo_service.rb index f968afe9fa..d8aedbd2ab 100644 --- a/app/models/project_services/bamboo_service.rb +++ b/app/models/project_services/bamboo_service.rb @@ -25,7 +25,7 @@ class BambooService < CiService validates :bamboo_url, presence: true, - format: { with: URI::regexp }, + format: { with: /\A#{URI.regexp}\z/ }, if: :activated? validates :build_key, presence: true, if: :activated? validates :username, diff --git a/app/models/project_services/external_wiki_service.rb b/app/models/project_services/external_wiki_service.rb index e521186798..a199d0e86f 100644 --- a/app/models/project_services/external_wiki_service.rb +++ b/app/models/project_services/external_wiki_service.rb @@ -18,7 +18,7 @@ class ExternalWikiService < Service prop_accessor :external_wiki_url validates :external_wiki_url, presence: true, - format: { with: URI::regexp }, + format: { with: /\A#{URI.regexp}\z/ }, if: :activated? def title diff --git a/app/models/project_services/teamcity_service.rb b/app/models/project_services/teamcity_service.rb index c26bc55135..3c002a1634 100644 --- a/app/models/project_services/teamcity_service.rb +++ b/app/models/project_services/teamcity_service.rb @@ -25,7 +25,7 @@ class TeamcityService < CiService validates :teamcity_url, presence: true, - format: { with: URI::regexp }, if: :activated? + format: { with: /\A#{URI.regexp}\z/ }, if: :activated? validates :build_type, presence: true, if: :activated? validates :username, presence: true, From 988b703548a87f4c9d5d25eb767046a2e39069d7 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Sat, 11 Apr 2015 11:40:28 +0200 Subject: [PATCH 098/194] Fix a whoopsie daisy in the changelog. --- CHANGELOG | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index 9f838ede78..fdd0a4466c 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -3,7 +3,6 @@ Please view this file on the master branch, on stable branches it's out of date. v 7.10.0 (unreleased) - Fix directory traversal vulnerability around uploads routes. - Fix directory traversal vulnerability around help pages. - - Fix broken file browsing with a submodule that contains a relative link (Stan Hu) - Fix bug where Wiki pages that included a '/' were no longer accessible (Stan Hu) - Fix bug where error messages from Dropzone would not be displayed on the issues page (Stan Hu) - Add ability to configure Reply-To address in gitlab.yml (Stan Hu) From 0988be4efa8c9db6b3adcecdbad97367e837961f Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Sat, 11 Apr 2015 11:40:51 +0200 Subject: [PATCH 099/194] Fix a whoopsie daisy in the changelog. --- CHANGELOG | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index 3c119a93d3..d10fc81015 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -2,7 +2,6 @@ Please view this file on the master branch, on stable branches it's out of date. v 7.10.0 (unreleased) - Fix persistent XSS vulnerability around profile website URLs. - - Fix broken file browsing with a submodule that contains a relative link (Stan Hu) - Fix bug where Wiki pages that included a '/' were no longer accessible (Stan Hu) - Fix bug where error messages from Dropzone would not be displayed on the issues page (Stan Hu) - Add ability to configure Reply-To address in gitlab.yml (Stan Hu) From 28f9c98641907089b7088a67195c5bb447c647d2 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Sat, 11 Apr 2015 11:41:14 +0200 Subject: [PATCH 100/194] Fix a whoopsie daisy in the changelog. --- CHANGELOG | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index adb1911844..8d6d48fcdc 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -3,7 +3,6 @@ Please view this file on the master branch, on stable branches it's out of date. v 7.10.0 (unreleased) - Don't leak existence of project via search autocomplete. - Don't leak existence of group or project via search. - - Fix broken file browsing with a submodule that contains a relative link (Stan Hu) - Fix bug where Wiki pages that included a '/' were no longer accessible (Stan Hu) - Fix bug where error messages from Dropzone would not be displayed on the issues page (Stan Hu) - Add ability to configure Reply-To address in gitlab.yml (Stan Hu) From 8666f497ff13c100f6cd2339971e430dbf05470f Mon Sep 17 00:00:00 2001 From: Valery Sizov Date: Sat, 11 Apr 2015 17:56:45 +0300 Subject: [PATCH 101/194] fix ldap identities --- app/models/identity.rb | 1 + config/initializers/1_settings.rb | 7 ++++++- db/migrate/20150411000035_fix_identities.rb | 16 ++++++++++++++++ db/schema.rb | 6 +++--- 4 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 db/migrate/20150411000035_fix_identities.rb diff --git a/app/models/identity.rb b/app/models/identity.rb index 440fcd0d05..756d19adec 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -15,4 +15,5 @@ class Identity < ActiveRecord::Base belongs_to :user validates :extern_uid, allow_blank: true, uniqueness: { scope: :provider } + validates :user_id, uniqueness: { scope: :provider } end diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index e6b00c531a..c25c799d8f 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -67,7 +67,7 @@ Settings.ldap['enabled'] = false if Settings.ldap['enabled'].nil? if Settings.ldap['enabled'] || Rails.env.test? if Settings.ldap['host'].present? server = Settings.ldap.except('sync_time') - server['provider_name'] = 'ldap' + server['provider_name'] = 'ldapmain' Settings.ldap['servers'] = { 'ldap' => server } @@ -80,8 +80,13 @@ if Settings.ldap['enabled'] || Rails.env.test? server['provider_name'] ||= "ldap#{key}".downcase server['provider_class'] = OmniAuth::Utils.camelize(server['provider_name']) end + + unless Settings.ldap['servers'].select{ |k, server| server['provider_name'] == "ldapmain"}.any? + raise "Wrong LDAP configuration. The 'main' LDAP section is missing" + end end + Settings['omniauth'] ||= Settingslogic.new({}) Settings.omniauth['enabled'] = false if Settings.omniauth['enabled'].nil? Settings.omniauth['providers'] ||= [] diff --git a/db/migrate/20150411000035_fix_identities.rb b/db/migrate/20150411000035_fix_identities.rb new file mode 100644 index 0000000000..12526b10e6 --- /dev/null +++ b/db/migrate/20150411000035_fix_identities.rb @@ -0,0 +1,16 @@ +class FixIdentities < ActiveRecord::Migration + def up + new_provider = Gitlab.config.ldap.servers.first.last['provider_name'] + # Delete duplicate identities + Identity.connection.select_one("DELETE FROM identities WHERE provider = 'ldap' AND user_id IN (SELECT user_id FROM identities WHERE provider = '#{new_provider}')") + # Update legacy identities + Identity.where(provider: 'ldap').update_all(provider: new_provider) + + if defined?(LdapGroupLink) + LdapGroupLink.where('provider IS NULL').update_all(provider: new_provider) + end + end + + def down + end +end diff --git a/db/schema.rb b/db/schema.rb index 14e32a7946..903ed161e4 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20150328132231) do +ActiveRecord::Schema.define(version: 20150411000035) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -336,12 +336,12 @@ ActiveRecord::Schema.define(version: 20150328132231) do t.string "import_url" t.integer "visibility_level", default: 0, null: false t.boolean "archived", default: false, null: false - t.string "avatar" t.string "import_status" t.float "repository_size", default: 0.0 t.integer "star_count", default: 0, null: false t.string "import_type" t.string "import_source" + t.string "avatar" end add_index "projects", ["created_at", "id"], name: "index_projects_on_created_at_and_id", using: :btree @@ -459,6 +459,7 @@ ActiveRecord::Schema.define(version: 20150328132231) do t.integer "notification_level", default: 1, null: false t.datetime "password_expires_at" t.integer "created_by_id" + t.datetime "last_credential_check_at" t.string "avatar" t.string "confirmation_token" t.datetime "confirmed_at" @@ -466,7 +467,6 @@ ActiveRecord::Schema.define(version: 20150328132231) do t.string "unconfirmed_email" t.boolean "hide_no_ssh_key", default: false t.string "website_url", default: "", null: false - t.datetime "last_credential_check_at" t.string "github_access_token" t.string "gitlab_access_token" t.string "notification_email" From 4077f66bebfd1ef8fe78ef93876b872c083d16a5 Mon Sep 17 00:00:00 2001 From: Robert Speicher Date: Thu, 9 Apr 2015 16:37:07 -0400 Subject: [PATCH 102/194] Sort available_services_names array alphabetically --- app/models/service.rb | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/app/models/service.rb b/app/models/service.rb index f54ad19666..b7eee5ee3e 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -122,25 +122,25 @@ class Service < ActiveRecord::Base def self.available_services_names %w( - gitlab_ci - campfire - hipchat - pivotaltracker - flowdock - assembla asana - emails_on_push - gemnasium - slack - pushover - buildbox + assembla bamboo - teamcity - jira - redmine + buildbox + campfire custom_issue_tracker - irker + emails_on_push external_wiki + flowdock + gemnasium + gitlab_ci + hipchat + irker + jira + pivotaltracker + pushover + redmine + slack + teamcity ) end From 93b17677999015ee5d942e61881ac21e6de4aadf Mon Sep 17 00:00:00 2001 From: Robert Speicher Date: Sat, 11 Apr 2015 13:57:04 -0400 Subject: [PATCH 103/194] Move buildbox_service files to buildkite_service --- .../{buildbox_service.rb => buildkite_service.rb} | 0 .../{buildbox_service_spec.rb => buildkite_service_spec.rb} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename app/models/project_services/{buildbox_service.rb => buildkite_service.rb} (100%) rename spec/models/project_services/{buildbox_service_spec.rb => buildkite_service_spec.rb} (100%) diff --git a/app/models/project_services/buildbox_service.rb b/app/models/project_services/buildkite_service.rb similarity index 100% rename from app/models/project_services/buildbox_service.rb rename to app/models/project_services/buildkite_service.rb diff --git a/spec/models/project_services/buildbox_service_spec.rb b/spec/models/project_services/buildkite_service_spec.rb similarity index 100% rename from spec/models/project_services/buildbox_service_spec.rb rename to spec/models/project_services/buildkite_service_spec.rb From 307962a0466f3b91862dd37178595f70e92d9c8b Mon Sep 17 00:00:00 2001 From: Robert Speicher Date: Sat, 11 Apr 2015 14:02:13 -0400 Subject: [PATCH 104/194] Rename last uses of Buildbox to Buildkite --- app/models/project.rb | 2 +- app/models/project_services/buildkite_service.rb | 4 +--- app/models/service.rb | 2 +- spec/models/project_services/buildkite_service_spec.rb | 4 ++-- 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/app/models/project.rb b/app/models/project.rb index 79572f255d..5c310b4a8d 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -81,7 +81,7 @@ class Project < ActiveRecord::Base has_one :asana_service, dependent: :destroy has_one :gemnasium_service, dependent: :destroy has_one :slack_service, dependent: :destroy - has_one :buildbox_service, dependent: :destroy + has_one :buildkite_service, dependent: :destroy has_one :bamboo_service, dependent: :destroy has_one :teamcity_service, dependent: :destroy has_one :pushover_service, dependent: :destroy diff --git a/app/models/project_services/buildkite_service.rb b/app/models/project_services/buildkite_service.rb index 3a381ff11b..a714bc8224 100644 --- a/app/models/project_services/buildkite_service.rb +++ b/app/models/project_services/buildkite_service.rb @@ -20,9 +20,7 @@ require "addressable/uri" -# Buildbox renamed to Buildkite, but for backwards compatability with the STI -# of Services, the class name is kept as "Buildbox" -class BuildboxService < CiService +class BuildkiteService < CiService ENDPOINT = "https://buildkite.com" prop_accessor :project_url, :token diff --git a/app/models/service.rb b/app/models/service.rb index b7eee5ee3e..393cf55a69 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -125,7 +125,7 @@ class Service < ActiveRecord::Base asana assembla bamboo - buildbox + buildkite campfire custom_issue_tracker emails_on_push diff --git a/spec/models/project_services/buildkite_service_spec.rb b/spec/models/project_services/buildkite_service_spec.rb index 9f29fbe12b..e987241f3c 100644 --- a/spec/models/project_services/buildkite_service_spec.rb +++ b/spec/models/project_services/buildkite_service_spec.rb @@ -19,7 +19,7 @@ require 'spec_helper' -describe BuildboxService do +describe BuildkiteService do describe 'Associations' do it { is_expected.to belong_to :project } it { is_expected.to have_one :service_hook } @@ -32,7 +32,7 @@ describe BuildboxService do default_branch: 'default-brancho' ) - @service = BuildboxService.new + @service = BuildkiteService.new @service.stub( project: @project, service_hook: true, From 1fd03a9b794eb5fb558df5dbb2c2b5bdaee21e79 Mon Sep 17 00:00:00 2001 From: Robert Speicher Date: Sat, 11 Apr 2015 14:02:44 -0400 Subject: [PATCH 105/194] Add migration to rename BuildboxService to BuildkiteService --- db/migrate/20150411180045_rename_buildbox_service.rb | 9 +++++++++ db/schema.rb | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20150411180045_rename_buildbox_service.rb diff --git a/db/migrate/20150411180045_rename_buildbox_service.rb b/db/migrate/20150411180045_rename_buildbox_service.rb new file mode 100644 index 0000000000..5a0b5d07e5 --- /dev/null +++ b/db/migrate/20150411180045_rename_buildbox_service.rb @@ -0,0 +1,9 @@ +class RenameBuildboxService < ActiveRecord::Migration + def up + execute "UPDATE services SET type = 'BuildkiteService' WHERE type = 'BuildboxService';" + end + + def down + execute "UPDATE services SET type = 'BuildboxService' WHERE type = 'BuildkiteService';" + end +end diff --git a/db/schema.rb b/db/schema.rb index 14e32a7946..3f481b67fd 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20150328132231) do +ActiveRecord::Schema.define(version: 20150411180045) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" From bb5f3e789725b20542d6336e14817c7213e2f744 Mon Sep 17 00:00:00 2001 From: Robert Speicher Date: Sat, 11 Apr 2015 14:03:02 -0400 Subject: [PATCH 106/194] Add CHANGELOG entry for Buildkite service renaming --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index 38a4117eb8..8341857e4e 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -67,6 +67,7 @@ v 7.10.0 (unreleased) - Fix and improve help rendering (Sullivan Sénéchal) - Fix final line in EmailsOnPush email diff being rendered as error. - Authometic setup GitLab CI project for forks if origin project has GitLab CI enabled + - Prevent duplicate Buildkite service creation. v 7.9.3 - Contains no changes From 273df6a44b5e4d501571bc2cb55dee1e5b57b290 Mon Sep 17 00:00:00 2001 From: Robert Speicher Date: Thu, 9 Apr 2015 17:16:01 -0400 Subject: [PATCH 107/194] Remove the invalid key factories They're only used once each, and they're easy to build in-place. --- spec/factories.rb | 12 ------------ spec/factories_spec.rb | 6 ------ spec/models/key_spec.rb | 13 +++++++++---- 3 files changed, 9 insertions(+), 22 deletions(-) diff --git a/spec/factories.rb b/spec/factories.rb index fc103e5b13..7461205a0a 100644 --- a/spec/factories.rb +++ b/spec/factories.rb @@ -101,23 +101,11 @@ FactoryGirl.define do user end - factory :key_with_a_space_in_the_middle do - key do - "ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt4596k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa ++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0=" - end - end - factory :another_key do key do "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDmTillFzNTrrGgwaCKaSj+QCz81E6jBc/s9av0+3b1Hwfxgkqjl4nAK/OD2NjgyrONDTDfR8cRN4eAAy6nY8GLkOyYBDyuc5nTMqs5z3yVuTwf3koGm/YQQCmo91psZ2BgDFTor8SVEE5Mm1D1k3JDMhDFxzzrOtRYFPci9lskTJaBjpqWZ4E9rDTD2q/QZntCqbC3wE9uSemRQB5f8kik7vD/AD8VQXuzKladrZKkzkONCPWsXDspUitjM8HkQdOf0PsYn1CMUC1xKYbCxkg5TkEosIwGv6CoEArUrdu/4+10LVslq494mAvEItywzrluCLCnwELfW+h/m8UHoVhZ" end end - - factory :invalid_key do - key do - "ssh-rsa this_is_invalid_key==" - end - end end factory :email do diff --git a/spec/factories_spec.rb b/spec/factories_spec.rb index c8e218d4d0..457859deda 100644 --- a/spec/factories_spec.rb +++ b/spec/factories_spec.rb @@ -1,12 +1,6 @@ require 'spec_helper' -INVALID_FACTORIES = [ - :key_with_a_space_in_the_middle, - :invalid_key, -] - FactoryGirl.factories.map(&:name).each do |factory_name| - next if INVALID_FACTORIES.include?(factory_name) describe "#{factory_name} factory" do it 'should be valid' do expect(build(factory_name)).to be_valid diff --git a/spec/models/key_spec.rb b/spec/models/key_spec.rb index a212b95a7d..2fb651bef1 100644 --- a/spec/models/key_spec.rb +++ b/spec/models/key_spec.rb @@ -58,12 +58,17 @@ describe Key do expect(build(:key)).to be_valid end - it "rejects the unfingerprintable key (contains space in middle)" do - expect(build(:key_with_a_space_in_the_middle)).not_to be_valid + it 'rejects an unfingerprintable key that contains a space' do + key = build(:key) + + # Not always the middle, but close enough + key.key = key.key[0..100] + ' ' + key.key[100..-1] + + expect(key).not_to be_valid end - it "rejects the unfingerprintable key (not a key)" do - expect(build(:invalid_key)).not_to be_valid + it 'rejects the unfingerprintable key (not a key)' do + expect(build(:key, key: 'ssh-rsa an-invalid-key==')).not_to be_valid end end From 8ce69bdc8ec866006cc17e068a6ff9a4b704b5f8 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Sat, 11 Apr 2015 21:13:14 -0700 Subject: [PATCH 108/194] Fix typo --- app/views/dashboard/projects/starred.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/dashboard/projects/starred.html.haml b/app/views/dashboard/projects/starred.html.haml index 670f5ac7af..f4ad2b294b 100644 --- a/app/views/dashboard/projects/starred.html.haml +++ b/app/views/dashboard/projects/starred.html.haml @@ -19,5 +19,5 @@ %i.fa.fa-angle-left - else - %h3 You dont have starred projects yet + %h3 You don't have starred projects yet %p.slead Visit project page and press on star icon and it will appear on this page. From f59a2e2dd6f3093d98ce5305e0ca0318b69e7e15 Mon Sep 17 00:00:00 2001 From: Ben Bodenmiller Date: Sun, 12 Apr 2015 04:50:43 -0700 Subject: [PATCH 109/194] fix link to details on how to run the tests [ci skip] --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 42b5ce22e3..3165b7379d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -71,7 +71,7 @@ If you can, please submit a merge request with the fix or improvements including 1. Fork the project on GitLab Cloud 1. Create a feature branch -1. Write [tests](README.md#run-the-tests) and code +1. Write [tests](https://gitlab.com/gitlab-org/gitlab-development-kit#running-the-tests) and code 1. Add your changes to the [CHANGELOG](CHANGELOG) 1. If you are changing the README, some documentation or other things which have no effect on the tests, add `[ci skip]` somewhere in the commit message 1. If you have multiple commits please combine them into one commit by [squashing them](http://git-scm.com/book/en/Git-Tools-Rewriting-History#Squashing-Commits) From 975f1e6ec8df4b50118fb3a757c2a4a50a12e98d Mon Sep 17 00:00:00 2001 From: Robert Speicher Date: Sun, 12 Apr 2015 17:34:05 -0400 Subject: [PATCH 110/194] Speed up the overridden `link_to` helper Only bothers to check the provided link's external status if it's a String that doesn't begin with a path or anchor character. --- app/helpers/application_helper.rb | 44 +++++++++++++------------ spec/helpers/application_helper_spec.rb | 26 ++++++++------- 2 files changed, 38 insertions(+), 32 deletions(-) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index b5b0015542..6aef82354a 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -236,33 +236,35 @@ module ApplicationHelper Gitlab::MarkdownHelper.gitlab_markdown?(filename) end - def link_to(name = nil, options = nil, html_options = nil, &block) - begin - uri = URI(options) - host = uri.host - absolute_uri = uri.absolute? - rescue URI::InvalidURIError, ArgumentError - host = nil - absolute_uri = nil - end - - # Add 'nofollow' only to external links - if host && host != Gitlab.config.gitlab.host && absolute_uri - if html_options - if html_options[:rel] - html_options[:rel] << ' nofollow' - else - html_options.merge!(rel: 'nofollow') - end - else - html_options = Hash.new - html_options[:rel] = 'nofollow' + # Overrides ActionView::Helpers::UrlHelper#link_to to add `rel="nofollow"` to + # external links + def link_to(name = nil, options = nil, html_options = {}) + if options.kind_of?(String) + if options[0] != '/' && options[0] != '#' + html_options = add_nofollow(options, html_options) end end super end + # Add `"rel=nofollow"` to external links + # + # link - String link to check + # html_options - Hash of `html_options` passed to `link_to` + # + # Returns `html_options`, adding `rel: nofollow` for external links + def add_nofollow(link, html_options = {}) + uri = URI(link) + + if uri && uri.absolute? && uri.host != Gitlab.config.gitlab.host + rel = html_options.fetch(:rel, '') + html_options[:rel] = (rel + ' nofollow').strip + end + + html_options + end + def escaped_autolink(text) auto_link ERB::Util.html_escape(text), link: :urls end diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index 4c11709ed6..015a66f7fa 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -225,25 +225,29 @@ describe ApplicationHelper do end describe 'link_to' do - it 'should not include rel=nofollow for internal links' do - expect(link_to('Home', root_path)).to eq("Home") + expect(link_to('Home', root_path)).to eq('Home') end it 'should include rel=nofollow for external links' do - expect(link_to('Example', 'http://www.example.com')).to eq("Example") + expect(link_to('Example', 'http://www.example.com')). + to eq 'Example' end - it 'should include re=nofollow for external links and honor existing html_options' do - expect( - link_to('Example', 'http://www.example.com', class: 'toggle', data: {toggle: 'dropdown'}) - ).to eq("Example") + it 'should include rel=nofollow for external links and honor existing html_options' do + expect(link_to('Example', 'http://www.example.com', class: 'toggle', data: {toggle: 'dropdown'})) + .to eq 'Example' end - it 'should include rel=nofollow for external links and preserver other rel values' do - expect( - link_to('Example', 'http://www.example.com', rel: 'noreferrer') - ).to eq("Example") + it 'should include rel=nofollow for external links and preserve other rel values' do + expect(link_to('Example', 'http://www.example.com', rel: 'noreferrer')) + .to eq 'Example' + end + + it 'should not include rel=nofollow for external links on the same host as GitLab' do + expect(Gitlab.config.gitlab).to receive(:host).and_return('example.foo') + expect(link_to('Example', 'http://example.foo/bar')). + to eq 'Example' end end From cd590bf1f4f795df92b724efede13a8234705271 Mon Sep 17 00:00:00 2001 From: Robert Speicher Date: Sun, 12 Apr 2015 18:03:55 -0400 Subject: [PATCH 111/194] Use `start_with?` instead of String slicing to check for external links [ci skip] --- app/helpers/application_helper.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 6aef82354a..20457572a0 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -240,7 +240,7 @@ module ApplicationHelper # external links def link_to(name = nil, options = nil, html_options = {}) if options.kind_of?(String) - if options[0] != '/' && options[0] != '#' + if !options.start_with?('#', '/') html_options = add_nofollow(options, html_options) end end From 896ea2482bd78f3683140bb8aa08f0583a58361e Mon Sep 17 00:00:00 2001 From: Dmitriy Zaporozhets Date: Mon, 13 Apr 2015 11:50:21 +0300 Subject: [PATCH 112/194] Change migration to SQL Signed-off-by: Dmitriy Zaporozhets --- db/migrate/20150411000035_fix_identities.rb | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/db/migrate/20150411000035_fix_identities.rb b/db/migrate/20150411000035_fix_identities.rb index 12526b10e6..93beb046d7 100644 --- a/db/migrate/20150411000035_fix_identities.rb +++ b/db/migrate/20150411000035_fix_identities.rb @@ -1,13 +1,15 @@ class FixIdentities < ActiveRecord::Migration def up new_provider = Gitlab.config.ldap.servers.first.last['provider_name'] + # Delete duplicate identities - Identity.connection.select_one("DELETE FROM identities WHERE provider = 'ldap' AND user_id IN (SELECT user_id FROM identities WHERE provider = '#{new_provider}')") - # Update legacy identities - Identity.where(provider: 'ldap').update_all(provider: new_provider) + execute "DELETE FROM identities WHERE provider = 'ldap' AND user_id IN (SELECT user_id FROM identities WHERE provider = '#{new_provider}')" + + # Update legacy identities + execute "UPDATE identities SET provider = '#{new_provider}' WHERE provider = 'ldap';" if defined?(LdapGroupLink) - LdapGroupLink.where('provider IS NULL').update_all(provider: new_provider) + execute "UPDATE ldap_group_links SET provider = '#{new_provider}' WHERE provider IS NULL;" end end From 325b66365bc0bac4c17398dca397cfa30637de24 Mon Sep 17 00:00:00 2001 From: Jacob Vosmaer Date: Mon, 13 Apr 2015 11:04:31 +0200 Subject: [PATCH 113/194] Remove special cases for the 'ldap' provider --- lib/gitlab/ldap/config.rb | 2 -- lib/gitlab/ldap/user.rb | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/gitlab/ldap/config.rb b/lib/gitlab/ldap/config.rb index 0cb24d0ccc..fa5b6c1e23 100644 --- a/lib/gitlab/ldap/config.rb +++ b/lib/gitlab/ldap/config.rb @@ -27,8 +27,6 @@ module Gitlab def initialize(provider) if self.class.valid_provider?(provider) @provider = provider - elsif provider == 'ldap' - @provider = self.class.providers.first else self.class.invalid_provider(provider) end diff --git a/lib/gitlab/ldap/user.rb b/lib/gitlab/ldap/user.rb index b04f5b4ac3..75026aeaeb 100644 --- a/lib/gitlab/ldap/user.rb +++ b/lib/gitlab/ldap/user.rb @@ -13,7 +13,7 @@ module Gitlab def find_by_uid_and_provider(uid, provider) # LDAP distinguished name is case-insensitive identity = ::Identity. - where(provider: [provider, :ldap]). + where(provider: provider). where('lower(extern_uid) = ?', uid.downcase).last identity && identity.user end From f64db1fab95751bc2b1cf04641bb031d6289d16b Mon Sep 17 00:00:00 2001 From: Jacob Vosmaer Date: Mon, 13 Apr 2015 11:22:31 +0200 Subject: [PATCH 114/194] Try to explain what we are doing --- db/migrate/20150411000035_fix_identities.rb | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/db/migrate/20150411000035_fix_identities.rb b/db/migrate/20150411000035_fix_identities.rb index 93beb046d7..b65ad138b0 100644 --- a/db/migrate/20150411000035_fix_identities.rb +++ b/db/migrate/20150411000035_fix_identities.rb @@ -1,5 +1,14 @@ class FixIdentities < ActiveRecord::Migration def up + # Up until now, legacy 'ldap' references in the database were charitably + # interpreted to point to the first LDAP server specified in the GitLab + # configuration. So if the database said 'provider: ldap' but the first + # LDAP server was called 'ldapmain', then we would try to interpret + # 'provider: ldap' as if it said 'provider: ldapmain'. This migration (and + # accompanying changes in the GitLab LDAP code) get rid of this complicated + # behavior. Any database references to 'provider: ldap' get rewritten to + # whatever the code would have interpreted it as, i.e. as a reference to + # the first LDAP server specified in gitlab.yml / gitlab.rb. new_provider = Gitlab.config.ldap.servers.first.last['provider_name'] # Delete duplicate identities From 04f05ac1fb43904229bc084813dab92e82343f02 Mon Sep 17 00:00:00 2001 From: Dmitriy Zaporozhets Date: Mon, 13 Apr 2015 12:26:04 +0300 Subject: [PATCH 115/194] Check for table instead of class --- db/migrate/20150411000035_fix_identities.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/db/migrate/20150411000035_fix_identities.rb b/db/migrate/20150411000035_fix_identities.rb index b65ad138b0..e86264b166 100644 --- a/db/migrate/20150411000035_fix_identities.rb +++ b/db/migrate/20150411000035_fix_identities.rb @@ -11,13 +11,13 @@ class FixIdentities < ActiveRecord::Migration # the first LDAP server specified in gitlab.yml / gitlab.rb. new_provider = Gitlab.config.ldap.servers.first.last['provider_name'] - # Delete duplicate identities + # Delete duplicate identities execute "DELETE FROM identities WHERE provider = 'ldap' AND user_id IN (SELECT user_id FROM identities WHERE provider = '#{new_provider}')" # Update legacy identities execute "UPDATE identities SET provider = '#{new_provider}' WHERE provider = 'ldap';" - if defined?(LdapGroupLink) + if table_exists?('ldap_group_links') execute "UPDATE ldap_group_links SET provider = '#{new_provider}' WHERE provider IS NULL;" end end From 8b4705fea6297a23f708c59cbce3c8a3115128c0 Mon Sep 17 00:00:00 2001 From: Jacob Vosmaer Date: Mon, 13 Apr 2015 11:50:00 +0200 Subject: [PATCH 116/194] Make migration work if LDAP is disabled --- db/migrate/20150411000035_fix_identities.rb | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/db/migrate/20150411000035_fix_identities.rb b/db/migrate/20150411000035_fix_identities.rb index b65ad138b0..297e7eaa5e 100644 --- a/db/migrate/20150411000035_fix_identities.rb +++ b/db/migrate/20150411000035_fix_identities.rb @@ -9,9 +9,14 @@ class FixIdentities < ActiveRecord::Migration # behavior. Any database references to 'provider: ldap' get rewritten to # whatever the code would have interpreted it as, i.e. as a reference to # the first LDAP server specified in gitlab.yml / gitlab.rb. - new_provider = Gitlab.config.ldap.servers.first.last['provider_name'] + new_provider = if Gitlab.config.ldap.enabled + first_ldap_server = Gitlab.config.ldap.servers.values.first + first_ldap_server['provider_name'] + else + 'ldapmain' + end - # Delete duplicate identities + # Delete duplicate identities execute "DELETE FROM identities WHERE provider = 'ldap' AND user_id IN (SELECT user_id FROM identities WHERE provider = '#{new_provider}')" # Update legacy identities From ec7a68a1a5c7df1a1710eb1df31e410f6c171289 Mon Sep 17 00:00:00 2001 From: Jacob Vosmaer Date: Mon, 13 Apr 2015 11:50:44 +0200 Subject: [PATCH 117/194] Simplify legacy LDAP config interpretation --- config/initializers/1_settings.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index c25c799d8f..e23ff1f985 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -66,10 +66,11 @@ Settings.ldap['enabled'] = false if Settings.ldap['enabled'].nil? # backwards compatibility, we only have one host if Settings.ldap['enabled'] || Rails.env.test? if Settings.ldap['host'].present? + # We detected old LDAP configuration syntax. Update the config to make it + # look like it was entered with the new syntax. server = Settings.ldap.except('sync_time') - server['provider_name'] = 'ldapmain' Settings.ldap['servers'] = { - 'ldap' => server + 'main' => server } end From afa47eddccc45ed9cfcd70891d4013c9f8d04d25 Mon Sep 17 00:00:00 2001 From: Jacob Vosmaer Date: Mon, 13 Apr 2015 11:56:35 +0200 Subject: [PATCH 118/194] Also ldap_group_links where provider='ldap' --- db/migrate/20150411000035_fix_identities.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/migrate/20150411000035_fix_identities.rb b/db/migrate/20150411000035_fix_identities.rb index d4c6e54520..8f11a96ab0 100644 --- a/db/migrate/20150411000035_fix_identities.rb +++ b/db/migrate/20150411000035_fix_identities.rb @@ -23,7 +23,7 @@ class FixIdentities < ActiveRecord::Migration execute "UPDATE identities SET provider = '#{new_provider}' WHERE provider = 'ldap';" if table_exists?('ldap_group_links') - execute "UPDATE ldap_group_links SET provider = '#{new_provider}' WHERE provider IS NULL;" + execute "UPDATE ldap_group_links SET provider = '#{new_provider}' WHERE provider IS NULL OR provider = 'ldap';" end end From 8e9fbd847d8ad44edd7346428276b055c765e252 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20Rosen=C3=B6gger?= <123haynes@gmail.com> Date: Mon, 13 Apr 2015 12:32:04 +0200 Subject: [PATCH 119/194] Revert "Merge branch 'fix_email_images' into 'master'" This reverts commit d66148ef393f1748c669c934eec4e928d92ef36a, reversing changes made to cdb64a81a8ca96961033b8ab06d5191ef5449634. This change needed to be reverted, because not enough email clients support inline images. --- app/helpers/emails_helper.rb | 25 ------ app/views/notify/_note_message.html.haml | 2 +- app/views/notify/new_issue_email.html.haml | 2 +- .../notify/new_merge_request_email.html.haml | 2 +- spec/mailers/notify_spec.rb | 82 +------------------ 5 files changed, 6 insertions(+), 107 deletions(-) diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb index 6c253213c3..0df3ecc90b 100644 --- a/app/helpers/emails_helper.rb +++ b/app/helpers/emails_helper.rb @@ -1,6 +1,3 @@ -require 'html/pipeline' -require 'html/pipeline/gitlab' - module EmailsHelper # Google Actions @@ -38,26 +35,4 @@ module EmailsHelper lexer = Rugments::Lexers::Diff.new raw formatter.format(lexer.lex(diffcontent)) end - - def replace_image_links_with_base64(text, project) - # Used pipelines in GitLab: - # GitlabEmailImageFilter - replaces images that have been uploaded as attachments with inline images in emails. - # - # see https://gitlab.com/gitlab-org/html-pipeline-gitlab for more filters - filters = [ - HTML::Pipeline::Gitlab::GitlabEmailImageFilter - ] - - context = { - base_url: File.join(Gitlab.config.gitlab.url, project.path_with_namespace, 'uploads'), - upload_path: File.join(Rails.root, 'public', 'uploads', project.path_with_namespace), - } - - pipeline = HTML::Pipeline::Gitlab.new(filters).pipeline - - result = pipeline.call(text, context) - text = result[:output].to_html(save_with: 0) - - text.html_safe - end end diff --git a/app/views/notify/_note_message.html.haml b/app/views/notify/_note_message.html.haml index e796353cec..3fd4b04ac8 100644 --- a/app/views/notify/_note_message.html.haml +++ b/app/views/notify/_note_message.html.haml @@ -1,2 +1,2 @@ %div - = replace_image_links_with_base64(markdown(@note.note, reference_only_path: false), @note.project) + = markdown(@note.note, reference_only_path: false) diff --git a/app/views/notify/new_issue_email.html.haml b/app/views/notify/new_issue_email.html.haml index d4d413b5b4..53a068be52 100644 --- a/app/views/notify/new_issue_email.html.haml +++ b/app/views/notify/new_issue_email.html.haml @@ -1,5 +1,5 @@ -if @issue.description - = replace_image_links_with_base64(markdown(@issue.description, reference_only_path: false), @issue.project) + = markdown(@issue.description, reference_only_path: false) - if @issue.assignee_id.present? %p diff --git a/app/views/notify/new_merge_request_email.html.haml b/app/views/notify/new_merge_request_email.html.haml index 60e33227e0..5b7dd117c1 100644 --- a/app/views/notify/new_merge_request_email.html.haml +++ b/app/views/notify/new_merge_request_email.html.haml @@ -6,4 +6,4 @@ Assignee: #{@merge_request.author_name} → #{@merge_request.assignee_name} -if @merge_request.description - = replace_image_links_with_base64(markdown(@merge_request.description, reference_only_path: false), @merge_request.project) + = markdown(@merge_request.description, reference_only_path: false) diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index 7c4235ab37..6bea0321cb 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -191,13 +191,6 @@ describe Notify do context 'for issues' do let(:issue) { create(:issue, author: current_user, assignee: assignee, project: project) } let(:issue_with_description) { create(:issue, author: current_user, assignee: assignee, project: project, description: Faker::Lorem.sentence) } - let(:issue_with_image) do - create(:issue, - author: current_user, - assignee: assignee, - project: project, - description: "![test](#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}/uploads/12345/test.jpg)") - end describe 'that are new' do subject { Notify.new_issue_email(issue.assignee_id, issue.id) } @@ -222,22 +215,6 @@ describe Notify do end end - describe 'that contain images' do - let(:png) { File.read("#{Rails.root}/spec/fixtures/dk.png") } - let(:png_encoded) { Base64::encode64(png) } - - before :each do - file_path = File.join(Rails.root, 'public', 'uploads', issue_with_image.project.path_with_namespace, '12345/test.jpg') - allow(File).to receive(:file?).with(file_path).and_return(true) - allow(File).to receive(:read).with(file_path).and_return(png) - end - - subject { Notify.new_issue_email(issue_with_image.assignee_id, issue_with_image.id) } - it 'replaces attached images with inline images' do - is_expected.to have_body_text URI.encode(png_encoded) - end - end - describe 'that have been reassigned' do subject { Notify.reassigned_issue_email(recipient.id, issue.id, previous_assignee.id, current_user) } @@ -302,14 +279,6 @@ describe Notify do let(:merge_author) { create(:user) } let(:merge_request) { create(:merge_request, author: current_user, assignee: assignee, source_project: project, target_project: project) } let(:merge_request_with_description) { create(:merge_request, author: current_user, assignee: assignee, source_project: project, target_project: project, description: Faker::Lorem.sentence) } - let(:merge_request_with_image) do - create(:merge_request, - author: current_user, - assignee: assignee, - source_project: project, - target_project: project, - description: "![test](#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}/uploads/12345/test.jpg)") - end describe 'that are new' do subject { Notify.new_merge_request_email(merge_request.assignee_id, merge_request.id) } @@ -346,22 +315,6 @@ describe Notify do end end - describe 'that are new and contain contain images in the description' do - let(:png) {File.read("#{Rails.root}/spec/fixtures/dk.png")} - let(:png_encoded) { Base64::encode64(png) } - - before :each do - file_path = File.join(Rails.root, 'public', 'uploads', merge_request_with_image.project.path_with_namespace, '/12345/test.jpg') - allow(File).to receive(:file?).with(file_path).and_return(true) - allow(File).to receive(:read).with(file_path).and_return(png) - end - - subject { Notify.new_merge_request_email(merge_request_with_image.assignee_id, merge_request_with_image.id) } - it 'replaces attached images with inline images' do - is_expected.to have_body_text URI.encode(png_encoded) - end - end - describe 'that are reassigned' do subject { Notify.reassigned_merge_request_email(recipient.id, merge_request.id, previous_assignee.id, current_user.id) } @@ -470,12 +423,9 @@ describe Notify do describe 'project access changed' do let(:project) { create(:project) } let(:user) { create(:user) } - let(:project_member) do - create(:project_member, - project: project, - user: user) - end - + let(:project_member) { create(:project_member, + project: project, + user: user) } subject { Notify.project_access_granted_email(project_member.id) } it_behaves_like 'an email sent from GitLab' @@ -515,32 +465,6 @@ describe Notify do end end - describe 'on a commit that contains an image' do - let(:commit) { project.repository.commit } - let(:note_with_image) do - create(:note, - project: project, - author: note_author, - note: "![test](#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}/uploads/12345/test.jpg)") - end - - let(:png) {File.read("#{Rails.root}/spec/fixtures/dk.png")} - let(:png_encoded) { Base64::encode64(png) } - - before :each do - file_path = File.join(Rails.root, 'public', 'uploads', note_with_image.project.path_with_namespace, '12345/test.jpg') - allow(File).to receive(:file?).with(file_path).and_return(true) - allow(File).to receive(:read).with(file_path).and_return(png) - allow(Note).to receive(:find).with(note_with_image.id).and_return(note_with_image) - allow(note_with_image).to receive(:noteable).and_return(commit) - end - - subject { Notify.note_commit_email(recipient.id, note_with_image.id) } - it 'replaces attached images with inline images' do - is_expected.to have_body_text URI.encode(png_encoded) - end - end - describe 'on a commit' do let(:commit) { project.repository.commit } From 65a3928b0b5900872142b632b93f1937ad373e77 Mon Sep 17 00:00:00 2001 From: Jacob Vosmaer Date: Mon, 13 Apr 2015 12:58:10 +0200 Subject: [PATCH 120/194] Remove test for 'ldap' provider special case --- spec/lib/gitlab/ldap/config_spec.rb | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/spec/lib/gitlab/ldap/config_spec.rb b/spec/lib/gitlab/ldap/config_spec.rb index 2df2beca7a..00e9076c78 100644 --- a/spec/lib/gitlab/ldap/config_spec.rb +++ b/spec/lib/gitlab/ldap/config_spec.rb @@ -16,19 +16,5 @@ describe Gitlab::LDAP::Config do it "raises an error if a unknow provider is used" do expect{ Gitlab::LDAP::Config.new 'unknown' }.to raise_error end - - context "if 'ldap' is the provider name" do - let(:provider) { 'ldap' } - - context "and 'ldap' is not in defined as a provider" do - before { Gitlab::LDAP::Config.stub(providers: %w{ldapmain}) } - - it "uses the first provider" do - # Fetch the provider_name attribute from 'options' so that we know - # that the 'options' Hash is not empty/nil. - expect(config.options['provider_name']).to eq('ldapmain') - end - end - end end end From c543cd1ed384b7b4f4f45e70e9e028a5db822671 Mon Sep 17 00:00:00 2001 From: Dmitriy Zaporozhets Date: Mon, 13 Apr 2015 15:51:28 +0300 Subject: [PATCH 121/194] Fix performance issue when browse big diffs Signed-off-by: Dmitriy Zaporozhets --- app/views/projects/diffs/_diffs.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml index 2b9b6599a7..b49aee504f 100644 --- a/app/views/projects/diffs/_diffs.html.haml +++ b/app/views/projects/diffs/_diffs.html.haml @@ -20,4 +20,4 @@ Maybe diff is really big and operation failed with timeout. Try to get diff locally :coffeescript - $('.files .diff-header').stick_in_parent(recalc_every: 1, offset_top: $('.navbar').height()) + $('.files .diff-header').stick_in_parent(offset_top: $('.navbar').height()) From f39b150a02836f620fe77e1731064b5e6e01b5b1 Mon Sep 17 00:00:00 2001 From: Jacob Vosmaer Date: Mon, 13 Apr 2015 15:45:19 +0200 Subject: [PATCH 122/194] Call your existing LDAP server 'main' By imposing this rule we avoid having to demand that 'ldapmain' exists in the settings initializer. --- config/gitlab.yml.example | 9 +++++++++ config/initializers/1_settings.rb | 4 ---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index 3d91b67e74..46b9f05cc1 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -105,6 +105,15 @@ production: &base ldap: enabled: false servers: + ########################################################################## + # + # Since GitLab 7.4, LDAP servers get ID's (below the ID is 'main'). GitLab + # Enterprise Edition now supports connecting to multiple LDAP servers. + # + # If you are updating from the old (pre-7.4) syntax, you MUST give your + # old server the ID 'main'. + # + ########################################################################## main: # 'main' is the GitLab 'provider ID' of this LDAP server ## label # diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index e23ff1f985..d5cddb8dbf 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -81,10 +81,6 @@ if Settings.ldap['enabled'] || Rails.env.test? server['provider_name'] ||= "ldap#{key}".downcase server['provider_class'] = OmniAuth::Utils.camelize(server['provider_name']) end - - unless Settings.ldap['servers'].select{ |k, server| server['provider_name'] == "ldapmain"}.any? - raise "Wrong LDAP configuration. The 'main' LDAP section is missing" - end end From f57dac27121a31f365d3088caf63717103c48032 Mon Sep 17 00:00:00 2001 From: Dmitriy Zaporozhets Date: Mon, 13 Apr 2015 16:59:56 +0300 Subject: [PATCH 123/194] Bump gitlab-shell version Signed-off-by: Dmitriy Zaporozhets --- GITLAB_SHELL_VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GITLAB_SHELL_VERSION b/GITLAB_SHELL_VERSION index 6a6a3d8e35..097a15a2af 100644 --- a/GITLAB_SHELL_VERSION +++ b/GITLAB_SHELL_VERSION @@ -1 +1 @@ -2.6.1 +2.6.2 From 1265dccb6b8eaa0b51b1368e92655328514df1d5 Mon Sep 17 00:00:00 2001 From: Robert Schilling Date: Mon, 13 Apr 2015 20:01:43 +0200 Subject: [PATCH 124/194] Fix wrong merged changelog entries --- CHANGELOG | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index eab4ff4e6a..9982a32f28 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -73,16 +73,15 @@ v 7.10.0 (unreleased) - Fix git over ssh errors 'fatal: protocol error: bad line length character' - Automatically setup GitLab CI project for forks if origin project has GitLab CI enabled - Bust group page project list cache when namespace name or path changes. - -v 7.9.3 - - Contains no changes - Add icons to Add dropdown items. - Allow admin to create public deploy keys that are accessible to any project. - Warn when gitlab-shell version doesn't match requirement. - Skip email confirmation when set by admin or via LDAP. - - Only allow users to reference groups, projects, issues, MRs, commits they have access to. +v 7.9.3 + - Contains no changes + v 7.9.2 - Contains no changes From 5fa7be3ab40de4cb411bf47e1afd417023e54bc9 Mon Sep 17 00:00:00 2001 From: Robert Schilling Date: Mon, 13 Apr 2015 20:35:50 +0200 Subject: [PATCH 125/194] Remove superseeded changelog entry for commit calendar --- CHANGELOG | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index 9982a32f28..fa88ce3d8a 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -19,7 +19,6 @@ v 7.10.0 (unreleased) - Disable reference creation for comments surrounded by code/preformatted blocks (Stan Hu) - Reduce Rack Attack false positives causing 403 errors during HTTP authentication (Stan Hu) - enable line wrapping per default and remove the checkbox to toggle it (Hannes Rosenögger) - - extend the commit calendar to show the actual commits made on a date (Hannes Rosenögger) - Fix a link in the patch update guide - Add a service to support external wikis (Hannes Rosenögger) - Omit the "email patches" link and fix plain diff view for merge commits From 57c2a9f03d9896d6b9358b8530f2e70f15cbdad3 Mon Sep 17 00:00:00 2001 From: Sven Strickroth Date: Wed, 8 Apr 2015 13:31:36 +0200 Subject: [PATCH 126/194] Explicitly set image alt to prevent graphical glitches Rails image_tag sets the alt-attribute to the filename which causes graphical glitches if the file could not be loaded. Fixes https://github.com/gitlabhq/gitlabhq/issues/6036. Signed-off-by: Sven Strickroth --- CHANGELOG | 1 + app/views/groups/group_members/_group_member.html.haml | 2 +- app/views/groups/milestones/_issue.html.haml | 2 +- app/views/groups/milestones/_merge_request.html.haml | 2 +- app/views/projects/milestones/_issue.html.haml | 2 +- app/views/projects/milestones/_merge_request.html.haml | 2 +- app/views/projects/notes/_note.html.haml | 4 ++-- app/views/projects/project_members/_project_member.html.haml | 2 +- app/views/snippets/show.html.haml | 2 +- 9 files changed, 10 insertions(+), 9 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index d176ee3fa6..f25e65265e 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -63,6 +63,7 @@ v 7.10.0 (unreleased) - Fixed link paths for HTTP and SSH on the admin project view (Jeremy Maziarz) - Fix and improve help rendering (Sullivan Sénéchal) - Fix final line in EmailsOnPush email diff being rendered as error. + - Explicitly set image alt-attribute to prevent graphical glitches if gravatars could not be loaded - Add icons to Add dropdown items. diff --git a/app/views/groups/group_members/_group_member.html.haml b/app/views/groups/group_members/_group_member.html.haml index 30e5faf822..2462c95209 100644 --- a/app/views/groups/group_members/_group_member.html.haml +++ b/app/views/groups/group_members/_group_member.html.haml @@ -4,7 +4,7 @@ %li{class: "#{dom_class(member)} js-toggle-container", id: dom_id(member)} %span{class: ("list-item-name" if show_controls)} - = image_tag avatar_icon(user.email, 16), class: "avatar s16" + = image_tag avatar_icon(user.email, 16), class: "avatar s16", alt: '' %strong= user.name %span.cgray= user.username - if user == current_user diff --git a/app/views/groups/milestones/_issue.html.haml b/app/views/groups/milestones/_issue.html.haml index 27d0c62df8..09f9b4b896 100644 --- a/app/views/groups/milestones/_issue.html.haml +++ b/app/views/groups/milestones/_issue.html.haml @@ -7,4 +7,4 @@ = link_to_gfm issue.title, [project.namespace.becomes(Namespace), project, issue], title: issue.title .pull-right.assignee-icon - if issue.assignee - = image_tag avatar_icon(issue.assignee.email, 16), class: "avatar s16" + = image_tag avatar_icon(issue.assignee.email, 16), class: "avatar s16", alt: '' diff --git a/app/views/groups/milestones/_merge_request.html.haml b/app/views/groups/milestones/_merge_request.html.haml index b2d2097dfa..d0d1426762 100644 --- a/app/views/groups/milestones/_merge_request.html.haml +++ b/app/views/groups/milestones/_merge_request.html.haml @@ -7,4 +7,4 @@ = link_to_gfm merge_request.title, [project.namespace.becomes(Namespace), project, merge_request], title: merge_request.title .pull-right.assignee-icon - if merge_request.assignee - = image_tag avatar_icon(merge_request.assignee.email, 16), class: "avatar s16" + = image_tag avatar_icon(merge_request.assignee.email, 16), class: "avatar s16", alt: '' diff --git a/app/views/projects/milestones/_issue.html.haml b/app/views/projects/milestones/_issue.html.haml index 26c83841a2..9f164d44f2 100644 --- a/app/views/projects/milestones/_issue.html.haml +++ b/app/views/projects/milestones/_issue.html.haml @@ -5,5 +5,5 @@ = link_to_gfm issue.title, [@project.namespace.becomes(Namespace), @project, issue], title: issue.title .pull-right.assignee-icon - if issue.assignee - = image_tag avatar_icon(issue.assignee.email, 16), class: "avatar s16" + = image_tag avatar_icon(issue.assignee.email, 16), class: "avatar s16", alt: '' diff --git a/app/views/projects/milestones/_merge_request.html.haml b/app/views/projects/milestones/_merge_request.html.haml index 42fbd0cd2c..0d7a118569 100644 --- a/app/views/projects/milestones/_merge_request.html.haml +++ b/app/views/projects/milestones/_merge_request.html.haml @@ -5,4 +5,4 @@ = link_to_gfm merge_request.title, [@project.namespace.becomes(Namespace), @project, merge_request], title: merge_request.title .pull-right.assignee-icon - if merge_request.assignee - = image_tag avatar_icon(merge_request.assignee.email, 16), class: "avatar s16" + = image_tag avatar_icon(merge_request.assignee.email, 16), class: "avatar s16", alt: '' diff --git a/app/views/projects/notes/_note.html.haml b/app/views/projects/notes/_note.html.haml index 71bdf5c8f2..0728f8fa42 100644 --- a/app/views/projects/notes/_note.html.haml +++ b/app/views/projects/notes/_note.html.haml @@ -5,7 +5,7 @@ %span.fa.fa-circle - else = link_to user_path(note.author) do - = image_tag avatar_icon(note.author_email), class: "avatar s40" + = image_tag avatar_icon(note.author_email), class: "avatar s40", alt: '' .timeline-content .note-header .note-actions @@ -23,7 +23,7 @@ Remove - if note.system = link_to user_path(note.author) do - = image_tag avatar_icon(note.author_email), class: "avatar s16" + = image_tag avatar_icon(note.author_email), class: "avatar s16", alt: '' = link_to_member(@project, note.author, avatar: false) %span.author-username = '@' + note.author.username diff --git a/app/views/projects/project_members/_project_member.html.haml b/app/views/projects/project_members/_project_member.html.haml index a07d076233..4f05397721 100644 --- a/app/views/projects/project_members/_project_member.html.haml +++ b/app/views/projects/project_members/_project_member.html.haml @@ -3,7 +3,7 @@ %li{class: "#{dom_class(member)} js-toggle-container project_member_row access-#{member.human_access.downcase}", id: dom_id(member)} %span.list-item-name - = image_tag avatar_icon(user.email, 16), class: "avatar s16" + = image_tag avatar_icon(user.email, 16), class: "avatar s16", alt: '' %strong= user.name %span.cgray= user.username - if user == current_user diff --git a/app/views/snippets/show.html.haml b/app/views/snippets/show.html.haml index 55a990c94e..5204fb9a90 100644 --- a/app/views/snippets/show.html.haml +++ b/app/views/snippets/show.html.haml @@ -17,7 +17,7 @@ %span.light by = link_to user_snippets_path(@snippet.author) do - = image_tag avatar_icon(@snippet.author_email), class: "avatar avatar-inline s16" + = image_tag avatar_icon(@snippet.author_email), class: "avatar avatar-inline s16", alt: '' = @snippet.author_name .back-link From a9306613f6502611d6f1eae268e82ed53943fa6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20Rosen=C3=B6gger?= <123haynes@gmail.com> Date: Mon, 13 Apr 2015 22:09:44 +0200 Subject: [PATCH 127/194] The help link has been removed. The commit removes the test for it. --- features/dashboard/active_tab.feature | 6 ------ features/steps/dashboard/active_tab.rb | 9 --------- features/steps/shared/paths.rb | 4 ---- 3 files changed, 19 deletions(-) delete mode 100644 features/steps/dashboard/active_tab.rb diff --git a/features/dashboard/active_tab.feature b/features/dashboard/active_tab.feature index 08b87808f3..803f9b3383 100644 --- a/features/dashboard/active_tab.feature +++ b/features/dashboard/active_tab.feature @@ -1,5 +1,4 @@ @dashboard -Feature: Dashboard Active Tab Background: Given I sign in as a user @@ -17,8 +16,3 @@ Feature: Dashboard Active Tab Given I visit dashboard merge requests page Then the active main tab should be Merge Requests And no other main tabs should be active - - Scenario: On Dashboard Help - Given I visit dashboard help page - Then the active main tab should be Help - And no other main tabs should be active diff --git a/features/steps/dashboard/active_tab.rb b/features/steps/dashboard/active_tab.rb deleted file mode 100644 index 0e2c04fb29..0000000000 --- a/features/steps/dashboard/active_tab.rb +++ /dev/null @@ -1,9 +0,0 @@ -class Spinach::Features::DashboardActiveTab < Spinach::FeatureSteps - include SharedAuthentication - include SharedPaths - include SharedActiveTab - - step 'the active main tab should be Help' do - ensure_active_main_tab('Help') - end -end diff --git a/features/steps/shared/paths.rb b/features/steps/shared/paths.rb index e3cf1b92cd..8ce17b9414 100644 --- a/features/steps/shared/paths.rb +++ b/features/steps/shared/paths.rb @@ -83,10 +83,6 @@ module SharedPaths visit search_path end - step 'I visit dashboard help page' do - visit help_path - end - step 'I visit dashboard groups page' do visit dashboard_groups_path end From a62dbd11cb14e6e96e89cc7e9f944072a0e9ed1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20Rosen=C3=B6gger?= <123haynes@gmail.com> Date: Mon, 13 Apr 2015 23:19:04 +0200 Subject: [PATCH 128/194] fix failing test --- features/dashboard/active_tab.feature | 1 + features/steps/dashboard/active_tab.rb | 6 ++++++ 2 files changed, 7 insertions(+) create mode 100644 features/steps/dashboard/active_tab.rb diff --git a/features/dashboard/active_tab.feature b/features/dashboard/active_tab.feature index 803f9b3383..c17dfd0706 100644 --- a/features/dashboard/active_tab.feature +++ b/features/dashboard/active_tab.feature @@ -1,4 +1,5 @@ @dashboard +Feature: Dashboard Active Tab Background: Given I sign in as a user diff --git a/features/steps/dashboard/active_tab.rb b/features/steps/dashboard/active_tab.rb new file mode 100644 index 0000000000..19ff77b9fe --- /dev/null +++ b/features/steps/dashboard/active_tab.rb @@ -0,0 +1,6 @@ +class Spinach::Features::DashboardActiveTab < Spinach::FeatureSteps + include SharedAuthentication + include SharedPaths + include SharedActiveTab + +end From fb1cd0a842a71903bf54e32aed84ed43254be552 Mon Sep 17 00:00:00 2001 From: Ben Bodenmiller Date: Tue, 14 Apr 2015 00:15:34 -0700 Subject: [PATCH 129/194] fix stuck MR fixes #348. related to 6487419364fa9c179e24028d85b2be10d574067f. --- CHANGELOG | 1 + app/models/merge_request.rb | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index eab4ff4e6a..d079c04406 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -73,6 +73,7 @@ v 7.10.0 (unreleased) - Fix git over ssh errors 'fatal: protocol error: bad line length character' - Automatically setup GitLab CI project for forks if origin project has GitLab CI enabled - Bust group page project list cache when namespace name or path changes. + - Fix stuck Merge Request merging events from old installations (Ben Bodenmiller) v 7.9.3 - Contains no changes diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 35cb920d8b..92026356b6 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -361,6 +361,6 @@ class MergeRequest < ActiveRecord::Base end def locked_long_ago? - locked_at && locked_at < (Time.now - 1.day) + locked_at.nil? || locked_at < (Time.now - 1.day) end end From bf2db3ec03913d3aaba2ff02f00e03e9e0974265 Mon Sep 17 00:00:00 2001 From: Zbigniew Motyka Date: Tue, 14 Apr 2015 10:20:07 +0200 Subject: [PATCH 130/194] Fix error message --- app/views/import/base/create.js.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/import/base/create.js.haml b/app/views/import/base/create.js.haml index 8d10722628..90a6f5f9d2 100644 --- a/app/views/import/base/create.js.haml +++ b/app/views/import/base/create.js.haml @@ -13,7 +13,7 @@ - elsif @access_denied :plain job = $("tr#repo_#{@repo_id}") - job.find(".import-actions").html("

    Access denied! Please verify you can add deploy keys to this repository.

    "") + job.find(".import-actions").html("

    Access denied! Please verify you can add deploy keys to this repository.

    ") - else :plain job = $("tr#repo_#{@repo_id}") From ae6d0aaae0d8f6251b30a8e5243a98f083f0cac9 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 14 Apr 2015 11:03:23 +0200 Subject: [PATCH 131/194] Revert "Merge branch 'fix-stuck-mr' into 'master'" This reverts commit 79b4d0b0c0682fc31d794d8b42d02b3d1bb5b9dc, reversing changes made to a1e147ce223ee52066e7f0ce77ece82eb2dc6bbe. --- CHANGELOG | 1 - app/models/merge_request.rb | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index fc721d3d15..f133cb2478 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -73,7 +73,6 @@ v 7.10.0 (unreleased) - Automatically setup GitLab CI project for forks if origin project has GitLab CI enabled - Bust group page project list cache when namespace name or path changes. - Explicitly set image alt-attribute to prevent graphical glitches if gravatars could not be loaded - - Fix stuck Merge Request merging events from old installations (Ben Bodenmiller) v 7.9.3 - Contains no changes diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 92026356b6..35cb920d8b 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -361,6 +361,6 @@ class MergeRequest < ActiveRecord::Base end def locked_long_ago? - locked_at.nil? || locked_at < (Time.now - 1.day) + locked_at && locked_at < (Time.now - 1.day) end end From e245547ea1c7a5c6e85cbbd4c634c3d11b80036e Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 14 Apr 2015 12:00:29 +0200 Subject: [PATCH 132/194] Add changelog entry. --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index 25936eb1e1..aaf01e009c 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 7.10.0 (unreleased) + - Fix generating SSH key fingerprints with OpenSSH 6.8. (Sašo Stanovnik) - Include missing events and fix save functionality in admin service template settings form (Stan Hu) - Fix "Import projects from" button to show the correct instructions (Stan Hu) - Fix dots in Wiki slugs causing errors (Stan Hu) From 1221bec56ca7d3bf1ba3b09e7a283ff5c11449f2 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 14 Apr 2015 12:00:43 +0200 Subject: [PATCH 133/194] Refactor key fingerprint generation. --- app/models/key.rb | 40 +----------------- lib/gitlab/key_fingerprint.rb | 55 +++++++++++++++++++++++++ spec/lib/gitlab/key_fingerprint_spec.rb | 12 ++++++ 3 files changed, 69 insertions(+), 38 deletions(-) create mode 100644 lib/gitlab/key_fingerprint.rb create mode 100644 spec/lib/gitlab/key_fingerprint_spec.rb diff --git a/app/models/key.rb b/app/models/key.rb index 480a16f933..016eee8699 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -16,7 +16,6 @@ require 'digest/md5' class Key < ActiveRecord::Base include Sortable - include Gitlab::Popen belongs_to :user @@ -79,44 +78,9 @@ class Key < ActiveRecord::Base def generate_fingerprint self.fingerprint = nil - return unless key.present? - cmd_status = 0 - cmd_output = '' - explicit_fingerprint_algorithm = false - Tempfile.open('gitlab_key_file') do |file| - file.puts key - file.rewind + return unless self.key.present? - # OpenSSH 6.8 introduces a new default output format for fingerprints. - # Check the version and decide which command to use. - version_output, version_status = popen(%W(ssh -V)) - if version_status.zero? - out, _ = version_output.scan /.*?(\d)\.(\d).*?,/ - major, minor = out[0], out[1] - if major.to_i > 6 or (major.to_i == 6 and minor.to_i >= 8) - explicit_fingerprint_algorithm = true - end - end - - if explicit_fingerprint_algorithm - cmd_output, cmd_status = popen(%W(ssh-keygen -E md5 -lf #{file.path}), '/tmp') - else - cmd_output, cmd_status = popen(%W(ssh-keygen -lf #{file.path}), '/tmp') - end - end - - if cmd_status.zero? - if explicit_fingerprint_algorithm - cmd_output.gsub /(MD5:)(\h{2}:)+\h{2}/ do |match| - match.slice! /^MD5:/ - self.fingerprint = match - end - else - cmd_output.gsub /(\h{2}:)+\h{2}/ do |match| - self.fingerprint = match - end - end - end + self.fingerprint = Gitlab::KeyFingerprint.new(self.key).fingerprint end end diff --git a/lib/gitlab/key_fingerprint.rb b/lib/gitlab/key_fingerprint.rb new file mode 100644 index 0000000000..550b10a4c9 --- /dev/null +++ b/lib/gitlab/key_fingerprint.rb @@ -0,0 +1,55 @@ +module Gitlab + class KeyFingerprint + include Gitlab::Popen + + attr_accessor :key + + def initialize(key) + @key = key + end + + def fingerprint + cmd_status = 0 + cmd_output = '' + + Tempfile.open('gitlab_key_file') do |file| + file.puts key + file.rewind + + cmd = [] + cmd.push *%W(ssh-keygen) + cmd.push *%W(-E md5) if explicit_fingerprint_algorithm? + cmd.push *%W(-lf #{file.path}) + + cmd_output, cmd_status = popen(cmd, '/tmp') + end + + return nil unless cmd_status.zero? + + # 16 hex bytes separated by ':', optionally starting with "MD5:" + fingerprint_match = cmd_output.match(/(MD5:)?(?(?:\h{2}:){15}\h{2})/) + return nil unless fingerprint_match + + fingerprint_match[:fingerprint] + end + + private + + def explicit_fingerprint_algorithm? + # OpenSSH 6.8 introduces a new default output format for fingerprints. + # Check the version and decide which command to use. + + version_output, version_status = popen(%W(ssh -V)) + return false unless version_status.zero? + + version_matches = version_output.match(/OpenSSH_(?\d+)\.(?\d+)/) + return false unless version_matches + + version_info = Gitlab::VersionInfo.new(version_matches[:major].to_i, version_matches[:minor].to_i) + + required_version_info = Gitlab::VersionInfo.new(6, 8) + + version_info >= required_version_info + end + end +end diff --git a/spec/lib/gitlab/key_fingerprint_spec.rb b/spec/lib/gitlab/key_fingerprint_spec.rb new file mode 100644 index 0000000000..266eab6e79 --- /dev/null +++ b/spec/lib/gitlab/key_fingerprint_spec.rb @@ -0,0 +1,12 @@ +require "spec_helper" + +describe Gitlab::KeyFingerprint do + let(:key) { "ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt4596k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0=" } + let(:fingerprint) { "3f:a2:ee:de:b5:de:53:c3:aa:2f:9c:45:24:4c:47:7b" } + + describe "#fingerprint" do + it "generates the key's fingerprint" do + expect(Gitlab::KeyFingerprint.new(key).fingerprint).to eq(fingerprint) + end + end +end From ff3caad4caf46049c8f202849a05fb15a2aaccb8 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Apr 2015 14:39:10 +0200 Subject: [PATCH 134/194] Rename manage_group ability to admin_group for consistency with project. --- app/controllers/groups/application_controller.rb | 2 +- app/controllers/groups/milestones_controller.rb | 2 +- app/models/ability.rb | 12 ++++++------ app/views/dashboard/groups/index.html.haml | 2 +- app/views/groups/group_members/index.html.haml | 2 +- app/views/groups/milestones/_milestone.html.haml | 2 +- app/views/groups/milestones/show.html.haml | 2 +- app/views/groups/projects.html.haml | 2 +- app/views/layouts/nav/_group.html.haml | 2 +- lib/api/group_members.rb | 6 +++--- lib/api/groups.rb | 2 +- 11 files changed, 18 insertions(+), 18 deletions(-) diff --git a/app/controllers/groups/application_controller.rb b/app/controllers/groups/application_controller.rb index a73b8fa212..469a6813ee 100644 --- a/app/controllers/groups/application_controller.rb +++ b/app/controllers/groups/application_controller.rb @@ -13,7 +13,7 @@ class Groups::ApplicationController < ApplicationController end def authorize_admin_group! - unless can?(current_user, :manage_group, group) + unless can?(current_user, :admin_group, group) return render_404 end end diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb index c46b8fff88..546ff2cc71 100644 --- a/app/controllers/groups/milestones_controller.rb +++ b/app/controllers/groups/milestones_controller.rb @@ -51,6 +51,6 @@ class Groups::MilestonesController < ApplicationController end def authorize_group_milestone! - return render_404 unless can?(current_user, :manage_group, group) + return render_404 unless can?(current_user, :admin_group, group) end end diff --git a/app/models/ability.rb b/app/models/ability.rb index d2b39f667f..85a15596f8 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -198,11 +198,11 @@ class Ability ]) end - # Only group owner and administrators can manage group + # Only group owner and administrators can admin group if group.has_owner?(user) || user.admin? rules.push(*[ - :manage_group, - :manage_namespace + :admin_group, + :admin_namespace ]) end @@ -212,11 +212,11 @@ class Ability def namespace_abilities(user, namespace) rules = [] - # Only namespace owner and administrators can manage it + # Only namespace owner and administrators can admin it if namespace.owner == user || user.admin? rules.push(*[ :create_projects, - :manage_namespace + :admin_namespace ]) end @@ -254,7 +254,7 @@ class Ability rules = [] target_user = subject.user group = subject.group - can_manage = group_abilities(user, group).include?(:manage_group) + can_manage = group_abilities(user, group).include?(:admin_group) if can_manage && (user != target_user) rules << :modify_group_member rules << :destroy_group_member diff --git a/app/views/dashboard/groups/index.html.haml b/app/views/dashboard/groups/index.html.haml index 165db214d7..0cb7f764fa 100644 --- a/app/views/dashboard/groups/index.html.haml +++ b/app/views/dashboard/groups/index.html.haml @@ -17,7 +17,7 @@ - group = group_member.group %li .pull-right - - if can?(current_user, :manage_group, group) + - if can?(current_user, :admin_group, group) = link_to edit_group_path(group), class: "btn-sm btn btn-grouped" do %i.fa.fa-cogs Settings diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml index 0d501fe7bd..c0c9cd170a 100644 --- a/app/views/groups/group_members/index.html.haml +++ b/app/views/groups/group_members/index.html.haml @@ -16,7 +16,7 @@ = search_field_tag :search, params[:search], { placeholder: 'Find existing member by name', class: 'form-control search-text-input input-mn-300' } = button_tag 'Search', class: 'btn' - - if current_user && current_user.can?(:manage_group, @group) + - if current_user && current_user.can?(:admin_group, @group) .pull-right = button_tag class: 'btn btn-new js-toggle-button', type: 'button' do Add members diff --git a/app/views/groups/milestones/_milestone.html.haml b/app/views/groups/milestones/_milestone.html.haml index 94fc43a581..30093d2d05 100644 --- a/app/views/groups/milestones/_milestone.html.haml +++ b/app/views/groups/milestones/_milestone.html.haml @@ -1,6 +1,6 @@ %li{class: "milestone milestone-#{milestone.closed? ? 'closed' : 'open'}", id: dom_id(milestone.milestones.first) } .pull-right - - if can?(current_user, :manage_group, @group) + - if can?(current_user, :admin_group, @group) - if milestone.closed? = link_to 'Reopen Milestone', group_milestone_path(@group, milestone.safe_title, title: milestone.title, milestone: {state_event: :activate }), method: :put, class: "btn btn-sm btn-grouped btn-reopen" - else diff --git a/app/views/groups/milestones/show.html.haml b/app/views/groups/milestones/show.html.haml index fea70f5cbc..fb32f2caa4 100644 --- a/app/views/groups/milestones/show.html.haml +++ b/app/views/groups/milestones/show.html.haml @@ -6,7 +6,7 @@ Open Milestone #{@group_milestone.title} .pull-right - - if can?(current_user, :manage_group, @group) + - if can?(current_user, :admin_group, @group) - if @group_milestone.active? = link_to 'Close Milestone', group_milestone_path(@group, @group_milestone.safe_title, title: @group_milestone.title, milestone: {state_event: :close }), method: :put, class: "btn btn-sm btn-close" - else diff --git a/app/views/groups/projects.html.haml b/app/views/groups/projects.html.haml index dd1fa3840d..0d547984cc 100644 --- a/app/views/groups/projects.html.haml +++ b/app/views/groups/projects.html.haml @@ -2,7 +2,7 @@ .panel-heading %strong= @group.name projects: - - if can? current_user, :manage_group, @group + - if can? current_user, :admin_group, @group .panel-head-actions = link_to new_project_path(namespace_id: @group.id), class: "btn btn-sm btn-success" do %i.fa.fa-plus diff --git a/app/views/layouts/nav/_group.html.haml b/app/views/layouts/nav/_group.html.haml index 32fe0e37df..f0d92b7a12 100644 --- a/app/views/layouts/nav/_group.html.haml +++ b/app/views/layouts/nav/_group.html.haml @@ -30,7 +30,7 @@ %span Members - - if can?(current_user, :manage_group, @group) + - if can?(current_user, :admin_group, @group) = nav_link(html_options: { class: "#{"active" if group_settings_page?} separate-item" }) do = link_to edit_group_path(@group), title: 'Settings', class: "tab no-highlight" do %i.fa.fa-cogs diff --git a/lib/api/group_members.rb b/lib/api/group_members.rb index ed54c7f6ff..ab5b2d6d9a 100644 --- a/lib/api/group_members.rb +++ b/lib/api/group_members.rb @@ -24,7 +24,7 @@ module API # POST /groups/:id/members post ":id/members" do group = find_group(params[:id]) - authorize! :manage_group, group + authorize! :admin_group, group required_attributes! [:user_id, :access_level] unless validate_access_level?(params[:access_level]) @@ -50,7 +50,7 @@ module API # PUT /groups/:id/members/:user_id put ':id/members/:user_id' do group = find_group(params[:id]) - authorize! :manage_group, group + authorize! :admin_group, group required_attributes! [:access_level] group_member = group.group_members.find_by(user_id: params[:user_id]) @@ -74,7 +74,7 @@ module API # DELETE /groups/:id/members/:user_id delete ":id/members/:user_id" do group = find_group(params[:id]) - authorize! :manage_group, group + authorize! :admin_group, group member = group.group_members.find_by(user_id: params[:user_id]) if member.nil? diff --git a/lib/api/groups.rb b/lib/api/groups.rb index a92abd4b69..8cb9f92097 100644 --- a/lib/api/groups.rb +++ b/lib/api/groups.rb @@ -61,7 +61,7 @@ module API # DELETE /groups/:id delete ":id" do group = find_group(params[:id]) - authorize! :manage_group, group + authorize! :admin_group, group group.destroy end From e6569defe61f114beb051a1ebf8186f03eed1f5c Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Apr 2015 14:40:26 +0200 Subject: [PATCH 135/194] Use through-relation instead of manually mapping. --- lib/api/group_members.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/api/group_members.rb b/lib/api/group_members.rb index ab5b2d6d9a..3a3987ab36 100644 --- a/lib/api/group_members.rb +++ b/lib/api/group_members.rb @@ -9,8 +9,7 @@ module API # GET /groups/:id/members get ":id/members" do group = find_group(params[:id]) - members = group.group_members - users = (paginate members).collect(&:user) + users = group.users present users, with: Entities::GroupMember, group: group end From 1e97864958b5ee7cb92a6d45ceab6b6e476c5e7e Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Apr 2015 14:41:21 +0200 Subject: [PATCH 136/194] Add invite data to member. --- .../20150406133311_add_invite_data_to_member.rb | 12 ++++++++++++ db/schema.rb | 7 ++++++- 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20150406133311_add_invite_data_to_member.rb diff --git a/db/migrate/20150406133311_add_invite_data_to_member.rb b/db/migrate/20150406133311_add_invite_data_to_member.rb new file mode 100644 index 0000000000..3452fd45c4 --- /dev/null +++ b/db/migrate/20150406133311_add_invite_data_to_member.rb @@ -0,0 +1,12 @@ +class AddInviteDataToMember < ActiveRecord::Migration + def change + add_column :members, :created_by_id, :integer + add_column :members, :invite_email, :string + add_column :members, :invite_token, :string + add_column :members, :invite_accepted_at, :datetime + + change_column :members, :user_id, :integer, null: true + + add_index :members, :invite_token, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 48f1b2ac2c..11371f168b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -163,15 +163,20 @@ ActiveRecord::Schema.define(version: 20150411180045) do t.integer "access_level", null: false t.integer "source_id", null: false t.string "source_type", null: false - t.integer "user_id", null: false + t.integer "user_id" t.integer "notification_level", null: false t.string "type" t.datetime "created_at" t.datetime "updated_at" + t.integer "created_by_id" + t.string "invite_email" + t.string "invite_token" + t.datetime "invite_accepted_at" end add_index "members", ["access_level"], name: "index_members_on_access_level", using: :btree add_index "members", ["created_at", "id"], name: "index_members_on_created_at_and_id", using: :btree + add_index "members", ["invite_token"], name: "index_members_on_invite_token", unique: true, using: :btree add_index "members", ["source_id", "source_type"], name: "index_members_on_source_id_and_source_type", using: :btree add_index "members", ["type"], name: "index_members_on_type", using: :btree add_index "members", ["user_id"], name: "index_members_on_user_id", using: :btree From 2b2bd402dcee427a43de771036b348e156fea6c9 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Apr 2015 14:46:09 +0200 Subject: [PATCH 137/194] Track who created a group or project member. --- app/controllers/admin/groups_controller.rb | 2 +- .../groups/group_members_controller.rb | 2 +- .../projects/project_members_controller.rb | 2 +- app/models/group.rb | 16 ++++++----- app/models/member.rb | 5 ++++ app/models/members/project_member.rb | 3 +- app/models/project_team.rb | 28 +++++++++---------- app/services/projects/create_service.rb | 2 +- app/services/projects/fork_service.rb | 2 +- lib/api/group_members.rb | 2 +- spec/requests/api/repositories_spec.rb | 2 -- 11 files changed, 35 insertions(+), 31 deletions(-) diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb index 9d9adaa467..22d045fc38 100644 --- a/app/controllers/admin/groups_controller.rb +++ b/app/controllers/admin/groups_controller.rb @@ -41,7 +41,7 @@ class Admin::GroupsController < Admin::ApplicationController end def members_update - @group.add_users(params[:user_ids].split(','), params[:access_level]) + @group.add_users(params[:user_ids].split(','), params[:access_level], current_user) redirect_to [:admin, @group], notice: 'Users were successfully added.' end diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index 2df51c97a2..93fc4edb3b 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -22,7 +22,7 @@ class Groups::GroupMembersController < Groups::ApplicationController end def create - @group.add_users(params[:user_ids].split(','), params[:access_level]) + @group.add_users(params[:user_ids].split(','), params[:access_level], current_user) redirect_to group_group_members_path(@group), notice: 'Users were successfully added.' end diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index 4ab15db01f..e534db7af8 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -69,7 +69,7 @@ class Projects::ProjectMembersController < Projects::ApplicationController def apply_import giver = Project.find(params[:source_project_id]) - status = @project.team.import(giver) + status = @project.team.import(giver, current_user) notice = status ? "Successfully imported" : "Import failed" redirect_to(namespace_project_project_members_path(project.namespace, project), diff --git a/app/models/group.rb b/app/models/group.rb index da9621a2a1..eec961f601 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -46,19 +46,21 @@ class Group < Namespace @owners ||= group_members.owners.map(&:user) end - def add_users(user_ids, access_level) + def add_users(user_ids, access_level, current_user = nil) user_ids.compact.each do |user_id| - user = self.group_members.find_or_initialize_by(user_id: user_id) - user.update_attributes(access_level: access_level) + member = self.group_members.find_or_initialize_by(user_id: user_id) + member.access_level = access_level + member.created_by ||= current_user + member.save end end - def add_user(user, access_level) - self.group_members.create(user_id: user.id, access_level: access_level) + def add_user(user, access_level, current_user = nil) + add_users([user], access_level, current_user) end - def add_owner(user) - self.add_user(user, Gitlab::Access::OWNER) + def add_owner(user, current_user = nil) + self.add_user(user, Gitlab::Access::OWNER, current_user) end def has_owner?(user) diff --git a/app/models/member.rb b/app/models/member.rb index fe3d2f40e8..d92e69b2ce 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -11,6 +11,10 @@ # type :string(255) # created_at :datetime # updated_at :datetime +# created_by_id :integer +# invite_email :string +# invite_token :string +# invite_accepted_at :datetime # class Member < ActiveRecord::Base @@ -18,6 +22,7 @@ class Member < ActiveRecord::Base include Notifiable include Gitlab::Access + belongs_to :created_by, class_name: "User" belongs_to :user belongs_to :source, polymorphic: true diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index 6b13e0ff30..2205041cd5 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -55,7 +55,7 @@ class ProjectMember < Member # :master # ) # - def add_users_into_projects(project_ids, user_ids, access) + def add_users_into_projects(project_ids, user_ids, access, current_user = nil) access_level = if roles_hash.has_key?(access) roles_hash[access] elsif roles_hash.values.include?(access.to_i) @@ -69,6 +69,7 @@ class ProjectMember < Member user_ids.each do |user_id| member = ProjectMember.new(access_level: access_level, user_id: user_id) member.source_id = project_id + member.created_by ||= current_user member.save end end diff --git a/app/models/project_team.rb b/app/models/project_team.rb index d4a07caf9e..313e459d70 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -12,12 +12,12 @@ class ProjectTeam # @team << [@users, :master] # def <<(args) - users = args.first + users, access, current_user = *args if users.respond_to?(:each) - add_users(users, args.second) + add_users(users, access, current_user) else - add_user(users, args.second) + add_user(users, access, current_user) end end @@ -43,22 +43,19 @@ class ProjectTeam member end - def add_user(user, access) - add_users_ids([user.id], access) - end - - def add_users(users, access) - add_users_ids(users.map(&:id), access) - end - - def add_users_ids(user_ids, access) + def add_users(users, access, current_user = nil) ProjectMember.add_users_into_projects( [project.id], - user_ids, - access + users, + access, + current_user ) end + def add_user(user, access, current_user = nil) + add_users([user], access, current_user) + end + # Remove all users from project team def truncate ProjectMember.truncate_team(project) @@ -88,7 +85,7 @@ class ProjectTeam @masters ||= fetch_members(:masters) end - def import(source_project) + def import(source_project, current_user = nil) target_project = project source_members = source_project.project_members.to_a @@ -103,6 +100,7 @@ class ProjectTeam new_member = member.dup new_member.id = nil new_member.source = target_project + new_member.created_by = current_user new_member end diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index 7ffd0b3882..a7afcf8f64 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -83,7 +83,7 @@ module Projects system_hook_service.execute_hooks_for(@project, :create) unless @project.group - @project.team << [current_user, :master] + @project.team << [current_user, :master, current_user] end @project.update_column(:last_activity_at, @project.created_at) diff --git a/app/services/projects/fork_service.rb b/app/services/projects/fork_service.rb index 4ec98696a6..1e4deb6ed3 100644 --- a/app/services/projects/fork_service.rb +++ b/app/services/projects/fork_service.rb @@ -38,7 +38,7 @@ module Projects #First save the DB entries as they can be rolled back if the repo fork fails project.build_forked_project_link(forked_to_project_id: project.id, forked_from_project_id: @from_project.id) if project.save - project.team << [@current_user, :master] + project.team << [@current_user, :master, @current_user] end #Now fork the repo diff --git a/lib/api/group_members.rb b/lib/api/group_members.rb index 3a3987ab36..ab9b7c602b 100644 --- a/lib/api/group_members.rb +++ b/lib/api/group_members.rb @@ -34,7 +34,7 @@ module API render_api_error!("Already exists", 409) end - group.add_users([params[:user_id]], params[:access_level]) + group.add_users([params[:user_id]], params[:access_level], current_user) member = group.group_members.find_by(user_id: params[:user_id]) present member.user, with: Entities::GroupMember, group: group end diff --git a/spec/requests/api/repositories_spec.rb b/spec/requests/api/repositories_spec.rb index 729970153d..09a79553f7 100644 --- a/spec/requests/api/repositories_spec.rb +++ b/spec/requests/api/repositories_spec.rb @@ -11,8 +11,6 @@ describe API::API, api: true do let!(:master) { create(:project_member, user: user, project: project, access_level: ProjectMember::MASTER) } let!(:guest) { create(:project_member, user: user2, project: project, access_level: ProjectMember::GUEST) } - before { project.team << [user, :reporter] } - describe "GET /projects/:id/repository/tags" do it "should return an array of project tags" do get api("/projects/#{project.id}/repository/tags", user) From 629fa7fbe3a8a2f06d7c053ddcbcd78beed24e9c Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Apr 2015 15:09:37 +0200 Subject: [PATCH 138/194] Add invite logic to Member. --- app/models/member.rb | 82 +++++++++++++++++++++++++++- app/models/members/group_member.rb | 23 ++------ app/models/members/project_member.rb | 33 +++++------ spec/models/members_spec.rb | 79 +++++++++++++++++++++++++++ 4 files changed, 179 insertions(+), 38 deletions(-) diff --git a/app/models/member.rb b/app/models/member.rb index d92e69b2ce..ec79f53174 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -26,16 +26,94 @@ class Member < ActiveRecord::Base belongs_to :user belongs_to :source, polymorphic: true - validates :user, presence: true + validates :user, presence: true, unless: :invite? validates :source, presence: true - validates :user_id, uniqueness: { scope: [:source_type, :source_id], message: "already exists in source" } + validates :user_id, uniqueness: { scope: [:source_type, :source_id], + message: "already exists in source", + allow_nil: true } validates :access_level, inclusion: { in: Gitlab::Access.all_values }, presence: true + validates :invite_email, presence: { if: :invite? }, + email: { strict_mode: true, allow_nil: true }, + uniqueness: { scope: [:source_type, :source_id], allow_nil: true } + scope :invite, -> { where(user_id: nil) } + scope :non_invite, -> { where("user_id IS NOT NULL") } scope :guests, -> { where(access_level: GUEST) } scope :reporters, -> { where(access_level: REPORTER) } scope :developers, -> { where(access_level: DEVELOPER) } scope :masters, -> { where(access_level: MASTER) } scope :owners, -> { where(access_level: OWNER) } + before_validation :generate_invite_token, on: :create, if: -> (member) { member.invite_email.present? } + after_create :send_invite, if: :invite? + after_create :post_create_hook, unless: :invite? + after_update :post_update_hook, unless: :invite? + after_destroy :post_destroy_hook, unless: :invite? + delegate :name, :username, :email, to: :user, prefix: true + + def invite? + self.invite_token.present? + end + + def accept_invite!(new_user) + self.invite_token = nil + self.invite_accepted_at = Time.now.utc + + self.user = new_user + + saved = self.save + + after_accept_invite if saved + + saved + end + + def generate_invite_token + raw, enc = Devise.token_generator.generate(self.class, :invite_token) + @raw_invite_token = raw + self.invite_token = enc + end + + def generate_invite_token! + generate_invite_token && save(validate: false) + end + + def resend_invite + return unless invite? + + generate_invite_token! unless @raw_invite_token + + send_invite + end + + private + + def send_invite + # override in subclass + end + + def post_create_hook + system_hook_service.execute_hooks_for(self, :create) + end + + def post_update_hook + # override in subclass + end + + def post_destroy_hook + system_hook_service.execute_hooks_for(self, :destroy) + end + + def after_accept_invite + post_create_hook + end + + def system_hook_service + SystemHooksService.new + end + + def notification_service + NotificationService.new + end end diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index 28d0b4483b..ccbbab6afc 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -27,10 +27,6 @@ class GroupMember < Member scope :with_group, ->(group) { where(source_id: group.id) } scope :with_user, ->(user) { where(user_id: user.id) } - after_create :post_create_hook - after_update :notify_update - after_destroy :post_destroy_hook - def self.access_level_roles Gitlab::Access.options_with_owner end @@ -43,26 +39,19 @@ class GroupMember < Member access_level end + private + def post_create_hook notification_service.new_group_member(self) - system_hook_service.execute_hooks_for(self, :create) + + super end - def notify_update + def post_update_hook if access_level_changed? notification_service.update_group_member(self) end - end - def post_destroy_hook - system_hook_service.execute_hooks_for(self, :destroy) - end - - def system_hook_service - SystemHooksService.new - end - - def notification_service - NotificationService.new + super end end diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index 2205041cd5..6c5d161940 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -27,10 +27,6 @@ class ProjectMember < Member validates_format_of :source_type, with: /\AProject\z/ default_scope { where(source_type: SOURCE_TYPE) } - after_create :post_create_hook - after_update :post_update_hook - after_destroy :post_destroy_hook - scope :in_project, ->(project) { where(source_id: project.id) } scope :in_projects, ->(projects) { where(source_id: projects.pluck(:id)) } scope :with_user, ->(user) { where(user_id: user.id) } @@ -110,41 +106,40 @@ class ProjectMember < Member access_level end + def project + source + end + def owner? project.owner == user end + private + def post_create_hook unless owner? event_service.join_project(self.project, self.user) notification_service.new_project_member(self) end - system_hook_service.execute_hooks_for(self, :create) + super end def post_update_hook - notification_service.update_project_member(self) if self.access_level_changed? + if access_level_changed? + notification_service.update_project_member(self) + end + + super end def post_destroy_hook event_service.leave_project(self.project, self.user) - system_hook_service.execute_hooks_for(self, :destroy) + + super end def event_service EventCreateService.new end - - def notification_service - NotificationService.new - end - - def system_hook_service - SystemHooksService.new - end - - def project - source - end end diff --git a/spec/models/members_spec.rb b/spec/models/members_spec.rb index dfd3f7feb6..3f14a11144 100644 --- a/spec/models/members_spec.rb +++ b/spec/models/members_spec.rb @@ -11,10 +11,89 @@ describe Member do it { is_expected.to validate_presence_of(:user) } it { is_expected.to validate_presence_of(:source) } it { is_expected.to validate_inclusion_of(:access_level).in_array(Gitlab::Access.values) } + + context "when an invite email is provided" do + + let(:member) { build(:project_member, invite_email: "user@example.com", user: nil) } + + it "doesn't require a user" do + expect(member).to be_valid + end + + it "requires a valid invite email" do + member.invite_email = "nope" + + expect(member).not_to be_valid + end + + it "requires a unique invite email scoped to this source" do + create(:project_member, source: member.source, invite_email: member.invite_email) + + expect(member).not_to be_valid + end + + it "is valid otherwise" do + expect(member).to be_valid + end + end + + context "when an invite email is not provided" do + + let(:member) { build(:project_member) } + + it "requires a user" do + member.user = nil + + expect(member).not_to be_valid + end + + it "is valid otherwise" do + expect(member).to be_valid + end + end end describe "Delegate methods" do it { is_expected.to respond_to(:user_name) } it { is_expected.to respond_to(:user_email) } end + + describe "#accept_invite!" do + + let!(:member) { create(:project_member, invite_email: "user@example.com", user: nil) } + let(:user) { create(:user) } + + it "resets the invite token" do + member.accept_invite!(user) + + expect(member.invite_token).to be_nil + end + + it "sets the invite accepted timestamp" do + member.accept_invite!(user) + + expect(member.invite_accepted_at).not_to be_nil + end + + it "sets the user" do + member.accept_invite!(user) + + expect(member.user).to eq(user) + end + + it "calls #after_accept_invite" do + expect(member).to receive(:after_accept_invite) + + member.accept_invite!(user) + end + end + + describe "#generate_invite_token" do + + let!(:member) { create(:project_member, invite_email: "user@example.com", user: nil) } + + it "sets the invite token" do + expect { member.generate_invite_token }.to change { member.invite_token} + end + end end From cee98f22478b5ec27f91da04fe564a9760f79a38 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Apr 2015 15:13:00 +0200 Subject: [PATCH 139/194] Reference project members by id instead of user_id. --- app/controllers/projects/project_members_controller.rb | 8 ++------ app/views/admin/projects/show.html.haml | 2 +- .../projects/project_members/_project_member.html.haml | 4 ++-- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index e534db7af8..99ee3ebf92 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -41,12 +41,12 @@ class Projects::ProjectMembersController < Projects::ApplicationController end def update - @project_member = @project.project_members.find_by(user_id: member) + @project_member = @project.project_members.find(params[:id]) @project_member.update_attributes(member_params) end def destroy - @project_member = @project.project_members.find_by(user_id: member) + @project_member = @project.project_members.find(params[:id]) @project_member.destroy respond_to do |format| @@ -78,10 +78,6 @@ class Projects::ProjectMembersController < Projects::ApplicationController protected - def member - @member ||= User.find_by(username: params[:id]) - end - def member_params params.require(:project_member).permit(:user_id, :access_level) end diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml index b0b2313256..be31551dc9 100644 --- a/app/views/admin/projects/show.html.haml +++ b/app/views/admin/projects/show.html.haml @@ -131,7 +131,7 @@ %span.light Owner - else %span.light= project_member.human_access - = link_to namespace_project_project_member_path(@project.namespace, @project, user), data: { confirm: remove_from_project_team_message(@project, user)}, method: :delete, remote: true, class: "btn btn-sm btn-remove" do + = link_to namespace_project_project_member_path(@project.namespace, @project, project_member), data: { confirm: remove_from_project_team_message(@project, user)}, method: :delete, remote: true, class: "btn btn-sm btn-remove" do %i.fa.fa-times .panel-footer = paginate @project_members, param_name: 'project_members_page', theme: 'gitlab' diff --git a/app/views/projects/project_members/_project_member.html.haml b/app/views/projects/project_members/_project_member.html.haml index 4f05397721..67d703a412 100644 --- a/app/views/projects/project_members/_project_member.html.haml +++ b/app/views/projects/project_members/_project_member.html.haml @@ -25,12 +25,12 @@ = link_to leave_namespace_project_project_members_path(@project.namespace, @project), data: { confirm: "Leave project?"}, method: :delete, class: "btn-xs btn btn-remove", title: 'Leave project' do %i.fa.fa-minus.fa-inverse - else - = link_to namespace_project_project_member_path(@project.namespace, @project, user), data: { confirm: remove_from_project_team_message(@project, user) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from team' do + = link_to namespace_project_project_member_path(@project.namespace, @project, member), data: { confirm: remove_from_project_team_message(@project, user) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from team' do %i.fa.fa-minus.fa-inverse .edit-member.hide.js-toggle-content %br - = form_for member, as: :project_member, url: namespace_project_project_member_path(@project.namespace, @project, member.user), remote: true do |f| + = form_for member, as: :project_member, url: namespace_project_project_member_path(@project.namespace, @project, member), remote: true do |f| .prepend-top-10 = f.select :access_level, options_for_select(ProjectMember.access_roles, member.access_level), {}, class: 'form-control' .prepend-top-10 From 2a1d4255da396d37579e25dd88d5a9ea76116c22 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Apr 2015 15:14:14 +0200 Subject: [PATCH 140/194] Correctly render invite members without user. --- app/helpers/groups_helper.rb | 8 +++-- app/helpers/projects_helper.rb | 8 +++-- app/views/admin/groups/show.html.haml | 13 +++++--- app/views/admin/projects/show.html.haml | 11 +++++-- app/views/admin/users/show.html.haml | 4 +-- .../group_members/_group_member.html.haml | 31 ++++++++++++------ .../project_members/_project_member.html.haml | 32 +++++++++++++------ 7 files changed, 74 insertions(+), 33 deletions(-) diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 2d0d0b494f..52d88133d5 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -1,6 +1,10 @@ module GroupsHelper - def remove_user_from_group_message(group, user) - "Are you sure you want to remove \"#{user.name}\" from \"#{group.name}\"?" + def remove_user_from_group_message(group, member) + if member.user + "Are you sure you want to remove \"#{member.user.name}\" from \"#{group.name}\"?" + else + "Are you sure you want to cancel invitation for \"#{member.invite_email}\" to join \"#{group.name}\"?" + end end def leave_group_message(group) diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index ebbd2bfd77..06e5d3b738 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -1,6 +1,10 @@ module ProjectsHelper - def remove_from_project_team_message(project, user) - "You are going to remove #{user.name} from #{project.name} project team. Are you sure?" + def remove_from_project_team_message(project, member) + if member.user + "You are going to remove #{member.user.name} from #{project.name} project team. Are you sure?" + else + "You are going to cancel invitation for #{member.invite_email} to join #{project.name} project team. Are you sure?" + end end def link_to_project(project) diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml index 7d29211807..404b918c24 100644 --- a/app/views/admin/groups/show.html.haml +++ b/app/views/admin/groups/show.html.haml @@ -74,13 +74,18 @@ %ul.well-list.group-users-list - @members.each do |member| - user = member.user - %li{class: dom_class(member), id: dom_id(user)} + %li{class: dom_class(member), id: (dom_id(user) if user)} .list-item-name - %strong - = link_to user.name, admin_user_path(user) + - if user + %strong + = link_to user.name, admin_user_path(user) + - else + %strong + = member.invite_email + (invited) %span.pull-right.light = member.human_access - = link_to group_group_member_path(@group, member), data: { confirm: remove_user_from_group_message(@group, user) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from group' do + = link_to group_group_member_path(@group, member), data: { confirm: remove_user_from_group_message(@group, member) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from group' do %i.fa.fa-minus.fa-inverse .panel-footer = paginate @members, param_name: 'members_page', theme: 'gitlab' diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml index be31551dc9..78684c692c 100644 --- a/app/views/admin/projects/show.html.haml +++ b/app/views/admin/projects/show.html.haml @@ -124,14 +124,19 @@ - user = project_member.user %li.project_member .list-item-name - %strong - = link_to user.name, admin_user_path(user) + - if user + %strong + = link_to user.name, admin_user_path(user) + - else + %strong + = project_member.invite_email + (invited) .pull-right - if project_member.owner? %span.light Owner - else %span.light= project_member.human_access - = link_to namespace_project_project_member_path(@project.namespace, @project, project_member), data: { confirm: remove_from_project_team_message(@project, user)}, method: :delete, remote: true, class: "btn btn-sm btn-remove" do + = link_to namespace_project_project_member_path(@project.namespace, @project, project_member), data: { confirm: remove_from_project_team_message(@project, project_member)}, method: :delete, remote: true, class: "btn btn-sm btn-remove" do %i.fa.fa-times .panel-footer = paginate @project_members, param_name: 'project_members_page', theme: 'gitlab' diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml index 0a2934d3bd..3524f04c5e 100644 --- a/app/views/admin/users/show.html.haml +++ b/app/views/admin/users/show.html.haml @@ -182,7 +182,7 @@ .pull-right %span.light= group_member.human_access - unless group_member.owner? - = link_to group_group_member_path(group, group_member), data: { confirm: remove_user_from_group_message(group, @user) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from group' do + = link_to group_group_member_path(group, group_member), data: { confirm: remove_user_from_group_message(group, group_member) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from group' do %i.fa.fa-times.fa-inverse - else .nothing-here-block This user has no groups. @@ -221,7 +221,7 @@ %span.light= member.human_access - if member.respond_to? :project - = link_to namespace_project_project_member_path(project.namespace, project, @user), data: { confirm: remove_from_project_team_message(project, @user) }, remote: true, method: :delete, class: "btn-xs btn btn-remove", title: 'Remove user from project' do + = link_to namespace_project_project_member_path(project.namespace, project, member), data: { confirm: remove_from_project_team_message(project, member) }, remote: true, method: :delete, class: "btn-xs btn btn-remove", title: 'Remove user from project' do %i.fa.fa-times #ssh-keys.tab-pane = render 'profiles/keys/key_table', admin: true diff --git a/app/views/groups/group_members/_group_member.html.haml b/app/views/groups/group_members/_group_member.html.haml index 2462c95209..670d5e02e6 100644 --- a/app/views/groups/group_members/_group_member.html.haml +++ b/app/views/groups/group_members/_group_member.html.haml @@ -1,17 +1,28 @@ - user = member.user -- return unless user +- return unless user || member.invite? - show_roles = true if show_roles.nil? %li{class: "#{dom_class(member)} js-toggle-container", id: dom_id(member)} %span{class: ("list-item-name" if show_controls)} - = image_tag avatar_icon(user.email, 16), class: "avatar s16", alt: '' - %strong= user.name - %span.cgray= user.username - - if user == current_user - %span.label.label-success It's you - - if user.blocked? - %label.label.label-danger - %strong Blocked + - if member.user + = image_tag avatar_icon(user.email, 16), class: "avatar s16", alt: '' + %strong= user.name + %span.cgray= user.username + - if user == current_user + %span.label.label-success It's you + - if user.blocked? + %label.label.label-danger + %strong Blocked + - else + = image_tag avatar_icon(member.invite_email, 16), class: "avatar s16", alt: '' + %strong + = member.invite_email + %span.cgray + invited + - if member.created_by + by + = link_to member.created_by.name, user_path(member.created_by) + = time_ago_with_tooltip(member.created_at) - if show_roles %span.pull-right @@ -27,7 +38,7 @@ = link_to leave_group_group_members_path(@group), data: { confirm: leave_group_message(@group.name)}, method: :delete, class: "btn-xs btn btn-remove", title: 'Remove user from group' do %i.fa.fa-minus.fa-inverse - else - = link_to group_group_member_path(@group, member), data: { confirm: remove_user_from_group_message(@group, user) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from group' do + = link_to group_group_member_path(@group, member), data: { confirm: remove_user_from_group_message(@group, member) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from group' do %i.fa.fa-minus.fa-inverse .edit-member.hide.js-toggle-content diff --git a/app/views/projects/project_members/_project_member.html.haml b/app/views/projects/project_members/_project_member.html.haml index 67d703a412..4be531dedd 100644 --- a/app/views/projects/project_members/_project_member.html.haml +++ b/app/views/projects/project_members/_project_member.html.haml @@ -1,16 +1,28 @@ - user = member.user -- return unless user +- return unless user || member.invite? %li{class: "#{dom_class(member)} js-toggle-container project_member_row access-#{member.human_access.downcase}", id: dom_id(member)} %span.list-item-name - = image_tag avatar_icon(user.email, 16), class: "avatar s16", alt: '' - %strong= user.name - %span.cgray= user.username - - if user == current_user - %span.label.label-success It's you - - if user.blocked? - %label.label.label-danger - %strong Blocked + - if member.user + = image_tag avatar_icon(user.email, 16), class: "avatar s16", alt: '' + %strong + = link_to user.name, user_path(user) + %span.cgray= user.username + - if user == current_user + %span.label.label-success It's you + - if user.blocked? + %label.label.label-danger + %strong Blocked + - else + = image_tag avatar_icon(member.invite_email, 16), class: "avatar s16", alt: '' + %strong + = member.invite_email + %span.cgray + invited + - if member.created_by + by + = link_to member.created_by.name, user_path(member.created_by) + = time_ago_with_tooltip(member.created_at) - if current_user_can_admin_project - unless @project.personal? && user == current_user @@ -25,7 +37,7 @@ = link_to leave_namespace_project_project_members_path(@project.namespace, @project), data: { confirm: "Leave project?"}, method: :delete, class: "btn-xs btn btn-remove", title: 'Leave project' do %i.fa.fa-minus.fa-inverse - else - = link_to namespace_project_project_member_path(@project.namespace, @project, member), data: { confirm: remove_from_project_team_message(@project, user) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from team' do + = link_to namespace_project_project_member_path(@project.namespace, @project, member), data: { confirm: remove_from_project_team_message(@project, member) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from team' do %i.fa.fa-minus.fa-inverse .edit-member.hide.js-toggle-content From 90dafe31c4b1bb54bcd0476a873c4f5cdb62e981 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Apr 2015 15:14:59 +0200 Subject: [PATCH 141/194] Only show invited users to people who can admin group. --- app/controllers/groups/group_members_controller.rb | 1 + app/controllers/projects/project_members_controller.rb | 2 ++ 2 files changed, 3 insertions(+) diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index 93fc4edb3b..f8aa4b0bbd 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -11,6 +11,7 @@ class Groups::GroupMembersController < Groups::ApplicationController def index @project = @group.projects.find(params[:project_id]) if params[:project_id] @members = @group.group_members + @members = @members.non_invite unless can?(current_user, :admin_group, @group) if params[:search].present? users = @group.users.search(params[:search]).to_a diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index 99ee3ebf92..148768e8fa 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -6,6 +6,7 @@ class Projects::ProjectMembersController < Projects::ApplicationController def index @project_members = @project.project_members + @project_members = @project_members.non_invite unless can?(current_user, :admin_project, @project) if params[:search].present? users = @project.users.search(params[:search]).to_a @@ -17,6 +18,7 @@ class Projects::ProjectMembersController < Projects::ApplicationController @group = @project.group if @group @group_members = @group.group_members + @group_members = @group_members.non_invite unless can?(current_user, :admin_group, @group) if params[:search].present? users = @group.users.search(params[:search]).to_a From 39f389ae0fff5706b73b03c8fb5fc4e3dd7da638 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Apr 2015 15:22:31 +0200 Subject: [PATCH 142/194] Add invites controller. --- app/controllers/confirmations_controller.rb | 4 +- app/controllers/invites_controller.rb | 57 +++++++++++++++++++ app/models/member.rb | 7 +++ app/views/invites/show.html.haml | 28 +++++++++ .../group_invite_accepted_email.html.haml | 7 +++ .../group_invite_accepted_email.text.erb | 3 + .../group_member_invited_email.html.haml | 12 ++++ .../group_member_invited_email.text.erb | 3 + .../project_invite_accepted_email.html.haml | 7 +++ .../project_invite_accepted_email.text.erb | 3 + .../project_member_invited_email.html.haml | 11 ++++ .../project_member_invited_email.text.erb | 3 + config/routes.rb | 9 +++ 13 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 app/controllers/invites_controller.rb create mode 100644 app/views/invites/show.html.haml create mode 100644 app/views/notify/group_invite_accepted_email.html.haml create mode 100644 app/views/notify/group_invite_accepted_email.text.erb create mode 100644 app/views/notify/group_member_invited_email.html.haml create mode 100644 app/views/notify/group_member_invited_email.text.erb create mode 100644 app/views/notify/project_invite_accepted_email.html.haml create mode 100644 app/views/notify/project_invite_accepted_email.text.erb create mode 100644 app/views/notify/project_member_invited_email.html.haml create mode 100644 app/views/notify/project_member_invited_email.text.erb diff --git a/app/controllers/confirmations_controller.rb b/app/controllers/confirmations_controller.rb index bc98eab133..af1faca93f 100644 --- a/app/controllers/confirmations_controller.rb +++ b/app/controllers/confirmations_controller.rb @@ -4,11 +4,11 @@ class ConfirmationsController < Devise::ConfirmationsController def after_confirmation_path_for(resource_name, resource) if signed_in?(resource_name) - signed_in_root_path(resource) + after_sign_in_path_for(resource) else sign_in(resource) if signed_in?(resource_name) - signed_in_root_path(resource) + after_sign_in_path_for(resource) else new_session_path(resource_name) end diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb new file mode 100644 index 0000000000..8b1f6eeba1 --- /dev/null +++ b/app/controllers/invites_controller.rb @@ -0,0 +1,57 @@ +class InvitesController < ApplicationController + before_filter :member + + respond_to :html + + layout 'navless' + + def show + + end + + def accept + if member.accept_invite!(current_user) + case member.source + when Project + project = member.source + source = "project #{project.name_with_namespace}" + path = namespace_project_path(project.namespace, project) + when Group + group = member.source + source = "group #{group.name}" + path = group_path(group) + else + source = "who knows what" + path = dashboard_path + end + + redirect_to path, notice: "You have been granted #{member.human_access} access to #{source}." + else + redirect_to :back, alert: "The invite could not be accepted." + end + end + + private + + def member + return @member if defined?(@member) + + @token = params[:id] + if member = Member.find_by_invite_token(@token) + @member = member + else + render_404 + end + end + + def authenticate_user! + return if current_user + + notice = "To accept this invitation, sign in" + notice << " or create an account" if current_application_settings.signup_enabled? + notice << "." + + store_location_for :user, request.fullpath + redirect_to new_user_session_path, notice: notice + end +end diff --git a/app/models/member.rb b/app/models/member.rb index ec79f53174..d8cc65ca6c 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -52,11 +52,18 @@ class Member < ActiveRecord::Base delegate :name, :username, :email, to: :user, prefix: true + def self.find_by_invite_token(invite_token) + invite_token = Devise.token_generator.digest(self, :invite_token, invite_token) + find_by(invite_token: invite_token) + end + def invite? self.invite_token.present? end def accept_invite!(new_user) + return false unless invite? + self.invite_token = nil self.invite_accepted_at = Time.now.utc diff --git a/app/views/invites/show.html.haml b/app/views/invites/show.html.haml new file mode 100644 index 0000000000..e2657ac6df --- /dev/null +++ b/app/views/invites/show.html.haml @@ -0,0 +1,28 @@ +%h3.page-title Invitation + +%p + You have been invited + - if inviter = @member.created_by + by + = link_to inviter.name, user_url(inviter) + to join + - case @member.source + - when Project + - project = @member.source + project + %strong + = link_to project.name_with_namespace, namespace_project_url(project.namespace, project) + - when Group + - group = @member.source + group + %strong + = link_to group.name, group_url(group) + as #{@member.human_access}. + +- if @member.source.users.include?(current_user) + %p + However, you are already a member of this #{@member.source.is_a?(Group) ? "group" : "project"}. + Sign in using a different account to accept the invitation. +- else + .actions + = link_to "Accept invitation", accept_invite_url(@token), method: :post, class: "btn btn-success" diff --git a/app/views/notify/group_invite_accepted_email.html.haml b/app/views/notify/group_invite_accepted_email.html.haml new file mode 100644 index 0000000000..d50d182102 --- /dev/null +++ b/app/views/notify/group_invite_accepted_email.html.haml @@ -0,0 +1,7 @@ +%p + #{@group_member.invite_email}, now known as + #{link_to @group_member.user.name, user_url(@group_member.user)}, + has accepted your invitation to join group + = link_to @group.name, group_url(@group) + as #{@group_member.human_access}. + diff --git a/app/views/notify/group_invite_accepted_email.text.erb b/app/views/notify/group_invite_accepted_email.text.erb new file mode 100644 index 0000000000..0072f5252d --- /dev/null +++ b/app/views/notify/group_invite_accepted_email.text.erb @@ -0,0 +1,3 @@ +<%= @group_member.invite_email %>, now known as <%= @group_member.user.name %>, has accepted your invitation to join group <%= @group.name %> as <%= @group_member.human_access %>. + +<%= group_url(@group) %> diff --git a/app/views/notify/group_member_invited_email.html.haml b/app/views/notify/group_member_invited_email.html.haml new file mode 100644 index 0000000000..39798a2c0e --- /dev/null +++ b/app/views/notify/group_member_invited_email.html.haml @@ -0,0 +1,12 @@ +%p + You have been invited + - if inviter = @group_member.created_by + by + = link_to inviter.name, user_url(inviter) + to join group + = link_to @group.name, group_url(@group) + as #{@group_member.human_access}. + +%p + = link_to 'Accept invitation', invite_url(@token) + diff --git a/app/views/notify/group_member_invited_email.text.erb b/app/views/notify/group_member_invited_email.text.erb new file mode 100644 index 0000000000..dec0cf0d01 --- /dev/null +++ b/app/views/notify/group_member_invited_email.text.erb @@ -0,0 +1,3 @@ +You have been invited <%= "by #{@group_member.created_by.name} " if @group_member.created_by %>to join group <%= @group.name %> as <%= @group_member.human_access %>. + +Accept invitation: <%= invite_url(@token) %> diff --git a/app/views/notify/project_invite_accepted_email.html.haml b/app/views/notify/project_invite_accepted_email.html.haml new file mode 100644 index 0000000000..0c01eef762 --- /dev/null +++ b/app/views/notify/project_invite_accepted_email.html.haml @@ -0,0 +1,7 @@ +%p + #{@project_member.invite_email}, now known as + #{link_to @project_member.user.name, user_url(@project_member.user)}, + has accepted your invitation to join project + = link_to @project.name_with_namespace, namespace_project_url(@project.namespace, @project) + as #{@project_member.human_access}. + diff --git a/app/views/notify/project_invite_accepted_email.text.erb b/app/views/notify/project_invite_accepted_email.text.erb new file mode 100644 index 0000000000..e90217c1ab --- /dev/null +++ b/app/views/notify/project_invite_accepted_email.text.erb @@ -0,0 +1,3 @@ +<%= @project_member.invite_email %>, now known as <%= @project_member.user.name %>, has accepted your invitation to join project <%= @group.name_with_namespace %> as <%= @project_member.human_access %>. + +<%= namespace_project_url(@project.namespace, @project) %> diff --git a/app/views/notify/project_member_invited_email.html.haml b/app/views/notify/project_member_invited_email.html.haml new file mode 100644 index 0000000000..f44adc244c --- /dev/null +++ b/app/views/notify/project_member_invited_email.html.haml @@ -0,0 +1,11 @@ +%p + You have been invited + - if inviter = @project_member.created_by + by + = link_to inviter.name, user_url(inviter) + to join project + = link_to @project.name_with_namespace, namespace_project_url(@project.namespace, @project) + as #{@project_member.human_access}. + +%p + = link_to 'Accept invitation', invite_url(@token) diff --git a/app/views/notify/project_member_invited_email.text.erb b/app/views/notify/project_member_invited_email.text.erb new file mode 100644 index 0000000000..55b16ef6c5 --- /dev/null +++ b/app/views/notify/project_member_invited_email.text.erb @@ -0,0 +1,3 @@ +You have been invited <%= "by #{@project_member.created_by.name} " if @project_member.created_by %>to join project <%= @project.name_with_namespace %> as <%= @project_member.human_access %>. + +Accept invitation: <%= invite_url(@token) %> diff --git a/config/routes.rb b/config/routes.rb index de21f41832..bd2a791f94 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -53,6 +53,15 @@ Gitlab::Application.routes.draw do end get '/s/:username' => 'snippets#user_index', as: :user_snippets, constraints: { username: /.*/ } + # + # Invites + # + + resources :invites, only: [:show], constraints: { id: /[A-Za-z0-9_-]+/ } do + member do + post :accept + end + end # # Import From 717fa136a5146d45660d3d97f5671352a47a0601 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Apr 2015 15:23:38 +0200 Subject: [PATCH 143/194] Add emails around invitation. --- app/mailers/emails/groups.rb | 21 ++++++++++++++++++++- app/mailers/emails/projects.rb | 25 ++++++++++++++++++++++--- app/models/members/group_member.rb | 12 ++++++++++++ app/models/members/project_member.rb | 12 ++++++++++++ app/services/notification_service.rb | 16 ++++++++++++++++ 5 files changed, 82 insertions(+), 4 deletions(-) diff --git a/app/mailers/emails/groups.rb b/app/mailers/emails/groups.rb index 626eb593d5..9db609006e 100644 --- a/app/mailers/emails/groups.rb +++ b/app/mailers/emails/groups.rb @@ -5,8 +5,27 @@ module Emails @group = @group_member.group @target_url = group_url(@group) @current_user = @group_member.user - mail(to: @group_member.user.email, + mail(to: @group_member.user.notification_email, subject: subject("Access to group was granted")) end + + def group_member_invited_email(group_member_id, token) + @group_member = GroupMember.find group_member_id + @group = @group_member.group + @token = token + @target_url = group_url(@group) + mail(to: @group_member.invite_email, + subject: "Invite to join group #{@group.name}") + end + + def group_invite_accepted_email(group_member_id) + @group_member = GroupMember.find group_member_id + return if @group_member.created_by.nil? + + @group = @group_member.group + @target_url = group_url(@group) + mail(to: @group_member.created_by.notification_email, + subject: subject("Invite accepted")) + end end end diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb index 20a863c374..64c35618a5 100644 --- a/app/mailers/emails/projects.rb +++ b/app/mailers/emails/projects.rb @@ -1,14 +1,33 @@ module Emails module Projects - def project_access_granted_email(user_project_id) - @project_member = ProjectMember.find user_project_id + def project_access_granted_email(project_member_id) + @project_member = ProjectMember.find project_member_id @project = @project_member.project @target_url = namespace_project_url(@project.namespace, @project) @current_user = @project_member.user - mail(to: @project_member.user.email, + mail(to: @project_member.user.notification_email, subject: subject("Access to project was granted")) end + def project_member_invited_email(project_member_id, token) + @project_member = ProjectMember.find project_member_id + @project = @project_member.project + @token = token + @target_url = namespace_project_url(@project.namespace, @project) + mail(to: @project_member.invite_email, + subject: "Invite to join project #{@project.name_with_namespace}") + end + + def project_invite_accepted_email(project_member_id) + @project_member = ProjectMember.find project_member_id + return if @project_member.created_by.nil? + + @project = @project_member.project + @target_url = namespace_project_url(@project.namespace, @project) + mail(to: @project_member.created_by.notification_email, + subject: subject("Invite accepted")) + end + def project_was_moved_email(project_id, user_id) @current_user = @user = User.find user_id @project = Project.find project_id diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index ccbbab6afc..68b7c169f7 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -41,6 +41,12 @@ class GroupMember < Member private + def send_invite + notification_service.invite_group_member(self, @raw_invite_token) + + super + end + def post_create_hook notification_service.new_group_member(self) @@ -54,4 +60,10 @@ class GroupMember < Member super end + + def after_accept_invite + notification_service.accept_group_invite(self) + + super + end end diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index 6c5d161940..a63d5af121 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -116,6 +116,12 @@ class ProjectMember < Member private + def send_invite + notification_service.invite_project_member(self, @raw_invite_token) + + super + end + def post_create_hook unless owner? event_service.join_project(self.project, self.user) @@ -139,6 +145,12 @@ class ProjectMember < Member super end + def after_accept_invite + notification_service.accept_project_invite(self) + + super + end + def event_service EventCreateService.new end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 42547f6f48..a97abe289f 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -165,6 +165,14 @@ class NotificationService end end + def invite_project_member(project_member, token) + mailer.project_member_invited_email(project_member.id, token) + end + + def accept_project_invite(project_member) + mailer.project_invite_accepted_email(project_member.id) + end + def new_project_member(project_member) mailer.project_access_granted_email(project_member.id) end @@ -173,6 +181,14 @@ class NotificationService mailer.project_access_granted_email(project_member.id) end + def invite_group_member(group_member, token) + mailer.group_member_invited_email(group_member.id, token) + end + + def accept_group_invite(group_member) + mailer.group_invite_accepted_email(group_member.id) + end + def new_group_member(group_member) mailer.group_access_granted_email(group_member.id) end From 87dd3f215f8c087d85cde7ac880c5480fd501303 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Apr 2015 15:25:32 +0200 Subject: [PATCH 144/194] Let members be invited from Add members pane. --- app/assets/javascripts/users_select.js.coffee | 38 ++++++++++++------- .../projects/project_members_controller.rb | 3 +- app/helpers/selects_helper.rb | 2 + app/models/group.rb | 20 ++++++++-- app/models/members/project_member.rb | 22 +++++++++-- app/views/admin/groups/show.html.haml | 2 +- .../group_members/_new_group_member.html.haml | 5 ++- .../_new_project_member.html.haml | 5 ++- 8 files changed, 72 insertions(+), 25 deletions(-) diff --git a/app/assets/javascripts/users_select.js.coffee b/app/assets/javascripts/users_select.js.coffee index ccd85f2455..aeeed9ca3c 100644 --- a/app/assets/javascripts/users_select.js.coffee +++ b/app/assets/javascripts/users_select.js.coffee @@ -8,6 +8,7 @@ class @UsersSelect @groupId = $(select).data('group-id') showNullUser = $(select).data('null-user') showAnyUser = $(select).data('any-user') + showEmailUser = $(select).data('email-user') firstUser = $(select).data('first-user') $(select).select2 @@ -19,20 +20,6 @@ class @UsersSelect data = { results: users } if query.term.length == 0 - anyUser = { - name: 'Any', - avatar: null, - username: 'none', - id: null - } - - nullUser = { - name: 'Unassigned', - avatar: null, - username: 'none', - id: 0 - } - if firstUser # Move current user to the front of the list for obj, index in data.results @@ -40,11 +27,34 @@ class @UsersSelect data.results.splice(index, 1) data.results.unshift(obj) break + if showNullUser + nullUser = { + name: 'Unassigned', + avatar: null, + username: 'none', + id: 0 + } data.results.unshift(nullUser) + if showAnyUser + anyUser = { + name: 'Any', + avatar: null, + username: 'none', + id: null + } data.results.unshift(anyUser) + if showEmailUser && data.results.length == 0 && query.term.match(/^[^@]+@[^@]+$/) + emailUser = { + name: "Invite \"#{query.term}\"", + avatar: null, + username: query.term, + id: query.term + } + data.results.unshift(emailUser) + query.callback(data) initSelection: (element, callback) => diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index 148768e8fa..91aba9a706 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -36,8 +36,7 @@ class Projects::ProjectMembersController < Projects::ApplicationController end def create - users = User.where(id: params[:user_ids].split(',')) - @project.team << [users, params[:access_level]] + @project.team.add_users(params[:user_ids].split(','), params[:access_level], current_user) redirect_to namespace_project_project_members_path(@project.namespace, @project) end diff --git a/app/helpers/selects_helper.rb b/app/helpers/selects_helper.rb index 54e0f4f9b3..bec8f2f1aa 100644 --- a/app/helpers/selects_helper.rb +++ b/app/helpers/selects_helper.rb @@ -8,6 +8,7 @@ module SelectsHelper null_user = opts[:null_user] || false any_user = opts[:any_user] || false + email_user = opts[:email_user] || false first_user = opts[:first_user] && current_user ? current_user.username : false html = { @@ -15,6 +16,7 @@ module SelectsHelper 'data-placeholder' => placeholder, 'data-null-user' => null_user, 'data-any-user' => any_user, + 'data-email-user' => email_user, 'data-first-user' => first_user } diff --git a/app/models/group.rb b/app/models/group.rb index eec961f601..2de397f90f 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -47,10 +47,24 @@ class Group < Namespace end def add_users(user_ids, access_level, current_user = nil) - user_ids.compact.each do |user_id| - member = self.group_members.find_or_initialize_by(user_id: user_id) - member.access_level = access_level + users = user_ids.map do |user_id| + (user_id if user_id.is_a?(User)) || + User.find_by(id: user_id) || + User.find_by(email: user_id) || + user_id + end + + users.compact.each do |user| + if user.is_a?(User) + member = self.group_members.find_or_initialize_by(user_id: user.id) + else + member = self.group_members.build + member.invite_email = user + end + member.created_by ||= current_user + member.access_level = access_level + member.save end end diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index a63d5af121..3b3b1bd468 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -60,12 +60,28 @@ class ProjectMember < Member raise "Non valid access" end + users = user_ids.map do |user_id| + (user_id if user_id.is_a?(User)) || + User.find_by(id: user_id) || + User.find_by(email: user_id) || + user_id + end + ProjectMember.transaction do project_ids.each do |project_id| - user_ids.each do |user_id| - member = ProjectMember.new(access_level: access_level, user_id: user_id) - member.source_id = project_id + project = Project.find(project_id) + + users.each do |user| + if user.is_a?(User) + member = project.project_members.find_or_initialize_by(user_id: user.id) + else + member = project.project_members.build + member.invite_email = user + end + member.created_by ||= current_user + member.access_level = access_level + member.save end end diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml index 404b918c24..14996dcd6a 100644 --- a/app/views/admin/groups/show.html.haml +++ b/app/views/admin/groups/show.html.haml @@ -60,7 +60,7 @@ = form_tag members_update_admin_group_path(@group), id: "new_project_member", class: "bulk_import", method: :put do %div - = users_select_tag(:user_ids, multiple: true) + = users_select_tag(:user_ids, multiple: true, email_user: true) %div.prepend-top-10 = select_tag :access_level, options_for_select(GroupMember.access_level_roles), class: "project-access-select select2" %hr diff --git a/app/views/groups/group_members/_new_group_member.html.haml b/app/views/groups/group_members/_new_group_member.html.haml index a52b819738..3361d7e2a8 100644 --- a/app/views/groups/group_members/_new_group_member.html.haml +++ b/app/views/groups/group_members/_new_group_member.html.haml @@ -1,7 +1,10 @@ = form_for @group_member, url: group_group_members_path(@group), html: { class: 'form-horizontal users-group-form' } do |f| .form-group = f.label :user_ids, "People", class: 'control-label' - .col-sm-10= users_select_tag(:user_ids, multiple: true, class: 'input-large', scope: :all) + .col-sm-10 + = users_select_tag(:user_ids, multiple: true, class: 'input-large', scope: :all, email_user: true) + .help-block + Search for existing users or invite new ones using their email address. .form-group = f.label :access_level, "Group Access", class: 'control-label' diff --git a/app/views/projects/project_members/_new_project_member.html.haml b/app/views/projects/project_members/_new_project_member.html.haml index 5daae2708e..d708b01a11 100644 --- a/app/views/projects/project_members/_new_project_member.html.haml +++ b/app/views/projects/project_members/_new_project_member.html.haml @@ -1,7 +1,10 @@ = form_for @project_member, as: :project_member, url: namespace_project_project_members_path(@project.namespace, @project), html: { class: 'form-horizontal users-project-form' } do |f| .form-group = f.label :user_ids, "People", class: 'control-label' - .col-sm-10= users_select_tag(:user_ids, multiple: true, class: 'input-large', scope: :all) + .col-sm-10 + = users_select_tag(:user_ids, multiple: true, class: 'input-large', scope: :all, email_user: true) + .help-block + Search for existing users or invite new ones using their email address. .form-group = f.label :access_level, "Project Access", class: 'control-label' From 453340d4496ce0500985d6432c32e8d31be350a7 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Apr 2015 15:26:03 +0200 Subject: [PATCH 145/194] Let membership invitations be resent. --- app/controllers/groups/group_members_controller.rb | 11 +++++++++++ .../projects/project_members_controller.rb | 13 +++++++++++++ .../groups/group_members/_group_member.html.haml | 4 ++++ .../project_members/_project_member.html.haml | 4 ++++ config/routes.rb | 5 +++++ 5 files changed, 37 insertions(+) diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index f8aa4b0bbd..1a4b13b9d7 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -47,6 +47,17 @@ class Groups::GroupMembersController < Groups::ApplicationController end end + def resend_invite + @group_member = @group.group_members.find(params[:id]) + if @group_member.invite? + @group_member.resend_invite + + redirect_to group_group_members_path(@group), notice: 'Invite was successfully resent.' + else + redirect_to group_group_members_path(@group), alert: 'The invite has already been accepted.' + end + end + def leave @group_member = @group.group_members.where(user_id: current_user.id).first diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index 91aba9a706..e55e8e4c50 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -59,6 +59,19 @@ class Projects::ProjectMembersController < Projects::ApplicationController end end + def resend_invite + @project_member = @project.project_members.find(params[:id]) + if @project_member.invite? + @project_member.resend_invite + + redirect_to namespace_project_project_members_path(@project.namespace, + @project), notice: 'Invite was successfully resent.' + else + redirect_to namespace_project_project_members_path(@project.namespace, + @project), alert: 'The invite has already been accepted.' + end + end + def leave @project.project_members.find_by(user_id: current_user).destroy diff --git a/app/views/groups/group_members/_group_member.html.haml b/app/views/groups/group_members/_group_member.html.haml index 670d5e02e6..56b1948a47 100644 --- a/app/views/groups/group_members/_group_member.html.haml +++ b/app/views/groups/group_members/_group_member.html.haml @@ -24,6 +24,10 @@ = link_to member.created_by.name, user_path(member.created_by) = time_ago_with_tooltip(member.created_at) + - if show_controls && can?(current_user, :admin_group, @group) + = link_to resend_invite_group_group_member_path(@group, member), method: :post, class: "btn-xs btn", title: 'Resend invite' do + Resend invite + - if show_roles %span.pull-right %strong= member.human_access diff --git a/app/views/projects/project_members/_project_member.html.haml b/app/views/projects/project_members/_project_member.html.haml index 4be531dedd..635e4d7094 100644 --- a/app/views/projects/project_members/_project_member.html.haml +++ b/app/views/projects/project_members/_project_member.html.haml @@ -24,6 +24,10 @@ = link_to member.created_by.name, user_path(member.created_by) = time_ago_with_tooltip(member.created_at) + - if current_user_can_admin_project + = link_to resend_invite_namespace_project_project_member_path(@project.namespace, @project, member), method: :post, class: "btn-xs btn", title: 'Resend invite' do + Resend invite + - if current_user_can_admin_project - unless @project.personal? && user == current_user .pull-right diff --git a/config/routes.rb b/config/routes.rb index bd2a791f94..50d11fde57 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -260,6 +260,7 @@ Gitlab::Application.routes.draw do scope module: :groups do resources :group_members, only: [:index, :create, :update, :destroy] do + post :resend_invite, on: :member delete :leave, on: :collection end @@ -486,6 +487,10 @@ Gitlab::Application.routes.draw do get :import post :apply_import end + + member do + post :resend_invite + end end resources :notes, only: [:index, :create, :destroy, :update], constraints: { id: /\d+/ } do From 2a45eb6a912a35b71e3cbe7aacbda3323730d9da Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Apr 2015 15:26:18 +0200 Subject: [PATCH 146/194] Use notice flash type rather than nonexistent info. --- app/controllers/groups/group_members_controller.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index 1a4b13b9d7..0018cd34bb 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -39,7 +39,7 @@ class Groups::GroupMembersController < Groups::ApplicationController if can?(current_user, :destroy_group_member, @group_member) # May fail if last owner. @group_member.destroy respond_to do |format| - format.html { redirect_to group_group_members_path(@group), notice: 'User was successfully removed from group.' } + format.html { redirect_to group_group_members_path(@group), notice: 'User was successfully removed from group.' } format.js { render nothing: true } end else @@ -63,7 +63,7 @@ class Groups::GroupMembersController < Groups::ApplicationController if can?(current_user, :destroy_group_member, @group_member) @group_member.destroy - redirect_to(dashboard_groups_path, info: "You left #{group.name} group.") + redirect_to(dashboard_groups_path, notice: "You left #{group.name} group.") else return render_403 end From 1c0824f451a3fe8f328f0360c94cd251ff664ed6 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Apr 2015 15:26:53 +0200 Subject: [PATCH 147/194] Correctly import invited members. --- app/models/project_team.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/project_team.rb b/app/models/project_team.rb index 313e459d70..56e49af232 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -93,7 +93,7 @@ class ProjectTeam source_members.reject! do |member| # Skip if user already present in team - target_user_ids.include?(member.user_id) + !member.invite? && target_user_ids.include?(member.user_id) end source_members.map! do |member| From 405df435587c674dda049868cb2009b00c243dd8 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Apr 2015 15:27:06 +0200 Subject: [PATCH 148/194] Use "Sign out" instead of "Logout" for consistency. --- app/views/layouts/_head_panel.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/layouts/_head_panel.html.haml b/app/views/layouts/_head_panel.html.haml index b1c2e1a7b1..d58582c107 100644 --- a/app/views/layouts/_head_panel.html.haml +++ b/app/views/layouts/_head_panel.html.haml @@ -39,7 +39,7 @@ = link_to profile_path, title: "Profile settings", class: 'has_bottom_tooltip', 'data-original-title' => 'Profile settings"' do %i.fa.fa-user %li - = link_to destroy_user_session_path, class: "logout", method: :delete, title: "Logout", class: 'has_bottom_tooltip', 'data-original-title' => 'Logout' do + = link_to destroy_user_session_path, class: "logout", method: :delete, title: "Sign out", class: 'has_bottom_tooltip', 'data-original-title' => 'Sign out' do %i.fa.fa-sign-out %li.hidden-xs = link_to current_user, class: "profile-pic has_bottom_tooltip", id: 'profile-pic', 'data-original-title' => 'Your profile' do From 8949af0c9f2aa90b979043a0a6ee264ac0c036b9 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Apr 2015 15:40:23 +0200 Subject: [PATCH 149/194] Add feature tests for inviting members. --- features/groups.feature | 7 +++++++ features/project/team_management.feature | 6 ++++++ features/steps/groups.rb | 17 +++++++++++++++++ features/steps/project/team_management.rb | 16 ++++++++++++++++ 4 files changed, 46 insertions(+) diff --git a/features/groups.feature b/features/groups.feature index 65d06a0daf..415e43d6ae 100644 --- a/features/groups.feature +++ b/features/groups.feature @@ -55,6 +55,13 @@ Feature: Groups When I select "Mike" as "Reporter" Then I should see "Mike" in team list as "Reporter" + @javascript + Scenario: Invite user to group + When I visit group "Owned" members page + And I click link "Add members" + When I select "sjobs@apple.com" as "Reporter" + Then I should see "sjobs@apple.com" in team list as invited "Reporter" + # Leave @javascript diff --git a/features/project/team_management.feature b/features/project/team_management.feature index 22393622bb..6cda225ea7 100644 --- a/features/project/team_management.feature +++ b/features/project/team_management.feature @@ -17,6 +17,12 @@ Feature: Project Team Management And I select "Mike" as "Reporter" Then I should see "Mike" in team list as "Reporter" + @javascript + Scenario: Invite user to project + Given I click link "Add members" + And I select "sjobs@apple.com" as "Reporter" + Then I should see "sjobs@apple.com" in team list as invited "Reporter" + @javascript Scenario: Update user access Given I should see "Sam" in team list as "Developer" diff --git a/features/steps/groups.rb b/features/steps/groups.rb index ec5213e4b9..228b83e5fd 100644 --- a/features/steps/groups.rb +++ b/features/steps/groups.rb @@ -31,6 +31,23 @@ class Spinach::Features::Groups < Spinach::FeatureSteps end end + step 'I select "sjobs@apple.com" as "Reporter"' do + within ".users-group-form" do + select2("sjobs@apple.com", from: "#user_ids", multiple: true) + select "Reporter", from: "access_level" + end + + click_button "Add users to group" + end + + step 'I should see "sjobs@apple.com" in team list as invited "Reporter"' do + within '.well-list' do + page.should have_content('sjobs@apple.com') + page.should have_content('invited') + page.should have_content('Reporter') + end + end + step 'I should see group "Owned" projects list' do Group.find_by(name: "Owned").projects.each do |project| page.should have_link project.name diff --git a/features/steps/project/team_management.rb b/features/steps/project/team_management.rb index 0eefe2b568..e95621071c 100644 --- a/features/steps/project/team_management.rb +++ b/features/steps/project/team_management.rb @@ -35,6 +35,22 @@ class Spinach::Features::ProjectTeamManagement < Spinach::FeatureSteps end end + step 'I select "sjobs@apple.com" as "Reporter"' do + within ".users-project-form" do + select2("sjobs@apple.com", from: "#user_ids", multiple: true) + select "Reporter", from: "access_level" + end + click_button "Add users to project" + end + + step 'I should see "sjobs@apple.com" in team list as invited "Reporter"' do + within ".access-reporter" do + page.should have_content('sjobs@apple.com') + page.should have_content('invited') + page.should have_content('Reporter') + end + end + step 'I should see "Sam" in team list as "Developer"' do within ".access-developer" do page.should have_content('Sam') From e50556fcc8d81dae5978bcb3e1dd5104789aab0f Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Apr 2015 16:37:02 +0200 Subject: [PATCH 150/194] Let invites be declined. --- app/controllers/invites_controller.rb | 27 +++++++++++++++++++ app/mailers/emails/groups.rb | 13 +++++++++ app/mailers/emails/projects.rb | 13 +++++++++ app/models/member.rb | 14 ++++++++++ app/models/members/group_member.rb | 6 +++++ app/models/members/project_member.rb | 6 +++++ app/services/notification_service.rb | 8 ++++++ app/views/invites/show.html.haml | 1 + .../group_invite_declined_email.html.haml | 6 +++++ .../group_invite_declined_email.text.erb | 3 +++ .../group_member_invited_email.html.haml | 2 ++ .../group_member_invited_email.text.erb | 1 + .../project_invite_accepted_email.text.erb | 2 +- .../project_invite_declined_email.html.haml | 6 +++++ .../project_invite_declined_email.text.erb | 3 +++ .../project_member_invited_email.html.haml | 2 ++ .../project_member_invited_email.text.erb | 1 + config/routes.rb | 1 + spec/models/members_spec.rb | 17 ++++++++++++ 19 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 app/views/notify/group_invite_declined_email.html.haml create mode 100644 app/views/notify/group_invite_declined_email.text.erb create mode 100644 app/views/notify/project_invite_declined_email.html.haml create mode 100644 app/views/notify/project_invite_declined_email.text.erb diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index 8b1f6eeba1..1b236a48e9 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -1,5 +1,6 @@ class InvitesController < ApplicationController before_filter :member + skip_before_filter :authenticate_user!, only: :decline respond_to :html @@ -31,6 +32,32 @@ class InvitesController < ApplicationController end end + def decline + if member.decline_invite! + case member.source + when Project + project = member.source + source = "project #{project.name_with_namespace}" + when Group + group = member.source + source = "group #{group.name}" + else + source = "who knows what" + end + + path = + if current_user + dashboard_path + else + new_user_session_path + end + + redirect_to path, notice: "You have declined the invite to join #{source}." + else + redirect_to :back, alert: "The invite could not be declined." + end + end + private def member diff --git a/app/mailers/emails/groups.rb b/app/mailers/emails/groups.rb index 9db609006e..f3da3990c5 100644 --- a/app/mailers/emails/groups.rb +++ b/app/mailers/emails/groups.rb @@ -27,5 +27,18 @@ module Emails mail(to: @group_member.created_by.notification_email, subject: subject("Invite accepted")) end + + def group_invite_declined_email(group_id, invite_email, access_level, created_by_id) + return if created_by_id.nil? + + @group = Group.find(group_id) + @created_by = User.find(created_by_id) + @access_level = access_level + @invite_email = invite_email + @target_url = group_url(@group) + + mail(to: @created_by.notification_email, + subject: subject("Invite declined")) + end end end diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb index 64c35618a5..bbfb7a3e40 100644 --- a/app/mailers/emails/projects.rb +++ b/app/mailers/emails/projects.rb @@ -28,6 +28,19 @@ module Emails subject: subject("Invite accepted")) end + def project_invite_declined_email(project_id, invite_email, access_level, created_by_id) + return if created_by_id.nil? + + @project = Project.find(project_id) + @created_by = User.find(created_by_id) + @access_level = access_level + @invite_email = invite_email + @target_url = namespace_project_url(@project.namespace, @project) + + mail(to: @created_by.notification_email, + subject: subject("Invite declined")) + end + def project_was_moved_email(project_id, user_id) @current_user = @user = User.find user_id @project = Project.find project_id diff --git a/app/models/member.rb b/app/models/member.rb index d8cc65ca6c..2421222eaa 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -76,6 +76,16 @@ class Member < ActiveRecord::Base saved end + def decline_invite! + return false unless invite? + + destroyed = self.destroy + + after_decline_invite if destroyed + + destroyed + end + def generate_invite_token raw, enc = Devise.token_generator.generate(self.class, :invite_token) @raw_invite_token = raw @@ -116,6 +126,10 @@ class Member < ActiveRecord::Base post_create_hook end + def after_decline_invite + # override in subclass + end + def system_hook_service SystemHooksService.new end diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index 68b7c169f7..84c91372b3 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -66,4 +66,10 @@ class GroupMember < Member super end + + def after_decline_invite + notification_service.decline_group_invite(self) + + super + end end diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index 3b3b1bd468..8af7499dd8 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -167,6 +167,12 @@ class ProjectMember < Member super end + def after_decline_invite + notification_service.decline_project_invite(self) + + super + end + def event_service EventCreateService.new end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index a97abe289f..203e654c18 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -173,6 +173,10 @@ class NotificationService mailer.project_invite_accepted_email(project_member.id) end + def decline_project_invite(project_member) + mailer.project_invite_declined_email(project_member.project.id, project_member.invite_email, project_member.access_level, project_member.created_by_id) + end + def new_project_member(project_member) mailer.project_access_granted_email(project_member.id) end @@ -189,6 +193,10 @@ class NotificationService mailer.group_invite_accepted_email(group_member.id) end + def decline_group_invite(group_member) + mailer.group_invite_declined_email(group_member.group.id, group_member.invite_email, group_member.access_level, group_member.created_by_id) + end + def new_group_member(group_member) mailer.group_access_granted_email(group_member.id) end diff --git a/app/views/invites/show.html.haml b/app/views/invites/show.html.haml index e2657ac6df..ab0ecffe4d 100644 --- a/app/views/invites/show.html.haml +++ b/app/views/invites/show.html.haml @@ -26,3 +26,4 @@ - else .actions = link_to "Accept invitation", accept_invite_url(@token), method: :post, class: "btn btn-success" + = link_to "Decline", decline_invite_url(@token), method: :post, class: "btn btn-danger prepend-left-10" diff --git a/app/views/notify/group_invite_declined_email.html.haml b/app/views/notify/group_invite_declined_email.html.haml new file mode 100644 index 0000000000..80f39c3f1e --- /dev/null +++ b/app/views/notify/group_invite_declined_email.html.haml @@ -0,0 +1,6 @@ +%p + #{@invite_email} + has declined your invitation to join group + = link_to @group.name, group_url(@group) + as #{Gitlab::Access.options_with_owner.key(@access_level)}. + diff --git a/app/views/notify/group_invite_declined_email.text.erb b/app/views/notify/group_invite_declined_email.text.erb new file mode 100644 index 0000000000..31cdac0e7f --- /dev/null +++ b/app/views/notify/group_invite_declined_email.text.erb @@ -0,0 +1,3 @@ +<%= @invite_email %> has declined your invitation to join group <%= @group.name %> as <%= Gitlab::Access.options_with_owner.key(@access_level) %>. + +<%= group_url(@group) %> diff --git a/app/views/notify/group_member_invited_email.html.haml b/app/views/notify/group_member_invited_email.html.haml index 39798a2c0e..163e88bfea 100644 --- a/app/views/notify/group_member_invited_email.html.haml +++ b/app/views/notify/group_member_invited_email.html.haml @@ -9,4 +9,6 @@ %p = link_to 'Accept invitation', invite_url(@token) + or + = link_to 'decline', decline_invite_url(@token) diff --git a/app/views/notify/group_member_invited_email.text.erb b/app/views/notify/group_member_invited_email.text.erb index dec0cf0d01..28ce4819b1 100644 --- a/app/views/notify/group_member_invited_email.text.erb +++ b/app/views/notify/group_member_invited_email.text.erb @@ -1,3 +1,4 @@ You have been invited <%= "by #{@group_member.created_by.name} " if @group_member.created_by %>to join group <%= @group.name %> as <%= @group_member.human_access %>. Accept invitation: <%= invite_url(@token) %> +Decline invitation: <%= decline_invite_url(@token) %> diff --git a/app/views/notify/project_invite_accepted_email.text.erb b/app/views/notify/project_invite_accepted_email.text.erb index e90217c1ab..0bbe274931 100644 --- a/app/views/notify/project_invite_accepted_email.text.erb +++ b/app/views/notify/project_invite_accepted_email.text.erb @@ -1,3 +1,3 @@ -<%= @project_member.invite_email %>, now known as <%= @project_member.user.name %>, has accepted your invitation to join project <%= @group.name_with_namespace %> as <%= @project_member.human_access %>. +<%= @project_member.invite_email %>, now known as <%= @project_member.user.name %>, has accepted your invitation to join project <%= @project.name_with_namespace %> as <%= @project_member.human_access %>. <%= namespace_project_url(@project.namespace, @project) %> diff --git a/app/views/notify/project_invite_declined_email.html.haml b/app/views/notify/project_invite_declined_email.html.haml new file mode 100644 index 0000000000..dad9f505b9 --- /dev/null +++ b/app/views/notify/project_invite_declined_email.html.haml @@ -0,0 +1,6 @@ +%p + #{@invite_email} + has declined your invitation to join project + = link_to @project.name_with_namespace, namespace_project_url(@project.namespace, @project) + as #{Gitlab::Access.options_with_owner.key(@access_level)}. + diff --git a/app/views/notify/project_invite_declined_email.text.erb b/app/views/notify/project_invite_declined_email.text.erb new file mode 100644 index 0000000000..06a0013cff --- /dev/null +++ b/app/views/notify/project_invite_declined_email.text.erb @@ -0,0 +1,3 @@ +<%= @invite_email %> has declined your invitation to join project <%= @project.name_with_namespace %> as <%= Gitlab::Access.options_with_owner.key(@access_level) %>. + +<%= namespace_project_url(@project.namespace, @project) %> diff --git a/app/views/notify/project_member_invited_email.html.haml b/app/views/notify/project_member_invited_email.html.haml index f44adc244c..79eb89616d 100644 --- a/app/views/notify/project_member_invited_email.html.haml +++ b/app/views/notify/project_member_invited_email.html.haml @@ -9,3 +9,5 @@ %p = link_to 'Accept invitation', invite_url(@token) + or + = link_to 'decline', decline_invite_url(@token) diff --git a/app/views/notify/project_member_invited_email.text.erb b/app/views/notify/project_member_invited_email.text.erb index 55b16ef6c5..e070627211 100644 --- a/app/views/notify/project_member_invited_email.text.erb +++ b/app/views/notify/project_member_invited_email.text.erb @@ -1,3 +1,4 @@ You have been invited <%= "by #{@project_member.created_by.name} " if @project_member.created_by %>to join project <%= @project.name_with_namespace %> as <%= @project_member.human_access %>. Accept invitation: <%= invite_url(@token) %> +Decline invitation: <%= decline_invite_url(@token) %> diff --git a/config/routes.rb b/config/routes.rb index 50d11fde57..d90268c218 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -60,6 +60,7 @@ Gitlab::Application.routes.draw do resources :invites, only: [:show], constraints: { id: /[A-Za-z0-9_-]+/ } do member do post :accept + match :decline, via: [:get, :post] end end diff --git a/spec/models/members_spec.rb b/spec/models/members_spec.rb index 3f14a11144..c3e47ab7e9 100644 --- a/spec/models/members_spec.rb +++ b/spec/models/members_spec.rb @@ -88,6 +88,23 @@ describe Member do end end + describe "#decline_invite!" do + + let!(:member) { create(:project_member, invite_email: "user@example.com", user: nil) } + + it "destroys the member" do + member.decline_invite! + + expect(member).to be_destroyed + end + + it "calls #after_decline_invite" do + expect(member).to receive(:after_decline_invite) + + member.decline_invite! + end + end + describe "#generate_invite_token" do let!(:member) { create(:project_member, invite_email: "user@example.com", user: nil) } From 1b5c483d89d9a20a92e98fc37879389ee2c8dd00 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 14 Apr 2015 12:25:16 +0200 Subject: [PATCH 151/194] Consistently use "invitation" rather than "invite" in text. --- app/controllers/groups/group_members_controller.rb | 4 ++-- app/controllers/invites_controller.rb | 6 +++--- .../projects/project_members_controller.rb | 8 ++++---- app/helpers/groups_helper.rb | 2 +- app/helpers/projects_helper.rb | 2 +- app/mailers/emails/groups.rb | 11 ++++++----- app/mailers/emails/projects.rb | 11 +++++++---- 7 files changed, 24 insertions(+), 20 deletions(-) diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index 0018cd34bb..359eeb4d27 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -52,9 +52,9 @@ class Groups::GroupMembersController < Groups::ApplicationController if @group_member.invite? @group_member.resend_invite - redirect_to group_group_members_path(@group), notice: 'Invite was successfully resent.' + redirect_to group_group_members_path(@group), notice: 'The invitation was successfully resent.' else - redirect_to group_group_members_path(@group), alert: 'The invite has already been accepted.' + redirect_to group_group_members_path(@group), alert: 'The invitation has already been accepted.' end end diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index 1b236a48e9..00d274a7f1 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -28,7 +28,7 @@ class InvitesController < ApplicationController redirect_to path, notice: "You have been granted #{member.human_access} access to #{source}." else - redirect_to :back, alert: "The invite could not be accepted." + redirect_to :back, alert: "The invitation could not be accepted." end end @@ -52,9 +52,9 @@ class InvitesController < ApplicationController new_user_session_path end - redirect_to path, notice: "You have declined the invite to join #{source}." + redirect_to path, notice: "You have declined the invitation to join #{label}." else - redirect_to :back, alert: "The invite could not be declined." + redirect_to :back, alert: "The invitation could not be declined." end end diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index e55e8e4c50..9831a2089b 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -60,15 +60,15 @@ class Projects::ProjectMembersController < Projects::ApplicationController end def resend_invite + redirect_path = namespace_project_project_members_path(@project.namespace, @project) + @project_member = @project.project_members.find(params[:id]) if @project_member.invite? @project_member.resend_invite - redirect_to namespace_project_project_members_path(@project.namespace, - @project), notice: 'Invite was successfully resent.' + redirect_to redirect_path, notice: 'The invitation was successfully resent.' else - redirect_to namespace_project_project_members_path(@project.namespace, - @project), alert: 'The invite has already been accepted.' + redirect_to redirect_path, alert: 'The invitation has already been accepted.' end end diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 52d88133d5..add0a776a6 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -3,7 +3,7 @@ module GroupsHelper if member.user "Are you sure you want to remove \"#{member.user.name}\" from \"#{group.name}\"?" else - "Are you sure you want to cancel invitation for \"#{member.invite_email}\" to join \"#{group.name}\"?" + "Are you sure you want to revoke the invitation for \"#{member.invite_email}\" to join \"#{group.name}\"?" end end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 06e5d3b738..c2a7732e6f 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -3,7 +3,7 @@ module ProjectsHelper if member.user "You are going to remove #{member.user.name} from #{project.name} project team. Are you sure?" else - "You are going to cancel invitation for #{member.invite_email} to join #{project.name} project team. Are you sure?" + "You are going to revoke the invitation for #{member.invite_email} to join #{project.name} project team. Are you sure?" end end diff --git a/app/mailers/emails/groups.rb b/app/mailers/emails/groups.rb index f3da3990c5..07b8f6d735 100644 --- a/app/mailers/emails/groups.rb +++ b/app/mailers/emails/groups.rb @@ -14,8 +14,9 @@ module Emails @group = @group_member.group @token = token @target_url = group_url(@group) + @current_user = @group_member.user mail(to: @group_member.invite_email, - subject: "Invite to join group #{@group.name}") + subject: "Invitation to join group #{@group.name}") end def group_invite_accepted_email(group_member_id) @@ -24,21 +25,21 @@ module Emails @group = @group_member.group @target_url = group_url(@group) + @current_user = @group_member.created_by mail(to: @group_member.created_by.notification_email, - subject: subject("Invite accepted")) + subject: subject("Invitation accepted")) end def group_invite_declined_email(group_id, invite_email, access_level, created_by_id) return if created_by_id.nil? @group = Group.find(group_id) - @created_by = User.find(created_by_id) + @current_user = @created_by = User.find(created_by_id) @access_level = access_level @invite_email = invite_email @target_url = group_url(@group) - mail(to: @created_by.notification_email, - subject: subject("Invite declined")) + subject: subject("Invitation declined")) end end end diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb index bbfb7a3e40..7baeead3fb 100644 --- a/app/mailers/emails/projects.rb +++ b/app/mailers/emails/projects.rb @@ -14,8 +14,9 @@ module Emails @project = @project_member.project @token = token @target_url = namespace_project_url(@project.namespace, @project) + @current_user = @project_member.user mail(to: @project_member.invite_email, - subject: "Invite to join project #{@project.name_with_namespace}") + subject: "Invitation to join project #{@project.name_with_namespace}") end def project_invite_accepted_email(project_member_id) @@ -24,21 +25,23 @@ module Emails @project = @project_member.project @target_url = namespace_project_url(@project.namespace, @project) + @current_user = @project_member.created_by + mail(to: @project_member.created_by.notification_email, - subject: subject("Invite accepted")) + subject: subject("Invitation accepted")) end def project_invite_declined_email(project_id, invite_email, access_level, created_by_id) return if created_by_id.nil? @project = Project.find(project_id) - @created_by = User.find(created_by_id) + @current_user = @created_by = User.find(created_by_id) @access_level = access_level @invite_email = invite_email @target_url = namespace_project_url(@project.namespace, @project) mail(to: @created_by.notification_email, - subject: subject("Invite declined")) + subject: subject("Invitation declined")) end def project_was_moved_email(project_id, user_id) From 1c0b58a7994f2942799c5ccdbdd37a48aa034f97 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 14 Apr 2015 12:25:38 +0200 Subject: [PATCH 152/194] Remove duplication from InvitesController. --- app/controllers/invites_controller.rb | 45 +++++++++++++-------------- 1 file changed, 21 insertions(+), 24 deletions(-) diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index 00d274a7f1..2a98fb0b48 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -12,21 +12,9 @@ class InvitesController < ApplicationController def accept if member.accept_invite!(current_user) - case member.source - when Project - project = member.source - source = "project #{project.name_with_namespace}" - path = namespace_project_path(project.namespace, project) - when Group - group = member.source - source = "group #{group.name}" - path = group_path(group) - else - source = "who knows what" - path = dashboard_path - end + label, path = source_info(member.source) - redirect_to path, notice: "You have been granted #{member.human_access} access to #{source}." + redirect_to path, notice: "You have been granted #{member.human_access} access to #{label}." else redirect_to :back, alert: "The invitation could not be accepted." end @@ -34,16 +22,7 @@ class InvitesController < ApplicationController def decline if member.decline_invite! - case member.source - when Project - project = member.source - source = "project #{project.name_with_namespace}" - when Group - group = member.source - source = "group #{group.name}" - else - source = "who knows what" - end + label, _ = source_info(member.source) path = if current_user @@ -81,4 +60,22 @@ class InvitesController < ApplicationController store_location_for :user, request.fullpath redirect_to new_user_session_path, notice: notice end + + def source_info(source) + case source + when Project + project = member.source + label = "project #{project.name_with_namespace}" + path = namespace_project_path(project.namespace, project) + when Group + group = member.source + label = "group #{group.name}" + path = group_path(group) + else + label = "who knows what" + path = dashboard_path + end + + [label, path] + end end From a8b462bbfe3647574e17c483aad27281c2adc5f2 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 14 Apr 2015 12:25:47 +0200 Subject: [PATCH 153/194] Clear up InvitesController member lookup logic. --- app/controllers/invites_controller.rb | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index 2a98fb0b48..3ac8654fcc 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -43,11 +43,10 @@ class InvitesController < ApplicationController return @member if defined?(@member) @token = params[:id] - if member = Member.find_by_invite_token(@token) - @member = member - else - render_404 - end + + @member = Member.find_by_invite_token(@token) + + render_404 unless @member end def authenticate_user! From 0013ea5130494326f37fb1d6988a369fa2c91d22 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 14 Apr 2015 12:33:27 +0200 Subject: [PATCH 154/194] Remove duplication between Group and ProjectMember. --- .../projects/project_members_controller.rb | 3 +- app/models/group.rb | 21 +--------- app/models/member.rb | 36 +++++++++++++++-- app/models/members/project_member.rb | 19 +-------- .../{members_spec.rb => member_spec.rb} | 39 ++++++++++++++++++- 5 files changed, 75 insertions(+), 43 deletions(-) rename spec/models/{members_spec.rb => member_spec.rb} (71%) diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index 9831a2089b..3da44cbc9d 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -52,8 +52,7 @@ class Projects::ProjectMembersController < Projects::ApplicationController respond_to do |format| format.html do - redirect_to namespace_project_project_members_path(@project.namespace, - @project) + redirect_to namespace_project_project_members_path(@project.namespace, @project) end format.js { render nothing: true } end diff --git a/app/models/group.rb b/app/models/group.rb index 2de397f90f..1386a9eccc 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -47,25 +47,8 @@ class Group < Namespace end def add_users(user_ids, access_level, current_user = nil) - users = user_ids.map do |user_id| - (user_id if user_id.is_a?(User)) || - User.find_by(id: user_id) || - User.find_by(email: user_id) || - user_id - end - - users.compact.each do |user| - if user.is_a?(User) - member = self.group_members.find_or_initialize_by(user_id: user.id) - else - member = self.group_members.build - member.invite_email = user - end - - member.created_by ||= current_user - member.access_level = access_level - - member.save + user_ids.each do |user_id| + Member.add_user(self.group_members, user_id, access_level, current_user) end end diff --git a/app/models/member.rb b/app/models/member.rb index 2421222eaa..9340c44217 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -52,9 +52,39 @@ class Member < ActiveRecord::Base delegate :name, :username, :email, to: :user, prefix: true - def self.find_by_invite_token(invite_token) - invite_token = Devise.token_generator.digest(self, :invite_token, invite_token) - find_by(invite_token: invite_token) + class << self + def find_by_invite_token(invite_token) + invite_token = Devise.token_generator.digest(self, :invite_token, invite_token) + find_by(invite_token: invite_token) + end + + # This method is used to find users that have been entered into the "Add members" field. + # These can be the User objects directly, their IDs, their emails, or new emails to be invited. + def user_for_id(user_id) + return user_id if user_id.is_a?(User) + + user = User.find_by(id: user_id) + user ||= User.find_by(email: user_id) + user ||= user_id + user + end + + def add_user(members, user_id, access_level, current_user = nil) + user = user_for_id(user_id) + + # `user` can be either a User object or an email to be invited + if user.is_a?(User) + member = members.find_or_initialize_by(user_id: user.id) + else + member = members.build + member.invite_email = user + end + + member.created_by ||= current_user + member.access_level = access_level + + member.save + end end def invite? diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index 8af7499dd8..ce704a60d6 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -60,29 +60,14 @@ class ProjectMember < Member raise "Non valid access" end - users = user_ids.map do |user_id| - (user_id if user_id.is_a?(User)) || - User.find_by(id: user_id) || - User.find_by(email: user_id) || - user_id - end + users = user_ids.map { |user_id| Member.user_for_id(user_id) } ProjectMember.transaction do project_ids.each do |project_id| project = Project.find(project_id) users.each do |user| - if user.is_a?(User) - member = project.project_members.find_or_initialize_by(user_id: user.id) - else - member = project.project_members.build - member.invite_email = user - end - - member.created_by ||= current_user - member.access_level = access_level - - member.save + Member.add_user(project.project_members, user, access_level, current_user) end end end diff --git a/spec/models/members_spec.rb b/spec/models/member_spec.rb similarity index 71% rename from spec/models/members_spec.rb rename to spec/models/member_spec.rb index c3e47ab7e9..a27931cd4e 100644 --- a/spec/models/members_spec.rb +++ b/spec/models/member_spec.rb @@ -58,6 +58,43 @@ describe Member do it { is_expected.to respond_to(:user_email) } end + describe ".add_user" do + let!(:user) { create(:user) } + let(:project) { create(:project) } + + context "when called with a user id" do + it "adds the user as a member" do + Member.add_user(project.project_members, user.id, ProjectMember::MASTER) + + expect(project.users).to include(user) + end + end + + context "when called with a user object" do + it "adds the user as a member" do + Member.add_user(project.project_members, user, ProjectMember::MASTER) + + expect(project.users).to include(user) + end + end + + context "when called with a known user email" do + it "adds the user as a member" do + Member.add_user(project.project_members, user.email, ProjectMember::MASTER) + + expect(project.users).to include(user) + end + end + + context "when called with an unknown user email" do + it "adds a member invite" do + Member.add_user(project.project_members, "user@example.com", ProjectMember::MASTER) + + expect(project.project_members.invite.pluck(:invite_email)).to include("user@example.com") + end + end + end + describe "#accept_invite!" do let!(:member) { create(:project_member, invite_email: "user@example.com", user: nil) } @@ -89,7 +126,6 @@ describe Member do end describe "#decline_invite!" do - let!(:member) { create(:project_member, invite_email: "user@example.com", user: nil) } it "destroys the member" do @@ -106,7 +142,6 @@ describe Member do end describe "#generate_invite_token" do - let!(:member) { create(:project_member, invite_email: "user@example.com", user: nil) } it "sets the invite token" do From c26b001ebcf43c3bdc18d1afcfbafa87b0b18366 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 14 Apr 2015 12:52:33 +0200 Subject: [PATCH 155/194] Set EmailsOnPush reply-to address to committer email when enabled. --- CHANGELOG | 1 + app/mailers/emails/projects.rb | 14 +++++++++++--- app/mailers/notify.rb | 22 +++++++++++++--------- spec/mailers/notify_spec.rb | 15 +++++++++++++++ 4 files changed, 40 insertions(+), 12 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index f133cb2478..ab13f8903e 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 7.10.0 (unreleased) + - Set EmailsOnPush reply-to address to committer email when enabled. - Fix broken file browsing with a submodule that contains a relative link (Stan Hu) - Fix persistent XSS vulnerability around profile website URLs. - Fix project import URL regex to prevent arbitary local repos from being imported. diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb index 20a863c374..d15123e731 100644 --- a/app/mailers/emails/projects.rb +++ b/app/mailers/emails/projects.rb @@ -84,9 +84,17 @@ module Emails @disable_footer = true - mail(from: sender(author_id, send_from_committer_email), - to: recipient, - subject: @subject) + reply_to = + if send_from_committer_email && can_send_from_user_email?(@author) + @author.email + else + Gitlab.config.gitlab.email_reply_to + end + + mail(from: sender(author_id, send_from_committer_email), + reply_to: reply_to, + to: recipient, + subject: @subject) end end end diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb index 7c8b37029d..2c0d451511 100644 --- a/app/mailers/notify.rb +++ b/app/mailers/notify.rb @@ -60,20 +60,24 @@ class Notify < ActionMailer::Base address end + def can_send_from_user_email?(sender) + sender_domain = sender.email.split("@").last + self.class.allowed_email_domains.include?(sender_domain) + end + # Return an email address that displays the name of the sender. # Only the displayed name changes; the actual email address is always the same. def sender(sender_id, send_from_user_email = false) - if sender = User.find(sender_id) - address = default_sender_address - address.display_name = sender.name + return unless sender = User.find(sender_id) + + address = default_sender_address + address.display_name = sender.name - sender_domain = sender.email.split("@").last - if send_from_user_email && self.class.allowed_email_domains.include?(sender_domain) - address.address = sender.email - end - - address.format + if send_from_user_email && can_send_from_user_email?(sender) + address.address = sender.email end + + address.format end # Look up a User by their ID and return their email address diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index 6bea0321cb..b297fbd511 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -725,6 +725,11 @@ describe Notify do sender = subject.header[:from].addrs[0] expect(sender.address).to eq(user.email) end + + it "is set to reply to the committer email" do + sender = subject.header[:reply_to].addrs[0] + expect(sender.address).to eq(user.email) + end end context "when the committer email domain is not completely within the GitLab domain" do @@ -738,6 +743,11 @@ describe Notify do sender = subject.header[:from].addrs[0] expect(sender.address).to eq(gitlab_sender) end + + it "is set to reply to the default email" do + sender = subject.header[:reply_to].addrs[0] + expect(sender.address).to eq(gitlab_sender_reply_to) + end end context "when the committer email domain is outside the GitLab domain" do @@ -751,6 +761,11 @@ describe Notify do sender = subject.header[:from].addrs[0] expect(sender.address).to eq(gitlab_sender) end + + it "is set to reply to the default email" do + sender = subject.header[:reply_to].addrs[0] + expect(sender.address).to eq(gitlab_sender_reply_to) + end end end end From 5e2f25c32ee36ed5a4ad137c299b60d91b7ebdeb Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 14 Apr 2015 13:07:15 +0200 Subject: [PATCH 156/194] Add explanation to HelpController#clean_path_info. --- app/controllers/help_controller.rb | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/app/controllers/help_controller.rb b/app/controllers/help_controller.rb index 0010caad77..0e5567c773 100644 --- a/app/controllers/help_controller.rb +++ b/app/controllers/help_controller.rb @@ -37,21 +37,34 @@ class HelpController < ApplicationController def ui end - # Taken from ActionDispatch::FileHandler PATH_SEPS = Regexp.union(*[::File::SEPARATOR, ::File::ALT_SEPARATOR].compact) + # Taken from ActionDispatch::FileHandler + # Cleans up the path, to prevent directory traversal outside the doc folder. def clean_path_info(path_info) - parts = path_info.split PATH_SEPS + parts = path_info.split(PATH_SEPS) clean = [] + # Walk over each part of the path parts.each do |part| + # Turn `one//two` or `one/./two` into `one/two`. next if part.empty? || part == '.' - part == '..' ? clean.pop : clean << part + + if part == '..' + # Turn `one/two/../` into `one` + clean.pop + else + # Add simple folder names to the clean path. + clean << part + end end + # If the path was an absolute path (i.e. `/` or `/one/two`), + # add `/` to the front of the clean path. clean.unshift '/' if parts.empty? || parts.first.empty? + # Join all the clean path parts by the path separator. ::File.join(*clean) end end From dfe659c16de6f9ad38e429b89131df20276e21d4 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 14 Apr 2015 13:10:25 +0200 Subject: [PATCH 157/194] Remove author link since all code.google.com links will be shut down. --- lib/gitlab/google_code_import/importer.rb | 14 ++++++-------- .../lib/gitlab/google_code_import/importer_spec.rb | 2 -- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/lib/gitlab/google_code_import/importer.rb b/lib/gitlab/google_code_import/importer.rb index 8bdb46237f..07eccfa4db 100644 --- a/lib/gitlab/google_code_import/importer.rb +++ b/lib/gitlab/google_code_import/importer.rb @@ -66,12 +66,11 @@ module Gitlab end last_id = raw_issue["id"] - author = mask_email(raw_issue["author"]["name"]) - author_link = raw_issue["author"]["htmlLink"] - date = DateTime.parse(raw_issue["published"]).to_formatted_s(:long) + author = mask_email(raw_issue["author"]["name"]) + date = DateTime.parse(raw_issue["published"]).to_formatted_s(:long) body = [] - body << "*By [#{author}](#{author_link}) on #{date}*" + body << "*By #{author} on #{date}*" body << "---" comments = raw_issue["comments"]["items"] @@ -120,12 +119,11 @@ module Gitlab comments.each_with_index do |raw_comment, i| next if raw_comment.has_key?("deletedBy") - author = mask_email(raw_comment["author"]["name"]) - author_link = raw_comment["author"]["htmlLink"] - date = DateTime.parse(raw_comment["published"]).to_formatted_s(:long) + author = mask_email(raw_comment["author"]["name"]) + date = DateTime.parse(raw_comment["published"]).to_formatted_s(:long) body = [] - body << "*By [#{author}](#{author_link}) on #{date}*" + body << "*By #{author} on #{date}*" body << "---" content = format_content(raw_comment["content"]) diff --git a/spec/lib/gitlab/google_code_import/importer_spec.rb b/spec/lib/gitlab/google_code_import/importer_spec.rb index 408553a98f..9ff972b211 100644 --- a/spec/lib/gitlab/google_code_import/importer_spec.rb +++ b/spec/lib/gitlab/google_code_import/importer_spec.rb @@ -43,7 +43,6 @@ describe Gitlab::GoogleCodeImport::Importer do expect(issue.title).to eq("Scrolling through tasks") expect(issue.state).to eq("closed") expect(issue.description).to include("schattenpr...") - expect(issue.description).to include("https://code.google.com/u/106498139506637530000/") expect(issue.description).to include("November 18, 2009 00:20") expect(issue.description).to include('I like to scroll through the tasks with my scrollwheel \(like in fluxbox\).') expect(issue.description).to include('Patch is attached that adds two new mouse\-actions \(next\_taskprev\_task\)') @@ -59,7 +58,6 @@ describe Gitlab::GoogleCodeImport::Importer do note = project.issues.first.notes.first expect(note).to_not be_nil expect(note.note).to include("thilo...") - expect(note.note).to include("https://code.google.com/u/104224918623172014000/") expect(note.note).to include("November 18, 2009 05:14") expect(note.note).to include("applied, thanks.") expect(note.note).to include("Status: Fixed") From f6cb42f3d197b7920207088f5fa5f5161be90905 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 14 Apr 2015 13:13:18 +0200 Subject: [PATCH 158/194] Add comment number to imported Google Code comments. --- lib/gitlab/google_code_import/importer.rb | 6 +++--- spec/lib/gitlab/google_code_import/importer_spec.rb | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/gitlab/google_code_import/importer.rb b/lib/gitlab/google_code_import/importer.rb index 07eccfa4db..eaa8c6bc6d 100644 --- a/lib/gitlab/google_code_import/importer.rb +++ b/lib/gitlab/google_code_import/importer.rb @@ -52,7 +52,7 @@ module Gitlab deleted_issues = [] issues = repo.raw_data["issues"]["items"] - issues.each_with_index do |raw_issue, i| + issues.each do |raw_issue| while raw_issue["id"] > last_id + 1 last_id += 1 @@ -116,14 +116,14 @@ module Gitlab end def import_issue_comments(issue, comments) - comments.each_with_index do |raw_comment, i| + comments.each do |raw_comment| next if raw_comment.has_key?("deletedBy") author = mask_email(raw_comment["author"]["name"]) date = DateTime.parse(raw_comment["published"]).to_formatted_s(:long) body = [] - body << "*By #{author} on #{date}*" + body << "*Comment #{raw_comment["id"]} by #{author} on #{date}*" body << "---" content = format_content(raw_comment["content"]) diff --git a/spec/lib/gitlab/google_code_import/importer_spec.rb b/spec/lib/gitlab/google_code_import/importer_spec.rb index 9ff972b211..370d441010 100644 --- a/spec/lib/gitlab/google_code_import/importer_spec.rb +++ b/spec/lib/gitlab/google_code_import/importer_spec.rb @@ -57,6 +57,7 @@ describe Gitlab::GoogleCodeImport::Importer do note = project.issues.first.notes.first expect(note).to_not be_nil + expect(note.note).to include("Comment 1") expect(note.note).to include("thilo...") expect(note.note).to include("November 18, 2009 05:14") expect(note.note).to include("applied, thanks.") From 7b28218f96d14c9650484a183ffab3b2132b05db Mon Sep 17 00:00:00 2001 From: Senorsen Date: Tue, 14 Apr 2015 00:28:49 +0800 Subject: [PATCH 159/194] Allow user to choose which email to be public This commit allows user to show one of their emails in profile page, or don't show email in this page. --- CHANGELOG | 1 + app/controllers/profiles/emails_controller.rb | 4 +++- app/controllers/profiles_controller.rb | 7 ++++--- app/models/user.rb | 9 +++++++++ app/views/profiles/emails/index.html.haml | 4 ++++ app/views/profiles/show.html.haml | 5 +++++ app/views/users/_profile.html.haml | 4 ++++ db/migrate/20150413192223_add_public_email_to_users.rb | 5 +++++ db/schema.rb | 3 ++- 9 files changed, 37 insertions(+), 5 deletions(-) create mode 100644 db/migrate/20150413192223_add_public_email_to_users.rb diff --git a/CHANGELOG b/CHANGELOG index f133cb2478..93dfa28d97 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -73,6 +73,7 @@ v 7.10.0 (unreleased) - Automatically setup GitLab CI project for forks if origin project has GitLab CI enabled - Bust group page project list cache when namespace name or path changes. - Explicitly set image alt-attribute to prevent graphical glitches if gravatars could not be loaded + - Allow user to choose a public email to show on public profile v 7.9.3 - Contains no changes diff --git a/app/controllers/profiles/emails_controller.rb b/app/controllers/profiles/emails_controller.rb index 4a65c978e5..954c98c0d9 100644 --- a/app/controllers/profiles/emails_controller.rb +++ b/app/controllers/profiles/emails_controller.rb @@ -3,6 +3,7 @@ class Profiles::EmailsController < ApplicationController def index @primary = current_user.email + @public_email = current_user.public_email @emails = current_user.emails end @@ -19,7 +20,8 @@ class Profiles::EmailsController < ApplicationController @email.destroy current_user.set_notification_email - current_user.save if current_user.notification_email_changed? + current_user.set_public_email + current_user.save if current_user.notification_email_changed? or current_user.public_email_changed? respond_to do |format| format.html { redirect_to profile_emails_url } diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index 9252e85e8c..7f76906066 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -67,9 +67,10 @@ class ProfilesController < ApplicationController def user_params params.require(:user).permit( - :email, :password, :password_confirmation, :bio, :name, :username, - :skype, :linkedin, :twitter, :website_url, :color_scheme_id, :theme_id, - :avatar, :hide_no_ssh_key, :hide_no_password, :location + :email, :password, :password_confirmation, :bio, :name, + :username, :skype, :linkedin, :twitter, :website_url, + :color_scheme_id, :theme_id, :avatar, :hide_no_ssh_key, + :hide_no_password, :location, :public_email ) end end diff --git a/app/models/user.rb b/app/models/user.rb index a40111e62d..d6b93afe73 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -49,6 +49,7 @@ # password_automatically_set :boolean default(FALSE) # bitbucket_access_token :string(255) # bitbucket_access_token_secret :string(255) +# public_email :string(255) default(""), not null # require 'carrierwave/orm/activerecord' @@ -123,6 +124,7 @@ class User < ActiveRecord::Base validates :name, presence: true validates :email, presence: true, email: { strict_mode: true }, uniqueness: true validates :notification_email, presence: true, email: { strict_mode: true } + validates :public_email, presence: true, email: { strict_mode: true }, allow_blank: true, uniqueness: true validates :bio, length: { maximum: 255 }, allow_blank: true validates :projects_limit, presence: true, numericality: { greater_than_or_equal_to: 0 } validates :username, @@ -142,6 +144,7 @@ class User < ActiveRecord::Base before_validation :generate_password, on: :create before_validation :sanitize_attrs before_validation :set_notification_email, if: ->(user) { user.email_changed? } + before_validation :set_public_email, if: ->(user) { user.public_email_changed? } before_save :ensure_authentication_token after_save :ensure_namespace_correct @@ -443,6 +446,12 @@ class User < ActiveRecord::Base end end + def set_public_email + if self.public_email.blank? || !self.all_emails.include?(self.public_email) + self.public_email = '' + end + end + def set_projects_limit connection_default_value_defined = new_record? && !projects_limit_changed? return unless self.projects_limit.nil? || connection_default_value_defined diff --git a/app/views/profiles/emails/index.html.haml b/app/views/profiles/emails/index.html.haml index 9d8f33cbba..09f290429e 100644 --- a/app/views/profiles/emails/index.html.haml +++ b/app/views/profiles/emails/index.html.haml @@ -20,9 +20,13 @@ %li %strong= @primary %span.label.label-success Primary Email + - if @primary === @public_email + %span.label.label-info Public Email - @emails.each do |email| %li %strong= email.email + - if email.email === @public_email + %span.label.label-info Public Email %span.cgray added #{time_ago_with_tooltip(email.created_at)} = link_to 'Remove', profile_email_path(email), data: { confirm: 'Are you sure?'}, method: :delete, class: 'btn btn-sm btn-remove pull-right' diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index 5a501e4314..6c745e69e4 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -41,6 +41,11 @@ - else %span.help-block We also use email for avatar detection if no avatar is uploaded. + .form-group + = f.label :public_email, class: "control-label" + .col-sm-10 + = f.select :public_email, options_for_select(@user.all_emails, selected: @user.public_email), {include_blank: 'Do not show in profile'}, class: "form-control" + %span.help-block This email will be displayed on your public profile. .form-group = f.label :skype, class: "control-label" .col-sm-10= f.text_field :skype, class: "form-control" diff --git a/app/views/users/_profile.html.haml b/app/views/users/_profile.html.haml index bca7144495..90d9980c85 100644 --- a/app/views/users/_profile.html.haml +++ b/app/views/users/_profile.html.haml @@ -5,6 +5,10 @@ %li %span.light Member since %strong= user.created_at.stamp("Aug 21, 2011") + - unless user.public_email.blank? + %li + %span.light E-mail: + %strong= link_to user.public_email, "mailto:#{user.public_email}" - unless user.skype.blank? %li %span.light Skype: diff --git a/db/migrate/20150413192223_add_public_email_to_users.rb b/db/migrate/20150413192223_add_public_email_to_users.rb new file mode 100644 index 0000000000..700e9f343a --- /dev/null +++ b/db/migrate/20150413192223_add_public_email_to_users.rb @@ -0,0 +1,5 @@ +class AddPublicEmailToUsers < ActiveRecord::Migration + def change + add_column :users, :public_email, :string, default: "", null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 48f1b2ac2c..7f464d7f97 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20150411180045) do +ActiveRecord::Schema.define(version: 20150413192223) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -476,6 +476,7 @@ ActiveRecord::Schema.define(version: 20150411180045) do t.string "bitbucket_access_token" t.string "bitbucket_access_token_secret" t.string "location" + t.string "public_email", default: "", null: false end add_index "users", ["admin"], name: "index_users_on_admin", using: :btree From dba63d667d536c939400979a22967ed9b25980b2 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 14 Apr 2015 14:50:56 +0200 Subject: [PATCH 160/194] Allow user map to be specified. --- .../import/google_code_controller.rb | 52 ++++++++++++++++++- app/views/import/google_code/new.html.haml | 13 ++++- .../import/google_code/new_user_map.html.haml | 20 +++++++ app/views/import/google_code/status.html.haml | 1 + config/routes.rb | 3 ++ lib/gitlab/google_code_import/client.rb | 25 +++++++++ lib/gitlab/google_code_import/importer.rb | 32 +++++++----- .../google_code_import/project_creator.rb | 12 +++-- lib/gitlab/google_code_import/repository.rb | 4 ++ .../google_code_import/importer_spec.rb | 11 +++- 10 files changed, 150 insertions(+), 23 deletions(-) create mode 100644 app/views/import/google_code/new_user_map.html.haml diff --git a/app/controllers/import/google_code_controller.rb b/app/controllers/import/google_code_controller.rb index 9bf29386bc..fb4ef98736 100644 --- a/app/controllers/import/google_code_controller.rb +++ b/app/controllers/import/google_code_controller.rb @@ -1,4 +1,5 @@ class Import::GoogleCodeController < Import::BaseController + before_filter :user_map, only: [:new_user_map, :create_user_map] def new @@ -17,11 +18,46 @@ class Import::GoogleCodeController < Import::BaseController return redirect_to :back, alert: "The uploaded file is not a valid Google Takeout archive." end - unless Gitlab::GoogleCodeImport::Client.new(dump).valid? + client = Gitlab::GoogleCodeImport::Client.new(dump) + unless client.valid? return redirect_to :back, alert: "The uploaded file is not a valid Google Takeout archive." end session[:google_code_dump] = dump + + if params[:create_user_map] == "1" + redirect_to new_user_map_import_google_code_path + else + redirect_to status_import_google_code_path + end + end + + def new_user_map + + end + + def create_user_map + user_map_json = params[:user_map] + user_map_json = "{}" if user_map_json.blank? + + begin + user_map = JSON.parse(user_map_json) + rescue + flash.now[:alert] = "The entered user map is not a valid JSON user map." + + render "new_user_map" and return + end + + unless user_map.is_a?(Hash) && user_map.all? { |k, v| k.is_a?(String) && v.is_a?(String) } + flash.now[:alert] = "The entered user map is not a valid JSON user map." + + render "new_user_map" and return + end + + session[:google_code_user_map] = user_map + + flash[:notice] = "The user map has been saved. Continue by selecting the projects you want to import." + redirect_to status_import_google_code_path end @@ -51,7 +87,9 @@ class Import::GoogleCodeController < Import::BaseController namespace = @target_namespace - @project = Gitlab::GoogleCodeImport::ProjectCreator.new(repo, namespace, current_user).execute + user_map = session[:google_code_user_map] + + @project = Gitlab::GoogleCodeImport::ProjectCreator.new(repo, namespace, current_user, user_map).execute end private @@ -60,4 +98,14 @@ class Import::GoogleCodeController < Import::BaseController @client ||= Gitlab::GoogleCodeImport::Client.new(session[:google_code_dump]) end + def user_map + @user_map ||= begin + user_map = client.user_map + + stored_user_map = session[:google_code_user_map] + user_map.update(stored_user_map) if stored_user_map + + Hash[user_map.sort] + end + end end diff --git a/app/views/import/google_code/new.html.haml b/app/views/import/google_code/new.html.haml index 0b4edc6842..ce78fec205 100644 --- a/app/views/import/google_code/new.html.haml +++ b/app/views/import/google_code/new.html.haml @@ -46,6 +46,15 @@ %input{type: "file", name: "dump_file", id: "dump_file"} %li %p - Continue to the next step: + Do you want to customize how Google Code email addresses and usernames are imported into GitLab? %p - = submit_tag 'Select projects to import', class: "btn btn-create" + = label_tag :create_user_map_0 do + = radio_button_tag :create_user_map, 0, true + No, directly import the existing email addresses and usernames. + %p + = label_tag :create_user_map_1 do + = radio_button_tag :create_user_map, 1, false + Yes, let me map Google Code users to full names or GitLab users. + %li + %p + = submit_tag 'Continue to the next step', class: "btn btn-create" diff --git a/app/views/import/google_code/new_user_map.html.haml b/app/views/import/google_code/new_user_map.html.haml new file mode 100644 index 0000000000..2996d3b659 --- /dev/null +++ b/app/views/import/google_code/new_user_map.html.haml @@ -0,0 +1,20 @@ +%h3.page-title + %i.fa.fa-google + Import projects from Google Code +%hr + += form_tag create_user_map_import_google_code_path, class: 'form-horizontal' do + %p + Customize how Google Code email addresses and usernames are imported into GitLab. + In the next step, you'll be able to select the projects you want to import. + %p + The user map is a JSON document mapping Google Code users (as keys) to the way they will be imported into GitLab (as values). By default the username is masked to ensure users' privacy. + %p + To map a Google Code user to a full name or GitLab user, simply replace the value, e.g. "johnsmith@gmail.com": "John Smith" or "johnsmith@gmail.com": "@johnsmith". Be sure to preserve the surrounding double quotes and other interpunction. + + .form-group + .col-sm-12 + = text_area_tag :user_map, JSON.pretty_generate(@user_map), class: 'form-control', rows: 15 + + .form-actions + = submit_tag 'Continue to the next step', class: "btn btn-create" diff --git a/app/views/import/google_code/status.html.haml b/app/views/import/google_code/status.html.haml index eba9c5296b..8f24a1ce85 100644 --- a/app/views/import/google_code/status.html.haml +++ b/app/views/import/google_code/status.html.haml @@ -7,6 +7,7 @@ %hr %p = button_tag 'Import all projects', class: "btn btn-success js-import-all" + = link_to "Specify user map", new_user_map_import_google_code_path, class: "btn prepend-left-10" %table.table.import-jobs %thead diff --git a/config/routes.rb b/config/routes.rb index 88e0227121..c99980f5e2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -86,6 +86,9 @@ Gitlab::Application.routes.draw do get :status post :callback get :jobs + + get :new_user_map, path: :user_map + post :create_user_map, path: :user_map end end diff --git a/lib/gitlab/google_code_import/client.rb b/lib/gitlab/google_code_import/client.rb index 0514494d3a..02f31e45f8 100644 --- a/lib/gitlab/google_code_import/client.rb +++ b/lib/gitlab/google_code_import/client.rb @@ -3,6 +3,12 @@ module Gitlab class Client attr_reader :raw_data + def self.mask_email(author) + parts = author.split("@", 2) + parts[0] = "#{parts[0][0...-3]}..." + parts.join("@") + end + def initialize(raw_data) @raw_data = raw_data end @@ -18,6 +24,25 @@ module Gitlab def repo(id) repos.find { |repo| repo.id == id } end + + def user_map + user_map = Hash.new { |hash, user| hash[user] = self.class.mask_email(user) } + + repos.each do |repo| + next unless repo.valid? && repo.issues + + repo.issues.each do |raw_issue| + # Touching is enough to add the entry and masked email. + user_map[raw_issue["author"]["name"]] + + raw_issue["comments"]["items"].each do |raw_comment| + user_map[raw_comment["author"]["name"]] + end + end + end + + Hash[user_map.sort] + end end end end diff --git a/lib/gitlab/google_code_import/importer.rb b/lib/gitlab/google_code_import/importer.rb index eaa8c6bc6d..730fbb8606 100644 --- a/lib/gitlab/google_code_import/importer.rb +++ b/lib/gitlab/google_code_import/importer.rb @@ -5,7 +5,7 @@ module Gitlab def initialize(project) @project = project - @repo = GoogleCodeImport::Repository.new(project.import_data) + @repo = GoogleCodeImport::Repository.new(project.import_data["repo"]) @closed_statuses = [] @known_labels = Set.new @@ -25,6 +25,17 @@ module Gitlab private + def user_map + @user_map ||= begin + user_map = Hash.new { |hash, user| hash[user] = Client.mask_email(user) } + + stored_user_map = project.import_data["user_map"] + user_map.update(stored_user_map) if stored_user_map + + user_map + end + end + def import_status_labels repo.raw_data["issuesConfig"]["statuses"].each do |status| closed = !status["meansOpen"] @@ -45,14 +56,13 @@ module Gitlab end def import_issues - return unless repo.raw_data["issues"] + return unless repo.issues last_id = 0 deleted_issues = [] - issues = repo.raw_data["issues"]["items"] - issues.each do |raw_issue| + repo.issues.each do |raw_issue| while raw_issue["id"] > last_id + 1 last_id += 1 @@ -66,7 +76,7 @@ module Gitlab end last_id = raw_issue["id"] - author = mask_email(raw_issue["author"]["name"]) + author = user_map[raw_issue["author"]["name"]] date = DateTime.parse(raw_issue["published"]).to_formatted_s(:long) body = [] @@ -119,7 +129,7 @@ module Gitlab comments.each do |raw_comment| next if raw_comment.has_key?("deletedBy") - author = mask_email(raw_comment["author"]["name"]) + author = user_map[raw_comment["author"]["name"]] date = DateTime.parse(raw_comment["published"]).to_formatted_s(:long) body = [] @@ -202,12 +212,6 @@ module Gitlab "Status: #{name}" end - def mask_email(author) - parts = author.split("@", 2) - parts[0] = "#{parts[0][0...-3]}..." - parts.join("@") - end - def linkify_issues(s) s.gsub(/([Ii]ssue) ([0-9]+)/, '\1 #\2') end @@ -248,14 +252,14 @@ module Gitlab end if raw_updates.has_key?("owner") - updates << "*Owner: #{mask_email(raw_updates["owner"])}*" + updates << "*Owner: #{user_map[raw_updates["owner"]]}*" end if raw_updates.has_key?("cc") cc = raw_updates["cc"].map do |l| deleted = l.start_with?("-") l = l[1..-1] if deleted - l = mask_email(l) + l = user_map[l] l = "~~#{l}~~" if deleted l end diff --git a/lib/gitlab/google_code_import/project_creator.rb b/lib/gitlab/google_code_import/project_creator.rb index 933e144d8e..7ac4387d79 100644 --- a/lib/gitlab/google_code_import/project_creator.rb +++ b/lib/gitlab/google_code_import/project_creator.rb @@ -1,15 +1,21 @@ module Gitlab module GoogleCodeImport class ProjectCreator - attr_reader :repo, :namespace, :current_user + attr_reader :repo, :namespace, :current_user, :user_map - def initialize(repo, namespace, current_user) + def initialize(repo, namespace, current_user, user_map = nil) @repo = repo @namespace = namespace @current_user = current_user + @user_map = user_map end def execute + import_data = { + "repo" => repo.raw_data, + "user_map" => user_map + } + @project = Project.new( name: repo.name, path: repo.name, @@ -20,7 +26,7 @@ module Gitlab import_type: "google_code", import_source: repo.name, import_url: repo.import_url, - import_data: repo.raw_data + import_data: import_data ) if @project.save! diff --git a/lib/gitlab/google_code_import/repository.rb b/lib/gitlab/google_code_import/repository.rb index 39a884d829..ad33fc2cad 100644 --- a/lib/gitlab/google_code_import/repository.rb +++ b/lib/gitlab/google_code_import/repository.rb @@ -34,6 +34,10 @@ module Gitlab def import_url raw_data["repositoryUrls"].first end + + def issues + raw_data["issues"] && raw_data["issues"]["items"] + end end end end diff --git a/spec/lib/gitlab/google_code_import/importer_spec.rb b/spec/lib/gitlab/google_code_import/importer_spec.rb index 370d441010..a68f69d337 100644 --- a/spec/lib/gitlab/google_code_import/importer_spec.rb +++ b/spec/lib/gitlab/google_code_import/importer_spec.rb @@ -3,7 +3,14 @@ require "spec_helper" describe Gitlab::GoogleCodeImport::Importer do let(:raw_data) { JSON.parse(File.read(Rails.root.join("spec/fixtures/GoogleCodeProjectHosting.json"))) } let(:client) { Gitlab::GoogleCodeImport::Client.new(raw_data) } - let(:import_data) { client.repo("tint2").raw_data } + let(:import_data) { + { + "repo" => client.repo("tint2").raw_data, + "user_map" => { + "thilo..." => "@thilo123" + } + } + } let(:project) { create(:project, import_data: import_data) } subject { described_class.new(project) } @@ -58,7 +65,7 @@ describe Gitlab::GoogleCodeImport::Importer do note = project.issues.first.notes.first expect(note).to_not be_nil expect(note.note).to include("Comment 1") - expect(note.note).to include("thilo...") + expect(note.note).to include("@thilo123") expect(note.note).to include("November 18, 2009 05:14") expect(note.note).to include("applied, thanks.") expect(note.note).to include("Status: Fixed") From 4c6ce0ef78eb501895baf4e1271440b4d0c82a8c Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 14 Apr 2015 14:57:15 +0200 Subject: [PATCH 161/194] Move Specify user map button to paragraph. --- app/views/import/google_code/status.html.haml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/views/import/google_code/status.html.haml b/app/views/import/google_code/status.html.haml index 8f24a1ce85..2013b8c03c 100644 --- a/app/views/import/google_code/status.html.haml +++ b/app/views/import/google_code/status.html.haml @@ -4,10 +4,13 @@ %p.light Select projects you want to import. +%p.light + Optionally, you can + = link_to "customize", new_user_map_import_google_code_path + how Google Code email addresses and usernames are imported into GitLab. %hr %p = button_tag 'Import all projects', class: "btn btn-success js-import-all" - = link_to "Specify user map", new_user_map_import_google_code_path, class: "btn prepend-left-10" %table.table.import-jobs %thead From 1abda4458e28a4ad2c7232e1836d9dda186a7794 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 14 Apr 2015 14:59:10 +0200 Subject: [PATCH 162/194] "interpunction" -> "punctuation" --- app/views/import/google_code/new_user_map.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/import/google_code/new_user_map.html.haml b/app/views/import/google_code/new_user_map.html.haml index 2996d3b659..d55fcfc97a 100644 --- a/app/views/import/google_code/new_user_map.html.haml +++ b/app/views/import/google_code/new_user_map.html.haml @@ -10,7 +10,7 @@ %p The user map is a JSON document mapping Google Code users (as keys) to the way they will be imported into GitLab (as values). By default the username is masked to ensure users' privacy. %p - To map a Google Code user to a full name or GitLab user, simply replace the value, e.g. "johnsmith@gmail.com": "John Smith" or "johnsmith@gmail.com": "@johnsmith". Be sure to preserve the surrounding double quotes and other interpunction. + To map a Google Code user to a full name or GitLab user, simply replace the value, e.g. "johnsmith@gmail.com": "John Smith" or "johnsmith@gmail.com": "@johnsmith". Be sure to preserve the surrounding double quotes and other punctuation. .form-group .col-sm-12 From cb5362e7ce0496e4fa7bec4ae629fd24e93d721e Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 14 Apr 2015 15:16:00 +0200 Subject: [PATCH 163/194] Follow newline guidelines. --- app/controllers/groups/group_members_controller.rb | 7 +++++-- app/controllers/invites_controller.rb | 7 +++++-- app/controllers/projects/project_members_controller.rb | 1 + app/mailers/emails/groups.rb | 7 +++++++ app/mailers/emails/projects.rb | 6 ++++++ app/models/members/project_member.rb | 1 + spec/models/member_spec.rb | 3 --- 7 files changed, 25 insertions(+), 7 deletions(-) diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index 359eeb4d27..265cf4f0f4 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -48,13 +48,16 @@ class Groups::GroupMembersController < Groups::ApplicationController end def resend_invite + redirect_path = group_group_members_path(@group) + @group_member = @group.group_members.find(params[:id]) + if @group_member.invite? @group_member.resend_invite - redirect_to group_group_members_path(@group), notice: 'The invitation was successfully resent.' + redirect_to redirect_path, notice: 'The invitation was successfully resent.' else - redirect_to group_group_members_path(@group), alert: 'The invitation has already been accepted.' + redirect_to redirect_path, alert: 'The invitation has already been accepted.' end end diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index 3ac8654fcc..1f97ff16c5 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -43,10 +43,13 @@ class InvitesController < ApplicationController return @member if defined?(@member) @token = params[:id] - @member = Member.find_by_invite_token(@token) - render_404 unless @member + unless @member + render_404 and return + end + + @member end def authenticate_user! diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index 3da44cbc9d..72967a26ff 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -62,6 +62,7 @@ class Projects::ProjectMembersController < Projects::ApplicationController redirect_path = namespace_project_project_members_path(@project.namespace, @project) @project_member = @project.project_members.find(params[:id]) + if @project_member.invite? @project_member.resend_invite diff --git a/app/mailers/emails/groups.rb b/app/mailers/emails/groups.rb index 07b8f6d735..1c43f95dc8 100644 --- a/app/mailers/emails/groups.rb +++ b/app/mailers/emails/groups.rb @@ -3,8 +3,10 @@ module Emails def group_access_granted_email(group_member_id) @group_member = GroupMember.find(group_member_id) @group = @group_member.group + @target_url = group_url(@group) @current_user = @group_member.user + mail(to: @group_member.user.notification_email, subject: subject("Access to group was granted")) end @@ -13,8 +15,10 @@ module Emails @group_member = GroupMember.find group_member_id @group = @group_member.group @token = token + @target_url = group_url(@group) @current_user = @group_member.user + mail(to: @group_member.invite_email, subject: "Invitation to join group #{@group.name}") end @@ -24,8 +28,10 @@ module Emails return if @group_member.created_by.nil? @group = @group_member.group + @target_url = group_url(@group) @current_user = @group_member.created_by + mail(to: @group_member.created_by.notification_email, subject: subject("Invitation accepted")) end @@ -37,6 +43,7 @@ module Emails @current_user = @created_by = User.find(created_by_id) @access_level = access_level @invite_email = invite_email + @target_url = group_url(@group) mail(to: @created_by.notification_email, subject: subject("Invitation declined")) diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb index 7baeead3fb..2584e9d48b 100644 --- a/app/mailers/emails/projects.rb +++ b/app/mailers/emails/projects.rb @@ -3,8 +3,10 @@ module Emails def project_access_granted_email(project_member_id) @project_member = ProjectMember.find project_member_id @project = @project_member.project + @target_url = namespace_project_url(@project.namespace, @project) @current_user = @project_member.user + mail(to: @project_member.user.notification_email, subject: subject("Access to project was granted")) end @@ -13,8 +15,10 @@ module Emails @project_member = ProjectMember.find project_member_id @project = @project_member.project @token = token + @target_url = namespace_project_url(@project.namespace, @project) @current_user = @project_member.user + mail(to: @project_member.invite_email, subject: "Invitation to join project #{@project.name_with_namespace}") end @@ -24,6 +28,7 @@ module Emails return if @project_member.created_by.nil? @project = @project_member.project + @target_url = namespace_project_url(@project.namespace, @project) @current_user = @project_member.created_by @@ -38,6 +43,7 @@ module Emails @current_user = @created_by = User.find(created_by_id) @access_level = access_level @invite_email = invite_email + @target_url = namespace_project_url(@project.namespace, @project) mail(to: @created_by.notification_email, diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index ce704a60d6..0a3b4d2182 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -80,6 +80,7 @@ class ProjectMember < Member def truncate_teams(project_ids) ProjectMember.transaction do members = ProjectMember.where(source_id: project_ids) + members.each do |member| member.destroy end diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb index a27931cd4e..56d030a03b 100644 --- a/spec/models/member_spec.rb +++ b/spec/models/member_spec.rb @@ -13,7 +13,6 @@ describe Member do it { is_expected.to validate_inclusion_of(:access_level).in_array(Gitlab::Access.values) } context "when an invite email is provided" do - let(:member) { build(:project_member, invite_email: "user@example.com", user: nil) } it "doesn't require a user" do @@ -38,7 +37,6 @@ describe Member do end context "when an invite email is not provided" do - let(:member) { build(:project_member) } it "requires a user" do @@ -96,7 +94,6 @@ describe Member do end describe "#accept_invite!" do - let!(:member) { create(:project_member, invite_email: "user@example.com", user: nil) } let(:user) { create(:user) } From f543f1c3dd3d4b75952be85e4e763a099acbb3d6 Mon Sep 17 00:00:00 2001 From: Jason Blanchard Date: Mon, 2 Mar 2015 18:47:51 -0500 Subject: [PATCH 164/194] Remove truncation from issue titles on milestone page --- CHANGELOG | 1 + app/views/projects/milestones/_issue.html.haml | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 0ecde5ef89..40a1718ade 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -76,6 +76,7 @@ v 7.10.0 (unreleased) - Bust group page project list cache when namespace name or path changes. - Explicitly set image alt-attribute to prevent graphical glitches if gravatars could not be loaded - Allow user to choose a public email to show on public profile + - Remove truncation from issue titles on milestone page (Jason Blanchard) v 7.9.3 - Contains no changes diff --git a/app/views/projects/milestones/_issue.html.haml b/app/views/projects/milestones/_issue.html.haml index 9f164d44f2..88fccfe498 100644 --- a/app/views/projects/milestones/_issue.html.haml +++ b/app/views/projects/milestones/_issue.html.haml @@ -1,9 +1,9 @@ %li{ id: dom_id(issue, 'sortable'), class: 'issue-row', 'data-iid' => issue.iid, 'data-url' => issue_path(issue) } - %span.str-truncated - = link_to [@project.namespace.becomes(Namespace), @project, issue] do - %span.cgray ##{issue.iid} - = link_to_gfm issue.title, [@project.namespace.becomes(Namespace), @project, issue], title: issue.title .pull-right.assignee-icon - if issue.assignee = image_tag avatar_icon(issue.assignee.email, 16), class: "avatar s16", alt: '' + %span + = link_to [@project.namespace.becomes(Namespace), @project, issue] do + %span.cgray ##{issue.iid} + = link_to_gfm issue.title, [@project.namespace.becomes(Namespace), @project, issue], title: issue.title From 29970204ea82118c649bb46d39f975a5c141e57b Mon Sep 17 00:00:00 2001 From: Dmitriy Zaporozhets Date: Tue, 14 Apr 2015 16:26:47 +0300 Subject: [PATCH 165/194] Fix button overflow for branches/tags pages Signed-off-by: Dmitriy Zaporozhets --- app/views/projects/branches/_branch.html.haml | 6 +++--- app/views/projects/tags/_tag.html.haml | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml index 0de8c509f2..4e7415be4a 100644 --- a/app/views/projects/branches/_branch.html.haml +++ b/app/views/projects/branches/_branch.html.haml @@ -11,14 +11,14 @@ protected .pull-right - if can?(current_user, :download_code, @project) - = render 'projects/repositories/download_archive', ref: branch.name, btn_class: 'btn-grouped btn-group-sm' + = render 'projects/repositories/download_archive', ref: branch.name, btn_class: 'btn-grouped btn-group-xs' - if branch.name != @repository.root_ref - = link_to namespace_project_compare_index_path(@project.namespace, @project, from: @repository.root_ref, to: branch.name), class: 'btn btn-grouped btn-sm', method: :post, title: "Compare" do + = link_to namespace_project_compare_index_path(@project.namespace, @project, from: @repository.root_ref, to: branch.name), class: 'btn btn-grouped btn-xs', method: :post, title: "Compare" do %i.fa.fa-files-o Compare - if can_remove_branch?(@project, branch.name) - = link_to namespace_project_branch_path(@project.namespace, @project, branch.name), class: 'btn btn-grouped btn-sm btn-remove remove-row', method: :delete, data: { confirm: 'Removed branch cannot be restored. Are you sure?'}, remote: true do + = link_to namespace_project_branch_path(@project.namespace, @project, branch.name), class: 'btn btn-grouped btn-xs btn-remove remove-row', method: :delete, data: { confirm: 'Removed branch cannot be restored. Are you sure?'}, remote: true do %i.fa.fa-trash-o - if commit diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml index f22308e54b..28ad272322 100644 --- a/app/views/projects/tags/_tag.html.haml +++ b/app/views/projects/tags/_tag.html.haml @@ -9,9 +9,9 @@ = strip_gpg_signature(tag.message) .pull-right - if can? current_user, :download_code, @project - = render 'projects/repositories/download_archive', ref: tag.name, btn_class: 'btn-grouped btn-group-sm' + = render 'projects/repositories/download_archive', ref: tag.name, btn_class: 'btn-grouped btn-group-xs' - if can?(current_user, :admin_project, @project) - = link_to namespace_project_tag_path(@project.namespace, @project, tag.name), class: 'btn btn-sm btn-remove remove-row grouped', method: :delete, data: { confirm: 'Removed tag cannot be restored. Are you sure?'}, remote: true do + = link_to namespace_project_tag_path(@project.namespace, @project, tag.name), class: 'btn btn-xs btn-remove remove-row grouped', method: :delete, data: { confirm: 'Removed tag cannot be restored. Are you sure?'}, remote: true do %i.fa.fa-trash-o - if commit From 0c6217e2dc248b632520ebf31b75983e4081a1e8 Mon Sep 17 00:00:00 2001 From: Dmitriy Zaporozhets Date: Tue, 14 Apr 2015 16:40:59 +0300 Subject: [PATCH 166/194] Add border radius and shadow to project avatar on project home page Signed-off-by: Dmitriy Zaporozhets --- app/assets/stylesheets/pages/projects.scss | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 5bd725d122..04a0e4fa0e 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -38,6 +38,11 @@ font-size: 45px; line-height: 1.6; } + + .avatar, .identicon { + @include border-radius(4px); + box-shadow: 0 1px 2px #ddd; + } } .project-home-dropdown { From 41a1cb88f4fec3d9598fca50c14407c45e5c8ce7 Mon Sep 17 00:00:00 2001 From: Jacob Vosmaer Date: Tue, 14 Apr 2015 15:44:19 +0200 Subject: [PATCH 167/194] Use different queries for MySQL and Postgres I could not find a query that worked on both; these two queries look very similar and seem to do the same thing. --- db/migrate/20150411000035_fix_identities.rb | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/db/migrate/20150411000035_fix_identities.rb b/db/migrate/20150411000035_fix_identities.rb index 8f11a96ab0..ebbb9f34f4 100644 --- a/db/migrate/20150411000035_fix_identities.rb +++ b/db/migrate/20150411000035_fix_identities.rb @@ -17,7 +17,20 @@ class FixIdentities < ActiveRecord::Migration end # Delete duplicate identities - execute "DELETE FROM identities WHERE provider = 'ldap' AND user_id IN (SELECT user_id FROM identities WHERE provider = '#{new_provider}')" + # We use a sort of self-join to find rows in identities which match on + # user_id but where one has provider 'ldap'. We delete the duplicate row + # with provider 'ldap'. + delete_statement = '' + case adapter_name.downcase + when /^mysql/ + delete_statement << 'DELETE FROM id1 USING identities AS id1, identities AS id2' + when 'postgresql' + delete_statement << 'DELETE FROM identities AS id1 USING identities AS id2' + else + raise "Unknown DB adapter: #{adapter_name}" + end + delete_statement << " WHERE id1.user_id = id2.user_id AND id1.provider = 'ldap' AND id2.provider = '#{new_provider}'" + execute delete_statement # Update legacy identities execute "UPDATE identities SET provider = '#{new_provider}' WHERE provider = 'ldap';" From 0b2670f82fb755318d7ce2f81bae85071ec4c707 Mon Sep 17 00:00:00 2001 From: Jacob Vosmaer Date: Tue, 14 Apr 2015 15:45:15 +0200 Subject: [PATCH 168/194] Remove unnecessary semicolons I could not help myself --- db/migrate/20150411000035_fix_identities.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/db/migrate/20150411000035_fix_identities.rb b/db/migrate/20150411000035_fix_identities.rb index ebbb9f34f4..d9051f9fff 100644 --- a/db/migrate/20150411000035_fix_identities.rb +++ b/db/migrate/20150411000035_fix_identities.rb @@ -33,10 +33,10 @@ class FixIdentities < ActiveRecord::Migration execute delete_statement # Update legacy identities - execute "UPDATE identities SET provider = '#{new_provider}' WHERE provider = 'ldap';" + execute "UPDATE identities SET provider = '#{new_provider}' WHERE provider = 'ldap'" if table_exists?('ldap_group_links') - execute "UPDATE ldap_group_links SET provider = '#{new_provider}' WHERE provider IS NULL OR provider = 'ldap';" + execute "UPDATE ldap_group_links SET provider = '#{new_provider}' WHERE provider IS NULL OR provider = 'ldap'" end end From 076494646d30cefd396e0d6a0ae879a31aecb454 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 14 Apr 2015 15:58:16 +0200 Subject: [PATCH 169/194] Simplify regex. --- lib/gitlab/key_fingerprint.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/gitlab/key_fingerprint.rb b/lib/gitlab/key_fingerprint.rb index 550b10a4c9..baf52ff750 100644 --- a/lib/gitlab/key_fingerprint.rb +++ b/lib/gitlab/key_fingerprint.rb @@ -27,10 +27,10 @@ module Gitlab return nil unless cmd_status.zero? # 16 hex bytes separated by ':', optionally starting with "MD5:" - fingerprint_match = cmd_output.match(/(MD5:)?(?(?:\h{2}:){15}\h{2})/) - return nil unless fingerprint_match + fingerprint_matches = cmd_output.match(/(MD5:)?(?(\h{2}:){15}\h{2})/) + return nil unless fingerprint_matches - fingerprint_match[:fingerprint] + fingerprint_matches[:fingerprint] end private From dab1824679406e116ea6a32dd8e544d88daf9f98 Mon Sep 17 00:00:00 2001 From: Dmitriy Zaporozhets Date: Tue, 14 Apr 2015 17:08:01 +0300 Subject: [PATCH 170/194] Align project download button with others in sidebar on project home page Signed-off-by: Dmitriy Zaporozhets --- app/assets/stylesheets/pages/projects.scss | 4 ++++ .../projects/repositories/_download_archive.html.haml | 6 +++--- app/views/projects/show.html.haml | 10 +++++----- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 04a0e4fa0e..cfce42a03c 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -220,6 +220,10 @@ ul.nav.nav-projects-tabs { background-color: #F9F9F9; border-color: #DDD; + &.dropdown-toggle { + text-align: center; + } + &:hover { background-color: #eee; border-color: #DDD; diff --git a/app/views/projects/repositories/_download_archive.html.haml b/app/views/projects/repositories/_download_archive.html.haml index 1ba7a1f206..b9486a9b49 100644 --- a/app/views/projects/repositories/_download_archive.html.haml +++ b/app/views/projects/repositories/_download_archive.html.haml @@ -3,14 +3,14 @@ - split_button = split_button || false - if split_button == true %span.btn-group{class: btn_class} - = link_to archive_namespace_project_repository_path(@project.namespace, @project, ref: ref, format: 'zip'), class: 'btn btn-sm', rel: 'nofollow' do + = link_to archive_namespace_project_repository_path(@project.namespace, @project, ref: ref, format: 'zip'), class: 'btn col-xs-10', rel: 'nofollow' do %i.fa.fa-download %span Download zip - %a.btn-sm.btn.dropdown-toggle{ 'data-toggle' => 'dropdown' } + %a.col-xs-2.btn.dropdown-toggle{ 'data-toggle' => 'dropdown' } %span.caret %span.sr-only Select Archive Format - %ul.dropdown-menu{ role: 'menu' } + %ul.col-xs-10.dropdown-menu{ role: 'menu' } %li = link_to archive_namespace_project_repository_path(@project.namespace, @project, ref: ref, format: 'zip'), rel: 'nofollow' do %i.fa.fa-download diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml index 160a17cebe..4464c51744 100644 --- a/app/views/projects/show.html.haml +++ b/app/views/projects/show.html.haml @@ -60,11 +60,16 @@ - unless @project.empty_repo? = link_to namespace_project_compare_index_path(@project.namespace, @project, from: @repository.root_ref, to: @ref || @repository.root_ref), class: 'btn btn-block' do + %i.fa.fa-exchange Compare code + - if can?(current_user, :download_code, @project) + = render 'projects/repositories/download_archive', split_button: true, btn_class: 'btn-block' + - if version = @repository.version - detail_url = changelog_url(@project) || version_url(@project) = link_to detail_url, class: 'btn btn-block' do + %i.fa.fa-file-text-o Version: %span.count = @repository.blob_by_oid(version.id).data @@ -80,11 +85,6 @@ - else #{link_to @project.owner_name, @project.owner} - - unless @project.empty_repo? - - if can? current_user, :download_code, @project - %hr - .prepend-top-10.append-bottom-10 - = render 'projects/repositories/download_archive', split_button: true .prepend-top-10 - @project.ci_services.each do |ci_service| From 5662b2413206db20f935a0dffb5f12debd248447 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 14 Apr 2015 15:45:06 +0200 Subject: [PATCH 171/194] Don't use HTML ellipsis in EmailsOnPush subject truncated commit message. --- CHANGELOG | 1 + app/models/commit.rb | 4 ++-- spec/models/commit_spec.rb | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 0ecde5ef89..3f3e8fc320 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 7.10.0 (unreleased) + - Don't use HTML ellipsis in EmailsOnPush subject truncated commit message. - Fix broken file browsing with a submodule that contains a relative link (Stan Hu) - Fix persistent XSS vulnerability around profile website URLs. - Fix project import URL regex to prevent arbitary local repos from being imported. diff --git a/app/models/commit.rb b/app/models/commit.rb index 7a0ad13765..006fa62c8f 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -77,7 +77,7 @@ class Commit title_end = title.index("\n") if (!title_end && title.length > 100) || (title_end && title_end > 100) - title[0..79] << "…".html_safe + title[0..79] << "…" else title.split("\n", 2).first end @@ -90,7 +90,7 @@ class Commit title_end = safe_message.index("\n") @description ||= if (!title_end && safe_message.length > 100) || (title_end && title_end > 100) - "…".html_safe << safe_message[80..-1] + "…" << safe_message[80..-1] else safe_message.split("\n", 2)[1].try(:chomp) end diff --git a/spec/models/commit_spec.rb b/spec/models/commit_spec.rb index 8b3d88640d..11cc7762ce 100644 --- a/spec/models/commit_spec.rb +++ b/spec/models/commit_spec.rb @@ -14,7 +14,7 @@ describe Commit do message = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec sodales id felis id blandit. Vivamus egestas lacinia lacus, sed rutrum mauris.' allow(commit).to receive(:safe_message).and_return(message) - expect(commit.title).to eq("#{message[0..79]}…") + expect(commit.title).to eq("#{message[0..79]}…") end it "truncates a message with a newline before 80 characters at the newline" do From 5262ba3c9a87d1f1773be7e158c962544fc8e988 Mon Sep 17 00:00:00 2001 From: Dmitriy Zaporozhets Date: Tue, 14 Apr 2015 17:45:43 +0300 Subject: [PATCH 172/194] Default buttons should look the same on project page Signed-off-by: Dmitriy Zaporozhets --- app/assets/stylesheets/pages/projects.scss | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index cfce42a03c..86b102690c 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -15,7 +15,7 @@ } .project-home-panel { - margin-bottom: 15px; + margin-bottom: 20px; position: relative; padding-left: 85px; @@ -66,13 +66,12 @@ .project-home-desc { float: left; - color: #666; + color: $gray; } } .visibility-level-label { - color: #555; - font-weight: bold; + color: $gray; i { color: inherit; } @@ -138,7 +137,7 @@ .option-descr { margin-left: 24px; - color: #666; + color: $gray; } } } @@ -217,8 +216,6 @@ ul.nav.nav-projects-tabs { white-space: normal; text-align: left; padding: 10px 15px; - background-color: #F9F9F9; - border-color: #DDD; &.dropdown-toggle { text-align: center; From c43411e97aae85386ab50f9caf5ad8be481fb91c Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 14 Apr 2015 17:08:49 +0200 Subject: [PATCH 173/194] Non-persisted users already have the identity by way of build_new_user. --- lib/gitlab/ldap/user.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/gitlab/ldap/user.rb b/lib/gitlab/ldap/user.rb index d054014039..d652e19217 100644 --- a/lib/gitlab/ldap/user.rb +++ b/lib/gitlab/ldap/user.rb @@ -39,6 +39,8 @@ module Gitlab end def update_user_attributes + return unless persisted? + gl_user.skip_reconfirmation! gl_user.email = auth_hash.email From 238e4f02954e910cab14c0be3b8b2569e08b5e87 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 14 Apr 2015 17:09:05 +0200 Subject: [PATCH 174/194] Add config var to block auto-created LDAP users. --- CHANGELOG | 1 + config/gitlab.yml.example | 3 ++ config/initializers/1_settings.rb | 1 + lib/gitlab/ldap/config.rb | 4 ++ lib/gitlab/ldap/user.rb | 8 +++- spec/lib/gitlab/ldap/user_spec.rb | 67 +++++++++++++++++++++++++++---- 6 files changed, 75 insertions(+), 9 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 0ecde5ef89..e3bd3bec04 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 7.10.0 (unreleased) + - Add config var to block auto-created LDAP users. - Fix broken file browsing with a submodule that contains a relative link (Stan Hu) - Fix persistent XSS vulnerability around profile website URLs. - Fix project import URL regex to prevent arbitary local repos from being imported. diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index 46b9f05cc1..6a78a032c5 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -146,6 +146,9 @@ production: &base # disable this setting, because the userPrincipalName contains an '@'. allow_username_or_email_login: false + # Locks down those users until they have been cleared by the admin (default: false). + block_auto_created_users: false + # Base where we can search for users # # Ex. ou=People,dc=gitlab,dc=example diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index d5cddb8dbf..0abd34fc3e 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -76,6 +76,7 @@ if Settings.ldap['enabled'] || Rails.env.test? Settings.ldap['servers'].each do |key, server| server['label'] ||= 'LDAP' + server['block_auto_created_users'] = false if server['block_auto_created_users'].nil? server['allow_username_or_email_login'] = false if server['allow_username_or_email_login'].nil? server['active_directory'] = true if server['active_directory'].nil? server['provider_name'] ||= "ldap#{key}".downcase diff --git a/lib/gitlab/ldap/config.rb b/lib/gitlab/ldap/config.rb index fa5b6c1e23..d2ffa2e1fe 100644 --- a/lib/gitlab/ldap/config.rb +++ b/lib/gitlab/ldap/config.rb @@ -80,6 +80,10 @@ module Gitlab options['active_directory'] end + def block_auto_created_users + options['block_auto_created_users'] + end + protected def base_config Gitlab.config.ldap diff --git a/lib/gitlab/ldap/user.rb b/lib/gitlab/ldap/user.rb index d652e19217..f7f3ba9ad7 100644 --- a/lib/gitlab/ldap/user.rb +++ b/lib/gitlab/ldap/user.rb @@ -55,13 +55,17 @@ module Gitlab gl_user.changed? || gl_user.identities.any?(&:changed?) end - def needs_blocking? - false + def block_after_signup? + ldap_config.block_auto_created_users end def allowed? Gitlab::LDAP::Access.allowed?(gl_user) end + + def ldap_config + Gitlab::LDAP::Config.new(auth_hash.provider) + end end end end diff --git a/spec/lib/gitlab/ldap/user_spec.rb b/spec/lib/gitlab/ldap/user_spec.rb index 4f93545feb..42015c28c8 100644 --- a/spec/lib/gitlab/ldap/user_spec.rb +++ b/spec/lib/gitlab/ldap/user_spec.rb @@ -1,7 +1,8 @@ require 'spec_helper' describe Gitlab::LDAP::User do - let(:gl_user) { Gitlab::LDAP::User.new(auth_hash) } + let(:ldap_user) { Gitlab::LDAP::User.new(auth_hash) } + let(:gl_user) { ldap_user.gl_user } let(:info) do { name: 'John', @@ -16,17 +17,17 @@ describe Gitlab::LDAP::User do describe :changed? do it "marks existing ldap user as changed" do existing_user = create(:omniauth_user, extern_uid: 'my-uid', provider: 'ldapmain') - expect(gl_user.changed?).to be_truthy + expect(ldap_user.changed?).to be_truthy end it "marks existing non-ldap user if the email matches as changed" do existing_user = create(:user, email: 'john@example.com') - expect(gl_user.changed?).to be_truthy + expect(ldap_user.changed?).to be_truthy end it "dont marks existing ldap user as changed" do existing_user = create(:omniauth_user, email: 'john@example.com', extern_uid: 'my-uid', provider: 'ldapmain') - expect(gl_user.changed?).to be_falsey + expect(ldap_user.changed?).to be_falsey end end @@ -34,12 +35,12 @@ describe Gitlab::LDAP::User do it "finds the user if already existing" do existing_user = create(:omniauth_user, extern_uid: 'my-uid', provider: 'ldapmain') - expect{ gl_user.save }.to_not change{ User.count } + expect{ ldap_user.save }.to_not change{ User.count } end it "connects to existing non-ldap user if the email matches" do existing_user = create(:omniauth_user, email: 'john@example.com', provider: "twitter") - expect{ gl_user.save }.to_not change{ User.count } + expect{ ldap_user.save }.to_not change{ User.count } existing_user.reload expect(existing_user.ldap_identity.extern_uid).to eql 'my-uid' @@ -47,7 +48,59 @@ describe Gitlab::LDAP::User do end it "creates a new user if not found" do - expect{ gl_user.save }.to change{ User.count }.by(1) + expect{ ldap_user.save }.to change{ User.count }.by(1) + end + end + + + describe 'blocking' do + context 'signup' do + context 'dont block on create' do + before { Gitlab::LDAP::Config.any_instance.stub block_auto_created_users: false } + + it do + ldap_user.save + expect(gl_user).to be_valid + expect(gl_user).not_to be_blocked + end + end + + context 'block on create' do + before { Gitlab::LDAP::Config.any_instance.stub block_auto_created_users: true } + + it do + ldap_user.save + expect(gl_user).to be_valid + expect(gl_user).to be_blocked + end + end + end + + context 'sign-in' do + before do + ldap_user.save + ldap_user.gl_user.activate + end + + context 'dont block on create' do + before { Gitlab::LDAP::Config.any_instance.stub block_auto_created_users: false } + + it do + ldap_user.save + expect(gl_user).to be_valid + expect(gl_user).not_to be_blocked + end + end + + context 'block on create' do + before { Gitlab::LDAP::Config.any_instance.stub block_auto_created_users: true } + + it do + ldap_user.save + expect(gl_user).to be_valid + expect(gl_user).not_to be_blocked + end + end end end end From afb43a8fcd50220cd0fb87609047d43de57f3348 Mon Sep 17 00:00:00 2001 From: Dmitriy Zaporozhets Date: Tue, 14 Apr 2015 18:29:20 +0300 Subject: [PATCH 175/194] Use smae background for git clone text and project visibility Signed-off-by: Dmitriy Zaporozhets --- app/assets/stylesheets/pages/projects.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 86b102690c..c005470355 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -119,6 +119,10 @@ background: #FAFAFA; width: 100%; } + + .input-group-addon { + background: #FAFAFA; + } } .project-visibility-level-holder { From c1531fe1d801b175d8ecf7646592b1f5e9a8e508 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 14 Apr 2015 18:04:29 +0200 Subject: [PATCH 176/194] Add spinach tests around accepting and declining invitations. --- app/models/member.rb | 2 + db/schema.rb | 3 +- features/invites.feature | 45 ++++++++++++++++++++++ features/steps/invites.rb | 80 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 features/invites.feature create mode 100644 features/steps/invites.rb diff --git a/app/models/member.rb b/app/models/member.rb index 9340c44217..d151c7b239 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -22,6 +22,8 @@ class Member < ActiveRecord::Base include Notifiable include Gitlab::Access + attr_accessor :raw_invite_token + belongs_to :created_by, class_name: "User" belongs_to :user belongs_to :source, polymorphic: true diff --git a/db/schema.rb b/db/schema.rb index 11371f168b..70ef1bb8ea 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20150411180045) do +ActiveRecord::Schema.define(version: 20150413192223) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -481,6 +481,7 @@ ActiveRecord::Schema.define(version: 20150411180045) do t.string "bitbucket_access_token" t.string "bitbucket_access_token_secret" t.string "location" + t.string "public_email", default: "", null: false end add_index "users", ["admin"], name: "index_users_on_admin", using: :btree diff --git a/features/invites.feature b/features/invites.feature new file mode 100644 index 0000000000..dc8eefaeae --- /dev/null +++ b/features/invites.feature @@ -0,0 +1,45 @@ +Feature: Invites + Background: + Given "John Doe" is owner of group "Owned" + And "John Doe" has invited "user@example.com" to group "Owned" + + Scenario: Viewing invitation when signed out + When I visit the invitation page + Then I should be redirected to the sign in page + And I should see a notice telling me to sign in + + Scenario: Signing in to view invitation + When I visit the invitation page + And I sign in as "Mary Jane" + Then I should be redirected to the invitation page + + Scenario: Viewing invitation when signed in + Given I sign in as "Mary Jane" + And I visit the invitation page + Then I should see the invitation details + And I should see an "Accept invitation" button + And I should see a "Decline" button + + Scenario: Viewing invitation as an existing member + Given I sign in as "John Doe" + And I visit the invitation page + Then I should see a message telling me I'm already a member + + Scenario: Accepting the invitation + Given I sign in as "Mary Jane" + And I visit the invitation page + And I click the "Accept invitation" button + Then I should be redirected to the group page + And I should see a notice telling me I have access + + Scenario: Declining the application when signed in + Given I sign in as "Mary Jane" + And I visit the invitation page + And I click the "Decline" button + Then I should be redirected to the dashboard + And I should see a notice telling me I have declined + + Scenario: Declining the application when signed out + When I visit the invitation's decline page + Then I should be redirected to the sign in page + And I should see a notice telling me I have declined diff --git a/features/steps/invites.rb b/features/steps/invites.rb new file mode 100644 index 0000000000..d051cc3edc --- /dev/null +++ b/features/steps/invites.rb @@ -0,0 +1,80 @@ +class Spinach::Features::Invites < Spinach::FeatureSteps + include SharedAuthentication + include SharedUser + include SharedGroup + + step '"John Doe" has invited "user@example.com" to group "Owned"' do + user = User.find_by(name: "John Doe") + group = Group.find_by(name: "Owned") + group.add_user("user@example.com", Gitlab::Access::DEVELOPER, user) + end + + step 'I visit the invitation page' do + group = Group.find_by(name: "Owned") + invite = group.group_members.invite.last + invite.generate_invite_token! + @raw_invite_token = invite.raw_invite_token + visit invite_path(@raw_invite_token) + end + + step 'I should be redirected to the sign in page' do + expect(current_path).to eq(new_user_session_path) + end + + step 'I should see a notice telling me to sign in' do + expect(page).to have_content "To accept this invitation, sign in" + end + + step 'I should be redirected to the invitation page' do + expect(current_path).to eq(invite_path(@raw_invite_token)) + end + + step 'I should see the invitation details' do + expect(page).to have_content("You have been invited by John Doe to join group Owned as Developer.") + end + + step "I should see a message telling me I'm already a member" do + expect(page).to have_content("However, you are already a member of this group.") + end + + step 'I should see an "Accept invitation" button' do + expect(page).to have_link("Accept invitation") + end + + step 'I should see a "Decline" button' do + expect(page).to have_link("Decline") + end + + step 'I click the "Accept invitation" button' do + page.click_link "Accept invitation" + end + + step 'I should be redirected to the group page' do + group = Group.find_by(name: "Owned") + expect(current_path).to eq(group_path(group)) + end + + step 'I should see a notice telling me I have access' do + expect(page).to have_content("You have been granted Developer access to group Owned.") + end + + step 'I click the "Decline" button' do + page.click_link "Decline" + end + + step 'I should be redirected to the dashboard' do + expect(current_path).to eq(dashboard_path) + end + + step 'I should see a notice telling me I have declined' do + expect(page).to have_content("You have declined the invitation to join group Owned.") + end + + step "I visit the invitation's decline page" do + group = Group.find_by(name: "Owned") + invite = group.group_members.invite.last + invite.generate_invite_token! + @raw_invite_token = invite.raw_invite_token + visit decline_invite_path(@raw_invite_token) + end +end From 55d086ba08313e0cd7c649f38b45529b2a0968bb Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 14 Apr 2015 18:17:46 +0200 Subject: [PATCH 177/194] Clarify config var and add to LDAP docs. --- config/gitlab.yml.example | 4 +++- doc/integration/ldap.md | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index 6a78a032c5..ba40671b16 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -146,7 +146,9 @@ production: &base # disable this setting, because the userPrincipalName contains an '@'. allow_username_or_email_login: false - # Locks down those users until they have been cleared by the admin (default: false). + # To maintain tight control over the number of active users on your GitLab installation, + # enable this setting to keep new users blocked until they have been cleared by the admin + # (default: false). block_auto_created_users: false # Base where we can search for users diff --git a/doc/integration/ldap.md b/doc/integration/ldap.md index 125ce31b52..b67f793c59 100644 --- a/doc/integration/ldap.md +++ b/doc/integration/ldap.md @@ -51,6 +51,11 @@ main: # 'main' is the GitLab 'provider ID' of this LDAP server # disable this setting, because the userPrincipalName contains an '@'. allow_username_or_email_login: false + # To maintain tight control over the number of active users on your GitLab installation, + # enable this setting to keep new users blocked until they have been cleared by the admin + # (default: false). + block_auto_created_users: false + # Base where we can search for users # # Ex. ou=People,dc=gitlab,dc=example From 48efd65ff69a456eaafbd5538e5d69f140ce6b40 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Tue, 14 Apr 2015 10:34:48 -0700 Subject: [PATCH 178/194] Remove timing dependency on test See: http://www.elabs.se/blog/53-why-wait_until-was-removed-from-capybara --- features/steps/project/merge_requests.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/features/steps/project/merge_requests.rb b/features/steps/project/merge_requests.rb index 40c102833a..bb1f9f129c 100644 --- a/features/steps/project/merge_requests.rb +++ b/features/steps/project/merge_requests.rb @@ -61,8 +61,7 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps end step 'I should see that I am unsubscribed' do - sleep 0.2 - find(".subscribe-button span").text.should == "Subscribe" + find(".subscribe-button span").should have_content("Subscribe") end step 'I click button "Unsubscribe"' do From 88c2639356e6e4b4b05d96584768e07a9ce4b048 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 14 Apr 2015 23:06:21 +0200 Subject: [PATCH 179/194] Remove superfluous info from invite accepted/declined mails. --- app/views/notify/group_invite_accepted_email.html.haml | 3 +-- app/views/notify/group_invite_accepted_email.text.erb | 2 +- app/views/notify/group_invite_declined_email.html.haml | 3 +-- app/views/notify/group_invite_declined_email.text.erb | 2 +- app/views/notify/project_invite_accepted_email.html.haml | 3 +-- app/views/notify/project_invite_accepted_email.text.erb | 2 +- app/views/notify/project_invite_declined_email.html.haml | 3 +-- app/views/notify/project_invite_declined_email.text.erb | 2 +- 8 files changed, 8 insertions(+), 12 deletions(-) diff --git a/app/views/notify/group_invite_accepted_email.html.haml b/app/views/notify/group_invite_accepted_email.html.haml index d50d182102..55efad384a 100644 --- a/app/views/notify/group_invite_accepted_email.html.haml +++ b/app/views/notify/group_invite_accepted_email.html.haml @@ -2,6 +2,5 @@ #{@group_member.invite_email}, now known as #{link_to @group_member.user.name, user_url(@group_member.user)}, has accepted your invitation to join group - = link_to @group.name, group_url(@group) - as #{@group_member.human_access}. + #{link_to @group.name, group_url(@group)}. diff --git a/app/views/notify/group_invite_accepted_email.text.erb b/app/views/notify/group_invite_accepted_email.text.erb index 0072f5252d..f8b70f7a5a 100644 --- a/app/views/notify/group_invite_accepted_email.text.erb +++ b/app/views/notify/group_invite_accepted_email.text.erb @@ -1,3 +1,3 @@ -<%= @group_member.invite_email %>, now known as <%= @group_member.user.name %>, has accepted your invitation to join group <%= @group.name %> as <%= @group_member.human_access %>. +<%= @group_member.invite_email %>, now known as <%= @group_member.user.name %>, has accepted your invitation to join group <%= @group.name %>. <%= group_url(@group) %> diff --git a/app/views/notify/group_invite_declined_email.html.haml b/app/views/notify/group_invite_declined_email.html.haml index 80f39c3f1e..f9525d84fa 100644 --- a/app/views/notify/group_invite_declined_email.html.haml +++ b/app/views/notify/group_invite_declined_email.html.haml @@ -1,6 +1,5 @@ %p #{@invite_email} has declined your invitation to join group - = link_to @group.name, group_url(@group) - as #{Gitlab::Access.options_with_owner.key(@access_level)}. + #{link_to @group.name, group_url(@group)}. diff --git a/app/views/notify/group_invite_declined_email.text.erb b/app/views/notify/group_invite_declined_email.text.erb index 31cdac0e7f..6c19a288d1 100644 --- a/app/views/notify/group_invite_declined_email.text.erb +++ b/app/views/notify/group_invite_declined_email.text.erb @@ -1,3 +1,3 @@ -<%= @invite_email %> has declined your invitation to join group <%= @group.name %> as <%= Gitlab::Access.options_with_owner.key(@access_level) %>. +<%= @invite_email %> has declined your invitation to join group <%= @group.name %>. <%= group_url(@group) %> diff --git a/app/views/notify/project_invite_accepted_email.html.haml b/app/views/notify/project_invite_accepted_email.html.haml index 0c01eef762..7e58d30b10 100644 --- a/app/views/notify/project_invite_accepted_email.html.haml +++ b/app/views/notify/project_invite_accepted_email.html.haml @@ -2,6 +2,5 @@ #{@project_member.invite_email}, now known as #{link_to @project_member.user.name, user_url(@project_member.user)}, has accepted your invitation to join project - = link_to @project.name_with_namespace, namespace_project_url(@project.namespace, @project) - as #{@project_member.human_access}. + #{link_to @project.name_with_namespace, namespace_project_url(@project.namespace, @project)}. diff --git a/app/views/notify/project_invite_accepted_email.text.erb b/app/views/notify/project_invite_accepted_email.text.erb index 0bbe274931..fcbe752114 100644 --- a/app/views/notify/project_invite_accepted_email.text.erb +++ b/app/views/notify/project_invite_accepted_email.text.erb @@ -1,3 +1,3 @@ -<%= @project_member.invite_email %>, now known as <%= @project_member.user.name %>, has accepted your invitation to join project <%= @project.name_with_namespace %> as <%= @project_member.human_access %>. +<%= @project_member.invite_email %>, now known as <%= @project_member.user.name %>, has accepted your invitation to join project <%= @project.name_with_namespace %>. <%= namespace_project_url(@project.namespace, @project) %> diff --git a/app/views/notify/project_invite_declined_email.html.haml b/app/views/notify/project_invite_declined_email.html.haml index dad9f505b9..c2d7e6f6e3 100644 --- a/app/views/notify/project_invite_declined_email.html.haml +++ b/app/views/notify/project_invite_declined_email.html.haml @@ -1,6 +1,5 @@ %p #{@invite_email} has declined your invitation to join project - = link_to @project.name_with_namespace, namespace_project_url(@project.namespace, @project) - as #{Gitlab::Access.options_with_owner.key(@access_level)}. + #{link_to @project.name_with_namespace, namespace_project_url(@project.namespace, @project)}. diff --git a/app/views/notify/project_invite_declined_email.text.erb b/app/views/notify/project_invite_declined_email.text.erb index 06a0013cff..484687fa51 100644 --- a/app/views/notify/project_invite_declined_email.text.erb +++ b/app/views/notify/project_invite_declined_email.text.erb @@ -1,3 +1,3 @@ -<%= @invite_email %> has declined your invitation to join project <%= @project.name_with_namespace %> as <%= Gitlab::Access.options_with_owner.key(@access_level) %>. +<%= @invite_email %> has declined your invitation to join project <%= @project.name_with_namespace %>. <%= namespace_project_url(@project.namespace, @project) %> From 2f797a140b9e4c2a5cc6b86b923cd3ff70e6d7e9 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 14 Apr 2015 23:13:48 +0200 Subject: [PATCH 180/194] Skip issue comment when its content, updates and attachments are empty. --- lib/gitlab/google_code_import/importer.rb | 100 +++++++++++++--------- 1 file changed, 61 insertions(+), 39 deletions(-) diff --git a/lib/gitlab/google_code_import/importer.rb b/lib/gitlab/google_code_import/importer.rb index 730fbb8606..3b506efddc 100644 --- a/lib/gitlab/google_code_import/importer.rb +++ b/lib/gitlab/google_code_import/importer.rb @@ -79,25 +79,13 @@ module Gitlab author = user_map[raw_issue["author"]["name"]] date = DateTime.parse(raw_issue["published"]).to_formatted_s(:long) - body = [] - body << "*By #{author} on #{date}*" - body << "---" - comments = raw_issue["comments"]["items"] - issue_comment = comments.shift - content = format_content(issue_comment["content"]) - if content.blank? - content = "*(No description has been entered for this issue)*" - end - body << content - + content = format_content(issue_comment["content"]) attachments = format_attachments(raw_issue["id"], 0, issue_comment["attachments"]) - if attachments.any? - body << "---" - body += attachments - end + + body = format_issue_body(author, date, content, attachments) labels = [] raw_issue["labels"].each do |label| @@ -113,7 +101,7 @@ module Gitlab issue = project.issues.create!( title: raw_issue["title"], - description: body.join("\n\n"), + description: body, author_id: project.creator_id, state: raw_issue["state"] == "closed" ? "closed" : "opened" ) @@ -129,35 +117,28 @@ module Gitlab comments.each do |raw_comment| next if raw_comment.has_key?("deletedBy") + content = format_content(raw_comment["content"]) + updates = format_updates(raw_comment["updates"]) + attachments = format_attachments(issue.iid, raw_comment["id"], raw_comment["attachments"]) + + next if content.blank? && updates.blank? && attachments.blank? + author = user_map[raw_comment["author"]["name"]] date = DateTime.parse(raw_comment["published"]).to_formatted_s(:long) - body = [] - body << "*Comment #{raw_comment["id"]} by #{author} on #{date}*" - body << "---" + body = format_issue_comment_body( + raw_comment["id"], + author, + date, + content, + updates, + attachments + ) - content = format_content(raw_comment["content"]) - if content.blank? - content = "*(No comment has been entered for this change)*" - end - body << content - - updates = format_updates(raw_comment["updates"]) - if updates.any? - body << "---" - body += updates - end - - attachments = format_attachments(issue.iid, raw_comment["id"], raw_comment["attachments"]) - if attachments.any? - body << "---" - body += attachments - end - - comment = issue.notes.create!( + issue.notes.create!( project_id: project.id, author_id: project.creator_id, - note: body.join("\n\n") + note: body ) end end @@ -324,6 +305,47 @@ module Gitlab text end.compact end + + def format_issue_comment_body(id, author, date, content, updates, attachments) + body = [] + body << "*Comment #{id} by #{author} on #{date}*" + body << "---" + + if content.blank? + content = "*(No comment has been entered for this change)*" + end + body << content + + if updates.any? + body << "---" + body += updates + end + + if attachments.any? + body << "---" + body += attachments + end + + body.join("\n\n") + end + + def format_issue_body(author, date, content, attachments) + body = [] + body << "*By #{author} on #{date}*" + body << "---" + + if content.blank? + content = "*(No description has been entered for this issue)*" + end + body << content + + if attachments.any? + body << "---" + body += attachments + end + + body.join("\n\n") + end end end end From 9d00bb08962e26b0c284bcfc5e7ed52cf141d5c6 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 14 Apr 2015 23:22:14 +0200 Subject: [PATCH 181/194] Import Google Code issue assignee when mapped. --- lib/gitlab/google_code_import/importer.rb | 14 ++++++++++++++ spec/fixtures/GoogleCodeProjectHosting.json | 5 +++++ .../lib/gitlab/google_code_import/importer_spec.rb | 10 +++++++--- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/lib/gitlab/google_code_import/importer.rb b/lib/gitlab/google_code_import/importer.rb index 3b506efddc..777472cf3c 100644 --- a/lib/gitlab/google_code_import/importer.rb +++ b/lib/gitlab/google_code_import/importer.rb @@ -99,10 +99,24 @@ module Gitlab end labels << nice_status_name(raw_issue["status"]) + assignee_id = nil + if raw_issue.has_key?("owner") + username = user_map[raw_issue["owner"]["name"]] + + if username.start_with?("@") + username = username[1..-1] + + if user = User.find_by(username: username) + assignee_id = user.id + end + end + end + issue = project.issues.create!( title: raw_issue["title"], description: body, author_id: project.creator_id, + assignee_id: assignee_id, state: raw_issue["state"] == "closed" ? "closed" : "opened" ) issue.add_labels_by_names(labels) diff --git a/spec/fixtures/GoogleCodeProjectHosting.json b/spec/fixtures/GoogleCodeProjectHosting.json index 85e354f8d0..d05e77271a 100644 --- a/spec/fixtures/GoogleCodeProjectHosting.json +++ b/spec/fixtures/GoogleCodeProjectHosting.json @@ -344,6 +344,11 @@ "name" : "schattenpr...", "htmlLink" : "https://code.google.com/u/106498139506637530000/" }, + "owner" : { + "kind" : "projecthosting#issuePerson", + "name" : "thilo...", + "htmlLink" : "https://code.google.com/u/104224918623172014000/" + }, "updated" : "2009-11-18T05:14:58.000Z", "published" : "2009-11-18T00:20:19.000Z", "closed" : "2009-11-18T05:14:58.000Z", diff --git a/spec/lib/gitlab/google_code_import/importer_spec.rb b/spec/lib/gitlab/google_code_import/importer_spec.rb index a68f69d337..107ba49962 100644 --- a/spec/lib/gitlab/google_code_import/importer_spec.rb +++ b/spec/lib/gitlab/google_code_import/importer_spec.rb @@ -1,20 +1,22 @@ require "spec_helper" describe Gitlab::GoogleCodeImport::Importer do + let(:mapped_user) { create(:user, username: "thilo123") } let(:raw_data) { JSON.parse(File.read(Rails.root.join("spec/fixtures/GoogleCodeProjectHosting.json"))) } let(:client) { Gitlab::GoogleCodeImport::Client.new(raw_data) } let(:import_data) { { "repo" => client.repo("tint2").raw_data, "user_map" => { - "thilo..." => "@thilo123" + "thilo..." => "@#{mapped_user.username}" } } } let(:project) { create(:project, import_data: import_data) } - subject { described_class.new(project) } + subject { described_class.new(project) } describe "#execute" do + it "imports status labels" do subject.execute @@ -43,6 +45,8 @@ describe Gitlab::GoogleCodeImport::Importer do issue = project.issues.first expect(issue).to_not be_nil expect(issue.iid).to eq(169) + expect(issue.author).to eq(project.creator) + expect(issue.assignee).to eq(mapped_user) expect(issue.state).to eq("closed") expect(issue.label_names).to include("Priority: Medium") expect(issue.label_names).to include("Status: Fixed") @@ -65,7 +69,7 @@ describe Gitlab::GoogleCodeImport::Importer do note = project.issues.first.notes.first expect(note).to_not be_nil expect(note.note).to include("Comment 1") - expect(note.note).to include("@thilo123") + expect(note.note).to include("@#{mapped_user.username}") expect(note.note).to include("November 18, 2009 05:14") expect(note.note).to include("applied, thanks.") expect(note.note).to include("Status: Fixed") From 36b54fc04f7619faad37f052528f9cb30c3dd99e Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 14 Apr 2015 23:26:53 +0200 Subject: [PATCH 182/194] Don't crash when project repository doesn't exist. --- CHANGELOG | 1 + app/controllers/application_controller.rb | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index 0ecde5ef89..b71bb5f491 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 7.10.0 (unreleased) + - Don't crash when project repository doesn't exist. - Fix broken file browsing with a submodule that contains a relative link (Stan Hu) - Fix persistent XSS vulnerability around profile website URLs. - Fix project import URL regex to prevent arbitary local repos from being imported. diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 0521a9ef8c..920a981e7c 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -126,7 +126,7 @@ class ApplicationController < ActionController::Base def repository @repository ||= project.repository - rescue Grit::NoSuchPathError(e) + rescue Grit::NoSuchPathError => e log_exception(e) nil end From f517dd2606e6d49a447cc952bd4b16ce017f1ddc Mon Sep 17 00:00:00 2001 From: Ben Bodenmiller Date: Tue, 14 Apr 2015 23:11:13 -0700 Subject: [PATCH 183/194] fix stuck mr fixes #348. related to 6487419364fa9c179e24028d85b2be10d574067f. --- CHANGELOG | 1 + app/models/merge_request.rb | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index eab4ff4e6a..d079c04406 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -73,6 +73,7 @@ v 7.10.0 (unreleased) - Fix git over ssh errors 'fatal: protocol error: bad line length character' - Automatically setup GitLab CI project for forks if origin project has GitLab CI enabled - Bust group page project list cache when namespace name or path changes. + - Fix stuck Merge Request merging events from old installations (Ben Bodenmiller) v 7.9.3 - Contains no changes diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 35cb920d8b..9c9e276250 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -361,6 +361,8 @@ class MergeRequest < ActiveRecord::Base end def locked_long_ago? - locked_at && locked_at < (Time.now - 1.day) + return false unless locked? + + locked_at.nil? || locked_at < (Time.now - 1.day) end end From 400c9c5ae2c04340dc5a3490b27b393c410f63ad Mon Sep 17 00:00:00 2001 From: Dmitriy Zaporozhets Date: Wed, 15 Apr 2015 12:12:58 +0300 Subject: [PATCH 184/194] Revert "Removed help link from sidebar" This reverts commit 6fe26e3e468ae0b79e2e05b4d4dd608cd9bbcd94. --- app/views/layouts/nav/_dashboard.html.haml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml index 511c0694b8..e4f630c6a1 100644 --- a/app/views/layouts/nav/_dashboard.html.haml +++ b/app/views/layouts/nav/_dashboard.html.haml @@ -31,3 +31,8 @@ %span Merge Requests %span.count= current_user.assigned_merge_requests.opened.count + = nav_link(controller: :help) do + = link_to help_path, title: 'Help' do + %i.fa.fa-question-circle + %span + Help From 5d1ab47bd2dc80e8c5237416405009786ef58629 Mon Sep 17 00:00:00 2001 From: Dmitriy Zaporozhets Date: Wed, 15 Apr 2015 12:13:49 +0300 Subject: [PATCH 185/194] Revert help link tests removal Signed-off-by: Dmitriy Zaporozhets --- features/dashboard/active_tab.feature | 5 +++++ features/steps/dashboard/active_tab.rb | 3 +++ features/steps/shared/paths.rb | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/features/dashboard/active_tab.feature b/features/dashboard/active_tab.feature index c17dfd0706..08b87808f3 100644 --- a/features/dashboard/active_tab.feature +++ b/features/dashboard/active_tab.feature @@ -17,3 +17,8 @@ Feature: Dashboard Active Tab Given I visit dashboard merge requests page Then the active main tab should be Merge Requests And no other main tabs should be active + + Scenario: On Dashboard Help + Given I visit dashboard help page + Then the active main tab should be Help + And no other main tabs should be active diff --git a/features/steps/dashboard/active_tab.rb b/features/steps/dashboard/active_tab.rb index 19ff77b9fe..0e2c04fb29 100644 --- a/features/steps/dashboard/active_tab.rb +++ b/features/steps/dashboard/active_tab.rb @@ -3,4 +3,7 @@ class Spinach::Features::DashboardActiveTab < Spinach::FeatureSteps include SharedPaths include SharedActiveTab + step 'the active main tab should be Help' do + ensure_active_main_tab('Help') + end end diff --git a/features/steps/shared/paths.rb b/features/steps/shared/paths.rb index 8ce17b9414..e3cf1b92cd 100644 --- a/features/steps/shared/paths.rb +++ b/features/steps/shared/paths.rb @@ -83,6 +83,10 @@ module SharedPaths visit search_path end + step 'I visit dashboard help page' do + visit help_path + end + step 'I visit dashboard groups page' do visit dashboard_groups_path end From 3d0c0bd922dadbf09c46f2b8acb58d44565394ce Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Wed, 15 Apr 2015 12:45:29 +0200 Subject: [PATCH 186/194] Add changelog item. --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index f133cb2478..bd3a5f3b1e 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 7.10.0 (unreleased) + - Allow users to be invited by email to join a group or project. - Fix broken file browsing with a submodule that contains a relative link (Stan Hu) - Fix persistent XSS vulnerability around profile website URLs. - Fix project import URL regex to prevent arbitary local repos from being imported. From 9193b94ecec58c7c386f9c1b5a6f06f01b1ef42a Mon Sep 17 00:00:00 2001 From: Dmitriy Zaporozhets Date: Wed, 15 Apr 2015 16:14:16 +0300 Subject: [PATCH 187/194] No tabs in code! Signed-off-by: Dmitriy Zaporozhets --- .rubocop.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.rubocop.yml b/.rubocop.yml index 7290d627d2..03b78d6884 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -652,7 +652,7 @@ Style/SymbolProc: Style/Tab: Description: 'No hard tabs.' StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#spaces-indentation' - Enabled: false + Enabled: true Style/TrailingBlankLines: Description: 'Checks trailing blank lines and final newline.' From 80097606f59c52ab962a3bec1e67f67d1a3cb6c7 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Wed, 15 Apr 2015 16:58:43 +0200 Subject: [PATCH 188/194] Make sure user has access to namespace in import tests. --- lib/gitlab/gitorious_import/client.rb | 32 ---------------- lib/gitlab/gitorious_import/repository.rb | 37 +++++++++++++++++++ .../bitbucket_import/project_creator_spec.rb | 6 ++- .../github_import/project_creator_spec.rb | 6 ++- .../gitlab_import/project_creator_spec.rb | 6 ++- .../gitorious_import/project_creator_spec.rb | 6 ++- .../project_creator_spec.rb | 6 ++- 7 files changed, 62 insertions(+), 37 deletions(-) create mode 100644 lib/gitlab/gitorious_import/repository.rb diff --git a/lib/gitlab/gitorious_import/client.rb b/lib/gitlab/gitorious_import/client.rb index 5043f6a2eb..8cdc3d4afa 100644 --- a/lib/gitlab/gitorious_import/client.rb +++ b/lib/gitlab/gitorious_import/client.rb @@ -27,37 +27,5 @@ module Gitlab repo_list.to_s.split(',').map(&:strip).reject(&:blank?) end end - - Repository = Struct.new(:full_name) do - def id - Digest::SHA1.hexdigest(full_name) - end - - def namespace - segments.first - end - - def path - segments.last - end - - def name - path.titleize - end - - def description - "" - end - - def import_url - "#{GITORIOUS_HOST}/#{full_name}.git" - end - - private - - def segments - full_name.split('/') - end - end end end diff --git a/lib/gitlab/gitorious_import/repository.rb b/lib/gitlab/gitorious_import/repository.rb new file mode 100644 index 0000000000..f702797dc6 --- /dev/null +++ b/lib/gitlab/gitorious_import/repository.rb @@ -0,0 +1,37 @@ +module Gitlab + module GitoriousImport + GITORIOUS_HOST = "https://gitorious.org" + + Repository = Struct.new(:full_name) do + def id + Digest::SHA1.hexdigest(full_name) + end + + def namespace + segments.first + end + + def path + segments.last + end + + def name + path.titleize + end + + def description + "" + end + + def import_url + "#{GITORIOUS_HOST}/#{full_name}.git" + end + + private + + def segments + full_name.split('/') + end + end + end +end diff --git a/spec/lib/gitlab/bitbucket_import/project_creator_spec.rb b/spec/lib/gitlab/bitbucket_import/project_creator_spec.rb index f552310584..0ec6a43f68 100644 --- a/spec/lib/gitlab/bitbucket_import/project_creator_spec.rb +++ b/spec/lib/gitlab/bitbucket_import/project_creator_spec.rb @@ -8,8 +8,12 @@ describe Gitlab::BitbucketImport::ProjectCreator do is_private: true, owner: "asd"}.with_indifferent_access } - let(:namespace){ create(:namespace) } + let(:namespace){ create(:group, owner: user) } + before do + namespace.add_owner(user) + end + it 'creates project' do allow_any_instance_of(Project).to receive(:add_import_job) diff --git a/spec/lib/gitlab/github_import/project_creator_spec.rb b/spec/lib/gitlab/github_import/project_creator_spec.rb index 8d594a112d..3bf52cb685 100644 --- a/spec/lib/gitlab/github_import/project_creator_spec.rb +++ b/spec/lib/gitlab/github_import/project_creator_spec.rb @@ -10,7 +10,11 @@ describe Gitlab::GithubImport::ProjectCreator do clone_url: "https://gitlab.com/asd/vim.git", owner: OpenStruct.new(login: "john")) } - let(:namespace){ create(:namespace) } + let(:namespace){ create(:group, owner: user) } + + before do + namespace.add_owner(user) + end it 'creates project' do allow_any_instance_of(Project).to receive(:add_import_job) diff --git a/spec/lib/gitlab/gitlab_import/project_creator_spec.rb b/spec/lib/gitlab/gitlab_import/project_creator_spec.rb index 4c0d64ed13..3cefe4ea8e 100644 --- a/spec/lib/gitlab/gitlab_import/project_creator_spec.rb +++ b/spec/lib/gitlab/gitlab_import/project_creator_spec.rb @@ -10,7 +10,11 @@ describe Gitlab::GitlabImport::ProjectCreator do http_url_to_repo: "https://gitlab.com/asd/vim.git", owner: {name: "john"}}.with_indifferent_access } - let(:namespace){ create(:namespace) } + let(:namespace){ create(:group, owner: user) } + + before do + namespace.add_owner(user) + end it 'creates project' do allow_any_instance_of(Project).to receive(:add_import_job) diff --git a/spec/lib/gitlab/gitorious_import/project_creator_spec.rb b/spec/lib/gitlab/gitorious_import/project_creator_spec.rb index 90271ea15e..c1125ca635 100644 --- a/spec/lib/gitlab/gitorious_import/project_creator_spec.rb +++ b/spec/lib/gitlab/gitorious_import/project_creator_spec.rb @@ -3,7 +3,11 @@ require 'spec_helper' describe Gitlab::GitoriousImport::ProjectCreator do let(:user) { create(:user) } let(:repo) { Gitlab::GitoriousImport::Repository.new('foo/bar-baz-qux') } - let(:namespace){ create(:namespace) } + let(:namespace){ create(:group, owner: user) } + + before do + namespace.add_owner(user) + end it 'creates project' do allow_any_instance_of(Project).to receive(:add_import_job) diff --git a/spec/lib/gitlab/google_code_import/project_creator_spec.rb b/spec/lib/gitlab/google_code_import/project_creator_spec.rb index 6cdd4dbbfb..7a224538b8 100644 --- a/spec/lib/gitlab/google_code_import/project_creator_spec.rb +++ b/spec/lib/gitlab/google_code_import/project_creator_spec.rb @@ -9,7 +9,11 @@ describe Gitlab::GoogleCodeImport::ProjectCreator do "repositoryUrls" => [ "https://vim.googlecode.com/git/" ] ) } - let(:namespace) { create(:namespace) } + let(:namespace){ create(:group, owner: user) } + + before do + namespace.add_owner(user) + end it 'creates project' do allow_any_instance_of(Project).to receive(:add_import_job) From efe962934661cd94edb1c24c6d34419cd28b82f9 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Wed, 15 Apr 2015 15:53:23 +0200 Subject: [PATCH 189/194] Fix Mention notification level. --- app/services/notification_service.rb | 37 ++++++++++------------------ 1 file changed, 13 insertions(+), 24 deletions(-) diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 203e654c18..ab5a653d5c 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -130,22 +130,23 @@ class NotificationService if note.commit_id.present? recipients << note.commit_author end - - # Get users who left comment in thread - recipients = recipients.concat(noteable_commenters(note)) + + # Add all users participating in the thread (author, assignee, comment authors) + participants = + if target.is_a?(Commit) + author_ids = Note.for_commit_id(target.id).pluck(:author_id).uniq + User.where(id: author_ids) + else + target.participants + end + recipients = recipients.concat(participants) # Merge project watchers recipients = recipients.concat(project_watchers(note.project)).compact.uniq - # Reject users with Mention notification level - recipients = reject_mention_users(recipients, note.project) - - # Add explicitly mentioned users - if target.respond_to?(:participants) - recipients = recipients.concat(target.participants) - else - recipients = recipients.concat(note.mentioned_users) - end + # Reject users with Mention notification level, except those mentioned in _this_ note. + recipients = reject_mention_users(recipients - note.mentioned_users, note.project) + recipients = recipients + note.mentioned_users # Reject mutes users recipients = reject_muted_users(recipients, note.project) @@ -216,18 +217,6 @@ class NotificationService protected - def noteable_commenters(note) - opts = { noteable_type: note.noteable_type, project_id: note.project_id } - - if note.commit_id.present? - opts.merge!(commit_id: note.commit_id) - else - opts.merge!(noteable_id: note.noteable_id) - end - - User.where(id: Note.where(opts).pluck(:author_id)) - end - # Get project users with WATCH notification level def project_watchers(project) project_members = project_member_notification(project) From b73ffbd3cb7dd89f54351b74e0cf4d1df63f0310 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Wed, 15 Apr 2015 17:01:14 +0200 Subject: [PATCH 190/194] Fix for snippets. --- app/services/notification_service.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index ab5a653d5c..cfed7964c3 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -133,11 +133,13 @@ class NotificationService # Add all users participating in the thread (author, assignee, comment authors) participants = - if target.is_a?(Commit) + if target.respond_to?(:participants) + target.participants + elsif target.is_a?(Commit) author_ids = Note.for_commit_id(target.id).pluck(:author_id).uniq User.where(id: author_ids) else - target.participants + note.mentioned_users end recipients = recipients.concat(participants) From d4fec49abc0fceec11f970c0699dfe71ee185290 Mon Sep 17 00:00:00 2001 From: Dominik Sander Date: Fri, 3 Apr 2015 16:46:23 +0200 Subject: [PATCH 191/194] Fix merge request comments on files with multiple commits Having a merge request with a comments on a line which is then changed in a later commit prevented new comments from properly showing up in the merge request show page. * `Note#set_diff` do not use stored the diff when creating a new note in merge requests (we can not be sure the diff did not changed since the last comment on that line) * Do not rely just on `outdated?` of the first note when displaying comments of a MR in the discussion tab, but partition the active/outdated notes and display them all * In the inline changes tab just select the active notes, so an outdated note does not prevent an active one from being rendered * Just show active comments in the side-by-side changes tab --- CHANGELOG | 1 + app/helpers/diff_helper.rb | 2 +- app/models/note.rb | 6 +++++- app/views/projects/diffs/_text_file.html.haml | 2 +- app/views/projects/notes/_discussion.html.haml | 7 +++---- 5 files changed, 11 insertions(+), 7 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index e8f367c332..0487cc6474 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -88,6 +88,7 @@ v 7.10.0 (unreleased) - Allow user to choose a public email to show on public profile - Remove truncation from issue titles on milestone page (Jason Blanchard) - Fix stuck Merge Request merging events from old installations (Ben Bodenmiller) + - Fix merge request comments on files with multiple commits v 7.9.3 - Contains no changes diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb index b56f21c7a1..4f42972a4d 100644 --- a/app/helpers/diff_helper.rb +++ b/app/helpers/diff_helper.rb @@ -101,7 +101,7 @@ module DiffHelper end def line_comments - @line_comments ||= @line_notes.group_by(&:line_code) + @line_comments ||= @line_notes.select(&:active?).group_by(&:line_code) end def organize_comments(type_left, type_right, line_code_left, line_code_right) diff --git a/app/models/note.rb b/app/models/note.rb index fdab4517df..2cf3fac2de 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -354,7 +354,7 @@ class Note < ActiveRecord::Base def set_diff # First lets find notes with same diff # before iterating over all mr diffs - diff = Note.where(noteable_id: self.noteable_id, noteable_type: self.noteable_type, line_code: self.line_code).last.try(:diff) + diff = diff_for_line_code unless for_merge_request? diff ||= find_diff self.st_diff = diff.to_hash if diff @@ -364,6 +364,10 @@ class Note < ActiveRecord::Base @diff ||= Gitlab::Git::Diff.new(st_diff) if st_diff.respond_to?(:map) end + def diff_for_line_code + Note.where(noteable_id: noteable_id, noteable_type: noteable_type, line_code: line_code).last.try(:diff) + end + # Check if such line of code exists in merge request diff # If exists - its active discussion # If not - its outdated diff diff --git a/app/views/projects/diffs/_text_file.html.haml b/app/views/projects/diffs/_text_file.html.haml index e691db9c08..e6dfbfd651 100644 --- a/app/views/projects/diffs/_text_file.html.haml +++ b/app/views/projects/diffs/_text_file.html.haml @@ -23,7 +23,7 @@ %td.line_content{class: "noteable_line #{type} #{line_code}", "line_code" => line_code}= raw diff_line_content(line.text) - if @reply_allowed - - comments = @line_notes.select { |n| n.line_code == line_code }.sort_by(&:created_at) + - comments = @line_notes.select { |n| n.line_code == line_code && n.active? }.sort_by(&:created_at) - unless comments.empty? = render "projects/notes/diff_notes_with_reply", notes: comments, line: line.text diff --git a/app/views/projects/notes/_discussion.html.haml b/app/views/projects/notes/_discussion.html.haml index 3561ca49f8..b8068835b3 100644 --- a/app/views/projects/notes/_discussion.html.haml +++ b/app/views/projects/notes/_discussion.html.haml @@ -6,9 +6,8 @@ = image_tag avatar_icon(note.author_email), class: "avatar s40" .timeline-content - if note.for_merge_request? - - if note.outdated? - = render "projects/notes/discussions/outdated", discussion_notes: discussion_notes - - else - = render "projects/notes/discussions/active", discussion_notes: discussion_notes + - (active_notes, outdated_notes) = discussion_notes.partition(&:active?) + = render "projects/notes/discussions/active", discussion_notes: active_notes if active_notes.length > 0 + = render "projects/notes/discussions/outdated", discussion_notes: outdated_notes if outdated_notes.length > 0 - else = render "projects/notes/discussions/commit", discussion_notes: discussion_notes From 135372849d84f3746510cabf8882e91435efadbb Mon Sep 17 00:00:00 2001 From: Dmitriy Zaporozhets Date: Wed, 15 Apr 2015 20:22:23 +0300 Subject: [PATCH 192/194] Fix conflict Signed-off-by: Dmitriy Zaporozhets --- app/controllers/groups/group_members_controller.rb | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index d9fad903ac..cdb96c3daa 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -80,13 +80,9 @@ class Groups::GroupMembersController < Groups::ApplicationController if can?(current_user, :destroy_group_member, @group_member) @group_member.destroy -<<<<<<< HEAD log_audit_event(@group_member, action: :destroy) - redirect_to(dashboard_groups_path, info: "You left #{group.name} group.") -======= redirect_to(dashboard_groups_path, notice: "You left #{group.name} group.") ->>>>>>> 9193b94ecec58c7c386f9c1b5a6f06f01b1ef42a else return render_403 end From d209b9793c94cc46bbdf6fb3a6a75680f5621ac0 Mon Sep 17 00:00:00 2001 From: Dmitriy Zaporozhets Date: Thu, 16 Apr 2015 13:36:32 +0300 Subject: [PATCH 193/194] Authorization rule manage_group does not exist any more Signed-off-by: Dmitriy Zaporozhets --- app/controllers/audit_events_controller.rb | 2 +- .../projects/project_members/_shared_group_members.html.haml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/audit_events_controller.rb b/app/controllers/audit_events_controller.rb index d5fc3c0c57..3ae0c43c52 100644 --- a/app/controllers/audit_events_controller.rb +++ b/app/controllers/audit_events_controller.rb @@ -22,7 +22,7 @@ class AuditEventsController < ApplicationController end def authorize_admin_group! - render_404 unless can?(current_user, :manage_group, group) + render_404 unless can?(current_user, :admin_group, group) end def determine_layout diff --git a/app/views/projects/project_members/_shared_group_members.html.haml b/app/views/projects/project_members/_shared_group_members.html.haml index 5dd32cfcaf..792de5a3d2 100644 --- a/app/views/projects/project_members/_shared_group_members.html.haml +++ b/app/views/projects/project_members/_shared_group_members.html.haml @@ -8,7 +8,7 @@ group, members with %strong #{group_links.human_access} role (#{shared_group_users_count}) - - if current_user.can?(:manage_group, shared_group) + - if current_user.can?(:admin_group, shared_group) .panel-head-actions = link_to group_group_members_path(shared_group), class: 'btn btn-sm' do %i.fa.fa-pencil-square-o From 1a22f0a2b1634bddd78fa736e3feaa5852d39071 Mon Sep 17 00:00:00 2001 From: Dmitriy Zaporozhets Date: Thu, 16 Apr 2015 13:58:07 +0300 Subject: [PATCH 194/194] Fix audit logging for project deploy keys Signed-off-by: Dmitriy Zaporozhets --- app/controllers/projects/deploy_keys_controller.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/controllers/projects/deploy_keys_controller.rb b/app/controllers/projects/deploy_keys_controller.rb index f6e4d2445e..ced8e25191 100644 --- a/app/controllers/projects/deploy_keys_controller.rb +++ b/app/controllers/projects/deploy_keys_controller.rb @@ -44,13 +44,16 @@ class Projects::DeployKeysController < Projects::ApplicationController def enable @key = accessible_keys.find(params[:id]) @project.deploy_keys << @key + log_audit_event(@key.title, action: :create) redirect_to namespace_project_deploy_keys_path(@project.namespace, @project) end def disable + @key = accessible_keys.find(params[:id]) @project.deploy_keys_projects.find_by(deploy_key_id: params[:id]).destroy + log_audit_event(@key.title, action: :destroy) redirect_to :back end