diff --git a/.csscomb.json b/.csscomb.json new file mode 100644 index 0000000000..e353e6a63d --- /dev/null +++ b/.csscomb.json @@ -0,0 +1,16 @@ +{ + "always-semicolon": true, + "color-case": "lower", + "block-indent": " ", + "color-shorthand": true, + "element-case": "lower", + "space-before-colon": "", + "space-after-colon": " ", + "space-before-combinator": " ", + "space-after-combinator": " ", + "space-between-declarations": "\n", + "space-before-opening-brace": " ", + "space-after-opening-brace": "\n", + "space-before-closing-brace": "\n", + "unitless-zero": true +} diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 1ba1d8d846..2ad63548d7 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -122,6 +122,14 @@ rubocop: - ruby - mysql +scss-lint: + stage: test + script: + - bundle exec rake scss_lint + tags: + - ruby + allow_failure: true + brakeman: stage: test script: @@ -148,6 +156,8 @@ flay: bundler:audit: stage: test + only: + - master script: - "bundle exec bundle-audit update" - "bundle exec bundle-audit check --ignore OSVDB-115941" @@ -161,7 +171,7 @@ spec:feature:ruby22: stage: test image: ruby:2.2 only: - - master + - master script: - RAILS_ENV=test bundle exec rake assets:precompile 2>/dev/null - RAILS_ENV=test SIMPLECOV=true bundle exec rake spec:feature diff --git a/.scss-lint.yml b/.scss-lint.yml new file mode 100644 index 0000000000..e350b2073c --- /dev/null +++ b/.scss-lint.yml @@ -0,0 +1,158 @@ +# Linter Documentation: +# https://github.com/brigade/scss-lint/blob/master/lib/scss_lint/linter/README.md + +scss_files: 'app/assets/stylesheets/**/*.scss' + +exclude: + - 'app/assets/stylesheets/pages/emojis.scss' + +linters: + BangFormat: + enabled: false + + BorderZero: + enabled: false + + ColorKeyword: + enabled: false + + ColorVariable: + enabled: false + + Comment: + enabled: false + + DeclarationOrder: + enabled: false + + # `scss-lint:disable` control comments should be preceded by a comment + # explaining why these linters are being disabled for this file. + # See https://github.com/brigade/scss-lint#disabling-linters-via-source for + # more information. + DisableLinterReason: + enabled: true + + DuplicateProperty: + enabled: false + + EmptyLineBetweenBlocks: + enabled: false + + EmptyRule: + enabled: false + + FinalNewline: + enabled: false + + # HEX colors should use three-character values where possible. + HexLength: + enabled: true + + # HEX color values should use lower-case colors to differentiate between + # letters and numbers, e.g. `#E3E3E3` vs. `#e3e3e3`. + HexNotation: + enabled: true + + IdSelector: + enabled: false + + ImportPath: + enabled: false + + ImportantRule: + enabled: false + + # Indentation should always be done in increments of 2 spaces. + Indentation: + enabled: true + width: 2 + + LeadingZero: + enabled: false + + MergeableSelector: + enabled: false + + NameFormat: + enabled: false + + NestingDepth: + enabled: false + + PlaceholderInExtend: + enabled: false + + PropertySortOrder: + enabled: false + + PropertySpelling: + enabled: false + + PseudoElement: + enabled: false + + QualifyingElement: + enabled: false + + SelectorDepth: + enabled: false + + # Selectors should always use hyphenated-lowercase, rather than camelCase or + # snake_case. + SelectorFormat: + enabled: true + convention: hyphenated_lowercase + + # Prefer the shortest shorthand form possible for properties that support it. + Shorthand: + enabled: true + + # Each property should have its own line, except in the special case of + # single line rulesets. + SingleLinePerProperty: + enabled: true + allow_single_line_rule_sets: true + + SingleLinePerSelector: + enabled: false + + SpaceAfterComma: + enabled: false + + # Properties should be formatted with a single space separating the colon + # from the property's value. + SpaceAfterPropertyColon: + enabled: true + + # Properties should be formatted with no space between the name and the + # colon. + SpaceAfterPropertyName: + enabled: true + + SpaceAroundOperator: + enabled: false + + SpaceBeforeBrace: + enabled: false + + StringQuotes: + enabled: false + + TrailingSemicolon: + enabled: false + + TrailingWhitespace: + enabled: false + + UnnecessaryMantissa: + enabled: false + + UnnecessaryParentReference: + enabled: false + + VendorPrefix: + enabled: false + + # Omit length units on zero values, e.g. `0px` vs. `0`. + ZeroUnit: + enabled: true diff --git a/CHANGELOG b/CHANGELOG index d38646ece6..ca360c6706 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,20 +1,25 @@ Please view this file on the master branch, on stable branches it's out of date. v 8.6.0 (unreleased) + - Add confidential issues - Bump gitlab_git to 9.0.3 (Stan Hu) - Support Golang subpackage fetching (Stan Hu) - Bump Capybara gem to 2.6.2 (Stan Hu) + - New branch button appears on issues where applicable - Contributions to forked projects are included in calendar - Improve the formatting for the user page bio (Connor Shea) - Removed the default password from the initial admin account created during setup. A password can be provided during setup (see installation docs), or GitLab will ask the user to create a new one upon first visit. - Fix issue when pushing to projects ending in .wiki - - Fix avatar stretching by providing a cropping feature (Johann Pardanaud) + - Add support for wiki with UTF-8 page names (Hiroyuki Sato) + - Fix wiki search results point to raw source (Hiroyuki Sato) - Don't load all of GitLab in mail_room + - HTTP error pages work independently from location and config (Artem Sidorenko) - Update `omniauth-saml` to 1.5.0 to allow for custom response attributes to be set - Memoize @group in Admin::GroupsController (Yatish Mehta) - Indicate how much an MR diverged from the target branch (Pierre de La Morinerie) + - Added omniauth-auth0 Gem (Daniel Carraro) - Strip leading and trailing spaces in URL validator (evuez) - Add "last_sign_in_at" and "confirmed_at" to GET /users/* API endpoints for admins (evuez) - Return empty array instead of 404 when commit has no statuses in commit status API @@ -22,28 +27,47 @@ v 8.6.0 (unreleased) - Rewrite logo to simplify SVG code (Sean Lang) - Allow to use YAML anchors when parsing the `.gitlab-ci.yml` (Pascal Bach) - Ignore jobs that start with `.` (hidden jobs) + - Hide builds from project's settings when the feature is disabled - Allow to pass name of created artifacts archive in `.gitlab-ci.yml` - Refactor and greatly improve search performance - Add support for cross-project label references + - Ensure "new SSH key" email do not ends up as dead Sidekiq jobs - Update documentation to reflect Guest role not being enforced on internal projects - Allow search for logged out users - Allow to define on which builds the current one depends on + - Allow user subscription to a label: get notified for issues/merge requests related to that label (Timothy Andrew) - Fix bug where Bitbucket `closed` issues were imported as `opened` (Iuri de Silvio) - Don't show Issues/MRs from archived projects in Groups view + - Fix wrong "iid of max iid" in Issuable sidebar for some merged MRs + - Fix empty source_sha on Merge Request when there is no diff (Pierre de La Morinerie) - Increase the notes polling timeout over time (Roberto Dip) - Add shortcut to toggle markdown preview (Florent Baldino) - Show labels in dashboard and group milestone views + - Fix an issue when the target branch of a MR had been deleted - Add main language of a project in the list of projects (Tiago Botelho) + - Add #upcoming filter to Milestone filter (Tiago Botelho) - Add ability to show archived projects on dashboard, explore and group pages - Move group activity to separate page + - Create external users which are excluded of internal and private projects unless access was explicitly granted - Continue parameters are checked to ensure redirection goes to the same instance + - User deletion is now done in the background so the request can not time out + - Canceled builds are now ignored in compound build status if marked as `allowed to fail` + - Trigger a todo for mentions on commits page + +v 8.5.8 + - Bump Git version requirement to 2.7.4 + +v 8.5.7 + - Bump Git version requirement to 2.7.3 + +v 8.5.6 + - Obtain a lease before querying LDAP v 8.5.5 - Ensure removing a project removes associated Todo entries - Prevent a 500 error in Todos when author was removed - Fix pagination for filtered dashboard and explore pages - Fix "Show all" link behavior - - Add #upcoming filter to Milestone filter (Tiago Botelho) v 8.5.4 - Do not cache requests for badges (including builds badge) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 30c9742904..7540fa1afc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -427,6 +427,7 @@ merge request: 1. [Rails](https://github.com/bbatsov/rails-style-guide) 1. [Testing](https://github.com/thoughtbot/guides/tree/master/style/testing) 1. [CoffeeScript](https://github.com/thoughtbot/guides/tree/master/style/coffeescript) +1. [SCSS styleguide][scss-styleguide] 1. [Shell commands](doc/development/shell_commands.md) created by GitLab contributors to enhance security 1. [Database Migrations](doc/development/migration_style_guide.md) @@ -494,6 +495,7 @@ available at [http://contributor-covenant.org/version/1/1/0/](http://contributor [rss-source]: https://github.com/bbatsov/ruby-style-guide/blob/master/README.md#source-code-layout [rss-naming]: https://github.com/bbatsov/ruby-style-guide/blob/master/README.md#naming [doc-styleguide]: doc/development/doc_styleguide.md "Documentation styleguide" +[scss-styleguide]: doc/development/scss_styleguide.md "SCSS styleguide" [gitlab-design]: https://gitlab.com/gitlab-org/gitlab-design [free Antetype viewer (Mac OSX only)]: https://itunes.apple.com/us/app/antetype-viewer/id824152298?mt=12 [`gitlab1.atype` file]: https://gitlab.com/gitlab-org/gitlab-design/tree/master/gitlab1.atype/ diff --git a/Gemfile b/Gemfile index 26b61c2fa5..e500bfb788 100644 --- a/Gemfile +++ b/Gemfile @@ -22,6 +22,7 @@ gem 'devise', '~> 3.5.4' gem 'devise-async', '~> 0.9.0' gem 'doorkeeper', '~> 2.2.0' gem 'omniauth', '~> 1.3.1' +gem 'omniauth-auth0', '~> 1.4.1' gem 'omniauth-azure-oauth2', '~> 0.0.6' gem 'omniauth-bitbucket', '~> 0.0.2' gem 'omniauth-cas3', '~> 1.1.2' @@ -50,7 +51,7 @@ gem "browser", '~> 1.0.0' # Extracting information from a git repository # Provide access to Gitlab::Git library -gem "gitlab_git", '~> 9.0' +gem "gitlab_git", '~> 10.0' # LDAP Auth # GitLab fork with several improvements to original library. For full list of changes @@ -58,7 +59,9 @@ gem "gitlab_git", '~> 9.0' gem 'gitlab_omniauth-ldap', '~> 1.2.1', require: "omniauth-ldap" # Git Wiki -gem 'gollum-lib', '~> 4.1.0' +# Required manually in config/initializers/gollum.rb to control load order +gem 'gollum-lib', '~> 4.1.0', require: false +gem 'gollum-rugged_adapter', '~> 0.4.2', require: false # Language detection gem "github-linguist", "~> 4.7.0", require: "linguist" @@ -77,9 +80,6 @@ gem "haml-rails", '~> 0.9.0' # Files attachments gem "carrierwave", '~> 0.10.0' -# Image editing -gem "mini_magick", '~> 4.4.0' - # Drag and Drop UI gem 'dropzonejs-rails', '~> 0.7.1' @@ -286,6 +286,7 @@ group :development, :test do gem 'spring-commands-teaspoon', '~> 0.0.2' gem 'rubocop', '~> 0.35.0', require: false + gem 'scss_lint', '~> 0.47.0', require: false gem 'coveralls', '~> 0.8.2', require: false gem 'simplecov', '~> 0.10.0', require: false gem 'flog', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 9772e7fdd3..63ed9441c6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -359,11 +359,11 @@ GEM posix-spawn (~> 0.3) gitlab_emoji (0.3.1) gemojione (~> 2.2, >= 2.2.1) - gitlab_git (9.0.3) + gitlab_git (10.0.0) activesupport (~> 4.0) charlock_holmes (~> 0.7.3) github-linguist (~> 4.7.0) - rugged (~> 0.24.0b13) + rugged (~> 0.24.0) gitlab_meta (7.0) gitlab_omniauth-ldap (1.2.1) net-ldap (~> 0.9) @@ -381,6 +381,9 @@ GEM rouge (~> 1.9) sanitize (~> 2.1.0) stringex (~> 2.5.1) + gollum-rugged_adapter (0.4.2) + mime-types (>= 1.15) + rugged (~> 0.24.0, >= 0.21.3) gon (6.0.1) actionpack (>= 3.0) json @@ -469,7 +472,6 @@ GEM method_source (0.8.2) mime-types (1.25.1) mimemagic (0.3.0) - mini_magick (4.4.0) mini_portile2 (2.0.0) minitest (5.7.0) mousetrap-rails (1.4.6) @@ -496,6 +498,8 @@ GEM omniauth (1.3.1) hashie (>= 1.2, < 4) rack (>= 1.0, < 3) + omniauth-auth0 (1.4.1) + omniauth-oauth2 (~> 1.1) omniauth-azure-oauth2 (0.0.6) jwt (~> 1.0) omniauth (~> 1.0) @@ -704,7 +708,7 @@ GEM rubyntlm (0.5.2) rubypants (0.2.0) rufus-scheduler (3.1.10) - rugged (0.24.0b13) + rugged (0.24.0) safe_yaml (1.0.4) sanitize (2.1.0) nokogiri (>= 1.4.4) @@ -718,6 +722,9 @@ GEM sawyer (0.6.0) addressable (~> 2.3.5) faraday (~> 0.8, < 0.10) + scss_lint (0.47.1) + rake (>= 0.9, < 11) + sass (~> 3.4.15) sdoc (0.3.20) json (>= 1.1.3) rdoc (~> 3.10) @@ -935,10 +942,11 @@ DEPENDENCIES github-markup (~> 1.3.1) gitlab-flowdock-git-hook (~> 1.0.1) gitlab_emoji (~> 0.3.0) - gitlab_git (~> 9.0) + gitlab_git (~> 10.0) gitlab_meta (= 7.0) gitlab_omniauth-ldap (~> 1.2.1) gollum-lib (~> 4.1.0) + gollum-rugged_adapter (~> 0.4.2) gon (~> 6.0.1) grape (~> 0.13.0) grape-entity (~> 0.4.2) @@ -957,7 +965,6 @@ DEPENDENCIES loofah (~> 2.0.3) mail_room (~> 0.6.1) method_source (~> 0.8) - mini_magick (~> 4.4.0) minitest (~> 5.7.0) mousetrap-rails (~> 1.4.6) mysql2 (~> 0.3.16) @@ -968,6 +975,7 @@ DEPENDENCIES oauth2 (~> 1.0.0) octokit (~> 3.8.0) omniauth (~> 1.3.1) + omniauth-auth0 (~> 1.4.1) omniauth-azure-oauth2 (~> 0.0.6) omniauth-bitbucket (~> 0.0.2) omniauth-cas3 (~> 1.1.2) @@ -1009,6 +1017,7 @@ DEPENDENCIES ruby-fogbugz (~> 0.2.1) sanitize (~> 2.0) sass-rails (~> 5.0.0) + scss_lint (~> 0.47.0) sdoc (~> 0.3.20) seed-fu (~> 2.3.5) select2-rails (~> 3.5.9) diff --git a/README.md b/README.md index 3ec1d4a776..afa60116eb 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ GitLab is a Ruby on Rails application that runs on the following software: - Ubuntu/Debian/CentOS/RHEL - Ruby (MRI) 2.1 -- Git 1.7.10+ +- Git 2.7.4+ - Redis 2.8+ - MySQL or PostgreSQL diff --git a/app/assets/javascripts/application.js.coffee b/app/assets/javascripts/application.js.coffee index e9c6196e92..d415bbd347 100644 --- a/app/assets/javascripts/application.js.coffee +++ b/app/assets/javascripts/application.js.coffee @@ -42,7 +42,6 @@ #= require jquery.nicescroll #= require_tree . #= require fuzzaldrin-plus -#= require cropper.js window.slugify = (text) -> text.replace(/[^-a-zA-Z0-9]+/g, '_').toLowerCase() diff --git a/app/assets/javascripts/dispatcher.js.coffee b/app/assets/javascripts/dispatcher.js.coffee index 1be86e3b82..f5e1ca9860 100644 --- a/app/assets/javascripts/dispatcher.js.coffee +++ b/app/assets/javascripts/dispatcher.js.coffee @@ -14,7 +14,6 @@ class Dispatcher path = page.split(':') shortcut_handler = null - switch page when 'projects:issues:index' Issues.init() @@ -25,6 +24,8 @@ class Dispatcher new ZenMode() when 'projects:milestones:show', 'groups:milestones:show', 'dashboard:milestones:show' new Milestone() + when 'dashboard:todos:index' + new Todos() when 'projects:milestones:new', 'projects:milestones:edit' new ZenMode() new DropzoneInput($('.milestone-form')) diff --git a/app/assets/javascripts/gl_dropdown.js.coffee b/app/assets/javascripts/gl_dropdown.js.coffee index 4f03847775..c81e8bf760 100644 --- a/app/assets/javascripts/gl_dropdown.js.coffee +++ b/app/assets/javascripts/gl_dropdown.js.coffee @@ -246,11 +246,15 @@ class GitLabDropdown if oldValue value = "#{oldValue},#{value}" else - @dropdown.find(ACTIVE_CLASS).removeClass ACTIVE_CLASS + @dropdown.find(".#{ACTIVE_CLASS}").removeClass ACTIVE_CLASS # Toggle active class for the tick mark el.toggleClass "is-active" + # Toggle the dropdown label + if @options.toggleLabel + $(@el).find(".dropdown-toggle-text").text @options.toggleLabel(selectedObject) + if value? if !field.length # Create hidden input for form diff --git a/app/assets/javascripts/issue.js.coffee b/app/assets/javascripts/issue.js.coffee index d663e34871..f50df1f5ea 100644 --- a/app/assets/javascripts/issue.js.coffee +++ b/app/assets/javascripts/issue.js.coffee @@ -7,6 +7,7 @@ class @Issue # Prevent duplicate event bindings @disableTaskList() @fixAffixScroll() + @initParticipants() if $('a.btn-close').length @initTaskList() @initIssueBtnEventListeners() @@ -84,3 +85,27 @@ class @Issue type: 'PATCH' url: $('form.js-issuable-update').attr('action') data: patchData + + initParticipants: -> + _this = @ + $(document).on "click", ".js-participants-more", @toggleHiddenParticipants + + $(".js-participants-author").each (i) -> + if i >= _this.PARTICIPANTS_ROW_COUNT + $(@) + .addClass "js-participants-hidden" + .hide() + + toggleHiddenParticipants: (e) -> + e.preventDefault() + + currentText = $(this).text().trim() + lessText = $(this).data("less-text") + originalText = $(this).data("original-text") + + if currentText is originalText + $(this).text(lessText) + else + $(this).text(originalText) + + $(".js-participants-hidden").toggle() diff --git a/app/assets/javascripts/issues.js.coffee b/app/assets/javascripts/issues.js.coffee index a0acf3028b..1127b28926 100644 --- a/app/assets/javascripts/issues.js.coffee +++ b/app/assets/javascripts/issues.js.coffee @@ -41,24 +41,28 @@ @timer = null $("#issue_search").keyup -> clearTimeout(@timer) - @timer = setTimeout(Issues.filterResults, 500) + @timer = setTimeout( -> + Issues.filterResults $("#issue_search_form") + , 500) - filterResults: => - form = $("#issue_search_form") - search = $("#issue_search").val() - $('.issues-holder').css("opacity", '0.5') - issues_url = form.attr('action') + '?' + form.serialize() + filterResults: (form) => + $('.issues-holder, .merge-requests-holder').css("opacity", '0.5') + formAction = form.attr('action') + formData = form.serialize() + issuesUrl = formAction + issuesUrl += ("#{if formAction.indexOf("?") < 0 then '?' else '&'}") + issuesUrl += formData $.ajax type: "GET" - url: form.attr('action') - data: form.serialize() + url: formAction + data: formData complete: -> - $('.issues-holder').css("opacity", '1.0') + $('.issues-holder, .merge-requests-holder').css("opacity", '1.0') success: (data) -> - $('.issues-holder').html(data.html) + $('.issues-holder, .merge-requests-holder').html(data.html) # Change url so if user reload a page - search results are saved - history.replaceState {page: issues_url}, document.title, issues_url + history.replaceState {page: issuesUrl}, document.title, issuesUrl Issues.reload() dataType: "json" diff --git a/app/assets/javascripts/labels_select.js.coffee b/app/assets/javascripts/labels_select.js.coffee index 5ade2cb66c..4a0c18a99a 100644 --- a/app/assets/javascripts/labels_select.js.coffee +++ b/app/assets/javascripts/labels_select.js.coffee @@ -1,30 +1,32 @@ class @LabelsSelect constructor: -> $('.js-label-select').each (i, dropdown) -> - projectId = $(dropdown).data('project-id') - labelUrl = $(dropdown).data("labels") - selectedLabel = $(dropdown).data('selected') + $dropdown = $(dropdown) + projectId = $dropdown.data('project-id') + labelUrl = $dropdown.data('labels') + selectedLabel = $dropdown.data('selected') if selectedLabel - selectedLabel = selectedLabel.split(",") + selectedLabel = selectedLabel.split(',') newLabelField = $('#new_label_name') newColorField = $('#new_label_color') - showNo = $(dropdown).data('show-no') - showAny = $(dropdown).data('show-any') + showNo = $dropdown.data('show-no') + showAny = $dropdown.data('show-any') + defaultLabel = $dropdown.data('default-label') if newLabelField.length - $('.suggest-colors-dropdown a').on "click", (e) -> + $('.suggest-colors-dropdown a').on 'click', (e) -> e.preventDefault() e.stopPropagation() - newColorField.val $(this).data("color") + newColorField.val $(this).data('color') $('.js-dropdown-label-color-preview') - .css 'background-color', $(this).data("color") + .css 'background-color', $(this).data('color') .addClass 'is-active' - $('.js-new-label-btn').on "click", (e) -> + $('.js-new-label-btn').on 'click', (e) -> e.preventDefault() e.stopPropagation() - if newLabelField.val() isnt "" && newColorField.val() isnt "" + if newLabelField.val() isnt '' and newColorField.val() isnt '' $('.js-new-label-btn').disable() # Create new label with API @@ -33,46 +35,38 @@ class @LabelsSelect color: newColorField.val() }, (label) -> $('.js-new-label-btn').enable() - $('.dropdown-menu-back', $(dropdown).parent()).trigger "click" + $('.dropdown-menu-back', $dropdown.parent()).trigger 'click' - $(dropdown).glDropdown( + $dropdown.glDropdown( data: (term, callback) -> - # We have to fetch the JS version of the labels list because there is no - # public facing JSON url for labels $.ajax( url: labelUrl ).done (data) -> - html = $(data) - data = [] - html.find('.label-row a').each -> - data.push( - title: $(@).text().trim() - ) - if showNo data.unshift( - id: "0" - title: 'No label' + id: 0 + title: 'No Label' ) if showAny data.unshift( - title: 'Any label' + isAny: true + title: 'Any Label' ) if data.length > 2 - data.splice 2, 0, "divider" + data.splice 2, 0, 'divider' callback data renderRow: (label) -> if $.isArray(selectedLabel) - selected = "" + selected = '' $.each selectedLabel, (i, selectedLbl) -> selectedLbl = selectedLbl.trim() - if selected is "" && label.title is selectedLbl - selected = "is-active" + if selected is '' and label.title is selectedLbl + selected = 'is-active' else - selected = if label.title is selectedLabel then "is-active" else "" + selected = if label.title is selectedLabel then 'is-active' else '' "
  • @@ -83,10 +77,24 @@ class @LabelsSelect search: fields: ['title'] selectable: true - fieldName: $(dropdown).data('field-name') + toggleLabel: (selected) -> + if selected and selected.title isnt 'Any Label' + selected.title + else + defaultLabel + fieldName: $dropdown.data('field-name') id: (label) -> - label.title + if label.isAny? + '' + else + label.title clicked: -> - if $(dropdown).hasClass "js-filter-submit" - $(dropdown).parents('form').submit() + page = $('body').data 'page' + isIssueIndex = page is 'projects:issues:index' + isMRIndex = page is page is 'projects:merge_requests:index' + + if $dropdown.hasClass('js-filter-submit') and (isIssueIndex or isMRIndex) + Issues.filterResults $dropdown.closest('form') + else if $dropdown.hasClass 'js-filter-submit' + $dropdown.closest('form').submit() ) diff --git a/app/assets/javascripts/milestone_select.js.coffee b/app/assets/javascripts/milestone_select.js.coffee index 5e884454a6..e17a1adb64 100644 --- a/app/assets/javascripts/milestone_select.js.coffee +++ b/app/assets/javascripts/milestone_select.js.coffee @@ -1,60 +1,65 @@ class @MilestoneSelect constructor: -> $('.js-milestone-select').each (i, dropdown) -> - projectId = $(dropdown).data('project-id') - milestonesUrl = $(dropdown).data('milestones') - selectedMilestone = $(dropdown).data('selected') - showNo = $(dropdown).data('show-no') - showAny = $(dropdown).data('show-any') - useId = $(dropdown).data('use-id') + $dropdown = $(dropdown) + projectId = $dropdown.data('project-id') + milestonesUrl = $dropdown.data('milestones') + selectedMilestone = $dropdown.data('selected') + showNo = $dropdown.data('show-no') + showAny = $dropdown.data('show-any') + useId = $dropdown.data('use-id') + defaultLabel = $dropdown.data('default-label') - $(dropdown).glDropdown( + $dropdown.glDropdown( data: (term, callback) -> $.ajax( url: milestonesUrl ).done (data) -> - html = $(data) - data = [] - html.find('.milestone strong a').each -> - link = $(@).attr("href").split("/") - data.push( - id: link[link.length - 1] - title: $(@).text().trim() - ) - if showNo data.unshift( - id: "0" + id: '0' title: 'No Milestone' ) if showAny data.unshift( + isAny: true title: 'Any Milestone' ) if data.length > 2 - data.splice 2, 0, "divider" + data.splice 2, 0, 'divider' callback(data) filterable: true search: fields: ['title'] selectable: true - fieldName: $(dropdown).data('field-name') + toggleLabel: (selected) -> + if selected && 'id' of selected + selected.title + else + defaultLabel + fieldName: $dropdown.data('field-name') text: (milestone) -> milestone.title id: (milestone) -> if !useId - if milestone.title isnt "Any milestone" + if !milestone.isAny? milestone.title else - "" + '' else milestone.id isSelected: (milestone) -> milestone.title is selectedMilestone clicked: -> - if $(dropdown).hasClass "js-filter-submit" - $(dropdown).parents('form').submit() + page = $('body').data 'page' + isIssueIndex = page is 'projects:issues:index' + isMRIndex = page is page is 'projects:merge_requests:index' + + if $dropdown.hasClass('js-filter-submit') and (isIssueIndex or isMRIndex) + Issues.filterResults $dropdown.closest('form') + else if $dropdown.hasClass 'js-filter-submit' + $dropdown.closest('form').submit() ) diff --git a/app/assets/javascripts/notes.js.coffee b/app/assets/javascripts/notes.js.coffee index 75d7f52bbb..8253221658 100644 --- a/app/assets/javascripts/notes.js.coffee +++ b/app/assets/javascripts/notes.js.coffee @@ -343,6 +343,7 @@ class @Notes updateNote: (_xhr, note, _status) => # Convert returned HTML to a jQuery object so we can modify it further $html = $(note.html) + $('.js-timeago', $html).timeago() $html.syntaxHighlight() $html.find('.js-task-list-container').taskList('enable') @@ -626,10 +627,10 @@ class @Notes if closebtn.text() isnt closetext closebtn.text(closetext) - if reopenbtn.is(':not(.btn-comment-and-reopen)') + if reopenbtn.is('.btn-comment-and-reopen') reopenbtn.removeClass('btn-comment-and-reopen') - if closebtn.is(':not(.btn-comment-and-close)') + if closebtn.is('.btn-comment-and-close') closebtn.removeClass('btn-comment-and-close') if discardbtn.is(':visible') diff --git a/app/assets/javascripts/profile.js.coffee b/app/assets/javascripts/profile.js.coffee index 59d44c30be..20f8744055 100644 --- a/app/assets/javascripts/profile.js.coffee +++ b/app/assets/javascripts/profile.js.coffee @@ -17,52 +17,14 @@ class @Profile $('.update-notifications').on 'ajax:complete', -> $(this).find('.btn-save').enable() - # Avatar management + $('.js-choose-user-avatar-button').bind "click", -> + form = $(this).closest("form") + form.find(".js-user-avatar-input").click() - $avatarInput = $('.js-user-avatar-input') - $filename = $('.js-avatar-filename') - $modalCrop = $('.modal-profile-crop') - $modalCropImg = $('.modal-profile-crop-image') - - $('.js-choose-user-avatar-button').on "click", -> - $form = $(this).closest("form") - $form.find(".js-user-avatar-input").click() - - $modalCrop.on 'shown.bs.modal', -> - setTimeout ( -> # The cropper must be asynchronously initialized - $modalCropImg.cropper - aspectRatio: 1 - modal: false - scalable: false - rotatable: false - zoomable: false - - crop: (event) -> - ['x', 'y'].forEach (key) -> - $("#user_avatar_crop_#{key}").val(Math.floor(event[key])) - $("#user_avatar_crop_size").val(Math.floor(event.width)) - ), 0 - - $modalCrop.on 'hidden.bs.modal', -> - $modalCropImg.attr('src', '').cropper('destroy') - $avatarInput.val('') - $filename.text($filename.data('label')) - - $('.js-upload-user-avatar').on 'click', -> - $('.edit-user').submit() - - $avatarInput.on "change", -> + $('.js-user-avatar-input').bind "change", -> form = $(this).closest("form") filename = $(this).val().replace(/^.*[\\\/]/, '') - $filename.data('label', $filename.text()).text(filename) - - reader = new FileReader - - reader.onload = (event) -> - $modalCrop.modal('show') - $modalCropImg.attr('src', event.target.result) - - fileData = reader.readAsDataURL(this.files[0]) + form.find(".js-avatar-filename").text(filename) $ -> # Extract the SSH Key title from its comment diff --git a/app/assets/javascripts/project_new.js.coffee b/app/assets/javascripts/project_new.js.coffee index fecdb9fc2e..63dee4ed5d 100644 --- a/app/assets/javascripts/project_new.js.coffee +++ b/app/assets/javascripts/project_new.js.coffee @@ -3,3 +3,16 @@ class @ProjectNew $('.project-edit-container').on 'ajax:before', => $('.project-edit-container').hide() $('.save-project-loader').show() + @toggleSettings() + @toggleSettingsOnclick() + + + toggleSettings: -> + checked = $("#project_builds_enabled").prop("checked") + if checked + $('.builds-feature').show() + else + $('.builds-feature').hide() + + toggleSettingsOnclick: -> + $("#project_builds_enabled").on 'click', @toggleSettings diff --git a/app/assets/javascripts/subscription.js.coffee b/app/assets/javascripts/subscription.js.coffee index 7f41616d4e..084f0e0dc6 100644 --- a/app/assets/javascripts/subscription.js.coffee +++ b/app/assets/javascripts/subscription.js.coffee @@ -1,17 +1,21 @@ class @Subscription - constructor: (url) -> - $(".subscribe-button").unbind("click").click (event)=> - btn = $(event.currentTarget) - action = btn.find("span").text() - current_status = $(".subscription-status").attr("data-status") - btn.prop("disabled", true) - - $.post url, => - btn.prop("disabled", false) - status = if current_status == "subscribed" then "unsubscribed" else "subscribed" - $(".subscription-status").attr("data-status", status) - action = if status == "subscribed" then "Unsubscribe" else "Subscribe" - btn.find("span").text(action) - $(".subscription-status>div").toggleClass("hidden") + constructor: (container) -> + $container = $(container) + @url = $container.attr('data-url') + @subscribe_button = $container.find('.subscribe-button') + @subscription_status = $container.find('.subscription-status') + @subscribe_button.unbind('click').click(@toggleSubscription) - + toggleSubscription: (event) => + btn = $(event.currentTarget) + action = btn.find('span').text() + current_status = @subscription_status.attr('data-status') + btn.prop('disabled', true) + + $.post @url, => + btn.prop('disabled', false) + status = if current_status == 'subscribed' then 'unsubscribed' else 'subscribed' + @subscription_status.attr('data-status', status) + action = if status == 'subscribed' then 'Unsubscribe' else 'Subscribe' + btn.find('span').text(action) + @subscription_status.find('>div').toggleClass('hidden') diff --git a/app/assets/javascripts/todos.js.coffee b/app/assets/javascripts/todos.js.coffee new file mode 100644 index 0000000000..b6b4bd90e6 --- /dev/null +++ b/app/assets/javascripts/todos.js.coffee @@ -0,0 +1,56 @@ +class @Todos + constructor: (@name) -> + @clearListeners() + @initBtnListeners() + + clearListeners: -> + $('.done-todo').off('click') + $('.js-todos-mark-all').off('click') + + initBtnListeners: -> + $('.done-todo').on('click', @doneClicked) + $('.js-todos-mark-all').on('click', @allDoneClicked) + + doneClicked: (e) => + e.preventDefault() + e.stopImmediatePropagation() + + $this = $(e.currentTarget) + $this.disable() + + $.ajax + type: 'POST' + url: $this.attr('href') + dataType: 'json' + data: '_method': 'delete' + success: (data) => + @clearDone $this.closest('li') + @updateBadges data + + allDoneClicked: (e) => + e.preventDefault() + e.stopImmediatePropagation() + + $this = $(e.currentTarget) + $this.disable() + + $.ajax + type: 'POST' + url: $this.attr('href') + dataType: 'json' + data: '_method': 'delete' + success: (data) => + $this.remove() + $('.js-todos-list').remove() + @updateBadges data + + clearDone: ($row) -> + $ul = $row.closest('ul') + $row.remove() + + if not $ul.find('li').length + $ul.parents('.panel').remove() + + updateBadges: (data) -> + $('.todos-pending .badge, .todos-pending-count').text data.count + $('.todos-done .badge').text data.done_count diff --git a/app/assets/javascripts/users_select.js.coffee b/app/assets/javascripts/users_select.js.coffee index 987c6f4b8d..3d6452d2f4 100644 --- a/app/assets/javascripts/users_select.js.coffee +++ b/app/assets/javascripts/users_select.js.coffee @@ -4,14 +4,16 @@ class @UsersSelect @userPath = "/autocomplete/users/:id.json" $('.js-user-search').each (i, dropdown) => - @projectId = $(dropdown).data('project-id') - @showCurrentUser = $(dropdown).data('current-user') - showNullUser = $(dropdown).data('null-user') - showAnyUser = $(dropdown).data('any-user') - firstUser = $(dropdown).data('first-user') - selectedId = $(dropdown).data('selected') + $dropdown = $(dropdown) + @projectId = $dropdown.data('project-id') + @showCurrentUser = $dropdown.data('current-user') + showNullUser = $dropdown.data('null-user') + showAnyUser = $dropdown.data('any-user') + firstUser = $dropdown.data('first-user') + selectedId = $dropdown.data('selected') + defaultLabel = $dropdown.data('default-label') - $(dropdown).glDropdown( + $dropdown.glDropdown( data: (term, callback) => @users term, (users) => if term.length is 0 @@ -52,10 +54,21 @@ class @UsersSelect search: fields: ['name', 'username'] selectable: true - fieldName: $(dropdown).data('field-name') + fieldName: $dropdown.data('field-name') + toggleLabel: (selected) -> + if selected && 'id' of selected + selected.name + else + defaultLabel clicked: -> - if $(dropdown).hasClass "js-filter-submit" - $(dropdown).parents('form').submit() + page = $('body').data 'page' + isIssueIndex = page is 'projects:issues:index' + isMRIndex = page is page is 'projects:merge_requests:index' + + if $dropdown.hasClass('js-filter-submit') and (isIssueIndex or isMRIndex) + Issues.filterResults $dropdown.closest('form') + else if $dropdown.hasClass 'js-filter-submit' + $dropdown.closest('form').submit() renderRow: (user) -> username = if user.username then "@#{user.username}" else "" avatar = if user.avatar_url then user.avatar_url else false diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index e2d590f4df..2d301d21ab 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -9,7 +9,6 @@ *= require_self *= require dropzone/basic *= require cal-heatmap - *= require cropper.css */ /* diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index d20b77ffae..c36f29dda0 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -23,15 +23,11 @@ margin-bottom: -$gl-padding; background-color: $background-color; padding: $gl-padding; - margin-bottom: 0px; + margin-bottom: 0; border-top: 1px solid $border-color; border-bottom: 1px solid $border-color; color: $gl-gray; - a { - color: $md-link-color; - } - &.oneline-block { line-height: 42px; } diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index 8d475137b0..657c5f033c 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -37,23 +37,23 @@ } @mixin btn-green { - @include btn-color($green-light, $border-green-light, $green-normal, $border-green-normal, $green-dark, $border-green-dark, #FFFFFF); + @include btn-color($green-light, $border-green-light, $green-normal, $border-green-normal, $green-dark, $border-green-dark, #fff); } @mixin btn-blue { - @include btn-color($blue-light, $border-blue-light, $blue-normal, $border-blue-normal, $blue-dark, $border-blue-dark, #FFFFFF); + @include btn-color($blue-light, $border-blue-light, $blue-normal, $border-blue-normal, $blue-dark, $border-blue-dark, #fff); } @mixin btn-blue-medium { - @include btn-color($blue-medium-light, $border-blue-light, $blue-medium, $border-blue-normal, $blue-medium-dark, $border-blue-dark, #FFFFFF); + @include btn-color($blue-medium-light, $border-blue-light, $blue-medium, $border-blue-normal, $blue-medium-dark, $border-blue-dark, #fff); } @mixin btn-orange { - @include btn-color($orange-light, $border-orange-light, $orange-normal, $border-orange-normal, $orange-dark, $border-orange-dark, #FFFFFF); + @include btn-color($orange-light, $border-orange-light, $orange-normal, $border-orange-normal, $orange-dark, $border-orange-dark, #fff); } @mixin btn-red { - @include btn-color($red-light, $border-red-light, $red-normal, $border-red-normal, $red-dark, $border-red-dark, #FFFFFF); + @include btn-color($red-light, $border-red-light, $red-normal, $border-red-normal, $red-dark, $border-red-dark, #fff); } @mixin btn-gray { @@ -127,7 +127,7 @@ margin-right: 7px; float: left; &:last-child { - margin-right: 0px; + margin-right: 0; } &.btn-xs { margin-right: 3px; @@ -139,7 +139,6 @@ .caret { margin-left: 5px; - color: $gray-darkest; } } @@ -170,7 +169,7 @@ margin-right: 7px; float: left; &:last-child { - margin-right: 0px; + margin-right: 0; } } } @@ -209,3 +208,13 @@ background-color: #e4e7ed !important; } } + +.btn-loading { + &:not(.disabled) .fa { + display: none; + } + + .fa { + margin-right: 5px; + } +} diff --git a/app/assets/stylesheets/framework/calendar.scss b/app/assets/stylesheets/framework/calendar.scss index 580012abd7..e3192823a1 100644 --- a/app/assets/stylesheets/framework/calendar.scss +++ b/app/assets/stylesheets/framework/calendar.scss @@ -33,19 +33,19 @@ } .q2 { - fill: #ACD5F2 !important; + fill: #acd5f2 !important; } .q3 { - fill: #7FA8D1 !important; + fill: #7fa8d1 !important; } .q4 { - fill: #49729B !important; + fill: #49729b !important; } .q5 { - fill: #254E77 !important; + fill: #254e77 !important; } .domain-background { diff --git a/app/assets/stylesheets/framework/callout.scss b/app/assets/stylesheets/framework/callout.scss index 20a9bfb981..da7bab74a3 100644 --- a/app/assets/stylesheets/framework/callout.scss +++ b/app/assets/stylesheets/framework/callout.scss @@ -39,6 +39,6 @@ } .bs-callout-success { background-color: #dff0d8; - border-color: #5cA64d; + border-color: #5ca64d; color: #3c763d; } diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index ff551f151f..bc03c2180b 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -1,6 +1,6 @@ /** COLORS **/ .cgray { color: $gl-gray; } -.clgray { color: #BBB } +.clgray { color: #bbb } .cred { color: $gl-text-red; } .cgreen { color: $gl-text-green; } .cdark { color: #444 } @@ -8,20 +8,20 @@ /** COMMON CLASSES **/ .prepend-top-0 { margin-top: 0; } .prepend-top-5 { margin-top: 5px; } -.prepend-top-10 { margin-top:10px } +.prepend-top-10 { margin-top: 10px } .prepend-top-default { margin-top: $gl-padding !important; } -.prepend-top-20 { margin-top:20px } -.prepend-left-10 { margin-left:10px } +.prepend-top-20 { margin-top: 20px } +.prepend-left-10 { margin-left: 10px } .prepend-left-default { margin-left: $gl-padding; } -.prepend-left-20 { margin-left:20px } +.prepend-left-20 { margin-left: 20px } .append-right-5 { margin-right: 5px } -.append-right-10 { margin-right:10px } +.append-right-10 { margin-right: 10px } .append-right-default { margin-right: $gl-padding; } -.append-right-20 { margin-right:20px } -.append-bottom-0 { margin-bottom:0 } -.append-bottom-10 { margin-bottom:10px } -.append-bottom-15 { margin-bottom:15px } -.append-bottom-20 { margin-bottom:20px } +.append-right-20 { margin-right: 20px } +.append-bottom-0 { margin-bottom: 0 } +.append-bottom-10 { margin-bottom: 10px } +.append-bottom-15 { margin-bottom: 15px } +.append-bottom-20 { margin-bottom: 20px } .append-bottom-default { margin-bottom: $gl-padding; } .inline { display: inline-block } .center { text-align: center } @@ -51,7 +51,7 @@ pre { } &.well-pre { - border: 1px solid #EEE; + border: 1px solid #eee; background: #f9f9f9; border-radius: 0; color: #555; @@ -103,7 +103,7 @@ span.update-author { } .user-mention { - color: #2FA0BB; + color: #2fa0bb; font-weight: bold; } @@ -134,10 +134,10 @@ p.time { // Fix issue with notes & lists creating a bunch of bottom borders. li.note { - img { max-width:100% } + img { max-width: 100% } .note-title { li { - border-bottom:none !important; + border-bottom: none !important; } } } @@ -187,9 +187,9 @@ li.note { .error-message { padding: 10px; - background: #C67; + background: #c67; margin: 0; - color: #FFF; + color: #fff; a { color: #fff; @@ -200,7 +200,7 @@ li.note { .browser-alert { padding: 10px; text-align: center; - background: #C67; + background: #c67; color: #fff; font-weight: bold; a { @@ -271,7 +271,7 @@ img.emoji { table { td.permission-x { - background: #D9EDF7 !important; + background: #d9edf7 !important; text-align: center; } } @@ -280,7 +280,7 @@ table { float: left; text-align: center; font-size: 32px; - color: #AAA; + color: #aaa; width: 60px; } @@ -347,7 +347,7 @@ table { .profiler-button, .profiler-controls { - border-color: #EEE !important; + border-color: #eee !important; } } diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 5b647fc617..a48b6c17fa 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -4,11 +4,17 @@ height: 0; margin-left: 2px; vertical-align: middle; - border-top: $caret-width-base dashed $dropdown-caret-color; + border-top: $caret-width-base dashed; border-right: $caret-width-base solid transparent; border-left: $caret-width-base solid transparent; } +.btn-group { + .caret { + margin-left: 0; + } +} + .dropdown { position: relative; } @@ -161,9 +167,8 @@ .dropdown-menu-user-full-name { display: block; - margin-bottom: 2px; font-weight: 600; - line-height: 1; + line-height: 16px; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; @@ -171,7 +176,7 @@ .dropdown-menu-user-username { display: block; - line-height: 1; + line-height: 16px; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; @@ -261,7 +266,7 @@ position: absolute; top: 10px; right: 10px; - color: #C7C7C7; + color: #c7c7c7; font-size: 12px; pointer-events: none; } diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index b034a4882c..646e261083 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -30,7 +30,7 @@ right: 15px; .btn { - padding: 0px 10px; + padding: 0 10px; font-size: 13px; line-height: 28px; } @@ -84,7 +84,7 @@ &.blob-no-preview { background: #eee; - text-shadow: 0 1px 2px #FFF; + text-shadow: 0 1px 2px #fff; padding: 100px 0; } @@ -124,7 +124,7 @@ } td.line-numbers { float: none; - border-left: 1px solid #DDD; + border-left: 1px solid #ddd; } td.lines { padding: 0; diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index c431e2b0df..40a508c1eb 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -3,22 +3,11 @@ vertical-align: top; } -@media (min-width: 800px) { +@media (min-width: $screen-sm-min) { .issues-filters, .issues_bulk_update { - select, .select2-container { - width: 120px !important; - display: inline-block; - } - } -} - -@media (min-width: 1200px) { - .issues-filters, - .issues_bulk_update { - select, .select2-container { - width: 150px !important; - display: inline-block; + .dropdown-menu-toggle { + width: 132px; } } } diff --git a/app/assets/stylesheets/framework/forms.scss b/app/assets/stylesheets/framework/forms.scss index 6c08005812..4cb4129b71 100644 --- a/app/assets/stylesheets/framework/forms.scss +++ b/app/assets/stylesheets/framework/forms.scss @@ -41,8 +41,8 @@ input[type='search'].search-input { } input[type='text'].danger { - background: #F2DEDE!important; - border-color: #D66; + background: #f2dede!important; + border-color: #d66; text-shadow: 0 1px 1px #fff } diff --git a/app/assets/stylesheets/framework/gitlab-theme.scss b/app/assets/stylesheets/framework/gitlab-theme.scss index 12cef6f8ea..2a4cf4fc33 100644 --- a/app/assets/stylesheets/framework/gitlab-theme.scss +++ b/app/assets/stylesheets/framework/gitlab-theme.scss @@ -23,13 +23,13 @@ &:hover { background-color: $color-darker; a { - color: #FFF; + color: #fff; } } } .collapse-nav a { - color: #FFF; + color: #fff; background: $color; } @@ -42,7 +42,7 @@ &:hover { background-color: $color-dark; - color: #FFF; + color: #fff; text-decoration: none; } } @@ -71,7 +71,7 @@ } &.active a { - color: #FFF; + color: #fff; background: $color-dark; &.no-highlight { @@ -79,42 +79,42 @@ } i { - color: #FFF + color: #fff } } } } } -$theme-blue: #2980B9; +$theme-blue: #2980b9; $theme-charcoal: #333c47; -$theme-graphite: #888888; +$theme-graphite: #888; $theme-gray: #373737; $theme-green: #019875; -$theme-violet: #554488; +$theme-violet: #548; body { &.ui_blue { - @include gitlab-theme(#BECDE9, $theme-blue, #1970A9, #096099); + @include gitlab-theme(#becde9, $theme-blue, #1970a9, #096099); } &.ui_charcoal { - @include gitlab-theme(#c5d0de, $theme-charcoal, #2b333d, #24272D); + @include gitlab-theme(#c5d0de, $theme-charcoal, #2b333d, #24272d); } &.ui_graphite { - @include gitlab-theme(#CCCCCC, $theme-graphite, #777777, #666666); + @include gitlab-theme(#ccc, $theme-graphite, #777, #666); } &.ui_gray { - @include gitlab-theme(#979797, $theme-gray, #272727, #222222); + @include gitlab-theme(#979797, $theme-gray, #272727, #222); } &.ui_green { - @include gitlab-theme(#AADDCC, $theme-green, #018865, #017855); + @include gitlab-theme(#adc, $theme-green, #018865, #017855); } &.ui_violet { - @include gitlab-theme(#9988CC, $theme-violet, #443366, #332255); + @include gitlab-theme(#98c, $theme-violet, #436, #325); } } \ No newline at end of file diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 4c4033e3ae..71a7ecab8e 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -7,8 +7,8 @@ header { &.navbar-empty { height: 58px; - background: #FFF; - border-bottom: 1px solid #EEE; + background: #fff; + border-bottom: 1px solid #eee; .center-logo { margin: 11px 0; @@ -28,7 +28,7 @@ header { min-height: $header-height; background-color: #fff; border: none; - border-bottom: 1px solid #EEE; + border-bottom: 1px solid #eee; .container-fluid { width: 100% !important; @@ -47,7 +47,7 @@ header { text-align: center; &:hover, &:focus, &:active { - background-color: #FFF; + background-color: #fff; } } @@ -59,7 +59,7 @@ header { right: 2px; &:hover { - background-color: #EEE; + background-color: #eee; } &.active { color: #7f8fa4; @@ -162,7 +162,7 @@ header { font-size: 18px; .navbar-nav { - margin: 0px; + margin: 0; float: none !important; .visible-xs, .visable-sm { diff --git a/app/assets/stylesheets/framework/highlight.scss b/app/assets/stylesheets/framework/highlight.scss index 12e2f00fe8..7cf4d4fba4 100644 --- a/app/assets/stylesheets/framework/highlight.scss +++ b/app/assets/stylesheets/framework/highlight.scss @@ -1,8 +1,8 @@ .file-content.code { border: none; box-shadow: none; - margin: 0px; - padding: 0px; + margin: 0; + padding: 0; table-layout: fixed; pre { diff --git a/app/assets/stylesheets/framework/issue_box.scss b/app/assets/stylesheets/framework/issue_box.scss index 77a00586b2..7f7b7c806e 100644 --- a/app/assets/stylesheets/framework/issue_box.scss +++ b/app/assets/stylesheets/framework/issue_box.scss @@ -20,7 +20,7 @@ display: block; float: left; margin-right: 10px; - color: #FFF; + color: #fff; font-size: $gl-font-size; line-height: 25px; diff --git a/app/assets/stylesheets/framework/jquery.scss b/app/assets/stylesheets/framework/jquery.scss index 0cdcd923b3..525ed81b05 100644 --- a/app/assets/stylesheets/framework/jquery.scss +++ b/app/assets/stylesheets/framework/jquery.scss @@ -3,13 +3,13 @@ font-size: $font-size-base; &.ui-datepicker-inline { - border: 1px solid #DDD; + border: 1px solid #ddd; padding: 10px; width: 270px; .ui-datepicker-header { - background: #FFF; - border-color: #DDD; + background: #fff; + border-color: #ddd; } .ui-datepicker-calendar td a { @@ -19,7 +19,7 @@ } &.ui-autocomplete { - border-color: #DDD; + border-color: #ddd; padding: 0; margin-top: 2px; z-index: 1001; @@ -30,20 +30,20 @@ } .ui-state-default { - border: 1px solid #FFF; - background: #FFF; + border: 1px solid #fff; + background: #fff; color: #777; } .ui-state-highlight { - border: 1px solid #EEE; - background: #EEE; + border: 1px solid #eee; + background: #eee; } .ui-state-active { border: 1px solid $gl-primary; background: $gl-primary; - color: #FFF; + color: #fff; } .ui-state-hover, diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index bfec0911b3..b17c8bcbb1 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -111,14 +111,17 @@ ul.content-list { > li { border-color: $table-border-color; - color: $list-text-color; font-size: $list-font-size; + color: $list-text-color; .title { - color: $list-title-color; font-weight: 600; } + a { + color: $gl-dark-link-color; + } + .description { p { @include str-truncated; @@ -141,6 +144,10 @@ ul.content-list { } } +.panel > .content-list > li { + padding: $gl-padding-top $gl-padding; +} + ul.controls { padding-top: 1px; float: right; diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss index 1d8611b04d..8328aac4e7 100644 --- a/app/assets/stylesheets/framework/markdown_area.scss +++ b/app/assets/stylesheets/framework/markdown_area.scss @@ -71,7 +71,7 @@ } .md-preview-holder { - background: #FFF; + background: #fff; border: 1px solid #ddd; min-height: 169px; padding: 5px; @@ -80,7 +80,7 @@ .markdown-area { @include border-radius(0); - background: #FFF; + background: #fff; border: 1px solid #ddd; min-height: 140px; max-height: 500px; diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index 368bbfe535..377bfa174b 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -41,12 +41,6 @@ transition: $transition; } -@mixin transform($transform) { - -webkit-transform: $transform; - -ms-transform: $transform; - transform: $transform; -} - /** * Prefilled mixins * Mixins with fixed values @@ -73,17 +67,17 @@ * Base mixin for lists in GitLab */ @mixin basic-list { - margin: 5px 0px; - padding: 0px; + margin: 5px 0; + padding: 0; list-style: none; > li { @include clearfix; padding: 10px 0; - border-bottom: 1px solid #EEE; + border-bottom: 1px solid #eee; display: block; - margin: 0px; + margin: 0; &:last-child { border-bottom: none; diff --git a/app/assets/stylesheets/framework/mobile.scss b/app/assets/stylesheets/framework/mobile.scss index 3bfac2ad9b..5ea4f9a49d 100644 --- a/app/assets/stylesheets/framework/mobile.scss +++ b/app/assets/stylesheets/framework/mobile.scss @@ -128,12 +128,12 @@ .show-aside { display: none; position: fixed; - right: 0px; + right: 0; top: 30%; padding: 5px 15px; - background: #EEE; + background: #eee; font-size: 20px; color: #777; z-index: 100; - @include box-shadow(0 1px 2px #DDD); + @include box-shadow(0 1px 2px #ddd); } diff --git a/app/assets/stylesheets/framework/nav.scss b/app/assets/stylesheets/framework/nav.scss index b2fbc95e04..5f4ce87b08 100644 --- a/app/assets/stylesheets/framework/nav.scss +++ b/app/assets/stylesheets/framework/nav.scss @@ -26,7 +26,7 @@ } &.active a { - color: #000000; + color: #000; border-bottom: 2px solid #4688f1; } @@ -41,7 +41,7 @@ .top-area { @include clearfix; - border-bottom: 1px solid #EEE; + border-bottom: 1px solid #eee; .nav-text { padding-top: 16px; @@ -59,7 +59,7 @@ .nav-links { display: inline-block; width: 50%; - margin-bottom: 0px; + margin-bottom: 0; border-bottom: none; /* Small devices (phones, tablets, 768px and lower) */ @@ -74,7 +74,7 @@ float: right; text-align: right; padding: 11px 0; - margin-bottom: 0px; + margin-bottom: 0; > .dropdown { margin-right: $gl-padding-top; diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss index 7bf04e4ad7..b3371229d5 100644 --- a/app/assets/stylesheets/framework/selects.scss +++ b/app/assets/stylesheets/framework/selects.scss @@ -151,7 +151,7 @@ padding: 2px 25px 2px 5px; background: #fff image-url('select2.png'); background-repeat: no-repeat; - background-position: right 0px bottom 6px; + background-position: right 0 bottom 6px; border: 1px solid $input-border; @include border-radius($border-radius-default); @include transition(border-color ease-in-out .15s, box-shadow ease-in-out .15s); @@ -229,7 +229,7 @@ .namespace-result { .namespace-kind { - color: #AAA; + color: #aaa; font-weight: normal; } .namespace-path { diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 26df9acd2a..be05db58c4 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -16,7 +16,7 @@ .gitlab-text-container-link { z-index: 1; position: absolute; - left: 0px; + left: 0; } #logo { @@ -47,7 +47,7 @@ width: 100%; .container-fluid { - background: #FFF; + background: #fff; padding: 0 $gl-padding; &.container-blank { @@ -103,7 +103,7 @@ } &:hover { - background-color: #EEE; + background-color: #eee; } } @@ -143,7 +143,7 @@ overflow: hidden; &.navbar-collapse { - padding: 0px !important; + padding: 0 !important; } li { @@ -182,7 +182,7 @@ .count { float: right; background: #eee; - padding: 0px 8px; + padding: 0 8px; @include border-radius(6px); } @@ -194,8 +194,8 @@ } .sidebar-subnav { - margin-left: 0px; - padding-left: 0px; + margin-left: 0; + padding-left: 0; li { list-style: none; diff --git a/app/assets/stylesheets/framework/tw_bootstrap.scss b/app/assets/stylesheets/framework/tw_bootstrap.scss index ddf76704a5..dd42db1840 100644 --- a/app/assets/stylesheets/framework/tw_bootstrap.scss +++ b/app/assets/stylesheets/framework/tw_bootstrap.scss @@ -95,7 +95,7 @@ } &.label-inverse { - background-color: #333333; + background-color: #333; } } @@ -138,7 +138,7 @@ } .btn-clipboard { - min-width: 0px; + min-width: 0; } } diff --git a/app/assets/stylesheets/framework/tw_bootstrap_variables.scss b/app/assets/stylesheets/framework/tw_bootstrap_variables.scss index b1b8295411..f63ac03323 100644 --- a/app/assets/stylesheets/framework/tw_bootstrap_variables.scss +++ b/app/assets/stylesheets/framework/tw_bootstrap_variables.scss @@ -57,7 +57,7 @@ $component-active-bg: $brand-info; $input-color: $text-color; $input-border: #e7e9ed; -$input-border-focus: #7F8FA4; +$input-border-focus: #7f8fa4; $legend-color: $text-color; @@ -125,8 +125,8 @@ $panel-inner-border: $border-color; // //## -$well-bg: #F9F9F9; -$well-border: #EEE; +$well-bg: #f9f9f9; +$well-border: #eee; //== Code // diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 9381cb3281..949295a1d0 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -27,13 +27,13 @@ line-height: 10px; color: #555; vertical-align: middle; - background-color: #FCFCFC; + background-color: #fcfcfc; border-width: 1px; border-style: solid; - border-color: #CCC #CCC #BBB; + border-color: #ccc #ccc #bbb; border-image: none; border-radius: 3px; - box-shadow: 0px -1px 0px #BBB inset; + box-shadow: 0 -1px 0 #bbb inset; } h1 { @@ -187,7 +187,7 @@ body { } .page-title-empty { - margin-top: 0px; + margin-top: 0; line-height: 1.3; font-size: 1.25em; font-weight: 600; diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 0261c384a5..be626678bd 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -1,53 +1,83 @@ -$row-hover: #f4f8fe; -$gl-text-color: #54565B; -$gl-text-green: #4A2; -$gl-text-red: #D12F19; -$gl-text-orange: #D90; -$gl-header-color: #323232; -$gl-link-color: #333c48; -$md-text-color: #444; -$md-link-color: #3084bb; -$progress-color: #c0392b; -$gl-font-size: 15px; -$list-font-size: 15px; +/* + * Layout + */ $sidebar_collapsed_width: 62px; $sidebar_width: 230px; $gutter_collapsed_width: 62px; $gutter_width: 290px; $gutter_inner_width: 258px; -$avatar_radius: 50%; + +/* + * UI elements + */ +$border-color: #efeff1; +$table-border-color: #eef0f2; +$background-color: #faf9f9; + +/* + * Text + */ +$gl-font-size: 15px; +$gl-title-color: #333; +$gl-text-color: #555; +$gl-text-green: #4a2; +$gl-text-red: #d12f19; +$gl-text-orange: #d90; +$gl-link-color: #3084bb; +$gl-dark-link-color: #333; +$gl-placeholder-color: #8f8f8f; +$gl-gray: $gl-text-color; +$gl-header-color: $gl-title-color; + +/* + * Lists + */ +$list-font-size: $gl-font-size; +$list-title-color: $gl-title-color; +$list-text-color: $gl-text-color; + +/* + * Markdown + */ +$md-text-color: $gl-text-color; +$md-link-color: $gl-link-color; + +/* + * Code + */ $code_font_size: 13px; $code_line_height: 1.5; -$border-color: #efeff1; -$table-border-color: #eef0f2; -$background-color: #faf9f9; -$header-height: 58px; -$fixed-layout-width: 1280px; -$gl-gray: #5a5a5a; + +/* + * Padding + */ $gl-padding: 16px; $gl-btn-padding: 10px; $gl-vert-padding: 6px; -$gl-padding-top:10px; +$gl-padding-top: 10px; + +/* + * Misc + */ +$row-hover: #f4f8fe; +$progress-color: #c0392b; +$avatar_radius: 50%; +$header-height: 58px; +$fixed-layout-width: 1280px; $gl-avatar-size: 40px; -$secondary-text: #7f8fa4; -$error-exclamation-point: #E62958; +$error-exclamation-point: #e62958; $border-radius-default: 3px; -$list-title-color: #333333; -$list-text-color: #555555; - -$btn-transparent-color: #8F8F8F; - -$ssh-key-icon-color: #8F8F8F; +$btn-transparent-color: #8f8f8f; +$ssh-key-icon-color: #8f8f8f; $ssh-key-icon-size: 18px; - -$provider-btn-group-border: #E5E5E5; -$provider-btn-not-active-color: #4688F1; +$provider-btn-group-border: #e5e5e5; +$provider-btn-not-active-color: #4688f1; /* * Color schema */ -$white-light: #FFFFFF; +$white-light: #fff; $white-normal: #ededed; $white-dark: #ededed; @@ -57,51 +87,55 @@ $gray-dark: #ededed; $gray-darkest: #c9c9c9; $green-light: #38ae67; -$green-normal: #2FAA60; -$green-dark: #2CA05B; +$green-normal: #2faa60; +$green-dark: #2ca05b; -$blue-light: #2EA8E5; -$blue-normal: #2D9FD8; -$blue-dark: #2897CE; +$blue-light: #2ea8e5; +$blue-normal: #2d9fd8; +$blue-dark: #2897ce; -$blue-medium-light: #3498CB; -$blue-medium: #2F8EBF; -$blue-medium-dark: #2D86B4; +$blue-medium-light: #3498cb; +$blue-medium: #2f8ebf; +$blue-medium-dark: #2d86b4; $orange-light: rgba(252, 109, 38, 0.80); -$orange-normal: #E75E40; -$orange-dark: #CE5237; +$orange-normal: #e75e40; +$orange-dark: #ce5237; -$red-light: #F06559; -$red-normal: #E52C5A; -$red-dark: #D22852; +$red-light: #f06559; +$red-normal: #e52c5a; +$red-dark: #d22852; -$border-white-light: #F1F2F4; -$border-white-normal: #D6DAE2; -$border-white-dark: #C6CACF; +$border-white-light: #f1f2f4; +$border-white-normal: #d6dae2; +$border-white-dark: #c6cacf; $border-gray-light: rgba(0, 0, 0, 0.06); $border-gray-normal: rgba(0, 0, 0, 0.10);; -$border-gray-dark: #C6CACF; +$border-gray-dark: #c6cacf; -$border-green-light: #2FAA60; -$border-green-normal: #2CA05B; +$border-green-light: #2faa60; +$border-green-normal: #2ca05b; $border-green-dark: #279654; -$border-blue-light: #2D9FD8; -$border-blue-normal: #2897CE; -$border-blue-dark: #258DC1; +$border-blue-light: #2d9fd8; +$border-blue-normal: #2897ce; +$border-blue-dark: #258dc1; $border-orange-light: #fc6d26; -$border-orange-normal: #CE5237; -$border-orange-dark: #C14E35; +$border-orange-normal: #ce5237; +$border-orange-dark: #c14e35; -$border-red-light: #F24F41; -$border-red-normal: #D22852; -$border-red-dark: #CA264F; +$border-red-light: #f24f41; +$border-red-normal: #d22852; +$border-red-dark: #ca264f; -$help-well-bg: #FAFAFA; -$help-well-border: #E5E5E5; +$help-well-bg: #fafafa; +$help-well-border: #e5e5e5; + +$warning-message-bg: #fbf2d9; +$warning-message-color: #9e8e60; +$warning-message-border: #f0e2bb; /* header */ $light-grey-header: #faf9f9; @@ -139,23 +173,22 @@ $dropdown-border-color: rgba(#000, .1); $dropdown-shadow-color: rgba(#000, .1); $dropdown-divider-color: rgba(#000, .1); $dropdown-header-color: #959494; -$dropdown-caret-color: #54565B; -$dropdown-title-btn-color: #BFBFBF; -$dropdown-input-color: #C7C7C7; +$dropdown-title-btn-color: #bfbfbf; +$dropdown-input-color: #c7c7c7; $dropdown-input-focus-border: rgb(58, 171, 240); $dropdown-input-focus-shadow: rgba(#000, .2); $dropdown-loading-bg: rgba(#fff, .6); $dropdown-toggle-bg: #fff; $dropdown-toggle-color: #626262; -$dropdown-toggle-border-color: #EAEAEA; +$dropdown-toggle-border-color: #eaeaea; $dropdown-toggle-hover-border-color: darken($dropdown-toggle-border-color, 15%); -$dropdown-toggle-icon-color: #C4C4C4; +$dropdown-toggle-icon-color: #c4c4c4; $dropdown-toggle-hover-icon-color: $dropdown-toggle-hover-border-color; /* * Award emoji */ -$award-emoji-menu-bg: #FFF; -$award-emoji-menu-border: #F1F2F4; -$award-emoji-new-btn-icon-color: #DCDCDC; +$award-emoji-menu-bg: #fff; +$award-emoji-menu-border: #f1f2f4; +$award-emoji-new-btn-icon-color: #dcdcdc; diff --git a/app/assets/stylesheets/framework/zen.scss b/app/assets/stylesheets/framework/zen.scss index c3f27333fa..02e24ec7c4 100644 --- a/app/assets/stylesheets/framework/zen.scss +++ b/app/assets/stylesheets/framework/zen.scss @@ -2,7 +2,7 @@ a.js-zen-enter { color: $gl-gray; position: absolute; - top: 0px; + top: 0; right: 4px; line-height: 56px; } diff --git a/app/assets/stylesheets/highlight/dark.scss b/app/assets/stylesheets/highlight/dark.scss index b794da2ce9..4767394489 100644 --- a/app/assets/stylesheets/highlight/dark.scss +++ b/app/assets/stylesheets/highlight/dark.scss @@ -43,12 +43,12 @@ // Search result highlight span.highlight_word { background-color: #ffe792 !important; - color: #000000 !important; + color: #000 !important; } .hll { background-color: #373b41 } .c { color: #969896 } /* Comment */ - .err { color: #cc6666 } /* Error */ + .err { color: #c66 } /* Error */ .k { color: #b294bb } /* Keyword */ .l { color: #de935f } /* Literal */ .n { color: #c5c8c6 } /* Name */ @@ -58,7 +58,7 @@ .cp { color: #969896 } /* Comment.Preproc */ .c1 { color: #969896 } /* Comment.Single */ .cs { color: #969896 } /* Comment.Special */ - .gd { color: #cc6666 } /* Generic.Deleted */ + .gd { color: #c66 } /* Generic.Deleted */ .ge { font-style: italic } /* Generic.Emph */ .gh { color: #c5c8c6; font-weight: bold } /* Generic.Heading */ .gi { color: #b5bd68 } /* Generic.Inserted */ @@ -77,17 +77,17 @@ .na { color: #81a2be } /* Name.Attribute */ .nb { color: #c5c8c6 } /* Name.Builtin */ .nc { color: #f0c674 } /* Name.Class */ - .no { color: #cc6666 } /* Name.Constant */ + .no { color: #c66 } /* Name.Constant */ .nd { color: #8abeb7 } /* Name.Decorator */ .ni { color: #c5c8c6 } /* Name.Entity */ - .ne { color: #cc6666 } /* Name.Exception */ + .ne { color: #c66 } /* Name.Exception */ .nf { color: #81a2be } /* Name.Function */ .nl { color: #c5c8c6 } /* Name.Label */ .nn { color: #f0c674 } /* Name.Namespace */ .nx { color: #81a2be } /* Name.Other */ .py { color: #c5c8c6 } /* Name.Property */ .nt { color: #8abeb7 } /* Name.Tag */ - .nv { color: #cc6666 } /* Name.Variable */ + .nv { color: #c66 } /* Name.Variable */ .ow { color: #8abeb7 } /* Operator.Word */ .w { color: #c5c8c6 } /* Text.Whitespace */ .mf { color: #de935f } /* Literal.Number.Float */ @@ -106,8 +106,8 @@ .s1 { color: #b5bd68 } /* Literal.String.Single */ .ss { color: #b5bd68 } /* Literal.String.Symbol */ .bp { color: #c5c8c6 } /* Name.Builtin.Pseudo */ - .vc { color: #cc6666 } /* Name.Variable.Class */ - .vg { color: #cc6666 } /* Name.Variable.Global */ - .vi { color: #cc6666 } /* Name.Variable.Instance */ + .vc { color: #c66 } /* Name.Variable.Class */ + .vg { color: #c66 } /* Name.Variable.Global */ + .vi { color: #c66 } /* Name.Variable.Instance */ .il { color: #de935f } /* Literal.Number.Integer.Long */ } diff --git a/app/assets/stylesheets/highlight/monokai.scss b/app/assets/stylesheets/highlight/monokai.scss index 9098e07adc..806401c21a 100644 --- a/app/assets/stylesheets/highlight/monokai.scss +++ b/app/assets/stylesheets/highlight/monokai.scss @@ -43,7 +43,7 @@ // Search result highlight span.highlight_word { background-color: #ffe792 !important; - color: #000000 !important; + color: #000 !important; } .hll { background-color: #49483e } diff --git a/app/assets/stylesheets/highlight/solarized_dark.scss b/app/assets/stylesheets/highlight/solarized_dark.scss index 8b1a2824f7..6a809d4dfd 100644 --- a/app/assets/stylesheets/highlight/solarized_dark.scss +++ b/app/assets/stylesheets/highlight/solarized_dark.scss @@ -96,7 +96,7 @@ .m { color: #2aa198 } /* Literal.Number */ .s { color: #2aa198 } /* Literal.String */ .na { color: #93a1a1 } /* Name.Attribute */ - .nb { color: #B58900 } /* Name.Builtin */ + .nb { color: #b58900 } /* Name.Builtin */ .nc { color: #268bd2 } /* Name.Class */ .no { color: #cb4b16 } /* Name.Constant */ .nd { color: #268bd2 } /* Name.Decorator */ diff --git a/app/assets/stylesheets/highlight/solarized_light.scss b/app/assets/stylesheets/highlight/solarized_light.scss index 7ad89dd2c7..b90c95c62d 100644 --- a/app/assets/stylesheets/highlight/solarized_light.scss +++ b/app/assets/stylesheets/highlight/solarized_light.scss @@ -96,7 +96,7 @@ .m { color: #2aa198 } /* Literal.Number */ .s { color: #2aa198 } /* Literal.String */ .na { color: #586e75 } /* Name.Attribute */ - .nb { color: #B58900 } /* Name.Builtin */ + .nb { color: #b58900 } /* Name.Builtin */ .nc { color: #268bd2 } /* Name.Class */ .no { color: #cb4b16 } /* Name.Constant */ .nd { color: #268bd2 } /* Name.Decorator */ diff --git a/app/assets/stylesheets/highlight/white.scss b/app/assets/stylesheets/highlight/white.scss index 8a091028a6..8c1b0cd84e 100644 --- a/app/assets/stylesheets/highlight/white.scss +++ b/app/assets/stylesheets/highlight/white.scss @@ -23,7 +23,7 @@ .line_holder { .diff-line-num { &.old { - background: #ffdddd; + background: #fdd; border-color: #f1c0c0; } @@ -68,66 +68,66 @@ } .hll { background-color: #f8f8f8 } - .c { color: #999988; font-style: italic; } + .c { color: #998; font-style: italic; } .err { color: #a61717; background-color: #e3d2d2; } .k { font-weight: bold; } .o { font-weight: bold; } - .cm { color: #999988; font-style: italic; } - .cp { color: #999999; font-weight: bold; } - .c1 { color: #999988; font-style: italic; } - .cs { color: #999999; font-weight: bold; font-style: italic; } - .gd { color: #000000; background-color: #ffdddd; } - .gd .x { color: #000000; background-color: #ffaaaa; } + .cm { color: #998; font-style: italic; } + .cp { color: #999; font-weight: bold; } + .c1 { color: #998; font-style: italic; } + .cs { color: #999; font-weight: bold; font-style: italic; } + .gd { color: #000; background-color: #fdd; } + .gd .x { color: #000; background-color: #faa; } .ge { font-style: italic; } - .gr { color: #aa0000; } - .gh { color: #999999; } - .gi { color: #000000; background-color: #ddffdd; } - .gi .x { color: #000000; background-color: #aaffaa; } - .go { color: #888888; } - .gp { color: #555555; } + .gr { color: #a00; } + .gh { color: #999; } + .gi { color: #000; background-color: #dfd; } + .gi .x { color: #000; background-color: #afa; } + .go { color: #888; } + .gp { color: #555; } .gs { font-weight: bold; } .gu { color: #800080; font-weight: bold; } - .gt { color: #aa0000; } + .gt { color: #a00; } .kc { font-weight: bold; } .kd { font-weight: bold; } .kn { font-weight: bold; } .kp { font-weight: bold; } .kr { font-weight: bold; } - .kt { color: #445588; font-weight: bold; } - .m { color: #009999; } - .s { color: #dd1144; } - .n { color: #333333; } + .kt { color: #458; font-weight: bold; } + .m { color: #099; } + .s { color: #d14; } + .n { color: #333; } .na { color: teal; } .nb { color: #0086b3; } - .nc { color: #445588; font-weight: bold; } + .nc { color: #458; font-weight: bold; } .no { color: teal; } .ni { color: purple; } - .ne { color: #990000; font-weight: bold; } - .nf { color: #990000; font-weight: bold; } - .nn { color: #555555; } + .ne { color: #900; font-weight: bold; } + .nf { color: #900; font-weight: bold; } + .nn { color: #555; } .nt { color: navy; } .nv { color: teal; } .ow { font-weight: bold; } - .w { color: #bbbbbb; } - .mf { color: #009999; } - .mh { color: #009999; } - .mi { color: #009999; } - .mo { color: #009999; } - .sb { color: #dd1144; } - .sc { color: #dd1144; } - .sd { color: #dd1144; } - .s2 { color: #dd1144; } - .se { color: #dd1144; } - .sh { color: #dd1144; } - .si { color: #dd1144; } - .sx { color: #dd1144; } + .w { color: #bbb; } + .mf { color: #099; } + .mh { color: #099; } + .mi { color: #099; } + .mo { color: #099; } + .sb { color: #d14; } + .sc { color: #d14; } + .sd { color: #d14; } + .s2 { color: #d14; } + .se { color: #d14; } + .sh { color: #d14; } + .si { color: #d14; } + .sx { color: #d14; } .sr { color: #009926; } - .s1 { color: #dd1144; } + .s1 { color: #d14; } .ss { color: #990073; } - .bp { color: #999999; } + .bp { color: #999; } .vc { color: teal; } .vg { color: teal; } .vi { color: teal; } - .il { color: #009999; } - .gc { color: #999; background-color: #EAF2F5; } + .il { color: #099; } + .gc { color: #999; background-color: #eaf2f5; } } diff --git a/app/assets/stylesheets/pages/appearances.scss b/app/assets/stylesheets/pages/appearances.scss index e2070f17c3..878f44116b 100644 --- a/app/assets/stylesheets/pages/appearances.scss +++ b/app/assets/stylesheets/pages/appearances.scss @@ -4,7 +4,7 @@ } .appearance-light-logo-preview { - background-color: $background-color; + background-color: $background-color; max-width: 72px; padding: 10px; margin-bottom: 10px; diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index 75f298019e..201f3e5ca4 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -1,6 +1,6 @@ .build-page { pre.trace { - background: #111111; + background: #111; color: #fff; font-family: $monospace_font; white-space: pre; diff --git a/app/assets/stylesheets/pages/commit.scss b/app/assets/stylesheets/pages/commit.scss index c0cc30d33a..971656feb4 100644 --- a/app/assets/stylesheets/pages/commit.scss +++ b/app/assets/stylesheets/pages/commit.scss @@ -55,7 +55,7 @@ padding: 10px 0; li { - padding: 3px 0px; + padding: 3px 0; line-height: 20px; } } diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 818fd03e2a..33b3c7558e 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -9,7 +9,7 @@ .lists-separator { margin: 10px 0; - border-color: #DDD; + border-color: #ddd; } .commits-row { @@ -55,7 +55,7 @@ li.commit { } .commit-row-message { - color: $gl-link-color; + color: $gl-dark-link-color; &:hover { text-decoration: underline; @@ -76,7 +76,7 @@ li.commit { .commit-row-description { font-size: 14px; - border-left: 1px solid #EEE; + border-left: 1px solid #eee; padding: 10px 15px; margin: 5px 0 10px 5px; background: #f9f9f9; @@ -152,7 +152,7 @@ li.commit { .count { padding-top: 6px; - padding-bottom: 0px; + padding-bottom: 0; font-size: 12px; color: #333; display: block; diff --git a/app/assets/stylesheets/pages/dashboard.scss b/app/assets/stylesheets/pages/dashboard.scss index 8863939914..cf7567513e 100644 --- a/app/assets/stylesheets/pages/dashboard.scss +++ b/app/assets/stylesheets/pages/dashboard.scss @@ -11,15 +11,15 @@ } .dashboard-search-filter { - padding:5px; + padding: 5px; .search-text-input { - float:left; + float: left; @extend .col-md-2; } .btn { margin-left: 5px; - float:left; + float: left; } } diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index a7925e7954..d5862a11ac 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -1,7 +1,7 @@ // Common .diff-file { border: 1px solid $border-color; - border-top: none; + margin-bottom: $gl-padding; .diff-header { position: relative; @@ -29,7 +29,7 @@ .diff-content { overflow: auto; overflow-y: hidden; - background: #FFF; + background: #fff; color: #333; .unfold { @@ -57,8 +57,8 @@ font-family: $monospace_font; border: none; border-collapse: separate; - margin: 0px; - padding: 0px; + margin: 0; + padding: 0; .line_holder td { line-height: $code_line_height; font-size: $code_font_size; @@ -76,10 +76,10 @@ } .old_line, .new_line { - margin: 0px; - padding: 0px; + margin: 0; + padding: 0; border: none; - padding: 0px 5px; + padding: 0 5px; border-right: 1px solid; text-align: right; min-width: 35px; @@ -97,8 +97,8 @@ } .line_content { display: block; - margin: 0px; - padding: 0px 0.5em; + margin: 0; + padding: 0 0.5em; border: none; &.parallel { display: table-cell; @@ -118,7 +118,7 @@ background-color: #fff; line-height: 0; img { - border: 1px solid #FFF; + border: 1px solid #fff; background: image-url('trans_bg.gif'); max-width: 100%; } @@ -183,7 +183,7 @@ height: 14px; width: 15px; position: absolute; - top: 0px; + top: 0; background: image-url('swipemode_sprites.gif') 0 3px no-repeat; } .bottom-handle { @@ -191,7 +191,7 @@ height: 14px; width: 15px; position: absolute; - bottom: 0px; + bottom: 0; background: image-url('swipemode_sprites.gif') 0 -11px no-repeat; } } @@ -206,8 +206,8 @@ .frame.added, .frame.deleted { position: absolute; display: block; - top: 0px; - left: 0px; + top: 0; + left: 0; } .controls { display: block; @@ -215,7 +215,7 @@ width: 300px; z-index: 100; position: absolute; - bottom: 0px; + bottom: 0; left: 50%; margin-left: -150px; @@ -231,11 +231,11 @@ .dragger { display: block; position: absolute; - left: 0px; - top: 0px; + left: 0; + top: 0; height: 14px; width: 14px; - background: image-url('onion_skin_sprites.gif') 0px -34px repeat-x; + background: image-url('onion_skin_sprites.gif') 0 -34px repeat-x; cursor: pointer; } @@ -243,17 +243,17 @@ display: block; position: absolute; top: 2px; - right: 0px; + right: 0; height: 10px; width: 10px; - background: image-url('onion_skin_sprites.gif') -2px 0px no-repeat; + background: image-url('onion_skin_sprites.gif') -2px 0 no-repeat; } .opaque { display: block; position: absolute; top: 2px; - left: 0px; + left: 0; height: 10px; width: 10px; background: image-url('onion_skin_sprites.gif') -2px -10px no-repeat; @@ -265,7 +265,7 @@ .view-modes { padding: 10px; text-align: center; - background: #EEE; + background: #eee; ul, li { list-style: none; @@ -361,3 +361,11 @@ border-color: $border; } } + +.files { + margin-top: -1px; + + .diff-file:last-child { + margin-bottom: 0; + } +} diff --git a/app/assets/stylesheets/pages/editor.scss b/app/assets/stylesheets/pages/editor.scss index 39d916cd33..43be5e38ba 100644 --- a/app/assets/stylesheets/pages/editor.scss +++ b/app/assets/stylesheets/pages/editor.scss @@ -14,9 +14,9 @@ } .cancel-btn { - color: #B94A48; + color: #b94a48; &:hover { - color: #B94A48; + color: #b94a48; } } diff --git a/app/assets/stylesheets/pages/emojis.scss b/app/assets/stylesheets/pages/emojis.scss index 6c721b514f..b731abc745 100644 --- a/app/assets/stylesheets/pages/emojis.scss +++ b/app/assets/stylesheets/pages/emojis.scss @@ -1,60 +1,60 @@ -.emoji-0023-20E3 { background-position: 0px 0px; } -.emoji-002A-20E3 { background-position: -20px 0px; } -.emoji-0030-20E3 { background-position: 0px -20px; } +.emoji-0023-20E3 { background-position: 0 0; } +.emoji-002A-20E3 { background-position: -20px 0; } +.emoji-0030-20E3 { background-position: 0 -20px; } .emoji-0031-20E3 { background-position: -20px -20px; } -.emoji-0032-20E3 { background-position: -40px 0px; } +.emoji-0032-20E3 { background-position: -40px 0; } .emoji-0033-20E3 { background-position: -40px -20px; } -.emoji-0034-20E3 { background-position: 0px -40px; } +.emoji-0034-20E3 { background-position: 0 -40px; } .emoji-0035-20E3 { background-position: -20px -40px; } .emoji-0036-20E3 { background-position: -40px -40px; } -.emoji-0037-20E3 { background-position: -60px 0px; } +.emoji-0037-20E3 { background-position: -60px 0; } .emoji-0038-20E3 { background-position: -60px -20px; } .emoji-0039-20E3 { background-position: -60px -40px; } -.emoji-00A9 { background-position: 0px -60px; } +.emoji-00A9 { background-position: 0 -60px; } .emoji-00AE { background-position: -20px -60px; } .emoji-1F004 { background-position: -40px -60px; } .emoji-1F0CF { background-position: -60px -60px; } -.emoji-1F170 { background-position: -80px 0px; } +.emoji-1F170 { background-position: -80px 0; } .emoji-1F171 { background-position: -80px -20px; } .emoji-1F17E { background-position: -80px -40px; } .emoji-1F17F { background-position: -80px -60px; } -.emoji-1F18E { background-position: 0px -80px; } +.emoji-1F18E { background-position: 0 -80px; } .emoji-1F191 { background-position: -20px -80px; } .emoji-1F192 { background-position: -40px -80px; } .emoji-1F193 { background-position: -60px -80px; } .emoji-1F194 { background-position: -80px -80px; } -.emoji-1F195 { background-position: -100px 0px; } +.emoji-1F195 { background-position: -100px 0; } .emoji-1F196 { background-position: -100px -20px; } .emoji-1F197 { background-position: -100px -40px; } .emoji-1F198 { background-position: -100px -60px; } .emoji-1F199 { background-position: -100px -80px; } -.emoji-1F19A { background-position: 0px -100px; } +.emoji-1F19A { background-position: 0 -100px; } .emoji-1F1E6-1F1E8 { background-position: -20px -100px; } .emoji-1F1E6-1F1E9 { background-position: -40px -100px; } .emoji-1F1E6-1F1EA { background-position: -60px -100px; } .emoji-1F1E6-1F1EB { background-position: -80px -100px; } .emoji-1F1E6-1F1EC { background-position: -100px -100px; } -.emoji-1F1E6-1F1EE { background-position: -120px 0px; } +.emoji-1F1E6-1F1EE { background-position: -120px 0; } .emoji-1F1E6-1F1F1 { background-position: -120px -20px; } .emoji-1F1E6-1F1F2 { background-position: -120px -40px; } .emoji-1F1E6-1F1F4 { background-position: -120px -60px; } .emoji-1F1E6-1F1F6 { background-position: -120px -80px; } .emoji-1F1E6-1F1F7 { background-position: -120px -100px; } -.emoji-1F1E6-1F1F8 { background-position: 0px -120px; } +.emoji-1F1E6-1F1F8 { background-position: 0 -120px; } .emoji-1F1E6-1F1F9 { background-position: -20px -120px; } .emoji-1F1E6-1F1FA { background-position: -40px -120px; } .emoji-1F1E6-1F1FC { background-position: -60px -120px; } .emoji-1F1E6-1F1FD { background-position: -80px -120px; } .emoji-1F1E6-1F1FF { background-position: -100px -120px; } .emoji-1F1E7-1F1E6 { background-position: -120px -120px; } -.emoji-1F1E7-1F1E7 { background-position: -140px 0px; } +.emoji-1F1E7-1F1E7 { background-position: -140px 0; } .emoji-1F1E7-1F1E9 { background-position: -140px -20px; } .emoji-1F1E7-1F1EA { background-position: -140px -40px; } .emoji-1F1E7-1F1EB { background-position: -140px -60px; } .emoji-1F1E7-1F1EC { background-position: -140px -80px; } .emoji-1F1E7-1F1ED { background-position: -140px -100px; } .emoji-1F1E7-1F1EE { background-position: -140px -120px; } -.emoji-1F1E7-1F1EF { background-position: 0px -140px; } +.emoji-1F1E7-1F1EF { background-position: 0 -140px; } .emoji-1F1E7-1F1F1 { background-position: -20px -140px; } .emoji-1F1E7-1F1F2 { background-position: -40px -140px; } .emoji-1F1E7-1F1F3 { background-position: -60px -140px; } @@ -62,7 +62,7 @@ .emoji-1F1E7-1F1F6 { background-position: -100px -140px; } .emoji-1F1E7-1F1F7 { background-position: -120px -140px; } .emoji-1F1E7-1F1F8 { background-position: -140px -140px; } -.emoji-1F1E7-1F1F9 { background-position: -160px 0px; } +.emoji-1F1E7-1F1F9 { background-position: -160px 0; } .emoji-1F1E7-1F1FB { background-position: -160px -20px; } .emoji-1F1E7-1F1FC { background-position: -160px -40px; } .emoji-1F1E7-1F1FE { background-position: -160px -60px; } @@ -70,7 +70,7 @@ .emoji-1F1E8-1F1E6 { background-position: -160px -100px; } .emoji-1F1E8-1F1E8 { background-position: -160px -120px; } .emoji-1F1E8-1F1E9 { background-position: -160px -140px; } -.emoji-1F1E8-1F1EB { background-position: 0px -160px; } +.emoji-1F1E8-1F1EB { background-position: 0 -160px; } .emoji-1F1E8-1F1EC { background-position: -20px -160px; } .emoji-1F1E8-1F1ED { background-position: -40px -160px; } .emoji-1F1E8-1F1EE { background-position: -60px -160px; } @@ -79,7 +79,7 @@ .emoji-1F1E8-1F1F2 { background-position: -120px -160px; } .emoji-1F1E8-1F1F3 { background-position: -140px -160px; } .emoji-1F1E8-1F1F4 { background-position: -160px -160px; } -.emoji-1F1E8-1F1F5 { background-position: -180px 0px; } +.emoji-1F1E8-1F1F5 { background-position: -180px 0; } .emoji-1F1E8-1F1F7 { background-position: -180px -20px; } .emoji-1F1E8-1F1FA { background-position: -180px -40px; } .emoji-1F1E8-1F1FB { background-position: -180px -60px; } @@ -88,7 +88,7 @@ .emoji-1F1E8-1F1FE { background-position: -180px -120px; } .emoji-1F1E8-1F1FF { background-position: -180px -140px; } .emoji-1F1E9-1F1EA { background-position: -180px -160px; } -.emoji-1F1E9-1F1EC { background-position: 0px -180px; } +.emoji-1F1E9-1F1EC { background-position: 0 -180px; } .emoji-1F1E9-1F1EF { background-position: -20px -180px; } .emoji-1F1E9-1F1F0 { background-position: -40px -180px; } .emoji-1F1E9-1F1F2 { background-position: -60px -180px; } @@ -98,7 +98,7 @@ .emoji-1F1EA-1F1E8 { background-position: -140px -180px; } .emoji-1F1EA-1F1EA { background-position: -160px -180px; } .emoji-1F1EA-1F1EC { background-position: -180px -180px; } -.emoji-1F1EA-1F1ED { background-position: -200px 0px; } +.emoji-1F1EA-1F1ED { background-position: -200px 0; } .emoji-1F1EA-1F1F7 { background-position: -200px -20px; } .emoji-1F1EA-1F1F8 { background-position: -200px -40px; } .emoji-1F1EA-1F1F9 { background-position: -200px -60px; } @@ -108,7 +108,7 @@ .emoji-1F1EB-1F1F0 { background-position: -200px -140px; } .emoji-1F1EB-1F1F2 { background-position: -200px -160px; } .emoji-1F1EB-1F1F4 { background-position: -200px -180px; } -.emoji-1F1EB-1F1F7 { background-position: 0px -200px; } +.emoji-1F1EB-1F1F7 { background-position: 0 -200px; } .emoji-1F1EC-1F1E6 { background-position: -20px -200px; } .emoji-1F1EC-1F1E7 { background-position: -40px -200px; } .emoji-1F1EC-1F1E9 { background-position: -60px -200px; } @@ -119,7 +119,7 @@ .emoji-1F1EC-1F1EE { background-position: -160px -200px; } .emoji-1F1EC-1F1F1 { background-position: -180px -200px; } .emoji-1F1EC-1F1F2 { background-position: -200px -200px; } -.emoji-1F1EC-1F1F3 { background-position: -220px 0px; } +.emoji-1F1EC-1F1F3 { background-position: -220px 0; } .emoji-1F1EC-1F1F5 { background-position: -220px -20px; } .emoji-1F1EC-1F1F6 { background-position: -220px -40px; } .emoji-1F1EC-1F1F7 { background-position: -220px -60px; } @@ -130,7 +130,7 @@ .emoji-1F1EC-1F1FE { background-position: -220px -160px; } .emoji-1F1ED-1F1F0 { background-position: -220px -180px; } .emoji-1F1ED-1F1F2 { background-position: -220px -200px; } -.emoji-1F1ED-1F1F3 { background-position: 0px -220px; } +.emoji-1F1ED-1F1F3 { background-position: 0 -220px; } .emoji-1F1ED-1F1F7 { background-position: -20px -220px; } .emoji-1F1ED-1F1F9 { background-position: -40px -220px; } .emoji-1F1ED-1F1FA { background-position: -60px -220px; } @@ -142,7 +142,7 @@ .emoji-1F1EE-1F1F3 { background-position: -180px -220px; } .emoji-1F1EE-1F1F4 { background-position: -200px -220px; } .emoji-1F1EE-1F1F6 { background-position: -220px -220px; } -.emoji-1F1EE-1F1F7 { background-position: -240px 0px; } +.emoji-1F1EE-1F1F7 { background-position: -240px 0; } .emoji-1F1EE-1F1F8 { background-position: -240px -20px; } .emoji-1F1EE-1F1F9 { background-position: -240px -40px; } .emoji-1F1EF-1F1EA { background-position: -240px -60px; } @@ -154,7 +154,7 @@ .emoji-1F1F0-1F1ED { background-position: -240px -180px; } .emoji-1F1F0-1F1EE { background-position: -240px -200px; } .emoji-1F1F0-1F1F2 { background-position: -240px -220px; } -.emoji-1F1F0-1F1F3 { background-position: 0px -240px; } +.emoji-1F1F0-1F1F3 { background-position: 0 -240px; } .emoji-1F1F0-1F1F5 { background-position: -20px -240px; } .emoji-1F1F0-1F1F7 { background-position: -40px -240px; } .emoji-1F1F0-1F1FC { background-position: -60px -240px; } @@ -167,7 +167,7 @@ .emoji-1F1F1-1F1F0 { background-position: -200px -240px; } .emoji-1F1F1-1F1F7 { background-position: -220px -240px; } .emoji-1F1F1-1F1F8 { background-position: -240px -240px; } -.emoji-1F1F1-1F1F9 { background-position: -260px 0px; } +.emoji-1F1F1-1F1F9 { background-position: -260px 0; } .emoji-1F1F1-1F1FA { background-position: -260px -20px; } .emoji-1F1F1-1F1FB { background-position: -260px -40px; } .emoji-1F1F1-1F1FE { background-position: -260px -60px; } @@ -180,7 +180,7 @@ .emoji-1F1F2-1F1ED { background-position: -260px -200px; } .emoji-1F1F2-1F1F0 { background-position: -260px -220px; } .emoji-1F1F2-1F1F1 { background-position: -260px -240px; } -.emoji-1F1F2-1F1F2 { background-position: 0px -260px; } +.emoji-1F1F2-1F1F2 { background-position: 0 -260px; } .emoji-1F1F2-1F1F3 { background-position: -20px -260px; } .emoji-1F1F2-1F1F4 { background-position: -40px -260px; } .emoji-1F1F2-1F1F5 { background-position: -60px -260px; } @@ -194,7 +194,7 @@ .emoji-1F1F2-1F1FD { background-position: -220px -260px; } .emoji-1F1F2-1F1FE { background-position: -240px -260px; } .emoji-1F1F2-1F1FF { background-position: -260px -260px; } -.emoji-1F1F3-1F1E6 { background-position: -280px 0px; } +.emoji-1F1F3-1F1E6 { background-position: -280px 0; } .emoji-1F1F3-1F1E8 { background-position: -280px -20px; } .emoji-1F1F3-1F1EA { background-position: -280px -40px; } .emoji-1F1F3-1F1EB { background-position: -280px -60px; } @@ -208,7 +208,7 @@ .emoji-1F1F3-1F1FF { background-position: -280px -220px; } .emoji-1F1F4-1F1F2 { background-position: -280px -240px; } .emoji-1F1F5-1F1E6 { background-position: -280px -260px; } -.emoji-1F1F5-1F1EA { background-position: 0px -280px; } +.emoji-1F1F5-1F1EA { background-position: 0 -280px; } .emoji-1F1F5-1F1EB { background-position: -20px -280px; } .emoji-1F1F5-1F1EC { background-position: -40px -280px; } .emoji-1F1F5-1F1ED { background-position: -60px -280px; } @@ -223,7 +223,7 @@ .emoji-1F1F5-1F1FE { background-position: -240px -280px; } .emoji-1F1F6-1F1E6 { background-position: -260px -280px; } .emoji-1F1F7-1F1EA { background-position: -280px -280px; } -.emoji-1F1F7-1F1F4 { background-position: -300px 0px; } +.emoji-1F1F7-1F1F4 { background-position: -300px 0; } .emoji-1F1F7-1F1F8 { background-position: -300px -20px; } .emoji-1F1F7-1F1FA { background-position: -300px -40px; } .emoji-1F1F7-1F1FC { background-position: -300px -60px; } @@ -238,7 +238,7 @@ .emoji-1F1F8-1F1EF { background-position: -300px -240px; } .emoji-1F1F8-1F1F0 { background-position: -300px -260px; } .emoji-1F1F8-1F1F1 { background-position: -300px -280px; } -.emoji-1F1F8-1F1F2 { background-position: 0px -300px; } +.emoji-1F1F8-1F1F2 { background-position: 0 -300px; } .emoji-1F1F8-1F1F3 { background-position: -20px -300px; } .emoji-1F1F8-1F1F4 { background-position: -40px -300px; } .emoji-1F1F8-1F1F7 { background-position: -60px -300px; } @@ -254,7 +254,7 @@ .emoji-1F1F9-1F1EB { background-position: -260px -300px; } .emoji-1F1F9-1F1EC { background-position: -280px -300px; } .emoji-1F1F9-1F1ED { background-position: -300px -300px; } -.emoji-1F1F9-1F1EF { background-position: -320px 0px; } +.emoji-1F1F9-1F1EF { background-position: -320px 0; } .emoji-1F1F9-1F1F0 { background-position: -320px -20px; } .emoji-1F1F9-1F1F1 { background-position: -320px -40px; } .emoji-1F1F9-1F1F2 { background-position: -320px -60px; } @@ -270,7 +270,7 @@ .emoji-1F1FA-1F1F2 { background-position: -320px -260px; } .emoji-1F1FA-1F1F8 { background-position: -320px -280px; } .emoji-1F1FA-1F1FE { background-position: -320px -300px; } -.emoji-1F1FA-1F1FF { background-position: 0px -320px; } +.emoji-1F1FA-1F1FF { background-position: 0 -320px; } .emoji-1F1FB-1F1E6 { background-position: -20px -320px; } .emoji-1F1FB-1F1E8 { background-position: -40px -320px; } .emoji-1F1FB-1F1EA { background-position: -60px -320px; } @@ -287,7 +287,7 @@ .emoji-1F1FF-1F1F2 { background-position: -280px -320px; } .emoji-1F1FF-1F1FC { background-position: -300px -320px; } .emoji-1F201 { background-position: -320px -320px; } -.emoji-1F202 { background-position: -340px 0px; } +.emoji-1F202 { background-position: -340px 0; } .emoji-1F21A { background-position: -340px -20px; } .emoji-1F22F { background-position: -340px -40px; } .emoji-1F232 { background-position: -340px -60px; } @@ -304,7 +304,7 @@ .emoji-1F300 { background-position: -340px -280px; } .emoji-1F301 { background-position: -340px -300px; } .emoji-1F302 { background-position: -340px -320px; } -.emoji-1F303 { background-position: 0px -340px; } +.emoji-1F303 { background-position: 0 -340px; } .emoji-1F304 { background-position: -20px -340px; } .emoji-1F305 { background-position: -40px -340px; } .emoji-1F306 { background-position: -60px -340px; } @@ -322,7 +322,7 @@ .emoji-1F312 { background-position: -300px -340px; } .emoji-1F313 { background-position: -320px -340px; } .emoji-1F314 { background-position: -340px -340px; } -.emoji-1F315 { background-position: -360px 0px; } +.emoji-1F315 { background-position: -360px 0; } .emoji-1F316 { background-position: -360px -20px; } .emoji-1F317 { background-position: -360px -40px; } .emoji-1F318 { background-position: -360px -60px; } @@ -340,7 +340,7 @@ .emoji-1F326 { background-position: -360px -300px; } .emoji-1F327 { background-position: -360px -320px; } .emoji-1F328 { background-position: -360px -340px; } -.emoji-1F329 { background-position: 0px -360px; } +.emoji-1F329 { background-position: 0 -360px; } .emoji-1F32A { background-position: -20px -360px; } .emoji-1F32B { background-position: -40px -360px; } .emoji-1F32C { background-position: -60px -360px; } @@ -359,7 +359,7 @@ .emoji-1F339 { background-position: -320px -360px; } .emoji-1F33A { background-position: -340px -360px; } .emoji-1F33B { background-position: -360px -360px; } -.emoji-1F33C { background-position: -380px 0px; } +.emoji-1F33C { background-position: -380px 0; } .emoji-1F33D { background-position: -380px -20px; } .emoji-1F33E { background-position: -380px -40px; } .emoji-1F33F { background-position: -380px -60px; } @@ -378,7 +378,7 @@ .emoji-1F34C { background-position: -380px -320px; } .emoji-1F34D { background-position: -380px -340px; } .emoji-1F34E { background-position: -380px -360px; } -.emoji-1F34F { background-position: 0px -380px; } +.emoji-1F34F { background-position: 0 -380px; } .emoji-1F350 { background-position: -20px -380px; } .emoji-1F351 { background-position: -40px -380px; } .emoji-1F352 { background-position: -60px -380px; } @@ -398,7 +398,7 @@ .emoji-1F360 { background-position: -340px -380px; } .emoji-1F361 { background-position: -360px -380px; } .emoji-1F362 { background-position: -380px -380px; } -.emoji-1F363 { background-position: -400px 0px; } +.emoji-1F363 { background-position: -400px 0; } .emoji-1F364 { background-position: -400px -20px; } .emoji-1F365 { background-position: -400px -40px; } .emoji-1F366 { background-position: -400px -60px; } @@ -418,7 +418,7 @@ .emoji-1F374 { background-position: -400px -340px; } .emoji-1F375 { background-position: -400px -360px; } .emoji-1F376 { background-position: -400px -380px; } -.emoji-1F377 { background-position: 0px -400px; } +.emoji-1F377 { background-position: 0 -400px; } .emoji-1F378 { background-position: -20px -400px; } .emoji-1F379 { background-position: -40px -400px; } .emoji-1F37A { background-position: -60px -400px; } @@ -439,7 +439,7 @@ .emoji-1F385-1F3FE { background-position: -360px -400px; } .emoji-1F385-1F3FF { background-position: -380px -400px; } .emoji-1F386 { background-position: -400px -400px; } -.emoji-1F387 { background-position: -420px 0px; } +.emoji-1F387 { background-position: -420px 0; } .emoji-1F388 { background-position: -420px -20px; } .emoji-1F389 { background-position: -420px -40px; } .emoji-1F38A { background-position: -420px -60px; } @@ -460,7 +460,7 @@ .emoji-1F399 { background-position: -420px -360px; } .emoji-1F39A { background-position: -420px -380px; } .emoji-1F39B { background-position: -420px -400px; } -.emoji-1F39C { background-position: 0px -420px; } +.emoji-1F39C { background-position: 0 -420px; } .emoji-1F39D { background-position: -20px -420px; } .emoji-1F39E { background-position: -40px -420px; } .emoji-1F39F { background-position: -60px -420px; } @@ -482,7 +482,7 @@ .emoji-1F3AF { background-position: -380px -420px; } .emoji-1F3B0 { background-position: -400px -420px; } .emoji-1F3B1 { background-position: -420px -420px; } -.emoji-1F3B2 { background-position: -440px 0px; } +.emoji-1F3B2 { background-position: -440px 0; } .emoji-1F3B3 { background-position: -440px -20px; } .emoji-1F3B4 { background-position: -440px -40px; } .emoji-1F3B5 { background-position: -440px -60px; } @@ -504,7 +504,7 @@ .emoji-1F3C3-1F3FC { background-position: -440px -380px; } .emoji-1F3C3-1F3FD { background-position: -440px -400px; } .emoji-1F3C3-1F3FE { background-position: -440px -420px; } -.emoji-1F3C3-1F3FF { background-position: 0px -440px; } +.emoji-1F3C3-1F3FF { background-position: 0 -440px; } .emoji-1F3C4 { background-position: -20px -440px; } .emoji-1F3C4-1F3FB { background-position: -40px -440px; } .emoji-1F3C4-1F3FC { background-position: -60px -440px; } @@ -527,7 +527,7 @@ .emoji-1F3CA-1F3FD { background-position: -400px -440px; } .emoji-1F3CA-1F3FE { background-position: -420px -440px; } .emoji-1F3CA-1F3FF { background-position: -440px -440px; } -.emoji-1F3CB { background-position: -460px 0px; } +.emoji-1F3CB { background-position: -460px 0; } .emoji-1F3CB-1F3FB { background-position: -460px -20px; } .emoji-1F3CB-1F3FC { background-position: -460px -40px; } .emoji-1F3CB-1F3FD { background-position: -460px -60px; } @@ -550,7 +550,7 @@ .emoji-1F3DA { background-position: -460px -400px; } .emoji-1F3DB { background-position: -460px -420px; } .emoji-1F3DC { background-position: -460px -440px; } -.emoji-1F3DD { background-position: 0px -460px; } +.emoji-1F3DD { background-position: 0 -460px; } .emoji-1F3DE { background-position: -20px -460px; } .emoji-1F3DF { background-position: -40px -460px; } .emoji-1F3E0 { background-position: -60px -460px; } @@ -574,7 +574,7 @@ .emoji-1F3F2 { background-position: -420px -460px; } .emoji-1F3F3 { background-position: -440px -460px; } .emoji-1F3F4 { background-position: -460px -460px; } -.emoji-1F3F5 { background-position: -480px 0px; } +.emoji-1F3F5 { background-position: -480px 0; } .emoji-1F3F6 { background-position: -480px -20px; } .emoji-1F3F7 { background-position: -480px -40px; } .emoji-1F3F8 { background-position: -480px -60px; } @@ -598,7 +598,7 @@ .emoji-1F40A { background-position: -480px -420px; } .emoji-1F40B { background-position: -480px -440px; } .emoji-1F40C { background-position: -480px -460px; } -.emoji-1F40D { background-position: 0px -480px; } +.emoji-1F40D { background-position: 0 -480px; } .emoji-1F40E { background-position: -20px -480px; } .emoji-1F40F { background-position: -40px -480px; } .emoji-1F410 { background-position: -60px -480px; } @@ -623,7 +623,7 @@ .emoji-1F423 { background-position: -440px -480px; } .emoji-1F424 { background-position: -460px -480px; } .emoji-1F425 { background-position: -480px -480px; } -.emoji-1F426 { background-position: -500px 0px; } +.emoji-1F426 { background-position: -500px 0; } .emoji-1F427 { background-position: -500px -20px; } .emoji-1F428 { background-position: -500px -40px; } .emoji-1F429 { background-position: -500px -60px; } @@ -648,7 +648,7 @@ .emoji-1F43C { background-position: -500px -440px; } .emoji-1F43D { background-position: -500px -460px; } .emoji-1F43E { background-position: -500px -480px; } -.emoji-1F43F { background-position: 0px -500px; } +.emoji-1F43F { background-position: 0 -500px; } .emoji-1F440 { background-position: -20px -500px; } .emoji-1F441 { background-position: -40px -500px; } .emoji-1F441-1F5E8 { background-position: -60px -500px; } @@ -674,7 +674,7 @@ .emoji-1F446-1F3FF { background-position: -460px -500px; } .emoji-1F447 { background-position: -480px -500px; } .emoji-1F447-1F3FB { background-position: -500px -500px; } -.emoji-1F447-1F3FC { background-position: -520px 0px; } +.emoji-1F447-1F3FC { background-position: -520px 0; } .emoji-1F447-1F3FD { background-position: -520px -20px; } .emoji-1F447-1F3FE { background-position: -520px -40px; } .emoji-1F447-1F3FF { background-position: -520px -60px; } @@ -700,7 +700,7 @@ .emoji-1F44B-1F3FB { background-position: -520px -460px; } .emoji-1F44B-1F3FC { background-position: -520px -480px; } .emoji-1F44B-1F3FD { background-position: -520px -500px; } -.emoji-1F44B-1F3FE { background-position: 0px -520px; } +.emoji-1F44B-1F3FE { background-position: 0 -520px; } .emoji-1F44B-1F3FF { background-position: -20px -520px; } .emoji-1F44C { background-position: -40px -520px; } .emoji-1F44C-1F3FB { background-position: -60px -520px; } @@ -727,7 +727,7 @@ .emoji-1F44F-1F3FE { background-position: -480px -520px; } .emoji-1F44F-1F3FF { background-position: -500px -520px; } .emoji-1F450 { background-position: -520px -520px; } -.emoji-1F450-1F3FB { background-position: -540px 0px; } +.emoji-1F450-1F3FB { background-position: -540px 0; } .emoji-1F450-1F3FC { background-position: -540px -20px; } .emoji-1F450-1F3FD { background-position: -540px -40px; } .emoji-1F450-1F3FE { background-position: -540px -60px; } @@ -754,7 +754,7 @@ .emoji-1F464 { background-position: -540px -480px; } .emoji-1F465 { background-position: -540px -500px; } .emoji-1F466 { background-position: -540px -520px; } -.emoji-1F466-1F3FB { background-position: 0px -540px; } +.emoji-1F466-1F3FB { background-position: 0 -540px; } .emoji-1F466-1F3FC { background-position: -20px -540px; } .emoji-1F466-1F3FD { background-position: -40px -540px; } .emoji-1F466-1F3FE { background-position: -60px -540px; } @@ -782,7 +782,7 @@ .emoji-1F468-1F469-1F467-1F467 { background-position: -500px -540px; } .emoji-1F468-2764-1F468 { background-position: -520px -540px; } .emoji-1F468-2764-1F48B-1F468 { background-position: -540px -540px; } -.emoji-1F469 { background-position: -560px 0px; } +.emoji-1F469 { background-position: -560px 0; } .emoji-1F469-1F3FB { background-position: -560px -20px; } .emoji-1F469-1F3FC { background-position: -560px -40px; } .emoji-1F469-1F3FD { background-position: -560px -60px; } @@ -810,7 +810,7 @@ .emoji-1F470-1F3FB { background-position: -560px -500px; } .emoji-1F470-1F3FC { background-position: -560px -520px; } .emoji-1F470-1F3FD { background-position: -560px -540px; } -.emoji-1F470-1F3FE { background-position: 0px -560px; } +.emoji-1F470-1F3FE { background-position: 0 -560px; } .emoji-1F470-1F3FF { background-position: -20px -560px; } .emoji-1F471 { background-position: -40px -560px; } .emoji-1F471-1F3FB { background-position: -60px -560px; } @@ -839,7 +839,7 @@ .emoji-1F475 { background-position: -520px -560px; } .emoji-1F475-1F3FB { background-position: -540px -560px; } .emoji-1F475-1F3FC { background-position: -560px -560px; } -.emoji-1F475-1F3FD { background-position: -580px 0px; } +.emoji-1F475-1F3FD { background-position: -580px 0; } .emoji-1F475-1F3FE { background-position: -580px -20px; } .emoji-1F475-1F3FF { background-position: -580px -40px; } .emoji-1F476 { background-position: -580px -60px; } @@ -868,7 +868,7 @@ .emoji-1F47C-1F3FC { background-position: -580px -520px; } .emoji-1F47C-1F3FD { background-position: -580px -540px; } .emoji-1F47C-1F3FE { background-position: -580px -560px; } -.emoji-1F47C-1F3FF { background-position: 0px -580px; } +.emoji-1F47C-1F3FF { background-position: 0 -580px; } .emoji-1F47D { background-position: -20px -580px; } .emoji-1F47E { background-position: -40px -580px; } .emoji-1F47F { background-position: -60px -580px; } @@ -898,7 +898,7 @@ .emoji-1F485-1F3FD { background-position: -540px -580px; } .emoji-1F485-1F3FE { background-position: -560px -580px; } .emoji-1F485-1F3FF { background-position: -580px -580px; } -.emoji-1F486 { background-position: -600px 0px; } +.emoji-1F486 { background-position: -600px 0; } .emoji-1F486-1F3FB { background-position: -600px -20px; } .emoji-1F486-1F3FC { background-position: -600px -40px; } .emoji-1F486-1F3FD { background-position: -600px -60px; } @@ -928,7 +928,7 @@ .emoji-1F497 { background-position: -600px -540px; } .emoji-1F498 { background-position: -600px -560px; } .emoji-1F499 { background-position: -600px -580px; } -.emoji-1F49A { background-position: 0px -600px; } +.emoji-1F49A { background-position: 0 -600px; } .emoji-1F49B { background-position: -20px -600px; } .emoji-1F49C { background-position: -40px -600px; } .emoji-1F49D { background-position: -60px -600px; } @@ -959,7 +959,7 @@ .emoji-1F4B1 { background-position: -560px -600px; } .emoji-1F4B2 { background-position: -580px -600px; } .emoji-1F4B3 { background-position: -600px -600px; } -.emoji-1F4B4 { background-position: -620px 0px; } +.emoji-1F4B4 { background-position: -620px 0; } .emoji-1F4B5 { background-position: -620px -20px; } .emoji-1F4B6 { background-position: -620px -40px; } .emoji-1F4B7 { background-position: -620px -60px; } @@ -990,7 +990,7 @@ .emoji-1F4D0 { background-position: -620px -560px; } .emoji-1F4D1 { background-position: -620px -580px; } .emoji-1F4D2 { background-position: -620px -600px; } -.emoji-1F4D3 { background-position: 0px -620px; } +.emoji-1F4D3 { background-position: 0 -620px; } .emoji-1F4D4 { background-position: -20px -620px; } .emoji-1F4D5 { background-position: -40px -620px; } .emoji-1F4D6 { background-position: -60px -620px; } @@ -1022,7 +1022,7 @@ .emoji-1F4F0 { background-position: -580px -620px; } .emoji-1F4F1 { background-position: -600px -620px; } .emoji-1F4F2 { background-position: -620px -620px; } -.emoji-1F4F3 { background-position: -640px 0px; } +.emoji-1F4F3 { background-position: -640px 0; } .emoji-1F4F4 { background-position: -640px -20px; } .emoji-1F4F5 { background-position: -640px -40px; } .emoji-1F4F6 { background-position: -640px -60px; } @@ -1054,7 +1054,7 @@ .emoji-1F510 { background-position: -640px -580px; } .emoji-1F511 { background-position: -640px -600px; } .emoji-1F512 { background-position: -640px -620px; } -.emoji-1F513 { background-position: 0px -640px; } +.emoji-1F513 { background-position: 0 -640px; } .emoji-1F514 { background-position: -20px -640px; } .emoji-1F515 { background-position: -40px -640px; } .emoji-1F516 { background-position: -60px -640px; } @@ -1087,7 +1087,7 @@ .emoji-1F531 { background-position: -600px -640px; } .emoji-1F532 { background-position: -620px -640px; } .emoji-1F533 { background-position: -640px -640px; } -.emoji-1F534 { background-position: -660px 0px; } +.emoji-1F534 { background-position: -660px 0; } .emoji-1F535 { background-position: -660px -20px; } .emoji-1F536 { background-position: -660px -40px; } .emoji-1F537 { background-position: -660px -60px; } @@ -1120,7 +1120,7 @@ .emoji-1F55B { background-position: -660px -600px; } .emoji-1F55C { background-position: -660px -620px; } .emoji-1F55D { background-position: -660px -640px; } -.emoji-1F55E { background-position: 0px -660px; } +.emoji-1F55E { background-position: 0 -660px; } .emoji-1F55F { background-position: -20px -660px; } .emoji-1F560 { background-position: -40px -660px; } .emoji-1F561 { background-position: -60px -660px; } @@ -1154,7 +1154,7 @@ .emoji-1F578 { background-position: -620px -660px; } .emoji-1F579 { background-position: -640px -660px; } .emoji-1F57B { background-position: -660px -660px; } -.emoji-1F57E { background-position: -680px 0px; } +.emoji-1F57E { background-position: -680px 0; } .emoji-1F57F { background-position: -680px -20px; } .emoji-1F581 { background-position: -680px -40px; } .emoji-1F582 { background-position: -680px -60px; } @@ -1188,7 +1188,7 @@ .emoji-1F595-1F3FF { background-position: -680px -620px; } .emoji-1F596 { background-position: -680px -640px; } .emoji-1F596-1F3FB { background-position: -680px -660px; } -.emoji-1F596-1F3FC { background-position: 0px -680px; } +.emoji-1F596-1F3FC { background-position: 0 -680px; } .emoji-1F596-1F3FD { background-position: -20px -680px; } .emoji-1F596-1F3FE { background-position: -40px -680px; } .emoji-1F596-1F3FF { background-position: -60px -680px; } @@ -1223,7 +1223,7 @@ .emoji-1F5C4 { background-position: -640px -680px; } .emoji-1F5C6 { background-position: -660px -680px; } .emoji-1F5C7 { background-position: -680px -680px; } -.emoji-1F5C9 { background-position: -700px 0px; } +.emoji-1F5C9 { background-position: -700px 0; } .emoji-1F5CA { background-position: -700px -20px; } .emoji-1F5CE { background-position: -700px -40px; } .emoji-1F5CF { background-position: -700px -60px; } @@ -1258,7 +1258,7 @@ .emoji-1F5F8 { background-position: -700px -640px; } .emoji-1F5F9 { background-position: -700px -660px; } .emoji-1F5FA { background-position: -700px -680px; } -.emoji-1F5FB { background-position: 0px -700px; } +.emoji-1F5FB { background-position: 0 -700px; } .emoji-1F5FC { background-position: -20px -700px; } .emoji-1F5FD { background-position: -40px -700px; } .emoji-1F5FE { background-position: -60px -700px; } @@ -1294,7 +1294,7 @@ .emoji-1F61C { background-position: -660px -700px; } .emoji-1F61D { background-position: -680px -700px; } .emoji-1F61E { background-position: -700px -700px; } -.emoji-1F61F { background-position: -720px 0px; } +.emoji-1F61F { background-position: -720px 0; } .emoji-1F620 { background-position: -720px -20px; } .emoji-1F621 { background-position: -720px -40px; } .emoji-1F622 { background-position: -720px -60px; } @@ -1330,7 +1330,7 @@ .emoji-1F640 { background-position: -720px -660px; } .emoji-1F641 { background-position: -720px -680px; } .emoji-1F642 { background-position: -720px -700px; } -.emoji-1F643 { background-position: 0px -720px; } +.emoji-1F643 { background-position: 0 -720px; } .emoji-1F644 { background-position: -20px -720px; } .emoji-1F645 { background-position: -40px -720px; } .emoji-1F645-1F3FB { background-position: -60px -720px; } @@ -1367,7 +1367,7 @@ .emoji-1F64C-1F3FF { background-position: -680px -720px; } .emoji-1F64D { background-position: -700px -720px; } .emoji-1F64D-1F3FB { background-position: -720px -720px; } -.emoji-1F64D-1F3FC { background-position: -740px 0px; } +.emoji-1F64D-1F3FC { background-position: -740px 0; } .emoji-1F64D-1F3FD { background-position: -740px -20px; } .emoji-1F64D-1F3FE { background-position: -740px -40px; } .emoji-1F64D-1F3FF { background-position: -740px -60px; } @@ -1404,7 +1404,7 @@ .emoji-1F692 { background-position: -740px -680px; } .emoji-1F693 { background-position: -740px -700px; } .emoji-1F694 { background-position: -740px -720px; } -.emoji-1F695 { background-position: 0px -740px; } +.emoji-1F695 { background-position: 0 -740px; } .emoji-1F696 { background-position: -20px -740px; } .emoji-1F697 { background-position: -40px -740px; } .emoji-1F698 { background-position: -60px -740px; } @@ -1442,7 +1442,7 @@ .emoji-1F6B3 { background-position: -700px -740px; } .emoji-1F6B4 { background-position: -720px -740px; } .emoji-1F6B4-1F3FB { background-position: -740px -740px; } -.emoji-1F6B4-1F3FC { background-position: -760px 0px; } +.emoji-1F6B4-1F3FC { background-position: -760px 0; } .emoji-1F6B4-1F3FD { background-position: -760px -20px; } .emoji-1F6B4-1F3FE { background-position: -760px -40px; } .emoji-1F6B4-1F3FF { background-position: -760px -60px; } @@ -1480,7 +1480,7 @@ .emoji-1F6C5 { background-position: -760px -700px; } .emoji-1F6C6 { background-position: -760px -720px; } .emoji-1F6C7 { background-position: -760px -740px; } -.emoji-1F6C8 { background-position: 0px -760px; } +.emoji-1F6C8 { background-position: 0 -760px; } .emoji-1F6C9 { background-position: -20px -760px; } .emoji-1F6CA { background-position: -40px -760px; } .emoji-1F6CB { background-position: -60px -760px; } @@ -1519,7 +1519,7 @@ .emoji-1F918-1F3FC { background-position: -720px -760px; } .emoji-1F918-1F3FD { background-position: -740px -760px; } .emoji-1F918-1F3FE { background-position: -760px -760px; } -.emoji-1F918-1F3FF { background-position: -780px 0px; } +.emoji-1F918-1F3FF { background-position: -780px 0; } .emoji-1F980 { background-position: -780px -20px; } .emoji-1F981 { background-position: -780px -40px; } .emoji-1F982 { background-position: -780px -60px; } @@ -1558,7 +1558,7 @@ .emoji-24C2 { background-position: -780px -720px; } .emoji-25AA { background-position: -780px -740px; } .emoji-25AB { background-position: -780px -760px; } -.emoji-25B6 { background-position: 0px -780px; } +.emoji-25B6 { background-position: 0 -780px; } .emoji-25C0 { background-position: -20px -780px; } .emoji-25FB { background-position: -40px -780px; } .emoji-25FC { background-position: -60px -780px; } @@ -1598,7 +1598,7 @@ .emoji-264D { background-position: -740px -780px; } .emoji-264E { background-position: -760px -780px; } .emoji-264F { background-position: -780px -780px; } -.emoji-2650 { background-position: -800px 0px; } +.emoji-2650 { background-position: -800px 0; } .emoji-2651 { background-position: -800px -20px; } .emoji-2652 { background-position: -800px -40px; } .emoji-2653 { background-position: -800px -60px; } @@ -1638,7 +1638,7 @@ .emoji-26F0 { background-position: -800px -740px; } .emoji-26F1 { background-position: -800px -760px; } .emoji-26F2 { background-position: -800px -780px; } -.emoji-26F3 { background-position: 0px -800px; } +.emoji-26F3 { background-position: 0 -800px; } .emoji-26F4 { background-position: -20px -800px; } .emoji-26F5 { background-position: -40px -800px; } .emoji-26F7 { background-position: -60px -800px; } @@ -1679,7 +1679,7 @@ .emoji-270D-1F3FD { background-position: -760px -800px; } .emoji-270D-1F3FE { background-position: -780px -800px; } .emoji-270D-1F3FF { background-position: -800px -800px; } -.emoji-270F { background-position: -820px 0px; } +.emoji-270F { background-position: -820px 0; } .emoji-2712 { background-position: -820px -20px; } .emoji-2714 { background-position: -820px -40px; } .emoji-2716 { background-position: -820px -60px; } diff --git a/app/assets/stylesheets/pages/events.scss b/app/assets/stylesheets/pages/events.scss index 35df9a61c8..84eefd01cf 100644 --- a/app/assets/stylesheets/pages/events.scss +++ b/app/assets/stylesheets/pages/events.scss @@ -6,7 +6,7 @@ font-size: $gl-font-size; padding: $gl-padding-top 0 $gl-padding-top ($gl-avatar-size + $gl-padding-top); border-bottom: 1px solid $table-border-color; - color: #7f8fa4; + color: $list-text-color; &.event-inline { .avatar { @@ -21,7 +21,7 @@ } a { - color: #4c4e54; + color: $gl-dark-link-color; } .avatar { @@ -31,10 +31,7 @@ .event-title { @include str-truncated(calc(100% - 174px)); font-weight: 600; - - .author_name { - color: #333; - } + color: $list-text-color; } .event-body { @@ -63,7 +60,7 @@ .note-image-attach { margin-top: 4px; - margin-left: 0px; + margin-left: 0; max-width: 200px; float: none; } @@ -83,10 +80,10 @@ .event_icon { position: relative; float: right; - border: 1px solid #EEE; + border: 1px solid #eee; padding: 5px; @include border-radius(5px); - background: #F9F9F9; + background: #f9f9f9; margin-left: 10px; top: -6px; img { @@ -94,7 +91,7 @@ } } - &:last-child { border:none } + &:last-child { border: none } .event_commits { li { @@ -138,7 +135,7 @@ @include str-truncated(100%); padding: 5px 0; font-size: 13px; - float:left; + float: left; margin-right: -150px; padding-right: 150px; line-height: 20px; @@ -160,7 +157,7 @@ .event-body { margin: 0; - border-left: 2px solid #DDD; + border-left: 2px solid #ddd; padding-left: 10px; } diff --git a/app/assets/stylesheets/pages/graph.scss b/app/assets/stylesheets/pages/graph.scss index c3b10d144e..4e5c4ed84b 100644 --- a/app/assets/stylesheets/pages/graph.scss +++ b/app/assets/stylesheets/pages/graph.scss @@ -6,11 +6,11 @@ font-size: 14px; padding: 5px; border-bottom: 1px solid $border-color; - background: #EEE; + background: #eee; } .network-graph { - background: #FFF; + background: #fff; height: 500px; overflow-y: scroll; overflow-x: hidden; diff --git a/app/assets/stylesheets/pages/import.scss b/app/assets/stylesheets/pages/import.scss index 3df4bb84bd..6a99cd9cb9 100644 --- a/app/assets/stylesheets/pages/import.scss +++ b/app/assets/stylesheets/pages/import.scss @@ -1,6 +1,6 @@ i.icon-gitorious { display: inline-block; - background-position: 0px 0px; + background-position: 0 0; background-size: contain; background-repeat: no-repeat; } diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 1310e6ad7c..2760af8a48 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -1,34 +1,3 @@ -@media (max-width: $screen-sm-max) { - .issuable-affix { - margin-top: 20px; - } -} - -@media (max-width: $screen-md-max) { - .issuable-affix { - position: static; - } -} - -@media (min-width: $screen-md-max) { - .issuable-affix { - &.affix-top { - position: static; - } - - &.affix { - position: fixed; - top: 70px; - margin-right: 35px; - - &.no-affix { - position: relative; - top: 0; - } - } - } -} - .issuable-details { section { .issuable-discussion { @@ -54,20 +23,25 @@ padding: 6px 10px; } } + + &.has-labels { + margin-bottom: -5px; + } } .issuable-sidebar { .block { @include clearfix; - padding: $gl-padding 0; + padding: $gl-padding 0; border-bottom: 1px solid $border-gray-light; // This prevents the mess when resizing the sidebar // of elements repositioning themselves.. width: $gutter_inner_width; // -- - &:first-child { - padding-top: 5px; + &.issuable-sidebar-header { + padding-top: 0; + padding-bottom: 10px; } &:last-child { @@ -75,7 +49,6 @@ } span { - margin-top: 7px; display: inline-block; } @@ -84,7 +57,7 @@ } .issuable-count { - + margin-top: 7px; } .gutter-toggle { @@ -99,19 +72,19 @@ .title { color: $gl-text-color; - margin-bottom: 8px; + margin-bottom: 10px; + line-height: 1; .avatar { margin-left: 0; } - label { - font-weight: normal; - margin-right: 4px; - } - .edit-link { color: $gl-gray; + + &:hover { + color: $md-link-color; + } } } @@ -144,11 +117,6 @@ .btn-clipboard { color: $gl-gray; } - - .participants .avatar { - margin-top: 6px; - margin-right: 2px; - } } .right-sidebar { @@ -163,8 +131,12 @@ &.right-sidebar-expanded { width: $gutter_width; - hr { - display: none; + .value { + line-height: 1; + } + + .bold { + font-weight: 600; } .sidebar-collapsed-icon { @@ -172,8 +144,23 @@ } .gutter-toggle { + margin-top: 7px; border-left: 1px solid $border-gray-light; } + + .assignee .avatar { + float: left; + margin-right: 10px; + margin-bottom: 0; + margin-left: 0; + } + + .username { + display: block; + margin-top: 4px; + font-size: 13px; + font-weight: normal; + } } .subscribe-button { @@ -193,14 +180,6 @@ width: $sidebar_collapsed_width; padding-top: 0; - hr { - margin: 0; - color: $gray-normal; - border-color: $gray-normal; - width: 62px; - margin-left: -20px - } - .block { width: $sidebar_collapsed_width - 1px; margin-left: -19px; @@ -209,12 +188,18 @@ overflow: hidden; } + .participants { + border-bottom: 1px solid $border-gray-light; + } + .hide-collapsed { display: none; } .gutter-toggle { - margin-left: -36px; + width: 100%; + margin-left: 0; + padding-left: 25px; } .sidebar-collapsed-icon { @@ -222,13 +207,17 @@ width: 100%; text-align: center; padding-bottom: 10px; - color: #999999; + color: #999; span { display: block; margin-top: 0; } + .author { + display: none; + } + .btn-clipboard { border: none; @@ -237,10 +226,15 @@ } i { - color: #999999; + color: #999; } } } + + .sidebar-collapsed-user { + padding-bottom: 0; + margin-bottom: 10px; + } } .btn { @@ -251,6 +245,13 @@ border: 1px solid $border-gray-dark; } } + + a:not(.btn) { + &:hover { + color: $md-link-color; + text-decoration: none; + } + } } .btn-default.gutter-toggle { @@ -262,3 +263,37 @@ color: $gray-darkest; } } + +.edited-text { + color: $gray-darkest; + + .author_link { + color: $gray-darkest; + } +} + +.participants-list { + margin: -5px -5px; +} + +.participants-author { + display: inline-block; + padding: 5px 5px; + + .author_link { + display: block; + } + + .avatar.avatar-inline { + margin: 0; + } +} + +.participants-more { + margin-top: 5px; + margin-left: 5px; + + a { + color: #8c8c8c; + } +} diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index 1b686c58ea..6a1d28590c 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -3,7 +3,7 @@ padding: 10px $gl-padding; position: relative; - .issue-title { + .title { margin-bottom: 2px; } @@ -49,7 +49,7 @@ form.edit-issue { margin: 0; } -.merge-requests-title { +.merge-requests-title, .related-branches-title { font-size: 16px; font-weight: 600; } @@ -68,18 +68,18 @@ form.edit-issue { .merge-request, .issue { &.today { - background: #EFE; - border-color: #CEC; + background: #efe; + border-color: #cec; } &.closed { - background: #F9F9F9; - border-color: #E5E5E5; + background: #f9f9f9; + border-color: #e5e5e5; } &.merged { - background: #F9F9F9; - border-color: #E5E5E5; + background: #f9f9f9; + border-color: #e5e5e5; } } @@ -130,14 +130,14 @@ form.edit-issue { } .issue-closed-by-widget { - color: $secondary-text; + color: $gl-text-color; margin-left: 52px; } .editor-details { display: block; - + @media (min-width: $screen-sm-min) { display: inline-block; } -} \ No newline at end of file +} diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index 5ec0966194..61ee34b695 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -41,3 +41,7 @@ .color-label { padding: 3px 4px; } + +.label-subscription { + display: inline-block; +} diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss index 61bec02f6c..bc41f7d306 100644 --- a/app/assets/stylesheets/pages/login.scss +++ b/app/assets/stylesheets/pages/login.scss @@ -28,7 +28,7 @@ img { max-width: 100%; - margin-bottom: 30px; + margin-bottom: 30px; } a { @@ -39,7 +39,7 @@ .login-box{ background: #fafafa; border-radius: 10px; - box-shadow: 0 0px 2px #CCC; + box-shadow: 0 0 2px #ccc; padding: 15px; .login-heading h3 { @@ -74,7 +74,7 @@ &.top { @include border-radius(5px 5px 0 0); - margin-bottom: 0px; + margin-bottom: 0; } &.bottom { @@ -85,12 +85,12 @@ &.middle { border-top: 0; - margin-bottom:0px; + margin-bottom: 0; @include border-radius(0); } &:active, &:focus { - background-color: #FFF; + background-color: #fff; } } diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 2772623f4b..cee5c47cfb 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -113,7 +113,7 @@ } .mr-widget-footer { - border-top: 1px solid #EEE; + border-top: 1px solid #eee; } .ci-coverage { @@ -222,7 +222,7 @@ margin-bottom: 20px; span { - color: #B2B2B2; + color: #b2b2b2; a { color: $md-link-color; diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 158c2a4786..61783ec46a 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -156,7 +156,7 @@ .comment-hints { color: #999; - background: #FFF; + background: #fff; padding: 7px; margin-top: -7px; border: 1px solid $border-color; diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index d5f9852ebe..d408853cc8 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -3,16 +3,16 @@ */ @-webkit-keyframes targe3-note { - from { background:#fffff0; } - 50% { background:#ffffd3; } - to { background:#fffff0; } + from { background: #fffff0; } + 50% { background: #ffffd3; } + to { background: #fffff0; } } ul.notes { display: block; list-style: none; - margin: 0px; - padding: 0px; + margin: 0; + padding: 0; .timeline-icon { float: left; @@ -30,7 +30,7 @@ ul.notes { font-size: 14px; padding-top: 10px; padding-bottom: 10px; - background: #FDFDFD; + background: #fdfdfd; .timeline-icon { .avatar { @@ -93,12 +93,12 @@ ul.notes { .discussion { overflow: hidden; display: block; - position:relative; + position: relative; } .note { display: block; - position:relative; + position: relative; .note-body { overflow: auto; @@ -108,6 +108,13 @@ ul.notes { word-wrap: break-word; @include md-typography; + // On diffs code should wrap nicely and not overflow + pre { + code { + white-space: pre-wrap; + } + } + // Reset ul style types since we're nested inside a ul already & > ul { list-style-type: disc; @@ -129,7 +136,7 @@ ul.notes { hr { // Darken 'whitesmoke' a bit to make it more visible in note bodies - border-color: darken(#F5F5F5, 8%); + border-color: darken(#f5f5f5, 8%); margin: 10px 0; } } @@ -166,7 +173,7 @@ ul.notes { vertical-align: middle; text-align: center; padding: 10px 0; - background: #FFF; + background: #fff; color: $text-color; } &.notes_line2 { @@ -232,7 +239,7 @@ ul.notes { .add-diff-note { margin-top: -4px; @include border-radius(40px); - background: #FFF; + background: #fff; padding: 4px; font-size: 16px; color: $gl-link-color; @@ -249,7 +256,7 @@ ul.notes { &:hover { background: $gl-info; - color: #FFF; + color: #fff; @include show-add-diff-note; } } diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index 248c56e459..260179074c 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -30,7 +30,7 @@ .btn { line-height: 40px; height: 42px; - padding: 0px 12px; + padding: 0 12px; img { width: 32px; @@ -109,42 +109,6 @@ } } -.modal-profile-crop { - .modal-dialog { - width: 500px; - } - - .modal-body { - p { - display: table; - margin: auto; - overflow: hidden; - } - - img { - display: block; - max-width: 400px; - max-height: 400px; - } - - .cropper-bg { - background: none; - } - - .cropper-crop-box { - box-sizing: content-box; - border: 999px solid transparentize(#ccc, 0.5); - @include transform(translate(-999px, -999px)); - } - } -} - -@media (max-width: 520px) { - .modal-profile-crop .modal-dialog { - width: auto; - } -} - .key-list-item { .key-list-item-info { @media (min-width: $screen-sm-min) { @@ -215,3 +179,21 @@ color: $provider-btn-not-active-color; } } + +.profile-settings-message { + line-height: 32px; + color: $warning-message-color; + background-color: $warning-message-bg; + border: 1px solid $warning-message-border; + border-radius: $border-radius-base; +} + +.oauth-applications { + form { + display: inline-block; + } + + .last-heading { + width: 105px; + } +} diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index b1b76edfb3..82c5069638 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -33,6 +33,13 @@ .project-settings-dropdown { margin-left: 10px; display: inline-block; + + .dropdown-menu { + left: auto; + width: auto; + right: 0px; + max-width: 240px; + } } } @@ -85,7 +92,7 @@ .project-repo-buttons { margin-top: 20px; - margin-bottom: 0px; + margin-bottom: 0; .count-buttons { display: block; @@ -140,7 +147,7 @@ left: 1px; margin-top: -9px; border-width: 10px 7px 10px 0; - border-right-color: #FFF; + border-right-color: #fff; } } .count { @@ -162,10 +169,10 @@ cursor: pointer; background-image: none; white-space: nowrap; - margin: 0 11px 0px 4px; + margin: 0 11px 0 4px; &:hover { - background: #FFF; + background: #fff; } } } @@ -210,7 +217,7 @@ } .project_member_row form { - margin: 0px; + margin: 0; } .transfer-project .select2-container { @@ -286,11 +293,11 @@ table.table.protected-branches-list tr.no-border { padding-bottom: 4px; ul.nav { - display:inline-block; + display: inline-block; } .nav li { - display:inline; + display: inline; } .nav > li > a { @@ -303,11 +310,11 @@ table.table.protected-branches-list tr.no-border { } li { - display:inline; + display: inline; } a { - float:left; + float: left; font-size: 17px; } @@ -462,7 +469,7 @@ pre.light-well { .form-control { @extend .monospace; - background: #FFF; + background: #fff; font-size: 14px; margin-left: -1px; cursor: auto; @@ -472,16 +479,16 @@ pre.light-well { .cannot-be-merged, .cannot-be-merged:hover { - color: #E62958; + color: #e62958; margin-top: 2px; } .private-forks-notice .private-fork-icon { i:nth-child(1) { - color: #2AA056; + color: #2aa056; } i:nth-child(2) { - color: #FFFFFF; + color: #fff; } } diff --git a/app/assets/stylesheets/pages/runners.scss b/app/assets/stylesheets/pages/runners.scss index a9111a7388..eec22c5dc9 100644 --- a/app/assets/stylesheets/pages/runners.scss +++ b/app/assets/stylesheets/pages/runners.scss @@ -1,7 +1,7 @@ .runner-state { padding: 6px 12px; margin-right: 10px; - color: #FFF; + color: #fff; &.runner-state-shared { background: #32b186; diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss index 84234b15c6..b6e4502464 100644 --- a/app/assets/stylesheets/pages/search.scss +++ b/app/assets/stylesheets/pages/search.scss @@ -16,7 +16,7 @@ margin-bottom: 20px; input { - border-color: #BBB; + border-color: #bbb; font-weight: bold; } } diff --git a/app/assets/stylesheets/pages/sherlock.scss b/app/assets/stylesheets/pages/sherlock.scss index 92d84d9640..bed6470dbd 100644 --- a/app/assets/stylesheets/pages/sherlock.scss +++ b/app/assets/stylesheets/pages/sherlock.scss @@ -13,13 +13,13 @@ table .sherlock-code { } .sherlock-line-samples-table { - margin-bottom: 0px !important; + margin-bottom: 0 !important; thead tr th, tbody tr td { font-size: 13px !important; text-align: right; - padding: 0px 10px !important; + padding: 0 10px !important; } } diff --git a/app/assets/stylesheets/pages/status.scss b/app/assets/stylesheets/pages/status.scss index 4b6ef03567..6f777d1164 100644 --- a/app/assets/stylesheets/pages/status.scss +++ b/app/assets/stylesheets/pages/status.scss @@ -1,7 +1,7 @@ .ci-status { padding: 2px 7px; margin-right: 5px; - border: 1px solid #EEE; + border: 1px solid #eee; white-space: nowrap; @include border-radius(4px); diff --git a/app/assets/stylesheets/pages/todos.scss b/app/assets/stylesheets/pages/todos.scss index 479c3c16d4..f983e9829e 100644 --- a/app/assets/stylesheets/pages/todos.scss +++ b/app/assets/stylesheets/pages/todos.scss @@ -14,25 +14,8 @@ } .todo-item { - font-size: $gl-font-size; - padding-left: $gl-avatar-size + $gl-padding-top; - color: $secondary-text; - - a { - color: #4c4e54; - } - - .avatar { - margin-left: -($gl-avatar-size + $gl-padding-top); - } - .todo-title { @include str-truncated(calc(100% - 174px)); - font-weight: 600; - - .author-name { - color: #333; - } } .todo-body { @@ -61,7 +44,7 @@ .note-image-attach { margin-top: 4px; - margin-left: 0px; + margin-left: 0; max-width: 200px; float: none; } @@ -89,7 +72,7 @@ .todo-body { margin: 0; - border-left: 2px solid #DDD; + border-left: 2px solid #ddd; padding-left: 10px; } } diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss index ef63b01060..25b5e95583 100644 --- a/app/assets/stylesheets/pages/tree.scss +++ b/app/assets/stylesheets/pages/tree.scss @@ -41,12 +41,12 @@ vertical-align: middle; i, a { - color: $gl-link-color; + color: $gl-dark-link-color; } img { position: relative; - top:-1px; + top: -1px; } } diff --git a/app/assets/stylesheets/pages/ui_dev_kit.scss b/app/assets/stylesheets/pages/ui_dev_kit.scss index 05fa9312ef..587bd6a1e8 100644 --- a/app/assets/stylesheets/pages/ui_dev_kit.scss +++ b/app/assets/stylesheets/pages/ui_dev_kit.scss @@ -7,7 +7,7 @@ .example { &:before { content: "Example"; - color: #BBB; + color: #bbb; } padding: 15px; diff --git a/app/assets/stylesheets/pages/xterm.scss b/app/assets/stylesheets/pages/xterm.scss index 9a50096c0d..8886c1dff5 100644 --- a/app/assets/stylesheets/pages/xterm.scss +++ b/app/assets/stylesheets/pages/xterm.scss @@ -2,23 +2,23 @@ // color codes are based on http://en.wikipedia.org/wiki/File:Xterm_256color_chart.svg // see also: https://gist.github.com/jasonm23/2868981 - $black: #000000; + $black: #000; $red: #cd0000; $green: #00cd00; $yellow: #cdcd00; - $blue: #0000ee; // according to wikipedia, this is the xterm standard + $blue: #00e; // according to wikipedia, this is the xterm standard //$blue: #1e90ff; // this is used by all the terminals I tried (when configured with the xterm color profile) $magenta: #cd00cd; $cyan: #00cdcd; $white: #e5e5e5; $l-black: #7f7f7f; - $l-red: #ff0000; - $l-green: #00ff00; - $l-yellow: #ffff00; + $l-red: #f00; + $l-green: #0f0; + $l-yellow: #ff0; $l-blue: #5c5cff; - $l-magenta: #ff00ff; - $l-cyan: #00ffff; - $l-white: #ffffff; + $l-magenta: #f0f; + $l-cyan: #0ff; + $l-white: #fff; .term-bold { font-weight: bold; @@ -136,7 +136,7 @@ .xterm-fg-0 { - color: #000000; + color: #000; } .xterm-fg-1 { color: #800000; @@ -163,28 +163,28 @@ color: #808080; } .xterm-fg-9 { - color: #ff0000; + color: #f00; } .xterm-fg-10 { - color: #00ff00; + color: #0f0; } .xterm-fg-11 { - color: #ffff00; + color: #ff0; } .xterm-fg-12 { - color: #0000ff; + color: #00f; } .xterm-fg-13 { - color: #ff00ff; + color: #f0f; } .xterm-fg-14 { - color: #00ffff; + color: #0ff; } .xterm-fg-15 { - color: #ffffff; + color: #fff; } .xterm-fg-16 { - color: #000000; + color: #000; } .xterm-fg-17 { color: #00005f; @@ -199,7 +199,7 @@ color: #0000d7; } .xterm-fg-21 { - color: #0000ff; + color: #00f; } .xterm-fg-22 { color: #005f00; @@ -274,7 +274,7 @@ color: #00d7ff; } .xterm-fg-46 { - color: #00ff00; + color: #0f0; } .xterm-fg-47 { color: #00ff5f; @@ -289,7 +289,7 @@ color: #00ffd7; } .xterm-fg-51 { - color: #00ffff; + color: #0ff; } .xterm-fg-52 { color: #5f0000; @@ -724,7 +724,7 @@ color: #d7ffff; } .xterm-fg-196 { - color: #ff0000; + color: #f00; } .xterm-fg-197 { color: #ff005f; @@ -739,7 +739,7 @@ color: #ff00d7; } .xterm-fg-201 { - color: #ff00ff; + color: #f0f; } .xterm-fg-202 { color: #ff5f00; @@ -814,7 +814,7 @@ color: #ffd7ff; } .xterm-fg-226 { - color: #ffff00; + color: #ff0; } .xterm-fg-227 { color: #ffff5f; @@ -829,7 +829,7 @@ color: #ffffd7; } .xterm-fg-231 { - color: #ffffff; + color: #fff; } .xterm-fg-232 { color: #080808; @@ -850,7 +850,7 @@ color: #3a3a3a; } .xterm-fg-238 { - color: #444444; + color: #444; } .xterm-fg-239 { color: #4e4e4e; @@ -901,6 +901,6 @@ color: #e4e4e4; } .xterm-fg-255 { - color: #eeeeee; + color: #eee; } } diff --git a/app/controllers/admin/abuse_reports_controller.rb b/app/controllers/admin/abuse_reports_controller.rb index 2463cfa87b..e9b0972bdd 100644 --- a/app/controllers/admin/abuse_reports_controller.rb +++ b/app/controllers/admin/abuse_reports_controller.rb @@ -6,7 +6,7 @@ class Admin::AbuseReportsController < Admin::ApplicationController def destroy abuse_report = AbuseReport.find(params[:id]) - abuse_report.remove_user if params[:remove_user] + abuse_report.remove_user(deleted_by: current_user) if params[:remove_user] abuse_report.destroy render nothing: true diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 87f4fb455b..9abf08d0e1 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -119,10 +119,10 @@ class Admin::UsersController < Admin::ApplicationController end def destroy - DeleteUserService.new(current_user).execute(user) + DeleteUserWorker.perform_async(current_user.id, user.id) respond_to do |format| - format.html { redirect_to admin_users_path } + format.html { redirect_to admin_users_path, notice: "The user is being deleted." } format.json { head :ok } end end @@ -150,7 +150,7 @@ class Admin::UsersController < Admin::ApplicationController :email, :remember_me, :bio, :name, :username, :skype, :linkedin, :twitter, :website_url, :color_scheme_id, :theme_id, :force_random_password, :extern_uid, :provider, :password_expires_at, :avatar, :hide_no_ssh_key, :hide_no_password, - :projects_limit, :can_create_group, :admin, :key_id + :projects_limit, :can_create_group, :admin, :key_id, :external ) end diff --git a/app/controllers/concerns/toggle_subscription_action.rb b/app/controllers/concerns/toggle_subscription_action.rb new file mode 100644 index 0000000000..8a43c0b93c --- /dev/null +++ b/app/controllers/concerns/toggle_subscription_action.rb @@ -0,0 +1,17 @@ +module ToggleSubscriptionAction + extend ActiveSupport::Concern + + def toggle_subscription + return unless current_user + + subscribable_resource.toggle_subscription(current_user) + + render nothing: true + end + + private + + def subscribable_resource + raise NotImplementedError + end +end diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb index 43cf8fa71a..be488483b0 100644 --- a/app/controllers/dashboard/todos_controller.rb +++ b/app/controllers/dashboard/todos_controller.rb @@ -1,25 +1,34 @@ class Dashboard::TodosController < Dashboard::ApplicationController - before_action :find_todos, only: [:index, :destroy_all] + before_action :find_todos, only: [:index, :destroy, :destroy_all] def index @todos = @todos.page(params[:page]).per(PER_PAGE) end def destroy - todo.done! + todo.done + + todo_notice = 'Todo was successfully marked as done.' respond_to do |format| - format.html { redirect_to dashboard_todos_path, notice: 'Todo was successfully marked as done.' } + format.html { redirect_to dashboard_todos_path, notice: todo_notice } format.js { render nothing: true } + format.json do + render json: { count: @todos.size, done_count: current_user.todos.done.count } + end end end def destroy_all - @todos.each(&:done!) + @todos.each(&:done) respond_to do |format| format.html { redirect_to dashboard_todos_path, notice: 'All todos were marked as done.' } format.js { render nothing: true } + format.json do + find_todos + render json: { count: @todos.size, done_count: current_user.todos.done.count } + end end end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 139e40db18..b538c7d160 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -3,7 +3,7 @@ class DashboardController < Dashboard::ApplicationController include MergeRequestsAction before_action :event_filter, only: :activity - before_action :projects, only: [:issues, :merge_requests] + before_action :projects, only: [:issues, :merge_requests, :labels, :milestones] respond_to :html @@ -20,6 +20,29 @@ class DashboardController < Dashboard::ApplicationController end end + def labels + labels = Label.where(project_id: @projects).select(:title, :color).uniq(:title) + + respond_to do |format| + format.json do + render json: labels + end + end + end + + def milestones + milestones = Milestone.where(project_id: @projects).active + epoch = DateTime.parse('1970-01-01') + grouped_milestones = GlobalMilestone.build_collection(milestones) + grouped_milestones = grouped_milestones.sort_by { |x| x.due_date.nil? ? epoch : x.due_date } + + respond_to do |format| + format.json do + render json: grouped_milestones + end + end + end + protected def load_events diff --git a/app/controllers/oauth/applications_controller.rb b/app/controllers/oauth/applications_controller.rb index dc22101cd5..d1e4ac10f6 100644 --- a/app/controllers/oauth/applications_controller.rb +++ b/app/controllers/oauth/applications_controller.rb @@ -8,7 +8,7 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController layout 'profile' def index - head :forbidden and return + set_index_vars end def create @@ -20,18 +20,11 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController flash[:notice] = I18n.t(:notice, scope: [:doorkeeper, :flash, :applications, :create]) redirect_to oauth_application_url(@application) else - render :new + set_index_vars + render :index end end - def destroy - if @application.destroy - flash[:notice] = I18n.t(:notice, scope: [:doorkeeper, :flash, :applications, :destroy]) - end - - redirect_to applications_profile_url - end - private def verify_user_oauth_applications_enabled @@ -40,6 +33,17 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController redirect_to applications_profile_url end + def set_index_vars + @applications = current_user.oauth_applications + @authorized_tokens = current_user.oauth_authorized_tokens + @authorized_anonymous_tokens = @authorized_tokens.reject(&:application) + @authorized_apps = @authorized_tokens.map(&:application).uniq.reject(&:nil?) + + # Don't overwrite a value possibly set by `create` + @application ||= Doorkeeper::Application.new + end + + # Override Doorkeeper to scope to the current user def set_application @application = current_user.oauth_applications.find(params[:id]) end diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index fa7a114896..32fca6b838 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -8,13 +8,6 @@ class ProfilesController < Profiles::ApplicationController def show end - def applications - @applications = current_user.oauth_applications - @authorized_tokens = current_user.oauth_authorized_tokens - @authorized_anonymous_tokens = @authorized_tokens.reject(&:application) - @authorized_apps = @authorized_tokens.map(&:application).uniq - [nil] - end - def update user_params.except!(:email) if @user.ldap_user? @@ -65,9 +58,6 @@ class ProfilesController < Profiles::ApplicationController def user_params params.require(:user).permit( - :avatar_crop_x, - :avatar_crop_y, - :avatar_crop_size, :avatar, :bio, :email, diff --git a/app/controllers/projects/badges_controller.rb b/app/controllers/projects/badges_controller.rb index dc9c96df00..6ff47c4033 100644 --- a/app/controllers/projects/badges_controller.rb +++ b/app/controllers/projects/badges_controller.rb @@ -1,5 +1,5 @@ class Projects::BadgesController < Projects::ApplicationController - before_action :set_no_cache + before_action :no_cache_headers def build respond_to do |format| @@ -10,15 +10,4 @@ class Projects::BadgesController < Projects::ApplicationController end end end - - private - - def set_no_cache - expires_now - - # Add some deprecated headers for older agents - # - response.headers['Pragma'] = 'no-cache' - response.headers['Expires'] = 'Fri, 01 Jan 1990 00:00:00 GMT' - end end diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb index 4db3b3bf23..43ea717cbd 100644 --- a/app/controllers/projects/branches_controller.rb +++ b/app/controllers/projects/branches_controller.rb @@ -9,7 +9,7 @@ class Projects::BranchesController < Projects::ApplicationController @sort = params[:sort] || 'name' @branches = @repository.branches_sorted_by(@sort) @branches = Kaminari.paginate_array(@branches).page(params[:page]).per(PER_PAGE) - + @max_commits = @branches.reduce(0) do |memo, branch| diverging_commit_counts = repository.diverging_commit_counts(branch) [memo, diverging_commit_counts[:behind], diverging_commit_counts[:ahead]].max @@ -23,11 +23,15 @@ class Projects::BranchesController < Projects::ApplicationController def create branch_name = sanitize(strip_tags(params[:branch_name])) branch_name = Addressable::URI.unescape(branch_name) - ref = sanitize(strip_tags(params[:ref])) - ref = Addressable::URI.unescape(ref) + result = CreateBranchService.new(project, current_user). execute(branch_name, ref) + if params[:issue_iid] + issue = @project.issues.find_by(iid: params[:issue_iid]) + SystemNoteService.new_issue_branch(issue, @project, current_user, branch_name) if issue + end + if result[:status] == :success @branch = result[:branch] redirect_to namespace_project_tree_path(@project.namespace, @project, @@ -49,4 +53,15 @@ class Projects::BranchesController < Projects::ApplicationController format.js { render status: status[:return_code] } end end + + private + + def ref + if params[:ref] + ref_escaped = sanitize(strip_tags(params[:ref])) + Addressable::URI.unescape(ref_escaped) + else + @project.default_branch + end + end end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 67faa1e443..6603f28a08 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -1,9 +1,11 @@ class Projects::IssuesController < Projects::ApplicationController + include ToggleSubscriptionAction + before_action :module_enabled - before_action :issue, only: [:edit, :update, :show, :toggle_subscription] + before_action :issue, only: [:edit, :update, :show] # Allow read any issue - before_action :authorize_read_issue! + before_action :authorize_read_issue!, only: [:show] # Allow write(create) issue before_action :authorize_create_issue!, only: [:new, :create] @@ -63,6 +65,7 @@ class Projects::IssuesController < Projects::ApplicationController @notes = @issue.notes.nonawards.with_associations.fresh @noteable = @issue @merge_requests = @issue.referenced_merge_requests(current_user) + @related_branches = @issue.related_branches - @merge_requests.map(&:source_branch) respond_with(@issue) end @@ -110,12 +113,6 @@ class Projects::IssuesController < Projects::ApplicationController redirect_back_or_default(default: { action: 'index' }, options: { notice: "#{result[:count]} issues updated" }) end - def toggle_subscription - @issue.toggle_subscription(current_user) - - render nothing: true - end - def closed_by_merge_requests @closed_by_merge_requests ||= @issue.closed_by_merge_requests(current_user) end @@ -129,6 +126,11 @@ class Projects::IssuesController < Projects::ApplicationController redirect_old end end + alias_method :subscribable_resource, :issue + + def authorize_read_issue! + return render_404 unless can?(current_user, :read_issue, @issue) + end def authorize_update_issue! return render_404 unless can?(current_user, :update_issue, @issue) @@ -160,7 +162,7 @@ class Projects::IssuesController < Projects::ApplicationController def issue_params params.require(:issue).permit( - :title, :assignee_id, :position, :description, + :title, :assignee_id, :position, :description, :confidential, :milestone_id, :state_event, :task_num, label_ids: [] ) end diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb index ecac3c395e..5f471d405f 100644 --- a/app/controllers/projects/labels_controller.rb +++ b/app/controllers/projects/labels_controller.rb @@ -1,13 +1,24 @@ class Projects::LabelsController < Projects::ApplicationController + include ToggleSubscriptionAction + before_action :module_enabled before_action :label, only: [:edit, :update, :destroy] before_action :authorize_read_label! - before_action :authorize_admin_labels!, except: [:index] + before_action :authorize_admin_labels!, only: [ + :new, :create, :edit, :update, :generate, :destroy + ] respond_to :js, :html def index @labels = @project.labels.page(params[:page]).per(PER_PAGE) + + respond_to do |format| + format.html + format.json do + render json: @project.labels + end + end end def new @@ -73,8 +84,9 @@ class Projects::LabelsController < Projects::ApplicationController end def label - @label = @project.labels.find(params[:id]) + @label ||= @project.labels.find(params[:id]) end + alias_method :subscribable_resource, :label def authorize_admin_labels! return render_404 unless can?(current_user, :admin_label, @project) diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 03ba289eb9..61b82c9db4 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -1,10 +1,11 @@ class Projects::MergeRequestsController < Projects::ApplicationController + include ToggleSubscriptionAction include DiffHelper before_action :module_enabled before_action :merge_request, only: [ :edit, :update, :show, :diffs, :commits, :builds, :merge, :merge_check, - :ci_status, :toggle_subscription, :cancel_merge_when_build_succeeds + :ci_status, :cancel_merge_when_build_succeeds ] before_action :closes_issues, only: [:edit, :update, :show, :diffs, :commits, :builds] before_action :validates_merge_request, only: [:show, :diffs, :commits, :builds] @@ -233,12 +234,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController render json: response end - def toggle_subscription - @merge_request.toggle_subscription(current_user) - - render nothing: true - end - protected def selected_target_project @@ -252,6 +247,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController def merge_request @merge_request ||= @project.merge_requests.find_by!(iid: params[:id]) end + alias_method :subscribable_resource, :merge_request def closes_issues @closes_issues ||= @merge_request.closes_issues diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb index da46731d94..0998b191c0 100644 --- a/app/controllers/projects/milestones_controller.rb +++ b/app/controllers/projects/milestones_controller.rb @@ -19,7 +19,15 @@ class Projects::MilestonesController < Projects::ApplicationController end @milestones = @milestones.includes(:project) - @milestones = @milestones.page(params[:page]).per(PER_PAGE) + + respond_to do |format| + format.html do + @milestones = @milestones.page(params[:page]).per(PER_PAGE) + end + format.json do + render json: @milestones + end + end end def new diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index c70add86a2..c993048077 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -134,7 +134,7 @@ class ProjectsController < ApplicationController def autocomplete_sources note_type = params['type'] note_id = params['type_id'] - autocomplete = ::Projects::AutocompleteService.new(@project) + autocomplete = ::Projects::AutocompleteService.new(@project, current_user) participants = ::Projects::ParticipantsService.new(@project, current_user).execute(note_type, note_id) @suggestions = { @@ -172,10 +172,15 @@ class ProjectsController < ApplicationController def housekeeping ::Projects::HousekeepingService.new(@project).execute - respond_to do |format| - flash[:notice] = "Housekeeping successfully started." - format.html { redirect_to project_path(@project) } - end + redirect_to( + project_path(@project), + notice: "Housekeeping successfully started" + ) + rescue ::Projects::HousekeepingService::LeaseTaken => ex + redirect_to( + edit_project_path(@project), + alert: ex.to_s + ) end def toggle_star diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb index 20a2b0ce8f..c2befa5a5b 100644 --- a/app/finders/issues_finder.rb +++ b/app/finders/issues_finder.rb @@ -19,4 +19,10 @@ class IssuesFinder < IssuableFinder def klass Issue end + + private + + def init_collection + Issue.visible_to_user(current_user) + end end diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb index 2c55f08859..3a5fc5b590 100644 --- a/app/finders/projects_finder.rb +++ b/app/finders/projects_finder.rb @@ -40,25 +40,26 @@ class ProjectsFinder private def group_projects(current_user, group) - if current_user - [ - group_projects_for_user(current_user, group), - group.projects.public_and_internal_only, - group.shared_projects.visible_to_user(current_user) - ] + return [group.projects.public_only] unless current_user + + user_group_projects = [ + group_projects_for_user(current_user, group), + group.shared_projects.visible_to_user(current_user) + ] + if current_user.external? + user_group_projects << group.projects.public_only else - [group.projects.public_only] + user_group_projects << group.projects.public_and_internal_only end end def all_projects(current_user) - if current_user - [ - current_user.authorized_projects, - public_and_internal_projects - ] + return [public_projects] unless current_user + + if current_user.external? + [current_user.authorized_projects, public_projects] else - [Project.public_only] + [current_user.authorized_projects, public_and_internal_projects] end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index d1b1c61b71..e6ceb21353 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -182,7 +182,7 @@ module ApplicationHelper # Returns an HTML-safe String def time_ago_with_tooltip(time, placement: 'top', html_class: 'time_ago', skip_js: false) element = content_tag :time, time.to_s, - class: "#{html_class} js-timeago js-timeago-pending", + class: "#{html_class} js-timeago #{"js-timeago-pending" unless skip_js}", datetime: time.to_time.getutc.iso8601, title: time.in_time_zone.to_s(:medium), data: { toggle: 'tooltip', placement: placement, container: 'body' } @@ -196,6 +196,22 @@ module ApplicationHelper element end + def edited_time_ago_with_tooltip(object, placement: 'top', html_class: 'time_ago', include_author: false) + return if object.updated_at == object.created_at + + content_tag :small, class: "edited-text" do + output = content_tag(:span, "Edited ") + output << time_ago_with_tooltip(object.updated_at, placement: placement, html_class: html_class) + + if include_author && object.updated_by && object.updated_by != object.author + output << content_tag(:span, " by ") + output << link_to_member(object.project, object.updated_by, avatar: false, author_class: nil) + end + + output + end + end + def render_markup(file_name, file_content) if gitlab_markdown?(file_name) Haml::Helpers.preserve(markdown(file_content)) @@ -285,7 +301,7 @@ module ApplicationHelper if project.nil? nil elsif current_controller?(:issues) - project.issues.send(entity).count + project.issues.visible_to_user(current_user).send(entity).count elsif current_controller?(:merge_requests) project.merge_requests.send(entity).count end diff --git a/app/helpers/ci_status_helper.rb b/app/helpers/ci_status_helper.rb index f20779f2fb..8b1575d5e0 100644 --- a/app/helpers/ci_status_helper.rb +++ b/app/helpers/ci_status_helper.rb @@ -12,9 +12,13 @@ module CiStatusHelper ci_label_for_status(ci_commit.status) end - def ci_status_with_icon(status) - content_tag :span, class: "ci-status ci-#{status}" do - ci_icon_for_status(status) + ' '.html_safe + ci_label_for_status(status) + def ci_status_with_icon(status, target = nil) + content = ci_icon_for_status(status) + ' '.html_safe + ci_label_for_status(status) + klass = "ci-status ci-#{status}" + if target + link_to content, target, class: klass + else + content_tag :span, content, class: klass end end diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb index 74f326e0b8..ceff1fbb16 100644 --- a/app/helpers/dropdowns_helper.rb +++ b/app/helpers/dropdowns_helper.rb @@ -24,7 +24,7 @@ module DropdownsHelper capture(&block) if block && !options.has_key?(:footer_content) end - if block && options.has_key?(:footer_content) + if block && options[:footer_content] output << content_tag(:div, class: "dropdown-footer") do capture(&block) end diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index e5fcaab955..a67a6b208e 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -3,7 +3,7 @@ module EventsHelper author = event.author if author - link_to author.name, user_path(author.username) + link_to author.name, user_path(author.username), title: h(author.name) else event.author_name end @@ -159,7 +159,7 @@ module EventsHelper link_to( namespace_project_commit_path(event.project.namespace, event.project, event.note_commit_id, - anchor: dom_id(event.target)), + anchor: dom_id(event.target), title: h(event.target_title)), class: "commit_short_id" ) do "#{event.note_target_type} #{event.note_short_commit_id}" @@ -167,7 +167,7 @@ module EventsHelper elsif event.note_project_snippet? link_to(namespace_project_snippet_path(event.project.namespace, event.project, - event.note_target)) do + event.note_target), title: h(event.project.name)) do "#{event.note_target_type} #{truncate event.note_target.to_reference}" end else @@ -194,7 +194,7 @@ module EventsHelper end def event_to_atom(xml, event) - if event.proper? + if event.proper?(current_user) xml.entry do event_link = event_feed_url(event) event_title = event_feed_title(event) diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 91a3aa371e..81df209439 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -20,6 +20,23 @@ module IssuablesHelper base_issuable_scope(issuable).where('iid < ?', issuable.iid).first end + def user_dropdown_label(user_id, default_label) + return "Unassigned" if user_id == "0" + + if @project + member = @project.team.find_member(user_id) + user = member.user if member + else + user = User.find_by(id: user_id) + end + + if user + user.name + else + default_label + end + end + private def sidebar_gutter_collapsed? @@ -31,7 +48,11 @@ module IssuablesHelper end def issuable_state_scope(issuable) - issuable.open? ? :opened : :closed + if issuable.respond_to?(:merged?) && issuable.merged? + :merged + else + issuable.open? ? :opened : :closed + end end end diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index ae4ebc0854..e00d320402 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -98,6 +98,10 @@ module IssuesHelper end.sort.to_sentence(last_word_connector: ', or ') end + def confidential_icon(issue) + icon('eye-slash') if issue.confidential? + end + def emoji_icon(name, unicode = nil, aliases = []) unicode ||= Emoji.emoji_filename(name) rescue "" diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb index 89a054289e..e238a7b4c2 100644 --- a/app/helpers/labels_helper.rb +++ b/app/helpers/labels_helper.rb @@ -109,19 +109,20 @@ module LabelsHelper end end - def projects_labels_options - labels = - if @project - @project.labels - else - Label.where(project_id: @projects) - end + def labels_filter_path + if @project + namespace_project_labels_path(@project.namespace, @project, :json) + else + labels_dashboard_path(:json) + end + end - grouped_labels = GlobalLabel.build_collection(labels) - grouped_labels.unshift(Label::None) - grouped_labels.unshift(Label::Any) + def label_subscription_status(label) + label.subscribed?(current_user) ? 'subscribed' : 'unsubscribed' + end - options_from_collection_for_select(grouped_labels, 'name', 'title', params[:label_name]) + def label_subscription_toggle_button_text(label) + label.subscribed?(current_user) ? 'Unsubscribe' : 'Subscribe' end # Required for Banzai::Filter::LabelReferenceFilter diff --git a/app/helpers/milestones_helper.rb b/app/helpers/milestones_helper.rb index e8ac8788d9..c9d8787bd1 100644 --- a/app/helpers/milestones_helper.rb +++ b/app/helpers/milestones_helper.rb @@ -38,7 +38,7 @@ module MilestonesHelper def milestone_progress_bar(milestone) options = { class: 'progress-bar progress-bar-success', - style: "width: #{milestone.percent_complete}%;" + style: "width: #{milestone.percent_complete(current_user)}%;" } content_tag :div, class: 'progress' do @@ -46,22 +46,12 @@ module MilestonesHelper end end - def projects_milestones_options - milestones = - if @project - @project.milestones - else - Milestone.where(project_id: @projects) - end.active - - epoch = DateTime.parse('1970-01-01') - grouped_milestones = GlobalMilestone.build_collection(milestones) - grouped_milestones = grouped_milestones.sort_by { |x| x.due_date.nil? ? epoch : x.due_date } - grouped_milestones.unshift(Milestone::None) - grouped_milestones.unshift(Milestone::Any) - grouped_milestones.unshift(Milestone::Upcoming) - - options_from_collection_for_select(grouped_milestones, 'name', 'title', params[:milestone_title]) + def milestones_filter_dropdown_path + if @project + namespace_project_milestones_path(@project.namespace, @project, :json) + else + milestones_dashboard_path(:json) + end end def milestone_remaining_days(milestone) diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index c8061fcdc5..5473419ef2 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -8,7 +8,7 @@ module ProjectsHelper end def link_to_project(project) - link_to [project.namespace.becomes(Namespace), project] do + link_to [project.namespace.becomes(Namespace), project], title: h(project.name) do title = content_tag(:span, project.name, class: 'project-name') if project.namespace @@ -26,7 +26,7 @@ module ProjectsHelper image_tag(avatar_icon(author, opts[:size]), width: opts[:size], class: "avatar avatar-inline #{"s#{opts[:size]}" if opts[:size]}", alt:'') if opts[:avatar] end - def link_to_member(project, author, opts = {}) + def link_to_member(project, author, opts = {}, &block) default_opts = { avatar: true, name: true, size: 16, author_class: 'author', title: ":name" } opts = default_opts.merge(opts) @@ -44,6 +44,8 @@ module ProjectsHelper author_html << content_tag(:span, sanitize(author.name), class: opts[:author_class]) if opts[:name] end + author_html << capture(&block) if block + author_html = author_html.html_safe if opts[:name] diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index 4b745a5b96..edc5686cf0 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -16,14 +16,19 @@ module TodosHelper def todo_target_link(todo) target = todo.target_type.titleize.downcase - link_to "#{target} #{todo.target.to_reference}", todo_target_path(todo) + link_to "#{target} #{todo.target_reference}", todo_target_path(todo), { title: todo.target.title } end def todo_target_path(todo) anchor = dom_id(todo.note) if todo.note.present? - polymorphic_path([todo.project.namespace.becomes(Namespace), - todo.project, todo.target], anchor: anchor) + if todo.for_commit? + namespace_project_commit_path(todo.project.namespace.becomes(Namespace), todo.project, + todo.target, anchor: anchor) + else + polymorphic_path([todo.project.namespace.becomes(Namespace), + todo.project, todo.target], anchor: anchor) + end end def todos_filter_params diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb index 4a88cb6113..5f9adb32e0 100644 --- a/app/mailers/emails/issues.rb +++ b/app/mailers/emails/issues.rb @@ -16,7 +16,15 @@ module Emails def closed_issue_email(recipient_id, issue_id, updated_by_user_id) setup_issue_mail(issue_id, recipient_id) - @updated_by = User.find updated_by_user_id + @updated_by = User.find(updated_by_user_id) + mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id)) + end + + def relabeled_issue_email(recipient_id, issue_id, label_names, updated_by_user_id) + setup_issue_mail(issue_id, recipient_id) + + @label_names = label_names + @labels_url = namespace_project_labels_url(@project.namespace, @project) mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id)) end @@ -24,20 +32,12 @@ module Emails setup_issue_mail(issue_id, recipient_id) @issue_status = status - @updated_by = User.find updated_by_user_id + @updated_by = User.find(updated_by_user_id) mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id)) end private - def issue_thread_options(sender_id, recipient_id) - { - from: sender(sender_id), - to: recipient(recipient_id), - subject: subject("#{@issue.title} (##{@issue.iid})") - } - end - def setup_issue_mail(issue_id, recipient_id) @issue = Issue.find(issue_id) @project = @issue.project @@ -45,5 +45,13 @@ module Emails @sent_notification = SentNotification.record(@issue, recipient_id, reply_key) end + + def issue_thread_options(sender_id, recipient_id) + { + from: sender(sender_id), + to: recipient(recipient_id), + subject: subject("#{@issue.title} (##{@issue.iid})") + } + end end end diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb index 325996e2e1..55bb4f6527 100644 --- a/app/mailers/emails/merge_requests.rb +++ b/app/mailers/emails/merge_requests.rb @@ -3,50 +3,43 @@ module Emails def new_merge_request_email(recipient_id, merge_request_id) setup_merge_request_mail(merge_request_id, recipient_id) - mail_new_thread(@merge_request, - from: sender(@merge_request.author_id), - to: recipient(recipient_id), - subject: subject("#{@merge_request.title} (##{@merge_request.iid})")) + mail_new_thread(@merge_request, merge_request_thread_options(@merge_request.author_id, recipient_id)) end def reassigned_merge_request_email(recipient_id, merge_request_id, previous_assignee_id, updated_by_user_id) setup_merge_request_mail(merge_request_id, recipient_id) @previous_assignee = User.find_by(id: previous_assignee_id) if previous_assignee_id - mail_answer_thread(@merge_request, - from: sender(updated_by_user_id), - to: recipient(recipient_id), - subject: subject("#{@merge_request.title} (##{@merge_request.iid})")) + mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id)) + end + + def relabeled_merge_request_email(recipient_id, merge_request_id, label_names, updated_by_user_id) + setup_merge_request_mail(merge_request_id, recipient_id) + + @label_names = label_names + @labels_url = namespace_project_labels_url(@project.namespace, @project) + mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id)) end def closed_merge_request_email(recipient_id, merge_request_id, updated_by_user_id) setup_merge_request_mail(merge_request_id, recipient_id) - @updated_by = User.find updated_by_user_id - mail_answer_thread(@merge_request, - from: sender(updated_by_user_id), - to: recipient(recipient_id), - subject: subject("#{@merge_request.title} (##{@merge_request.iid})")) + @updated_by = User.find(updated_by_user_id) + mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id)) end def merged_merge_request_email(recipient_id, merge_request_id, updated_by_user_id) setup_merge_request_mail(merge_request_id, recipient_id) - mail_answer_thread(@merge_request, - from: sender(updated_by_user_id), - to: recipient(recipient_id), - subject: subject("#{@merge_request.title} (##{@merge_request.iid})")) + mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id)) end def merge_request_status_email(recipient_id, merge_request_id, status, updated_by_user_id) setup_merge_request_mail(merge_request_id, recipient_id) @mr_status = status - @updated_by = User.find updated_by_user_id - mail_answer_thread(@merge_request, - from: sender(updated_by_user_id), - to: recipient(recipient_id), - subject: subject("#{@merge_request.title} (##{@merge_request.iid})")) + @updated_by = User.find(updated_by_user_id) + mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id)) end private @@ -54,11 +47,17 @@ module Emails def setup_merge_request_mail(merge_request_id, recipient_id) @merge_request = MergeRequest.find(merge_request_id) @project = @merge_request.project - @target_url = namespace_project_merge_request_url(@project.namespace, - @project, - @merge_request) + @target_url = namespace_project_merge_request_url(@project.namespace, @project, @merge_request) @sent_notification = SentNotification.record(@merge_request, recipient_id, reply_key) end + + def merge_request_thread_options(sender_id, recipient_id) + { + from: sender(sender_id), + to: recipient(recipient_id), + subject: subject("#{@merge_request.title} (##{@merge_request.iid})") + } + end end end diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb index 3a83b08310..256cbcd73a 100644 --- a/app/mailers/emails/profile.rb +++ b/app/mailers/emails/profile.rb @@ -14,7 +14,10 @@ module Emails end def new_ssh_key_email(key_id) - @key = Key.find(key_id) + @key = Key.find_by_id(key_id) + + return unless @key + @current_user = @user = @key.user @target_url = user_url(@user) mail(to: @user.notification_email, subject: subject("SSH key was added to your account")) diff --git a/app/models/ability.rb b/app/models/ability.rb index fe9e0aab71..e22da4806e 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -49,7 +49,6 @@ class Ability rules = [ :read_project, :read_wiki, - :read_issue, :read_label, :read_milestone, :read_project_snippet, @@ -63,6 +62,9 @@ class Ability # Allow to read builds by anonymous user if guests are allowed rules << :read_build if project.public_builds? + # Allow to read issues by anonymous user if issue is not confidential + rules << :read_issue unless subject.is_a?(Issue) && subject.confidential? + rules - project_disabled_features_rules(project) else [] @@ -109,23 +111,10 @@ class Ability key = "/user/#{user.id}/project/#{project.id}" RequestStore.store[key] ||= begin - team = project.team + # Push abilities on the users team role + rules.push(*project_team_rules(project.team, user)) - # Rules based on role in project - if team.master?(user) - rules.push(*project_master_rules) - - elsif team.developer?(user) - rules.push(*project_dev_rules) - - elsif team.reporter?(user) - rules.push(*project_report_rules) - - elsif team.guest?(user) - rules.push(*project_guest_rules) - end - - if project.public? || project.internal? + if project.public? || (project.internal? && !user.external?) rules.push(*public_project_rules) # Allow to read builds for internal projects @@ -148,6 +137,19 @@ class Ability end end + def project_team_rules(team, user) + # Rules based on role in project + if team.master?(user) + project_master_rules + elsif team.developer?(user) + project_dev_rules + elsif team.reporter?(user) + project_report_rules + elsif team.guest?(user) + project_guest_rules + end + end + def public_project_rules @public_project_rules ||= project_guest_rules + [ :download_code, @@ -321,6 +323,7 @@ class Ability end rules += project_abilities(user, subject.project) + rules = filter_confidential_issues_abilities(user, subject, rules) if subject.is_a?(Issue) rules end end @@ -356,7 +359,7 @@ class Ability ] end - if snippet.public? || snippet.internal? + if snippet.public? || (snippet.internal? && !user.external?) rules << :read_personal_snippet end @@ -439,5 +442,17 @@ class Ability :"admin_#{name}" ] end + + def filter_confidential_issues_abilities(user, issue, rules) + return rules if user.admin? || !issue.confidential? + + unless issue.author == user || issue.assignee == user || issue.project.team.member?(user.id) + rules.delete(:admin_issue) + rules.delete(:read_issue) + rules.delete(:update_issue) + end + + rules + end end end diff --git a/app/models/abuse_report.rb b/app/models/abuse_report.rb index cc59aa4e91..b61f512312 100644 --- a/app/models/abuse_report.rb +++ b/app/models/abuse_report.rb @@ -19,9 +19,9 @@ class AbuseReport < ActiveRecord::Base validates :message, presence: true validates :user_id, uniqueness: { message: 'has already been reported' } - def remove_user + def remove_user(deleted_by:) user.block - user.destroy + DeleteUserWorker.perform_async(deleted_by.id, user.id, delete_solo_owned_groups: true) end def notify diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 3b1aa0f5c8..3377a85a55 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -114,7 +114,7 @@ class CommitStatus < ActiveRecord::Base end def ignored? - failed? && allow_failure? + allow_failure? && (failed? || canceled?) end def duration diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 3c42f58293..86ab84615b 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -8,6 +8,7 @@ module Issuable extend ActiveSupport::Concern include Participable include Mentionable + include Subscribable include StripAttribute included do @@ -18,7 +19,6 @@ module Issuable has_many :notes, as: :noteable, dependent: :destroy has_many :label_links, as: :target, dependent: :destroy has_many :labels, through: :label_links - has_many :subscriptions, dependent: :destroy, as: :subscribable validates :author, presence: true validates :title, presence: true, length: { within: 0..255 } @@ -149,28 +149,10 @@ module Issuable notes.awards.where(note: "thumbsup").count end - def subscribed?(user) - subscription = subscriptions.find_by_user_id(user.id) - - if subscription - return subscription.subscribed - end - + def subscribed_without_subscriptions?(user) participants(user).include?(user) end - def toggle_subscription(user) - subscriptions. - find_or_initialize_by(user_id: user.id). - update(subscribed: !subscribed?(user)) - end - - def unsubscribe(user) - subscriptions. - find_or_initialize_by(user_id: user.id). - update(subscribed: false) - end - def to_hook_data(user) hook_data = { object_kind: self.class.name.underscore, diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb index d67df7c1d9..5b8e3f654e 100644 --- a/app/models/concerns/milestoneish.rb +++ b/app/models/concerns/milestoneish.rb @@ -1,18 +1,18 @@ module Milestoneish - def closed_items_count - issues.closed.size + merge_requests.closed_and_merged.size + def closed_items_count(user = nil) + issues_visible_to_user(user).closed.size + merge_requests.closed_and_merged.size end - def total_items_count - issues.size + merge_requests.size + def total_items_count(user = nil) + issues_visible_to_user(user).size + merge_requests.size end - def complete? - total_items_count == closed_items_count + def complete?(user = nil) + total_items_count(user) == closed_items_count(user) end - def percent_complete - ((closed_items_count * 100) / total_items_count).abs + def percent_complete(user = nil) + ((closed_items_count(user) * 100) / total_items_count(user)).abs rescue ZeroDivisionError 0 end @@ -22,4 +22,8 @@ module Milestoneish (due_date - Date.today).to_i end + + def issues_visible_to_user(user = nil) + issues.visible_to_user(user) + end end diff --git a/app/models/concerns/subscribable.rb b/app/models/concerns/subscribable.rb new file mode 100644 index 0000000000..d5a881b244 --- /dev/null +++ b/app/models/concerns/subscribable.rb @@ -0,0 +1,44 @@ +# == Subscribable concern +# +# Users can subscribe to these models. +# +# Used by Issue, MergeRequest, Label +# + +module Subscribable + extend ActiveSupport::Concern + + included do + has_many :subscriptions, dependent: :destroy, as: :subscribable + end + + def subscribed?(user) + if subscription = subscriptions.find_by_user_id(user.id) + subscription.subscribed + else + subscribed_without_subscriptions?(user) + end + end + + # Override this method to define custom logic to consider a subscribable as + # subscribed without an explicit subscription record. + def subscribed_without_subscriptions?(user) + false + end + + def subscribers + subscriptions.where(subscribed: true).map(&:user) + end + + def toggle_subscription(user) + subscriptions. + find_or_initialize_by(user_id: user.id). + update(subscribed: !subscribed?(user)) + end + + def unsubscribe(user) + subscriptions. + find_or_initialize_by(user_id: user.id). + update(subscribed: false) + end +end diff --git a/app/models/event.rb b/app/models/event.rb index 9a0bbf50f8..a5cfeaf388 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -73,15 +73,17 @@ class Event < ActiveRecord::Base end end - def proper? + def proper?(user = nil) if push? true elsif membership_changed? true elsif created_project? true + elsif issue? + Ability.abilities.allowed?(user, :read_issue, issue) else - ((issue? || merge_request? || note?) && target) || milestone? + ((merge_request? || note?) && target) || milestone? end end diff --git a/app/models/issue.rb b/app/models/issue.rb index 5f58c0508f..053387cffd 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -58,6 +58,13 @@ class Issue < ActiveRecord::Base attributes end + def self.visible_to_user(user) + return where(confidential: false) if user.blank? + return all if user.admin? + + where('issues.confidential = false OR (issues.confidential = true AND (issues.author_id = :user_id OR issues.assignee_id = :user_id OR issues.project_id IN(:project_ids)))', user_id: user.id, project_ids: user.authorized_projects.select(:id)) + end + def self.reference_prefix '#' end @@ -87,11 +94,21 @@ class Issue < ActiveRecord::Base end def referenced_merge_requests(current_user = nil) - Gitlab::ReferenceExtractor.lazily do - [self, *notes].flat_map do |note| - note.all_references(current_user).merge_requests - end - end.sort_by(&:iid) + @referenced_merge_requests ||= {} + @referenced_merge_requests[current_user] ||= begin + Gitlab::ReferenceExtractor.lazily do + [self, *notes].flat_map do |note| + note.all_references(current_user).merge_requests + end + end.sort_by(&:iid).uniq + end + end + + def related_branches + return [] if self.project.empty_repo? + self.project.repository.branch_names.select do |branch| + branch =~ /\A#{iid}-(?!\d+-stable)/i + end end # Reset issue events cache @@ -120,4 +137,15 @@ class Issue < ActiveRecord::Base note.all_references(current_user).merge_requests end.uniq.select { |mr| mr.open? && mr.closes_issue?(self) } end + + def to_branch_name + "#{iid}-#{title.parameterize}" + end + + def can_be_worked_on?(current_user) + !self.closed? && + !self.project.forked? && + self.related_branches.empty? && + self.closed_by_merge_requests(current_user).empty? + end end diff --git a/app/models/key.rb b/app/models/key.rb index 406a1257b5..0282ad1813 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -16,6 +16,7 @@ require 'digest/md5' class Key < ActiveRecord::Base + include AfterCommitQueue include Sortable belongs_to :user @@ -62,7 +63,7 @@ class Key < ActiveRecord::Base end def notify_user - NotificationService.new.new_key(self) + run_after_commit { NotificationService.new.new_key(self) } end def post_create_hook diff --git a/app/models/label.rb b/app/models/label.rb index 5ff644b842..f7ffc0b7f3 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -14,6 +14,8 @@ class Label < ActiveRecord::Base include Referable + include Subscribable + # Represents a "No Label" state used for filtering Issues and Merge # Requests that have no label assigned. LabelStruct = Struct.new(:title, :name) diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 188325045e..a6140b5b04 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -516,11 +516,15 @@ class MergeRequest < ActiveRecord::Base end def target_sha - @target_sha ||= target_project.repository.commit(target_branch).sha + @target_sha ||= target_project.repository.commit(target_branch).try(:sha) end def source_sha - last_commit.try(:sha) + last_commit.try(:sha) || source_tip.try(:sha) + end + + def source_tip + source_branch && source_project.repository.commit(source_branch) end def fetch_ref @@ -568,8 +572,11 @@ class MergeRequest < ActiveRecord::Base end def compute_diverged_commits_count + return 0 unless source_sha && target_sha + Gitlab::Git::Commit.between(target_project.repository.raw_repository, source_sha, target_sha).size end + private :compute_diverged_commits_count def diverged_from_target_branch? diverged_commits_count > 0 diff --git a/app/models/milestone.rb b/app/models/milestone.rb index 374590ba0c..de7183bf6b 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -121,8 +121,8 @@ class Milestone < ActiveRecord::Base active? && issues.opened.count.zero? end - def is_empty? - total_items_count.zero? + def is_empty?(user = nil) + total_items_count(user).zero? end def author_id diff --git a/app/models/project.rb b/app/models/project.rb index 79e0cc7b23..412c6c6732 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -254,12 +254,6 @@ class Project < ActiveRecord::Base where('projects.last_activity_at < ?', 6.months.ago) end - def publicish(user) - visibility_levels = [Project::PUBLIC] - visibility_levels << Project::INTERNAL if user - where(visibility_level: visibility_levels) - end - def with_push joins(:events).where('events.action = ?', Event::PUSHED) end @@ -286,7 +280,14 @@ class Project < ActiveRecord::Base or(ptable[:description].matches(pattern)) ) + # We explicitly remove any eager loading clauses as they're: + # + # 1. Not needed by this query + # 2. Combined with .joins(:namespace) lead to all columns from the + # projects & namespaces tables being selected, leading to a SQL error + # due to the columns of all UNION'd queries no longer being the same. namespaces = select(:id). + except(:includes). joins(:namespace). where(ntable[:name].matches(pattern)) @@ -508,6 +509,7 @@ class Project < ActiveRecord::Base end def external_issue_tracker + return @external_issue_tracker if defined?(@external_issue_tracker) @external_issue_tracker ||= services.issue_trackers.active.without_defaults.first end @@ -569,10 +571,7 @@ class Project < ActiveRecord::Base end def avatar_in_git - @avatar_file ||= 'logo.png' if repository.blob_at_branch('master', 'logo.png') - @avatar_file ||= 'logo.jpg' if repository.blob_at_branch('master', 'logo.jpg') - @avatar_file ||= 'logo.gif' if repository.blob_at_branch('master', 'logo.gif') - @avatar_file + repository.avatar end def avatar_url diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index c96e6f0b8e..59b1b86d1f 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -2,7 +2,7 @@ class ProjectWiki include Gitlab::ShellAdapter MARKUPS = { - 'Markdown' => :md, + 'Markdown' => :markdown, 'RDoc' => :rdoc, 'AsciiDoc' => :asciidoc } unless defined?(MARKUPS) @@ -47,7 +47,7 @@ class ProjectWiki def wiki @wiki ||= begin Gollum::Wiki.new(path_to_repo) - rescue Gollum::NoSuchPathError + rescue Rugged::OSError create_repo! end end @@ -90,7 +90,7 @@ class ProjectWiki def create_page(title, content, format = :markdown, message = nil) commit = commit_details(:created, message, title) - wiki.write_page(title, format, content, commit) + wiki.write_page(title, format.to_sym, content, commit) update_project_activity rescue Gollum::DuplicatePageError => e @@ -101,7 +101,7 @@ class ProjectWiki def update_page(page, content, format = :markdown, message = nil) commit = commit_details(:updated, message, page.title) - wiki.update_page(page, page.name, format, content, commit) + wiki.update_page(page, page.name, format.to_sym, content, commit) update_project_activity end diff --git a/app/models/repository.rb b/app/models/repository.rb index 6441cd87e8..25d24493f6 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -3,6 +3,10 @@ require 'securerandom' class Repository class CommitError < StandardError; end + # Files to use as a project avatar in case no avatar was uploaded via the web + # UI. + AVATAR_FILES = %w{logo.png logo.jpg logo.gif} + include Gitlab::ShellAdapter attr_accessor :path_with_namespace, :project @@ -223,12 +227,6 @@ class Repository send(key) end end - - branches.each do |branch| - unless cache.exist?(:"diverging_commit_counts_#{branch.name}") - send(:diverging_commit_counts, branch) - end - end end def expire_tags_cache @@ -241,12 +239,13 @@ class Repository @branches = nil end - def expire_cache(branch_name = nil) + def expire_cache(branch_name = nil, revision = nil) cache_keys.each do |key| cache.expire(key) end expire_branch_cache(branch_name) + expire_avatar_cache(branch_name, revision) # This ensures this particular cache is flushed after the first commit to a # new repository. @@ -296,18 +295,6 @@ class Repository @tag_count = nil end - def rebuild_cache - cache_keys.each do |key| - cache.expire(key) - send(key) - end - - branches.each do |branch| - cache.expire(:"diverging_commit_counts_#{branch.name}") - diverging_commit_counts(branch) - end - end - def lookup_cache @lookup_cache ||= {} end @@ -316,6 +303,23 @@ class Repository cache.expire(:branch_names) end + def expire_avatar_cache(branch_name = nil, revision = nil) + # Avatars are pulled from the default branch, thus if somebody pushes to a + # different branch there's no need to expire anything. + return if branch_name && branch_name != root_ref + + # We don't want to flush the cache if the commit didn't actually make any + # changes to any of the possible avatar files. + if revision && commit = self.commit(revision) + return unless commit.diffs. + any? { |diff| AVATAR_FILES.include?(diff.new_path) } + end + + cache.expire(:avatar) + + @avatar = nil + end + # Runs code just before a repository is deleted. def before_delete expire_cache if exists? @@ -350,8 +354,8 @@ class Repository end # Runs code after a new commit has been pushed. - def after_push_commit(branch_name) - expire_cache(branch_name) + def after_push_commit(branch_name, revision) + expire_cache(branch_name, revision) end # Runs code after a new branch has been created. @@ -758,12 +762,15 @@ class Repository def parse_search_result(result) ref = nil filename = nil + basename = nil startline = 0 result.each_line.each_with_index do |line, index| if line =~ /^.*:.*:\d+:/ ref, filename, startline = line.split(':') startline = startline.to_i - index + extname = File.extname(filename) + basename = filename.sub(/#{extname}$/, '') break end end @@ -776,6 +783,7 @@ class Repository OpenStruct.new( filename: filename, + basename: basename, ref: ref, startline: startline, data: data @@ -853,6 +861,14 @@ class Repository end end + def avatar + @avatar ||= cache.fetch(:avatar) do + AVATAR_FILES.find do |file| + blob_at_branch('master', file) + end + end + end + private def cache diff --git a/app/models/subscription.rb b/app/models/subscription.rb index dd75d3ab8b..dd800ce110 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -15,7 +15,7 @@ class Subscription < ActiveRecord::Base belongs_to :user belongs_to :subscribable, polymorphic: true - validates :user_id, + validates :user_id, uniqueness: { scope: [:subscribable_id, :subscribable_type] }, presence: true end diff --git a/app/models/todo.rb b/app/models/todo.rb index 5f91991f78..d85f7bfdf5 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -5,14 +5,15 @@ # id :integer not null, primary key # user_id :integer not null # project_id :integer not null -# target_id :integer not null +# target_id :integer # target_type :string not null # author_id :integer -# note_id :integer # action :integer not null # state :string not null # created_at :datetime # updated_at :datetime +# note_id :integer +# commit_id :string # class Todo < ActiveRecord::Base @@ -27,7 +28,9 @@ class Todo < ActiveRecord::Base delegate :name, :email, to: :author, prefix: true, allow_nil: true - validates :action, :project, :target, :user, presence: true + validates :action, :project, :target_type, :user, presence: true + validates :target_id, presence: true, unless: :for_commit? + validates :commit_id, presence: true, if: :for_commit? default_scope { reorder(id: :desc) } @@ -36,7 +39,7 @@ class Todo < ActiveRecord::Base state_machine :state, initial: :pending do event :done do - transition [:pending, :done] => :done + transition [:pending] => :done end state :pending @@ -50,4 +53,25 @@ class Todo < ActiveRecord::Base target.title end end + + def for_commit? + target_type == "Commit" + end + + # override to return commits, which are not active record + def target + if for_commit? + project.commit(commit_id) rescue nil + else + super + end + end + + def target_reference + if for_commit? + target.short_id + else + target.to_reference + end + end end diff --git a/app/models/user.rb b/app/models/user.rb index 8871b0ab9f..c011af0359 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -59,6 +59,7 @@ # hide_project_limit :boolean default(FALSE) # unlock_token :string # otp_grace_period_started_at :datetime +# external :boolean default(FALSE) # require 'carrierwave/orm/activerecord' @@ -77,6 +78,7 @@ class User < ActiveRecord::Base add_authentication_token_field :authentication_token default_value_for :admin, false + default_value_for :external, false default_value_for :can_create_group, gitlab_config.default_can_create_group default_value_for :can_create_team, false default_value_for :hide_no_ssh_key, false @@ -98,9 +100,6 @@ class User < ActiveRecord::Base # Virtual attribute for authenticating by either username or email attr_accessor :login - # Virtual attributes to define avatar cropping - attr_accessor :avatar_crop_x, :avatar_crop_y, :avatar_crop_size - # # Relations # @@ -166,11 +165,6 @@ class User < ActiveRecord::Base validate :owns_public_email, if: ->(user) { user.public_email_changed? } validates :avatar, file_size: { maximum: 200.kilobytes.to_i } - validates :avatar_crop_x, :avatar_crop_y, :avatar_crop_size, - numericality: { only_integer: true }, - presence: true, - if: ->(user) { user.avatar? && user.avatar_changed? } - before_validation :generate_password, on: :create before_validation :restricted_signup_domains, on: :create before_validation :sanitize_attrs @@ -179,6 +173,7 @@ class User < ActiveRecord::Base after_update :update_emails_with_primary_email, if: ->(user) { user.email_changed? } before_save :ensure_authentication_token + before_save :ensure_external_user_rights after_save :ensure_namespace_correct after_initialize :set_projects_limit after_create :post_create_hook @@ -226,6 +221,7 @@ class User < ActiveRecord::Base # Scopes scope :admins, -> { where(admin: true) } scope :blocked, -> { with_states(:blocked, :ldap_blocked) } + scope :external, -> { where(external: true) } scope :active, -> { with_state(:active) } scope :not_in_project, ->(project) { project.users.present? ? where("id not in (:ids)", ids: project.users.map(&:id) ) : all } scope :without_projects, -> { where('id NOT IN (SELECT DISTINCT(user_id) FROM members)') } @@ -281,6 +277,8 @@ class User < ActiveRecord::Base self.with_two_factor when 'wop' self.without_projects + when 'external' + self.external else self.active end @@ -849,4 +847,11 @@ class User < ActiveRecord::Base def send_devise_notification(notification, *args) devise_mailer.send(notification, self, *args).deliver_later end + + def ensure_external_user_rights + return unless self.external? + + self.can_create_group = false + self.projects_limit = 0 + end end diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index dbd70dc5a4..526760779a 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -62,7 +62,7 @@ class WikiPage # The raw content of this page. def content @attributes[:content] ||= if @page - @page.raw_data + @page.text_data end end diff --git a/app/services/commits/revert_service.rb b/app/services/commits/revert_service.rb index 9cb918d7a2..a3c950ede1 100644 --- a/app/services/commits/revert_service.rb +++ b/app/services/commits/revert_service.rb @@ -9,7 +9,8 @@ module Commits @commit = params[:commit] @create_merge_request = params[:create_merge_request].present? - validate and commit + check_push_permissions unless @create_merge_request + commit rescue Repository::CommitError, Gitlab::Git::Repository::InvalidBlobName, GitHooksService::PreReceiveError, ValidationError, ReversionError => ex error(ex.message) @@ -45,11 +46,11 @@ module Commits end end - def validate + def check_push_permissions allowed = ::Gitlab::GitAccess.new(current_user, project).can_push_to_branch?(@target_branch) unless allowed - raise_error('You are not allowed to push into this branch') + raise ValidationError.new('You are not allowed to push into this branch') end true diff --git a/app/services/delete_user_service.rb b/app/services/delete_user_service.rb index 173e50c920..ce79287e35 100644 --- a/app/services/delete_user_service.rb +++ b/app/services/delete_user_service.rb @@ -5,18 +5,22 @@ class DeleteUserService @current_user = current_user end - def execute(user) - if user.solo_owned_groups.present? + def execute(user, options = {}) + if !options[:delete_solo_owned_groups] && user.solo_owned_groups.present? user.errors[:base] << 'You must transfer ownership or delete groups before you can remove user' - user - else - user.personal_projects.each do |project| - # Skip repository removal because we remove directory with namespace - # that contain all this repositories - ::Projects::DestroyService.new(project, current_user, skip_repo: true).pending_delete! - end - - user.destroy + return user end + + user.solo_owned_groups.each do |group| + DestroyGroupService.new(group, current_user).execute + end + + user.personal_projects.each do |project| + # Skip repository removal because we remove directory with namespace + # that contain all this repositories + ::Projects::DestroyService.new(project, current_user, skip_repo: true).pending_delete! + end + + user.destroy end end diff --git a/app/services/destroy_group_service.rb b/app/services/destroy_group_service.rb index 9189de390a..3c42ac61be 100644 --- a/app/services/destroy_group_service.rb +++ b/app/services/destroy_group_service.rb @@ -6,12 +6,12 @@ class DestroyGroupService end def execute - @group.projects.each do |project| + group.projects.each do |project| # Skip repository removal because we remove directory with namespace # that contain all this repositories ::Projects::DestroyService.new(project, current_user, skip_repo: true).pending_delete! end - @group.destroy + group.destroy end end diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb index bd31a61774..14e2a2c069 100644 --- a/app/services/git_push_service.rb +++ b/app/services/git_push_service.rb @@ -17,7 +17,7 @@ class GitPushService < BaseService # 6. Checks if the project's main language has changed # def execute - @project.repository.after_push_commit(branch_name) + @project.repository.after_push_commit(branch_name, params[:newrev]) if push_remove_branch? @project.repository.after_remove_branch @@ -49,6 +49,8 @@ class GitPushService < BaseService # Update merge requests that may be affected by this push. A new branch # could cause the last commit of a merge request to change. update_merge_requests + + perform_housekeeping end def update_main_language @@ -73,6 +75,13 @@ class GitPushService < BaseService ProjectCacheWorker.perform_async(@project.id) end + def perform_housekeeping + housekeeping = Projects::HousekeepingService.new(@project) + housekeeping.increment! + housekeeping.execute if housekeeping.needed? + rescue Projects::HousekeepingService::LeaseTaken + end + def process_default_branch @push_commits = project.repository.commits(params[:newrev]) @@ -80,7 +89,7 @@ class GitPushService < BaseService project.change_head(branch_name) # Set protection on the default branch if configured - if (current_application_settings.default_branch_protection != PROTECTION_NONE) + if current_application_settings.default_branch_protection != PROTECTION_NONE developers_can_push = current_application_settings.default_branch_protection == PROTECTION_DEV_CAN_PUSH ? true : false @project.protected_branches.create({ name: @project.default_branch, developers_can_push: developers_can_push }) end diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index ca87dca4a7..18f76d3f65 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -11,7 +11,10 @@ class IssuableBaseService < BaseService issuable, issuable.project, current_user, issuable.milestone) end - def create_labels_note(issuable, added_labels, removed_labels) + def create_labels_note(issuable, old_labels) + added_labels = issuable.labels - old_labels + removed_labels = old_labels - issuable.labels + SystemNoteService.change_label( issuable, issuable.project, current_user, added_labels, removed_labels) end @@ -71,20 +74,19 @@ class IssuableBaseService < BaseService end end - def has_changes?(issuable, options = {}) + def has_changes?(issuable, old_labels: []) valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch] attrs_changed = valid_attrs.any? do |attr| issuable.previous_changes.include?(attr.to_s) end - old_labels = options[:old_labels] - labels_changed = old_labels && issuable.labels != old_labels + labels_changed = issuable.labels != old_labels attrs_changed || labels_changed end - def handle_common_system_notes(issuable, options = {}) + def handle_common_system_notes(issuable, old_labels: []) if issuable.previous_changes.include?('title') create_title_change_note(issuable, issuable.previous_changes['title'].first) end @@ -93,9 +95,6 @@ class IssuableBaseService < BaseService create_task_status_note(issuable) end - old_labels = options[:old_labels] - if old_labels && (issuable.labels != old_labels) - create_labels_note(issuable, issuable.labels - old_labels, old_labels - issuable.labels) - end + create_labels_note(issuable, old_labels) if issuable.labels != old_labels end end diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index 51ef9dfe61..3563cbaa99 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -4,8 +4,8 @@ module Issues update(issue) end - def handle_changes(issue, options = {}) - if has_changes?(issue, options) + def handle_changes(issue, old_labels: []) + if has_changes?(issue, old_labels: old_labels) todo_service.mark_pending_todos_as_done(issue, current_user) end @@ -23,6 +23,11 @@ module Issues notification_service.reassigned_issue(issue, current_user) todo_service.reassigned_issue(issue, current_user) end + + added_labels = issue.labels - old_labels + if added_labels.present? + notification_service.relabeled_issue(issue, added_labels, current_user) + end end def reopen_service diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb index 954746a39a..fa34753c4f 100644 --- a/app/services/merge_requests/build_service.rb +++ b/app/services/merge_requests/build_service.rb @@ -47,6 +47,21 @@ module MergeRequests merge_request.title = merge_request.source_branch.titleize.humanize end + # When your branch name starts with an iid followed by a dash this pattern will + # be interpreted as the use wants to close that issue on this project + # Pattern example: 112-fix-mep-mep + # Will lead to appending `Closes #112` to the description + if match = merge_request.source_branch.match(/\A(\d+)-/) + iid = match[1] + closes_issue = "Closes ##{iid}" + + if merge_request.description.present? + merge_request.description << closes_issue.prepend("\n") + else + merge_request.description = closes_issue + end + end + merge_request end diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index 6319ad805b..477c64e737 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -14,8 +14,8 @@ module MergeRequests update(merge_request) end - def handle_changes(merge_request, options = {}) - if has_changes?(merge_request, options) + def handle_changes(merge_request, old_labels: []) + if has_changes?(merge_request, old_labels: old_labels) todo_service.mark_pending_todos_as_done(merge_request, current_user) end @@ -44,6 +44,15 @@ module MergeRequests merge_request.previous_changes.include?('source_branch') merge_request.mark_as_unchecked end + + added_labels = merge_request.labels - old_labels + if added_labels.present? + notification_service.relabeled_merge_request( + merge_request, + added_labels, + current_user + ) + end end def reopen_service diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index ca8a41d93b..19a6779dea 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -24,16 +24,17 @@ class NotificationService end end - # When create an issue we should send next emails: + # When create an issue we should send an email to: # # * issue assignee if their notification level is not Disabled # * project team members with notification level higher then Participating + # * watchers of the issue's labels # def new_issue(issue, current_user) new_resource_email(issue, issue.project, 'new_issue_email') end - # When we close an issue we should send next emails: + # When we close an issue we should send an email to: # # * issue author if their notification level is not Disabled # * issue assignee if their notification level is not Disabled @@ -43,7 +44,7 @@ class NotificationService close_resource_email(issue, issue.project, current_user, 'closed_issue_email') end - # When we reassign an issue we should send next emails: + # When we reassign an issue we should send an email to: # # * issue old assignee if their notification level is not Disabled # * issue new assignee if their notification level is not Disabled @@ -52,16 +53,25 @@ class NotificationService reassign_resource_email(issue, issue.project, current_user, 'reassigned_issue_email') end + # When we add labels to an issue we should send an email to: + # + # * watchers of the issue's labels + # + def relabeled_issue(issue, added_labels, current_user) + relabeled_resource_email(issue, added_labels, current_user, 'relabeled_issue_email') + end - # When create a merge request we should send next emails: + # When create a merge request we should send an email to: # # * mr assignee if their notification level is not Disabled + # * project team members with notification level higher then Participating + # * watchers of the mr's labels # def new_merge_request(merge_request, current_user) new_resource_email(merge_request, merge_request.target_project, 'new_merge_request_email') end - # When we reassign a merge_request we should send next emails: + # When we reassign a merge_request we should send an email to: # # * merge_request old assignee if their notification level is not Disabled # * merge_request assignee if their notification level is not Disabled @@ -70,6 +80,14 @@ class NotificationService reassign_resource_email(merge_request, merge_request.target_project, current_user, 'reassigned_merge_request_email') end + # When we add labels to a merge request we should send an email to: + # + # * watchers of the mr's labels + # + def relabeled_merge_request(merge_request, added_labels, current_user) + relabeled_resource_email(merge_request, added_labels, current_user, 'relabeled_merge_request_email') + end + def close_mr(merge_request, current_user) close_resource_email(merge_request, merge_request.target_project, current_user, 'closed_merge_request_email') end @@ -91,7 +109,8 @@ class NotificationService reopen_resource_email( merge_request, merge_request.target_project, - current_user, 'merge_request_status_email', + current_user, + 'merge_request_status_email', 'reopened' ) end @@ -348,19 +367,23 @@ class NotificationService end def add_subscribed_users(recipients, target) - return recipients unless target.respond_to? :subscriptions + return recipients unless target.respond_to? :subscribers - subscriptions = target.subscriptions + recipients + target.subscribers + end - if subscriptions.any? - recipients + subscriptions.where(subscribed: true).map(&:user) - else - recipients + def add_labels_subscribers(recipients, target, labels: nil) + return recipients unless target.respond_to? :labels + + (labels || target.labels).each do |label| + recipients += label.subscribers end + + recipients end def new_resource_email(target, project, method) - recipients = build_recipients(target, project, target.author) + recipients = build_recipients(target, project, target.author, action: :new) recipients.each do |recipient| mailer.send(method, recipient.id, target.id).deliver_later @@ -392,6 +415,15 @@ class NotificationService end end + def relabeled_resource_email(target, labels, current_user, method) + recipients = build_relabeled_recipients(target, current_user, labels: labels) + label_names = labels.map(&:name) + + recipients.each do |recipient| + mailer.send(method, recipient.id, target.id, label_names, current_user.id).deliver_later + end + end + def reopen_resource_email(target, project, current_user, method, status) recipients = build_recipients(target, project, current_user) @@ -416,6 +448,11 @@ class NotificationService recipients = reject_muted_users(recipients, project) recipients = add_subscribed_users(recipients, target) + + if action == :new + recipients = add_labels_subscribers(recipients, target) + end + recipients = reject_unsubscribed_users(recipients, target) recipients.delete(current_user) @@ -423,6 +460,13 @@ class NotificationService recipients.uniq end + def build_relabeled_recipients(target, current_user, labels:) + recipients = add_labels_subscribers([], target, labels: labels) + recipients = reject_unsubscribed_users(recipients, target) + recipients.delete(current_user) + recipients.uniq + end + def mailer Notify end diff --git a/app/services/projects/autocomplete_service.rb b/app/services/projects/autocomplete_service.rb index 7408e09ed1..ba50305dbd 100644 --- a/app/services/projects/autocomplete_service.rb +++ b/app/services/projects/autocomplete_service.rb @@ -1,11 +1,7 @@ module Projects class AutocompleteService < BaseService - def initialize(project) - @project = project - end - def issues - @project.issues.opened.select([:iid, :title]) + @project.issues.visible_to_user(current_user).opened.select([:iid, :title]) end def merge_requests diff --git a/app/services/projects/housekeeping_service.rb b/app/services/projects/housekeeping_service.rb index 0db85ac214..a0973c5d26 100644 --- a/app/services/projects/housekeeping_service.rb +++ b/app/services/projects/housekeeping_service.rb @@ -9,12 +9,39 @@ module Projects class HousekeepingService < BaseService include Gitlab::ShellAdapter + LEASE_TIMEOUT = 3600 + + class LeaseTaken < StandardError + def to_s + "Somebody already triggered housekeeping for this project in the past #{LEASE_TIMEOUT / 60} minutes" + end + end + def initialize(project) @project = project end def execute - GitlabShellWorker.perform_async(:gc, @project.path_with_namespace) + raise LeaseTaken if !try_obtain_lease + + GitlabShellOneShotWorker.perform_async(:gc, @project.path_with_namespace) + ensure + @project.update_column(:pushes_since_gc, 0) + end + + def needed? + @project.pushes_since_gc >= 10 + end + + def increment! + @project.increment!(:pushes_since_gc) + end + + private + + def try_obtain_lease + lease = ::Gitlab::ExclusiveLease.new("project_housekeeping:#{@project.id}", timeout: LEASE_TIMEOUT) + lease.try_obtain end end end diff --git a/app/services/search/global_service.rb b/app/services/search/global_service.rb index e1e94c5cc3..aa9837038a 100644 --- a/app/services/search/global_service.rb +++ b/app/services/search/global_service.rb @@ -11,7 +11,7 @@ module Search projects = ProjectsFinder.new.execute(current_user) projects = projects.in_namespace(group.id) if group - Gitlab::SearchResults.new(projects, params[:search]) + Gitlab::SearchResults.new(current_user, projects, params[:search]) end end end diff --git a/app/services/search/project_service.rb b/app/services/search/project_service.rb index c08881dce4..4b500914cf 100644 --- a/app/services/search/project_service.rb +++ b/app/services/search/project_service.rb @@ -7,7 +7,8 @@ module Search end def execute - Gitlab::ProjectSearchResults.new(project, + Gitlab::ProjectSearchResults.new(current_user, + project, params[:search], params[:repository_ref]) end diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 58a861ee08..f09b77c4a5 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -207,6 +207,18 @@ class SystemNoteService create_note(noteable: noteable, project: project, author: author, note: body) end + # Called when a branch is created from the 'new branch' button on a issue + # Example note text: + # + # "Started branch `201-issue-branch-button`" + def self.new_issue_branch(issue, project, author, branch) + h = Gitlab::Application.routes.url_helpers + link = h.namespace_project_compare_url(project.namespace, project, from: project.default_branch, to: branch) + + body = "Started branch [`#{branch}`](#{link})" + create_note(noteable: issue, project: project, author: author, note: body) + end + # Called when a Mentionable references a Noteable # # noteable - Noteable object being referenced diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index 4392e2d17f..f2662922e9 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -103,24 +103,16 @@ class TodoService # * mark all pending todos related to the target for the current user as done # def mark_pending_todos_as_done(target, user) - pending_todos(user, target.project, target).update_all(state: :done) + attributes = attributes_for_target(target) + pending_todos(user, attributes).update_all(state: :done) end private - def create_todos(project, target, author, users, action, note = nil) + def create_todos(users, attributes) Array(users).each do |user| - next if pending_todos(user, project, target).exists? - - Todo.create( - project: project, - user_id: user.id, - author_id: author.id, - target_id: target.id, - target_type: target.class.name, - action: action, - note: note - ) + next if pending_todos(user, attributes).exists? + Todo.create(attributes.merge(user_id: user.id)) end end @@ -130,8 +122,8 @@ class TodoService end def handle_note(note, author) - # Skip system notes, notes on commit, and notes on project snippet - return if note.system? || ['Commit', 'Snippet'].include?(note.noteable_type) + # Skip system notes, and notes on project snippet + return if note.system? || note.for_project_snippet? project = note.project target = note.noteable @@ -142,13 +134,39 @@ class TodoService def create_assignment_todo(issuable, author) if issuable.assignee && issuable.assignee != author - create_todos(issuable.project, issuable, author, issuable.assignee, Todo::ASSIGNED) + attributes = attributes_for_todo(issuable.project, issuable, author, Todo::ASSIGNED) + create_todos(issuable.assignee, attributes) end end - def create_mention_todos(project, issuable, author, note = nil) - mentioned_users = filter_mentioned_users(project, note || issuable, author) - create_todos(project, issuable, author, mentioned_users, Todo::MENTIONED, note) + def create_mention_todos(project, target, author, note = nil) + mentioned_users = filter_mentioned_users(project, note || target, author) + attributes = attributes_for_todo(project, target, author, Todo::MENTIONED, note) + create_todos(mentioned_users, attributes) + end + + def attributes_for_target(target) + attributes = { + project_id: target.project.id, + target_id: target.id, + target_type: target.class.name, + commit_id: nil + } + + if target.is_a?(Commit) + attributes.merge!(target_id: nil, commit_id: target.id) + end + + attributes + end + + def attributes_for_todo(project, target, author, action, note = nil) + attributes_for_target(target).merge!( + project_id: project.id, + author_id: author.id, + action: action, + note: note + ) end def filter_mentioned_users(project, target, author) @@ -160,11 +178,8 @@ class TodoService mentioned_users.uniq end - def pending_todos(user, project, target) - user.todos.pending.where( - project_id: project.id, - target_id: target.id, - target_type: target.class.name - ) + def pending_todos(user, criteria = {}) + valid_keys = [:project_id, :target_id, :target_type, :commit_id] + user.todos.pending.where(criteria.slice(*valid_keys)) end end diff --git a/app/uploaders/avatar_uploader.rb b/app/uploaders/avatar_uploader.rb index 2c72df44ff..6135c3ad96 100644 --- a/app/uploaders/avatar_uploader.rb +++ b/app/uploaders/avatar_uploader.rb @@ -2,22 +2,11 @@ class AvatarUploader < CarrierWave::Uploader::Base include UploaderHelper - include CarrierWave::MiniMagick storage :file after :store, :reset_events_cache - process :cropper - - def cropper - return unless model.respond_to?(:avatar_crop_size) && model.valid? - - manipulate! do |img| - img.crop "#{model.avatar_crop_size}x#{model.avatar_crop_size}+#{model.avatar_crop_x}+#{model.avatar_crop_y}" - end - end - def store_dir "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}" end diff --git a/app/views/admin/users/_form.html.haml b/app/views/admin/users/_form.html.haml index e18dd9bc90..d2527ede99 100644 --- a/app/views/admin/users/_form.html.haml +++ b/app/views/admin/users/_form.html.haml @@ -58,9 +58,15 @@ = f.label :admin, class: 'control-label' - if current_user == @user .col-sm-10= f.check_box :admin, disabled: true - .col-sm-10 You cannot remove your own admin rights + .col-sm-10 You cannot remove your own admin rights. - else .col-sm-10= f.check_box :admin + + .form-group + = f.label :external, class: 'control-label' + .col-sm-10= f.check_box :external + .col-sm-10 External users cannot see internal or private projects unless access is explicitly granted. Also, external users cannot create projects or groups. + %fieldset %legend Profile .form-group diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml index b6b1168bd3..0ee8dc962b 100644 --- a/app/views/admin/users/index.html.haml +++ b/app/views/admin/users/index.html.haml @@ -19,6 +19,10 @@ = link_to admin_users_path(filter: 'two_factor_disabled') do 2FA Disabled %small.badge= number_with_delimiter(User.without_two_factor.count) + %li.filter-external{class: "#{'active' if params[:filter] == 'external'}"} + = link_to admin_users_path(filter: 'external') do + External + %small.badge= number_with_delimiter(User.external.count) %li{class: "#{'active' if params[:filter] == "blocked"}"} = link_to admin_users_path(filter: "blocked") do Blocked @@ -70,12 +74,14 @@ %li .list-item-name - if user.blocked? - %i.fa.fa-lock.cred + = icon("lock", class: "cred") - else - %i.fa.fa-user.cgreen + = icon("user", class: "cgreen") = link_to user.name, [:admin, user] - if user.admin? %strong.cred (Admin) + - if user.external? + %strong.cred (External) - if user == current_user %span.cred It's you! .pull-right diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml index 2bdbae1958..d37489bebe 100644 --- a/app/views/admin/users/show.html.haml +++ b/app/views/admin/users/show.html.haml @@ -47,6 +47,10 @@ - else Disabled + %li + %span.light External User: + %strong + = @user.external? ? "Yes" : "No" %li %span.light Can create groups: %strong diff --git a/app/views/dashboard/projects/_zero_authorized_projects.html.haml b/app/views/dashboard/projects/_zero_authorized_projects.html.haml index c3efa7727b..d54c7cad7b 100644 --- a/app/views/dashboard/projects/_zero_authorized_projects.html.haml +++ b/app/views/dashboard/projects/_zero_authorized_projects.html.haml @@ -1,4 +1,4 @@ -- publicish_project_count = Project.publicish(current_user).count +- publicish_project_count = ProjectsFinder.new.execute(current_user).count %h3.page-title Welcome to GitLab! %p.light Self hosted Git management application. %hr @@ -18,7 +18,7 @@ - if current_user.can_create_project? .link_holder = link_to new_project_path, class: "btn btn-new" do - %i.fa.fa-plus + = icon('plus') New Project - if current_user.can_create_group? diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml index 45cfe3da18..e3a4d64df0 100644 --- a/app/views/dashboard/todos/_todo.html.haml +++ b/app/views/dashboard/todos/_todo.html.haml @@ -2,7 +2,7 @@ .todo-item.todo-block = image_tag avatar_icon(todo.author_email, 40), class: 'avatar s40', alt:'' - .todo-title + .todo-title.title %span.author-name - if todo.author = link_to_author(todo) @@ -16,7 +16,9 @@ - if todo.pending? .todo-actions.pull-right - = link_to 'Done', [:dashboard, todo], method: :delete, class: 'btn' + = link_to [:dashboard, todo], method: :delete, class: 'btn btn-loading done-todo' do + Done + = icon('spinner spin') .todo-body .todo-note diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml index 946d7df393..f9ec3a8915 100644 --- a/app/views/dashboard/todos/index.html.haml +++ b/app/views/dashboard/todos/index.html.haml @@ -3,13 +3,15 @@ .top-area %ul.nav-links - %li{class: ('active' if params[:state].blank? || params[:state] == 'pending')} + - todo_pending_active = ('active' if params[:state].blank? || params[:state] == 'pending') + %li{class: "todos-pending #{todo_pending_active}"} = link_to todos_filter_path(state: 'pending') do %span To do %span{class: 'badge'} = todos_pending_count - %li{class: ('active' if params[:state] == 'done')} + - todo_done_active = ('active' if params[:state] == 'done') + %li{class: "todos-done #{todo_done_active}"} = link_to todos_filter_path(state: 'done') do %span Done @@ -18,7 +20,9 @@ .nav-controls - if @todos.any?(&:pending?) - = link_to 'Mark all as done', destroy_all_dashboard_todos_path(todos_filter_params), class: 'btn', method: :delete + = link_to destroy_all_dashboard_todos_path(todos_filter_params), class: 'btn btn-loading js-todos-mark-all', method: :delete do + Mark all as done + = icon('spinner spin') .todos-filters .gray-content-block.second-block @@ -42,12 +46,12 @@ .prepend-top-default - if @todos.any? - @todos.group_by(&:project).each do |group| - .panel.panel-default.panel-small + .panel.panel-default.panel-small.js-todos-list - project = group[0] .panel-heading = link_to project.name_with_namespace, namespace_project_path(project.namespace, project) - %ul.well-list.todos-list + %ul.content-list.todos-list = render group[1] = paginate @todos, theme: "gitlab" - else diff --git a/app/views/doorkeeper/applications/_delete_form.html.haml b/app/views/doorkeeper/applications/_delete_form.html.haml index 6a5c917049..001a711b1d 100644 --- a/app/views/doorkeeper/applications/_delete_form.html.haml +++ b/app/views/doorkeeper/applications/_delete_form.html.haml @@ -1,4 +1,10 @@ - submit_btn_css ||= 'btn btn-link btn-remove btn-sm' = form_tag oauth_application_path(application) do %input{:name => "_method", :type => "hidden", :value => "delete"}/ - = submit_tag 'Destroy', onclick: "return confirm('Are you sure?')", class: submit_btn_css \ No newline at end of file + - if defined? small + = button_tag type: "submit", class: "btn btn-transparent", data: { confirm: "Are you sure?" } do + %span.sr-only + Destroy + = icon('trash') + - else + = submit_tag 'Destroy', data: { confirm: "Are you sure?" }, class: submit_btn_css diff --git a/app/views/doorkeeper/applications/_form.html.haml b/app/views/doorkeeper/applications/_form.html.haml index 98a61ab211..906b067615 100644 --- a/app/views/doorkeeper/applications/_form.html.haml +++ b/app/views/doorkeeper/applications/_form.html.haml @@ -1,4 +1,4 @@ -= form_for application, url: doorkeeper_submit_path(application), html: {class: 'form-horizontal', role: 'form'} do |f| += form_for application, url: doorkeeper_submit_path(application), html: {role: 'form'} do |f| - if application.errors.any? .alert.alert-danger %ul @@ -6,25 +6,20 @@ %li= msg .form-group - = f.label :name, class: 'control-label' - - .col-sm-10 - = f.text_field :name, class: 'form-control', required: true + = f.label :name, class: 'label-light' + = f.text_field :name, class: 'form-control', required: true .form-group - = f.label :redirect_uri, class: 'control-label' - - .col-sm-10 - = f.text_area :redirect_uri, class: 'form-control', required: true + = f.label :redirect_uri, class: 'label-light' + = f.text_area :redirect_uri, class: 'form-control', required: true + %span.help-block + Use one line per URI + - if Doorkeeper.configuration.native_redirect_uri %span.help-block - Use one line per URI - - if Doorkeeper.configuration.native_redirect_uri - %span.help-block - Use - %code= Doorkeeper.configuration.native_redirect_uri - for local tests + Use + %code= Doorkeeper.configuration.native_redirect_uri + for local tests - .form-actions - = f.submit 'Submit', class: "btn btn-create" - = link_to "Cancel", applications_profile_path, class: "btn btn-cancel" + .prepend-top-default + = f.submit 'Save application', class: "btn btn-create" diff --git a/app/views/doorkeeper/applications/index.html.haml b/app/views/doorkeeper/applications/index.html.haml index ba4c5b86ef..ea0b66c932 100644 --- a/app/views/doorkeeper/applications/index.html.haml +++ b/app/views/doorkeeper/applications/index.html.haml @@ -1,19 +1,83 @@ - page_title "Applications" -%h3.page-title Your applications -%p= link_to 'New Application', new_oauth_application_path, class: 'btn btn-success' +- header_title page_title, applications_profile_path -.table-holder - %table.table.table-striped - %thead - %tr - %th Name - %th Callback URL - %th - %th - %tbody - - @applications.each do |application| - %tr{:id => "application_#{application.id}"} - %td= link_to application.name, oauth_application_path(application) - %td= application.redirect_uri - %td= link_to 'Edit', edit_oauth_application_path(application), class: 'btn btn-link' - %td= render 'delete_form', application: application +.row.prepend-top-default + .col-lg-3.profile-settings-sidebar + %h4.prepend-top-0 + = page_title + %p + - if user_oauth_applications? + Manage applications that can use GitLab as an OAuth provider, + and applications that you've authorized to use your account. + - else + Manage applications that you've authorized to use your account. + .col-lg-9 + - if user_oauth_applications? + %h5.prepend-top-0 + Add new application + = render 'form', application: @application + %hr + - if user_oauth_applications? + .oauth-applications + %h5 + Your applications (#{@applications.size}) + - if @applications.any? + .table-responsive + %table.table + %thead + %tr + %th Name + %th Callback URL + %th Clients + %th.last-heading + %tbody + - @applications.each do |application| + %tr{id: "application_#{application.id}"} + %td= link_to application.name, oauth_application_path(application) + %td + - application.redirect_uri.split.each do |uri| + %div= uri + %td= application.access_tokens.count + %td + = link_to edit_oauth_application_path(application), class: "btn btn-transparent append-right-5" do + %span.sr-only + Edit + = icon('pencil') + = render 'delete_form', application: application, small: true + - else + .profile-settings-message.text-center + You don't have any applications + .oauth-authorized-applications.prepend-top-20.append-bottom-default + - if user_oauth_applications? + %h5 + Authorized applications (#{@authorized_tokens.size}) + + - if @authorized_tokens.any? + .table-responsive + %table.table.table-striped + %thead + %tr + %th Name + %th Authorized At + %th Scope + %th + %tbody + - @authorized_apps.each do |app| + - token = app.authorized_tokens.order('created_at desc').first + %tr{id: "application_#{app.id}"} + %td= app.name + %td= token.created_at + %td= token.scopes + %td= render 'delete_form', application: app + - @authorized_anonymous_tokens.each do |token| + %tr + %td + Anonymous + %div.help-block + %em Authorization was granted by entering your username and password in the application. + %td= token.created_at + %td= token.scopes + %td= render 'delete_form', token: token + - else + .profile-settings-message.text-center + You don't have any authorized applications diff --git a/app/views/events/_commit.html.haml b/app/views/events/_commit.html.haml index 4ba8b84fd9..dce4081288 100644 --- a/app/views/events/_commit.html.haml +++ b/app/views/events/_commit.html.haml @@ -1,5 +1,5 @@ %li.commit .commit-row-title - = link_to truncate_sha(commit[:id]), namespace_project_commit_path(project.namespace, project, commit[:id]), class: "commit_short_id", alt: '' + = link_to truncate_sha(commit[:id]), namespace_project_commit_path(project.namespace, project, commit[:id]), class: "commit_short_id", alt: '', title: truncate_sha(commit[:id]) · = markdown event_commit_title(commit[:message]), project: project, pipeline: :single_line diff --git a/app/views/events/_event.html.haml b/app/views/events/_event.html.haml index 36fb2d5162..2d9d9dd634 100644 --- a/app/views/events/_event.html.haml +++ b/app/views/events/_event.html.haml @@ -1,4 +1,4 @@ -- if event.proper? +- if event.proper?(current_user) .event-item{class: "#{event.body? ? "event-block" : "event-inline" }"} .event-item-timestamp #{time_ago_with_tooltip(event.created_at)} diff --git a/app/views/events/_event_last_push.html.haml b/app/views/events/_event_last_push.html.haml index abea86b026..5753158c24 100644 --- a/app/views/events/_event_last_push.html.haml +++ b/app/views/events/_event_last_push.html.haml @@ -3,7 +3,7 @@ .event-last-push .event-last-push-text %span You pushed to - = link_to namespace_project_commits_path(event.project.namespace, event.project, event.ref_name) do + = link_to namespace_project_commits_path(event.project.namespace, event.project, event.ref_name), title: h(event.project.name) do %strong= event.ref_name %span at %strong= link_to_project event.project diff --git a/app/views/events/event/_push.html.haml b/app/views/events/event/_push.html.haml index 8bed5cdb9c..235bd46107 100644 --- a/app/views/events/event/_push.html.haml +++ b/app/views/events/event/_push.html.haml @@ -5,7 +5,7 @@ %strong= event.ref_name - else %strong - = link_to event.ref_name, namespace_project_commits_path(event.project.namespace, event.project, event.ref_name) + = link_to event.ref_name, namespace_project_commits_path(event.project.namespace, event.project, event.ref_name), title: h(event.target_title) at = link_to_project event.project diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index 77d01a7736..f3090b9670 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -46,6 +46,8 @@ %h1.title= title = render 'shared/outdated_browser' + - if @project && !@project.empty_repo? - :javascript - var findFileURL = "#{namespace_project_find_file_path(@project.namespace, @project, @ref || @project.repository.root_ref)}"; + - if ref = @ref || @project.repository.root_ref + :javascript + var findFileURL = "#{namespace_project_find_file_path(@project.namespace, @project, ref)}"; diff --git a/app/views/layouts/nav/_profile.html.haml b/app/views/layouts/nav/_profile.html.haml index f3ded04419..3b9d31a6fc 100644 --- a/app/views/layouts/nav/_profile.html.haml +++ b/app/views/layouts/nav/_profile.html.haml @@ -17,7 +17,7 @@ = icon('gear fw') %span Account - = nav_link(path: ['profiles#applications', 'applications#edit', 'applications#show', 'applications#new', 'applications#create']) do + = nav_link(controller: 'oauth/applications') do = link_to applications_profile_path, title: 'Applications' do = icon('cloud fw') %span diff --git a/app/views/layouts/nav/_project.html.haml b/app/views/layouts/nav/_project.html.haml index 0ae83ee01e..86b46e8c75 100644 --- a/app/views/layouts/nav/_project.html.haml +++ b/app/views/layouts/nav/_project.html.haml @@ -67,7 +67,7 @@ %span Issues - if @project.default_issues_tracker? - %span.count.issue_counter= number_with_delimiter(@project.issues.opened.count) + %span.count.issue_counter= number_with_delimiter(@project.issues.visible_to_user(current_user).opened.count) - if project_nav_tab? :merge_requests = nav_link(controller: :merge_requests) do diff --git a/app/views/layouts/notify.html.haml b/app/views/layouts/notify.html.haml index 325c68c69d..37b4d56296 100644 --- a/app/views/layouts/notify.html.haml +++ b/app/views/layouts/notify.html.haml @@ -42,12 +42,15 @@ - else #{link_to "View it on GitLab", @target_url}. %br - -# Don't link the host is the line below, one link in the email is easier to quickly click than two. + -# Don't link the host in the line below, one link in the email is easier to quickly click than two. You're receiving this email because of your account on #{Gitlab.config.gitlab.host}. If you'd like to receive fewer emails, you can - - if @sent_notification && @sent_notification.unsubscribable? - = link_to "unsubscribe", unsubscribe_sent_notification_url(@sent_notification) - from this thread or - adjust your notification settings. + - if @labels_url + adjust your #{link_to 'label subscriptions', @labels_url}. + - else + - if @sent_notification && @sent_notification.unsubscribable? + = link_to "unsubscribe", unsubscribe_sent_notification_url(@sent_notification) + from this thread or + adjust your notification settings. = email_action @target_url diff --git a/app/views/notify/_reassigned_issuable_email.text.erb b/app/views/notify/_reassigned_issuable_email.text.erb index 855d37429d..daf20a226d 100644 --- a/app/views/notify/_reassigned_issuable_email.text.erb +++ b/app/views/notify/_reassigned_issuable_email.text.erb @@ -1,6 +1,6 @@ Reassigned <%= issuable.class.model_name.human.titleize %> <%= issuable.iid %> -<%= url_for([issuable.project.namespace.becomes(Namespace), issuable.project, issuable, {only_path: false}]) %> +<%= url_for([issuable.project.namespace.becomes(Namespace), issuable.project, issuable, { only_path: false }]) %> Assignee changed <%= "from #{@previous_assignee.name}" if @previous_assignee -%> to <%= "#{issuable.assignee_id ? issuable.assignee_name : 'Unassigned'}" %> diff --git a/app/views/notify/_relabeled_issuable_email.html.haml b/app/views/notify/_relabeled_issuable_email.html.haml new file mode 100644 index 0000000000..80a0de255b --- /dev/null +++ b/app/views/notify/_relabeled_issuable_email.html.haml @@ -0,0 +1,3 @@ +%p + #{'Label'.pluralize(@label_names.size)} added: + %em= @label_names.to_sentence diff --git a/app/views/notify/_relabeled_issuable_email.text.erb b/app/views/notify/_relabeled_issuable_email.text.erb new file mode 100644 index 0000000000..6a83d79fd6 --- /dev/null +++ b/app/views/notify/_relabeled_issuable_email.text.erb @@ -0,0 +1,3 @@ +<%= 'Label'.pluralize(@label_names.size) %> added: <%= @label_names.to_sentence %> + +<%= url_for([issuable.project.namespace.becomes(Namespace), issuable.project, issuable, { only_path: false }]) %> diff --git a/app/views/notify/relabeled_issue_email.html.haml b/app/views/notify/relabeled_issue_email.html.haml new file mode 100644 index 0000000000..b17b16e181 --- /dev/null +++ b/app/views/notify/relabeled_issue_email.html.haml @@ -0,0 +1 @@ += render 'relabeled_issuable_email', issuable: @issue diff --git a/app/views/notify/relabeled_issue_email.text.erb b/app/views/notify/relabeled_issue_email.text.erb new file mode 100644 index 0000000000..eeced97f60 --- /dev/null +++ b/app/views/notify/relabeled_issue_email.text.erb @@ -0,0 +1 @@ +<%= render 'relabeled_issuable_email', issuable: @issue %> diff --git a/app/views/notify/relabeled_merge_request_email.html.haml b/app/views/notify/relabeled_merge_request_email.html.haml new file mode 100644 index 0000000000..9eaa9afa5b --- /dev/null +++ b/app/views/notify/relabeled_merge_request_email.html.haml @@ -0,0 +1 @@ += render 'relabeled_issuable_email', issuable: @merge_request diff --git a/app/views/notify/relabeled_merge_request_email.text.erb b/app/views/notify/relabeled_merge_request_email.text.erb new file mode 100644 index 0000000000..87bc80ead3 --- /dev/null +++ b/app/views/notify/relabeled_merge_request_email.text.erb @@ -0,0 +1 @@ +<%= render 'relabeled_issuable_email', issuable: @merge_request %> diff --git a/app/views/profiles/applications.html.haml b/app/views/profiles/applications.html.haml deleted file mode 100644 index 86f3582340..0000000000 --- a/app/views/profiles/applications.html.haml +++ /dev/null @@ -1,70 +0,0 @@ -- page_title "Applications" -- header_title page_title, applications_profile_path - -.alert.alert-help.prepend-top-default - - if user_oauth_applications? - Manage applications that can use GitLab as an OAuth provider, - and applications that you've authorized to use your account. - - else - Manage applications that you've authorized to use your account. - -- if user_oauth_applications? - .oauth-applications - %h3 - Your applications - .pull-right - = link_to 'New Application', new_oauth_application_path, class: 'btn btn-success' - - if @applications.any? - .table-holder - %table.table.table-striped - %thead - %tr - %th Name - %th Callback URL - %th Clients - %th - %th - %tbody - - @applications.each do |application| - %tr{:id => "application_#{application.id}"} - %td= link_to application.name, oauth_application_path(application) - %td - - application.redirect_uri.split.each do |uri| - %div= uri - %td= application.access_tokens.count - %td= link_to 'Edit', edit_oauth_application_path(application), class: 'btn btn-link btn-sm' - %td= render 'doorkeeper/applications/delete_form', application: application - -.oauth-authorized-applications.prepend-top-20 - - if user_oauth_applications? - %h3 - Authorized applications - - - if @authorized_tokens.any? - .table-holder - %table.table.table-striped - %thead - %tr - %th Name - %th Authorized At - %th Scope - %th - %tbody - - @authorized_apps.each do |app| - - token = app.authorized_tokens.order('created_at desc').first - %tr{:id => "application_#{app.id}"} - %td= app.name - %td= token.created_at - %td= token.scopes - %td= render 'doorkeeper/authorized_applications/delete_form', application: app - - @authorized_anonymous_tokens.each do |token| - %tr - %td - Anonymous - %div.help-block - %em Authorization was granted by entering your username and password in the application. - %td= token.created_at - %td= token.scopes - %td= render 'doorkeeper/authorized_applications/delete_form', token: token - - else - %p.light You don't have any authorized applications diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index 3d1ba49491..cd582ba706 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -1,7 +1,4 @@ = form_for @user, url: profile_path, method: :put, html: { multipart: true, class: "edit-user prepend-top-default" }, authenticity_token: true do |f| - = f.hidden_field :avatar_crop_x - = f.hidden_field :avatar_crop_y - = f.hidden_field :avatar_crop_size -if @user.errors.any? %div.alert.alert-danger %ul @@ -97,19 +94,3 @@ .prepend-top-default.append-bottom-default = f.submit 'Update profile settings', class: "btn btn-success" = link_to "Cancel", user_path(current_user), class: "btn btn-cancel" - -.modal.modal-profile-crop - .modal-dialog - .modal-content - .modal-header - %button.close{type: 'button', data: {dismiss: 'modal'}} - %span - × - %h4.modal-title - Crop your new profile picture - .modal-body - %p - %img.modal-profile-crop-image - .modal-footer - %button.btn.btn-primary.js-upload-user-avatar{:type => "button"} - Set new profile picture diff --git a/app/views/projects/_builds_settings.html.haml b/app/views/projects/_builds_settings.html.haml new file mode 100644 index 0000000000..95ab9ecf3e --- /dev/null +++ b/app/views/projects/_builds_settings.html.haml @@ -0,0 +1,60 @@ +%fieldset.builds-feature + %legend + Builds: + .form-group + .col-sm-offset-2.col-sm-10 + %p Get recent application code using the following command: + .radio + = f.label :build_allow_git_fetch_false do + = f.radio_button :build_allow_git_fetch, 'false' + %strong git clone + %br + %span.descr Slower but makes sure you have a clean dir before every build + .radio + = f.label :build_allow_git_fetch_true do + = f.radio_button :build_allow_git_fetch, 'true' + %strong git fetch + %br + %span.descr Faster + + .form-group + = f.label :build_timeout_in_minutes, 'Timeout', class: 'control-label' + .col-sm-10 + = f.number_field :build_timeout_in_minutes, class: 'form-control', min: '0' + %p.help-block per build in minutes + .form-group + = f.label :build_coverage_regex, "Test coverage parsing", class: 'control-label' + .col-sm-10 + .input-group + %span.input-group-addon / + = f.text_field :build_coverage_regex, class: 'form-control', placeholder: '\(\d+.\d+\%\) covered' + %span.input-group-addon / + %p.help-block + We will use this regular expression to find test coverage output in build trace. + Leave blank if you want to disable this feature + .bs-callout.bs-callout-info + %p Below are examples of regex for existing tools: + %ul + %li + Simplecov (Ruby) - + %code \(\d+.\d+\%\) covered + %li + pytest-cov (Python) - + %code \d+\%\s*$ + %li + phpunit --coverage-text --colors=never (PHP) - + %code ^\s*Lines:\s*\d+.\d+\% + + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :public_builds do + = f.check_box :public_builds + %strong Public builds + .help-block Allow everyone to access builds for Public and Internal projects + + .form-group + = f.label :runners_token, "Runners token", class: 'control-label' + .col-sm-10 + = f.text_field :runners_token, class: "form-control", placeholder: 'xEeFCaDAB89' + %p.help-block The secure token used to checkout project. diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index 7123efffd5..d22d1da840 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -1,8 +1,7 @@ %tr.build %td.status - if can?(current_user, :read_build, build) - = link_to namespace_project_build_url(build.project.namespace, build.project, build), class: "ci-status ci-#{build.status}" do - = ci_status_with_icon(build.status) + = ci_status_with_icon(build.status, namespace_project_build_url(build.project.namespace, build.project, build)) - else = ci_status_with_icon(build.status) diff --git a/app/views/projects/diffs/_file.html.haml b/app/views/projects/diffs/_file.html.haml index 3ac058a3bf..dc34032b1b 100644 --- a/app/views/projects/diffs/_file.html.haml +++ b/app/views/projects/diffs/_file.html.haml @@ -42,13 +42,17 @@ .diff-content.diff-wrap-lines -# Skipp all non non-supported blobs - return unless blob.respond_to?('text?') - - if blob_text_viewable?(blob) - - if diff_view == 'parallel' - = render "projects/diffs/parallel_view", diff_file: diff_file, project: project, blob: blob, index: i - - else - = render "projects/diffs/text_file", diff_file: diff_file, index: i - - elsif blob.image? - - old_file = project.repository.prev_blob_for_diff(diff_commit, diff_file) - = render "projects/diffs/image", diff_file: diff_file, old_file: old_file, file: blob, index: i + - if diff_file.too_large? + .nothing-here-block + This diff could not be displayed because it is too large. - else - .nothing-here-block No preview for this file type + - if blob_text_viewable?(blob) + - if diff_view == 'parallel' + = render "projects/diffs/parallel_view", diff_file: diff_file, project: project, blob: blob, index: i + - else + = render "projects/diffs/text_file", diff_file: diff_file, index: i + - elsif blob.image? + - old_file = project.repository.prev_blob_for_diff(diff_commit, diff_file) + = render "projects/diffs/image", diff_file: diff_file, old_file: old_file, file: blob, index: i + - else + .nothing-here-block No preview for this file type diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index f2e56081af..6d872cd0b2 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -84,6 +84,8 @@ %br %span.descr Share code pastes with others out of git repository + = render 'builds_settings', f: f + %fieldset.features %legend Project avatar: @@ -110,69 +112,6 @@ %hr = link_to 'Remove avatar', namespace_project_avatar_path(@project.namespace, @project), data: { confirm: "Project avatar will be removed. Are you sure?"}, method: :delete, class: "btn btn-remove btn-sm remove-avatar" - %fieldset.features - %legend - Continuous Integration - .form-group - .col-sm-offset-2.col-sm-10 - %p Get recent application code using the following command: - .radio - = f.label :build_allow_git_fetch_false do - = f.radio_button :build_allow_git_fetch, 'false' - %strong git clone - %br - %span.descr Slower but makes sure you have a clean dir before every build - .radio - = f.label :build_allow_git_fetch_true do - = f.radio_button :build_allow_git_fetch, 'true' - %strong git fetch - %br - %span.descr Faster - - .form-group - = f.label :build_timeout_in_minutes, 'Timeout', class: 'control-label' - .col-sm-10 - = f.number_field :build_timeout_in_minutes, class: 'form-control', min: '0' - %p.help-block per build in minutes - .form-group - = f.label :build_coverage_regex, "Test coverage parsing", class: 'control-label' - .col-sm-10 - .input-group - %span.input-group-addon / - = f.text_field :build_coverage_regex, class: 'form-control', placeholder: '\(\d+.\d+\%\) covered' - %span.input-group-addon / - %p.help-block - We will use this regular expression to find test coverage output in build trace. - Leave blank if you want to disable this feature - .bs-callout.bs-callout-info - %p Below are examples of regex for existing tools: - %ul - %li - Simplecov (Ruby) - - %code \(\d+.\d+\%\) covered - %li - pytest-cov (Python) - - %code \d+\%\s*$ - %li - phpunit --coverage-text --colors=never (PHP) - - %code ^\s*Lines:\s*\d+.\d+\% - - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :public_builds do - = f.check_box :public_builds - %strong Public builds - .help-block Allow everyone to access builds for Public and Internal projects - - %fieldset.features - %legend - Advanced settings - .form-group - = f.label :runners_token, "CI token", class: 'control-label' - .col-sm-10 - = f.text_field :runners_token, class: "form-control", placeholder: 'xEeFCaDAB89' - %p.help-block The secure token used to checkout project. .form-actions = f.submit 'Save changes', class: "btn btn-save" diff --git a/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml index 4143ea1306..c15386b488 100644 --- a/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml +++ b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml @@ -1,8 +1,7 @@ %tr.generic_commit_status %td.status - if can?(current_user, :read_commit_status, generic_commit_status) && generic_commit_status.target_url - = link_to generic_commit_status.target_url, class: "ci-status ci-#{generic_commit_status.status}" do - = ci_status_with_icon(generic_commit_status.status) + = ci_status_with_icon(generic_commit_status.status, generic_commit_status.target_url) - else = ci_status_with_icon(generic_commit_status.status) diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml index a44f34c2a6..4aa92d0b39 100644 --- a/app/views/projects/issues/_issue.html.haml +++ b/app/views/projects/issues/_issue.html.haml @@ -3,10 +3,11 @@ .issue-check = check_box_tag dom_id(issue,"selected"), nil, false, 'data-id' => issue.id, class: "selected_issue" - .issue-title + .issue-title.title %span.issue-title-text - = link_to_gfm issue.title, issue_path(issue), class: "title" - %ul.controls.light + = confidential_icon(issue) + = link_to_gfm issue.title, issue_path(issue) + %ul.controls - if issue.closed? %li CLOSED diff --git a/app/views/projects/issues/_merge_requests.html.haml b/app/views/projects/issues/_merge_requests.html.haml index d9868ad1f0..d6b38b327f 100644 --- a/app/views/projects/issues/_merge_requests.html.haml +++ b/app/views/projects/issues/_merge_requests.html.haml @@ -1,4 +1,4 @@ --if @merge_requests.any? +- if @merge_requests.any? %h2.merge-requests-title = pluralize(@merge_requests.count, 'Related Merge Request') %ul.unstyled-list diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml new file mode 100644 index 0000000000..e66e4669d4 --- /dev/null +++ b/app/views/projects/issues/_new_branch.html.haml @@ -0,0 +1,5 @@ +- if current_user && can?(current_user, :push_code, @project) && @issue.can_be_worked_on?(current_user) + .pull-right + = link_to namespace_project_branches_path(@project.namespace, @project, branch_name: @issue.to_branch_name, issue_iid: @issue.iid), method: :post, class: 'btn', title: @issue.to_branch_name do + = icon('code-fork') + New Branch diff --git a/app/views/projects/issues/_related_branches.html.haml b/app/views/projects/issues/_related_branches.html.haml new file mode 100644 index 0000000000..b10cd03515 --- /dev/null +++ b/app/views/projects/issues/_related_branches.html.haml @@ -0,0 +1,15 @@ +- if @related_branches.any? + %h2.related-branches-title + = pluralize(@related_branches.count, 'Related Branch') + %ul.unstyled-list + - @related_branches.each do |branch| + %li + - sha = @project.repository.find_branch(branch).target + - ci_commit = @project.ci_commit(sha) if sha + - if ci_commit + %span.related-branch-ci-status + = render_ci_status(ci_commit) + %span.related-branch-info + %strong + = link_to namespace_project_compare_path(@project.namespace, @project, from: @project.default_branch, to: branch), class: "label-branch" do + = branch diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 0242276cd8..52df3de8a2 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -22,20 +22,20 @@ = icon('angle-double-left') .issue-meta + = confidential_icon(@issue) %strong.identifier Issue ##{@issue.iid} %span.creator - by + opened .editor-details .editor-details + = time_ago_with_tooltip(@issue.created_at) + by %strong = link_to_member(@project, @issue.author, size: 24, mobile_classes: "hidden-xs") - %span.hidden-xs - = '@' + @issue.author.username %strong = link_to_member(@project, @issue.author, size: 24, mobile_classes: "hidden-sm hidden-md hidden-lg", by_username: true, avatar: false) - = time_ago_with_tooltip(@issue.created_at) .pull-right.issue-btn-group - if can?(current_user, :create_issue, @project) @@ -63,15 +63,14 @@ = markdown(@issue.description, cache_key: [@issue, "description"]) %textarea.hidden.js-task-list-field = @issue.description - - if @issue.updated_at != @issue.created_at - %small - Edited - = time_ago_with_tooltip(@issue.updated_at, placement: 'bottom', html_class: 'issue_edited_ago') + = edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue_edited_ago') .merge-requests = render 'merge_requests' + = render 'related_branches' .content-block.content-block-small + = render 'new_branch' = render 'votes/votes_block', votable: @issue .row diff --git a/app/views/projects/labels/_label.html.haml b/app/views/projects/labels/_label.html.haml index f7ddd30c5a..4927d239c1 100644 --- a/app/views/projects/labels/_label.html.haml +++ b/app/views/projects/labels/_label.html.haml @@ -10,6 +10,16 @@ = link_to_label(label) do = pluralize label.open_issues_count, 'open issue' + - if current_user + .label-subscription{data: {url: toggle_subscription_namespace_project_label_path(@project.namespace, @project, label)}} + .subscription-status{data: {status: label_subscription_status(label)}} + %button.btn.btn-sm.btn-info.subscribe-button + %span= label_subscription_toggle_button_text(label) + - if can? current_user, :admin_label, @project = link_to 'Edit', edit_namespace_project_label_path(@project.namespace, @project, label), class: 'btn btn-sm' = link_to 'Delete', namespace_project_label_path(@project.namespace, @project, label), class: 'btn btn-sm btn-remove remove-row', method: :delete, remote: true, data: {confirm: "Remove this label? Are you sure?"} + +- if current_user + :javascript + new Subscription('##{dom_id(label)} .label-subscription'); diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml index 18cf3f14f0..13d0cbdde1 100644 --- a/app/views/projects/merge_requests/_merge_request.html.haml +++ b/app/views/projects/merge_requests/_merge_request.html.haml @@ -1,8 +1,8 @@ %li{ class: mr_css_classes(merge_request) } - .merge-request-title + .merge-request-title.title %span.merge-request-title-text - = link_to_gfm merge_request.title, merge_request_path(merge_request), class: "title" - %ul.controls.light + = link_to_gfm merge_request.title, merge_request_path(merge_request) + %ul.controls - if merge_request.merged? %li MERGED diff --git a/app/views/projects/merge_requests/show/_mr_box.html.haml b/app/views/projects/merge_requests/show/_mr_box.html.haml index 602f787e6c..a23bd8d18d 100644 --- a/app/views/projects/merge_requests/show/_mr_box.html.haml +++ b/app/views/projects/merge_requests/show/_mr_box.html.haml @@ -11,7 +11,4 @@ %textarea.hidden.js-task-list-field = @merge_request.description - - if @merge_request.updated_at != @merge_request.created_at - %small - Edited - = time_ago_with_tooltip(@merge_request.updated_at, placement: 'bottom') + = edited_time_ago_with_tooltip(@merge_request, placement: 'bottom') diff --git a/app/views/projects/merge_requests/show/_mr_title.html.haml b/app/views/projects/merge_requests/show/_mr_title.html.haml index a75c0d96c5..eeb605e2dc 100644 --- a/app/views/projects/merge_requests/show/_mr_title.html.haml +++ b/app/views/projects/merge_requests/show/_mr_title.html.haml @@ -8,18 +8,21 @@ = icon('angle-double-left') .issue-meta %strong.identifier - Merge Request ##{@merge_request.iid} + %span.hidden-sm.hidden-md.hidden-lg + MR + %span.hidden-xs + Merge Request + !#{@merge_request.iid} %span.creator - by + opened .editor-details + = time_ago_with_tooltip(@merge_request.created_at) + by %strong = link_to_member(@project, @merge_request.author, size: 24, mobile_classes: "hidden-xs") - %span.hidden-xs - = '@' + @merge_request.author.username %strong = link_to_member(@project, @merge_request.author, size: 24, mobile_classes: "hidden-sm hidden-md hidden-lg", by_username: true, avatar: false) - = time_ago_with_tooltip(@merge_request.created_at) .issue-btn-group.pull-right - if can?(current_user, :update_merge_request, @merge_request) diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml index b4597043a2..be63875ab3 100644 --- a/app/views/projects/milestones/show.html.haml +++ b/app/views/projects/milestones/show.html.haml @@ -42,7 +42,7 @@ = preserve do = markdown @milestone.description -- if @milestone.complete? && @milestone.active? +- if @milestone.complete?(current_user) && @milestone.active? .alert.alert-success.prepend-top-default %span All issues for this milestone are closed. You may close milestone now. diff --git a/app/views/projects/notes/_note.html.haml b/app/views/projects/notes/_note.html.haml index 52972576af..2cf32e6093 100644 --- a/app/views/projects/notes/_note.html.haml +++ b/app/views/projects/notes/_note.html.haml @@ -27,20 +27,13 @@ %span.note-last-update %a{name: dom_id(note), href: "##{dom_id(note)}", title: 'Link here'} = time_ago_with_tooltip(note.created_at, placement: 'bottom', html_class: 'note_created_ago') - - if note.updated_at != note.created_at - %span.note-updated-at - · - = icon('edit', title: 'edited') - = time_ago_with_tooltip(note.updated_at, placement: 'bottom', html_class: 'note_edited_ago') - - if note.updated_by && note.updated_by != note.author - by #{link_to_member(note.project, note.updated_by, avatar: false, author_class: nil)} - .note-body{class: note_editable?(note) ? 'js-task-list-container' : ''} .note-text = preserve do = markdown(note.note, pipeline: :note, cache_key: [note, "note"]) - if note_editable?(note) = render 'projects/notes/edit_form', note: note + = edited_time_ago_with_tooltip(note, placement: 'bottom', html_class: 'note_edited_ago', include_author: true) - if note.attachment.url .note-attachment @@ -54,4 +47,3 @@ = link_to delete_attachment_namespace_project_note_path(note.project.namespace, note.project, note), title: 'Delete this attachment', method: :delete, remote: true, data: { confirm: 'Are you sure you want to remove the attachment?' }, class: 'danger js-note-attachment-delete' do = icon('trash-o', class: 'cred') - .clear diff --git a/app/views/projects/repositories/_download_archive.html.haml b/app/views/projects/repositories/_download_archive.html.haml index b9486a9b49..2465831906 100644 --- a/app/views/projects/repositories/_download_archive.html.haml +++ b/app/views/projects/repositories/_download_archive.html.haml @@ -10,7 +10,7 @@ %span.caret %span.sr-only Select Archive Format - %ul.col-xs-10.dropdown-menu{ role: 'menu' } + %ul.col-xs-10.dropdown-menu.dropdown-menu-align-right{ 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/search/results/_issue.html.haml b/app/views/search/results/_issue.html.haml index 45d700781f..710f5613c8 100644 --- a/app/views/search/results/_issue.html.haml +++ b/app/views/search/results/_issue.html.haml @@ -1,5 +1,6 @@ .search-result-row %h4 + = confidential_icon(issue) = link_to [issue.project.namespace.becomes(Namespace), issue.project, issue] do %span.term.str-truncated= issue.title .pull-right ##{issue.iid} diff --git a/app/views/search/results/_wiki_blob.html.haml b/app/views/search/results/_wiki_blob.html.haml index f5859481d4..235106c4f7 100644 --- a/app/views/search/results/_wiki_blob.html.haml +++ b/app/views/search/results/_wiki_blob.html.haml @@ -2,9 +2,9 @@ .blob-result .file-holder .file-title - = link_to namespace_project_wiki_path(@project.namespace, @project, wiki_blob.filename) do + = link_to namespace_project_wiki_path(@project.namespace, @project, wiki_blob.basename) do %i.fa.fa-file %strong - = wiki_blob.filename + = wiki_blob.basename .file-content.code.term = render 'shared/file_highlight', blob: wiki_blob, first_line_number: wiki_blob.startline diff --git a/app/views/shared/groups/_group.html.haml b/app/views/shared/groups/_group.html.haml index fb9a8db088..f172350f5f 100644 --- a/app/views/shared/groups/_group.html.haml +++ b/app/views/shared/groups/_group.html.haml @@ -10,7 +10,7 @@ %i.fa.fa-cogs = link_to leave_group_group_members_path(group), data: { confirm: leave_group_message(group.name) }, method: :delete, class: "btn-sm btn btn-grouped", title: 'Leave this group' do - %i.fa.fa-sign-out + = icon('sign-out') .stats %span @@ -22,12 +22,13 @@ = number_with_delimiter(group.users.count) = image_tag group_icon(group), class: "avatar s40 hidden-xs" - = link_to group, class: 'group-name title' do - = group.name + .title + = link_to group, class: 'group-name' do + = group.name - - if group_member - as - %span #{group_member.human_access} + - if group_member + as + %span #{group_member.human_access} - if group.description.present? .description diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml index 42a3c2c3f0..ac20f7d1f7 100644 --- a/app/views/shared/issuable/_filter.html.haml +++ b/app/views/shared/issuable/_filter.html.haml @@ -9,75 +9,20 @@ .filter-item.inline - if params[:author_id] = hidden_field_tag(:author_id, params[:author_id]) - = dropdown_tag("Author", options: { toggle_class: "js-user-search js-filter-submit js-author-search", title: "Filter by author", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-author", - placeholder: "Search authors", data: { any_user: "Any Author", first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), selected: params[:author_id], field_name: "author_id" } }) + = dropdown_tag(user_dropdown_label(params[:author_id], "Author"), options: { toggle_class: "js-user-search js-filter-submit js-author-search", title: "Filter by author", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-author", + placeholder: "Search authors", data: { any_user: "Any Author", first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), selected: params[:author_id], field_name: "author_id", default_label: "Author" } }) .filter-item.inline - if params[:assignee_id] = hidden_field_tag(:assignee_id, params[:assignee_id]) - = dropdown_tag("Assignee", options: { toggle_class: "js-user-search js-filter-submit js-assignee-search", title: "Filter by assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee", - placeholder: "Search assignee", data: { any_user: "Any Author", first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: (@project.id if @project), selected: params[:assignee_id], field_name: "assignee_id" } }) + = dropdown_tag(user_dropdown_label(params[:assignee_id], "Assignee"), options: { toggle_class: "js-user-search js-filter-submit js-assignee-search", title: "Filter by assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee", + placeholder: "Search assignee", data: { any_user: "Any Assignee", first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: (@project.id if @project), selected: params[:assignee_id], field_name: "assignee_id", default_label: "Assignee" } }) .filter-item.inline.milestone-filter - - if params[:milestone_title] - = hidden_field_tag(:milestone_title, params[:milestone_title]) - = dropdown_tag("Milestone", options: { title: "Filter by milestone", toggle_class: 'js-milestone-select js-filter-submit', filter: true, dropdown_class: "dropdown-menu-selectable", - placeholder: "Search milestones", footer_content: true, data: { show_no: true, show_any: true, field_name: "milestone_title", selected: params[:milestone_title], project_id: (@project.id if @project), milestones: (namespace_project_milestones_path(@project.namespace, @project, :js) if @project) } }) do - - if @project - %ul.dropdown-footer-list - - if can? current_user, :admin_milestone, @project - %li - = link_to new_namespace_project_milestone_path(@project.namespace, @project), title: "New Milestone" do - Create new - %li - = link_to namespace_project_milestones_path(@project.namespace, @project) do - - if can? current_user, :admin_milestone, @project - Manage milestones - - else - View milestones + = render "shared/issuable/milestone_dropdown" .filter-item.inline.labels-filter - - if params[:label_name] - = hidden_field_tag(:label_name, params[:label_name]) - .dropdown - %button.dropdown-menu-toggle.js-label-select.js-filter-submit{type: "button", data: {toggle: "dropdown", field_name: "label_name", show_no: "true", show_any: "true", selected: params[:label_name], project_id: (@project.id if @project), labels: (namespace_project_labels_path(@project.namespace, @project, :js) if @project)}} - %span.dropdown-toggle-text - Label - = icon('chevron-down') - .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable - .dropdown-page-one - = dropdown_title("Filter by label") - = dropdown_filter("Search labels") - = dropdown_content - - if @project - = dropdown_footer do - %ul.dropdown-footer-list - - if can? current_user, :admin_label, @project - %li - %a.dropdown-toggle-page{href: "#"} - Create new - %li - = link_to namespace_project_labels_path(@project.namespace, @project) do - - if can? current_user, :admin_label, @project - Manage labels - - else - View labels - - if can? current_user, :admin_label, @project - .dropdown-page-two - = dropdown_title("Create new label", back: true) - = dropdown_content do - %input#new_label_color{type: "hidden"} - %input#new_label_name.dropdown-input-field{type: "text", placeholder: "Name new label"} - .dropdown-label-color-preview.js-dropdown-label-color-preview - .suggest-colors.suggest-colors-dropdown - - suggested_colors.each do |color| - = link_to '#', style: "background-color: #{color}", data: { color: color } do -   - %button.btn.btn-primary.js-new-label-btn{type: "button"} - Create - = dropdown_loading - .dropdown-loading - = icon('spinner spin') + = render "shared/issuable/label_dropdown" .pull-right = render 'shared/sort_dropdown' diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index d5a4aad05d..9ef729e960 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -29,6 +29,15 @@ = render 'projects/notes/hints' .clearfix .error-alert + +- if issuable.is_a?(Issue) && !issuable.project.private? + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :confidential do + = f.check_box :confidential + This issue is confidential and should only be visible to team members + - if can?(current_user, :"admin_#{issuable.to_ability_name}", issuable.project) %hr .form-group diff --git a/app/views/shared/issuable/_label_dropdown.html.haml b/app/views/shared/issuable/_label_dropdown.html.haml new file mode 100644 index 0000000000..8761731518 --- /dev/null +++ b/app/views/shared/issuable/_label_dropdown.html.haml @@ -0,0 +1,39 @@ +- if params[:label_name] + = hidden_field_tag(:label_name, params[:label_name]) +.dropdown + %button.dropdown-menu-toggle.js-label-select.js-filter-submit{type: "button", data: {toggle: "dropdown", field_name: "label_name", show_no: "true", show_any: "true", selected: params[:label_name], project_id: @project.try(:id), labels: labels_filter_path, default_label: "Label"}} + %span.dropdown-toggle-text + = h(params[:label_name].presence || "Label") + = icon('chevron-down') + .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable + .dropdown-page-one + = dropdown_title("Filter by label") + = dropdown_filter("Search labels") + = dropdown_content + - if @project + = dropdown_footer do + %ul.dropdown-footer-list + - if can? current_user, :admin_label, @project + %li + %a.dropdown-toggle-page{href: "#"} + Create new + %li + = link_to namespace_project_labels_path(@project.namespace, @project) do + - if can? current_user, :admin_label, @project + Manage labels + - else + View labels + - if can? current_user, :admin_label, @project and @project + .dropdown-page-two + = dropdown_title("Create new label", back: true) + = dropdown_content do + %input#new_label_color{type: "hidden"} + %input#new_label_name.dropdown-input-field{type: "text", placeholder: "Name new label"} + .dropdown-label-color-preview.js-dropdown-label-color-preview + .suggest-colors.suggest-colors-dropdown + - suggested_colors.each do |color| + = link_to '#', style: "background-color: #{color}", data: { color: color } do +   + %button.btn.btn-primary.js-new-label-btn{type: "button"} + Create + = dropdown_loading diff --git a/app/views/shared/issuable/_milestone_dropdown.html.haml b/app/views/shared/issuable/_milestone_dropdown.html.haml new file mode 100644 index 0000000000..0434506c8d --- /dev/null +++ b/app/views/shared/issuable/_milestone_dropdown.html.haml @@ -0,0 +1,16 @@ +- if params[:milestone_title] + = hidden_field_tag(:milestone_title, params[:milestone_title]) += dropdown_tag(h(params[:milestone_title].presence || "Milestone"), options: { title: "Filter by milestone", toggle_class: 'js-milestone-select js-filter-submit', filter: true, dropdown_class: "dropdown-menu-selectable", + placeholder: "Search milestones", footer_content: @project.present?, data: { show_no: true, show_any: true, field_name: "milestone_title", selected: params[:milestone_title], project_id: @project.try(:id), milestones: milestones_filter_dropdown_path, default_label: "Milestone" } }) do + - if @project + %ul.dropdown-footer-list + - if can? current_user, :admin_milestone, @project + %li + = link_to new_namespace_project_milestone_path(@project.namespace, @project), title: "New Milestone" do + Create new + %li + = link_to namespace_project_milestones_path(@project.namespace, @project) do + - if can? current_user, :admin_milestone, @project + Manage milestones + - else + View milestones diff --git a/app/views/shared/issuable/_participants.html.haml b/app/views/shared/issuable/_participants.html.haml index f1d92ef48b..3fb409ff72 100644 --- a/app/views/shared/issuable/_participants.html.haml +++ b/app/views/shared/issuable/_participants.html.haml @@ -1,3 +1,6 @@ +- participants_row = 7 +- participants_size = participants.size +- participants_extra = participants_size - participants_row .block.participants .sidebar-collapsed-icon = icon('users') @@ -5,6 +8,13 @@ = participants.count .title.hide-collapsed = pluralize participants.count, "participant" - - participants.each do |participant| - %span.hide-collapsed - = link_to_member(@project, participant, name: false, size: 24) + .hide-collapsed.participants-list + - participants.each do |participant| + .participants-author.js-participants-author + = link_to_member(@project, participant, name: false, size: 24) + - if participants_extra > 0 + %div.participants-more + %a.js-participants-more{href: "#", data: {original_text: "+ #{participants_size - 7} more", less_text: "- show less"}} + + #{participants_extra} more +:javascript + Issue.prototype.PARTICIPANTS_ROW_COUNT = #{participants_row}; diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 9020a1330a..2b95b19fac 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -1,13 +1,12 @@ %aside.right-sidebar{ class: sidebar_gutter_collapsed_class } .issuable-sidebar - .block + .block.issuable-sidebar-header %span.issuable-count.hide-collapsed.pull-left = issuable.iid of = issuables_count(issuable) - %span.pull-right - %a.gutter-toggle.js-sidebar-toggle{href: '#'} - = sidebar_gutter_toggle_icon + %a.gutter-toggle.pull-right.js-sidebar-toggle{href: '#'} + = sidebar_gutter_toggle_icon .issuable-nav.hide-collapsed.pull-right.btn-group{role: 'group', "aria-label" => '...'} - if prev_issuable = prev_issuable_for(issuable) = link_to 'Prev', [@project.namespace.becomes(Namespace), @project, prev_issuable], class: 'btn btn-default prev-btn' @@ -22,20 +21,20 @@ = form_for [@project.namespace.becomes(Namespace), @project, issuable], remote: true, html: {class: 'issuable-context-form inline-update js-issuable-update'} do |f| .block.assignee - .sidebar-collapsed-icon + .sidebar-collapsed-icon.sidebar-collapsed-user{data: {toggle: "tooltip", placement: "left", container: "body"}, title: (issuable.assignee.to_reference if issuable.assignee)} - if issuable.assignee - = link_to_member_avatar(issuable.assignee, size: 24) + = link_to_member(@project, issuable.assignee, size: 24) - else = icon('user') .title.hide-collapsed - %label - Assignee + Assignee - if can?(current_user, :"admin_#{issuable.to_ability_name}", @project) - .pull-right - = link_to 'Edit', '#', class: 'edit-link' - .value.hide-collapsed + = link_to 'Edit', '#', class: 'edit-link pull-right' + .value.bold.hide-collapsed - if issuable.assignee - %strong= link_to_member(@project, issuable.assignee, size: 24) + = link_to_member(@project, issuable.assignee, size: 32) do + %span.username + = issuable.assignee.to_reference - if issuable.instance_of?(MergeRequest) && !issuable.can_be_merged_by?(issuable.assignee) %a.pull-right.cannot-be-merged{href: '#', data: {toggle: 'tooltip'}, title: 'Not allowed to merge'} = icon('exclamation-triangle') @@ -54,18 +53,13 @@ - else No .title.hide-collapsed - %label - Milestone + Milestone - if can?(current_user, :"admin_#{issuable.to_ability_name}", @project) - .pull-right - = link_to 'Edit', '#', class: 'edit-link' - .value.hide-collapsed + = link_to 'Edit', '#', class: 'edit-link pull-right' + .value.bold.hide-collapsed - if issuable.milestone - %span.back-to-milestone - = link_to namespace_project_milestone_path(@project.namespace, @project, issuable.milestone) do - %strong - = icon('clock-o') - = issuable.milestone.title + = link_to namespace_project_milestone_path(@project.namespace, @project, issuable.milestone) do + = issuable.milestone.title - else .light None .selectbox.hide-collapsed @@ -80,11 +74,10 @@ %span = issuable.labels.count .title.hide-collapsed - %label Labels + Labels - if can?(current_user, :"admin_#{issuable.to_ability_name}", @project) - .pull-right - = link_to 'Edit', '#', class: 'edit-link' - .value.issuable-show-labels.hide-collapsed + = link_to 'Edit', '#', class: 'edit-link pull-right' + .value.issuable-show-labels.hide-collapsed{class: ("has-labels" if issuable.labels.any?)} - if issuable.labels.any? - issuable.labels.each do |label| = link_to_label(label, type: issuable.to_ability_name) @@ -95,14 +88,13 @@ { selected: issuable.label_ids }, multiple: true, class: 'select2 js-select2', data: { placeholder: "Select labels" } = render "shared/issuable/participants", participants: issuable.participants(current_user) - %hr - if current_user - subscribed = issuable.subscribed?(current_user) - .block.light + .block.light.subscription{data: {url: toggle_subscription_path(issuable)}} .sidebar-collapsed-icon = icon('rss') .title.hide-collapsed - %label.light Notifications + Notifications - subscribtion_status = subscribed ? 'subscribed' : 'unsubscribed' %button.btn.btn-block.btn-gray.subscribe-button.hide-collapsed{:type => 'button'} %span= subscribed ? 'Unsubscribe' : 'Subscribe' @@ -124,5 +116,5 @@ = clipboard_button(clipboard_text: project_ref) :javascript - new Subscription("#{toggle_subscription_path(issuable)}"); + new Subscription('.subscription'); new IssuableContext(); diff --git a/app/views/shared/milestones/_issuable.html.haml b/app/views/shared/milestones/_issuable.html.haml index f7c6fc14ad..8588809672 100644 --- a/app/views/shared/milestones/_issuable.html.haml +++ b/app/views/shared/milestones/_issuable.html.haml @@ -10,6 +10,8 @@ %strong #{project.name} · - elsif show_full_project_name %strong #{project.name_with_namespace} · + - if issuable.is_a?(Issue) + = confidential_icon(issuable) = link_to_gfm issuable.title, [project.namespace.becomes(Namespace), project, issuable], title: issuable.title %div{class: 'issuable-detail'} = link_to [project.namespace.becomes(Namespace), project, issuable] do diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml index f01138af3f..6b25745c55 100644 --- a/app/views/shared/milestones/_milestone.html.haml +++ b/app/views/shared/milestones/_milestone.html.haml @@ -6,10 +6,10 @@ .col-sm-6 %strong= link_to_gfm truncate(milestone.title, length: 100), milestone_path .col-sm-6 - .pull-right.light #{milestone.percent_complete}% complete + .pull-right.light #{milestone.percent_complete(current_user)}% complete .row .col-sm-6 - = link_to pluralize(milestone.issues.size, 'Issue'), issues_path + = link_to pluralize(milestone.issues_visible_to_user(current_user).size, 'Issue'), issues_path · = link_to pluralize(milestone.merge_requests.size, 'Merge Request'), merge_requests_path .col-sm-6= milestone_progress_bar(milestone) diff --git a/app/views/shared/milestones/_summary.html.haml b/app/views/shared/milestones/_summary.html.haml index 59d4ae29f7..385c659660 100644 --- a/app/views/shared/milestones/_summary.html.haml +++ b/app/views/shared/milestones/_summary.html.haml @@ -3,15 +3,15 @@ .context.prepend-top-default .milestone-summary %h4 Progress - %strong= milestone.issues.size + %strong= milestone.issues_visible_to_user(current_user).size issues: %span.milestone-stat - %strong= milestone.issues.opened.size + %strong= milestone.issues_visible_to_user(current_user).opened.size open and - %strong= milestone.issues.closed.size + %strong= milestone.issues_visible_to_user(current_user).closed.size closed %span.milestone-stat - %strong== #{milestone.percent_complete}% + %strong== #{milestone.percent_complete(current_user)}% complete %span.milestone-stat diff --git a/app/views/shared/milestones/_tabs.html.haml b/app/views/shared/milestones/_tabs.html.haml index 57d7ee85a3..2b6ce2d7e7 100644 --- a/app/views/shared/milestones/_tabs.html.haml +++ b/app/views/shared/milestones/_tabs.html.haml @@ -2,7 +2,7 @@ %li.active = link_to '#tab-issues', 'data-toggle' => 'tab', 'data-show' => '.tab-issues-buttons' do Issues - %span.badge= milestone.issues.size + %span.badge= milestone.issues_visible_to_user(current_user).size %li = link_to '#tab-merge-requests', 'data-toggle' => 'tab', 'data-show' => '.tab-merge-requests-buttons' do Merge Requests @@ -21,7 +21,7 @@ .tab-content.milestone-content .tab-pane.active#tab-issues - = render 'shared/milestones/issues_tab', issues: milestone.issues, show_project_name: show_project_name, show_full_project_name: show_full_project_name + = render 'shared/milestones/issues_tab', issues: milestone.issues_visible_to_user(current_user), show_project_name: show_project_name, show_full_project_name: show_full_project_name .tab-pane#tab-merge-requests = render 'shared/milestones/merge_requests_tab', merge_requests: milestone.merge_requests, show_project_name: show_project_name, show_full_project_name: show_full_project_name .tab-pane#tab-participants diff --git a/app/views/shared/milestones/_top.html.haml b/app/views/shared/milestones/_top.html.haml index 4cf1d948b5..cab8743a07 100644 --- a/app/views/shared/milestones/_top.html.haml +++ b/app/views/shared/milestones/_top.html.haml @@ -28,7 +28,7 @@ %h2.title = markdown escape_once(milestone.title), pipeline: :single_line -- if milestone.complete? && milestone.active? +- if milestone.complete?(current_user) && milestone.active? .alert.alert-success.prepend-top-default - close_msg = group ? 'You may close the milestone now.' : 'Navigate to the project to close the milestone.' %span All issues for this milestone are closed. #{close_msg} @@ -47,7 +47,7 @@ - project_name = group ? ms.project.name : ms.project.name_with_namespace = link_to project_name, namespace_project_milestone_path(ms.project.namespace, ms.project, ms) %td - = ms.issues.opened.count + = ms.issues_visible_to_user(current_user).opened.count %td - if ms.closed? Closed diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index 97cfb76cdb..872d2bdf46 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -7,26 +7,11 @@ - show_last_commit_as_description = false unless local_assigns[:show_last_commit_as_description] == true && project.commit - css_class += " no-description" if project.description.blank? && !show_last_commit_as_description - ci_commit = project.ci_commit(project.commit.sha) if ci && !project.empty_repo? && project.commit -- cache_key = [project.namespace, project, controller.controller_name, controller.action_name, current_application_settings, 'v2.2'] +- cache_key = [project.namespace, project, controller.controller_name, controller.action_name, current_application_settings, 'v2.3'] - cache_key.push(ci_commit.status) if ci_commit %li.project-row{ class: css_class } = cache(cache_key) do - = link_to project_path(project), class: dom_class(project) do - - if avatar - .dash-project-avatar - - if use_creator_avatar - = image_tag avatar_icon(project.creator.email, 40), class: "avatar s40", alt:'' - - else - = project_icon(project, alt: '', class: 'avatar project-avatar s40') - %span.project-full-name.title - %span.namespace-name - - if project.namespace && !skip_namespace - = project.namespace.human_name - \/ - %span.project-name.filter-title - = project.name - .controls - if project.main_language %span @@ -45,6 +30,23 @@ %span.visibility-icon.has_tooltip{data: { container: 'body', placement: 'left' }, title: "#{visibility_level_label(project.visibility_level)} - #{project_visibility_level_description(project.visibility_level)}"} = visibility_level_icon(project.visibility_level, fw: false) + + .title + = link_to project_path(project), class: dom_class(project) do + - if avatar + .dash-project-avatar + - if use_creator_avatar + = image_tag avatar_icon(project.creator.email, 40), class: "avatar s40", alt:'' + - else + = project_icon(project, alt: '', class: 'avatar project-avatar s40') + %span.project-full-name + %span.namespace-name + - if project.namespace && !skip_namespace + = project.namespace.human_name + \/ + %span.project-name.filter-title + = project.name + - if show_last_commit_as_description .description = link_to_gfm project.commit.title, namespace_project_commit_path(project.namespace, project, project.commit), diff --git a/app/views/shared/snippets/_snippet.html.haml b/app/views/shared/snippets/_snippet.html.haml index a316a08510..c96dfefe17 100644 --- a/app/views/shared/snippets/_snippet.html.haml +++ b/app/views/shared/snippets/_snippet.html.haml @@ -1,8 +1,8 @@ %li.snippet-row = image_tag avatar_icon(snippet.author_email), class: "avatar s40 hidden-xs", alt: '' - .snippet-title - = link_to reliable_snippet_path(snippet), class: 'title' do + .title + = link_to reliable_snippet_path(snippet) do = truncate(snippet.title, length: 60) - if snippet.private? %span.label.label-gray diff --git a/app/workers/delete_user_worker.rb b/app/workers/delete_user_worker.rb new file mode 100644 index 0000000000..6ff361e4d8 --- /dev/null +++ b/app/workers/delete_user_worker.rb @@ -0,0 +1,10 @@ +class DeleteUserWorker + include Sidekiq::Worker + + def perform(current_user_id, delete_user_id, options = {}) + delete_user = User.find(delete_user_id) + current_user = User.find(current_user_id) + + DeleteUserService.new(current_user).execute(delete_user, options.symbolize_keys) + end +end diff --git a/app/workers/gitlab_shell_one_shot_worker.rb b/app/workers/gitlab_shell_one_shot_worker.rb new file mode 100644 index 0000000000..4ddbcf574d --- /dev/null +++ b/app/workers/gitlab_shell_one_shot_worker.rb @@ -0,0 +1,10 @@ +class GitlabShellOneShotWorker + include Sidekiq::Worker + include Gitlab::ShellAdapter + + sidekiq_options queue: :gitlab_shell, retry: false + + def perform(action, *arg) + gitlab_shell.send(action, *arg) + end +end diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb index 14d7813412..3cc232ef1a 100644 --- a/app/workers/post_receive.rb +++ b/app/workers/post_receive.rb @@ -1,6 +1,5 @@ class PostReceive include Sidekiq::Worker - include Gitlab::Identifier sidekiq_options queue: :post_receive @@ -11,51 +10,44 @@ 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\z/, "") - repo_path.gsub!(/\A\//, "") + post_received = Gitlab::GitPostReceive.new(repo_path, identifier, changes) - project = Project.find_with_namespace(repo_path) - - if project.nil? + if post_received.project.nil? log("Triggered hook for non-existing project with full path \"#{repo_path} \"") return false end - changes = Base64.decode64(changes) unless changes.include?(" ") - changes = utf8_encode_changes(changes) - changes = changes.lines + if post_received.wiki? + # Nothing defined here yet. + elsif post_received.regular_project? + process_project_changes(post_received) + else + log("Triggered hook for unidentifiable repository type with full path \"#{repo_path} \"") + false + end + end - changes.each do |change| + def process_project_changes(post_received) + post_received.changes.each do |change| oldrev, newrev, ref = change.strip.split(' ') - @user ||= identify(identifier, project, newrev) + @user ||= post_received.identify(newrev) unless @user - log("Triggered hook for non-existing user \"#{identifier} \"") + log("Triggered hook for non-existing user \"#{post_received.identifier} \"") return false end if Gitlab::Git.tag_ref?(ref) - GitTagPushService.new.execute(project, @user, oldrev, newrev, ref) + GitTagPushService.new.execute(post_received.project, @user, oldrev, newrev, ref) else - GitPushService.new(project, @user, oldrev: oldrev, newrev: newrev, ref: ref).execute + GitPushService.new(post_received.project, @user, oldrev: oldrev, newrev: newrev, ref: ref).execute end end end - def utf8_encode_changes(changes) - changes = changes.dup - - changes.force_encoding("UTF-8") - return changes if changes.valid_encoding? - - # Convert non-UTF-8 branch/tag names to UTF-8 so they can be dumped as JSON. - detection = CharlockHolmes::EncodingDetector.detect(changes) - return changes unless detection && detection[:encoding] - - CharlockHolmes::Converter.convert(changes, detection[:encoding], 'UTF-8') - end - + private + def log(message) Gitlab::GitLogger.error("POST-RECEIVE: #{message}") end diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index 05f127d622..500b745f55 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -357,6 +357,12 @@ production: &base # crowd_server_url: 'CROWD SERVER URL', # application_name: 'YOUR_APP_NAME', # application_password: 'YOUR_APP_PASSWORD' } } + # + # - { name: 'auth0', + # args: { + # client_id: 'YOUR_AUTH0_CLIENT_ID', + # client_secret: 'YOUR_AUTH0_CLIENT_SECRET', + # namespace: 'YOUR_AUTH0_DOMAIN' } } # SSO maximum session duration in seconds. Defaults to CAS default of 8 hours. # cas3: diff --git a/config/initializers/gollum.rb b/config/initializers/gollum.rb new file mode 100644 index 0000000000..703f24f93b --- /dev/null +++ b/config/initializers/gollum.rb @@ -0,0 +1,13 @@ +module Gollum + GIT_ADAPTER = "rugged" +end +require "gollum-lib" + +module Gollum + class Committer + # Patch for UTF-8 path + def method_missing(name, *args) + index.send(name, *args) + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 536397e318..ec79522002 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -295,7 +295,7 @@ Rails.application.routes.draw do resource :profile, only: [:show, :update] do member do get :audit_log - get :applications + get :applications, to: 'oauth/applications#index' put :reset_private_token put :update_username @@ -351,6 +351,8 @@ Rails.application.routes.draw do get :issues get :merge_requests get :activity + get :labels + get :milestones scope module: :dashboard do resources :milestones, only: [:index, :show] @@ -675,6 +677,10 @@ Rails.application.routes.draw do collection do post :generate end + + member do + post :toggle_subscription + end end resources :issues, constraints: { id: /\d+/ }, except: [:destroy] do diff --git a/db/migrate/20160223192159_add_confidential_to_issues.rb b/db/migrate/20160223192159_add_confidential_to_issues.rb new file mode 100644 index 0000000000..e9d47fd589 --- /dev/null +++ b/db/migrate/20160223192159_add_confidential_to_issues.rb @@ -0,0 +1,6 @@ +class AddConfidentialToIssues < ActiveRecord::Migration + def change + add_column :issues, :confidential, :boolean, default: false + add_index :issues, :confidential + end +end diff --git a/db/migrate/20160310185910_add_external_flag_to_users.rb b/db/migrate/20160310185910_add_external_flag_to_users.rb new file mode 100644 index 0000000000..54937f1eb7 --- /dev/null +++ b/db/migrate/20160310185910_add_external_flag_to_users.rb @@ -0,0 +1,5 @@ +class AddExternalFlagToUsers < ActiveRecord::Migration + def change + add_column :users, :external, :boolean, default: false + end +end diff --git a/db/migrate/20160314143402_projects_add_pushes_since_gc.rb b/db/migrate/20160314143402_projects_add_pushes_since_gc.rb new file mode 100644 index 0000000000..5d30a38bc9 --- /dev/null +++ b/db/migrate/20160314143402_projects_add_pushes_since_gc.rb @@ -0,0 +1,5 @@ +class ProjectsAddPushesSinceGc < ActiveRecord::Migration + def change + add_column :projects, :pushes_since_gc, :integer, default: 0 + end +end diff --git a/db/migrate/20160316123110_ci_runners_token_index.rb b/db/migrate/20160316123110_ci_runners_token_index.rb new file mode 100644 index 0000000000..67bf5b4f97 --- /dev/null +++ b/db/migrate/20160316123110_ci_runners_token_index.rb @@ -0,0 +1,13 @@ +class CiRunnersTokenIndex < ActiveRecord::Migration + disable_ddl_transaction! + + def change + args = [:ci_runners, :token] + + if Gitlab::Database.postgresql? + args << { algorithm: :concurrently } + end + + add_index(*args) + end +end diff --git a/db/migrate/20160316192622_change_target_id_to_null_on_todos.rb b/db/migrate/20160316192622_change_target_id_to_null_on_todos.rb new file mode 100644 index 0000000000..6871b3920d --- /dev/null +++ b/db/migrate/20160316192622_change_target_id_to_null_on_todos.rb @@ -0,0 +1,5 @@ +class ChangeTargetIdToNullOnTodos < ActiveRecord::Migration + def change + change_column_null :todos, :target_id, true + end +end diff --git a/db/migrate/20160316204731_add_commit_id_to_todos.rb b/db/migrate/20160316204731_add_commit_id_to_todos.rb new file mode 100644 index 0000000000..ae19fdd1ab --- /dev/null +++ b/db/migrate/20160316204731_add_commit_id_to_todos.rb @@ -0,0 +1,6 @@ +class AddCommitIdToTodos < ActiveRecord::Migration + def change + add_column :todos, :commit_id, :string + add_index :todos, :commit_id + end +end diff --git a/db/schema.rb b/db/schema.rb index fa406f7090..5b2f5aa3dd 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: 20160309140734) do +ActiveRecord::Schema.define(version: 20160316204731) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -260,6 +260,7 @@ ActiveRecord::Schema.define(version: 20160309140734) do end add_index "ci_runners", ["description"], name: "index_ci_runners_on_description_trigram", using: :gin, opclasses: {"description"=>"gin_trgm_ops"} + add_index "ci_runners", ["token"], name: "index_ci_runners_on_token", using: :btree add_index "ci_runners", ["token"], name: "index_ci_runners_on_token_trigram", using: :gin, opclasses: {"token"=>"gin_trgm_ops"} create_table "ci_services", force: :cascade do |t| @@ -415,10 +416,12 @@ ActiveRecord::Schema.define(version: 20160309140734) do t.string "state" t.integer "iid" t.integer "updated_by_id" + t.boolean "confidential", default: false end add_index "issues", ["assignee_id"], name: "index_issues_on_assignee_id", using: :btree add_index "issues", ["author_id"], name: "index_issues_on_author_id", using: :btree + add_index "issues", ["confidential"], name: "index_issues_on_confidential", using: :btree add_index "issues", ["created_at", "id"], name: "index_issues_on_created_at_and_id", using: :btree add_index "issues", ["created_at"], name: "index_issues_on_created_at", using: :btree add_index "issues", ["description"], name: "index_issues_on_description_trigram", using: :gin, opclasses: {"description"=>"gin_trgm_ops"} @@ -720,6 +723,7 @@ ActiveRecord::Schema.define(version: 20160309140734) do t.boolean "pending_delete", default: false t.boolean "public_builds", default: true, null: false t.string "main_language" + t.integer "pushes_since_gc", default: 0 end add_index "projects", ["builds_enabled", "shared_runners_enabled"], name: "index_projects_on_builds_enabled_and_shared_runners_enabled", using: :btree @@ -774,9 +778,9 @@ ActiveRecord::Schema.define(version: 20160309140734) do t.string "type" t.string "title" t.integer "project_id" - t.datetime "created_at" - t.datetime "updated_at" - t.boolean "active", default: false, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.boolean "active", null: false t.text "properties" t.boolean "template", default: false t.boolean "push_events", default: true @@ -863,7 +867,7 @@ ActiveRecord::Schema.define(version: 20160309140734) do create_table "todos", force: :cascade do |t| t.integer "user_id", null: false t.integer "project_id", null: false - t.integer "target_id", null: false + t.integer "target_id" t.string "target_type", null: false t.integer "author_id" t.integer "action", null: false @@ -871,9 +875,11 @@ ActiveRecord::Schema.define(version: 20160309140734) do t.datetime "created_at" t.datetime "updated_at" t.integer "note_id" + t.string "commit_id" end add_index "todos", ["author_id"], name: "index_todos_on_author_id", using: :btree + add_index "todos", ["commit_id"], name: "index_todos_on_commit_id", using: :btree add_index "todos", ["note_id"], name: "index_todos_on_note_id", using: :btree add_index "todos", ["project_id"], name: "index_todos_on_project_id", using: :btree add_index "todos", ["state"], name: "index_todos_on_state", using: :btree @@ -938,6 +944,7 @@ ActiveRecord::Schema.define(version: 20160309140734) do t.string "unlock_token" t.datetime "otp_grace_period_started_at" t.boolean "ldap_email", default: false, null: false + t.boolean "external", default: false end add_index "users", ["admin"], name: "index_users_on_admin", using: :btree diff --git a/doc/README.md b/doc/README.md index 0ca30e4e0f..08d0a6a5bf 100644 --- a/doc/README.md +++ b/doc/README.md @@ -3,12 +3,13 @@ ## User documentation - [API](api/README.md) Automate GitLab via a simple and powerful API. +- [CI](ci/README.md) - [GitLab as OAuth2 authentication service provider](integration/oauth_provider.md). It allows you to login to other applications from GitLab. - [GitLab Basics](gitlab-basics/README.md) Find step by step how to start working on your commandline and on GitLab. - [Importing to GitLab](workflow/importing/README.md). - [Markdown](markdown/markdown.md) GitLab's advanced formatting system. - [Migrating from SVN](workflow/importing/migrating_from_svn.md) Convert a SVN repository to Git and GitLab -- [Permissions](permissions/permissions.md) Learn what each role in a project (guest/reporter/developer/master/owner) can do. +- [Permissions](permissions/permissions.md) Learn what each role in a project (external/guest/reporter/developer/master/owner) can do. - [Profile Settings](profile/README.md) - [Project Services](project_services/project_services.md) Integrate a project with external services, such as CI and chat. - [Public access](public_access/public_access.md) Learn how you can allow public and internal access to projects. @@ -16,42 +17,6 @@ - [Webhooks](web_hooks/web_hooks.md) Let GitLab notify you when new code has been pushed to your project. - [Workflow](workflow/README.md) Using GitLab functionality and importing projects from GitHub and SVN. -## CI User documentation - -- [Get started with GitLab CI](ci/quick_start/README.md) -- [Learn how to enable or disable GitLab CI](ci/enable_or_disable_ci.md) -- [Learn how `.gitlab-ci.yml` works](ci/yaml/README.md) -- [Configure a Runner, the application that runs your builds](ci/runners/README.md) -- [Use Docker images with GitLab Runner](ci/docker/using_docker_images.md) -- [Use CI to build Docker images](ci/docker/using_docker_build.md) -- [Use variables in your `.gitlab-ci.yml`](ci/variables/README.md) -- [Use SSH keys in your build environment](ci/ssh_keys/README.md) -- [Trigger builds through the API](ci/triggers/README.md) -- [Build artifacts](ci/build_artifacts/README.md) -- [User permissions](ci/permissions/README.md) -- [API](ci/api/README.md) - -### CI Examples - -- [The .gitlab-ci.yml file for GitLab itself](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/.gitlab-ci.yml) -- [Test your PHP applications](ci/examples/php.md) -- [Test and deploy Ruby applications to Heroku](ci/examples/test-and-deploy-ruby-application-to-heroku.md) -- [Test and deploy Python applications to Heroku](ci/examples/test-and-deploy-python-application-to-heroku.md) -- [Test Clojure applications](ci/examples/test-clojure-application.md) -- [Using `dpl` as deployment tool](ci/deployment/README.md) -- Help your favorite programming language and GitLab by sending a merge request - with a guide for that language. - -### CI Services - -GitLab CI uses the `services` keyword to define what docker containers should -be linked with your base image. Below is a list of examples you may use: - -- [Using MySQL](ci/services/mysql.md) -- [Using PostgreSQL](ci/services/postgres.md) -- [Using Redis](ci/services/redis.md) -- [Using Other Services](ci/docker/using_docker_images.md#how-to-use-other-images-as-services) - ## Administrator documentation - [Custom git hooks](hooks/custom_hooks.md) Custom git hooks (on the filesystem) for when webhooks aren't enough. diff --git a/doc/api/users.md b/doc/api/users.md index 82c57a2fd4..383e7c76ab 100644 --- a/doc/api/users.md +++ b/doc/api/users.md @@ -194,6 +194,7 @@ Parameters: - `admin` (optional) - User is admin - true or false (default) - `can_create_group` (optional) - User can create groups - true or false - `confirm` (optional) - Require confirmation - true (default) or false +- `external` (optional) - Flags the user as external - true or false(default) ## User modification @@ -219,6 +220,7 @@ Parameters: - `bio` - User's biography - `admin` (optional) - User is admin - true or false (default) - `can_create_group` (optional) - User can create groups - true or false +- `external` (optional) - Flags the user as external - true or false(default) Note, at the moment this method does only return a 404 error, even in cases where a 409 (Conflict) would be more appropriate, @@ -560,7 +562,7 @@ Parameters: - `uid` (required) - id of specified user -Will return `200 OK` on success, `404 User Not Found` is user cannot be found or +Will return `200 OK` on success, `404 User Not Found` is user cannot be found or `403 Forbidden` when trying to block an already blocked user by LDAP synchronization. ## Unblock user diff --git a/doc/ci/deployment/README.md b/doc/ci/deployment/README.md index ffd841ca9e..7d91ce6710 100644 --- a/doc/ci/deployment/README.md +++ b/doc/ci/deployment/README.md @@ -89,7 +89,7 @@ We also use two secure variables: In GitLab CI 7.12 a new feature was introduced: Secure Variables. Secure Variables can added by going to `Project > Variables > Add Variable`. **This feature requires `gitlab-runner` with version equal or greater than 0.4.0.** -The variables that are defined in the project settings are send along with the build script to the runner. +The variables that are defined in the project settings are sent along with the build script to the runner. The secure variables are stored out of the repository. Never store secrets in your projects' .gitlab-ci.yml. It is also important that secret's value is hidden in the build log. diff --git a/doc/ci/examples/test-and-deploy-ruby-application-to-heroku.md b/doc/ci/examples/test-and-deploy-ruby-application-to-heroku.md index c1bb47e429..f5645d586a 100644 --- a/doc/ci/examples/test-and-deploy-ruby-application-to-heroku.md +++ b/doc/ci/examples/test-and-deploy-ruby-application-to-heroku.md @@ -1,5 +1,5 @@ ## Test and Deploy a ruby application -This example will guide you how to run tests in your Ruby application and deploy it automatiacally as Heroku application. +This example will guide you how to run tests in your Ruby application and deploy it automatically as Heroku application. You can checkout the example [source](https://gitlab.com/ayufan/ruby-getting-started) and check [CI status](https://gitlab.com/ayufan/ruby-getting-started/builds?scope=all). diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 4d27c07913..762b35859b 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -135,6 +135,9 @@ thus allowing to fine tune them. ### cache +>**Note:** +Introduced in GitLab Runner v0.7.0. + `cache` is used to specify a list of files and directories which should be cached between builds. @@ -143,15 +146,55 @@ cached between builds. If `cache` is defined outside the scope of the jobs, it means it is set globally and all jobs will use its definition. -To cache all git untracked files and files in `binaries`: +Cache all files in `binaries` and `.config`: + +```yaml +rspec: + script: test + cache: + paths: + - binaries/ + - .config +``` + +Cache all Git untracked files: + +```yaml +rspec: + script: test + cache: + untracked: true +``` + +Cache all Git untracked files and files in `binaries`: + +```yaml +rspec: + script: test + cache: + untracked: true + paths: + - binaries/ +``` + +Locally defined cache overwrites globally defined options. This will cache only +`binaries/`: ```yaml cache: - untracked: true paths: - - binaries/ + - my/files + +rspec: + script: test + cache: + paths: + - binaries/ ``` +The cache is provided on best effort basis, so don't expect that cache will be +always present. For implementation details please check GitLab Runner. + #### cache:key >**Note:** @@ -236,6 +279,8 @@ job_name: | Keyword | Required | Description | |---------------|----------|-------------| | script | yes | Defines a shell script which is executed by runner | +| image | no | Use docker image, covered in [Using Docker Images](../docker/using_docker_images.md#define-image-and-services-from-gitlab-ciyml) | +| services | no | Use docker services, covered in [Using Docker Images](../docker/using_docker_images.md#define-image-and-services-from-gitlab-ciyml) | | stage | no | Defines a build stage (default: `test`) | | type | no | Alias for `stage` | | only | no | Defines a list of git refs for which build is created | @@ -399,7 +444,7 @@ The above script will: >**Notes:** > > - Introduced in GitLab Runner v0.7.0 for non-Windows platforms. -> - Limited Windows support was added in GitLab Runner v.1.0.0. +> - Windows support was added in GitLab Runner v.1.0.0. > - Currently not all executors are supported. > - Build artifacts are only collected for successful builds. @@ -418,14 +463,14 @@ artifacts: - .config ``` -Send all git untracked files: +Send all Git untracked files: ```yaml artifacts: untracked: true ``` -Send all git untracked files and files in `binaries`: +Send all Git untracked files and files in `binaries`: ```yaml artifacts: @@ -579,63 +624,6 @@ deploy: script: make deploy ``` -### cache - ->**Note:** -Introduced in GitLab Runner v0.7.0. - -`cache` is used to specify list of files and directories which should be cached -between builds. Below are some examples: - -Cache all files in `binaries` and `.config`: - -```yaml -rspec: - script: test - cache: - paths: - - binaries/ - - .config -``` - -Cache all git untracked files: - -```yaml -rspec: - script: test - cache: - untracked: true -``` - -Cache all git untracked files and files in `binaries`: - -```yaml -rspec: - script: test - cache: - untracked: true - paths: - - binaries/ -``` - -Locally defined cache overwrites globally defined options. This will cache only -`binaries/`: - -```yaml -cache: - paths: - - my/files - -rspec: - script: test - cache: - paths: - - binaries/ -``` - -The cache is provided on best effort basis, so don't expect that cache will be -always present. For implementation details please check GitLab Runner. - ## Hidden jobs >**Note:** diff --git a/doc/development/scss_styleguide.md b/doc/development/scss_styleguide.md new file mode 100644 index 0000000000..6c48c25448 --- /dev/null +++ b/doc/development/scss_styleguide.md @@ -0,0 +1,194 @@ +# SCSS styleguide + +This style guide recommends best practices for SCSS to make styles easy to read, +easy to maintain, and performant for the end-user. + +## Rules + +### Naming + +CSS classes should use the `lowercase-hyphenated` format rather than +`snake_case` or `camelCase`. + +```scss +// Bad +.class_name { + color: #fff; +} + +// Bad +.className { + color: #fff; +} + +// Good +.class-name { + color: #fff; +} +``` + +### Formatting + +You should always use a space before a brace, braces should be on the same +line, each property should each get its own line, and there should be a space +between the property and its value. + +```scss +// Bad +.container-item { + width: 100px; height: 100px; + margin-top: 0; +} + +// Bad +.container-item +{ + width: 100px; + height: 100px; + margin-top: 0; +} + +// Bad +.container-item{ + width:100px; + height:100px; + margin-top:0; +} + +// Good +.container-item { + width: 100px; + height: 100px; + margin-top: 0; +} +``` + +Note that there is an exception for single-line rulesets, although these are +not typically recommended. + +```scss +p { margin: 0; padding: 0; } +``` + +### Colors + +HEX (hexadecimal) colors short-form should use shortform where possible, and +should use lower case letters to differenciate between letters and numbers, e. +g. `#E3E3E3` vs. `#e3e3e3`. + +```scss +// Bad +p { + color: #ffffff; +} + +// Bad +p { + color: #FFFFFF; +} + +// Good +p { + color: #fff; +} +``` + +### Indentation + +Indentation should always use two spaces for each indentation level. + +```scss +// Bad, four spaces +p { + color: #f00; +} + +// Good +p { + color: #f00; +} +``` + +### Semicolons + +Always include semicolons after every property. When the stylesheets are +minified, the semicolons will be removed automatically. + +```scss +// Bad +.container-item { + width: 100px; + height: 100px +} + +// Good +.container-item { + width: 100px; + height: 100px; +} +``` + +### Shorthand + +The shorthand form should be used for properties that support it. + +```scss +// Bad +margin: 10px 15px 10px 15px; +padding: 10px 10px 10px 10px; + +// Good +margin: 10px 15px; +padding: 10px; +``` + +### Zero Units + +Omit length units on zero values, they're unnecessary and not including them +is slightly more performant. + +```scss +// Bad +.item-with-padding { + padding: 0px; +} + +// Good +.item-with-padding { + padding: 0; +} +``` + +### Selectors with a `js-` Prefix +Do not use any selector prefixed with `js-` for styling purposes. These +selectors are intended for use only with JavaScript to allow for removal or +renaming without breaking styling. + +## Linting + +We use [SCSS Lint][scss-lint] to check for style guide conformity. It uses the +ruleset in `.scss-lint.yml`, which is located in the home directory of the +project. + +To check if any warnings will be produced by your changes, you can run `rake +scss_lint` in the GitLab directory. SCSS Lint will also run in GitLab CI to +catch any warnings. + +If the Rake task is throwing warnings you don't understand, SCSS Lint's +documentation includes [a full list of their linters][scss-lint-documentation]. + +### Fixing issues + +If you want to automate changing a large portion of the codebase to conform to +the SCSS style guide, you can use [CSSComb][csscomb]. First install +[Node][node] and [NPM][npm], then run `npm install csscomb -g` to install +CSSComb globally (system-wide). Run it in the GitLab directory with +`csscomb app/assets/stylesheets` to automatically fix issues with CSS/SCSS. + +Note that this won't fix every problem, but it should fix a majority. + +[csscomb]: https://github.com/csscomb/csscomb.js +[node]: https://github.com/nodejs/node +[npm]: https://www.npmjs.com/ +[scss-lint]: https://github.com/brigade/scss-lint +[scss-lint-documentation]: https://github.com/brigade/scss-lint/blob/master/lib/scss_lint/linter/README.md diff --git a/doc/hooks/custom_hooks.md b/doc/hooks/custom_hooks.md index 15051dd76f..dcdf49d337 100644 --- a/doc/hooks/custom_hooks.md +++ b/doc/hooks/custom_hooks.md @@ -2,7 +2,7 @@ **Note: Custom git hooks must be configured on the filesystem of the GitLab server. Only GitLab server administrators will be able to complete these tasks. -Please explore [webhooks](doc/web_hooks/web_hooks.md) as an option if you do not have filesystem access. For a user configurable Git Hooks interface, please see [GitLab Enterprise Edition Git Hooks](http://doc.gitlab.com/ee/git_hooks/git_hooks.html).** +Please explore [webhooks](../web_hooks/web_hooks.md) as an option if you do not have filesystem access. For a user configurable Git Hooks interface, please see [GitLab Enterprise Edition Git Hooks](http://doc.gitlab.com/ee/git_hooks/git_hooks.html).** Git natively supports hooks that are executed on different actions. Examples of server-side git hooks include pre-receive, post-receive, and update. diff --git a/doc/install/installation.md b/doc/install/installation.md index 4f01139726..c567846f62 100644 --- a/doc/install/installation.md +++ b/doc/install/installation.md @@ -22,7 +22,7 @@ If the highest number stable branch is unclear please check the [GitLab Blog](ht This guide is long because it covers many cases and includes all commands you need, this is [one of the few installation scripts that actually works out of the box](https://twitter.com/robinvdvleuten/status/424163226532986880). -This installation guide was created for and tested on **Debian/Ubuntu** operating systems. Please read [doc/install/requirements.md](./requirements.md) for hardware and operating system requirements. If you want to install on RHEL/CentOS we recommend using the [Omnibus packages](https://about.gitlab.com/downloads/). +This installation guide was created for and tested on **Debian/Ubuntu** operating systems. Please read [requirements.md](requirements.md) for hardware and operating system requirements. If you want to install on RHEL/CentOS we recommend using the [Omnibus packages](https://about.gitlab.com/downloads/). This is the official installation guide to set up a production server. To set up a **development installation** or for many other installation options please see [the installation section of the readme](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/README.md#installation). @@ -76,7 +76,7 @@ Make sure you have the right version of Git installed # Install Git sudo apt-get install -y git-core - # Make sure Git is version 1.7.10 or higher, for example 1.7.12 or 2.0.0 + # Make sure Git is version 2.7.4 or higher git --version Is the system packaged Git too old? Remove it and compile from source. @@ -89,8 +89,9 @@ Is the system packaged Git too old? Remove it and compile from source. # Download and compile from source cd /tmp - curl -L --progress https://www.kernel.org/pub/software/scm/git/git-2.4.3.tar.gz | tar xz - cd git-2.4.3/ + curl -O --progress https://www.kernel.org/pub/software/scm/git/git-2.7.4.tar.gz + echo '7104c4f5d948a75b499a954524cb281fe30c6649d8abe20982936f75ec1f275b git-2.7.4.tar.gz' | shasum -a256 -c - && tar -xzf git-2.7.4.tar.gz + cd git-2.7.4/ ./configure make prefix=/usr/local all @@ -143,7 +144,7 @@ use 64-bit Linux. You can find downloads for other platforms at the [Go download page](https://golang.org/dl). curl -O --progress https://storage.googleapis.com/golang/go1.5.3.linux-amd64.tar.gz - echo '43afe0c5017e502630b1aea4d44b8a7f059bf60d7f29dfd58db454d4e4e0ae53 go1.5.3.linux-amd64.tar.gz' | shasum -c - && \ + echo '43afe0c5017e502630b1aea4d44b8a7f059bf60d7f29dfd58db454d4e4e0ae53 go1.5.3.linux-amd64.tar.gz' | shasum -a256 -c - && \ sudo tar -C /usr/local -xzf go1.5.3.linux-amd64.tar.gz sudo ln -sf /usr/local/go/bin/{go,godoc,gofmt} /usr/local/bin/ rm go1.5.3.linux-amd64.tar.gz @@ -161,18 +162,11 @@ We recommend using a PostgreSQL database. For MySQL check [MySQL setup guide](da # Install the database packages sudo apt-get install -y postgresql postgresql-client libpq-dev - # Login to PostgreSQL - sudo -u postgres psql -d template1 - # Create a user for GitLab - # Do not type the 'template1=#', this is part of the prompt - template1=# CREATE USER git CREATEDB; + sudo -u postgres psql -d template1 -c "CREATE USER git CREATEDB;" # Create the GitLab production database & grant all privileges on database - template1=# CREATE DATABASE gitlabhq_production OWNER git; - - # Quit the database session - template1=# \q + sudo -u postgres psql -d template1 -c "CREATE DATABASE gitlabhq_production OWNER git;" # Try connecting to the new database with the new user sudo -u git -H psql -d gitlabhq_production diff --git a/doc/install/requirements.md b/doc/install/requirements.md index d59b7f0e84..03cb08dd1f 100644 --- a/doc/install/requirements.md +++ b/doc/install/requirements.md @@ -22,7 +22,7 @@ For the installations options please see [the installation page on the GitLab we - FreeBSD On the above unsupported distributions is still possible to install GitLab yourself. -Please see the [installation from source guide](https://github.com/gitlabhq/gitlabhq/blob/master/doc/install/installation.md) and the [unofficial installation guides](https://github.com/gitlabhq/gitlab-public-wiki/wiki/Unofficial-Installation-Guides) on the public wiki for more information. +Please see the [installation from source guide](installation.md) and the [installation guides](https://about.gitlab.com/installation/) for more information. ### Non-Unix operating systems such as Windows diff --git a/doc/integration/auth0.md b/doc/integration/auth0.md new file mode 100644 index 0000000000..e5247082a8 --- /dev/null +++ b/doc/integration/auth0.md @@ -0,0 +1,89 @@ +# Auth0 OmniAuth Provider + +To enable the Auth0 OmniAuth provider, you must create an Auth0 account, and an +application. + +1. Sign in to the [Auth0 Console](https://manage.auth0.com). If you need to +create an account, you can do so at the same link. + +1. Select "New App/API". + +1. Provide the Application Name ('GitLab' works fine). + +1. Once created, you should see the Quick Start options. Disregard them and +select 'Settings' above the Quick Start options. + +1. At the top of the Settings screen, you should see your Domain, Client ID and +Client Secret. Take note of these as you'll need to put them in the +configuration file. For example: + - Domain: `test1234.auth0.com` + - Client ID: `t6X8L2465bNePWLOvt9yi41i` + - Client Secret: `KbveM3nqfjwCbrhaUy_gDu2dss8TIlHIdzlyf33pB7dEK5u_NyQdp65O_o02hXs2` + +1. Fill in the Allowed Callback URLs: + - http://`YOUR_GITLAB_URL`/users/auth/auth0/callback (or) + - https://`YOUR_GITLAB_URL`/users/auth/auth0/callback + +1. Fill in the Allowed Origins (CORS): + - http://`YOUR_GITLAB_URL` (or) + - https://`YOUR_GITLAB_URL` + +1. On your GitLab server, open the configuration file. + + For omnibus package: + + ```sh + sudo editor /etc/gitlab/gitlab.rb + ``` + + For installations from source: + + ```sh + cd /home/git/gitlab + sudo -u git -H editor config/gitlab.yml + ``` + +1. See [Initial OmniAuth Configuration](omniauth.md#initial-omniauth-configuration) +for initial settings. + +1. Add the provider configuration: + + For omnibus package: + + ```ruby + gitlab_rails['omniauth_providers'] = [ + { + "name" => "auth0", + "args" => { client_id: 'YOUR_AUTH0_CLIENT_ID'', + client_secret: 'YOUR_AUTH0_CLIENT_SECRET', + namespace: 'YOUR_AUTH0_DOMAIN' + } + } + ] + ``` + + For installations from source: + + ```yaml + - { name: 'auth0', + args: { + client_id: 'YOUR_AUTH0_CLIENT_ID', + client_secret: 'YOUR_AUTH0_CLIENT_SECRET', + namespace: 'YOUR_AUTH0_DOMAIN' + } + } + ``` + +1. Change `YOUR_AUTH0_CLIENT_ID` to the client ID from the Auth0 Console page +from step 5. + +1. Change `YOUR_AUTH0_CLIENT_SECRET` to the client secret from the Auth0 Console +page from step 5. + +1. Save the file and [reconfigure GitLab](../administration/restart_gitlab.md) +for the changes to take effect. + +On the sign in page there should now be an Auth0 icon below the regular sign in +form. Click the icon to begin the authentication process. Auth0 will ask the +user to sign in and authorize the GitLab application. If everything goes well +the user will be returned to GitLab and will be signed in. diff --git a/doc/integration/omniauth.md b/doc/integration/omniauth.md index ba47cb1626..25f3598830 100644 --- a/doc/integration/omniauth.md +++ b/doc/integration/omniauth.md @@ -28,6 +28,7 @@ contains some settings that are common for all providers. - [SAML](saml.md) - [Crowd](crowd.md) - [Azure](azure.md) +- [Auth0](auth0.md) ## Initial OmniAuth Configuration diff --git a/doc/permissions/permissions.md b/doc/permissions/permissions.md index ac0fd3d175..3d375e47c8 100644 --- a/doc/permissions/permissions.md +++ b/doc/permissions/permissions.md @@ -71,3 +71,24 @@ Any user can remove themselves from a group, unless they are the last Owner of t | Create project in group | | | | ✓ | ✓ | | Manage group members | | | | | ✓ | | Remove group | | | | | ✓ | + +## External Users + +In cases where it is desired that a user has access only to some internal or +private projects, there is the option of creating **External Users**. This +feature may be useful when for example a contractor is working on a given +project and should only have access to that project. + +External users can only access projects to which they are explicitly granted +access, thus hiding all other internal or private ones from them. Access can be +granted by adding the user as member to the project or group. + +They will, like usual users, receive a role in the project or group with all +the abilities that are mentioned in the table above. They cannot however create +groups or projects, and they have the same access as logged out users in all +other cases. + +An administrator can flag a user as external [through the API](../api/users.md) +or by checking the checkbox on the admin panel. As an administrator, navigate +to **Admin > Users** to create a new user or edit an existing one. There, you +will find the option to flag the user as external. diff --git a/doc/release/security.md b/doc/release/security.md index b1a62b333e..118c016ba4 100644 --- a/doc/release/security.md +++ b/doc/release/security.md @@ -15,7 +15,7 @@ Please report suspected security vulnerabilities in private to 'no-cache') + expect(page.response_headers['Cache-Control']).to include 'no-cache' end def expect_badge(status) diff --git a/features/steps/project/issues/award_emoji.rb b/features/steps/project/issues/award_emoji.rb index ce2554bc80..c5d45709b4 100644 --- a/features/steps/project/issues/award_emoji.rb +++ b/features/steps/project/issues/award_emoji.rb @@ -92,6 +92,10 @@ class Spinach::Features::AwardEmoji < Spinach::FeatureSteps end end + step 'The emoji menu is visible' do + page.find(".emoji-menu.is-visible") + end + step 'The search field is focused' do expect(page).to have_selector('#emoji_search') expect(page.evaluate_script('document.activeElement.id')).to eq('emoji_search') diff --git a/features/steps/project/labels.rb b/features/steps/project/labels.rb new file mode 100644 index 0000000000..17944527e3 --- /dev/null +++ b/features/steps/project/labels.rb @@ -0,0 +1,34 @@ +class Spinach::Features::Labels < Spinach::FeatureSteps + include SharedAuthentication + include SharedIssuable + include SharedProject + include SharedNote + include SharedPaths + include SharedMarkdown + + step 'And I visit project "Shop" labels page' do + visit namespace_project_labels_path(project.namespace, project) + end + + step 'I should see that I am subscribed to the "bug" label' do + expect(subscribe_button).to have_content 'Unsubscribe' + end + + step 'I should see that I am not subscribed to the "bug" label' do + expect(subscribe_button).to have_content 'Subscribe' + end + + step 'I click button "Unsubscribe" for the "bug" label' do + subscribe_button.click + end + + step 'I click button "Subscribe" for the "bug" label' do + subscribe_button.click + end + + private + + def subscribe_button + first('.subscribe-button span') + end +end diff --git a/features/steps/project/merge_requests.rb b/features/steps/project/merge_requests.rb index df4259b9dd..91fe19dd47 100644 --- a/features/steps/project/merge_requests.rb +++ b/features/steps/project/merge_requests.rb @@ -16,10 +16,18 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps click_link "Bug NS-04" end + step 'I click link "Feature NS-05"' do + click_link "Feature NS-05" + end + step 'I click link "All"' do click_link "All" end + step 'I click link "Merged"' do + click_link "Merged" + end + step 'I click link "Closed"' do click_link "Closed" end @@ -40,6 +48,10 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps expect(page).to have_content "Bug NS-04" end + step 'I should see merge request "Feature NS-05"' do + expect(page).to have_content "Feature NS-05" + end + step 'I should not see "master" branch' do expect(find('.merge-request-info')).not_to have_content "master" end @@ -120,6 +132,14 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps author: project.users.first) end + step 'project "Shop" have "Feature NS-05" merged merge request' do + create(:merged_merge_request, + title: "Feature NS-05", + source_project: project, + target_project: project, + author: project.users.first) + end + step 'project "Shop" have "Bug NS-07" open merge request with rebased branch' do create(:merge_request, :rebased, title: "Bug NS-07", diff --git a/features/steps/search.rb b/features/steps/search.rb index 48ea3fa387..0ad837ebe1 100644 --- a/features/steps/search.rb +++ b/features/steps/search.rb @@ -100,7 +100,7 @@ class Spinach::Features::Search < Spinach::FeatureSteps step 'I should see "test_wiki" link in the search results' do page.within('.results') do - find(:css, '.search-results').should have_link 'test_wiki.md' + expect(find(:css, '.search-results')).to have_link 'test_wiki' end end diff --git a/features/steps/shared/issuable.rb b/features/steps/shared/issuable.rb index e59bfbea99..b6d70a26c2 100644 --- a/features/steps/shared/issuable.rb +++ b/features/steps/shared/issuable.rb @@ -147,6 +147,10 @@ module SharedIssuable expect_sidebar_content('2 of 2') end + step 'I should see "3 of 3" in the sidebar' do + expect_sidebar_content('3 of 3') + end + step 'I click link "Next" in the sidebar' do page.within '.issuable-sidebar' do click_link 'Next' diff --git a/features/support/capybara.rb b/features/support/capybara.rb index f33379f76c..fe9e39cf50 100644 --- a/features/support/capybara.rb +++ b/features/support/capybara.rb @@ -9,7 +9,7 @@ Capybara.register_driver :poltergeist do |app| Capybara::Poltergeist::Driver.new(app, js_errors: true, timeout: timeout, window_size: [1366, 768]) end -Capybara.default_wait_time = timeout +Capybara.default_max_wait_time = timeout Capybara.ignore_hidden_elements = false unless ENV['CI'] || ENV['CI_SERVER'] diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 9805e53624..71197205f3 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -31,6 +31,7 @@ module API expose :can_create_group?, as: :can_create_group expose :can_create_project?, as: :can_create_project expose :two_factor_enabled + expose :external end class UserLogin < UserFull diff --git a/lib/api/issues.rb b/lib/api/issues.rb index 252744515d..fda6f84143 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -82,7 +82,7 @@ module API # GET /projects/:id/issues?milestone=1.0.0&state=closed # GET /issues?iid=42 get ":id/issues" do - issues = user_project.issues + issues = user_project.issues.visible_to_user(current_user) issues = filter_issues_state(issues, params[:state]) unless params[:state].nil? issues = filter_issues_labels(issues, params[:labels]) unless params[:labels].nil? issues = filter_by_iid(issues, params[:iid]) unless params[:iid].nil? @@ -104,6 +104,7 @@ module API # GET /projects/:id/issues/:issue_id get ":id/issues/:issue_id" do @issue = user_project.issues.find(params[:issue_id]) + not_found! unless can?(current_user, :read_issue, @issue) present @issue, with: Entities::Issue end diff --git a/lib/api/users.rb b/lib/api/users.rb index fd2128bd17..13ab17c690 100644 --- a/lib/api/users.rb +++ b/lib/api/users.rb @@ -61,19 +61,20 @@ module API # admin - User is admin - true or false (default) # can_create_group - User can create groups - true or false # confirm - Require user confirmation - true (default) or false + # external - Flags the user as external - true or false(default) # Example Request: # POST /users post do authenticated_as_admin! required_attributes! [:email, :password, :name, :username] - attrs = attributes_for_keys [:email, :name, :password, :skype, :linkedin, :twitter, :projects_limit, :username, :bio, :can_create_group, :admin, :confirm] + attrs = attributes_for_keys [:email, :name, :password, :skype, :linkedin, :twitter, :projects_limit, :username, :bio, :can_create_group, :admin, :confirm, :external] admin = attrs.delete(:admin) confirm = !(attrs.delete(:confirm) =~ (/(false|f|no|0)$/i)) user = User.build_user(attrs) user.admin = admin unless admin.nil? user.skip_confirmation! unless confirm - identity_attrs = attributes_for_keys [:provider, :extern_uid] + if identity_attrs.any? user.identities.build(identity_attrs) end @@ -107,12 +108,13 @@ module API # bio - Bio # admin - User is admin - true or false (default) # can_create_group - User can create groups - true or false + # external - Flags the user as external - true or false(default) # Example Request: # PUT /users/:id put ":id" do authenticated_as_admin! - attrs = attributes_for_keys [:email, :name, :password, :skype, :linkedin, :twitter, :website_url, :projects_limit, :username, :bio, :can_create_group, :admin] + attrs = attributes_for_keys [:email, :name, :password, :skype, :linkedin, :twitter, :website_url, :projects_limit, :username, :bio, :can_create_group, :admin, :external] user = User.find(params[:id]) not_found!('User') unless user diff --git a/lib/banzai/filter/issue_reference_filter.rb b/lib/banzai/filter/issue_reference_filter.rb index 9f08aa36e8..2732e0b514 100644 --- a/lib/banzai/filter/issue_reference_filter.rb +++ b/lib/banzai/filter/issue_reference_filter.rb @@ -9,6 +9,11 @@ module Banzai Issue end + def self.user_can_see_reference?(user, node, context) + issue = Issue.find(node.attr('data-issue')) rescue nil + Ability.abilities.allowed?(user, :read_issue, issue) + end + def find_object(project, id) project.get_issue(id) end diff --git a/lib/gitlab/diff/file.rb b/lib/gitlab/diff/file.rb index faa2830c16..d2e85cabf7 100644 --- a/lib/gitlab/diff/file.rb +++ b/lib/gitlab/diff/file.rb @@ -24,6 +24,10 @@ module Gitlab @lines ||= parser.parse(raw_diff.each_line).to_a end + def too_large? + diff.too_large? + end + def highlighted_diff_lines Gitlab::Diff::Highlight.new(self).highlight end diff --git a/lib/gitlab/diff/parser.rb b/lib/gitlab/diff/parser.rb index d0f6ba23ab..d0815fc7ee 100644 --- a/lib/gitlab/diff/parser.rb +++ b/lib/gitlab/diff/parser.rb @@ -4,6 +4,8 @@ module Gitlab include Enumerable def parse(lines) + return [] if lines.blank? + @lines = lines line_obj_index = 0 line_old = 1 diff --git a/lib/gitlab/git_post_receive.rb b/lib/gitlab/git_post_receive.rb new file mode 100644 index 0000000000..a088e19d1e --- /dev/null +++ b/lib/gitlab/git_post_receive.rb @@ -0,0 +1,60 @@ +module Gitlab + class GitPostReceive + include Gitlab::Identifier + attr_reader :repo_path, :identifier, :changes, :project + + def initialize(repo_path, identifier, changes) + repo_path.gsub!(/\.git\z/, '') + repo_path.gsub!(/\A\//, '') + + @repo_path = repo_path + @identifier = identifier + @changes = deserialize_changes(changes) + + retrieve_project_and_type + end + + def wiki? + @type == :wiki + end + + def regular_project? + @type == :project + end + + def identify(revision) + super(identifier, project, revision) + end + + private + + def retrieve_project_and_type + @type = :project + @project = Project.find_with_namespace(@repo_path) + + if @repo_path.end_with?('.wiki') && !@project + @type = :wiki + @project = Project.find_with_namespace(@repo_path.gsub(/\.wiki\z/, '')) + end + end + + def deserialize_changes(changes) + changes = Base64.decode64(changes) unless changes.include?(' ') + changes = utf8_encode_changes(changes) + changes.lines + end + + def utf8_encode_changes(changes) + changes = changes.dup + + changes.force_encoding('UTF-8') + return changes if changes.valid_encoding? + + # Convert non-UTF-8 branch/tag names to UTF-8 so they can be dumped as JSON. + detection = CharlockHolmes::EncodingDetector.detect(changes) + return changes unless detection && detection[:encoding] + + CharlockHolmes::Converter.convert(changes, detection[:encoding], 'UTF-8') + end + end +end diff --git a/lib/gitlab/project_search_results.rb b/lib/gitlab/project_search_results.rb index 0607a8b959..71c5b6801f 100644 --- a/lib/gitlab/project_search_results.rb +++ b/lib/gitlab/project_search_results.rb @@ -2,7 +2,8 @@ module Gitlab class ProjectSearchResults < SearchResults attr_reader :project, :repository_ref - def initialize(project, query, repository_ref = nil) + def initialize(current_user, project, query, repository_ref = nil) + @current_user = current_user @project = project @repository_ref = if repository_ref.present? repository_ref diff --git a/lib/gitlab/search_results.rb b/lib/gitlab/search_results.rb index f13528a2ee..f8ab2b1f09 100644 --- a/lib/gitlab/search_results.rb +++ b/lib/gitlab/search_results.rb @@ -1,12 +1,13 @@ module Gitlab class SearchResults - attr_reader :query + attr_reader :current_user, :query # Limit search results by passed projects # It allows us to search only for projects user has access to attr_reader :limit_projects - def initialize(limit_projects, query) + def initialize(current_user, limit_projects, query) + @current_user = current_user @limit_projects = limit_projects || Project.all @query = Shellwords.shellescape(query) if query.present? end @@ -58,7 +59,7 @@ module Gitlab end def issues - issues = Issue.where(project_id: project_ids_relation) + issues = Issue.visible_to_user(current_user).where(project_id: project_ids_relation) if query =~ /#(\d+)\z/ issues = issues.where(iid: $1) diff --git a/lib/support/nginx/gitlab b/lib/support/nginx/gitlab index fc5475c4ee..1324e4cd26 100644 --- a/lib/support/nginx/gitlab +++ b/lib/support/nginx/gitlab @@ -30,7 +30,6 @@ server { listen [::]:80 default_server; server_name YOUR_SERVER_FQDN; ## Replace this with something like gitlab.example.com server_tokens off; ## Don't show the nginx version number, a security best practice - root /home/git/gitlab/public; ## See app/controllers/application_controller.rb for headers set @@ -57,4 +56,14 @@ server { proxy_pass http://gitlab-workhorse; } + + error_page 404 /404.html; + error_page 422 /422.html; + error_page 500 /500.html; + error_page 502 /502.html; + location ~ ^/(404|422|500|502)\.html$ { + root /home/git/gitlab/public; + internal; + } + } diff --git a/lib/support/nginx/gitlab-ssl b/lib/support/nginx/gitlab-ssl index 1e5f85413e..af6ea9ed70 100644 --- a/lib/support/nginx/gitlab-ssl +++ b/lib/support/nginx/gitlab-ssl @@ -45,7 +45,6 @@ server { listen [::]:443 ipv6only=on ssl default_server; server_name YOUR_SERVER_FQDN; ## Replace this with something like gitlab.example.com server_tokens off; ## Don't show the nginx version number, a security best practice - root /home/git/gitlab/public; ## Strong SSL Security ## https://raymii.org/s/tutorials/Strong_SSL_Security_On_nginx.html & https://cipherli.st/ @@ -101,4 +100,13 @@ server { proxy_set_header X-Forwarded-Proto $scheme; proxy_pass http://gitlab-workhorse; } + + error_page 404 /404.html; + error_page 422 /422.html; + error_page 500 /500.html; + error_page 502 /502.html; + location ~ ^/(404|422|500|502)\.html$ { + root /home/git/gitlab/public; + internal; + } } diff --git a/lib/tasks/gemojione.rake b/lib/tasks/gemojione.rake index ebe301c1fc..cfaf4a129b 100644 --- a/lib/tasks/gemojione.rake +++ b/lib/tasks/gemojione.rake @@ -47,6 +47,7 @@ namespace :gemojione do # let's simplify it system(%Q(sed -i '' "s/width: #{SIZE}px; height: #{SIZE}px; background: image-url('emoji.png')/background-position:/" #{style_path})) system(%Q(sed -i '' "s/ no-repeat//" #{style_path})) + system(%Q(sed -i '' "s/ 0px/ 0/" #{style_path})) # Append a generic rule that applies to all Emojis File.open(style_path, 'a') do |f| diff --git a/lib/tasks/gitlab/check.rake b/lib/tasks/gitlab/check.rake index 581ab26db7..27ed57efe5 100644 --- a/lib/tasks/gitlab/check.rake +++ b/lib/tasks/gitlab/check.rake @@ -913,7 +913,7 @@ namespace :gitlab do end def check_git_version - required_version = Gitlab::VersionInfo.new(1, 7, 10) + required_version = Gitlab::VersionInfo.new(2, 7, 3) current_version = Gitlab::VersionInfo.parse(run(%W(#{Gitlab.config.git.bin_path} --version))) puts "Your git bin path is \"#{Gitlab.config.git.bin_path}\"" diff --git a/lib/tasks/scss-lint.rake b/lib/tasks/scss-lint.rake new file mode 100644 index 0000000000..250fd8699e --- /dev/null +++ b/lib/tasks/scss-lint.rake @@ -0,0 +1,10 @@ +unless Rails.env.production? + require 'scss_lint/rake_task' + + SCSSLint::RakeTask.new do |t| + t.config = '.scss-lint.yml' + # See https://github.com/brigade/scss-lint/issues/726 + # Hack, otherwise linter won't respect scss_files option in config file. + t.files = [] + end +end diff --git a/public/404.html b/public/404.html index a0106bc760..4862770cc2 100644 --- a/public/404.html +++ b/public/404.html @@ -2,11 +2,51 @@ The page you're looking for could not be found (404) - + -

    404

    +

    +
    + 404 +

    The page you're looking for could not be found.


    Make sure the address is correct and that the page hasn't moved.

    diff --git a/public/422.html b/public/422.html index 026997b48e..055b0bde16 100644 --- a/public/422.html +++ b/public/422.html @@ -2,12 +2,51 @@ The change you requested was rejected (422) - + - -

    422

    +

    +
    + 422 +

    The change you requested was rejected.


    Make sure you have access to the thing you tried to change.

    diff --git a/public/500.html b/public/500.html index 08c11bbd05..3d59d1392f 100644 --- a/public/500.html +++ b/public/500.html @@ -2,10 +2,50 @@ Something went wrong (500) - + -

    500

    +

    +
    + 500 +

    Whoops, something went wrong on our end.


    Try refreshing the page, or going back and attempting the action again.

    diff --git a/public/502.html b/public/502.html index 9480a92843..67dfd8a274 100644 --- a/public/502.html +++ b/public/502.html @@ -2,10 +2,50 @@ GitLab is not responding (502) - + -

    502

    +

    +
    + 502 +

    Whoops, GitLab is taking too much time to respond.


    Try refreshing the page, or going back and attempting the action again.

    diff --git a/public/deploy.html b/public/deploy.html index 3822ed4b64..48976dacf4 100644 --- a/public/deploy.html +++ b/public/deploy.html @@ -2,12 +2,49 @@ Deploy in progress - +

    -
    +
    Deploy in progress

    Please try again in a few minutes.

    diff --git a/public/logo.svg b/public/logo.svg deleted file mode 100644 index fc4553137f..0000000000 --- a/public/logo.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/public/static.css b/public/static.css deleted file mode 100644 index 0a2b6060d4..0000000000 --- a/public/static.css +++ /dev/null @@ -1,36 +0,0 @@ -body { - color: #666; - text-align: center; - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; - margin: 0; - width: 800px; - margin: auto; - font-size: 14px; -} - -h1 { - font-size: 56px; - line-height: 100px; - font-weight: normal; - color: #456; -} - -h2 { - font-size: 24px; - color: #666; - line-height: 1.5em; -} - -h3 { - color: #456; - font-size: 20px; - font-weight: normal; - line-height: 28px; -} - -hr { - margin: 18px 0; - border: 0; - border-top: 1px solid #EEE; - border-bottom: 1px solid white; -} diff --git a/spec/controllers/namespaces_controller_spec.rb b/spec/controllers/namespaces_controller_spec.rb index d4a380cc2e..7743695871 100644 --- a/spec/controllers/namespaces_controller_spec.rb +++ b/spec/controllers/namespaces_controller_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe NamespacesController do - let!(:user) { create(:user, :with_avatar) } + let!(:user) { create(:user, avatar: fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "image/png")) } describe "GET show" do context "when the namespace belongs to a user" do diff --git a/spec/controllers/profiles/avatars_controller_spec.rb b/spec/controllers/profiles/avatars_controller_spec.rb index 85dff009bc..ad5855df0a 100644 --- a/spec/controllers/profiles/avatars_controller_spec.rb +++ b/spec/controllers/profiles/avatars_controller_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe Profiles::AvatarsController do - let(:user) { create(:user, :with_avatar) } + let(:user) { create(:user, avatar: fixture_file_upload(Rails.root + "spec/fixtures/dk.png")) } before do sign_in(user) diff --git a/spec/controllers/projects/branches_controller_spec.rb b/spec/controllers/projects/branches_controller_spec.rb index 8e06d4bdc7..98ae424ed7 100644 --- a/spec/controllers/projects/branches_controller_spec.rb +++ b/spec/controllers/projects/branches_controller_spec.rb @@ -17,49 +17,79 @@ describe Projects::BranchesController do describe "POST create" do render_views - before do - post :create, - namespace_id: project.namespace.to_param, - project_id: project.to_param, - branch_name: branch, - ref: ref - end + context "on creation of a new branch" do + before do + post :create, + namespace_id: project.namespace.to_param, + project_id: project.to_param, + branch_name: branch, + ref: ref + end - context "valid branch name, valid source" do - let(:branch) { "merge_branch" } - let(:ref) { "master" } - it 'redirects' do - expect(subject). - to redirect_to("/#{project.path_with_namespace}/tree/merge_branch") + context "valid branch name, valid source" do + let(:branch) { "merge_branch" } + let(:ref) { "master" } + it 'redirects' do + expect(subject). + to redirect_to("/#{project.path_with_namespace}/tree/merge_branch") + end + end + + context "invalid branch name, valid ref" do + let(:branch) { "" } + let(:ref) { "master" } + it 'redirects' do + expect(subject). + to redirect_to("/#{project.path_with_namespace}/tree/alert('merge');") + end + end + + context "valid branch name, invalid ref" do + let(:branch) { "merge_branch" } + let(:ref) { "" } + it { is_expected.to render_template('new') } + end + + context "invalid branch name, invalid ref" do + let(:branch) { "" } + let(:ref) { "" } + it { is_expected.to render_template('new') } + end + + context "valid branch name with encoded slashes" do + let(:branch) { "feature%2Ftest" } + let(:ref) { "" } + it { is_expected.to render_template('new') } + it { project.repository.branch_names.include?('feature/test') } end end - context "invalid branch name, valid ref" do - let(:branch) { "" } - let(:ref) { "master" } + describe "created from the new branch button on issues" do + let(:branch) { "1-feature-branch" } + let!(:issue) { create(:issue, project: project) } + + it 'redirects' do + post :create, + namespace_id: project.namespace.to_param, + project_id: project.to_param, + branch_name: branch, + issue_iid: issue.iid + expect(subject). - to redirect_to("/#{project.path_with_namespace}/tree/alert('merge');") + to redirect_to("/#{project.path_with_namespace}/tree/1-feature-branch") end - end - context "valid branch name, invalid ref" do - let(:branch) { "merge_branch" } - let(:ref) { "" } - it { is_expected.to render_template('new') } - end + it 'posts a system note' do + expect(SystemNoteService).to receive(:new_issue_branch).with(issue, project, user, "1-feature-branch") - context "invalid branch name, invalid ref" do - let(:branch) { "" } - let(:ref) { "" } - it { is_expected.to render_template('new') } - end + post :create, + namespace_id: project.namespace.to_param, + project_id: project.to_param, + branch_name: branch, + issue_iid: issue.iid + end - context "valid branch name with encoded slashes" do - let(:branch) { "feature%2Ftest" } - let(:ref) { "" } - it { is_expected.to render_template('new') } - it { project.repository.branch_names.include?('feature/test')} end end diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 76d56bc989..2cd8123114 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -1,16 +1,16 @@ require('spec_helper') describe Projects::IssuesController do - let(:project) { create(:project) } - let(:user) { create(:user) } - let(:issue) { create(:issue, project: project) } - - before do - sign_in(user) - project.team << [user, :developer] - end - describe "GET #index" do + let(:project) { create(:project) } + let(:user) { create(:user) } + let(:issue) { create(:issue, project: project) } + + before do + sign_in(user) + project.team << [user, :developer] + end + it "returns index" do get :index, namespace_id: project.namespace.path, project_id: project.path @@ -38,6 +38,152 @@ describe Projects::IssuesController do get :index, namespace_id: project.namespace.path, project_id: project.path expect(response.status).to eq(404) end + end + describe 'Confidential Issues' do + let(:project) { create(:empty_project, :public) } + let(:assignee) { create(:assignee) } + let(:author) { create(:user) } + let(:non_member) { create(:user) } + let(:member) { create(:user) } + let(:admin) { create(:admin) } + let!(:issue) { create(:issue, project: project) } + let!(:unescaped_parameter_value) { create(:issue, :confidential, project: project, author: author) } + let!(:request_forgery_timing_attack) { create(:issue, :confidential, project: project, assignee: assignee) } + + describe 'GET #index' do + it 'should not list confidential issues for guests' do + sign_out(:user) + get_issues + + expect(assigns(:issues)).to eq [issue] + end + + it 'should not list confidential issues for non project members' do + sign_in(non_member) + get_issues + + expect(assigns(:issues)).to eq [issue] + end + + it 'should list confidential issues for author' do + sign_in(author) + get_issues + + expect(assigns(:issues)).to include unescaped_parameter_value + expect(assigns(:issues)).not_to include request_forgery_timing_attack + end + + it 'should list confidential issues for assignee' do + sign_in(assignee) + get_issues + + expect(assigns(:issues)).not_to include unescaped_parameter_value + expect(assigns(:issues)).to include request_forgery_timing_attack + end + + it 'should list confidential issues for project members' do + sign_in(member) + project.team << [member, :developer] + + get_issues + + expect(assigns(:issues)).to include unescaped_parameter_value + expect(assigns(:issues)).to include request_forgery_timing_attack + end + + it 'should list confidential issues for admin' do + sign_in(admin) + get_issues + + expect(assigns(:issues)).to include unescaped_parameter_value + expect(assigns(:issues)).to include request_forgery_timing_attack + end + + def get_issues + get :index, + namespace_id: project.namespace.to_param, + project_id: project.to_param + end + end + + shared_examples_for 'restricted action' do |http_status| + it 'returns 404 for guests' do + sign_out :user + go(id: unescaped_parameter_value.to_param) + + expect(response).to have_http_status :not_found + end + + it 'returns 404 for non project members' do + sign_in(non_member) + go(id: unescaped_parameter_value.to_param) + + expect(response).to have_http_status :not_found + end + + it "returns #{http_status[:success]} for author" do + sign_in(author) + go(id: unescaped_parameter_value.to_param) + + expect(response).to have_http_status http_status[:success] + end + + it "returns #{http_status[:success]} for assignee" do + sign_in(assignee) + go(id: request_forgery_timing_attack.to_param) + + expect(response).to have_http_status http_status[:success] + end + + it "returns #{http_status[:success]} for project members" do + sign_in(member) + project.team << [member, :developer] + go(id: unescaped_parameter_value.to_param) + + expect(response).to have_http_status http_status[:success] + end + + it "returns #{http_status[:success]} for admin" do + sign_in(admin) + go(id: unescaped_parameter_value.to_param) + + expect(response).to have_http_status http_status[:success] + end + end + + describe 'GET #show' do + it_behaves_like 'restricted action', success: 200 + + def go(id:) + get :show, + namespace_id: project.namespace.to_param, + project_id: project.to_param, + id: id + end + end + + describe 'GET #edit' do + it_behaves_like 'restricted action', success: 200 + + def go(id:) + get :edit, + namespace_id: project.namespace.to_param, + project_id: project.to_param, + id: id + end + end + + describe 'PUT #update' do + it_behaves_like 'restricted action', success: 302 + + def go(id:) + put :update, + namespace_id: project.namespace.to_param, + project_id: project.to_param, + id: id, + issue: { title: 'New title' } + end + end end end diff --git a/spec/controllers/uploads_controller_spec.rb b/spec/controllers/uploads_controller_spec.rb index 0d9f4b299b..af5d043cf0 100644 --- a/spec/controllers/uploads_controller_spec.rb +++ b/spec/controllers/uploads_controller_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe UploadsController do - let!(:user) { create(:user, :with_avatar) } + let!(:user) { create(:user, avatar: fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "image/png")) } describe "GET show" do context "when viewing a user avatar" do diff --git a/spec/factories/issues.rb b/spec/factories/issues.rb index 722095de59..e72aa9479b 100644 --- a/spec/factories/issues.rb +++ b/spec/factories/issues.rb @@ -4,6 +4,10 @@ FactoryGirl.define do author project + trait :confidential do + confidential true + end + trait :closed do state :closed end diff --git a/spec/factories/labels.rb b/spec/factories/labels.rb index 6e70af10af..ea2be8928d 100644 --- a/spec/factories/labels.rb +++ b/spec/factories/labels.rb @@ -13,7 +13,7 @@ FactoryGirl.define do factory :label do - title "Bug" + sequence(:title) { |n| "label#{n}" } color "#990000" project end diff --git a/spec/factories/merge_requests.rb b/spec/factories/merge_requests.rb index ca1c636fce..e281e2f227 100644 --- a/spec/factories/merge_requests.rb +++ b/spec/factories/merge_requests.rb @@ -51,11 +51,20 @@ FactoryGirl.define do trait :with_diffs do end + trait :without_diffs do + source_branch "improve/awesome" + target_branch "master" + end + trait :conflict do source_branch "feature_conflict" target_branch "feature" end + trait :merged do + state :merged + end + trait :closed do state :closed end @@ -84,6 +93,7 @@ FactoryGirl.define do merge_user author end + factory :merged_merge_request, traits: [:merged] factory :closed_merge_request, traits: [:closed] factory :reopened_merge_request, traits: [:reopened] factory :merge_request_with_diffs, traits: [:with_diffs] diff --git a/spec/factories/todos.rb b/spec/factories/todos.rb index bd85b1d798..7ae06c2784 100644 --- a/spec/factories/todos.rb +++ b/spec/factories/todos.rb @@ -5,14 +5,15 @@ # id :integer not null, primary key # user_id :integer not null # project_id :integer not null -# target_id :integer not null +# target_id :integer # target_type :string not null # author_id :integer -# note_id :integer # action :integer not null # state :string not null # created_at :datetime # updated_at :datetime +# note_id :integer +# commit_id :string # FactoryGirl.define do @@ -30,5 +31,10 @@ FactoryGirl.define do trait :mentioned do action { Todo::MENTIONED } end + + trait :on_commit do + commit_id RepoHelpers.sample_commit.id + target_type "Commit" + end end end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 785c2a3d81..a5c60c51c5 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -23,13 +23,6 @@ FactoryGirl.define do end end - trait :with_avatar do - avatar { fixture_file_upload(Rails.root.join(*%w(spec fixtures dk.png)), 'image/png') } - avatar_crop_x 0 - avatar_crop_y 0 - avatar_crop_size 256 - end - factory :omniauth_user do transient do extern_uid '123456' diff --git a/spec/features/issues/new_branch_button_spec.rb b/spec/features/issues/new_branch_button_spec.rb new file mode 100644 index 0000000000..9219b76754 --- /dev/null +++ b/spec/features/issues/new_branch_button_spec.rb @@ -0,0 +1,49 @@ +require 'rails_helper' + +feature 'Start new branch from an issue', feature: true do + let!(:project) { create(:project) } + let!(:issue) { create(:issue, project: project) } + let!(:user) { create(:user)} + + context "for team members" do + before do + project.team << [user, :master] + login_as(user) + end + + it 'shown the new branch button', js: false do + visit namespace_project_issue_path(project.namespace, project, issue) + + expect(page).to have_link "New Branch" + end + + context "when there is a referenced merge request" do + let(:note) do + create(:note, :on_issue, :system, project: project, + note: "mentioned in !#{referenced_mr.iid}") + end + let(:referenced_mr) do + create(:merge_request, :simple, source_project: project, target_project: project, + description: "Fixes ##{issue.iid}", author: user) + end + + before do + issue.notes << note + + visit namespace_project_issue_path(project.namespace, project, issue) + end + + it "hides the new branch button", js: true do + expect(page).not_to have_link "New Branch" + expect(page).to have_content /1 Related Merge Request/ + end + end + end + + context "for visiters" do + it 'no button is shown', js: false do + visit namespace_project_issue_path(project.namespace, project, issue) + expect(page).not_to have_link "New Branch" + end + end +end diff --git a/spec/features/merge_requests/filter_by_milestone_spec.rb b/spec/features/merge_requests/filter_by_milestone_spec.rb index 1b2fd1bab1..b76e4c74c7 100644 --- a/spec/features/merge_requests/filter_by_milestone_spec.rb +++ b/spec/features/merge_requests/filter_by_milestone_spec.rb @@ -30,8 +30,6 @@ feature 'Merge Request filtering by Milestone', feature: true do def filter_by_milestone(title) find(".js-milestone-select").click - sleep 0.5 find(".milestone-filter a", text: title).click - sleep 1 end end diff --git a/spec/features/security/project/internal_access_spec.rb b/spec/features/security/project/internal_access_spec.rb index 57563add74..f88c591d89 100644 --- a/spec/features/security/project/internal_access_spec.rb +++ b/spec/features/security/project/internal_access_spec.rb @@ -8,10 +8,12 @@ describe "Internal Project Access", feature: true do let(:master) { create(:user) } let(:guest) { create(:user) } let(:reporter) { create(:user) } + let(:external_team_member) { create(:user, external: true) } before do # full access project.team << [master, :master] + project.team << [external_team_member, :master] # readonly project.team << [reporter, :reporter] @@ -34,6 +36,8 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -45,6 +49,8 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -56,6 +62,8 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -67,6 +75,8 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -78,6 +88,8 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -89,22 +101,23 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_denied_for guest } it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end describe "GET /:project_path/blob" do - before do - commit = project.repository.commit - path = '.gitignore' - @blob_path = namespace_project_blob_path(project.namespace, project, File.join(commit.id, path)) - end + let(:commit) { project.repository.commit } + subject { namespace_project_blob_path(project.namespace, project, File.join(commit.id, '.gitignore')) } - it { expect(@blob_path).to be_allowed_for master } - it { expect(@blob_path).to be_allowed_for reporter } - it { expect(@blob_path).to be_allowed_for :admin } - it { expect(@blob_path).to be_allowed_for guest } - it { expect(@blob_path).to be_allowed_for :user } - it { expect(@blob_path).to be_denied_for :visitor } + it { is_expected.to be_allowed_for master } + it { is_expected.to be_allowed_for reporter } + it { is_expected.to be_allowed_for :admin } + it { is_expected.to be_allowed_for guest } + it { is_expected.to be_allowed_for :user } + it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } + it { is_expected.to be_denied_for :visitor } end describe "GET /:project_path/edit" do @@ -115,6 +128,8 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_denied_for guest } it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -126,6 +141,8 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_denied_for guest } it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -137,6 +154,8 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -149,6 +168,8 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_denied_for guest } it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -160,6 +181,8 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -171,6 +194,8 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_denied_for guest } it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -182,6 +207,8 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -193,6 +220,8 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_denied_for guest } it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -209,6 +238,8 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -225,6 +256,8 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -236,6 +269,8 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_denied_for guest } it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end end diff --git a/spec/features/security/project/private_access_spec.rb b/spec/features/security/project/private_access_spec.rb index a1e111c6ca..19f287ce7a 100644 --- a/spec/features/security/project/private_access_spec.rb +++ b/spec/features/security/project/private_access_spec.rb @@ -8,10 +8,12 @@ describe "Private Project Access", feature: true do let(:master) { create(:user) } let(:guest) { create(:user) } let(:reporter) { create(:user) } + let(:external_team_member) { create(:user, external: true) } before do # full access project.team << [master, :master] + project.team << [external_team_member, :master] # readonly project.team << [reporter, :reporter] @@ -34,6 +36,8 @@ describe "Private Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_denied_for guest } it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -45,6 +49,8 @@ describe "Private Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_denied_for guest } it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -56,6 +62,8 @@ describe "Private Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_denied_for guest } it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -67,6 +75,7 @@ describe "Private Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_denied_for guest } it { is_expected.to be_denied_for :user } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -78,6 +87,8 @@ describe "Private Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_denied_for guest } it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -89,22 +100,23 @@ describe "Private Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_denied_for guest } it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end describe "GET /:project_path/blob" do - before do - commit = project.repository.commit - path = '.gitignore' - @blob_path = namespace_project_blob_path(project.namespace, project, File.join(commit.id, path)) - end + let(:commit) { project.repository.commit } + subject { namespace_project_blob_path(project.namespace, project, File.join(commit.id, '.gitignore'))} - it { expect(@blob_path).to be_allowed_for master } - it { expect(@blob_path).to be_allowed_for reporter } - it { expect(@blob_path).to be_allowed_for :admin } - it { expect(@blob_path).to be_denied_for guest } - it { expect(@blob_path).to be_denied_for :user } - it { expect(@blob_path).to be_denied_for :visitor } + it { is_expected.to be_allowed_for master } + it { is_expected.to be_allowed_for reporter } + it { is_expected.to be_allowed_for :admin } + it { is_expected.to be_denied_for guest } + it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } + it { is_expected.to be_denied_for :visitor } end describe "GET /:project_path/edit" do @@ -115,6 +127,8 @@ describe "Private Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_denied_for guest } it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -126,6 +140,8 @@ describe "Private Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_denied_for guest } it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -137,6 +153,8 @@ describe "Private Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_denied_for guest } it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -149,6 +167,8 @@ describe "Private Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_denied_for guest } it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -160,6 +180,8 @@ describe "Private Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_denied_for guest } it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -171,6 +193,8 @@ describe "Private Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_denied_for guest } it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -187,6 +211,8 @@ describe "Private Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_denied_for guest } it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -203,6 +229,8 @@ describe "Private Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_denied_for guest } it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -214,6 +242,8 @@ describe "Private Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_denied_for guest } it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end end diff --git a/spec/features/security/project/public_access_spec.rb b/spec/features/security/project/public_access_spec.rb index b98476f854..4e13507636 100644 --- a/spec/features/security/project/public_access_spec.rb +++ b/spec/features/security/project/public_access_spec.rb @@ -38,6 +38,7 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_allowed_for :external } it { is_expected.to be_allowed_for :visitor } end @@ -49,6 +50,7 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_allowed_for :external } it { is_expected.to be_allowed_for :visitor } end @@ -60,6 +62,7 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_allowed_for :external } it { is_expected.to be_allowed_for :visitor } end @@ -71,6 +74,7 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_allowed_for :external } it { is_expected.to be_allowed_for :visitor } end @@ -82,6 +86,7 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_allowed_for :external } it { is_expected.to be_allowed_for :visitor } end @@ -93,6 +98,7 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_denied_for guest } it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :external } it { is_expected.to be_denied_for :visitor } end @@ -107,6 +113,7 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_allowed_for :external } it { is_expected.to be_allowed_for :visitor } end @@ -118,6 +125,7 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_denied_for guest } it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :external } it { is_expected.to be_denied_for :visitor } end end @@ -135,6 +143,7 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_allowed_for :external } it { is_expected.to be_allowed_for :visitor } end @@ -146,23 +155,22 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_denied_for guest } it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :external } it { is_expected.to be_denied_for :visitor } end end describe "GET /:project_path/blob" do - before do - commit = project.repository.commit - path = '.gitignore' - @blob_path = namespace_project_blob_path(project.namespace, project, File.join(commit.id, path)) - end + let(:commit) { project.repository.commit } - it { expect(@blob_path).to be_allowed_for master } - it { expect(@blob_path).to be_allowed_for reporter } - it { expect(@blob_path).to be_allowed_for :admin } - it { expect(@blob_path).to be_allowed_for guest } - it { expect(@blob_path).to be_allowed_for :user } - it { expect(@blob_path).to be_allowed_for :visitor } + subject { namespace_project_blob_path(project.namespace, project, File.join(commit.id, '.gitignore')) } + + it { is_expected.to be_allowed_for master } + it { is_expected.to be_allowed_for reporter } + it { is_expected.to be_allowed_for :admin } + it { is_expected.to be_allowed_for guest } + it { is_expected.to be_allowed_for :user } + it { is_expected.to be_allowed_for :visitor } end describe "GET /:project_path/edit" do @@ -173,6 +181,7 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_denied_for guest } it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :external } it { is_expected.to be_denied_for :visitor } end @@ -184,6 +193,7 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_denied_for guest } it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :external } it { is_expected.to be_denied_for :visitor } end @@ -195,6 +205,7 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_allowed_for :external } it { is_expected.to be_allowed_for :visitor } end @@ -207,6 +218,7 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_denied_for guest } it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :external } it { is_expected.to be_denied_for :visitor } end @@ -218,6 +230,7 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_allowed_for :external } it { is_expected.to be_allowed_for :visitor } end @@ -229,6 +242,7 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_denied_for guest } it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :external } it { is_expected.to be_denied_for :visitor } end @@ -240,6 +254,7 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_allowed_for :external } it { is_expected.to be_allowed_for :visitor } end @@ -251,6 +266,7 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_denied_for guest } it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :external } it { is_expected.to be_denied_for :visitor } end @@ -267,6 +283,7 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_allowed_for :external } it { is_expected.to be_allowed_for :visitor } end @@ -283,6 +300,7 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_allowed_for :external } it { is_expected.to be_allowed_for :visitor } end @@ -294,6 +312,7 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_denied_for guest } it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :external } it { is_expected.to be_denied_for :visitor } end end diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index 8013b31524..f6c1005d26 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -77,7 +77,7 @@ describe ApplicationHelper do let(:avatar_file_path) { File.join(Rails.root, 'spec', 'fixtures', 'banana_sample.gif') } it 'should return an url for the avatar' do - user = create(:user, :with_avatar, avatar: File.open(avatar_file_path)) + user = create(:user, avatar: File.open(avatar_file_path)) expect(helper.avatar_icon(user.email).to_s). to match("/uploads/user/avatar/#{user.id}/banana_sample.gif") @@ -88,7 +88,7 @@ describe ApplicationHelper do # Must be stubbed after the stub above, and separately stub_config_setting(url: Settings.send(:build_gitlab_url)) - user = create(:user, :with_avatar, avatar: File.open(avatar_file_path)) + user = create(:user, avatar: File.open(avatar_file_path)) expect(helper.avatar_icon(user.email).to_s). to match("/gitlab/uploads/user/avatar/#{user.id}/banana_sample.gif") @@ -102,7 +102,7 @@ describe ApplicationHelper do describe 'using a User' do it 'should return an URL for the avatar' do - user = create(:user, :with_avatar, avatar: File.open(avatar_file_path)) + user = create(:user, avatar: File.open(avatar_file_path)) expect(helper.avatar_icon(user).to_s). to match("/uploads/user/avatar/#{user.id}/banana_sample.gif") diff --git a/spec/lib/banzai/filter/redactor_filter_spec.rb b/spec/lib/banzai/filter/redactor_filter_spec.rb index e9bb388e36..9acf6304bc 100644 --- a/spec/lib/banzai/filter/redactor_filter_spec.rb +++ b/spec/lib/banzai/filter/redactor_filter_spec.rb @@ -44,8 +44,78 @@ describe Banzai::Filter::RedactorFilter, lib: true do end end - context "for user references" do + context 'with data-issue' do + context 'for confidential issues' do + it 'removes references for non project members' do + non_member = create(:user) + project = create(:empty_project, :public) + issue = create(:issue, :confidential, project: project) + link = reference_link(project: project.id, issue: issue.id, reference_filter: 'IssueReferenceFilter') + doc = filter(link, current_user: non_member) + + expect(doc.css('a').length).to eq 0 + end + + it 'allows references for author' do + author = create(:user) + project = create(:empty_project, :public) + issue = create(:issue, :confidential, project: project, author: author) + + link = reference_link(project: project.id, issue: issue.id, reference_filter: 'IssueReferenceFilter') + doc = filter(link, current_user: author) + + expect(doc.css('a').length).to eq 1 + end + + it 'allows references for assignee' do + assignee = create(:user) + project = create(:empty_project, :public) + issue = create(:issue, :confidential, project: project, assignee: assignee) + + link = reference_link(project: project.id, issue: issue.id, reference_filter: 'IssueReferenceFilter') + doc = filter(link, current_user: assignee) + + expect(doc.css('a').length).to eq 1 + end + + it 'allows references for project members' do + member = create(:user) + project = create(:empty_project, :public) + project.team << [member, :developer] + issue = create(:issue, :confidential, project: project) + + link = reference_link(project: project.id, issue: issue.id, reference_filter: 'IssueReferenceFilter') + doc = filter(link, current_user: member) + + expect(doc.css('a').length).to eq 1 + end + + it 'allows references for admin' do + admin = create(:admin) + project = create(:empty_project, :public) + issue = create(:issue, :confidential, project: project) + + link = reference_link(project: project.id, issue: issue.id, reference_filter: 'IssueReferenceFilter') + doc = filter(link, current_user: admin) + + expect(doc.css('a').length).to eq 1 + end + end + + it 'allows references for non confidential issues' do + user = create(:user) + project = create(:empty_project, :public) + issue = create(:issue, project: project) + + link = reference_link(project: project.id, issue: issue.id, reference_filter: 'IssueReferenceFilter') + doc = filter(link, current_user: user) + + expect(doc.css('a').length).to eq 1 + end + end + + context "for user references" do context 'with data-group' do it 'removes unpermitted Group references' do user = create(:user) diff --git a/spec/lib/ci/status_spec.rb b/spec/lib/ci/status_spec.rb index 1539720bb8..47f3df6e3c 100644 --- a/spec/lib/ci/status_spec.rb +++ b/spec/lib/ci/status_spec.rb @@ -48,6 +48,29 @@ describe Ci::Status do it { is_expected.to eq 'success' } end + context 'success and canceled' do + let(:statuses) do + [create(type, status: :success), create(type, status: :canceled)] + end + it { is_expected.to eq 'failed' } + end + + context 'all canceled' do + let(:statuses) do + [create(type, status: :canceled), create(type, status: :canceled)] + end + it { is_expected.to eq 'canceled' } + end + + context 'success and canceled but allowed to fail' do + let(:statuses) do + [create(type, status: :success), + create(type, status: :canceled, allow_failure: true)] + end + + it { is_expected.to eq 'success' } + end + context 'one finished and second running but allowed to fail' do let(:statuses) do [create(type, status: :success), diff --git a/spec/lib/gitlab/closing_issue_extractor_spec.rb b/spec/lib/gitlab/closing_issue_extractor_spec.rb index 04cf11fc6f..844fd79c99 100644 --- a/spec/lib/gitlab/closing_issue_extractor_spec.rb +++ b/spec/lib/gitlab/closing_issue_extractor_spec.rb @@ -11,6 +11,7 @@ describe Gitlab::ClosingIssueExtractor, lib: true do subject { described_class.new(project, project.creator) } before do + project.team << [project.creator, :developer] project2.team << [project.creator, :master] end diff --git a/spec/lib/gitlab/diff/file_spec.rb b/spec/lib/gitlab/diff/file_spec.rb index 0d9694f2c1..a0cbef6e6a 100644 --- a/spec/lib/gitlab/diff/file_spec.rb +++ b/spec/lib/gitlab/diff/file_spec.rb @@ -18,4 +18,18 @@ describe Gitlab::Diff::File, lib: true do describe :mode_changed? do it { expect(diff_file.mode_changed?).to be_falsey } end + + describe '#too_large?' do + it 'returns true for a file that is too large' do + expect(diff).to receive(:too_large?).and_return(true) + + expect(diff_file.too_large?).to eq(true) + end + + it 'returns false for a file that is small enough' do + expect(diff).to receive(:too_large?).and_return(false) + + expect(diff_file.too_large?).to eq(false) + end + end end diff --git a/spec/lib/gitlab/diff/parser_spec.rb b/spec/lib/gitlab/diff/parser_spec.rb index f576c39284..cdff063a9e 100644 --- a/spec/lib/gitlab/diff/parser_spec.rb +++ b/spec/lib/gitlab/diff/parser_spec.rb @@ -90,4 +90,9 @@ eos end end end + + context 'when lines is empty' do + it { expect(parser.parse([])).to eq([]) } + it { expect(parser.parse(nil)).to eq([]) } + end end diff --git a/spec/lib/gitlab/project_search_results_spec.rb b/spec/lib/gitlab/project_search_results_spec.rb index 09adbc07dc..db0ff95b4f 100644 --- a/spec/lib/gitlab/project_search_results_spec.rb +++ b/spec/lib/gitlab/project_search_results_spec.rb @@ -1,11 +1,12 @@ require 'spec_helper' describe Gitlab::ProjectSearchResults, lib: true do + let(:user) { create(:user) } let(:project) { create(:project) } let(:query) { 'hello world' } describe 'initialize with empty ref' do - let(:results) { Gitlab::ProjectSearchResults.new(project, query, '') } + let(:results) { Gitlab::ProjectSearchResults.new(user, project, query, '') } it { expect(results.project).to eq(project) } it { expect(results.repository_ref).to be_nil } @@ -14,10 +15,74 @@ describe Gitlab::ProjectSearchResults, lib: true do describe 'initialize with ref' do let(:ref) { 'refs/heads/test' } - let(:results) { Gitlab::ProjectSearchResults.new(project, query, ref) } + let(:results) { Gitlab::ProjectSearchResults.new(user, project, query, ref) } it { expect(results.project).to eq(project) } it { expect(results.repository_ref).to eq(ref) } it { expect(results.query).to eq('hello world') } end + + describe 'confidential issues' do + let(:query) { 'issue' } + let(:author) { create(:user) } + let(:assignee) { create(:user) } + let(:non_member) { create(:user) } + let(:member) { create(:user) } + let(:admin) { create(:admin) } + let!(:issue) { create(:issue, project: project, title: 'Issue 1') } + let!(:security_issue_1) { create(:issue, :confidential, project: project, title: 'Security issue 1', author: author) } + let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project, assignee: assignee) } + + it 'should not list project confidential issues for non project members' do + results = described_class.new(non_member, project, query) + issues = results.objects('issues') + + expect(issues).to include issue + expect(issues).not_to include security_issue_1 + expect(issues).not_to include security_issue_2 + expect(results.issues_count).to eq 1 + end + + it 'should list project confidential issues for author' do + results = described_class.new(author, project, query) + issues = results.objects('issues') + + expect(issues).to include issue + expect(issues).to include security_issue_1 + expect(issues).not_to include security_issue_2 + expect(results.issues_count).to eq 2 + end + + it 'should list project confidential issues for assignee' do + results = described_class.new(assignee, project.id, query) + issues = results.objects('issues') + + expect(issues).to include issue + expect(issues).not_to include security_issue_1 + expect(issues).to include security_issue_2 + expect(results.issues_count).to eq 2 + end + + it 'should list project confidential issues for project members' do + project.team << [member, :developer] + + results = described_class.new(member, project, query) + issues = results.objects('issues') + + expect(issues).to include issue + expect(issues).to include security_issue_1 + expect(issues).to include security_issue_2 + expect(results.issues_count).to eq 3 + end + + it 'should list all project issues for admin' do + results = described_class.new(admin, project, query) + issues = results.objects('issues') + + expect(issues).to include issue + expect(issues).to include security_issue_1 + expect(issues).to include security_issue_2 + expect(results.issues_count).to eq 3 + end + end end diff --git a/spec/lib/gitlab/reference_extractor_spec.rb b/spec/lib/gitlab/reference_extractor_spec.rb index 7d963795e1..65af37e24f 100644 --- a/spec/lib/gitlab/reference_extractor_spec.rb +++ b/spec/lib/gitlab/reference_extractor_spec.rb @@ -2,6 +2,7 @@ require 'spec_helper' describe Gitlab::ReferenceExtractor, lib: true do let(:project) { create(:project) } + subject { Gitlab::ReferenceExtractor.new(project, project.creator) } it 'accesses valid user objects' do @@ -41,6 +42,7 @@ describe Gitlab::ReferenceExtractor, lib: true do end it 'accesses valid issue objects' do + project.team << [project.creator, :developer] @i0 = create(:issue, project: project) @i1 = create(:issue, project: project) diff --git a/spec/lib/gitlab/search_results_spec.rb b/spec/lib/gitlab/search_results_spec.rb index bb18f41785..f4afe597e8 100644 --- a/spec/lib/gitlab/search_results_spec.rb +++ b/spec/lib/gitlab/search_results_spec.rb @@ -1,6 +1,7 @@ require 'spec_helper' describe Gitlab::SearchResults do + let(:user) { create(:user) } let!(:project) { create(:project, name: 'foo') } let!(:issue) { create(:issue, project: project, title: 'foo') } @@ -9,7 +10,7 @@ describe Gitlab::SearchResults do end let!(:milestone) { create(:milestone, project: project, title: 'foo') } - let(:results) { described_class.new(Project.all, 'foo') } + let(:results) { described_class.new(user, Project.all, 'foo') } describe '#total_count' do it 'returns the total amount of search hits' do @@ -52,4 +53,92 @@ describe Gitlab::SearchResults do expect(results.empty?).to eq(false) end end + + describe 'confidential issues' do + let(:project_1) { create(:empty_project) } + let(:project_2) { create(:empty_project) } + let(:project_3) { create(:empty_project) } + let(:project_4) { create(:empty_project) } + let(:query) { 'issue' } + let(:limit_projects) { Project.where(id: [project_1.id, project_2.id, project_3.id]) } + let(:author) { create(:user) } + let(:assignee) { create(:user) } + let(:non_member) { create(:user) } + let(:member) { create(:user) } + let(:admin) { create(:admin) } + let!(:issue) { create(:issue, project: project_1, title: 'Issue 1') } + let!(:security_issue_1) { create(:issue, :confidential, project: project_1, title: 'Security issue 1', author: author) } + let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project_1, assignee: assignee) } + let!(:security_issue_3) { create(:issue, :confidential, project: project_2, title: 'Security issue 3', author: author) } + let!(:security_issue_4) { create(:issue, :confidential, project: project_3, title: 'Security issue 4', assignee: assignee) } + let!(:security_issue_5) { create(:issue, :confidential, project: project_4, title: 'Security issue 5') } + + it 'should not list confidential issues for non project members' do + results = described_class.new(non_member, limit_projects, query) + issues = results.objects('issues') + + expect(issues).to include issue + expect(issues).not_to include security_issue_1 + expect(issues).not_to include security_issue_2 + expect(issues).not_to include security_issue_3 + expect(issues).not_to include security_issue_4 + expect(issues).not_to include security_issue_5 + expect(results.issues_count).to eq 1 + end + + it 'should list confidential issues for author' do + results = described_class.new(author, limit_projects, query) + issues = results.objects('issues') + + expect(issues).to include issue + expect(issues).to include security_issue_1 + expect(issues).not_to include security_issue_2 + expect(issues).to include security_issue_3 + expect(issues).not_to include security_issue_4 + expect(issues).not_to include security_issue_5 + expect(results.issues_count).to eq 3 + end + + it 'should list confidential issues for assignee' do + results = described_class.new(assignee, limit_projects, query) + issues = results.objects('issues') + + expect(issues).to include issue + expect(issues).not_to include security_issue_1 + expect(issues).to include security_issue_2 + expect(issues).not_to include security_issue_3 + expect(issues).to include security_issue_4 + expect(issues).not_to include security_issue_5 + expect(results.issues_count).to eq 3 + end + + it 'should list confidential issues for project members' do + project_1.team << [member, :developer] + project_2.team << [member, :developer] + + results = described_class.new(member, limit_projects, query) + issues = results.objects('issues') + + expect(issues).to include issue + expect(issues).to include security_issue_1 + expect(issues).to include security_issue_2 + expect(issues).to include security_issue_3 + expect(issues).not_to include security_issue_4 + expect(issues).not_to include security_issue_5 + expect(results.issues_count).to eq 4 + end + + it 'should list all issues for admin' do + results = described_class.new(admin, limit_projects, query) + issues = results.objects('issues') + + expect(issues).to include issue + expect(issues).to include security_issue_1 + expect(issues).to include security_issue_2 + expect(issues).to include security_issue_3 + expect(issues).to include security_issue_4 + expect(issues).not_to include security_issue_5 + expect(results.issues_count).to eq 5 + end + end end diff --git a/spec/mailers/emails/profile_spec.rb b/spec/mailers/emails/profile_spec.rb index 5b575da34f..c6758ccad3 100644 --- a/spec/mailers/emails/profile_spec.rb +++ b/spec/mailers/emails/profile_spec.rb @@ -11,7 +11,7 @@ describe Notify do let(:example_site_path) { root_path } let(:new_user) { create(:user, email: new_user_address, created_by_id: 1) } let(:token) { 'kETLwRaayvigPq_x3SNM' } - + subject { Notify.new_user_email(new_user.id, token) } it_behaves_like 'an email sent from GitLab' @@ -77,6 +77,10 @@ describe Notify do it 'includes a link to ssh keys page' do is_expected.to have_body_text /#{profile_keys_path}/ end + + context 'with SSH key that does not exist' do + it { expect { Notify.new_ssh_key_email('foo') }.not_to raise_error } + end end describe 'user added email' do diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index 232a11245a..f910424d85 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -100,6 +100,34 @@ describe Notify do end end + describe 'that have been relabeled' do + subject { Notify.relabeled_issue_email(recipient.id, issue.id, %w[foo bar baz], current_user.id) } + + it_behaves_like 'a multiple recipients email' + it_behaves_like 'an answer to an existing thread', 'issue' + it_behaves_like 'it should show Gmail Actions View Issue link' + it_behaves_like 'a user cannot unsubscribe through footer link' + it_behaves_like 'an email with a labels subscriptions link in its footer' + + it 'is sent as the author' do + sender = subject.header[:from].addrs[0] + expect(sender.display_name).to eq(current_user.name) + expect(sender.address).to eq(gitlab_sender) + end + + it 'has the correct subject' do + is_expected.to have_subject /#{issue.title} \(##{issue.iid}\)/ + end + + it 'contains the names of the added labels' do + is_expected.to have_body_text /foo, bar, and baz/ + end + + it 'contains a link to the issue' do + is_expected.to have_body_text /#{namespace_project_issue_path project.namespace, project, issue}/ + end + end + describe 'status changed' do let(:status) { 'closed' } subject { Notify.issue_status_changed_email(recipient.id, issue.id, status, current_user.id) } @@ -219,6 +247,34 @@ describe Notify do end end + describe 'that have been relabeled' do + subject { Notify.relabeled_merge_request_email(recipient.id, merge_request.id, %w[foo bar baz], current_user.id) } + + it_behaves_like 'a multiple recipients email' + it_behaves_like 'an answer to an existing thread', 'merge_request' + it_behaves_like 'it should show Gmail Actions View Merge request link' + it_behaves_like 'a user cannot unsubscribe through footer link' + it_behaves_like 'an email with a labels subscriptions link in its footer' + + it 'is sent as the author' do + sender = subject.header[:from].addrs[0] + expect(sender.display_name).to eq(current_user.name) + expect(sender.address).to eq(gitlab_sender) + end + + it 'has the correct subject' do + is_expected.to have_subject /#{merge_request.title} \(##{merge_request.iid}\)/ + end + + it 'contains the names of the added labels' do + is_expected.to have_body_text /foo, bar, and baz/ + end + + it 'contains a link to the merge request' do + is_expected.to have_body_text /#{namespace_project_merge_request_path project.namespace, project, merge_request}/ + end + end + describe 'status changed' do let(:status) { 'reopened' } subject { Notify.merge_request_status_email(recipient.id, merge_request.id, status, current_user.id) } diff --git a/spec/mailers/shared/notify.rb b/spec/mailers/shared/notify.rb index 48c851ebbd..6019af544d 100644 --- a/spec/mailers/shared/notify.rb +++ b/spec/mailers/shared/notify.rb @@ -112,6 +112,10 @@ shared_examples 'an unsubscribeable thread' do it { is_expected.to have_body_text /unsubscribe/ } end -shared_examples "a user cannot unsubscribe through footer link" do +shared_examples 'a user cannot unsubscribe through footer link' do it { is_expected.not_to have_body_text /unsubscribe/ } end + +shared_examples 'an email with a labels subscriptions link in its footer' do + it { is_expected.to have_body_text /label subscriptions/ } +end diff --git a/spec/models/abuse_report_spec.rb b/spec/models/abuse_report_spec.rb index 4799bbaa57..ac12ab6c75 100644 --- a/spec/models/abuse_report_spec.rb +++ b/spec/models/abuse_report_spec.rb @@ -13,7 +13,8 @@ require 'rails_helper' RSpec.describe AbuseReport, type: :model do - subject { create(:abuse_report) } + subject { create(:abuse_report) } + let(:user) { create(:user) } it { expect(subject).to be_valid } @@ -31,17 +32,14 @@ RSpec.describe AbuseReport, type: :model do describe '#remove_user' do it 'blocks the user' do - report = build(:abuse_report) - - allow(report.user).to receive(:destroy) - - expect { report.remove_user }.to change { report.user.blocked? }.to(true) + expect { subject.remove_user(deleted_by: user) }.to change { subject.user.blocked? }.to(true) end - it 'removes the user' do - report = build(:abuse_report) + it 'lets a worker delete the user' do + expect(DeleteUserWorker).to receive(:perform_async).with(user.id, subject.user.id, + delete_solo_owned_groups: true) - expect { report.remove_user }.to change { User.count }.by(-1) + subject.remove_user(deleted_by: user) end end diff --git a/spec/models/commit_spec.rb b/spec/models/commit_spec.rb index 253902512c..0e9111c802 100644 --- a/spec/models/commit_spec.rb +++ b/spec/models/commit_spec.rb @@ -86,10 +86,21 @@ eos let(:issue) { create :issue, project: project } let(:other_project) { create :project, :public } let(:other_issue) { create :issue, project: other_project } + let(:commiter) { create :user } + + before do + project.team << [commiter, :developer] + other_project.team << [commiter, :developer] + end it 'detects issues that this commit is marked as closing' do ext_ref = "#{other_project.path_with_namespace}##{other_issue.iid}" - allow(commit).to receive(:safe_message).and_return("Fixes ##{issue.iid} and #{ext_ref}") + + allow(commit).to receive_messages( + safe_message: "Fixes ##{issue.iid} and #{ext_ref}", + committer_email: commiter.email + ) + expect(commit.closes_issues).to include(issue) expect(commit.closes_issues).to include(other_issue) end diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb index aff384c294..be29b6d66f 100644 --- a/spec/models/concerns/issuable_spec.rb +++ b/spec/models/concerns/issuable_spec.rb @@ -113,6 +113,48 @@ describe Issue, "Issuable" do end end + describe '#subscribed?' do + context 'user is not a participant in the issue' do + before { allow(issue).to receive(:participants).with(user).and_return([]) } + + it 'returns false when no subcription exists' do + expect(issue.subscribed?(user)).to be_falsey + end + + it 'returns true when a subcription exists and subscribed is true' do + issue.subscriptions.create(user: user, subscribed: true) + + expect(issue.subscribed?(user)).to be_truthy + end + + it 'returns false when a subcription exists and subscribed is false' do + issue.subscriptions.create(user: user, subscribed: false) + + expect(issue.subscribed?(user)).to be_falsey + end + end + + context 'user is a participant in the issue' do + before { allow(issue).to receive(:participants).with(user).and_return([user]) } + + it 'returns false when no subcription exists' do + expect(issue.subscribed?(user)).to be_truthy + end + + it 'returns true when a subcription exists and subscribed is true' do + issue.subscriptions.create(user: user, subscribed: true) + + expect(issue.subscribed?(user)).to be_truthy + end + + it 'returns false when a subcription exists and subscribed is false' do + issue.subscriptions.create(user: user, subscribed: false) + + expect(issue.subscribed?(user)).to be_falsey + end + end + end + describe "#to_hook_data" do let(:data) { issue.to_hook_data(user) } let(:project) { issue.project } diff --git a/spec/models/concerns/mentionable_spec.rb b/spec/models/concerns/mentionable_spec.rb index 20f0c561e4..cb33edde82 100644 --- a/spec/models/concerns/mentionable_spec.rb +++ b/spec/models/concerns/mentionable_spec.rb @@ -48,7 +48,8 @@ describe Issue, "Mentionable" do describe '#create_new_cross_references!' do let(:project) { create(:project) } - let(:issues) { create_list(:issue, 2, project: project) } + let(:author) { create(:author) } + let(:issues) { create_list(:issue, 2, project: project, author: author) } context 'before changes are persisted' do it 'ignores pre-existing references' do @@ -91,7 +92,7 @@ describe Issue, "Mentionable" do end def create_issue(description:) - create(:issue, project: project, description: description) + create(:issue, project: project, description: description, author: author) end end end diff --git a/spec/models/concerns/milestoneish_spec.rb b/spec/models/concerns/milestoneish_spec.rb new file mode 100644 index 0000000000..47c3be673c --- /dev/null +++ b/spec/models/concerns/milestoneish_spec.rb @@ -0,0 +1,104 @@ +require 'spec_helper' + +describe Milestone, 'Milestoneish' do + let(:author) { create(:user) } + let(:assignee) { create(:user) } + let(:non_member) { create(:user) } + let(:member) { create(:user) } + let(:admin) { create(:admin) } + let(:project) { create(:project, :public) } + let(:milestone) { create(:milestone, project: project) } + let!(:issue) { create(:issue, project: project, milestone: milestone) } + let!(:security_issue_1) { create(:issue, :confidential, project: project, author: author, milestone: milestone) } + let!(:security_issue_2) { create(:issue, :confidential, project: project, assignee: assignee, milestone: milestone) } + let!(:closed_issue_1) { create(:issue, :closed, project: project, milestone: milestone) } + let!(:closed_issue_2) { create(:issue, :closed, project: project, milestone: milestone) } + let!(:closed_security_issue_1) { create(:issue, :confidential, :closed, project: project, author: author, milestone: milestone) } + let!(:closed_security_issue_2) { create(:issue, :confidential, :closed, project: project, assignee: assignee, milestone: milestone) } + let!(:closed_security_issue_3) { create(:issue, :confidential, :closed, project: project, author: author, milestone: milestone) } + let!(:closed_security_issue_4) { create(:issue, :confidential, :closed, project: project, assignee: assignee, milestone: milestone) } + let!(:merge_request) { create(:merge_request, source_project: project, target_project: project, milestone: milestone) } + + before do + project.team << [member, :developer] + end + + describe '#closed_items_count' do + it 'should not count confidential issues for non project members' do + expect(milestone.closed_items_count(non_member)).to eq 2 + end + + it 'should count confidential issues for author' do + expect(milestone.closed_items_count(author)).to eq 4 + end + + it 'should count confidential issues for assignee' do + expect(milestone.closed_items_count(assignee)).to eq 4 + end + + it 'should count confidential issues for project members' do + expect(milestone.closed_items_count(member)).to eq 6 + end + + it 'should count all issues for admin' do + expect(milestone.closed_items_count(admin)).to eq 6 + end + end + + describe '#total_items_count' do + it 'should not count confidential issues for non project members' do + expect(milestone.total_items_count(non_member)).to eq 4 + end + + it 'should count confidential issues for author' do + expect(milestone.total_items_count(author)).to eq 7 + end + + it 'should count confidential issues for assignee' do + expect(milestone.total_items_count(assignee)).to eq 7 + end + + it 'should count confidential issues for project members' do + expect(milestone.total_items_count(member)).to eq 10 + end + + it 'should count all issues for admin' do + expect(milestone.total_items_count(admin)).to eq 10 + end + end + + describe '#complete?' do + it 'returns false when has items opened' do + expect(milestone.complete?(non_member)).to eq false + end + + it 'returns true when all items are closed' do + issue.close + merge_request.close + + expect(milestone.complete?(non_member)).to eq true + end + end + + describe '#percent_complete' do + it 'should not count confidential issues for non project members' do + expect(milestone.percent_complete(non_member)).to eq 50 + end + + it 'should count confidential issues for author' do + expect(milestone.percent_complete(author)).to eq 57 + end + + it 'should count confidential issues for assignee' do + expect(milestone.percent_complete(assignee)).to eq 57 + end + + it 'should count confidential issues for project members' do + expect(milestone.percent_complete(member)).to eq 60 + end + + it 'should count confidential issues for admin' do + expect(milestone.percent_complete(admin)).to eq 60 + end + end +end diff --git a/spec/models/concerns/subscribable_spec.rb b/spec/models/concerns/subscribable_spec.rb new file mode 100644 index 0000000000..e31fdb0bff --- /dev/null +++ b/spec/models/concerns/subscribable_spec.rb @@ -0,0 +1,57 @@ +require 'spec_helper' + +describe Subscribable, 'Subscribable' do + let(:resource) { create(:issue) } + let(:user) { create(:user) } + + describe '#subscribed?' do + it 'returns false when no subcription exists' do + expect(resource.subscribed?(user)).to be_falsey + end + + it 'returns true when a subcription exists and subscribed is true' do + resource.subscriptions.create(user: user, subscribed: true) + + expect(resource.subscribed?(user)).to be_truthy + end + + it 'returns false when a subcription exists and subscribed is false' do + resource.subscriptions.create(user: user, subscribed: false) + + expect(resource.subscribed?(user)).to be_falsey + end + end + describe '#subscribers' do + it 'returns [] when no subcribers exists' do + expect(resource.subscribers).to be_empty + end + + it 'returns the subscribed users' do + resource.subscriptions.create(user: user, subscribed: true) + resource.subscriptions.create(user: create(:user), subscribed: false) + + expect(resource.subscribers).to eq [user] + end + end + + describe '#toggle_subscription' do + it 'toggles the current subscription state for the given user' do + expect(resource.subscribed?(user)).to be_falsey + + resource.toggle_subscription(user) + + expect(resource.subscribed?(user)).to be_truthy + end + end + + describe '#unsubscribe' do + it 'unsubscribes the given current user' do + resource.subscriptions.create(user: user, subscribed: true) + expect(resource.subscribed?(user)).to be_truthy + + resource.unsubscribe(user) + + expect(resource.subscribed?(user)).to be_falsey + end + end +end diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb index ec2a923f91..5fe4424673 100644 --- a/spec/models/event_spec.rb +++ b/spec/models/event_spec.rb @@ -65,6 +65,42 @@ describe Event, models: true do it { expect(@event.author).to eq(@user) } end + describe '#proper?' do + context 'issue event' do + let(:project) { create(:empty_project, :public) } + let(:non_member) { create(:user) } + let(:member) { create(:user) } + let(:author) { create(:author) } + let(:assignee) { create(:user) } + let(:admin) { create(:admin) } + let(:event) { Event.new(project: project, action: Event::CREATED, target: issue, author_id: author.id) } + + before do + project.team << [member, :developer] + end + + context 'for non confidential issues' do + let(:issue) { create(:issue, project: project, author: author, assignee: assignee) } + + it { expect(event.proper?(non_member)).to eq true } + it { expect(event.proper?(author)).to eq true } + it { expect(event.proper?(assignee)).to eq true } + it { expect(event.proper?(member)).to eq true } + it { expect(event.proper?(admin)).to eq true } + end + + context 'for confidential issues' do + let(:issue) { create(:issue, :confidential, project: project, author: author, assignee: assignee) } + + it { expect(event.proper?(non_member)).to eq false } + it { expect(event.proper?(author)).to eq true } + it { expect(event.proper?(assignee)).to eq true } + it { expect(event.proper?(member)).to eq true } + it { expect(event.proper?(admin)).to eq true } + end + end + end + describe '.limit_recent' do let!(:event1) { create(:closed_issue_event) } let!(:event2) { create(:closed_issue_event) } diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb index 7f44ca2f7d..2ccdec1eef 100644 --- a/spec/models/issue_spec.rb +++ b/spec/models/issue_spec.rb @@ -130,6 +130,15 @@ describe Issue, models: true do end end + describe '#related_branches' do + it "should " do + allow(subject.project.repository).to receive(:branch_names). + and_return(["mpempe", "#{subject.iid}mepmep", subject.to_branch_name]) + + expect(subject.related_branches).to eq [subject.to_branch_name] + end + end + it_behaves_like 'an editable mentionable' do subject { create(:issue) } @@ -140,4 +149,12 @@ describe Issue, models: true do it_behaves_like 'a Taskable' do let(:subject) { create :issue } end + + describe "#to_branch_name" do + let(:issue) { build(:issue, title: 'a' * 30) } + + it "starts with the issue iid" do + expect(issue.to_branch_name).to match /\A#{issue.iid}-a+\z/ + end + end end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 8bf68013fd..2165cfb7a3 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -86,6 +86,41 @@ describe MergeRequest, models: true do end end + describe '#target_sha' do + context 'when the target branch does not exist anymore' do + subject { create(:merge_request).tap { |mr| mr.update_attribute(:target_branch, 'deleted') } } + + it 'returns nil' do + expect(subject.target_sha).to be_nil + end + end + end + + describe '#source_sha' do + let(:last_branch_commit) { subject.source_project.repository.commit(subject.source_branch) } + + context 'with diffs' do + subject { create(:merge_request, :with_diffs) } + it 'returns the sha of the source branch last commit' do + expect(subject.source_sha).to eq(last_branch_commit.sha) + end + end + + context 'without diffs' do + subject { create(:merge_request, :without_diffs) } + it 'returns the sha of the source branch last commit' do + expect(subject.source_sha).to eq(last_branch_commit.sha) + end + end + + context 'when the merge request is being created' do + subject { build(:merge_request, source_branch: nil, compare_commits: []) } + it 'returns nil' do + expect(subject.source_sha).to be_nil + end + end + end + describe '#to_reference' do it 'returns a String reference to the object' do expect(subject.to_reference).to eq "!#{subject.iid}" @@ -150,6 +185,7 @@ describe MergeRequest, models: true do let(:commit2) { double('commit2', safe_message: "Fixes #{issue1.to_reference}") } before do + subject.project.team << [subject.author, :developer] allow(subject).to receive(:commits).and_return([commit0, commit1, commit2]) end @@ -284,6 +320,18 @@ describe MergeRequest, models: true do let(:project) { create(:project) } let(:fork_project) { create(:project, forked_from_project: project) } + context 'when the target branch does not exist anymore' do + subject { create(:merge_request).tap { |mr| mr.update_attribute(:target_branch, 'deleted') } } + + it 'does not crash' do + expect{ subject.diverged_commits_count }.not_to raise_error + end + + it 'returns 0' do + expect(subject.diverged_commits_count).to eq(0) + end + end + context 'diverged on same repository' do subject(:merge_request_with_divergence) { create(:merge_request, :diverged, source_project: project, target_project: project) } diff --git a/spec/models/milestone_spec.rb b/spec/models/milestone_spec.rb index de1757bf67..72a4ea7022 100644 --- a/spec/models/milestone_spec.rb +++ b/spec/models/milestone_spec.rb @@ -32,6 +32,7 @@ describe Milestone, models: true do let(:milestone) { create(:milestone) } let(:issue) { create(:issue) } + let(:user) { create(:user) } describe "unique milestone title per project" do it "shouldn't accept the same title in a project twice" do @@ -50,18 +51,17 @@ describe Milestone, models: true do describe "#percent_complete" do it "should not count open issues" do milestone.issues << issue - expect(milestone.percent_complete).to eq(0) + expect(milestone.percent_complete(user)).to eq(0) end it "should count closed issues" do issue.close milestone.issues << issue - expect(milestone.percent_complete).to eq(100) + expect(milestone.percent_complete(user)).to eq(100) end it "should recover from dividing by zero" do - expect(milestone.issues).to receive(:size).and_return(0) - expect(milestone.percent_complete).to eq(0) + expect(milestone.percent_complete(user)).to eq(0) end end @@ -103,7 +103,7 @@ describe Milestone, models: true do ) end - it { expect(milestone.percent_complete).to eq(75) } + it { expect(milestone.percent_complete(user)).to eq(75) } end describe :items_count do @@ -113,23 +113,23 @@ describe Milestone, models: true do milestone.merge_requests << create(:merge_request) end - it { expect(milestone.closed_items_count).to eq(1) } - it { expect(milestone.total_items_count).to eq(3) } - it { expect(milestone.is_empty?).to be_falsey } + it { expect(milestone.closed_items_count(user)).to eq(1) } + it { expect(milestone.total_items_count(user)).to eq(3) } + it { expect(milestone.is_empty?(user)).to be_falsey } end describe :can_be_closed? do it { expect(milestone.can_be_closed?).to be_truthy } end - describe :is_empty? do + describe :total_items_count do before do create :closed_issue, milestone: milestone create :merge_request, milestone: milestone end it 'Should return total count of issues and merge requests assigned to milestone' do - expect(milestone.total_items_count).to eq 2 + expect(milestone.total_items_count(user)).to eq 2 end end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 59c5ffa6b9..b8b9a455b8 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -634,6 +634,12 @@ describe Project, models: true do it 'returns projects with a matching namespace name regardless of the casing' do expect(described_class.search(project.namespace.name.upcase)).to eq([project]) end + + it 'returns projects when eager loading namespaces' do + relation = described_class.all.includes(:namespace) + + expect(relation.search(project.namespace.name)).to eq([project]) + end end describe '#rename_repo' do diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index 34866be339..a57229a4fd 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -101,13 +101,29 @@ describe Repository, models: true do end describe 'parsing result' do - subject { repository.parse_search_result(results.first) } + subject { repository.parse_search_result(search_result) } + let(:search_result) { results.first } it { is_expected.to be_an OpenStruct } it { expect(subject.filename).to eq('CHANGELOG') } + it { expect(subject.basename).to eq('CHANGELOG') } it { expect(subject.ref).to eq('master') } it { expect(subject.startline).to eq(186) } it { expect(subject.data.lines[2]).to eq(" - Feature: Replace teams with group membership\n") } + + context "when filename has extension" do + let(:search_result) { "master:CONTRIBUTE.md:5:- [Contribute to GitLab](#contribute-to-gitlab)\n" } + + it { expect(subject.filename).to eq('CONTRIBUTE.md') } + it { expect(subject.basename).to eq('CONTRIBUTE') } + end + + context "when file under directory" do + let(:search_result) { "master:a/b/c.md:5:a b c\n" } + + it { expect(subject.filename).to eq('a/b/c.md') } + it { expect(subject.basename).to eq('a/b/c') } + end end end @@ -581,9 +597,9 @@ describe Repository, models: true do describe '#after_push_commit' do it 'flushes the cache' do - expect(repository).to receive(:expire_cache).with('master') + expect(repository).to receive(:expire_cache).with('master', '123') - repository.after_push_commit('master') + repository.after_push_commit('master', '123') end end @@ -687,4 +703,111 @@ describe Repository, models: true do repository.rm_tag('8.5') end end + + describe '#avatar' do + it 'returns the first avatar file found in the repository' do + expect(repository).to receive(:blob_at_branch). + with('master', 'logo.png'). + and_return(true) + + expect(repository.avatar).to eq('logo.png') + end + + it 'caches the output' do + allow(repository).to receive(:blob_at_branch). + with('master', 'logo.png'). + and_return(true) + + expect(repository.avatar).to eq('logo.png') + + expect(repository).to_not receive(:blob_at_branch) + expect(repository.avatar).to eq('logo.png') + end + end + + describe '#expire_avatar_cache' do + let(:cache) { repository.send(:cache) } + + before do + allow(repository).to receive(:cache).and_return(cache) + end + + context 'without a branch or revision' do + it 'flushes the cache' do + expect(cache).to receive(:expire).with(:avatar) + + repository.expire_avatar_cache + end + end + + context 'with a branch' do + it 'does not flush the cache if the branch is not the default branch' do + expect(cache).not_to receive(:expire) + + repository.expire_avatar_cache('cats') + end + + it 'flushes the cache if the branch equals the default branch' do + expect(cache).to receive(:expire).with(:avatar) + + repository.expire_avatar_cache(repository.root_ref) + end + end + + context 'with a branch and revision' do + let(:commit) { double(:commit) } + + before do + allow(repository).to receive(:commit).and_return(commit) + end + + it 'does not flush the cache if the commit does not change any logos' do + diff = double(:diff, new_path: 'test.txt') + + expect(commit).to receive(:diffs).and_return([diff]) + expect(cache).not_to receive(:expire) + + repository.expire_avatar_cache(repository.root_ref, '123') + end + + it 'flushes the cache if the commit changes any of the logos' do + diff = double(:diff, new_path: Repository::AVATAR_FILES[0]) + + expect(commit).to receive(:diffs).and_return([diff]) + expect(cache).to receive(:expire).with(:avatar) + + repository.expire_avatar_cache(repository.root_ref, '123') + end + end + end + + describe '#build_cache' do + let(:cache) { repository.send(:cache) } + + it 'builds the caches if they do not already exist' do + expect(cache).to receive(:exist?). + exactly(repository.cache_keys.length). + times. + and_return(false) + + repository.cache_keys.each do |key| + expect(repository).to receive(key) + end + + repository.build_cache + end + + it 'does not build any caches that already exist' do + expect(cache).to receive(:exist?). + exactly(repository.cache_keys.length). + times. + and_return(true) + + repository.cache_keys.each do |key| + expect(repository).to_not receive(key) + end + + repository.build_cache + end + end end diff --git a/spec/models/todo_spec.rb b/spec/models/todo_spec.rb index fe9ea7e7d1..d9b86b9368 100644 --- a/spec/models/todo_spec.rb +++ b/spec/models/todo_spec.rb @@ -5,19 +5,24 @@ # id :integer not null, primary key # user_id :integer not null # project_id :integer not null -# target_id :integer not null +# target_id :integer # target_type :string not null # author_id :integer -# note_id :integer # action :integer not null # state :string not null # created_at :datetime # updated_at :datetime +# note_id :integer +# commit_id :string # require 'spec_helper' describe Todo, models: true do + let(:project) { create(:project) } + let(:commit) { project.commit } + let(:issue) { create(:issue) } + describe 'relationships' do it { is_expected.to belong_to(:author).class_name("User") } it { is_expected.to belong_to(:note) } @@ -33,8 +38,22 @@ describe Todo, models: true do describe 'validations' do it { is_expected.to validate_presence_of(:action) } - it { is_expected.to validate_presence_of(:target) } + it { is_expected.to validate_presence_of(:target_type) } it { is_expected.to validate_presence_of(:user) } + + context 'for commits' do + subject { described_class.new(target_type: 'Commit') } + + it { is_expected.to validate_presence_of(:commit_id) } + it { is_expected.not_to validate_presence_of(:target_id) } + end + + context 'for issuables' do + subject { described_class.new(target: issue) } + + it { is_expected.to validate_presence_of(:target_id) } + it { is_expected.not_to validate_presence_of(:commit_id) } + end end describe '#body' do @@ -55,15 +74,69 @@ describe Todo, models: true do end end - describe '#done!' do + describe '#done' do it 'changes state to done' do todo = create(:todo, state: :pending) - expect { todo.done! }.to change(todo, :state).from('pending').to('done') + expect { todo.done }.to change(todo, :state).from('pending').to('done') end it 'does not raise error when is already done' do todo = create(:todo, state: :done) - expect { todo.done! }.not_to raise_error + expect { todo.done }.not_to raise_error + end + end + + describe '#for_commit?' do + it 'returns true when target is a commit' do + subject.target_type = 'Commit' + expect(subject.for_commit?).to eq true + end + + it 'returns false when target is an issuable' do + subject.target_type = 'Issue' + expect(subject.for_commit?).to eq false + end + end + + describe '#target' do + context 'for commits' do + it 'returns an instance of Commit when exists' do + subject.project = project + subject.target_type = 'Commit' + subject.commit_id = commit.id + + expect(subject.target).to be_a(Commit) + expect(subject.target).to eq commit + end + + it 'returns nil when does not exists' do + subject.project = project + subject.target_type = 'Commit' + subject.commit_id = 'xxxx' + + expect(subject.target).to be_nil + end + end + + it 'returns the issuable for issuables' do + subject.target_id = issue.id + subject.target_type = issue.class.name + expect(subject.target).to eq issue + end + end + + describe '#target_reference' do + it 'returns the short commit id for commits' do + subject.project = project + subject.target_type = 'Commit' + subject.commit_id = commit.id + + expect(subject.target_reference).to eq commit.short_id + end + + it 'returns reference for issuables' do + subject.target = issue + expect(subject.target_reference).to eq issue.to_reference end end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 909b679659..0ab7fd88ce 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -174,38 +174,26 @@ describe User, models: true do end end end - - describe 'avatar' do - it 'only validates when avatar is present and changed' do - user = build(:user, :with_avatar) - - user.avatar_crop_x = nil - user.avatar_crop_y = nil - user.avatar_crop_size = nil - - expect(user).not_to be_valid - expect(user.errors.keys). - to match_array %i(avatar_crop_x avatar_crop_y avatar_crop_size) - end - - it 'does not validate when avatar has not changed' do - user = create(:user, :with_avatar) - - expect { user.avatar_crop_x = nil }.not_to change(user, :valid?) - end - - it 'does not validate when avatar is not present' do - user = create(:user) - - expect { user.avatar_crop_y = nil }.not_to change(user, :valid?) - end - end end describe "Respond to" do it { is_expected.to respond_to(:is_admin?) } it { is_expected.to respond_to(:name) } it { is_expected.to respond_to(:private_token) } + it { is_expected.to respond_to(:external?) } + end + + describe 'before save hook' do + context 'when saving an external user' do + let(:user) { create(:user) } + let(:external_user) { create(:user, external: true) } + + it "sets other properties aswell" do + expect(external_user.can_create_team).to be_falsey + expect(external_user.can_create_group).to be_falsey + expect(external_user.projects_limit).to be 0 + end + end end describe '#confirm' do @@ -430,6 +418,7 @@ describe User, models: true do expect(user.projects_limit).to eq(Gitlab.config.gitlab.default_projects_limit) expect(user.can_create_group).to eq(Gitlab.config.gitlab.default_can_create_group) expect(user.theme_id).to eq(Gitlab.config.gitlab.default_theme) + expect(user.external).to be_falsey end end diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb index 571ea2dae4..bb2ab05800 100644 --- a/spec/requests/api/issues_spec.rb +++ b/spec/requests/api/issues_spec.rb @@ -3,7 +3,11 @@ require 'spec_helper' describe API::API, api: true do include ApiHelpers let(:user) { create(:user) } - let!(:project) { create(:project, namespace: user.namespace ) } + let(:non_member) { create(:user) } + let(:author) { create(:author) } + let(:assignee) { create(:assignee) } + let(:admin) { create(:admin) } + let!(:project) { create(:project, :public, namespace: user.namespace ) } let!(:closed_issue) do create :closed_issue, author: user, @@ -12,6 +16,13 @@ describe API::API, api: true do state: :closed, milestone: milestone end + let!(:confidential_issue) do + create :issue, + :confidential, + project: project, + author: author, + assignee: assignee + end let!(:issue) do create :issue, author: user, @@ -123,10 +134,43 @@ describe API::API, api: true do let(:base_url) { "/projects/#{project.id}" } let(:title) { milestone.title } - it "should return project issues" do + it 'should return project issues without confidential issues for non project members' do + get api("#{base_url}/issues", non_member) + expect(response.status).to eq(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(2) + expect(json_response.first['title']).to eq(issue.title) + end + + it 'should return project confidential issues for author' do + get api("#{base_url}/issues", author) + expect(response.status).to eq(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(3) + expect(json_response.first['title']).to eq(issue.title) + end + + it 'should return project confidential issues for assignee' do + get api("#{base_url}/issues", assignee) + expect(response.status).to eq(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(3) + expect(json_response.first['title']).to eq(issue.title) + end + + it 'should return project issues with confidential issues for project members' do get api("#{base_url}/issues", user) expect(response.status).to eq(200) expect(json_response).to be_an Array + expect(json_response.length).to eq(3) + expect(json_response.first['title']).to eq(issue.title) + end + + it 'should return project confidential issues for admin' do + get api("#{base_url}/issues", admin) + expect(response.status).to eq(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(3) expect(json_response.first['title']).to eq(issue.title) end @@ -206,6 +250,41 @@ describe API::API, api: true do get api("/projects/#{project.id}/issues/54321", user) expect(response.status).to eq(404) end + + context 'confidential issues' do + it "should return 404 for non project members" do + get api("/projects/#{project.id}/issues/#{confidential_issue.id}", non_member) + expect(response.status).to eq(404) + end + + it "should return confidential issue for project members" do + get api("/projects/#{project.id}/issues/#{confidential_issue.id}", user) + expect(response.status).to eq(200) + expect(json_response['title']).to eq(confidential_issue.title) + expect(json_response['iid']).to eq(confidential_issue.iid) + end + + it "should return confidential issue for author" do + get api("/projects/#{project.id}/issues/#{confidential_issue.id}", author) + expect(response.status).to eq(200) + expect(json_response['title']).to eq(confidential_issue.title) + expect(json_response['iid']).to eq(confidential_issue.iid) + end + + it "should return confidential issue for assignee" do + get api("/projects/#{project.id}/issues/#{confidential_issue.id}", assignee) + expect(response.status).to eq(200) + expect(json_response['title']).to eq(confidential_issue.title) + expect(json_response['iid']).to eq(confidential_issue.iid) + end + + it "should return confidential issue for admin" do + get api("/projects/#{project.id}/issues/#{confidential_issue.id}", admin) + expect(response.status).to eq(200) + expect(json_response['title']).to eq(confidential_issue.title) + expect(json_response['iid']).to eq(confidential_issue.iid) + end + end end describe "POST /projects/:id/issues" do @@ -294,6 +373,35 @@ describe API::API, api: true do expect(response.status).to eq(400) expect(json_response['message']['labels']['?']['title']).to eq(['is invalid']) end + + context 'confidential issues' do + it "should return 403 for non project members" do + put api("/projects/#{project.id}/issues/#{confidential_issue.id}", non_member), + title: 'updated title' + expect(response.status).to eq(403) + end + + it "should update a confidential issue for project members" do + put api("/projects/#{project.id}/issues/#{confidential_issue.id}", user), + title: 'updated title' + expect(response.status).to eq(200) + expect(json_response['title']).to eq('updated title') + end + + it "should update a confidential issue for author" do + put api("/projects/#{project.id}/issues/#{confidential_issue.id}", author), + title: 'updated title' + expect(response.status).to eq(200) + expect(json_response['title']).to eq('updated title') + end + + it "should update a confidential issue for admin" do + put api("/projects/#{project.id}/issues/#{confidential_issue.id}", admin), + title: 'updated title' + expect(response.status).to eq(200) + expect(json_response['title']).to eq('updated title') + end + end end describe 'PUT /projects/:id/issues/:issue_id to update labels' do diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb index 96e8c8c51f..679227bf88 100644 --- a/spec/requests/api/users_spec.rb +++ b/spec/requests/api/users_spec.rb @@ -120,6 +120,26 @@ describe API::API, api: true do expect(response.status).to eq(201) end + it 'creates non-external users by default' do + post api("/users", admin), attributes_for(:user) + expect(response.status).to eq(201) + + user_id = json_response['id'] + new_user = User.find(user_id) + expect(new_user).not_to eq nil + expect(new_user.external).to be_falsy + end + + it 'should allow an external user to be created' do + post api("/users", admin), attributes_for(:user, external: true) + expect(response.status).to eq(201) + + user_id = json_response['id'] + new_user = User.find(user_id) + expect(new_user).not_to eq nil + expect(new_user.external).to be_truthy + end + it "should not create user with invalid email" do post api('/users', admin), email: 'invalid email', @@ -262,6 +282,13 @@ describe API::API, api: true do expect(user.reload.admin).to eq(true) end + it "should update external status" do + put api("/users/#{user.id}", admin), { external: true } + expect(response.status).to eq 200 + expect(json_response['external']).to eq(true) + expect(user.reload.external?).to be_truthy + end + it "should not update admin status" do put api("/users/#{admin_user.id}", admin), { can_create_group: false } expect(response.status).to eq(200) diff --git a/spec/services/delete_user_service_spec.rb b/spec/services/delete_user_service_spec.rb new file mode 100644 index 0000000000..a65938fa03 --- /dev/null +++ b/spec/services/delete_user_service_spec.rb @@ -0,0 +1,58 @@ +require 'spec_helper' + +describe DeleteUserService, services: true do + describe "Deletes a user and all their personal projects" do + let!(:user) { create(:user) } + let!(:current_user) { create(:user) } + let!(:namespace) { create(:namespace, owner: user) } + let!(:project) { create(:project, namespace: namespace) } + + context 'no options are given' do + it 'deletes the user' do + DeleteUserService.new(current_user).execute(user) + + expect { User.find(user.id) }.to raise_error(ActiveRecord::RecordNotFound) + end + + it 'will delete the project in the near future' do + expect_any_instance_of(Projects::DestroyService).to receive(:pending_delete!).once + + DeleteUserService.new(current_user).execute(user) + end + end + + context "solo owned groups present" do + let(:solo_owned) { create(:group) } + let(:member) { create(:group_member) } + let(:user) { member.user } + + before do + solo_owned.group_members = [member] + DeleteUserService.new(current_user).execute(user) + end + + it 'does not delete the user' do + expect(User.find(user.id)).to eq user + end + end + + context "deletions with solo owned groups" do + let(:solo_owned) { create(:group) } + let(:member) { create(:group_member) } + let(:user) { member.user } + + before do + solo_owned.group_members = [member] + DeleteUserService.new(current_user).execute(user, delete_solo_owned_groups: true) + end + + it 'deletes solo owned groups' do + expect { Project.find(solo_owned.id) }.to raise_error(ActiveRecord::RecordNotFound) + end + + it 'deletes the user' do + expect { User.find(user.id) }.to raise_error(ActiveRecord::RecordNotFound) + end + end + end +end diff --git a/spec/services/git_push_service_spec.rb b/spec/services/git_push_service_spec.rb index a7e2e1b179..8490a729e5 100644 --- a/spec/services/git_push_service_spec.rb +++ b/spec/services/git_push_service_spec.rb @@ -29,7 +29,8 @@ describe GitPushService, services: true do it { is_expected.to be_truthy } it 'flushes general cached data' do - expect(project.repository).to receive(:expire_cache).with('master') + expect(project.repository).to receive(:expire_cache). + with('master', newrev) subject end @@ -46,7 +47,8 @@ describe GitPushService, services: true do it { is_expected.to be_truthy } it 'flushes general cached data' do - expect(project.repository).to receive(:expire_cache).with('master') + expect(project.repository).to receive(:expire_cache). + with('master', newrev) subject end @@ -65,7 +67,8 @@ describe GitPushService, services: true do end it 'flushes general cached data' do - expect(project.repository).to receive(:expire_cache).with('master') + expect(project.repository).to receive(:expire_cache). + with('master', newrev) subject end @@ -212,12 +215,16 @@ describe GitPushService, services: true do let(:commit) { project.commit } before do + project.team << [commit_author, :developer] + project.team << [user, :developer] + allow(commit).to receive_messages( safe_message: "this commit \n mentions #{issue.to_reference}", references: [issue], author_name: commit_author.name, author_email: commit_author.email ) + allow(project.repository).to receive(:commits_between).and_return([commit]) end @@ -401,6 +408,45 @@ describe GitPushService, services: true do end end + describe "housekeeping" do + let(:housekeeping) { Projects::HousekeepingService.new(project) } + + before do + allow(Projects::HousekeepingService).to receive(:new).and_return(housekeeping) + end + + it 'does not perform housekeeping when not needed' do + expect(housekeeping).not_to receive(:execute) + + execute_service(project, user, @oldrev, @newrev, @ref) + end + + context 'when housekeeping is needed' do + before do + allow(housekeeping).to receive(:needed?).and_return(true) + end + + it 'performs housekeeping' do + expect(housekeeping).to receive(:execute) + + execute_service(project, user, @oldrev, @newrev, @ref) + end + + it 'does not raise an exception' do + allow(housekeeping).to receive(:try_obtain_lease).and_return(false) + + execute_service(project, user, @oldrev, @newrev, @ref) + end + end + + + it 'increments the push counter' do + expect(housekeeping).to receive(:increment!) + + execute_service(project, user, @oldrev, @newrev, @ref) + end + end + def execute_service(project, user, oldrev, newrev, ref) service = described_class.new(project, user, oldrev: oldrev, newrev: newrev, ref: ref ) service.execute diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb index e579e49dfa..4ffe753fef 100644 --- a/spec/services/issues/update_service_spec.rb +++ b/spec/services/issues/update_service_spec.rb @@ -6,6 +6,7 @@ describe Issues::UpdateService, services: true do let(:user3) { create(:user) } let(:issue) { create(:issue, title: 'Old title', assignee_id: user3.id) } let(:label) { create(:label) } + let(:label2) { create(:label) } let(:project) { issue.project } before do @@ -48,7 +49,7 @@ describe Issues::UpdateService, services: true do it { expect(@issue.assignee).to eq(user2) } it { expect(@issue).to be_closed } it { expect(@issue.labels.count).to eq(1) } - it { expect(@issue.labels.first.title).to eq('Bug') } + it { expect(@issue.labels.first.title).to eq(label.name) } it 'should send email to user2 about assign of new issue and email to user3 about issue unassignment' do deliveries = ActionMailer::Base.deliveries @@ -148,6 +149,48 @@ describe Issues::UpdateService, services: true do end end + context 'when the issue is relabeled' do + let!(:non_subscriber) { create(:user) } + let!(:subscriber) { create(:user).tap { |u| label.toggle_subscription(u) } } + + it 'sends notifications for subscribers of newly added labels' do + opts = { label_ids: [label.id] } + + perform_enqueued_jobs do + @issue = Issues::UpdateService.new(project, user, opts).execute(issue) + end + + should_email(subscriber) + should_not_email(non_subscriber) + end + + context 'when issue has the `label` label' do + before { issue.labels << label } + + it 'does not send notifications for existing labels' do + opts = { label_ids: [label.id, label2.id] } + + perform_enqueued_jobs do + @issue = Issues::UpdateService.new(project, user, opts).execute(issue) + end + + should_not_email(subscriber) + should_not_email(non_subscriber) + end + + it 'does not send notifications for removed labels' do + opts = { label_ids: [label2.id] } + + perform_enqueued_jobs do + @issue = Issues::UpdateService.new(project, user, opts).execute(issue) + end + + should_not_email(subscriber) + should_not_email(non_subscriber) + end + end + end + context 'when Issue has tasks' do before { update_issue({ description: "- [ ] Task 1\n- [ ] Task 2" }) } diff --git a/spec/services/merge_requests/update_service_spec.rb b/spec/services/merge_requests/update_service_spec.rb index 99703c7a8e..cb8cff2fa8 100644 --- a/spec/services/merge_requests/update_service_spec.rb +++ b/spec/services/merge_requests/update_service_spec.rb @@ -7,6 +7,7 @@ describe MergeRequests::UpdateService, services: true do let(:merge_request) { create(:merge_request, :simple, title: 'Old title', assignee_id: user3.id) } let(:project) { merge_request.project } let(:label) { create(:label) } + let(:label2) { create(:label) } before do project.team << [user, :master] @@ -53,7 +54,7 @@ describe MergeRequests::UpdateService, services: true do it { expect(@merge_request.assignee).to eq(user2) } it { expect(@merge_request).to be_closed } it { expect(@merge_request.labels.count).to eq(1) } - it { expect(@merge_request.labels.first.title).to eq('Bug') } + it { expect(@merge_request.labels.first.title).to eq(label.name) } it { expect(@merge_request.target_branch).to eq('target') } it 'should execute hooks with update action' do @@ -176,6 +177,48 @@ describe MergeRequests::UpdateService, services: true do end end + context 'when the issue is relabeled' do + let!(:non_subscriber) { create(:user) } + let!(:subscriber) { create(:user).tap { |u| label.toggle_subscription(u) } } + + it 'sends notifications for subscribers of newly added labels' do + opts = { label_ids: [label.id] } + + perform_enqueued_jobs do + @merge_request = MergeRequests::UpdateService.new(project, user, opts).execute(merge_request) + end + + should_email(subscriber) + should_not_email(non_subscriber) + end + + context 'when issue has the `label` label' do + before { merge_request.labels << label } + + it 'does not send notifications for existing labels' do + opts = { label_ids: [label.id, label2.id] } + + perform_enqueued_jobs do + @merge_request = MergeRequests::UpdateService.new(project, user, opts).execute(merge_request) + end + + should_not_email(subscriber) + should_not_email(non_subscriber) + end + + it 'does not send notifications for removed labels' do + opts = { label_ids: [label2.id] } + + perform_enqueued_jobs do + @merge_request = MergeRequests::UpdateService.new(project, user, opts).execute(merge_request) + end + + should_not_email(subscriber) + should_not_email(non_subscriber) + end + end + end + context 'when MergeRequest has tasks' do before { update_merge_request({ description: "- [ ] Task 1\n- [ ] Task 2" }) } diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index 2d0b5df422..b5407397c1 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -224,6 +224,15 @@ describe NotificationService, services: true do should_not_email(issue.assignee) end + + it "emails subscribers of the issue's labels" do + subscriber = create(:user) + label = create(:label, issues: [issue]) + label.toggle_subscription(subscriber) + notification.new_issue(issue, @u_disabled) + + should_email(subscriber) + end end describe :reassigned_issue do @@ -296,6 +305,35 @@ describe NotificationService, services: true do end end + describe '#relabeled_issue' do + let(:label) { create(:label, issues: [issue]) } + let(:label2) { create(:label) } + let!(:subscriber_to_label) { create(:user).tap { |u| label.toggle_subscription(u) } } + let!(:subscriber_to_label2) { create(:user).tap { |u| label2.toggle_subscription(u) } } + + it "emails subscribers of the issue's added labels only" do + notification.relabeled_issue(issue, [label2], @u_disabled) + + should_not_email(subscriber_to_label) + should_email(subscriber_to_label2) + end + + it "doesn't send email to anyone but subscribers of the given labels" do + notification.relabeled_issue(issue, [label2], @u_disabled) + + should_not_email(issue.assignee) + should_not_email(issue.author) + should_not_email(@u_watcher) + should_not_email(@u_participant_mentioned) + should_not_email(@subscriber) + should_not_email(@watcher_and_subscriber) + should_not_email(@unsubscriber) + should_not_email(@u_participating) + should_not_email(subscriber_to_label) + should_email(subscriber_to_label2) + end + end + describe :close_issue do it 'should sent email to issue assignee and issue author' do notification.close_issue(issue, @u_disabled) @@ -349,6 +387,15 @@ describe NotificationService, services: true do should_not_email(@u_participating) should_not_email(@u_disabled) end + + it "emails subscribers of the merge request's labels" do + subscriber = create(:user) + label = create(:label, merge_requests: [merge_request]) + label.toggle_subscription(subscriber) + notification.new_merge_request(merge_request, @u_disabled) + + should_email(subscriber) + end end describe :reassigned_merge_request do @@ -366,6 +413,35 @@ describe NotificationService, services: true do end end + describe :relabel_merge_request do + let(:label) { create(:label, merge_requests: [merge_request]) } + let(:label2) { create(:label) } + let!(:subscriber_to_label) { create(:user).tap { |u| label.toggle_subscription(u) } } + let!(:subscriber_to_label2) { create(:user).tap { |u| label2.toggle_subscription(u) } } + + it "emails subscribers of the merge request's added labels only" do + notification.relabeled_merge_request(merge_request, [label2], @u_disabled) + + should_not_email(subscriber_to_label) + should_email(subscriber_to_label2) + end + + it "doesn't send email to anyone but subscribers of the given labels" do + notification.relabeled_merge_request(merge_request, [label2], @u_disabled) + + should_not_email(merge_request.assignee) + should_not_email(merge_request.author) + should_not_email(@u_watcher) + should_not_email(@u_participant_mentioned) + should_not_email(@subscriber) + should_not_email(@watcher_and_subscriber) + should_not_email(@unsubscriber) + should_not_email(@u_participating) + should_not_email(subscriber_to_label) + should_email(subscriber_to_label2) + end + end + describe :closed_merge_request do it do notification.close_mr(merge_request, @u_disabled) @@ -467,16 +543,4 @@ describe NotificationService, services: true do # Make the watcher a subscriber to detect dupes issuable.subscriptions.create(user: @watcher_and_subscriber, subscribed: true) end - - def sent_to_user?(user) - ActionMailer::Base.deliveries.map(&:to).flatten.count(user.email) == 1 - end - - def should_email(user) - expect(sent_to_user?(user)).to be_truthy - end - - def should_not_email(user) - expect(sent_to_user?(user)).to be_falsey - end end diff --git a/spec/services/projects/autocomplete_service_spec.rb b/spec/services/projects/autocomplete_service_spec.rb new file mode 100644 index 0000000000..6108c26a78 --- /dev/null +++ b/spec/services/projects/autocomplete_service_spec.rb @@ -0,0 +1,79 @@ +require 'spec_helper' + +describe Projects::AutocompleteService, services: true do + describe '#issues' do + describe 'confidential issues' do + let(:author) { create(:user) } + let(:assignee) { create(:user) } + let(:non_member) { create(:user) } + let(:member) { create(:user) } + let(:admin) { create(:admin) } + let(:project) { create(:empty_project, :public) } + let!(:issue) { create(:issue, project: project, title: 'Issue 1') } + let!(:security_issue_1) { create(:issue, :confidential, project: project, title: 'Security issue 1', author: author) } + let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project, assignee: assignee) } + + it 'should not list project confidential issues for guests' do + autocomplete = described_class.new(project, nil) + issues = autocomplete.issues.map(&:iid) + + expect(issues).to include issue.iid + expect(issues).not_to include security_issue_1.iid + expect(issues).not_to include security_issue_2.iid + expect(issues.count).to eq 1 + end + + it 'should not list project confidential issues for non project members' do + autocomplete = described_class.new(project, non_member) + issues = autocomplete.issues.map(&:iid) + + expect(issues).to include issue.iid + expect(issues).not_to include security_issue_1.iid + expect(issues).not_to include security_issue_2.iid + expect(issues.count).to eq 1 + end + + it 'should list project confidential issues for author' do + autocomplete = described_class.new(project, author) + issues = autocomplete.issues.map(&:iid) + + expect(issues).to include issue.iid + expect(issues).to include security_issue_1.iid + expect(issues).not_to include security_issue_2.iid + expect(issues.count).to eq 2 + end + + it 'should list project confidential issues for assignee' do + autocomplete = described_class.new(project, assignee) + issues = autocomplete.issues.map(&:iid) + + expect(issues).to include issue.iid + expect(issues).not_to include security_issue_1.iid + expect(issues).to include security_issue_2.iid + expect(issues.count).to eq 2 + end + + it 'should list project confidential issues for project members' do + project.team << [member, :developer] + + autocomplete = described_class.new(project, member) + issues = autocomplete.issues.map(&:iid) + + expect(issues).to include issue.iid + expect(issues).to include security_issue_1.iid + expect(issues).to include security_issue_2.iid + expect(issues.count).to eq 3 + end + + it 'should list all project issues for admin' do + autocomplete = described_class.new(project, admin) + issues = autocomplete.issues.map(&:iid) + + expect(issues).to include issue.iid + expect(issues).to include security_issue_1.iid + expect(issues).to include security_issue_2.iid + expect(issues.count).to eq 3 + end + end + end +end diff --git a/spec/services/projects/housekeeping_service_spec.rb b/spec/services/projects/housekeeping_service_spec.rb new file mode 100644 index 0000000000..4c5ced7e74 --- /dev/null +++ b/spec/services/projects/housekeeping_service_spec.rb @@ -0,0 +1,48 @@ +require 'spec_helper' + +describe Projects::HousekeepingService do + subject { Projects::HousekeepingService.new(project) } + let(:project) { create :project } + + describe 'execute' do + before do + project.pushes_since_gc = 3 + project.save! + end + + it 'enqueues a sidekiq job' do + expect(subject).to receive(:try_obtain_lease).and_return(true) + expect(GitlabShellOneShotWorker).to receive(:perform_async).with(:gc, project.path_with_namespace) + + subject.execute + expect(project.pushes_since_gc).to eq(0) + end + + it 'does not enqueue a job when no lease can be obtained' do + expect(subject).to receive(:try_obtain_lease).and_return(false) + expect(GitlabShellOneShotWorker).not_to receive(:perform_async) + + expect { subject.execute }.to raise_error(Projects::HousekeepingService::LeaseTaken) + expect(project.pushes_since_gc).to eq(0) + end + end + + describe 'needed?' do + it 'when the count is low enough' do + expect(subject.needed?).to eq(false) + end + + it 'when the count is high enough' do + allow(project).to receive(:pushes_since_gc).and_return(10) + expect(subject.needed?).to eq(true) + end + end + + describe 'increment!' do + it 'increments the pushes_since_gc counter' do + expect(project.pushes_since_gc).to eq(0) + subject.increment! + expect(project.pushes_since_gc).to eq(1) + end + end +end diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index 5dcc39f5fd..8e6292014d 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -280,6 +280,18 @@ describe SystemNoteService, services: true do end end + describe '.new_issue_branch' do + subject { described_class.new_issue_branch(noteable, project, author, "1-mepmep") } + + it_behaves_like 'a system note' + + context 'when a branch is created from the new branch button' do + it 'sets the note text' do + expect(subject.note).to match /\AStarted branch [`1-mepmep`]/ + end + end + end + describe '.cross_reference' do subject { described_class.cross_reference(noteable, mentioner, author) } diff --git a/spec/services/todo_service_spec.rb b/spec/services/todo_service_spec.rb index 96420acb31..b4728807b8 100644 --- a/spec/services/todo_service_spec.rb +++ b/spec/services/todo_service_spec.rb @@ -148,8 +148,13 @@ describe TodoService, services: true do should_not_create_todo(user: stranger, target: issue, author: john_doe, action: Todo::MENTIONED, note: note) end - it 'does not create todo when leaving a note on commit' do - should_not_create_any_todo { service.new_note(note_on_commit, john_doe) } + it 'creates a todo for each valid mentioned user when leaving a note on commit' do + service.new_note(note_on_commit, john_doe) + + should_create_todo(user: michael, target_id: nil, target_type: 'Commit', commit_id: note_on_commit.commit_id, author: john_doe, action: Todo::MENTIONED, note: note_on_commit) + should_create_todo(user: author, target_id: nil, target_type: 'Commit', commit_id: note_on_commit.commit_id, author: john_doe, action: Todo::MENTIONED, note: note_on_commit) + should_not_create_todo(user: john_doe, target_id: nil, target_type: 'Commit', commit_id: note_on_commit.commit_id, author: john_doe, action: Todo::MENTIONED, note: note_on_commit) + should_not_create_todo(user: stranger, target_id: nil, target_type: 'Commit', commit_id: note_on_commit.commit_id, author: john_doe, action: Todo::MENTIONED, note: note_on_commit) end it 'does not create todo when leaving a note on snippet' do diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 7d939ca750..596d607f2a 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -32,6 +32,7 @@ RSpec.configure do |config| config.include LoginHelpers, type: :feature config.include LoginHelpers, type: :request config.include StubConfiguration + config.include EmailHelpers config.include RelativeUrl, type: feature config.include TestEnv config.include ActiveJob::TestHelper diff --git a/spec/support/capybara.rb b/spec/support/capybara.rb index 65d59e6813..e1f90e17cc 100644 --- a/spec/support/capybara.rb +++ b/spec/support/capybara.rb @@ -10,7 +10,7 @@ Capybara.register_driver :poltergeist do |app| Capybara::Poltergeist::Driver.new(app, js_errors: true, timeout: timeout, window_size: [1366, 768]) end -Capybara.default_wait_time = timeout +Capybara.default_max_wait_time = timeout Capybara.ignore_hidden_elements = true unless ENV['CI'] || ENV['CI_SERVER'] diff --git a/spec/support/email_helpers.rb b/spec/support/email_helpers.rb new file mode 100644 index 0000000000..a85ab22ce3 --- /dev/null +++ b/spec/support/email_helpers.rb @@ -0,0 +1,13 @@ +module EmailHelpers + def sent_to_user?(user) + ActionMailer::Base.deliveries.map(&:to).flatten.count(user.email) == 1 + end + + def should_email(user) + expect(sent_to_user?(user)).to be_truthy + end + + def should_not_email(user) + expect(sent_to_user?(user)).to be_falsey + end +end diff --git a/spec/support/matchers/access_matchers.rb b/spec/support/matchers/access_matchers.rb index 558e8b1612..4e007c777e 100644 --- a/spec/support/matchers/access_matchers.rb +++ b/spec/support/matchers/access_matchers.rb @@ -15,6 +15,8 @@ module AccessMatchers logout when :admin login_as(create(:admin)) + when :external + login_as(create(:user, external: true)) when User login_as(user) else diff --git a/spec/support/mentionable_shared_examples.rb b/spec/support/mentionable_shared_examples.rb index fce91015fd..e876d44c16 100644 --- a/spec/support/mentionable_shared_examples.rb +++ b/spec/support/mentionable_shared_examples.rb @@ -52,6 +52,8 @@ shared_context 'mentionable context' do end set_mentionable_text.call(ref_string) + + project.team << [author, :developer] end end diff --git a/spec/support/wait_for_ajax.rb b/spec/support/wait_for_ajax.rb index 692d219e9f..b90fc11267 100644 --- a/spec/support/wait_for_ajax.rb +++ b/spec/support/wait_for_ajax.rb @@ -1,6 +1,6 @@ module WaitForAjax def wait_for_ajax - Timeout.timeout(Capybara.default_wait_time) do + Timeout.timeout(Capybara.default_max_wait_time) do loop until finished_all_ajax_requests? end end diff --git a/spec/workers/delete_user_worker_spec.rb b/spec/workers/delete_user_worker_spec.rb new file mode 100644 index 0000000000..14c5652128 --- /dev/null +++ b/spec/workers/delete_user_worker_spec.rb @@ -0,0 +1,20 @@ +require 'spec_helper' + +describe DeleteUserWorker do + let!(:user) { create(:user) } + let!(:current_user) { create(:user) } + + it "calls the DeleteUserWorker with the params it was given" do + expect_any_instance_of(DeleteUserService).to receive(:execute). + with(user, {}) + + DeleteUserWorker.new.perform(current_user.id, user.id) + end + + it "uses symbolized keys" do + expect_any_instance_of(DeleteUserService).to receive(:execute). + with(user, test: "test") + + DeleteUserWorker.new.perform(current_user.id, user.id, "test" => "test") + end +end diff --git a/vendor/assets/javascripts/cropper.js b/vendor/assets/javascripts/cropper.js deleted file mode 100755 index 84aa6119ec..0000000000 --- a/vendor/assets/javascripts/cropper.js +++ /dev/null @@ -1,2972 +0,0 @@ -/*! - * Cropper v2.2.5 - * https://github.com/fengyuanchen/cropper - * - * Copyright (c) 2014-2016 Fengyuan Chen and contributors - * Released under the MIT license - * - * Date: 2016-01-18T05:42:50.800Z - */ - -(function (factory) { - if (typeof define === 'function' && define.amd) { - // AMD. Register as anonymous module. - define(['jquery'], factory); - } else if (typeof exports === 'object') { - // Node / CommonJS - factory(require('jquery')); - } else { - // Browser globals. - factory(jQuery); - } -})(function ($) { - - 'use strict'; - - // Globals - var $window = $(window); - var $document = $(document); - var location = window.location; - var ArrayBuffer = window.ArrayBuffer; - var Uint8Array = window.Uint8Array; - var DataView = window.DataView; - var btoa = window.btoa; - - // Constants - var NAMESPACE = 'cropper'; - - // Classes - var CLASS_MODAL = 'cropper-modal'; - var CLASS_HIDE = 'cropper-hide'; - var CLASS_HIDDEN = 'cropper-hidden'; - var CLASS_INVISIBLE = 'cropper-invisible'; - var CLASS_MOVE = 'cropper-move'; - var CLASS_CROP = 'cropper-crop'; - var CLASS_DISABLED = 'cropper-disabled'; - var CLASS_BG = 'cropper-bg'; - - // Events - var EVENT_MOUSE_DOWN = 'mousedown touchstart pointerdown MSPointerDown'; - var EVENT_MOUSE_MOVE = 'mousemove touchmove pointermove MSPointerMove'; - var EVENT_MOUSE_UP = 'mouseup touchend touchcancel pointerup pointercancel MSPointerUp MSPointerCancel'; - var EVENT_WHEEL = 'wheel mousewheel DOMMouseScroll'; - var EVENT_DBLCLICK = 'dblclick'; - var EVENT_LOAD = 'load.' + NAMESPACE; - var EVENT_ERROR = 'error.' + NAMESPACE; - var EVENT_RESIZE = 'resize.' + NAMESPACE; // Bind to window with namespace - var EVENT_BUILD = 'build.' + NAMESPACE; - var EVENT_BUILT = 'built.' + NAMESPACE; - var EVENT_CROP_START = 'cropstart.' + NAMESPACE; - var EVENT_CROP_MOVE = 'cropmove.' + NAMESPACE; - var EVENT_CROP_END = 'cropend.' + NAMESPACE; - var EVENT_CROP = 'crop.' + NAMESPACE; - var EVENT_ZOOM = 'zoom.' + NAMESPACE; - - // RegExps - var REGEXP_ACTIONS = /e|w|s|n|se|sw|ne|nw|all|crop|move|zoom/; - var REGEXP_DATA_URL = /^data\:/; - var REGEXP_DATA_URL_HEAD = /^data\:([^\;]+)\;base64,/; - var REGEXP_DATA_URL_JPEG = /^data\:image\/jpeg.*;base64,/; - - // Data keys - var DATA_PREVIEW = 'preview'; - var DATA_ACTION = 'action'; - - // Actions - var ACTION_EAST = 'e'; - var ACTION_WEST = 'w'; - var ACTION_SOUTH = 's'; - var ACTION_NORTH = 'n'; - var ACTION_SOUTH_EAST = 'se'; - var ACTION_SOUTH_WEST = 'sw'; - var ACTION_NORTH_EAST = 'ne'; - var ACTION_NORTH_WEST = 'nw'; - var ACTION_ALL = 'all'; - var ACTION_CROP = 'crop'; - var ACTION_MOVE = 'move'; - var ACTION_ZOOM = 'zoom'; - var ACTION_NONE = 'none'; - - // Supports - var SUPPORT_CANVAS = $.isFunction($('')[0].getContext); - - // Maths - var num = Number; - var min = Math.min; - var max = Math.max; - var abs = Math.abs; - var sin = Math.sin; - var cos = Math.cos; - var sqrt = Math.sqrt; - var round = Math.round; - var floor = Math.floor; - - // Utilities - var fromCharCode = String.fromCharCode; - - function isNumber(n) { - return typeof n === 'number' && !isNaN(n); - } - - function isUndefined(n) { - return typeof n === 'undefined'; - } - - function toArray(obj, offset) { - var args = []; - - // This is necessary for IE8 - if (isNumber(offset)) { - args.push(offset); - } - - return args.slice.apply(obj, args); - } - - // Custom proxy to avoid jQuery's guid - function proxy(fn, context) { - var args = toArray(arguments, 2); - - return function () { - return fn.apply(context, args.concat(toArray(arguments))); - }; - } - - function isCrossOriginURL(url) { - var parts = url.match(/^(https?:)\/\/([^\:\/\?#]+):?(\d*)/i); - - return parts && ( - parts[1] !== location.protocol || - parts[2] !== location.hostname || - parts[3] !== location.port - ); - } - - function addTimestamp(url) { - var timestamp = 'timestamp=' + (new Date()).getTime(); - - return (url + (url.indexOf('?') === -1 ? '?' : '&') + timestamp); - } - - function getCrossOrigin(crossOrigin) { - return crossOrigin ? ' crossOrigin="' + crossOrigin + '"' : ''; - } - - function getImageSize(image, callback) { - var newImage; - - // Modern browsers - if (image.naturalWidth) { - return callback(image.naturalWidth, image.naturalHeight); - } - - // IE8: Don't use `new Image()` here (#319) - newImage = document.createElement('img'); - - newImage.onload = function () { - callback(this.width, this.height); - }; - - newImage.src = image.src; - } - - function getTransform(options) { - var transforms = []; - var rotate = options.rotate; - var scaleX = options.scaleX; - var scaleY = options.scaleY; - - if (isNumber(rotate)) { - transforms.push('rotate(' + rotate + 'deg)'); - } - - if (isNumber(scaleX) && isNumber(scaleY)) { - transforms.push('scale(' + scaleX + ',' + scaleY + ')'); - } - - return transforms.length ? transforms.join(' ') : 'none'; - } - - function getRotatedSizes(data, isReversed) { - var deg = abs(data.degree) % 180; - var arc = (deg > 90 ? (180 - deg) : deg) * Math.PI / 180; - var sinArc = sin(arc); - var cosArc = cos(arc); - var width = data.width; - var height = data.height; - var aspectRatio = data.aspectRatio; - var newWidth; - var newHeight; - - if (!isReversed) { - newWidth = width * cosArc + height * sinArc; - newHeight = width * sinArc + height * cosArc; - } else { - newWidth = width / (cosArc + sinArc / aspectRatio); - newHeight = newWidth / aspectRatio; - } - - return { - width: newWidth, - height: newHeight - }; - } - - function getSourceCanvas(image, data) { - var canvas = $('')[0]; - var context = canvas.getContext('2d'); - var x = 0; - var y = 0; - var width = data.naturalWidth; - var height = data.naturalHeight; - var rotate = data.rotate; - var scaleX = data.scaleX; - var scaleY = data.scaleY; - var scalable = isNumber(scaleX) && isNumber(scaleY) && (scaleX !== 1 || scaleY !== 1); - var rotatable = isNumber(rotate) && rotate !== 0; - var advanced = rotatable || scalable; - var canvasWidth = width; - var canvasHeight = height; - var translateX; - var translateY; - var rotated; - - if (scalable) { - translateX = width / 2; - translateY = height / 2; - } - - if (rotatable) { - rotated = getRotatedSizes({ - width: width, - height: height, - degree: rotate - }); - - canvasWidth = rotated.width; - canvasHeight = rotated.height; - translateX = rotated.width / 2; - translateY = rotated.height / 2; - } - - canvas.width = canvasWidth; - canvas.height = canvasHeight; - - if (advanced) { - x = -width / 2; - y = -height / 2; - - context.save(); - context.translate(translateX, translateY); - } - - if (rotatable) { - context.rotate(rotate * Math.PI / 180); - } - - // Should call `scale` after rotated - if (scalable) { - context.scale(scaleX, scaleY); - } - - context.drawImage(image, floor(x), floor(y), floor(width), floor(height)); - - if (advanced) { - context.restore(); - } - - return canvas; - } - - function getTouchesCenter(touches) { - var length = touches.length; - var pageX = 0; - var pageY = 0; - - if (length) { - $.each(touches, function (i, touch) { - pageX += touch.pageX; - pageY += touch.pageY; - }); - - pageX /= length; - pageY /= length; - } - - return { - pageX: pageX, - pageY: pageY - }; - } - - function getStringFromCharCode(dataView, start, length) { - var str = ''; - var i; - - for (i = start, length += start; i < length; i++) { - str += fromCharCode(dataView.getUint8(i)); - } - - return str; - } - - function getOrientation(arrayBuffer) { - var dataView = new DataView(arrayBuffer); - var length = dataView.byteLength; - var orientation; - var exifIDCode; - var tiffOffset; - var firstIFDOffset; - var littleEndian; - var endianness; - var app1Start; - var ifdStart; - var offset; - var i; - - // Only handle JPEG image (start by 0xFFD8) - if (dataView.getUint8(0) === 0xFF && dataView.getUint8(1) === 0xD8) { - offset = 2; - - while (offset < length) { - if (dataView.getUint8(offset) === 0xFF && dataView.getUint8(offset + 1) === 0xE1) { - app1Start = offset; - break; - } - - offset++; - } - } - - if (app1Start) { - exifIDCode = app1Start + 4; - tiffOffset = app1Start + 10; - - if (getStringFromCharCode(dataView, exifIDCode, 4) === 'Exif') { - endianness = dataView.getUint16(tiffOffset); - littleEndian = endianness === 0x4949; - - if (littleEndian || endianness === 0x4D4D /* bigEndian */) { - if (dataView.getUint16(tiffOffset + 2, littleEndian) === 0x002A) { - firstIFDOffset = dataView.getUint32(tiffOffset + 4, littleEndian); - - if (firstIFDOffset >= 0x00000008) { - ifdStart = tiffOffset + firstIFDOffset; - } - } - } - } - } - - if (ifdStart) { - length = dataView.getUint16(ifdStart, littleEndian); - - for (i = 0; i < length; i++) { - offset = ifdStart + i * 12 + 2; - - if (dataView.getUint16(offset, littleEndian) === 0x0112 /* Orientation */) { - - // 8 is the offset of the current tag's value - offset += 8; - - // Get the original orientation value - orientation = dataView.getUint16(offset, littleEndian); - - // Override the orientation with the default value: 1 - dataView.setUint16(offset, 1, littleEndian); - break; - } - } - } - - return orientation; - } - - function dataURLToArrayBuffer(dataURL) { - var base64 = dataURL.replace(REGEXP_DATA_URL_HEAD, ''); - var binary = atob(base64); - var length = binary.length; - var arrayBuffer = new ArrayBuffer(length); - var dataView = new Uint8Array(arrayBuffer); - var i; - - for (i = 0; i < length; i++) { - dataView[i] = binary.charCodeAt(i); - } - - return arrayBuffer; - } - - // Only available for JPEG image - function arrayBufferToDataURL(arrayBuffer) { - var dataView = new Uint8Array(arrayBuffer); - var length = dataView.length; - var base64 = ''; - var i; - - for (i = 0; i < length; i++) { - base64 += fromCharCode(dataView[i]); - } - - return 'data:image/jpeg;base64,' + btoa(base64); - } - - function Cropper(element, options) { - this.$element = $(element); - this.options = $.extend({}, Cropper.DEFAULTS, $.isPlainObject(options) && options); - this.isLoaded = false; - this.isBuilt = false; - this.isCompleted = false; - this.isRotated = false; - this.isCropped = false; - this.isDisabled = false; - this.isReplaced = false; - this.isLimited = false; - this.wheeling = false; - this.isImg = false; - this.originalUrl = ''; - this.canvas = null; - this.cropBox = null; - this.init(); - } - - Cropper.prototype = { - constructor: Cropper, - - init: function () { - var $this = this.$element; - var url; - - if ($this.is('img')) { - this.isImg = true; - - // Should use `$.fn.attr` here. e.g.: "img/picture.jpg" - this.originalUrl = url = $this.attr('src'); - - // Stop when it's a blank image - if (!url) { - return; - } - - // Should use `$.fn.prop` here. e.g.: "http://example.com/img/picture.jpg" - url = $this.prop('src'); - } else if ($this.is('canvas') && SUPPORT_CANVAS) { - url = $this[0].toDataURL(); - } - - this.load(url); - }, - - // A shortcut for triggering custom events - trigger: function (type, data) { - var e = $.Event(type, data); - - this.$element.trigger(e); - - return e; - }, - - load: function (url) { - var options = this.options; - var $this = this.$element; - var read; - var xhr; - - if (!url) { - return; - } - - // Trigger build event first - $this.one(EVENT_BUILD, options.build); - - if (this.trigger(EVENT_BUILD).isDefaultPrevented()) { - return; - } - - this.url = url; - this.image = {}; - - if (!options.checkOrientation || !ArrayBuffer) { - return this.clone(); - } - - read = $.proxy(this.read, this); - - // XMLHttpRequest disallows to open a Data URL in some browsers like IE11 and Safari - if (REGEXP_DATA_URL.test(url)) { - return REGEXP_DATA_URL_JPEG.test(url) ? - read(dataURLToArrayBuffer(url)) : - this.clone(); - } - - xhr = new XMLHttpRequest(); - - xhr.onerror = xhr.onabort = $.proxy(function () { - this.clone(); - }, this); - - xhr.onload = function () { - read(this.response); - }; - - xhr.open('get', url); - xhr.responseType = 'arraybuffer'; - xhr.send(); - }, - - read: function (arrayBuffer) { - var options = this.options; - var orientation = getOrientation(arrayBuffer); - var image = this.image; - var rotate; - var scaleX; - var scaleY; - - if (orientation > 1) { - this.url = arrayBufferToDataURL(arrayBuffer); - - switch (orientation) { - - // flip horizontal - case 2: - scaleX = -1; - break; - - // rotate left 180° - case 3: - rotate = -180; - break; - - // flip vertical - case 4: - scaleY = -1; - break; - - // flip vertical + rotate right 90° - case 5: - rotate = 90; - scaleY = -1; - break; - - // rotate right 90° - case 6: - rotate = 90; - break; - - // flip horizontal + rotate right 90° - case 7: - rotate = 90; - scaleX = -1; - break; - - // rotate left 90° - case 8: - rotate = -90; - break; - } - } - - if (options.rotatable) { - image.rotate = rotate; - } - - if (options.scalable) { - image.scaleX = scaleX; - image.scaleY = scaleY; - } - - this.clone(); - }, - - clone: function () { - var options = this.options; - var $this = this.$element; - var url = this.url; - var crossOrigin = ''; - var crossOriginUrl; - var $clone; - - if (options.checkCrossOrigin && isCrossOriginURL(url)) { - crossOrigin = $this.prop('crossOrigin'); - - if (crossOrigin) { - crossOriginUrl = url; - } else { - crossOrigin = 'anonymous'; - - // Bust cache (#148) when there is not a "crossOrigin" property - crossOriginUrl = addTimestamp(url); - } - } - - this.crossOrigin = crossOrigin; - this.crossOriginUrl = crossOriginUrl; - this.$clone = $clone = $(''); - - if (this.isImg) { - if ($this[0].complete) { - this.start(); - } else { - $this.one(EVENT_LOAD, $.proxy(this.start, this)); - } - } else { - $clone. - one(EVENT_LOAD, $.proxy(this.start, this)). - one(EVENT_ERROR, $.proxy(this.stop, this)). - addClass(CLASS_HIDE). - insertAfter($this); - } - }, - - start: function () { - var $image = this.$element; - var $clone = this.$clone; - - if (!this.isImg) { - $clone.off(EVENT_ERROR, this.stop); - $image = $clone; - } - - getImageSize($image[0], $.proxy(function (naturalWidth, naturalHeight) { - $.extend(this.image, { - naturalWidth: naturalWidth, - naturalHeight: naturalHeight, - aspectRatio: naturalWidth / naturalHeight - }); - - this.isLoaded = true; - this.build(); - }, this)); - }, - - stop: function () { - this.$clone.remove(); - this.$clone = null; - }, - - build: function () { - var options = this.options; - var $this = this.$element; - var $clone = this.$clone; - var $cropper; - var $cropBox; - var $face; - - if (!this.isLoaded) { - return; - } - - // Unbuild first when replace - if (this.isBuilt) { - this.unbuild(); - } - - // Create cropper elements - this.$container = $this.parent(); - this.$cropper = $cropper = $(Cropper.TEMPLATE); - this.$canvas = $cropper.find('.cropper-canvas').append($clone); - this.$dragBox = $cropper.find('.cropper-drag-box'); - this.$cropBox = $cropBox = $cropper.find('.cropper-crop-box'); - this.$viewBox = $cropper.find('.cropper-view-box'); - this.$face = $face = $cropBox.find('.cropper-face'); - - // Hide the original image - $this.addClass(CLASS_HIDDEN).after($cropper); - - // Show the clone image if is hidden - if (!this.isImg) { - $clone.removeClass(CLASS_HIDE); - } - - this.initPreview(); - this.bind(); - - options.aspectRatio = max(0, options.aspectRatio) || NaN; - options.viewMode = max(0, min(3, round(options.viewMode))) || 0; - - if (options.autoCrop) { - this.isCropped = true; - - if (options.modal) { - this.$dragBox.addClass(CLASS_MODAL); - } - } else { - $cropBox.addClass(CLASS_HIDDEN); - } - - if (!options.guides) { - $cropBox.find('.cropper-dashed').addClass(CLASS_HIDDEN); - } - - if (!options.center) { - $cropBox.find('.cropper-center').addClass(CLASS_HIDDEN); - } - - if (options.cropBoxMovable) { - $face.addClass(CLASS_MOVE).data(DATA_ACTION, ACTION_ALL); - } - - if (!options.highlight) { - $face.addClass(CLASS_INVISIBLE); - } - - if (options.background) { - $cropper.addClass(CLASS_BG); - } - - if (!options.cropBoxResizable) { - $cropBox.find('.cropper-line, .cropper-point').addClass(CLASS_HIDDEN); - } - - this.setDragMode(options.dragMode); - this.render(); - this.isBuilt = true; - this.setData(options.data); - $this.one(EVENT_BUILT, options.built); - - // Trigger the built event asynchronously to keep `data('cropper')` is defined - setTimeout($.proxy(function () { - this.trigger(EVENT_BUILT); - this.isCompleted = true; - }, this), 0); - }, - - unbuild: function () { - if (!this.isBuilt) { - return; - } - - this.isBuilt = false; - this.isCompleted = false; - this.initialImage = null; - - // Clear `initialCanvas` is necessary when replace - this.initialCanvas = null; - this.initialCropBox = null; - this.container = null; - this.canvas = null; - - // Clear `cropBox` is necessary when replace - this.cropBox = null; - this.unbind(); - - this.resetPreview(); - this.$preview = null; - - this.$viewBox = null; - this.$cropBox = null; - this.$dragBox = null; - this.$canvas = null; - this.$container = null; - - this.$cropper.remove(); - this.$cropper = null; - }, - - render: function () { - this.initContainer(); - this.initCanvas(); - this.initCropBox(); - - this.renderCanvas(); - - if (this.isCropped) { - this.renderCropBox(); - } - }, - - initContainer: function () { - var options = this.options; - var $this = this.$element; - var $container = this.$container; - var $cropper = this.$cropper; - - $cropper.addClass(CLASS_HIDDEN); - $this.removeClass(CLASS_HIDDEN); - - $cropper.css((this.container = { - width: max($container.width(), num(options.minContainerWidth) || 200), - height: max($container.height(), num(options.minContainerHeight) || 100) - })); - - $this.addClass(CLASS_HIDDEN); - $cropper.removeClass(CLASS_HIDDEN); - }, - - // Canvas (image wrapper) - initCanvas: function () { - var viewMode = this.options.viewMode; - var container = this.container; - var containerWidth = container.width; - var containerHeight = container.height; - var image = this.image; - var imageNaturalWidth = image.naturalWidth; - var imageNaturalHeight = image.naturalHeight; - var is90Degree = abs(image.rotate) === 90; - var naturalWidth = is90Degree ? imageNaturalHeight : imageNaturalWidth; - var naturalHeight = is90Degree ? imageNaturalWidth : imageNaturalHeight; - var aspectRatio = naturalWidth / naturalHeight; - var canvasWidth = containerWidth; - var canvasHeight = containerHeight; - var canvas; - - if (containerHeight * aspectRatio > containerWidth) { - if (viewMode === 3) { - canvasWidth = containerHeight * aspectRatio; - } else { - canvasHeight = containerWidth / aspectRatio; - } - } else { - if (viewMode === 3) { - canvasHeight = containerWidth / aspectRatio; - } else { - canvasWidth = containerHeight * aspectRatio; - } - } - - canvas = { - naturalWidth: naturalWidth, - naturalHeight: naturalHeight, - aspectRatio: aspectRatio, - width: canvasWidth, - height: canvasHeight - }; - - canvas.oldLeft = canvas.left = (containerWidth - canvasWidth) / 2; - canvas.oldTop = canvas.top = (containerHeight - canvasHeight) / 2; - - this.canvas = canvas; - this.isLimited = (viewMode === 1 || viewMode === 2); - this.limitCanvas(true, true); - this.initialImage = $.extend({}, image); - this.initialCanvas = $.extend({}, canvas); - }, - - limitCanvas: function (isSizeLimited, isPositionLimited) { - var options = this.options; - var viewMode = options.viewMode; - var container = this.container; - var containerWidth = container.width; - var containerHeight = container.height; - var canvas = this.canvas; - var aspectRatio = canvas.aspectRatio; - var cropBox = this.cropBox; - var isCropped = this.isCropped && cropBox; - var minCanvasWidth; - var minCanvasHeight; - var newCanvasLeft; - var newCanvasTop; - - if (isSizeLimited) { - minCanvasWidth = num(options.minCanvasWidth) || 0; - minCanvasHeight = num(options.minCanvasHeight) || 0; - - if (viewMode) { - if (viewMode > 1) { - minCanvasWidth = max(minCanvasWidth, containerWidth); - minCanvasHeight = max(minCanvasHeight, containerHeight); - - if (viewMode === 3) { - if (minCanvasHeight * aspectRatio > minCanvasWidth) { - minCanvasWidth = minCanvasHeight * aspectRatio; - } else { - minCanvasHeight = minCanvasWidth / aspectRatio; - } - } - } else { - if (minCanvasWidth) { - minCanvasWidth = max(minCanvasWidth, isCropped ? cropBox.width : 0); - } else if (minCanvasHeight) { - minCanvasHeight = max(minCanvasHeight, isCropped ? cropBox.height : 0); - } else if (isCropped) { - minCanvasWidth = cropBox.width; - minCanvasHeight = cropBox.height; - - if (minCanvasHeight * aspectRatio > minCanvasWidth) { - minCanvasWidth = minCanvasHeight * aspectRatio; - } else { - minCanvasHeight = minCanvasWidth / aspectRatio; - } - } - } - } - - if (minCanvasWidth && minCanvasHeight) { - if (minCanvasHeight * aspectRatio > minCanvasWidth) { - minCanvasHeight = minCanvasWidth / aspectRatio; - } else { - minCanvasWidth = minCanvasHeight * aspectRatio; - } - } else if (minCanvasWidth) { - minCanvasHeight = minCanvasWidth / aspectRatio; - } else if (minCanvasHeight) { - minCanvasWidth = minCanvasHeight * aspectRatio; - } - - canvas.minWidth = minCanvasWidth; - canvas.minHeight = minCanvasHeight; - canvas.maxWidth = Infinity; - canvas.maxHeight = Infinity; - } - - if (isPositionLimited) { - if (viewMode) { - newCanvasLeft = containerWidth - canvas.width; - newCanvasTop = containerHeight - canvas.height; - - canvas.minLeft = min(0, newCanvasLeft); - canvas.minTop = min(0, newCanvasTop); - canvas.maxLeft = max(0, newCanvasLeft); - canvas.maxTop = max(0, newCanvasTop); - - if (isCropped && this.isLimited) { - canvas.minLeft = min( - cropBox.left, - cropBox.left + cropBox.width - canvas.width - ); - canvas.minTop = min( - cropBox.top, - cropBox.top + cropBox.height - canvas.height - ); - canvas.maxLeft = cropBox.left; - canvas.maxTop = cropBox.top; - - if (viewMode === 2) { - if (canvas.width >= containerWidth) { - canvas.minLeft = min(0, newCanvasLeft); - canvas.maxLeft = max(0, newCanvasLeft); - } - - if (canvas.height >= containerHeight) { - canvas.minTop = min(0, newCanvasTop); - canvas.maxTop = max(0, newCanvasTop); - } - } - } - } else { - canvas.minLeft = -canvas.width; - canvas.minTop = -canvas.height; - canvas.maxLeft = containerWidth; - canvas.maxTop = containerHeight; - } - } - }, - - renderCanvas: function (isChanged) { - var canvas = this.canvas; - var image = this.image; - var rotate = image.rotate; - var naturalWidth = image.naturalWidth; - var naturalHeight = image.naturalHeight; - var aspectRatio; - var rotated; - - if (this.isRotated) { - this.isRotated = false; - - // Computes rotated sizes with image sizes - rotated = getRotatedSizes({ - width: image.width, - height: image.height, - degree: rotate - }); - - aspectRatio = rotated.width / rotated.height; - - if (aspectRatio !== canvas.aspectRatio) { - canvas.left -= (rotated.width - canvas.width) / 2; - canvas.top -= (rotated.height - canvas.height) / 2; - canvas.width = rotated.width; - canvas.height = rotated.height; - canvas.aspectRatio = aspectRatio; - canvas.naturalWidth = naturalWidth; - canvas.naturalHeight = naturalHeight; - - // Computes rotated sizes with natural image sizes - if (rotate % 180) { - rotated = getRotatedSizes({ - width: naturalWidth, - height: naturalHeight, - degree: rotate - }); - - canvas.naturalWidth = rotated.width; - canvas.naturalHeight = rotated.height; - } - - this.limitCanvas(true, false); - } - } - - if (canvas.width > canvas.maxWidth || canvas.width < canvas.minWidth) { - canvas.left = canvas.oldLeft; - } - - if (canvas.height > canvas.maxHeight || canvas.height < canvas.minHeight) { - canvas.top = canvas.oldTop; - } - - canvas.width = min(max(canvas.width, canvas.minWidth), canvas.maxWidth); - canvas.height = min(max(canvas.height, canvas.minHeight), canvas.maxHeight); - - this.limitCanvas(false, true); - - canvas.oldLeft = canvas.left = min(max(canvas.left, canvas.minLeft), canvas.maxLeft); - canvas.oldTop = canvas.top = min(max(canvas.top, canvas.minTop), canvas.maxTop); - - this.$canvas.css({ - width: canvas.width, - height: canvas.height, - left: canvas.left, - top: canvas.top - }); - - this.renderImage(); - - if (this.isCropped && this.isLimited) { - this.limitCropBox(true, true); - } - - if (isChanged) { - this.output(); - } - }, - - renderImage: function (isChanged) { - var canvas = this.canvas; - var image = this.image; - var reversed; - - if (image.rotate) { - reversed = getRotatedSizes({ - width: canvas.width, - height: canvas.height, - degree: image.rotate, - aspectRatio: image.aspectRatio - }, true); - } - - $.extend(image, reversed ? { - width: reversed.width, - height: reversed.height, - left: (canvas.width - reversed.width) / 2, - top: (canvas.height - reversed.height) / 2 - } : { - width: canvas.width, - height: canvas.height, - left: 0, - top: 0 - }); - - this.$clone.css({ - width: image.width, - height: image.height, - marginLeft: image.left, - marginTop: image.top, - transform: getTransform(image) - }); - - if (isChanged) { - this.output(); - } - }, - - initCropBox: function () { - var options = this.options; - var canvas = this.canvas; - var aspectRatio = options.aspectRatio; - var autoCropArea = num(options.autoCropArea) || 0.8; - var cropBox = { - width: canvas.width, - height: canvas.height - }; - - if (aspectRatio) { - if (canvas.height * aspectRatio > canvas.width) { - cropBox.height = cropBox.width / aspectRatio; - } else { - cropBox.width = cropBox.height * aspectRatio; - } - } - - this.cropBox = cropBox; - this.limitCropBox(true, true); - - // Initialize auto crop area - cropBox.width = min(max(cropBox.width, cropBox.minWidth), cropBox.maxWidth); - cropBox.height = min(max(cropBox.height, cropBox.minHeight), cropBox.maxHeight); - - // The width of auto crop area must large than "minWidth", and the height too. (#164) - cropBox.width = max(cropBox.minWidth, cropBox.width * autoCropArea); - cropBox.height = max(cropBox.minHeight, cropBox.height * autoCropArea); - cropBox.oldLeft = cropBox.left = canvas.left + (canvas.width - cropBox.width) / 2; - cropBox.oldTop = cropBox.top = canvas.top + (canvas.height - cropBox.height) / 2; - - this.initialCropBox = $.extend({}, cropBox); - }, - - limitCropBox: function (isSizeLimited, isPositionLimited) { - var options = this.options; - var aspectRatio = options.aspectRatio; - var container = this.container; - var containerWidth = container.width; - var containerHeight = container.height; - var canvas = this.canvas; - var cropBox = this.cropBox; - var isLimited = this.isLimited; - var minCropBoxWidth; - var minCropBoxHeight; - var maxCropBoxWidth; - var maxCropBoxHeight; - - if (isSizeLimited) { - minCropBoxWidth = num(options.minCropBoxWidth) || 0; - minCropBoxHeight = num(options.minCropBoxHeight) || 0; - - // The min/maxCropBoxWidth/Height must be less than containerWidth/Height - minCropBoxWidth = min(minCropBoxWidth, containerWidth); - minCropBoxHeight = min(minCropBoxHeight, containerHeight); - maxCropBoxWidth = min(containerWidth, isLimited ? canvas.width : containerWidth); - maxCropBoxHeight = min(containerHeight, isLimited ? canvas.height : containerHeight); - - if (aspectRatio) { - if (minCropBoxWidth && minCropBoxHeight) { - if (minCropBoxHeight * aspectRatio > minCropBoxWidth) { - minCropBoxHeight = minCropBoxWidth / aspectRatio; - } else { - minCropBoxWidth = minCropBoxHeight * aspectRatio; - } - } else if (minCropBoxWidth) { - minCropBoxHeight = minCropBoxWidth / aspectRatio; - } else if (minCropBoxHeight) { - minCropBoxWidth = minCropBoxHeight * aspectRatio; - } - - if (maxCropBoxHeight * aspectRatio > maxCropBoxWidth) { - maxCropBoxHeight = maxCropBoxWidth / aspectRatio; - } else { - maxCropBoxWidth = maxCropBoxHeight * aspectRatio; - } - } - - // The minWidth/Height must be less than maxWidth/Height - cropBox.minWidth = min(minCropBoxWidth, maxCropBoxWidth); - cropBox.minHeight = min(minCropBoxHeight, maxCropBoxHeight); - cropBox.maxWidth = maxCropBoxWidth; - cropBox.maxHeight = maxCropBoxHeight; - } - - if (isPositionLimited) { - if (isLimited) { - cropBox.minLeft = max(0, canvas.left); - cropBox.minTop = max(0, canvas.top); - cropBox.maxLeft = min(containerWidth, canvas.left + canvas.width) - cropBox.width; - cropBox.maxTop = min(containerHeight, canvas.top + canvas.height) - cropBox.height; - } else { - cropBox.minLeft = 0; - cropBox.minTop = 0; - cropBox.maxLeft = containerWidth - cropBox.width; - cropBox.maxTop = containerHeight - cropBox.height; - } - } - }, - - renderCropBox: function () { - var options = this.options; - var container = this.container; - var containerWidth = container.width; - var containerHeight = container.height; - var cropBox = this.cropBox; - - if (cropBox.width > cropBox.maxWidth || cropBox.width < cropBox.minWidth) { - cropBox.left = cropBox.oldLeft; - } - - if (cropBox.height > cropBox.maxHeight || cropBox.height < cropBox.minHeight) { - cropBox.top = cropBox.oldTop; - } - - cropBox.width = min(max(cropBox.width, cropBox.minWidth), cropBox.maxWidth); - cropBox.height = min(max(cropBox.height, cropBox.minHeight), cropBox.maxHeight); - - this.limitCropBox(false, true); - - cropBox.oldLeft = cropBox.left = min(max(cropBox.left, cropBox.minLeft), cropBox.maxLeft); - cropBox.oldTop = cropBox.top = min(max(cropBox.top, cropBox.minTop), cropBox.maxTop); - - if (options.movable && options.cropBoxMovable) { - - // Turn to move the canvas when the crop box is equal to the container - this.$face.data(DATA_ACTION, (cropBox.width === containerWidth && cropBox.height === containerHeight) ? ACTION_MOVE : ACTION_ALL); - } - - this.$cropBox.css({ - width: cropBox.width, - height: cropBox.height, - left: cropBox.left, - top: cropBox.top - }); - - if (this.isCropped && this.isLimited) { - this.limitCanvas(true, true); - } - - if (!this.isDisabled) { - this.output(); - } - }, - - output: function () { - this.preview(); - - if (this.isCompleted) { - this.trigger(EVENT_CROP, this.getData()); - } else if (!this.isBuilt) { - - // Only trigger one crop event before complete - this.$element.one(EVENT_BUILT, $.proxy(function () { - this.trigger(EVENT_CROP, this.getData()); - }, this)); - } - }, - - initPreview: function () { - var crossOrigin = getCrossOrigin(this.crossOrigin); - var url = crossOrigin ? this.crossOriginUrl : this.url; - - this.$preview = $(this.options.preview); - this.$viewBox.html(''); - this.$preview.each(function () { - var $this = $(this); - - // Save the original size for recover - $this.data(DATA_PREVIEW, { - width: $this.width(), - height: $this.height(), - html: $this.html() - }); - - /** - * Override img element styles - * Add `display:block` to avoid margin top issue - * (Occur only when margin-top <= -height) - */ - $this.html( - '' - ); - }); - }, - - resetPreview: function () { - this.$preview.each(function () { - var $this = $(this); - var data = $this.data(DATA_PREVIEW); - - $this.css({ - width: data.width, - height: data.height - }).html(data.html).removeData(DATA_PREVIEW); - }); - }, - - preview: function () { - var image = this.image; - var canvas = this.canvas; - var cropBox = this.cropBox; - var cropBoxWidth = cropBox.width; - var cropBoxHeight = cropBox.height; - var width = image.width; - var height = image.height; - var left = cropBox.left - canvas.left - image.left; - var top = cropBox.top - canvas.top - image.top; - - if (!this.isCropped || this.isDisabled) { - return; - } - - this.$viewBox.find('img').css({ - width: width, - height: height, - marginLeft: -left, - marginTop: -top, - transform: getTransform(image) - }); - - this.$preview.each(function () { - var $this = $(this); - var data = $this.data(DATA_PREVIEW); - var originalWidth = data.width; - var originalHeight = data.height; - var newWidth = originalWidth; - var newHeight = originalHeight; - var ratio = 1; - - if (cropBoxWidth) { - ratio = originalWidth / cropBoxWidth; - newHeight = cropBoxHeight * ratio; - } - - if (cropBoxHeight && newHeight > originalHeight) { - ratio = originalHeight / cropBoxHeight; - newWidth = cropBoxWidth * ratio; - newHeight = originalHeight; - } - - $this.css({ - width: newWidth, - height: newHeight - }).find('img').css({ - width: width * ratio, - height: height * ratio, - marginLeft: -left * ratio, - marginTop: -top * ratio, - transform: getTransform(image) - }); - }); - }, - - bind: function () { - var options = this.options; - var $this = this.$element; - var $cropper = this.$cropper; - - if ($.isFunction(options.cropstart)) { - $this.on(EVENT_CROP_START, options.cropstart); - } - - if ($.isFunction(options.cropmove)) { - $this.on(EVENT_CROP_MOVE, options.cropmove); - } - - if ($.isFunction(options.cropend)) { - $this.on(EVENT_CROP_END, options.cropend); - } - - if ($.isFunction(options.crop)) { - $this.on(EVENT_CROP, options.crop); - } - - if ($.isFunction(options.zoom)) { - $this.on(EVENT_ZOOM, options.zoom); - } - - $cropper.on(EVENT_MOUSE_DOWN, $.proxy(this.cropStart, this)); - - if (options.zoomable && options.zoomOnWheel) { - $cropper.on(EVENT_WHEEL, $.proxy(this.wheel, this)); - } - - if (options.toggleDragModeOnDblclick) { - $cropper.on(EVENT_DBLCLICK, $.proxy(this.dblclick, this)); - } - - $document. - on(EVENT_MOUSE_MOVE, (this._cropMove = proxy(this.cropMove, this))). - on(EVENT_MOUSE_UP, (this._cropEnd = proxy(this.cropEnd, this))); - - if (options.responsive) { - $window.on(EVENT_RESIZE, (this._resize = proxy(this.resize, this))); - } - }, - - unbind: function () { - var options = this.options; - var $this = this.$element; - var $cropper = this.$cropper; - - if ($.isFunction(options.cropstart)) { - $this.off(EVENT_CROP_START, options.cropstart); - } - - if ($.isFunction(options.cropmove)) { - $this.off(EVENT_CROP_MOVE, options.cropmove); - } - - if ($.isFunction(options.cropend)) { - $this.off(EVENT_CROP_END, options.cropend); - } - - if ($.isFunction(options.crop)) { - $this.off(EVENT_CROP, options.crop); - } - - if ($.isFunction(options.zoom)) { - $this.off(EVENT_ZOOM, options.zoom); - } - - $cropper.off(EVENT_MOUSE_DOWN, this.cropStart); - - if (options.zoomable && options.zoomOnWheel) { - $cropper.off(EVENT_WHEEL, this.wheel); - } - - if (options.toggleDragModeOnDblclick) { - $cropper.off(EVENT_DBLCLICK, this.dblclick); - } - - $document. - off(EVENT_MOUSE_MOVE, this._cropMove). - off(EVENT_MOUSE_UP, this._cropEnd); - - if (options.responsive) { - $window.off(EVENT_RESIZE, this._resize); - } - }, - - resize: function () { - var restore = this.options.restore; - var $container = this.$container; - var container = this.container; - var canvasData; - var cropBoxData; - var ratio; - - // Check `container` is necessary for IE8 - if (this.isDisabled || !container) { - return; - } - - ratio = $container.width() / container.width; - - // Resize when width changed or height changed - if (ratio !== 1 || $container.height() !== container.height) { - if (restore) { - canvasData = this.getCanvasData(); - cropBoxData = this.getCropBoxData(); - } - - this.render(); - - if (restore) { - this.setCanvasData($.each(canvasData, function (i, n) { - canvasData[i] = n * ratio; - })); - this.setCropBoxData($.each(cropBoxData, function (i, n) { - cropBoxData[i] = n * ratio; - })); - } - } - }, - - dblclick: function () { - if (this.isDisabled) { - return; - } - - if (this.$dragBox.hasClass(CLASS_CROP)) { - this.setDragMode(ACTION_MOVE); - } else { - this.setDragMode(ACTION_CROP); - } - }, - - wheel: function (event) { - var e = event.originalEvent || event; - var ratio = num(this.options.wheelZoomRatio) || 0.1; - var delta = 1; - - if (this.isDisabled) { - return; - } - - event.preventDefault(); - - // Limit wheel speed to prevent zoom too fast - if (this.wheeling) { - return; - } - - this.wheeling = true; - - setTimeout($.proxy(function () { - this.wheeling = false; - }, this), 50); - - if (e.deltaY) { - delta = e.deltaY > 0 ? 1 : -1; - } else if (e.wheelDelta) { - delta = -e.wheelDelta / 120; - } else if (e.detail) { - delta = e.detail > 0 ? 1 : -1; - } - - this.zoom(-delta * ratio, event); - }, - - cropStart: function (event) { - var options = this.options; - var originalEvent = event.originalEvent; - var touches = originalEvent && originalEvent.touches; - var e = event; - var touchesLength; - var action; - - if (this.isDisabled) { - return; - } - - if (touches) { - touchesLength = touches.length; - - if (touchesLength > 1) { - if (options.zoomable && options.zoomOnTouch && touchesLength === 2) { - e = touches[1]; - this.startX2 = e.pageX; - this.startY2 = e.pageY; - action = ACTION_ZOOM; - } else { - return; - } - } - - e = touches[0]; - } - - action = action || $(e.target).data(DATA_ACTION); - - if (REGEXP_ACTIONS.test(action)) { - if (this.trigger(EVENT_CROP_START, { - originalEvent: originalEvent, - action: action - }).isDefaultPrevented()) { - return; - } - - event.preventDefault(); - - this.action = action; - this.cropping = false; - - // IE8 has `event.pageX/Y`, but not `event.originalEvent.pageX/Y` - // IE10 has `event.originalEvent.pageX/Y`, but not `event.pageX/Y` - this.startX = e.pageX || originalEvent && originalEvent.pageX; - this.startY = e.pageY || originalEvent && originalEvent.pageY; - - if (action === ACTION_CROP) { - this.cropping = true; - this.$dragBox.addClass(CLASS_MODAL); - } - } - }, - - cropMove: function (event) { - var options = this.options; - var originalEvent = event.originalEvent; - var touches = originalEvent && originalEvent.touches; - var e = event; - var action = this.action; - var touchesLength; - - if (this.isDisabled) { - return; - } - - if (touches) { - touchesLength = touches.length; - - if (touchesLength > 1) { - if (options.zoomable && options.zoomOnTouch && touchesLength === 2) { - e = touches[1]; - this.endX2 = e.pageX; - this.endY2 = e.pageY; - } else { - return; - } - } - - e = touches[0]; - } - - if (action) { - if (this.trigger(EVENT_CROP_MOVE, { - originalEvent: originalEvent, - action: action - }).isDefaultPrevented()) { - return; - } - - event.preventDefault(); - - this.endX = e.pageX || originalEvent && originalEvent.pageX; - this.endY = e.pageY || originalEvent && originalEvent.pageY; - - this.change(e.shiftKey, action === ACTION_ZOOM ? event : null); - } - }, - - cropEnd: function (event) { - var originalEvent = event.originalEvent; - var action = this.action; - - if (this.isDisabled) { - return; - } - - if (action) { - event.preventDefault(); - - if (this.cropping) { - this.cropping = false; - this.$dragBox.toggleClass(CLASS_MODAL, this.isCropped && this.options.modal); - } - - this.action = ''; - - this.trigger(EVENT_CROP_END, { - originalEvent: originalEvent, - action: action - }); - } - }, - - change: function (shiftKey, event) { - var options = this.options; - var aspectRatio = options.aspectRatio; - var action = this.action; - var container = this.container; - var canvas = this.canvas; - var cropBox = this.cropBox; - var width = cropBox.width; - var height = cropBox.height; - var left = cropBox.left; - var top = cropBox.top; - var right = left + width; - var bottom = top + height; - var minLeft = 0; - var minTop = 0; - var maxWidth = container.width; - var maxHeight = container.height; - var renderable = true; - var offset; - var range; - - // Locking aspect ratio in "free mode" by holding shift key (#259) - if (!aspectRatio && shiftKey) { - aspectRatio = width && height ? width / height : 1; - } - - if (this.limited) { - minLeft = cropBox.minLeft; - minTop = cropBox.minTop; - maxWidth = minLeft + min(container.width, canvas.width); - maxHeight = minTop + min(container.height, canvas.height); - } - - range = { - x: this.endX - this.startX, - y: this.endY - this.startY - }; - - if (aspectRatio) { - range.X = range.y * aspectRatio; - range.Y = range.x / aspectRatio; - } - - switch (action) { - // Move crop box - case ACTION_ALL: - left += range.x; - top += range.y; - break; - - // Resize crop box - case ACTION_EAST: - if (range.x >= 0 && (right >= maxWidth || aspectRatio && - (top <= minTop || bottom >= maxHeight))) { - - renderable = false; - break; - } - - width += range.x; - - if (aspectRatio) { - height = width / aspectRatio; - top -= range.Y / 2; - } - - if (width < 0) { - action = ACTION_WEST; - width = 0; - } - - break; - - case ACTION_NORTH: - if (range.y <= 0 && (top <= minTop || aspectRatio && - (left <= minLeft || right >= maxWidth))) { - - renderable = false; - break; - } - - height -= range.y; - top += range.y; - - if (aspectRatio) { - width = height * aspectRatio; - left += range.X / 2; - } - - if (height < 0) { - action = ACTION_SOUTH; - height = 0; - } - - break; - - case ACTION_WEST: - if (range.x <= 0 && (left <= minLeft || aspectRatio && - (top <= minTop || bottom >= maxHeight))) { - - renderable = false; - break; - } - - width -= range.x; - left += range.x; - - if (aspectRatio) { - height = width / aspectRatio; - top += range.Y / 2; - } - - if (width < 0) { - action = ACTION_EAST; - width = 0; - } - - break; - - case ACTION_SOUTH: - if (range.y >= 0 && (bottom >= maxHeight || aspectRatio && - (left <= minLeft || right >= maxWidth))) { - - renderable = false; - break; - } - - height += range.y; - - if (aspectRatio) { - width = height * aspectRatio; - left -= range.X / 2; - } - - if (height < 0) { - action = ACTION_NORTH; - height = 0; - } - - break; - - case ACTION_NORTH_EAST: - if (aspectRatio) { - if (range.y <= 0 && (top <= minTop || right >= maxWidth)) { - renderable = false; - break; - } - - height -= range.y; - top += range.y; - width = height * aspectRatio; - } else { - if (range.x >= 0) { - if (right < maxWidth) { - width += range.x; - } else if (range.y <= 0 && top <= minTop) { - renderable = false; - } - } else { - width += range.x; - } - - if (range.y <= 0) { - if (top > minTop) { - height -= range.y; - top += range.y; - } - } else { - height -= range.y; - top += range.y; - } - } - - if (width < 0 && height < 0) { - action = ACTION_SOUTH_WEST; - height = 0; - width = 0; - } else if (width < 0) { - action = ACTION_NORTH_WEST; - width = 0; - } else if (height < 0) { - action = ACTION_SOUTH_EAST; - height = 0; - } - - break; - - case ACTION_NORTH_WEST: - if (aspectRatio) { - if (range.y <= 0 && (top <= minTop || left <= minLeft)) { - renderable = false; - break; - } - - height -= range.y; - top += range.y; - width = height * aspectRatio; - left += range.X; - } else { - if (range.x <= 0) { - if (left > minLeft) { - width -= range.x; - left += range.x; - } else if (range.y <= 0 && top <= minTop) { - renderable = false; - } - } else { - width -= range.x; - left += range.x; - } - - if (range.y <= 0) { - if (top > minTop) { - height -= range.y; - top += range.y; - } - } else { - height -= range.y; - top += range.y; - } - } - - if (width < 0 && height < 0) { - action = ACTION_SOUTH_EAST; - height = 0; - width = 0; - } else if (width < 0) { - action = ACTION_NORTH_EAST; - width = 0; - } else if (height < 0) { - action = ACTION_SOUTH_WEST; - height = 0; - } - - break; - - case ACTION_SOUTH_WEST: - if (aspectRatio) { - if (range.x <= 0 && (left <= minLeft || bottom >= maxHeight)) { - renderable = false; - break; - } - - width -= range.x; - left += range.x; - height = width / aspectRatio; - } else { - if (range.x <= 0) { - if (left > minLeft) { - width -= range.x; - left += range.x; - } else if (range.y >= 0 && bottom >= maxHeight) { - renderable = false; - } - } else { - width -= range.x; - left += range.x; - } - - if (range.y >= 0) { - if (bottom < maxHeight) { - height += range.y; - } - } else { - height += range.y; - } - } - - if (width < 0 && height < 0) { - action = ACTION_NORTH_EAST; - height = 0; - width = 0; - } else if (width < 0) { - action = ACTION_SOUTH_EAST; - width = 0; - } else if (height < 0) { - action = ACTION_NORTH_WEST; - height = 0; - } - - break; - - case ACTION_SOUTH_EAST: - if (aspectRatio) { - if (range.x >= 0 && (right >= maxWidth || bottom >= maxHeight)) { - renderable = false; - break; - } - - width += range.x; - height = width / aspectRatio; - } else { - if (range.x >= 0) { - if (right < maxWidth) { - width += range.x; - } else if (range.y >= 0 && bottom >= maxHeight) { - renderable = false; - } - } else { - width += range.x; - } - - if (range.y >= 0) { - if (bottom < maxHeight) { - height += range.y; - } - } else { - height += range.y; - } - } - - if (width < 0 && height < 0) { - action = ACTION_NORTH_WEST; - height = 0; - width = 0; - } else if (width < 0) { - action = ACTION_SOUTH_WEST; - width = 0; - } else if (height < 0) { - action = ACTION_NORTH_EAST; - height = 0; - } - - break; - - // Move canvas - case ACTION_MOVE: - this.move(range.x, range.y); - renderable = false; - break; - - // Zoom canvas - case ACTION_ZOOM: - this.zoom((function (x1, y1, x2, y2) { - var z1 = sqrt(x1 * x1 + y1 * y1); - var z2 = sqrt(x2 * x2 + y2 * y2); - - return (z2 - z1) / z1; - })( - abs(this.startX - this.startX2), - abs(this.startY - this.startY2), - abs(this.endX - this.endX2), - abs(this.endY - this.endY2) - ), event); - this.startX2 = this.endX2; - this.startY2 = this.endY2; - renderable = false; - break; - - // Create crop box - case ACTION_CROP: - if (!range.x || !range.y) { - renderable = false; - break; - } - - offset = this.$cropper.offset(); - left = this.startX - offset.left; - top = this.startY - offset.top; - width = cropBox.minWidth; - height = cropBox.minHeight; - - if (range.x > 0) { - action = range.y > 0 ? ACTION_SOUTH_EAST : ACTION_NORTH_EAST; - } else if (range.x < 0) { - left -= width; - action = range.y > 0 ? ACTION_SOUTH_WEST : ACTION_NORTH_WEST; - } - - if (range.y < 0) { - top -= height; - } - - // Show the crop box if is hidden - if (!this.isCropped) { - this.$cropBox.removeClass(CLASS_HIDDEN); - this.isCropped = true; - - if (this.limited) { - this.limitCropBox(true, true); - } - } - - break; - - // No default - } - - if (renderable) { - cropBox.width = width; - cropBox.height = height; - cropBox.left = left; - cropBox.top = top; - this.action = action; - - this.renderCropBox(); - } - - // Override - this.startX = this.endX; - this.startY = this.endY; - }, - - // Show the crop box manually - crop: function () { - if (!this.isBuilt || this.isDisabled) { - return; - } - - if (!this.isCropped) { - this.isCropped = true; - this.limitCropBox(true, true); - - if (this.options.modal) { - this.$dragBox.addClass(CLASS_MODAL); - } - - this.$cropBox.removeClass(CLASS_HIDDEN); - } - - this.setCropBoxData(this.initialCropBox); - }, - - // Reset the image and crop box to their initial states - reset: function () { - if (!this.isBuilt || this.isDisabled) { - return; - } - - this.image = $.extend({}, this.initialImage); - this.canvas = $.extend({}, this.initialCanvas); - this.cropBox = $.extend({}, this.initialCropBox); - - this.renderCanvas(); - - if (this.isCropped) { - this.renderCropBox(); - } - }, - - // Clear the crop box - clear: function () { - if (!this.isCropped || this.isDisabled) { - return; - } - - $.extend(this.cropBox, { - left: 0, - top: 0, - width: 0, - height: 0 - }); - - this.isCropped = false; - this.renderCropBox(); - - this.limitCanvas(true, true); - - // Render canvas after crop box rendered - this.renderCanvas(); - - this.$dragBox.removeClass(CLASS_MODAL); - this.$cropBox.addClass(CLASS_HIDDEN); - }, - - /** - * Replace the image's src and rebuild the cropper - * - * @param {String} url - */ - replace: function (url) { - if (!this.isDisabled && url) { - if (this.isImg) { - this.isReplaced = true; - this.$element.attr('src', url); - } - - // Clear previous data - this.options.data = null; - this.load(url); - } - }, - - // Enable (unfreeze) the cropper - enable: function () { - if (this.isBuilt) { - this.isDisabled = false; - this.$cropper.removeClass(CLASS_DISABLED); - } - }, - - // Disable (freeze) the cropper - disable: function () { - if (this.isBuilt) { - this.isDisabled = true; - this.$cropper.addClass(CLASS_DISABLED); - } - }, - - // Destroy the cropper and remove the instance from the image - destroy: function () { - var $this = this.$element; - - if (this.isLoaded) { - if (this.isImg && this.isReplaced) { - $this.attr('src', this.originalUrl); - } - - this.unbuild(); - $this.removeClass(CLASS_HIDDEN); - } else { - if (this.isImg) { - $this.off(EVENT_LOAD, this.start); - } else if (this.$clone) { - this.$clone.remove(); - } - } - - $this.removeData(NAMESPACE); - }, - - /** - * Move the canvas with relative offsets - * - * @param {Number} offsetX - * @param {Number} offsetY (optional) - */ - move: function (offsetX, offsetY) { - var canvas = this.canvas; - - this.moveTo( - isUndefined(offsetX) ? offsetX : canvas.left + num(offsetX), - isUndefined(offsetY) ? offsetY : canvas.top + num(offsetY) - ); - }, - - /** - * Move the canvas to an absolute point - * - * @param {Number} x - * @param {Number} y (optional) - */ - moveTo: function (x, y) { - var canvas = this.canvas; - var isChanged = false; - - // If "y" is not present, its default value is "x" - if (isUndefined(y)) { - y = x; - } - - x = num(x); - y = num(y); - - if (this.isBuilt && !this.isDisabled && this.options.movable) { - if (isNumber(x)) { - canvas.left = x; - isChanged = true; - } - - if (isNumber(y)) { - canvas.top = y; - isChanged = true; - } - - if (isChanged) { - this.renderCanvas(true); - } - } - }, - - /** - * Zoom the canvas with a relative ratio - * - * @param {Number} ratio - * @param {jQuery Event} _event (private) - */ - zoom: function (ratio, _event) { - var canvas = this.canvas; - - ratio = num(ratio); - - if (ratio < 0) { - ratio = 1 / (1 - ratio); - } else { - ratio = 1 + ratio; - } - - this.zoomTo(canvas.width * ratio / canvas.naturalWidth, _event); - }, - - /** - * Zoom the canvas to an absolute ratio - * - * @param {Number} ratio - * @param {jQuery Event} _event (private) - */ - zoomTo: function (ratio, _event) { - var options = this.options; - var canvas = this.canvas; - var width = canvas.width; - var height = canvas.height; - var naturalWidth = canvas.naturalWidth; - var naturalHeight = canvas.naturalHeight; - var originalEvent; - var newWidth; - var newHeight; - var offset; - var center; - - ratio = num(ratio); - - if (ratio >= 0 && this.isBuilt && !this.isDisabled && options.zoomable) { - newWidth = naturalWidth * ratio; - newHeight = naturalHeight * ratio; - - if (_event) { - originalEvent = _event.originalEvent; - } - - if (this.trigger(EVENT_ZOOM, { - originalEvent: originalEvent, - oldRatio: width / naturalWidth, - ratio: newWidth / naturalWidth - }).isDefaultPrevented()) { - return; - } - - if (originalEvent) { - offset = this.$cropper.offset(); - center = originalEvent.touches ? getTouchesCenter(originalEvent.touches) : { - pageX: _event.pageX || originalEvent.pageX || 0, - pageY: _event.pageY || originalEvent.pageY || 0 - }; - - // Zoom from the triggering point of the event - canvas.left -= (newWidth - width) * ( - ((center.pageX - offset.left) - canvas.left) / width - ); - canvas.top -= (newHeight - height) * ( - ((center.pageY - offset.top) - canvas.top) / height - ); - } else { - - // Zoom from the center of the canvas - canvas.left -= (newWidth - width) / 2; - canvas.top -= (newHeight - height) / 2; - } - - canvas.width = newWidth; - canvas.height = newHeight; - this.renderCanvas(true); - } - }, - - /** - * Rotate the canvas with a relative degree - * - * @param {Number} degree - */ - rotate: function (degree) { - this.rotateTo((this.image.rotate || 0) + num(degree)); - }, - - /** - * Rotate the canvas to an absolute degree - * https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function#rotate() - * - * @param {Number} degree - */ - rotateTo: function (degree) { - degree = num(degree); - - if (isNumber(degree) && this.isBuilt && !this.isDisabled && this.options.rotatable) { - this.image.rotate = degree % 360; - this.isRotated = true; - this.renderCanvas(true); - } - }, - - /** - * Scale the image - * https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function#scale() - * - * @param {Number} scaleX - * @param {Number} scaleY (optional) - */ - scale: function (scaleX, scaleY) { - var image = this.image; - var isChanged = false; - - // If "scaleY" is not present, its default value is "scaleX" - if (isUndefined(scaleY)) { - scaleY = scaleX; - } - - scaleX = num(scaleX); - scaleY = num(scaleY); - - if (this.isBuilt && !this.isDisabled && this.options.scalable) { - if (isNumber(scaleX)) { - image.scaleX = scaleX; - isChanged = true; - } - - if (isNumber(scaleY)) { - image.scaleY = scaleY; - isChanged = true; - } - - if (isChanged) { - this.renderImage(true); - } - } - }, - - /** - * Scale the abscissa of the image - * - * @param {Number} scaleX - */ - scaleX: function (scaleX) { - var scaleY = this.image.scaleY; - - this.scale(scaleX, isNumber(scaleY) ? scaleY : 1); - }, - - /** - * Scale the ordinate of the image - * - * @param {Number} scaleY - */ - scaleY: function (scaleY) { - var scaleX = this.image.scaleX; - - this.scale(isNumber(scaleX) ? scaleX : 1, scaleY); - }, - - /** - * Get the cropped area position and size data (base on the original image) - * - * @param {Boolean} isRounded (optional) - * @return {Object} data - */ - getData: function (isRounded) { - var options = this.options; - var image = this.image; - var canvas = this.canvas; - var cropBox = this.cropBox; - var ratio; - var data; - - if (this.isBuilt && this.isCropped) { - data = { - x: cropBox.left - canvas.left, - y: cropBox.top - canvas.top, - width: cropBox.width, - height: cropBox.height - }; - - ratio = image.width / image.naturalWidth; - - $.each(data, function (i, n) { - n = n / ratio; - data[i] = isRounded ? round(n) : n; - }); - - } else { - data = { - x: 0, - y: 0, - width: 0, - height: 0 - }; - } - - if (options.rotatable) { - data.rotate = image.rotate || 0; - } - - if (options.scalable) { - data.scaleX = image.scaleX || 1; - data.scaleY = image.scaleY || 1; - } - - return data; - }, - - /** - * Set the cropped area position and size with new data - * - * @param {Object} data - */ - setData: function (data) { - var options = this.options; - var image = this.image; - var canvas = this.canvas; - var cropBoxData = {}; - var isRotated; - var isScaled; - var ratio; - - if ($.isFunction(data)) { - data = data.call(this.element); - } - - if (this.isBuilt && !this.isDisabled && $.isPlainObject(data)) { - if (options.rotatable) { - if (isNumber(data.rotate) && data.rotate !== image.rotate) { - image.rotate = data.rotate; - this.isRotated = isRotated = true; - } - } - - if (options.scalable) { - if (isNumber(data.scaleX) && data.scaleX !== image.scaleX) { - image.scaleX = data.scaleX; - isScaled = true; - } - - if (isNumber(data.scaleY) && data.scaleY !== image.scaleY) { - image.scaleY = data.scaleY; - isScaled = true; - } - } - - if (isRotated) { - this.renderCanvas(); - } else if (isScaled) { - this.renderImage(); - } - - ratio = image.width / image.naturalWidth; - - if (isNumber(data.x)) { - cropBoxData.left = data.x * ratio + canvas.left; - } - - if (isNumber(data.y)) { - cropBoxData.top = data.y * ratio + canvas.top; - } - - if (isNumber(data.width)) { - cropBoxData.width = data.width * ratio; - } - - if (isNumber(data.height)) { - cropBoxData.height = data.height * ratio; - } - - this.setCropBoxData(cropBoxData); - } - }, - - /** - * Get the container size data - * - * @return {Object} data - */ - getContainerData: function () { - return this.isBuilt ? this.container : {}; - }, - - /** - * Get the image position and size data - * - * @return {Object} data - */ - getImageData: function () { - return this.isLoaded ? this.image : {}; - }, - - /** - * Get the canvas position and size data - * - * @return {Object} data - */ - getCanvasData: function () { - var canvas = this.canvas; - var data = {}; - - if (this.isBuilt) { - $.each([ - 'left', - 'top', - 'width', - 'height', - 'naturalWidth', - 'naturalHeight' - ], function (i, n) { - data[n] = canvas[n]; - }); - } - - return data; - }, - - /** - * Set the canvas position and size with new data - * - * @param {Object} data - */ - setCanvasData: function (data) { - var canvas = this.canvas; - var aspectRatio = canvas.aspectRatio; - - if ($.isFunction(data)) { - data = data.call(this.$element); - } - - if (this.isBuilt && !this.isDisabled && $.isPlainObject(data)) { - if (isNumber(data.left)) { - canvas.left = data.left; - } - - if (isNumber(data.top)) { - canvas.top = data.top; - } - - if (isNumber(data.width)) { - canvas.width = data.width; - canvas.height = data.width / aspectRatio; - } else if (isNumber(data.height)) { - canvas.height = data.height; - canvas.width = data.height * aspectRatio; - } - - this.renderCanvas(true); - } - }, - - /** - * Get the crop box position and size data - * - * @return {Object} data - */ - getCropBoxData: function () { - var cropBox = this.cropBox; - var data; - - if (this.isBuilt && this.isCropped) { - data = { - left: cropBox.left, - top: cropBox.top, - width: cropBox.width, - height: cropBox.height - }; - } - - return data || {}; - }, - - /** - * Set the crop box position and size with new data - * - * @param {Object} data - */ - setCropBoxData: function (data) { - var cropBox = this.cropBox; - var aspectRatio = this.options.aspectRatio; - var isWidthChanged; - var isHeightChanged; - - if ($.isFunction(data)) { - data = data.call(this.$element); - } - - if (this.isBuilt && this.isCropped && !this.isDisabled && $.isPlainObject(data)) { - - if (isNumber(data.left)) { - cropBox.left = data.left; - } - - if (isNumber(data.top)) { - cropBox.top = data.top; - } - - if (isNumber(data.width)) { - isWidthChanged = true; - cropBox.width = data.width; - } - - if (isNumber(data.height)) { - isHeightChanged = true; - cropBox.height = data.height; - } - - if (aspectRatio) { - if (isWidthChanged) { - cropBox.height = cropBox.width / aspectRatio; - } else if (isHeightChanged) { - cropBox.width = cropBox.height * aspectRatio; - } - } - - this.renderCropBox(); - } - }, - - /** - * Get a canvas drawn the cropped image - * - * @param {Object} options (optional) - * @return {HTMLCanvasElement} canvas - */ - getCroppedCanvas: function (options) { - var originalWidth; - var originalHeight; - var canvasWidth; - var canvasHeight; - var scaledWidth; - var scaledHeight; - var scaledRatio; - var aspectRatio; - var canvas; - var context; - var data; - - if (!this.isBuilt || !this.isCropped || !SUPPORT_CANVAS) { - return; - } - - if (!$.isPlainObject(options)) { - options = {}; - } - - data = this.getData(); - originalWidth = data.width; - originalHeight = data.height; - aspectRatio = originalWidth / originalHeight; - - if ($.isPlainObject(options)) { - scaledWidth = options.width; - scaledHeight = options.height; - - if (scaledWidth) { - scaledHeight = scaledWidth / aspectRatio; - scaledRatio = scaledWidth / originalWidth; - } else if (scaledHeight) { - scaledWidth = scaledHeight * aspectRatio; - scaledRatio = scaledHeight / originalHeight; - } - } - - // The canvas element will use `Math.floor` on a float number, so floor first - canvasWidth = floor(scaledWidth || originalWidth); - canvasHeight = floor(scaledHeight || originalHeight); - - canvas = $('')[0]; - canvas.width = canvasWidth; - canvas.height = canvasHeight; - context = canvas.getContext('2d'); - - if (options.fillColor) { - context.fillStyle = options.fillColor; - context.fillRect(0, 0, canvasWidth, canvasHeight); - } - - // https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D.drawImage - context.drawImage.apply(context, (function () { - var source = getSourceCanvas(this.$clone[0], this.image); - var sourceWidth = source.width; - var sourceHeight = source.height; - var args = [source]; - - // Source canvas - var srcX = data.x; - var srcY = data.y; - var srcWidth; - var srcHeight; - - // Destination canvas - var dstX; - var dstY; - var dstWidth; - var dstHeight; - - if (srcX <= -originalWidth || srcX > sourceWidth) { - srcX = srcWidth = dstX = dstWidth = 0; - } else if (srcX <= 0) { - dstX = -srcX; - srcX = 0; - srcWidth = dstWidth = min(sourceWidth, originalWidth + srcX); - } else if (srcX <= sourceWidth) { - dstX = 0; - srcWidth = dstWidth = min(originalWidth, sourceWidth - srcX); - } - - if (srcWidth <= 0 || srcY <= -originalHeight || srcY > sourceHeight) { - srcY = srcHeight = dstY = dstHeight = 0; - } else if (srcY <= 0) { - dstY = -srcY; - srcY = 0; - srcHeight = dstHeight = min(sourceHeight, originalHeight + srcY); - } else if (srcY <= sourceHeight) { - dstY = 0; - srcHeight = dstHeight = min(originalHeight, sourceHeight - srcY); - } - - // All the numerical parameters should be integer for `drawImage` (#476) - args.push(floor(srcX), floor(srcY), floor(srcWidth), floor(srcHeight)); - - // Scale destination sizes - if (scaledRatio) { - dstX *= scaledRatio; - dstY *= scaledRatio; - dstWidth *= scaledRatio; - dstHeight *= scaledRatio; - } - - // Avoid "IndexSizeError" in IE and Firefox - if (dstWidth > 0 && dstHeight > 0) { - args.push(floor(dstX), floor(dstY), floor(dstWidth), floor(dstHeight)); - } - - return args; - }).call(this)); - - return canvas; - }, - - /** - * Change the aspect ratio of the crop box - * - * @param {Number} aspectRatio - */ - setAspectRatio: function (aspectRatio) { - var options = this.options; - - if (!this.isDisabled && !isUndefined(aspectRatio)) { - - // 0 -> NaN - options.aspectRatio = max(0, aspectRatio) || NaN; - - if (this.isBuilt) { - this.initCropBox(); - - if (this.isCropped) { - this.renderCropBox(); - } - } - } - }, - - /** - * Change the drag mode - * - * @param {String} mode (optional) - */ - setDragMode: function (mode) { - var options = this.options; - var croppable; - var movable; - - if (this.isLoaded && !this.isDisabled) { - croppable = mode === ACTION_CROP; - movable = options.movable && mode === ACTION_MOVE; - mode = (croppable || movable) ? mode : ACTION_NONE; - - this.$dragBox. - data(DATA_ACTION, mode). - toggleClass(CLASS_CROP, croppable). - toggleClass(CLASS_MOVE, movable); - - if (!options.cropBoxMovable) { - - // Sync drag mode to crop box when it is not movable(#300) - this.$face. - data(DATA_ACTION, mode). - toggleClass(CLASS_CROP, croppable). - toggleClass(CLASS_MOVE, movable); - } - } - } - }; - - Cropper.DEFAULTS = { - - // Define the view mode of the cropper - viewMode: 0, // 0, 1, 2, 3 - - // Define the dragging mode of the cropper - dragMode: 'crop', // 'crop', 'move' or 'none' - - // Define the aspect ratio of the crop box - aspectRatio: NaN, - - // An object with the previous cropping result data - data: null, - - // A jQuery selector for adding extra containers to preview - preview: '', - - // Re-render the cropper when resize the window - responsive: true, - - // Restore the cropped area after resize the window - restore: true, - - // Check if the current image is a cross-origin image - checkCrossOrigin: true, - - // Check the current image's Exif Orientation information - checkOrientation: true, - - // Show the black modal - modal: true, - - // Show the dashed lines for guiding - guides: true, - - // Show the center indicator for guiding - center: true, - - // Show the white modal to highlight the crop box - highlight: true, - - // Show the grid background - background: true, - - // Enable to crop the image automatically when initialize - autoCrop: true, - - // Define the percentage of automatic cropping area when initializes - autoCropArea: 0.8, - - // Enable to move the image - movable: true, - - // Enable to rotate the image - rotatable: true, - - // Enable to scale the image - scalable: true, - - // Enable to zoom the image - zoomable: true, - - // Enable to zoom the image by dragging touch - zoomOnTouch: true, - - // Enable to zoom the image by wheeling mouse - zoomOnWheel: true, - - // Define zoom ratio when zoom the image by wheeling mouse - wheelZoomRatio: 0.1, - - // Enable to move the crop box - cropBoxMovable: true, - - // Enable to resize the crop box - cropBoxResizable: true, - - // Toggle drag mode between "crop" and "move" when click twice on the cropper - toggleDragModeOnDblclick: true, - - // Size limitation - minCanvasWidth: 0, - minCanvasHeight: 0, - minCropBoxWidth: 0, - minCropBoxHeight: 0, - minContainerWidth: 200, - minContainerHeight: 100, - - // Shortcuts of events - build: null, - built: null, - cropstart: null, - cropmove: null, - cropend: null, - crop: null, - zoom: null - }; - - Cropper.setDefaults = function (options) { - $.extend(Cropper.DEFAULTS, options); - }; - - Cropper.TEMPLATE = ( - '
    ' + - '
    ' + - '
    ' + - '
    ' + - '
    ' + - '
    ' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '
    ' + - '
    ' - ); - - // Save the other cropper - Cropper.other = $.fn.cropper; - - // Register as jQuery plugin - $.fn.cropper = function (option) { - var args = toArray(arguments, 1); - var result; - - this.each(function () { - var $this = $(this); - var data = $this.data(NAMESPACE); - var options; - var fn; - - if (!data) { - if (/destroy/.test(option)) { - return; - } - - options = $.extend({}, $this.data(), $.isPlainObject(option) && option); - $this.data(NAMESPACE, (data = new Cropper(this, options))); - } - - if (typeof option === 'string' && $.isFunction(fn = data[option])) { - result = fn.apply(data, args); - } - }); - - return isUndefined(result) ? this : result; - }; - - $.fn.cropper.Constructor = Cropper; - $.fn.cropper.setDefaults = Cropper.setDefaults; - - // No conflict - $.fn.cropper.noConflict = function () { - $.fn.cropper = Cropper.other; - return this; - }; - -}); diff --git a/vendor/assets/stylesheets/cropper.css b/vendor/assets/stylesheets/cropper.css deleted file mode 100755 index 41ee4bd546..0000000000 --- a/vendor/assets/stylesheets/cropper.css +++ /dev/null @@ -1,379 +0,0 @@ -/*! - * Cropper v2.2.5 - * https://github.com/fengyuanchen/cropper - * - * Copyright (c) 2014-2016 Fengyuan Chen and contributors - * Released under the MIT license - * - * Date: 2016-01-18T05:42:29.639Z - */ -.cropper-container { - font-size: 0; - line-height: 0; - - position: relative; - - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - - direction: ltr !important; - -ms-touch-action: none; - touch-action: none; - -webkit-tap-highlight-color: transparent; - -webkit-touch-callout: none; -} - -.cropper-container img { - display: block; - - width: 100%; - min-width: 0 !important; - max-width: none !important; - height: 100%; - min-height: 0 !important; - max-height: none !important; - - image-orientation: 0deg !important; -} - -.cropper-wrap-box, -.cropper-canvas, -.cropper-drag-box, -.cropper-crop-box, -.cropper-modal { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; -} - -.cropper-wrap-box { - overflow: hidden; -} - -.cropper-drag-box { - opacity: 0; - background-color: #fff; - - filter: alpha(opacity=0); -} - -.cropper-modal { - opacity: .5; - background-color: #000; - - filter: alpha(opacity=50); -} - -.cropper-view-box { - display: block; - overflow: hidden; - - width: 100%; - height: 100%; - - outline: 1px solid #39f; - outline-color: rgba(51, 153, 255, .75); -} - -.cropper-dashed { - position: absolute; - - display: block; - - opacity: .5; - border: 0 dashed #eee; - - filter: alpha(opacity=50); -} - -.cropper-dashed.dashed-h { - top: 33.33333%; - left: 0; - - width: 100%; - height: 33.33333%; - - border-top-width: 1px; - border-bottom-width: 1px; -} - -.cropper-dashed.dashed-v { - top: 0; - left: 33.33333%; - - width: 33.33333%; - height: 100%; - - border-right-width: 1px; - border-left-width: 1px; -} - -.cropper-center { - position: absolute; - top: 50%; - left: 50%; - - display: block; - - width: 0; - height: 0; - - opacity: .75; - - filter: alpha(opacity=75); -} - -.cropper-center:before, -.cropper-center:after { - position: absolute; - - display: block; - - content: ' '; - - background-color: #eee; -} - -.cropper-center:before { - top: 0; - left: -3px; - - width: 7px; - height: 1px; -} - -.cropper-center:after { - top: -3px; - left: 0; - - width: 1px; - height: 7px; -} - -.cropper-face, -.cropper-line, -.cropper-point { - position: absolute; - - display: block; - - width: 100%; - height: 100%; - - opacity: .1; - - filter: alpha(opacity=10); -} - -.cropper-face { - top: 0; - left: 0; - - background-color: #fff; -} - -.cropper-line { - background-color: #39f; -} - -.cropper-line.line-e { - top: 0; - right: -3px; - - width: 5px; - - cursor: e-resize; -} - -.cropper-line.line-n { - top: -3px; - left: 0; - - height: 5px; - - cursor: n-resize; -} - -.cropper-line.line-w { - top: 0; - left: -3px; - - width: 5px; - - cursor: w-resize; -} - -.cropper-line.line-s { - bottom: -3px; - left: 0; - - height: 5px; - - cursor: s-resize; -} - -.cropper-point { - width: 5px; - height: 5px; - - opacity: .75; - background-color: #39f; - - filter: alpha(opacity=75); -} - -.cropper-point.point-e { - top: 50%; - right: -3px; - - margin-top: -3px; - - cursor: e-resize; -} - -.cropper-point.point-n { - top: -3px; - left: 50%; - - margin-left: -3px; - - cursor: n-resize; -} - -.cropper-point.point-w { - top: 50%; - left: -3px; - - margin-top: -3px; - - cursor: w-resize; -} - -.cropper-point.point-s { - bottom: -3px; - left: 50%; - - margin-left: -3px; - - cursor: s-resize; -} - -.cropper-point.point-ne { - top: -3px; - right: -3px; - - cursor: ne-resize; -} - -.cropper-point.point-nw { - top: -3px; - left: -3px; - - cursor: nw-resize; -} - -.cropper-point.point-sw { - bottom: -3px; - left: -3px; - - cursor: sw-resize; -} - -.cropper-point.point-se { - right: -3px; - bottom: -3px; - - width: 20px; - height: 20px; - - cursor: se-resize; - - opacity: 1; - - filter: alpha(opacity=100); -} - -.cropper-point.point-se:before { - position: absolute; - right: -50%; - bottom: -50%; - - display: block; - - width: 200%; - height: 200%; - - content: ' '; - - opacity: 0; - background-color: #39f; - - filter: alpha(opacity=0); -} - -@media (min-width: 768px) { - .cropper-point.point-se { - width: 15px; - height: 15px; - } -} - -@media (min-width: 992px) { - .cropper-point.point-se { - width: 10px; - height: 10px; - } -} - -@media (min-width: 1200px) { - .cropper-point.point-se { - width: 5px; - height: 5px; - - opacity: .75; - - filter: alpha(opacity=75); - } -} - -.cropper-invisible { - opacity: 0; - - filter: alpha(opacity=0); -} - -.cropper-bg { - background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC'); -} - -.cropper-hide { - position: absolute; - - display: block; - - width: 0; - height: 0; -} - -.cropper-hidden { - display: none !important; -} - -.cropper-move { - cursor: move; -} - -.cropper-crop { - cursor: crosshair; -} - -.cropper-disabled .cropper-drag-box, -.cropper-disabled .cropper-face, -.cropper-disabled .cropper-line, -.cropper-disabled .cropper-point { - cursor: not-allowed; -}